added detection boxes & bugfixes
This commit is contained in:
parent
0f8a60a3b7
commit
3456655220
@ -23,7 +23,7 @@ def main():
|
||||
root = Path(args.root)
|
||||
image_path = Path(args.image)
|
||||
|
||||
model_path = root / "detector" / "best.pt"
|
||||
model_path = root / "detector" / "model" / "best.pt"
|
||||
if not model_path.exists():
|
||||
print(json.dumps({
|
||||
"available": False,
|
||||
|
||||
@ -916,13 +916,16 @@ func trainingWriteDetectorSample(root string, sample *TrainingSample, boxes []Tr
|
||||
continue
|
||||
}
|
||||
|
||||
// Erwartung: box.X/Y/W/H sind bereits YOLO-normalisiert:
|
||||
// x_center, y_center, width, height.
|
||||
// Frontend/Predictor nutzen x/y als linke obere Ecke.
|
||||
// YOLO erwartet x_center/y_center.
|
||||
xCenter := clamp01(x + w/2)
|
||||
yCenter := clamp01(y + h/2)
|
||||
|
||||
lines = append(lines, fmt.Sprintf(
|
||||
"%d %.6f %.6f %.6f %.6f",
|
||||
classID,
|
||||
x,
|
||||
y,
|
||||
xCenter,
|
||||
yCenter,
|
||||
w,
|
||||
h,
|
||||
))
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// frontend/src/components/ui/TrainingTab.tsx
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import Button from './Button'
|
||||
import LoadingSpinner from './LoadingSpinner'
|
||||
import { formatBytes, formatDuration } from './formatters'
|
||||
@ -60,6 +60,7 @@ type CorrectionState = {
|
||||
bodyPartsPresent: string[]
|
||||
objectsPresent: string[]
|
||||
clothingPresent: string[]
|
||||
boxes: TrainingBox[]
|
||||
}
|
||||
|
||||
type TrainingBox = {
|
||||
@ -99,20 +100,6 @@ function scoredLabelsText(items?: ScoredLabel[] | null) {
|
||||
.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) {
|
||||
if (!Number.isFinite(v)) return 0
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
const p = sample?.prediction
|
||||
|
||||
@ -148,6 +169,14 @@ function predictionToCorrection(sample: TrainingSample | null): CorrectionState
|
||||
bodyPartsPresent: (p?.bodyPartsPresent ?? []).map((x) => x.label),
|
||||
objectsPresent: (p?.objectsPresent ?? []).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 [error, setError] = 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(
|
||||
() => totalPeopleFromGenderCounts(correction),
|
||||
@ -195,8 +243,6 @@ export default function TrainingTab() {
|
||||
return `${sample.frameUrl}&t=${encodeURIComponent(sample.sampleId)}`
|
||||
}, [sample])
|
||||
|
||||
const boxes = useMemo(() => detectionBoxes(sample), [sample])
|
||||
|
||||
const canStartTraining = Boolean(trainingStatus?.canTrain)
|
||||
const feedbackCount = trainingStatus?.feedbackCount ?? 0
|
||||
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(() => {
|
||||
void loadLabels()
|
||||
void loadNext()
|
||||
@ -267,12 +318,17 @@ export default function TrainingTab() {
|
||||
...correction,
|
||||
peopleCount: totalPeopleFromGenderCounts(correction),
|
||||
unknownCount: 0,
|
||||
boxes: (correction.boxes ?? [])
|
||||
.map(normalizeBox)
|
||||
.filter((box) => box.label && box.w > 0 && box.h > 0),
|
||||
}
|
||||
|
||||
const payload = {
|
||||
sampleId: sample.sampleId,
|
||||
accepted,
|
||||
correction: accepted ? undefined : correctionPayload,
|
||||
correction: accepted && correctionPayload.boxes.length === 0
|
||||
? undefined
|
||||
: correctionPayload,
|
||||
}
|
||||
|
||||
const res = await fetch('/api/training/feedback', {
|
||||
@ -363,6 +419,86 @@ export default function TrainingTab() {
|
||||
}
|
||||
}, [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 (
|
||||
<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 */}
|
||||
@ -372,6 +508,10 @@ export default function TrainingTab() {
|
||||
Training
|
||||
</div>
|
||||
|
||||
<div className="rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-700 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10">
|
||||
{feedbackCount}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
@ -389,18 +529,6 @@ export default function TrainingTab() {
|
||||
Bestätige oder korrigiere die Analyse. Jede Antwort wird als Trainingsdatenpunkt gespeichert.
|
||||
</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="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>
|
||||
@ -431,17 +559,9 @@ export default function TrainingTab() {
|
||||
Aktuelle Modell-Erkennung
|
||||
</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>Personen: {sample?.prediction.peopleCount ?? '—'}</div>
|
||||
<div>Männlich: {sample?.prediction.maleCount ?? '—'}</div>
|
||||
<div>Weiblich: {sample?.prediction.femaleCount ?? '—'}</div>
|
||||
<div>Unklar: {sample?.prediction.unknownCount ?? '—'}</div>
|
||||
|
||||
<div>
|
||||
Position: {sample?.prediction.sexPosition ?? '—'}{' '}
|
||||
@ -460,6 +580,79 @@ export default function TrainingTab() {
|
||||
Kleidung: {scoredLabelsText(sample?.prediction.clothingPresent)}
|
||||
</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 className="mt-4 space-y-2">
|
||||
@ -527,39 +720,69 @@ export default function TrainingTab() {
|
||||
/>
|
||||
) : imageSrc ? (
|
||||
<div className="relative flex h-full w-full items-center justify-center">
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt="Training Frame"
|
||||
className="max-h-[72dvh] w-full object-contain"
|
||||
/>
|
||||
<div
|
||||
ref={imageBoxRef}
|
||||
className="relative inline-block max-h-[72dvh] max-w-full select-none"
|
||||
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="pointer-events-none absolute inset-0">
|
||||
{boxes.map((box, index) => {
|
||||
<div className="absolute inset-0">
|
||||
{visibleBoxes.map((box, index) => {
|
||||
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 isDraft = drawingBox && index === visibleBoxes.length - 1
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${box.label}-${index}`}
|
||||
className="absolute rounded border-2 border-emerald-400 shadow-[0_0_0_1px_rgba(0,0,0,0.75)]"
|
||||
<button
|
||||
key={`${box.label}-${index}-${isDraft ? 'draft' : 'box'}`}
|
||||
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={{
|
||||
left: `${left}%`,
|
||||
top: `${top}%`,
|
||||
width: `${width}%`,
|
||||
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) : ''}
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-white/80">Kein Frame geladen</div>
|
||||
|
||||
@ -72,4 +72,5 @@ export function formatDateTime(v?: string | number | Date | null): string {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user