From ab362bc79cd31095048e928544fd0e14402cb15a Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Sun, 5 Jul 2026 19:51:55 -0400
Subject: [PATCH] =?UTF-8?q?feat(ml):=20Settings=20=E2=86=92=20Tagging=20'C?=
=?UTF-8?q?rop=20proposers'=20card=20(#134=20step=203)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Exposes the detector config (per-proposer enable + weights + confidence, caps,
dedupe IoU) in Settings → Tagging, backed by MLSettings via /api/ml/settings.
ml_admin adds the detector fields to _EDITABLE + GET payload + validation (conf
0..1, caps >=1, IoU 0..1). New CropProposersCard.vue (mirrors HeadsCard) with
working defaults pre-filled, per-field live-save (no restart — the agent picks
changes up on its next lease), weights-format help, switch-revert on error.
Closes milestone #134: all three proposers are on out-of-the-box and tunable in
the UI. Test: detector defaults GET + patch round-trip + range validation.
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
---
backend/app/api/ml_admin.py | 35 ++++
.../components/settings/CropProposersCard.vue | 151 ++++++++++++++++++
.../components/settings/MaintenancePanel.vue | 2 +
tests/test_api_ml_admin.py | 29 ++++
4 files changed, 217 insertions(+)
create mode 100644 frontend/src/components/settings/CropProposersCard.vue
diff --git a/backend/app/api/ml_admin.py b/backend/app/api/ml_admin.py
index 62fbc26..c146b70 100644
--- a/backend/app/api/ml_admin.py
+++ b/backend/app/api/ml_admin.py
@@ -8,6 +8,26 @@ from ..models import MLSettings
ml_admin_bp = Blueprint("ml_admin", __name__, url_prefix="/api/ml")
+# Crop-proposer / detector config (#134). Announced to the GPU agent in the lease
+# → tunable here with no restart. weights = ultralytics name | URL | hf_repo::file
+# (empty, or enabled off, skips that proposer).
+_DETECTOR_FIELDS = (
+ "detector_person_enabled",
+ "detector_person_weights",
+ "detector_person_conf",
+ "detector_anatomy_enabled",
+ "detector_anatomy_weights",
+ "detector_anatomy_conf",
+ "detector_panel_enabled",
+ "detector_panel_weights",
+ "detector_panel_conf",
+ "detector_max_figures",
+ "detector_max_components",
+ "detector_max_panels",
+ "detector_max_regions",
+ "detector_dedupe_iou",
+)
+
_EDITABLE = (
"cpu_embed_enabled",
"video_frame_interval_seconds",
@@ -21,6 +41,7 @@ _EDITABLE = (
"ccip_auto_apply_threshold",
"embedder_model_name",
"embedder_model_version",
+ *_DETECTOR_FIELDS,
)
@@ -76,6 +97,7 @@ async def get_settings():
"ccip_auto_apply_enabled": s.ccip_auto_apply_enabled,
"ccip_auto_apply_threshold": s.ccip_auto_apply_threshold,
"embedder_model_name": s.embedder_model_name,
+ **{f: getattr(s, f) for f in _DETECTOR_FIELDS},
}
)
@@ -133,6 +155,19 @@ def _validate(p: dict) -> str | None:
for key in ("embedder_model_name", "embedder_model_version"):
if not str(p[key]).strip():
return f"{key} must not be empty"
+ # Crop proposers (#134). Weights may be empty (that proposer is just off);
+ # confidences are probabilities; caps are positive counts; IoU is [0,1].
+ for key in ("detector_person_conf", "detector_anatomy_conf", "detector_panel_conf"):
+ if not (0.0 <= float(p[key]) <= 1.0):
+ return f"{key} must be between 0 and 1"
+ for key in (
+ "detector_max_figures", "detector_max_components",
+ "detector_max_panels", "detector_max_regions",
+ ):
+ if int(p[key]) < 1:
+ return f"{key} must be >= 1"
+ if not (0.0 <= float(p["detector_dedupe_iou"]) <= 1.0):
+ return "detector_dedupe_iou must be between 0 and 1"
return None
diff --git a/frontend/src/components/settings/CropProposersCard.vue b/frontend/src/components/settings/CropProposersCard.vue
new file mode 100644
index 0000000..46edd54
--- /dev/null
+++ b/frontend/src/components/settings/CropProposersCard.vue
@@ -0,0 +1,151 @@
+
+
+
+ Each proposer finds regions that the crop → SigLIP bag scores alongside the
+ whole image; the best-scoring region is what grounds a tag. Weights accept
+ an ultralytics name (yolo11n.pt), a
+ URL, or hf_repo::file. Edits reach the GPU
+ agent on its next lease — no restart. After turning a proposer on, re-process
+ the library so existing images gain its regions.
+
+
+
+
+ {{ p.icon }}
+ {{ p.label }}
+ saveToggle(p, v)"
+ />
+
+
{{ p.help }}
+
+
+
+
+
+
+
+
Caps
+
+ Bound how many crops each image can produce — protects GPU time and
+ storage. Dedupe IoU drops near-duplicate crops before embedding.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/settings/MaintenancePanel.vue b/frontend/src/components/settings/MaintenancePanel.vue
index 26cae1f..dfc5693 100644
--- a/frontend/src/components/settings/MaintenancePanel.vue
+++ b/frontend/src/components/settings/MaintenancePanel.vue
@@ -36,6 +36,7 @@
@@ -75,6 +76,7 @@ import MissingFileRepairCard from './MissingFileRepairCard.vue'
import GpuTriageCard from './GpuTriageCard.vue'
import DbMaintenanceCard from './DbMaintenanceCard.vue'
import MLThresholdSliders from './MLThresholdSliders.vue'
+import CropProposersCard from './CropProposersCard.vue'
import HeadsCard from './HeadsCard.vue'
import GpuAgentCard from './GpuAgentCard.vue'
import AliasTable from './AliasTable.vue'
diff --git a/tests/test_api_ml_admin.py b/tests/test_api_ml_admin.py
index 5602f8c..33faf09 100644
--- a/tests/test_api_ml_admin.py
+++ b/tests/test_api_ml_admin.py
@@ -52,6 +52,35 @@ async def test_embedder_model_default_settable_and_empty_rejected(client):
assert bad.status_code == 400
+@pytest.mark.asyncio
+async def test_detector_settings_defaults_patch_and_validation(client):
+ # #134: crop-proposer config is exposed + editable here (announced to the
+ # agent via the lease). Defaults are all-on with the pinned weights.
+ body = await (await client.get("/api/ml/settings")).get_json()
+ assert body["detector_person_enabled"] is True
+ assert body["detector_anatomy_enabled"] is True
+ assert "yolov11m_aa22" in body["detector_anatomy_weights"]
+ assert body["detector_panel_weights"].endswith("::best.pt")
+ assert body["detector_max_regions"] == 128
+
+ ok = await client.patch("/api/ml/settings", json={
+ "detector_anatomy_enabled": False,
+ "detector_person_conf": 0.5,
+ "detector_max_panels": 4,
+ })
+ assert ok.status_code == 200
+ out = await ok.get_json()
+ assert out["detector_anatomy_enabled"] is False
+ assert out["detector_person_conf"] == 0.5
+ assert out["detector_max_panels"] == 4
+
+ # Out-of-range confidence + non-positive cap are rejected.
+ assert (await client.patch(
+ "/api/ml/settings", json={"detector_panel_conf": 1.5})).status_code == 400
+ assert (await client.patch(
+ "/api/ml/settings", json={"detector_max_regions": 0})).status_code == 400
+
+
@pytest.mark.asyncio
async def test_embedder_models_list(client):
# #1203: the dropdown reads the supported-model list from the server.