feat(tagging): SigLIP concept crops + max-over-bag scoring (#114)
Lift recall on small/local concepts (glasses, cum, stomach-bulge, xray, lactation) that the whole-image SigLIP vector washes out: the GPU agent now embeds figure crops with SigLIP too, stored as kind='concept' regions, and the suggestion rail scores each image as a BAG (whole-image + every concept crop), taking each head's MAX over the bag. The whole-image vector is always in the bag, so this can never score lower than before. Model-agnostic by construction: the server ANNOUNCES the embedding model (HF name + version) in the lease, so the agent loads whatever the heads were trained in and stays in lock-step — a model swap is a server setting + a re-embed migration, never an agent change. - agent: model-agnostic CropEmbedder (torch/transformers get_image_features, fp16 on CUDA, inference-locked); worker branches on job.task — 'ccip' emits figure(CCIP)+concept(SigLIP) in one pass, 'siglip' emits concept-only so the back-catalogue backfill never churns figure/CCIP regions; torch cu124 + transformers in the image. - server: lease announces embed_model_name/embed_version; score_image is max-over-bag (version-filtered region embeddings); enqueue_gpu_backfill 'siglip' gates on a missing concept region (drains the back-catalogue, retries failures, no double-enqueue); daily siglip-backfill beat; UI button; /api/ccip/overview reports images_with_concept_siglip. - v1 scope: suggestion rail only — auto-apply stays whole-image (conservative; heads' thresholds were calibrated on whole-image). Bulk-apply bag = follow-up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
This commit is contained in:
+6
-1
@@ -10,11 +10,16 @@ RUN apt-get update \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
# torch from the CUDA-12.4 wheel index (matches the base image); its wheels
|
||||
# bundle their own CUDA + cuDNN and coexist with onnxruntime-gpu. Installed
|
||||
# first + separately so the GPU build of torch is deterministic and layer-cached.
|
||||
RUN pip3 install --no-cache-dir torch==2.6.0 --index-url https://download.pytorch.org/whl/cu124
|
||||
COPY requirements.txt .
|
||||
RUN pip3 install --no-cache-dir -r requirements.txt
|
||||
COPY fc_agent ./fc_agent
|
||||
|
||||
# imgutils caches downloaded ONNX models here; mount a volume to persist them.
|
||||
# imgutils ONNX models + the transformers SigLIP weights both cache here; mount
|
||||
# a volume to persist them across restarts (the SigLIP download is ~3.5 GB once).
|
||||
ENV HF_HOME=/models
|
||||
EXPOSE 8770
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ class Config:
|
||||
ccip_model: str # imgutils CCIP model name ("" → imgutils default)
|
||||
detector_level: str # imgutils person-detector level: n|s|m|x
|
||||
poll_idle_seconds: float # wait between empty leases
|
||||
embed_dtype: str # torch dtype for the crop embedder: float16|float32
|
||||
embed_model_override: str # force a SigLIP-family model ("" → use the one
|
||||
# the server announces in the lease)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "Config":
|
||||
@@ -25,4 +28,6 @@ class Config:
|
||||
ccip_model=os.environ.get("CCIP_MODEL", ""),
|
||||
detector_level=os.environ.get("DETECTOR_LEVEL", "m"),
|
||||
poll_idle_seconds=float(os.environ.get("POLL_IDLE_SECONDS", "10")),
|
||||
embed_dtype=os.environ.get("SIGLIP_DTYPE", "float16"),
|
||||
embed_model_override=os.environ.get("EMBED_MODEL_NAME", ""),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Crop EMBEDDER for the concept bag — model-agnostic (CLIP/SigLIP-family).
|
||||
|
||||
The server trains its per-concept heads in the embedding space of whatever model
|
||||
its `embedder_model_version` names; a crop must be embedded with the SAME model
|
||||
or its vector lands in a different coordinate system and every head misfires. So
|
||||
the model identity (HF name + version) is ANNOUNCED BY THE SERVER in the lease —
|
||||
nothing here is hardcoded to SigLIP. Whatever name the server sends is loaded via
|
||||
transformers `get_image_features` (the CLIP/SigLIP-family image-tower call); a
|
||||
non-CLIP backbone (e.g. a DINO encoder) would need its own pooling adapter.
|
||||
|
||||
torch on CUDA, fp16 by default to keep VRAM low on a shared desktop GPU — the
|
||||
tiny fp16-vs-fp32 difference is negligible for the linear heads (cosine ~0.999).
|
||||
A single inference lock serializes the forward pass: the pipeline is I/O-bound,
|
||||
so the GPU isn't the bottleneck, and one model shared across worker threads is
|
||||
safest behind a lock.
|
||||
"""
|
||||
import threading
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
|
||||
class CropEmbedder:
|
||||
def __init__(self, model_name: str, dtype: str = "float16"):
|
||||
self._name = model_name
|
||||
self._dtype_name = dtype
|
||||
self._model = None
|
||||
self._processor = None
|
||||
self._torch = None
|
||||
self._device = None
|
||||
self._dt = None
|
||||
self._load_lock = threading.Lock()
|
||||
self._infer_lock = threading.Lock()
|
||||
|
||||
@property
|
||||
def model_name(self) -> str:
|
||||
return self._name
|
||||
|
||||
def load(self) -> None:
|
||||
if self._model is not None:
|
||||
return
|
||||
with self._load_lock:
|
||||
if self._model is not None:
|
||||
return
|
||||
import torch
|
||||
from transformers import AutoImageProcessor, AutoModel
|
||||
|
||||
self._torch = torch
|
||||
self._device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
dt = getattr(torch, self._dtype_name, torch.float16)
|
||||
if self._device == "cpu":
|
||||
dt = torch.float32 # fp16 matmul is unsupported/slow on CPU
|
||||
self._dt = dt
|
||||
self._processor = AutoImageProcessor.from_pretrained(self._name)
|
||||
model = AutoModel.from_pretrained(self._name, torch_dtype=dt)
|
||||
model.eval().to(self._device)
|
||||
self._model = model
|
||||
|
||||
def embed(self, image: Image.Image) -> list[float]:
|
||||
"""A crop → its embedding as a plain float list, ready to POST."""
|
||||
self.load()
|
||||
torch = self._torch
|
||||
enc = self._processor(images=image, return_tensors="pt")
|
||||
pixel_values = enc["pixel_values"].to(self._device, self._dt)
|
||||
with self._infer_lock, torch.no_grad():
|
||||
out = self._model.get_image_features(pixel_values=pixel_values)
|
||||
pooled = out.pooler_output if hasattr(out, "pooler_output") else out
|
||||
vec = pooled[0].float().cpu().numpy().astype(np.float32).reshape(-1)
|
||||
return vec.tolist()
|
||||
+66
-12
@@ -22,6 +22,12 @@ from .crops import crop_region
|
||||
# Push it up while watching the GPU util + VRAM in the UI.
|
||||
MAX_CONCURRENCY = 32
|
||||
|
||||
# Fallbacks only — the server ANNOUNCES the embedding model (name + version) in
|
||||
# the lease so the agent stays model-agnostic and in lock-step with the space
|
||||
# the heads were trained in. These cover an older server that doesn't send them.
|
||||
DEFAULT_EMBED_MODEL = "google/siglip-so400m-patch14-384"
|
||||
DEFAULT_EMBED_VERSION = "siglip-so400m-patch14-384"
|
||||
|
||||
|
||||
class _Slot:
|
||||
"""One worker loop. `inflight` = jobs leased but not yet processed, so a
|
||||
@@ -44,6 +50,10 @@ class Worker:
|
||||
self.processed = 0
|
||||
self.errors = 0
|
||||
self._active = 0 # slots currently mid-image
|
||||
# The crop embedder (SigLIP-family) is built lazily on the first job that
|
||||
# needs it, from the model the server announces — one shared instance.
|
||||
self._embedder = None
|
||||
self._embedder_lock = threading.Lock()
|
||||
|
||||
# --- control -----------------------------------------------------------
|
||||
def start(self):
|
||||
@@ -114,6 +124,15 @@ class Worker:
|
||||
self.client.release(slot.inflight)
|
||||
slot.inflight = []
|
||||
|
||||
def _ensure_embedder(self, model_name: str):
|
||||
if self._embedder is not None:
|
||||
return self._embedder
|
||||
with self._embedder_lock:
|
||||
if self._embedder is None:
|
||||
from .embedder import CropEmbedder
|
||||
self._embedder = CropEmbedder(model_name, self.cfg.embed_dtype)
|
||||
return self._embedder
|
||||
|
||||
def _process(self, job: dict):
|
||||
self._bump(active=1)
|
||||
try:
|
||||
@@ -126,8 +145,31 @@ class Worker:
|
||||
else:
|
||||
frames = [(None, media.load_image(data))]
|
||||
|
||||
# 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)
|
||||
|
||||
regions = []
|
||||
ev = self.cfg.ccip_model or "ccip-default"
|
||||
ccip_ev = self.cfg.ccip_model or "ccip-default"
|
||||
dv = f"person-{self.cfg.detector_level}"
|
||||
for t, frame in frames:
|
||||
figs = models.detect_figures(frame, self.cfg.detector_level)
|
||||
@@ -137,17 +179,29 @@ class Worker:
|
||||
crop = crop_region(frame, bbox)
|
||||
if crop is None:
|
||||
continue
|
||||
vec = models.ccip_vector(crop, self.cfg.ccip_model or None)
|
||||
regions.append({
|
||||
"kind": "figure",
|
||||
"bbox": list(bbox),
|
||||
"frame_time": t,
|
||||
"score": score,
|
||||
"ccip_embedding": vec,
|
||||
"embedding_version": ev,
|
||||
"detector_version": dv,
|
||||
})
|
||||
self.client.submit(job["job_id"], regions, ["figure", "face"])
|
||||
if want_ccip:
|
||||
regions.append({
|
||||
"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,
|
||||
})
|
||||
if want_siglip:
|
||||
regions.append({
|
||||
"kind": "concept",
|
||||
"bbox": list(bbox),
|
||||
"frame_time": t,
|
||||
"score": score,
|
||||
"siglip_embedding": embedder.embed(crop),
|
||||
"embedding_version": embed_version,
|
||||
"detector_version": dv,
|
||||
})
|
||||
self.client.submit(job["job_id"], regions, replace_kinds)
|
||||
self._bump(processed=1)
|
||||
except Exception as exc: # noqa: BLE001 — report + move on
|
||||
self._bump(errors=1)
|
||||
|
||||
@@ -3,6 +3,10 @@ dghs-imgutils>=0.4
|
||||
# GPU inference for the ONNX models. Swap to onnxruntime (CPU) for a slow
|
||||
# server-side fallback run.
|
||||
onnxruntime-gpu
|
||||
# The crop EMBEDDER (concept bag). torch is installed separately in the
|
||||
# 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
|
||||
# Control surface + HTTP.
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
|
||||
Reference in New Issue
Block a user