added real training progress + bugfixes

This commit is contained in:
Linrador 2026-05-03 15:42:44 +02:00
parent 34df62aa13
commit a1000e9085
6 changed files with 369 additions and 65 deletions

View File

@ -52,13 +52,24 @@ def main():
device = 0 if torch.cuda.is_available() else "cpu"
results = model.predict(
source=str(image_path),
conf=float(args.conf),
imgsz=int(args.imgsz),
verbose=False,
device=device,
)
try:
results = model.predict(
source=str(image_path),
conf=float(args.conf),
imgsz=int(args.imgsz),
verbose=False,
device=device,
)
except Exception as e:
print(json.dumps({
"available": False,
"source": "detector_predict_failed",
"modelPath": str(model_path),
"image": str(image_path),
"error": repr(e),
"boxes": [],
}, ensure_ascii=False))
return
boxes = []
model_names = {}

View File

@ -56,11 +56,13 @@ def main():
parser.add_argument("--base", default="yolo11n.pt")
parser.add_argument("--epochs", default="80")
parser.add_argument("--imgsz", default="640")
parser.add_argument("--device", default="cpu")
parser.add_argument("--device", default="auto")
parser.add_argument("--workers", default="2")
parser.add_argument("--patience", default="20")
args = parser.parse_args()
import torch
root = Path(args.root).resolve()
dataset_root = root / "detector" / "dataset"
yaml_path = dataset_root / "dataset.yaml"
@ -72,6 +74,11 @@ def main():
workers = max(0, safe_int(args.workers, 2))
patience = max(0, safe_int(args.patience, 20))
if str(args.device).lower() == "auto":
train_device = 0 if torch.cuda.is_available() else "cpu"
else:
train_device = args.device
if not yaml_path.exists():
raise SystemExit(f"dataset.yaml not found: {yaml_path}")
@ -86,7 +93,7 @@ def main():
valSamples=val_count,
epochs=epochs,
imgsz=imgsz,
device=args.device,
device=str(train_device),
)
if train_count <= 0:
@ -100,6 +107,7 @@ def main():
0.03,
"YOLO-Basismodell wird geladen…",
base=args.base,
device=str(train_device),
)
model = YOLO(args.base)
@ -119,6 +127,7 @@ def main():
epochs=total,
trainSamples=train_count,
valSamples=val_count,
device=str(train_device),
)
def on_train_epoch_end(trainer):
@ -136,12 +145,14 @@ def main():
epochs=total,
trainSamples=train_count,
valSamples=val_count,
device=str(train_device),
)
def on_fit_epoch_end(trainer):
nonlocal best_epoch
epoch = int(getattr(trainer, "epoch", 0)) + 1
total = int(getattr(trainer, "epochs", epochs) or epochs)
metrics = getattr(trainer, "metrics", None) or {}
# Ultralytics nutzt je nach Version unterschiedliche Keys.
@ -161,12 +172,13 @@ def main():
emit_progress(
"detector",
0.04 + 0.90 * (epoch / max(1, epochs)),
f"Object Detector validiert… Epoche {epoch}/{epochs}",
0.04 + 0.90 * (epoch / max(1, total)),
f"Object Detector validiert… Epoche {epoch}/{total}",
epoch=epoch,
epochs=epochs,
epochs=total,
mAP50=map50,
mAP5095=map5095,
device=str(train_device),
)
model.add_callback("on_train_epoch_start", on_train_epoch_start)
@ -180,6 +192,8 @@ def main():
trainSamples=train_count,
valSamples=val_count,
epochs=epochs,
imgsz=imgsz,
device=str(train_device),
)
result = model.train(
@ -189,7 +203,7 @@ def main():
project=str(runs_dir),
name="detect",
exist_ok=True,
device=args.device,
device=train_device,
workers=workers,
patience=patience,
)
@ -200,6 +214,7 @@ def main():
"Bestes YOLO-Modell wird übernommen…",
lastEpoch=last_epoch,
bestEpoch=best_epoch,
device=str(train_device),
)
best = runs_dir / "detect" / "weights" / "best.pt"
@ -226,7 +241,7 @@ def main():
"valSamples": val_count,
"epochs": epochs,
"imgsz": imgsz,
"device": str(args.device),
"device": str(train_device),
}
with (out_dir / "status.json").open("w", encoding="utf-8") as f:

