Tier-3 frontend DRY for the ML settings cards, plus the F-D2 clamp bug and the F-D3 card misgrouping. New primitives (components/common + composables): - <SettingToggleRow> — the accent-icon + .fc-section-h label + right-aligned switch row (HeadsCard x3, CropProposersCard). iconColor prop absorbs the on/off dim. - <SettingNumberField> — compact numeric field that CLAMPS to [min,max] on commit. This fixes F-D2: HeadsCard/CropProposersCard previously sent Number(raw) straight to the API, so an out-of-range threshold bounced off the 400 validator (only TranslationCard clamped). density prop for the grid cards. - useSettingSave(patchFn) — the busy + patch + toast + revert-on-failure flow each card hand-rolled (HeadsCard x6 handlers, CropProposersCard, MLBackfillCard, VideoEmbeddingCard). Returns ok/false for the optimistic-switch revert. Adopted in HeadsCard, CropProposersCard, MLBackfillCard (handler only — its plain labelled switch is a different affordance), VideoEmbeddingCard. F-D3: MLThresholdSliders.vue actually rendered a "Video embedding" (frame- sampling) card but sat under "Tagging → Suggestion thresholds". Renamed it VideoEmbeddingCard.vue and moved it to the "GPU agent & embeddings" section. Left deliberately (over-DRY guard): TranslationCard uses an inline error ALERT (not a toast), already clamps its confidence with a NaN fallback, and lives on the ImportStore — a genuinely different save pattern, so forcing it onto useSettingSave would change its UX. Behaviour-preserving refactor; CI has no Vue type-check so this needs a live UI pass (toggles persist + revert on failure, thresholds clamp on blur, video card now under Embeddings). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NsmJSQxnNxGgtM5Yz4GAqi
138 lines
5.3 KiB
Vue
138 lines
5.3 KiB
Vue
<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">
|
|
<SettingToggleRow
|
|
v-model="p.on" :loading="busy" :icon="p.icon"
|
|
:icon-color="p.on ? 'accent' : null" :label="p.label"
|
|
@change="v => saveToggle(p, v)"
|
|
/>
|
|
<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="saveField({ [`detector_${p.key}_weights`]: p.weights })"
|
|
/>
|
|
<SettingNumberField
|
|
v-model="p.conf" label="Confidence" :min="0" :max="1" :step="0.05"
|
|
max-width="140px" :disabled="busy || !p.on"
|
|
@change="saveField({ [`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;">
|
|
<SettingNumberField
|
|
v-for="c in caps" :key="c.key"
|
|
v-model="c.val" :label="c.label"
|
|
:min="c.min" :max="c.max" :step="c.step || 1"
|
|
max-width="165px" :disabled="busy"
|
|
@change="saveField({ [c.key]: Number(c.val) })"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</MaintenanceTile>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { onMounted, ref } from 'vue'
|
|
|
|
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
|
import SettingNumberField from '../common/SettingNumberField.vue'
|
|
import SettingToggleRow from '../common/SettingToggleRow.vue'
|
|
import { useSettingSave } from '../../composables/useSettingSave.js'
|
|
import { useMLStore } from '../../stores/ml.js'
|
|
|
|
const mlSettings = useMLStore()
|
|
const { busy, save } = useSettingSave(mlSettings.patchSettings)
|
|
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 }))
|
|
})
|
|
|
|
// Field @change → persist with a "Saved" confirmation. SettingNumberField has
|
|
// already clamped numeric values to their [min,max] before this fires.
|
|
function saveField(patch) {
|
|
save(patch, { successMessage: 'Saved' })
|
|
}
|
|
|
|
async function saveToggle(p, v) {
|
|
// Revert the switch on failure so it never lies about the persisted state.
|
|
const ok = await save({ [`detector_${p.key}_enabled`]: !!v }, { successMessage: 'Saved' })
|
|
if (!ok) p.on = !v
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.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>
|