From 0f472b2f9e8406d6a84f46c0aa2bc098846d5785 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Tue, 30 Jun 2026 09:01:01 -0400
Subject: [PATCH 01/10] fix(explore): diversify "more like this" so it stops
getting stuck (#1188)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Pure nearest-cosine piled near-identical images into the neighbour grid — a
reposted banner filled all 24 slots, and once you wandered into a B&W /
comic-panel cluster every neighbour was more of the same with no way back to
colour without the Random button (operator-reported, with screenshot).
similar() now over-fetches a wide candidate pool (5x the requested limit, cap
200), then diversifies down to `limit`:
- pHash near-duplicate collapse: drop candidates within 6 Hamming bits of the
anchor or an already-kept candidate, so a repost (and the anchor's own clones)
appears at most once.
- MMR re-rank: greedily pick for closeness-to-anchor minus similarity-to-already
-picked (lambda 0.55), so the result SPANS clusters instead of returning 40
variations of one image. Falls back to nearest-order on any failure / small
pool, so existing nearest-first behaviour is unchanged when there's nothing to
diversify.
Frontend forwardTarget drops the now-redundant skip-nearest-third hack (the list
is already diversified server-side) — plain random-over-unvisited gives the
variance now.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
---
backend/app/services/gallery_service.py | 95 ++++++++++++++++++++++---
frontend/src/stores/explore.js | 12 ++--
tests/test_gallery_similar.py | 23 ++++++
3 files changed, 115 insertions(+), 15 deletions(-)
diff --git a/backend/app/services/gallery_service.py b/backend/app/services/gallery_service.py
index cd93804..ca2a7df 100644
--- a/backend/app/services/gallery_service.py
+++ b/backend/app/services/gallery_service.py
@@ -289,6 +289,75 @@ def _gallery_images(rows, artists: dict[int, dict]) -> list[GalleryImage]:
]
+def _diversify_similar(src, rows, limit, *, dup_threshold=6, lam=0.55):
+ """Trim a nearest-cosine candidate pool down to `limit` diverse picks.
+
+ 1. pHash collapse: drop any candidate whose perceptual hash is within
+ `dup_threshold` Hamming bits of the anchor or an already-kept candidate —
+ so a reposted banner (and the anchor's own clones) appears at most once.
+ 2. MMR (Maximal Marginal Relevance): greedily pick the candidate maximising
+ `lam * sim_to_anchor - (1 - lam) * max_sim_to_already_picked`. This keeps
+ the most relevant up top but pushes the selection to SPAN clusters
+ instead of returning 40 variations of one image.
+
+ Falls back to nearest-order (`rows[:limit]`) on any failure or a small pool.
+ """
+ if len(rows) <= 1:
+ return rows[:limit]
+ try:
+ import imagehash
+ import numpy as np
+ except Exception:
+ return rows[:limit]
+
+ # --- 1. pHash near-duplicate collapse (videos/NULL phash pass through) ---
+ kept = []
+ seen = []
+ if src.phash:
+ try:
+ seen.append(imagehash.hex_to_hash(src.phash))
+ except Exception:
+ pass
+ for row in rows:
+ ph = row[0].phash
+ if ph:
+ try:
+ h = imagehash.hex_to_hash(ph)
+ if any((h - k) <= dup_threshold for k in seen):
+ continue
+ seen.append(h)
+ except Exception:
+ pass
+ kept.append(row)
+ if len(kept) <= limit:
+ return kept
+
+ # --- 2. MMR re-rank on the L2-normalised SigLIP embeddings ---
+ try:
+ a = np.asarray(src.siglip_embedding, dtype=np.float32)
+ a = a / (np.linalg.norm(a) or 1.0)
+ V = np.vstack([
+ np.asarray(row[0].siglip_embedding, dtype=np.float32) for row in kept
+ ])
+ V = V / np.clip(np.linalg.norm(V, axis=1, keepdims=True), 1e-8, None)
+ except Exception:
+ return kept[:limit]
+
+ rel = V @ a # (N,) cosine to the anchor
+ n = len(kept)
+ picked_mask = np.zeros(n, dtype=bool)
+ max_sim = np.zeros(n, dtype=np.float32) # max sim to anything picked yet
+ order = []
+ for _ in range(min(limit, n)):
+ scores = lam * rel - (1.0 - lam) * max_sim
+ scores[picked_mask] = -np.inf
+ i = int(np.argmax(scores))
+ order.append(i)
+ picked_mask[i] = True
+ max_sim = np.maximum(max_sim, V @ V[i])
+ return [kept[i] for i in order]
+
+
async def _artists_for(session, image_ids: list[int]) -> dict[int, dict]:
"""Map image_id -> {"name","slug"} via the canonical
image_record.artist_id (FC-2d-vii-c). Bounded by page size."""
@@ -565,14 +634,20 @@ class GalleryService:
untagged: bool = False, no_artist: bool = False,
date_from: datetime | None = None, date_to: datetime | None = None,
) -> list[GalleryImage] | None:
- """Visual "more like this": images ranked by cosine distance to
- `image_id`'s SigLIP embedding (pgvector, HNSW-indexed — alembic 0036).
- No ML inference here; the embedding was computed at import.
+ """Visual "more like this": images near `image_id`'s SigLIP embedding
+ (pgvector, HNSW-indexed — alembic 0036), then DIVERSIFIED so the result
+ doesn't collapse into one cluster. No ML inference here.
- Returns None if the source image doesn't exist (→ 404), [] if it has
- no embedding (a video / not-yet-embedded). Composes with the Phase-1/2
- scope filters (AND) but REPLACES the date sort — always nearest-first,
- bounded to `limit` (no cursor; distance-ranking has no date cursor).
+ Pure nearest-cosine piles up near-identical images — a reposted banner
+ fills the whole grid, and once you wander into a B&W / comic-panel
+ cluster every neighbour is more of the same with no way back to colour
+ (operator-reported 2026-06-30). So we pull a WIDER candidate pool, then:
+ 1. collapse near-duplicate pHashes (and drop clones of the anchor),
+ 2. MMR re-rank — pick for closeness-to-anchor but penalise similarity
+ to what's already picked, so the result SPANS clusters.
+
+ Returns None if the source doesn't exist (→ 404), [] if it has no
+ embedding. Composes with the scope filters (AND); REPLACES the date sort.
"""
if limit < 1 or limit > 200:
raise ValueError("limit must be between 1 and 200")
@@ -582,6 +657,9 @@ class GalleryService:
if src.siglip_embedding is None:
return []
+ # Over-fetch so diversification has clusters to spread across — without a
+ # wide pool there's nothing but the near-dupes to choose from.
+ pool_n = min(200, max(limit * 5, 60))
distance = ImageRecord.siglip_embedding.cosine_distance(src.siglip_embedding)
eff = _effective_date_col()
stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
@@ -597,8 +675,9 @@ class GalleryService:
platform=platform, untagged=untagged, no_artist=no_artist,
date_from=date_from, date_to=date_to,
)
- stmt = stmt.order_by(distance.asc()).limit(limit)
+ stmt = stmt.order_by(distance.asc()).limit(pool_n)
rows = (await self.session.execute(stmt)).all()
+ rows = _diversify_similar(src, rows, limit)
artists = await _artists_for(self.session, [r[0].id for r in rows])
return _gallery_images(rows, artists)
diff --git a/frontend/src/stores/explore.js b/frontend/src/stores/explore.js
index 1bb269f..8b5c45f 100644
--- a/frontend/src/stores/explore.js
+++ b/frontend/src/stores/explore.js
@@ -93,13 +93,11 @@ export const useExploreStore = defineStore('explore', () => {
// a crumb (which snaps the cursor back into the trail — the "loops back"
// report). Fall back to the full set only if every neighbour's been seen.
const seen = new Set(breadcrumb.value.map((c) => c.id))
- let pool = neighbors.value.filter((n) => !seen.has(n.id))
- if (!pool.length) pool = neighbors.value
- // neighbors come similarity-sorted (nearest first). Skip the closest slice —
- // those near-duplicates are exactly what you get stuck cycling through — and
- // pick from the more-varied remainder, for real variance in the walk.
- const skip = pool.length >= 6 ? Math.floor(pool.length / 3) : 0
- const cands = pool.slice(skip)
+ const pool = neighbors.value.filter((n) => !seen.has(n.id))
+ const cands = pool.length ? pool : neighbors.value
+ // The list is already pHash-deduped + MMR-diversified server-side (it spans
+ // clusters, not 40 near-dupes), so a plain random pick gives real variance —
+ // no need to skip the nearest slice the way the raw nearest-list required.
return cands[Math.floor(Math.random() * cands.length)].id
}
diff --git a/tests/test_gallery_similar.py b/tests/test_gallery_similar.py
index 07ae149..ea9b355 100644
--- a/tests/test_gallery_similar.py
+++ b/tests/test_gallery_similar.py
@@ -87,6 +87,29 @@ async def test_similar_composes_with_tag_filter(db):
assert [i.id for i in res] == [tagged.id] # scope AND-narrows the ranked set
+@pytest.mark.asyncio
+async def test_similar_collapses_near_duplicate_phashes(db):
+ # The reported failure: a reposted image fills the whole neighbour grid.
+ # A wall of same-pHash reposts must collapse to at most one, and the
+ # genuinely distinct images must still come through.
+ src = await _img(db, 1, _vec(1, 0))
+ dupes = []
+ for n in range(2, 7): # 5 near-identical reposts
+ r = await _img(db, n, _vec(1, 0.01 * n))
+ r.phash = "ffffffffffffffff" # identical perceptual hash
+ dupes.append(r)
+ distinct_a = await _img(db, 7, _vec(1, 1))
+ distinct_a.phash = "0000000000000000"
+ distinct_b = await _img(db, 8, _vec(0, 1))
+ distinct_b.phash = "0f0f0f0f0f0f0f0f"
+ await db.flush()
+
+ res = await GalleryService(db).similar(src.id, limit=10)
+ ids = [i.id for i in res]
+ assert sum(1 for d in dupes if d.id in ids) <= 1 # repost wall collapsed
+ assert distinct_a.id in ids and distinct_b.id in ids
+
+
@pytest.mark.asyncio
async def test_similar_respects_limit(db):
src = await _img(db, 1, _vec(1, 0))
From 4daa3f27903838a5be98d8a0bd35ba83d2597f96 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Tue, 30 Jun 2026 10:24:30 -0400
Subject: [PATCH 02/10] =?UTF-8?q?feat(ml):=20operator=20model=20swap=20?=
=?UTF-8?q?=E2=80=94=20GPU=20re-embed=20+=20embedder=20as=20a=20setting=20?=
=?UTF-8?q?(#1190)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Make the SigLIP embedder an operator choice (drop-in to SigLIP 2:
google/siglip2-so400m-patch16-512 is a verified 1152-d model at 512px → no
schema change, better small-cue fidelity). A swap = set model + re-embed +
retrain, all operator-driven; the GPU agent does the re-embed so it's fast.
- settings: embedder_model_name is now a setting (migration 0065) alongside the
existing embedder_model_version; both editable + validated (non-empty) in the
ml admin API. The server embedder loads by HF name (AutoImageProcessor/Model,
model-agnostic), preferring the pre-downloaded local dir for the default so
existing deploys don't re-download; rebuilds on a name change.
- agent: new 'embed' job = whole-image SigLIP embedding (mean-pool video frames)
under the lease-announced model → POST /jobs/submit_embedding writes
image_record.siglip_embedding + siglip_model_version. The lease now announces
the model FROM THE SETTING (not a constant).
- re-embed routing: enqueue_gpu_backfill('embed') selects unembedded + stale-
version images; 'siglip' now re-embeds concept crops whose version != current
(so a swap re-triggers crops, not just the never-embedded back-catalogue). The
CPU ml-worker backfill no longer re-embeds on a version mismatch (it can't
churn the library at 512px) — the GPU agent owns version re-embeds. Daily
'embed' + 'siglip' beats self-heal.
- scoring: score_image only bags embeddings in the CURRENT model's space (whole-
image gated by siglip_model_version, concept regions by embedding_version) so a
mid-swap stale vector isn't scored by new-space heads; legacy NULL = current.
- UI: GpuAgentCard "Embedding model (advanced)" — edit name/version, Save, and
"Re-embed library (GPU)" (queues embed + siglip); points at SigLIP 2.
Tests: lease announces model + submit_embedding round-trip; enqueue 'embed'
selects stale/unembedded; stale-version excluded from scoring; embedder model
settable + empty rejected; siglip gate updated to current-version concept.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
---
agent/fc_agent/client.py | 13 ++++
agent/fc_agent/worker.py | 37 +++++++---
alembic/versions/0065_embedder_model_name.py | 35 ++++++++++
backend/app/api/gpu.py | 40 +++++++++--
backend/app/api/ml_admin.py | 8 +++
backend/app/celery_app.py | 5 ++
backend/app/models/ml_settings.py | 6 ++
backend/app/services/ml/embedder.py | 53 +++++++++------
backend/app/services/ml/heads.py | 19 ++++--
backend/app/tasks/ml.py | 43 +++++++++---
.../src/components/settings/GpuAgentCard.vue | 68 +++++++++++++++++++
tests/test_api_gpu.py | 33 +++++++++
tests/test_api_ml_admin.py | 20 ++++++
tests/test_gpu_jobs.py | 38 ++++++++++-
tests/test_ml_suggestions.py | 14 ++++
15 files changed, 379 insertions(+), 53 deletions(-)
create mode 100644 alembic/versions/0065_embedder_model_name.py
diff --git a/agent/fc_agent/client.py b/agent/fc_agent/client.py
index 7003bcc..1c297c9 100644
--- a/agent/fc_agent/client.py
+++ b/agent/fc_agent/client.py
@@ -40,6 +40,19 @@ class FcClient:
r.raise_for_status()
return r.json()
+ def submit_embedding(self, job_id: int, embedding: list, version: str) -> dict:
+ """Post a whole-image SigLIP embedding (the 'embed' task) → image_record."""
+ r = self.s.post(
+ f"{self.base}/api/gpu/jobs/submit_embedding",
+ json={
+ "agent_id": self.agent_id, "job_id": job_id,
+ "embedding": embedding, "embedding_version": version,
+ },
+ timeout=120,
+ )
+ r.raise_for_status()
+ return r.json()
+
def heartbeat(self, job_ids: list[int]) -> None:
try:
self.s.post(
diff --git a/agent/fc_agent/worker.py b/agent/fc_agent/worker.py
index 8b42277..dd5b206 100644
--- a/agent/fc_agent/worker.py
+++ b/agent/fc_agent/worker.py
@@ -11,6 +11,7 @@ orphaned work is re-picked at once rather than waiting out the lease.
"""
import threading
+import numpy as np
import requests
from . import media, models
@@ -193,28 +194,42 @@ class Worker:
else:
frames = [(None, media.load_image(data))]
+ task = job.get("task") or "ccip"
+ embed_version = job.get("embed_version") or DEFAULT_EMBED_VERSION
+ model_name = (
+ self.cfg.embed_model_override
+ or job.get("embed_model_name")
+ or DEFAULT_EMBED_MODEL
+ )
+
+ # 'embed' = WHOLE-IMAGE SigLIP embedding (re-embed the library under a
+ # new model, #1190) → image_record.siglip_embedding. Mean-pool video
+ # frames, matching the server's tag_and_embed. No regions.
+ if task == "embed":
+ embedder = self._ensure_embedder(model_name)
+ vecs = [embedder.embed(frame) for _, frame in frames]
+ if len(vecs) > 1:
+ vec = np.mean(
+ np.asarray(vecs, dtype=np.float32), axis=0
+ ).tolist()
+ else:
+ vec = vecs[0]
+ self.client.submit_embedding(job["job_id"], vec, embed_version)
+ self._bump(processed=1)
+ return True
+
# task picks what to produce per crop:
# 'siglip' (backfill existing images) → concept (SigLIP) regions
# ONLY, so it never churns their figure/CCIP regions or the
# character-reference cache.
# 'ccip' / 'both' (a new image's first pass) → figure (CCIP) AND
# concept (SigLIP) in one go, off the same crop.
- task = job.get("task") or "ccip"
want_ccip = task in ("ccip", "both")
want_siglip = task in ("ccip", "siglip", "both")
replace_kinds = (
["concept"] if task == "siglip" else ["figure", "face", "concept"]
)
-
- embed_version = job.get("embed_version") or DEFAULT_EMBED_VERSION
- embedder = None
- if want_siglip:
- model_name = (
- self.cfg.embed_model_override
- or job.get("embed_model_name")
- or DEFAULT_EMBED_MODEL
- )
- embedder = self._ensure_embedder(model_name)
+ embedder = self._ensure_embedder(model_name) if want_siglip else None
regions = []
ccip_ev = self.cfg.ccip_model or "ccip-default"
diff --git a/alembic/versions/0065_embedder_model_name.py b/alembic/versions/0065_embedder_model_name.py
new file mode 100644
index 0000000..0a986b3
--- /dev/null
+++ b/alembic/versions/0065_embedder_model_name.py
@@ -0,0 +1,35 @@
+"""ml_settings: embedder_model_name (#1190 operator model swap)
+
+The embedder MODEL VERSION was already a setting (and stamps image_record.
+siglip_model_version); the HF model NAME was env-only, so an operator couldn't
+actually point the pipeline at a different embedder. Storing the name as a
+setting makes the model an operator choice: set name + version → re-embed (the
+GPU agent) → retrain heads. Default = the current SigLIP so400m.
+
+Revision ID: 0065
+Revises: 0064
+Create Date: 2026-06-30
+"""
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "0065"
+down_revision: Union[str, None] = "0064"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "ml_settings",
+ sa.Column(
+ "embedder_model_name", sa.String(length=128), nullable=False,
+ server_default="google/siglip-so400m-patch14-384",
+ ),
+ )
+
+
+def downgrade() -> None:
+ op.drop_column("ml_settings", "embedder_model_name")
diff --git a/backend/app/api/gpu.py b/backend/app/api/gpu.py
index cf8fc73..09a488f 100644
--- a/backend/app/api/gpu.py
+++ b/backend/app/api/gpu.py
@@ -17,7 +17,6 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
from ..extensions import get_session
from ..models import AppSetting, GpuJob, ImageRecord, MLSettings
from ..services.gallery_service import image_url
-from ..services.ml.embedder import MODEL_NAME as EMBED_MODEL_NAME
from ..services.ml.gpu_jobs import GpuJobService
from ..services.ml.regions import RegionService
@@ -138,11 +137,12 @@ async def lease():
# For video/animated: the agent samples at this cadence.
"frame_interval_seconds": ml.video_frame_interval_seconds,
"max_frames": ml.video_max_frames,
- # The embedding model the agent must use for concept crops, so
- # its region vectors land in the SAME space the heads trained in.
- # Server-announced → the agent stays model-agnostic; a swap is a
- # server setting + a re-embed migration, never an agent change.
- "embed_model_name": EMBED_MODEL_NAME,
+ # The embedding model the agent must use for concept crops + the
+ # whole-image 'embed' task, so its vectors land in the SAME space
+ # the heads trained in. Server-announced FROM THE SETTING → the
+ # agent stays model-agnostic; an operator swap is a setting + a
+ # re-embed, never an agent change.
+ "embed_model_name": ml.embedder_model_name,
"embed_version": ml.embedder_model_version,
})
return jsonify({"jobs": out})
@@ -188,6 +188,34 @@ async def submit():
return jsonify({"ok": True, "stored": len(regions)})
+@gpu_bp.route("/jobs/submit_embedding", methods=["POST"])
+async def submit_embedding():
+ """Store a whole-image SigLIP embedding (the 'embed' task) on image_record +
+ close the job. Body: {agent_id, job_id, embedding:[...], embedding_version}.
+ This is how the GPU agent re-embeds the library under a new model (#1190) —
+ much faster than the CPU ml-worker at higher resolutions."""
+ body = await request.get_json(silent=True) or {}
+ agent_id = str(body.get("agent_id") or "agent")
+ job_id = body.get("job_id")
+ embedding = body.get("embedding")
+ version = body.get("embedding_version")
+ if job_id is None or not embedding or not version:
+ return jsonify({"error": "job_id, embedding, embedding_version required"}), 400
+ async with get_session() as session:
+ if not await _agent_authed(session):
+ return jsonify({"error": "unauthorized"}), 401
+ job = await session.get(GpuJob, int(job_id))
+ if job is None or job.status != "leased" or job.lease_token != agent_id:
+ return jsonify({"error": "lease_invalid"}), 409
+ img = await session.get(ImageRecord, job.image_record_id)
+ if img is not None:
+ img.siglip_embedding = embedding
+ img.siglip_model_version = version
+ await GpuJobService(session).complete(agent_id, int(job_id))
+ await session.commit()
+ return jsonify({"ok": True})
+
+
@gpu_bp.route("/jobs/fail", methods=["POST"])
async def fail():
body = await request.get_json(silent=True) or {}
diff --git a/backend/app/api/ml_admin.py b/backend/app/api/ml_admin.py
index 1472770..102c004 100644
--- a/backend/app/api/ml_admin.py
+++ b/backend/app/api/ml_admin.py
@@ -24,6 +24,8 @@ _EDITABLE = (
"ccip_match_threshold",
"ccip_auto_apply_enabled",
"ccip_auto_apply_threshold",
+ "embedder_model_name",
+ "embedder_model_version",
)
@@ -54,6 +56,7 @@ async def get_settings():
"ccip_match_threshold": s.ccip_match_threshold,
"ccip_auto_apply_enabled": s.ccip_auto_apply_enabled,
"ccip_auto_apply_threshold": s.ccip_auto_apply_threshold,
+ "embedder_model_name": s.embedder_model_name,
}
)
@@ -125,6 +128,11 @@ def _validate(p: dict) -> str | None:
return "ccip_match_threshold must be between 0.5 and 0.999"
if not (0.5 <= float(p["ccip_auto_apply_threshold"]) <= 0.999):
return "ccip_auto_apply_threshold must be between 0.5 and 0.999"
+ # Embedder model swap (#1190): both must be non-empty. Changing them means a
+ # different embedding space — the operator must re-embed + retrain after.
+ for key in ("embedder_model_name", "embedder_model_version"):
+ if not str(p[key]).strip():
+ return f"{key} must not be empty"
return None
diff --git a/backend/app/celery_app.py b/backend/app/celery_app.py
index 5778a1e..7b7a4e3 100644
--- a/backend/app/celery_app.py
+++ b/backend/app/celery_app.py
@@ -131,6 +131,11 @@ def make_celery() -> Celery:
"schedule": 86400.0, # drain the concept-crop back-catalogue +
"args": ("siglip",), # retry failed embeds, no button needed
},
+ "enqueue-embed-backfill-daily": {
+ "task": "backend.app.tasks.ml.enqueue_gpu_backfill",
+ "schedule": 86400.0, # whole-image re-embed under the current
+ "args": ("embed",), # model (an operator swap) drains via agent
+ },
"ccip-auto-apply-daily": {
"task": "backend.app.tasks.ml.scheduled_ccip_auto_apply",
"schedule": 86400.0, # no-op unless ccip_auto_apply_enabled
diff --git a/backend/app/models/ml_settings.py b/backend/app/models/ml_settings.py
index 9825568..0e84940 100644
--- a/backend/app/models/ml_settings.py
+++ b/backend/app/models/ml_settings.py
@@ -107,6 +107,12 @@ class MLSettings(Base):
embedder_model_version: Mapped[str] = mapped_column(
String(128), nullable=False, default="siglip-so400m-patch14-384"
)
+ # The HF model NAME the embedder loads (server CPU embed + announced to the
+ # GPU agent in the lease). Operator-settable so the embedder is a choice, not
+ # a hardcode (#1190): set name + version together, then re-embed + retrain.
+ embedder_model_name: Mapped[str] = mapped_column(
+ String(128), nullable=False, default="google/siglip-so400m-patch14-384"
+ )
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
diff --git a/backend/app/services/ml/embedder.py b/backend/app/services/ml/embedder.py
index d94f71e..d55646c 100644
--- a/backend/app/services/ml/embedder.py
+++ b/backend/app/services/ml/embedder.py
@@ -18,9 +18,11 @@ ImageFile.LOAD_TRUNCATED_IMAGES = True
# N_replicas × this within the cores allotted to ML to avoid oversubscription.
_INTRA_OP_THREADS = 4
-MODEL_NAME = os.environ.get(
+DEFAULT_MODEL_NAME = os.environ.get(
"SIGLIP_MODEL_NAME", "google/siglip-so400m-patch14-384"
)
+# Back-compat alias (api/gpu imported this name as the fallback embedder id).
+MODEL_NAME = DEFAULT_MODEL_NAME
MODEL_VERSION = os.environ.get(
"SIGLIP_MODEL_VERSION", "siglip-so400m-patch14-384"
)
@@ -29,35 +31,42 @@ _LOCAL_DIR = Path(os.environ.get("ML_MODEL_DIR", "/models")) / "siglip"
class Embedder:
- def __init__(self, model_dir: Path | None = None):
- self._model_dir = model_dir or _LOCAL_DIR
+ """Loads whatever SigLIP-family model it's given by HF NAME. For the default
+ model it prefers the pre-downloaded local dir (no re-download on existing
+ deploys); any other name resolves as an HF repo id (downloaded + cached on
+ first use), so an operator model swap (#1190) just works server-side."""
+
+ def __init__(self, model_name: str | None = None, model_dir: Path | None = None):
+ self.model_name = model_name or DEFAULT_MODEL_NAME
+ self._explicit_dir = model_dir
self._model = None
self._processor = None
self._torch = None
+ def _source(self) -> str:
+ if self._explicit_dir is not None:
+ return str(self._explicit_dir)
+ if self.model_name == DEFAULT_MODEL_NAME and _LOCAL_DIR.exists():
+ return str(_LOCAL_DIR)
+ return self.model_name
+
def load(self) -> None:
if self._model is not None:
return
import torch
- from transformers import AutoModel, SiglipImageProcessor
+ from transformers import AutoImageProcessor, AutoModel
self._torch = torch
# Bound torch's CPU thread pool (see _INTRA_OP_THREADS) so each replica
# stays a predictable core consumer on a shared node.
torch.set_num_threads(_INTRA_OP_THREADS)
- # FC's embedder only does IMAGE inference — never text. AutoProcessor
- # loads the full processor including SiglipTokenizer, which requires
- # the sentencepiece library at import time even if we never call it.
- # SiglipImageProcessor loads ONLY preprocessor_config.json (image
- # side) and skips the tokenizer config entirely. Operator hit the
- # ImportError 2026-05-25 once the ml-worker started actually running
- # tag_and_embed; switching to the image-only loader avoids the
- # tokenizer dep without adding ~30 MB of unused C++ build to the
- # lean ml-worker image.
- self._processor = SiglipImageProcessor.from_pretrained(
- str(self._model_dir)
- )
- self._model = AutoModel.from_pretrained(str(self._model_dir))
+ # IMAGE inference only — AutoImageProcessor loads just the image side
+ # (preprocessor_config.json), skipping the SigLIP tokenizer + its
+ # sentencepiece dep (operator hit that ImportError 2026-05-25). Works
+ # for any SigLIP-family model, keeping the embedder model-agnostic.
+ src = self._source()
+ self._processor = AutoImageProcessor.from_pretrained(src)
+ self._model = AutoModel.from_pretrained(src)
self._model.eval()
def infer(self, image_path: Path) -> np.ndarray:
@@ -74,8 +83,12 @@ class Embedder:
_default_embedder: Embedder | None = None
-def get_embedder() -> Embedder:
+def get_embedder(model_name: str | None = None) -> Embedder:
+ """Cached embedder for `model_name` (default if None). Rebuilds the singleton
+ when the requested name changes, so an operator model swap takes effect
+ without restarting the worker."""
global _default_embedder
- if _default_embedder is None:
- _default_embedder = Embedder()
+ name = model_name or DEFAULT_MODEL_NAME
+ if _default_embedder is None or _default_embedder.model_name != name:
+ _default_embedder = Embedder(model_name=name)
return _default_embedder
diff --git a/backend/app/services/ml/heads.py b/backend/app/services/ml/heads.py
index 879b922..b234897 100644
--- a/backend/app/services/ml/heads.py
+++ b/backend/app/services/ml/heads.py
@@ -308,25 +308,36 @@ async def score_image(
import numpy as np
img = await session.get(ImageRecord, image_id)
- if img is None or img.siglip_embedding is None:
+ if img is None:
return []
settings = await _settings_async(session)
- heads = await _current_heads(session, settings.embedder_model_version)
+ cur_version = settings.embedder_model_version
+ heads = await _current_heads(session, cur_version)
if heads["W"] is None:
return []
- bag = [np.asarray(img.siglip_embedding, dtype=np.float32)]
+ # Only embeddings in the CURRENT model's space enter the bag. Mid model-swap
+ # (#1190), an image still carrying the OLD-version whole-image vector is
+ # skipped rather than scored by heads trained in a different space; a legacy
+ # NULL version is treated as current (those predate per-row stamping).
+ bag = []
+ if img.siglip_embedding is not None and img.siglip_model_version in (
+ cur_version, None,
+ ):
+ bag.append(np.asarray(img.siglip_embedding, dtype=np.float32))
region_vecs = (
await session.execute(
select(ImageRegion.siglip_embedding)
.where(ImageRegion.image_record_id == image_id)
.where(ImageRegion.siglip_embedding.is_not(None))
- .where(ImageRegion.embedding_version == settings.embedder_model_version)
+ .where(ImageRegion.embedding_version == cur_version)
)
).all()
for (vec,) in region_vecs:
if vec is not None:
bag.append(np.asarray(vec, dtype=np.float32))
+ if not bag:
+ return []
X = np.vstack(bag) # (B, D)
norms = np.linalg.norm(X, axis=1, keepdims=True)
diff --git a/backend/app/tasks/ml.py b/backend/app/tasks/ml.py
index 84b40a2..f2aa6a7 100644
--- a/backend/app/tasks/ml.py
+++ b/backend/app/tasks/ml.py
@@ -95,7 +95,7 @@ def tag_and_embed(self, image_id: int) -> dict:
phase = "load_models"
tagger = get_tagger()
- embedder = get_embedder()
+ embedder = get_embedder(settings.embedder_model_name)
if is_vid:
# Layer-3 isolation: ffprobe (a separate process) validates
@@ -330,10 +330,10 @@ def backfill(self) -> int:
!= settings.tagger_model_version
)
| (ImageRecord.siglip_embedding.is_(None))
- | (
- ImageRecord.siglip_model_version
- != settings.embedder_model_version
- )
+ # NB: a siglip MODEL-VERSION mismatch (an operator model swap,
+ # #1190) is intentionally NOT re-embedded here — the CPU
+ # ml-worker can't churn the whole library at 384/512px. The
+ # GPU agent owns version re-embeds via the 'embed' job.
)
.order_by(ImageRecord.id.asc())
.limit(500)
@@ -750,17 +750,40 @@ def enqueue_gpu_backfill(task_name: str) -> int:
job, so it picks up the back-catalogue of images that were CCIP-embedded
before concept crops existed, and retries images whose concept embed failed —
without re-touching their figure/CCIP regions."""
- from sqlalchemy import exists, insert, literal
+ from sqlalchemy import exists, insert, literal, or_
from sqlalchemy import select as sa_select
- from ..models import GpuJob, ImageRecord, ImageRegion
+ from ..models import GpuJob, ImageRecord, ImageRegion, MLSettings
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
- if task_name == "siglip":
- has_concept = exists().where(
+ cur_version = session.execute(
+ select(MLSettings.embedder_model_version).where(MLSettings.id == 1)
+ ).scalar_one()
+ if task_name == "embed":
+ # Whole-image GPU re-embed (#1190): images with no embedding, or one
+ # stamped under a DIFFERENT model version (an operator model swap).
+ stale = or_(
+ ImageRecord.siglip_embedding.is_(None),
+ ImageRecord.siglip_model_version.is_(None),
+ ImageRecord.siglip_model_version != cur_version,
+ )
+ queued = exists().where(
+ GpuJob.image_record_id == ImageRecord.id,
+ GpuJob.task == "embed",
+ GpuJob.status.in_(["pending", "leased"]),
+ )
+ sel = sa_select(
+ ImageRecord.id, literal("embed"), literal("pending")
+ ).where(stale).where(~queued)
+ elif task_name == "siglip":
+ # Concept-crop re-embed: enqueue when there's no concept region AT THE
+ # CURRENT model version — so a model swap re-triggers crops too, not
+ # only the never-embedded back-catalogue.
+ has_current_concept = exists().where(
ImageRegion.image_record_id == ImageRecord.id,
ImageRegion.kind == "concept",
+ ImageRegion.embedding_version == cur_version,
)
queued = exists().where(
GpuJob.image_record_id == ImageRecord.id,
@@ -769,7 +792,7 @@ def enqueue_gpu_backfill(task_name: str) -> int:
)
sel = sa_select(
ImageRecord.id, literal("siglip"), literal("pending")
- ).where(~has_concept).where(~queued)
+ ).where(~has_current_concept).where(~queued)
else:
already = exists().where(
GpuJob.image_record_id == ImageRecord.id,
diff --git a/frontend/src/components/settings/GpuAgentCard.vue b/frontend/src/components/settings/GpuAgentCard.vue
index dc0ed24..b0da818 100644
--- a/frontend/src/components/settings/GpuAgentCard.vue
+++ b/frontend/src/components/settings/GpuAgentCard.vue
@@ -106,6 +106,37 @@
reversible) — so identity tags keep flowing without review. Stricter than
the suggest cut; 0.92 recommended.
+
+
+ Embedding model (advanced)
+
+
+
+
+ Save model
+ Re-embed library (GPU)
+
+
+ Changing the model means a DIFFERENT embedding space. After saving a new
+ model + version, run Re-embed library (the GPU agent re-embeds
+ whole images + concept crops), then Retrain heads. Suggestions
+ degrade until both finish. SigLIP 2 (google/siglip2-so400m-patch16-512,
+ version siglip2-so400m-patch16-512) is a 1152-d drop-in at
+ 512px — no schema change.
+
+
@@ -131,6 +162,10 @@ const savingThreshold = ref(false)
const autoApply = ref(true)
const autoThreshold = ref(0.92)
const savingAuto = ref(false)
+const modelName = ref('')
+const modelVersion = ref('')
+const savingModel = ref(false)
+const reembedding = ref(false)
const queue = ref({ pending: 0, leased: 0, done: 0, error: 0 })
let pollTimer = null
@@ -157,9 +192,42 @@ onMounted(async () => {
autoApply.value = ml.settings.ccip_auto_apply_enabled
autoThreshold.value = ml.settings.ccip_auto_apply_threshold
}
+ if (ml.settings?.embedder_model_name != null) {
+ modelName.value = ml.settings.embedder_model_name
+ modelVersion.value = ml.settings.embedder_model_version
+ }
} catch { /* non-fatal */ }
})
+async function onSaveModel() {
+ savingModel.value = true
+ try {
+ await ml.patchSettings({
+ embedder_model_name: modelName.value.trim(),
+ embedder_model_version: modelVersion.value.trim(),
+ })
+ toast({ text: 'Embedding model saved — now Re-embed library, then Retrain heads', type: 'success' })
+ } catch (e) {
+ toast({ text: `Could not save model: ${e.message}`, type: 'error' })
+ } finally {
+ savingModel.value = false
+ }
+}
+
+async function onReembed() {
+ reembedding.value = true
+ try {
+ await store.backfill('embed')
+ await store.backfill('siglip')
+ toast({ text: 'Queued whole-image + concept re-embed — run the agent, then Retrain heads', type: 'success' })
+ await refreshQueue()
+ } catch (e) {
+ toast({ text: `Could not queue re-embed: ${e.message}`, type: 'error' })
+ } finally {
+ reembedding.value = false
+ }
+}
+
async function onSaveAuto() {
savingAuto.value = true
try {
diff --git a/tests/test_api_gpu.py b/tests/test_api_gpu.py
index 40345d8..08fbc5e 100644
--- a/tests/test_api_gpu.py
+++ b/tests/test_api_gpu.py
@@ -69,6 +69,39 @@ async def test_lease_submit_round_trip(client, db):
assert len(regs) == 1 and len(list(regs[0].ccip_embedding)) == 768
+@pytest.mark.asyncio
+async def test_lease_announces_embed_model_then_submit_embedding(client, db):
+ # Whole-image GPU re-embed (#1190): the lease announces the embedder model so
+ # the agent loads the right one, and submit_embedding writes it back onto
+ # image_record with its version stamp.
+ img = await _img(db, "b" * 64)
+ await GpuJobService(db).enqueue(img.id, "embed")
+ await db.commit()
+
+ token = (await (await client.post("/api/gpu/token/rotate")).get_json())["token"]
+ hdr = {"Authorization": f"Bearer {token}"}
+
+ leased = await client.post(
+ "/api/gpu/jobs/lease", json={"agent_id": "a1", "batch_size": 5}, headers=hdr,
+ )
+ j = (await leased.get_json())["jobs"][0]
+ assert j["task"] == "embed"
+ assert j["embed_model_name"] and j["embed_version"] # server-announced model
+
+ submitted = await client.post("/api/gpu/jobs/submit_embedding", json={
+ "agent_id": "a1", "job_id": j["job_id"],
+ "embedding": [0.2] * 1152, "embedding_version": "siglip2-test-v9",
+ }, headers=hdr)
+ assert submitted.status_code == 200
+
+ st = await (await client.get("/api/gpu/status")).get_json()
+ assert st["done"] == 1 and st["leased"] == 0
+
+ await db.refresh(img)
+ assert img.siglip_model_version == "siglip2-test-v9"
+ assert img.siglip_embedding is not None and len(list(img.siglip_embedding)) == 1152
+
+
@pytest.mark.asyncio
async def test_submit_with_stale_lease_is_409(client, db):
img = await _img(db, "b" * 64)
diff --git a/tests/test_api_ml_admin.py b/tests/test_api_ml_admin.py
index e46be48..ebc944d 100644
--- a/tests/test_api_ml_admin.py
+++ b/tests/test_api_ml_admin.py
@@ -34,6 +34,26 @@ async def test_get_and_patch_settings(client):
assert (await resp.get_json())["suggestion_threshold_general"] == pytest.approx(0.90)
+@pytest.mark.asyncio
+async def test_embedder_model_settable_and_empty_rejected(client):
+ # #1190: the embedder model name + version are operator-settable (a swap),
+ # and neither may be blanked.
+ body = await (await client.get("/api/ml/settings")).get_json()
+ assert body["embedder_model_name"] == "google/siglip-so400m-patch14-384"
+
+ ok = await client.patch("/api/ml/settings", json={
+ "embedder_model_name": "google/siglip2-so400m-patch16-512",
+ "embedder_model_version": "siglip2-so400m-patch16-512",
+ })
+ assert ok.status_code == 200
+ out = await ok.get_json()
+ assert out["embedder_model_name"] == "google/siglip2-so400m-patch16-512"
+ assert out["embedder_model_version"] == "siglip2-so400m-patch16-512"
+
+ bad = await client.patch("/api/ml/settings", json={"embedder_model_name": " "})
+ assert bad.status_code == 400
+
+
@pytest.mark.asyncio
async def test_tagger_store_floor_default_and_patch(client):
body = await (await client.get("/api/ml/settings")).get_json()
diff --git a/tests/test_gpu_jobs.py b/tests/test_gpu_jobs.py
index 3606755..38d23ca 100644
--- a/tests/test_gpu_jobs.py
+++ b/tests/test_gpu_jobs.py
@@ -25,13 +25,17 @@ async def test_enqueue_siglip_backfill_gates_on_concept_region(db):
# 'siglip' backfill enqueues images that lack a concept region (the
# back-catalogue) and skips ones that already have one — and never double-
# enqueues an image that already has a pending siglip job.
+ from backend.app.models import MLSettings
from backend.app.tasks.ml import enqueue_gpu_backfill
+ cur = (await db.execute(
+ select(MLSettings.embedder_model_version).where(MLSettings.id == 1)
+ )).scalar_one()
need = await _img(db, "e1" * 32) # no concept region → wants one
- have = await _img(db, "e2" * 32) # already embedded → skip
+ have = await _img(db, "e2" * 32) # concept @ current version → skip
db.add(ImageRegion(
image_record_id=have.id, kind="concept", rx=0.0, ry=0.0, rw=1.0, rh=1.0,
- siglip_embedding=[0.0] * 1152, embedding_version="siglip-test",
+ siglip_embedding=[0.0] * 1152, embedding_version=cur,
))
await db.commit()
@@ -57,6 +61,36 @@ async def test_enqueue_siglip_backfill_gates_on_concept_region(db):
assert n_for_need == 1
+@pytest.mark.asyncio
+async def test_enqueue_embed_backfill_selects_stale_and_unembedded(db):
+ # Whole-image GPU re-embed (#1190): enqueue images with no embedding or one
+ # stamped under a DIFFERENT model version (an operator swap); skip ones
+ # already at the current version.
+ from backend.app.models import MLSettings
+ from backend.app.tasks.ml import enqueue_gpu_backfill
+
+ cur = (await db.execute(
+ select(MLSettings.embedder_model_version).where(MLSettings.id == 1)
+ )).scalar_one()
+ current = await _img(db, "f1" * 32)
+ current.siglip_embedding = [0.0] * 1152
+ current.siglip_model_version = cur # up to date → skip
+ stale = await _img(db, "f2" * 32)
+ stale.siglip_embedding = [0.0] * 1152
+ stale.siglip_model_version = "old-embedder-v0" # wrong space → re-embed
+ unembedded = await _img(db, "f3" * 32) # never embedded → embed
+ await db.commit()
+
+ assert enqueue_gpu_backfill("embed") >= 2
+ queued = {
+ j.image_record_id for j in (
+ await db.execute(select(GpuJob).where(GpuJob.task == "embed"))
+ ).scalars()
+ }
+ assert stale.id in queued and unembedded.id in queued
+ assert current.id not in queued
+
+
@pytest.mark.asyncio
async def test_enqueue_dedupes_same_pair(db):
img = await _img(db, "a" * 64)
diff --git a/tests/test_ml_suggestions.py b/tests/test_ml_suggestions.py
index 40d938b..a797013 100644
--- a/tests/test_ml_suggestions.py
+++ b/tests/test_ml_suggestions.py
@@ -145,6 +145,20 @@ async def test_concept_region_surfaces_via_max_over_bag(db):
assert any(s.canonical_tag_id == tag.id and s.score > 0.7 for s in general)
+@pytest.mark.asyncio
+async def test_stale_embedding_version_excluded_from_scoring(db):
+ # Mid model-swap (#1190): an image still carrying an OLD-version whole-image
+ # embedding must NOT be scored by heads trained in the new model's space —
+ # even though the vector aligns with the head, it's the wrong coordinate
+ # system, so nothing surfaces until it's re-embedded.
+ tag = await TagService(db).find_or_create("glasses", TagKind.general)
+ img = await _img(db, "c1" * 32, _emb(0))
+ img.siglip_model_version = "some-old-model-v0" # != current embedder
+ await _head(db, tag.id, slot=0, suggest_threshold=0.5)
+ await db.commit()
+ assert not (await SuggestionService(db).for_image(img.id)).by_category.get("general")
+
+
@pytest.mark.asyncio
async def test_rejected_tag_surfaced_flagged_then_reversible(db):
# A dismissed suggestion is NOT dropped: it stays flagged rejected so the
From 3d77a38a25c5bd94beb07a33042827849d40203f Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Tue, 30 Jun 2026 11:48:09 -0400
Subject: [PATCH 03/10] refactor(ml): remove the dead per-tag centroid
subsystem (#1189)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The v2 pivot replaced per-tag SigLIP centroids with learned heads + CCIP.
Centroids were still recomputed (on every tag merge + a daily beat) but NOTHING
read them — suggestions come from heads+CCIP and apply_allowlist_tags applies
via Camie predictions, not centroids. Pure dead wiring; remove it.
Removed: CentroidService, recompute_centroid/recompute_centroids tasks, the
daily beat, POST /api/ml/recompute-centroids, the recompute-on-merge trigger,
the tag_reference_embedding table + model, the centroid_similarity_threshold +
min_reference_images settings (migration 0066), the CentroidRecomputeCard +
its store action + MaintenancePanel tile, and the centroid slider in
MLThresholdSliders. _keep_as_alias drops its vestigial has-centroid branch (the
allowlist branch already covers "could re-emit"); tag merge no longer clears a
table that no longer exists.
NOT touched (still live, parallel to heads): the Camie tagger, ImagePrediction,
and the allowlist bulk-apply — accepting a suggestion still allowlists + applies
it across the library. The tag-eval "centroid" baseline metric is unrelated
(in-memory) and stays. (image_record.centroid_scores JSON column also remains —
separate legacy field, its own micro-cleanup.)
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
---
alembic/versions/0066_drop_centroids.py | 57 ++++++
backend/app/api/ml_admin.py | 14 +-
backend/app/api/tags.py | 6 -
backend/app/celery_app.py | 4 -
backend/app/models/__init__.py | 2 -
backend/app/models/ml_settings.py | 15 +-
backend/app/models/tag_reference_embedding.py | 23 ---
backend/app/services/ml/centroids.py | 163 ------------------
backend/app/services/tag_service.py | 26 +--
backend/app/tasks/ml.py | 63 +------
.../settings/CentroidRecomputeCard.vue | 36 ----
.../settings/MLThresholdSliders.vue | 6 +-
.../components/settings/MaintenancePanel.vue | 7 +-
frontend/src/stores/ml.js | 6 +-
tests/test_api_ml_admin.py | 4 +-
tests/test_migration_0003.py | 8 -
tests/test_ml_artist_retired.py | 6 -
tests/test_ml_centroids.py | 112 ------------
tests/test_tag_merge.py | 28 ---
19 files changed, 78 insertions(+), 508 deletions(-)
create mode 100644 alembic/versions/0066_drop_centroids.py
delete mode 100644 backend/app/models/tag_reference_embedding.py
delete mode 100644 backend/app/services/ml/centroids.py
delete mode 100644 frontend/src/components/settings/CentroidRecomputeCard.vue
delete mode 100644 tests/test_ml_centroids.py
diff --git a/alembic/versions/0066_drop_centroids.py b/alembic/versions/0066_drop_centroids.py
new file mode 100644
index 0000000..d75a334
--- /dev/null
+++ b/alembic/versions/0066_drop_centroids.py
@@ -0,0 +1,57 @@
+"""drop the dead per-tag centroid subsystem (#1189 cleanup)
+
+The v2 pivot replaced per-tag SigLIP centroids with learned heads + CCIP.
+Nothing read the centroids anymore — they were recomputed (on merge + a daily
+beat) but never consumed for suggestions or auto-apply. Remove the storage +
+its two now-unused settings columns. (The recompute tasks, beat, endpoint,
+service, and UI card are removed in the same change.)
+
+Revision ID: 0066
+Revises: 0065
+Create Date: 2026-06-30
+"""
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "0066"
+down_revision: Union[str, None] = "0065"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.drop_table("tag_reference_embedding")
+ op.drop_column("ml_settings", "centroid_similarity_threshold")
+ op.drop_column("ml_settings", "min_reference_images")
+
+
+def downgrade() -> None:
+ op.add_column(
+ "ml_settings",
+ sa.Column(
+ "min_reference_images", sa.Integer(), nullable=False,
+ server_default="5",
+ ),
+ )
+ op.add_column(
+ "ml_settings",
+ sa.Column(
+ "centroid_similarity_threshold", sa.Float(), nullable=False,
+ server_default="0.55",
+ ),
+ )
+ op.create_table(
+ "tag_reference_embedding",
+ sa.Column("tag_id", sa.Integer(), nullable=False),
+ sa.Column("embedding", sa.LargeBinary(), nullable=False),
+ sa.Column("reference_count", sa.Integer(), nullable=False),
+ sa.Column("model_version", sa.String(length=128), nullable=False),
+ sa.Column(
+ "updated_at", sa.DateTime(timezone=True),
+ server_default=sa.func.now(), nullable=False,
+ ),
+ sa.ForeignKeyConstraint(["tag_id"], ["tag.id"], ondelete="CASCADE"),
+ sa.PrimaryKeyConstraint("tag_id"),
+ )
diff --git a/backend/app/api/ml_admin.py b/backend/app/api/ml_admin.py
index 102c004..cd0cf56 100644
--- a/backend/app/api/ml_admin.py
+++ b/backend/app/api/ml_admin.py
@@ -1,4 +1,4 @@
-"""ML admin API: settings, backfill trigger, centroid recompute trigger."""
+"""ML admin API: settings + backfill trigger."""
from quart import Blueprint, jsonify, request
@@ -11,8 +11,6 @@ ml_admin_bp = Blueprint("ml_admin", __name__, url_prefix="/api/ml")
_EDITABLE = (
"suggestion_threshold_character",
"suggestion_threshold_general",
- "centroid_similarity_threshold",
- "min_reference_images",
"tagger_store_floor",
"video_frame_interval_seconds",
"video_max_frames",
@@ -41,8 +39,6 @@ async def get_settings():
{
"suggestion_threshold_character": s.suggestion_threshold_character,
"suggestion_threshold_general": s.suggestion_threshold_general,
- "centroid_similarity_threshold": s.centroid_similarity_threshold,
- "min_reference_images": s.min_reference_images,
"tagger_store_floor": s.tagger_store_floor,
"video_frame_interval_seconds": s.video_frame_interval_seconds,
"video_max_frames": s.video_max_frames,
@@ -142,11 +138,3 @@ async def trigger_backfill():
r = backfill.delay()
return jsonify({"celery_task_id": r.id}), 202
-
-
-@ml_admin_bp.route("/recompute-centroids", methods=["POST"])
-async def trigger_recompute():
- from ..tasks.ml import recompute_centroids
-
- r = recompute_centroids.delay()
- return jsonify({"celery_task_id": r.id}), 202
diff --git a/backend/app/api/tags.py b/backend/app/api/tags.py
index 23b0817..59a1031 100644
--- a/backend/app/api/tags.py
+++ b/backend/app/api/tags.py
@@ -304,12 +304,6 @@ async def merge_tag(source_id: int):
from ..tasks.ml import apply_allowlist_tags
apply_allowlist_tags.delay(tag_id=result.target_id)
- # Tag merge invalidates the target's centroid (the merged-in source
- # tag's images now contribute to it). Daily list_drifted catches it
- # within 24h, but eager recompute closes the suggestion-quality dip
- # in the meantime. Audit 2026-06-02.
- from ..tasks.ml import recompute_centroid
- recompute_centroid.delay(result.target_id)
return jsonify(
{
"target": {
diff --git a/backend/app/celery_app.py b/backend/app/celery_app.py
index 7b7a4e3..d022717 100644
--- a/backend/app/celery_app.py
+++ b/backend/app/celery_app.py
@@ -101,10 +101,6 @@ def make_celery() -> Celery:
"task": "backend.app.tasks.ml.backfill",
"schedule": 86400.0,
},
- "recompute-centroids-daily": {
- "task": "backend.app.tasks.ml.recompute_centroids",
- "schedule": 86400.0,
- },
"apply-allowlist-sweep-daily": {
"task": "backend.app.tasks.ml.apply_allowlist_tags",
"schedule": 86400.0,
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index 087ff0b..5cd3c7e 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -38,7 +38,6 @@ from .tag_allowlist import TagAllowlist
from .tag_eval_run import TagEvalRun
from .tag_head import TagHead
from .tag_positive_confirmation import TagPositiveConfirmation
-from .tag_reference_embedding import TagReferenceEmbedding
from .tag_suggestion_rejection import TagSuggestionRejection
from .task_run import TaskRun
@@ -83,7 +82,6 @@ __all__ = [
"TagEvalRun",
"TagHead",
"TagPositiveConfirmation",
- "TagReferenceEmbedding",
"TagSuggestionRejection",
"TaskRun",
]
diff --git a/backend/app/models/ml_settings.py b/backend/app/models/ml_settings.py
index 0e84940..75db5b2 100644
--- a/backend/app/models/ml_settings.py
+++ b/backend/app/models/ml_settings.py
@@ -33,21 +33,14 @@ class MLSettings(Base):
suggestion_threshold_general: Mapped[float] = mapped_column(
Float, nullable=False, default=0.70
)
- centroid_similarity_threshold: Mapped[float] = mapped_column(
- Float, nullable=False, default=0.55
- )
# Ingest floor: tagger predictions below this confidence are not stored
- # (tagger.Tagger.infer). Default 0.70 — the suggestion path already
- # filters at 0.70 and the centroid/learned path covers low-confidence
- # preferred tags, so the sub-0.70 tail is redundant weight (it had
- # bloated image_record's TOAST to ~100 GB; plan-task #764). Operator-
- # tunable via Settings → ML; must stay ≤ the suggestion thresholds.
+ # (tagger.Tagger.infer). Default 0.70 — the suggestion path already filters
+ # there, so the sub-0.70 tail is redundant weight (it had bloated
+ # image_record's TOAST to ~100 GB; plan-task #764). Operator-tunable via
+ # Settings → ML; must stay ≤ the suggestion thresholds.
tagger_store_floor: Mapped[float] = mapped_column(
Float, nullable=False, default=0.70
)
- min_reference_images: Mapped[int] = mapped_column(
- Integer, nullable=False, default=5
- )
# Video tagging (#747). Sample one frame every N seconds (fixed CADENCE, not a
# fixed count) so a tag's frame-presence reflects real screen time regardless
# of video length; cap the total so a long video can't explode into hundreds
diff --git a/backend/app/models/tag_reference_embedding.py b/backend/app/models/tag_reference_embedding.py
deleted file mode 100644
index 2dc841b..0000000
--- a/backend/app/models/tag_reference_embedding.py
+++ /dev/null
@@ -1,23 +0,0 @@
-"""TagReferenceEmbedding — per-tag centroid (mean SigLIP embedding of members)."""
-
-from datetime import datetime
-
-from pgvector.sqlalchemy import Vector
-from sqlalchemy import DateTime, ForeignKey, Integer, String, func
-from sqlalchemy.orm import Mapped, mapped_column
-
-from .base import Base
-
-
-class TagReferenceEmbedding(Base):
- __tablename__ = "tag_reference_embedding"
-
- tag_id: Mapped[int] = mapped_column(
- ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True
- )
- embedding: Mapped[list[float]] = mapped_column(Vector(1152), nullable=False)
- reference_count: Mapped[int] = mapped_column(Integer, nullable=False)
- model_version: Mapped[str] = mapped_column(String(128), nullable=False)
- updated_at: Mapped[datetime] = mapped_column(
- DateTime(timezone=True), nullable=False, server_default=func.now()
- )
diff --git a/backend/app/services/ml/centroids.py b/backend/app/services/ml/centroids.py
deleted file mode 100644
index e18907f..0000000
--- a/backend/app/services/ml/centroids.py
+++ /dev/null
@@ -1,163 +0,0 @@
-"""Tag centroids: the mean SigLIP embedding of a tag's member images.
-
-Powers centroid-augmented suggestions (a tag whose centroid is close to an
-image's embedding becomes a suggestion even if Camie didn't predict it).
-"""
-
-from dataclasses import dataclass
-
-import numpy as np
-from sqlalchemy import func, select
-from sqlalchemy.dialects.postgresql import insert
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from ...models import (
- ImageRecord,
- MLSettings,
- Tag,
- TagKind,
- TagReferenceEmbedding,
-)
-from ...models.tag import image_tag
-
-ELIGIBLE_KINDS = {
- TagKind.character,
- TagKind.fandom,
- TagKind.general,
- TagKind.series,
-}
-
-
-@dataclass(frozen=True)
-class CentroidHit:
- tag_id: int
- similarity: float
-
-
-class CentroidService:
- def __init__(self, session: AsyncSession):
- self.session = session
-
- async def _min_reference_images(self) -> int:
- return (
- await self.session.execute(
- select(MLSettings.min_reference_images).where(MLSettings.id == 1)
- )
- ).scalar_one()
-
- async def _model_version(self) -> str:
- """Audit 2026-06-02: SigLIP model-version stamp comes from the
- DB row, not the env constant. tag_and_embed (tasks/ml.py:110)
- already reads from MLSettings.embedder_model_version, so by
- sourcing centroid stamps + drift checks from the same row, we
- eliminate the silent-drift case the audit flagged. env
- SIGLIP_MODEL_VERSION still drives which model embedder.py
- loads at runtime; the version stamp is purely the operator-
- controlled identifier."""
- return (
- await self.session.execute(
- select(MLSettings.embedder_model_version).where(MLSettings.id == 1)
- )
- ).scalar_one()
-
- async def recompute_for_tag(self, tag_id: int) -> bool:
- """Recompute one tag's centroid. Returns True if a centroid was
- written, False if skipped (ineligible kind or too few members)."""
- tag = await self.session.get(Tag, tag_id)
- if tag is None or tag.kind not in ELIGIBLE_KINDS:
- return False
-
- min_refs = await self._min_reference_images()
-
- stmt = (
- select(ImageRecord.siglip_embedding)
- .join(image_tag, image_tag.c.image_record_id == ImageRecord.id)
- .where(image_tag.c.tag_id == tag_id)
- .where(ImageRecord.siglip_embedding.is_not(None))
- )
- embeddings = [
- np.array(e, dtype=np.float32)
- for e in (await self.session.execute(stmt)).scalars().all()
- ]
- if len(embeddings) < min_refs:
- return False
-
- centroid = np.mean(np.stack(embeddings), axis=0).astype(np.float32)
- model_version = await self._model_version()
-
- stmt = insert(TagReferenceEmbedding).values(
- tag_id=tag_id,
- embedding=centroid.tolist(),
- reference_count=len(embeddings),
- model_version=model_version,
- )
- stmt = stmt.on_conflict_do_update(
- index_elements=["tag_id"],
- set_={
- "embedding": centroid.tolist(),
- "reference_count": len(embeddings),
- "model_version": model_version,
- "updated_at": func.now(),
- },
- )
- await self.session.execute(stmt)
- return True
-
- async def list_drifted(self) -> list[int]:
- """Tag ids whose centroid is stale: member count != reference_count,
- OR no centroid row, OR centroid built on a different SigLIP version.
- Only considers eligible-kind tags with embeddings present."""
- current_model_version = await self._model_version()
- member_counts = (
- select(
- image_tag.c.tag_id.label("tag_id"),
- func.count(image_tag.c.image_record_id).label("members"),
- )
- .join(ImageRecord, ImageRecord.id == image_tag.c.image_record_id)
- .where(ImageRecord.siglip_embedding.is_not(None))
- .group_by(image_tag.c.tag_id)
- .subquery()
- )
- stmt = (
- select(Tag.id)
- .join(member_counts, member_counts.c.tag_id == Tag.id)
- .outerjoin(
- TagReferenceEmbedding,
- TagReferenceEmbedding.tag_id == Tag.id,
- )
- .where(Tag.kind.in_(ELIGIBLE_KINDS))
- .where(
- (TagReferenceEmbedding.tag_id.is_(None))
- | (
- TagReferenceEmbedding.reference_count
- != member_counts.c.members
- )
- | (TagReferenceEmbedding.model_version != current_model_version)
- )
- )
- return list((await self.session.execute(stmt)).scalars().all())
-
- async def find_similar_tags(
- self, image_id: int, limit: int = 20
- ) -> list[CentroidHit]:
- """Cosine similarity between an image's embedding and stored
- centroids. Returns top-`limit` by similarity DESC. pgvector's
- cosine_distance gives 1 - cosine_similarity."""
- img = await self.session.get(ImageRecord, image_id)
- if img is None or img.siglip_embedding is None:
- return []
- emb = img.siglip_embedding
- distance = TagReferenceEmbedding.embedding.cosine_distance(emb)
- stmt = (
- select(
- TagReferenceEmbedding.tag_id,
- (1 - distance).label("similarity"),
- )
- .order_by(distance.asc())
- .limit(limit)
- )
- rows = (await self.session.execute(stmt)).all()
- return [
- CentroidHit(tag_id=r.tag_id, similarity=float(r.similarity))
- for r in rows
- ]
diff --git a/backend/app/services/tag_service.py b/backend/app/services/tag_service.py
index 839071c..66e174f 100644
--- a/backend/app/services/tag_service.py
+++ b/backend/app/services/tag_service.py
@@ -11,7 +11,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from ..models import HeadMetric, Tag, TagHead, TagKind, image_tag
from ..models.tag_allowlist import TagAllowlist
-from ..models.tag_reference_embedding import TagReferenceEmbedding
from .db_helpers import get_or_create
from .tag_query import fandom_join_alias, tag_columns
@@ -304,10 +303,10 @@ class TagService:
async def _keep_as_alias(self, tag_id: int) -> bool:
"""A merged-away tag's old name must survive as an alias iff the ML
- pipeline has ever applied it OR could re-emit it (allowlisted / has
- a centroid) — otherwise the proactive apply_allowlist_tags worker
- would silently regenerate it. Purely-manual, ML-unknown tags are
- deleted outright (no DB bloat)."""
+ pipeline has ever applied it OR could re-emit it (allowlisted) —
+ otherwise the proactive apply_allowlist_tags worker would silently
+ regenerate it. Purely-manual, ML-unknown tags are deleted outright (no
+ DB bloat)."""
is_machine = await self.session.scalar(
select(
exists().where(
@@ -325,14 +324,7 @@ class TagService:
allowlisted = await self.session.scalar(
select(exists().where(TagAllowlist.tag_id == tag_id))
)
- if allowlisted:
- return True
- has_centroid = await self.session.scalar(
- select(
- exists().where(TagReferenceEmbedding.tag_id == tag_id)
- )
- )
- return bool(has_centroid)
+ return bool(allowlisted)
async def rename(self, tag_id: int, new_name: str) -> Tag:
"""Rename a tag. Raises TagMergeConflict if the new name collides
@@ -573,7 +565,6 @@ class TagService:
merged_count = await self._repoint_image_tags(source_id, target_id)
await self._repoint_rejections(source_id, target_id)
await self._repoint_allowlist(source_id, target_id)
- await self._repoint_embedding(source_id)
await self._repoint_aliases(source_id, target_id)
await self._repoint_fandom_children(
source_id, target_id, source_kind
@@ -655,13 +646,6 @@ class TagService:
.values(tag_id=tgt)
)
- async def _repoint_embedding(self, src: int) -> None:
- await self.session.execute(
- text(
- "DELETE FROM tag_reference_embedding WHERE tag_id = :src"
- ),
- {"src": src},
- )
async def _repoint_aliases(self, src: int, tgt: int) -> None:
from ..models.tag_alias import TagAlias
diff --git a/backend/app/tasks/ml.py b/backend/app/tasks/ml.py
index f2aa6a7..b2d5e3c 100644
--- a/backend/app/tasks/ml.py
+++ b/backend/app/tasks/ml.py
@@ -1,9 +1,9 @@
-"""ML Celery tasks: per-image inference, backfill discovery, centroid
-recompute, allowlist auto-apply, model self-heal.
+"""ML Celery tasks: per-image inference, backfill discovery, head training,
+allowlist auto-apply, model self-heal.
-All run on the ml-worker (queue 'ml') except recompute_centroids and
-apply_allowlist_tags sweeps which are 'maintenance' lane. Sync sessions
-(Celery workers are sync processes), same pattern as FC-2a tasks.
+All run on the ml-worker (queue 'ml') except apply_allowlist_tags sweeps which
+are 'maintenance' lane. Sync sessions (Celery workers are sync processes), same
+pattern as FC-2a tasks.
"""
import logging
@@ -487,59 +487,6 @@ def _confidence_for_tag(session, tag, preds: dict) -> float | None:
return best
-@celery.task(name="backend.app.tasks.ml.recompute_centroid", bind=True)
-def recompute_centroid(self, tag_id: int) -> bool:
- import asyncio
-
- from ..services.ml.centroids import CentroidService
- from ._async_session import async_session_factory
-
- async def _run() -> bool:
- # Per-task NullPool engine bound to THIS asyncio.run loop — the shared
- # process-wide engine reuses connections across loops and raises
- # "Future attached to a different loop" on every call after the first.
- async_factory, async_engine = async_session_factory()
- try:
- async with async_factory() as session:
- svc = CentroidService(session)
- result = await svc.recompute_for_tag(tag_id)
- await session.commit()
- return result
- finally:
- await async_engine.dispose()
-
- return asyncio.run(_run())
-
-
-@celery.task(
- name="backend.app.tasks.ml.recompute_centroids",
- bind=True,
- # Audit 2026-06-02 — drifted-centroid rebuild over potentially
- # hundreds of tags.
- soft_time_limit=1800, time_limit=2100,
-)
-def recompute_centroids(self) -> int:
- """Daily: find drifted centroids, enqueue recompute_centroid for each."""
- import asyncio
-
- from ..services.ml.centroids import CentroidService
- from ._async_session import async_session_factory
-
- async def _list() -> list[int]:
- # Per-task NullPool engine bound to this loop (see recompute_centroid).
- async_factory, async_engine = async_session_factory()
- try:
- async with async_factory() as session:
- return await CentroidService(session).list_drifted()
- finally:
- await async_engine.dispose()
-
- drifted = asyncio.run(_list())
- for tid in drifted:
- recompute_centroid.delay(tid)
- return len(drifted)
-
-
@celery.task(
name="backend.app.tasks.ml.tag_eval_run",
bind=True,
diff --git a/frontend/src/components/settings/CentroidRecomputeCard.vue b/frontend/src/components/settings/CentroidRecomputeCard.vue
deleted file mode 100644
index f9c5df4..0000000
--- a/frontend/src/components/settings/CentroidRecomputeCard.vue
+++ /dev/null
@@ -1,36 +0,0 @@
-
-
-
- Rebuild the per-tag SigLIP centroids that power similarity-based
- suggestions. Runs nightly automatically; trigger manually after a
- large tagging session.
-
-
- mdi-vector-triangle Recompute centroids
-
- Enqueued.
-
-
-
-
-
diff --git a/frontend/src/components/settings/MLThresholdSliders.vue b/frontend/src/components/settings/MLThresholdSliders.vue
index eb1a8de..685009f 100644
--- a/frontend/src/components/settings/MLThresholdSliders.vue
+++ b/frontend/src/components/settings/MLThresholdSliders.vue
@@ -28,8 +28,7 @@
Tagger predictions below this confidence aren't stored — raising it
keeps the image library lean. Suggestions can't be shown below the
- floor; lower-confidence tags you actually want still surface through
- the learned centroid path.
+ floor.
@@ -84,8 +83,7 @@ const store = useMLStore()
// tagger store floor (nothing below the floor is stored to surface).
const fields = [
{ key: 'suggestion_threshold_character', label: 'Character', floorMin: true },
- { key: 'suggestion_threshold_general', label: 'General', floorMin: true },
- { key: 'centroid_similarity_threshold', label: 'Centroid similarity' }
+ { key: 'suggestion_threshold_general', label: 'General', floorMin: true }
]
const local = reactive({})
watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true })
diff --git a/frontend/src/components/settings/MaintenancePanel.vue b/frontend/src/components/settings/MaintenancePanel.vue
index 77a8035..aba2deb 100644
--- a/frontend/src/components/settings/MaintenancePanel.vue
+++ b/frontend/src/components/settings/MaintenancePanel.vue
@@ -1,9 +1,8 @@
- One-off backfills, tagging config and storage tools. The ML backfill and
- centroid recompute also run nightly; the allowlist auto-applies accepted
- tags. Click a tile to open it.
+ One-off backfills, tagging config and storage tools. The ML backfill runs
+ nightly; the allowlist auto-applies accepted tags. Click a tile to open it.
@@ -11,7 +10,6 @@
Re-run tagging, thumbnails, extraction and DB upkeep.
-
@@ -48,7 +46,6 @@
import { onMounted, onUnmounted } from 'vue'
import MLBackfillCard from './MLBackfillCard.vue'
-import CentroidRecomputeCard from './CentroidRecomputeCard.vue'
import ThumbnailBackfillCard from './ThumbnailBackfillCard.vue'
import ArchiveReextractCard from './ArchiveReextractCard.vue'
import MissingFileRepairCard from './MissingFileRepairCard.vue'
diff --git a/frontend/src/stores/ml.js b/frontend/src/stores/ml.js
index 5c55982..c67e82e 100644
--- a/frontend/src/stores/ml.js
+++ b/frontend/src/stores/ml.js
@@ -22,12 +22,8 @@ export const useMLStore = defineStore('ml', () => {
await api.post('/api/ml/backfill')
}
- async function triggerRecomputeCentroids() {
- await api.post('/api/ml/recompute-centroids')
- }
-
return {
settings, loading, error,
- loadSettings, patchSettings, triggerBackfill, triggerRecomputeCentroids
+ loadSettings, patchSettings, triggerBackfill
}
})
diff --git a/tests/test_api_ml_admin.py b/tests/test_api_ml_admin.py
index ebc944d..9e4f4ff 100644
--- a/tests/test_api_ml_admin.py
+++ b/tests/test_api_ml_admin.py
@@ -107,11 +107,9 @@ async def test_video_min_tag_frames_above_max_rejected(client):
@pytest.mark.asyncio
-async def test_backfill_and_recompute_trigger(client):
+async def test_backfill_trigger(client):
r1 = await client.post("/api/ml/backfill")
assert r1.status_code == 202
- r2 = await client.post("/api/ml/recompute-centroids")
- assert r2.status_code == 202
@pytest.mark.asyncio
diff --git a/tests/test_migration_0003.py b/tests/test_migration_0003.py
index 4fd9fab..41828b7 100644
--- a/tests/test_migration_0003.py
+++ b/tests/test_migration_0003.py
@@ -6,7 +6,6 @@ from backend.app.models import (
MLSettings,
TagAlias,
TagAllowlist,
- TagReferenceEmbedding,
TagSuggestionRejection,
)
@@ -16,7 +15,6 @@ def test_new_tables_registered():
"tag_allowlist",
"tag_suggestion_rejection",
"tag_alias",
- "tag_reference_embedding",
"ml_settings",
}
assert expected.issubset(Base.metadata.tables.keys())
@@ -42,12 +40,6 @@ def test_ml_settings_singleton_constraint():
assert "ck_ml_settings_singleton" in names
-def test_tag_reference_embedding_has_vector():
- cols = {c.name for c in TagReferenceEmbedding.__table__.columns}
- assert "embedding" in cols
- assert "reference_count" in cols
-
-
def test_tag_allowlist_confidence_check():
names = {c.name for c in TagAllowlist.__table__.constraints}
assert "ck_tag_allowlist_confidence_range" in names
diff --git a/tests/test_ml_artist_retired.py b/tests/test_ml_artist_retired.py
index 0a3e208..2d335f0 100644
--- a/tests/test_ml_artist_retired.py
+++ b/tests/test_ml_artist_retired.py
@@ -8,12 +8,6 @@ def test_artist_not_surfaced():
assert "artist" not in SURFACED_CATEGORIES
-def test_artist_not_centroid_eligible():
- from backend.app.models import TagKind
- from backend.app.services.ml.centroids import ELIGIBLE_KINDS
- assert TagKind.artist not in ELIGIBLE_KINDS
-
-
def test_artist_not_head_eligible():
# Tagging-v2: suggestions come from heads, and heads are only trained for
# general/character concepts — so 'artist' (and any other kind) can't surface.
diff --git a/tests/test_ml_centroids.py b/tests/test_ml_centroids.py
deleted file mode 100644
index 192bd1c..0000000
--- a/tests/test_ml_centroids.py
+++ /dev/null
@@ -1,112 +0,0 @@
-import numpy as np
-import pytest
-
-from backend.app.models import ImageRecord, TagKind
-from backend.app.models.tag import image_tag
-from backend.app.services.ml.centroids import CentroidService
-from backend.app.services.tag_service import TagService
-
-pytestmark = pytest.mark.integration
-
-
-def _img(sha: str, embedding: list[float] | None) -> ImageRecord:
- return ImageRecord(
- path=f"/images/{sha}.jpg",
- sha256=sha,
- size_bytes=1,
- mime="image/jpeg",
- width=1,
- height=1,
- origin="imported_filesystem",
- integrity_status="unknown",
- siglip_embedding=embedding,
- )
-
-
-async def _attach(db, image_id: int, tag_id: int):
- await db.execute(
- image_tag.insert().values(
- image_record_id=image_id, tag_id=tag_id, source="manual"
- )
- )
-
-
-@pytest.mark.asyncio
-async def test_recompute_skips_too_few_members(db):
- tags = TagService(db)
- tag = await tags.find_or_create("Lonely", TagKind.character)
- img = _img("a" * 64, [0.1] * 1152)
- db.add(img)
- await db.flush()
- await _attach(db, img.id, tag.id)
-
- svc = CentroidService(db)
- assert await svc.recompute_for_tag(tag.id) is False
-
-
-@pytest.mark.asyncio
-async def test_recompute_writes_centroid(db):
- tags = TagService(db)
- tag = await tags.find_or_create("Popular", TagKind.character)
- for i in range(5):
- img = _img(f"{i:064d}", [float(i)] * 1152)
- db.add(img)
- await db.flush()
- await _attach(db, img.id, tag.id)
-
- svc = CentroidService(db)
- assert await svc.recompute_for_tag(tag.id) is True
-
- from backend.app.models import TagReferenceEmbedding
- cen = await db.get(TagReferenceEmbedding, tag.id)
- assert cen is not None
- assert cen.reference_count == 5
- assert abs(np.array(cen.embedding)[0] - 2.0) < 1e-4
-
-
-@pytest.mark.asyncio
-async def test_recompute_skips_ineligible_kind(db):
- tags = TagService(db)
- tag = await tags.find_or_create("somearchive", TagKind.archive)
- for i in range(5):
- img = _img(f"arch{i:060d}", [1.0] * 1152)
- db.add(img)
- await db.flush()
- await _attach(db, img.id, tag.id)
- svc = CentroidService(db)
- assert await svc.recompute_for_tag(tag.id) is False
-
-
-@pytest.mark.asyncio
-async def test_list_drifted_includes_uncomputed(db):
- tags = TagService(db)
- tag = await tags.find_or_create("Drifty", TagKind.character)
- for i in range(5):
- img = _img(f"d{i:063d}", [0.5] * 1152)
- db.add(img)
- await db.flush()
- await _attach(db, img.id, tag.id)
- svc = CentroidService(db)
- drifted = await svc.list_drifted()
- assert tag.id in drifted
-
-
-@pytest.mark.asyncio
-async def test_find_similar_tags(db):
- tags = TagService(db)
- tag = await tags.find_or_create("SimTag", TagKind.character)
- for i in range(5):
- img = _img(f"s{i:063d}", [1.0] * 1152)
- db.add(img)
- await db.flush()
- await _attach(db, img.id, tag.id)
- svc = CentroidService(db)
- await svc.recompute_for_tag(tag.id)
-
- query_img = _img("q" * 64, [1.0] * 1152)
- db.add(query_img)
- await db.flush()
- hits = await svc.find_similar_tags(query_img.id, limit=10)
- assert any(h.tag_id == tag.id for h in hits)
- sim = next(h.similarity for h in hits if h.tag_id == tag.id)
- assert sim > 0.99
diff --git a/tests/test_tag_merge.py b/tests/test_tag_merge.py
index d99ec44..17835ee 100644
--- a/tests/test_tag_merge.py
+++ b/tests/test_tag_merge.py
@@ -279,34 +279,6 @@ async def test_merge_allowlist_source_only_moves_to_target(db):
assert rows[0].min_confidence == 0.42
-@pytest.mark.asyncio
-async def test_merge_deletes_source_embedding(db):
- from backend.app.models.tag_reference_embedding import (
- TagReferenceEmbedding,
- )
-
- svc = TagService(db)
- a = await svc.find_or_create("SrcEmb", TagKind.general)
- b = await svc.find_or_create("TgtEmb", TagKind.general)
- db.add(
- TagReferenceEmbedding(
- tag_id=a.id,
- embedding=[0.0] * 1152,
- reference_count=1,
- model_version="v",
- )
- )
- await db.flush()
- await svc.merge(a.id, b.id)
- db.expire_all() # merge() uses Core DML; drop stale identity-map state
- remaining = await db.scalar(
- select(func.count())
- .select_from(TagReferenceEmbedding)
- .where(TagReferenceEmbedding.tag_id == a.id)
- )
- assert remaining == 0
-
-
@pytest.mark.asyncio
async def test_merge_repoints_existing_aliases(db):
from backend.app.models.tag_alias import TagAlias
From 485387ff0ba025d3ae05299240591ceb20649cee Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Tue, 30 Jun 2026 13:04:31 -0400
Subject: [PATCH 04/10] refactor(ml): retire the Camie tagger + allowlist
bulk-apply (#1189)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Heads + CCIP are the tag source and head auto-apply is the earned propagation.
The Camie tagger ran only to feed the allowlist bulk-apply (its ImagePrediction
rows had no other consumer), and the allowlist was a SECOND, un-earned auto-apply
path firing in parallel with heads on every accept — exactly the un-earned spray
the v2 pivot replaced. Retire both.
Behavior change: accepting a suggestion now applies the tag to THAT image only
(source='ml_accepted', a head-training positive) — it no longer allowlists +
fans the tag across the library via Camie. Propagation is heads' earned
auto-apply. (Loses instant cold-start propagation for booru-vocab tags; that was
un-earned and bypassed the precision gate.)
- tag_and_embed is now EMBED-ONLY (no Camie load/infer, no ImagePrediction
writes); backfill enqueues it for images with no embedding.
- Removed: services/ml/tagger.py, apply_allowlist_tags + helpers + daily beat +
every enqueue caller (accept/alias/merge/per-image), api/allowlist.py +
blueprint, ImagePrediction + TagAllowlist models/tables (migration 0067),
AllowlistTable.vue + allowlist store, the accept coverage-projection payload.
- AllowlistService gutted to accept/dismiss/undismiss/reject (the rejection store
the rail still needs); accept returns nothing, API returns {accepted, tag_id}.
- tag merge no longer repoints/triggers the allowlist; _keep_as_alias now keys on
ML-applied image_tag sources (incl. head_auto) instead of the allowlist.
- UI: MLBackfillCard relabelled to embedding-only; accept toast simplified;
MaintenancePanel drops the allowlist tile.
Left for a follow-up hygiene pass (now-inert, harmless): the dead settings
columns (tagger_store_floor, tagger_model_version, suggestion_threshold_*,
video_min_tag_frames), image_record.tagger_model_version, MLThresholdSliders
trim, and the Camie model download in download_models.py.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
---
.../versions/0067_retire_camie_allowlist.py | 66 ++++
backend/app/api/__init__.py | 2 -
backend/app/api/allowlist.py | 84 ------
backend/app/api/suggestions.py | 41 +--
backend/app/api/tags.py | 10 +-
backend/app/celery_app.py | 4 -
backend/app/models/__init__.py | 4 -
backend/app/models/image_prediction.py | 37 ---
backend/app/models/tag_allowlist.py | 32 --
backend/app/services/importer.py | 10 -
backend/app/services/ml/allowlist.py | 169 ++---------
backend/app/services/ml/tagger.py | 210 -------------
backend/app/services/tag_service.py | 35 +--
backend/app/tasks/ml.py | 285 ++----------------
.../components/settings/AllowlistTable.vue | 120 --------
.../components/settings/MLBackfillCard.vue | 7 +-
.../components/settings/MaintenancePanel.vue | 6 +-
frontend/src/stores/allowlist.js | 44 ---
frontend/src/stores/suggestions.js | 27 +-
tests/_prediction_helpers.py | 21 --
tests/test_api_allowlist.py | 88 ------
tests/test_api_suggestions.py | 54 ++--
tests/test_api_tag_merge.py | 40 +--
tests/test_image_prediction.py | 57 ----
tests/test_migration_0003.py | 7 -
tests/test_ml_allowlist.py | 182 -----------
tests/test_ml_artist_retired.py | 5 -
tests/test_ml_tagger.py | 54 ----
tests/test_phash_dedup.py | 11 -
tests/test_tag_merge.py | 46 +--
tests/test_tasks_ml.py | 111 +------
31 files changed, 159 insertions(+), 1710 deletions(-)
create mode 100644 alembic/versions/0067_retire_camie_allowlist.py
delete mode 100644 backend/app/api/allowlist.py
delete mode 100644 backend/app/models/image_prediction.py
delete mode 100644 backend/app/models/tag_allowlist.py
delete mode 100644 backend/app/services/ml/tagger.py
delete mode 100644 frontend/src/components/settings/AllowlistTable.vue
delete mode 100644 frontend/src/stores/allowlist.js
delete mode 100644 tests/_prediction_helpers.py
delete mode 100644 tests/test_api_allowlist.py
delete mode 100644 tests/test_image_prediction.py
delete mode 100644 tests/test_ml_allowlist.py
delete mode 100644 tests/test_ml_tagger.py
diff --git a/alembic/versions/0067_retire_camie_allowlist.py b/alembic/versions/0067_retire_camie_allowlist.py
new file mode 100644
index 0000000..e3edd02
--- /dev/null
+++ b/alembic/versions/0067_retire_camie_allowlist.py
@@ -0,0 +1,66 @@
+"""retire the Camie tagger + allowlist bulk-apply (#1189)
+
+The v2 pivot made heads + CCIP the tag source and head auto-apply the earned
+propagation. The Camie tagger ran only to feed the allowlist bulk-apply (its
+predictions had no other consumer), and the allowlist was a second, un-earned
+auto-apply path parallel to heads. Both are retired — drop their storage.
+
+(image_prediction = Camie's per-image predictions; tag_allowlist = the bulk-
+apply allowlist. Nothing references INTO these tables, so the drop is clean.)
+
+Revision ID: 0067
+Revises: 0066
+Create Date: 2026-06-30
+"""
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "0067"
+down_revision: Union[str, None] = "0066"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.drop_table("image_prediction")
+ op.drop_table("tag_allowlist")
+
+
+def downgrade() -> None:
+ op.create_table(
+ "tag_allowlist",
+ sa.Column("tag_id", sa.Integer(), nullable=False),
+ sa.Column(
+ "min_confidence", sa.Float(), nullable=False, server_default="0.9"
+ ),
+ sa.Column(
+ "created_at", sa.DateTime(timezone=True),
+ server_default=sa.func.now(), nullable=False,
+ ),
+ sa.ForeignKeyConstraint(["tag_id"], ["tag.id"], ondelete="CASCADE"),
+ sa.PrimaryKeyConstraint("tag_id"),
+ sa.CheckConstraint(
+ "min_confidence >= 0 AND min_confidence <= 1",
+ name="ck_tag_allowlist_confidence_range",
+ ),
+ )
+ op.create_table(
+ "image_prediction",
+ sa.Column("id", sa.Integer(), primary_key=True),
+ sa.Column("image_record_id", sa.Integer(), nullable=False),
+ sa.Column("raw_name", sa.String(length=255), nullable=False),
+ sa.Column("category", sa.String(length=32), nullable=False),
+ sa.Column("score", sa.Float(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["image_record_id"], ["image_record.id"], ondelete="CASCADE"
+ ),
+ )
+ op.create_index(
+ "ix_image_prediction_image", "image_prediction", ["image_record_id"]
+ )
+ op.create_index(
+ "ix_image_prediction_name_score", "image_prediction",
+ ["raw_name", "score"],
+ )
diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py
index cdc1442..8345f2d 100644
--- a/backend/app/api/__init__.py
+++ b/backend/app/api/__init__.py
@@ -16,7 +16,6 @@ api_bp.add_url_rule("/health", view_func=health.get_health, methods=["GET"])
def all_blueprints() -> list[Blueprint]:
from .admin import admin_bp
from .aliases import aliases_bp
- from .allowlist import allowlist_bp
from .artist import artist_bp
from .artists import artists_bp
from .attachments import attachments_bp
@@ -58,7 +57,6 @@ def all_blueprints() -> list[Blueprint]:
cleanup_bp,
import_admin_bp,
suggestions_bp,
- allowlist_bp,
aliases_bp,
tag_eval_bp,
heads_bp,
diff --git a/backend/app/api/allowlist.py b/backend/app/api/allowlist.py
deleted file mode 100644
index 31241de..0000000
--- a/backend/app/api/allowlist.py
+++ /dev/null
@@ -1,84 +0,0 @@
-"""Allowlist API: list, adjust threshold, remove."""
-
-from quart import Blueprint, jsonify, request
-
-from ..extensions import get_session
-from ..models import TagAllowlist
-from ..services.ml.allowlist import AllowlistService
-
-allowlist_bp = Blueprint("allowlist", __name__, url_prefix="/api")
-
-
-@allowlist_bp.route("/allowlist", methods=["GET"])
-async def list_allowlist():
- async with get_session() as session:
- rows = await AllowlistService(session).list_all()
- return jsonify(
- [
- {
- "tag_id": r.tag_id,
- "tag_name": r.tag_name,
- "tag_kind": r.tag_kind,
- "min_confidence": r.min_confidence,
- "applied_count": r.applied_count,
- "coverage_count": r.coverage_count,
- }
- for r in rows
- ]
- )
-
-
-@allowlist_bp.route("/tags//allowlist/coverage", methods=["GET"])
-async def coverage(tag_id: int):
- """Live "at threshold T, a sweep would cover ~N images" projection for the
- allowlist tuning dashboard. Defaults to the tag's stored threshold."""
- raw = request.args.get("threshold")
- async with get_session() as session:
- svc = AllowlistService(session)
- if raw is not None:
- try:
- threshold = float(raw)
- except ValueError:
- return jsonify({"error": "threshold must be a float"}), 400
- if not (0 < threshold <= 1):
- return jsonify({"error": "threshold must be in (0, 1]"}), 400
- else:
- row = await session.get(TagAllowlist, tag_id)
- if row is None:
- return jsonify({"error": "not on allowlist"}), 404
- threshold = row.min_confidence
- count = await svc.coverage(tag_id, threshold)
- return jsonify({"count": count, "threshold": threshold})
-
-
-@allowlist_bp.route("/tags//allowlist", methods=["GET"])
-async def get_one(tag_id: int):
- async with get_session() as session:
- row = await session.get(TagAllowlist, tag_id)
- if row is None:
- return jsonify({"error": "not on allowlist"}), 404
- return jsonify(
- {"min_confidence": row.min_confidence, "added_at": row.added_at.isoformat()}
- )
-
-
-@allowlist_bp.route("/tags//allowlist", methods=["PATCH"])
-async def patch_threshold(tag_id: int):
- body = await request.get_json()
- if not body or "min_confidence" not in body:
- return jsonify({"error": "min_confidence required"}), 400
- mc = float(body["min_confidence"])
- if not (0 < mc <= 1):
- return jsonify({"error": "min_confidence must be in (0, 1]"}), 400
- async with get_session() as session:
- await AllowlistService(session).update_threshold(tag_id, mc)
- await session.commit()
- return "", 204
-
-
-@allowlist_bp.route("/tags//allowlist", methods=["DELETE"])
-async def remove(tag_id: int):
- async with get_session() as session:
- await AllowlistService(session).remove(tag_id)
- await session.commit()
- return "", 204
diff --git a/backend/app/api/suggestions.py b/backend/app/api/suggestions.py
index 7a5ca1d..cf27125 100644
--- a/backend/app/api/suggestions.py
+++ b/backend/app/api/suggestions.py
@@ -3,31 +3,12 @@
from quart import Blueprint, jsonify, request
from ..extensions import get_session
-from ..models import Tag, TagAllowlist
from ..services.ml.allowlist import AllowlistService
from ..services.ml.suggestions import SuggestionService
suggestions_bp = Blueprint("suggestions", __name__, url_prefix="/api")
-async def _accept_payload(session, svc, newly_added: bool, tag_id: int) -> dict:
- """Shape the accept/alias response. When accepting newly allowlists a tag,
- include the coverage PROJECTION (at the tag's threshold) so the UI can show
- a non-blocking "auto-applying to ~N images" toast — the actual apply runs
- async via apply_allowlist_tags, so this is an estimate, not a post-hoc
- count (#7)."""
- payload = {"allowlisted": newly_added}
- if newly_added:
- tag = await session.get(Tag, tag_id)
- row = await session.get(TagAllowlist, tag_id)
- payload["tag_id"] = tag_id
- payload["tag_name"] = tag.name if tag is not None else None
- payload["projected_count"] = await svc.coverage(
- tag_id, row.min_confidence if row is not None else 0.90,
- )
- return payload
-
-
@suggestions_bp.route("/images//suggestions", methods=["GET"])
async def get_suggestions(image_id: int):
# ?min= overrides the configured per-category thresholds so the typed
@@ -83,15 +64,9 @@ async def accept_suggestion(image_id: int):
return jsonify({"error": "tag_id required"}), 400
tag_id = body["tag_id"]
async with get_session() as session:
- svc = AllowlistService(session)
- newly_added = await svc.accept(image_id, tag_id)
- payload = await _accept_payload(session, svc, newly_added, tag_id)
+ await AllowlistService(session).accept(image_id, tag_id)
await session.commit()
- if newly_added:
- from ..tasks.ml import apply_allowlist_tags
-
- apply_allowlist_tags.delay(tag_id=tag_id)
- return jsonify(payload)
+ return jsonify({"accepted": True, "tag_id": tag_id})
@suggestions_bp.route(
@@ -104,22 +79,14 @@ async def alias_suggestion(image_id: int):
return jsonify({"error": f"required: {sorted(required)}"}), 400
canonical_tag_id = body["canonical_tag_id"]
async with get_session() as session:
- svc = AllowlistService(session)
- newly_added = await svc.add_alias_and_accept(
+ await AllowlistService(session).add_alias_and_accept(
image_id,
body["alias_string"],
body["alias_category"],
canonical_tag_id,
)
- payload = await _accept_payload(
- session, svc, newly_added, canonical_tag_id,
- )
await session.commit()
- if newly_added:
- from ..tasks.ml import apply_allowlist_tags
-
- apply_allowlist_tags.delay(tag_id=canonical_tag_id)
- return jsonify(payload)
+ return jsonify({"accepted": True, "tag_id": canonical_tag_id})
@suggestions_bp.route(
diff --git a/backend/app/api/tags.py b/backend/app/api/tags.py
index 59a1031..92e7c9f 100644
--- a/backend/app/api/tags.py
+++ b/backend/app/api/tags.py
@@ -1,13 +1,12 @@
"""Tags API: autocomplete, create, list/add/remove for an image."""
from quart import Blueprint, jsonify, request
-from sqlalchemy import exists, select
+from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.exc import IntegrityError
from ..extensions import get_session
from ..models import Tag, TagKind, TagPositiveConfirmation
-from ..models.tag_allowlist import TagAllowlist
from ..services.bulk_tag_service import BulkTagService
from ..services.ml.aliases import AliasService
from ..services.series_match_service import SeriesMatchService
@@ -297,13 +296,6 @@ async def merge_tag(source_id: int):
status = 404 if "not found" in msg else 400
return jsonify({"error": msg}), status
await session.commit()
- target_allowlisted = await session.scalar(
- select(exists().where(TagAllowlist.tag_id == result.target_id))
- )
- if target_allowlisted:
- from ..tasks.ml import apply_allowlist_tags
-
- apply_allowlist_tags.delay(tag_id=result.target_id)
return jsonify(
{
"target": {
diff --git a/backend/app/celery_app.py b/backend/app/celery_app.py
index d022717..811b390 100644
--- a/backend/app/celery_app.py
+++ b/backend/app/celery_app.py
@@ -101,10 +101,6 @@ def make_celery() -> Celery:
"task": "backend.app.tasks.ml.backfill",
"schedule": 86400.0,
},
- "apply-allowlist-sweep-daily": {
- "task": "backend.app.tasks.ml.apply_allowlist_tags",
- "schedule": 86400.0,
- },
"train-heads-nightly": {
"task": "backend.app.tasks.ml.scheduled_train_heads",
"schedule": 86400.0, # passive cadence; manual retrain stays available
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index 5cd3c7e..8707467 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -13,7 +13,6 @@ from .head_auto_apply_run import HeadAutoApplyRun
from .head_metric import HeadMetric
from .head_metrics_snapshot import HeadMetricsSnapshot
from .head_training_run import HeadTrainingRun
-from .image_prediction import ImagePrediction
from .image_provenance import ImageProvenance
from .image_record import ImageRecord
from .image_region import ImageRegion
@@ -34,7 +33,6 @@ from .subscribestar_failed_media import SubscribeStarFailedMedia
from .subscribestar_seen_media import SubscribeStarSeenMedia
from .tag import Tag, TagKind, image_tag
from .tag_alias import TagAlias
-from .tag_allowlist import TagAllowlist
from .tag_eval_run import TagEvalRun
from .tag_head import TagHead
from .tag_positive_confirmation import TagPositiveConfirmation
@@ -59,7 +57,6 @@ __all__ = [
"SeriesPage",
"SeriesSuggestion",
"ImageRecord",
- "ImagePrediction",
"ImageProvenance",
"ImageRegion",
"Tag",
@@ -78,7 +75,6 @@ __all__ = [
"HeadMetricsSnapshot",
"HeadTrainingRun",
"TagAlias",
- "TagAllowlist",
"TagEvalRun",
"TagHead",
"TagPositiveConfirmation",
diff --git a/backend/app/models/image_prediction.py b/backend/app/models/image_prediction.py
deleted file mode 100644
index f532243..0000000
--- a/backend/app/models/image_prediction.py
+++ /dev/null
@@ -1,37 +0,0 @@
-"""ImagePrediction — one row per (image, tagger vocab prediction).
-
-Replaces the image_record.tagger_predictions JSON blob (#768). Storing the
-raw Camie/booru vocab name (not a tag_id) preserves the suggestion read
-path's semantics: raw_name → canonical Tag resolution happens at read time
-via the alias map, and accepting a prediction can CREATE the Tag. The store
-floor (ml_settings.tagger_store_floor) is applied at WRITE time, so only
-predictions >= the floor land here.
-"""
-
-from sqlalchemy import Float, ForeignKey, Index, String, UniqueConstraint
-from sqlalchemy.orm import Mapped, mapped_column
-
-from .base import Base
-
-
-class ImagePrediction(Base):
- __tablename__ = "image_prediction"
- __table_args__ = (
- UniqueConstraint(
- "image_record_id", "raw_name", name="image_raw_name",
- ),
- # Per-image read (suggestion build) and the "images with tag X above
- # Y" query the JSON blob never allowed.
- Index("ix_image_prediction_image", "image_record_id"),
- Index("ix_image_prediction_name_score", "raw_name", "score"),
- )
-
- id: Mapped[int] = mapped_column(primary_key=True)
- image_record_id: Mapped[int] = mapped_column(
- ForeignKey("image_record.id", ondelete="CASCADE"), nullable=False,
- )
- # The raw tagger vocab key (booru form) — NOT a tag_id. Resolved to a
- # canonical Tag at read time, exactly as the old JSON keys were.
- raw_name: Mapped[str] = mapped_column(String(255), nullable=False)
- category: Mapped[str] = mapped_column(String(64), nullable=False)
- score: Mapped[float] = mapped_column(Float, nullable=False)
diff --git a/backend/app/models/tag_allowlist.py b/backend/app/models/tag_allowlist.py
deleted file mode 100644
index 3bbbc7a..0000000
--- a/backend/app/models/tag_allowlist.py
+++ /dev/null
@@ -1,32 +0,0 @@
-"""TagAllowlist — tags the operator opted-in to auto-apply via maintenance."""
-
-from datetime import datetime
-
-from sqlalchemy import CheckConstraint, DateTime, Float, ForeignKey, func
-from sqlalchemy.orm import Mapped, mapped_column
-
-from .base import Base
-
-
-class TagAllowlist(Base):
- __tablename__ = "tag_allowlist"
- # Bare name — Base.metadata's naming convention prepends ck__,
- # producing the final ck_tag_allowlist_confidence_range (matches migration 0003).
- __table_args__ = (
- CheckConstraint(
- "min_confidence > 0 AND min_confidence <= 1",
- name="confidence_range",
- ),
- )
-
- tag_id: Mapped[int] = mapped_column(
- ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True
- )
- # Default auto-apply threshold for a newly-accepted tag. 0.90 (lowered from
- # 0.95 on operator evidence 2026-06-07: 0.95 was too strict and skipped
- # confident-enough applications). Per-tag value is still tunable in the
- # allowlist table; existing rows keep whatever they were stored with.
- min_confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.90)
- added_at: Mapped[datetime] = mapped_column(
- DateTime(timezone=True), nullable=False, server_default=func.now()
- )
diff --git a/backend/app/services/importer.py b/backend/app/services/importer.py
index 5634928..7ba8e5c 100644
--- a/backend/app/services/importer.py
+++ b/backend/app/services/importer.py
@@ -1479,16 +1479,6 @@ class Importer:
existing.siglip_embedding = None
existing.siglip_model_version = None
existing.centroid_scores = None
- # #768: predictions also live in the normalized image_prediction table
- # now — clear them so a re-imported file re-derives a fresh set.
- from sqlalchemy import delete as _delete
-
- from ..models import ImagePrediction as _ImagePrediction
- self.session.execute(
- _delete(_ImagePrediction).where(
- _ImagePrediction.image_record_id == existing.id
- )
- )
# created_at intentionally preserved; updated_at auto-bumps.
self.session.flush()
self.session.commit()
diff --git a/backend/app/services/ml/allowlist.py b/backend/app/services/ml/allowlist.py
index 857d665..a6bee65 100644
--- a/backend/app/services/ml/allowlist.py
+++ b/backend/app/services/ml/allowlist.py
@@ -1,36 +1,20 @@
-"""Allowlist semantics: accepting a suggestion adds the canonical tag to
-image_tag AND to tag_allowlist; per-image removal/dismiss writes a rejection.
+"""Suggestion actions: accept applies the canonical tag to an image (which
+feeds head training); dismiss / reject record a per-image rejection.
+
+(The Camie allowlist bulk-apply was retired #1189 — heads + CCIP are the tag
+source, and head auto-apply is the earned propagation. Accept no longer
+allowlists or fans a tag out across the library.)
"""
-from collections.abc import Sequence
-from dataclasses import dataclass
-
-from sqlalchemy import and_, delete, distinct, func, or_, select
+from sqlalchemy import delete
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
-from ...models import (
- ImagePrediction,
- MLSettings,
- Tag,
- TagAlias,
- TagAllowlist,
- TagSuggestionRejection,
-)
+from ...models import TagSuggestionRejection
from ...models.tag import image_tag
from .aliases import AliasService
-@dataclass(frozen=True)
-class AllowlistRow:
- tag_id: int
- tag_name: str
- tag_kind: str
- min_confidence: float
- applied_count: int # image_tag rows currently carrying this tag
- coverage_count: int # images a sweep WOULD cover at min_confidence
-
-
class AllowlistService:
def __init__(self, session: AsyncSession):
self.session = session
@@ -39,21 +23,11 @@ class AllowlistService:
async def _apply_image_tag(self, image_id: int, tag_id: int, source: str):
stmt = insert(image_tag).values(
image_record_id=image_id, tag_id=tag_id, source=source
- )
- stmt = stmt.on_conflict_do_nothing(
+ ).on_conflict_do_nothing(
index_elements=["image_record_id", "tag_id"]
)
await self.session.execute(stmt)
- async def _add_to_allowlist(self, tag_id: int) -> bool:
- """Returns True if newly added (caller should kick off retro-apply)."""
- exists = await self.session.get(TagAllowlist, tag_id)
- if exists is not None:
- return False
- self.session.add(TagAllowlist(tag_id=tag_id))
- await self.session.flush()
- return True
-
async def _clear_rejection(self, image_id: int, tag_id: int):
await self.session.execute(
delete(TagSuggestionRejection)
@@ -61,12 +35,11 @@ class AllowlistService:
.where(TagSuggestionRejection.tag_id == tag_id)
)
- async def accept(self, image_id: int, tag_id: int) -> bool:
- """Accept a suggestion. Returns True if the tag was newly added to
- the allowlist (the API layer enqueues apply_allowlist_tags then)."""
+ async def accept(self, image_id: int, tag_id: int) -> None:
+ """Apply the accepted tag to this image (source='ml_accepted', a head
+ training positive) and clear any prior rejection."""
await self._apply_image_tag(image_id, tag_id, source="ml_accepted")
await self._clear_rejection(image_id, tag_id)
- return await self._add_to_allowlist(tag_id)
async def add_alias_and_accept(
self,
@@ -74,17 +47,16 @@ class AllowlistService:
alias_string: str,
alias_category: str,
canonical_tag_id: int,
- ) -> bool:
+ ) -> None:
await self.aliases.create(
alias_string, alias_category, canonical_tag_id
)
- return await self.accept(image_id, canonical_tag_id)
+ await self.accept(image_id, canonical_tag_id)
async def dismiss(self, image_id: int, tag_id: int) -> None:
stmt = insert(TagSuggestionRejection).values(
image_record_id=image_id, tag_id=tag_id
- )
- stmt = stmt.on_conflict_do_nothing(
+ ).on_conflict_do_nothing(
index_elements=["image_record_id", "tag_id"]
)
await self.session.execute(stmt)
@@ -96,118 +68,11 @@ class AllowlistService:
await self._clear_rejection(image_id, tag_id)
async def reject_applied_tag(self, image_id: int, tag_id: int) -> None:
- """Operator removed an applied tag from an image. Remove the
- image_tag row AND record a rejection so the allowlist won't
- re-apply it on the next maintenance sweep."""
+ """Operator removed an applied tag from an image. Remove the image_tag
+ row AND record a rejection so head auto-apply won't re-apply it."""
await self.session.execute(
image_tag.delete()
.where(image_tag.c.image_record_id == image_id)
.where(image_tag.c.tag_id == tag_id)
)
await self.dismiss(image_id, tag_id)
-
- async def _store_floor(self) -> float:
- return (
- await self.session.execute(
- select(MLSettings.tagger_store_floor).where(MLSettings.id == 1)
- )
- ).scalar_one()
-
- async def update_threshold(
- self, tag_id: int, min_confidence: float
- ) -> None:
- row = await self.session.get(TagAllowlist, tag_id)
- if row is not None:
- # An allowlist tag can't auto-apply more permissively than the
- # ingest store floor — predictions below tagger_store_floor aren't
- # stored, so a lower min_confidence would behave identically to the
- # floor. Clamp so the stored threshold matches actual behavior
- # (#764).
- floor = await self._store_floor()
- row.min_confidence = max(min_confidence, floor)
-
- async def remove(self, tag_id: int) -> None:
- await self.session.execute(
- delete(TagAllowlist).where(TagAllowlist.tag_id == tag_id)
- )
-
- async def _coverage_match(self, tag: Tag):
- """The predicate over image_prediction rows that resolve to `tag`,
- mirroring tasks.ml._confidence_for_tag's resolution: a prediction whose
- raw_name equals the tag name (any category), OR an alias maps
- (raw_name, category) -> this tag. Returns a SQLAlchemy boolean clause.
- """
- alias_rows = (
- await self.session.execute(
- select(TagAlias.alias_string, TagAlias.alias_category).where(
- TagAlias.canonical_tag_id == tag.id
- )
- )
- ).all()
- name_clause = ImagePrediction.raw_name == tag.name
- alias_clauses = [
- and_(
- ImagePrediction.raw_name == a,
- ImagePrediction.category == c,
- )
- for a, c in alias_rows
- ]
- return or_(name_clause, *alias_clauses) if alias_clauses else name_clause
-
- async def coverage(self, tag_id: int, threshold: float) -> int:
- """How many distinct images a sweep WOULD cover for this tag at
- `threshold`: images with a resolving prediction scoring >= threshold.
- The gross candidate pool (NOT minus already-applied/rejected) — it's
- the tuning signal for "lower the threshold and ~N more images qualify".
- """
- tag = await self.session.get(Tag, tag_id)
- if tag is None:
- return 0
- match = await self._coverage_match(tag)
- stmt = select(
- func.count(distinct(ImagePrediction.image_record_id))
- ).where(ImagePrediction.score >= threshold, match)
- return (await self.session.execute(stmt)).scalar_one()
-
- async def list_all(self) -> Sequence[AllowlistRow]:
- stmt = (
- select(
- TagAllowlist.tag_id,
- Tag.name,
- Tag.kind,
- TagAllowlist.min_confidence,
- )
- .join(Tag, Tag.id == TagAllowlist.tag_id)
- .order_by(Tag.name.asc())
- )
- rows = (await self.session.execute(stmt)).all()
- tag_ids = [r[0] for r in rows]
-
- # Applied counts in ONE grouped query (vs N per-row counts).
- applied: dict[int, int] = {}
- if tag_ids:
- applied = dict(
- (
- await self.session.execute(
- select(image_tag.c.tag_id, func.count())
- .where(image_tag.c.tag_id.in_(tag_ids))
- .group_by(image_tag.c.tag_id)
- )
- ).all()
- )
-
- result = []
- for r in rows:
- # Coverage is per-tag (alias set differs); allowlist is small.
- cov = await self.coverage(r[0], r[3])
- result.append(
- AllowlistRow(
- tag_id=r[0],
- tag_name=r[1],
- tag_kind=r[2].value if hasattr(r[2], "value") else str(r[2]),
- min_confidence=r[3],
- applied_count=applied.get(r[0], 0),
- coverage_count=cov,
- )
- )
- return result
diff --git a/backend/app/services/ml/tagger.py b/backend/app/services/ml/tagger.py
deleted file mode 100644
index 0f15c4a..0000000
--- a/backend/app/services/ml/tagger.py
+++ /dev/null
@@ -1,210 +0,0 @@
-"""Camie-tagger-v2 ONNX wrapper (CPU).
-
-Single-image at a time. Loaded lazily inside the ml-worker process; NOT
-thread-safe — the ml queue worker runs --concurrency=1 per process (scale ML by
-running multiple worker replicas, not threads).
-
-v2 layout reference: HuggingFace Camais03/camie-tagger-v2 root has
-camie-tagger-v2.onnx (789 MB) + camie-tagger-v2-metadata.json (7.77 MB)
-+ config.json. Tags ship as nested JSON, not CSV. Preprocessing and
-output handling follow the published onnx_inference.py reference:
-ImageNet normalize, NCHW layout, sigmoid on refined logits (output[1]).
-"""
-
-import json
-import os
-from dataclasses import dataclass
-from pathlib import Path
-
-import numpy as np
-from PIL import Image, ImageFile
-
-# Cap inference threads (see Tagger.load) so each ml-worker replica is a bounded
-# core consumer on a shared node — keep N_replicas × this within the cores
-# allotted to ML so replicas don't oversubscribe the box / starve the DB.
-_INTRA_OP_THREADS = 4
-
-# onnxruntime lives in requirements-ml.txt only — it is NOT installed in the
-# lean web image or in CI. Imported lazily inside Tagger.load() so this module
-# imports fine without it (the suggestion service imports SURFACED_CATEGORIES
-# from here in the web container, and CI collects the pure-logic tests).
-
-# Tolerate minutely-truncated source images (same rationale as IR's wd14.py:
-# a few missing bytes at the JPEG EOI shouldn't block tagging the whole image).
-ImageFile.LOAD_TRUNCATED_IMAGES = True
-
-MODEL_NAME = os.environ.get("CAMIE_MODEL_NAME", "camie-tagger-v2")
-_MODEL_DIR = Path(os.environ.get("ML_MODEL_DIR", "/models")) / "camie"
-_MODEL_FILE = f"{MODEL_NAME}.onnx"
-_METADATA_FILE = f"{MODEL_NAME}-metadata.json"
-
-# Ingest floor below which predictions aren't stored (keeps the JSON compact).
-# DEFAULT/fallback only — the live value is DB-backed
-# (ml_settings.tagger_store_floor) and passed into infer() per call by the ml
-# task. 0.70: the suggestion path already filters there and the centroid path
-# covers lower-confidence preferred tags, so the sub-0.70 tail is redundant
-# (it had bloated image_record's TOAST to ~100 GB; plan-task #764).
-DEFAULT_STORE_FLOOR = 0.70
-
-# The categories FC-2b surfaces in the UI. Others (meta/rating/year) are
-# still stored but the suggestion service filters them out.
-# 'artist' retired in FC-2d-vii-c — artist identity is acquisition-derived
-# (image_record.artist_id), never ML-inferred. 'copyright' retired
-# 2026-06-01 — operator doesn't use the copyright tag-kind; fandom is
-# this app's franchise/series concept (per TagsView.vue's doc comment).
-# Raw predictions for both categories still get stored at STORE_FLOOR but
-# don't surface in suggestions.
-SURFACED_CATEGORIES = {"character", "general"}
-
-# ImageNet preprocessing constants (per Camie v2 onnx_inference.py).
-_IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
-_IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
-# Square-pad color ≈ ImageNet mean × 255 (matches reference inference).
-_PAD_COLOR = (124, 116, 104)
-
-
-@dataclass(frozen=True)
-class TagPrediction:
- name: str
- category: str
- confidence: float
-
-
-class Tagger:
- def __init__(self, model_dir: Path | None = None):
- self._model_dir = model_dir or _MODEL_DIR
- self._session = None # onnxruntime.InferenceSession once load()ed
- self._tag_names: list[str] | None = None
- self._tag_categories: list[str] | None = None
- self._input_name: str | None = None
- self._input_size: int = 512
-
- def load(self) -> None:
- if self._session is not None:
- return
- model_path = self._model_dir / _MODEL_FILE
- meta_path = self._model_dir / _METADATA_FILE
- if not model_path.is_file():
- raise RuntimeError(
- f"Camie {_MODEL_FILE} missing at {model_path}. "
- f"Populate /models via the ml-worker downloader."
- )
- if not meta_path.is_file():
- raise RuntimeError(
- f"Camie {_METADATA_FILE} missing at {meta_path}. "
- f"Populate /models via the ml-worker downloader."
- )
-
- with open(meta_path) as f:
- metadata = json.load(f)
-
- # Per Camie v2 onnx_inference.py: idx_to_tag is keyed by str(idx);
- # tag_to_category maps tag_name -> category. Project to two parallel
- # lists indexed by output position for O(1) lookup in the hot path.
- ds = metadata["dataset_info"]
- idx_to_tag = ds["tag_mapping"]["idx_to_tag"]
- tag_to_category = ds["tag_mapping"]["tag_to_category"]
- total = ds["total_tags"]
- names: list[str] = []
- cats: list[str] = []
- for i in range(total):
- name = idx_to_tag.get(str(i), f"unknown-{i}")
- names.append(name)
- cats.append(tag_to_category.get(name, "general"))
-
- # Input size from metadata; fall back to 512 (the v2 default).
- self._input_size = int(
- metadata.get("model_info", {}).get("img_size", 512)
- )
-
- # Lazy import — kept after the file-existence checks so the
- # missing-model RuntimeError still fires first in environments
- # without onnxruntime (CI / lean web image).
- import onnxruntime as ort
-
- # Cap the intra-op thread pool. ONNX Runtime otherwise sizes it to ALL
- # host cores, so on a shared node each ml-worker replica would grab every
- # core and oversubscribe (and starve the co-located DB/web). Bounding it
- # makes each replica a predictable core consumer — run N replicas where
- # N × _INTRA_OP_THREADS stays within the cores you allot to ML.
- opts = ort.SessionOptions()
- opts.intra_op_num_threads = _INTRA_OP_THREADS
- session = ort.InferenceSession(
- str(model_path), sess_options=opts, providers=["CPUExecutionProvider"],
- )
- self._input_name = session.get_inputs()[0].name
- # Assign sentinels last so a partial load isn't observable.
- self._tag_names = names
- self._tag_categories = cats
- self._session = session
-
- def _preprocess(self, image_path: Path) -> np.ndarray:
- img = Image.open(image_path)
- # Composite RGBA onto neutral so transparency doesn't bias the model.
- if img.mode == "RGBA":
- bg = Image.new("RGBA", img.size, (255, 255, 255, 255))
- bg.paste(img, mask=img.split()[3])
- img = bg.convert("RGB")
- elif img.mode != "RGB":
- img = img.convert("RGB")
-
- # Pad to square with ImageNet-mean color, then bicubic resize.
- w, h = img.size
- side = max(w, h)
- square = Image.new("RGB", (side, side), _PAD_COLOR)
- square.paste(img, ((side - w) // 2, (side - h) // 2))
- square = square.resize(
- (self._input_size, self._input_size), Image.BICUBIC
- )
-
- arr = np.array(square, dtype=np.float32) / 255.0 # HWC, [0,1]
- arr = (arr - _IMAGENET_MEAN) / _IMAGENET_STD # ImageNet normalize
- arr = arr.transpose(2, 0, 1) # HWC -> CHW
- return arr[np.newaxis, :, :, :] # NCHW
-
- def infer(
- self, image_path: Path, *, store_floor: float = DEFAULT_STORE_FLOOR,
- ) -> dict[str, TagPrediction]:
- """Run Camie v2 on one image. Returns {name: TagPrediction} with
- confidence >= store_floor (across all categories — the suggestion
- service does category filtering later). store_floor is the DB-backed
- ml_settings.tagger_store_floor, passed in by the ml task.
-
- v2 emits multiple outputs; we use the refined predictions
- (output[1] per onnx_inference.py). Sigmoid is applied to raw
- logits to produce [0,1] confidence scores.
- """
- self.load()
- x = self._preprocess(image_path)
- outputs = self._session.run(None, {self._input_name: x})
- # Refined predictions if present (v2 emits initial + refined),
- # fall back to initial for single-output forks.
- logits = outputs[1] if len(outputs) > 1 else outputs[0]
- # Squeeze batch dim, apply sigmoid.
- probs = 1.0 / (1.0 + np.exp(-logits[0]))
- results: dict[str, TagPrediction] = {}
- names = self._tag_names
- cats = self._tag_categories
- for idx, score in enumerate(probs):
- conf = float(score)
- if conf < store_floor:
- continue
- if idx >= len(names):
- # Output longer than metadata declared — shouldn't happen but
- # don't crash the import pipeline if v2 metadata desynchronizes.
- continue
- results[names[idx]] = TagPrediction(
- name=names[idx], category=cats[idx], confidence=conf
- )
- return results
-
-
-_default_tagger: Tagger | None = None
-
-
-def get_tagger() -> Tagger:
- """Process-level singleton so the ONNX session loads once per worker."""
- global _default_tagger
- if _default_tagger is None:
- _default_tagger = Tagger()
- return _default_tagger
diff --git a/backend/app/services/tag_service.py b/backend/app/services/tag_service.py
index 66e174f..a3ca07e 100644
--- a/backend/app/services/tag_service.py
+++ b/backend/app/services/tag_service.py
@@ -10,7 +10,6 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import HeadMetric, Tag, TagHead, TagKind, image_tag
-from ..models.tag_allowlist import TagAllowlist
from .db_helpers import get_or_create
from .tag_query import fandom_join_alias, tag_columns
@@ -303,28 +302,22 @@ class TagService:
async def _keep_as_alias(self, tag_id: int) -> bool:
"""A merged-away tag's old name must survive as an alias iff the ML
- pipeline has ever applied it OR could re-emit it (allowlisted) —
- otherwise the proactive apply_allowlist_tags worker would silently
- regenerate it. Purely-manual, ML-unknown tags are deleted outright (no
- DB bloat)."""
+ pipeline has ever applied it (manual accept or head auto-apply) — so a
+ re-application or an alias remap resolves the canonical name. Purely-
+ manual, ML-unknown tags are deleted outright (no DB bloat)."""
is_machine = await self.session.scalar(
select(
exists().where(
and_(
image_tag.c.tag_id == tag_id,
image_tag.c.source.in_(
- ("ml_auto", "ml_accepted", "auto")
+ ("ml_auto", "ml_accepted", "head_auto", "auto")
),
)
)
)
)
- if is_machine:
- return True
- allowlisted = await self.session.scalar(
- select(exists().where(TagAllowlist.tag_id == tag_id))
- )
- return bool(allowlisted)
+ return bool(is_machine)
async def rename(self, tag_id: int, new_name: str) -> Tag:
"""Rename a tag. Raises TagMergeConflict if the new name collides
@@ -564,7 +557,6 @@ class TagService:
merged_count = await self._repoint_image_tags(source_id, target_id)
await self._repoint_rejections(source_id, target_id)
- await self._repoint_allowlist(source_id, target_id)
await self._repoint_aliases(source_id, target_id)
await self._repoint_fandom_children(
source_id, target_id, source_kind
@@ -630,23 +622,6 @@ class TagService:
.values(tag_id=tgt)
)
- async def _repoint_allowlist(self, src: int, tgt: int) -> None:
- tgt_has = await self.session.scalar(
- select(exists().where(TagAllowlist.tag_id == tgt))
- )
- if tgt_has:
- await self.session.execute(
- text("DELETE FROM tag_allowlist WHERE tag_id = :src"),
- {"src": src},
- )
- else:
- await self.session.execute(
- update(TagAllowlist)
- .where(TagAllowlist.tag_id == src)
- .values(tag_id=tgt)
- )
-
-
async def _repoint_aliases(self, src: int, tgt: int) -> None:
from ..models.tag_alias import TagAlias
diff --git a/backend/app/tasks/ml.py b/backend/app/tasks/ml.py
index b2d5e3c..84ca0ee 100644
--- a/backend/app/tasks/ml.py
+++ b/backend/app/tasks/ml.py
@@ -1,20 +1,19 @@
-"""ML Celery tasks: per-image inference, backfill discovery, head training,
-allowlist auto-apply, model self-heal.
+"""ML Celery tasks: per-image embedding, backfill discovery, head training,
+model self-heal.
-All run on the ml-worker (queue 'ml') except apply_allowlist_tags sweeps which
-are 'maintenance' lane. Sync sessions (Celery workers are sync processes), same
-pattern as FC-2a tasks.
+All run on the ml-worker (queue 'ml'). Sync sessions (Celery workers are sync
+processes), same pattern as FC-2a tasks.
"""
import logging
from pathlib import Path
from celery.exceptions import SoftTimeLimitExceeded
-from sqlalchemy import delete, select
+from sqlalchemy import select
from sqlalchemy.exc import DBAPIError, OperationalError
from ..celery_app import celery
-from ..models import ImagePrediction, ImageRecord, MLSettings
+from ..models import ImageRecord, MLSettings
from ._sync_engine import sync_session_factory as _sync_session_factory
log = logging.getLogger(__name__)
@@ -46,19 +45,16 @@ def _is_video(path: Path) -> bool:
time_limit=1200, # 20 min hard
)
def tag_and_embed(self, image_id: int) -> dict:
- """Run Camie + SigLIP on one image; store predictions + embedding;
- then enqueue per-image allowlist application.
+ """Compute + store one image's SigLIP embedding.
Video (#747): sample frames at a fixed cadence (ml_settings
- video_frame_interval_seconds, capped at video_max_frames), keep a tag only if
- it appears in >= video_min_tag_frames frames and average its confidence over
- those frames (mean-pool, not max — kills one-frame noise); mean-pool the
- SigLIP embeddings. On no-frames returns status='no_frames' (not an error).
+ video_frame_interval_seconds, capped at video_max_frames) and mean-pool the
+ per-frame SigLIP embeddings. On no-frames returns status='no_frames' (not an
+ error). (Camie tagging was retired #1189 — heads + CCIP are the tag source.)
"""
import time
from ..services.ml.embedder import get_embedder
- from ..services.ml.tagger import get_tagger
# Phase + file context, so a timeout/crash names WHICH file and WHERE it
# died instead of a bare SoftTimeLimitExceeded() (operator-flagged 2026-06-08:
@@ -94,15 +90,13 @@ def tag_and_embed(self, image_id: int) -> dict:
return {"status": "file_missing", "image_id": image_id}
phase = "load_models"
- tagger = get_tagger()
embedder = get_embedder(settings.embedder_model_name)
if is_vid:
# Layer-3 isolation: ffprobe (a separate process) validates
- # the container before we burn ~20 GPU ops sampling frames
- # from it. A corrupt video that would crash the frame
- # decoder is rejected cleanly here instead of taking down
- # the ml-worker. Operator-flagged 2026-05-28.
+ # the container before we burn GPU ops sampling frames from it.
+ # A corrupt video that would crash the frame decoder is rejected
+ # cleanly here instead of taking down the ml-worker.
phase = "video_probe"
from ..utils import safe_probe
vprobe = safe_probe.probe_video(src)
@@ -115,48 +109,23 @@ def tag_and_embed(self, image_id: int) -> dict:
"reason": vprobe.reason,
}
phase = "video_sample_frames"
- t0 = time.monotonic()
frames = _sample_video_frames(
src,
interval=settings.video_frame_interval_seconds,
max_frames=settings.video_max_frames,
)
- log.info(
- "tag_and_embed sampled %d frame(s) in %.1fs: %s",
- len(frames), time.monotonic() - t0, ctx,
- )
if not frames:
return {"status": "no_frames", "image_id": image_id}
- phase = "video_infer"
+ phase = "video_embed"
import numpy as np
- preds = _aggregate_video_predictions(
- [tagger.infer(f, store_floor=settings.tagger_store_floor)
- for f in frames],
- min_frames=settings.video_min_tag_frames,
- )
+ # Mean-pool the per-frame SigLIP embeddings into one vector.
embedding = np.mean(
[embedder.infer(f) for f in frames], axis=0
).astype("float32")
- log.info(
- "tag_and_embed video aggregated %d tag(s) from %d frame(s) "
- "(min_frames=%d): %s",
- len(preds), len(frames), settings.video_min_tag_frames, ctx,
- )
for f in frames:
f.unlink(missing_ok=True)
else:
- phase = "tag"
- t0 = time.monotonic()
- raw = tagger.infer(src, store_floor=settings.tagger_store_floor)
- log.info(
- "tag_and_embed tagged in %.1fs (%d tags): %s",
- time.monotonic() - t0, len(raw), ctx,
- )
- preds = {
- name: {"category": p.category, "confidence": p.confidence}
- for name, p in raw.items()
- }
phase = "embed"
t0 = time.monotonic()
embedding = embedder.infer(src)
@@ -166,28 +135,9 @@ def tag_and_embed(self, image_id: int) -> dict:
)
phase = "persist"
- record.tagger_model_version = settings.tagger_model_version
record.siglip_embedding = embedding.tolist()
record.siglip_model_version = settings.embedder_model_version
session.add(record)
- # Write the normalized image_prediction rows (#768) — the sole home
- # for predictions now (image_record.tagger_predictions was dropped in
- # migration 0046). Delete-then-insert keeps a re-tag idempotent;
- # tagger_store_floor was already applied in tagger.infer, so preds is
- # the >=floor set.
- session.execute(
- delete(ImagePrediction).where(
- ImagePrediction.image_record_id == image_id
- )
- )
- session.add_all([
- ImagePrediction(
- image_record_id=image_id, raw_name=name,
- category=p.get("category", "general"),
- score=float(p.get("confidence", 0.0)),
- )
- for name, p in preds.items()
- ])
session.commit()
except SoftTimeLimitExceeded:
log.error(
@@ -210,11 +160,8 @@ def tag_and_embed(self, image_id: int) -> dict:
)
raise
- log.info(
- "tag_and_embed ok in %.1fs (%d tags): %s", _elapsed(), len(preds), ctx
- )
- apply_allowlist_tags.delay(image_id=image_id)
- return {"status": "ok", "image_id": image_id, "tags": len(preds)}
+ log.info("tag_and_embed ok in %.1fs: %s", _elapsed(), ctx)
+ return {"status": "ok", "image_id": image_id}
def _sample_video_frames(
@@ -273,68 +220,24 @@ def _sample_video_frames(
return out
-def _aggregate_video_predictions(per_frame: list[dict], *, min_frames: int) -> dict:
- """Aggregate per-frame {name: TagPrediction} into one prediction set (#747).
-
- A tag is kept only if it appears (≥ the tagger store floor, already applied)
- in at least `min_frames` of the sampled frames — because sampling is at a
- fixed cadence, that means it was on screen for roughly min_frames×interval
- seconds, so a single-frame flicker / scene-transition artifact is dropped
- while a genuine scene-local tag in a long video survives. Confidence is the
- MEAN over the frames where the tag appears (not max — max re-inflated the
- one-frame noise this whole change exists to remove).
-
- `min_frames` is clamped to the number of frames actually sampled so a very
- short video (1–2 frames) still tags instead of dropping everything.
- """
- n = len(per_frame)
- if n == 0:
- return {}
- threshold = max(1, min(min_frames, n))
- agg: dict[str, dict] = {}
- for frame_preds in per_frame:
- for name, p in frame_preds.items():
- cur = agg.get(name)
- if cur is None:
- agg[name] = {"category": p.category, "sum": p.confidence, "count": 1}
- else:
- cur["sum"] += p.confidence
- cur["count"] += 1
- return {
- name: {"category": v["category"], "confidence": v["sum"] / v["count"]}
- for name, v in agg.items()
- if v["count"] >= threshold
- }
-
-
@celery.task(name="backend.app.tasks.ml.backfill", bind=True)
def backfill(self) -> int:
- """Enqueue tag_and_embed for images missing predictions/embeddings for
- the current model versions. Keyset pagination by id ASC (restart-safe).
+ """Enqueue tag_and_embed (embed-only) for images with no SigLIP embedding.
+ Keyset pagination by id ASC (restart-safe).
+
+ NB: a siglip MODEL-VERSION mismatch (an operator model swap, #1190) is NOT
+ re-embedded here — the CPU ml-worker can't churn the library at 384/512px;
+ the GPU agent owns version re-embeds via the 'embed' job.
"""
SessionLocal = _sync_session_factory()
enqueued = 0
last_id = 0
with SessionLocal() as session:
- settings = session.execute(
- select(MLSettings).where(MLSettings.id == 1)
- ).scalar_one()
while True:
rows = session.execute(
select(ImageRecord.id)
.where(ImageRecord.id > last_id)
- .where(
- (ImageRecord.tagger_model_version.is_(None))
- | (
- ImageRecord.tagger_model_version
- != settings.tagger_model_version
- )
- | (ImageRecord.siglip_embedding.is_(None))
- # NB: a siglip MODEL-VERSION mismatch (an operator model swap,
- # #1190) is intentionally NOT re-embedded here — the CPU
- # ml-worker can't churn the whole library at 384/512px. The
- # GPU agent owns version re-embeds via the 'embed' job.
- )
+ .where(ImageRecord.siglip_embedding.is_(None))
.order_by(ImageRecord.id.asc())
.limit(500)
).scalars().all()
@@ -347,146 +250,6 @@ def backfill(self) -> int:
return enqueued
-@celery.task(
- name="backend.app.tasks.ml.apply_allowlist_tags",
- bind=True,
- # Audit 2026-06-02 — the full-sweep mode (neither tag_id nor image_id)
- # is O(images × allowlist) and legitimately runs >5 min on large
- # libraries. Cap matches the maintenance queue's recovery threshold.
- soft_time_limit=1800, time_limit=2100,
-)
-def apply_allowlist_tags(self, tag_id: int | None = None,
- image_id: int | None = None) -> int:
- """Retroactively apply allowlisted tags.
-
- Modes:
- - tag_id only : scan all images for this tag.
- - image_id only : scan all allowlisted tags for this image.
- - both : just the (image, tag) pair.
- - neither : full sweep (daily beat).
-
- Skips: already-applied, rejected (tag_suggestion_rejection), or
- confidence below the tag's allowlist min_confidence. Applied with
- source='ml_auto'.
- """
- from sqlalchemy import and_
- from sqlalchemy import select as sa_select
- from sqlalchemy.dialects.postgresql import insert as pg_insert
-
- from ..models import TagAllowlist, TagSuggestionRejection
- from ..models.tag import image_tag
-
- SessionLocal = _sync_session_factory()
- applied = 0
- with SessionLocal() as session:
- allow_rows = session.execute(
- sa_select(TagAllowlist.tag_id, TagAllowlist.min_confidence)
- if tag_id is None
- else sa_select(
- TagAllowlist.tag_id, TagAllowlist.min_confidence
- ).where(TagAllowlist.tag_id == tag_id)
- ).all()
- allow = {r[0]: r[1] for r in allow_rows}
- if not allow:
- return 0
-
- # Images that have any predictions (#768: from image_prediction, not
- # the old JSON column), optionally narrowed to one image.
- img_ids_query = sa_select(ImagePrediction.image_record_id).distinct()
- if image_id is not None:
- img_ids_query = img_ids_query.where(
- ImagePrediction.image_record_id == image_id
- )
-
- for (img_id,) in session.execute(img_ids_query).all():
- preds = _load_predictions_sync(session, img_id)
- for a_tag_id, min_conf in allow.items():
- exists = session.execute(
- sa_select(image_tag.c.tag_id).where(
- and_(
- image_tag.c.image_record_id == img_id,
- image_tag.c.tag_id == a_tag_id,
- )
- )
- ).scalar_one_or_none()
- if exists is not None:
- continue
- rej = session.get(
- TagSuggestionRejection, (img_id, a_tag_id)
- )
- if rej is not None:
- continue
- from ..models import Tag
-
- tag = session.get(Tag, a_tag_id)
- if tag is None:
- continue
- conf = _confidence_for_tag(session, tag, preds)
- if conf is None or conf < min_conf:
- continue
- stmt = pg_insert(image_tag).values(
- image_record_id=img_id,
- tag_id=a_tag_id,
- source="ml_auto",
- )
- stmt = stmt.on_conflict_do_nothing(
- index_elements=["image_record_id", "tag_id"]
- )
- session.execute(stmt)
- applied += 1
- session.commit()
- return applied
-
-
-def _load_predictions_sync(session, image_id: int) -> dict:
- """Predictions for one image from image_prediction (#768), in the
- {raw_name: {category, confidence}} shape _confidence_for_tag consumes —
- keeps the allowlist resolution logic unchanged."""
- from sqlalchemy import select as sa_select
-
- rows = session.execute(
- sa_select(
- ImagePrediction.raw_name,
- ImagePrediction.category,
- ImagePrediction.score,
- ).where(ImagePrediction.image_record_id == image_id)
- ).all()
- return {
- r.raw_name: {"category": r.category, "confidence": r.score}
- for r in rows
- }
-
-
-def _confidence_for_tag(session, tag, preds: dict) -> float | None:
- """Highest confidence among predictions that resolve to `tag` —
- either the prediction name equals the tag name, or an alias maps
- (prediction name, category) -> tag.id.
- """
- from sqlalchemy import select as sa_select
-
- from ..models import TagAlias
-
- best: float | None = None
- direct = preds.get(tag.name)
- if direct is not None:
- best = float(direct.get("confidence", 0.0))
- alias_rows = session.execute(
- sa_select(TagAlias.alias_string, TagAlias.alias_category).where(
- TagAlias.canonical_tag_id == tag.id
- )
- ).all()
- for alias_string, alias_category in alias_rows:
- p = preds.get(alias_string)
- if p is None:
- continue
- if p.get("category") != alias_category:
- continue
- c = float(p.get("confidence", 0.0))
- if best is None or c > best:
- best = c
- return best
-
-
@celery.task(
name="backend.app.tasks.ml.tag_eval_run",
bind=True,
diff --git a/frontend/src/components/settings/AllowlistTable.vue b/frontend/src/components/settings/AllowlistTable.vue
deleted file mode 100644
index d128611..0000000
--- a/frontend/src/components/settings/AllowlistTable.vue
+++ /dev/null
@@ -1,120 +0,0 @@
-
-
-
-
- {{ item.applied_count ?? '—' }}
-
-
-
-
- onThreshold(item, v)"
- />
- ≈ {{ proj[item.tag_id].count }} at {{ proj[item.tag_id].threshold }}
-
-
-
-
-
- {{ item.coverage_count ?? '—' }}
-
-
-
-
-
-
-
-
- Applied = images currently carrying the tag.
- Covers = images a sweep would auto-apply it to at the
- current threshold. Lower the threshold to cover more (less certain) images.
-
-
-
-
-
-
-
diff --git a/frontend/src/components/settings/MLBackfillCard.vue b/frontend/src/components/settings/MLBackfillCard.vue
index e4e2fb4..d0e9c21 100644
--- a/frontend/src/components/settings/MLBackfillCard.vue
+++ b/frontend/src/components/settings/MLBackfillCard.vue
@@ -2,12 +2,13 @@
- Re-run Camie + SigLIP on images missing predictions or embeddings
- for the current model versions. Safe to re-run.
+ Compute the SigLIP embedding for any image that doesn't have one yet
+ (CPU). Safe to re-run. To re-embed under a NEW model, use the GPU
+ agent's "Re-embed library" instead.
mdi-refresh Run backfill now
diff --git a/frontend/src/components/settings/MaintenancePanel.vue b/frontend/src/components/settings/MaintenancePanel.vue
index aba2deb..55d5f75 100644
--- a/frontend/src/components/settings/MaintenancePanel.vue
+++ b/frontend/src/components/settings/MaintenancePanel.vue
@@ -1,8 +1,8 @@
- One-off backfills, tagging config and storage tools. The ML backfill runs
- nightly; the allowlist auto-applies accepted tags. Click a tile to open it.
+ One-off backfills, tagging config and storage tools. Heads train nightly
+ and auto-apply earned tags. Click a tile to open it.
@@ -53,7 +52,6 @@ import DbMaintenanceCard from './DbMaintenanceCard.vue'
import MLThresholdSliders from './MLThresholdSliders.vue'
import HeadsCard from './HeadsCard.vue'
import GpuAgentCard from './GpuAgentCard.vue'
-import AllowlistTable from './AllowlistTable.vue'
import AliasTable from './AliasTable.vue'
import TagEvalCard from './TagEvalCard.vue'
import BackupCard from './BackupCard.vue'
diff --git a/frontend/src/stores/allowlist.js b/frontend/src/stores/allowlist.js
deleted file mode 100644
index 5d730f1..0000000
--- a/frontend/src/stores/allowlist.js
+++ /dev/null
@@ -1,44 +0,0 @@
-import { defineStore } from 'pinia'
-import { ref } from 'vue'
-import { useApi } from '../composables/useApi.js'
-
-export const useAllowlistStore = defineStore('allowlist', () => {
- const api = useApi()
- const rows = ref([])
- const loading = ref(false)
-
- async function load() {
- loading.value = true
- try { rows.value = await api.get('/api/allowlist') }
- finally { loading.value = false }
- }
-
- async function updateThreshold(tagId, minConfidence) {
- await api.patch(`/api/tags/${tagId}/allowlist`, {
- body: { min_confidence: minConfidence }
- })
- const r = rows.value.find(x => x.tag_id === tagId)
- if (r) {
- r.min_confidence = minConfidence
- // The committed threshold changed the covered pool — refresh the row's
- // coverage so the table stays truthful after a save.
- try { r.coverage_count = (await coverage(tagId, minConfidence)).count }
- catch { /* leave the stale count rather than blank it */ }
- }
- }
-
- // Live "at threshold T, a sweep would cover ~N images" projection for the
- // tuning dashboard. Returns { count, threshold }.
- async function coverage(tagId, threshold) {
- return api.get(`/api/tags/${tagId}/allowlist/coverage`, {
- params: { threshold }
- })
- }
-
- async function remove(tagId) {
- await api.delete(`/api/tags/${tagId}/allowlist`)
- rows.value = rows.value.filter(x => x.tag_id !== tagId)
- }
-
- return { rows, loading, load, updateThreshold, coverage, remove }
-})
diff --git a/frontend/src/stores/suggestions.js b/frontend/src/stores/suggestions.js
index b7dd6c5..6a1198d 100644
--- a/frontend/src/stores/suggestions.js
+++ b/frontend/src/stores/suggestions.js
@@ -113,7 +113,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
})
tagId = created.id
}
- const res = await api.post(`/api/images/${imageId}/suggestions/accept`, {
+ await api.post(`/api/images/${imageId}/suggestions/accept`, {
body: { tag_id: tagId }
})
// Only drop from THIS image's category list — if the user navigated,
@@ -121,23 +121,14 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
if (currentImageId === imageId) {
_dropEverywhere(suggestion)
}
- _acceptToast('Tagged', suggestion.display_name, res)
+ _acceptToast('Tagged', suggestion.display_name)
}
- // One non-blocking toast for accept/alias. When the accept newly allowlisted
- // the tag, surface the coverage PROJECTION (#7) so the operator sees the
- // auto-apply reach without a blocking pre-accept preview — the apply itself
- // runs async, hence "~N".
- function _acceptToast(verb, displayName, res) {
- if (res?.allowlisted) {
- const n = res.projected_count
- toast({
- text: `${verb}: ${displayName} — allowlisted, auto-applying to ~${n} image${n === 1 ? '' : 's'}`,
- type: 'success'
- })
- } else {
- toast({ text: `${verb}: ${displayName}`, type: 'success' })
- }
+ // One non-blocking toast for accept/alias. The accepted tag is applied to this
+ // image and feeds head training; head auto-apply handles propagation (earned),
+ // so there's no instant fan-out to project.
+ function _acceptToast(verb, displayName) {
+ toast({ text: `${verb}: ${displayName}`, type: 'success' })
}
async function aliasAccept(suggestion, canonicalTagId) {
@@ -149,7 +140,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
// reappearing unaliased. raw_name is null only for centroid hits, which
// can't be aliased (the UI hides the action for them).
const aliasString = suggestion.raw_name ?? suggestion.display_name
- const res = await api.post(`/api/images/${imageId}/suggestions/alias`, {
+ await api.post(`/api/images/${imageId}/suggestions/alias`, {
body: {
alias_string: aliasString,
alias_category: suggestion.category,
@@ -159,7 +150,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
if (currentImageId === imageId) {
_dropEverywhere(suggestion)
}
- _acceptToast('Aliased & tagged', suggestion.display_name, res)
+ _acceptToast('Aliased & tagged', suggestion.display_name)
}
// Remove the alias behind an aliased suggestion (the raw prediction reverts to
diff --git a/tests/_prediction_helpers.py b/tests/_prediction_helpers.py
deleted file mode 100644
index e689ccc..0000000
--- a/tests/_prediction_helpers.py
+++ /dev/null
@@ -1,21 +0,0 @@
-"""#768 test helper: seed image_prediction rows.
-
-Read-path tests used to seed ImageRecord(tagger_predictions={...}); predictions
-now live in the normalized image_prediction table, so seed there instead.
-"""
-from backend.app.models import ImagePrediction
-
-
-async def seed_predictions(session, image_id: int, predictions: dict) -> None:
- """Insert image_prediction rows from a {raw_name: {category, confidence}}
- dict (the old JSON shape). Caller commits/flushes as needed; this flushes."""
- session.add_all([
- ImagePrediction(
- image_record_id=image_id,
- raw_name=name,
- category=p.get("category", "general"),
- score=float(p.get("confidence", 0.0)),
- )
- for name, p in predictions.items()
- ])
- await session.flush()
diff --git a/tests/test_api_allowlist.py b/tests/test_api_allowlist.py
deleted file mode 100644
index 01539ae..0000000
--- a/tests/test_api_allowlist.py
+++ /dev/null
@@ -1,88 +0,0 @@
-import pytest
-
-from backend.app.models import ImagePrediction, ImageRecord, TagAllowlist, TagKind
-from backend.app.services.tag_service import TagService
-
-pytestmark = pytest.mark.integration
-
-
-@pytest.mark.asyncio
-async def test_list_and_patch_and_delete(client, db):
- tag = await TagService(db).find_or_create("AL", TagKind.character)
- db.add(TagAllowlist(tag_id=tag.id, min_confidence=0.95))
- await db.commit()
-
- resp = await client.get("/api/allowlist")
- assert resp.status_code == 200
- assert any(r["tag_id"] == tag.id for r in await resp.get_json())
-
- resp = await client.patch(
- f"/api/tags/{tag.id}/allowlist", json={"min_confidence": 0.80}
- )
- assert resp.status_code == 204
-
- resp = await client.get(f"/api/tags/{tag.id}/allowlist")
- assert (await resp.get_json())["min_confidence"] == pytest.approx(0.80)
-
- resp = await client.delete(f"/api/tags/{tag.id}/allowlist")
- assert resp.status_code == 204
- resp = await client.get(f"/api/tags/{tag.id}/allowlist")
- assert resp.status_code == 404
-
-
-@pytest.mark.asyncio
-async def test_patch_rejects_out_of_range(client, db):
- tag = await TagService(db).find_or_create("AL2", TagKind.character)
- db.add(TagAllowlist(tag_id=tag.id))
- await db.commit()
- resp = await client.patch(
- f"/api/tags/{tag.id}/allowlist", json={"min_confidence": 1.5}
- )
- assert resp.status_code == 400
-
-
-@pytest.mark.asyncio
-async def test_coverage_endpoint(client, db):
- tag = await TagService(db).find_or_create("Cover", TagKind.general)
- db.add(TagAllowlist(tag_id=tag.id, min_confidence=0.90))
- for i, score in enumerate((0.95, 0.60)):
- img = ImageRecord(
- path=f"/images/cov{i}.jpg", sha256=f"cv{i:062d}", size_bytes=1,
- mime="image/jpeg", width=1, height=1,
- origin="imported_filesystem", integrity_status="unknown",
- )
- db.add(img)
- await db.flush()
- db.add(ImagePrediction(
- image_record_id=img.id, raw_name="Cover",
- category="general", score=score,
- ))
- await db.commit()
-
- # Explicit threshold.
- resp = await client.get(
- f"/api/tags/{tag.id}/allowlist/coverage?threshold=0.90"
- )
- assert resp.status_code == 200
- assert (await resp.get_json())["count"] == 1
- # Lower what-if threshold widens coverage.
- resp = await client.get(
- f"/api/tags/{tag.id}/allowlist/coverage?threshold=0.50"
- )
- assert (await resp.get_json())["count"] == 2
- # No threshold → uses the stored min_confidence (0.90).
- resp = await client.get(f"/api/tags/{tag.id}/allowlist/coverage")
- body = await resp.get_json()
- assert body["count"] == 1
- assert body["threshold"] == pytest.approx(0.90)
-
-
-@pytest.mark.asyncio
-async def test_coverage_rejects_bad_threshold(client, db):
- tag = await TagService(db).find_or_create("Cover2", TagKind.general)
- db.add(TagAllowlist(tag_id=tag.id))
- await db.commit()
- resp = await client.get(
- f"/api/tags/{tag.id}/allowlist/coverage?threshold=2.0"
- )
- assert resp.status_code == 400
diff --git a/tests/test_api_suggestions.py b/tests/test_api_suggestions.py
index ac02374..1e3355c 100644
--- a/tests/test_api_suggestions.py
+++ b/tests/test_api_suggestions.py
@@ -15,9 +15,7 @@ def eager():
celery.conf.task_always_eager = False
-async def _img(db, preds, sha="s" * 64):
- from tests._prediction_helpers import seed_predictions
-
+async def _img(db, sha="s" * 64):
img = ImageRecord(
path=f"/images/{sha}.jpg", sha256=sha, size_bytes=1,
mime="image/jpeg", width=1, height=1,
@@ -25,8 +23,6 @@ async def _img(db, preds, sha="s" * 64):
)
db.add(img)
await db.commit()
- await seed_predictions(db, img.id, preds)
- await db.commit()
return img
@@ -60,7 +56,7 @@ async def test_get_suggestions(client, db):
@pytest.mark.asyncio
async def test_accept_requires_tag_id(client, db):
- img = await _img(db, {})
+ img = await _img(db)
resp = await client.post(
f"/api/images/{img.id}/suggestions/accept", json={}
)
@@ -68,43 +64,31 @@ async def test_accept_requires_tag_id(client, db):
@pytest.mark.asyncio
-async def test_accept_then_applied(client, db):
- img = await _img(db, {})
+async def test_accept_applies_tag_to_image(client, db):
+ # Camie/allowlist retired (#1189): accept applies the tag to THIS image
+ # (source='ml_accepted', a head-training positive) — no bulk allowlist
+ # fan-out anymore.
+ from backend.app.models.tag import image_tag
+
+ img = await _img(db)
tag = await TagService(db).find_or_create("AcceptMe", TagKind.character)
await db.commit()
resp = await client.post(
f"/api/images/{img.id}/suggestions/accept", json={"tag_id": tag.id}
)
assert resp.status_code == 200
- body = await resp.get_json()
- # #7b: a fresh accept newly-allowlists → projection payload for the toast.
- assert body["allowlisted"] is True
- assert body["tag_id"] == tag.id
- assert body["tag_name"] == "AcceptMe"
- assert "projected_count" in body
-
-
-@pytest.mark.asyncio
-async def test_accept_already_allowlisted_reports_not_new(client, db):
- img1 = await _img(db, {}, sha="c" * 64)
- img2 = await _img(db, {}, sha="d" * 64)
- tag = await TagService(db).find_or_create("Twice", TagKind.character)
- await db.commit()
- first = await client.post(
- f"/api/images/{img1.id}/suggestions/accept", json={"tag_id": tag.id}
- )
- assert (await first.get_json())["allowlisted"] is True
- second = await client.post(
- f"/api/images/{img2.id}/suggestions/accept", json={"tag_id": tag.id}
- )
- body = await second.get_json()
- assert body["allowlisted"] is False # already on the allowlist
- assert "projected_count" not in body
+ assert (await resp.get_json())["accepted"] is True
+ src = (await db.execute(
+ select(image_tag.c.source)
+ .where(image_tag.c.image_record_id == img.id)
+ .where(image_tag.c.tag_id == tag.id)
+ )).scalar_one()
+ assert src == "ml_accepted"
@pytest.mark.asyncio
async def test_dismiss(client, db):
- img = await _img(db, {})
+ img = await _img(db)
tag = await TagService(db).find_or_create("DismissMe", TagKind.general)
await db.commit()
resp = await client.post(
@@ -115,7 +99,7 @@ async def test_dismiss(client, db):
@pytest.mark.asyncio
async def test_undismiss_reverses_rejection(client, db):
- img = await _img(db, {})
+ img = await _img(db)
tag = await TagService(db).find_or_create("UndismissMe", TagKind.general)
await db.commit()
await client.post(
@@ -134,7 +118,7 @@ async def test_undismiss_reverses_rejection(client, db):
@pytest.mark.asyncio
async def test_alias_requires_fields(client, db):
- img = await _img(db, {})
+ img = await _img(db)
resp = await client.post(
f"/api/images/{img.id}/suggestions/alias", json={"alias_string": "x"}
)
diff --git a/tests/test_api_tag_merge.py b/tests/test_api_tag_merge.py
index 55183b4..4c3e217 100644
--- a/tests/test_api_tag_merge.py
+++ b/tests/test_api_tag_merge.py
@@ -68,15 +68,7 @@ async def test_rename_collision_returns_rich_409(client):
@pytest.mark.asyncio
-async def test_merge_endpoint_moves_and_deletes(client, monkeypatch):
- calls = []
- from backend.app.tasks import ml as ml_tasks
-
- monkeypatch.setattr(
- ml_tasks.apply_allowlist_tags,
- "delay",
- lambda **kw: calls.append(kw),
- )
+async def test_merge_endpoint_moves_and_deletes(client):
tgt = await _mk(client, "Keep", "general")
src = await _mk(client, "Gone", "general")
resp = await client.post(
@@ -92,36 +84,6 @@ async def test_merge_endpoint_moves_and_deletes(client, monkeypatch):
assert r2.status_code == 200
-@pytest.mark.asyncio
-async def test_merge_enqueues_backfill_when_target_allowlisted(
- client, monkeypatch
-):
- calls = []
- from backend.app.tasks import ml as ml_tasks
-
- monkeypatch.setattr(
- ml_tasks.apply_allowlist_tags,
- "delay",
- lambda **kw: calls.append(kw),
- )
- tgt = await _mk(client, "AllowTgt", "general")
- src = await _mk(client, "AllowSrc", "general")
- # No public route adds a tag to the allowlist (it happens via
- # accept-suggestion); set the row directly through the app session.
- from backend.app.extensions import get_session
- from backend.app.models.tag_allowlist import TagAllowlist
-
- async with get_session() as s:
- s.add(TagAllowlist(tag_id=tgt))
- await s.commit()
-
- resp = await client.post(
- f"/api/tags/{src}/merge", json={"target_id": tgt}
- )
- assert resp.status_code == 200
- assert calls == [{"tag_id": tgt}]
-
-
@pytest.mark.asyncio
async def test_merge_self_is_400(client):
t = await _mk(client, "Selfie", "general")
diff --git a/tests/test_image_prediction.py b/tests/test_image_prediction.py
deleted file mode 100644
index 43e05e9..0000000
--- a/tests/test_image_prediction.py
+++ /dev/null
@@ -1,57 +0,0 @@
-"""#768: image_prediction table — model + constraints round-trip."""
-import pytest
-from sqlalchemy import select
-from sqlalchemy.exc import IntegrityError
-
-from backend.app.models import ImagePrediction, ImageRecord
-
-pytestmark = pytest.mark.integration
-
-
-async def _make_image(db, path="/img/p0.jpg", sha="0"):
- rec = ImageRecord(
- path=path, sha256=sha.ljust(64, "0")[:64], size_bytes=10,
- mime="image/jpeg", origin="imported_filesystem",
- )
- db.add(rec)
- await db.flush()
- return rec
-
-
-@pytest.mark.asyncio
-async def test_image_prediction_round_trip(db):
- rec = await _make_image(db)
- db.add_all([
- ImagePrediction(
- image_record_id=rec.id, raw_name="blue_eyes",
- category="general", score=0.92,
- ),
- ImagePrediction(
- image_record_id=rec.id, raw_name="hatsune_miku",
- category="character", score=0.88,
- ),
- ])
- await db.commit()
-
- rows = (await db.execute(
- select(ImagePrediction.raw_name, ImagePrediction.score)
- .where(ImagePrediction.image_record_id == rec.id)
- .order_by(ImagePrediction.score.desc())
- )).all()
- assert [r.raw_name for r in rows] == ["blue_eyes", "hatsune_miku"]
-
-
-@pytest.mark.asyncio
-async def test_image_prediction_unique_per_image_name(db):
- rec = await _make_image(db, path="/img/p1.jpg", sha="1")
- db.add(ImagePrediction(
- image_record_id=rec.id, raw_name="dup",
- category="general", score=0.9,
- ))
- await db.commit()
- db.add(ImagePrediction(
- image_record_id=rec.id, raw_name="dup",
- category="general", score=0.7,
- ))
- with pytest.raises(IntegrityError):
- await db.commit()
diff --git a/tests/test_migration_0003.py b/tests/test_migration_0003.py
index 41828b7..9eb14ff 100644
--- a/tests/test_migration_0003.py
+++ b/tests/test_migration_0003.py
@@ -5,14 +5,12 @@ from backend.app.models import (
ImageRecord,
MLSettings,
TagAlias,
- TagAllowlist,
TagSuggestionRejection,
)
def test_new_tables_registered():
expected = {
- "tag_allowlist",
"tag_suggestion_rejection",
"tag_alias",
"ml_settings",
@@ -40,11 +38,6 @@ def test_ml_settings_singleton_constraint():
assert "ck_ml_settings_singleton" in names
-def test_tag_allowlist_confidence_check():
- names = {c.name for c in TagAllowlist.__table__.constraints}
- assert "ck_tag_allowlist_confidence_range" in names
-
-
def test_tag_suggestion_rejection_pk():
pk_cols = {c.name for c in TagSuggestionRejection.__table__.primary_key.columns}
assert pk_cols == {"image_record_id", "tag_id"}
diff --git a/tests/test_ml_allowlist.py b/tests/test_ml_allowlist.py
deleted file mode 100644
index 198d171..0000000
--- a/tests/test_ml_allowlist.py
+++ /dev/null
@@ -1,182 +0,0 @@
-import pytest
-from sqlalchemy import select
-
-from backend.app.models import (
- ImagePrediction,
- TagAlias,
- TagAllowlist,
- TagKind,
- TagSuggestionRejection,
-)
-from backend.app.models.tag import image_tag
-from backend.app.services.ml.allowlist import AllowlistService
-from backend.app.services.tag_service import TagService
-
-pytestmark = pytest.mark.integration
-
-
-async def _make_image(db, sha: str = "x" * 64):
- from backend.app.models import ImageRecord
- img = ImageRecord(
- # Full sha in the path — the first 8 chars collide for sequential
- # shas like c{i:063d}, and path is UNIQUE (uq_image_record_path).
- path=f"/images/{sha}.jpg", sha256=sha, size_bytes=1,
- mime="image/jpeg", width=1, height=1,
- origin="imported_filesystem", integrity_status="unknown",
- )
- db.add(img)
- await db.flush()
- return img
-
-
-async def _add_pred(db, image_id, raw_name, score, category="general"):
- db.add(ImagePrediction(
- image_record_id=image_id, raw_name=raw_name,
- category=category, score=score,
- ))
- await db.flush()
-
-
-@pytest.mark.asyncio
-async def test_accept_applies_and_allowlists(db):
- img = await _make_image(db)
- tag = await TagService(db).find_or_create("Hero", TagKind.character)
- svc = AllowlistService(db)
- newly_added = await svc.accept(img.id, tag.id)
- assert newly_added is True
-
- applied = (
- await db.execute(
- select(image_tag.c.source)
- .where(image_tag.c.image_record_id == img.id)
- .where(image_tag.c.tag_id == tag.id)
- )
- ).scalar_one()
- assert applied == "ml_accepted"
- assert await db.get(TagAllowlist, tag.id) is not None
-
-
-@pytest.mark.asyncio
-async def test_accept_idempotent_allowlist(db):
- img = await _make_image(db)
- tag = await TagService(db).find_or_create("Hero2", TagKind.character)
- svc = AllowlistService(db)
- assert await svc.accept(img.id, tag.id) is True
- assert await svc.accept(img.id, tag.id) is False
-
-
-@pytest.mark.asyncio
-async def test_reject_applied_tag_records_rejection(db):
- img = await _make_image(db)
- tag = await TagService(db).find_or_create("Removeme", TagKind.general)
- svc = AllowlistService(db)
- await svc.accept(img.id, tag.id)
- await svc.reject_applied_tag(img.id, tag.id)
-
- still_applied = (
- await db.execute(
- select(image_tag.c.tag_id)
- .where(image_tag.c.image_record_id == img.id)
- .where(image_tag.c.tag_id == tag.id)
- )
- ).scalar_one_or_none()
- assert still_applied is None
- rej = await db.get(TagSuggestionRejection, (img.id, tag.id))
- assert rej is not None
-
-
-@pytest.mark.asyncio
-async def test_dismiss_records_rejection(db):
- img = await _make_image(db)
- tag = await TagService(db).find_or_create("Dismissme", TagKind.general)
- await AllowlistService(db).dismiss(img.id, tag.id)
- assert await db.get(TagSuggestionRejection, (img.id, tag.id)) is not None
-
-
-@pytest.mark.asyncio
-async def test_add_alias_and_accept(db):
- img = await _make_image(db)
- canonical = await TagService(db).find_or_create(
- "Canonical Char", TagKind.character
- )
- svc = AllowlistService(db)
- await svc.add_alias_and_accept(
- img.id, "model_char_name", "character", canonical.id
- )
- from backend.app.services.ml.aliases import AliasService
- resolved = await AliasService(db).resolve("model_char_name", "character")
- assert resolved.id == canonical.id
- assert await db.get(TagAllowlist, canonical.id) is not None
-
-
-@pytest.mark.asyncio
-async def test_update_threshold_and_remove(db):
- tag = await TagService(db).find_or_create("Thr", TagKind.general)
- svc = AllowlistService(db)
- img = await _make_image(db)
- await svc.accept(img.id, tag.id)
- await svc.update_threshold(tag.id, 0.80)
- row = await db.get(TagAllowlist, tag.id)
- assert abs(row.min_confidence - 0.80) < 1e-6
- await svc.remove(tag.id)
- assert await db.get(TagAllowlist, tag.id) is None
-
-
-@pytest.mark.asyncio
-async def test_coverage_by_threshold_direct_name(db):
- tag = await TagService(db).find_or_create("Cov", TagKind.general)
- svc = AllowlistService(db)
- for i, score in enumerate((0.95, 0.80, 0.60)):
- img = await _make_image(db, sha=f"c{i:063d}")
- await _add_pred(db, img.id, "Cov", score)
- assert await svc.coverage(tag.id, 0.90) == 1
- assert await svc.coverage(tag.id, 0.70) == 2
- assert await svc.coverage(tag.id, 0.50) == 3
-
-
-@pytest.mark.asyncio
-async def test_coverage_via_alias_respects_category(db):
- tag = await TagService(db).find_or_create("Aliased", TagKind.character)
- db.add(TagAlias(
- alias_string="model_key", alias_category="character",
- canonical_tag_id=tag.id,
- ))
- await db.flush()
- svc = AllowlistService(db)
- hit = await _make_image(db, sha=f"a{0:063d}")
- await _add_pred(db, hit.id, "model_key", 0.92, category="character")
- # Same alias string but wrong category must NOT resolve to the tag.
- miss = await _make_image(db, sha=f"a{1:063d}")
- await _add_pred(db, miss.id, "model_key", 0.99, category="general")
- assert await svc.coverage(tag.id, 0.90) == 1
-
-
-@pytest.mark.asyncio
-async def test_list_all_reports_applied_and_coverage(db):
- tag = await TagService(db).find_or_create("Both", TagKind.general)
- svc = AllowlistService(db)
- applied_img = await _make_image(db, sha=f"b{0:063d}")
- await svc.accept(applied_img.id, tag.id) # applies + allowlists
- await _add_pred(db, applied_img.id, "Both", 0.95)
- # A second image only has a qualifying prediction (covered, not applied).
- cov_img = await _make_image(db, sha=f"b{1:063d}")
- await _add_pred(db, cov_img.id, "Both", 0.95)
-
- rows = await svc.list_all()
- row = next(r for r in rows if r.tag_id == tag.id)
- assert row.applied_count == 1 # only the accepted image
- assert row.coverage_count == 2 # both have a ≥threshold pred
-
-
-@pytest.mark.asyncio
-async def test_update_threshold_clamped_to_store_floor(db):
- # A min_confidence below the store floor (default 0.70) is clamped up —
- # predictions below the floor aren't stored, so a lower threshold can't
- # apply more permissively than the floor (#764).
- tag = await TagService(db).find_or_create("Lowthr", TagKind.general)
- svc = AllowlistService(db)
- img = await _make_image(db)
- await svc.accept(img.id, tag.id)
- await svc.update_threshold(tag.id, 0.30)
- row = await db.get(TagAllowlist, tag.id)
- assert abs(row.min_confidence - 0.70) < 1e-6
diff --git a/tests/test_ml_artist_retired.py b/tests/test_ml_artist_retired.py
index 2d335f0..3c4130c 100644
--- a/tests/test_ml_artist_retired.py
+++ b/tests/test_ml_artist_retired.py
@@ -3,11 +3,6 @@ import pytest
pytestmark = pytest.mark.integration
-def test_artist_not_surfaced():
- from backend.app.services.ml.tagger import SURFACED_CATEGORIES
- assert "artist" not in SURFACED_CATEGORIES
-
-
def test_artist_not_head_eligible():
# Tagging-v2: suggestions come from heads, and heads are only trained for
# general/character concepts — so 'artist' (and any other kind) can't surface.
diff --git a/tests/test_ml_tagger.py b/tests/test_ml_tagger.py
deleted file mode 100644
index 369ce62..0000000
--- a/tests/test_ml_tagger.py
+++ /dev/null
@@ -1,54 +0,0 @@
-"""Tagger unit tests. The ONNX model isn't available in CI (it's a 1GB
-download into /models), so these test the pure-logic surface:
-DEFAULT_STORE_FLOOR constant, SURFACED_CATEGORIES set, TagPrediction
-dataclass, and the load()-missing-file error path. Full inference is
-exercised by the local integration suite against a real /models volume.
-"""
-
-import pytest
-
-from backend.app.services.ml.tagger import (
- DEFAULT_STORE_FLOOR,
- SURFACED_CATEGORIES,
- Tagger,
- TagPrediction,
- get_tagger,
-)
-
-
-def test_surfaced_categories():
- # FC-2d-vii-c: 'artist' retired — artist identity is acquisition-
- # derived (image_record.artist_id), never ML-inferred.
- # 2026-06-01: 'copyright' retired — fandom serves as the franchise/
- # copyright concept; operator doesn't use a separate copyright kind.
- assert SURFACED_CATEGORIES == {"character", "general"}
- assert "artist" not in SURFACED_CATEGORIES
- assert "copyright" not in SURFACED_CATEGORIES
-
-
-def test_default_store_floor():
- # Raised 0.05 → 0.70 (plan-task #764): the suggestion path filters at
- # 0.70 and the centroid path covers lower-confidence preferred tags, so
- # storing the sub-0.70 tail was redundant (100 GB of TOAST). The live
- # value is DB-backed (ml_settings.tagger_store_floor); this is the default.
- assert DEFAULT_STORE_FLOOR == 0.70
-
-
-def test_tag_prediction_dataclass():
- p = TagPrediction(name="x", category="general", confidence=0.9)
- assert p.name == "x"
- assert p.category == "general"
- assert p.confidence == 0.9
-
-
-def test_get_tagger_singleton():
- assert get_tagger() is get_tagger()
-
-
-def test_load_raises_when_model_missing(tmp_path):
- t = Tagger(model_dir=tmp_path / "nonexistent")
- # Match the trailing "missing at " rather than the specific
- # filename, so a future model-version bump (camie-tagger-v3.onnx, etc.)
- # doesn't bounce this test.
- with pytest.raises(RuntimeError, match=r"\.onnx missing at "):
- t.load()
diff --git a/tests/test_phash_dedup.py b/tests/test_phash_dedup.py
index a051616..fe0bb26 100644
--- a/tests/test_phash_dedup.py
+++ b/tests/test_phash_dedup.py
@@ -11,7 +11,6 @@ from PIL import Image
from sqlalchemy import func, select
from backend.app.models import (
- ImagePrediction,
ImageProvenance,
ImageRecord,
ImportSettings,
@@ -119,11 +118,6 @@ def test_smaller_existing_is_superseded(importer, import_layout):
image_record_id=eid, tag_id=tag.id, source="manual"
)
)
- importer.session.add(
- ImagePrediction(
- image_record_id=eid, raw_name="x", category="general", score=0.9
- )
- )
old.siglip_embedding = [0.0] * 1152
old.integrity_status = "ok"
importer.session.commit()
@@ -141,11 +135,6 @@ def test_smaller_existing_is_superseded(importer, import_layout):
assert row.path != old_path
assert row.phash is not None
assert row.integrity_status == "unknown"
- # #768: re-import clears the normalized predictions too
- assert importer.session.execute(
- select(func.count()).select_from(ImagePrediction)
- .where(ImagePrediction.image_record_id == eid)
- ).scalar_one() == 0
assert row.siglip_embedding is None
linked = importer.session.execute(
select(image_tag.c.tag_id).where(
diff --git a/tests/test_tag_merge.py b/tests/test_tag_merge.py
index 17835ee..e5536ae 100644
--- a/tests/test_tag_merge.py
+++ b/tests/test_tag_merge.py
@@ -2,7 +2,6 @@ import pytest
from sqlalchemy import func, select
from backend.app.models import Tag, TagKind, image_tag
-from backend.app.models.tag_allowlist import TagAllowlist
from backend.app.services.tag_service import (
MergeResult,
TagMergeConflict,
@@ -110,18 +109,6 @@ async def test_will_alias_true_when_machine_sourced(db):
assert ei.value.will_alias is True
-@pytest.mark.asyncio
-async def test_will_alias_true_when_allowlisted(db):
- svc = TagService(db)
- await svc.find_or_create("Canon2", TagKind.general)
- source = await svc.find_or_create("Allowed", TagKind.general)
- db.add(TagAllowlist(tag_id=source.id))
- await db.flush()
- with pytest.raises(TagMergeConflict) as ei:
- await svc.rename(source.id, "Canon2")
- assert ei.value.will_alias is True
-
-
@pytest.mark.asyncio
async def test_merge_rejects_self_merge(db):
svc = TagService(db)
@@ -250,35 +237,6 @@ async def test_merge_dedups_suggestion_rejections(db):
).first() is None
-@pytest.mark.asyncio
-async def test_merge_allowlist_target_has_keeps_target_threshold(db):
- svc = TagService(db)
- a = await svc.find_or_create("SrcAL", TagKind.general)
- b = await svc.find_or_create("TgtAL", TagKind.general)
- db.add(TagAllowlist(tag_id=a.id, min_confidence=0.5))
- db.add(TagAllowlist(tag_id=b.id, min_confidence=0.9))
- await db.flush()
- await svc.merge(a.id, b.id)
- rows = (await db.execute(select(TagAllowlist))).scalars().all()
- assert len(rows) == 1
- assert rows[0].tag_id == b.id
- assert rows[0].min_confidence == 0.9
-
-
-@pytest.mark.asyncio
-async def test_merge_allowlist_source_only_moves_to_target(db):
- svc = TagService(db)
- a = await svc.find_or_create("SrcAL2", TagKind.general)
- b = await svc.find_or_create("TgtAL2", TagKind.general)
- db.add(TagAllowlist(tag_id=a.id, min_confidence=0.42))
- await db.flush()
- await svc.merge(a.id, b.id)
- rows = (await db.execute(select(TagAllowlist))).scalars().all()
- assert len(rows) == 1
- assert rows[0].tag_id == b.id
- assert rows[0].min_confidence == 0.42
-
-
@pytest.mark.asyncio
async def test_merge_repoints_existing_aliases(db):
from backend.app.models.tag_alias import TagAlias
@@ -372,7 +330,9 @@ async def test_alias_fallback_to_kind_when_no_predictions(db):
svc = TagService(db)
a = await svc.find_or_create("AllowNoPred", TagKind.character)
b = await svc.find_or_create("CanonF", TagKind.character)
- db.add(TagAllowlist(tag_id=a.id))
+ # Machine-known via a prior accept (source='ml_accepted') → kept as alias.
+ img = await _img(db)
+ await svc.add_to_image(img, a.id, source="ml_accepted")
await db.flush()
result = await svc.merge(a.id, b.id)
assert result.alias_created is True
diff --git a/tests/test_tasks_ml.py b/tests/test_tasks_ml.py
index 62e5c5b..2fde76c 100644
--- a/tests/test_tasks_ml.py
+++ b/tests/test_tasks_ml.py
@@ -1,15 +1,12 @@
-"""tag_and_embed / backfill task tests. Models aren't in CI, so we test
-the pure helpers (_aggregate_video_predictions, _is_video) as unit tests, and
-the DB-touching backfill query as an integration test with monkeypatched
-inference.
-"""
+"""tag_and_embed (embed-only) / backfill task tests. The pure _is_video helper
+is a unit test; the DB-touching backfill query is an integration test with
+monkeypatched dispatch."""
from pathlib import Path
import pytest
-from backend.app.services.ml.tagger import TagPrediction
-from backend.app.tasks.ml import _aggregate_video_predictions, _is_video
+from backend.app.tasks.ml import _is_video
def test_is_video():
@@ -18,34 +15,6 @@ def test_is_video():
assert _is_video(Path("a.jpg")) is False
-def _pred(name, conf, cat="general"):
- return {name: TagPrediction(name, cat, conf)}
-
-
-def test_aggregate_video_keeps_corroborated_and_means():
- # #747: 4 frames; "smile" in 3, "sword" in 1 (noise). min_frames=2.
- per_frame = [
- {"smile": TagPrediction("smile", "general", 0.6),
- "sword": TagPrediction("sword", "general", 0.9)},
- _pred("smile", 0.8),
- _pred("smile", 0.7),
- {},
- ]
- out = _aggregate_video_predictions(per_frame, min_frames=2)
- assert "sword" not in out # one-frame flicker dropped
- assert abs(out["smile"]["confidence"] - (0.6 + 0.8 + 0.7) / 3) < 1e-9 # mean, not max
-
-
-def test_aggregate_video_clamps_min_frames_to_sample_count():
- # Short video: 1 frame but min_frames=3 — clamp so it still tags.
- out = _aggregate_video_predictions([_pred("solo", 0.8)], min_frames=3)
- assert out["solo"]["confidence"] == 0.8
-
-
-def test_aggregate_video_empty():
- assert _aggregate_video_predictions([], min_frames=3) == {}
-
-
@pytest.mark.integration
@pytest.mark.asyncio
async def test_backfill_enqueues_missing(db, monkeypatch):
@@ -69,75 +38,3 @@ async def test_backfill_enqueues_missing(db, monkeypatch):
count = ml_tasks.backfill()
assert count >= 1
assert img.id in calls
-
-
-@pytest.mark.integration
-@pytest.mark.asyncio
-async def test_apply_allowlist_applies_above_threshold(db):
- from sqlalchemy import select
-
- from backend.app.models import ImageRecord, TagAllowlist, TagKind
- from backend.app.models.tag import image_tag
- from backend.app.services.tag_service import TagService
- from backend.app.tasks import ml as ml_tasks
- from tests._prediction_helpers import seed_predictions
-
- tag = await TagService(db).find_or_create("autohero", TagKind.character)
- db.add(TagAllowlist(tag_id=tag.id, min_confidence=0.95))
- img = ImageRecord(
- path="/images/al.jpg", sha256="al" + "0" * 62, size_bytes=1,
- mime="image/jpeg", width=1, height=1,
- origin="imported_filesystem", integrity_status="unknown",
- )
- db.add(img)
- await db.commit()
- await seed_predictions(
- db, img.id, {"autohero": {"category": "character", "confidence": 0.97}}
- )
- await db.commit()
-
- n = ml_tasks.apply_allowlist_tags(tag_id=tag.id)
- assert n >= 1
- src = (
- await db.execute(
- select(image_tag.c.source)
- .where(image_tag.c.image_record_id == img.id)
- .where(image_tag.c.tag_id == tag.id)
- )
- ).scalar_one()
- assert src == "ml_auto"
-
-
-@pytest.mark.integration
-@pytest.mark.asyncio
-async def test_apply_allowlist_skips_below_threshold(db):
- from sqlalchemy import select
-
- from backend.app.models import ImageRecord, TagAllowlist, TagKind
- from backend.app.models.tag import image_tag
- from backend.app.services.tag_service import TagService
- from backend.app.tasks import ml as ml_tasks
- from tests._prediction_helpers import seed_predictions
-
- tag = await TagService(db).find_or_create("lowconf", TagKind.character)
- db.add(TagAllowlist(tag_id=tag.id, min_confidence=0.95))
- img = ImageRecord(
- path="/images/lc.jpg", sha256="lc" + "0" * 62, size_bytes=1,
- mime="image/jpeg", width=1, height=1,
- origin="imported_filesystem", integrity_status="unknown",
- )
- db.add(img)
- await db.commit()
- await seed_predictions(
- db, img.id, {"lowconf": {"category": "character", "confidence": 0.40}}
- )
- await db.commit()
- ml_tasks.apply_allowlist_tags(tag_id=tag.id)
- applied = (
- await db.execute(
- select(image_tag.c.tag_id)
- .where(image_tag.c.image_record_id == img.id)
- .where(image_tag.c.tag_id == tag.id)
- )
- ).scalar_one_or_none()
- assert applied is None
From 3d97667f5b1750b273421f980fb5097e97c4b5dd Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Tue, 30 Jun 2026 13:08:46 -0400
Subject: [PATCH 05/10] fix(lint): drop unused select import in tags.py after
allowlist removal
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
---
backend/app/api/tags.py | 1 -
1 file changed, 1 deletion(-)
diff --git a/backend/app/api/tags.py b/backend/app/api/tags.py
index 92e7c9f..42cd6c8 100644
--- a/backend/app/api/tags.py
+++ b/backend/app/api/tags.py
@@ -1,7 +1,6 @@
"""Tags API: autocomplete, create, list/add/remove for an image."""
from quart import Blueprint, jsonify, request
-from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.exc import IntegrityError
From bc6d43d3f229ba7a6652999b321d5d3177354d48 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Tue, 30 Jun 2026 13:41:25 -0400
Subject: [PATCH 06/10] refactor(ml): drop dead tagger/suggestion settings +
columns (#1199)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Hygiene follow-up to the Camie retirement (#1189) — these were left inert to
bound that change; nothing reads them now. Migration 0068 drops:
- ml_settings: tagger_store_floor, tagger_model_version, suggestion_threshold_
character/general (already dead pre-retirement — scoring uses per-head
thresholds), video_min_tag_frames (only the deleted video-prediction
aggregator used it).
- image_record: tagger_model_version (no writer), centroid_scores (dead JSON
cache, no reader).
Also: ml_admin _EDITABLE/GET/_validate pruned (dropped the store-floor invariant
+ video_min_tag_frames check); MLThresholdSliders trimmed to a video-embedding
card (interval + max frames only); importer no longer resets the dropped cols;
download_models drops the Camie fetch; stale CASCADE comments in cleanup_service
no longer name the removed tables. Tests updated.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
---
.../0068_drop_dead_tagger_settings.py | 80 +++++++++++++++++++
backend/app/api/ml_admin.py | 32 +-------
backend/app/models/image_record.py | 15 +---
backend/app/models/ml_settings.py | 34 +-------
backend/app/scripts/download_models.py | 30 -------
backend/app/services/cleanup_service.py | 28 +++----
backend/app/services/importer.py | 2 -
.../settings/MLThresholdSliders.vue | 80 +++----------------
tests/test_api_ml_admin.py | 55 ++++---------
tests/test_download_models.py | 39 +++------
tests/test_migration_0003.py | 11 ++-
11 files changed, 146 insertions(+), 260 deletions(-)
create mode 100644 alembic/versions/0068_drop_dead_tagger_settings.py
diff --git a/alembic/versions/0068_drop_dead_tagger_settings.py b/alembic/versions/0068_drop_dead_tagger_settings.py
new file mode 100644
index 0000000..770676d
--- /dev/null
+++ b/alembic/versions/0068_drop_dead_tagger_settings.py
@@ -0,0 +1,80 @@
+"""drop dead tagger/suggestion settings + columns left after Camie retirement (#1199)
+
+Hygiene follow-up to #1189. These were left inert to bound that change; nothing
+reads them now:
+- ml_settings: tagger_store_floor + tagger_model_version (only the deleted Camie
+ tagger used them), suggestion_threshold_character/general (already dead pre-
+ retirement — scoring uses per-head thresholds), video_min_tag_frames (only the
+ deleted video-prediction aggregator used it).
+- image_record: tagger_model_version (no writer now), centroid_scores (long-dead
+ JSON cache, no reader).
+
+Revision ID: 0068
+Revises: 0067
+Create Date: 2026-06-30
+"""
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "0068"
+down_revision: Union[str, None] = "0067"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.drop_column("ml_settings", "suggestion_threshold_character")
+ op.drop_column("ml_settings", "suggestion_threshold_general")
+ op.drop_column("ml_settings", "tagger_store_floor")
+ op.drop_column("ml_settings", "video_min_tag_frames")
+ op.drop_column("ml_settings", "tagger_model_version")
+ op.drop_column("image_record", "tagger_model_version")
+ op.drop_column("image_record", "centroid_scores")
+
+
+def downgrade() -> None:
+ op.add_column(
+ "image_record",
+ sa.Column("centroid_scores", sa.JSON(), nullable=True),
+ )
+ op.add_column(
+ "image_record",
+ sa.Column("tagger_model_version", sa.String(length=128), nullable=True),
+ )
+ op.add_column(
+ "ml_settings",
+ sa.Column(
+ "tagger_model_version", sa.String(length=128), nullable=False,
+ server_default="camie-tagger-v2",
+ ),
+ )
+ op.add_column(
+ "ml_settings",
+ sa.Column(
+ "video_min_tag_frames", sa.Integer(), nullable=False,
+ server_default="3",
+ ),
+ )
+ op.add_column(
+ "ml_settings",
+ sa.Column(
+ "tagger_store_floor", sa.Float(), nullable=False,
+ server_default="0.7",
+ ),
+ )
+ op.add_column(
+ "ml_settings",
+ sa.Column(
+ "suggestion_threshold_general", sa.Float(), nullable=False,
+ server_default="0.7",
+ ),
+ )
+ op.add_column(
+ "ml_settings",
+ sa.Column(
+ "suggestion_threshold_character", sa.Float(), nullable=False,
+ server_default="0.7",
+ ),
+ )
diff --git a/backend/app/api/ml_admin.py b/backend/app/api/ml_admin.py
index cd0cf56..054fa69 100644
--- a/backend/app/api/ml_admin.py
+++ b/backend/app/api/ml_admin.py
@@ -9,12 +9,8 @@ ml_admin_bp = Blueprint("ml_admin", __name__, url_prefix="/api/ml")
_EDITABLE = (
- "suggestion_threshold_character",
- "suggestion_threshold_general",
- "tagger_store_floor",
"video_frame_interval_seconds",
"video_max_frames",
- "video_min_tag_frames",
"head_min_positives",
"head_auto_apply_precision",
"head_auto_apply_enabled",
@@ -37,13 +33,8 @@ async def get_settings():
).scalar_one()
return jsonify(
{
- "suggestion_threshold_character": s.suggestion_threshold_character,
- "suggestion_threshold_general": s.suggestion_threshold_general,
- "tagger_store_floor": s.tagger_store_floor,
"video_frame_interval_seconds": s.video_frame_interval_seconds,
"video_max_frames": s.video_max_frames,
- "video_min_tag_frames": s.video_min_tag_frames,
- "tagger_model_version": s.tagger_model_version,
"embedder_model_version": s.embedder_model_version,
"head_min_positives": s.head_min_positives,
"head_auto_apply_precision": s.head_auto_apply_precision,
@@ -88,31 +79,12 @@ async def patch_settings():
def _validate(p: dict) -> str | None:
- """Returns an error string if the proposed settings are invalid, else None.
-
- Invariant (plan-task #764): the per-category suggestion thresholds can't
- drop below tagger_store_floor — nothing below the floor is stored, so a
- lower threshold would silently surface nothing in that gap. The UI clamps
- the sliders to the floor; this is the server-side backstop.
- """
- floor = p["tagger_store_floor"]
- if not (0.0 <= floor <= 1.0):
- return "tagger_store_floor must be between 0 and 1"
- for cat in ("character", "general"):
- if p[f"suggestion_threshold_{cat}"] < floor:
- return (
- f"suggestion_threshold_{cat} cannot be below tagger_store_floor "
- f"({floor}) — predictions below the floor are not stored"
- )
- # Video tagging (#747).
+ """Returns an error string if the proposed settings are invalid, else None."""
+ # Video embedding (#747).
if p["video_frame_interval_seconds"] <= 0:
return "video_frame_interval_seconds must be > 0"
if p["video_max_frames"] < 1:
return "video_max_frames must be >= 1"
- if p["video_min_tag_frames"] < 1:
- return "video_min_tag_frames must be >= 1"
- if p["video_min_tag_frames"] > p["video_max_frames"]:
- return "video_min_tag_frames cannot exceed video_max_frames"
# Head training (#114).
if int(p["head_min_positives"]) < 1:
return "head_min_positives must be >= 1"
diff --git a/backend/app/models/image_record.py b/backend/app/models/image_record.py
index b3ba120..fc459dd 100644
--- a/backend/app/models/image_record.py
+++ b/backend/app/models/image_record.py
@@ -9,7 +9,6 @@ from datetime import datetime
from pgvector.sqlalchemy import Vector
from sqlalchemy import (
- JSON,
BigInteger,
DateTime,
Enum,
@@ -77,19 +76,13 @@ class ImageRecord(Base):
ForeignKey("artist.id", ondelete="SET NULL"), nullable=True, index=True
)
- # ML fields (populated by FC-2's ml-worker). Per-tag predictions live in the
- # normalized image_prediction table (#768) — the tagger_predictions JSON
- # column was dropped in migration 0046. tagger_model_version stays as the
- # "has this been tagged / is it current?" signal the backfill sweep reads.
- tagger_model_version: Mapped[str | None] = mapped_column(String(128), nullable=True)
- # 1152 = SigLIP-so400m embedding dim. Swapping models in FC-2 may require
- # a column-width migration.
+ # ML fields (populated by the ml-worker / GPU agent). 1152 = SigLIP-so400m
+ # embedding dim; siglip_model_version stamps which model produced it (so an
+ # operator model swap, #1190, can re-embed the stale rows). A different-dim
+ # model would need a column-width migration.
siglip_embedding: Mapped[list[float] | None] = mapped_column(Vector(1152), nullable=True)
siglip_model_version: Mapped[str | None] = mapped_column(String(128), nullable=True)
- # Centroid score cache (populated post-tagging)
- centroid_scores: Mapped[dict | None] = mapped_column(JSON, nullable=True)
-
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
diff --git a/backend/app/models/ml_settings.py b/backend/app/models/ml_settings.py
index 75db5b2..1d7c729 100644
--- a/backend/app/models/ml_settings.py
+++ b/backend/app/models/ml_settings.py
@@ -23,39 +23,16 @@ class MLSettings(Base):
__table_args__ = (CheckConstraint("id = 1", name="singleton"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
- suggestion_threshold_character: Mapped[float] = mapped_column(
- Float, nullable=False, default=0.70
- )
- # Default raised 0.50 → 0.70 on 2026-06-02 — operator-flagged 0.50
- # surfaced too many low-confidence picks; 0.70 keeps the rail
- # signal-rich while still surfacing more than the original 0.95
- # which hid almost everything. Operator-tunable via Settings → ML.
- suggestion_threshold_general: Mapped[float] = mapped_column(
- Float, nullable=False, default=0.70
- )
- # Ingest floor: tagger predictions below this confidence are not stored
- # (tagger.Tagger.infer). Default 0.70 — the suggestion path already filters
- # there, so the sub-0.70 tail is redundant weight (it had bloated
- # image_record's TOAST to ~100 GB; plan-task #764). Operator-tunable via
- # Settings → ML; must stay ≤ the suggestion thresholds.
- tagger_store_floor: Mapped[float] = mapped_column(
- Float, nullable=False, default=0.70
- )
- # Video tagging (#747). Sample one frame every N seconds (fixed CADENCE, not a
- # fixed count) so a tag's frame-presence reflects real screen time regardless
- # of video length; cap the total so a long video can't explode into hundreds
- # of inferences (the cadence stretches past the cap). A tag is kept only if it
- # appears in >= video_min_tag_frames sampled frames (≈ that many × interval
- # seconds on screen) — duration-independent noise rejection. Operator-tunable.
+ # Video embedding (#747). Sample one frame every N seconds (fixed CADENCE, not
+ # a fixed count) so coverage reflects real screen time regardless of length;
+ # cap the total so a long video can't explode into hundreds of embeds. The
+ # per-frame SigLIP embeddings are mean-pooled. Operator-tunable.
video_frame_interval_seconds: Mapped[float] = mapped_column(
Float, nullable=False, default=4.0
)
video_max_frames: Mapped[int] = mapped_column(
Integer, nullable=False, default=64
)
- video_min_tag_frames: Mapped[int] = mapped_column(
- Integer, nullable=False, default=3
- )
# Tagging-v2 head training (#114). The head is the suggestion source that
# LEARNS from the operator's tags (replacing Camie + centroid). A concept
# needs >= head_min_positives labelled images before a head is trained;
@@ -94,9 +71,6 @@ class MLSettings(Base):
ccip_auto_apply_threshold: Mapped[float] = mapped_column(
Float, nullable=False, default=0.92
)
- tagger_model_version: Mapped[str] = mapped_column(
- String(128), nullable=False, default="camie-tagger-v2"
- )
embedder_model_version: Mapped[str] = mapped_column(
String(128), nullable=False, default="siglip-so400m-patch14-384"
)
diff --git a/backend/app/scripts/download_models.py b/backend/app/scripts/download_models.py
index 4b5e6d3..c6ff7a3 100644
--- a/backend/app/scripts/download_models.py
+++ b/backend/app/scripts/download_models.py
@@ -7,7 +7,6 @@ import sys
from pathlib import Path
MODEL_ROOT = Path(os.environ.get("ML_MODEL_DIR", "/models"))
-CAMIE_REPO = os.environ.get("CAMIE_HF_REPO", "Camais03/camie-tagger-v2")
SIGLIP_REPO = os.environ.get(
"SIGLIP_HF_REPO", "google/siglip-so400m-patch14-384"
)
@@ -24,34 +23,6 @@ def _snapshot(repo_id: str, dest: Path, allow_patterns: list[str] | None) -> Non
)
-def ensure_camie() -> None:
- """Fetch Camie v2 weights + metadata.
-
- v2 layout (HuggingFace Camais03/camie-tagger-v2): the ONNX file is
- named camie-tagger-v2.onnx (not model.onnx) and tags ship inside
- camie-tagger-v2-metadata.json (not selected_tags.csv). Both at root.
- The repo also contains app/, game/, training/, images/ subdirs full
- of setup/demo files we don't need — allow_patterns scopes the fetch
- to just the inference essentials (~790 MB instead of ~2 GB).
- """
- dest = MODEL_ROOT / "camie"
- model_file = dest / "camie-tagger-v2.onnx"
- meta_file = dest / "camie-tagger-v2-metadata.json"
- if model_file.is_file() and meta_file.is_file():
- print(f"[download_models] Camie present at {dest}")
- return
- print(f"[download_models] Fetching {CAMIE_REPO} -> {dest}")
- _snapshot(
- CAMIE_REPO, dest,
- [
- "camie-tagger-v2.onnx",
- "camie-tagger-v2-metadata.json",
- "config.json",
- "config.yaml",
- ],
- )
-
-
def ensure_siglip() -> None:
dest = MODEL_ROOT / "siglip"
if (dest / "config.json").is_file() and any(dest.glob("*.safetensors")):
@@ -62,7 +33,6 @@ def ensure_siglip() -> None:
def main() -> int:
- ensure_camie()
ensure_siglip()
print("[download_models] Done.")
return 0
diff --git a/backend/app/services/cleanup_service.py b/backend/app/services/cleanup_service.py
index d5f86d4..3916909 100644
--- a/backend/app/services/cleanup_service.py
+++ b/backend/app/services/cleanup_service.py
@@ -395,9 +395,8 @@ def delete_images(
def delete_tag(session: Session, *, tag_id: int) -> dict:
"""Simple DELETE FROM tag WHERE id=?.
- Postgres cascades the rest (image_tag, tag_alias, tag_allowlist,
- tag_reference_embedding, tag_suggestion_rejection, series_page).
- Returns counts BEFORE delete so the caller can surface them.
+ Postgres cascades the rest (image_tag, tag_alias, tag_suggestion_rejection,
+ series_page). Returns counts BEFORE delete so the caller can surface them.
Raises LookupError if tag_id not found.
"""
tag = session.get(Tag, tag_id)
@@ -742,8 +741,7 @@ def purge_legacy_tags(session: Session, *, dry_run: bool = False) -> dict:
artist-kind tags PLUS general tags whose name matches a legacy
prefix (source:*).
- CASCADE on image_tag / tag_alias / tag_allowlist /
- tag_reference_embedding / tag_suggestion_rejection / series_page
+ CASCADE on image_tag / tag_alias / tag_suggestion_rejection / series_page
clears the related rows on the parent DELETE.
Returns:
@@ -785,23 +783,21 @@ def purge_legacy_tags(session: Session, *, dry_run: bool = False) -> dict:
return result
-# The Camie-suggestable CONTENT vocabulary. "Reset content tagging" wipes
-# these so the operator can re-tag from scratch via auto-suggest. fandom +
-# series (and series_page ordering) are deliberately NOT here — they're kept.
+# The CONTENT vocabulary. "Reset content tagging" wipes these so the operator
+# can re-tag from scratch. fandom + series (and series_page ordering) are
+# deliberately NOT here — they're kept.
RESETTABLE_TAG_KINDS = ("general", "character")
def reset_content_tagging(session: Session, *, dry_run: bool = False) -> dict:
"""Count (dry_run) or DELETE every general + character tag so the operator
- can re-tag from scratch via the Camie auto-suggest.
+ can re-tag from scratch (heads/CCIP repopulate suggestions).
- PRESERVED: fandom + series tags and their series_page ordering, plus every
- image's image_prediction rows (untouched) so suggestions
- repopulate immediately. CASCADE on image_tag / tag_alias / tag_allowlist /
- tag_reference_embedding / tag_suggestion_rejection clears each deleted
- tag's applications + metadata. Tag.fandom_id is SET NULL, so deleting
- character tags never touches the fandom rows. Irreversible except via DB
- backup restore.
+ PRESERVED: fandom + series tags and their series_page ordering. CASCADE on
+ image_tag / tag_alias / tag_suggestion_rejection clears each deleted tag's
+ applications + metadata. Tag.fandom_id is SET NULL, so deleting character
+ tags never touches the fandom rows. Irreversible except via DB backup
+ restore.
Returns:
{"by_kind": {"general": N, "character": M},
diff --git a/backend/app/services/importer.py b/backend/app/services/importer.py
index 7ba8e5c..70bc7c8 100644
--- a/backend/app/services/importer.py
+++ b/backend/app/services/importer.py
@@ -1475,10 +1475,8 @@ class Importer:
existing.duration_seconds = duration # #871: keep the kept copy's duration
existing.thumbnail_path = None
existing.integrity_status = "unknown"
- existing.tagger_model_version = None
existing.siglip_embedding = None
existing.siglip_model_version = None
- existing.centroid_scores = None
# created_at intentionally preserved; updated_at auto-bumps.
self.session.flush()
self.session.commit()
diff --git a/frontend/src/components/settings/MLThresholdSliders.vue b/frontend/src/components/settings/MLThresholdSliders.vue
index 685009f..49c4a59 100644
--- a/frontend/src/components/settings/MLThresholdSliders.vue
+++ b/frontend/src/components/settings/MLThresholdSliders.vue
@@ -1,69 +1,30 @@
-
-
-
-
-
-
-
-
-
-
-
-
- Tagger predictions below this confidence aren't stored — raising it
- keeps the image library lean. Suggestions can't be shown below the
- floor.
-
-
-
-
-
-
-
Video tagging
- Videos are tagged by sampling frames at a fixed cadence. A tag is kept
- only if it shows up in enough frames (≈ that many × the interval in
- seconds of screen time), which filters one-frame noise without losing
- tags that only appear in part of a longer video.
+ Videos are embedded by sampling frames at a fixed cadence and mean-pooling
+ their SigLIP embeddings. The interval sets the cadence; the cap bounds how
+ many frames a long video samples.
-
+
-
+
-
-
-
@@ -77,31 +38,14 @@ import { useMLStore } from '../../stores/ml.js'
import MaintenanceTile from '../common/MaintenanceTile.vue'
const store = useMLStore()
-// 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired as
-// suggestion categories; their threshold rows are gone.
-// floorMin: the per-category suggestion thresholds can't drop below the
-// tagger store floor (nothing below the floor is stored to surface).
-const fields = [
- { key: 'suggestion_threshold_character', label: 'Character', floorMin: true },
- { key: 'suggestion_threshold_general', label: 'General', floorMin: true }
-]
const local = reactive({})
watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true })
async function save() {
- // Mirror the server invariant: keep the category thresholds at or above the
- // store floor so a raised floor doesn't leave a threshold stranded below it.
- const floor = local.tagger_store_floor
- local.suggestion_threshold_character = Math.max(local.suggestion_threshold_character, floor)
- local.suggestion_threshold_general = Math.max(local.suggestion_threshold_general, floor)
- // Mirror the server invariant: a tag can't require more frames than are sampled.
- local.video_min_tag_frames = Math.min(local.video_min_tag_frames, local.video_max_frames)
- const patch = {}
- for (const f of fields) patch[f.key] = local[f.key]
- patch.tagger_store_floor = local.tagger_store_floor
- patch.video_frame_interval_seconds = local.video_frame_interval_seconds
- patch.video_max_frames = local.video_max_frames
- patch.video_min_tag_frames = local.video_min_tag_frames
+ const patch = {
+ video_frame_interval_seconds: local.video_frame_interval_seconds,
+ video_max_frames: local.video_max_frames
+ }
try { await store.patchSettings(patch) }
catch (e) { toast({ text: e.message, type: 'error' }) }
}
diff --git a/tests/test_api_ml_admin.py b/tests/test_api_ml_admin.py
index 9e4f4ff..caa676a 100644
--- a/tests/test_api_ml_admin.py
+++ b/tests/test_api_ml_admin.py
@@ -19,19 +19,18 @@ async def test_get_and_patch_settings(client):
resp = await client.get("/api/ml/settings")
assert resp.status_code == 200
body = await resp.get_json()
- # Default raised 0.50 → 0.70 on 2026-06-02 (alembic 0033) — 0.50
- # was too noisy in practice. The 0.70 default keeps the rail
- # signal-rich without hiding everything like the original 0.95.
- assert body["suggestion_threshold_general"] == pytest.approx(0.70)
- # Retired threshold columns must not appear in the payload.
- assert "suggestion_threshold_artist" not in body
- assert "suggestion_threshold_copyright" not in body
+ assert body["head_min_positives"] == 8
+ # Retired tagger/suggestion-threshold columns are gone from the payload
+ # (Camie retirement #1189/#1199).
+ assert "suggestion_threshold_general" not in body
+ assert "tagger_store_floor" not in body
+ assert "tagger_model_version" not in body
resp = await client.patch(
- "/api/ml/settings", json={"suggestion_threshold_general": 0.90}
+ "/api/ml/settings", json={"head_min_positives": 12}
)
assert resp.status_code == 200
- assert (await resp.get_json())["suggestion_threshold_general"] == pytest.approx(0.90)
+ assert (await resp.get_json())["head_min_positives"] == 12
@pytest.mark.asyncio
@@ -55,55 +54,29 @@ async def test_embedder_model_settable_and_empty_rejected(client):
@pytest.mark.asyncio
-async def test_tagger_store_floor_default_and_patch(client):
- body = await (await client.get("/api/ml/settings")).get_json()
- assert body["tagger_store_floor"] == pytest.approx(0.70)
-
- resp = await client.patch("/api/ml/settings", json={"tagger_store_floor": 0.6})
- assert resp.status_code == 200
- assert (await resp.get_json())["tagger_store_floor"] == pytest.approx(0.6)
-
-
-@pytest.mark.asyncio
-async def test_suggestion_threshold_below_store_floor_rejected(client):
- # Invariant (#764): a category threshold can't sit below the store floor —
- # nothing below the floor is stored, so the gap would surface nothing.
- # Floor defaults to 0.70; pushing general down to 0.50 must 400.
- resp = await client.patch(
- "/api/ml/settings", json={"suggestion_threshold_general": 0.50}
- )
- assert resp.status_code == 400
- assert "tagger_store_floor" in (await resp.get_json())["error"]
-
-
-@pytest.mark.asyncio
-async def test_video_tagging_settings_default_and_patch(client):
- """#747: video cadence/noise knobs are exposed + patchable."""
+async def test_video_settings_default_and_patch(client):
+ """#747: video frame-sampling knobs are exposed + patchable."""
body = await (await client.get("/api/ml/settings")).get_json()
assert body["video_frame_interval_seconds"] == pytest.approx(4.0)
assert body["video_max_frames"] == 64
- assert body["video_min_tag_frames"] == 3
resp = await client.patch(
"/api/ml/settings",
- json={"video_frame_interval_seconds": 5, "video_max_frames": 40,
- "video_min_tag_frames": 4},
+ json={"video_frame_interval_seconds": 5, "video_max_frames": 40},
)
assert resp.status_code == 200
out = await resp.get_json()
assert out["video_frame_interval_seconds"] == pytest.approx(5.0)
assert out["video_max_frames"] == 40
- assert out["video_min_tag_frames"] == 4
@pytest.mark.asyncio
-async def test_video_min_tag_frames_above_max_rejected(client):
+async def test_video_max_frames_below_one_rejected(client):
resp = await client.patch(
- "/api/ml/settings",
- json={"video_max_frames": 10, "video_min_tag_frames": 20},
+ "/api/ml/settings", json={"video_max_frames": 0},
)
assert resp.status_code == 400
- assert "video_min_tag_frames" in (await resp.get_json())["error"]
+ assert "video_max_frames" in (await resp.get_json())["error"]
@pytest.mark.asyncio
diff --git a/tests/test_download_models.py b/tests/test_download_models.py
index 366bd81..cf3f2e8 100644
--- a/tests/test_download_models.py
+++ b/tests/test_download_models.py
@@ -1,6 +1,6 @@
"""download_models tests. No network in CI; we test the 'already present →
-skip' short-circuit by faking the expected files, and that main() wires
-both ensure_* calls.
+skip' short-circuit by faking the expected files, and that main() wires the
+SigLIP fetch. (Camie download retired with the tagger, #1199.)
"""
from unittest.mock import patch
@@ -8,29 +8,6 @@ from unittest.mock import patch
from backend.app.scripts import download_models as dm
-def test_ensure_camie_skips_when_present(tmp_path, monkeypatch):
- """v2 layout (HF Camais03/camie-tagger-v2): the ONNX file is named
- camie-tagger-v2.onnx (not model.onnx) and tags ship inside
- camie-tagger-v2-metadata.json (not selected_tags.csv). Both at root.
- Updated 2026-05-25 after the actual repo layout was confirmed via
- WebFetch — the old assertion pinned the v1 filenames."""
- monkeypatch.setattr(dm, "MODEL_ROOT", tmp_path)
- camie = tmp_path / "camie"
- camie.mkdir(parents=True)
- (camie / "camie-tagger-v2.onnx").write_bytes(b"x")
- (camie / "camie-tagger-v2-metadata.json").write_text("{}")
- with patch.object(dm, "_snapshot") as snap:
- dm.ensure_camie()
- snap.assert_not_called()
-
-
-def test_ensure_camie_downloads_when_missing(tmp_path, monkeypatch):
- monkeypatch.setattr(dm, "MODEL_ROOT", tmp_path)
- with patch.object(dm, "_snapshot") as snap:
- dm.ensure_camie()
- snap.assert_called_once()
-
-
def test_ensure_siglip_skips_when_present(tmp_path, monkeypatch):
monkeypatch.setattr(dm, "MODEL_ROOT", tmp_path)
sig = tmp_path / "siglip"
@@ -42,9 +19,15 @@ def test_ensure_siglip_skips_when_present(tmp_path, monkeypatch):
snap.assert_not_called()
-def test_main_calls_both(monkeypatch):
+def test_ensure_siglip_downloads_when_missing(tmp_path, monkeypatch):
+ monkeypatch.setattr(dm, "MODEL_ROOT", tmp_path)
+ with patch.object(dm, "_snapshot") as snap:
+ dm.ensure_siglip()
+ snap.assert_called_once()
+
+
+def test_main_fetches_siglip(monkeypatch):
calls = []
- monkeypatch.setattr(dm, "ensure_camie", lambda: calls.append("camie"))
monkeypatch.setattr(dm, "ensure_siglip", lambda: calls.append("siglip"))
assert dm.main() == 0
- assert calls == ["camie", "siglip"]
+ assert calls == ["siglip"]
diff --git a/tests/test_migration_0003.py b/tests/test_migration_0003.py
index 9eb14ff..1752120 100644
--- a/tests/test_migration_0003.py
+++ b/tests/test_migration_0003.py
@@ -20,12 +20,15 @@ def test_new_tables_registered():
def test_image_record_columns_renamed():
cols = {c.name for c in ImageRecord.__table__.columns}
- # tagger_predictions (the renamed wd14_predictions) was later dropped in
- # migration 0046 — predictions live in image_prediction now (#768).
- assert "tagger_model_version" in cols
+ # Legacy tagger columns are all gone: tagger_predictions/wd14_* dropped in
+ # 0046, tagger_model_version + centroid_scores dropped in 0068 (#1199, Camie
+ # retirement). The SigLIP embedding columns are the live ML fields.
+ assert "siglip_embedding" in cols
+ assert "siglip_model_version" in cols
+ assert "tagger_model_version" not in cols
+ assert "centroid_scores" not in cols
assert "tagger_predictions" not in cols
assert "wd14_predictions" not in cols
- assert "wd14_model_version" not in cols
def test_tag_alias_composite_pk():
From 9a3cda007a60028eeec9ff61f5b12f0ea42a9002 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Tue, 30 Jun 2026 13:47:55 -0400
Subject: [PATCH 07/10] =?UTF-8?q?feat(api):=20agent-friendly=20tag=20analy?=
=?UTF-8?q?sis=20endpoints=20=E2=80=94=20/tags/top=20+=20/tags//stats?=
=?UTF-8?q?=20(#1136)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fast, read-only, indexed aggregates shaped for ANALYSIS (not the paged UI
directory, which is alphabetical + builds previews and timed out at 10 min on a
full count sweep).
- GET /api/tags/top — top tags by image count, desc. ?kind, ?limit (cap 500),
?min_count, ?source=all|human|manual|accepted|auto (human=manual+ml_accepted,
auto=head_auto+ccip_auto+ml_auto). One GROUP BY over image_tag (indexed on
tag_id).
- GET /api/tags//stats — per-tag dataset health: total + per-source counts
(manual/accepted/head_auto/ccip_auto), human vs auto rollups, rejection count,
and whether a trained head exists. Backs concept-readiness + source-split
analysis.
Plain-HTTP homelab posture, no auth change. Tests cover ranking, source filter,
min_count, the source breakdown, and 404.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
---
backend/app/api/tags.py | 116 +++++++++++++++++++++++++++++++++++-
tests/test_api_tag_stats.py | 84 ++++++++++++++++++++++++++
2 files changed, 199 insertions(+), 1 deletion(-)
create mode 100644 tests/test_api_tag_stats.py
diff --git a/backend/app/api/tags.py b/backend/app/api/tags.py
index 42cd6c8..97e5e30 100644
--- a/backend/app/api/tags.py
+++ b/backend/app/api/tags.py
@@ -1,11 +1,14 @@
"""Tags API: autocomplete, create, list/add/remove for an image."""
from quart import Blueprint, jsonify, request
+from sqlalchemy import func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.exc import IntegrityError
from ..extensions import get_session
-from ..models import Tag, TagKind, TagPositiveConfirmation
+from ..models import Tag, TagHead, TagKind, TagPositiveConfirmation
+from ..models.tag import image_tag
+from ..models.tag_suggestion_rejection import TagSuggestionRejection
from ..services.bulk_tag_service import BulkTagService
from ..services.ml.aliases import AliasService
from ..services.series_match_service import SeriesMatchService
@@ -59,6 +62,117 @@ def _parse_bulk_ids(
return ids, None
+# Application-source groupings (image_tag.source). HUMAN = operator signal;
+# AUTO = machine-applied (heads/CCIP, + legacy Camie ml_auto).
+_SOURCE_GROUPS = {
+ "human": ("manual", "ml_accepted"),
+ "manual": ("manual",),
+ "accepted": ("ml_accepted",),
+ "auto": ("head_auto", "ccip_auto", "ml_auto"),
+}
+
+
+@tags_bp.route("/tags/top", methods=["GET"])
+async def tags_top():
+ """Top tags by image count — a fast indexed aggregate for ANALYSIS (not the
+ paged UI directory, which is alphabetical + builds previews). Params:
+ ?kind=general|character|fandom|… ?source=all|human|manual|accepted|auto
+ ?limit=50 (cap 500) ?min_count=N. → {tags:[{tag_id,name,kind,count}]} desc."""
+ kind = _coerce_kind(request.args.get("kind"))
+ try:
+ limit = min(max(int(request.args.get("limit", "50")), 1), 500)
+ except ValueError:
+ return jsonify({"error": "limit must be an integer"}), 400
+ min_count = None
+ if "min_count" in request.args:
+ try:
+ min_count = int(request.args["min_count"])
+ except ValueError:
+ return jsonify({"error": "min_count must be an integer"}), 400
+ src_vals = _SOURCE_GROUPS.get((request.args.get("source") or "all").lower())
+
+ cnt = func.count(image_tag.c.image_record_id)
+ stmt = (
+ select(Tag.id, Tag.name, Tag.kind, cnt.label("count"))
+ .select_from(Tag)
+ .join(image_tag, image_tag.c.tag_id == Tag.id)
+ .group_by(Tag.id, Tag.name, Tag.kind)
+ .order_by(cnt.desc(), Tag.name.asc())
+ .limit(limit)
+ )
+ if kind is not None:
+ stmt = stmt.where(Tag.kind == kind)
+ if src_vals is not None:
+ stmt = stmt.where(image_tag.c.source.in_(src_vals))
+ if min_count is not None:
+ stmt = stmt.having(cnt >= min_count)
+ async with get_session() as session:
+ rows = (await session.execute(stmt)).all()
+ return jsonify({"tags": [
+ {
+ "tag_id": r.id, "name": r.name,
+ "kind": r.kind.value if hasattr(r.kind, "value") else str(r.kind),
+ "count": r.count,
+ }
+ for r in rows
+ ]})
+
+
+@tags_bp.route("/tags//stats", methods=["GET"])
+async def tag_stats(tag_id: int):
+ """Per-tag dataset health: total + per-source application counts (human vs
+ machine), rejection count, and whether a trained head exists. Read-only,
+ analysis-shaped — backs concept-readiness + source-split decisions."""
+ async with get_session() as session:
+ tag = await session.get(Tag, tag_id)
+ if tag is None:
+ return jsonify({"error": "not found"}), 404
+ by_source = {
+ src: n for src, n in (
+ await session.execute(
+ select(image_tag.c.source, func.count())
+ .where(image_tag.c.tag_id == tag_id)
+ .group_by(image_tag.c.source)
+ )
+ ).all()
+ }
+ rejected = (
+ await session.execute(
+ select(func.count())
+ .select_from(TagSuggestionRejection)
+ .where(TagSuggestionRejection.tag_id == tag_id)
+ )
+ ).scalar_one()
+ has_head = (
+ await session.execute(
+ select(func.count())
+ .select_from(TagHead)
+ .where(TagHead.tag_id == tag_id)
+ )
+ ).scalar_one() > 0
+ human = by_source.get("manual", 0) + by_source.get("ml_accepted", 0)
+ auto = (
+ by_source.get("head_auto", 0)
+ + by_source.get("ccip_auto", 0)
+ + by_source.get("ml_auto", 0)
+ )
+ return jsonify({
+ "tag_id": tag_id,
+ "name": tag.name,
+ "kind": tag.kind.value if hasattr(tag.kind, "value") else str(tag.kind),
+ "count_total": sum(by_source.values()),
+ "count_human": human,
+ "count_manual": by_source.get("manual", 0),
+ "count_accepted": by_source.get("ml_accepted", 0),
+ "count_auto": auto,
+ "count_head_auto": by_source.get("head_auto", 0),
+ "count_ccip_auto": by_source.get("ccip_auto", 0),
+ "count_rejected": rejected,
+ "by_source": by_source,
+ "has_head": has_head,
+ })
+
+
@tags_bp.route("/tags/autocomplete", methods=["GET"])
async def autocomplete():
q = request.args.get("q", "")
diff --git a/tests/test_api_tag_stats.py b/tests/test_api_tag_stats.py
new file mode 100644
index 0000000..67900b6
--- /dev/null
+++ b/tests/test_api_tag_stats.py
@@ -0,0 +1,84 @@
+"""Agent-friendly tag analysis endpoints (#1136): /api/tags/top + /tags//stats."""
+import pytest
+
+from backend.app.models import ImageRecord, TagHead, TagKind
+from backend.app.models.tag import image_tag
+from backend.app.models.tag_suggestion_rejection import TagSuggestionRejection
+from backend.app.services.tag_service import TagService
+
+pytestmark = pytest.mark.integration
+
+
+async def _img(db, sha) -> ImageRecord:
+ img = ImageRecord(
+ path=f"/images/{sha}.jpg", sha256=sha, size_bytes=1, mime="image/jpeg",
+ width=1, height=1, origin="imported_filesystem", integrity_status="unknown",
+ )
+ db.add(img)
+ await db.flush()
+ return img
+
+
+async def _apply(db, image_id, tag_id, source):
+ await db.execute(image_tag.insert().values(
+ image_record_id=image_id, tag_id=tag_id, source=source,
+ ))
+
+
+@pytest.mark.asyncio
+async def test_tags_top_ranks_by_count_and_filters(client, db):
+ svc = TagService(db)
+ common = await svc.find_or_create("Common", TagKind.general)
+ rare = await svc.find_or_create("Rare", TagKind.general)
+ imgs = [await _img(db, f"{i:064d}") for i in range(3)]
+ await _apply(db, imgs[0].id, common.id, "manual")
+ await _apply(db, imgs[1].id, common.id, "manual")
+ await _apply(db, imgs[2].id, common.id, "head_auto") # 3 total, 2 human
+ await _apply(db, imgs[0].id, rare.id, "manual") # 1
+ await db.commit()
+
+ top = await (await client.get("/api/tags/top?kind=general&limit=10")).get_json()
+ counts = {t["name"]: t["count"] for t in top["tags"]}
+ assert counts["Common"] == 3 and counts["Rare"] == 1
+ assert [t["name"] for t in top["tags"]][0] == "Common" # count desc
+
+ # source=human drops the head_auto application
+ human = await (await client.get("/api/tags/top?source=human&kind=general")).get_json()
+ assert {t["name"]: t["count"] for t in human["tags"]}["Common"] == 2
+
+ # min_count filters out the rare tag
+ mc = await (await client.get("/api/tags/top?min_count=2&kind=general")).get_json()
+ assert "Rare" not in [t["name"] for t in mc["tags"]]
+
+
+@pytest.mark.asyncio
+async def test_tag_stats_source_breakdown(client, db):
+ svc = TagService(db)
+ tag = await svc.find_or_create("Hero", TagKind.character)
+ i1, i2, i3, i4 = [await _img(db, c * 64) for c in "abcd"]
+ await _apply(db, i1.id, tag.id, "manual")
+ await _apply(db, i2.id, tag.id, "ml_accepted")
+ await _apply(db, i3.id, tag.id, "ccip_auto")
+ db.add(TagSuggestionRejection(image_record_id=i4.id, tag_id=tag.id))
+ db.add(TagHead(
+ tag_id=tag.id, embedding_version="v", weights=[0.0] * 1152, bias=0.0,
+ suggest_threshold=0.5, auto_apply_threshold=None, n_pos=10, n_neg=30,
+ ap=0.8, precision_cv=0.9, recall=0.6,
+ ))
+ await db.commit()
+
+ body = await (await client.get(f"/api/tags/{tag.id}/stats")).get_json()
+ assert body["count_total"] == 3
+ assert body["count_human"] == 2 # manual + ml_accepted
+ assert body["count_manual"] == 1
+ assert body["count_accepted"] == 1
+ assert body["count_ccip_auto"] == 1
+ assert body["count_auto"] == 1
+ assert body["count_rejected"] == 1
+ assert body["has_head"] is True
+
+
+@pytest.mark.asyncio
+async def test_tag_stats_404(client):
+ resp = await client.get("/api/tags/99999/stats")
+ assert resp.status_code == 404
From 3d7f60a6e3cfa750dff64aaed3e5f5c23aa42ed6 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Tue, 30 Jun 2026 13:52:16 -0400
Subject: [PATCH 08/10] fix(lint): use dict() not a dict-comprehension in
tag_stats (C416)
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
---
backend/app/api/tags.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/backend/app/api/tags.py b/backend/app/api/tags.py
index 97e5e30..ec9990a 100644
--- a/backend/app/api/tags.py
+++ b/backend/app/api/tags.py
@@ -127,15 +127,15 @@ async def tag_stats(tag_id: int):
tag = await session.get(Tag, tag_id)
if tag is None:
return jsonify({"error": "not found"}), 404
- by_source = {
- src: n for src, n in (
+ by_source = dict(
+ (
await session.execute(
select(image_tag.c.source, func.count())
.where(image_tag.c.tag_id == tag_id)
.group_by(image_tag.c.source)
)
).all()
- }
+ )
rejected = (
await session.execute(
select(func.count())
From d5f29f7056d27a3b95935b888239685e71cf6799 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Tue, 30 Jun 2026 15:27:26 -0400
Subject: [PATCH 09/10] =?UTF-8?q?feat(agent):=20crop=20proposers=20?=
=?UTF-8?q?=E2=80=94=20booru=5Fyolo=20anatomy=20+=20COCO=20person=20+=20co?=
=?UTF-8?q?mic=20panels=20(#1202)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Better region PROPOSERS feeding the existing crop→SigLIP→max-over-bag heads (no
change to the learned-tagging approach; no per-tag cost — propose once, embed
each region, all heads in one matmul).
- detectors.py: lazy ultralytics YOLO wrapper, each proposer independently
optional + guarded (a bad weight spec / inference error self-disables that one,
logged, never breaks the worker). Weights resolve from an ultralytics name |
http(s) URL | "hf_repo::file", cached under HF_HOME. NMS merge so a figure two
detectors both find collapses to one crop.
- worker: figure boxes = imgutils detect_person ∪ general COCO person (merged)
→ CCIP + concept (anime + Western/realistic coverage); booru_yolo anatomy
components (head/cat-head/anatomy/…) → concept crops; comic panels → kind=
'panel' concept crops. Capped per frame (MAX_COMPONENTS/MAX_PANELS).
- config + compose: PERSON_WEIGHTS (default yolo11n.pt, works OOB),
ANATOMY_WEIGHTS + PANEL_WEIGHTS (operator sets booru_yolo URL + mosesb panel
hf::file; empty = off). ultralytics added to requirements.
- backend: image_region 'kind' doc notes 'panel'; no migration (free String,
and the bag scorer keys on a non-null siglip_embedding, not the kind, so any
SigLIP region joins the bag automatically).
Agent is outside CI — py-compiled here; operator tests on the GPU and checks
Western-vs-anime crop quality via /api/ccip observability.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
---
agent/docker-compose.yml | 11 ++
agent/fc_agent/config.py | 18 ++++
agent/fc_agent/detectors.py | 163 +++++++++++++++++++++++++++++
agent/fc_agent/worker.py | 65 +++++++++---
agent/requirements.txt | 3 +
backend/app/models/image_region.py | 5 +-
6 files changed, 250 insertions(+), 15 deletions(-)
create mode 100644 agent/fc_agent/detectors.py
diff --git a/agent/docker-compose.yml b/agent/docker-compose.yml
index 0f07cfa..d37e214 100644
--- a/agent/docker-compose.yml
+++ b/agent/docker-compose.yml
@@ -37,6 +37,17 @@ services:
# Crop embedder (SigLIP concept bag): float16 keeps VRAM low on a shared
# desktop GPU; the model itself is announced by the server.
SIGLIP_DTYPE: ${SIGLIP_DTYPE:-float16}
+ # Crop PROPOSERS (extra YOLO detectors → more/better concept crops).
+ # PERSON_WEIGHTS: general COCO person detector (Western/realistic figures),
+ # merged with the anime person detector. Default works out of the box.
+ # ANATOMY_WEIGHTS: booru_yolo (anime/furry/NSFW components). Set to the
+ # weights URL, e.g. the yolov11m_aa22.pt from github.com/aperveyev/booru_yolo.
+ # PANEL_WEIGHTS: comic-panel detector as "hf_repo::file",
+ # e.g. mosesb/best-comic-panel-detection::.pt
+ # Empty = that proposer is off. Each self-disables if its weights fail to load.
+ PERSON_WEIGHTS: ${PERSON_WEIGHTS:-yolo11n.pt}
+ ANATOMY_WEIGHTS: ${ANATOMY_WEIGHTS:-}
+ PANEL_WEIGHTS: ${PANEL_WEIGHTS:-}
volumes:
# Persist the downloaded ONNX models so restarts are fast.
- fc-agent-models:/models
diff --git a/agent/fc_agent/config.py b/agent/fc_agent/config.py
index 42dee5c..630fc3f 100644
--- a/agent/fc_agent/config.py
+++ b/agent/fc_agent/config.py
@@ -18,6 +18,16 @@ class Config:
# the server announces in the lease)
auto_start: bool # start the worker pool on boot (so a container restart
# resumes processing without anyone clicking Start)
+ # Crop PROPOSERS (extra YOLO detectors that say where to crop). Each weight
+ # spec is an ultralytics name | http(s) URL | "hf_repo::file" ("" = off).
+ person_weights: str # general COCO person detector (Western/realistic figs)
+ person_conf: float
+ anatomy_weights: str # booru_yolo anime/furry/NSFW components
+ anatomy_conf: float
+ panel_weights: str # comic-panel detector
+ panel_conf: float
+ max_components: int # cap anatomy component crops per frame
+ max_panels: int # cap panel crops per frame
@classmethod
def from_env(cls) -> "Config":
@@ -33,4 +43,12 @@ class Config:
embed_dtype=os.environ.get("SIGLIP_DTYPE", "float16"),
embed_model_override=os.environ.get("EMBED_MODEL_NAME", ""),
auto_start=os.environ.get("AUTO_START", "").lower() in ("1", "true", "yes"),
+ person_weights=os.environ.get("PERSON_WEIGHTS", "yolo11n.pt"),
+ person_conf=float(os.environ.get("PERSON_CONF", "0.35")),
+ anatomy_weights=os.environ.get("ANATOMY_WEIGHTS", ""),
+ anatomy_conf=float(os.environ.get("ANATOMY_CONF", "0.30")),
+ panel_weights=os.environ.get("PANEL_WEIGHTS", ""),
+ panel_conf=float(os.environ.get("PANEL_CONF", "0.30")),
+ max_components=int(os.environ.get("MAX_COMPONENTS", "8")),
+ max_panels=int(os.environ.get("MAX_PANELS", "8")),
)
diff --git a/agent/fc_agent/detectors.py b/agent/fc_agent/detectors.py
new file mode 100644
index 0000000..f91957f
--- /dev/null
+++ b/agent/fc_agent/detectors.py
@@ -0,0 +1,163 @@
+"""Region PROPOSERS — small YOLO detectors that decide WHERE to crop. They run
+on the agent GPU and their boxes feed the crop → SigLIP → max-over-bag pipeline:
+
+ - person (general COCO yolo11n): full-figure boxes for realistic / Western art
+ the anime person-detector misses; NMS-merged with imgutils detect_person and
+ fed to CCIP (identity) + a concept crop.
+ - anatomy (booru_yolo): anime / furry / NSFW torso components (head, cat-head,
+ boob, hip, …) — concept crops aligned to the operator's tag vocabulary.
+ - panel (mosesb): a comic page → panel regions → concept crops.
+
+Each proposer is INDEPENDENTLY optional + guarded: a bad weight path or an
+inference error disables just that proposer (logged) and never breaks the
+worker, which still falls back to imgutils detection. Weights resolve from an
+ultralytics builtin name ("yolo11n.pt"), an http(s) URL, or "hf_repo::file" —
+cached under HF_HOME so the download happens once.
+"""
+import logging
+import os
+import threading
+from pathlib import Path
+
+log = logging.getLogger("fc_agent.detectors")
+_CACHE = Path(os.environ.get("HF_HOME", "/models")) / "yolo"
+
+
+def _resolve(spec: str) -> str | None:
+ """A local weights path (downloading if needed) or an ultralytics builtin
+ name. None if the spec is empty/unresolvable."""
+ if not spec:
+ return None
+ if "::" in spec: # hf_repo::filename
+ repo, _, fname = spec.partition("::")
+ from huggingface_hub import hf_hub_download
+ return hf_hub_download(
+ repo_id=repo, filename=fname, cache_dir=str(_CACHE)
+ )
+ if spec.startswith(("http://", "https://")):
+ _CACHE.mkdir(parents=True, exist_ok=True)
+ dest = _CACHE / spec.rsplit("/", 1)[-1]
+ if not dest.is_file():
+ import requests
+ r = requests.get(spec, timeout=300)
+ r.raise_for_status()
+ dest.write_bytes(r.content)
+ return str(dest)
+ return spec # ultralytics builtin name
+
+
+def _iou(a, b) -> float:
+ ax, ay, aw, ah = a
+ bx, by, bw, bh = b
+ ix = max(0.0, min(ax + aw, bx + bw) - max(ax, bx))
+ iy = max(0.0, min(ay + ah, by + bh) - max(ay, by))
+ inter = ix * iy
+ union = aw * ah + bw * bh - inter
+ return inter / union if union > 0 else 0.0
+
+
+def nms_merge(boxes, iou_thresh: float = 0.6):
+ """Greedy NMS over (bbox_norm, score, label) from possibly several detectors,
+ so the same figure found by two of them collapses to one (higher-score) box."""
+ kept = []
+ for bb, sc, lb in sorted(boxes, key=lambda b: b[1], reverse=True):
+ if all(_iou(bb, k[0]) < iou_thresh for k in kept):
+ kept.append((bb, sc, lb))
+ return kept
+
+
+class YoloProposer:
+ """One lazily-loaded ultralytics YOLO. detect(image) → [(bbox_norm, score,
+ label)] with bbox normalized (x, y, w, h) in [0,1]. Self-disables on any
+ load/inference failure."""
+
+ def __init__(self, name, weights, conf=0.25, keep_labels=None):
+ self.name = name
+ self._spec = weights
+ self._conf = conf
+ self._keep = [k.lower() for k in keep_labels] if keep_labels else None
+ self._model = None
+ self._ok = True
+ self._lock = threading.Lock()
+
+ def _load(self):
+ if self._model is not None or not self._ok:
+ return
+ with self._lock:
+ if self._model is not None or not self._ok:
+ return
+ try:
+ from ultralytics import YOLO
+ path = _resolve(self._spec)
+ if path is None:
+ self._ok = False
+ return
+ self._model = YOLO(path)
+ log.info("detector %s loaded (%s)", self.name, path)
+ except Exception as exc: # noqa: BLE001
+ log.warning("detector %s disabled (load failed): %s", self.name, exc)
+ self._ok = False
+
+ def detect(self, image):
+ self._load()
+ if self._model is None:
+ return []
+ try:
+ res = self._model.predict(image, conf=self._conf, verbose=False)[0]
+ except Exception as exc: # noqa: BLE001
+ log.warning("detector %s inference failed: %s", self.name, exc)
+ return []
+ iw, ih = image.size
+ names = getattr(res, "names", None) or {}
+ out = []
+ for b in res.boxes:
+ label = str(names.get(int(b.cls), int(b.cls))).lower()
+ if self._keep is not None and not any(k in label for k in self._keep):
+ continue
+ x0, y0, x1, y1 = (float(v) for v in b.xyxy[0].tolist())
+ out.append((
+ (x0 / iw, y0 / ih, (x1 - x0) / iw, (y1 - y0) / ih),
+ float(b.conf), label,
+ ))
+ return out
+
+
+class Proposers:
+ """The agent's proposer set, built from config. Each detector is optional —
+ an empty weight spec leaves that proposer off."""
+
+ def __init__(self, cfg):
+ self.cfg = cfg
+ self._person = (
+ YoloProposer("person-coco", cfg.person_weights,
+ conf=cfg.person_conf, keep_labels=["person"])
+ if cfg.person_weights else None
+ )
+ self._anatomy = (
+ YoloProposer("anatomy", cfg.anatomy_weights, conf=cfg.anatomy_conf)
+ if cfg.anatomy_weights else None
+ )
+ self._panel = (
+ YoloProposer("panel", cfg.panel_weights, conf=cfg.panel_conf)
+ if cfg.panel_weights else None
+ )
+
+ def figures(self, image, base_boxes):
+ """Merge imgutils person boxes (base_boxes: [(bbox, score)]) with the
+ general COCO person detector → NMS'd figure boxes [(bbox, score, label)]."""
+ boxes = [(bb, sc if sc is not None else 1.0, "person") for bb, sc in base_boxes]
+ if self._person is not None:
+ boxes += self._person.detect(image)
+ return nms_merge(boxes)
+
+ def components(self, image):
+ if self._anatomy is None:
+ return []
+ items = sorted(self._anatomy.detect(image), key=lambda b: b[1], reverse=True)
+ return items[: self.cfg.max_components]
+
+ def panels(self, image):
+ if self._panel is None:
+ return []
+ items = sorted(self._panel.detect(image), key=lambda b: b[1], reverse=True)
+ return items[: self.cfg.max_panels]
diff --git a/agent/fc_agent/worker.py b/agent/fc_agent/worker.py
index dd5b206..2b7e412 100644
--- a/agent/fc_agent/worker.py
+++ b/agent/fc_agent/worker.py
@@ -76,6 +76,9 @@ class Worker:
# needs it, from the model the server announces — one shared instance.
self._embedder = None
self._embedder_lock = threading.Lock()
+ # Region proposers (extra YOLO detectors) — lazily built once, shared.
+ self._proposers = None
+ self._proposers_lock = threading.Lock()
# --- control -----------------------------------------------------------
def start(self):
@@ -177,6 +180,15 @@ class Worker:
self._embedder = CropEmbedder(model_name, self.cfg.embed_dtype)
return self._embedder
+ def _ensure_proposers(self):
+ if self._proposers is not None:
+ return self._proposers
+ with self._proposers_lock:
+ if self._proposers is None:
+ from .detectors import Proposers
+ self._proposers = Proposers(self.cfg)
+ return self._proposers
+
def _process(self, job: dict) -> bool:
"""Process one job. Returns True when handled (completed, or hard-failed
because the job itself is bad) and False on a TRANSPORT error (curator
@@ -227,43 +239,68 @@ class Worker:
want_ccip = task in ("ccip", "both")
want_siglip = task in ("ccip", "siglip", "both")
replace_kinds = (
- ["concept"] if task == "siglip" else ["figure", "face", "concept"]
+ ["concept", "panel"] if task == "siglip"
+ else ["figure", "face", "concept", "panel"]
)
embedder = self._ensure_embedder(model_name) if want_siglip else None
+ proposers = self._ensure_proposers()
regions = []
ccip_ev = self.cfg.ccip_model or "ccip-default"
dv = f"person-{self.cfg.detector_level}"
+
+ def _concept(frame, bbox, t, score, detver, kind="concept"):
+ """A SigLIP region for one crop (None if below the size floor)."""
+ crop = crop_region(frame, bbox)
+ if crop is None:
+ return None
+ return {
+ "kind": kind, "bbox": list(bbox), "frame_time": t,
+ "score": score, "siglip_embedding": embedder.embed(crop),
+ "embedding_version": embed_version, "detector_version": detver,
+ }
+
for t, frame in frames:
- figs = models.detect_figures(frame, self.cfg.detector_level)
+ # FIGURE boxes: imgutils detect_person ∪ general COCO person,
+ # NMS-merged → CCIP identity (+ a concept crop). Covers anime +
+ # Western/realistic figures.
+ base = models.detect_figures(frame, self.cfg.detector_level)
+ figs = proposers.figures(frame, base)
if not figs:
- figs = [((0.0, 0.0, 1.0, 1.0), None)] # whole-frame fallback
- for bbox, score in figs:
+ figs = [((0.0, 0.0, 1.0, 1.0), 1.0, "whole")] # whole-frame fallback
+ for bbox, score, _label in figs:
crop = crop_region(frame, bbox)
if crop is None:
continue
if want_ccip:
regions.append({
- "kind": "figure",
- "bbox": list(bbox),
- "frame_time": t,
+ "kind": "figure", "bbox": list(bbox), "frame_time": t,
"score": score,
"ccip_embedding": models.ccip_vector(
crop, self.cfg.ccip_model or None
),
- "embedding_version": ccip_ev,
- "detector_version": dv,
+ "embedding_version": ccip_ev, "detector_version": dv,
})
if want_siglip:
regions.append({
- "kind": "concept",
- "bbox": list(bbox),
- "frame_time": t,
+ "kind": "concept", "bbox": list(bbox), "frame_time": t,
"score": score,
"siglip_embedding": embedder.embed(crop),
- "embedding_version": embed_version,
- "detector_version": dv,
+ "embedding_version": embed_version, "detector_version": dv,
})
+ if not want_siglip:
+ continue
+ # ANATOMY components (booru_yolo: head/cat-head/anatomy/…) →
+ # concept crops only (not full characters, so no CCIP).
+ for bbox, score, label in proposers.components(frame):
+ r = _concept(frame, bbox, t, score, f"booru:{label}")
+ if r is not None:
+ regions.append(r)
+ # PANEL crops (comic page → panels) → kind='panel' (still SigLIP).
+ for bbox, score, _label in proposers.panels(frame):
+ r = _concept(frame, bbox, t, score, "panel", kind="panel")
+ if r is not None:
+ regions.append(r)
self.client.submit(job["job_id"], regions, replace_kinds)
self._bump(processed=1)
return True
diff --git a/agent/requirements.txt b/agent/requirements.txt
index 53f87bb..b71fdb0 100644
--- a/agent/requirements.txt
+++ b/agent/requirements.txt
@@ -7,6 +7,9 @@ onnxruntime-gpu
# Dockerfile from the CUDA-12.4 wheel index so the GPU build is deterministic;
# transformers loads whatever SigLIP-family model the server announces.
transformers>=4.45
+# Crop PROPOSERS — small YOLO detectors (booru_yolo anatomy, COCO person, comic
+# panel) that decide where to crop. Uses the torch already installed above.
+ultralytics>=8.3
# Control surface + HTTP.
fastapi
uvicorn[standard]
diff --git a/backend/app/models/image_region.py b/backend/app/models/image_region.py
index 58cfa8c..7ae6d4d 100644
--- a/backend/app/models/image_region.py
+++ b/backend/app/models/image_region.py
@@ -31,7 +31,10 @@ class ImageRegion(Base):
ForeignKey("image_record.id", ondelete="CASCADE"), index=True
)
# 'frame' (a whole video frame → SigLIP bag) | 'face' | 'figure' (→ CCIP
- # character id) | 'concept' (→ SigLIP head bag).
+ # character id) | 'concept' (→ SigLIP head bag) | 'panel' (a comic panel crop,
+ # also SigLIP → the bag). Free String, not an enum — proposers can add kinds
+ # without a migration; the bag scorer keys on a non-null siglip_embedding, not
+ # the kind, so any SigLIP-embedded region joins the bag.
kind: Mapped[str] = mapped_column(String(16), nullable=False)
# For video/animated media: the source frame's timestamp in SECONDS. NULL for
# static images. Lets a video be a BAG of per-frame instances (fixes the
From ce5db5caaf3617f06516c8cfbfc7dc0b14d45693 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Tue, 30 Jun 2026 15:28:50 -0400
Subject: [PATCH 10/10] chore(agent): default the anatomy + panel proposer
weights to working values
booru_yolo yolov11m_aa22.pt (40MB) + mosesb/best-comic-panel-detection::best.pt.
Each self-disables if its download fails, so defaulting them on is safe.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
---
agent/docker-compose.yml | 19 ++++++++++---------
1 file changed, 10 insertions(+), 9 deletions(-)
diff --git a/agent/docker-compose.yml b/agent/docker-compose.yml
index d37e214..5032669 100644
--- a/agent/docker-compose.yml
+++ b/agent/docker-compose.yml
@@ -37,17 +37,18 @@ services:
# Crop embedder (SigLIP concept bag): float16 keeps VRAM low on a shared
# desktop GPU; the model itself is announced by the server.
SIGLIP_DTYPE: ${SIGLIP_DTYPE:-float16}
- # Crop PROPOSERS (extra YOLO detectors → more/better concept crops).
+ # Crop PROPOSERS (extra YOLO detectors → more/better concept crops). Each
+ # downloads its weights once (cached on the models volume) and self-disables
+ # if the download/load fails. Blank any one to turn it off.
# PERSON_WEIGHTS: general COCO person detector (Western/realistic figures),
- # merged with the anime person detector. Default works out of the box.
- # ANATOMY_WEIGHTS: booru_yolo (anime/furry/NSFW components). Set to the
- # weights URL, e.g. the yolov11m_aa22.pt from github.com/aperveyev/booru_yolo.
- # PANEL_WEIGHTS: comic-panel detector as "hf_repo::file",
- # e.g. mosesb/best-comic-panel-detection::.pt
- # Empty = that proposer is off. Each self-disables if its weights fail to load.
+ # merged with the anime detector. yolo11n.pt (~6 MB, auto-downloaded).
+ # ANATOMY_WEIGHTS: booru_yolo anime/furry/NSFW components (~40 MB). NB the
+ # repo states no license — fine for private use. yolov8n_as01.pt is the
+ # 6 MB nano if you want lighter than yolov11m_aa22.pt.
+ # PANEL_WEIGHTS: mosesb comic-panel detector (Apache-2.0), "hf_repo::file".
PERSON_WEIGHTS: ${PERSON_WEIGHTS:-yolo11n.pt}
- ANATOMY_WEIGHTS: ${ANATOMY_WEIGHTS:-}
- PANEL_WEIGHTS: ${PANEL_WEIGHTS:-}
+ ANATOMY_WEIGHTS: ${ANATOMY_WEIGHTS:-https://github.com/aperveyev/booru_yolo/raw/main/models/yolov11m_aa22.pt}
+ PANEL_WEIGHTS: ${PANEL_WEIGHTS:-mosesb/best-comic-panel-detection::best.pt}
volumes:
# Persist the downloaded ONNX models so restarts are fast.
- fc-agent-models:/models