d5f29f7056
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
164 lines
6.3 KiB
Python
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]
|