From b1618fd218b8ef4235e2d93f4183643a5851a19f Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:33:14 +0200 Subject: [PATCH] bugfixes --- backend/training.go | 147 ++++++++--- frontend/src/components/ui/TrainingTab.tsx | 290 +++++++++++++++++---- 2 files changed, 345 insertions(+), 92 deletions(-) diff --git a/backend/training.go b/backend/training.go index b71681c..d02681f 100644 --- a/backend/training.go +++ b/backend/training.go @@ -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, }) } diff --git a/frontend/src/components/ui/TrainingTab.tsx b/frontend/src/components/ui/TrainingTab.tsx index 97c867d..dce45bf 100644 --- a/frontend/src/components/ui/TrainingTab.tsx +++ b/frontend/src/components/ui/TrainingTab.tsx @@ -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(null) const [deletingTrainingData, setDeletingTrainingData] = useState(false) + const [trainingProgress, setTrainingProgress] = useState(0) + const [trainingStep, setTrainingStep] = useState('') const [error, setError] = useState(null) const [message, setMessage] = useState(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) => { 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) => { 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() {