added detection boxes & bugfixes
This commit is contained in:
parent
0f8a60a3b7
commit
3456655220
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
))
|
))
|
||||||
|
|||||||
@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -185,6 +214,25 @@ export default function TrainingTab() {
|
|||||||
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),
|
||||||
[correction.maleCount, correction.femaleCount]
|
[correction.maleCount, correction.femaleCount]
|
||||||
@ -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>
|
||||||
|
|||||||
@ -73,3 +73,4 @@ export function formatDateTime(v?: string | number | Date | null): string {
|
|||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user