4834 lines
166 KiB
TypeScript
4834 lines
166 KiB
TypeScript
// frontend/src/components/ui/TrainingTab.tsx
|
|
'use client'
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType, type CSSProperties } from 'react'
|
|
import Button from './Button'
|
|
import LoadingSpinner from './LoadingSpinner'
|
|
import { formatDuration } from './formatters'
|
|
import {
|
|
ArrowPathIcon,
|
|
BoltIcon,
|
|
ClockIcon,
|
|
ForwardIcon,
|
|
TrashIcon,
|
|
XCircleIcon,
|
|
} from '@heroicons/react/20/solid'
|
|
import { getSegmentLabelItem } from './Icons'
|
|
import Modal from './Modal'
|
|
import { createPortal } from 'react-dom'
|
|
|
|
type DrawingTrainingBox = TrainingBox & {
|
|
startX: number
|
|
startY: number
|
|
}
|
|
|
|
type ScoredLabel = {
|
|
label: string
|
|
score: number
|
|
}
|
|
|
|
type TrainingDetectorStatus = {
|
|
trainCount: number
|
|
valCount: number
|
|
requiredTrain: number
|
|
requiredVal: number
|
|
datasetReady: boolean
|
|
dataReady: boolean
|
|
modelExists: boolean
|
|
modelPath?: string
|
|
source?: string
|
|
}
|
|
|
|
type TrainingStatus = {
|
|
feedbackCount: number
|
|
requiredCount: number
|
|
canTrain: boolean
|
|
training?: TrainingJobStatus
|
|
detector?: TrainingDetectorStatus
|
|
}
|
|
|
|
type TrainingJobStatus = {
|
|
running: boolean
|
|
progress: number
|
|
step: string
|
|
message?: string
|
|
error?: string
|
|
startedAt?: string
|
|
finishedAt?: string
|
|
durationMs?: number
|
|
stage?: string
|
|
epoch?: number
|
|
epochs?: number
|
|
}
|
|
|
|
type TrainingPrediction = {
|
|
modelAvailable: boolean
|
|
source?: string
|
|
|
|
sexPosition: string
|
|
sexPositionScore: number
|
|
peoplePresent: ScoredLabel[]
|
|
bodyPartsPresent: ScoredLabel[]
|
|
objectsPresent: ScoredLabel[]
|
|
clothingPresent: ScoredLabel[]
|
|
boxes?: TrainingBox[]
|
|
}
|
|
|
|
type TrainingSample = {
|
|
sampleId: string
|
|
frameUrl: string
|
|
sourceFile: string
|
|
sourcePath?: string
|
|
sourceSizeBytes?: number
|
|
second: number
|
|
createdAt: string
|
|
prediction: TrainingPrediction
|
|
}
|
|
|
|
type TrainingLabels = {
|
|
people: string[]
|
|
sexPositions: string[]
|
|
bodyParts: string[]
|
|
objects: string[]
|
|
clothing: string[]
|
|
}
|
|
|
|
type CorrectionState = {
|
|
sexPosition: string
|
|
peoplePresent: string[]
|
|
bodyPartsPresent: string[]
|
|
objectsPresent: string[]
|
|
clothingPresent: string[]
|
|
boxes: TrainingBox[]
|
|
}
|
|
|
|
type TrainingBox = {
|
|
label: string
|
|
score?: number
|
|
x: number
|
|
y: number
|
|
w: number
|
|
h: number
|
|
}
|
|
|
|
type BoxInteraction =
|
|
| {
|
|
type: 'move'
|
|
index: number
|
|
startX: number
|
|
startY: number
|
|
original: TrainingBox
|
|
}
|
|
| {
|
|
type: 'resize'
|
|
index: number
|
|
handle: 'nw' | 'ne' | 'sw' | 'se'
|
|
startX: number
|
|
startY: number
|
|
original: TrainingBox
|
|
}
|
|
|
|
type MagnifierState = {
|
|
visible: boolean
|
|
clientX: number
|
|
clientY: number
|
|
imageX: 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[]
|
|
}
|
|
}
|
|
|
|
type TrainingNoticeKind = 'success' | 'error' | 'info' | 'warning'
|
|
|
|
type TrainingNoticeItem = {
|
|
icon: ComponentType<{
|
|
className?: string
|
|
'aria-hidden'?: boolean | 'true' | 'false'
|
|
}>
|
|
label: string
|
|
value: string
|
|
}
|
|
|
|
type TrainingNotice = {
|
|
kind: TrainingNoticeKind
|
|
title: string
|
|
message: string
|
|
items?: TrainingNoticeItem[]
|
|
progress?: number
|
|
}
|
|
|
|
function trainingNoticeClass(kind: TrainingNoticeKind) {
|
|
switch (kind) {
|
|
case 'success':
|
|
return {
|
|
wrap: 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-400/30 dark:bg-emerald-500/10 dark:text-emerald-100',
|
|
icon: 'bg-emerald-500 text-white',
|
|
detail: 'text-emerald-800/80 dark:text-emerald-100/70',
|
|
}
|
|
case 'error':
|
|
return {
|
|
wrap: 'border-red-200 bg-red-50 text-red-900 dark:border-red-400/30 dark:bg-red-500/10 dark:text-red-100',
|
|
icon: 'bg-red-500 text-white',
|
|
detail: 'text-red-800/80 dark:text-red-100/70',
|
|
}
|
|
case 'warning':
|
|
return {
|
|
wrap: 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-400/30 dark:bg-amber-500/10 dark:text-amber-100',
|
|
icon: 'bg-amber-500 text-white',
|
|
detail: 'text-amber-800/80 dark:text-amber-100/70',
|
|
}
|
|
default:
|
|
return {
|
|
wrap: 'border-indigo-200 bg-indigo-50 text-indigo-900 dark:border-indigo-400/30 dark:bg-indigo-500/10 dark:text-indigo-100',
|
|
icon: 'bg-indigo-500 text-white',
|
|
detail: 'text-indigo-800/80 dark:text-indigo-100/70',
|
|
}
|
|
}
|
|
}
|
|
|
|
function TrainingNoticeCard(props: {
|
|
notice: TrainingNotice
|
|
onClose?: () => void
|
|
}) {
|
|
const cls = trainingNoticeClass(props.notice.kind)
|
|
|
|
const icon =
|
|
props.notice.kind === 'success'
|
|
? '✓'
|
|
: props.notice.kind === 'error'
|
|
? '!'
|
|
: props.notice.kind === 'warning'
|
|
? '⚠'
|
|
: 'i'
|
|
|
|
return (
|
|
<div
|
|
className={[
|
|
'rounded-xl border px-3 py-2 shadow-sm',
|
|
cls.wrap,
|
|
].join(' ')}
|
|
role={props.notice.kind === 'error' ? 'alert' : 'status'}
|
|
aria-live={props.notice.kind === 'error' ? 'assertive' : 'polite'}
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<div
|
|
className={[
|
|
'mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-black',
|
|
cls.icon,
|
|
].join(' ')}
|
|
aria-hidden="true"
|
|
>
|
|
{icon}
|
|
</div>
|
|
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold">
|
|
<span>{props.notice.title}</span>
|
|
|
|
{typeof props.notice.progress === 'number' ? (
|
|
<span className="rounded-full bg-black/5 px-2 py-0.5 text-[11px] font-bold ring-1 ring-black/10 dark:bg-white/10 dark:ring-white/10">
|
|
{Math.round(clampPercent(props.notice.progress))}%
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
|
|
{props.notice.items?.length ? (
|
|
<div className="mt-2 grid grid-cols-1 gap-1.5 text-xs sm:grid-cols-2">
|
|
{props.notice.items.map((item) => {
|
|
const Icon = item.icon
|
|
|
|
return (
|
|
<div
|
|
key={`${item.label}-${item.value}`}
|
|
className="flex min-w-0 items-center gap-2 rounded-lg bg-black/5 px-2 py-1.5 ring-1 ring-black/5 dark:bg-white/10 dark:ring-white/10"
|
|
>
|
|
<Icon
|
|
className="h-4 w-4 shrink-0 opacity-80"
|
|
aria-hidden="true"
|
|
/>
|
|
|
|
<span className="min-w-0 flex-1 truncate opacity-75">
|
|
{item.label}
|
|
</span>
|
|
|
|
<span className="shrink-0 font-bold">
|
|
{item.value}
|
|
</span>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="mt-0.5 break-words text-xs leading-snug">
|
|
{props.notice.message}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{props.onClose ? (
|
|
<button
|
|
type="button"
|
|
onClick={props.onClose}
|
|
className="shrink-0 rounded-lg px-2 py-1 text-xs font-semibold opacity-70 transition hover:bg-black/5 hover:opacity-100 dark:hover:bg-white/10"
|
|
aria-label="Meldung schließen"
|
|
title="Meldung schließen"
|
|
>
|
|
✕
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function backendText(data: any, fallback: string) {
|
|
return String(
|
|
data?.message ||
|
|
data?.error ||
|
|
data?.detail ||
|
|
fallback
|
|
).trim()
|
|
}
|
|
|
|
function trainingDurationMs(job?: TrainingJobStatus | null) {
|
|
const direct = Number(job?.durationMs)
|
|
|
|
if (Number.isFinite(direct) && direct > 0) {
|
|
return direct
|
|
}
|
|
|
|
const started = job?.startedAt ? Date.parse(job.startedAt) : NaN
|
|
const finished = job?.finishedAt ? Date.parse(job.finishedAt) : NaN
|
|
|
|
if (!Number.isFinite(started) || !Number.isFinite(finished)) {
|
|
return 0
|
|
}
|
|
|
|
return Math.max(0, finished - started)
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
// YOLO-Detector: Sexposition als Full-Frame-/Positions-Label
|
|
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'],
|
|
bodyParts: [],
|
|
objects: [],
|
|
clothing: [],
|
|
}
|
|
|
|
function percent(v: number) {
|
|
if (!Number.isFinite(v)) return '—'
|
|
return `${Math.round(v * 100)}%`
|
|
}
|
|
|
|
function scoreLevel(score?: number | null): 'none' | 'low' | 'mid' | 'high' {
|
|
const n = Number(score)
|
|
|
|
if (!Number.isFinite(n)) return 'none'
|
|
if (n < 0.5) return 'low'
|
|
if (n < 0.75) return 'mid'
|
|
return 'high'
|
|
}
|
|
|
|
function scoreBorderClass(score?: number | null, opts?: { draft?: boolean }) {
|
|
if (opts?.draft) return 'border-amber-400'
|
|
|
|
switch (scoreLevel(score)) {
|
|
case 'low':
|
|
return 'border-red-500'
|
|
case 'mid':
|
|
return 'border-yellow-400'
|
|
case 'high':
|
|
return 'border-emerald-400'
|
|
default:
|
|
return 'border-gray-300'
|
|
}
|
|
}
|
|
|
|
function scoreRingClass(score?: number | null, opts?: { draft?: boolean }) {
|
|
if (opts?.draft) return 'ring-amber-500'
|
|
|
|
switch (scoreLevel(score)) {
|
|
case 'low':
|
|
return 'ring-red-500'
|
|
case 'mid':
|
|
return 'ring-yellow-400'
|
|
case 'high':
|
|
return 'ring-emerald-500'
|
|
default:
|
|
return 'ring-gray-400'
|
|
}
|
|
}
|
|
|
|
function scoreDetectionPillClass(score?: number | null) {
|
|
switch (scoreLevel(score)) {
|
|
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'
|
|
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 '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'
|
|
default:
|
|
return 'bg-gray-50 text-gray-700 ring-gray-200 dark:bg-white/5 dark:text-gray-200 dark:ring-white/10'
|
|
}
|
|
}
|
|
|
|
function detectorBoxAppearance(label: string) {
|
|
const clean = String(label || '').trim()
|
|
|
|
if (clean === 'person_female' || clean === 'female_person') {
|
|
return {
|
|
activeSurface:
|
|
'dark:bg-pink-500/10 dark:shadow-[0_0_0_1px_rgba(244,114,182,0.20),0_10px_24px_rgba(2,6,23,0.38)]',
|
|
idleHover: 'dark:hover:bg-pink-500/[0.05]',
|
|
line: 'bg-pink-400',
|
|
lineHover: 'dark:group-hover:bg-pink-400/70',
|
|
iconActive:
|
|
'dark:bg-pink-500/15 dark:text-pink-100 dark:ring-pink-400/25',
|
|
iconIdle:
|
|
'dark:text-pink-200/80 dark:group-hover:bg-pink-500/10 dark:group-hover:text-pink-100 dark:group-hover:ring-pink-400/20',
|
|
selectedText: 'dark:text-pink-300',
|
|
}
|
|
}
|
|
|
|
if (clean === 'person_male' || clean === 'male_person') {
|
|
return {
|
|
activeSurface:
|
|
'dark:bg-sky-500/10 dark:shadow-[0_0_0_1px_rgba(56,189,248,0.20),0_10px_24px_rgba(2,6,23,0.38)]',
|
|
idleHover: 'dark:hover:bg-sky-500/[0.05]',
|
|
line: 'bg-sky-400',
|
|
lineHover: 'dark:group-hover:bg-sky-400/70',
|
|
iconActive:
|
|
'dark:bg-sky-500/15 dark:text-sky-100 dark:ring-sky-400/25',
|
|
iconIdle:
|
|
'dark:text-sky-200/80 dark:group-hover:bg-sky-500/10 dark:group-hover:text-sky-100 dark:group-hover:ring-sky-400/20',
|
|
selectedText: 'dark:text-sky-300',
|
|
}
|
|
}
|
|
|
|
return {
|
|
activeSurface:
|
|
'dark:bg-indigo-500/10 dark:shadow-[0_0_0_1px_rgba(129,140,248,0.20),0_10px_24px_rgba(2,6,23,0.38)]',
|
|
idleHover: 'dark:hover:bg-indigo-500/[0.05]',
|
|
line: 'bg-indigo-400',
|
|
lineHover: 'dark:group-hover:bg-indigo-400/70',
|
|
iconActive:
|
|
'dark:bg-indigo-500/15 dark:text-indigo-100 dark:ring-indigo-400/25',
|
|
iconIdle:
|
|
'dark:text-indigo-200/80 dark:group-hover:bg-indigo-500/10 dark:group-hover:text-indigo-100 dark:group-hover:ring-indigo-400/20',
|
|
selectedText: 'dark:text-indigo-300',
|
|
}
|
|
}
|
|
|
|
function normalizeMovedBox(box: TrainingBox): TrainingBox {
|
|
const w = clamp01(box.w)
|
|
const h = clamp01(box.h)
|
|
|
|
return {
|
|
...box,
|
|
x: Math.max(0, Math.min(1 - w, Number(box.x) || 0)),
|
|
y: Math.max(0, Math.min(1 - h, Number(box.y) || 0)),
|
|
w,
|
|
h,
|
|
}
|
|
}
|
|
|
|
function clampPercent(v: number) {
|
|
if (!Number.isFinite(v)) return 0
|
|
return Math.max(0, Math.min(100, v))
|
|
}
|
|
|
|
function toggleArrayValue(arr: string[], value: string) {
|
|
return arr.includes(value)
|
|
? arr.filter((x) => x !== value)
|
|
: [...arr, value]
|
|
}
|
|
|
|
function clamp01(v: number) {
|
|
if (!Number.isFinite(v)) return 0
|
|
return Math.max(0, Math.min(1, v))
|
|
}
|
|
|
|
function snap01(v: number, epsilon = 0.006) {
|
|
const n = clamp01(v)
|
|
|
|
if (n <= epsilon) return 0
|
|
if (n >= 1 - epsilon) return 1
|
|
|
|
return n
|
|
}
|
|
|
|
function normalizeBox(box: TrainingBox): TrainingBox {
|
|
const x = clamp01(box.x)
|
|
const y = clamp01(box.y)
|
|
const w = clamp01(Math.min(box.w, 1 - x))
|
|
const h = clamp01(Math.min(box.h, 1 - y))
|
|
|
|
return {
|
|
...box,
|
|
x,
|
|
y,
|
|
w,
|
|
h,
|
|
}
|
|
}
|
|
|
|
function boxGeometryChanged(a: TrainingBox, b: TrainingBox) {
|
|
const epsilon = 0.0005
|
|
|
|
return (
|
|
Math.abs(a.x - b.x) > epsilon ||
|
|
Math.abs(a.y - b.y) > epsilon ||
|
|
Math.abs(a.w - b.w) > epsilon ||
|
|
Math.abs(a.h - b.h) > epsilon
|
|
)
|
|
}
|
|
|
|
function markBoxCorrected(box: TrainingBox): TrainingBox {
|
|
const { score: _oldScore, ...boxWithoutScore } = box
|
|
return boxWithoutScore
|
|
}
|
|
|
|
function uniqStrings(values: string[]) {
|
|
const seen = new Set<string>()
|
|
const out: string[] = []
|
|
|
|
for (const value of values) {
|
|
const clean = String(value || '').trim()
|
|
if (!clean || seen.has(clean)) continue
|
|
seen.add(clean)
|
|
out.push(clean)
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
function peopleLabelsFromBoxes(boxes: TrainingBox[], labels: TrainingLabels) {
|
|
return uniqStrings(
|
|
boxes
|
|
.map((box) => String(box.label || '').trim())
|
|
.filter((label) => labels.people.includes(label))
|
|
)
|
|
}
|
|
|
|
function predictionToCorrection(sample: TrainingSample | null): CorrectionState {
|
|
const p = sample?.prediction
|
|
|
|
const boxes = (p?.boxes ?? [])
|
|
.map((box) => ({
|
|
label: String(box.label || '').trim(),
|
|
score: box.score,
|
|
x: clamp01(Number(box.x)),
|
|
y: clamp01(Number(box.y)),
|
|
w: clamp01(Number(box.w)),
|
|
h: clamp01(Number(box.h)),
|
|
}))
|
|
.filter((box) => box.label && box.w > 0 && box.h > 0)
|
|
|
|
return {
|
|
sexPosition: p?.sexPosition || 'unknown',
|
|
peoplePresent: (p?.peoplePresent ?? []).map((x) => x.label),
|
|
bodyPartsPresent: (p?.bodyPartsPresent ?? []).map((x) => x.label),
|
|
objectsPresent: (p?.objectsPresent ?? []).map((x) => x.label),
|
|
clothingPresent: (p?.clothingPresent ?? []).map((x) => x.label),
|
|
boxes,
|
|
}
|
|
}
|
|
|
|
function applyBoxLabelToCorrection(
|
|
state: CorrectionState,
|
|
label: string,
|
|
labels: TrainingLabels
|
|
): CorrectionState {
|
|
const clean = String(label || '').trim()
|
|
if (!clean) return state
|
|
|
|
if (labels.people.includes(clean)) {
|
|
return {
|
|
...state,
|
|
peoplePresent: state.peoplePresent.includes(clean)
|
|
? state.peoplePresent
|
|
: [...state.peoplePresent, clean],
|
|
}
|
|
}
|
|
|
|
if (labels.bodyParts.includes(clean)) {
|
|
return {
|
|
...state,
|
|
bodyPartsPresent: state.bodyPartsPresent.includes(clean)
|
|
? state.bodyPartsPresent
|
|
: [...state.bodyPartsPresent, clean],
|
|
}
|
|
}
|
|
|
|
if (labels.objects.includes(clean)) {
|
|
return {
|
|
...state,
|
|
objectsPresent: state.objectsPresent.includes(clean)
|
|
? state.objectsPresent
|
|
: [...state.objectsPresent, clean],
|
|
}
|
|
}
|
|
|
|
if (labels.clothing.includes(clean)) {
|
|
return {
|
|
...state,
|
|
clothingPresent: state.clothingPresent.includes(clean)
|
|
? state.clothingPresent
|
|
: [...state.clothingPresent, clean],
|
|
}
|
|
}
|
|
|
|
return state
|
|
}
|
|
|
|
function removeBoxLabelFromCorrection(
|
|
state: CorrectionState,
|
|
label: string,
|
|
labels: TrainingLabels
|
|
): CorrectionState {
|
|
const clean = String(label || '').trim()
|
|
if (!clean) return state
|
|
|
|
const remainingBoxesWithSameLabel = (state.boxes ?? []).some(
|
|
(box) => String(box.label || '').trim() === clean
|
|
)
|
|
|
|
// Badge nur abwählen, wenn keine weitere Box mit diesem Label existiert.
|
|
if (remainingBoxesWithSameLabel) return state
|
|
|
|
if (labels.people.includes(clean)) {
|
|
return {
|
|
...state,
|
|
peoplePresent: state.peoplePresent.filter((x) => x !== clean),
|
|
}
|
|
}
|
|
|
|
if (labels.bodyParts.includes(clean)) {
|
|
return {
|
|
...state,
|
|
bodyPartsPresent: state.bodyPartsPresent.filter((x) => x !== clean),
|
|
}
|
|
}
|
|
|
|
if (labels.objects.includes(clean)) {
|
|
return {
|
|
...state,
|
|
objectsPresent: state.objectsPresent.filter((x) => x !== clean),
|
|
}
|
|
}
|
|
|
|
if (labels.clothing.includes(clean)) {
|
|
return {
|
|
...state,
|
|
clothingPresent: state.clothingPresent.filter((x) => x !== clean),
|
|
}
|
|
}
|
|
|
|
return state
|
|
}
|
|
|
|
function removeBoxFromCorrection(
|
|
state: CorrectionState,
|
|
index: number,
|
|
labels: TrainingLabels
|
|
): CorrectionState {
|
|
const boxes = state.boxes ?? []
|
|
const removed = boxes[index]
|
|
|
|
if (!removed) return state
|
|
|
|
const removedLabel = String(removed.label || '').trim()
|
|
|
|
const next: CorrectionState = {
|
|
...state,
|
|
boxes: boxes.filter((_, i) => i !== index),
|
|
}
|
|
|
|
return removeBoxLabelFromCorrection(next, removedLabel, labels)
|
|
}
|
|
|
|
function changeBoxLabelInCorrection(
|
|
state: CorrectionState,
|
|
index: number,
|
|
nextLabel: string,
|
|
labels: TrainingLabels
|
|
): CorrectionState {
|
|
const boxes = state.boxes ?? []
|
|
const currentBox = boxes[index]
|
|
const cleanNextLabel = String(nextLabel || '').trim()
|
|
|
|
if (!currentBox || !cleanNextLabel) return state
|
|
|
|
const oldLabel = String(currentBox.label || '').trim()
|
|
if (oldLabel === cleanNextLabel) return state
|
|
|
|
let next: CorrectionState = {
|
|
...state,
|
|
boxes: boxes.map((box, i) => {
|
|
if (i !== index) return box
|
|
|
|
const { score: _oldScore, ...boxWithoutScore } = box
|
|
|
|
return {
|
|
...boxWithoutScore,
|
|
label: cleanNextLabel,
|
|
}
|
|
}),
|
|
}
|
|
|
|
next = removeBoxLabelFromCorrection(next, oldLabel, labels)
|
|
next = applyBoxLabelToCorrection(next, cleanNextLabel, labels)
|
|
|
|
return next
|
|
}
|
|
|
|
function sortLabelList(values?: string[], opts?: { keepUnknownFirst?: boolean }) {
|
|
const list = [...(values ?? [])].sort((a, b) =>
|
|
a.localeCompare(b, undefined, { sensitivity: 'base' })
|
|
)
|
|
|
|
if (!opts?.keepUnknownFirst) return list
|
|
|
|
return [
|
|
...list.filter((x) => x === 'unknown'),
|
|
...list.filter((x) => x !== 'unknown'),
|
|
]
|
|
}
|
|
|
|
function sortTrainingLabels(input: Partial<TrainingLabels> | null | undefined): TrainingLabels {
|
|
return {
|
|
people: sortLabelList(input?.people),
|
|
sexPositions: sortLabelList(input?.sexPositions, { keepUnknownFirst: true }),
|
|
bodyParts: sortLabelList(input?.bodyParts),
|
|
objects: sortLabelList(input?.objects),
|
|
clothing: sortLabelList(input?.clothing),
|
|
}
|
|
}
|
|
|
|
function TrainingOverlay(props: { step: string; progress: number }) {
|
|
return (
|
|
<div className="absolute inset-0 z-20 flex items-center justify-center text-center text-white">
|
|
<div className="absolute inset-0 rounded-lg bg-black/45 backdrop-blur-[8px] shadow-[inset_0_0_72px_30px_rgba(0,0,0,0.75)]" />
|
|
|
|
<div className="relative z-10 flex flex-col items-center justify-center">
|
|
<LoadingSpinner
|
|
size="lg"
|
|
className="text-white"
|
|
srLabel="Training läuft…"
|
|
/>
|
|
|
|
<div className="mt-3 text-sm font-semibold">
|
|
Training läuft…
|
|
</div>
|
|
|
|
<div className="mt-1 max-w-[260px] px-4 text-xs text-white/80">
|
|
{props.step || 'Bitte warten. Die Oberfläche ist währenddessen gesperrt.'}
|
|
</div>
|
|
|
|
<div className="mt-3 h-2 w-48 overflow-hidden rounded-full bg-white/20">
|
|
<div
|
|
className="h-full rounded-full bg-emerald-400 transition-all duration-500"
|
|
style={{ width: `${clampPercent(props.progress)}%` }}
|
|
/>
|
|
</div>
|
|
|
|
<div className="mt-1 text-[11px] text-white/70">
|
|
{Math.round(props.progress)}%
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function LoadingImageOverlay(props: {
|
|
text?: string
|
|
progress?: number
|
|
}) {
|
|
const progress = clampPercent(props.progress ?? 0)
|
|
|
|
return (
|
|
<div className="absolute inset-0 z-20 flex items-center justify-center text-center text-white">
|
|
<div className="absolute inset-0 rounded-lg bg-black/45 backdrop-blur-[8px] shadow-[inset_0_0_72px_30px_rgba(0,0,0,0.75)]" />
|
|
|
|
<div className="relative z-10 flex flex-col items-center justify-center">
|
|
<LoadingSpinner
|
|
size="lg"
|
|
className="text-white"
|
|
srLabel="Analyse läuft…"
|
|
/>
|
|
|
|
<div className="mt-3 text-sm font-semibold">
|
|
Analyse läuft…
|
|
</div>
|
|
|
|
<div className="mt-1 max-w-[260px] px-4 text-xs text-white/80">
|
|
{props.text || 'Bild wird erstellt und analysiert. Bitte warten.'}
|
|
</div>
|
|
|
|
<div className="mt-3 h-2 w-48 overflow-hidden rounded-full bg-white/20">
|
|
<div
|
|
className="h-full rounded-full bg-indigo-400 transition-all duration-500"
|
|
style={{ width: `${progress}%` }}
|
|
/>
|
|
</div>
|
|
|
|
<div className="mt-1 text-[11px] text-white/70">
|
|
{Math.round(progress)}%
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function labelTileClass(active: boolean) {
|
|
return [
|
|
'group flex min-h-[58px] w-full flex-col items-center justify-center gap-1 rounded-xl px-2 py-1.5 text-center text-[10px] font-semibold leading-tight ring-1 transition sm:min-h-[74px] sm:py-2',
|
|
'focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1 dark:focus:ring-offset-gray-900',
|
|
active
|
|
? [
|
|
'bg-indigo-100 text-indigo-900 ring-2 ring-indigo-500 shadow-sm',
|
|
'hover:bg-indigo-200',
|
|
'dark:bg-indigo-500/30 dark:text-indigo-50 dark:ring-indigo-300/70',
|
|
'dark:hover:bg-indigo-500/40',
|
|
].join(' ')
|
|
: [
|
|
'bg-white text-gray-700 ring-gray-200 hover:bg-gray-50 hover:text-gray-900',
|
|
'dark:bg-white/5 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10 dark:hover:text-white',
|
|
].join(' '),
|
|
].join(' ')
|
|
}
|
|
|
|
function LabelToggleGrid(props: {
|
|
values: string[]
|
|
selected: string[]
|
|
scores?: Record<string, number>
|
|
onToggle: (value: string) => void
|
|
drawLabel?: string
|
|
onDrawLabelChange?: (value: string) => void
|
|
disabled?: boolean
|
|
gridClassName?: string
|
|
}) {
|
|
if (props.values.length === 0) {
|
|
return (
|
|
<div className="rounded-lg bg-gray-50 px-3 py-2 text-[11px] text-gray-500 ring-1 ring-black/5 dark:bg-white/5 dark:text-gray-400 dark:ring-white/10">
|
|
Keine Einträge verfügbar.
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className={props.gridClassName || 'grid grid-cols-2 gap-2 sm:grid-cols-3'}>
|
|
{props.values.map((value) => {
|
|
const active = props.selected.includes(value)
|
|
const item = getSegmentLabelItem(value)
|
|
const Icon = item.icon
|
|
const score = props.scores?.[value]
|
|
const hasScore = typeof score === 'number' && Number.isFinite(score)
|
|
const isDrawLabel = props.drawLabel === value
|
|
|
|
return (
|
|
<button
|
|
key={value}
|
|
type="button"
|
|
aria-pressed={active || isDrawLabel}
|
|
title={
|
|
hasScore
|
|
? `${value} ${percent(score)}`
|
|
: isDrawLabel
|
|
? `${value} zum Zeichnen ausgewählt`
|
|
: value
|
|
}
|
|
disabled={props.disabled}
|
|
onClick={() => {
|
|
if (props.onDrawLabelChange) {
|
|
props.onDrawLabelChange(value)
|
|
return
|
|
}
|
|
|
|
props.onToggle(value)
|
|
}}
|
|
className={[
|
|
labelTileClass(active || isDrawLabel),
|
|
props.disabled ? 'cursor-not-allowed opacity-50' : '',
|
|
].join(' ')}
|
|
>
|
|
<Icon
|
|
className={[
|
|
'h-5 w-5 transition sm:h-6 sm:w-6',
|
|
active
|
|
? 'text-indigo-700 dark:text-indigo-100'
|
|
: 'text-gray-500 group-hover:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-200',
|
|
].join(' ')}
|
|
aria-hidden="true"
|
|
/>
|
|
|
|
<span className="line-clamp-2 max-w-full break-words">
|
|
{item.text}
|
|
</span>
|
|
|
|
{hasScore ? (
|
|
<span
|
|
className={[
|
|
'mt-0.5 rounded-full px-1.5 py-0.5 text-[9px] font-bold ring-1',
|
|
scoreDetectionPillClass(score),
|
|
].join(' ')}
|
|
>
|
|
{percent(score)}
|
|
</span>
|
|
) : isDrawLabel ? (
|
|
<span className="mt-0.5 rounded-full bg-amber-50 px-1.5 py-0.5 text-[9px] font-bold text-amber-700 ring-1 ring-amber-200 dark:bg-amber-500/15 dark:text-amber-200 dark:ring-amber-400/30">
|
|
zeichnen
|
|
</span>
|
|
) : active ? (
|
|
<span className="mt-0.5 rounded-full bg-indigo-50 px-1.5 py-0.5 text-[9px] font-bold text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-500/15 dark:text-indigo-200 dark:ring-indigo-400/30">
|
|
aktiv
|
|
</span>
|
|
) : null}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function DetectorBoxLabelSelect(props: {
|
|
values: string[]
|
|
value: string
|
|
disabled?: boolean
|
|
compact?: boolean
|
|
onChange: (value: string) => void
|
|
}) {
|
|
const [open, setOpen] = useState(false)
|
|
const [menuStyle, setMenuStyle] = useState<CSSProperties>({})
|
|
const buttonRef = useRef<HTMLButtonElement | null>(null)
|
|
|
|
const selectedItem = getSegmentLabelItem(props.value)
|
|
const SelectedIcon = selectedItem.icon
|
|
|
|
const close = useCallback(() => {
|
|
setOpen(false)
|
|
}, [])
|
|
|
|
const updateMenuPosition = useCallback(() => {
|
|
const button = buttonRef.current
|
|
if (!button) return
|
|
|
|
const rect = button.getBoundingClientRect()
|
|
const viewportH = window.innerHeight
|
|
const viewportW = window.innerWidth
|
|
const spaceBelow = viewportH - rect.bottom
|
|
const spaceAbove = rect.top
|
|
const openUp = spaceBelow < 180 && spaceAbove > spaceBelow
|
|
const maxHeight = openUp
|
|
? Math.min(260, Math.max(140, spaceAbove - 12))
|
|
: Math.min(260, Math.max(140, spaceBelow - 12))
|
|
|
|
setMenuStyle({
|
|
position: 'fixed',
|
|
left: Math.max(8, Math.min(rect.left, viewportW - rect.width - 8)),
|
|
top: openUp ? undefined : rect.bottom + 4,
|
|
bottom: openUp ? viewportH - rect.top + 4 : undefined,
|
|
width: rect.width,
|
|
maxHeight,
|
|
zIndex: 2147483647,
|
|
})
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
|
|
updateMenuPosition()
|
|
|
|
const onPointerDown = (e: PointerEvent) => {
|
|
const target = e.target as HTMLElement | null
|
|
if (target?.closest('[data-detector-label-select="true"]')) return
|
|
close()
|
|
}
|
|
|
|
const onUpdate = () => updateMenuPosition()
|
|
|
|
window.addEventListener('pointerdown', onPointerDown)
|
|
window.addEventListener('resize', onUpdate)
|
|
window.addEventListener('scroll', onUpdate, true)
|
|
|
|
return () => {
|
|
window.removeEventListener('pointerdown', onPointerDown)
|
|
window.removeEventListener('resize', onUpdate)
|
|
window.removeEventListener('scroll', onUpdate, true)
|
|
}
|
|
}, [open, close, updateMenuPosition])
|
|
|
|
if (props.values.length === 0) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
disabled
|
|
className="mt-2 flex h-9 w-full items-center rounded-md border border-gray-200 bg-white px-2 text-sm text-gray-400 disabled:cursor-not-allowed dark:border-white/10 dark:bg-gray-950 dark:text-gray-500"
|
|
>
|
|
Keine Box-Labels
|
|
</button>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={props.compact ? 'mt-1.5' : 'mt-2'}
|
|
data-detector-label-select="true"
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<button
|
|
ref={buttonRef}
|
|
type="button"
|
|
disabled={props.disabled}
|
|
onClick={() => {
|
|
if (props.disabled) return
|
|
updateMenuPosition()
|
|
setOpen((value) => !value)
|
|
}}
|
|
className={[
|
|
'flex w-full items-center justify-between gap-2 rounded-xl border px-2.5 text-gray-900 shadow-sm outline-none transition',
|
|
'focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30 disabled:cursor-not-allowed disabled:opacity-50',
|
|
'border-gray-200 bg-white',
|
|
'dark:border-white/10 dark:bg-white/[0.05] dark:text-gray-100 dark:hover:bg-white/[0.07]',
|
|
props.compact ? 'h-8 text-xs' : 'h-9 text-sm',
|
|
].join(' ')}
|
|
aria-haspopup="listbox"
|
|
aria-expanded={open}
|
|
>
|
|
<span className="flex min-w-0 items-center gap-2">
|
|
<SelectedIcon
|
|
className="h-4 w-4 shrink-0 text-gray-600 dark:text-gray-300"
|
|
aria-hidden="true"
|
|
/>
|
|
|
|
<span className="truncate">
|
|
{selectedItem.text}
|
|
</span>
|
|
</span>
|
|
|
|
<span
|
|
className={[
|
|
'shrink-0 text-sm text-gray-500 transition-transform dark:text-gray-400',
|
|
open ? 'rotate-180' : '',
|
|
].join(' ')}
|
|
aria-hidden="true"
|
|
>
|
|
▾
|
|
</span>
|
|
</button>
|
|
|
|
{open && typeof document !== 'undefined'
|
|
? createPortal(
|
|
<div
|
|
role="listbox"
|
|
data-detector-label-select="true"
|
|
style={menuStyle}
|
|
className={[
|
|
'overflow-y-auto rounded-xl border border-gray-200 bg-white py-1 shadow-2xl ring-1 ring-black/10',
|
|
'dark:border-white/10 dark:bg-gray-900 dark:ring-white/15',
|
|
].join(' ')}
|
|
>
|
|
{props.values.map((value) => {
|
|
const active = value === props.value
|
|
const item = getSegmentLabelItem(value)
|
|
const Icon = item.icon
|
|
|
|
return (
|
|
<button
|
|
key={value}
|
|
type="button"
|
|
role="option"
|
|
aria-selected={active}
|
|
onClick={() => {
|
|
props.onChange(value)
|
|
setOpen(false)
|
|
}}
|
|
className={[
|
|
'flex w-full items-center gap-2 px-2 py-2 text-left text-sm transition',
|
|
active
|
|
? 'bg-indigo-50 text-indigo-900 dark:bg-indigo-500/20 dark:text-indigo-100'
|
|
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-200 dark:hover:bg-white/10 dark:hover:text-white',
|
|
].join(' ')}
|
|
>
|
|
<Icon
|
|
className={[
|
|
'h-4 w-4 shrink-0',
|
|
active
|
|
? 'text-indigo-700 dark:text-indigo-100'
|
|
: 'text-gray-600 dark:text-gray-300',
|
|
].join(' ')}
|
|
aria-hidden="true"
|
|
/>
|
|
|
|
<span className="min-w-0 flex-1 truncate">
|
|
{item.text}
|
|
</span>
|
|
|
|
<span className="shrink-0 text-[10px] text-gray-400">
|
|
{value}
|
|
</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>,
|
|
document.body
|
|
)
|
|
: null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CollapsibleSingleLabelSection(props: {
|
|
title: string
|
|
values: string[]
|
|
value: string
|
|
score?: number
|
|
predictionValue?: string
|
|
expanded: boolean
|
|
onExpandedChange: (expanded: boolean) => void
|
|
onChange: (value: string) => void
|
|
disabled?: boolean
|
|
gridClassName?: string
|
|
}) {
|
|
const currentValue = String(props.value || 'unknown').trim() || 'unknown'
|
|
const selectedItem = getSegmentLabelItem(currentValue)
|
|
const SelectedIcon = selectedItem.icon
|
|
const shown = props.expanded
|
|
const hasSelection = currentValue !== '' && currentValue !== 'unknown'
|
|
|
|
return (
|
|
<div className="rounded-lg bg-gray-50 p-2 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center justify-between gap-2 text-left"
|
|
onClick={() => props.onExpandedChange(!props.expanded)}
|
|
aria-expanded={shown}
|
|
>
|
|
<div className="min-w-0">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<SelectedIcon
|
|
className={[
|
|
'h-4 w-4 shrink-0',
|
|
hasSelection
|
|
? 'text-indigo-600 dark:text-indigo-300'
|
|
: 'text-gray-500 dark:text-gray-400',
|
|
].join(' ')}
|
|
aria-hidden="true"
|
|
/>
|
|
|
|
<div className="min-w-0 truncate text-xs font-medium text-gray-700 dark:text-gray-200">
|
|
{props.title}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-0.5 text-[10px] text-gray-500 dark:text-gray-400">
|
|
{hasSelection
|
|
? selectedItem.text
|
|
: shown
|
|
? 'Zum Einklappen klicken'
|
|
: 'Zum Ausklappen klicken'}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
{typeof props.score === 'number' && Number.isFinite(props.score) ? (
|
|
<span
|
|
className={[
|
|
'rounded-full px-1.5 py-0.5 text-[10px] font-bold ring-1',
|
|
scoreDetectionPillClass(props.score),
|
|
].join(' ')}
|
|
title={
|
|
props.predictionValue
|
|
? `Modell: ${props.predictionValue}`
|
|
: 'Modell-Vorhersage'
|
|
}
|
|
>
|
|
{percent(props.score)}
|
|
</span>
|
|
) : null}
|
|
|
|
{hasSelection ? (
|
|
<span className="rounded-full bg-indigo-100 px-2 py-0.5 text-[11px] font-semibold text-indigo-800 ring-1 ring-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-100 dark:ring-indigo-300/30">
|
|
aktiv
|
|
</span>
|
|
) : null}
|
|
|
|
<span
|
|
className={[
|
|
'text-sm text-gray-500 transition-transform dark:text-gray-400',
|
|
shown ? 'rotate-180' : '',
|
|
].join(' ')}
|
|
aria-hidden="true"
|
|
>
|
|
▾
|
|
</span>
|
|
</div>
|
|
</button>
|
|
|
|
{shown ? (
|
|
<div className="mt-2">
|
|
{props.values.length === 0 ? (
|
|
<div className="rounded-lg bg-gray-50 px-3 py-2 text-[11px] text-gray-500 ring-1 ring-black/5 dark:bg-white/5 dark:text-gray-400 dark:ring-white/10">
|
|
Keine Einträge verfügbar.
|
|
</div>
|
|
) : (
|
|
<div className={props.gridClassName || 'grid grid-cols-2 gap-2'}>
|
|
{props.values.map((value) => {
|
|
const active = value === currentValue
|
|
const item = getSegmentLabelItem(value)
|
|
const Icon = item.icon
|
|
const isPrediction = value === props.predictionValue
|
|
|
|
return (
|
|
<button
|
|
key={value}
|
|
type="button"
|
|
aria-pressed={active}
|
|
title={value}
|
|
disabled={props.disabled}
|
|
onClick={() => props.onChange(value)}
|
|
className={[
|
|
labelTileClass(active),
|
|
props.disabled ? 'cursor-not-allowed opacity-50' : '',
|
|
].join(' ')}
|
|
>
|
|
<Icon
|
|
className={[
|
|
'h-5 w-5 transition sm:h-6 sm:w-6',
|
|
active
|
|
? 'text-indigo-700 dark:text-indigo-100'
|
|
: 'text-gray-500 group-hover:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-200',
|
|
].join(' ')}
|
|
aria-hidden="true"
|
|
/>
|
|
|
|
<span className="line-clamp-2 max-w-full break-words">
|
|
{item.text}
|
|
</span>
|
|
|
|
{active ? (
|
|
<span className="mt-0.5 rounded-full bg-indigo-50 px-1.5 py-0.5 text-[9px] font-bold text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-500/15 dark:text-indigo-200 dark:ring-indigo-400/30">
|
|
aktiv
|
|
</span>
|
|
) : isPrediction && typeof props.score === 'number' ? (
|
|
<span
|
|
className={[
|
|
'mt-0.5 rounded-full px-1.5 py-0.5 text-[9px] font-bold ring-1',
|
|
scoreDetectionPillClass(props.score),
|
|
].join(' ')}
|
|
>
|
|
Modell {percent(props.score)}
|
|
</span>
|
|
) : null}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CollapsibleLabelSection(props: {
|
|
title: string
|
|
values: string[]
|
|
selected: string[]
|
|
scores?: Record<string, number>
|
|
expanded: boolean
|
|
onExpandedChange: (expanded: boolean) => void
|
|
onToggle: (value: string) => void
|
|
drawLabel?: string
|
|
onDrawLabelChange?: (value: string) => void
|
|
disabled?: boolean
|
|
singleDrawMode?: boolean
|
|
gridClassName?: string
|
|
}) {
|
|
const cleanDrawLabel = String(props.drawLabel || '').trim()
|
|
const hasDrawLabelInSection =
|
|
cleanDrawLabel !== '' && props.values.includes(cleanDrawLabel)
|
|
|
|
const activeCount = props.singleDrawMode
|
|
? Math.max(props.selected.length, hasDrawLabelInSection ? 1 : 0)
|
|
: props.selected.length
|
|
|
|
const hasActiveItems = activeCount > 0
|
|
const shown = props.expanded
|
|
|
|
return (
|
|
<div className="rounded-lg bg-gray-50 p-2 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center justify-between gap-2 text-left"
|
|
onClick={() => props.onExpandedChange(!props.expanded)}
|
|
aria-expanded={shown}
|
|
>
|
|
<div className="min-w-0">
|
|
<div className="text-xs font-medium text-gray-700 dark:text-gray-200">
|
|
{props.title}
|
|
</div>
|
|
|
|
<div className="mt-0.5 text-[10px] text-gray-500 dark:text-gray-400">
|
|
{hasActiveItems
|
|
? `${activeCount} aktiv`
|
|
: shown
|
|
? 'Zum Einklappen klicken'
|
|
: 'Zum Ausklappen klicken'}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
{hasActiveItems ? (
|
|
<span className="rounded-full bg-indigo-100 px-2 py-0.5 text-[11px] font-semibold text-indigo-800 ring-1 ring-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-100 dark:ring-indigo-300/30">
|
|
{activeCount}
|
|
</span>
|
|
) : null}
|
|
|
|
<span
|
|
className={[
|
|
'text-sm text-gray-500 transition-transform dark:text-gray-400',
|
|
shown ? 'rotate-180' : '',
|
|
].join(' ')}
|
|
aria-hidden="true"
|
|
>
|
|
▾
|
|
</span>
|
|
</div>
|
|
</button>
|
|
|
|
{shown ? (
|
|
<div className="mt-2">
|
|
<LabelToggleGrid
|
|
values={props.values}
|
|
selected={props.selected}
|
|
scores={props.scores}
|
|
onToggle={props.onToggle}
|
|
drawLabel={props.drawLabel}
|
|
onDrawLabelChange={props.onDrawLabelChange}
|
|
disabled={props.disabled}
|
|
gridClassName={props.gridClassName}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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-xl 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
|
|
shortTitle: string
|
|
description: string
|
|
values: TrainingLabelStat[]
|
|
total: number
|
|
confidence?: TrainingConfidence
|
|
}> = [
|
|
{
|
|
key: 'people',
|
|
title: 'Personen',
|
|
shortTitle: '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',
|
|
shortTitle: 'Positionen',
|
|
description: 'Positions-Labels aus YOLO-Detector-Labels pro bewertetem Frame.',
|
|
values: stats?.labels.sexPositions ?? [],
|
|
total: Math.max(1, totalFeedback),
|
|
confidence: averageCategoryConfidence(stats?.labels.sexPositions ?? []),
|
|
},
|
|
{
|
|
key: 'bodyParts',
|
|
title: 'Körperteile',
|
|
shortTitle: 'Körper',
|
|
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',
|
|
shortTitle: 'Objekte',
|
|
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',
|
|
shortTitle: '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-0"
|
|
>
|
|
<div className="px-3 pb-4 pt-2 sm:px-6 sm:pb-6 sm:pt-4">
|
|
{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-3 sm:space-y-4">
|
|
{/* Mobile: kompakte Top-Zusammenfassung */}
|
|
<div className="sm:hidden">
|
|
<div className="rounded-xl border border-gray-200 bg-white p-3 shadow-sm dark:border-white/10 dark:bg-gray-900/70">
|
|
<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">
|
|
{stats?.modelAvailable
|
|
? 'Modell verfügbar'
|
|
: 'Noch kein Modell'}
|
|
</div>
|
|
|
|
<div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
|
{totalFeedback} Feedback · {boxCount} Boxen · {sampleCount} Samples
|
|
</div>
|
|
</div>
|
|
|
|
<span
|
|
className={[
|
|
'shrink-0 rounded-full px-2.5 py-1 text-xs font-bold ring-1',
|
|
confidencePillClass(overallConfidence),
|
|
].join(' ')}
|
|
title={`Daten-Confidence: ${confidencePercent(overallConfidence)}`}
|
|
>
|
|
{confidencePercent(overallConfidence)}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="mt-3 h-2 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-3 grid grid-cols-4 gap-1.5">
|
|
<div className="rounded-xl bg-gray-50 px-2 py-1.5 text-center ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
|
|
<div className="text-[9px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
|
Feedback
|
|
</div>
|
|
<div className="mt-0.5 text-sm font-black text-gray-900 dark:text-white">
|
|
{totalFeedback}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-xl bg-gray-50 px-2 py-1.5 text-center ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
|
|
<div className="text-[9px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
|
Passt
|
|
</div>
|
|
<div className="mt-0.5 text-sm font-black text-emerald-700 dark:text-emerald-300">
|
|
{acceptedCount}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-xl bg-gray-50 px-2 py-1.5 text-center ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
|
|
<div className="text-[9px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
|
Korr.
|
|
</div>
|
|
<div className="mt-0.5 text-sm font-black text-amber-700 dark:text-amber-300">
|
|
{correctedCount}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-xl bg-gray-50 px-2 py-1.5 text-center ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
|
|
<div className="text-[9px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
|
Boxen
|
|
</div>
|
|
<div className="mt-0.5 text-sm font-black text-gray-900 dark:text-white">
|
|
{boxCount}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Desktop/Tablet: ausführliche Karten */}
|
|
<div className="hidden sm:grid sm:grid-cols-4 sm:gap-2">
|
|
<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="hidden grid-cols-1 gap-3 sm:grid sm:grid-cols-2">
|
|
<div className="rounded-xl 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-xl 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">
|
|
Daten-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">
|
|
Daten-Confidence aus Feedback-Menge, Boxen, Label-Abdeckung und Korrekturanteil. Kein direkter Modell-Qualitätswert.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mobile: Tabs kompakter, direkt nach Summary */}
|
|
<div className="overflow-hidden rounded-xl border border-gray-200 bg-gray-50/70 p-1.5 dark:border-white/10 dark:bg-white/[0.03] sm:p-2">
|
|
<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">
|
|
<span className="sm:hidden">{item.shortTitle}</span>
|
|
<span className="hidden sm:inline">{item.title}</span>
|
|
</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(props: {
|
|
onTrainingRunningChange?: (running: boolean) => void
|
|
}) {
|
|
const [labels, setLabels] = useState<TrainingLabels>(emptyLabels)
|
|
const [sample, setSample] = useState<TrainingSample | null>(null)
|
|
const [correction, setCorrection] = useState<CorrectionState>(() => predictionToCorrection(null))
|
|
const [hasManualCorrection, setHasManualCorrection] = useState(false)
|
|
const [loading, setLoading] = useState(false)
|
|
const [analysisProgress, setAnalysisProgress] = useState(0)
|
|
const [analysisStep, setAnalysisStep] = useState('')
|
|
const [saving, setSaving] = useState(false)
|
|
const [training, setTraining] = useState(false)
|
|
const [trainingStatus, setTrainingStatus] = useState<TrainingStatus | null>(null)
|
|
const [deletingTrainingData, setDeletingTrainingData] = useState(false)
|
|
const [trainingProgress, setTrainingProgress] = useState(0)
|
|
const [trainingStep, setTrainingStep] = useState('')
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [message, setMessage] = useState<string | null>(null)
|
|
const [statsModalOpen, setStatsModalOpen] = useState(false)
|
|
const [cancellingTraining, setCancellingTraining] = 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 shownTrainingCompletionRef = useRef<string | null>(null)
|
|
|
|
const [frameImageLoaded, setFrameImageLoaded] = useState(false)
|
|
|
|
const imageBoxRef = useRef<HTMLDivElement | null>(null)
|
|
|
|
const detectorBoxesScrollRef = useRef<HTMLDivElement | null>(null)
|
|
const detectorBoxItemRefs = useRef<Array<HTMLDivElement | null>>([])
|
|
|
|
const epochTimingRef = useRef<{
|
|
firstEpochAt: number
|
|
lastEpoch: number
|
|
lastAt: number
|
|
}>({
|
|
firstEpochAt: 0,
|
|
lastEpoch: 0,
|
|
lastAt: 0,
|
|
})
|
|
|
|
const etaSmoothingRef = useRef<{
|
|
lastAt: number
|
|
}>({
|
|
lastAt: 0,
|
|
})
|
|
|
|
const [trainingNowMs, setTrainingNowMs] = useState(() => Date.now())
|
|
const [smoothedTrainingEtaMs, setSmoothedTrainingEtaMs] = useState(0)
|
|
|
|
const [estimatedEpochMs, setEstimatedEpochMs] = useState(0)
|
|
|
|
const [drawingBox, setDrawingBox] = useState<DrawingTrainingBox | null>(null)
|
|
const [boxInteraction, setBoxInteraction] = useState<BoxInteraction | null>(null)
|
|
const [touchMagnifier, setTouchMagnifier] = useState<MagnifierState | null>(null)
|
|
const [boxLabel, setBoxLabel] = useState('')
|
|
const [activeBoxIndex, setActiveBoxIndex] = useState<number | null>(null)
|
|
const [imageReloadKey, setImageReloadKey] = useState(0)
|
|
const [expandedCorrectionSections, setExpandedCorrectionSections] = useState({
|
|
sexPosition: false,
|
|
people: false,
|
|
bodyParts: false,
|
|
objects: false,
|
|
clothing: false,
|
|
})
|
|
|
|
const [mobilePanel, setMobilePanel] = useState<'labels' | 'boxes' | 'training'>('labels')
|
|
|
|
const mobileLabelsScrollRef = useRef<HTMLDivElement | null>(null)
|
|
|
|
const mobileSectionRefs = useRef<
|
|
Record<keyof typeof expandedCorrectionSections, HTMLDivElement | null>
|
|
>({
|
|
sexPosition: null,
|
|
people: null,
|
|
bodyParts: null,
|
|
objects: null,
|
|
clothing: null,
|
|
})
|
|
|
|
const scrollMobileSectionToTop = useCallback(
|
|
(key: keyof typeof expandedCorrectionSections) => {
|
|
window.requestAnimationFrame(() => {
|
|
const scrollEl = mobileLabelsScrollRef.current
|
|
const sectionEl = mobileSectionRefs.current[key]
|
|
|
|
if (!scrollEl || !sectionEl) return
|
|
|
|
const scrollRect = scrollEl.getBoundingClientRect()
|
|
const sectionRect = sectionEl.getBoundingClientRect()
|
|
|
|
scrollEl.scrollTo({
|
|
top: scrollEl.scrollTop + sectionRect.top - scrollRect.top,
|
|
behavior: 'smooth',
|
|
})
|
|
})
|
|
},
|
|
[]
|
|
)
|
|
|
|
const toggleMobileCorrectionSection = useCallback(
|
|
(key: keyof typeof expandedCorrectionSections, expanded: boolean) => {
|
|
setExpandedCorrectionSections((p) => ({
|
|
...p,
|
|
[key]: expanded,
|
|
}))
|
|
|
|
if (expanded && mobilePanel === 'labels') {
|
|
scrollMobileSectionToTop(key)
|
|
}
|
|
},
|
|
[mobilePanel, scrollMobileSectionToTop]
|
|
)
|
|
|
|
const labelsRef = useRef<TrainingLabels>(emptyLabels)
|
|
|
|
useEffect(() => {
|
|
labelsRef.current = labels
|
|
}, [labels])
|
|
|
|
const boxLabels = useMemo(() => {
|
|
return uniqStrings([
|
|
...labels.people,
|
|
...labels.bodyParts,
|
|
...labels.objects,
|
|
...labels.clothing,
|
|
]).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
|
|
}, [labels.people, labels.bodyParts, labels.objects, labels.clothing])
|
|
|
|
const correctionBoxes = correction.boxes ?? []
|
|
|
|
const visibleBoxes = [
|
|
...correctionBoxes.map((box, index) => ({ box, index, isDraft: false })),
|
|
...(drawingBox
|
|
? [{ box: drawingBox, index: -1, isDraft: true }]
|
|
: []),
|
|
]
|
|
|
|
const bodyPartScores = useMemo(() => {
|
|
return Object.fromEntries(
|
|
(sample?.prediction.bodyPartsPresent ?? [])
|
|
.filter((x) => x.label)
|
|
.map((x) => [x.label, x.score])
|
|
)
|
|
}, [sample?.prediction.bodyPartsPresent])
|
|
|
|
const objectScores = useMemo(() => {
|
|
return Object.fromEntries(
|
|
(sample?.prediction.objectsPresent ?? [])
|
|
.filter((x) => x.label)
|
|
.map((x) => [x.label, x.score])
|
|
)
|
|
}, [sample?.prediction.objectsPresent])
|
|
|
|
const clothingScores = useMemo(() => {
|
|
return Object.fromEntries(
|
|
(sample?.prediction.clothingPresent ?? [])
|
|
.filter((x) => x.label)
|
|
.map((x) => [x.label, x.score])
|
|
)
|
|
}, [sample?.prediction.clothingPresent])
|
|
|
|
const peopleScores = useMemo(() => {
|
|
const bestScores = new Map<string, number>()
|
|
|
|
for (const box of sample?.prediction.boxes ?? []) {
|
|
const cleanLabel = String(box.label || '').trim()
|
|
const n = Number(box.score)
|
|
|
|
if (!cleanLabel || !labels.people.includes(cleanLabel)) continue
|
|
if (!Number.isFinite(n)) continue
|
|
|
|
const current = bestScores.get(cleanLabel)
|
|
if (current === undefined || n > current) {
|
|
bestScores.set(cleanLabel, n)
|
|
}
|
|
}
|
|
|
|
return Object.fromEntries(bestScores)
|
|
}, [sample?.prediction.boxes, labels.people])
|
|
|
|
const selectedPeopleLabels = useMemo(() => {
|
|
return correction.peoplePresent
|
|
}, [correction.peoplePresent])
|
|
|
|
const drawLabelForSection = useCallback((values: string[]) => {
|
|
const clean = String(boxLabel || '').trim()
|
|
return values.includes(clean) ? clean : ''
|
|
}, [boxLabel])
|
|
|
|
const imageSrc = useMemo(() => {
|
|
if (!sample?.frameUrl) return ''
|
|
|
|
return `${sample.frameUrl}&t=${encodeURIComponent(sample.sampleId)}&r=${imageReloadKey}`
|
|
}, [sample, imageReloadKey])
|
|
|
|
useEffect(() => {
|
|
if (!imageSrc) {
|
|
setFrameImageLoaded(false)
|
|
return
|
|
}
|
|
|
|
setFrameImageLoaded(false)
|
|
}, [imageSrc])
|
|
|
|
const canStartTraining = Boolean(trainingStatus?.canTrain)
|
|
const feedbackCount = trainingStatus?.feedbackCount ?? 0
|
|
const requiredCount = trainingStatus?.requiredCount ?? 5
|
|
|
|
const trainingRunning = training || Boolean(trainingStatus?.training?.running)
|
|
const uiLocked = loading || saving || trainingRunning || deletingTrainingData
|
|
const shownTrainingProgress = trainingRunning
|
|
? trainingStatus?.training?.progress ?? trainingProgress
|
|
: trainingProgress
|
|
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' })
|
|
if (!res.ok) return
|
|
|
|
const data = await res.json().catch(() => null)
|
|
if (data) setLabels(sortTrainingLabels(data))
|
|
}, [])
|
|
|
|
const applyTrainingStatus = useCallback((data: any) => {
|
|
if (!data) return
|
|
|
|
const job = data.training || null
|
|
|
|
setTrainingStatus((prev) => ({
|
|
feedbackCount: Number(data.feedbackCount ?? prev?.feedbackCount ?? 0),
|
|
requiredCount: Number(data.requiredCount ?? prev?.requiredCount ?? 5),
|
|
canTrain: Boolean(data.canTrain ?? prev?.canTrain ?? false),
|
|
|
|
detector: data.detector
|
|
? {
|
|
trainCount: Number(data.detector.trainCount ?? 0),
|
|
valCount: Number(data.detector.valCount ?? 0),
|
|
requiredTrain: Number(data.detector.requiredTrain ?? 20),
|
|
requiredVal: Number(data.detector.requiredVal ?? 3),
|
|
datasetReady: Boolean(data.detector.datasetReady),
|
|
dataReady: Boolean(data.detector.dataReady),
|
|
modelExists: Boolean(data.detector.modelExists),
|
|
modelPath: data.detector.modelPath,
|
|
source: data.detector.source,
|
|
}
|
|
: prev?.detector,
|
|
|
|
training: job
|
|
? {
|
|
running: Boolean(job.running),
|
|
progress: Number(job.progress ?? 0),
|
|
step: String(job.step ?? ''),
|
|
message: job.message,
|
|
error: job.error,
|
|
startedAt: job.startedAt,
|
|
finishedAt: job.finishedAt,
|
|
durationMs: Number(job.durationMs ?? 0),
|
|
stage: job.stage,
|
|
epoch: Number(job.epoch ?? 0),
|
|
epochs: Number(job.epochs ?? 0),
|
|
}
|
|
: prev?.training,
|
|
}))
|
|
|
|
setTraining(Boolean(job?.running))
|
|
|
|
if (job?.message && !job.running) {
|
|
setTrainingProgress(100)
|
|
setTrainingStep('Training abgeschlossen.')
|
|
|
|
const finishedAt = String(job.finishedAt || '').trim()
|
|
const completionKey =
|
|
finishedAt || `${String(job.startedAt || '')}:${String(job.message || '')}`
|
|
|
|
if (completionKey && shownTrainingCompletionRef.current !== completionKey) {
|
|
shownTrainingCompletionRef.current = completionKey
|
|
|
|
const duration = trainingDurationMs(job)
|
|
const durationText = duration > 0
|
|
? ` Dauer: ${formatDuration(duration)}.`
|
|
: ''
|
|
|
|
setMessage(`${String(job.message)}${durationText}`)
|
|
}
|
|
}
|
|
|
|
if (job?.error && !job.running) {
|
|
const finishedAt = String(job.finishedAt || '').trim()
|
|
const errorKey = finishedAt
|
|
? `${finishedAt}:error`
|
|
: `${String(job.startedAt || '')}:error`
|
|
|
|
if (errorKey && shownTrainingCompletionRef.current !== errorKey) {
|
|
shownTrainingCompletionRef.current = errorKey
|
|
setError(String(job.error))
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
const loadNext = useCallback(async (opts?: {
|
|
forceNew?: boolean
|
|
refreshPrediction?: boolean
|
|
preserveNotice?: boolean
|
|
}) => {
|
|
setLoading(true)
|
|
setAnalysisProgress(8)
|
|
setAnalysisStep(
|
|
opts?.refreshPrediction
|
|
? 'Aktuelles Bild wird neu analysiert…'
|
|
: opts?.forceNew
|
|
? 'Neues Trainingsbild wird gesucht…'
|
|
: 'Trainingsbild wird geladen…'
|
|
)
|
|
|
|
if (!opts?.preserveNotice) {
|
|
setError(null)
|
|
setMessage(null)
|
|
}
|
|
|
|
try {
|
|
const params = new URLSearchParams()
|
|
|
|
if (opts?.forceNew) params.set('force', '1')
|
|
if (opts?.refreshPrediction) params.set('refresh', '1')
|
|
|
|
const url = `/api/training/next${params.toString() ? `?${params.toString()}` : ''}`
|
|
|
|
setAnalysisProgress(25)
|
|
setAnalysisStep('Bild wird vorbereitet…')
|
|
|
|
const res = await fetch(url, { cache: 'no-store' })
|
|
const data = await res.json().catch(() => null)
|
|
|
|
if (!res.ok) {
|
|
throw new Error(data?.error || `HTTP ${res.status}`)
|
|
}
|
|
|
|
setAnalysisProgress(92)
|
|
setAnalysisStep('Analyse-Ergebnis wird übernommen…')
|
|
|
|
const nextCorrection = predictionToCorrection(data)
|
|
|
|
setDrawingBox(null)
|
|
setBoxInteraction(null)
|
|
setTouchMagnifier(null)
|
|
setBoxLabel('')
|
|
setActiveBoxIndex(null)
|
|
setMobilePanel('labels')
|
|
|
|
window.requestAnimationFrame(() => {
|
|
mobileLabelsScrollRef.current?.scrollTo({
|
|
top: 0,
|
|
behavior: 'smooth',
|
|
})
|
|
})
|
|
|
|
setSample(data)
|
|
setCorrection(nextCorrection)
|
|
setHasManualCorrection(false)
|
|
|
|
setExpandedCorrectionSections({
|
|
sexPosition:
|
|
Boolean(nextCorrection.sexPosition) &&
|
|
nextCorrection.sexPosition !== 'unknown',
|
|
people: nextCorrection.peoplePresent.length > 0,
|
|
bodyParts: nextCorrection.bodyPartsPresent.length > 0,
|
|
objects: nextCorrection.objectsPresent.length > 0,
|
|
clothing: nextCorrection.clothingPresent.length > 0,
|
|
})
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e))
|
|
} finally {
|
|
setAnalysisProgress((value) => Math.max(value, 100))
|
|
setAnalysisStep((value) => value || 'Analyse abgeschlossen.')
|
|
|
|
window.setTimeout(() => {
|
|
setLoading(false)
|
|
setAnalysisProgress(0)
|
|
setAnalysisStep('')
|
|
}, 500)
|
|
}
|
|
}, [])
|
|
|
|
const reloadCurrentImage = useCallback(async () => {
|
|
setDrawingBox(null)
|
|
setBoxInteraction(null)
|
|
setTouchMagnifier(null)
|
|
setActiveBoxIndex(null)
|
|
|
|
await loadNext({ refreshPrediction: true })
|
|
setImageReloadKey((value) => value + 1)
|
|
}, [loadNext])
|
|
|
|
const loadTrainingStatus = useCallback(async () => {
|
|
const res = await fetch('/api/training/status', { cache: 'no-store' })
|
|
const data = await res.json().catch(() => null)
|
|
|
|
if (!res.ok || !data) return
|
|
|
|
applyTrainingStatus(data)
|
|
}, [applyTrainingStatus])
|
|
|
|
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(() => {
|
|
const es = new EventSource('/api/events/stream')
|
|
|
|
const onTraining = (ev: MessageEvent) => {
|
|
try {
|
|
const data = JSON.parse(String(ev.data ?? 'null'))
|
|
|
|
if (data?.type !== 'training_status') return
|
|
|
|
applyTrainingStatus({
|
|
training: data.training,
|
|
})
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
es.addEventListener('training', onTraining as EventListener)
|
|
|
|
es.onerror = () => {
|
|
// Optional: Polling-Fallback bleibt separat bestehen.
|
|
}
|
|
|
|
return () => {
|
|
es.removeEventListener('training', onTraining as EventListener)
|
|
es.close()
|
|
}
|
|
}, [applyTrainingStatus])
|
|
|
|
useEffect(() => {
|
|
const draggingBox = Boolean(drawingBox || boxInteraction)
|
|
|
|
if (!draggingBox) return
|
|
|
|
const previousUserSelect = document.body.style.userSelect
|
|
const previousWebkitUserSelect = document.body.style.webkitUserSelect
|
|
|
|
document.body.style.userSelect = 'none'
|
|
document.body.style.webkitUserSelect = 'none'
|
|
|
|
const clearSelection = () => {
|
|
window.getSelection()?.removeAllRanges()
|
|
}
|
|
|
|
const preventSelection = (event: Event) => {
|
|
event.preventDefault()
|
|
}
|
|
|
|
clearSelection()
|
|
|
|
document.addEventListener('selectionchange', clearSelection)
|
|
document.addEventListener('selectstart', preventSelection)
|
|
|
|
return () => {
|
|
document.body.style.userSelect = previousUserSelect
|
|
document.body.style.webkitUserSelect = previousWebkitUserSelect
|
|
|
|
document.removeEventListener('selectionchange', clearSelection)
|
|
document.removeEventListener('selectstart', preventSelection)
|
|
|
|
clearSelection()
|
|
}
|
|
}, [drawingBox, boxInteraction])
|
|
|
|
useEffect(() => {
|
|
if (!statsModalOpen) return
|
|
|
|
void loadTrainingStats()
|
|
}, [statsModalOpen, loadTrainingStats])
|
|
|
|
const onTrainingRunningChange = props.onTrainingRunningChange
|
|
|
|
useEffect(() => {
|
|
onTrainingRunningChange?.(trainingRunning)
|
|
}, [trainingRunning, onTrainingRunningChange])
|
|
|
|
useEffect(() => {
|
|
if (!trainingRunning) {
|
|
etaSmoothingRef.current = {
|
|
lastAt: 0,
|
|
}
|
|
|
|
setSmoothedTrainingEtaMs(0)
|
|
return
|
|
}
|
|
|
|
setTrainingNowMs(Date.now())
|
|
|
|
const timer = window.setInterval(() => {
|
|
setTrainingNowMs(Date.now())
|
|
}, 1000)
|
|
|
|
return () => window.clearInterval(timer)
|
|
}, [trainingRunning])
|
|
|
|
useEffect(() => {
|
|
if (!boxLabel) return
|
|
|
|
const currentLabels = labelsRef.current
|
|
const hasLoadedBoxLabels =
|
|
currentLabels.people.length > 0 ||
|
|
currentLabels.bodyParts.length > 0 ||
|
|
currentLabels.objects.length > 0 ||
|
|
currentLabels.clothing.length > 0
|
|
|
|
// Wichtig: Während Labels noch laden oder kurz leer sind, Auswahl nicht löschen.
|
|
if (!hasLoadedBoxLabels) return
|
|
|
|
const stillExists = [
|
|
...currentLabels.people,
|
|
...currentLabels.bodyParts,
|
|
...currentLabels.objects,
|
|
...currentLabels.clothing,
|
|
].includes(boxLabel)
|
|
|
|
if (!stillExists) {
|
|
setBoxLabel('')
|
|
}
|
|
}, [boxLabel, labels])
|
|
|
|
useEffect(() => {
|
|
const wasRunning = wasTrainingRunningRef.current
|
|
|
|
if (wasRunning && !trainingRunning && trainingStatus?.training?.finishedAt) {
|
|
void loadNext({ refreshPrediction: true, preserveNotice: true })
|
|
}
|
|
|
|
wasTrainingRunningRef.current = trainingRunning
|
|
}, [trainingRunning, trainingStatus?.training?.finishedAt, loadNext])
|
|
|
|
useEffect(() => {
|
|
if (activeBoxIndex === null) return
|
|
|
|
const scrollEl = detectorBoxesScrollRef.current
|
|
const itemEl = detectorBoxItemRefs.current[activeBoxIndex]
|
|
|
|
if (!scrollEl || !itemEl) return
|
|
|
|
itemEl.scrollIntoView({
|
|
block: 'nearest',
|
|
inline: 'nearest',
|
|
behavior: 'smooth',
|
|
})
|
|
}, [activeBoxIndex])
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
|
|
async function init() {
|
|
await loadLabels()
|
|
await loadTrainingStatus()
|
|
|
|
if (cancelled) return
|
|
|
|
// Wichtig: Auch während laufendem Training wieder das aktuelle offene Sample laden,
|
|
// damit nicht "Kein Bild geladen" angezeigt wird.
|
|
await loadNext()
|
|
}
|
|
|
|
void init()
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [loadLabels, loadNext, loadTrainingStatus])
|
|
|
|
useEffect(() => {
|
|
if (!trainingRunning) return
|
|
|
|
const onVisibilityChange = () => {
|
|
if (!document.hidden) {
|
|
void loadTrainingStatus()
|
|
}
|
|
}
|
|
|
|
document.addEventListener('visibilitychange', onVisibilityChange)
|
|
|
|
return () => {
|
|
document.removeEventListener('visibilitychange', onVisibilityChange)
|
|
}
|
|
}, [loadTrainingStatus, trainingRunning])
|
|
|
|
useEffect(() => {
|
|
if (!trainingRunning) {
|
|
const timer = window.setTimeout(() => {
|
|
setTrainingProgress(0)
|
|
setTrainingStep('')
|
|
}, 800)
|
|
|
|
return () => window.clearTimeout(timer)
|
|
}
|
|
|
|
const serverProgress = Number(trainingStatus?.training?.progress ?? 0)
|
|
const serverStep = String(trainingStatus?.training?.step ?? '')
|
|
|
|
setTrainingProgress(Number.isFinite(serverProgress) ? clampPercent(serverProgress) : 0)
|
|
setTrainingStep(serverStep || 'Training läuft…')
|
|
}, [
|
|
trainingRunning,
|
|
trainingStatus?.training?.progress,
|
|
trainingStatus?.training?.step,
|
|
])
|
|
|
|
useEffect(() => {
|
|
const job = trainingStatus?.training
|
|
const epoch = Number(job?.epoch ?? 0)
|
|
const epochs = Number(job?.epochs ?? 0)
|
|
|
|
if (!trainingRunning || epoch <= 0 || epochs <= 0) {
|
|
epochTimingRef.current = {
|
|
firstEpochAt: 0,
|
|
lastEpoch: 0,
|
|
lastAt: 0,
|
|
}
|
|
|
|
setEstimatedEpochMs(0)
|
|
return
|
|
}
|
|
|
|
const now = Date.now()
|
|
const previous = epochTimingRef.current
|
|
|
|
const firstEpochAt =
|
|
previous.firstEpochAt > 0
|
|
? previous.firstEpochAt
|
|
: job?.startedAt && Number.isFinite(Date.parse(job.startedAt))
|
|
? Date.parse(job.startedAt)
|
|
: now
|
|
|
|
const safeEpoch = Math.max(1, Math.min(epochs, Math.floor(epoch)))
|
|
const elapsedSinceStartMs = Math.max(0, now - firstEpochAt)
|
|
|
|
// Direkt ab Epoche 1 eine erste Schätzung:
|
|
// bisherige Laufzeit / aktuelle Epoche.
|
|
const averageFromElapsed =
|
|
elapsedSinceStartMs > 0 && safeEpoch > 0
|
|
? elapsedSinceStartMs / safeEpoch
|
|
: 0
|
|
|
|
if (Number.isFinite(averageFromElapsed) && averageFromElapsed > 0) {
|
|
setEstimatedEpochMs((old) => {
|
|
if (!Number.isFinite(old) || old <= 0) {
|
|
return averageFromElapsed
|
|
}
|
|
|
|
const clampedMeasured = Math.max(
|
|
old * 0.60,
|
|
Math.min(old * 1.60, averageFromElapsed)
|
|
)
|
|
|
|
return old * 0.75 + clampedMeasured * 0.25
|
|
})
|
|
}
|
|
|
|
epochTimingRef.current = {
|
|
firstEpochAt,
|
|
lastEpoch: safeEpoch,
|
|
lastAt: now,
|
|
}
|
|
}, [
|
|
trainingRunning,
|
|
trainingStatus?.training?.epoch,
|
|
trainingStatus?.training?.epochs,
|
|
trainingStatus?.training?.startedAt,
|
|
trainingNowMs,
|
|
])
|
|
|
|
const saveFeedback = useCallback(
|
|
async (accepted: boolean) => {
|
|
if (!sample) return
|
|
|
|
setSaving(true)
|
|
setError(null)
|
|
setMessage(null)
|
|
|
|
try {
|
|
const normalizedBoxes = (correction.boxes ?? [])
|
|
.map(normalizeBox)
|
|
.filter((box) => box.label && box.w > 0 && box.h > 0)
|
|
|
|
const correctionPayload: CorrectionState = {
|
|
...correction,
|
|
peoplePresent: peopleLabelsFromBoxes(normalizedBoxes, labelsRef.current),
|
|
boxes: normalizedBoxes,
|
|
}
|
|
|
|
const payload = {
|
|
sampleId: sample.sampleId,
|
|
accepted,
|
|
correction: accepted && correctionPayload.boxes.length === 0
|
|
? undefined
|
|
: correctionPayload,
|
|
}
|
|
|
|
const res = await fetch('/api/training/feedback', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
})
|
|
|
|
const data = await res.json().catch(() => null)
|
|
|
|
if (!res.ok) {
|
|
throw new Error(backendText(data, `HTTP ${res.status}`))
|
|
}
|
|
|
|
setMessage(
|
|
accepted
|
|
? 'Feedback gespeichert: Prediction wurde als korrekt übernommen.'
|
|
: 'Korrektur gespeichert.'
|
|
)
|
|
|
|
await loadTrainingStatus()
|
|
await loadNext({ preserveNotice: true })
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e))
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
},
|
|
[sample, correction, loadNext, loadTrainingStatus]
|
|
)
|
|
|
|
const startTraining = useCallback(async () => {
|
|
shownTrainingCompletionRef.current = null
|
|
|
|
setTraining(true)
|
|
setTrainingProgress(5)
|
|
setTrainingStep('Training wird gestartet…')
|
|
setError(null)
|
|
setMessage(null)
|
|
|
|
try {
|
|
const res = await fetch('/api/training/train', {
|
|
method: 'POST',
|
|
})
|
|
|
|
const data = await res.json().catch(() => null)
|
|
|
|
if (!res.ok) {
|
|
throw new Error(backendText(data, `HTTP ${res.status}`))
|
|
}
|
|
|
|
await loadTrainingStatus()
|
|
|
|
// WICHTIG:
|
|
// Hier NICHT direkt loadNext() aufrufen.
|
|
// Das Training läuft im Backend asynchron weiter.
|
|
} catch (e) {
|
|
setTraining(false)
|
|
setTrainingProgress(0)
|
|
setTrainingStep('')
|
|
setError(e instanceof Error ? e.message : String(e))
|
|
}
|
|
}, [loadTrainingStatus])
|
|
|
|
const cancelTraining = useCallback(async () => {
|
|
const confirmed = window.confirm(
|
|
'Training wirklich abbrechen? Temporäre Trainingsausgaben dieses Laufs werden gelöscht. Deine Feedbacks und Labels bleiben erhalten.'
|
|
)
|
|
|
|
if (!confirmed) return
|
|
|
|
setCancellingTraining(true)
|
|
setError(null)
|
|
setMessage(null)
|
|
setTrainingStep('Training wird abgebrochen…')
|
|
|
|
try {
|
|
const res = await fetch('/api/training/cancel', {
|
|
method: 'POST',
|
|
})
|
|
|
|
const data = await res.json().catch(() => null)
|
|
|
|
if (!res.ok) {
|
|
throw new Error(backendText(data, `HTTP ${res.status}`))
|
|
}
|
|
|
|
await loadTrainingStatus()
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e))
|
|
} finally {
|
|
setCancellingTraining(false)
|
|
}
|
|
}, [loadTrainingStatus])
|
|
|
|
const deleteAllTrainingData = useCallback(async () => {
|
|
const confirmed = window.confirm(
|
|
'Wirklich alle Trainingsdaten löschen? Das entfernt Feedback, Frames, Samples und Detector-Daten. Diese Aktion kann nicht rückgängig gemacht werden.'
|
|
)
|
|
|
|
if (!confirmed) return
|
|
|
|
setDeletingTrainingData(true)
|
|
setError(null)
|
|
setMessage(null)
|
|
|
|
try {
|
|
const res = await fetch('/api/training/delete-all', {
|
|
method: 'DELETE',
|
|
})
|
|
|
|
const data = await res.json().catch(() => null)
|
|
|
|
if (!res.ok) {
|
|
throw new Error(data?.error || `HTTP ${res.status}`)
|
|
}
|
|
|
|
setSample(null)
|
|
setCorrection(predictionToCorrection(null))
|
|
setTrainingStatus({
|
|
feedbackCount: 0,
|
|
requiredCount,
|
|
canTrain: false,
|
|
})
|
|
|
|
setMessage(backendText(data, 'Alle Trainingsdaten wurden gelöscht.'))
|
|
|
|
await loadTrainingStatus()
|
|
await loadNext({ forceNew: true, preserveNotice: true })
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e))
|
|
} finally {
|
|
setDeletingTrainingData(false)
|
|
}
|
|
}, [loadNext, loadTrainingStatus, requiredCount])
|
|
|
|
const getPointerPosInImage = useCallback((
|
|
clientX: number,
|
|
clientY: number,
|
|
opts?: { clamp?: boolean }
|
|
) => {
|
|
const el = imageBoxRef.current
|
|
if (!el) return null
|
|
|
|
const rect = el.getBoundingClientRect()
|
|
if (rect.width <= 0 || rect.height <= 0) return null
|
|
|
|
const x = (clientX - rect.left) / rect.width
|
|
const y = (clientY - rect.top) / rect.height
|
|
|
|
if (opts?.clamp === false) {
|
|
return { x, y }
|
|
}
|
|
|
|
return {
|
|
x: clamp01(x),
|
|
y: clamp01(y),
|
|
}
|
|
}, [])
|
|
|
|
const startDrawBox = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
|
if (!boxLabel) return
|
|
if (uiLocked) return
|
|
if (boxInteraction) return
|
|
|
|
const target = e.target as HTMLElement | null
|
|
if (target?.closest('[data-box-control="true"]')) return
|
|
|
|
const pos = getPointerPosInImage(e.clientX, e.clientY)
|
|
if (!pos) return
|
|
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
window.getSelection()?.removeAllRanges()
|
|
|
|
try {
|
|
e.currentTarget.setPointerCapture(e.pointerId)
|
|
} catch {
|
|
// Pointer wurde vom Browser bereits abgebrochen.
|
|
}
|
|
|
|
setTouchMagnifier({
|
|
visible: true,
|
|
clientX: e.clientX,
|
|
clientY: e.clientY,
|
|
imageX: pos.x,
|
|
imageY: pos.y,
|
|
})
|
|
|
|
setDrawingBox({
|
|
label: boxLabel,
|
|
startX: pos.x,
|
|
startY: pos.y,
|
|
x: pos.x,
|
|
y: pos.y,
|
|
w: 0,
|
|
h: 0,
|
|
})
|
|
}, [boxLabel, boxInteraction, getPointerPosInImage, uiLocked])
|
|
|
|
const moveDrawBox = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
|
if (drawingBox || boxInteraction) {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
}
|
|
|
|
const clampedPos = getPointerPosInImage(e.clientX, e.clientY)
|
|
if (!clampedPos) return
|
|
|
|
const pos =
|
|
boxInteraction?.type === 'move'
|
|
? getPointerPosInImage(e.clientX, e.clientY, { clamp: false }) ?? clampedPos
|
|
: clampedPos
|
|
|
|
if (drawingBox || boxInteraction) {
|
|
setTouchMagnifier({
|
|
visible: true,
|
|
clientX: e.clientX,
|
|
clientY: e.clientY,
|
|
imageX: clampedPos.x,
|
|
imageY: clampedPos.y,
|
|
})
|
|
}
|
|
|
|
if (boxInteraction) {
|
|
const dx = pos.x - boxInteraction.startX
|
|
const dy = pos.y - boxInteraction.startY
|
|
const original = boxInteraction.original
|
|
|
|
let nextBox: TrainingBox = original
|
|
|
|
if (boxInteraction.type === 'move') {
|
|
nextBox = normalizeMovedBox({
|
|
...original,
|
|
x: original.x + dx,
|
|
y: original.y + dy,
|
|
})
|
|
}
|
|
|
|
if (boxInteraction.type === 'resize') {
|
|
let x1 = original.x
|
|
let y1 = original.y
|
|
let x2 = original.x + original.w
|
|
let y2 = original.y + original.h
|
|
|
|
const pointerX = snap01(clampedPos.x)
|
|
const pointerY = snap01(clampedPos.y)
|
|
|
|
// Wichtig:
|
|
// Beim Resize folgt die gezogene Ecke direkt dem Pointer.
|
|
// Dadurch bleibt kein Grab-Offset übrig, wenn der Handle nicht exakt
|
|
// auf der mathematischen Box-Ecke getroffen wurde.
|
|
if (boxInteraction.handle.includes('n')) y1 = pointerY
|
|
if (boxInteraction.handle.includes('s')) y2 = pointerY
|
|
if (boxInteraction.handle.includes('w')) x1 = pointerX
|
|
if (boxInteraction.handle.includes('e')) x2 = pointerX
|
|
|
|
const left = Math.min(x1, x2)
|
|
const top = Math.min(y1, y2)
|
|
const right = Math.max(x1, x2)
|
|
const bottom = Math.max(y1, y2)
|
|
|
|
nextBox = normalizeBox({
|
|
...original,
|
|
x: left,
|
|
y: top,
|
|
w: right - left,
|
|
h: bottom - top,
|
|
})
|
|
}
|
|
|
|
const geometryChanged = boxGeometryChanged(original, nextBox)
|
|
|
|
const correctedNextBox = geometryChanged
|
|
? markBoxCorrected(nextBox)
|
|
: nextBox
|
|
|
|
if (geometryChanged) {
|
|
setHasManualCorrection(true)
|
|
}
|
|
|
|
setCorrection((prev) => ({
|
|
...prev,
|
|
boxes: (prev.boxes ?? []).map((box, index) =>
|
|
index === boxInteraction.index ? correctedNextBox : box
|
|
),
|
|
}))
|
|
|
|
return
|
|
}
|
|
|
|
if (!drawingBox) return
|
|
|
|
const x1 = drawingBox.startX
|
|
const y1 = drawingBox.startY
|
|
const x2 = pos.x
|
|
const y2 = pos.y
|
|
|
|
setDrawingBox({
|
|
...drawingBox,
|
|
x: Math.min(x1, x2),
|
|
y: Math.min(y1, y2),
|
|
w: Math.abs(x2 - x1),
|
|
h: Math.abs(y2 - y1),
|
|
})
|
|
}, [boxInteraction, drawingBox, getPointerPosInImage])
|
|
|
|
const finishDrawBox = useCallback((e?: React.PointerEvent<HTMLDivElement>) => {
|
|
setTouchMagnifier(null)
|
|
|
|
if (drawingBox || boxInteraction) {
|
|
e?.preventDefault()
|
|
e?.stopPropagation()
|
|
}
|
|
|
|
if (boxInteraction) {
|
|
setBoxInteraction(null)
|
|
return
|
|
}
|
|
|
|
if (!drawingBox) return
|
|
|
|
const { startX, startY, ...drawingTrainingBox } = drawingBox
|
|
const box = normalizeBox(drawingTrainingBox)
|
|
|
|
setDrawingBox(null)
|
|
|
|
if (box.w < 0.01 || box.h < 0.01) return
|
|
|
|
setHasManualCorrection(true)
|
|
|
|
setCorrection((prev) => {
|
|
const previousBoxes = prev.boxes ?? []
|
|
const newBoxIndex = previousBoxes.length
|
|
|
|
const next: CorrectionState = {
|
|
...prev,
|
|
boxes: [...previousBoxes, box],
|
|
}
|
|
|
|
setActiveBoxIndex(newBoxIndex)
|
|
|
|
return applyBoxLabelToCorrection(next, box.label, labelsRef.current)
|
|
})
|
|
}, [boxInteraction, drawingBox])
|
|
|
|
const removeBox = useCallback((index: number) => {
|
|
setHasManualCorrection(true)
|
|
|
|
let removedLabel = ''
|
|
let shouldClearBoxLabel = false
|
|
|
|
setCorrection((prev) => {
|
|
const removed = prev.boxes?.[index]
|
|
removedLabel = String(removed?.label || '').trim()
|
|
|
|
const next = removeBoxFromCorrection(prev, index, labelsRef.current)
|
|
|
|
if (removedLabel) {
|
|
shouldClearBoxLabel = !next.boxes.some(
|
|
(box) => String(box.label || '').trim() === removedLabel
|
|
)
|
|
}
|
|
|
|
return next
|
|
})
|
|
|
|
if (removedLabel && shouldClearBoxLabel) {
|
|
setBoxLabel((current) => current === removedLabel ? '' : current)
|
|
}
|
|
|
|
setActiveBoxIndex((current) => {
|
|
if (current === null) return null
|
|
if (current === index) return null
|
|
if (current > index) return current - 1
|
|
return current
|
|
})
|
|
}, [])
|
|
|
|
const changeBoxLabel = useCallback((index: number, nextLabel: string) => {
|
|
const currentLabel = String(correction.boxes?.[index]?.label || '').trim()
|
|
const cleanNextLabel = String(nextLabel || '').trim()
|
|
|
|
if (currentLabel !== cleanNextLabel) {
|
|
setHasManualCorrection(true)
|
|
}
|
|
|
|
if (cleanNextLabel) {
|
|
setBoxLabel(cleanNextLabel)
|
|
}
|
|
|
|
setCorrection((prev) =>
|
|
changeBoxLabelInCorrection(prev, index, nextLabel, labelsRef.current)
|
|
)
|
|
}, [correction.boxes])
|
|
|
|
const clearBoxes = useCallback(() => {
|
|
setHasManualCorrection(true)
|
|
|
|
setBoxLabel('')
|
|
setActiveBoxIndex(null)
|
|
|
|
setCorrection((prev) => ({
|
|
...prev,
|
|
peoplePresent: [],
|
|
bodyPartsPresent: [],
|
|
objectsPresent: [],
|
|
clothingPresent: [],
|
|
boxes: [],
|
|
}))
|
|
|
|
setExpandedCorrectionSections({
|
|
sexPosition: false,
|
|
people: false,
|
|
bodyParts: false,
|
|
objects: false,
|
|
clothing: false,
|
|
})
|
|
}, [])
|
|
|
|
const frameBusy = loading || (!!imageSrc && !frameImageLoaded)
|
|
|
|
const showImageBoxes = !frameBusy && !trainingRunning
|
|
|
|
const shownTrainingDurationMs = useMemo(() => {
|
|
const job = trainingStatus?.training
|
|
|
|
if (!job) return 0
|
|
|
|
if (job.running && job.startedAt) {
|
|
const started = Date.parse(job.startedAt)
|
|
if (Number.isFinite(started)) {
|
|
return Math.max(0, trainingNowMs - started)
|
|
}
|
|
}
|
|
|
|
return trainingDurationMs(job)
|
|
}, [trainingStatus?.training, trainingNowMs])
|
|
|
|
const rawTrainingEtaMs = useMemo(() => {
|
|
if (!trainingRunning) return 0
|
|
|
|
const job = trainingStatus?.training
|
|
const epoch = Number(job?.epoch ?? 0)
|
|
const epochs = Number(job?.epochs ?? 0)
|
|
|
|
if (
|
|
!Number.isFinite(epoch) ||
|
|
!Number.isFinite(epochs) ||
|
|
!Number.isFinite(estimatedEpochMs) ||
|
|
epoch <= 0 ||
|
|
epochs <= 0 ||
|
|
estimatedEpochMs <= 0
|
|
) {
|
|
return 0
|
|
}
|
|
|
|
const completedEpochs = Math.max(1, Math.min(epochs, Math.floor(epoch)))
|
|
const remainingEpochs = Math.max(0, epochs - completedEpochs)
|
|
|
|
return Math.max(0, remainingEpochs * estimatedEpochMs)
|
|
}, [
|
|
trainingRunning,
|
|
trainingStatus?.training?.epoch,
|
|
trainingStatus?.training?.epochs,
|
|
estimatedEpochMs,
|
|
])
|
|
|
|
useEffect(() => {
|
|
if (!trainingRunning || rawTrainingEtaMs <= 0) {
|
|
etaSmoothingRef.current = {
|
|
lastAt: 0,
|
|
}
|
|
|
|
setSmoothedTrainingEtaMs(0)
|
|
return
|
|
}
|
|
|
|
setSmoothedTrainingEtaMs((previous) => {
|
|
const lastAt = etaSmoothingRef.current.lastAt || trainingNowMs
|
|
const elapsed = Math.max(0, trainingNowMs - lastAt)
|
|
|
|
// Anzeige zählt zwischen Backend-Updates weiter runter.
|
|
const countedDown = previous > 0
|
|
? Math.max(0, previous - elapsed)
|
|
: rawTrainingEtaMs
|
|
|
|
const diff = rawTrainingEtaMs - countedDown
|
|
|
|
// Nach oben sehr vorsichtig glätten, nach unten etwas schneller.
|
|
const factor = diff > 0 ? 0.08 : 0.18
|
|
|
|
let next = countedDown + diff * factor
|
|
|
|
// Harte Sprünge zusätzlich begrenzen.
|
|
if (diff > 0) {
|
|
next = Math.min(next, countedDown + 10_000)
|
|
} else {
|
|
next = Math.max(next, countedDown - 20_000)
|
|
}
|
|
|
|
next = Math.max(0, next)
|
|
|
|
etaSmoothingRef.current = {
|
|
lastAt: trainingNowMs,
|
|
}
|
|
|
|
return next
|
|
})
|
|
}, [trainingRunning, rawTrainingEtaMs, trainingNowMs])
|
|
|
|
const shownTrainingEtaMs = smoothedTrainingEtaMs
|
|
|
|
const shownTrainingEpochText = useMemo(() => {
|
|
const epoch = Number(trainingStatus?.training?.epoch ?? 0)
|
|
const epochs = Number(trainingStatus?.training?.epochs ?? 0)
|
|
|
|
if (!Number.isFinite(epoch) || !Number.isFinite(epochs)) return ''
|
|
if (epoch <= 0 || epochs <= 0) return ''
|
|
|
|
return `Epoche ${epoch}/${epochs}`
|
|
}, [
|
|
trainingStatus?.training?.epoch,
|
|
trainingStatus?.training?.epochs,
|
|
])
|
|
|
|
const activeNotice = useMemo<TrainingNotice | null>(() => {
|
|
if (error) {
|
|
return {
|
|
kind: 'error',
|
|
title: 'Aktion fehlgeschlagen',
|
|
message: error,
|
|
}
|
|
}
|
|
|
|
if (message) {
|
|
const lowerMessage = message.toLowerCase()
|
|
|
|
const looksPartial =
|
|
lowerMessage.includes('übersprungen') ||
|
|
lowerMessage.includes('fehlgeschlagen') ||
|
|
lowerMessage.includes('abgebrochen')
|
|
|
|
return {
|
|
kind: looksPartial ? 'warning' : 'success',
|
|
title: looksPartial ? 'Training teilweise abgeschlossen' : 'Erfolg',
|
|
message,
|
|
}
|
|
}
|
|
|
|
if (trainingRunning) {
|
|
const items: TrainingNoticeItem[] = []
|
|
|
|
items.push({
|
|
icon: ClockIcon,
|
|
label: 'Bisherige Laufzeit',
|
|
value: formatDuration(shownTrainingDurationMs),
|
|
})
|
|
|
|
if (shownTrainingEtaMs > 0) {
|
|
items.push({
|
|
icon: ForwardIcon,
|
|
label: 'Geschätzte Restzeit',
|
|
value: `ca. ${formatDuration(shownTrainingEtaMs)}`,
|
|
})
|
|
}
|
|
|
|
if (shownTrainingEpochText) {
|
|
items.push({
|
|
icon: ArrowPathIcon,
|
|
label: 'Epoche',
|
|
value: shownTrainingEpochText.replace(/^Epoche\s+/i, ''),
|
|
})
|
|
}
|
|
|
|
if (estimatedEpochMs > 0) {
|
|
items.push({
|
|
icon: BoltIcon,
|
|
label: 'Ø Zeit pro Epoche',
|
|
value: formatDuration(estimatedEpochMs),
|
|
})
|
|
}
|
|
|
|
return {
|
|
kind: 'info',
|
|
title: 'Training läuft',
|
|
progress: shownTrainingProgress,
|
|
message: shownTrainingStep || 'Training läuft…',
|
|
items,
|
|
}
|
|
}
|
|
|
|
return null
|
|
}, [
|
|
error,
|
|
message,
|
|
trainingRunning,
|
|
shownTrainingStep,
|
|
shownTrainingDurationMs,
|
|
shownTrainingEtaMs,
|
|
shownTrainingEpochText,
|
|
estimatedEpochMs,
|
|
shownTrainingProgress,
|
|
])
|
|
|
|
useEffect(() => {
|
|
if (!message) return
|
|
if (error) return
|
|
if (trainingRunning) return
|
|
|
|
const looksPartial =
|
|
message.toLowerCase().includes('übersprungen') ||
|
|
message.toLowerCase().includes('fehlgeschlagen')
|
|
|
|
if (looksPartial) return
|
|
|
|
const timer = window.setTimeout(() => {
|
|
setMessage(null)
|
|
}, 2500)
|
|
|
|
return () => window.clearTimeout(timer)
|
|
}, [message, error, trainingRunning])
|
|
|
|
const trainingActionsPanel = (opts?: { compact?: boolean }) => {
|
|
const compact = Boolean(opts?.compact)
|
|
const progress = clampPercent(
|
|
trainingRunning
|
|
? shownTrainingProgress
|
|
: Math.min(100, (feedbackCount / Math.max(1, requiredCount)) * 100)
|
|
)
|
|
|
|
const feedbackReady = feedbackCount >= requiredCount
|
|
const detector = trainingStatus?.detector
|
|
const detectorReady = Boolean(detector?.dataReady)
|
|
const missingTrain = Math.max(
|
|
0,
|
|
Number(detector?.requiredTrain ?? 20) - Number(detector?.trainCount ?? 0)
|
|
)
|
|
const missingVal = Math.max(
|
|
0,
|
|
Number(detector?.requiredVal ?? 3) - Number(detector?.valCount ?? 0)
|
|
)
|
|
const statusText = trainingRunning
|
|
? shownTrainingStep || 'Training läuft…'
|
|
: !feedbackReady
|
|
? `${Math.max(0, requiredCount - feedbackCount)} Feedback fehlen noch`
|
|
: !detectorReady
|
|
? `YOLO-Boxen fehlen: ${missingTrain} Train, ${missingVal} Val`
|
|
: canStartTraining
|
|
? 'Bereit zum Trainieren'
|
|
: 'Noch nicht trainingsbereit'
|
|
return (
|
|
<div
|
|
className={[
|
|
'relative z-0 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900/70',
|
|
compact ? 'p-2' : 'p-3',
|
|
].join(' ')}
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className={[
|
|
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ring-1',
|
|
trainingRunning
|
|
? 'bg-indigo-50 text-indigo-700 ring-indigo-200 dark:bg-indigo-500/15 dark:text-indigo-200 dark:ring-indigo-400/30'
|
|
: feedbackReady
|
|
? 'bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-200 dark:ring-emerald-400/30'
|
|
: 'bg-gray-50 text-gray-600 ring-gray-200 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10',
|
|
].join(' ')}
|
|
>
|
|
{trainingRunning ? (
|
|
<ArrowPathIcon className="h-4 w-4 animate-spin" aria-hidden="true" />
|
|
) : (
|
|
<BoltIcon className="h-4 w-4" aria-hidden="true" />
|
|
)}
|
|
</div>
|
|
|
|
<div className="min-w-0">
|
|
<div className="text-xs font-semibold text-gray-900 dark:text-white">
|
|
Training-Aktionen
|
|
</div>
|
|
|
|
<div className="mt-0.5 truncate text-[11px] text-gray-500 dark:text-gray-400">
|
|
{statusText}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => setStatsModalOpen(true)}
|
|
className="shrink-0 rounded-full bg-gray-50 px-2 py-1 text-[11px] font-bold text-gray-700 ring-1 ring-gray-200 transition hover:bg-indigo-50 hover:text-indigo-700 hover:ring-indigo-200 dark:bg-white/5 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"
|
|
title="Training-Datenstatistiken anzeigen"
|
|
aria-label="Training-Datenstatistiken anzeigen"
|
|
>
|
|
{feedbackCount}/{requiredCount}
|
|
</button>
|
|
</div>
|
|
|
|
{trainingRunning || feedbackCount < requiredCount ? (
|
|
<div className="mt-3">
|
|
<div className="mb-1 flex items-center justify-between text-[10px] font-medium text-gray-500 dark:text-gray-400">
|
|
<span>{trainingRunning ? 'Trainingsfortschritt' : 'Feedback-Fortschritt'}</span>
|
|
<span>{Math.round(progress)}%</span>
|
|
</div>
|
|
|
|
<div className="h-2 overflow-hidden rounded-full bg-gray-100 ring-1 ring-black/5 dark:bg-white/10 dark:ring-white/10">
|
|
<div
|
|
className={[
|
|
'h-full rounded-full transition-all duration-500',
|
|
trainingRunning
|
|
? 'bg-indigo-500'
|
|
: feedbackReady
|
|
? 'bg-emerald-500'
|
|
: 'bg-gray-400',
|
|
].join(' ')}
|
|
style={{ width: `${progress}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
|
<Button
|
|
size="md"
|
|
variant="secondary"
|
|
className="w-full justify-center px-2 text-[11px]"
|
|
disabled={uiLocked || !sample}
|
|
onClick={() => void reloadCurrentImage()}
|
|
title="Lädt das aktuelle Bild erneut und führt die Analyse neu aus."
|
|
>
|
|
<span className="inline-flex items-center gap-1.5">
|
|
<ArrowPathIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
|
Neuladen
|
|
</span>
|
|
</Button>
|
|
|
|
<Button
|
|
size="md"
|
|
variant={trainingRunning ? 'primary' : canStartTraining ? 'primary' : 'soft'}
|
|
color={trainingRunning ? 'red' : undefined}
|
|
className="w-full justify-center px-2 text-[11px]"
|
|
disabled={
|
|
trainingRunning
|
|
? cancellingTraining || deletingTrainingData
|
|
: uiLocked || !canStartTraining
|
|
}
|
|
onClick={() => {
|
|
if (trainingRunning) {
|
|
void cancelTraining()
|
|
return
|
|
}
|
|
|
|
void startTraining()
|
|
}}
|
|
title={
|
|
trainingRunning
|
|
? 'Training abbrechen und temporäre Trainingsausgaben löschen.'
|
|
: canStartTraining
|
|
? 'YOLO26-Detector mit allen gespeicherten Box-Labels trainieren.'
|
|
: !feedbackReady
|
|
? `Noch zu wenig Feedback: ${feedbackCount}/${requiredCount}.`
|
|
: !detectorReady
|
|
? `Noch zu wenige YOLO-Box-Labels: Train ${detector?.trainCount ?? 0}/${detector?.requiredTrain ?? 20}, Val ${detector?.valCount ?? 0}/${detector?.requiredVal ?? 3}.`
|
|
: 'Training ist aktuell nicht verfügbar.'
|
|
}
|
|
>
|
|
<span className="inline-flex items-center gap-1.5">
|
|
{trainingRunning ? (
|
|
<XCircleIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
|
) : (
|
|
<BoltIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
|
)}
|
|
|
|
{trainingRunning
|
|
? cancellingTraining
|
|
? 'Breche ab…'
|
|
: 'Abbrechen'
|
|
: 'Trainieren'}
|
|
</span>
|
|
</Button>
|
|
</div>
|
|
|
|
{trainingRunning ? (
|
|
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px]">
|
|
<div className="rounded-lg bg-indigo-50 px-2 py-1.5 text-indigo-900 ring-1 ring-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-100 dark:ring-indigo-400/20">
|
|
<div className="text-[9px] font-semibold uppercase tracking-wide opacity-70">
|
|
Laufzeit
|
|
</div>
|
|
<div className="mt-0.5 font-bold">
|
|
{formatDuration(shownTrainingDurationMs)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-lg bg-indigo-50 px-2 py-1.5 text-indigo-900 ring-1 ring-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-100 dark:ring-indigo-400/20">
|
|
<div className="text-[9px] font-semibold uppercase tracking-wide opacity-70">
|
|
Restzeit
|
|
</div>
|
|
<div className="mt-0.5 font-bold">
|
|
{shownTrainingEtaMs > 0 ? `ca. ${formatDuration(shownTrainingEtaMs)}` : '—'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="mt-3 border-t border-gray-100 pt-3 dark:border-white/10">
|
|
<Button
|
|
size="md"
|
|
variant="primary"
|
|
color="red"
|
|
className="w-full justify-center px-2 text-[11px]"
|
|
disabled={uiLocked}
|
|
onClick={() => void deleteAllTrainingData()}
|
|
title="Löscht Feedback, Frames, Samples und Detector-Daten."
|
|
>
|
|
<span className="inline-flex items-center gap-1.5">
|
|
<TrashIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
|
{deletingTrainingData ? 'Lösche…' : 'Trainingsdaten löschen'}
|
|
</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const detectorBoxesPanel = (opts?: {
|
|
compact?: boolean
|
|
stretch?: boolean
|
|
maxHeightClassName?: string
|
|
}) => {
|
|
const compact = Boolean(opts?.compact)
|
|
const stretch = Boolean(opts?.stretch)
|
|
const hasBoxes = correctionBoxes.length > 0
|
|
|
|
return (
|
|
<div
|
|
className={[
|
|
'relative z-0 flex min-h-0 flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm',
|
|
'dark:border-white/10 dark:bg-gray-900/70',
|
|
stretch ? 'h-full max-h-full flex-1' : 'max-h-full',
|
|
].join(' ')}
|
|
>
|
|
<div
|
|
className={[
|
|
'shrink-0 border-b border-gray-100 bg-gray-50/80 dark:border-white/10 dark:bg-white/[0.03]',
|
|
compact ? 'px-2.5 py-2' : 'px-3 py-2.5',
|
|
].join(' ')}
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<div className="truncate text-xs font-bold text-gray-900 dark:text-white">
|
|
Detector-Boxen
|
|
</div>
|
|
|
|
<span
|
|
className={[
|
|
'rounded-full px-2 py-0.5 text-[10px] font-black ring-1',
|
|
hasBoxes
|
|
? 'bg-blue-50 text-blue-700 ring-blue-200 dark:bg-blue-500/15 dark:text-blue-100 dark:ring-blue-400/30'
|
|
: 'bg-gray-100 text-gray-500 ring-gray-200 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10',
|
|
].join(' ')}
|
|
>
|
|
{correctionBoxes.length}
|
|
</span>
|
|
</div>
|
|
|
|
{!compact ? (
|
|
<div className="mt-0.5 truncate text-[10px] text-gray-500 dark:text-gray-400">
|
|
Prüfen, Label ändern oder einzeln löschen.
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{hasBoxes ? (
|
|
<button
|
|
type="button"
|
|
disabled={uiLocked}
|
|
onClick={clearBoxes}
|
|
className={[
|
|
'shrink-0 rounded-lg px-2 py-1 text-[10px] font-bold transition ring-1',
|
|
'bg-white text-red-600 ring-red-100 hover:bg-red-50 hover:text-red-700 hover:ring-red-200',
|
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
'dark:bg-white/5 dark:text-red-300 dark:ring-red-400/20 dark:hover:bg-red-500/10',
|
|
].join(' ')}
|
|
title="Alle Boxen entfernen"
|
|
>
|
|
Alle löschen
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
ref={detectorBoxesScrollRef}
|
|
className={[
|
|
'min-h-0 flex-1 overflow-y-auto overscroll-contain scroll-smooth',
|
|
compact ? 'space-y-1 p-1.5' : 'space-y-1.5 p-2',
|
|
!stretch && (opts?.maxHeightClassName || 'lg:max-h-[32dvh]'),
|
|
].filter(Boolean).join(' ')}
|
|
>
|
|
{!hasBoxes ? (
|
|
<div className="flex min-h-32 items-center justify-center rounded-xl border border-dashed border-gray-200 bg-gray-50 px-3 py-6 text-center dark:border-white/10 dark:bg-white/[0.03]">
|
|
<div>
|
|
<div className="text-xs font-semibold text-gray-700 dark:text-gray-200">
|
|
Keine Boxen vorhanden
|
|
</div>
|
|
|
|
<div className="mt-1 max-w-[220px] text-[11px] leading-snug text-gray-500 dark:text-gray-400">
|
|
Wähle rechts ein Label aus und zeichne im Bild eine neue Box.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
correctionBoxes.map((box, index) => {
|
|
const item = getSegmentLabelItem(box.label)
|
|
const Icon = item.icon
|
|
const isActive = activeBoxIndex === index
|
|
const isCorrected = typeof box.score !== 'number'
|
|
const scoreText = typeof box.score === 'number' ? percent(box.score) : 'korrigiert'
|
|
const tone = detectorBoxAppearance(box.label)
|
|
|
|
return (
|
|
<div
|
|
ref={(el) => {
|
|
detectorBoxItemRefs.current[index] = el
|
|
}}
|
|
key={`box-${index}`}
|
|
onClick={() => setActiveBoxIndex(index)}
|
|
className={[
|
|
'group relative cursor-pointer overflow-hidden rounded-2xl border transition-all duration-200',
|
|
'bg-white shadow-sm',
|
|
'dark:border-white/10 dark:bg-gray-950/55',
|
|
isActive
|
|
? [
|
|
'border-gray-200 bg-white',
|
|
'dark:border-white/10',
|
|
tone.activeSurface,
|
|
].join(' ')
|
|
: [
|
|
'border-gray-200 hover:bg-gray-50/80 hover:shadow-md',
|
|
'dark:border-white/10 dark:hover:bg-white/[0.04]',
|
|
tone.idleHover,
|
|
].join(' '),
|
|
].join(' ')}
|
|
>
|
|
<div
|
|
className={[
|
|
'absolute inset-y-2 left-0 w-1 rounded-r-full transition-all duration-200',
|
|
isActive
|
|
? tone.line
|
|
: ['bg-transparent', tone.lineHover].join(' '),
|
|
].join(' ')}
|
|
aria-hidden="true"
|
|
/>
|
|
|
|
<div className={compact ? 'p-2 pl-3' : 'p-2.5 pl-3.5'}>
|
|
<div className="flex items-center gap-2.5">
|
|
<button
|
|
type="button"
|
|
disabled={uiLocked}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
setActiveBoxIndex(index)
|
|
}}
|
|
className={[
|
|
'flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ring-1 transition',
|
|
isActive
|
|
? [
|
|
'bg-blue-100 text-blue-700 ring-blue-200',
|
|
tone.iconActive,
|
|
].join(' ')
|
|
: [
|
|
'bg-gray-50 text-gray-500 ring-gray-200',
|
|
'group-hover:bg-gray-100 group-hover:text-gray-700',
|
|
'dark:bg-white/5 dark:ring-white/10',
|
|
tone.iconIdle,
|
|
].join(' '),
|
|
].join(' ')}
|
|
title="Box im Bild markieren"
|
|
aria-label={`Box ${index + 1} im Bild markieren`}
|
|
>
|
|
<Icon className="h-4.5 w-4.5" aria-hidden="true" />
|
|
</button>
|
|
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex min-w-0 items-center justify-between gap-2">
|
|
<div className="min-w-0">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<div className="truncate text-[13px] font-bold leading-tight text-gray-950 dark:text-white">
|
|
{item.text}
|
|
</div>
|
|
|
|
<span className="shrink-0 text-[10px] font-black text-gray-400 dark:text-gray-500">
|
|
#{index + 1}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[10px] font-medium leading-tight text-gray-500 dark:text-gray-400">
|
|
<span
|
|
className={[
|
|
'h-1.5 w-1.5 shrink-0 rounded-full',
|
|
isCorrected
|
|
? 'bg-amber-400'
|
|
: scoreLevel(box.score) === 'high'
|
|
? 'bg-emerald-400'
|
|
: scoreLevel(box.score) === 'mid'
|
|
? 'bg-yellow-400'
|
|
: scoreLevel(box.score) === 'low'
|
|
? 'bg-red-400'
|
|
: 'bg-gray-300 dark:bg-gray-500',
|
|
].join(' ')}
|
|
aria-hidden="true"
|
|
/>
|
|
|
|
<span className="truncate">
|
|
{isCorrected ? 'korrigiert' : `Confidence ${scoreText}`}
|
|
</span>
|
|
|
|
{isActive ? (
|
|
<>
|
|
<span className="text-gray-300 dark:text-gray-600">·</span>
|
|
<span
|
|
className={[
|
|
'font-semibold text-blue-700',
|
|
tone.selectedText,
|
|
].join(' ')}
|
|
>
|
|
ausgewählt
|
|
</span>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
disabled={uiLocked}
|
|
className={[
|
|
'inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition',
|
|
'text-gray-400 hover:bg-red-50 hover:text-red-600',
|
|
'focus:outline-none focus:ring-2 focus:ring-red-500/30',
|
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
'dark:text-gray-500 dark:hover:bg-red-500/10 dark:hover:text-red-300',
|
|
].join(' ')}
|
|
title={`${item.text} löschen`}
|
|
aria-label={`${item.text} löschen`}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
removeBox(index)
|
|
}}
|
|
>
|
|
<TrashIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
|
|
{!isActive ? null : (
|
|
<div className={compact ? 'mt-2' : 'mt-3'}>
|
|
<DetectorBoxLabelSelect
|
|
values={boxLabels}
|
|
value={box.label}
|
|
compact={compact}
|
|
disabled={uiLocked || boxLabels.length === 0}
|
|
onChange={(value) => changeBoxLabel(index, value)}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
{hasBoxes ? (
|
|
<div className="shrink-0 border-t border-gray-100 bg-gray-50/80 p-2 dark:border-white/10 dark:bg-white/[0.03]">
|
|
<Button
|
|
size="md"
|
|
variant="secondary"
|
|
className={compact ? 'w-full text-xs' : 'w-full shrink-0'}
|
|
disabled={uiLocked}
|
|
onClick={clearBoxes}
|
|
>
|
|
Alle Boxen entfernen
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const imageTouchClass =
|
|
boxLabel || drawingBox || boxInteraction
|
|
? 'touch-none'
|
|
: 'touch-pan-y'
|
|
|
|
return (
|
|
<div className="min-h-full lg:h-full lg:min-h-0 lg:overflow-hidden">
|
|
<div className="mb-2 flex items-center justify-between gap-2 rounded-xl border border-gray-200 bg-white px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/80 lg:hidden">
|
|
<div className="min-w-0">
|
|
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
|
|
Training
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => setStatsModalOpen(true)}
|
|
className="ml-auto shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-bold leading-none text-gray-700 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10"
|
|
title="Training-Datenstatistiken anzeigen"
|
|
aria-label="Training-Datenstatistiken anzeigen"
|
|
>
|
|
{feedbackCount}/{requiredCount}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 items-start gap-2 lg:h-full lg:min-h-0 lg:overflow-hidden lg:grid-cols-[300px_minmax(0,1fr)_300px] xl:grid-cols-[320px_minmax(0,1fr)_320px]">
|
|
{/* Sidebar links */}
|
|
<aside className="hidden h-full max-h-full min-h-0 flex-col overflow-hidden rounded-xl border border-gray-200 bg-white p-3 shadow-sm dark:border-white/10 dark:bg-gray-900/60 lg:flex">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="min-w-0 text-sm font-semibold text-gray-900 dark:text-white">
|
|
Training
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => setStatsModalOpen(true)}
|
|
className={[
|
|
'shrink-0 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-Datenstatistiken anzeigen"
|
|
aria-label="Training-Datenstatistiken anzeigen"
|
|
>
|
|
{feedbackCount}/{requiredCount}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="mt-2 text-xs text-gray-600 dark:text-gray-300">
|
|
Bestätige oder korrigiere die Analyse. Jede Antwort wird als Trainingsdatenpunkt gespeichert.
|
|
</div>
|
|
|
|
<div className="mt-3 flex min-h-0 flex-1 flex-col gap-3 overflow-hidden">
|
|
<div className="min-h-0 flex-1 rounded-xl overflow-hidden">
|
|
{detectorBoxesPanel({
|
|
stretch: true,
|
|
maxHeightClassName: 'lg:max-h-none',
|
|
})}
|
|
</div>
|
|
|
|
<div className="shrink-0">
|
|
{trainingActionsPanel()}
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Mitte */}
|
|
<section className="min-w-0 rounded-xl border border-gray-200 bg-white p-2 shadow-sm dark:border-white/10 dark:bg-gray-900/60 sm:p-3 lg:self-start">
|
|
<div className="relative flex min-h-[180px] flex-1 items-center justify-center overflow-visible rounded-lg bg-black p-2 sm:p-3 lg:min-h-[300px]">
|
|
{imageSrc ? (
|
|
<div className="relative flex h-full w-full items-center justify-center">
|
|
<div
|
|
ref={imageBoxRef}
|
|
className={[
|
|
'relative inline-block max-h-[52dvh] max-w-full select-none overscroll-contain transition sm:max-h-[60dvh] lg:max-h-[72dvh]',
|
|
imageTouchClass,
|
|
'[-webkit-touch-callout:none] [-webkit-user-select:none] [user-select:none]',
|
|
trainingRunning || loading ? 'pointer-events-none' : '',
|
|
].join(' ')}
|
|
onContextMenu={(e) => e.preventDefault()}
|
|
onPointerDown={startDrawBox}
|
|
onPointerMove={moveDrawBox}
|
|
onPointerUp={finishDrawBox}
|
|
onPointerCancel={finishDrawBox}
|
|
>
|
|
<img
|
|
src={imageSrc}
|
|
alt="Training Frame"
|
|
draggable={false}
|
|
onLoad={() => setFrameImageLoaded(true)}
|
|
onError={() => setFrameImageLoaded(true)}
|
|
onContextMenu={(e) => e.preventDefault()}
|
|
onDragStart={(e) => e.preventDefault()}
|
|
className={[
|
|
'block rounded-lg max-h-[52dvh] max-w-full object-contain sm:max-h-[60dvh] lg:max-h-[72dvh]',
|
|
'select-none',
|
|
imageTouchClass,
|
|
'[-webkit-user-drag:none] [-webkit-touch-callout:none]',
|
|
].join(' ')}
|
|
/>
|
|
|
|
{showImageBoxes ? (
|
|
<div className="absolute inset-0">
|
|
{visibleBoxes.map(({ box, index, isDraft }) => {
|
|
const left = clampPercent(box.x * 100)
|
|
const top = clampPercent(box.y * 100)
|
|
const width = clampPercent(box.w * 100)
|
|
const height = clampPercent(box.h * 100)
|
|
|
|
const item = getSegmentLabelItem(box.label)
|
|
const Icon = item.icon
|
|
const isSmallBox = width < 18 || height < 12
|
|
const isActiveBox = !isDraft && activeBoxIndex === index
|
|
|
|
return (
|
|
<div
|
|
key={`${box.label}-${index}-${isDraft ? 'draft' : 'box'}`}
|
|
className={[
|
|
'pointer-events-none absolute rounded border-2',
|
|
isActiveBox
|
|
? 'border-blue-500 shadow-none'
|
|
: [
|
|
'shadow-none',
|
|
scoreBorderClass(box.score, { draft: isDraft }),
|
|
].join(' '),
|
|
].join(' ')}
|
|
style={{
|
|
left: `${left}%`,
|
|
top: `${top}%`,
|
|
width: `${width}%`,
|
|
height: `${height}%`,
|
|
backgroundColor: 'transparent',
|
|
zIndex: isActiveBox ? 30 : isDraft ? 25 : 10,
|
|
}}
|
|
title={box.label}
|
|
>
|
|
<div
|
|
data-box-control="true"
|
|
className={[
|
|
'pointer-events-auto absolute left-0 top-0 z-[35] flex -translate-y-[calc(100%+4px)] touch-none select-none items-center',
|
|
'[-webkit-user-select:none] [-webkit-touch-callout:none]',
|
|
isSmallBox
|
|
? 'max-w-none'
|
|
: 'max-w-[min(210px,calc(100vw-24px))]',
|
|
].join(' ')}
|
|
title={isDraft ? box.label : `${item.text} verschieben`}
|
|
>
|
|
<button
|
|
type="button"
|
|
className={[
|
|
'group/label flex h-5 min-w-0 touch-none select-none items-center rounded-full text-left',
|
|
isSmallBox ? 'gap-0.5 px-1' : 'gap-1 pl-1.5 pr-0.5',
|
|
'text-[10px] font-bold leading-none shadow-md ring-1 transition',
|
|
'[-webkit-user-select:none] [-webkit-touch-callout:none]',
|
|
isActiveBox
|
|
? [
|
|
'bg-white/95 text-blue-700 ring-blue-500/30 hover:bg-blue-50',
|
|
'dark:bg-blue-600 dark:text-white dark:ring-white/25 dark:hover:bg-blue-700',
|
|
].join(' ')
|
|
: isDraft
|
|
? [
|
|
'bg-amber-100/95 text-amber-950 ring-amber-500/30',
|
|
'dark:bg-amber-400 dark:text-black dark:ring-black/15',
|
|
].join(' ')
|
|
: [
|
|
'bg-white/95 text-gray-950 ring-black/10 hover:bg-gray-50',
|
|
'dark:bg-white/95 dark:text-gray-950 dark:ring-black/10 dark:hover:bg-gray-50',
|
|
].join(' '),
|
|
isDraft ? 'cursor-default' : 'cursor-move',
|
|
].join(' ')}
|
|
disabled={Boolean(isDraft) || uiLocked}
|
|
onPointerDown={(e) => {
|
|
if (isDraft || uiLocked) return
|
|
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
window.getSelection()?.removeAllRanges()
|
|
|
|
const target = e.target as HTMLElement | null
|
|
if (target?.closest('[data-box-trash="true"]')) return
|
|
|
|
const requiresActivationFirst = !isActiveBox
|
|
|
|
setActiveBoxIndex(index)
|
|
|
|
if (requiresActivationFirst) return
|
|
|
|
const pos = getPointerPosInImage(e.clientX, e.clientY, { clamp: false })
|
|
const clampedPos = getPointerPosInImage(e.clientX, e.clientY)
|
|
|
|
if (!pos || !clampedPos) return
|
|
|
|
try {
|
|
imageBoxRef.current?.setPointerCapture(e.pointerId)
|
|
} catch {
|
|
// Pointer wurde vom Browser bereits abgebrochen.
|
|
}
|
|
|
|
setTouchMagnifier({
|
|
visible: true,
|
|
clientX: e.clientX,
|
|
clientY: e.clientY,
|
|
imageX: clampedPos.x,
|
|
imageY: clampedPos.y,
|
|
})
|
|
|
|
setBoxInteraction({
|
|
type: 'move',
|
|
index,
|
|
startX: pos.x,
|
|
startY: pos.y,
|
|
original: box,
|
|
})
|
|
}}
|
|
>
|
|
<span
|
|
className={[
|
|
'shrink-0 rounded-full px-1 py-0.5 text-[9px] font-black leading-none',
|
|
isActiveBox
|
|
? 'bg-blue-100 text-blue-700 dark:bg-white/20 dark:text-white'
|
|
: isDraft
|
|
? 'bg-amber-200 text-amber-950 dark:bg-black/15 dark:text-black'
|
|
: 'bg-gray-100 text-gray-700 dark:bg-gray-100 dark:text-gray-700',
|
|
].join(' ')}
|
|
>
|
|
{isDraft ? 'neu' : `#${index + 1}`}
|
|
</span>
|
|
|
|
<Icon
|
|
className="h-3 w-3 shrink-0 text-current"
|
|
aria-hidden="true"
|
|
/>
|
|
|
|
{!isSmallBox ? (
|
|
<span className="min-w-0 max-w-[104px] truncate text-current sm:max-w-[132px]">
|
|
{item.text}
|
|
</span>
|
|
) : null}
|
|
|
|
{!isDraft ? (
|
|
<span
|
|
data-box-trash="true"
|
|
role="button"
|
|
tabIndex={0}
|
|
className={[
|
|
'ml-0.5 flex h-4.5 w-4.5 shrink-0 cursor-pointer touch-none items-center justify-center rounded-full',
|
|
'!bg-red-600 !text-white ring-1 ring-red-800/40 transition',
|
|
'hover:!bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500/60',
|
|
'dark:!bg-red-500 dark:!text-white dark:ring-white/20 dark:hover:!bg-red-600',
|
|
].join(' ')}
|
|
title={`${box.label} löschen`}
|
|
aria-label={`${box.label} löschen`}
|
|
onPointerDown={(e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
}}
|
|
onClick={(e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
removeBox(index)
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key !== 'Enter' && e.key !== ' ') return
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
removeBox(index)
|
|
}}
|
|
>
|
|
<TrashIcon
|
|
className="h-3 w-3 shrink-0 !text-white"
|
|
aria-hidden="true"
|
|
/>
|
|
</span>
|
|
) : null}
|
|
</button>
|
|
</div>
|
|
|
|
{!isDraft ? (
|
|
<>
|
|
{(['nw', 'ne', 'sw', 'se'] as const).map((handle) => (
|
|
<button
|
|
key={handle}
|
|
data-box-control="true"
|
|
type="button"
|
|
disabled={uiLocked}
|
|
className={[
|
|
isActiveBox
|
|
? [
|
|
'pointer-events-auto absolute z-[34] flex h-7 w-7 touch-none items-center justify-center',
|
|
'appearance-none rounded-none border-0 p-0 opacity-100 shadow-none ring-0',
|
|
'!bg-transparent hover:!bg-transparent active:!bg-transparent focus:!bg-transparent',
|
|
'focus:outline-none disabled:opacity-50 sm:h-5 sm:w-5',
|
|
].join(' ')
|
|
: [
|
|
'pointer-events-none absolute z-20 hidden h-7 w-7 touch-none items-center justify-center',
|
|
'appearance-none rounded-none border-0 p-0 opacity-100 shadow-none ring-0',
|
|
'!bg-transparent hover:!bg-transparent active:!bg-transparent focus:!bg-transparent',
|
|
'focus:outline-none disabled:opacity-50 sm:h-5 sm:w-5',
|
|
].join(' '),
|
|
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' : '',
|
|
handle === 'se' ? '-bottom-3.5 -right-3.5 cursor-nwse-resize sm:-bottom-2.5 sm:-right-2.5' : '',
|
|
].join(' ')}
|
|
title="Boxgröße ändern"
|
|
onPointerDown={(e) => {
|
|
if (uiLocked) return
|
|
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
window.getSelection()?.removeAllRanges()
|
|
|
|
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
|
|
|
|
try {
|
|
imageBoxRef.current?.setPointerCapture(e.pointerId)
|
|
} catch {
|
|
// Pointer wurde vom Browser bereits abgebrochen.
|
|
}
|
|
|
|
setTouchMagnifier({
|
|
visible: true,
|
|
clientX: e.clientX,
|
|
clientY: e.clientY,
|
|
imageX: pos.x,
|
|
imageY: pos.y,
|
|
})
|
|
|
|
setBoxInteraction({
|
|
type: 'resize',
|
|
index,
|
|
handle,
|
|
startX: pos.x,
|
|
startY: pos.y,
|
|
original: box,
|
|
})
|
|
}}
|
|
>
|
|
<span
|
|
className={[
|
|
'block h-2 w-2 rounded-full bg-white shadow-none ring-2 sm:h-2 sm:w-2',
|
|
isActiveBox ? 'ring-blue-500' : scoreRingClass(box.score),
|
|
].join(' ')}
|
|
/>
|
|
</button>
|
|
))}
|
|
</>
|
|
) : null}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
) : null}
|
|
|
|
{touchMagnifier?.visible && imageSrc && showImageBoxes ? (() => {
|
|
const el = imageBoxRef.current
|
|
const rect = el?.getBoundingClientRect()
|
|
|
|
if (!rect || rect.width <= 0 || rect.height <= 0) return null
|
|
|
|
const size = 156
|
|
const padding = 20
|
|
|
|
const activeBox =
|
|
drawingBox ||
|
|
(boxInteraction
|
|
? correction.boxes?.[boxInteraction.index] ?? null
|
|
: null)
|
|
|
|
const hasUsableBox =
|
|
activeBox &&
|
|
Number.isFinite(activeBox.w) &&
|
|
Number.isFinite(activeBox.h) &&
|
|
activeBox.w > 0.003 &&
|
|
activeBox.h > 0.003
|
|
|
|
const boxCenterX = hasUsableBox
|
|
? clamp01(activeBox.x + activeBox.w / 2)
|
|
: touchMagnifier.imageX
|
|
|
|
const boxCenterY = hasUsableBox
|
|
? clamp01(activeBox.y + activeBox.h / 2)
|
|
: touchMagnifier.imageY
|
|
|
|
const boxPixelW = hasUsableBox ? activeBox.w * rect.width : 0
|
|
const boxPixelH = hasUsableBox ? activeBox.h * rect.height : 0
|
|
|
|
const fitZoom = hasUsableBox
|
|
? Math.min(
|
|
(size - padding * 2) / Math.max(1, boxPixelW),
|
|
(size - padding * 2) / Math.max(1, boxPixelH)
|
|
)
|
|
: 2
|
|
|
|
const zoom = hasUsableBox
|
|
? Math.max(0.55, Math.min(2.25, fitZoom))
|
|
: 2
|
|
|
|
const viewportW = typeof window !== 'undefined' ? window.innerWidth : 390
|
|
const viewportH = typeof window !== 'undefined' ? window.innerHeight : 800
|
|
|
|
let left = touchMagnifier.clientX - size / 2
|
|
let top = touchMagnifier.clientY - size - 28
|
|
|
|
if (top < 8) {
|
|
top = touchMagnifier.clientY + 28
|
|
}
|
|
|
|
left = Math.max(8, Math.min(viewportW - size - 8, left))
|
|
top = Math.max(8, Math.min(viewportH - size - 8, top))
|
|
|
|
const imageWidth = rect.width * zoom
|
|
const imageHeight = rect.height * zoom
|
|
|
|
const imageLeft = size / 2 - boxCenterX * imageWidth
|
|
const imageTop = size / 2 - boxCenterY * imageHeight
|
|
|
|
const pointerX = imageLeft + touchMagnifier.imageX * imageWidth
|
|
const pointerY = imageTop + touchMagnifier.imageY * imageHeight
|
|
|
|
const boxLeft = hasUsableBox ? imageLeft + activeBox.x * imageWidth : 0
|
|
const boxTop = hasUsableBox ? imageTop + activeBox.y * imageHeight : 0
|
|
const boxWidth = hasUsableBox ? activeBox.w * imageWidth : 0
|
|
const boxHeight = hasUsableBox ? activeBox.h * imageHeight : 0
|
|
|
|
return (
|
|
<div
|
|
className="pointer-events-none fixed z-[9999] overflow-hidden rounded-xl border-2 border-white bg-black shadow-2xl ring-2 ring-black/70"
|
|
style={{
|
|
left,
|
|
top,
|
|
width: size,
|
|
height: size,
|
|
}}
|
|
>
|
|
<img
|
|
src={imageSrc}
|
|
alt=""
|
|
draggable={false}
|
|
className="absolute max-h-none max-w-none select-none"
|
|
style={{
|
|
left: imageLeft,
|
|
top: imageTop,
|
|
width: imageWidth,
|
|
height: imageHeight,
|
|
}}
|
|
/>
|
|
|
|
{hasUsableBox ? (
|
|
<div
|
|
className={[
|
|
'absolute rounded border-2 bg-black/0 shadow-[0_0_0_1px_rgba(0,0,0,0.85)]',
|
|
scoreBorderClass(activeBox.score, { draft: Boolean(drawingBox) }),
|
|
].join(' ')}
|
|
style={{
|
|
left: boxLeft,
|
|
top: boxTop,
|
|
width: boxWidth,
|
|
height: boxHeight,
|
|
}}
|
|
/>
|
|
) : null}
|
|
|
|
<div
|
|
className="absolute h-5 w-px -translate-x-1/2 -translate-y-1/2 bg-red-500/90"
|
|
style={{
|
|
left: pointerX,
|
|
top: pointerY,
|
|
}}
|
|
/>
|
|
<div
|
|
className="absolute h-px w-5 -translate-x-1/2 -translate-y-1/2 bg-red-500/90"
|
|
style={{
|
|
left: pointerX,
|
|
top: pointerY,
|
|
}}
|
|
/>
|
|
|
|
<div
|
|
className="absolute h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full border border-white bg-red-500 shadow"
|
|
style={{
|
|
left: pointerX,
|
|
top: pointerY,
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
})() : null}
|
|
</div>
|
|
|
|
{trainingRunning ? (
|
|
<TrainingOverlay
|
|
step={shownTrainingStep}
|
|
progress={shownTrainingProgress}
|
|
/>
|
|
) : frameBusy ? (
|
|
<LoadingImageOverlay
|
|
text={analysisStep || 'Bild wird geladen…'}
|
|
progress={loading ? analysisProgress : 100}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
) : trainingRunning ? (
|
|
<TrainingOverlay
|
|
step={shownTrainingStep || 'Aktuelles Bild wird geladen…'}
|
|
progress={shownTrainingProgress}
|
|
/>
|
|
) : loading ? (
|
|
<LoadingImageOverlay
|
|
text={analysisStep}
|
|
progress={analysisProgress}
|
|
/>
|
|
) : (
|
|
<div className="text-sm text-white/80">Kein Bild geladen</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="sticky bottom-0 z-40 mt-3 grid grid-cols-2 gap-2 sm:static sm:mt-4">
|
|
<Button
|
|
size="md"
|
|
variant={hasManualCorrection ? 'primary' : 'soft'}
|
|
color={hasManualCorrection ? undefined : 'emerald'}
|
|
disabled={
|
|
uiLocked ||
|
|
frameBusy ||
|
|
!sample ||
|
|
(!hasManualCorrection && !sample.prediction.modelAvailable)
|
|
}
|
|
onClick={() => void saveFeedback(!hasManualCorrection)}
|
|
className="w-full justify-center px-2 text-xs sm:text-sm"
|
|
title={
|
|
hasManualCorrection
|
|
? 'Die korrigierten Werte werden gespeichert.'
|
|
: sample?.prediction.modelAvailable
|
|
? 'Die Erkennung stimmt. Prediction wird als korrekt gespeichert.'
|
|
: 'Erst verfügbar, wenn ein Modell trainiert wurde.'
|
|
}
|
|
>
|
|
{saving ? (
|
|
'Speichere…'
|
|
) : hasManualCorrection ? (
|
|
<>
|
|
<span className="sm:hidden">Speichern</span>
|
|
<span className="hidden sm:inline">Speichern & weiter</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<span className="sm:hidden">Passt</span>
|
|
<span className="hidden sm:inline">Passt so & weiter</span>
|
|
</>
|
|
)}
|
|
</Button>
|
|
|
|
<Button
|
|
size="md"
|
|
variant="secondary"
|
|
disabled={uiLocked || frameBusy || !sample}
|
|
onClick={() => void loadNext({ forceNew: true })}
|
|
className="w-full justify-center px-2 text-xs sm:text-sm"
|
|
title="Dieses Bild nicht bewerten und ein anderes laden."
|
|
>
|
|
<span className="sm:hidden">Skip</span>
|
|
<span className="hidden sm:inline">Überspringen</span>
|
|
</Button>
|
|
</div>
|
|
|
|
{activeNotice ? (
|
|
<div className="mx-auto mt-3 hidden w-full max-w-2xl sm:block">
|
|
<TrainingNoticeCard
|
|
notice={activeNotice}
|
|
onClose={
|
|
trainingRunning
|
|
? undefined
|
|
: () => {
|
|
setError(null)
|
|
setMessage(null)
|
|
}
|
|
}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="mt-2 hidden text-center text-xs text-gray-500 dark:text-gray-400 lg:block">
|
|
Prüfe das Bild. Wenn die Erkennung stimmt: „Passt so“. Wenn nicht: korrigieren und speichern.
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<div className="flex h-[48dvh] min-h-[260px] flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900/80 lg:hidden">
|
|
<div className="grid grid-cols-3 gap-1 border-b border-gray-200 p-1 dark:border-white/10">
|
|
{[
|
|
{ key: 'labels', label: 'Labels' },
|
|
{ key: 'boxes', label: 'Boxen', count: correctionBoxes.length },
|
|
{ key: 'training', label: 'Training' },
|
|
].map((item) => {
|
|
const active = mobilePanel === item.key
|
|
|
|
return (
|
|
<button
|
|
key={item.key}
|
|
type="button"
|
|
onClick={() => setMobilePanel(item.key as 'labels' | 'boxes' | 'training')}
|
|
className={[
|
|
'rounded-md px-2 py-2 text-xs font-semibold transition',
|
|
active
|
|
? [
|
|
'bg-indigo-100 text-indigo-900 shadow-sm ring-1 ring-indigo-200',
|
|
'dark:bg-indigo-600 dark:text-white dark:ring-indigo-500',
|
|
].join(' ')
|
|
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/10',
|
|
].join(' ')}
|
|
>
|
|
<span className="inline-flex items-center justify-center gap-1.5">
|
|
<span>{item.label}</span>
|
|
|
|
{typeof item.count === 'number' ? (
|
|
<span
|
|
className={[
|
|
'min-w-5 rounded-full px-1.5 py-0.5 text-[10px] font-black leading-none ring-1',
|
|
active
|
|
? 'bg-indigo-600 text-white ring-indigo-600 dark:bg-white dark:text-indigo-700 dark:ring-white'
|
|
: 'bg-gray-100 text-gray-700 ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10',
|
|
].join(' ')}
|
|
aria-label={`${item.count} Boxen`}
|
|
>
|
|
{item.count}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
<div
|
|
ref={mobileLabelsScrollRef}
|
|
className="min-h-0 h-full flex-1 overflow-y-auto overscroll-contain p-3 scroll-smooth"
|
|
>
|
|
{mobilePanel === 'labels' ? (
|
|
<div
|
|
className={[
|
|
'space-y-3',
|
|
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>
|
|
|
|
<div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
|
Label wählen, dann Box im Bild zeichnen.
|
|
</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
|
|
ref={(el) => {
|
|
mobileSectionRefs.current.sexPosition = el
|
|
}}
|
|
>
|
|
<CollapsibleSingleLabelSection
|
|
title="Sexposition"
|
|
values={labels.sexPositions}
|
|
value={correction.sexPosition}
|
|
score={sample?.prediction.sexPositionScore}
|
|
predictionValue={sample?.prediction.sexPosition}
|
|
expanded={expandedCorrectionSections.sexPosition}
|
|
onExpandedChange={(expanded) =>
|
|
toggleMobileCorrectionSection('sexPosition', expanded)
|
|
}
|
|
onChange={(value) =>
|
|
setCorrection((p) => {
|
|
if (p.sexPosition !== value) {
|
|
setHasManualCorrection(true)
|
|
}
|
|
|
|
return {
|
|
...p,
|
|
sexPosition: value,
|
|
}
|
|
})
|
|
}
|
|
disabled={uiLocked}
|
|
gridClassName="grid grid-cols-3 gap-2"
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
ref={(el) => {
|
|
mobileSectionRefs.current.people = el
|
|
}}
|
|
>
|
|
<CollapsibleLabelSection
|
|
title="Personen"
|
|
values={labels.people}
|
|
selected={selectedPeopleLabels}
|
|
scores={peopleScores}
|
|
expanded={expandedCorrectionSections.people}
|
|
onExpandedChange={(expanded) =>
|
|
toggleMobileCorrectionSection('people', expanded)
|
|
}
|
|
onToggle={() => {}}
|
|
drawLabel={drawLabelForSection(labels.people)}
|
|
onDrawLabelChange={setBoxLabel}
|
|
disabled={uiLocked}
|
|
singleDrawMode
|
|
gridClassName="grid grid-cols-2 gap-2"
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
ref={(el) => {
|
|
mobileSectionRefs.current.bodyParts = el
|
|
}}
|
|
>
|
|
<CollapsibleLabelSection
|
|
title="Körperteile"
|
|
values={labels.bodyParts}
|
|
selected={correction.bodyPartsPresent}
|
|
scores={bodyPartScores}
|
|
expanded={expandedCorrectionSections.bodyParts}
|
|
gridClassName="grid grid-cols-3 gap-2"
|
|
onExpandedChange={(expanded) =>
|
|
toggleMobileCorrectionSection('bodyParts', expanded)
|
|
}
|
|
onToggle={(value) =>
|
|
setCorrection((p) => {
|
|
setHasManualCorrection(true)
|
|
|
|
return {
|
|
...p,
|
|
bodyPartsPresent: toggleArrayValue(p.bodyPartsPresent, value),
|
|
}
|
|
})
|
|
}
|
|
drawLabel={drawLabelForSection(labels.bodyParts)}
|
|
onDrawLabelChange={setBoxLabel}
|
|
disabled={uiLocked}
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
ref={(el) => {
|
|
mobileSectionRefs.current.objects = el
|
|
}}
|
|
>
|
|
<CollapsibleLabelSection
|
|
title="Gegenstände"
|
|
values={labels.objects}
|
|
selected={correction.objectsPresent}
|
|
scores={objectScores}
|
|
expanded={expandedCorrectionSections.objects}
|
|
gridClassName="grid grid-cols-3 gap-2"
|
|
onExpandedChange={(expanded) =>
|
|
toggleMobileCorrectionSection('objects', expanded)
|
|
}
|
|
onToggle={(value) =>
|
|
setCorrection((p) => {
|
|
setHasManualCorrection(true)
|
|
|
|
return {
|
|
...p,
|
|
objectsPresent: toggleArrayValue(p.objectsPresent, value),
|
|
}
|
|
})
|
|
}
|
|
drawLabel={drawLabelForSection(labels.objects)}
|
|
onDrawLabelChange={setBoxLabel}
|
|
disabled={uiLocked}
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
ref={(el) => {
|
|
mobileSectionRefs.current.clothing = el
|
|
}}
|
|
>
|
|
<CollapsibleLabelSection
|
|
title="Kleidung"
|
|
values={labels.clothing}
|
|
selected={correction.clothingPresent}
|
|
scores={clothingScores}
|
|
expanded={expandedCorrectionSections.clothing}
|
|
gridClassName="grid grid-cols-3 gap-2"
|
|
onExpandedChange={(expanded) =>
|
|
toggleMobileCorrectionSection('clothing', expanded)
|
|
}
|
|
onToggle={(value) =>
|
|
setCorrection((p) => {
|
|
setHasManualCorrection(true)
|
|
|
|
return {
|
|
...p,
|
|
clothingPresent: toggleArrayValue(p.clothingPresent, value),
|
|
}
|
|
})
|
|
}
|
|
drawLabel={drawLabelForSection(labels.clothing)}
|
|
onDrawLabelChange={setBoxLabel}
|
|
disabled={uiLocked}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{mobilePanel === 'boxes' ? (
|
|
<div>
|
|
{detectorBoxesPanel({ compact: true })}
|
|
</div>
|
|
) : null}
|
|
|
|
{mobilePanel === 'training' ? (
|
|
<div className="space-y-2 text-xs">
|
|
<div className="pt-1">
|
|
{trainingActionsPanel({ compact: true })}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Rechte Details/Korrektur */}
|
|
<aside
|
|
className={[
|
|
'hidden h-full max-h-full min-h-0 overflow-y-auto overscroll-contain rounded-xl border border-gray-200 bg-white p-3 shadow-sm dark:border-white/10 dark:bg-gray-900/60 lg:block',
|
|
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>
|
|
|
|
<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
|
|
title="Sexposition"
|
|
values={labels.sexPositions}
|
|
value={correction.sexPosition}
|
|
score={sample?.prediction.sexPositionScore}
|
|
predictionValue={sample?.prediction.sexPosition}
|
|
expanded={expandedCorrectionSections.sexPosition}
|
|
onExpandedChange={(expanded) =>
|
|
setExpandedCorrectionSections((p) => ({
|
|
...p,
|
|
sexPosition: expanded,
|
|
}))
|
|
}
|
|
onChange={(value) =>
|
|
setCorrection((p) => {
|
|
if (p.sexPosition !== value) {
|
|
setHasManualCorrection(true)
|
|
}
|
|
|
|
return {
|
|
...p,
|
|
sexPosition: value,
|
|
}
|
|
})
|
|
}
|
|
disabled={uiLocked}
|
|
gridClassName="grid grid-cols-3 gap-2"
|
|
/>
|
|
|
|
<CollapsibleLabelSection
|
|
title="Personen"
|
|
values={labels.people}
|
|
selected={selectedPeopleLabels}
|
|
scores={peopleScores}
|
|
expanded={expandedCorrectionSections.people}
|
|
onExpandedChange={(expanded) =>
|
|
setExpandedCorrectionSections((p) => ({
|
|
...p,
|
|
people: expanded,
|
|
}))
|
|
}
|
|
onToggle={() => {}}
|
|
drawLabel={drawLabelForSection(labels.people)}
|
|
onDrawLabelChange={setBoxLabel}
|
|
disabled={uiLocked}
|
|
singleDrawMode
|
|
gridClassName="grid grid-cols-2 gap-2"
|
|
/>
|
|
|
|
<CollapsibleLabelSection
|
|
title="Körperteile"
|
|
values={labels.bodyParts}
|
|
selected={correction.bodyPartsPresent}
|
|
scores={bodyPartScores}
|
|
expanded={expandedCorrectionSections.bodyParts}
|
|
gridClassName="grid grid-cols-3 gap-2"
|
|
onExpandedChange={(expanded) =>
|
|
setExpandedCorrectionSections((p) => ({
|
|
...p,
|
|
bodyParts: expanded,
|
|
}))
|
|
}
|
|
onToggle={(value) =>
|
|
setCorrection((p) => {
|
|
setHasManualCorrection(true)
|
|
|
|
return {
|
|
...p,
|
|
bodyPartsPresent: toggleArrayValue(p.bodyPartsPresent, value),
|
|
}
|
|
})
|
|
}
|
|
drawLabel={drawLabelForSection(labels.bodyParts)}
|
|
onDrawLabelChange={setBoxLabel}
|
|
disabled={uiLocked}
|
|
/>
|
|
|
|
<CollapsibleLabelSection
|
|
title="Gegenstände"
|
|
values={labels.objects}
|
|
selected={correction.objectsPresent}
|
|
scores={objectScores}
|
|
expanded={expandedCorrectionSections.objects}
|
|
gridClassName="grid grid-cols-3 gap-2"
|
|
onExpandedChange={(expanded) =>
|
|
setExpandedCorrectionSections((p) => ({
|
|
...p,
|
|
objects: expanded,
|
|
}))
|
|
}
|
|
onToggle={(value) =>
|
|
setCorrection((p) => {
|
|
setHasManualCorrection(true)
|
|
|
|
return {
|
|
...p,
|
|
objectsPresent: toggleArrayValue(p.objectsPresent, value),
|
|
}
|
|
})
|
|
}
|
|
drawLabel={drawLabelForSection(labels.objects)}
|
|
onDrawLabelChange={setBoxLabel}
|
|
disabled={uiLocked}
|
|
/>
|
|
|
|
<CollapsibleLabelSection
|
|
title="Kleidung"
|
|
values={labels.clothing}
|
|
selected={correction.clothingPresent}
|
|
scores={clothingScores}
|
|
expanded={expandedCorrectionSections.clothing}
|
|
gridClassName="grid grid-cols-3 gap-2"
|
|
onExpandedChange={(expanded) =>
|
|
setExpandedCorrectionSections((p) => ({
|
|
...p,
|
|
clothing: expanded,
|
|
}))
|
|
}
|
|
onToggle={(value) =>
|
|
setCorrection((p) => {
|
|
setHasManualCorrection(true)
|
|
|
|
return {
|
|
...p,
|
|
clothingPresent: toggleArrayValue(p.clothingPresent, value),
|
|
}
|
|
})
|
|
}
|
|
drawLabel={drawLabelForSection(labels.clothing)}
|
|
onDrawLabelChange={setBoxLabel}
|
|
disabled={uiLocked}
|
|
/>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
|
|
<TrainingStatsModal
|
|
open={statsModalOpen}
|
|
onClose={() => setStatsModalOpen(false)}
|
|
stats={trainingStats}
|
|
loading={trainingStatsLoading}
|
|
error={trainingStatsError}
|
|
feedbackCount={feedbackCount}
|
|
requiredCount={requiredCount}
|
|
/>
|
|
</div>
|
|
)
|
|
} |