View File

@ -205,8 +205,9 @@ def main():
continue
label = target_from_annotation(row)
if not label:
label = "unknown"
if not label or label == "unknown":
skipped += 1
continue
emb = embed_image(clip_model, processor, device, image_path)

View File

@ -82,6 +82,119 @@ type taskStateEvent struct {
TS int64 `json:"ts"`
}
type analysisProgressEvent struct {
Type string `json:"type"` // "analysis_progress"
Running bool `json:"running"`
Phase string `json:"phase,omitempty"`
Progress float64 `json:"progress"` // 0..1
Current int `json:"current,omitempty"`
Total int `json:"total,omitempty"`
File string `json:"file,omitempty"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
StartedAtMs int64 `json:"startedAtMs,omitempty"`
FinishedAtMs int64 `json:"finishedAtMs,omitempty"`
DurationMs int64 `json:"durationMs,omitempty"`
TS int64 `json:"ts"`
}
func publishAnalysisProgress(ev analysisProgressEvent) {
ev.Type = "analysis_progress"
ev.TS = time.Now().UnixMilli()
if ev.Progress < 0 {
ev.Progress = 0
}
if ev.Progress > 1 {
ev.Progress = 1
}
b, err := json.Marshal(ev)
if err != nil {
return
}
publishSSE("analysisProgress", b)
}
func publishAnalysisStarted(total int, message string) int64 {
startedAtMs := time.Now().UnixMilli()
publishAnalysisProgress(analysisProgressEvent{
Running: true,
Phase: "starting",
Progress: 0,
Current: 0,
Total: total,
Message: message,
StartedAtMs: startedAtMs,
})
return startedAtMs
}
func publishAnalysisStep(startedAtMs int64, current int, total int, file string, message string) {
progress := 0.0
if total > 0 {
progress = float64(current) / float64(total)
}
publishAnalysisProgress(analysisProgressEvent{
Running: true,
Phase: "running",
Progress: progress,
Current: current,
Total: total,
File: file,
Message: message,
StartedAtMs: startedAtMs,
})
}
func publishAnalysisFinished(startedAtMs int64, total int, message string) {
finishedAtMs := time.Now().UnixMilli()
durationMs := finishedAtMs - startedAtMs
if durationMs < 0 {
durationMs = 0
}
publishAnalysisProgress(analysisProgressEvent{
Running: false,
Phase: "done",
Progress: 1,
Current: total,
Total: total,
Message: message,
StartedAtMs: startedAtMs,
FinishedAtMs: finishedAtMs,
DurationMs: durationMs,
})
}
func publishAnalysisError(startedAtMs int64, message string, err error) {
finishedAtMs := time.Now().UnixMilli()
durationMs := finishedAtMs - startedAtMs
if durationMs < 0 {
durationMs = 0
}
errText := ""
if err != nil {
errText = err.Error()
}
publishAnalysisProgress(analysisProgressEvent{
Running: false,
Phase: "error",
Progress: 0,
Message: message,
Error: errText,
StartedAtMs: startedAtMs,
FinishedAtMs: finishedAtMs,
DurationMs: durationMs,
})
}
func publishFinishedPostworkStateForJob(
j *RecordJob,
queue string,

View File

@ -391,25 +391,44 @@ func trainingNextHandler(w http.ResponseWriter, r *http.Request) {
strings.EqualFold(r.URL.Query().Get("refresh"), "true")
if !forceNew {
if sample, ok, err := trainingLatestOpenSample(root, refreshPrediction); err != nil {
var startedAtMs int64
if refreshPrediction {
startedAtMs = publishAnalysisStarted(2, "Aktuelles Bild wird neu analysiert…")
}
if sample, ok, err := trainingLatestOpenSample(root, refreshPrediction, startedAtMs); err != nil {
if refreshPrediction {
publishAnalysisError(startedAtMs, "Aktuelles Bild konnte nicht neu analysiert werden.", err)
}
trainingWriteError(w, http.StatusInternalServerError, err.Error())
return
} else if ok {
if refreshPrediction {
publishAnalysisFinished(startedAtMs, 2, "Analyse abgeschlossen.")
}
trainingWriteJSON(w, http.StatusOK, sample)
return
}
}
sample, err := trainingCreateNextSample()
startedAtMs := publishAnalysisStarted(4, "Neues Trainingsbild wird vorbereitet…")
sample, err := trainingCreateNextSampleWithProgress(startedAtMs)
if err != nil {
publishAnalysisError(startedAtMs, "Trainingsbild konnte nicht erstellt werden.", err)
trainingWriteError(w, http.StatusInternalServerError, err.Error())
return
}
publishAnalysisFinished(startedAtMs, 4, "Analyse abgeschlossen.")
trainingWriteJSON(w, http.StatusOK, sample)
}
func trainingLatestOpenSample(root string, refreshPrediction bool) (*TrainingSample, bool, error) {
func trainingLatestOpenSample(root string, refreshPrediction bool, startedAtMs int64) (*TrainingSample, bool, error) {
answered, err := trainingAnsweredSampleIDs(root)
if err != nil {
return nil, false, err
@ -475,11 +494,6 @@ func trainingLatestOpenSample(root string, refreshPrediction bool) (*TrainingSam
continue
}
if refreshPrediction {
sample.Prediction = trainingPredictFrame(framePath)
_ = trainingWriteSample(root, sample)
}
return sample, true, nil
}
@ -603,8 +617,16 @@ func trainingFeedbackHandler(w http.ResponseWriter, r *http.Request) {
return
}
if req.Correction != nil && len(req.Correction.Boxes) > 0 {
if err := trainingWriteDetectorSample(root, sample, req.Correction.Boxes); err != nil {
detectorBoxes := []TrainingBox{}
if req.Correction != nil {
detectorBoxes = req.Correction.Boxes
} else if req.Accepted {
detectorBoxes = sample.Prediction.Boxes
}
if len(detectorBoxes) > 0 {
if err := trainingWriteDetectorSample(root, sample, detectorBoxes); err != nil {
fmt.Println("⚠️ detector sample write failed:", err)
}
}
@ -1014,7 +1036,7 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) {
// Pipeline:
// - YOLO erkennt Personen/Gender für die Counts.
// - Automatisch erkannte Personenboxen werden nicht an das Frontend als sichtbare Boxen zurückgegeben.
// - Personenboxen werden jetzt auch sichtbar zurückgegeben.
// - Manuell gezeichnete Personenboxen werden trotzdem als Trainingsdaten gespeichert.
trainingWriteJSON(w, http.StatusOK, map[string]any{
"ok": true,
@ -1742,6 +1764,84 @@ func trainingCreateNextSample() (*TrainingSample, error) {
return sample, nil
}
func trainingCreateNextSampleWithProgress(startedAtMs int64) (*TrainingSample, error) {
publishAnalysisStep(startedAtMs, 1, 4, "", "Video wird ausgewählt…")
settings := getSettings()
doneDir := strings.TrimSpace(settings.DoneDir)
if doneDir == "" {
return nil, errors.New("doneDir ist leer")
}
videoPath, err := trainingPickRandomVideo(doneDir)
if err != nil {
return nil, err
}
publishAnalysisStep(startedAtMs, 2, 4, filepath.Base(videoPath), "Frame wird extrahiert…")
duration := trainingProbeDurationSeconds(videoPath)
second := trainingRandomSecond(duration)
root, err := trainingRootDir()
if err != nil {
return nil, err
}
if err := trainingEnsureDetectorDirs(root); err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Join(root, "frames"), 0755); err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Join(root, "samples"), 0755); err != nil {
return nil, err
}
id := trainingMakeSampleID(videoPath, second)
framePath := filepath.Join(root, "frames", id+".jpg")
if err := trainingExtractFrame(videoPath, framePath, second); err != nil {
second = 0
id = trainingMakeSampleID(videoPath, second)
framePath = filepath.Join(root, "frames", id+".jpg")
if err2 := trainingExtractFrame(videoPath, framePath, second); err2 != nil {
return nil, fmt.Errorf("frame extraction failed: %v / fallback: %w", err, err2)
}
}
publishAnalysisStep(startedAtMs, 3, 4, filepath.Base(videoPath), "Frame wird analysiert…")
prediction := trainingPredictFrame(framePath)
var sourceSizeBytes int64
if st, err := os.Stat(videoPath); err == nil && st != nil && !st.IsDir() {
sourceSizeBytes = st.Size()
}
sample := &TrainingSample{
SampleID: id,
FrameURL: "/api/training/frame?id=" + id,
SourceFile: filepath.Base(videoPath),
SourcePath: videoPath,
SourceSizeBytes: sourceSizeBytes,
Second: second,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
Prediction: prediction,
}
publishAnalysisStep(startedAtMs, 4, 4, filepath.Base(videoPath), "Analyse-Ergebnis wird gespeichert…")
if err := trainingWriteSample(root, sample); err != nil {
return nil, err
}
return sample, nil
}
func trainingPickRandomVideo(doneDir string) (string, error) {
extOK := map[string]bool{
".mp4": true,

View File

@ -14,6 +14,29 @@ type ScoredLabel = {
score: number
}
type AnalysisProgressEvent = {
type?: 'analysis_progress'
running?: boolean
phase?: string
progress?: number
current?: number
total?: number
file?: string
message?: string
error?: string
startedAtMs?: number
finishedAtMs?: number
durationMs?: number
ts?: number
}
type TrainingStatus = {
feedbackCount: number
requiredCount: number
canTrain: boolean
training?: TrainingJobStatus
}
type TrainingJobStatus = {
running: boolean
progress: number
@ -28,13 +51,6 @@ type TrainingJobStatus = {
epochs?: number
}
type TrainingStatus = {
feedbackCount: number
requiredCount: number
canTrain: boolean
training?: TrainingJobStatus
}
type TrainingPrediction = {
modelAvailable: boolean
source?: string
@ -551,6 +567,15 @@ function clamp01(v: number) {
return Math.max(0, Math.min(1, v))
}
function snap01(v: number, epsilon = 0.006) {
const n = clamp01(v)
if (n <= epsilon) return 0
if (n >= 1 - epsilon) return 1
return n
}
function normalizeBox(box: TrainingBox): TrainingBox {
const x = clamp01(box.x)
const y = clamp01(box.y)
@ -601,12 +626,13 @@ function predictionToCorrection(sample: TrainingSample | null): CorrectionState
const maleCount = safeCount(p?.maleCount)
const femaleCount = safeCount(p?.femaleCount)
const unknownCount = safeCount(p?.unknownCount)
return {
peopleCount: maleCount + femaleCount,
peopleCount: maleCount + femaleCount + unknownCount,
maleCount,
femaleCount,
unknownCount: 0,
unknownCount,
sexPosition: p?.sexPosition || 'unknown',
bodyPartsPresent: (p?.bodyPartsPresent ?? []).map((x) => x.label),
objectsPresent: (p?.objectsPresent ?? []).map((x) => x.label),
@ -1099,8 +1125,12 @@ function DetectorBoxLabelSelect(props: {
const rect = button.getBoundingClientRect()
const viewportH = window.innerHeight
const viewportW = window.innerWidth
const maxHeight = Math.min(260, Math.max(140, viewportH - rect.bottom - 12))
const openUp = viewportH - rect.bottom < 180 && rect.top > rect.bottom
const spaceBelow = viewportH - rect.bottom
const spaceAbove = rect.top
const openUp = spaceBelow < 180 && spaceAbove > spaceBelow
const maxHeight = openUp
? Math.min(260, Math.max(140, spaceAbove - 12))
: Math.min(260, Math.max(140, spaceBelow - 12))
setMenuStyle({
position: 'fixed',
@ -2149,19 +2179,6 @@ export default function TrainingTab(props: {
: 'Trainingsbild wird geladen…'
)
let progressTimer: number | undefined
progressTimer = window.setInterval(() => {
setAnalysisProgress((value) => {
if (value < 35) return Math.min(35, value + 4)
if (value < 65) return Math.min(65, value + 2)
if (value < 95) return Math.min(95, value + 1)
setAnalysisStep('Analyse läuft noch… Ergebnis wird erwartet.')
return value
})
}, 350)
if (!opts?.preserveNotice) {
setError(null)
setMessage(null)
@ -2219,12 +2236,8 @@ export default function TrainingTab(props: {
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
if (progressTimer !== undefined) {
window.clearInterval(progressTimer)
}
setAnalysisProgress(100)
setAnalysisStep('Analyse abgeschlossen.')
setAnalysisProgress((value) => Math.max(value, 100))
setAnalysisStep((value) => value || 'Analyse abgeschlossen.')
window.setTimeout(() => {
setLoading(false)
@ -2305,7 +2318,46 @@ export default function TrainingTab(props: {
}
}
const onAnalysisProgress = (ev: MessageEvent) => {
try {
const data = JSON.parse(String(ev.data ?? 'null')) as AnalysisProgressEvent
if (data?.type !== 'analysis_progress') return
const progress = clampPercent(Number(data.progress ?? 0) * 100)
const message = String(data.message || '').trim()
setAnalysisProgress(progress)
if (message) {
setAnalysisStep(message)
}
if (data.running) {
setLoading(true)
}
if (!data.running && data.phase === 'error') {
setLoading(false)
setAnalysisProgress(0)
setAnalysisStep('')
if (data.error || data.message) {
setError(String(data.error || data.message))
}
}
if (!data.running && data.phase === 'done') {
setAnalysisProgress(100)
setAnalysisStep(message || 'Analyse abgeschlossen.')
}
} catch {
// ignore
}
}
es.addEventListener('training', onTraining as EventListener)
es.addEventListener('analysisProgress', onAnalysisProgress as EventListener)
es.onerror = () => {
// Optional: Polling-Fallback bleibt separat bestehen.
@ -2313,6 +2365,7 @@ export default function TrainingTab(props: {
return () => {
es.removeEventListener('training', onTraining as EventListener)
es.removeEventListener('analysisProgress', onAnalysisProgress as EventListener)
es.close()
}
}, [applyTrainingStatus])
@ -2516,12 +2569,16 @@ export default function TrainingTab(props: {
setMessage(null)
try {
const maleCount = safeCount(correction.maleCount)
const femaleCount = safeCount(correction.femaleCount)
const unknownCount = safeCount(correction.unknownCount)
const correctionPayload: CorrectionState = {
...correction,
peopleCount:
safeCount(correction.maleCount) +
safeCount(correction.femaleCount),
unknownCount: 0,
maleCount,
femaleCount,
unknownCount,
peopleCount: maleCount + femaleCount + unknownCount,
boxes: (correction.boxes ?? [])
.map(normalizeBox)
.filter((box) => box.label && box.w > 0 && box.h > 0),
@ -2741,10 +2798,17 @@ export default function TrainingTab(props: {
let x2 = original.x + original.w
let y2 = original.y + original.h
if (boxInteraction.handle.includes('n')) y1 = clamp01(y1 + dy)
if (boxInteraction.handle.includes('s')) y2 = clamp01(y2 + dy)
if (boxInteraction.handle.includes('w')) x1 = clamp01(x1 + dx)
if (boxInteraction.handle.includes('e')) x2 = clamp01(x2 + dx)
const pointerX = snap01(clampedPos.x)
const pointerY = snap01(clampedPos.y)
// Wichtig:
// Beim Resize folgt die gezogene Ecke direkt dem Pointer.
// Dadurch bleibt kein Grab-Offset übrig, wenn der Handle nicht exakt
// auf der mathematischen Box-Ecke getroffen wurde.
if (boxInteraction.handle.includes('n')) y1 = pointerY
if (boxInteraction.handle.includes('s')) y2 = pointerY
if (boxInteraction.handle.includes('w')) x1 = pointerX
if (boxInteraction.handle.includes('e')) x2 = pointerX
const left = Math.min(x1, x2)
const top = Math.min(y1, y2)