afef95a87d
The agent workload is download-bound (download 400–5462ms vs GPU ~300–600ms), so the old N-slot serial chain (each slot: lease→download→decode→GPU→submit) left the fast GPU idle during every download. Rearchitect worker.py into a producer/consumer pipeline: downloader pool (autoscaled by BUFFER OCCUPANCY) → bounded queue → 1–2 GPU consumers (detect+embed→submit) - Downloaders are I/O-bound → many overlap; the autoscaler now tunes DOWNLOADER count by buffer fill (empty = GPU starving → add; full = outpacing GPU → add a 2nd consumer if it has util/VRAM headroom and lifts throughput, else trim). - Bounded buffer (12) = backpressure: a full buffer blocks downloaders, capping RAM + lease look-ahead. VRAM pressure sheds a consumer immediately. - Heartbeat thread keeps every held lease alive (buffered jobs wait on the GPU; curator's 180s TTL would otherwise reclaim them mid-buffer). - Preserves all resilience: lease exp-backoff, submit-path retry (#169), release-on-stop, region caps + video early-exit (#171). Stop drains BOTH pools and releases every held lease at once (single held-set as source of truth). - Consumers SHARE one embedder + proposers instance (a 2nd consumer adds concurrent inference, not N× VRAM — bounds the VRAM creep seen with N slots). - UI reworked for the pipeline: tiles show downloaders · buffer · on-GPU · processed · errors, a buffer-occupancy meter, and a consumers/waited-out line; the dial now tunes downloaders. Build marker 2026-07-01.1. Also fix the operator-flagged detector warning: yolo11n + the comic-panel model threw "'Conv' object has no attribute 'bn'" on every image (ultralytics' load- time Conv+BN fusion on a version-mismatched graph), silently disabling 2 of 3 crop proposers and spamming the log per image. Disable that fusion (unfused inference is correct, marginally slower) and permanently self-disable a proposer on the first inference failure instead of re-throwing forever. Refs milestone 122. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
185 lines
7.8 KiB
Python
185 lines
7.8 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
|
|
|
|
|
|
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
|
|
|
|
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]
|