Files
FabledCurator/agent/fc_agent/detectors.py
T
bvandeusen d5f29f7056
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m25s
feat(agent): crop proposers — booru_yolo anatomy + COCO person + comic panels (#1202)
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-30 15:27:26 -04:00

164 lines
6.3 KiB
Python

"""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]