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"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
@ -105,6 +106,46 @@ type TrainingAnnotation struct {
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) {
if r.Method != http.MethodGet {
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
@ -390,6 +431,16 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) {
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()
if err != nil {
trainingWriteError(w, http.StatusInternalServerError, err.Error())
@ -417,38 +468,50 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) {
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()
// --------------------------------------------------
// 1) Scene-Modell trainieren: ResNet18-KNN
// --------------------------------------------------
trainingSetJobStatus(func(s *TrainingJobStatus) {
s.Progress = 15
s.Step = "Scene-Modell wird trainiert…"
})
sceneScript := trainingScriptPath("train_scene_model.py")
sceneCmd := exec.Command(
python,
sceneScript,
"--root", root,
)
sceneCmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000,
}
sceneOut, err := sceneCmd.CombinedOutput()
sceneOut, err := trainingRunCommand(python, sceneScript, "--root", root)
if err != nil {
trainingWriteError(
w,
http.StatusInternalServerError,
fmt.Sprintf("scene training failed: %v: %s", err, strings.TrimSpace(string(sceneOut))),
)
trainingSetJobStatus(func(s *TrainingJobStatus) {
s.Running = false
s.Progress = 0
s.Step = ""
s.Error = fmt.Sprintf("scene training failed: %v: %s", err, sceneOut)
s.FinishedAt = time.Now().UTC().Format(time.RFC3339)
})
return
}
// --------------------------------------------------
// 2) Detector trainieren: YOLO
// Nur starten, wenn dataset.yaml existiert.
// --------------------------------------------------
trainingSetJobStatus(func(s *TrainingJobStatus) {
s.Progress = 65
s.Step = "Detector-Daten werden geprüft…"
})
detectorOutput := ""
detectorStatus := "skipped"
@ -462,9 +525,13 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) {
valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels)
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,
detectorScript,
"--root", root,
@ -473,13 +540,7 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) {
"--imgsz", "640",
)
detectorCmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000,
}
detectorOut, detectorErr := detectorCmd.CombinedOutput()
detectorOutput = strings.TrimSpace(string(detectorOut))
detectorOutput = detectorOut
if detectorErr != nil {
detectorStatus = "failed"
@ -504,14 +565,13 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) {
message = "Scene-Modell wurde trainiert, Detector-Training ist fehlgeschlagen."
}
trainingWriteJSON(w, http.StatusOK, map[string]any{
"ok": true,
"message": message,
"annotationCount": count,
"sceneStatus": "trained",
"sceneOutput": strings.TrimSpace(string(sceneOut)),
"detectorStatus": detectorStatus,
"detectorOutput": detectorOutput,
trainingSetJobStatus(func(s *TrainingJobStatus) {
s.Running = false
s.Progress = 100
s.Step = "Training abgeschlossen."
s.Message = message
s.Error = ""
s.FinishedAt = time.Now().UTC().Format(time.RFC3339)
})
}
@ -530,11 +590,14 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) {
feedbackPath := filepath.Join(root, "feedback.jsonl")
count, _ := trainingCountAnnotations(feedbackPath)
job := trainingGetJobStatus()
trainingWriteJSON(w, http.StatusOK, map[string]any{
"ok": true,
"feedbackCount": count,
"requiredCount": minTrainingFeedbackCount,
"canTrain": count >= minTrainingFeedbackCount,
"training": job,
})
}

View File

