This commit is contained in:
Linrador 2026-04-29 14:33:14 +02:00
parent bbab8e5e81
commit b1618fd218
2 changed files with 345 additions and 92 deletions

View File

@ -17,6 +17,7 @@ import (
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"sync"
"syscall" "syscall"
"time" "time"
) )
@ -105,6 +106,46 @@ type TrainingAnnotation struct {
const minTrainingFeedbackCount = 5 const minTrainingFeedbackCount = 5
type TrainingJobStatus struct {
Running bool `json:"running"`
Progress int `json:"progress"`
Step string `json:"step"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
StartedAt string `json:"startedAt,omitempty"`
FinishedAt string `json:"finishedAt,omitempty"`
}
var trainingJob = struct {
mu sync.Mutex
status TrainingJobStatus
}{}
func trainingSetJobStatus(update func(*TrainingJobStatus)) {
trainingJob.mu.Lock()
defer trainingJob.mu.Unlock()
update(&trainingJob.status)
}
func trainingGetJobStatus() TrainingJobStatus {
trainingJob.mu.Lock()
defer trainingJob.mu.Unlock()
return trainingJob.status
}
func trainingRunCommand(python string, script string, args ...string) (string, error) {
cmdArgs := append([]string{script}, args...)
cmd := exec.Command(python, cmdArgs...)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000,
}
out, err := cmd.CombinedOutput()
return strings.TrimSpace(string(out)), err
}
func trainingLabelsHandler(w http.ResponseWriter, r *http.Request) { func trainingLabelsHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
@ -390,6 +431,16 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
current := trainingGetJobStatus()
if current.Running {
trainingWriteJSON(w, http.StatusOK, map[string]any{
"ok": true,
"message": "Training läuft bereits.",
"training": current,
})
return
}
root, err := trainingRootDir() root, err := trainingRootDir()
if err != nil { if err != nil {
trainingWriteError(w, http.StatusInternalServerError, err.Error()) trainingWriteError(w, http.StatusInternalServerError, err.Error())
@ -417,38 +468,50 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
trainingSetJobStatus(func(s *TrainingJobStatus) {
*s = TrainingJobStatus{
Running: true,
Progress: 5,
Step: "Training wird vorbereitet…",
StartedAt: time.Now().UTC().Format(time.RFC3339),
}
})
go trainingRunJob(root, count)
trainingWriteJSON(w, http.StatusAccepted, map[string]any{
"ok": true,
"message": "Training gestartet.",
"training": trainingGetJobStatus(),
})
}
func trainingRunJob(root string, count int) {
python := trainingPythonExe() python := trainingPythonExe()
// -------------------------------------------------- trainingSetJobStatus(func(s *TrainingJobStatus) {
// 1) Scene-Modell trainieren: ResNet18-KNN s.Progress = 15
// -------------------------------------------------- s.Step = "Scene-Modell wird trainiert…"
})
sceneScript := trainingScriptPath("train_scene_model.py") sceneScript := trainingScriptPath("train_scene_model.py")
sceneOut, err := trainingRunCommand(python, sceneScript, "--root", root)
sceneCmd := exec.Command(
python,
sceneScript,
"--root", root,
)
sceneCmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000,
}
sceneOut, err := sceneCmd.CombinedOutput()
if err != nil { if err != nil {
trainingWriteError( trainingSetJobStatus(func(s *TrainingJobStatus) {
w, s.Running = false
http.StatusInternalServerError, s.Progress = 0
fmt.Sprintf("scene training failed: %v: %s", err, strings.TrimSpace(string(sceneOut))), s.Step = ""
) s.Error = fmt.Sprintf("scene training failed: %v: %s", err, sceneOut)
s.FinishedAt = time.Now().UTC().Format(time.RFC3339)
})
return return
} }
// -------------------------------------------------- trainingSetJobStatus(func(s *TrainingJobStatus) {
// 2) Detector trainieren: YOLO s.Progress = 65
// Nur starten, wenn dataset.yaml existiert. s.Step = "Detector-Daten werden geprüft…"
// -------------------------------------------------- })
detectorOutput := "" detectorOutput := ""
detectorStatus := "skipped" detectorStatus := "skipped"
@ -462,9 +525,13 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) {
valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels) valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels)
if fileExistsNonEmpty(detectorDatasetYAML) && trainCount >= 5 && valCount >= 1 { if fileExistsNonEmpty(detectorDatasetYAML) && trainCount >= 5 && valCount >= 1 {
detectorScript := trainingScriptPath("train_detector_model.py") trainingSetJobStatus(func(s *TrainingJobStatus) {
s.Progress = 75
s.Step = "Detector wird trainiert…"
})
detectorCmd := exec.Command( detectorScript := trainingScriptPath("train_detector_model.py")
detectorOut, detectorErr := trainingRunCommand(
python, python,
detectorScript, detectorScript,
"--root", root, "--root", root,
@ -473,13 +540,7 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) {
"--imgsz", "640", "--imgsz", "640",
) )
detectorCmd.SysProcAttr = &syscall.SysProcAttr{ detectorOutput = detectorOut
HideWindow: true,
CreationFlags: 0x08000000,
}
detectorOut, detectorErr := detectorCmd.CombinedOutput()
detectorOutput = strings.TrimSpace(string(detectorOut))
if detectorErr != nil { if detectorErr != nil {
detectorStatus = "failed" detectorStatus = "failed"
@ -504,14 +565,13 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) {
message = "Scene-Modell wurde trainiert, Detector-Training ist fehlgeschlagen." message = "Scene-Modell wurde trainiert, Detector-Training ist fehlgeschlagen."
} }
trainingWriteJSON(w, http.StatusOK, map[string]any{ trainingSetJobStatus(func(s *TrainingJobStatus) {
"ok": true, s.Running = false
"message": message, s.Progress = 100
"annotationCount": count, s.Step = "Training abgeschlossen."
"sceneStatus": "trained", s.Message = message
"sceneOutput": strings.TrimSpace(string(sceneOut)), s.Error = ""
"detectorStatus": detectorStatus, s.FinishedAt = time.Now().UTC().Format(time.RFC3339)
"detectorOutput": detectorOutput,
}) })
} }
@ -530,11 +590,14 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) {
feedbackPath := filepath.Join(root, "feedback.jsonl") feedbackPath := filepath.Join(root, "feedback.jsonl")
count, _ := trainingCountAnnotations(feedbackPath) count, _ := trainingCountAnnotations(feedbackPath)
job := trainingGetJobStatus()
trainingWriteJSON(w, http.StatusOK, map[string]any{ trainingWriteJSON(w, http.StatusOK, map[string]any{
"ok": true, "ok": true,
"feedbackCount": count, "feedbackCount": count,
"requiredCount": minTrainingFeedbackCount, "requiredCount": minTrainingFeedbackCount,
"canTrain": count >= minTrainingFeedbackCount, "canTrain": count >= minTrainingFeedbackCount,
"training": job,
}) })
} }

View File

@ -11,10 +11,21 @@ type ScoredLabel = {
score: number score: number
} }
type TrainingJobStatus = {
running: boolean
progress: number
step: string
message?: string
error?: string
startedAt?: string
finishedAt?: string
}
type TrainingStatus = { type TrainingStatus = {
feedbackCount: number feedbackCount: number
requiredCount: number requiredCount: number
canTrain: boolean canTrain: boolean
training?: TrainingJobStatus
} }
type TrainingPrediction = { type TrainingPrediction = {
@ -211,6 +222,8 @@ export default function TrainingTab() {
const [training, setTraining] = useState(false) const [training, setTraining] = useState(false)
const [trainingStatus, setTrainingStatus] = useState<TrainingStatus | null>(null) const [trainingStatus, setTrainingStatus] = useState<TrainingStatus | null>(null)
const [deletingTrainingData, setDeletingTrainingData] = useState(false) const [deletingTrainingData, setDeletingTrainingData] = useState(false)
const [trainingProgress, setTrainingProgress] = useState(0)
const [trainingStep, setTrainingStep] = useState('')
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)
@ -247,6 +260,15 @@ export default function TrainingTab() {
const feedbackCount = trainingStatus?.feedbackCount ?? 0 const feedbackCount = trainingStatus?.feedbackCount ?? 0
const requiredCount = trainingStatus?.requiredCount ?? 5 const requiredCount = trainingStatus?.requiredCount ?? 5
const trainingRunning = training || Boolean(trainingStatus?.training?.running)
const uiLocked = loading || saving || trainingRunning || deletingTrainingData
const shownTrainingProgress = trainingRunning
? trainingStatus?.training?.progress ?? trainingProgress
: trainingProgress
const shownTrainingStep = trainingRunning
? trainingStatus?.training?.step || trainingStep || 'Training läuft…'
: trainingStep
const loadLabels = useCallback(async () => { const loadLabels = useCallback(async () => {
const res = await fetch('/api/training/labels', { cache: 'no-store' }) const res = await fetch('/api/training/labels', { cache: 'no-store' })
if (!res.ok) return if (!res.ok) return
@ -287,11 +309,34 @@ export default function TrainingTab() {
if (!res.ok || !data) return if (!res.ok || !data) return
const job = data.training || null
setTrainingStatus({ setTrainingStatus({
feedbackCount: Number(data.feedbackCount ?? 0), feedbackCount: Number(data.feedbackCount ?? 0),
requiredCount: Number(data.requiredCount ?? 5), requiredCount: Number(data.requiredCount ?? 5),
canTrain: Boolean(data.canTrain), canTrain: Boolean(data.canTrain),
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,
}
: undefined,
}) })
setTraining(Boolean(job?.running))
if (job?.message && !job.running) {
setMessage((prev) => prev || String(job.message))
}
if (job?.error && !job.running) {
setError((prev) => prev || String(job.error))
}
}, []) }, [])
useEffect(() => { useEffect(() => {
@ -300,11 +345,92 @@ export default function TrainingTab() {
}, [boxLabel, boxLabels]) }, [boxLabel, boxLabels])
useEffect(() => { useEffect(() => {
void loadLabels() let cancelled = false
void loadNext()
void loadTrainingStatus() async function init() {
await loadLabels()
await loadTrainingStatus()
if (cancelled) return
const res = await fetch('/api/training/status', { cache: 'no-store' })
const data = await res.json().catch(() => null)
if (!cancelled && !data?.training?.running) {
await loadNext()
}
}
void init()
return () => {
cancelled = true
}
}, [loadLabels, loadNext, loadTrainingStatus]) }, [loadLabels, loadNext, loadTrainingStatus])
useEffect(() => {
const timer = window.setInterval(() => {
void loadTrainingStatus()
}, trainingRunning ? 1500 : 5000)
return () => window.clearInterval(timer)
}, [loadTrainingStatus, trainingRunning])
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) {
setTrainingProgress(0)
setTrainingStep('')
return
}
setTrainingProgress((prev) => (prev > 0 ? prev : 8))
setTrainingStep((prev) => prev || 'Training wird vorbereitet…')
const startedAt = Date.now()
const timer = window.setInterval(() => {
const elapsed = Date.now() - startedAt
setTrainingProgress((prev) => {
const serverProgress = trainingStatus?.training?.progress
if (typeof serverProgress === 'number' && serverProgress > prev) {
return serverProgress
}
if (elapsed > 90_000) {
setTrainingStep('Detector wird trainiert…')
return Math.min(prev + 0.4, 92)
}
if (elapsed > 25_000) {
setTrainingStep('Scene-Modell wird trainiert…')
return Math.min(prev + 0.8, 80)
}
setTrainingStep('Trainingsdaten werden verarbeitet…')
return Math.min(prev + 1.2, 55)
})
}, 700)
return () => window.clearInterval(timer)
}, [trainingRunning, trainingStatus?.training?.progress])
const saveFeedback = useCallback( const saveFeedback = useCallback(
async (accepted: boolean) => { async (accepted: boolean) => {
if (!sample) return if (!sample) return
@ -356,6 +482,8 @@ export default function TrainingTab() {
const startTraining = useCallback(async () => { const startTraining = useCallback(async () => {
setTraining(true) setTraining(true)
setTrainingProgress(5)
setTrainingStep('Training wird gestartet…')
setError(null) setError(null)
setMessage(null) setMessage(null)
@ -370,13 +498,14 @@ export default function TrainingTab() {
throw new Error(data?.error || `HTTP ${res.status}`) throw new Error(data?.error || `HTTP ${res.status}`)
} }
setMessage(data?.message || 'Training gestartet.') await loadTrainingStatus()
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setTraining(false) setTraining(false)
setTrainingProgress(0)
setTrainingStep('')
setError(e instanceof Error ? e.message : String(e))
} }
}, []) }, [loadTrainingStatus])
const deleteAllTrainingData = useCallback(async () => { const deleteAllTrainingData = useCallback(async () => {
const confirmed = window.confirm( const confirmed = window.confirm(
@ -434,7 +563,7 @@ export default function TrainingTab() {
const startDrawBox = useCallback((e: React.PointerEvent<HTMLDivElement>) => { const startDrawBox = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (!boxLabel) return if (!boxLabel) return
if (loading || saving) return if (uiLocked) return
const pos = getPointerPosInImage(e.clientX, e.clientY) const pos = getPointerPosInImage(e.clientX, e.clientY)
if (!pos) return if (!pos) return
@ -448,7 +577,7 @@ export default function TrainingTab() {
w: 0, w: 0,
h: 0, h: 0,
}) })
}, [boxLabel, getPointerPosInImage, loading, saving]) }, [boxLabel, getPointerPosInImage, uiLocked])
const moveDrawBox = useCallback((e: React.PointerEvent<HTMLDivElement>) => { const moveDrawBox = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (!drawingBox) return if (!drawingBox) return
@ -517,7 +646,7 @@ export default function TrainingTab() {
variant="primary" variant="primary"
color='red' color='red'
className="shrink-0 px-2 py-1 text-[11px]" className="shrink-0 px-2 py-1 text-[11px]"
disabled={loading || saving || training || deletingTrainingData} disabled={uiLocked}
onClick={() => void deleteAllTrainingData()} onClick={() => void deleteAllTrainingData()}
title="Löscht alle gespeicherten Trainingsdaten." title="Löscht alle gespeicherten Trainingsdaten."
> >
@ -600,7 +729,7 @@ export default function TrainingTab() {
<select <select
value={boxLabel} value={boxLabel}
onChange={(e) => setBoxLabel(e.target.value)} onChange={(e) => setBoxLabel(e.target.value)}
disabled={boxLabels.length === 0} disabled={uiLocked || 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" 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 ? ( {boxLabels.length === 0 ? (
@ -625,13 +754,22 @@ export default function TrainingTab() {
key={`${box.label}-${index}`} 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" 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"> <div className="flex min-w-0 flex-1 items-center justify-between gap-2 text-gray-700 dark:text-gray-200">
<span className="min-w-0 truncate">
{index + 1}. {box.label} {index + 1}. {box.label}
</span>
{typeof box.score === 'number' ? (
<span className="shrink-0 text-[10px] text-gray-500 dark:text-gray-400">
{percent(box.score)}
</span>
) : null}
</div> </div>
<button <button
type="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" disabled={uiLocked}
className="shrink-0 rounded px-1.5 py-0.5 text-red-600 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-50 dark:text-red-300 dark:hover:bg-red-500/10"
onClick={() => removeBox(index)} onClick={() => removeBox(index)}
> >
löschen löschen
@ -646,7 +784,7 @@ export default function TrainingTab() {
size="sm" size="sm"
variant="secondary" variant="secondary"
className="mt-2 w-full" className="mt-2 w-full"
disabled={saving || loading} disabled={uiLocked}
onClick={clearBoxes} onClick={clearBoxes}
> >
Alle Boxen entfernen Alle Boxen entfernen
@ -660,7 +798,7 @@ export default function TrainingTab() {
size="sm" size="sm"
variant="secondary" variant="secondary"
className="w-full" className="w-full"
disabled={loading || saving} disabled={uiLocked}
onClick={() => void loadNext({ forceNew: true })} onClick={() => void loadNext({ forceNew: true })}
> >
Anderes Bild Anderes Bild
@ -670,7 +808,7 @@ export default function TrainingTab() {
size="sm" size="sm"
variant="soft" variant="soft"
className="w-full" className="w-full"
disabled={training || !canStartTraining} disabled={uiLocked || !canStartTraining}
onClick={() => void startTraining()} onClick={() => void startTraining()}
title={ title={
canStartTraining canStartTraining
@ -678,9 +816,25 @@ export default function TrainingTab() {
: `Noch zu wenig Feedback: ${feedbackCount}/${requiredCount}.` : `Noch zu wenig Feedback: ${feedbackCount}/${requiredCount}.`
} }
> >
Training starten {trainingRunning ? 'Training läuft…' : 'Training starten'}
</Button> </Button>
{trainingRunning ? (
<div className="rounded-lg bg-gray-50 p-2 text-xs ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="flex items-center justify-between gap-2 text-[11px] text-gray-600 dark:text-gray-300">
<span>{shownTrainingStep || 'Training läuft…'}</span>
<span>{Math.round(shownTrainingProgress)}%</span>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
<div
className="h-full rounded-full bg-emerald-500 transition-all duration-500"
style={{ width: `${clampPercent(shownTrainingProgress)}%` }}
/>
</div>
</div>
) : null}
<div className="text-[11px] leading-relaxed text-gray-500 dark:text-gray-400"> <div className="text-[11px] leading-relaxed text-gray-500 dark:text-gray-400">
{canStartTraining ? ( {canStartTraining ? (
<> <>
@ -722,7 +876,10 @@ export default function TrainingTab() {
<div className="relative flex h-full w-full items-center justify-center"> <div className="relative flex h-full w-full items-center justify-center">
<div <div
ref={imageBoxRef} ref={imageBoxRef}
className="relative inline-block max-h-[72dvh] max-w-full select-none" className={[
'relative inline-block max-h-[72dvh] max-w-full select-none transition',
trainingRunning ? 'pointer-events-none blur-sm' : '',
].join(' ')}
onPointerDown={startDrawBox} onPointerDown={startDrawBox}
onPointerMove={moveDrawBox} onPointerMove={moveDrawBox}
onPointerUp={finishDrawBox} onPointerUp={finishDrawBox}
@ -746,13 +903,11 @@ export default function TrainingTab() {
return ( return (
<div <div
key={`${box.label}-${index}-${isDraft ? 'draft' : 'box'}`} key={`${box.label}-${index}-${isDraft ? 'draft' : 'box'}`}
role={isDraft ? undefined : 'button'}
tabIndex={isDraft ? undefined : 0}
className={[ className={[
'absolute rounded border-2 shadow-[0_0_0_1px_rgba(0,0,0,0.75)]', 'absolute rounded border-2 shadow-[0_0_0_1px_rgba(0,0,0,0.75)]',
isDraft isDraft
? 'pointer-events-none border-amber-400' ? 'pointer-events-none border-amber-400'
: 'cursor-pointer border-emerald-400 hover:border-red-400', : 'pointer-events-none border-emerald-400',
].join(' ')} ].join(' ')}
style={{ style={{
left: `${left}%`, left: `${left}%`,
@ -760,8 +915,18 @@ export default function TrainingTab() {
width: `${width}%`, width: `${width}%`,
height: `${height}%`, height: `${height}%`,
backgroundColor: 'transparent', backgroundColor: 'transparent',
appearance: 'none',
}} }}
title={box.label}
>
<button
type="button"
className={[
'pointer-events-auto absolute left-0 top-0 flex -translate-y-full items-center rounded-t px-1.5 py-0.5 text-[11px] font-semibold text-black shadow',
isDraft
? 'cursor-default bg-amber-400'
: 'cursor-pointer bg-emerald-400 hover:bg-red-400',
].join(' ')}
disabled={Boolean(isDraft)}
title={isDraft ? box.label : `${box.label} löschen`} title={isDraft ? box.label : `${box.label} löschen`}
onPointerDown={(e) => { onPointerDown={(e) => {
e.preventDefault() e.preventDefault()
@ -772,28 +937,48 @@ export default function TrainingTab() {
e.stopPropagation() e.stopPropagation()
if (!isDraft) removeBox(index) if (!isDraft) removeBox(index)
}} }}
onKeyDown={(e) => { >
if (isDraft) return <span>{box.label}</span>
if (e.key !== 'Enter' && e.key !== ' ') return
e.preventDefault() {typeof box.score === 'number' ? (
e.stopPropagation() <span className="ml-1 opacity-80">
removeBox(index) ({percent(box.score)})
}}
>
<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) : ''}
</span> </span>
) : null}
</button>
</div> </div>
) )
})} })}
</div> </div>
</div> </div>
{trainingRunning ? (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-black/45 text-center text-white backdrop-blur-[1px]">
<LoadingSpinner
size="lg"
className="text-white"
srLabel="Training läuft…"
/>
<div className="mt-3 text-sm font-semibold">
Training läuft
</div>
<div className="mt-1 max-w-[260px] px-4 text-xs text-white/80">
{shownTrainingStep || 'Bitte warten. Die Oberfläche ist währenddessen gesperrt.'}
</div>
<div className="mt-3 h-2 w-48 overflow-hidden rounded-full bg-white/20">
<div
className="h-full rounded-full bg-emerald-400 transition-all duration-500"
style={{ width: `${clampPercent(shownTrainingProgress)}%` }}
/>
</div>
<div className="mt-1 text-[11px] text-white/70">
{Math.round(shownTrainingProgress)}%
</div>
</div>
) : null}
</div> </div>
) : ( ) : (
<div className="text-sm text-white/80">Kein Frame geladen</div> <div className="text-sm text-white/80">Kein Frame geladen</div>
@ -804,7 +989,7 @@ export default function TrainingTab() {
<Button <Button
variant="soft" variant="soft"
color="emerald" color="emerald"
disabled={!sample || saving || loading || !sample.prediction.modelAvailable} disabled={uiLocked || !sample || !sample.prediction.modelAvailable}
onClick={() => void saveFeedback(true)} onClick={() => void saveFeedback(true)}
className="w-full justify-center" className="w-full justify-center"
title={ title={
@ -818,7 +1003,7 @@ export default function TrainingTab() {
<Button <Button
variant="primary" variant="primary"
disabled={!sample || saving || loading} disabled={uiLocked || !sample}
onClick={() => void saveFeedback(false)} onClick={() => void saveFeedback(false)}
className="w-full justify-center" className="w-full justify-center"
title="Die Werte rechts werden als richtige Korrektur gespeichert." title="Die Werte rechts werden als richtige Korrektur gespeichert."
@ -828,7 +1013,7 @@ export default function TrainingTab() {
<Button <Button
variant="secondary" variant="secondary"
disabled={loading || saving} disabled={uiLocked || !sample}
onClick={() => void loadNext({ forceNew: true })} onClick={() => void loadNext({ forceNew: true })}
className="w-full justify-center" className="w-full justify-center"
title="Dieses Bild nicht bewerten und ein anderes laden." title="Dieses Bild nicht bewerten und ein anderes laden."
@ -843,7 +1028,12 @@ export default function TrainingTab() {
</section> </section>
{/* Rechte Details/Korrektur */} {/* Rechte Details/Korrektur */}
<aside className="rounded-xl border border-gray-200 bg-white p-3 shadow-sm dark:border-white/10 dark:bg-gray-900/60"> <aside
className={[
'rounded-xl border border-gray-200 bg-white p-3 shadow-sm dark:border-white/10 dark:bg-gray-900/60',
uiLocked ? 'pointer-events-none opacity-60' : ''
].join(' ')}
>
<div className="text-sm font-semibold text-gray-900 dark:text-white"> <div className="text-sm font-semibold text-gray-900 dark:text-white">
Korrektur Korrektur
</div> </div>