bugfixes + added Training Stats

This commit is contained in:
Linrador 2026-05-02 13:02:48 +02:00
parent 973a07fef8
commit c905753f7a
3 changed files with 1396 additions and 676 deletions

View File

@ -2033,6 +2033,24 @@ export default function App() {
pausedByDisk: false,
})
const applyAutostartState = useCallback((data: unknown) => {
const d = (data ?? {}) as any
setAutostartState({
paused: Boolean(d?.paused),
pausedByUser: Boolean(d?.pausedByUser),
pausedByDisk: Boolean(d?.pausedByDisk),
})
}, [])
const loadAutostartState = useCallback(async () => {
const s = await apiJSON<AutostartState>('/api/autostart/state', {
cache: 'no-store' as any,
})
applyAutostartState(s)
}, [applyAutostartState])
useEffect(() => {
busyRef.current = busy
}, [busy])
@ -2714,6 +2732,7 @@ export default function App() {
if (!authed) return
let es: EventSource | null = null
let autostartEs: EventSource | null = null
let fallbackTimer: number | null = null
const stopFallbackPoll = () => {
@ -2758,15 +2777,6 @@ export default function App() {
}
}
const applyAutostartState = (data: unknown) => {
const d = (data ?? {}) as any
setAutostartState({
paused: Boolean(d?.paused),
pausedByUser: Boolean(d?.pausedByUser),
pausedByDisk: Boolean(d?.pausedByDisk),
})
}
const onDoneChanged = () => {
if (selectedTabRef.current !== 'finished') return
void loadDonePage(donePageRef.current, doneSortRef.current)
@ -2837,20 +2847,26 @@ export default function App() {
void loadDoneCount()
void loadTaskStateOnce()
void apiJSON<AutostartState>('/api/autostart/state', { cache: 'no-store' as any })
.then((s) => {
setAutostartState({
paused: Boolean(s?.paused),
pausedByUser: Boolean(s?.pausedByUser),
pausedByDisk: Boolean(s?.pausedByDisk),
})
})
.catch(() => {})
void loadAutostartState().catch(() => {})
es = new EventSource('/api/events/stream')
eventSourceRef.current = es
modelEventNamesRef.current = new Set()
autostartEs = new EventSource('/api/autostart/state/stream')
autostartEs.addEventListener('autostart', (ev) => {
try {
applyAutostartState(JSON.parse(String((ev as MessageEvent).data ?? 'null')))
} catch {
// ignore
}
})
autostartEs.onerror = () => {
// Falls der separate Stream ausfällt, wenigstens beim Fokus/Visibility wieder korrekt ziehen.
}
es.onopen = () => {
stopFallbackPoll()
void loadTaskStateOnce()
@ -2867,8 +2883,10 @@ export default function App() {
const onVis = () => {
if (document.hidden) return
void loadJobs()
void loadTaskStateOnce()
void loadAutostartState().catch(() => {})
if (selectedTabRef.current === 'finished') {
void loadDonePage(donePageRef.current, doneSortRef.current)
@ -2897,10 +2915,21 @@ export default function App() {
es.close()
}
if (autostartEs) {
autostartEs.close()
}
eventSourceRef.current = null
modelEventNamesRef.current = new Set()
}
}, [authed, loadJobs, loadDoneCount, loadPendingAutoStarts])
}, [
authed,
loadJobs,
loadDoneCount,
loadPendingAutoStarts,
applyAutostartState,
loadAutostartState,
])
useEffect(() => {
const desired = new Set<string>()
@ -3970,14 +3999,14 @@ export default function App() {
roomStatusByModelKey={roomStatusByModelKey}
pending={pendingWatchedRooms}
autostartState={autostartState}
onRefreshAutostartState={async () => {
onRefreshAutostartState={async (next?: AutostartState) => {
try {
const s = await apiJSON<AutostartState>('/api/autostart/state', { cache: 'no-store' as any })
setAutostartState({
paused: Boolean(s?.paused),
pausedByUser: Boolean(s?.pausedByUser),
pausedByDisk: Boolean(s?.pausedByDisk),
})
if (next) {
applyAutostartState(next)
return
}
await loadAutostartState()
} catch {
// ignore
}

View File

@ -32,7 +32,7 @@ type Props = {
jobs: RecordJob[]
pending?: PendingWatchedRoom[]
autostartState?: AutostartState
onRefreshAutostartState?: () => Promise<void> | void
onRefreshAutostartState?: (next?: AutostartState) => Promise<void> | void
modelsByKey?: Record<
string,
{
@ -644,9 +644,9 @@ export default function Downloads({
const [watchedBusy, setWatchedBusy] = useState(false)
const refreshWatchedState = useCallback(async () => {
const refreshWatchedState = useCallback(async (next?: AutostartState) => {
try {
await onRefreshAutostartState?.()
await onRefreshAutostartState?.(next)
} catch {
// ignore
}
@ -678,11 +678,22 @@ export default function Downloads({
setWatchedBusy(true)
try {
const res = await fetch('/api/autostart/pause', { method: 'POST' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
await refreshWatchedState()
} catch {
// ignore
const res = await fetch('/api/autostart/pause', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
cache: 'no-store' as any,
body: JSON.stringify({ paused: true }),
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
const data = await res.json().catch(() => null)
await refreshWatchedState(data ?? undefined)
} catch (e) {
console.warn('Autostart pausieren fehlgeschlagen:', e)
} finally {
setWatchedBusy(false)
}
@ -693,11 +704,20 @@ export default function Downloads({
setWatchedBusy(true)
try {
const res = await fetch('/api/autostart/resume', { method: 'POST' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
await refreshWatchedState()
} catch {
// ignore
const res = await fetch('/api/autostart/resume', {
method: 'POST',
cache: 'no-store' as any,
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
const data = await res.json().catch(() => null)
await refreshWatchedState(data ?? undefined)
} catch (e) {
console.warn('Autostart fortsetzen fehlgeschlagen:', e)
} finally {
setWatchedBusy(false)
}
@ -1578,8 +1598,8 @@ export default function Downloads({
}
leadingIcon={
watchedPaused
? <PauseIcon className="size-4 shrink-0" />
: <PlayIcon className="size-4 shrink-0" />
? <PlayIcon className="size-4 shrink-0" />
: <PauseIcon className="size-4 shrink-0" />
}
>
Autostart
@ -1652,8 +1672,8 @@ export default function Downloads({
}
leadingIcon={
watchedPaused
? <PauseIcon className="size-4 shrink-0" />
: <PlayIcon className="size-4 shrink-0" />
? <PlayIcon className="size-4 shrink-0" />
: <PauseIcon className="size-4 shrink-0" />
}
>
Autostart

View File

@ -7,6 +7,7 @@ import LoadingSpinner from './LoadingSpinner'
import { formatBytes, formatDuration } from './formatters'
import { TrashIcon } from '@heroicons/react/20/solid'
import { getSegmentLabelItem } from './Icons'
import Modal from './Modal'
type ScoredLabel = {
label: string
@ -111,6 +112,163 @@ type MagnifierState = {
imageY: number
}
type TrainingConfidence = {
score: number
level: 'none' | 'low' | 'mid' | 'high'
label: string
}
type TrainingLabelStat = {
label: string
count: number
confidence?: TrainingConfidence
}
type TrainingStats = {
feedbackCount: number
acceptedCount: number
correctedCount: number
sampleCount: number
boxCount: number
modelAvailable: boolean
confidence?: TrainingConfidence
labels: {
people: TrainingLabelStat[]
sexPositions: TrainingLabelStat[]
bodyParts: TrainingLabelStat[]
objects: TrainingLabelStat[]
clothing: TrainingLabelStat[]
}
}
function countPercent(count: number, total: number) {
if (!Number.isFinite(count) || !Number.isFinite(total) || total <= 0) return '0%'
return `${Math.round((count / total) * 100)}%`
}
function confidencePercent(confidence?: TrainingConfidence | null) {
const score = Number(confidence?.score)
if (!Number.isFinite(score)) return '0%'
return `${Math.round(clamp01(score) * 100)}%`
}
function confidenceLabel(confidence?: TrainingConfidence | null) {
return confidence?.label || 'Keine'
}
function confidencePillClass(confidence?: TrainingConfidence | null) {
switch (confidence?.level) {
case 'high':
return 'bg-emerald-50 text-emerald-800 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-400/30'
case 'mid':
return 'bg-yellow-50 text-yellow-900 ring-yellow-200 dark:bg-yellow-500/15 dark:text-yellow-100 dark:ring-yellow-400/30'
case 'low':
return 'bg-red-50 text-red-800 ring-red-200 dark:bg-red-500/15 dark:text-red-100 dark:ring-red-400/30'
default:
return 'bg-gray-50 text-gray-700 ring-gray-200 dark:bg-white/5 dark:text-gray-200 dark:ring-white/10'
}
}
function averageCategoryConfidence(values: TrainingLabelStat[]) {
const scores = values
.map((item) => Number(item.confidence?.score))
.filter((score) => Number.isFinite(score))
if (scores.length === 0) {
return undefined
}
const avg = scores.reduce((sum, score) => sum + clamp01(score), 0) / scores.length
return confidenceFromScore(avg)
}
function confidenceFromScore(score: number): TrainingConfidence {
const safeScore = clamp01(Number(score))
if (safeScore >= 0.75) {
return {
score: safeScore,
level: 'high',
label: 'Hoch',
}
}
if (safeScore >= 0.45) {
return {
score: safeScore,
level: 'mid',
label: 'Mittel',
}
}
if (safeScore > 0) {
return {
score: safeScore,
level: 'low',
label: 'Niedrig',
}
}
return {
score: 0,
level: 'none',
label: 'Keine',
}
}
function currentAnalysisConfidence(prediction?: TrainingPrediction | null): TrainingConfidence {
if (!prediction?.modelAvailable) {
return confidenceFromScore(0)
}
const scores: number[] = []
const addScore = (value: unknown) => {
const n = Number(value)
if (!Number.isFinite(n)) return
if (n <= 0) return
scores.push(clamp01(n))
}
// Scene-Modell: Sexposition
if (
prediction.sexPosition &&
prediction.sexPosition !== 'unknown'
) {
addScore(prediction.sexPositionScore)
}
// Detector: sichtbare Boxen sind der wichtigste Signalträger.
for (const box of prediction.boxes ?? []) {
addScore(box.score)
}
// Fallback, falls Labels Scores haben, aber keine Boxen vorhanden sind.
if ((prediction.boxes ?? []).length === 0) {
for (const item of prediction.bodyPartsPresent ?? []) {
addScore(item.score)
}
for (const item of prediction.objectsPresent ?? []) {
addScore(item.score)
}
for (const item of prediction.clothingPresent ?? []) {
addScore(item.score)
}
}
if (scores.length === 0) {
return confidenceFromScore(0)
}
const avg = scores.reduce((sum, score) => sum + score, 0) / scores.length
return confidenceFromScore(avg)
}
const emptyLabels: TrainingLabels = {
people: [],
sexPositions: ['unknown'],
@ -1152,6 +1310,432 @@ function CollapsibleLabelSection(props: {
)
}
function TrainingStatsList(props: {
title: string
description?: string
values: TrainingLabelStat[]
total: number
confidence?: TrainingConfidence
emptyText?: string
}) {
const sorted = [...props.values].sort((a, b) => b.count - a.count)
return (
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900/70">
<div className="border-b border-gray-200 bg-gray-50/80 px-4 py-3 dark:border-white/10 dark:bg-white/[0.03]">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
{props.title}
</div>
{props.description ? (
<div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{props.description}
</div>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
<span
className={[
'rounded-full px-2.5 py-1 text-xs font-bold ring-1',
confidencePillClass(props.confidence),
].join(' ')}
title={`Kategorie-Confidence: ${confidencePercent(props.confidence)}`}
>
{confidenceLabel(props.confidence)} · {confidencePercent(props.confidence)}
</span>
<span className="rounded-full bg-white px-2.5 py-1 text-xs font-semibold text-gray-700 ring-1 ring-gray-200 dark:bg-gray-950 dark:text-gray-200 dark:ring-white/10">
{sorted.length} Labels
</span>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-[11px] text-gray-500 dark:text-gray-400">
<span className="inline-flex items-center gap-1">
<span className="h-1.5 w-5 rounded-full bg-indigo-500" />
Häufigkeit
</span>
<span className="inline-flex items-center gap-1">
<span className="h-1.5 w-5 rounded-full bg-emerald-500" />
Confidence
</span>
</div>
</div>
{sorted.length === 0 ? (
<div className="flex min-h-40 items-center justify-center px-4 py-8 text-center">
<div>
<div className="text-sm font-medium text-gray-700 dark:text-gray-200">
Keine Labels gefunden
</div>
<div className="mt-1 max-w-sm text-xs text-gray-500 dark:text-gray-400">
{props.emptyText || 'Sobald Feedback für diese Kategorie gespeichert wurde, erscheinen hier die Werte.'}
</div>
</div>
</div>
) : (
<div className="divide-y divide-gray-100 dark:divide-white/10">
{sorted.map((item) => {
const labelItem = getSegmentLabelItem(item.label)
const Icon = labelItem.icon
const shareWidth = countPercent(item.count, props.total)
const confWidth = confidencePercent(item.confidence)
return (
<div
key={item.label}
className="px-4 py-3 transition hover:bg-gray-50 dark:hover:bg-white/[0.03]"
>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3">
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gray-100 ring-1 ring-gray-200 dark:bg-white/10 dark:ring-white/10">
<Icon
className="h-5 w-5 text-gray-600 dark:text-gray-300"
aria-hidden="true"
/>
</div>
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
{labelItem.text}
</div>
<div className="mt-0.5 truncate text-[11px] text-gray-500 dark:text-gray-400">
{item.label}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span
className={[
'rounded-full px-2 py-0.5 text-[11px] font-bold ring-1',
confidencePillClass(item.confidence),
].join(' ')}
title={`Confidence: ${confidencePercent(item.confidence)}`}
>
{confidenceLabel(item.confidence)}
</span>
<div className="min-w-10 text-right text-sm font-bold text-gray-900 dark:text-white">
{item.count}
</div>
</div>
</div>
<div className="mt-3 space-y-1.5">
<div>
<div className="mb-1 flex items-center justify-between text-[10px] text-gray-500 dark:text-gray-400">
<span>Häufigkeit in dieser Gruppe</span>
<span>{shareWidth}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
<div
className="h-full rounded-full bg-indigo-500 transition-all"
style={{ width: shareWidth }}
/>
</div>
</div>
<div>
<div className="mb-1 flex items-center justify-between text-[10px] text-gray-500 dark:text-gray-400">
<span>Daten-Confidence</span>
<span>{confWidth}</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
<div
className="h-full rounded-full bg-emerald-500 transition-all"
style={{ width: confWidth }}
/>
</div>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
)
}
type TrainingStatsTabKey =
| 'people'
| 'sexPositions'
| 'bodyParts'
| 'objects'
| 'clothing'
function TrainingStatsModal(props: {
open: boolean
onClose: () => void
stats: TrainingStats | null
loading: boolean
error: string | null
feedbackCount: number
requiredCount: number
}) {
const [activeTab, setActiveTab] = useState<TrainingStatsTabKey>('people')
const stats = props.stats
const acceptedCount = stats?.acceptedCount ?? 0
const correctedCount = stats?.correctedCount ?? 0
const totalFeedback = stats?.feedbackCount ?? props.feedbackCount
const boxCount = stats?.boxCount ?? 0
const sampleCount = stats?.sampleCount ?? 0
const overallConfidence = stats?.confidence
const tabItems: Array<{
key: TrainingStatsTabKey
title: string
description: string
values: TrainingLabelStat[]
total: number
confidence?: TrainingConfidence
}> = [
{
key: 'people',
title: 'Personen',
description: 'Personen- und Gender-Labels aus Boxen.',
values: stats?.labels.people ?? [],
total: Math.max(1, boxCount),
confidence: averageCategoryConfidence(stats?.labels.people ?? []),
},
{
key: 'sexPositions',
title: 'Sexpositionen',
description: 'Scene-Labels pro bewertetem Frame.',
values: stats?.labels.sexPositions ?? [],
total: Math.max(1, totalFeedback),
confidence: averageCategoryConfidence(stats?.labels.sexPositions ?? []),
},
{
key: 'bodyParts',
title: 'Körperteile',
description: 'Körperteil-Labels aus Korrekturen und Boxen.',
values: stats?.labels.bodyParts ?? [],
total: Math.max(1, totalFeedback),
confidence: averageCategoryConfidence(stats?.labels.bodyParts ?? []),
},
{
key: 'objects',
title: 'Gegenstände',
description: 'Objekt-Labels aus Korrekturen und Boxen.',
values: stats?.labels.objects ?? [],
total: Math.max(1, totalFeedback),
confidence: averageCategoryConfidence(stats?.labels.objects ?? []),
},
{
key: 'clothing',
title: 'Kleidung',
description: 'Kleidungs-Labels aus Korrekturen und Boxen.',
values: stats?.labels.clothing ?? [],
total: Math.max(1, totalFeedback),
confidence: averageCategoryConfidence(stats?.labels.clothing ?? []),
},
]
const activeTabItem =
tabItems.find((item) => item.key === activeTab) ?? tabItems[0]
return (
<Modal
open={props.open}
onClose={props.onClose}
title="Training-Statistiken"
width="max-w-3xl"
bodyClassName="p-4 sm:p-6"
>
<div className="p-4 sm:p-6">
{props.loading ? (
<div className="flex min-h-40 items-center justify-center">
<div className="text-center">
<LoadingSpinner
size="lg"
srLabel="Statistiken werden geladen…"
/>
<div className="mt-3 text-sm font-medium text-gray-700 dark:text-gray-200">
Statistiken werden geladen
</div>
</div>
</div>
) : props.error ? (
<div className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-500/10 dark:text-red-200">
{props.error}
</div>
) : (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<div className="rounded-xl bg-gray-50 p-3 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Feedback
</div>
<div className="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
{totalFeedback}
</div>
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
benötigt: {props.requiredCount}
</div>
</div>
<div className="rounded-xl bg-gray-50 p-3 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Passt so
</div>
<div className="mt-1 text-2xl font-bold text-emerald-700 dark:text-emerald-300">
{acceptedCount}
</div>
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
{countPercent(acceptedCount, totalFeedback)}
</div>
</div>
<div className="rounded-xl bg-gray-50 p-3 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Korrigiert
</div>
<div className="mt-1 text-2xl font-bold text-amber-700 dark:text-amber-300">
{correctedCount}
</div>
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
{countPercent(correctedCount, totalFeedback)}
</div>
</div>
<div className="rounded-xl bg-gray-50 p-3 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Boxen
</div>
<div className="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
{boxCount}
</div>
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
{sampleCount} Samples
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="rounded-2xl border border-indigo-100 bg-indigo-50 p-4 dark:border-indigo-400/20 dark:bg-indigo-500/10">
<div className="text-[11px] font-medium uppercase tracking-wide text-indigo-700/80 dark:text-indigo-200/80">
Modellstatus
</div>
<div className="mt-2 text-sm font-semibold text-indigo-950 dark:text-indigo-50">
{stats?.modelAvailable
? 'Trainiertes Modell verfügbar'
: 'Noch kein trainiertes Modell verfügbar'}
</div>
<div className="mt-1 text-xs leading-relaxed text-indigo-800/80 dark:text-indigo-100/70">
{stats?.modelAvailable
? 'Die aktuellen Trainingsdaten können bereits von einem Modell genutzt werden.'
: 'Sammle weiter Feedback und starte anschließend das Training.'}
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-900/70">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Gesamt-Confidence
</div>
<div className="mt-2 text-xl font-bold text-gray-900 dark:text-white">
{confidenceLabel(overallConfidence)}
</div>
</div>
<span
className={[
'rounded-full px-3 py-1.5 text-sm font-bold ring-1',
confidencePillClass(overallConfidence),
].join(' ')}
>
{confidencePercent(overallConfidence)}
</span>
</div>
<div className="mt-3 h-2.5 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
<div
className="h-full rounded-full bg-emerald-500 transition-all"
style={{ width: confidencePercent(overallConfidence) }}
/>
</div>
<div className="mt-2 text-xs leading-relaxed text-gray-500 dark:text-gray-400">
Grober Wert aus Feedback-Menge, Boxen, Label-Abdeckung und Korrekturanteil.
</div>
</div>
</div>
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-gray-50/70 p-2 dark:border-white/10 dark:bg-white/[0.03]">
<div className="grid grid-cols-2 gap-1 sm:grid-cols-5">
{tabItems.map((item) => {
const active = item.key === activeTab
const count = item.values.length
return (
<button
key={item.key}
type="button"
onClick={() => setActiveTab(item.key)}
className={[
'rounded-xl px-2.5 py-2 text-left transition',
'focus:outline-none focus:ring-2 focus:ring-indigo-500/40',
active
? 'bg-white text-indigo-700 shadow-sm ring-1 ring-indigo-200 dark:bg-gray-950 dark:text-indigo-200 dark:ring-indigo-400/30'
: 'text-gray-600 hover:bg-white/70 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-white/10 dark:hover:text-white',
].join(' ')}
>
<div className="truncate text-xs font-semibold">
{item.title}
</div>
<div className="mt-1 flex items-center justify-between gap-2">
<span className="text-[10px] opacity-70">
{count} Labels
</span>
<span
className={[
'rounded-full px-1.5 py-0.5 text-[9px] font-bold ring-1',
confidencePillClass(item.confidence),
].join(' ')}
title={`Kategorie-Confidence: ${confidencePercent(item.confidence)}`}
>
{confidencePercent(item.confidence)}
</span>
</div>
</button>
)
})}
</div>
</div>
<TrainingStatsList
title={activeTabItem.title}
description={activeTabItem.description}
values={activeTabItem.values}
total={activeTabItem.total}
confidence={activeTabItem.confidence}
/>
</div>
)}
</div>
</Modal>
)
}
export default function TrainingTab() {
const [labels, setLabels] = useState<TrainingLabels>(emptyLabels)
const [sample, setSample] = useState<TrainingSample | null>(null)
@ -1165,6 +1749,10 @@ export default function TrainingTab() {
const [trainingStep, setTrainingStep] = useState('')
const [error, setError] = useState<string | null>(null)
const [message, setMessage] = useState<string | null>(null)
const [statsModalOpen, setStatsModalOpen] = useState(false)
const [trainingStats, setTrainingStats] = useState<TrainingStats | null>(null)
const [trainingStatsLoading, setTrainingStatsLoading] = useState(false)
const [trainingStatsError, setTrainingStatsError] = useState<string | null>(null)
const wasTrainingRunningRef = useRef(false)
const imageBoxRef = useRef<HTMLDivElement | null>(null)
@ -1279,6 +1867,9 @@ export default function TrainingTab() {
const shownTrainingStep = trainingRunning
? trainingStatus?.training?.step || trainingStep || 'Training läuft…'
: trainingStep
const analysisConfidence = useMemo(() => {
return currentAnalysisConfidence(sample?.prediction)
}, [sample?.prediction])
const loadLabels = useCallback(async () => {
const res = await fetch('/api/training/labels', { cache: 'no-store' })
@ -1391,6 +1982,47 @@ export default function TrainingTab() {
}
}, [])
const loadTrainingStats = useCallback(async () => {
setTrainingStatsLoading(true)
setTrainingStatsError(null)
try {
const res = await fetch('/api/training/stats', { cache: 'no-store' })
const data = await res.json().catch(() => null)
if (!res.ok) {
throw new Error(data?.error || `HTTP ${res.status}`)
}
setTrainingStats({
feedbackCount: Number(data?.feedbackCount ?? 0),
acceptedCount: Number(data?.acceptedCount ?? 0),
correctedCount: Number(data?.correctedCount ?? 0),
sampleCount: Number(data?.sampleCount ?? 0),
boxCount: Number(data?.boxCount ?? 0),
modelAvailable: Boolean(data?.modelAvailable),
confidence: data?.confidence,
labels: {
people: Array.isArray(data?.labels?.people) ? data.labels.people : [],
sexPositions: Array.isArray(data?.labels?.sexPositions) ? data.labels.sexPositions : [],
bodyParts: Array.isArray(data?.labels?.bodyParts) ? data.labels.bodyParts : [],
objects: Array.isArray(data?.labels?.objects) ? data.labels.objects : [],
clothing: Array.isArray(data?.labels?.clothing) ? data.labels.clothing : [],
},
})
} catch (e) {
setTrainingStatsError(e instanceof Error ? e.message : String(e))
} finally {
setTrainingStatsLoading(false)
}
}, [])
useEffect(() => {
if (!statsModalOpen) return
void loadTrainingStats()
}, [statsModalOpen, loadTrainingStats])
useEffect(() => {
if (!boxLabel) return
@ -1893,11 +2525,6 @@ export default function TrainingTab() {
})
}, [])
const isCoarsePointer = useCallback(() => {
if (typeof window === 'undefined') return false
return window.matchMedia('(pointer: coarse)').matches
}, [])
const showImageBoxes = !loading && !trainingRunning
const detectorBoxesPanel = (
@ -2040,6 +2667,7 @@ export default function TrainingTab() {
)
return (
<>
<div className="grid grid-cols-1 items-stretch gap-3 lg:grid-cols-[300px_minmax(0,1fr)_300px] xl:grid-cols-[320px_minmax(0,1fr)_320px]">
{/* Sidebar links */}
<aside className="max-h-[calc(100dvh-190px)] overflow-y-auto rounded-xl border border-gray-200 bg-white p-3 shadow-sm dark:border-white/10 dark:bg-gray-900/60">
@ -2049,9 +2677,20 @@ export default function TrainingTab() {
Training
</div>
<div className="rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-700 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10">
<button
type="button"
onClick={() => setStatsModalOpen(true)}
className={[
'rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-700 ring-1 ring-gray-200 transition',
'hover:bg-indigo-50 hover:text-indigo-700 hover:ring-indigo-200',
'focus:outline-none focus:ring-2 focus:ring-indigo-500/40',
'dark:bg-white/10 dark:text-gray-200 dark:ring-white/10 dark:hover:bg-indigo-500/20 dark:hover:text-indigo-100 dark:hover:ring-indigo-300/30',
].join(' ')}
title="Training-Statistiken anzeigen"
aria-label="Training-Statistiken anzeigen"
>
{feedbackCount}
</div>
</button>
</div>
<Button
@ -2259,12 +2898,12 @@ export default function TrainingTab() {
e.preventDefault()
e.stopPropagation()
const requiresActivationFirst = isCoarsePointer() && !isActiveBox
const requiresActivationFirst = !isActiveBox
setActiveBoxIndex(index)
// Mobile: erster Touch aktiviert nur die Box.
// Erst ein zweiter Touch auf die aktive Box darf verschieben.
// Erster Klick/Touch aktiviert nur die Box.
// Erst ein zweiter Klick/Touch auf die aktive Box darf verschieben.
if (requiresActivationFirst) return
const pos = getPointerPosInImage(e.clientX, e.clientY, { clamp: false })
@ -2354,7 +2993,7 @@ export default function TrainingTab() {
className={[
isActiveBox
? 'pointer-events-auto absolute z-20 flex h-7 w-7 touch-none items-center justify-center rounded-full bg-transparent p-0 opacity-100 disabled:opacity-50 sm:h-5 sm:w-5'
: 'pointer-events-auto absolute z-20 hidden h-7 w-7 touch-none items-center justify-center rounded-full bg-transparent p-0 opacity-100 disabled:opacity-50 sm:flex sm:h-5 sm:w-5',
: 'pointer-events-none absolute z-20 hidden h-7 w-7 touch-none items-center justify-center rounded-full bg-transparent p-0 opacity-100 disabled:opacity-50 sm:h-5 sm:w-5',
handle === 'nw' ? '-left-3.5 -top-3.5 cursor-nwse-resize sm:-left-2.5 sm:-top-2.5' : '',
handle === 'ne' ? '-right-3.5 -top-3.5 cursor-nesw-resize sm:-right-2.5 sm:-top-2.5' : '',
handle === 'sw' ? '-bottom-3.5 -left-3.5 cursor-nesw-resize sm:-bottom-2.5 sm:-left-2.5' : '',
@ -2363,11 +3002,18 @@ export default function TrainingTab() {
title="Boxgröße ändern"
onPointerDown={(e) => {
if (uiLocked) return
setActiveBoxIndex(index)
e.preventDefault()
e.stopPropagation()
const requiresActivationFirst = !isActiveBox
setActiveBoxIndex(index)
// Erster Klick/Touch aktiviert nur die Box.
// Erst danach darf Resize starten.
if (requiresActivationFirst) return
const pos = getPointerPosInImage(e.clientX, e.clientY)
if (!pos) return
@ -2617,6 +3263,8 @@ export default function TrainingTab() {
uiLocked ? 'pointer-events-none opacity-60' : ''
].join(' ')}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
Korrektur
</div>
@ -2624,6 +3272,18 @@ export default function TrainingTab() {
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Diese Werte werden gespeichert, wenn du Korrektur speichern & weiter klickst.
</div>
</div>
<span
className={[
'shrink-0 rounded-full px-2.5 py-1 text-[11px] font-bold ring-1',
confidencePillClass(analysisConfidence),
].join(' ')}
title={`Analyse-Confidence aktuelles Bild: ${confidencePercent(analysisConfidence)}`}
>
{confidencePercent(analysisConfidence)}
</span>
</div>
<div className="mt-3 space-y-3">
<CollapsibleSingleLabelSection
@ -2743,5 +3403,16 @@ export default function TrainingTab() {
</div>
</aside>
</div>
<TrainingStatsModal
open={statsModalOpen}
onClose={() => setStatsModalOpen(false)}
stats={trainingStats}
loading={trainingStatsLoading}
error={trainingStatsError}
feedbackCount={feedbackCount}
requiredCount={requiredCount}
/>
</>
)
}