c587ac667c
Three safety/robustness fixes from the operator's run logs: - Cap figures per frame (MAX_FIGURES, default 8) like components/panels already are. Uncapped, a huge/busy image yielded hundreds of figure boxes → hundreds of per-figure CCIP calls + crops → a 38s job AND a submit too big to accept (image 81602 looped on 413). This is the acute fix. - Global per-JOB backstop (MAX_REGIONS, default 128): if total regions still exceed the cap (long video), keep the highest-scoring and log the drop, so a submit body can never blow past curator's limit. - Stale "active" meter: stop() now resets _active to 0 (no slots remain, so the meter must read 0 at once), and _bump clamps at 0 so a slot finishing after the reset can't drive it negative. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
167 lines
6.6 KiB
Python
167 lines
6.6 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)],
|
|
capped to the highest-scoring max_figures. Uncapped, a busy/huge image
|
|
(many characters) yields hundreds of boxes → hundreds of per-figure CCIP
|
|
calls + crops → a 30s+ job and an oversized submit (operator-flagged)."""
|
|
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)[: self.cfg.max_figures] # nms_merge is score-desc
|
|
|
|
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]
|