6713 lines
231 KiB
TypeScript
6713 lines
231 KiB
TypeScript
// frontend\src\components\ui\TrainingTab.tsx
|
||
'use client'
|
||
|
||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
|
||
import Button from './Button'
|
||
import LoadingSpinner from './LoadingSpinner'
|
||
import { formatDuration } from './formatters'
|
||
import {
|
||
ArrowPathIcon,
|
||
ArrowsPointingInIcon,
|
||
ArrowsPointingOutIcon,
|
||
BoltIcon,
|
||
CheckIcon,
|
||
ForwardIcon,
|
||
InboxArrowDownIcon,
|
||
TrashIcon,
|
||
XCircleIcon,
|
||
} from '@heroicons/react/20/solid'
|
||
import { getSegmentLabelItem } from './Icons'
|
||
import Modal from './Modal'
|
||
import { useNotify } from './notify'
|
||
import { createPortal } from 'react-dom'
|
||
import TrainingFeedbackHistoryModal from './TrainingFeedbackHistoryModal'
|
||
|
||
|
||
type DrawingTrainingBox = TrainingBox & {
|
||
startX: number
|
||
startY: number
|
||
}
|
||
|
||
type ScoredLabel = {
|
||
label: string
|
||
score: number
|
||
}
|
||
|
||
type TrainingDetectorStatus = {
|
||
trainCount: number
|
||
valCount: number
|
||
positiveTrainCount: number
|
||
positiveValCount: 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
|
||
previewUrl?: string
|
||
map50?: number
|
||
map5095?: 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
|
||
uncertaintyScore?: number
|
||
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 PendingTrainingVideoImport = {
|
||
jobId?: string
|
||
output: string
|
||
sourceFile?: string
|
||
count?: number
|
||
}
|
||
|
||
type TrainingConfidence = {
|
||
score: number
|
||
level: 'none' | 'low' | 'mid' | 'high'
|
||
label: string
|
||
}
|
||
|
||
type TrainingLabelStat = {
|
||
label: string
|
||
count: number
|
||
confidence?: TrainingConfidence
|
||
}
|
||
|
||
type TrainingModelInfo = {
|
||
trainedAt?: string
|
||
trainedAtMs?: number
|
||
epochs?: number
|
||
trainSamples?: number
|
||
valSamples?: number
|
||
imgsz?: number
|
||
device?: string
|
||
map50?: number
|
||
map5095?: number
|
||
}
|
||
|
||
type TrainingHistoryEntry = {
|
||
trainedAt?: string
|
||
trainedAtMs?: number
|
||
durationMs?: number
|
||
epochs?: number
|
||
trainSamples?: number
|
||
valSamples?: number
|
||
imgsz?: number
|
||
device?: string
|
||
map50?: number
|
||
map5095?: number
|
||
}
|
||
|
||
type TrainingStats = {
|
||
feedbackCount: number
|
||
acceptedCount: number
|
||
correctedCount: number
|
||
negativeCount: number
|
||
sampleCount: number
|
||
boxCount: number
|
||
modelAvailable: boolean
|
||
modelInfo?: TrainingModelInfo
|
||
confidence?: TrainingConfidence
|
||
labels: {
|
||
people: TrainingLabelStat[]
|
||
sexPositions: TrainingLabelStat[]
|
||
bodyParts: TrainingLabelStat[]
|
||
objects: TrainingLabelStat[]
|
||
clothing: TrainingLabelStat[]
|
||
}
|
||
}
|
||
|
||
type TrainingAnnotation = {
|
||
sampleId: string
|
||
frameUrl: string
|
||
sourceFile: string
|
||
sourcePath?: string
|
||
sourceSizeBytes?: number
|
||
second: number
|
||
createdAt: string
|
||
answeredAt: string
|
||
prediction: TrainingPrediction
|
||
accepted: boolean
|
||
negative?: boolean
|
||
correction?: CorrectionState
|
||
notes?: string
|
||
}
|
||
|
||
type TrainingFeedbackListResponse = {
|
||
ok: boolean
|
||
items: TrainingAnnotation[]
|
||
total: number
|
||
limit: number
|
||
offset: number
|
||
hasMore: boolean
|
||
}
|
||
|
||
type TrainingSampleMode = 'random' | 'uncertain'
|
||
type FeedbackFilter = 'all' | 'accepted' | 'corrected' | 'negative'
|
||
|
||
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 formatModelTrainedAt(info?: TrainingModelInfo): string {
|
||
if (!info) return ''
|
||
|
||
const ms = Number(info.trainedAtMs)
|
||
const date =
|
||
Number.isFinite(ms) && ms > 0
|
||
? new Date(ms)
|
||
: info.trainedAt
|
||
? new Date(info.trainedAt)
|
||
: null
|
||
|
||
if (!date || Number.isNaN(date.getTime())) return ''
|
||
|
||
return date.toLocaleString('de-DE', {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
})
|
||
}
|
||
|
||
function formatMapPercent(value?: number | null): string {
|
||
const n = Number(value)
|
||
if (!Number.isFinite(n) || n <= 0) return ''
|
||
return `${(n * 100).toFixed(1)}%`
|
||
}
|
||
|
||
function formatHistoryDate(entry: TrainingHistoryEntry): string {
|
||
const ms = Number(entry.trainedAtMs)
|
||
const date =
|
||
Number.isFinite(ms) && ms > 0
|
||
? new Date(ms)
|
||
: entry.trainedAt
|
||
? new Date(entry.trainedAt)
|
||
: null
|
||
|
||
if (!date || Number.isNaN(date.getTime())) return '—'
|
||
|
||
return date.toLocaleString('de-DE', {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
})
|
||
}
|
||
|
||
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 correctionHasRelevantContent(value: CorrectionState) {
|
||
return (
|
||
(value.sexPosition && value.sexPosition !== 'unknown') ||
|
||
(value.peoplePresent?.length ?? 0) > 0 ||
|
||
(value.bodyPartsPresent?.length ?? 0) > 0 ||
|
||
(value.objectsPresent?.length ?? 0) > 0 ||
|
||
(value.clothingPresent?.length ?? 0) > 0 ||
|
||
(value.boxes?.length ?? 0) > 0
|
||
)
|
||
}
|
||
|
||
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 TrainingStageOverlay(props: {
|
||
mode: 'training' | 'analysis'
|
||
text?: string
|
||
progress?: number
|
||
backgroundUrl?: string
|
||
visible?: boolean
|
||
instantBackground?: boolean
|
||
}) {
|
||
const progress = clampPercent(props.progress ?? 0)
|
||
const isTraining = props.mode === 'training'
|
||
const hasBackground = Boolean(props.backgroundUrl)
|
||
const visible = props.visible ?? true
|
||
|
||
const [backgroundVisible, setBackgroundVisible] = useState(false)
|
||
|
||
useEffect(() => {
|
||
if (!props.backgroundUrl) {
|
||
setBackgroundVisible(false)
|
||
return
|
||
}
|
||
|
||
setBackgroundVisible(false)
|
||
|
||
const frame = window.requestAnimationFrame(() => {
|
||
setBackgroundVisible(true)
|
||
})
|
||
|
||
return () => window.cancelAnimationFrame(frame)
|
||
}, [props.backgroundUrl])
|
||
|
||
const title = isTraining ? 'Training läuft…' : 'Analyse läuft…'
|
||
const fallbackText = isTraining
|
||
? 'Bitte warten. Die Oberfläche ist währenddessen gesperrt.'
|
||
: 'Bild wird erstellt und analysiert. Bitte warten.'
|
||
|
||
return (
|
||
<div
|
||
className={[
|
||
'absolute inset-0 z-[500] flex items-center justify-center overflow-hidden rounded-md bg-black text-center text-white',
|
||
'transition-opacity duration-300 ease-out will-change-opacity motion-reduce:transition-none',
|
||
visible ? 'opacity-100' : 'pointer-events-none opacity-0',
|
||
].join(' ')}
|
||
>
|
||
<div className="absolute inset-1 overflow-hidden rounded-md sm:inset-2">
|
||
{hasBackground ? (
|
||
<img
|
||
// Beim schnellen Durchlaufen (Training) dasselbe Element wiederverwenden,
|
||
// damit nur die Bildquelle wechselt und kein Ein-/Ausblenden je Bild läuft.
|
||
key={props.instantBackground ? undefined : props.backgroundUrl}
|
||
src={props.backgroundUrl}
|
||
alt=""
|
||
aria-hidden="true"
|
||
draggable={false}
|
||
className={[
|
||
'absolute inset-0 z-0 h-full w-full object-contain blur-[1px]',
|
||
props.instantBackground
|
||
? 'opacity-80'
|
||
: [
|
||
'transition-opacity duration-500 ease-out will-change-opacity motion-reduce:transition-none',
|
||
backgroundVisible ? 'opacity-80' : 'opacity-0',
|
||
].join(' '),
|
||
].join(' ')}
|
||
/>
|
||
) : null}
|
||
|
||
<div
|
||
className={[
|
||
'absolute inset-0 z-[1] rounded-md',
|
||
hasBackground
|
||
? 'bg-black/30 backdrop-blur-[4px] shadow-[inset_0_0_48px_18px_rgba(0,0,0,0.55)]'
|
||
: 'bg-black/45 backdrop-blur-[8px] shadow-[inset_0_0_72px_30px_rgba(0,0,0,0.75)]',
|
||
].join(' ')}
|
||
/>
|
||
</div>
|
||
|
||
<div className="relative z-10 flex flex-col items-center justify-center">
|
||
<LoadingSpinner
|
||
size="lg"
|
||
className="text-white"
|
||
srLabel={title}
|
||
/>
|
||
|
||
<div className="mt-3 text-sm font-semibold">
|
||
{title}
|
||
</div>
|
||
|
||
<div className="mt-1 max-w-[260px] px-4 text-xs text-white/80">
|
||
{props.text || fallbackText}
|
||
</div>
|
||
|
||
<div className="mt-3 h-2 w-48 overflow-hidden rounded-full bg-white/20">
|
||
<div
|
||
className={[
|
||
'h-full rounded-full transition-all duration-500',
|
||
isTraining ? 'bg-indigo-400' : 'bg-emerald-400',
|
||
].join(' ')}
|
||
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-white/10 dark:text-gray-100 dark:ring-white/20',
|
||
'dark:shadow-sm',
|
||
].join(' ')}
|
||
>
|
||
{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 topTrainingLabelStat(values: TrainingLabelStat[]) {
|
||
return [...values]
|
||
.filter((item) => String(item.label || '').trim())
|
||
.sort((a, b) => b.count - a.count)[0]
|
||
}
|
||
|
||
function TrainingStatsModal(props: {
|
||
open: boolean
|
||
onClose: () => void
|
||
stats: TrainingStats | null
|
||
history?: TrainingHistoryEntry[]
|
||
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 negativeCount = stats?.negativeCount ?? 0
|
||
const totalFeedback = stats?.feedbackCount ?? props.feedbackCount
|
||
const boxCount = stats?.boxCount ?? 0
|
||
const sampleCount = stats?.sampleCount ?? 0
|
||
const overallConfidence = stats?.confidence
|
||
|
||
const modelTrainedAtLabel = formatModelTrainedAt(stats?.modelInfo)
|
||
const modelMap50Label = formatMapPercent(stats?.modelInfo?.map50)
|
||
const modelMap5095Label = formatMapPercent(stats?.modelInfo?.map5095)
|
||
const modelInfoDetails = (() => {
|
||
const info = stats?.modelInfo
|
||
if (!info) return ''
|
||
|
||
const parts: string[] = []
|
||
if (Number(info.epochs) > 0) parts.push(`${info.epochs} Epochen`)
|
||
if (Number(info.trainSamples) > 0) parts.push(`${info.trainSamples} Train`)
|
||
if (Number(info.valSamples) > 0) parts.push(`${info.valSamples} Val`)
|
||
if (String(info.device || '').trim()) parts.push(String(info.device).trim())
|
||
|
||
return parts.join(' · ')
|
||
})()
|
||
|
||
const history = props.history ?? []
|
||
|
||
const tabItems: Array<{
|
||
key: TrainingStatsTabKey
|
||
title: string
|
||
shortTitle: string
|
||
description: string
|
||
values: TrainingLabelStat[]
|
||
total: number
|
||
confidence?: TrainingConfidence
|
||
topLabel?: TrainingLabelStat
|
||
}> = [
|
||
{
|
||
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 ?? []),
|
||
topLabel: topTrainingLabelStat(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 ?? []),
|
||
topLabel: topTrainingLabelStat(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 ?? []),
|
||
topLabel: topTrainingLabelStat(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 ?? []),
|
||
topLabel: topTrainingLabelStat(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 ?? []),
|
||
topLabel: topTrainingLabelStat(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-5 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">
|
||
Negativ
|
||
</div>
|
||
<div className="mt-0.5 text-sm font-black text-blue-700 dark:text-blue-300">
|
||
{negativeCount}
|
||
</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-5 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">
|
||
Negativ
|
||
</div>
|
||
<div className="mt-1 text-2xl font-bold text-blue-700 dark:text-blue-300">
|
||
{negativeCount}
|
||
</div>
|
||
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||
{countPercent(negativeCount, 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>
|
||
|
||
{stats?.modelAvailable && modelTrainedAtLabel ? (
|
||
<div className="mt-2 space-y-1">
|
||
<div className="flex items-center justify-between gap-2 text-xs">
|
||
<span className="font-medium text-indigo-800/80 dark:text-indigo-100/70">
|
||
Version vom
|
||
</span>
|
||
<span className="font-semibold tabular-nums text-indigo-950 dark:text-indigo-50">
|
||
{modelTrainedAtLabel}
|
||
</span>
|
||
</div>
|
||
|
||
{modelMap50Label ? (
|
||
<div className="flex items-center justify-between gap-2 text-xs">
|
||
<span className="font-medium text-indigo-800/80 dark:text-indigo-100/70">
|
||
Qualität (mAP50{modelMap5095Label ? ' / 50-95' : ''})
|
||
</span>
|
||
<span className="font-semibold tabular-nums text-indigo-950 dark:text-indigo-50">
|
||
{modelMap50Label}
|
||
{modelMap5095Label ? ` / ${modelMap5095Label}` : ''}
|
||
</span>
|
||
</div>
|
||
) : null}
|
||
|
||
{modelInfoDetails ? (
|
||
<div className="text-[11px] text-indigo-800/70 dark:text-indigo-100/60">
|
||
{modelInfoDetails}
|
||
</div>
|
||
) : null}
|
||
</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>
|
||
|
||
{history.length > 0 ? (
|
||
<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="text-sm font-semibold text-gray-900 dark:text-white">
|
||
Trainings-Verlauf
|
||
</div>
|
||
<div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||
Modellqualität (mAP) über die letzten Trainingsläufe
|
||
</div>
|
||
</div>
|
||
|
||
<div className="max-h-56 divide-y divide-gray-100 overflow-y-auto dark:divide-white/10">
|
||
{history.map((entry, idx) => {
|
||
const map50 = formatMapPercent(entry.map50)
|
||
const map5095 = formatMapPercent(entry.map5095)
|
||
const duration =
|
||
Number(entry.durationMs) > 0
|
||
? formatDuration(Number(entry.durationMs))
|
||
: ''
|
||
const barPct = Math.max(
|
||
0,
|
||
Math.min(100, Math.round(Number(entry.map50 ?? 0) * 100))
|
||
)
|
||
|
||
const meta: string[] = []
|
||
if (Number(entry.epochs) > 0) meta.push(`${entry.epochs} Ep.`)
|
||
if (Number(entry.trainSamples) > 0) meta.push(`${entry.trainSamples} Train`)
|
||
if (duration) meta.push(duration)
|
||
|
||
return (
|
||
<div key={`${entry.trainedAtMs}-${idx}`} className="px-4 py-2.5">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
|
||
{formatHistoryDate(entry)}
|
||
{idx === 0 ? (
|
||
<span className="ml-2 align-middle rounded-full bg-indigo-100 px-1.5 py-0.5 text-[9px] font-bold text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-100 dark:ring-indigo-300/30">
|
||
aktuell
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
{meta.length > 0 ? (
|
||
<div className="mt-0.5 truncate text-[11px] text-gray-500 dark:text-gray-400">
|
||
{meta.join(' · ')}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="shrink-0 text-right">
|
||
<div className="text-sm font-bold tabular-nums text-gray-900 dark:text-white">
|
||
{map50 || '—'}
|
||
</div>
|
||
<div className="text-[10px] tabular-nums text-gray-500 dark:text-gray-400">
|
||
{map5095 ? `50-95: ${map5095}` : 'mAP50'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
|
||
<div
|
||
className="h-full rounded-full bg-indigo-500 transition-all"
|
||
style={{ width: `${barPct}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{/* 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 topLabelItem = item.topLabel
|
||
? getSegmentLabelItem(item.topLabel.label)
|
||
: null
|
||
const TopIcon = topLabelItem?.icon
|
||
|
||
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-indigo-500/18 dark:text-indigo-50 dark:ring-indigo-300/45',
|
||
'dark:shadow-[0_0_0_1px_rgba(129,140,248,0.20),0_10px_24px_rgba(79,70,229,0.18)]',
|
||
].join(' ')
|
||
: [
|
||
'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(' '),
|
||
].join(' ')}
|
||
>
|
||
<div className="flex min-w-0 items-center gap-2.5">
|
||
{TopIcon ? (
|
||
<div
|
||
className={[
|
||
'flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ring-1',
|
||
active
|
||
? [
|
||
'bg-indigo-50 text-indigo-700 ring-indigo-200',
|
||
'dark:bg-white/12 dark:text-indigo-50 dark:ring-white/20',
|
||
].join(' ')
|
||
: [
|
||
'bg-white text-gray-500 ring-gray-200',
|
||
'dark:bg-white/7 dark:text-gray-300 dark:ring-white/10',
|
||
].join(' '),
|
||
].join(' ')}
|
||
aria-hidden="true"
|
||
>
|
||
<TopIcon className="h-5 w-5" aria-hidden="true" />
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="min-w-0 flex-1">
|
||
<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-start gap-2">
|
||
<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)}`}
|
||
>
|
||
{confidenceLabel(item.confidence)} · {confidencePercent(item.confidence)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<TrainingStatsList
|
||
title={activeTabItem.title}
|
||
description={activeTabItem.description}
|
||
values={activeTabItem.values}
|
||
total={activeTabItem.total}
|
||
confidence={activeTabItem.confidence}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Modal>
|
||
)
|
||
}
|
||
|
||
function makeRequestId() {
|
||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
||
return crypto.randomUUID()
|
||
}
|
||
|
||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||
}
|
||
|
||
function annotationToTrainingSample(item: TrainingAnnotation): TrainingSample {
|
||
return {
|
||
sampleId: item.sampleId,
|
||
frameUrl: item.frameUrl,
|
||
sourceFile: item.sourceFile,
|
||
sourcePath: item.sourcePath,
|
||
sourceSizeBytes: item.sourceSizeBytes,
|
||
second: item.second,
|
||
createdAt: item.createdAt,
|
||
prediction: item.prediction,
|
||
}
|
||
}
|
||
|
||
function annotationToCorrectionState(item: TrainingAnnotation): CorrectionState {
|
||
if (item.negative) {
|
||
return {
|
||
sexPosition: 'unknown',
|
||
peoplePresent: [],
|
||
bodyPartsPresent: [],
|
||
objectsPresent: [],
|
||
clothingPresent: [],
|
||
boxes: [],
|
||
}
|
||
}
|
||
|
||
if (item.correction) {
|
||
return item.correction
|
||
}
|
||
|
||
return predictionToCorrection(annotationToTrainingSample(item))
|
||
}
|
||
|
||
const FEEDBACK_PAGE_SIZE = 10
|
||
|
||
const TRAINING_INFO_DISMISSED_STORAGE_KEY = 'training:info-dismissed-key'
|
||
const TRAINING_IMAGE_EXPANDED_STORAGE_KEY = 'training:image-expanded'
|
||
|
||
export default function TrainingTab(props: {
|
||
onTrainingRunningChange?: (running: boolean) => void
|
||
onImageExpandedChange?: (expanded: 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 [negativeExample, setNegativeExample] = 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 [trainingHistory, setTrainingHistory] = useState<TrainingHistoryEntry[]>([])
|
||
const wasTrainingRunningRef = useRef(false)
|
||
const shownTrainingCompletionRef = useRef<string | null>(null)
|
||
const [dismissedTrainingInfoKey, setDismissedTrainingInfoKey] = useState(() => {
|
||
if (typeof window === 'undefined') return ''
|
||
|
||
try {
|
||
return window.localStorage.getItem(TRAINING_INFO_DISMISSED_STORAGE_KEY) || ''
|
||
} catch {
|
||
return ''
|
||
}
|
||
})
|
||
|
||
const [importedSampleQueue, setImportedSampleQueue] = useState<TrainingSample[]>([])
|
||
const importedSampleQueueRef = useRef<TrainingSample[]>([])
|
||
|
||
const [feedbackModalOpen, setFeedbackModalOpen] = useState(false)
|
||
const [feedbackItems, setFeedbackItems] = useState<TrainingAnnotation[]>([])
|
||
const [feedbackLoading, setFeedbackLoading] = useState(false)
|
||
const [feedbackLoadingMore, setFeedbackLoadingMore] = useState(false)
|
||
const [feedbackError, setFeedbackError] = useState<string | null>(null)
|
||
const [feedbackTotal, setFeedbackTotal] = useState(0)
|
||
const [feedbackHasMore, setFeedbackHasMore] = useState(false)
|
||
const [selectedFeedbackIndex, setSelectedFeedbackIndex] = useState(0)
|
||
const [feedbackSearchQuery, setFeedbackSearchQuery] = useState('')
|
||
const [feedbackSearchFilter, setFeedbackSearchFilter] = useState<FeedbackFilter>('all')
|
||
|
||
const [editingFeedback, setEditingFeedback] = useState<{
|
||
sampleId: string
|
||
answeredAt: string
|
||
} | null>(null)
|
||
|
||
const notify = useNotify()
|
||
|
||
const activePointerIdRef = useRef<number | null>(null)
|
||
const finishingGestureRef = useRef(false)
|
||
|
||
const [frameImageLoaded, setFrameImageLoaded] = useState(false)
|
||
const [imageExpanded, setImageExpanded] = useState(() => {
|
||
if (typeof window === 'undefined') return false
|
||
|
||
try {
|
||
return window.localStorage.getItem(TRAINING_IMAGE_EXPANDED_STORAGE_KEY) === '1'
|
||
} catch {
|
||
return false
|
||
}
|
||
})
|
||
|
||
const [frameNaturalSize, setFrameNaturalSize] = useState<{
|
||
width: number
|
||
height: number
|
||
} | null>(null)
|
||
|
||
const [loadingPreviewUrl, setLoadingPreviewUrl] = useState('')
|
||
const [loadingPreviewLoaded, setLoadingPreviewLoaded] = useState(false)
|
||
const [loadingPreviewFailed, setLoadingPreviewFailed] = useState(false)
|
||
|
||
// Während des Trainings sendet das Backend zum gerade trainierten Batch eine
|
||
// Vorschau. Wir zeigen immer das zuletzt eingegangene Bild (live) und merken es
|
||
// in einem Ref, damit die Anzeige unabhängig von den (gedrosselten) Status-
|
||
// Updates bleibt und die Render-Rate begrenzt ist.
|
||
const [trainingPreviewUrl, setTrainingPreviewUrl] = useState('')
|
||
const latestTrainingPreviewRef = useRef('')
|
||
const lastTrainingStatusApplyRef = useRef(0)
|
||
|
||
const [stageOverlayMounted, setStageOverlayMounted] = useState(false)
|
||
const [stageOverlayVisible, setStageOverlayVisible] = useState(false)
|
||
|
||
const imageBoxRef = useRef<HTMLDivElement | null>(null)
|
||
const frameImageRef = useRef<HTMLImageElement | null>(null)
|
||
|
||
const [imageLayerStyle, setImageLayerStyle] = useState<CSSProperties | null>(null)
|
||
|
||
type ImageContentRect = {
|
||
left: number
|
||
top: number
|
||
width: number
|
||
height: number
|
||
right: number
|
||
bottom: number
|
||
}
|
||
|
||
const loadFeedbackHistoryInitial = useCallback(async (
|
||
options: {
|
||
query?: string
|
||
filter?: FeedbackFilter
|
||
} = {}
|
||
) => {
|
||
const query = options.query ?? ''
|
||
const filter = options.filter ?? 'all'
|
||
|
||
setFeedbackLoading(true)
|
||
setFeedbackError(null)
|
||
|
||
try {
|
||
const params = new URLSearchParams()
|
||
params.set('limit', String(FEEDBACK_PAGE_SIZE))
|
||
params.set('offset', '0')
|
||
|
||
if (query.trim()) {
|
||
params.set('q', query.trim())
|
||
}
|
||
|
||
if (filter !== 'all') {
|
||
params.set('filter', filter)
|
||
}
|
||
|
||
const res = await fetch(`/api/training/feedback/list?${params.toString()}`, {
|
||
cache: 'no-store',
|
||
})
|
||
|
||
const data = await res.json().catch(() => null) as TrainingFeedbackListResponse | null
|
||
|
||
if (!res.ok || !data) {
|
||
throw new Error(data && 'error' in data ? String((data as any).error) : `HTTP ${res.status}`)
|
||
}
|
||
|
||
setFeedbackItems(data.items)
|
||
setFeedbackTotal(data.total)
|
||
setFeedbackHasMore(data.hasMore)
|
||
setSelectedFeedbackIndex(0)
|
||
} catch (e) {
|
||
setFeedbackError(e instanceof Error ? e.message : String(e))
|
||
} finally {
|
||
setFeedbackLoading(false)
|
||
}
|
||
}, [])
|
||
|
||
const loadMoreFeedbackHistory = useCallback(async () => {
|
||
if (feedbackLoading || feedbackLoadingMore || !feedbackHasMore) return
|
||
|
||
setFeedbackLoadingMore(true)
|
||
setFeedbackError(null)
|
||
|
||
try {
|
||
const params = new URLSearchParams()
|
||
params.set('limit', String(FEEDBACK_PAGE_SIZE))
|
||
params.set('offset', String(feedbackItems.length))
|
||
|
||
if (feedbackSearchQuery.trim()) {
|
||
params.set('q', feedbackSearchQuery.trim())
|
||
}
|
||
|
||
if (feedbackSearchFilter !== 'all') {
|
||
params.set('filter', feedbackSearchFilter)
|
||
}
|
||
|
||
const res = await fetch(`/api/training/feedback/list?${params.toString()}`, {
|
||
cache: 'no-store',
|
||
})
|
||
|
||
const data = await res.json().catch(() => null) as TrainingFeedbackListResponse | null
|
||
|
||
if (!res.ok || !data) {
|
||
throw new Error(data && 'error' in data ? String((data as any).error) : `HTTP ${res.status}`)
|
||
}
|
||
|
||
setFeedbackItems((current) => {
|
||
const existing = new Set(
|
||
current.map((item) => `${item.sampleId}-${item.answeredAt}`)
|
||
)
|
||
|
||
const nextItems = data.items.filter(
|
||
(item) => !existing.has(`${item.sampleId}-${item.answeredAt}`)
|
||
)
|
||
|
||
return [...current, ...nextItems]
|
||
})
|
||
|
||
setFeedbackTotal(data.total)
|
||
setFeedbackHasMore(data.hasMore)
|
||
} catch (e) {
|
||
setFeedbackError(e instanceof Error ? e.message : String(e))
|
||
} finally {
|
||
setFeedbackLoadingMore(false)
|
||
}
|
||
}, [
|
||
feedbackHasMore,
|
||
feedbackItems.length,
|
||
feedbackLoading,
|
||
feedbackLoadingMore,
|
||
feedbackSearchFilter,
|
||
feedbackSearchQuery,
|
||
])
|
||
|
||
const editFeedbackItem = useCallback((item: TrainingAnnotation) => {
|
||
setSample(annotationToTrainingSample(item))
|
||
setCorrection(annotationToCorrectionState(item))
|
||
setHasManualCorrection(!item.accepted)
|
||
setNegativeExample(Boolean(item.negative))
|
||
|
||
setEditingFeedback({
|
||
sampleId: item.sampleId,
|
||
answeredAt: item.answeredAt,
|
||
})
|
||
|
||
setDrawingBox(null)
|
||
setBoxInteraction(null)
|
||
setTouchMagnifier(null)
|
||
setBoxLabel('')
|
||
setActiveBoxIndex(null)
|
||
setFeedbackModalOpen(false)
|
||
|
||
window.requestAnimationFrame(() => {
|
||
mobileLabelsScrollRef.current?.scrollTo({
|
||
top: 0,
|
||
behavior: 'smooth',
|
||
})
|
||
})
|
||
}, [])
|
||
|
||
const getImageContentRect = useCallback((): ImageContentRect | null => {
|
||
const imgEl = frameImageRef.current
|
||
if (!imgEl) return null
|
||
|
||
const rect = imgEl.getBoundingClientRect()
|
||
|
||
const naturalW = imgEl.naturalWidth
|
||
const naturalH = imgEl.naturalHeight
|
||
|
||
if (
|
||
rect.width <= 0 ||
|
||
rect.height <= 0 ||
|
||
naturalW <= 0 ||
|
||
naturalH <= 0
|
||
) {
|
||
return null
|
||
}
|
||
|
||
// Entspricht object-contain: sichtbarer Bildinhalt innerhalb der img-Box.
|
||
const scale = Math.min(rect.width / naturalW, rect.height / naturalH)
|
||
|
||
const contentW = naturalW * scale
|
||
const contentH = naturalH * scale
|
||
|
||
const left = rect.left + (rect.width - contentW) / 2
|
||
const top = rect.top + (rect.height - contentH) / 2
|
||
|
||
return {
|
||
left,
|
||
top,
|
||
width: contentW,
|
||
height: contentH,
|
||
right: left + contentW,
|
||
bottom: top + contentH,
|
||
}
|
||
}, [])
|
||
|
||
const updateImageLayerStyle = useCallback(() => {
|
||
const boxEl = imageBoxRef.current
|
||
const contentRect = getImageContentRect()
|
||
|
||
if (!boxEl || !contentRect) {
|
||
setImageLayerStyle(null)
|
||
return
|
||
}
|
||
|
||
const boxRect = boxEl.getBoundingClientRect()
|
||
|
||
setImageLayerStyle({
|
||
left: contentRect.left - boxRect.left,
|
||
top: contentRect.top - boxRect.top,
|
||
width: contentRect.width,
|
||
height: contentRect.height,
|
||
})
|
||
}, [getImageContentRect])
|
||
|
||
const detectorBoxesScrollRef = useRef<HTMLDivElement | null>(null)
|
||
const detectorBoxItemRefs = useRef<Array<HTMLDivElement | null>>([])
|
||
|
||
const activeAnalysisRequestIdRef = useRef<string | null>(null)
|
||
const loadingRef = useRef(false)
|
||
|
||
const videoImportStartedRef = useRef(false)
|
||
const videoImportInFlightKeyRef = useRef<string | null>(null)
|
||
|
||
|
||
const epochTimingRef = useRef<{
|
||
firstEpochAt: number
|
||
lastEpoch: number
|
||
lastAt: number
|
||
}>({
|
||
firstEpochAt: 0,
|
||
lastEpoch: 0,
|
||
lastAt: 0,
|
||
})
|
||
|
||
const etaSmoothingRef = useRef<{
|
||
lastAt: number
|
||
lastRawEtaMs: number
|
||
}>({
|
||
lastAt: 0,
|
||
lastRawEtaMs: 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 [trainingSampleMode, setTrainingSampleMode] = useState<TrainingSampleMode>('random')
|
||
const trainingSampleModeRef = useRef<TrainingSampleMode>('random')
|
||
const [expandedCorrectionSections, setExpandedCorrectionSections] = useState({
|
||
sexPosition: false,
|
||
people: false,
|
||
bodyParts: false,
|
||
objects: false,
|
||
clothing: false,
|
||
})
|
||
|
||
type CorrectionSectionKey = keyof typeof expandedCorrectionSections
|
||
|
||
function nextExpandedCorrectionSections(
|
||
key: CorrectionSectionKey,
|
||
expanded: boolean
|
||
) {
|
||
return {
|
||
sexPosition: false,
|
||
people: false,
|
||
bodyParts: false,
|
||
objects: false,
|
||
clothing: false,
|
||
[key]: expanded,
|
||
}
|
||
}
|
||
|
||
const [mobilePanel, setMobilePanel] = useState<'labels' | 'boxes' | 'training'>('labels')
|
||
|
||
const trainingRunningRef = useRef(false)
|
||
|
||
const mobileLabelsScrollRef = useRef<HTMLDivElement | null>(null)
|
||
|
||
const mobileSectionRefs = useRef<Record<CorrectionSectionKey, 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: CorrectionSectionKey, expanded: boolean) => {
|
||
setExpandedCorrectionSections(
|
||
nextExpandedCorrectionSections(key, expanded)
|
||
)
|
||
|
||
if (expanded && mobilePanel === 'labels') {
|
||
scrollMobileSectionToTop(key)
|
||
}
|
||
},
|
||
[mobilePanel, scrollMobileSectionToTop]
|
||
)
|
||
|
||
const toggleCorrectionSection = useCallback(
|
||
(key: CorrectionSectionKey, expanded: boolean) => {
|
||
setExpandedCorrectionSections(
|
||
nextExpandedCorrectionSections(key, expanded)
|
||
)
|
||
},
|
||
[]
|
||
)
|
||
|
||
const labelsRef = useRef<TrainingLabels>(emptyLabels)
|
||
|
||
useEffect(() => {
|
||
importedSampleQueueRef.current = importedSampleQueue
|
||
}, [importedSampleQueue])
|
||
|
||
useEffect(() => {
|
||
if (!feedbackModalOpen) return
|
||
|
||
setFeedbackSearchQuery('')
|
||
setFeedbackSearchFilter('all')
|
||
|
||
void loadFeedbackHistoryInitial({
|
||
query: '',
|
||
filter: 'all',
|
||
})
|
||
}, [feedbackModalOpen, loadFeedbackHistoryInitial])
|
||
|
||
useEffect(() => {
|
||
loadingRef.current = loading
|
||
}, [loading])
|
||
|
||
useEffect(() => {
|
||
labelsRef.current = labels
|
||
}, [labels])
|
||
|
||
useEffect(() => {
|
||
trainingSampleModeRef.current = trainingSampleMode
|
||
}, [trainingSampleMode])
|
||
|
||
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 isNegativeCorrection =
|
||
negativeExample && !correctionHasRelevantContent(correction)
|
||
|
||
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 ''
|
||
|
||
const sep = sample.frameUrl.includes('?') ? '&' : '?'
|
||
|
||
return `${sample.frameUrl}${sep}t=${encodeURIComponent(sample.sampleId)}&r=${imageReloadKey}`
|
||
}, [sample?.frameUrl, sample?.sampleId, imageReloadKey])
|
||
|
||
const loadingPreviewRawUrlRef = useRef('')
|
||
|
||
const setLoadingPreviewCandidate = useCallback((url: string) => {
|
||
const clean = String(url || '').trim()
|
||
|
||
if (!clean) {
|
||
loadingPreviewRawUrlRef.current = ''
|
||
setLoadingPreviewUrl('')
|
||
setLoadingPreviewLoaded(false)
|
||
setLoadingPreviewFailed(false)
|
||
return
|
||
}
|
||
|
||
// Wichtig:
|
||
// Bei gleichem Preview nicht neu cache-busten und nicht loaded=false setzen.
|
||
// Sonst flackert das Overlay bei jedem Phasenwechsel.
|
||
if (loadingPreviewRawUrlRef.current === clean) {
|
||
return
|
||
}
|
||
|
||
loadingPreviewRawUrlRef.current = clean
|
||
|
||
const sep = clean.includes('?') ? '&' : '?'
|
||
|
||
setLoadingPreviewUrl(`${clean}${sep}r=${Date.now()}`)
|
||
setLoadingPreviewLoaded(false)
|
||
setLoadingPreviewFailed(false)
|
||
}, [])
|
||
|
||
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 feedbackBadgeText =
|
||
feedbackCount > requiredCount
|
||
? String(feedbackCount)
|
||
: `${feedbackCount}/${requiredCount}`
|
||
|
||
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 drawingCursorClass =
|
||
boxInteraction?.type === 'move'
|
||
? '[@media_(hover:hover)_and_(pointer:fine)]:cursor-grabbing'
|
||
: drawingBox || (boxLabel && !uiLocked)
|
||
? '[@media_(hover:hover)_and_(pointer:fine)]:cursor-crosshair'
|
||
: ''
|
||
|
||
const trainingInfoJob = trainingStatus?.training ?? null
|
||
const trainingInfoKey = useMemo(() => {
|
||
const job = trainingInfoJob
|
||
|
||
if (!job || job.running) return ''
|
||
|
||
const finishedAt = String(job.finishedAt || '').trim()
|
||
const startedAt = String(job.startedAt || '').trim()
|
||
const message = String(job.message || job.error || '').trim()
|
||
|
||
if (finishedAt) return finishedAt
|
||
if (startedAt && message) return `${startedAt}:${message}`
|
||
|
||
return ''
|
||
}, [
|
||
trainingInfoJob?.running,
|
||
trainingInfoJob?.finishedAt,
|
||
trainingInfoJob?.startedAt,
|
||
trainingInfoJob?.message,
|
||
trainingInfoJob?.error,
|
||
])
|
||
|
||
const showTrainingInfo =
|
||
Boolean(trainingInfoKey) &&
|
||
!trainingRunning &&
|
||
dismissedTrainingInfoKey !== trainingInfoKey
|
||
|
||
const trainingInfoMessage = String(
|
||
trainingInfoJob?.message ||
|
||
trainingInfoJob?.error ||
|
||
'Training abgeschlossen.'
|
||
).trim()
|
||
|
||
const trainingInfoDurationMs = trainingDurationMs(trainingInfoJob)
|
||
|
||
const trainingInfoLooksPartial =
|
||
Boolean(trainingInfoJob?.error) ||
|
||
/übersprungen|fehlgeschlagen|abgebrochen/i.test(trainingInfoMessage)
|
||
|
||
const dismissTrainingInfo = useCallback(() => {
|
||
if (!trainingInfoKey) return
|
||
|
||
setDismissedTrainingInfoKey(trainingInfoKey)
|
||
|
||
try {
|
||
window.localStorage.setItem(
|
||
TRAINING_INFO_DISMISSED_STORAGE_KEY,
|
||
trainingInfoKey
|
||
)
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}, [trainingInfoKey])
|
||
|
||
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),
|
||
positiveTrainCount: Number(data.detector.positiveTrainCount ?? 0),
|
||
positiveValCount: Number(data.detector.positiveValCount ?? 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),
|
||
previewUrl: String(job.previewUrl ?? ''),
|
||
map50: Number(job.map50 ?? 0),
|
||
map5095: Number(job.map5095 ?? 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 loadTrainingSampleIntoTab = useCallback((
|
||
nextSample: TrainingSample,
|
||
opts?: { manualCorrection?: boolean }
|
||
) => {
|
||
const nextCorrection = predictionToCorrection(nextSample)
|
||
|
||
setDrawingBox(null)
|
||
setBoxInteraction(null)
|
||
setTouchMagnifier(null)
|
||
setBoxLabel('')
|
||
setActiveBoxIndex(null)
|
||
setMobilePanel(trainingRunningRef.current ? 'training' : 'labels')
|
||
|
||
window.requestAnimationFrame(() => {
|
||
mobileLabelsScrollRef.current?.scrollTo({
|
||
top: 0,
|
||
behavior: 'smooth',
|
||
})
|
||
})
|
||
|
||
setSample(nextSample)
|
||
setCorrection(nextCorrection)
|
||
setHasManualCorrection(Boolean(opts?.manualCorrection))
|
||
setNegativeExample(false)
|
||
|
||
const initiallyExpandedSection: CorrectionSectionKey | null =
|
||
nextCorrection.sexPosition && nextCorrection.sexPosition !== 'unknown'
|
||
? 'sexPosition'
|
||
: nextCorrection.peoplePresent.length > 0
|
||
? 'people'
|
||
: nextCorrection.bodyPartsPresent.length > 0
|
||
? 'bodyParts'
|
||
: nextCorrection.objectsPresent.length > 0
|
||
? 'objects'
|
||
: nextCorrection.clothingPresent.length > 0
|
||
? 'clothing'
|
||
: null
|
||
|
||
setExpandedCorrectionSections(
|
||
initiallyExpandedSection
|
||
? nextExpandedCorrectionSections(initiallyExpandedSection, true)
|
||
: {
|
||
sexPosition: false,
|
||
people: false,
|
||
bodyParts: false,
|
||
objects: false,
|
||
clothing: false,
|
||
}
|
||
)
|
||
}, [])
|
||
|
||
const loadNextImportedQueuedSample = useCallback(() => {
|
||
const [nextSample, ...rest] = importedSampleQueueRef.current
|
||
|
||
if (!nextSample) {
|
||
return false
|
||
}
|
||
|
||
importedSampleQueueRef.current = rest
|
||
setImportedSampleQueue(rest)
|
||
|
||
loadTrainingSampleIntoTab(nextSample)
|
||
|
||
return true
|
||
}, [loadTrainingSampleIntoTab])
|
||
|
||
const loadNext = useCallback(async (opts?: {
|
||
forceNew?: boolean
|
||
refreshPrediction?: boolean
|
||
preserveNotice?: boolean
|
||
mode?: TrainingSampleMode
|
||
}) => {
|
||
const requestId = makeRequestId()
|
||
activeAnalysisRequestIdRef.current = requestId
|
||
const isCurrentRequest = () => activeAnalysisRequestIdRef.current === requestId
|
||
|
||
const mode = opts?.mode ?? trainingSampleModeRef.current
|
||
const uncertainMode = mode === 'uncertain' && !opts?.refreshPrediction
|
||
|
||
setLoadingPreviewCandidate('')
|
||
setLoading(true)
|
||
setAnalysisProgress(8)
|
||
setAnalysisStep(
|
||
opts?.refreshPrediction
|
||
? 'Aktuelles Bild wird neu analysiert…'
|
||
: uncertainMode
|
||
? 'Unsichere Prediction wird gesucht…'
|
||
: opts?.forceNew
|
||
? 'Neues Trainingsbild wird gesucht…'
|
||
: 'Trainingsbild wird geladen…'
|
||
)
|
||
|
||
if (!opts?.preserveNotice) {
|
||
setError(null)
|
||
setMessage(null)
|
||
}
|
||
|
||
try {
|
||
const params = new URLSearchParams()
|
||
|
||
params.set('analysisRequestId', requestId)
|
||
|
||
if (opts?.forceNew) params.set('force', '1')
|
||
if (opts?.refreshPrediction) params.set('refresh', '1')
|
||
if (uncertainMode) params.set('mode', 'uncertain')
|
||
|
||
const url = `/api/training/next${params.toString() ? `?${params.toString()}` : ''}`
|
||
|
||
setAnalysisProgress(uncertainMode ? 5 : 25)
|
||
setAnalysisStep(
|
||
uncertainMode
|
||
? 'Mehrere Kandidaten werden vorbereitet…'
|
||
: '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}`)
|
||
}
|
||
|
||
if (!isCurrentRequest()) {
|
||
return
|
||
}
|
||
|
||
setAnalysisProgress(92)
|
||
setAnalysisStep('Analyse-Ergebnis wird übernommen…')
|
||
|
||
loadTrainingSampleIntoTab(data as TrainingSample)
|
||
} catch (e) {
|
||
if (isCurrentRequest()) {
|
||
setError(e instanceof Error ? e.message : String(e))
|
||
}
|
||
} finally {
|
||
if (!isCurrentRequest()) {
|
||
return
|
||
}
|
||
|
||
setAnalysisProgress((value) => Math.max(value, 100))
|
||
setAnalysisStep((value) => value || 'Analyse abgeschlossen.')
|
||
|
||
const finishedRequestId = requestId
|
||
|
||
window.setTimeout(() => {
|
||
if (activeAnalysisRequestIdRef.current !== finishedRequestId) return
|
||
|
||
activeAnalysisRequestIdRef.current = null
|
||
setLoading(false)
|
||
setAnalysisProgress(0)
|
||
setAnalysisStep('')
|
||
}, 500)
|
||
}
|
||
}, [loadTrainingSampleIntoTab, setLoadingPreviewCandidate])
|
||
|
||
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 importVideoIntoTraining = useCallback(async (raw: any) => {
|
||
const output = String(raw?.output || '').trim()
|
||
if (!output) return false
|
||
|
||
setLoadingPreviewCandidate('')
|
||
|
||
const TRAINING_IMPORT_FRAME_COUNT = 10
|
||
|
||
const detail: PendingTrainingVideoImport = {
|
||
jobId: String(raw?.jobId || '').trim(),
|
||
output,
|
||
sourceFile: String(raw?.sourceFile || '').trim(),
|
||
count: Number(raw?.count || TRAINING_IMPORT_FRAME_COUNT),
|
||
}
|
||
|
||
const importKey = `${detail.jobId || ''}|${detail.output}|${detail.count || TRAINING_IMPORT_FRAME_COUNT}`
|
||
|
||
// Verhindert Doppelimport durch sessionStorage + CustomEvent.
|
||
if (videoImportInFlightKeyRef.current === importKey) {
|
||
return false
|
||
}
|
||
|
||
videoImportStartedRef.current = true
|
||
videoImportInFlightKeyRef.current = importKey
|
||
|
||
const requestId = makeRequestId()
|
||
activeAnalysisRequestIdRef.current = requestId
|
||
loadingRef.current = true
|
||
|
||
setLoading(true)
|
||
setAnalysisProgress(5)
|
||
setAnalysisStep('Video wird ins Training übernommen…')
|
||
setError(null)
|
||
setMessage(null)
|
||
|
||
try {
|
||
try {
|
||
window.sessionStorage.removeItem('training:pending-import-video')
|
||
} catch {
|
||
// ignore
|
||
}
|
||
|
||
const res = await fetch('/api/training/import-video', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
cache: 'no-store',
|
||
body: JSON.stringify({
|
||
jobId: detail.jobId,
|
||
output: detail.output,
|
||
count: detail.count || TRAINING_IMPORT_FRAME_COUNT,
|
||
analysisRequestId: requestId,
|
||
}),
|
||
})
|
||
|
||
const data = await res.json().catch(() => null)
|
||
|
||
if (!res.ok || !data?.ok) {
|
||
throw new Error(backendText(data, `HTTP ${res.status}`))
|
||
}
|
||
|
||
const samples: TrainingSample[] = Array.isArray(data.samples)
|
||
? data.samples
|
||
: data.sample
|
||
? [data.sample]
|
||
: []
|
||
|
||
if (samples.length === 0) {
|
||
throw new Error('Es wurden keine Trainingsframes erzeugt.')
|
||
}
|
||
|
||
const [firstSample, ...queuedSamples] = samples
|
||
|
||
importedSampleQueueRef.current = queuedSamples
|
||
setImportedSampleQueue(queuedSamples)
|
||
|
||
loadTrainingSampleIntoTab(firstSample)
|
||
setImageReloadKey((value) => value + 1)
|
||
|
||
await loadTrainingStatus()
|
||
|
||
const errorCount = Array.isArray(data.errors) ? data.errors.length : 0
|
||
|
||
setMessage(
|
||
errorCount > 0
|
||
? `${samples.length} Frames ins Training übernommen, ${errorCount} Frames fehlgeschlagen.`
|
||
: `${samples.length} Frames ins Training übernommen.`
|
||
)
|
||
|
||
return true
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : String(e))
|
||
return false
|
||
} finally {
|
||
setAnalysisProgress(100)
|
||
setAnalysisStep('Video-Import abgeschlossen.')
|
||
|
||
const finishedRequestId = requestId
|
||
|
||
window.setTimeout(() => {
|
||
if (activeAnalysisRequestIdRef.current === finishedRequestId) {
|
||
activeAnalysisRequestIdRef.current = null
|
||
loadingRef.current = false
|
||
setLoading(false)
|
||
setAnalysisProgress(0)
|
||
setAnalysisStep('')
|
||
}
|
||
|
||
if (videoImportInFlightKeyRef.current === importKey) {
|
||
videoImportInFlightKeyRef.current = null
|
||
}
|
||
}, 500)
|
||
}
|
||
}, [
|
||
loadTrainingSampleIntoTab,
|
||
loadTrainingStatus,
|
||
setLoadingPreviewCandidate,
|
||
])
|
||
|
||
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),
|
||
negativeCount: Number(data?.negativeCount ?? 0),
|
||
sampleCount: Number(data?.sampleCount ?? 0),
|
||
boxCount: Number(data?.boxCount ?? 0),
|
||
modelAvailable: Boolean(data?.modelAvailable),
|
||
modelInfo: data?.modelInfo
|
||
? {
|
||
trainedAt: data.modelInfo.trainedAt,
|
||
trainedAtMs: Number(data.modelInfo.trainedAtMs ?? 0),
|
||
epochs: Number(data.modelInfo.epochs ?? 0),
|
||
trainSamples: Number(data.modelInfo.trainSamples ?? 0),
|
||
valSamples: Number(data.modelInfo.valSamples ?? 0),
|
||
imgsz: Number(data.modelInfo.imgsz ?? 0),
|
||
device: data.modelInfo.device,
|
||
map50: Number(data.modelInfo.map50 ?? 0),
|
||
map5095: Number(data.modelInfo.map5095 ?? 0),
|
||
}
|
||
: undefined,
|
||
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)
|
||
}
|
||
}, [])
|
||
|
||
const loadTrainingHistory = useCallback(async () => {
|
||
try {
|
||
const res = await fetch('/api/training/history', { cache: 'no-store' })
|
||
const data = await res.json().catch(() => null)
|
||
|
||
if (!res.ok || !data) return
|
||
|
||
setTrainingHistory(
|
||
Array.isArray(data?.entries)
|
||
? data.entries.map((e: any) => ({
|
||
trainedAt: e?.trainedAt,
|
||
trainedAtMs: Number(e?.trainedAtMs ?? 0),
|
||
durationMs: Number(e?.durationMs ?? 0),
|
||
epochs: Number(e?.epochs ?? 0),
|
||
trainSamples: Number(e?.trainSamples ?? 0),
|
||
valSamples: Number(e?.valSamples ?? 0),
|
||
imgsz: Number(e?.imgsz ?? 0),
|
||
device: e?.device,
|
||
map50: Number(e?.map50 ?? 0),
|
||
map5095: Number(e?.map5095 ?? 0),
|
||
}))
|
||
: []
|
||
)
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
updateImageLayerStyle()
|
||
|
||
const boxEl = imageBoxRef.current
|
||
const imgEl = frameImageRef.current
|
||
|
||
const resizeObserver =
|
||
typeof ResizeObserver !== 'undefined'
|
||
? new ResizeObserver(() => updateImageLayerStyle())
|
||
: null
|
||
|
||
if (resizeObserver) {
|
||
if (boxEl) resizeObserver.observe(boxEl)
|
||
if (imgEl) resizeObserver.observe(imgEl)
|
||
}
|
||
|
||
window.addEventListener('resize', updateImageLayerStyle)
|
||
window.addEventListener('scroll', updateImageLayerStyle, true)
|
||
|
||
return () => {
|
||
resizeObserver?.disconnect()
|
||
window.removeEventListener('resize', updateImageLayerStyle)
|
||
window.removeEventListener('scroll', updateImageLayerStyle, true)
|
||
}
|
||
}, [imageSrc, imageExpanded, frameImageLoaded, updateImageLayerStyle])
|
||
|
||
useEffect(() => {
|
||
trainingRunningRef.current = trainingRunning
|
||
|
||
if (trainingRunning) {
|
||
setMobilePanel('training')
|
||
}
|
||
}, [trainingRunning])
|
||
|
||
useEffect(() => {
|
||
const onTraining = (event: Event) => {
|
||
try {
|
||
const data = (event as CustomEvent<any>).detail
|
||
|
||
if (data?.type !== 'training_status') return
|
||
|
||
const job = data.training || null
|
||
const running = Boolean(job?.running)
|
||
|
||
// Immer das zuletzt gesendete Bild merken (live, ältere werden übersprungen).
|
||
const previewUrl = String(job?.previewUrl ?? '').trim()
|
||
if (running && previewUrl) {
|
||
latestTrainingPreviewRef.current = previewUrl
|
||
}
|
||
|
||
// Status (Progress/Epoche) nur gedrosselt anwenden, damit pro Bild
|
||
// kein Re-Render des gesamten Tabs ausgelöst wird. Das Abschluss-Event
|
||
// (running=false) wird immer angewendet.
|
||
const now = Date.now()
|
||
if (!running || now - lastTrainingStatusApplyRef.current >= 200) {
|
||
lastTrainingStatusApplyRef.current = now
|
||
applyTrainingStatus({ training: job })
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
window.addEventListener('app:sse:training', onTraining as EventListener)
|
||
|
||
return () => {
|
||
window.removeEventListener('app:sse:training', onTraining as EventListener)
|
||
}
|
||
}, [applyTrainingStatus])
|
||
|
||
// Im festen Takt immer das zuletzt eingegangene Bild anzeigen.
|
||
// So bleibt die Anzeige live (max. ~110 ms Versatz) und die Render-Rate begrenzt.
|
||
useEffect(() => {
|
||
if (!trainingRunning) {
|
||
latestTrainingPreviewRef.current = ''
|
||
setTrainingPreviewUrl('')
|
||
return
|
||
}
|
||
|
||
const timer = window.setInterval(() => {
|
||
const latest = latestTrainingPreviewRef.current
|
||
if (!latest) return
|
||
|
||
setTrainingPreviewUrl((cur) => (cur === latest ? cur : latest))
|
||
}, 110)
|
||
|
||
return () => window.clearInterval(timer)
|
||
}, [trainingRunning])
|
||
|
||
useEffect(() => {
|
||
const onAnalysis = (event: Event) => {
|
||
try {
|
||
const data = (event as CustomEvent<any>).detail
|
||
|
||
const activeRequestId = activeAnalysisRequestIdRef.current
|
||
|
||
if (!loadingRef.current || !activeRequestId) return
|
||
|
||
const requestId = String(
|
||
data?.requestId ||
|
||
data?.analysisRequestId ||
|
||
data?.analysis?.requestId ||
|
||
data?.analysis?.analysisRequestId ||
|
||
''
|
||
).trim()
|
||
|
||
const scope = String(
|
||
data?.scope ||
|
||
data?.analysis?.scope ||
|
||
''
|
||
).trim()
|
||
|
||
if (scope !== 'training') return
|
||
if (requestId !== activeRequestId) return
|
||
|
||
const message = String(
|
||
data?.message ||
|
||
data?.step ||
|
||
data?.title ||
|
||
''
|
||
).trim()
|
||
|
||
const previewUrl = String(
|
||
data?.previewUrl ||
|
||
data?.analysis?.previewUrl ||
|
||
''
|
||
).trim()
|
||
|
||
if (previewUrl) {
|
||
setLoadingPreviewCandidate(previewUrl)
|
||
}
|
||
|
||
const current = Number(data?.current ?? data?.stepIndex ?? data?.index)
|
||
const total = Number(data?.total ?? data?.steps ?? data?.stepTotal)
|
||
|
||
const rawProgress = Number(
|
||
data?.progress ??
|
||
data?.percent ??
|
||
data?.analysis?.progress
|
||
)
|
||
|
||
let nextProgress: number | null = null
|
||
|
||
if (
|
||
Number.isFinite(current) &&
|
||
Number.isFinite(total) &&
|
||
total > 0
|
||
) {
|
||
nextProgress = (current / total) * 100
|
||
} else if (Number.isFinite(rawProgress)) {
|
||
nextProgress = rawProgress <= 1
|
||
? rawProgress * 100
|
||
: rawProgress
|
||
}
|
||
|
||
if (message) {
|
||
setAnalysisStep(message)
|
||
}
|
||
|
||
if (nextProgress !== null) {
|
||
setAnalysisProgress((prev) =>
|
||
Math.max(prev, clampPercent(nextProgress))
|
||
)
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
window.addEventListener('app:sse:analysis', onAnalysis as EventListener)
|
||
|
||
return () => {
|
||
window.removeEventListener('app:sse:analysis', onAnalysis as EventListener)
|
||
}
|
||
}, [setLoadingPreviewCandidate])
|
||
|
||
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()
|
||
void loadTrainingHistory()
|
||
}, [statsModalOpen, loadTrainingStats, loadTrainingHistory])
|
||
|
||
const onTrainingRunningChange = props.onTrainingRunningChange
|
||
|
||
useEffect(() => {
|
||
onTrainingRunningChange?.(trainingRunning)
|
||
}, [trainingRunning, onTrainingRunningChange])
|
||
|
||
useEffect(() => {
|
||
props.onImageExpandedChange?.(imageExpanded)
|
||
|
||
return () => {
|
||
props.onImageExpandedChange?.(false)
|
||
}
|
||
}, [imageExpanded, props.onImageExpandedChange])
|
||
|
||
useEffect(() => {
|
||
try {
|
||
window.localStorage.setItem(
|
||
TRAINING_IMAGE_EXPANDED_STORAGE_KEY,
|
||
imageExpanded ? '1' : '0'
|
||
)
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}, [imageExpanded])
|
||
|
||
useEffect(() => {
|
||
if (!trainingRunning) {
|
||
etaSmoothingRef.current = {
|
||
lastAt: 0,
|
||
lastRawEtaMs: 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(() => {
|
||
const onImportVideo = (event: Event) => {
|
||
const detail = (event as CustomEvent<any>).detail
|
||
void importVideoIntoTraining(detail)
|
||
}
|
||
|
||
window.addEventListener('training:import-video', onImportVideo as EventListener)
|
||
|
||
try {
|
||
const raw = window.sessionStorage.getItem('training:pending-import-video')
|
||
|
||
if (raw) {
|
||
const detail = JSON.parse(raw)
|
||
void importVideoIntoTraining(detail)
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
|
||
return () => {
|
||
window.removeEventListener('training:import-video', onImportVideo as EventListener)
|
||
}
|
||
}, [importVideoIntoTraining])
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
|
||
async function init() {
|
||
await loadLabels()
|
||
await loadTrainingStatus()
|
||
|
||
if (cancelled) return
|
||
|
||
// Wichtig:
|
||
// Wenn gerade ein Video-Import über "Video ins Training übernehmen" läuft,
|
||
// darf loadNext() nicht danach ein zufälliges/letztes Sample darüberlegen.
|
||
if (videoImportStartedRef.current || videoImportInFlightKeyRef.current) {
|
||
return
|
||
}
|
||
|
||
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,
|
||
options?: {
|
||
negative?: 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 negative = options?.negative ?? isNegativeCorrection
|
||
const correctionPayload: CorrectionState = negative
|
||
? {
|
||
sexPosition: 'unknown',
|
||
peoplePresent: [],
|
||
bodyPartsPresent: [],
|
||
objectsPresent: [],
|
||
clothingPresent: [],
|
||
boxes: [],
|
||
}
|
||
: {
|
||
...correction,
|
||
peoplePresent: peopleLabelsFromBoxes(normalizedBoxes, labelsRef.current),
|
||
boxes: normalizedBoxes,
|
||
}
|
||
const effectiveAccepted = negative ? false : accepted
|
||
|
||
const payload = {
|
||
sampleId: sample.sampleId,
|
||
accepted: effectiveAccepted,
|
||
negative,
|
||
correction: effectiveAccepted && correctionPayload.boxes.length === 0
|
||
? undefined
|
||
: correctionPayload,
|
||
}
|
||
|
||
const res = await fetch(
|
||
editingFeedback ? '/api/training/feedback/update' : '/api/training/feedback',
|
||
{
|
||
method: editingFeedback ? 'PUT' : 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(
|
||
editingFeedback
|
||
? {
|
||
...payload,
|
||
sampleId: editingFeedback.sampleId,
|
||
answeredAt: editingFeedback.answeredAt,
|
||
}
|
||
: payload
|
||
),
|
||
}
|
||
)
|
||
|
||
const data = await res.json().catch(() => null)
|
||
|
||
if (!res.ok) {
|
||
throw new Error(backendText(data, `HTTP ${res.status}`))
|
||
}
|
||
|
||
const wasEditingFeedback = Boolean(editingFeedback)
|
||
|
||
if (wasEditingFeedback) {
|
||
setEditingFeedback(null)
|
||
|
||
setFeedbackItems((current) =>
|
||
current.map((item) =>
|
||
item.sampleId === editingFeedback?.sampleId &&
|
||
item.answeredAt === editingFeedback?.answeredAt
|
||
? {
|
||
...item,
|
||
accepted: effectiveAccepted,
|
||
negative,
|
||
correction: effectiveAccepted ? undefined : correctionPayload,
|
||
}
|
||
: item
|
||
)
|
||
)
|
||
}
|
||
|
||
setMessage(
|
||
wasEditingFeedback
|
||
? negative
|
||
? 'Negativbeispiel aktualisiert.'
|
||
: effectiveAccepted
|
||
? 'Feedback aktualisiert.'
|
||
: 'Korrektur aktualisiert.'
|
||
: negative
|
||
? 'Negativbeispiel gespeichert.'
|
||
: effectiveAccepted
|
||
? 'Feedback gespeichert.'
|
||
: 'Korrektur gespeichert.'
|
||
)
|
||
|
||
await loadTrainingStatus()
|
||
|
||
if (!loadNextImportedQueuedSample()) {
|
||
await loadNext({
|
||
forceNew: true,
|
||
preserveNotice: true,
|
||
})
|
||
}
|
||
} catch (e) {
|
||
console.error('Feedback speichern fehlgeschlagen:', e)
|
||
|
||
const raw = e instanceof Error ? e.message : String(e)
|
||
const short = raw.length > 220
|
||
? `${raw.slice(0, 220).trimEnd()}…`
|
||
: raw
|
||
|
||
setError(`Feedback konnte nicht gespeichert werden. ${short}`)
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
},
|
||
[
|
||
sample,
|
||
correction,
|
||
editingFeedback,
|
||
isNegativeCorrection,
|
||
loadNext,
|
||
loadTrainingStatus,
|
||
loadNextImportedQueuedSample,
|
||
]
|
||
)
|
||
|
||
const skipCurrentSample = useCallback(async () => {
|
||
if (!sample) return
|
||
|
||
const skippedSampleId = sample.sampleId
|
||
|
||
setSaving(true)
|
||
setError(null)
|
||
setMessage(null)
|
||
|
||
try {
|
||
const res = await fetch('/api/training/skip', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ sampleId: skippedSampleId }),
|
||
})
|
||
|
||
const data = await res.json().catch(() => null)
|
||
|
||
if (!res.ok) {
|
||
throw new Error(backendText(data, `HTTP ${res.status}`))
|
||
}
|
||
|
||
setSample(null)
|
||
setCorrection(predictionToCorrection(null))
|
||
setHasManualCorrection(false)
|
||
setNegativeExample(false)
|
||
setDrawingBox(null)
|
||
setBoxInteraction(null)
|
||
setTouchMagnifier(null)
|
||
setBoxLabel('')
|
||
setActiveBoxIndex(null)
|
||
|
||
if (!loadNextImportedQueuedSample()) {
|
||
await loadNext({
|
||
forceNew: true,
|
||
preserveNotice: true,
|
||
})
|
||
}
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : String(e))
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}, [sample, loadNext, loadNextImportedQueuedSample])
|
||
|
||
const startTraining = useCallback(async () => {
|
||
shownTrainingCompletionRef.current = null
|
||
setDismissedTrainingInfoKey('')
|
||
|
||
try {
|
||
window.localStorage.removeItem(TRAINING_INFO_DISMISSED_STORAGE_KEY)
|
||
} catch {
|
||
// ignore
|
||
}
|
||
|
||
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))
|
||
setNegativeExample(false)
|
||
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 rect = getImageContentRect()
|
||
if (!rect) 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),
|
||
}
|
||
}, [getImageContentRect])
|
||
|
||
const releaseActivePointerCapture = useCallback((pointerId?: number | null) => {
|
||
const id = typeof pointerId === 'number'
|
||
? pointerId
|
||
: activePointerIdRef.current
|
||
|
||
if (typeof id !== 'number') {
|
||
activePointerIdRef.current = null
|
||
return
|
||
}
|
||
|
||
try {
|
||
imageBoxRef.current?.releasePointerCapture(id)
|
||
} catch {
|
||
// Pointer-Capture war evtl. schon weg.
|
||
}
|
||
|
||
activePointerIdRef.current = null
|
||
}, [])
|
||
|
||
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 rawPos = getPointerPosInImage(e.clientX, e.clientY, { clamp: false })
|
||
if (!rawPos) return
|
||
|
||
// Nicht im schwarzen Randbereich starten.
|
||
if (
|
||
rawPos.x < 0 ||
|
||
rawPos.x > 1 ||
|
||
rawPos.y < 0 ||
|
||
rawPos.y > 1
|
||
) {
|
||
return
|
||
}
|
||
|
||
const pos = {
|
||
x: clamp01(rawPos.x),
|
||
y: clamp01(rawPos.y),
|
||
}
|
||
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
window.getSelection()?.removeAllRanges()
|
||
|
||
finishingGestureRef.current = false
|
||
activePointerIdRef.current = e.pointerId
|
||
|
||
try {
|
||
e.currentTarget.setPointerCapture(e.pointerId)
|
||
} catch {
|
||
activePointerIdRef.current = null
|
||
}
|
||
|
||
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> | PointerEvent) => {
|
||
releaseActivePointerCapture(
|
||
typeof e?.pointerId === 'number' ? e.pointerId : null
|
||
)
|
||
|
||
setTouchMagnifier(null)
|
||
|
||
if (finishingGestureRef.current) {
|
||
setDrawingBox(null)
|
||
setBoxInteraction(null)
|
||
return
|
||
}
|
||
|
||
finishingGestureRef.current = true
|
||
|
||
if (drawingBox || boxInteraction) {
|
||
e?.preventDefault()
|
||
e?.stopPropagation()
|
||
}
|
||
|
||
if (boxInteraction) {
|
||
setBoxInteraction(null)
|
||
return
|
||
}
|
||
|
||
if (!drawingBox) {
|
||
setDrawingBox(null)
|
||
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, releaseActivePointerCapture])
|
||
|
||
useEffect(() => {
|
||
if (!drawingBox && !boxInteraction) return
|
||
|
||
const finishPointer = (event: PointerEvent) => {
|
||
finishDrawBox(event)
|
||
}
|
||
|
||
const finishWithoutPointer = () => {
|
||
finishDrawBox()
|
||
}
|
||
|
||
const finishOnHidden = () => {
|
||
if (document.hidden) {
|
||
finishDrawBox()
|
||
}
|
||
}
|
||
|
||
window.addEventListener('pointerup', finishPointer, true)
|
||
window.addEventListener('pointercancel', finishPointer, true)
|
||
window.addEventListener('blur', finishWithoutPointer)
|
||
document.addEventListener('visibilitychange', finishOnHidden)
|
||
|
||
return () => {
|
||
window.removeEventListener('pointerup', finishPointer, true)
|
||
window.removeEventListener('pointercancel', finishPointer, true)
|
||
window.removeEventListener('blur', finishWithoutPointer)
|
||
document.removeEventListener('visibilitychange', finishOnHidden)
|
||
}
|
||
}, [drawingBox, boxInteraction, finishDrawBox])
|
||
|
||
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)
|
||
|
||
useEffect(() => {
|
||
if (loading || frameBusy) return
|
||
if (!loadingPreviewUrl) return
|
||
|
||
setLoadingPreviewUrl('')
|
||
setLoadingPreviewLoaded(false)
|
||
setLoadingPreviewFailed(false)
|
||
}, [loading, frameBusy, loadingPreviewUrl])
|
||
|
||
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,
|
||
lastRawEtaMs: 0,
|
||
}
|
||
|
||
setSmoothedTrainingEtaMs(0)
|
||
return
|
||
}
|
||
|
||
setSmoothedTrainingEtaMs((previous) => {
|
||
const state = etaSmoothingRef.current
|
||
|
||
const lastAt = state.lastAt || trainingNowMs
|
||
const elapsed = Math.max(0, trainingNowMs - lastAt)
|
||
|
||
// 1) Immer erstmal echte Anzeige runterzählen.
|
||
const countedDown =
|
||
previous > 0
|
||
? Math.max(0, previous - elapsed)
|
||
: rawTrainingEtaMs
|
||
|
||
const rawChanged =
|
||
state.lastRawEtaMs <= 0 ||
|
||
Math.abs(rawTrainingEtaMs - state.lastRawEtaMs) > 1000
|
||
|
||
let next = countedDown
|
||
|
||
if (rawChanged) {
|
||
const diff = rawTrainingEtaMs - countedDown
|
||
|
||
// Neue Backend-Schätzung einblenden, aber nicht hart springen.
|
||
if (diff > 0) {
|
||
// Restzeit wurde höher neu berechnet: langsam nach oben.
|
||
next = countedDown + Math.min(diff * 0.20, 15_000)
|
||
} else {
|
||
// Restzeit wurde niedriger neu berechnet: schneller nach unten.
|
||
next = countedDown + diff * 0.45
|
||
}
|
||
}
|
||
|
||
next = Math.max(0, next)
|
||
|
||
etaSmoothingRef.current = {
|
||
lastAt: trainingNowMs,
|
||
lastRawEtaMs: rawTrainingEtaMs,
|
||
}
|
||
|
||
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,
|
||
])
|
||
|
||
useEffect(() => {
|
||
const toastImage =
|
||
imageSrc
|
||
? {
|
||
imageUrl: imageSrc,
|
||
imageAlt: sample?.sourceFile || 'Training Frame',
|
||
}
|
||
: undefined
|
||
|
||
if (error) {
|
||
notify.error('Aktion fehlgeschlagen', error, toastImage)
|
||
setError(null)
|
||
return
|
||
}
|
||
|
||
if (!message) return
|
||
|
||
const lowerMessage = message.toLowerCase()
|
||
|
||
const looksPartial =
|
||
lowerMessage.includes('übersprungen') ||
|
||
lowerMessage.includes('fehlgeschlagen') ||
|
||
lowerMessage.includes('abgebrochen')
|
||
|
||
if (looksPartial) {
|
||
notify.warning('Training teilweise abgeschlossen', message, toastImage)
|
||
} else {
|
||
notify.success('Erfolg', message, toastImage)
|
||
}
|
||
|
||
setMessage(null)
|
||
}, [error, message, notify, imageSrc, sample?.sourceFile])
|
||
|
||
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 missingPositiveTrain = Number(detector?.positiveTrainCount ?? 0) < 1
|
||
const missingPositiveVal = Number(detector?.positiveValCount ?? 0) < 1
|
||
const positiveSamplesMissing = missingPositiveTrain || missingPositiveVal
|
||
const statusText = trainingRunning
|
||
? shownTrainingStep || 'Training läuft…'
|
||
: !feedbackReady
|
||
? `${Math.max(0, requiredCount - feedbackCount)} Feedback fehlen noch`
|
||
: !detectorReady
|
||
? missingTrain > 0 || missingVal > 0
|
||
? `YOLO-Beispiele fehlen: ${missingTrain} Train, ${missingVal} Val`
|
||
: positiveSamplesMissing
|
||
? 'Je ein positives Beispiel in Train und Val erforderlich'
|
||
: 'YOLO-Datensatz noch nicht bereit'
|
||
: 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"
|
||
>
|
||
{feedbackBadgeText}
|
||
</button>
|
||
</div>
|
||
|
||
<div className="mt-3 rounded-xl bg-gray-50 p-2 ring-1 ring-black/5 dark:bg-white/[0.04] dark:ring-white/10">
|
||
<button
|
||
type="button"
|
||
disabled={uiLocked}
|
||
onClick={() => {
|
||
const nextMode: TrainingSampleMode =
|
||
trainingSampleMode === 'uncertain' ? 'random' : 'uncertain'
|
||
|
||
setTrainingSampleMode(nextMode)
|
||
|
||
if (!uiLocked) {
|
||
void loadNext({
|
||
forceNew: true,
|
||
mode: nextMode,
|
||
})
|
||
}
|
||
}}
|
||
className={[
|
||
'flex w-full items-center justify-between gap-3 rounded-lg px-2 py-1.5 text-left transition',
|
||
'focus:outline-none focus:ring-2 focus:ring-indigo-500/40',
|
||
uiLocked
|
||
? 'cursor-not-allowed opacity-50'
|
||
: 'hover:bg-white dark:hover:bg-white/10',
|
||
].join(' ')}
|
||
title="Wenn aktiv, werden bevorzugt Frames mit niedriger oder mittlerer Modell-Confidence geladen."
|
||
aria-pressed={trainingSampleMode === 'uncertain'}
|
||
>
|
||
<span className="min-w-0">
|
||
<span className="block text-[11px] font-bold text-gray-900 dark:text-white">
|
||
Unsichere zuerst
|
||
</span>
|
||
|
||
<span className="mt-0.5 block text-[10px] leading-snug text-gray-500 dark:text-gray-400">
|
||
Lädt bevorzugt Grenzfälle, die dem Modell am meisten helfen.
|
||
</span>
|
||
</span>
|
||
|
||
<span
|
||
className={[
|
||
'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition',
|
||
trainingSampleMode === 'uncertain'
|
||
? 'bg-indigo-600'
|
||
: 'bg-gray-300 dark:bg-white/20',
|
||
].join(' ')}
|
||
aria-hidden="true"
|
||
>
|
||
<span
|
||
className={[
|
||
'inline-block h-4 w-4 rounded-full bg-white shadow transition',
|
||
trainingSampleMode === 'uncertain'
|
||
? 'translate-x-4'
|
||
: 'translate-x-0.5',
|
||
].join(' ')}
|
||
/>
|
||
</span>
|
||
</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}
|
||
|
||
{showTrainingInfo ? (
|
||
<div
|
||
className={[
|
||
'mt-3 rounded-xl p-3 ring-1',
|
||
trainingInfoLooksPartial
|
||
? 'bg-amber-50 text-amber-950 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-100 dark:ring-amber-400/30'
|
||
: 'bg-emerald-50 text-emerald-950 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-100 dark:ring-emerald-400/30',
|
||
].join(' ')}
|
||
>
|
||
<div className="flex items-start gap-3">
|
||
<div
|
||
className={[
|
||
'mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ring-1',
|
||
trainingInfoLooksPartial
|
||
? 'bg-amber-100 text-amber-700 ring-amber-200 dark:bg-amber-500/15 dark:text-amber-200 dark:ring-amber-400/30'
|
||
: 'bg-emerald-100 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-200 dark:ring-emerald-400/30',
|
||
].join(' ')}
|
||
>
|
||
{trainingInfoLooksPartial ? (
|
||
<XCircleIcon className="h-4 w-4" aria-hidden="true" />
|
||
) : (
|
||
<CheckIcon className="h-4 w-4" aria-hidden="true" />
|
||
)}
|
||
</div>
|
||
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div className="min-w-0">
|
||
<div className="text-xs font-bold">
|
||
Training-Info
|
||
</div>
|
||
|
||
<div className="mt-1 text-[11px] leading-snug opacity-90">
|
||
{trainingInfoMessage}
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={dismissTrainingInfo}
|
||
className="shrink-0 rounded-md px-1.5 py-0.5 text-xs font-bold opacity-60 transition hover:bg-black/5 hover:opacity-100 dark:hover:bg-white/10"
|
||
aria-label="Training-Info ausblenden"
|
||
title="Bis zum nächsten Training ausblenden"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
<div className="mt-3 grid grid-cols-2 gap-2 text-[10px]">
|
||
<div
|
||
className="rounded-lg bg-white/70 px-2 py-1.5 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10"
|
||
title={`${trainingStatus?.detector?.positiveTrainCount ?? 0} positive Trainingsbeispiele`}
|
||
>
|
||
<div className="font-semibold opacity-60">
|
||
Dauer
|
||
</div>
|
||
<div className="mt-0.5 font-bold">
|
||
{trainingInfoDurationMs > 0
|
||
? formatDuration(trainingInfoDurationMs)
|
||
: '—'}
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
className="rounded-lg bg-white/70 px-2 py-1.5 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10"
|
||
title={`${trainingStatus?.detector?.positiveValCount ?? 0} positive Validierungsbeispiele`}
|
||
>
|
||
<div className="font-semibold opacity-60">
|
||
Feedback
|
||
</div>
|
||
<div className="mt-0.5 font-bold">
|
||
{feedbackCount}/{requiredCount}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-lg bg-white/70 px-2 py-1.5 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
|
||
<div className="font-semibold opacity-60">
|
||
YOLO Train
|
||
</div>
|
||
<div className="mt-0.5 font-bold">
|
||
{trainingStatus?.detector?.trainCount ?? 0}/{trainingStatus?.detector?.requiredTrain ?? 20}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-lg bg-white/70 px-2 py-1.5 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
|
||
<div className="font-semibold opacity-60">
|
||
YOLO Val
|
||
</div>
|
||
<div className="mt-0.5 font-bold">
|
||
{trainingStatus?.detector?.valCount ?? 0}/{trainingStatus?.detector?.requiredVal ?? 3}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</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
|
||
? `YOLO-Datensatz noch nicht bereit: Train ${detector?.trainCount ?? 0}/${detector?.requiredTrain ?? 20} (${detector?.positiveTrainCount ?? 0} positiv), Val ${detector?.valCount ?? 0}/${detector?.requiredVal ?? 3} (${detector?.positiveValCount ?? 0} positiv).`
|
||
: '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>
|
||
|
||
<div className="mt-2">
|
||
<Button
|
||
size="md"
|
||
variant="secondary"
|
||
className="w-full justify-center px-2 text-[11px]"
|
||
disabled={feedbackCount === 0 || uiLocked}
|
||
onClick={() => setFeedbackModalOpen(true)}
|
||
title="Bisher abgegebenes Feedback ansehen."
|
||
>
|
||
Abgegebenes Feedback ansehen
|
||
</Button>
|
||
</div>
|
||
|
||
{trainingRunning && compact ? (
|
||
<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 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">
|
||
Epoche
|
||
</div>
|
||
<div className="mt-0.5 font-bold">
|
||
{shownTrainingEpochText
|
||
? shownTrainingEpochText.replace(/^Epoche\s+/i, '')
|
||
: '—'}
|
||
</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">
|
||
Ø / Epoche
|
||
</div>
|
||
<div className="mt-0.5 font-bold">
|
||
{estimatedEpochMs > 0 ? formatDuration(estimatedEpochMs) : '—'}
|
||
</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"
|
||
>
|
||
<span className="inline-flex items-center gap-1">
|
||
<TrashIcon className="h-3 w-3" aria-hidden="true" />
|
||
Alle löschen
|
||
</span>
|
||
</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>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const imageTouchClass =
|
||
boxLabel || drawingBox || boxInteraction
|
||
? 'touch-none'
|
||
: 'touch-pan-y'
|
||
|
||
const loadingPreviewBackgroundUrl =
|
||
loadingPreviewUrl && loadingPreviewLoaded && !loadingPreviewFailed
|
||
? loadingPreviewUrl
|
||
: ''
|
||
|
||
const frameLayoutSize = useMemo(() => {
|
||
const width = Number(frameNaturalSize?.width)
|
||
const height = Number(frameNaturalSize?.height)
|
||
|
||
if (
|
||
Number.isFinite(width) &&
|
||
Number.isFinite(height) &&
|
||
width > 0 &&
|
||
height > 0
|
||
) {
|
||
return { width, height }
|
||
}
|
||
|
||
// Fallback, bis das echte Bildformat bekannt ist.
|
||
return { width: 1600, height: 900 }
|
||
}, [frameNaturalSize])
|
||
|
||
const imageAspectRatio = Math.max(
|
||
0.25,
|
||
Math.min(4, frameLayoutSize.width / frameLayoutSize.height)
|
||
)
|
||
|
||
const imageStageLimits = imageExpanded
|
||
? {
|
||
baseDvh: 52,
|
||
smDvh: 60,
|
||
lgDvh: 78,
|
||
lgPx: 820,
|
||
|
||
// Platz für:
|
||
// - Speichern/Überspringen
|
||
// - Bild vergrößern/Normal anzeigen
|
||
// - Abstände
|
||
bottomReservePx: 158,
|
||
}
|
||
: {
|
||
baseDvh: 44,
|
||
smDvh: 52,
|
||
lgDvh: 64,
|
||
lgPx: 680,
|
||
bottomReservePx: 138,
|
||
}
|
||
|
||
const imageStageStyle = imageExpanded
|
||
? ({
|
||
'--image-stage-max-h': `${imageStageLimits.baseDvh}dvh`,
|
||
'--image-stage-max-h-sm': `${imageStageLimits.smDvh}dvh`,
|
||
} as CSSProperties & Record<string, string | number>)
|
||
: ({
|
||
aspectRatio: `${frameLayoutSize.width} / ${frameLayoutSize.height}`,
|
||
|
||
'--image-stage-max-h': `${imageStageLimits.baseDvh}dvh`,
|
||
'--image-stage-max-h-sm': `${imageStageLimits.smDvh}dvh`,
|
||
'--image-stage-max-h-lg': `min(${imageStageLimits.lgDvh}dvh, ${imageStageLimits.lgPx}px)`,
|
||
|
||
'--image-stage-w': `${imageStageLimits.baseDvh * imageAspectRatio}dvh`,
|
||
'--image-stage-w-sm': `${imageStageLimits.smDvh * imageAspectRatio}dvh`,
|
||
'--image-stage-w-lg': `min(${imageStageLimits.lgDvh * imageAspectRatio}dvh, ${Math.round(
|
||
imageStageLimits.lgPx * imageAspectRatio
|
||
)}px)`,
|
||
} as CSSProperties & Record<string, string | number>)
|
||
|
||
const imageStageHeightClass = [
|
||
'max-h-[var(--image-stage-max-h)]',
|
||
'sm:max-h-[var(--image-stage-max-h-sm)]',
|
||
'lg:max-h-[var(--image-stage-max-h-lg)]',
|
||
'w-[min(100%,var(--image-stage-w))]',
|
||
'sm:w-[min(100%,var(--image-stage-w-sm))]',
|
||
'lg:w-[min(100%,var(--image-stage-w-lg))]',
|
||
].join(' ')
|
||
|
||
const stageBusy = trainingRunning || frameBusy
|
||
|
||
const stageOverlayMode: 'training' | 'analysis' =
|
||
trainingRunning ? 'training' : 'analysis'
|
||
|
||
const stageOverlayText = trainingRunning
|
||
? shownTrainingStep || 'Aktuelles Bild wird geladen…'
|
||
: analysisStep || 'Bild wird geladen…'
|
||
|
||
const stageOverlayProgress = trainingRunning
|
||
? shownTrainingProgress
|
||
: loading
|
||
? analysisProgress
|
||
: 100
|
||
|
||
const stageOverlayFadeMs = 300
|
||
|
||
useEffect(() => {
|
||
if (stageBusy) {
|
||
setStageOverlayMounted(true)
|
||
|
||
const frame = window.requestAnimationFrame(() => {
|
||
setStageOverlayVisible(true)
|
||
})
|
||
|
||
return () => window.cancelAnimationFrame(frame)
|
||
}
|
||
|
||
setStageOverlayVisible(false)
|
||
|
||
const timer = window.setTimeout(() => {
|
||
setStageOverlayMounted(false)
|
||
}, stageOverlayFadeMs)
|
||
|
||
return () => window.clearTimeout(timer)
|
||
}, [stageBusy])
|
||
|
||
const renderStageOverlay = stageBusy || stageOverlayMounted
|
||
const stageOverlayIsVisible = stageBusy && stageOverlayVisible
|
||
|
||
return (
|
||
<div className="min-h-full lg:h-[calc(100dvh-15rem)] lg:min-h-0 lg:max-h-[calc(100dvh-15rem)] lg:overflow-hidden">
|
||
{loadingPreviewUrl ? (
|
||
<img
|
||
src={loadingPreviewUrl}
|
||
alt=""
|
||
aria-hidden="true"
|
||
draggable={false}
|
||
className="pointer-events-none fixed h-px w-px opacity-0"
|
||
style={{ left: -9999, top: -9999 }}
|
||
onLoad={(e) => {
|
||
const img = e.currentTarget
|
||
|
||
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
||
setFrameNaturalSize({
|
||
width: img.naturalWidth,
|
||
height: img.naturalHeight,
|
||
})
|
||
}
|
||
|
||
setLoadingPreviewLoaded(true)
|
||
setLoadingPreviewFailed(false)
|
||
}}
|
||
onError={() => {
|
||
setLoadingPreviewLoaded(false)
|
||
setLoadingPreviewFailed(true)
|
||
}}
|
||
/>
|
||
) : null}
|
||
|
||
<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 flex-1">
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<div className="shrink-0 text-sm font-semibold text-gray-900 dark:text-white">
|
||
Training
|
||
</div>
|
||
|
||
{sample?.sourceFile ? (
|
||
<div
|
||
className="inline-flex h-6 min-w-0 max-w-[52vw] items-center overflow-hidden rounded-full bg-gray-100 px-2.5 text-[11px] font-medium text-gray-600 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10"
|
||
title={sample.sourceFile}
|
||
>
|
||
<span className="min-w-0 translate-y-px truncate leading-[14px]">
|
||
{sample.sourceFile}
|
||
</span>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setStatsModalOpen(true)}
|
||
className="ml-auto inline-flex h-6 shrink-0 items-center rounded-full bg-gray-100 px-2 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"
|
||
>
|
||
{feedbackBadgeText}
|
||
</button>
|
||
</div>
|
||
|
||
<div
|
||
className={[
|
||
'grid grid-cols-1 items-start gap-2 lg:h-full lg:min-h-0 lg:items-stretch lg:overflow-hidden',
|
||
imageExpanded
|
||
? [
|
||
// Desktop: Sidebars bleiben sichtbar, Mitte bekommt deutlich mehr Breite.
|
||
'lg:grid-cols-[280px_minmax(0,1fr)_280px]',
|
||
'xl:grid-cols-[300px_minmax(0,1fr)_300px]',
|
||
'2xl:grid-cols-[320px_minmax(0,1fr)_320px]',
|
||
].join(' ')
|
||
: 'lg:grid-cols-[300px_minmax(0,1fr)_300px] xl:grid-cols-[320px_minmax(0,1fr)_320px]',
|
||
].join(' ')}
|
||
>
|
||
{/* 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',
|
||
].join(' ')}
|
||
>
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<div className="shrink-0 text-sm font-semibold text-gray-900 dark:text-white">
|
||
Training
|
||
</div>
|
||
|
||
{sample?.sourceFile ? (
|
||
<div
|
||
className="inline-flex h-6 min-w-0 max-w-[52vw] items-center overflow-hidden rounded-full bg-gray-100 px-2.5 text-[11px] font-medium text-gray-600 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10"
|
||
title={sample.sourceFile}
|
||
>
|
||
<span className="min-w-0 translate-y-px truncate leading-[14px]">
|
||
{sample.sourceFile}
|
||
</span>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setStatsModalOpen(true)}
|
||
className={[
|
||
'inline-flex h-6 shrink-0 items-center rounded-full bg-gray-100 px-2 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"
|
||
>
|
||
{feedbackBadgeText}
|
||
</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 */}
|
||
<div className="min-w-0 lg:flex lg:h-full lg:min-h-0 lg:flex-col lg:overflow-hidden">
|
||
<section
|
||
className={[
|
||
'min-w-0 w-full rounded-xl border border-gray-200 bg-white p-2 shadow-sm dark:border-white/10 dark:bg-gray-900/60 sm:p-3',
|
||
imageExpanded
|
||
? 'lg:grid lg:h-full lg:min-h-0 lg:grid-rows-[minmax(0,1fr)_auto] lg:gap-3 lg:overflow-hidden'
|
||
: 'lg:self-start'
|
||
].join(' ')}
|
||
>
|
||
<div
|
||
className={[
|
||
'relative z-50 mx-auto flex items-center justify-center rounded-lg bg-black p-2 sm:p-3',
|
||
imageExpanded
|
||
? 'w-full min-h-0 max-h-[var(--image-stage-max-h)] sm:max-h-[var(--image-stage-max-h-sm)] lg:h-full lg:max-h-full lg:self-stretch'
|
||
: imageStageHeightClass,
|
||
'overflow-visible',
|
||
].join(' ')}
|
||
style={imageStageStyle}
|
||
>
|
||
{imageSrc ? (
|
||
<div className="relative z-50 flex h-full min-h-0 w-full items-center justify-center overflow-visible">
|
||
<div
|
||
ref={imageBoxRef}
|
||
className={[
|
||
imageExpanded
|
||
? 'relative z-50 flex h-full w-full min-h-0 select-none items-center justify-center overscroll-contain transition'
|
||
: 'relative z-50 inline-flex min-h-0 max-h-full max-w-full select-none overscroll-contain transition',
|
||
imageTouchClass,
|
||
drawingCursorClass,
|
||
'[-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}
|
||
>
|
||
<img
|
||
ref={frameImageRef}
|
||
src={imageSrc}
|
||
alt="Training Frame"
|
||
draggable={false}
|
||
width={frameLayoutSize.width}
|
||
height={frameLayoutSize.height}
|
||
onLoad={(e) => {
|
||
const img = e.currentTarget
|
||
|
||
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
||
setFrameNaturalSize({
|
||
width: img.naturalWidth,
|
||
height: img.naturalHeight,
|
||
})
|
||
}
|
||
|
||
setFrameImageLoaded(true)
|
||
window.requestAnimationFrame(updateImageLayerStyle)
|
||
}}
|
||
onError={() => {
|
||
setFrameImageLoaded(true)
|
||
window.requestAnimationFrame(updateImageLayerStyle)
|
||
}}
|
||
onContextMenu={(e) => e.preventDefault()}
|
||
onDragStart={(e) => e.preventDefault()}
|
||
className={[
|
||
imageExpanded
|
||
? 'block h-full w-full min-h-0 rounded-md object-contain'
|
||
: 'block h-auto min-h-0 max-h-full max-w-full rounded-md object-contain',
|
||
'select-none',
|
||
'transition-opacity duration-500 ease-out will-change-opacity motion-reduce:transition-none',
|
||
frameImageLoaded ? 'opacity-100' : 'opacity-0',
|
||
imageTouchClass,
|
||
'[-webkit-user-drag:none] [-webkit-touch-callout:none]',
|
||
].join(' ')}
|
||
/>
|
||
|
||
{showImageBoxes && imageLayerStyle ? (
|
||
<div
|
||
className="absolute z-[300] overflow-visible"
|
||
style={imageLayerStyle}
|
||
>
|
||
{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
|
||
const alignLabelRight = box.x + box.w > 0.62
|
||
const imageRect = frameImageRef.current?.getBoundingClientRect()
|
||
const labelMaxWidth = imageRect?.width
|
||
? Math.max(72, Math.min(210, imageRect.width - 8))
|
||
: 210
|
||
|
||
return (
|
||
<div
|
||
key={`${box.label}-${index}-${isDraft ? 'draft' : 'box'}`}
|
||
className={[
|
||
'pointer-events-none absolute overflow-visible 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 ? 330 : isDraft ? 320 : 310,
|
||
}}
|
||
title={box.label}
|
||
>
|
||
<div
|
||
data-box-control="true"
|
||
className={[
|
||
'pointer-events-auto absolute top-0 z-[360] flex -translate-y-[calc(100%+4px)] touch-none select-none items-center',
|
||
'[-webkit-user-select:none] [-webkit-touch-callout:none]',
|
||
alignLabelRight ? 'right-0 justify-end' : 'left-0 justify-start',
|
||
].join(' ')}
|
||
style={{
|
||
maxWidth: labelMaxWidth,
|
||
}}
|
||
title={isDraft ? box.label : `${item.text} verschieben`}
|
||
>
|
||
<div
|
||
role={isDraft || uiLocked ? undefined : 'button'}
|
||
aria-disabled={Boolean(isDraft) || uiLocked}
|
||
className={[
|
||
'group/label flex h-5 max-w-full min-w-0 touch-none select-none items-center overflow-hidden 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(' ')}
|
||
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
|
||
|
||
finishingGestureRef.current = false
|
||
activePointerIdRef.current = e.pointerId
|
||
|
||
try {
|
||
imageBoxRef.current?.setPointerCapture(e.pointerId)
|
||
} catch {
|
||
activePointerIdRef.current = null
|
||
}
|
||
|
||
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 ? (
|
||
<button
|
||
data-box-trash="true"
|
||
type="button"
|
||
disabled={uiLocked}
|
||
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',
|
||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||
'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.stopPropagation()
|
||
}}
|
||
onClick={(e) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
removeBox(index)
|
||
}}
|
||
>
|
||
<TrashIcon
|
||
className="h-3 w-3 shrink-0 !text-white"
|
||
aria-hidden="true"
|
||
/>
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</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-[350] 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-[100] 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)
|
||
|
||
if (requiresActivationFirst) return
|
||
|
||
const pos = getPointerPosInImage(e.clientX, e.clientY)
|
||
if (!pos) return
|
||
|
||
finishingGestureRef.current = false
|
||
activePointerIdRef.current = e.pointerId
|
||
|
||
try {
|
||
imageBoxRef.current?.setPointerCapture(e.pointerId)
|
||
} catch {
|
||
activePointerIdRef.current = null
|
||
}
|
||
|
||
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 rect = getImageContentRect()
|
||
|
||
if (!rect || rect.width <= 0 || rect.height <= 0) return null
|
||
|
||
const visualViewport =
|
||
typeof window !== 'undefined' ? window.visualViewport : undefined
|
||
|
||
const viewportLeft = visualViewport?.offsetLeft ?? 0
|
||
const viewportTop = visualViewport?.offsetTop ?? 0
|
||
|
||
const viewportW =
|
||
visualViewport?.width ??
|
||
(typeof window !== 'undefined' ? window.innerWidth : 390)
|
||
|
||
const viewportH =
|
||
visualViewport?.height ??
|
||
(typeof window !== 'undefined' ? window.innerHeight : 800)
|
||
|
||
const isCoarsePointer =
|
||
typeof window !== 'undefined' &&
|
||
typeof window.matchMedia === 'function' &&
|
||
window.matchMedia('(pointer: coarse)').matches
|
||
|
||
const isTouchLike = isCoarsePointer || viewportW < 640
|
||
|
||
const baseSize = isTouchLike ? 136 : 156
|
||
const largeBoxSize = isTouchLike
|
||
? Math.min(176, Math.max(152, viewportW - 32))
|
||
: 190
|
||
|
||
const padding = isTouchLike ? 16 : 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 boxNeedsLargeMagnifier =
|
||
hasUsableBox &&
|
||
(
|
||
boxPixelW > baseSize - padding * 2 ||
|
||
boxPixelH > baseSize - padding * 2
|
||
)
|
||
|
||
const size = boxNeedsLargeMagnifier ? largeBoxSize : baseSize
|
||
|
||
const fitPadding = isTouchLike ? 14 : 18
|
||
|
||
const fitZoom = hasUsableBox
|
||
? Math.min(
|
||
(size - fitPadding * 2) / Math.max(1, boxPixelW),
|
||
(size - fitPadding * 2) / Math.max(1, boxPixelH)
|
||
)
|
||
: 2
|
||
|
||
const zoom = hasUsableBox
|
||
? Math.min(isTouchLike ? 2.25 : 2.5, fitZoom * 0.94)
|
||
: 2
|
||
|
||
const gap = isTouchLike ? 10 : 12
|
||
const edgeGap = isTouchLike ? 8 : 10
|
||
|
||
// Die Lupe wird an der Box ausgerichtet, nicht am Finger.
|
||
// Wenn gerade noch keine echte Box existiert, fällt sie auf den aktuellen Bildpunkt zurück.
|
||
const anchorX = hasUsableBox
|
||
? rect.left + boxCenterX * rect.width
|
||
: rect.left + touchMagnifier.imageX * rect.width
|
||
|
||
const anchorTop = hasUsableBox
|
||
? rect.top + activeBox.y * rect.height
|
||
: rect.top + touchMagnifier.imageY * rect.height
|
||
|
||
const anchorBottom = hasUsableBox
|
||
? rect.top + (activeBox.y + activeBox.h) * rect.height
|
||
: rect.top + touchMagnifier.imageY * rect.height
|
||
|
||
let left = anchorX - size / 2
|
||
|
||
// Normalfall: Lupe über der Box.
|
||
let top = anchorTop - size - gap
|
||
|
||
// Wenn oben kein Platz ist: Lupe unter die Box setzen.
|
||
if (top < viewportTop + edgeGap) {
|
||
top = anchorBottom + gap
|
||
}
|
||
|
||
// Wenn die Lupe unten aus dem sichtbaren Bereich laufen würde:
|
||
// an das untere Ende des sichtbaren Viewports klemmen.
|
||
if (top + size > viewportTop + viewportH - edgeGap) {
|
||
top = viewportTop + viewportH - size - edgeGap
|
||
}
|
||
|
||
// Falls der Viewport extrem klein ist, trotzdem sichtbar halten.
|
||
if (top < viewportTop + edgeGap) {
|
||
top = viewportTop + edgeGap
|
||
}
|
||
|
||
left = Math.max(
|
||
viewportLeft + edgeGap,
|
||
Math.min(viewportLeft + viewportW - size - edgeGap, left)
|
||
)
|
||
|
||
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 typeof document !== 'undefined'
|
||
? createPortal(
|
||
<div
|
||
className="pointer-events-none fixed z-[2147483647] 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>,
|
||
document.body
|
||
)
|
||
: null
|
||
})() : null}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="relative z-10 text-sm text-white/80">
|
||
Kein Bild geladen
|
||
</div>
|
||
)}
|
||
|
||
{renderStageOverlay ? (
|
||
<TrainingStageOverlay
|
||
mode={stageOverlayMode}
|
||
text={stageOverlayText}
|
||
progress={stageOverlayProgress}
|
||
visible={stageOverlayIsVisible}
|
||
instantBackground={stageOverlayMode === 'training'}
|
||
backgroundUrl={
|
||
stageOverlayMode === 'training'
|
||
? trainingPreviewUrl || trainingStatus?.training?.previewUrl || imageSrc
|
||
: loadingPreviewBackgroundUrl
|
||
}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
|
||
<div
|
||
className={[
|
||
'z-10 grid shrink-0 grid-cols-2 gap-2',
|
||
imageExpanded
|
||
? 'mt-3 lg:mt-0'
|
||
: 'sticky bottom-0 mt-3 sm:static sm:mt-4',
|
||
].join(' ')}
|
||
>
|
||
<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 ? (
|
||
<span className="inline-flex items-center gap-1.5">
|
||
<ArrowPathIcon className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
|
||
Speichere…
|
||
</span>
|
||
) : hasManualCorrection ? (
|
||
<span className="inline-flex items-center gap-1.5">
|
||
<InboxArrowDownIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||
<span className="sm:hidden">Speichern</span>
|
||
<span className="hidden sm:inline">Speichern & weiter</span>
|
||
</span>
|
||
) : (
|
||
<span className="inline-flex items-center gap-1.5">
|
||
<CheckIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||
<span className="sm:hidden">Passt</span>
|
||
<span className="hidden sm:inline">Passt so & weiter</span>
|
||
</span>
|
||
)}
|
||
</Button>
|
||
|
||
<Button
|
||
size="md"
|
||
variant="secondary"
|
||
disabled={uiLocked || frameBusy || !sample}
|
||
onClick={() => void skipCurrentSample()}
|
||
className="w-full justify-center px-2 text-xs sm:text-sm"
|
||
title={
|
||
trainingSampleMode === 'uncertain'
|
||
? 'Dieses Bild löschen und eine andere unsichere Prediction laden.'
|
||
: 'Dieses Bild löschen und ein anderes laden.'
|
||
}
|
||
>
|
||
<span className="inline-flex items-center gap-1.5">
|
||
<ForwardIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||
Überspringen
|
||
</span>
|
||
</Button>
|
||
|
||
<Button
|
||
size="md"
|
||
variant={isNegativeCorrection ? 'primary' : 'soft'}
|
||
color="blue"
|
||
disabled={uiLocked || frameBusy || !sample}
|
||
onClick={() => void saveFeedback(false, { negative: true })}
|
||
className="col-span-2 w-full justify-center px-2 text-xs sm:text-sm"
|
||
title="Speichert das Bild ausdrücklich ohne relevante Labels oder Boxen als YOLO-Negativbeispiel."
|
||
>
|
||
<span className="inline-flex items-center gap-1.5">
|
||
<XCircleIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||
<span className="sm:hidden">Negativbeispiel</span>
|
||
<span className="hidden sm:inline">Keine relevanten Inhalte & weiter</span>
|
||
</span>
|
||
</Button>
|
||
|
||
<div className="col-span-2 hidden lg:block">
|
||
<Button
|
||
size="md"
|
||
variant="secondary"
|
||
disabled={uiLocked}
|
||
onClick={() => setImageExpanded((value) => !value)}
|
||
className="w-full justify-center px-2 text-xs sm:text-sm"
|
||
title={imageExpanded ? 'Layout wieder normal anzeigen' : 'Mittlere Spalte vergrößern'}
|
||
aria-pressed={imageExpanded}
|
||
>
|
||
<span className="inline-flex items-center gap-1.5">
|
||
{imageExpanded ? (
|
||
<ArrowsPointingInIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||
) : (
|
||
<ArrowsPointingOutIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||
)}
|
||
|
||
{imageExpanded ? 'Normal anzeigen' : 'Bild vergrößern'}
|
||
</span>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{trainingRunning ? (
|
||
<div className="mt-2 hidden rounded-xl border border-gray-200 bg-white p-3 shadow-sm dark:border-white/10 dark:bg-gray-900/70 lg:block">
|
||
<div className="mb-2 flex items-center justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="text-xs font-semibold text-gray-900 dark:text-white">
|
||
Trainingszeit
|
||
</div>
|
||
|
||
<div className="mt-0.5 truncate text-[11px] text-gray-500 dark:text-gray-400">
|
||
{shownTrainingStep || 'Training läuft…'}
|
||
</div>
|
||
</div>
|
||
|
||
<span className="shrink-0 rounded-full bg-indigo-50 px-2 py-1 text-[11px] font-bold text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-500/15 dark:text-indigo-100 dark:ring-indigo-400/30">
|
||
{Math.round(shownTrainingProgress)}%
|
||
</span>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-5 gap-2 text-[11px]">
|
||
<div className="rounded-lg bg-indigo-50 px-2 py-1.5 text-center 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-center 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 className="rounded-lg bg-indigo-50 px-2 py-1.5 text-center 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">
|
||
Epoche
|
||
</div>
|
||
<div className="mt-0.5 font-bold">
|
||
{shownTrainingEpochText
|
||
? shownTrainingEpochText.replace(/^Epoche\s+/i, '')
|
||
: '—'}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-lg bg-indigo-50 px-2 py-1.5 text-center 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">
|
||
Ø / Epoche
|
||
</div>
|
||
<div className="mt-0.5 font-bold">
|
||
{estimatedEpochMs > 0 ? formatDuration(estimatedEpochMs) : '—'}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-lg bg-emerald-50 px-2 py-1.5 text-center text-emerald-900 ring-1 ring-emerald-100 dark:bg-emerald-500/10 dark:text-emerald-100 dark:ring-emerald-400/20">
|
||
<div className="text-[9px] font-semibold uppercase tracking-wide opacity-70">
|
||
mAP50
|
||
</div>
|
||
<div className="mt-0.5 font-bold tabular-nums">
|
||
{formatMapPercent(trainingStatus?.training?.map50) || '—'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<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>
|
||
|
||
{ /* Rechte Seite */ }
|
||
<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) =>
|
||
toggleCorrectionSection('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) =>
|
||
toggleCorrectionSection('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) =>
|
||
toggleCorrectionSection('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) =>
|
||
toggleCorrectionSection('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) =>
|
||
toggleCorrectionSection('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}
|
||
history={trainingHistory}
|
||
loading={trainingStatsLoading}
|
||
error={trainingStatsError}
|
||
feedbackCount={feedbackCount}
|
||
requiredCount={requiredCount}
|
||
/>
|
||
|
||
<TrainingFeedbackHistoryModal
|
||
open={feedbackModalOpen}
|
||
onClose={() => setFeedbackModalOpen(false)}
|
||
items={feedbackItems}
|
||
loading={feedbackLoading || feedbackLoadingMore}
|
||
error={feedbackError}
|
||
total={feedbackTotal}
|
||
hasMore={feedbackHasMore}
|
||
selectedIndex={selectedFeedbackIndex}
|
||
onSelectedIndexChange={setSelectedFeedbackIndex}
|
||
onLoadMore={() => void loadMoreFeedbackHistory()}
|
||
onEditItem={editFeedbackItem}
|
||
onSearchChange={(query, filter) => {
|
||
setFeedbackSearchQuery(query)
|
||
setFeedbackSearchFilter(filter)
|
||
|
||
void loadFeedbackHistoryInitial({
|
||
query,
|
||
filter,
|
||
})
|
||
}}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|