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 ? (
+
+ ) : props.accepted ? (
) : (
)}
- {props.short ? (props.accepted ? 'Passt' : 'Korr.') : feedbackStatusText(props.accepted)}
+ {props.short
+ ? props.negative
+ ? 'Negativ'
+ : props.accepted
+ ? 'Passt'
+ : 'Korr.'
+ : feedbackStatusText(props.accepted, props.negative)}
)
}
@@ -646,7 +672,11 @@ function FeedbackListItem(props: {
-