diff --git a/agent/fc_agent/worker.py b/agent/fc_agent/worker.py index 9c8db2d..2601d7e 100644 --- a/agent/fc_agent/worker.py +++ b/agent/fc_agent/worker.py @@ -265,6 +265,8 @@ class Worker: self._embedder = None self._embedder_lock = threading.Lock() self._proposers = None + self._proposers_sig = None # detector-config signature the current + # proposers were built for (#134) self._proposers_lock = threading.Lock() # --- held-lease bookkeeping -------------------------------------------- @@ -796,14 +798,52 @@ class Worker: self._embedder = CropEmbedder(model_name, self.cfg.embed_dtype) return self._embedder - def _ensure_proposers(self): - if self._proposers is not None: - return self._proposers + def _effective_cfg(self, det: dict | None): + """The detector config for a job: the LEASE-ANNOUNCED block (the DB/UI + source of truth, #134) overlaid on the env cfg, or the env cfg unchanged + when the server announced nothing (bootstrap / older backend). An enabled + toggle off maps to empty weights — the 'weights == "" → proposer skipped' + contract in detectors.py. detector_level + ccip_model stay env (separate + settings, not part of the crop-proposer config).""" + if not det: + return self.cfg + + def _w(p: str) -> str: + slot = det.get(p) or {} + return (slot.get("weights") or "") if slot.get("enabled", True) else "" + + from dataclasses import replace + return replace( + self.cfg, + person_weights=_w("person"), + person_conf=float(det["person"]["conf"]), + anatomy_weights=_w("anatomy"), + anatomy_conf=float(det["anatomy"]["conf"]), + panel_weights=_w("panel"), + panel_conf=float(det["panel"]["conf"]), + max_figures=int(det["max_figures"]), + max_components=int(det["max_components"]), + max_panels=int(det["max_panels"]), + max_regions=int(det["max_regions"]), + dedupe_iou=float(det["dedupe_iou"]), + ) + + def _proposers_for(self, eff): + """Proposers built from the effective detector config, rebuilt ONLY when + that config changes — so an operator's UI edit takes effect on the next + lease with no restart, and steady-state reuses the loaded YOLO models.""" + sig = ( + eff.person_weights, eff.person_conf, + eff.anatomy_weights, eff.anatomy_conf, + eff.panel_weights, eff.panel_conf, + eff.max_figures, eff.max_components, eff.max_panels, + ) with self._proposers_lock: - if self._proposers is None: + if self._proposers is None or self._proposers_sig != sig: from .detectors import Proposers - self._proposers = Proposers(self.cfg) - return self._proposers + self._proposers = Proposers(eff) + self._proposers_sig = sig + return self._proposers def _consume(self, job: dict, frames: list, stop_evt: threading.Event) -> bool: """Detect + embed the decoded frames and submit the result. Returns True @@ -856,7 +896,8 @@ class Worker: else ["figure", "face", "concept", "panel"] ) embedder = self._ensure_embedder(model_name) if want_siglip else None - proposers = self._ensure_proposers() + eff = self._effective_cfg(job.get("detectors")) + proposers = self._proposers_for(eff) regions = [] ccip_ev = self.cfg.ccip_model or "ccip-default" @@ -922,7 +963,7 @@ class Worker: # we never SigLIP the same region twice — saves GPU and a # slot against max_regions. High-IoU + kind-aware, so # intentional nested crops (figure ⊃ head) survive. - pending = dedupe_crops(pending, self.cfg.dedupe_iou) + pending = dedupe_crops(pending, eff.dedupe_iou) vecs = embedder.embed_batch([c for c, _ in pending]) for (_c, tmpl), vec in zip(pending, vecs, strict=True): tmpl["siglip_embedding"] = vec @@ -932,7 +973,7 @@ class Worker: # mp4, 64 sampled frames × ~32 regions) would otherwise burn # ~38s of GPU across every frame before the submit is even # truncated. Bounds the WORK, not just the POST body. - if len(regions) >= self.cfg.max_regions: + if len(regions) >= eff.max_regions: break # A Stop mid-frame-loop leaves partial regions — don't submit those; diff --git a/backend/app/api/gpu.py b/backend/app/api/gpu.py index c6e0cd8..44a7aac 100644 --- a/backend/app/api/gpu.py +++ b/backend/app/api/gpu.py @@ -269,6 +269,33 @@ async def lease(): ).scalars() } if ids else {} await session.commit() + # Crop-proposer config, announced FROM THE SETTING like embed_model_name + # (#134): the agent builds its detectors from this, rebuilding live when + # it changes — so tuning is a DB/UI edit, never an agent restart. Same + # block for every job in the batch (it's global), built once. An enabled + # toggle off is carried through so the agent skips that proposer. + detectors = { + "person": { + "enabled": ml.detector_person_enabled, + "weights": ml.detector_person_weights, + "conf": ml.detector_person_conf, + }, + "anatomy": { + "enabled": ml.detector_anatomy_enabled, + "weights": ml.detector_anatomy_weights, + "conf": ml.detector_anatomy_conf, + }, + "panel": { + "enabled": ml.detector_panel_enabled, + "weights": ml.detector_panel_weights, + "conf": ml.detector_panel_conf, + }, + "max_figures": ml.detector_max_figures, + "max_components": ml.detector_max_components, + "max_panels": ml.detector_max_panels, + "max_regions": ml.detector_max_regions, + "dedupe_iou": ml.detector_dedupe_iou, + } out = [] for j in jobs: img = imgs.get(j.image_record_id) @@ -290,6 +317,7 @@ async def lease(): # re-embed, never an agent change. "embed_model_name": ml.embedder_model_name, "embed_version": ml.embedder_model_version, + "detectors": detectors, }) return jsonify({"jobs": out}) diff --git a/tests/test_api_gpu.py b/tests/test_api_gpu.py index e476c6b..25f0a7b 100644 --- a/tests/test_api_gpu.py +++ b/tests/test_api_gpu.py @@ -105,6 +105,30 @@ async def test_lease_announces_embed_model_then_submit_embedding(client, db): assert img.siglip_embedding is not None and len(list(img.siglip_embedding)) == 1152 +@pytest.mark.asyncio +async def test_lease_announces_detector_config(client, db): + # #134: the lease carries the crop-proposer config from MLSettings, so the + # agent builds its detectors from the DB/UI (no restart). Defaults are all-on + # with the pinned working weights. + img = await _img(db, "c" * 64) + await GpuJobService(db).enqueue(img.id, "embed") + await db.commit() + token = (await (await client.post("/api/gpu/token/rotate")).get_json())["token"] + hdr = {"Authorization": f"Bearer {token}"} + + leased = await client.post( + "/api/gpu/jobs/lease", json={"agent_id": "a1", "batch_size": 5}, headers=hdr, + ) + det = (await leased.get_json())["jobs"][0]["detectors"] + assert det["person"]["enabled"] + assert det["anatomy"]["enabled"] + assert det["panel"]["enabled"] + assert "yolov11m_aa22" in det["anatomy"]["weights"] # booru_yolo default + assert det["panel"]["weights"].endswith("::best.pt") # mosesb default + assert det["max_regions"] == 128 + assert det["dedupe_iou"] == 0.85 + + @pytest.mark.asyncio async def test_submit_with_stale_lease_is_409(client, db): img = await _img(db, "b" * 64)