@ -11,10 +11,21 @@ type ScoredLabel = {
score: number
}
type TrainingJobStatus = {
running: boolean
progress: number
step: string
message?: string
error?: string
startedAt?: string
finishedAt?: string
}
type TrainingStatus = {
feedbackCount: number
requiredCount: number
canTrain: boolean
training?: TrainingJobStatus
}
type TrainingPrediction = {
@ -211,6 +222,8 @@ export default function TrainingTab() {
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)
@ -247,6 +260,15 @@ export default function TrainingTab() {
const feedbackCount = trainingStatus?.feedbackCount ?? 0
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 res = await fetch('/api/training/labels', { cache: 'no-store' })
if (!res.ok) return
@ -287,11 +309,34 @@ export default function TrainingTab() {
if (!res.ok || !data) return
const job = data.training || null
setTrainingStatus({
feedbackCount: Number(data.feedbackCount ?? 0),
requiredCount: Number(data.requiredCount ?? 5),
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(() => {
@ -300,11 +345,92 @@ export default function TrainingTab() {
}, [boxLabel, boxLabels])
useEffect(() => {
void loadLabels()
void loadNext()
void loadTrainingStatus()
let cancelled = false
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])
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(
async (accepted: boolean) => {
if (!sample) return
@ -356,6 +482,8 @@ export default function TrainingTab() {
const startTraining = useCallback(async () => {
setTraining(true)
setTrainingProgress(5)
setTrainingStep('Training wird gestartet…')
setError(null)
setMessage(null)
@ -370,13 +498,14 @@ export default function TrainingTab() {
throw new Error(data?.error || `HTTP ${res.status}`)
}
setMessage(data?.message || 'Training gestartet.')
await loadTrainingStatus()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setTraining(false)
setTrainingProgress(0)
setTrainingStep('')
setError(e instanceof Error ? e.message : String(e))
}
}, [])
}, [loadTrainingStatus])
const deleteAllTrainingData = useCallback(async () => {
const confirmed = window.confirm(
@ -434,7 +563,7 @@ export default function TrainingTab() {
const startDrawBox = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (!boxLabel) return
if (loading || saving) return
if (uiLocked) return
const pos = getPointerPosInImage(e.clientX, e.clientY)
if (!pos) return
@ -448,7 +577,7 @@ export default function TrainingTab() {
w: 0,
h: 0,
})
}, [boxLabel, getPointerPosInImage, loading, saving])
}, [boxLabel, getPointerPosInImage, uiLocked])
const moveDrawBox = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (!drawingBox) return
@ -517,7 +646,7 @@ export default function TrainingTab() {
variant="primary"
color='red'
className="shrink-0 px-2 py-1 text-[11px]"
disabled={loading || saving || training || deletingTrainingData}
disabled={uiLocked}
onClick={() => void deleteAllTrainingData()}
title="Löscht alle gespeicherten Trainingsdaten."
>
@ -600,7 +729,7 @@ export default function TrainingTab() {
<select
value={boxLabel}
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"
>
{boxLabels.length === 0 ? (
@ -625,13 +754,22 @@ export default function TrainingTab() {
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 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}
</span>
{typeof box.score === 'number' ? (
<span className="shrink-0 text-[10px] text-gray-500 dark:text-gray-400">
{percent(box.score)}
</span>
) : null}
</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"
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)}
>
löschen
@ -646,7 +784,7 @@ export default function TrainingTab() {
size="sm"
variant="secondary"
className="mt-2 w-full"
disabled={saving || loading}
disabled={uiLocked}
onClick={clearBoxes}
>
Alle Boxen entfernen
@ -660,7 +798,7 @@ export default function TrainingTab() {
size="sm"
variant="secondary"
className="w-full"
disabled={loading || saving}
disabled={uiLocked}
onClick={() => void loadNext({ forceNew: true })}
>
Anderes Bild
@ -670,7 +808,7 @@ export default function TrainingTab() {
size="sm"
variant="soft"
className="w-full"
disabled={training || !canStartTraining}
disabled={uiLocked || !canStartTraining}
onClick={() => void startTraining()}
title={
canStartTraining
@ -678,9 +816,25 @@ export default function TrainingTab() {
: `Noch zu wenig Feedback: ${feedbackCount}/${requiredCount}.`
}
>
Training starten
{trainingRunning ? 'Training läuft…' : 'Training starten'}
</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">
{canStartTraining ? (
<>
@ -722,7 +876,10 @@ export default function TrainingTab() {
<div className="relative flex h-full w-full items-center justify-center">
<div
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}
onPointerMove={moveDrawBox}
onPointerUp={finishDrawBox}
@ -746,13 +903,11 @@ export default function TrainingTab() {
return (
<div
key={`${box.label}-${index}-${isDraft ? 'draft' : 'box'}`}
role={isDraft ? undefined : 'button'}
tabIndex={isDraft ? undefined : 0}
className={[
'absolute rounded border-2 shadow-[0_0_0_1px_rgba(0,0,0,0.75)]',
isDraft
? 'pointer-events-none border-amber-400'
: 'cursor-pointer border-emerald-400 hover:border-red-400',
: 'pointer-events-none border-emerald-400',
].join(' ')}
style={{
left: `${left}%`,
@ -760,40 +915,70 @@ export default function TrainingTab() {
width: `${width}%`,
height: `${height}%`,
backgroundColor: 'transparent',
appearance: 'none',
}}
title={isDraft ? box.label : `${box.label} löschen`}
onPointerDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (!isDraft) removeBox(index)
}}
onKeyDown={(e) => {
if (isDraft) return
if (e.key !== 'Enter' && e.key !== ' ') return
e.preventDefault()
e.stopPropagation()
removeBox(index)
}}
title={box.label}
>
<span
<button
type="button"
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',
'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`}
onPointerDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (!isDraft) removeBox(index)
}}
>
{box.label} {typeof box.score === 'number' ? percent(box.score) : ''}
</span>
<span>{box.label}</span>
{typeof box.score === 'number' ? (
<span className="ml-1 opacity-80">
({percent(box.score)})
</span>
) : null}
</button>
</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 className="text-sm text-white/80">Kein Frame geladen</div>
@ -804,7 +989,7 @@ export default function TrainingTab() {
<Button
variant="soft"
color="emerald"
disabled={!sample || saving || loading || !sample.prediction.modelAvailable}
disabled={uiLocked || !sample || !sample.prediction.modelAvailable}
onClick={() => void saveFeedback(true)}
className="w-full justify-center"
title={
@ -818,7 +1003,7 @@ export default function TrainingTab() {
<Button
variant="primary"
disabled={!sample || saving || loading}
disabled={uiLocked || !sample}
onClick={() => void saveFeedback(false)}
className="w-full justify-center"
title="Die Werte rechts werden als richtige Korrektur gespeichert."
@ -828,7 +1013,7 @@ export default function TrainingTab() {
<Button
variant="secondary"
disabled={loading || saving}
disabled={uiLocked || !sample}
onClick={() => void loadNext({ forceNew: true })}
className="w-full justify-center"
title="Dieses Bild nicht bewerten und ein anderes laden."
@ -843,7 +1028,12 @@ export default function TrainingTab() {
</section>
{/* 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">
Korrektur
</div>