added detection boxes & bugfixes

This commit is contained in:
Linrador 2026-04-29 13:31:43 +02:00
parent 0f8a60a3b7
commit 3456655220
4 changed files with 286 additions and 59 deletions

View File

@ -23,7 +23,7 @@ def main():
root = Path(args.root) root = Path(args.root)
image_path = Path(args.image) image_path = Path(args.image)
model_path = root / "detector" / "best.pt" model_path = root / "detector" / "model" / "best.pt"
if not model_path.exists(): if not model_path.exists():
print(json.dumps({ print(json.dumps({
"available": False, "available": False,

View File

@ -916,13 +916,16 @@ func trainingWriteDetectorSample(root string, sample *TrainingSample, boxes []Tr
continue continue
} }
// Erwartung: box.X/Y/W/H sind bereits YOLO-normalisiert: // Frontend/Predictor nutzen x/y als linke obere Ecke.
// x_center, y_center, width, height. // YOLO erwartet x_center/y_center.
xCenter := clamp01(x + w/2)
yCenter := clamp01(y + h/2)
lines = append(lines, fmt.Sprintf( lines = append(lines, fmt.Sprintf(
"%d %.6f %.6f %.6f %.6f", "%d %.6f %.6f %.6f %.6f",
classID, classID,
x, xCenter,
y, yCenter,
w, w,
h, h,
)) ))

View File

@ -1,7 +1,7 @@
// frontend/src/components/ui/TrainingTab.tsx // frontend/src/components/ui/TrainingTab.tsx
'use client' 'use client'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Button from './Button' import Button from './Button'
import LoadingSpinner from './LoadingSpinner' import LoadingSpinner from './LoadingSpinner'
import { formatBytes, formatDuration } from './formatters' import { formatBytes, formatDuration } from './formatters'
@ -60,6 +60,7 @@ type CorrectionState = {
bodyPartsPresent: string[] bodyPartsPresent: string[]
objectsPresent: string[] objectsPresent: string[]
clothingPresent: string[] clothingPresent: string[]
boxes: TrainingBox[]
} }
type TrainingBox = { type TrainingBox = {
@ -99,20 +100,6 @@ function scoredLabelsText(items?: ScoredLabel[] | null) {
.join(', ') .join(', ')
} }
function detectionBoxes(sample: TrainingSample | null) {
const boxes = sample?.prediction.boxes ?? []
return boxes.filter((box) => {
const label = String(box.label || '').trim()
if (!label) return false
const isBodyPart = sample?.prediction.bodyPartsPresent?.some((x) => x.label === label)
const isObject = sample?.prediction.objectsPresent?.some((x) => x.label === label)
return isBodyPart || isObject
})
}
function clampPercent(v: number) { function clampPercent(v: number) {
if (!Number.isFinite(v)) return 0 if (!Number.isFinite(v)) return 0
return Math.max(0, Math.min(100, v)) return Math.max(0, Math.min(100, v))
@ -133,6 +120,40 @@ function totalPeopleFromGenderCounts(state: Pick<CorrectionState, 'maleCount' |
return safeCount(state.maleCount) + safeCount(state.femaleCount) return safeCount(state.maleCount) + safeCount(state.femaleCount)
} }
function clamp01(v: number) {
if (!Number.isFinite(v)) return 0
return Math.max(0, Math.min(1, v))
}
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 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 predictionToCorrection(sample: TrainingSample | null): CorrectionState { function predictionToCorrection(sample: TrainingSample | null): CorrectionState {
const p = sample?.prediction const p = sample?.prediction
@ -148,6 +169,14 @@ function predictionToCorrection(sample: TrainingSample | null): CorrectionState
bodyPartsPresent: (p?.bodyPartsPresent ?? []).map((x) => x.label), bodyPartsPresent: (p?.bodyPartsPresent ?? []).map((x) => x.label),
objectsPresent: (p?.objectsPresent ?? []).map((x) => x.label), objectsPresent: (p?.objectsPresent ?? []).map((x) => x.label),
clothingPresent: (p?.clothingPresent ?? []).map((x) => x.label), clothingPresent: (p?.clothingPresent ?? []).map((x) => x.label),
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),
} }
} }
@ -184,6 +213,25 @@ export default function TrainingTab() {
const [deletingTrainingData, setDeletingTrainingData] = useState(false) const [deletingTrainingData, setDeletingTrainingData] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [message, setMessage] = useState<string | null>(null) const [message, setMessage] = useState<string | null>(null)
const imageBoxRef = useRef<HTMLDivElement | null>(null)
const [drawingBox, setDrawingBox] = useState<TrainingBox | null>(null)
const [boxLabel, setBoxLabel] = useState('')
const boxLabels = useMemo(() => {
return uniqStrings([
...labels.bodyParts,
...labels.objects,
...labels.clothing,
]).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
}, [labels.bodyParts, labels.objects, labels.clothing])
const correctionBoxes = correction.boxes ?? []
const visibleBoxes = drawingBox
? [...correctionBoxes, drawingBox]
: correctionBoxes
const computedPeopleCount = useMemo( const computedPeopleCount = useMemo(
() => totalPeopleFromGenderCounts(correction), () => totalPeopleFromGenderCounts(correction),
@ -195,8 +243,6 @@ export default function TrainingTab() {
return `${sample.frameUrl}&t=${encodeURIComponent(sample.sampleId)}` return `${sample.frameUrl}&t=${encodeURIComponent(sample.sampleId)}`
}, [sample]) }, [sample])
const boxes = useMemo(() => detectionBoxes(sample), [sample])
const canStartTraining = Boolean(trainingStatus?.canTrain) const canStartTraining = Boolean(trainingStatus?.canTrain)
const feedbackCount = trainingStatus?.feedbackCount ?? 0 const feedbackCount = trainingStatus?.feedbackCount ?? 0
const requiredCount = trainingStatus?.requiredCount ?? 5 const requiredCount = trainingStatus?.requiredCount ?? 5
@ -248,6 +294,11 @@ export default function TrainingTab() {
}) })
}, []) }, [])
useEffect(() => {
if (boxLabel && boxLabels.includes(boxLabel)) return
setBoxLabel(boxLabels[0] || '')
}, [boxLabel, boxLabels])
useEffect(() => { useEffect(() => {
void loadLabels() void loadLabels()
void loadNext() void loadNext()
@ -267,12 +318,17 @@ export default function TrainingTab() {
...correction, ...correction,
peopleCount: totalPeopleFromGenderCounts(correction), peopleCount: totalPeopleFromGenderCounts(correction),
unknownCount: 0, unknownCount: 0,
boxes: (correction.boxes ?? [])
.map(normalizeBox)
.filter((box) => box.label && box.w > 0 && box.h > 0),
} }
const payload = { const payload = {
sampleId: sample.sampleId, sampleId: sample.sampleId,
accepted, accepted,
correction: accepted ? undefined : correctionPayload, correction: accepted && correctionPayload.boxes.length === 0
? undefined
: correctionPayload,
} }
const res = await fetch('/api/training/feedback', { const res = await fetch('/api/training/feedback', {
@ -363,6 +419,86 @@ export default function TrainingTab() {
} }
}, [loadNext, loadTrainingStatus, requiredCount]) }, [loadNext, loadTrainingStatus, requiredCount])
const getPointerPosInImage = useCallback((clientX: number, clientY: number) => {
const el = imageBoxRef.current
if (!el) return null
const rect = el.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) return null
return {
x: clamp01((clientX - rect.left) / rect.width),
y: clamp01((clientY - rect.top) / rect.height),
}
}, [])
const startDrawBox = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (!boxLabel) return
if (loading || saving) return
const pos = getPointerPosInImage(e.clientX, e.clientY)
if (!pos) return
e.currentTarget.setPointerCapture(e.pointerId)
setDrawingBox({
label: boxLabel,
x: pos.x,
y: pos.y,
w: 0,
h: 0,
})
}, [boxLabel, getPointerPosInImage, loading, saving])
const moveDrawBox = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (!drawingBox) return
const pos = getPointerPosInImage(e.clientX, e.clientY)
if (!pos) return
const x1 = drawingBox.x
const y1 = drawingBox.y
const x2 = pos.x
const y2 = pos.y
setDrawingBox({
label: drawingBox.label,
x: Math.min(x1, x2),
y: Math.min(y1, y2),
w: Math.abs(x2 - x1),
h: Math.abs(y2 - y1),
})
}, [drawingBox, getPointerPosInImage])
const finishDrawBox = useCallback(() => {
if (!drawingBox) return
const box = normalizeBox(drawingBox)
setDrawingBox(null)
if (box.w < 0.01 || box.h < 0.01) return
setCorrection((prev) => ({
...prev,
boxes: [...(prev.boxes ?? []), box],
}))
}, [drawingBox])
const removeBox = useCallback((index: number) => {
setCorrection((prev) => ({
...prev,
boxes: (prev.boxes ?? []).filter((_, i) => i !== index),
}))
}, [])
const clearBoxes = useCallback(() => {
setCorrection((prev) => ({
...prev,
boxes: [],
}))
}, [])
return ( return (
<div className="grid grid-cols-1 items-stretch gap-3 lg:grid-cols-[300px_minmax(0,1fr)_300px] xl:grid-cols-[320px_minmax(0,1fr)_320px]"> <div className="grid grid-cols-1 items-stretch gap-3 lg:grid-cols-[300px_minmax(0,1fr)_300px] xl:grid-cols-[320px_minmax(0,1fr)_320px]">
{/* Sidebar links */} {/* Sidebar links */}
@ -372,6 +508,10 @@ export default function TrainingTab() {
Training Training
</div> </div>
<div className="rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-700 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10">
{feedbackCount}
</div>
<Button <Button
size="sm" size="sm"
variant="primary" variant="primary"
@ -389,18 +529,6 @@ export default function TrainingTab() {
Bestätige oder korrigiere die Analyse. Jede Antwort wird als Trainingsdatenpunkt gespeichert. Bestätige oder korrigiere die Analyse. Jede Antwort wird als Trainingsdatenpunkt gespeichert.
</div> </div>
<div className="mt-4 rounded-lg bg-gray-50 p-2 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="flex items-center justify-between gap-2">
<div className="text-xs font-medium text-gray-900 dark:text-white">
Feedback
</div>
<div className="rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-700 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10">
{feedbackCount}
</div>
</div>
</div>
<div className="mt-4 space-y-2 text-xs"> <div className="mt-4 space-y-2 text-xs">
<div className="rounded-lg bg-gray-50 p-2 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10"> <div className="rounded-lg bg-gray-50 p-2 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="font-medium text-gray-900 dark:text-white">Datei</div> <div className="font-medium text-gray-900 dark:text-white">Datei</div>
@ -431,17 +559,9 @@ export default function TrainingTab() {
Aktuelle Modell-Erkennung Aktuelle Modell-Erkennung
</div> </div>
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
{sample?.prediction.modelAvailable
? `Modell aktiv: ${sample.prediction.source || 'lokal'}`
: 'Noch kein trainiertes Modell vorhanden.'}
</div>
<div className="mt-2 space-y-1 text-xs text-gray-600 dark:text-gray-300"> <div className="mt-2 space-y-1 text-xs text-gray-600 dark:text-gray-300">
<div>Personen: {sample?.prediction.peopleCount ?? '—'}</div>
<div>Männlich: {sample?.prediction.maleCount ?? '—'}</div> <div>Männlich: {sample?.prediction.maleCount ?? '—'}</div>
<div>Weiblich: {sample?.prediction.femaleCount ?? '—'}</div> <div>Weiblich: {sample?.prediction.femaleCount ?? '—'}</div>
<div>Unklar: {sample?.prediction.unknownCount ?? '—'}</div>
<div> <div>
Position: {sample?.prediction.sexPosition ?? '—'}{' '} Position: {sample?.prediction.sexPosition ?? '—'}{' '}
@ -460,6 +580,79 @@ export default function TrainingTab() {
Kleidung: {scoredLabelsText(sample?.prediction.clothingPresent)} Kleidung: {scoredLabelsText(sample?.prediction.clothingPresent)}
</div> </div>
</div> </div>
<div className="mt-2 rounded-lg bg-gray-50 p-2 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-xs font-medium text-gray-700 dark:text-gray-200">
Detector-Boxen
</div>
<div className="text-[10px] text-gray-500 dark:text-gray-400">
Label wählen, dann im Bild ziehen.
</div>
</div>
<div className="rounded-full bg-white px-2 py-0.5 text-[11px] font-semibold text-gray-800 ring-1 ring-gray-200 dark:bg-gray-950 dark:text-gray-100 dark:ring-white/10">
{correctionBoxes.length}
</div>
</div>
<select
value={boxLabel}
onChange={(e) => setBoxLabel(e.target.value)}
disabled={boxLabels.length === 0}
className="mt-2 h-9 w-full rounded-md border border-gray-200 bg-white px-2 text-sm dark:border-white/10 dark:bg-gray-950"
>
{boxLabels.length === 0 ? (
<option value="">Keine Box-Labels</option>
) : (
boxLabels.map((x) => (
<option key={x} value={x}>
{x}
</option>
))
)}
</select>
<div className="mt-2 space-y-1">
{correctionBoxes.length === 0 ? (
<div className="text-[11px] text-gray-500 dark:text-gray-400">
Noch keine Boxen gezeichnet.
</div>
) : (
correctionBoxes.map((box, index) => (
<div
key={`${box.label}-${index}`}
className="flex items-center justify-between gap-2 rounded-md bg-white px-2 py-1 text-[11px] ring-1 ring-gray-200 dark:bg-gray-950 dark:ring-white/10"
>
<div className="min-w-0 truncate text-gray-700 dark:text-gray-200">
{index + 1}. {box.label}
</div>
<button
type="button"
className="shrink-0 rounded px-1.5 py-0.5 text-red-600 hover:bg-red-50 dark:text-red-300 dark:hover:bg-red-500/10"
onClick={() => removeBox(index)}
>
löschen
</button>
</div>
))
)}
</div>
{correctionBoxes.length > 0 ? (
<Button
size="sm"
variant="secondary"
className="mt-2 w-full"
disabled={saving || loading}
onClick={clearBoxes}
>
Alle Boxen entfernen
</Button>
) : null}
</div>
</div> </div>
<div className="mt-4 space-y-2"> <div className="mt-4 space-y-2">
@ -527,39 +720,69 @@ export default function TrainingTab() {
/> />
) : imageSrc ? ( ) : imageSrc ? (
<div className="relative flex h-full w-full items-center justify-center"> <div className="relative flex h-full w-full items-center justify-center">
<img <div
src={imageSrc} ref={imageBoxRef}
alt="Training Frame" className="relative inline-block max-h-[72dvh] max-w-full select-none"
className="max-h-[72dvh] w-full object-contain" onPointerDown={startDrawBox}
/> onPointerMove={moveDrawBox}
onPointerUp={finishDrawBox}
onPointerCancel={finishDrawBox}
>
<img
src={imageSrc}
alt="Training Frame"
draggable={false}
className="block max-h-[72dvh] max-w-full object-contain"
/>
{boxes.length > 0 ? ( <div className="absolute inset-0">
<div className="pointer-events-none absolute inset-0"> {visibleBoxes.map((box, index) => {
{boxes.map((box, index) => {
const left = clampPercent(box.x * 100) const left = clampPercent(box.x * 100)
const top = clampPercent(box.y * 100) const top = clampPercent(box.y * 100)
const width = clampPercent(box.w * 100) const width = clampPercent(box.w * 100)
const height = clampPercent(box.h * 100) const height = clampPercent(box.h * 100)
const isDraft = drawingBox && index === visibleBoxes.length - 1
return ( return (
<div <button
key={`${box.label}-${index}`} key={`${box.label}-${index}-${isDraft ? 'draft' : 'box'}`}
className="absolute rounded border-2 border-emerald-400 shadow-[0_0_0_1px_rgba(0,0,0,0.75)]" type="button"
className={[
'absolute rounded border-2 bg-transparent hover:bg-transparent active:bg-transparent focus:bg-transparent shadow-[0_0_0_1px_rgba(0,0,0,0.75)]',
isDraft
? 'pointer-events-none border-amber-400'
: 'border-emerald-400 hover:border-red-400',
].join(' ')}
style={{ style={{
left: `${left}%`, left: `${left}%`,
top: `${top}%`, top: `${top}%`,
width: `${width}%`, width: `${width}%`,
height: `${height}%`, height: `${height}%`,
}} }}
title={isDraft ? box.label : `${box.label} löschen`}
onPointerDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (!isDraft) removeBox(index)
}}
> >
<div className="absolute left-0 top-0 -translate-y-full rounded-t bg-emerald-400 px-1.5 py-0.5 text-[11px] font-semibold text-black shadow"> <span
className={[
'absolute left-0 top-0 -translate-y-full rounded-t px-1.5 py-0.5 text-[11px] font-semibold text-black shadow',
isDraft ? 'bg-amber-400' : 'bg-emerald-400',
].join(' ')}
>
{box.label} {typeof box.score === 'number' ? percent(box.score) : ''} {box.label} {typeof box.score === 'number' ? percent(box.score) : ''}
</div> </span>
</div> </button>
) )
})} })}
</div> </div>
) : null} </div>
</div> </div>
) : ( ) : (
<div className="text-sm text-white/80">Kein Frame geladen</div> <div className="text-sm text-white/80">Kein Frame geladen</div>

View File

@ -72,4 +72,5 @@ export function formatDateTime(v?: string | number | Date | null): string {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
}) })
} }