feat(ml): lease announces detector config; agent builds proposers from it live (#134 step 2)
The GPU lease now carries the crop-proposer config from MLSettings in a per-job 'detectors' block (same pattern as embed_model_name). The agent's worker builds its Proposers from the announced config via _effective_cfg (lease block overlaid on env) + _proposers_for (rebuilds only when a config signature changes) — so an operator's UI edit takes effect on the next lease with NO restart, and env is now just the bootstrap fallback until the server announces. enabled-off maps to empty weights (proposer skipped); dedupe_iou + max_regions also come from the effective cfg. Test: lease announces the detectors block with the seeded default weights. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
@@ -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,13 +798,51 @@ 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)
|
||||
self._proposers = Proposers(eff)
|
||||
self._proposers_sig = sig
|
||||
return self._proposers
|
||||
|
||||
def _consume(self, job: dict, frames: list, stop_evt: threading.Event) -> bool:
|
||||
@@ -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;
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user