feat(ml): Settings → Tagging 'Crop proposers' card (#134 step 3)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 40s
CI / integration (push) Successful in 3m39s

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
2026-07-05 19:51:55 -04:00
parent a4df279343
commit ab362bc79c
4 changed files with 217 additions and 0 deletions
+35
View File
@@ -8,6 +8,26 @@ from ..models import MLSettings
ml_admin_bp = Blueprint("ml_admin", __name__, url_prefix="/api/ml") 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 = ( _EDITABLE = (
"cpu_embed_enabled", "cpu_embed_enabled",
"video_frame_interval_seconds", "video_frame_interval_seconds",
@@ -21,6 +41,7 @@ _EDITABLE = (
"ccip_auto_apply_threshold", "ccip_auto_apply_threshold",
"embedder_model_name", "embedder_model_name",
"embedder_model_version", "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_enabled": s.ccip_auto_apply_enabled,
"ccip_auto_apply_threshold": s.ccip_auto_apply_threshold, "ccip_auto_apply_threshold": s.ccip_auto_apply_threshold,
"embedder_model_name": s.embedder_model_name, "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"): for key in ("embedder_model_name", "embedder_model_version"):
if not str(p[key]).strip(): if not str(p[key]).strip():
return f"{key} must not be empty" 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 return None
@@ -0,0 +1,151 @@
<template>
<MaintenanceTile
icon="mdi-crop"
title="Crop proposers (where to crop)"
blurb="Detectors that decide WHERE to crop each image before embedding — general figures, anime/furry anatomy, and comic panels — so a tag can ground to a region, not just the whole image."
:open="false"
>
<p class="fc-muted text-body-2 mb-4">
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 <strong>ultralytics name</strong> (<code>yolo11n.pt</code>), a
<strong>URL</strong>, or <strong>hf_repo::file</strong>. 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>
<div v-for="p in proposers" :key="p.key" class="fc-proposer">
<div class="d-flex align-center mb-1" style="gap: 10px;">
<v-icon size="18" :color="p.on ? 'accent' : undefined">{{ p.icon }}</v-icon>
<span class="fc-section-h">{{ p.label }}</span>
<v-switch
v-model="p.on" :loading="busy" hide-details density="compact"
color="success" class="ml-auto"
@update:model-value="v => saveToggle(p, v)"
/>
</div>
<p class="fc-muted text-body-2 mb-2">{{ p.help }}</p>
<div class="d-flex flex-wrap mb-4" style="gap: 12px;">
<v-text-field
v-model="p.weights" label="Weights" density="compact" hide-details
style="min-width: 300px; flex: 1;" :disabled="busy || !p.on"
placeholder="name | URL | hf_repo::file"
@change="save({ [`detector_${p.key}_weights`]: p.weights })"
/>
<v-text-field
v-model.number="p.conf" label="Confidence" type="number"
min="0" max="1" step="0.05" density="compact" hide-details
style="max-width: 140px;" :disabled="busy || !p.on"
@change="save({ [`detector_${p.key}_conf`]: Number(p.conf) })"
/>
</div>
</div>
<div class="fc-caps">
<div class="fc-section-h mb-1">Caps</div>
<p class="fc-muted text-body-2 mb-3">
Bound how many crops each image can produce — protects GPU time and
storage. Dedupe IoU drops near-duplicate crops before embedding.
</p>
<div class="d-flex flex-wrap" style="gap: 12px;">
<v-text-field
v-for="c in caps" :key="c.key"
v-model.number="c.val" :label="c.label" type="number"
:min="c.min" :max="c.max" :step="c.step || 1" density="compact"
hide-details style="max-width: 165px;" :disabled="busy"
@change="save({ [c.key]: Number(c.val) })"
/>
</div>
</div>
</MaintenanceTile>
</template>
<script setup>
import { toast } from '../../utils/toast.js'
import { onMounted, ref } from 'vue'
import MaintenanceTile from '../common/MaintenanceTile.vue'
import { useMLStore } from '../../stores/ml.js'
const mlSettings = useMLStore()
const busy = ref(false)
const proposers = ref([])
const caps = ref([])
// Static copy per proposer; the mutable state (on/weights/conf) is seeded from
// settings on mount so the card reflects the live DB config (rule 26 — it works
// untouched).
const DEFS = [
{
key: 'person', label: 'Figures (person)', icon: 'mdi-account-outline',
help: 'General figure detector for the Western/realistic art the anime ' +
'detector misses → character identity + a whole-figure crop.',
},
{
key: 'anatomy', label: 'Anatomy (booru)', icon: 'mdi-dog-side',
help: 'Anime / furry / NSFW torso components (heads, busts, hips, …) → ' +
'localized concept crops.',
},
{
key: 'panel', label: 'Comic panels', icon: 'mdi-view-grid-outline',
help: 'Splits a comic page into its panels → one concept crop each.',
},
]
const CAP_DEFS = [
{ key: 'detector_max_figures', label: 'Max figures', min: 1 },
{ key: 'detector_max_components', label: 'Max components', min: 1 },
{ key: 'detector_max_panels', label: 'Max panels', min: 1 },
{ key: 'detector_max_regions', label: 'Max regions / job', min: 1 },
{ key: 'detector_dedupe_iou', label: 'Dedupe IoU', min: 0, max: 1, step: 0.05 },
]
onMounted(async () => {
try { await mlSettings.loadSettings() } catch { /* non-fatal — defaults show */ }
const s = mlSettings.settings || {}
proposers.value = DEFS.map(d => ({
...d,
on: !!s[`detector_${d.key}_enabled`],
weights: s[`detector_${d.key}_weights`] ?? '',
conf: s[`detector_${d.key}_conf`] ?? 0.3,
}))
caps.value = CAP_DEFS.map(c => ({ ...c, val: s[c.key] ?? 0 }))
})
async function save(patch, revert) {
busy.value = true
try {
await mlSettings.patchSettings(patch)
toast({ text: 'Saved', type: 'success' })
} catch (e) {
if (revert) revert()
toast({ text: `Could not save: ${e.message}`, type: 'error' })
} finally {
busy.value = false
}
}
function saveToggle (p, v) {
// Revert the switch on failure so it never lies about the persisted state.
save({ [`detector_${p.key}_enabled`]: !!v }, () => { p.on = !v })
}
</script>
<style scoped>
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-section-h {
font-size: 13px; font-weight: 700; letter-spacing: 0.03em;
text-transform: uppercase; color: rgb(var(--v-theme-on-surface));
}
.fc-proposer {
border-top: 1px solid rgb(var(--v-theme-surface-light)); padding-top: 14px;
}
.fc-proposer:first-of-type { border-top: none; padding-top: 0; }
.fc-caps {
border-top: 1px solid rgb(var(--v-theme-surface-light)); padding-top: 16px;
}
code {
font-family: 'JetBrains Mono', monospace; font-size: 0.85em;
background: rgb(var(--v-theme-surface-light)); padding: 1px 5px;
border-radius: 4px;
}
</style>
@@ -36,6 +36,7 @@
</p> </p>
<div class="fc-tile-stack"> <div class="fc-tile-stack">
<MLThresholdSliders /> <MLThresholdSliders />
<CropProposersCard />
<HeadsCard /> <HeadsCard />
<AliasTable /> <AliasTable />
</div> </div>
@@ -75,6 +76,7 @@ import MissingFileRepairCard from './MissingFileRepairCard.vue'
import GpuTriageCard from './GpuTriageCard.vue' import GpuTriageCard from './GpuTriageCard.vue'
import DbMaintenanceCard from './DbMaintenanceCard.vue' import DbMaintenanceCard from './DbMaintenanceCard.vue'
import MLThresholdSliders from './MLThresholdSliders.vue' import MLThresholdSliders from './MLThresholdSliders.vue'
import CropProposersCard from './CropProposersCard.vue'
import HeadsCard from './HeadsCard.vue' import HeadsCard from './HeadsCard.vue'
import GpuAgentCard from './GpuAgentCard.vue' import GpuAgentCard from './GpuAgentCard.vue'
import AliasTable from './AliasTable.vue' import AliasTable from './AliasTable.vue'
+29
View File
@@ -52,6 +52,35 @@ async def test_embedder_model_default_settable_and_empty_rejected(client):
assert bad.status_code == 400 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 @pytest.mark.asyncio
async def test_embedder_models_list(client): async def test_embedder_models_list(client):
# #1203: the dropdown reads the supported-model list from the server. # #1203: the dropdown reads the supported-model list from the server.