98b2ac90dd
Consolidate genuine duplication in agent/fc_agent into single-source helpers (behavior-preserving; DRY Pass process #594): worker.py - _fail(jid, image_id, exc, verb) — 4 terminal "fail this job" blocks (downloader HTTP-fault + decode, consumer non-transient + generic). - _release(job_ids) (was _release_owned) — the one lease hand-back path; 6 inline release([jid])+unhold sites now route through it. - _stopped(stop_evt) + _abort_if_stopped(jid, stop_evt) — 4 stop-check -and-release blocks and every bare stop-check. - _timed(stage) contextmanager — ~8 monotonic()/_record() timing pairs; records only on clean exit, matching the old skip-on-raise behavior. - _ewma(prev, x, alpha) module fn — 3 EWMA updates in the autoscaler. client.py - _submit(path, payload) — submit / submit_embedding (retrying session). - _post_quiet(path, payload) — heartbeat / fail / release fire-and-forget. detectors.py - Proposers._top(detector, image, cap) — merges components() and panels(). config.py - _bool_env(name, default) — auto_start / auto_scale env parsing. Left alone (recorded): the xyxy→norm-xywh conversion duplicated across models.py/detectors.py (2 copies, independent wrapper modules — sharing would couple them), and the _ensure_embedder/_ensure_proposers pair (same lock shape, different concepts). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
219 lines
9.5 KiB
Python
219 lines
9.5 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
|
||
import types
|
||
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
|
||
|
||
|
||
def dedupe_crops(pending, iou_thresh: float = 0.85):
|
||
"""Greedy high-IoU dedupe over a list of (crop, region_template) pairs, run
|
||
just before the batched SigLIP embed so we never embed the same region twice.
|
||
|
||
Figure boxes are already NMS-merged and each YOLO self-NMSes, but the combined
|
||
per-frame pile (figure→concept ∪ anatomy component→concept ∪ panel) can still
|
||
carry genuine near-duplicates across proposers — e.g. a figure box that nearly
|
||
coincides with an anatomy component on a solo bust, or overlapping booru head
|
||
classes on one head. Those embed the same region twice, wasting GPU and a slot
|
||
against max_regions.
|
||
|
||
Boxes are compared ONLY within the same output kind and dropped when they
|
||
overlap at >= iou_thresh, keeping the highest-scoring one. The HIGH default
|
||
threshold is deliberate: it collapses only true near-identical boxes while
|
||
preserving intentional nested crops across scopes (a whole figure vs a small
|
||
head component sit well below it) and distinct kinds (concept vs panel). A
|
||
value >= 1.0 effectively disables it (nothing but an exact box matches)."""
|
||
kept = []
|
||
kept_boxes: dict = {} # kind -> [bbox, ...] already kept
|
||
for crop, tmpl in sorted(
|
||
pending, key=lambda p: p[1].get("score") or 0.0, reverse=True
|
||
):
|
||
bb = tmpl.get("bbox")
|
||
prior = kept_boxes.setdefault(tmpl.get("kind"), [])
|
||
if bb is not None and any(_iou(bb, kb) >= iou_thresh for kb in prior):
|
||
continue
|
||
prior.append(bb)
|
||
kept.append((crop, tmpl))
|
||
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)
|
||
# Disable ultralytics' load-time Conv+BN fusion. AutoBackend fuses
|
||
# the graph on the first predict; some checkpoints (yolo11n, the
|
||
# comic-panel model) crash that step with "'Conv' object has no
|
||
# attribute 'bn'" (a partially-fused / version-mismatched graph),
|
||
# which silently disabled those proposers (operator-flagged
|
||
# 2026-07-01). Unfused inference is correct — only marginally
|
||
# slower — and this is robust across ultralytics versions; if a
|
||
# future version ignores the override, the detect() guard below
|
||
# still self-disables the proposer instead of spamming per image.
|
||
inner = getattr(self._model, "model", None)
|
||
if inner is not None:
|
||
inner.fuse = types.MethodType(lambda self, *a, **k: self, inner)
|
||
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
|
||
# Permanently self-disable on the FIRST inference failure rather than
|
||
# re-throwing (and re-logging) on every image forever — an unfixable
|
||
# model fault degrades to "this proposer is off", logged once.
|
||
log.warning("detector %s disabled (inference failed): %s", self.name, exc)
|
||
self._ok = False
|
||
self._model = None
|
||
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
|
||
|
||
@staticmethod
|
||
def _top(detector, image, cap: int):
|
||
"""Top-`cap` detections by score from an optional proposer (None → the
|
||
proposer is off → []). Shared by the anatomy + panel proposers, which
|
||
differ only in which detector and which cap."""
|
||
if detector is None:
|
||
return []
|
||
return sorted(detector.detect(image), key=lambda b: b[1], reverse=True)[:cap]
|
||
|
||
def components(self, image):
|
||
return self._top(self._anatomy, image, self.cfg.max_components)
|
||
|
||
def panels(self, image):
|
||
return self._top(self._panel, image, self.cfg.max_panels)
|