bugfixes + added Training Stats
This commit is contained in:
parent
973a07fef8
commit
c905753f7a
@ -2033,6 +2033,24 @@ export default function App() {
|
|||||||
pausedByDisk: false,
|
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(() => {
|
useEffect(() => {
|
||||||
busyRef.current = busy
|
busyRef.current = busy
|
||||||
}, [busy])
|
}, [busy])
|
||||||
@ -2714,6 +2732,7 @@ export default function App() {
|
|||||||
if (!authed) return
|
if (!authed) return
|
||||||
|
|
||||||
let es: EventSource | null = null
|
let es: EventSource | null = null
|
||||||
|
let autostartEs: EventSource | null = null
|
||||||
let fallbackTimer: number | null = null
|
let fallbackTimer: number | null = null
|
||||||
|
|
||||||
const stopFallbackPoll = () => {
|
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 = () => {
|
const onDoneChanged = () => {
|
||||||
if (selectedTabRef.current !== 'finished') return
|
if (selectedTabRef.current !== 'finished') return
|
||||||
void loadDonePage(donePageRef.current, doneSortRef.current)
|
void loadDonePage(donePageRef.current, doneSortRef.current)
|
||||||
@ -2837,20 +2847,26 @@ export default function App() {
|
|||||||
void loadDoneCount()
|
void loadDoneCount()
|
||||||
void loadTaskStateOnce()
|
void loadTaskStateOnce()
|
||||||
|
|
||||||
void apiJSON<AutostartState>('/api/autostart/state', { cache: 'no-store' as any })
|
void loadAutostartState().catch(() => {})
|
||||||
.then((s) => {
|
|
||||||
setAutostartState({
|
|
||||||
paused: Boolean(s?.paused),
|
|
||||||
pausedByUser: Boolean(s?.pausedByUser),
|
|
||||||
pausedByDisk: Boolean(s?.pausedByDisk),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
|
|
||||||
es = new EventSource('/api/events/stream')
|
es = new EventSource('/api/events/stream')
|
||||||
eventSourceRef.current = es
|
eventSourceRef.current = es
|
||||||
modelEventNamesRef.current = new Set()
|
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 = () => {
|
es.onopen = () => {
|
||||||
stopFallbackPoll()
|
stopFallbackPoll()
|
||||||
void loadTaskStateOnce()
|
void loadTaskStateOnce()
|
||||||
@ -2867,8 +2883,10 @@ export default function App() {
|
|||||||
|
|
||||||
const onVis = () => {
|
const onVis = () => {
|
||||||
if (document.hidden) return
|
if (document.hidden) return
|
||||||
|
|
||||||
void loadJobs()
|
void loadJobs()
|
||||||
void loadTaskStateOnce()
|
void loadTaskStateOnce()
|
||||||
|
void loadAutostartState().catch(() => {})
|
||||||
|
|
||||||
if (selectedTabRef.current === 'finished') {
|
if (selectedTabRef.current === 'finished') {
|
||||||
void loadDonePage(donePageRef.current, doneSortRef.current)
|
void loadDonePage(donePageRef.current, doneSortRef.current)
|
||||||
@ -2897,10 +2915,21 @@ export default function App() {
|
|||||||
es.close()
|
es.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (autostartEs) {
|
||||||
|
autostartEs.close()
|
||||||
|
}
|
||||||
|
|
||||||
eventSourceRef.current = null
|
eventSourceRef.current = null
|
||||||
modelEventNamesRef.current = new Set()
|
modelEventNamesRef.current = new Set()
|
||||||
}
|
}
|
||||||
}, [authed, loadJobs, loadDoneCount, loadPendingAutoStarts])
|
}, [
|
||||||
|
authed,
|
||||||
|
loadJobs,
|
||||||
|
loadDoneCount,
|
||||||
|
loadPendingAutoStarts,
|
||||||
|
applyAutostartState,
|
||||||
|
loadAutostartState,
|
||||||
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const desired = new Set<string>()
|
const desired = new Set<string>()
|
||||||
@ -3970,14 +3999,14 @@ export default function App() {
|
|||||||
roomStatusByModelKey={roomStatusByModelKey}
|
roomStatusByModelKey={roomStatusByModelKey}
|
||||||
pending={pendingWatchedRooms}
|
pending={pendingWatchedRooms}
|
||||||
autostartState={autostartState}
|
autostartState={autostartState}
|
||||||
onRefreshAutostartState={async () => {
|
onRefreshAutostartState={async (next?: AutostartState) => {
|
||||||
try {
|
try {
|
||||||
const s = await apiJSON<AutostartState>('/api/autostart/state', { cache: 'no-store' as any })
|
if (next) {
|
||||||
setAutostartState({
|
applyAutostartState(next)
|
||||||
paused: Boolean(s?.paused),
|
return
|
||||||
pausedByUser: Boolean(s?.pausedByUser),
|
}
|
||||||
pausedByDisk: Boolean(s?.pausedByDisk),
|
|
||||||
})
|
await loadAutostartState()
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,7 +32,7 @@ type Props = {
|
|||||||
jobs: RecordJob[]
|
jobs: RecordJob[]
|
||||||
pending?: PendingWatchedRoom[]
|
pending?: PendingWatchedRoom[]
|
||||||
autostartState?: AutostartState
|
autostartState?: AutostartState
|
||||||
onRefreshAutostartState?: () => Promise<void> | void
|
onRefreshAutostartState?: (next?: AutostartState) => Promise<void> | void
|
||||||
modelsByKey?: Record<
|
modelsByKey?: Record<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
@ -644,9 +644,9 @@ export default function Downloads({
|
|||||||
|
|
||||||
const [watchedBusy, setWatchedBusy] = useState(false)
|
const [watchedBusy, setWatchedBusy] = useState(false)
|
||||||
|
|
||||||
const refreshWatchedState = useCallback(async () => {
|
const refreshWatchedState = useCallback(async (next?: AutostartState) => {
|
||||||
try {
|
try {
|
||||||
await onRefreshAutostartState?.()
|
await onRefreshAutostartState?.(next)
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@ -678,11 +678,22 @@ export default function Downloads({
|
|||||||
|
|
||||||
setWatchedBusy(true)
|
setWatchedBusy(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/autostart/pause', { method: 'POST' })
|
const res = await fetch('/api/autostart/pause', {
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
method: 'POST',
|
||||||
await refreshWatchedState()
|
headers: { 'Content-Type': 'application/json' },
|
||||||
} catch {
|
cache: 'no-store' as any,
|
||||||
// ignore
|
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 {
|
} finally {
|
||||||
setWatchedBusy(false)
|
setWatchedBusy(false)
|
||||||
}
|
}
|
||||||
@ -693,11 +704,20 @@ export default function Downloads({
|
|||||||
|
|
||||||
setWatchedBusy(true)
|
setWatchedBusy(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/autostart/resume', { method: 'POST' })
|
const res = await fetch('/api/autostart/resume', {
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
method: 'POST',
|
||||||
await refreshWatchedState()
|
cache: 'no-store' as any,
|
||||||
} catch {
|
})
|
||||||
// ignore
|
|
||||||
|
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 {
|
} finally {
|
||||||
setWatchedBusy(false)
|
setWatchedBusy(false)
|
||||||
}
|
}
|
||||||
@ -1578,8 +1598,8 @@ export default function Downloads({
|
|||||||
}
|
}
|
||||||
leadingIcon={
|
leadingIcon={
|
||||||
watchedPaused
|
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
|
Autostart
|
||||||
@ -1652,8 +1672,8 @@ export default function Downloads({
|
|||||||
}
|
}
|
||||||
leadingIcon={
|
leadingIcon={
|
||||||
watchedPaused
|
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
|
Autostart
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import LoadingSpinner from './LoadingSpinner'
|
|||||||
import { formatBytes, formatDuration } from './formatters'
|
import { formatBytes, formatDuration } from './formatters'
|
||||||
import { TrashIcon } from '@heroicons/react/20/solid'
|
import { TrashIcon } from '@heroicons/react/20/solid'
|
||||||
import { getSegmentLabelItem } from './Icons'
|
import { getSegmentLabelItem } from './Icons'
|
||||||
|
import Modal from './Modal'
|
||||||
|
|
||||||
type ScoredLabel = {
|
type ScoredLabel = {
|
||||||
label: string
|
label: string
|
||||||
@ -111,6 +112,163 @@ type MagnifierState = {
|
|||||||
imageY: number
|
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 = {
|
const emptyLabels: TrainingLabels = {
|
||||||
people: [],
|
people: [],
|
||||||
sexPositions: ['unknown'],
|
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() {
|
export default function TrainingTab() {
|
||||||
const [labels, setLabels] = useState<TrainingLabels>(emptyLabels)
|
const [labels, setLabels] = useState<TrainingLabels>(emptyLabels)
|
||||||
const [sample, setSample] = useState<TrainingSample | null>(null)
|
const [sample, setSample] = useState<TrainingSample | null>(null)
|
||||||
@ -1165,6 +1749,10 @@ export default function TrainingTab() {
|
|||||||
const [trainingStep, setTrainingStep] = useState('')
|
const [trainingStep, setTrainingStep] = useState('')
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [message, setMessage] = 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 wasTrainingRunningRef = useRef(false)
|
||||||
|
|
||||||
const imageBoxRef = useRef<HTMLDivElement | null>(null)
|
const imageBoxRef = useRef<HTMLDivElement | null>(null)
|
||||||
@ -1279,6 +1867,9 @@ export default function TrainingTab() {
|
|||||||
const shownTrainingStep = trainingRunning
|
const shownTrainingStep = trainingRunning
|
||||||
? trainingStatus?.training?.step || trainingStep || 'Training läuft…'
|
? trainingStatus?.training?.step || trainingStep || 'Training läuft…'
|
||||||
: trainingStep
|
: trainingStep
|
||||||
|
const analysisConfidence = useMemo(() => {
|
||||||
|
return currentAnalysisConfidence(sample?.prediction)
|
||||||
|
}, [sample?.prediction])
|
||||||
|
|
||||||
const loadLabels = useCallback(async () => {
|
const loadLabels = useCallback(async () => {
|
||||||
const res = await fetch('/api/training/labels', { cache: 'no-store' })
|
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(() => {
|
useEffect(() => {
|
||||||
if (!boxLabel) return
|
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 showImageBoxes = !loading && !trainingRunning
|
||||||
|
|
||||||
const detectorBoxesPanel = (
|
const detectorBoxesPanel = (
|
||||||
@ -2040,6 +2667,7 @@ export default function TrainingTab() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
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]">
|
<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 */}
|
{/* 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">
|
<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
|
Training
|
||||||
</div>
|
</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}
|
{feedbackCount}
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@ -2259,12 +2898,12 @@ export default function TrainingTab() {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
||||||
const requiresActivationFirst = isCoarsePointer() && !isActiveBox
|
const requiresActivationFirst = !isActiveBox
|
||||||
|
|
||||||
setActiveBoxIndex(index)
|
setActiveBoxIndex(index)
|
||||||
|
|
||||||
// Mobile: erster Touch aktiviert nur die Box.
|
// Erster Klick/Touch aktiviert nur die Box.
|
||||||
// Erst ein zweiter Touch auf die aktive Box darf verschieben.
|
// Erst ein zweiter Klick/Touch auf die aktive Box darf verschieben.
|
||||||
if (requiresActivationFirst) return
|
if (requiresActivationFirst) return
|
||||||
|
|
||||||
const pos = getPointerPosInImage(e.clientX, e.clientY, { clamp: false })
|
const pos = getPointerPosInImage(e.clientX, e.clientY, { clamp: false })
|
||||||
@ -2354,7 +2993,7 @@ export default function TrainingTab() {
|
|||||||
className={[
|
className={[
|
||||||
isActiveBox
|
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 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 === '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 === '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' : '',
|
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"
|
title="Boxgröße ändern"
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
if (uiLocked) return
|
if (uiLocked) return
|
||||||
setActiveBoxIndex(index)
|
|
||||||
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
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)
|
const pos = getPointerPosInImage(e.clientX, e.clientY)
|
||||||
if (!pos) return
|
if (!pos) return
|
||||||
|
|
||||||
@ -2617,6 +3263,8 @@ export default function TrainingTab() {
|
|||||||
uiLocked ? 'pointer-events-none opacity-60' : ''
|
uiLocked ? 'pointer-events-none opacity-60' : ''
|
||||||
].join(' ')}
|
].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">
|
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
Korrektur
|
Korrektur
|
||||||
</div>
|
</div>
|
||||||
@ -2624,6 +3272,18 @@ export default function TrainingTab() {
|
|||||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
Diese Werte werden gespeichert, wenn du „Korrektur speichern & weiter“ klickst.
|
Diese Werte werden gespeichert, wenn du „Korrektur speichern & weiter“ klickst.
|
||||||
</div>
|
</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">
|
<div className="mt-3 space-y-3">
|
||||||
<CollapsibleSingleLabelSection
|
<CollapsibleSingleLabelSection
|
||||||
@ -2743,5 +3403,16 @@ export default function TrainingTab() {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</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