diff --git a/backend/ml/train_detector_model.py b/backend/ml/train_detector_model.py index 209e49e..08cf5c9 100644 --- a/backend/ml/train_detector_model.py +++ b/backend/ml/train_detector_model.py @@ -37,7 +37,8 @@ def count_yolo_samples(dataset_root: Path, split: str) -> int: continue label_path = labels_dir / f"{image_path.stem}.txt" - if label_path.exists() and label_path.stat().st_size > 0: + # Eine vorhandene, leere Labeldatei ist ein gültiges YOLO-Negativbeispiel. + if label_path.exists() and label_path.is_file(): count += 1 return count @@ -258,4 +259,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/backend/training.go b/backend/training.go index af52cee..17f641a 100644 --- a/backend/training.go +++ b/backend/training.go @@ -99,6 +99,7 @@ type trainingUncertainCandidate struct { type TrainingFeedbackRequest struct { SampleID string `json:"sampleId"` Accepted bool `json:"accepted"` + Negative bool `json:"negative,omitempty"` Correction *TrainingCorrection `json:"correction,omitempty"` Notes string `json:"notes,omitempty"` } @@ -107,6 +108,7 @@ type TrainingFeedbackUpdateRequest struct { SampleID string `json:"sampleId"` AnsweredAt string `json:"answeredAt"` Accepted bool `json:"accepted"` + Negative bool `json:"negative,omitempty"` Correction *TrainingCorrection `json:"correction,omitempty"` Notes string `json:"notes,omitempty"` } @@ -126,6 +128,7 @@ type TrainingAnnotation struct { AnsweredAt string `json:"answeredAt"` Prediction TrainingPrediction `json:"prediction"` Accepted bool `json:"accepted"` + Negative bool `json:"negative,omitempty"` Correction *TrainingCorrection `json:"correction,omitempty"` Notes string `json:"notes,omitempty"` } @@ -176,6 +179,7 @@ type TrainingStatsResponse struct { FeedbackCount int `json:"feedbackCount"` AcceptedCount int `json:"acceptedCount"` CorrectedCount int `json:"correctedCount"` + NegativeCount int `json:"negativeCount"` SampleCount int `json:"sampleCount"` BoxCount int `json:"boxCount"` ModelAvailable bool `json:"modelAvailable"` @@ -333,12 +337,17 @@ func trainingFilterAnnotations( for _, item := range items { switch cleanFilter { case "accepted": - if !item.Accepted { + if !item.Accepted || item.Negative { continue } case "corrected": - if item.Accepted { + if item.Accepted || item.Negative { + continue + } + + case "negative": + if !item.Negative { continue } } @@ -365,6 +374,9 @@ func trainingAnnotationMatchesQuery(item TrainingAnnotation, cleanQuery string) item.Notes, effective.SexPosition, } + if item.Negative { + parts = append(parts, "negative negativ leer keine labels") + } parts = append(parts, effective.PeoplePresent...) parts = append(parts, effective.BodyPartsPresent...) @@ -1875,6 +1887,10 @@ func trainingFrameHandler(w http.ResponseWriter, r *http.Request) { } func trainingDetectorBoxesForAnnotation(sample *TrainingSample, req TrainingFeedbackRequest) []TrainingBox { + if req.Negative { + return []TrainingBox{} + } + boxes := []TrainingBox{} if req.Correction != nil { @@ -1905,6 +1921,17 @@ func trainingDetectorBoxesForAnnotation(sample *TrainingSample, req TrainingFeed return boxes } +func trainingNegativeCorrection() *TrainingCorrection { + return &TrainingCorrection{ + SexPosition: "unknown", + PeoplePresent: []string{}, + BodyPartsPresent: []string{}, + ObjectsPresent: []string{}, + ClothingPresent: []string{}, + Boxes: []TrainingBox{}, + } +} + func trainingFeedbackHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") @@ -1922,6 +1949,10 @@ func trainingFeedbackHandler(w http.ResponseWriter, r *http.Request) { trainingWriteError(w, http.StatusBadRequest, "sampleId missing") return } + if req.Negative { + req.Accepted = false + req.Correction = trainingNegativeCorrection() + } root, err := trainingRootDir() if err != nil { @@ -1946,6 +1977,7 @@ func trainingFeedbackHandler(w http.ResponseWriter, r *http.Request) { AnsweredAt: time.Now().UTC().Format(time.RFC3339), Prediction: sample.Prediction, Accepted: req.Accepted, + Negative: req.Negative, Correction: req.Correction, Notes: strings.TrimSpace(req.Notes), } @@ -1962,8 +1994,8 @@ func trainingFeedbackHandler(w http.ResponseWriter, r *http.Request) { detectorBoxes := trainingDetectorBoxesForAnnotation(sample, req) - if len(detectorBoxes) > 0 { - if err := trainingWriteDetectorSample(root, sample, detectorBoxes); err != nil { + if req.Negative || len(detectorBoxes) > 0 { + if err := trainingWriteDetectorSample(root, sample, detectorBoxes, req.Negative); err != nil { appLogln("⚠️ detector sample write failed:", err) } } @@ -1987,6 +2019,10 @@ func trainingFeedbackUpdateHandler(w http.ResponseWriter, r *http.Request) { req.SampleID = strings.TrimSpace(req.SampleID) req.AnsweredAt = strings.TrimSpace(req.AnsweredAt) + if req.Negative { + req.Accepted = false + req.Correction = trainingNegativeCorrection() + } if req.SampleID == "" { trainingWriteError(w, http.StatusBadRequest, "sampleId missing") @@ -2039,6 +2075,7 @@ func trainingFeedbackUpdateHandler(w http.ResponseWriter, r *http.Request) { updated := old updated.Accepted = req.Accepted + updated.Negative = req.Negative updated.Notes = strings.TrimSpace(req.Notes) if req.Accepted { @@ -2073,12 +2110,13 @@ func trainingFeedbackUpdateHandler(w http.ResponseWriter, r *http.Request) { detectorBoxes := trainingDetectorBoxesForAnnotation(sample, TrainingFeedbackRequest{ SampleID: req.SampleID, Accepted: req.Accepted, + Negative: req.Negative, Correction: req.Correction, Notes: req.Notes, }) - if len(detectorBoxes) > 0 { - if err := trainingWriteDetectorSample(root, sample, detectorBoxes); err != nil { + if req.Negative || len(detectorBoxes) > 0 { + if err := trainingWriteDetectorSample(root, sample, detectorBoxes, req.Negative); err != nil { appLogln("⚠️ detector sample update failed:", err) } } @@ -2161,7 +2199,7 @@ func trainingHasDetectorTrainingData(imagesDir string, labelsDir string) bool { id := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name())) labelPath := filepath.Join(labelsDir, id+".txt") - if fileExistsNonEmpty(labelPath) { + if fileExists(labelPath) { count++ } } @@ -2227,17 +2265,23 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) { trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels) valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels) + positiveTrainCount := trainingCountPositiveDetectorSamples(detectorTrainImages, detectorTrainLabels) + positiveValCount := trainingCountPositiveDetectorSamples(detectorValImages, detectorValLabels) if !fileExistsNonEmpty(detectorDatasetYAML) || trainCount < minDetectorTrainCount || - valCount < minDetectorValCount { + valCount < minDetectorValCount || + positiveTrainCount == 0 || + positiveValCount == 0 { trainingWriteError( w, http.StatusBadRequest, fmt.Sprintf( - "Zu wenige YOLO26-Box-Labels. Train=%d, Val=%d. Benötigt: mindestens %d Train und %d Val.", + "Zu wenige YOLO26-Beispiele. Train=%d (%d positiv), Val=%d (%d positiv). Benötigt: mindestens %d Train, %d Val und je ein positives Beispiel.", trainCount, + positiveTrainCount, valCount, + positiveValCount, minDetectorTrainCount, minDetectorValCount, ), @@ -2256,15 +2300,17 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) { "message": "Training gestartet.", "training": trainingGetJobStatus(), "detector": map[string]any{ - "trainCount": trainCount, - "valCount": valCount, - "requiredTrain": minDetectorTrainCount, - "requiredVal": minDetectorValCount, - "datasetYAML": detectorDatasetYAML, - "usesSceneCLIP": false, - "usesSceneKNN": false, - "source": "yolo26_detector", - "detectsPosition": true, + "trainCount": trainCount, + "valCount": valCount, + "positiveTrainCount": positiveTrainCount, + "positiveValCount": positiveValCount, + "requiredTrain": minDetectorTrainCount, + "requiredVal": minDetectorValCount, + "datasetYAML": detectorDatasetYAML, + "usesSceneCLIP": false, + "usesSceneKNN": false, + "source": "yolo26_detector", + "detectsPosition": true, }, }) } @@ -2337,17 +2383,23 @@ func trainingRunJob(ctx context.Context, root string, count int) { trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels) valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels) + positiveTrainCount := trainingCountPositiveDetectorSamples(detectorTrainImages, detectorTrainLabels) + positiveValCount := trainingCountPositiveDetectorSamples(detectorValImages, detectorValLabels) fmt.Printf( - "🔎 detector data: train=%d val=%d yaml=%v\n", + "🔎 detector data: train=%d (%d positive) val=%d (%d positive) yaml=%v\n", trainCount, + positiveTrainCount, valCount, + positiveValCount, fileExistsNonEmpty(detectorDatasetYAML), ) if fileExistsNonEmpty(detectorDatasetYAML) && trainCount >= minDetectorTrainCount && - valCount >= minDetectorValCount { + valCount >= minDetectorValCount && + positiveTrainCount > 0 && + positiveValCount > 0 { trainingSetJobStatus(func(s *TrainingJobStatus) { s.Progress = 15 s.Step = "YOLO26 Detector wird trainiert…" @@ -2398,9 +2450,11 @@ func trainingRunJob(ctx context.Context, root string, count int) { } else { detectorStatus = "skipped_no_detector_data" detectorOutput = fmt.Sprintf( - "YOLO26 Detector übersprungen: zu wenige Box-Labels. Train=%d, Val=%d. Benötigt: mindestens %d Train und %d Val.", + "YOLO26 Detector übersprungen: zu wenige Beispiele. Train=%d (%d positiv), Val=%d (%d positiv). Benötigt: mindestens %d Train, %d Val und je ein positives Beispiel.", trainCount, + positiveTrainCount, valCount, + positiveValCount, minDetectorTrainCount, minDetectorValCount, ) @@ -2545,7 +2599,9 @@ func trainingBuildStats(root string) (*TrainingStatsResponse, error) { stats.FeedbackCount++ - if annotation.Accepted { + if annotation.Negative { + stats.NegativeCount++ + } else if annotation.Accepted { stats.AcceptedCount++ } else { stats.CorrectedCount++ @@ -2643,6 +2699,10 @@ func trainingBuildStats(root string) (*TrainingStatsResponse, error) { } func trainingEffectiveCorrection(annotation TrainingAnnotation) TrainingCorrection { + if annotation.Negative { + return *trainingNegativeCorrection() + } + if annotation.Correction != nil { return *annotation.Correction } @@ -2761,11 +2821,15 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) { trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels) valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels) + positiveTrainCount := trainingCountPositiveDetectorSamples(detectorTrainImages, detectorTrainLabels) + positiveValCount := trainingCountPositiveDetectorSamples(detectorValImages, detectorValLabels) datasetReady := fileExistsNonEmpty(detectorDatasetYAML) detectorDataReady := datasetReady && trainCount >= minDetectorTrainCount && - valCount >= minDetectorValCount + valCount >= minDetectorValCount && + positiveTrainCount > 0 && + positiveValCount > 0 canTrain := feedbackCount >= minTrainingFeedbackCount && detectorDataReady @@ -2791,10 +2855,12 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) { "detectsClothing": true, "detectsBoxes": true, - "trainCount": trainCount, - "valCount": valCount, - "requiredTrain": minDetectorTrainCount, - "requiredVal": minDetectorValCount, + "trainCount": trainCount, + "valCount": valCount, + "positiveTrainCount": positiveTrainCount, + "positiveValCount": positiveValCount, + "requiredTrain": minDetectorTrainCount, + "requiredVal": minDetectorValCount, "datasetReady": datasetReady, "datasetYAML": detectorDatasetYAML, @@ -2955,16 +3021,17 @@ func trainingOverallConfidence( // Viele "Passt so"-Antworten bedeuten, dass die Vorhersagen brauchbar sind. // Bei 4/229 ist dieser Teil bewusst sehr niedrig. agreementScore := 0.0 - if feedbackCount > 0 { - agreementScore = clamp01(float64(acceptedCount) / float64(feedbackCount)) + decisionCount := acceptedCount + correctedCount + if decisionCount > 0 { + agreementScore = clamp01(float64(acceptedCount) / float64(decisionCount)) } // Korrekturquote als zusätzlicher Dämpfer. // 98% korrigiert soll die Gesamt-Confidence sichtbar drücken, // aber nicht alle gesammelten Daten entwerten. correctionRate := 0.0 - if feedbackCount > 0 { - correctionRate = clamp01(float64(correctedCount) / float64(feedbackCount)) + if decisionCount > 0 { + correctionRate = clamp01(float64(correctedCount) / float64(decisionCount)) } correctionPenalty := 1.0 - math.Min(0.45, correctionRate*0.45) @@ -3065,6 +3132,42 @@ func trainingCountDetectorSamples(imagesDir string, labelsDir string) int { count := 0 + for _, e := range entries { + if e.IsDir() { + continue + } + + ext := strings.ToLower(filepath.Ext(e.Name())) + if !imageExts[ext] { + continue + } + + id := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name())) + labelPath := filepath.Join(labelsDir, id+".txt") + + if fileExists(labelPath) { + count++ + } + } + + return count +} + +func trainingCountPositiveDetectorSamples(imagesDir string, labelsDir string) int { + imageExts := map[string]bool{ + ".jpg": true, + ".jpeg": true, + ".png": true, + ".webp": true, + } + + entries, err := os.ReadDir(imagesDir) + if err != nil { + return 0 + } + + count := 0 + for _, e := range entries { if e.IsDir() { continue @@ -4057,39 +4160,11 @@ func trainingApplyDetectorToPrediction(pred TrainingPrediction, det TrainingDete return pred } -func trainingWriteDetectorSample(root string, sample *TrainingSample, boxes []TrainingBox) error { - if sample == nil { - return errors.New("sample missing") - } - - classMap, err := trainingDetectorClassMap() - if err != nil { - return err - } - - srcFrame := filepath.Join(root, "frames", sample.SampleID+".jpg") - if _, err := os.Stat(srcFrame); err != nil { - return appErrorf("frame missing: %w", err) - } - - // Stabiler 80/20 Split: gleicher sampleID landet immer im gleichen Split. - split := trainingStableSplit(sample.SampleID) - - imgDir := filepath.Join(root, "detector", "dataset", "images", split) - lblDir := filepath.Join(root, "detector", "dataset", "labels", split) - - if err := os.MkdirAll(imgDir, 0755); err != nil { - return err - } - if err := os.MkdirAll(lblDir, 0755); err != nil { - return err - } - - dstFrame := filepath.Join(imgDir, sample.SampleID+".jpg") - if err := copyFile(srcFrame, dstFrame); err != nil { - return err - } - +func trainingDetectorLabelContent( + boxes []TrainingBox, + classMap map[string]int, + allowEmpty bool, +) ([]byte, error) { var lines []string for _, box := range boxes { @@ -4128,11 +4203,60 @@ func trainingWriteDetectorSample(root string, sample *TrainingSample, boxes []Tr } if len(lines) == 0 { - return errors.New("no valid detector boxes") + if allowEmpty { + return []byte{}, nil + } + return nil, errors.New("no valid detector boxes") + } + + return []byte(strings.Join(lines, "\n") + "\n"), nil +} + +func trainingWriteDetectorSample( + root string, + sample *TrainingSample, + boxes []TrainingBox, + allowEmpty bool, +) error { + if sample == nil { + return errors.New("sample missing") + } + + classMap, err := trainingDetectorClassMap() + if err != nil { + return err + } + + labelContent, err := trainingDetectorLabelContent(boxes, classMap, allowEmpty) + if err != nil { + return err + } + + srcFrame := filepath.Join(root, "frames", sample.SampleID+".jpg") + if _, err := os.Stat(srcFrame); err != nil { + return appErrorf("frame missing: %w", err) + } + + // Stabiler 80/20 Split: gleicher sampleID landet immer im gleichen Split. + split := trainingStableSplit(sample.SampleID) + + imgDir := filepath.Join(root, "detector", "dataset", "images", split) + lblDir := filepath.Join(root, "detector", "dataset", "labels", split) + + if err := os.MkdirAll(imgDir, 0755); err != nil { + return err + } + if err := os.MkdirAll(lblDir, 0755); err != nil { + return err + } + + dstFrame := filepath.Join(imgDir, sample.SampleID+".jpg") + if err := copyFile(srcFrame, dstFrame); err != nil { + return err } labelPath := filepath.Join(lblDir, sample.SampleID+".txt") - return os.WriteFile(labelPath, []byte(strings.Join(lines, "\n")+"\n"), 0644) + return os.WriteFile(labelPath, labelContent, 0644) } func trainingDeleteDetectorSample(root string, sampleID string) { @@ -4160,11 +4284,13 @@ func trainingEnsureDetectorValidationSample(root string) error { valLabels := filepath.Join(root, "detector", "dataset", "labels", "val") currentVal := trainingCountDetectorSamples(valImages, valLabels) - if currentVal >= minDetectorValCount { + currentPositiveVal := trainingCountPositiveDetectorSamples(valImages, valLabels) + if currentVal >= minDetectorValCount && currentPositiveVal > 0 { return nil } - if trainingCountDetectorSamples(trainImages, trainLabels) < minDetectorTrainCount { + if trainingCountDetectorSamples(trainImages, trainLabels) < minDetectorTrainCount || + trainingCountPositiveDetectorSamples(trainImages, trainLabels) == 0 { return nil } @@ -4181,10 +4307,19 @@ func trainingEnsureDetectorValidationSample(root string) error { } copied := 0 - needed := minDetectorValCount - currentVal + needed := max(0, minDetectorValCount-currentVal) + needsPositive := currentPositiveVal == 0 + + sort.SliceStable(entries, func(i, j int) bool { + iID := strings.TrimSuffix(entries[i].Name(), filepath.Ext(entries[i].Name())) + jID := strings.TrimSuffix(entries[j].Name(), filepath.Ext(entries[j].Name())) + + return fileExistsNonEmpty(filepath.Join(trainLabels, iID+".txt")) && + !fileExistsNonEmpty(filepath.Join(trainLabels, jID+".txt")) + }) for _, e := range entries { - if copied >= needed { + if copied >= needed && !needsPositive { break } @@ -4202,14 +4337,14 @@ func trainingEnsureDetectorValidationSample(root string) error { srcImage := filepath.Join(trainImages, e.Name()) srcLabel := filepath.Join(trainLabels, id+".txt") - if !fileExistsNonEmpty(srcImage) || !fileExistsNonEmpty(srcLabel) { + if !fileExistsNonEmpty(srcImage) || !fileExists(srcLabel) { continue } dstImage := filepath.Join(valImages, e.Name()) dstLabel := filepath.Join(valLabels, id+".txt") - if fileExistsNonEmpty(dstImage) && fileExistsNonEmpty(dstLabel) { + if fileExistsNonEmpty(dstImage) && fileExists(dstLabel) { continue } @@ -4221,6 +4356,9 @@ func trainingEnsureDetectorValidationSample(root string) error { } copied++ + if fileExistsNonEmpty(srcLabel) { + needsPositive = false + } } return nil diff --git a/backend/training_test.go b/backend/training_test.go new file mode 100644 index 0000000..603dd1f --- /dev/null +++ b/backend/training_test.go @@ -0,0 +1,133 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestTrainingDetectorLabelContentAllowsExplicitNegative(t *testing.T) { + content, err := trainingDetectorLabelContent(nil, map[string]int{"person": 0}, true) + if err != nil { + t.Fatalf("negative label content returned error: %v", err) + } + if len(content) != 0 { + t.Fatalf("negative label content = %q, want empty", string(content)) + } +} + +func TestTrainingDetectorLabelContentRejectsAccidentalEmptySample(t *testing.T) { + _, err := trainingDetectorLabelContent( + []TrainingBox{{Label: "unknown_label", X: 0, Y: 0, W: 1, H: 1}}, + map[string]int{"person": 0}, + false, + ) + if err == nil { + t.Fatal("invalid non-negative sample should be rejected") + } +} + +func TestTrainingDetectorLabelContentWritesYOLOBox(t *testing.T) { + content, err := trainingDetectorLabelContent( + []TrainingBox{{Label: "person", X: 0.1, Y: 0.2, W: 0.4, H: 0.6}}, + map[string]int{"person": 3}, + false, + ) + if err != nil { + t.Fatalf("positive label content returned error: %v", err) + } + + got := strings.TrimSpace(string(content)) + want := "3 0.300000 0.500000 0.400000 0.600000" + if got != want { + t.Fatalf("label content = %q, want %q", got, want) + } +} + +func TestTrainingCountDetectorSamplesIncludesEmptyLabels(t *testing.T) { + root := t.TempDir() + imagesDir := filepath.Join(root, "images") + labelsDir := filepath.Join(root, "labels") + + if err := os.MkdirAll(imagesDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(labelsDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(imagesDir, "negative.jpg"), []byte("image"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(labelsDir, "negative.txt"), []byte{}, 0644); err != nil { + t.Fatal(err) + } + + if got := trainingCountDetectorSamples(imagesDir, labelsDir); got != 1 { + t.Fatalf("sample count = %d, want 1", got) + } + if got := trainingCountPositiveDetectorSamples(imagesDir, labelsDir); got != 0 { + t.Fatalf("positive sample count = %d, want 0", got) + } +} + +func TestTrainingEnsureDetectorValidationSampleIncludesPositiveExample(t *testing.T) { + root := t.TempDir() + trainImages := filepath.Join(root, "detector", "dataset", "images", "train") + trainLabels := filepath.Join(root, "detector", "dataset", "labels", "train") + + if err := os.MkdirAll(trainImages, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(trainLabels, 0755); err != nil { + t.Fatal(err) + } + + for i := 0; i < minDetectorTrainCount; i++ { + id := fmt.Sprintf("sample-%02d", i) + if err := os.WriteFile(filepath.Join(trainImages, id+".jpg"), []byte("image"), 0644); err != nil { + t.Fatal(err) + } + + label := []byte{} + if i == minDetectorTrainCount-1 { + label = []byte("0 0.5 0.5 1 1\n") + } + if err := os.WriteFile(filepath.Join(trainLabels, id+".txt"), label, 0644); err != nil { + t.Fatal(err) + } + } + + if err := trainingEnsureDetectorValidationSample(root); err != nil { + t.Fatal(err) + } + + valImages := filepath.Join(root, "detector", "dataset", "images", "val") + valLabels := filepath.Join(root, "detector", "dataset", "labels", "val") + if got := trainingCountDetectorSamples(valImages, valLabels); got < minDetectorValCount { + t.Fatalf("validation sample count = %d, want at least %d", got, minDetectorValCount) + } + if got := trainingCountPositiveDetectorSamples(valImages, valLabels); got < 1 { + t.Fatalf("positive validation sample count = %d, want at least 1", got) + } +} + +func TestTrainingEffectiveCorrectionClearsNegativeAnnotation(t *testing.T) { + effective := trainingEffectiveCorrection(TrainingAnnotation{ + Negative: true, + Prediction: TrainingPrediction{ + SexPosition: "doggy", + Boxes: []TrainingBox{ + {Label: "person", X: 0, Y: 0, W: 1, H: 1}, + }, + }, + }) + + if effective.SexPosition != "unknown" { + t.Fatalf("sex position = %q, want unknown", effective.SexPosition) + } + if len(effective.Boxes) != 0 { + t.Fatalf("negative annotation has %d boxes, want 0", len(effective.Boxes)) + } +} diff --git a/frontend/src/components/ui/TrainingFeedbackHistoryModal.tsx b/frontend/src/components/ui/TrainingFeedbackHistoryModal.tsx index 5c85c47..3c1a468 100644 --- a/frontend/src/components/ui/TrainingFeedbackHistoryModal.tsx +++ b/frontend/src/components/ui/TrainingFeedbackHistoryModal.tsx @@ -67,11 +67,12 @@ export type TrainingFeedbackAnnotation = { answeredAt: string prediction: TrainingFeedbackPrediction accepted: boolean + negative?: boolean correction?: TrainingFeedbackCorrection notes?: string } -type FeedbackFilter = 'all' | 'accepted' | 'corrected' +type FeedbackFilter = 'all' | 'accepted' | 'corrected' | 'negative' function clamp01(value: number) { if (!Number.isFinite(value)) return 0 @@ -101,6 +102,17 @@ function normalizeBox(box: TrainingFeedbackBox): TrainingFeedbackBox { function annotationEffectiveCorrection( annotation: TrainingFeedbackAnnotation ): TrainingFeedbackCorrection { + if (annotation.negative) { + return { + sexPosition: 'unknown', + peoplePresent: [], + bodyPartsPresent: [], + objectsPresent: [], + clothingPresent: [], + boxes: [], + } + } + if (annotation.correction) { return annotation.correction } @@ -196,13 +208,18 @@ function frameUrlForHistory(item?: TrainingFeedbackAnnotation | null) { return `${item.frameUrl}${separator}history=1&t=${encodeURIComponent(item.sampleId)}` } -function feedbackStatusClass(accepted: boolean) { +function feedbackStatusClass(accepted: boolean, negative?: boolean) { + if (negative) { + return 'bg-blue-50 text-blue-800 ring-blue-200 dark:bg-blue-500/15 dark:text-blue-100 dark:ring-blue-400/30' + } + return accepted ? 'bg-emerald-50 text-emerald-800 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-400/30' : 'bg-amber-50 text-amber-800 ring-amber-200 dark:bg-amber-500/15 dark:text-amber-100 dark:ring-amber-400/30' } -function feedbackStatusText(accepted: boolean) { +function feedbackStatusText(accepted: boolean, negative?: boolean) { + if (negative) return 'Negativbeispiel' return accepted ? 'Passt so' : 'Korrigiert' } @@ -212,22 +229,31 @@ function labelText(value: string) { function StatusBadge(props: { accepted: boolean + negative?: boolean short?: boolean }) { return ( - {props.accepted ? ( + {props.negative ? ( + ) } @@ -646,7 +672,11 @@ function FeedbackListItem(props: { - +
@@ -792,7 +822,10 @@ function CompactSourceCard(props: {
- +
@@ -850,7 +883,11 @@ function ResultLabelsCard(props: {
- +
@@ -926,8 +963,9 @@ export default function TrainingFeedbackHistoryModal(props: { const effective = selected ? annotationEffectiveCorrection(selected) : null const selectedImageSrc = frameUrlForHistory(selected) - const acceptedCount = props.items.filter((item) => item.accepted).length - const correctedCount = props.items.filter((item) => !item.accepted).length + const acceptedCount = props.items.filter((item) => item.accepted && !item.negative).length + const correctedCount = props.items.filter((item) => !item.accepted && !item.negative).length + const negativeCount = props.items.filter((item) => item.negative).length const shownCount = props.items.length const selectedBoxCount = effective?.boxes?.length ?? 0 @@ -941,8 +979,9 @@ export default function TrainingFeedbackHistoryModal(props: { return props.items .map((item, index) => ({ item, index })) .filter(({ item }) => { - if (filter === 'accepted' && !item.accepted) return false - if (filter === 'corrected' && item.accepted) return false + if (filter === 'accepted' && (!item.accepted || item.negative)) return false + if (filter === 'corrected' && (item.accepted || item.negative)) return false + if (filter === 'negative' && !item.negative) return false if (!cleanQuery) return true @@ -953,6 +992,7 @@ export default function TrainingFeedbackHistoryModal(props: { item.sourceFile, item.sourcePath, item.notes, + item.negative ? 'negative negativ leer keine labels' : '', effectiveItem.sexPosition, ...effectiveItem.peoplePresent, ...effectiveItem.bodyPartsPresent, @@ -1010,7 +1050,7 @@ export default function TrainingFeedbackHistoryModal(props: {
-
+
@@ -1119,7 +1167,7 @@ export default function TrainingFeedbackHistoryModal(props: { /> -
+
setFilter('all')} @@ -1140,6 +1188,13 @@ export default function TrainingFeedbackHistoryModal(props: { > Korrigiert + + setFilter('negative')} + > + Negativ +
@@ -1223,7 +1278,10 @@ export default function TrainingFeedbackHistoryModal(props: { ← Zurück - + {props.onEditItem ? ( @@ -1275,4 +1333,4 @@ export default function TrainingFeedbackHistoryModal(props: { ) -} \ No newline at end of file +} diff --git a/frontend/src/components/ui/TrainingTab.tsx b/frontend/src/components/ui/TrainingTab.tsx index 5530974..543d413 100644 --- a/frontend/src/components/ui/TrainingTab.tsx +++ b/frontend/src/components/ui/TrainingTab.tsx @@ -36,6 +36,8 @@ type ScoredLabel = { type TrainingDetectorStatus = { trainCount: number valCount: number + positiveTrainCount: number + positiveValCount: number requiredTrain: number requiredVal: number datasetReady: boolean @@ -166,6 +168,7 @@ type TrainingStats = { feedbackCount: number acceptedCount: number correctedCount: number + negativeCount: number sampleCount: number boxCount: number modelAvailable: boolean @@ -190,6 +193,7 @@ type TrainingAnnotation = { answeredAt: string prediction: TrainingPrediction accepted: boolean + negative?: boolean correction?: CorrectionState notes?: string } @@ -204,7 +208,7 @@ type TrainingFeedbackListResponse = { } type TrainingSampleMode = 'random' | 'uncertain' -type FeedbackFilter = 'all' | 'accepted' | 'corrected' +type FeedbackFilter = 'all' | 'accepted' | 'corrected' | 'negative' function backendText(data: any, fallback: string) { return String( @@ -587,6 +591,17 @@ function predictionToCorrection(sample: TrainingSample | null): CorrectionState } } +function correctionHasRelevantContent(value: CorrectionState) { + return ( + (value.sexPosition && value.sexPosition !== 'unknown') || + (value.peoplePresent?.length ?? 0) > 0 || + (value.bodyPartsPresent?.length ?? 0) > 0 || + (value.objectsPresent?.length ?? 0) > 0 || + (value.clothingPresent?.length ?? 0) > 0 || + (value.boxes?.length ?? 0) > 0 + ) +} + function applyBoxLabelToCorrection( state: CorrectionState, label: string, @@ -1585,6 +1600,7 @@ function TrainingStatsModal(props: { const acceptedCount = stats?.acceptedCount ?? 0 const correctedCount = stats?.correctedCount ?? 0 + const negativeCount = stats?.negativeCount ?? 0 const totalFeedback = stats?.feedbackCount ?? props.feedbackCount const boxCount = stats?.boxCount ?? 0 const sampleCount = stats?.sampleCount ?? 0 @@ -1717,7 +1733,7 @@ function TrainingStatsModal(props: { /> -
+
Feedback @@ -1745,6 +1761,15 @@ function TrainingStatsModal(props: {
+
+
+ Negativ +
+
+ {negativeCount} +
+
+
Boxen @@ -1758,7 +1783,7 @@ function TrainingStatsModal(props: {
{/* Desktop/Tablet: ausführliche Karten */} -
+
Feedback @@ -1795,6 +1820,18 @@ function TrainingStatsModal(props: {
+
+
+ Negativ +
+
+ {negativeCount} +
+
+ {countPercent(negativeCount, totalFeedback)} +
+
+
Boxen @@ -1974,6 +2011,17 @@ function annotationToTrainingSample(item: TrainingAnnotation): TrainingSample { } function annotationToCorrectionState(item: TrainingAnnotation): CorrectionState { + if (item.negative) { + return { + sexPosition: 'unknown', + peoplePresent: [], + bodyPartsPresent: [], + objectsPresent: [], + clothingPresent: [], + boxes: [], + } + } + if (item.correction) { return item.correction } @@ -1993,6 +2041,7 @@ export default function TrainingTab(props: { const [sample, setSample] = useState(null) const [correction, setCorrection] = useState(() => predictionToCorrection(null)) const [hasManualCorrection, setHasManualCorrection] = useState(false) + const [negativeExample, setNegativeExample] = useState(false) const [loading, setLoading] = useState(false) const [analysisProgress, setAnalysisProgress] = useState(0) const [analysisStep, setAnalysisStep] = useState('') @@ -2181,6 +2230,7 @@ export default function TrainingTab(props: { setSample(annotationToTrainingSample(item)) setCorrection(annotationToCorrectionState(item)) setHasManualCorrection(!item.accepted) + setNegativeExample(Boolean(item.negative)) setEditingFeedback({ sampleId: item.sampleId, @@ -2419,6 +2469,8 @@ export default function TrainingTab(props: { }, [labels.people, labels.bodyParts, labels.objects, labels.clothing]) const correctionBoxes = correction.boxes ?? [] + const isNegativeCorrection = + negativeExample && !correctionHasRelevantContent(correction) const visibleBoxes = [ ...correctionBoxes.map((box, index) => ({ box, index, isDraft: false })), @@ -2629,6 +2681,8 @@ export default function TrainingTab(props: { ? { trainCount: Number(data.detector.trainCount ?? 0), valCount: Number(data.detector.valCount ?? 0), + positiveTrainCount: Number(data.detector.positiveTrainCount ?? 0), + positiveValCount: Number(data.detector.positiveValCount ?? 0), requiredTrain: Number(data.detector.requiredTrain ?? 20), requiredVal: Number(data.detector.requiredVal ?? 3), datasetReady: Boolean(data.detector.datasetReady), @@ -2714,6 +2768,7 @@ export default function TrainingTab(props: { setSample(nextSample) setCorrection(nextCorrection) setHasManualCorrection(Boolean(opts?.manualCorrection)) + setNegativeExample(false) const initiallyExpandedSection: CorrectionSectionKey | null = nextCorrection.sexPosition && nextCorrection.sexPosition !== 'unknown' @@ -2998,6 +3053,7 @@ export default function TrainingTab(props: { feedbackCount: Number(data?.feedbackCount ?? 0), acceptedCount: Number(data?.acceptedCount ?? 0), correctedCount: Number(data?.correctedCount ?? 0), + negativeCount: Number(data?.negativeCount ?? 0), sampleCount: Number(data?.sampleCount ?? 0), boxCount: Number(data?.boxCount ?? 0), modelAvailable: Boolean(data?.modelAvailable), @@ -3437,7 +3493,12 @@ export default function TrainingTab(props: { ]) const saveFeedback = useCallback( - async (accepted: boolean) => { + async ( + accepted: boolean, + options?: { + negative?: boolean + } + ) => { if (!sample) return setSaving(true) @@ -3449,16 +3510,28 @@ export default function TrainingTab(props: { .map(normalizeBox) .filter((box) => box.label && box.w > 0 && box.h > 0) - const correctionPayload: CorrectionState = { - ...correction, - peoplePresent: peopleLabelsFromBoxes(normalizedBoxes, labelsRef.current), - boxes: normalizedBoxes, - } + const negative = options?.negative ?? isNegativeCorrection + const correctionPayload: CorrectionState = negative + ? { + sexPosition: 'unknown', + peoplePresent: [], + bodyPartsPresent: [], + objectsPresent: [], + clothingPresent: [], + boxes: [], + } + : { + ...correction, + peoplePresent: peopleLabelsFromBoxes(normalizedBoxes, labelsRef.current), + boxes: normalizedBoxes, + } + const effectiveAccepted = negative ? false : accepted const payload = { sampleId: sample.sampleId, - accepted, - correction: accepted && correctionPayload.boxes.length === 0 + accepted: effectiveAccepted, + negative, + correction: effectiveAccepted && correctionPayload.boxes.length === 0 ? undefined : correctionPayload, } @@ -3497,8 +3570,9 @@ export default function TrainingTab(props: { item.answeredAt === editingFeedback?.answeredAt ? { ...item, - accepted, - correction: accepted ? undefined : correctionPayload, + accepted: effectiveAccepted, + negative, + correction: effectiveAccepted ? undefined : correctionPayload, } : item ) @@ -3507,12 +3581,16 @@ export default function TrainingTab(props: { setMessage( wasEditingFeedback - ? accepted - ? 'Feedback aktualisiert.' - : 'Korrektur aktualisiert.' - : accepted - ? 'Feedback gespeichert.' - : 'Korrektur gespeichert.' + ? negative + ? 'Negativbeispiel aktualisiert.' + : effectiveAccepted + ? 'Feedback aktualisiert.' + : 'Korrektur aktualisiert.' + : negative + ? 'Negativbeispiel gespeichert.' + : effectiveAccepted + ? 'Feedback gespeichert.' + : 'Korrektur gespeichert.' ) await loadTrainingStatus() @@ -3540,6 +3618,7 @@ export default function TrainingTab(props: { sample, correction, editingFeedback, + isNegativeCorrection, loadNext, loadTrainingStatus, loadNextImportedQueuedSample, @@ -3571,6 +3650,7 @@ export default function TrainingTab(props: { setSample(null) setCorrection(predictionToCorrection(null)) setHasManualCorrection(false) + setNegativeExample(false) setDrawingBox(null) setBoxInteraction(null) setTouchMagnifier(null) @@ -3685,6 +3765,7 @@ export default function TrainingTab(props: { setSample(null) setCorrection(predictionToCorrection(null)) + setNegativeExample(false) setTrainingStatus({ feedbackCount: 0, requiredCount, @@ -4242,12 +4323,19 @@ export default function TrainingTab(props: { 0, Number(detector?.requiredVal ?? 3) - Number(detector?.valCount ?? 0) ) + const missingPositiveTrain = Number(detector?.positiveTrainCount ?? 0) < 1 + const missingPositiveVal = Number(detector?.positiveValCount ?? 0) < 1 + const positiveSamplesMissing = missingPositiveTrain || missingPositiveVal const statusText = trainingRunning ? shownTrainingStep || 'Training läuft…' : !feedbackReady ? `${Math.max(0, requiredCount - feedbackCount)} Feedback fehlen noch` : !detectorReady - ? `YOLO-Boxen fehlen: ${missingTrain} Train, ${missingVal} Val` + ? missingTrain > 0 || missingVal > 0 + ? `YOLO-Beispiele fehlen: ${missingTrain} Train, ${missingVal} Val` + : positiveSamplesMissing + ? 'Je ein positives Beispiel in Train und Val erforderlich' + : 'YOLO-Datensatz noch nicht bereit' : canStartTraining ? 'Bereit zum Trainieren' : 'Noch nicht trainingsbereit' @@ -4431,7 +4519,10 @@ export default function TrainingTab(props: {
-
+
Dauer
@@ -4442,7 +4533,10 @@ export default function TrainingTab(props: {
-
+
Feedback
@@ -4515,7 +4609,7 @@ export default function TrainingTab(props: { : !feedbackReady ? `Noch zu wenig Feedback: ${feedbackCount}/${requiredCount}.` : !detectorReady - ? `Noch zu wenige YOLO-Box-Labels: Train ${detector?.trainCount ?? 0}/${detector?.requiredTrain ?? 20}, Val ${detector?.valCount ?? 0}/${detector?.requiredVal ?? 3}.` + ? `YOLO-Datensatz noch nicht bereit: Train ${detector?.trainCount ?? 0}/${detector?.requiredTrain ?? 20} (${detector?.positiveTrainCount ?? 0} positiv), Val ${detector?.valCount ?? 0}/${detector?.requiredVal ?? 3} (${detector?.positiveValCount ?? 0} positiv).` : 'Training ist aktuell nicht verfügbar.' } > @@ -5759,6 +5853,22 @@ export default function TrainingTab(props: { + +