bugfixes + added Training Stats
This commit is contained in:
parent
973a07fef8
commit
c905753f7a
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user