updated negative feedback

This commit is contained in:
Linrador 2026-06-14 22:50:42 +02:00
parent 56b63223f9
commit 2311857661
5 changed files with 554 additions and 114 deletions

View File

@ -37,7 +37,8 @@ def count_yolo_samples(dataset_root: Path, split: str) -> int:
continue continue
label_path = labels_dir / f"{image_path.stem}.txt" 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 count += 1
return count return count

View File

@ -99,6 +99,7 @@ type trainingUncertainCandidate struct {
type TrainingFeedbackRequest struct { type TrainingFeedbackRequest struct {
SampleID string `json:"sampleId"` SampleID string `json:"sampleId"`
Accepted bool `json:"accepted"` Accepted bool `json:"accepted"`
Negative bool `json:"negative,omitempty"`
Correction *TrainingCorrection `json:"correction,omitempty"` Correction *TrainingCorrection `json:"correction,omitempty"`
Notes string `json:"notes,omitempty"` Notes string `json:"notes,omitempty"`
} }
@ -107,6 +108,7 @@ type TrainingFeedbackUpdateRequest struct {
SampleID string `json:"sampleId"` SampleID string `json:"sampleId"`
AnsweredAt string `json:"answeredAt"` AnsweredAt string `json:"answeredAt"`
Accepted bool `json:"accepted"` Accepted bool `json:"accepted"`
Negative bool `json:"negative,omitempty"`
Correction *TrainingCorrection `json:"correction,omitempty"` Correction *TrainingCorrection `json:"correction,omitempty"`
Notes string `json:"notes,omitempty"` Notes string `json:"notes,omitempty"`
} }
@ -126,6 +128,7 @@ type TrainingAnnotation struct {
AnsweredAt string `json:"answeredAt"` AnsweredAt string `json:"answeredAt"`
Prediction TrainingPrediction `json:"prediction"` Prediction TrainingPrediction `json:"prediction"`
Accepted bool `json:"accepted"` Accepted bool `json:"accepted"`
Negative bool `json:"negative,omitempty"`
Correction *TrainingCorrection `json:"correction,omitempty"` Correction *TrainingCorrection `json:"correction,omitempty"`
Notes string `json:"notes,omitempty"` Notes string `json:"notes,omitempty"`
} }
@ -176,6 +179,7 @@ type TrainingStatsResponse struct {
FeedbackCount int `json:"feedbackCount"` FeedbackCount int `json:"feedbackCount"`
AcceptedCount int `json:"acceptedCount"` AcceptedCount int `json:"acceptedCount"`
CorrectedCount int `json:"correctedCount"` CorrectedCount int `json:"correctedCount"`
NegativeCount int `json:"negativeCount"`
SampleCount int `json:"sampleCount"` SampleCount int `json:"sampleCount"`
BoxCount int `json:"boxCount"` BoxCount int `json:"boxCount"`
ModelAvailable bool `json:"modelAvailable"` ModelAvailable bool `json:"modelAvailable"`
@ -333,12 +337,17 @@ func trainingFilterAnnotations(
for _, item := range items { for _, item := range items {
switch cleanFilter { switch cleanFilter {
case "accepted": case "accepted":
if !item.Accepted { if !item.Accepted || item.Negative {
continue continue
} }
case "corrected": case "corrected":
if item.Accepted { if item.Accepted || item.Negative {
continue
}
case "negative":
if !item.Negative {
continue continue
} }
} }
@ -365,6 +374,9 @@ func trainingAnnotationMatchesQuery(item TrainingAnnotation, cleanQuery string)
item.Notes, item.Notes,
effective.SexPosition, effective.SexPosition,
} }
if item.Negative {
parts = append(parts, "negative negativ leer keine labels")
}
parts = append(parts, effective.PeoplePresent...) parts = append(parts, effective.PeoplePresent...)
parts = append(parts, effective.BodyPartsPresent...) parts = append(parts, effective.BodyPartsPresent...)
@ -1875,6 +1887,10 @@ func trainingFrameHandler(w http.ResponseWriter, r *http.Request) {
} }
func trainingDetectorBoxesForAnnotation(sample *TrainingSample, req TrainingFeedbackRequest) []TrainingBox { func trainingDetectorBoxesForAnnotation(sample *TrainingSample, req TrainingFeedbackRequest) []TrainingBox {
if req.Negative {
return []TrainingBox{}
}
boxes := []TrainingBox{} boxes := []TrainingBox{}
if req.Correction != nil { if req.Correction != nil {
@ -1905,6 +1921,17 @@ func trainingDetectorBoxesForAnnotation(sample *TrainingSample, req TrainingFeed
return boxes 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) { func trainingFeedbackHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") 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") trainingWriteError(w, http.StatusBadRequest, "sampleId missing")
return return
} }
if req.Negative {
req.Accepted = false
req.Correction = trainingNegativeCorrection()
}
root, err := trainingRootDir() root, err := trainingRootDir()
if err != nil { if err != nil {
@ -1946,6 +1977,7 @@ func trainingFeedbackHandler(w http.ResponseWriter, r *http.Request) {
AnsweredAt: time.Now().UTC().Format(time.RFC3339), AnsweredAt: time.Now().UTC().Format(time.RFC3339),
Prediction: sample.Prediction, Prediction: sample.Prediction,
Accepted: req.Accepted, Accepted: req.Accepted,
Negative: req.Negative,
Correction: req.Correction, Correction: req.Correction,
Notes: strings.TrimSpace(req.Notes), Notes: strings.TrimSpace(req.Notes),
} }
@ -1962,8 +1994,8 @@ func trainingFeedbackHandler(w http.ResponseWriter, r *http.Request) {
detectorBoxes := trainingDetectorBoxesForAnnotation(sample, req) detectorBoxes := trainingDetectorBoxesForAnnotation(sample, req)
if len(detectorBoxes) > 0 { if req.Negative || len(detectorBoxes) > 0 {
if err := trainingWriteDetectorSample(root, sample, detectorBoxes); err != nil { if err := trainingWriteDetectorSample(root, sample, detectorBoxes, req.Negative); err != nil {
appLogln("⚠️ detector sample write failed:", err) 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.SampleID = strings.TrimSpace(req.SampleID)
req.AnsweredAt = strings.TrimSpace(req.AnsweredAt) req.AnsweredAt = strings.TrimSpace(req.AnsweredAt)
if req.Negative {
req.Accepted = false
req.Correction = trainingNegativeCorrection()
}
if req.SampleID == "" { if req.SampleID == "" {
trainingWriteError(w, http.StatusBadRequest, "sampleId missing") trainingWriteError(w, http.StatusBadRequest, "sampleId missing")
@ -2039,6 +2075,7 @@ func trainingFeedbackUpdateHandler(w http.ResponseWriter, r *http.Request) {
updated := old updated := old
updated.Accepted = req.Accepted updated.Accepted = req.Accepted
updated.Negative = req.Negative
updated.Notes = strings.TrimSpace(req.Notes) updated.Notes = strings.TrimSpace(req.Notes)
if req.Accepted { if req.Accepted {
@ -2073,12 +2110,13 @@ func trainingFeedbackUpdateHandler(w http.ResponseWriter, r *http.Request) {
detectorBoxes := trainingDetectorBoxesForAnnotation(sample, TrainingFeedbackRequest{ detectorBoxes := trainingDetectorBoxesForAnnotation(sample, TrainingFeedbackRequest{
SampleID: req.SampleID, SampleID: req.SampleID,
Accepted: req.Accepted, Accepted: req.Accepted,
Negative: req.Negative,
Correction: req.Correction, Correction: req.Correction,
Notes: req.Notes, Notes: req.Notes,
}) })
if len(detectorBoxes) > 0 { if req.Negative || len(detectorBoxes) > 0 {
if err := trainingWriteDetectorSample(root, sample, detectorBoxes); err != nil { if err := trainingWriteDetectorSample(root, sample, detectorBoxes, req.Negative); err != nil {
appLogln("⚠️ detector sample update failed:", err) 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())) id := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name()))
labelPath := filepath.Join(labelsDir, id+".txt") labelPath := filepath.Join(labelsDir, id+".txt")
if fileExistsNonEmpty(labelPath) { if fileExists(labelPath) {
count++ count++
} }
} }
@ -2227,17 +2265,23 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) {
trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels) trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels)
valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels) valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels)
positiveTrainCount := trainingCountPositiveDetectorSamples(detectorTrainImages, detectorTrainLabels)
positiveValCount := trainingCountPositiveDetectorSamples(detectorValImages, detectorValLabels)
if !fileExistsNonEmpty(detectorDatasetYAML) || if !fileExistsNonEmpty(detectorDatasetYAML) ||
trainCount < minDetectorTrainCount || trainCount < minDetectorTrainCount ||
valCount < minDetectorValCount { valCount < minDetectorValCount ||
positiveTrainCount == 0 ||
positiveValCount == 0 {
trainingWriteError( trainingWriteError(
w, w,
http.StatusBadRequest, http.StatusBadRequest,
fmt.Sprintf( 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, trainCount,
positiveTrainCount,
valCount, valCount,
positiveValCount,
minDetectorTrainCount, minDetectorTrainCount,
minDetectorValCount, minDetectorValCount,
), ),
@ -2258,6 +2302,8 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) {
"detector": map[string]any{ "detector": map[string]any{
"trainCount": trainCount, "trainCount": trainCount,
"valCount": valCount, "valCount": valCount,
"positiveTrainCount": positiveTrainCount,
"positiveValCount": positiveValCount,
"requiredTrain": minDetectorTrainCount, "requiredTrain": minDetectorTrainCount,
"requiredVal": minDetectorValCount, "requiredVal": minDetectorValCount,
"datasetYAML": detectorDatasetYAML, "datasetYAML": detectorDatasetYAML,
@ -2337,17 +2383,23 @@ func trainingRunJob(ctx context.Context, root string, count int) {
trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels) trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels)
valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels) valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels)
positiveTrainCount := trainingCountPositiveDetectorSamples(detectorTrainImages, detectorTrainLabels)
positiveValCount := trainingCountPositiveDetectorSamples(detectorValImages, detectorValLabels)
fmt.Printf( 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, trainCount,
positiveTrainCount,
valCount, valCount,
positiveValCount,
fileExistsNonEmpty(detectorDatasetYAML), fileExistsNonEmpty(detectorDatasetYAML),
) )
if fileExistsNonEmpty(detectorDatasetYAML) && if fileExistsNonEmpty(detectorDatasetYAML) &&
trainCount >= minDetectorTrainCount && trainCount >= minDetectorTrainCount &&
valCount >= minDetectorValCount { valCount >= minDetectorValCount &&
positiveTrainCount > 0 &&
positiveValCount > 0 {
trainingSetJobStatus(func(s *TrainingJobStatus) { trainingSetJobStatus(func(s *TrainingJobStatus) {
s.Progress = 15 s.Progress = 15
s.Step = "YOLO26 Detector wird trainiert…" s.Step = "YOLO26 Detector wird trainiert…"
@ -2398,9 +2450,11 @@ func trainingRunJob(ctx context.Context, root string, count int) {
} else { } else {
detectorStatus = "skipped_no_detector_data" detectorStatus = "skipped_no_detector_data"
detectorOutput = fmt.Sprintf( 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, trainCount,
positiveTrainCount,
valCount, valCount,
positiveValCount,
minDetectorTrainCount, minDetectorTrainCount,
minDetectorValCount, minDetectorValCount,
) )
@ -2545,7 +2599,9 @@ func trainingBuildStats(root string) (*TrainingStatsResponse, error) {
stats.FeedbackCount++ stats.FeedbackCount++
if annotation.Accepted { if annotation.Negative {
stats.NegativeCount++
} else if annotation.Accepted {
stats.AcceptedCount++ stats.AcceptedCount++
} else { } else {
stats.CorrectedCount++ stats.CorrectedCount++
@ -2643,6 +2699,10 @@ func trainingBuildStats(root string) (*TrainingStatsResponse, error) {
} }
func trainingEffectiveCorrection(annotation TrainingAnnotation) TrainingCorrection { func trainingEffectiveCorrection(annotation TrainingAnnotation) TrainingCorrection {
if annotation.Negative {
return *trainingNegativeCorrection()
}
if annotation.Correction != nil { if annotation.Correction != nil {
return *annotation.Correction return *annotation.Correction
} }
@ -2761,11 +2821,15 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) {
trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels) trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels)
valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels) valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels)
positiveTrainCount := trainingCountPositiveDetectorSamples(detectorTrainImages, detectorTrainLabels)
positiveValCount := trainingCountPositiveDetectorSamples(detectorValImages, detectorValLabels)
datasetReady := fileExistsNonEmpty(detectorDatasetYAML) datasetReady := fileExistsNonEmpty(detectorDatasetYAML)
detectorDataReady := datasetReady && detectorDataReady := datasetReady &&
trainCount >= minDetectorTrainCount && trainCount >= minDetectorTrainCount &&
valCount >= minDetectorValCount valCount >= minDetectorValCount &&
positiveTrainCount > 0 &&
positiveValCount > 0
canTrain := feedbackCount >= minTrainingFeedbackCount && detectorDataReady canTrain := feedbackCount >= minTrainingFeedbackCount && detectorDataReady
@ -2793,6 +2857,8 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) {
"trainCount": trainCount, "trainCount": trainCount,
"valCount": valCount, "valCount": valCount,
"positiveTrainCount": positiveTrainCount,
"positiveValCount": positiveValCount,
"requiredTrain": minDetectorTrainCount, "requiredTrain": minDetectorTrainCount,
"requiredVal": minDetectorValCount, "requiredVal": minDetectorValCount,
@ -2955,16 +3021,17 @@ func trainingOverallConfidence(
// Viele "Passt so"-Antworten bedeuten, dass die Vorhersagen brauchbar sind. // Viele "Passt so"-Antworten bedeuten, dass die Vorhersagen brauchbar sind.
// Bei 4/229 ist dieser Teil bewusst sehr niedrig. // Bei 4/229 ist dieser Teil bewusst sehr niedrig.
agreementScore := 0.0 agreementScore := 0.0
if feedbackCount > 0 { decisionCount := acceptedCount + correctedCount
agreementScore = clamp01(float64(acceptedCount) / float64(feedbackCount)) if decisionCount > 0 {
agreementScore = clamp01(float64(acceptedCount) / float64(decisionCount))
} }
// Korrekturquote als zusätzlicher Dämpfer. // Korrekturquote als zusätzlicher Dämpfer.
// 98% korrigiert soll die Gesamt-Confidence sichtbar drücken, // 98% korrigiert soll die Gesamt-Confidence sichtbar drücken,
// aber nicht alle gesammelten Daten entwerten. // aber nicht alle gesammelten Daten entwerten.
correctionRate := 0.0 correctionRate := 0.0
if feedbackCount > 0 { if decisionCount > 0 {
correctionRate = clamp01(float64(correctedCount) / float64(feedbackCount)) correctionRate = clamp01(float64(correctedCount) / float64(decisionCount))
} }
correctionPenalty := 1.0 - math.Min(0.45, correctionRate*0.45) correctionPenalty := 1.0 - math.Min(0.45, correctionRate*0.45)
@ -3065,6 +3132,42 @@ func trainingCountDetectorSamples(imagesDir string, labelsDir string) int {
count := 0 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 { for _, e := range entries {
if e.IsDir() { if e.IsDir() {
continue continue
@ -4057,39 +4160,11 @@ func trainingApplyDetectorToPrediction(pred TrainingPrediction, det TrainingDete
return pred return pred
} }
func trainingWriteDetectorSample(root string, sample *TrainingSample, boxes []TrainingBox) error { func trainingDetectorLabelContent(
if sample == nil { boxes []TrainingBox,
return errors.New("sample missing") classMap map[string]int,
} allowEmpty bool,
) ([]byte, error) {
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
}
var lines []string var lines []string
for _, box := range boxes { for _, box := range boxes {
@ -4128,11 +4203,60 @@ func trainingWriteDetectorSample(root string, sample *TrainingSample, boxes []Tr
} }
if len(lines) == 0 { 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") 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) { func trainingDeleteDetectorSample(root string, sampleID string) {
@ -4160,11 +4284,13 @@ func trainingEnsureDetectorValidationSample(root string) error {
valLabels := filepath.Join(root, "detector", "dataset", "labels", "val") valLabels := filepath.Join(root, "detector", "dataset", "labels", "val")
currentVal := trainingCountDetectorSamples(valImages, valLabels) currentVal := trainingCountDetectorSamples(valImages, valLabels)
if currentVal >= minDetectorValCount { currentPositiveVal := trainingCountPositiveDetectorSamples(valImages, valLabels)
if currentVal >= minDetectorValCount && currentPositiveVal > 0 {
return nil return nil
} }
if trainingCountDetectorSamples(trainImages, trainLabels) < minDetectorTrainCount { if trainingCountDetectorSamples(trainImages, trainLabels) < minDetectorTrainCount ||
trainingCountPositiveDetectorSamples(trainImages, trainLabels) == 0 {
return nil return nil
} }
@ -4181,10 +4307,19 @@ func trainingEnsureDetectorValidationSample(root string) error {
} }
copied := 0 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 { for _, e := range entries {
if copied >= needed { if copied >= needed && !needsPositive {
break break
} }
@ -4202,14 +4337,14 @@ func trainingEnsureDetectorValidationSample(root string) error {
srcImage := filepath.Join(trainImages, e.Name()) srcImage := filepath.Join(trainImages, e.Name())
srcLabel := filepath.Join(trainLabels, id+".txt") srcLabel := filepath.Join(trainLabels, id+".txt")
if !fileExistsNonEmpty(srcImage) || !fileExistsNonEmpty(srcLabel) { if !fileExistsNonEmpty(srcImage) || !fileExists(srcLabel) {
continue continue
} }
dstImage := filepath.Join(valImages, e.Name()) dstImage := filepath.Join(valImages, e.Name())
dstLabel := filepath.Join(valLabels, id+".txt") dstLabel := filepath.Join(valLabels, id+".txt")
if fileExistsNonEmpty(dstImage) && fileExistsNonEmpty(dstLabel) { if fileExistsNonEmpty(dstImage) && fileExists(dstLabel) {
continue continue
} }
@ -4221,6 +4356,9 @@ func trainingEnsureDetectorValidationSample(root string) error {
} }
copied++ copied++
if fileExistsNonEmpty(srcLabel) {
needsPositive = false
}
} }
return nil return nil

133
backend/training_test.go Normal file
View File

@ -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))
}
}

View File

@ -67,11 +67,12 @@ export type TrainingFeedbackAnnotation = {
answeredAt: string answeredAt: string
prediction: TrainingFeedbackPrediction prediction: TrainingFeedbackPrediction
accepted: boolean accepted: boolean
negative?: boolean
correction?: TrainingFeedbackCorrection correction?: TrainingFeedbackCorrection
notes?: string notes?: string
} }
type FeedbackFilter = 'all' | 'accepted' | 'corrected' type FeedbackFilter = 'all' | 'accepted' | 'corrected' | 'negative'
function clamp01(value: number) { function clamp01(value: number) {
if (!Number.isFinite(value)) return 0 if (!Number.isFinite(value)) return 0
@ -101,6 +102,17 @@ function normalizeBox(box: TrainingFeedbackBox): TrainingFeedbackBox {
function annotationEffectiveCorrection( function annotationEffectiveCorrection(
annotation: TrainingFeedbackAnnotation annotation: TrainingFeedbackAnnotation
): TrainingFeedbackCorrection { ): TrainingFeedbackCorrection {
if (annotation.negative) {
return {
sexPosition: 'unknown',
peoplePresent: [],
bodyPartsPresent: [],
objectsPresent: [],
clothingPresent: [],
boxes: [],
}
}
if (annotation.correction) { if (annotation.correction) {
return annotation.correction return annotation.correction
} }
@ -196,13 +208,18 @@ function frameUrlForHistory(item?: TrainingFeedbackAnnotation | null) {
return `${item.frameUrl}${separator}history=1&t=${encodeURIComponent(item.sampleId)}` 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 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-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' : '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' return accepted ? 'Passt so' : 'Korrigiert'
} }
@ -212,22 +229,31 @@ function labelText(value: string) {
function StatusBadge(props: { function StatusBadge(props: {
accepted: boolean accepted: boolean
negative?: boolean
short?: boolean short?: boolean
}) { }) {
return ( return (
<span <span
className={[ className={[
'inline-flex shrink-0 items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ring-1', 'inline-flex shrink-0 items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ring-1',
feedbackStatusClass(props.accepted), feedbackStatusClass(props.accepted, props.negative),
].join(' ')} ].join(' ')}
> >
{props.accepted ? ( {props.negative ? (
<CubeTransparentIcon className="h-3.5 w-3.5" aria-hidden="true" />
) : props.accepted ? (
<CheckCircleIcon className="h-3.5 w-3.5" aria-hidden="true" /> <CheckCircleIcon className="h-3.5 w-3.5" aria-hidden="true" />
) : ( ) : (
<AdjustmentsHorizontalIcon className="h-3.5 w-3.5" aria-hidden="true" /> <AdjustmentsHorizontalIcon className="h-3.5 w-3.5" aria-hidden="true" />
)} )}
{props.short ? (props.accepted ? 'Passt' : 'Korr.') : feedbackStatusText(props.accepted)} {props.short
? props.negative
? 'Negativ'
: props.accepted
? 'Passt'
: 'Korr.'
: feedbackStatusText(props.accepted, props.negative)}
</span> </span>
) )
} }
@ -646,7 +672,11 @@ function FeedbackListItem(props: {
</div> </div>
</div> </div>
<StatusBadge accepted={props.item.accepted} short /> <StatusBadge
accepted={props.item.accepted}
negative={props.item.negative}
short
/>
</div> </div>
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-[11px] text-gray-500 dark:text-gray-400"> <div className="mt-2 flex flex-wrap items-center gap-1.5 text-[11px] text-gray-500 dark:text-gray-400">
@ -792,7 +822,10 @@ function CompactSourceCard(props: {
</div> </div>
</div> </div>
<StatusBadge accepted={props.item.accepted} /> <StatusBadge
accepted={props.item.accepted}
negative={props.item.negative}
/>
</div> </div>
<div className="mt-2.5 flex flex-wrap gap-1.5"> <div className="mt-2.5 flex flex-wrap gap-1.5">
@ -850,7 +883,11 @@ function ResultLabelsCard(props: {
</div> </div>
</div> </div>
<StatusBadge accepted={props.selected.accepted} short /> <StatusBadge
accepted={props.selected.accepted}
negative={props.selected.negative}
short
/>
</div> </div>
<div className="space-y-2 p-3"> <div className="space-y-2 p-3">
@ -926,8 +963,9 @@ export default function TrainingFeedbackHistoryModal(props: {
const effective = selected ? annotationEffectiveCorrection(selected) : null const effective = selected ? annotationEffectiveCorrection(selected) : null
const selectedImageSrc = frameUrlForHistory(selected) const selectedImageSrc = frameUrlForHistory(selected)
const acceptedCount = 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).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 shownCount = props.items.length
const selectedBoxCount = effective?.boxes?.length ?? 0 const selectedBoxCount = effective?.boxes?.length ?? 0
@ -941,8 +979,9 @@ export default function TrainingFeedbackHistoryModal(props: {
return props.items return props.items
.map((item, index) => ({ item, index })) .map((item, index) => ({ item, index }))
.filter(({ item }) => { .filter(({ item }) => {
if (filter === 'accepted' && !item.accepted) return false if (filter === 'accepted' && (!item.accepted || item.negative)) return false
if (filter === 'corrected' && item.accepted) return false if (filter === 'corrected' && (item.accepted || item.negative)) return false
if (filter === 'negative' && !item.negative) return false
if (!cleanQuery) return true if (!cleanQuery) return true
@ -953,6 +992,7 @@ export default function TrainingFeedbackHistoryModal(props: {
item.sourceFile, item.sourceFile,
item.sourcePath, item.sourcePath,
item.notes, item.notes,
item.negative ? 'negative negativ leer keine labels' : '',
effectiveItem.sexPosition, effectiveItem.sexPosition,
...effectiveItem.peoplePresent, ...effectiveItem.peoplePresent,
...effectiveItem.bodyPartsPresent, ...effectiveItem.bodyPartsPresent,
@ -1010,7 +1050,7 @@ export default function TrainingFeedbackHistoryModal(props: {
</div> </div>
</div> </div>
<div className="grid grid-cols-3 gap-1.5 lg:flex lg:items-center"> <div className="grid grid-cols-4 gap-1.5 lg:flex lg:items-center">
<MetricTile <MetricTile
compact compact
label="Geladen" label="Geladen"
@ -1033,6 +1073,14 @@ export default function TrainingFeedbackHistoryModal(props: {
tone="amber" tone="amber"
icon={<AdjustmentsHorizontalIcon className="h-4 w-4" aria-hidden="true" />} icon={<AdjustmentsHorizontalIcon className="h-4 w-4" aria-hidden="true" />}
/> />
<MetricTile
compact
label="Negativ"
value={negativeCount}
tone="blue"
icon={<CubeTransparentIcon className="h-4 w-4" aria-hidden="true" />}
/>
</div> </div>
</div> </div>
</div> </div>
@ -1119,7 +1167,7 @@ export default function TrainingFeedbackHistoryModal(props: {
/> />
</div> </div>
<div className="grid grid-cols-3 gap-1 overflow-visible rounded-2xl bg-gray-100 p-1 ring-1 ring-black/5 dark:bg-gray-800/80 dark:ring-white/10"> <div className="grid grid-cols-4 gap-1 overflow-visible rounded-2xl bg-gray-100 p-1 ring-1 ring-black/5 dark:bg-gray-800/80 dark:ring-white/10">
<FilterButton <FilterButton
active={filter === 'all'} active={filter === 'all'}
onClick={() => setFilter('all')} onClick={() => setFilter('all')}
@ -1140,6 +1188,13 @@ export default function TrainingFeedbackHistoryModal(props: {
> >
Korrigiert Korrigiert
</FilterButton> </FilterButton>
<FilterButton
active={filter === 'negative'}
onClick={() => setFilter('negative')}
>
Negativ
</FilterButton>
</div> </div>
</div> </div>
@ -1223,7 +1278,10 @@ export default function TrainingFeedbackHistoryModal(props: {
Zurück Zurück
</button> </button>
<StatusBadge accepted={selected.accepted} /> <StatusBadge
accepted={selected.accepted}
negative={selected.negative}
/>
</div> </div>
{props.onEditItem ? ( {props.onEditItem ? (

View File

@ -36,6 +36,8 @@ type ScoredLabel = {
type TrainingDetectorStatus = { type TrainingDetectorStatus = {
trainCount: number trainCount: number
valCount: number valCount: number
positiveTrainCount: number
positiveValCount: number
requiredTrain: number requiredTrain: number
requiredVal: number requiredVal: number
datasetReady: boolean datasetReady: boolean
@ -166,6 +168,7 @@ type TrainingStats = {
feedbackCount: number feedbackCount: number
acceptedCount: number acceptedCount: number
correctedCount: number correctedCount: number
negativeCount: number
sampleCount: number sampleCount: number
boxCount: number boxCount: number
modelAvailable: boolean modelAvailable: boolean
@ -190,6 +193,7 @@ type TrainingAnnotation = {
answeredAt: string answeredAt: string
prediction: TrainingPrediction prediction: TrainingPrediction
accepted: boolean accepted: boolean
negative?: boolean
correction?: CorrectionState correction?: CorrectionState
notes?: string notes?: string
} }
@ -204,7 +208,7 @@ type TrainingFeedbackListResponse = {
} }
type TrainingSampleMode = 'random' | 'uncertain' type TrainingSampleMode = 'random' | 'uncertain'
type FeedbackFilter = 'all' | 'accepted' | 'corrected' type FeedbackFilter = 'all' | 'accepted' | 'corrected' | 'negative'
function backendText(data: any, fallback: string) { function backendText(data: any, fallback: string) {
return 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( function applyBoxLabelToCorrection(
state: CorrectionState, state: CorrectionState,
label: string, label: string,
@ -1585,6 +1600,7 @@ function TrainingStatsModal(props: {
const acceptedCount = stats?.acceptedCount ?? 0 const acceptedCount = stats?.acceptedCount ?? 0
const correctedCount = stats?.correctedCount ?? 0 const correctedCount = stats?.correctedCount ?? 0
const negativeCount = stats?.negativeCount ?? 0
const totalFeedback = stats?.feedbackCount ?? props.feedbackCount const totalFeedback = stats?.feedbackCount ?? props.feedbackCount
const boxCount = stats?.boxCount ?? 0 const boxCount = stats?.boxCount ?? 0
const sampleCount = stats?.sampleCount ?? 0 const sampleCount = stats?.sampleCount ?? 0
@ -1717,7 +1733,7 @@ function TrainingStatsModal(props: {
/> />
</div> </div>
<div className="mt-3 grid grid-cols-4 gap-1.5"> <div className="mt-3 grid grid-cols-5 gap-1.5">
<div className="rounded-xl bg-gray-50 px-2 py-1.5 text-center ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10"> <div className="rounded-xl bg-gray-50 px-2 py-1.5 text-center ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="text-[9px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"> <div className="text-[9px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Feedback Feedback
@ -1745,6 +1761,15 @@ function TrainingStatsModal(props: {
</div> </div>
</div> </div>
<div className="rounded-xl bg-gray-50 px-2 py-1.5 text-center ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="text-[9px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Negativ
</div>
<div className="mt-0.5 text-sm font-black text-blue-700 dark:text-blue-300">
{negativeCount}
</div>
</div>
<div className="rounded-xl bg-gray-50 px-2 py-1.5 text-center ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10"> <div className="rounded-xl bg-gray-50 px-2 py-1.5 text-center ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="text-[9px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"> <div className="text-[9px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Boxen Boxen
@ -1758,7 +1783,7 @@ function TrainingStatsModal(props: {
</div> </div>
{/* Desktop/Tablet: ausführliche Karten */} {/* Desktop/Tablet: ausführliche Karten */}
<div className="hidden sm:grid sm:grid-cols-4 sm:gap-2"> <div className="hidden sm:grid sm:grid-cols-5 sm:gap-2">
<div className="rounded-xl bg-gray-50 p-3 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10"> <div className="rounded-xl bg-gray-50 p-3 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400"> <div className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Feedback Feedback
@ -1795,6 +1820,18 @@ function TrainingStatsModal(props: {
</div> </div>
</div> </div>
<div className="rounded-xl bg-gray-50 p-3 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Negativ
</div>
<div className="mt-1 text-2xl font-bold text-blue-700 dark:text-blue-300">
{negativeCount}
</div>
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
{countPercent(negativeCount, totalFeedback)}
</div>
</div>
<div className="rounded-xl bg-gray-50 p-3 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10"> <div className="rounded-xl bg-gray-50 p-3 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400"> <div className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Boxen Boxen
@ -1974,6 +2011,17 @@ function annotationToTrainingSample(item: TrainingAnnotation): TrainingSample {
} }
function annotationToCorrectionState(item: TrainingAnnotation): CorrectionState { function annotationToCorrectionState(item: TrainingAnnotation): CorrectionState {
if (item.negative) {
return {
sexPosition: 'unknown',
peoplePresent: [],
bodyPartsPresent: [],
objectsPresent: [],
clothingPresent: [],
boxes: [],
}
}
if (item.correction) { if (item.correction) {
return item.correction return item.correction
} }
@ -1993,6 +2041,7 @@ export default function TrainingTab(props: {
const [sample, setSample] = useState<TrainingSample | null>(null) const [sample, setSample] = useState<TrainingSample | null>(null)
const [correction, setCorrection] = useState<CorrectionState>(() => predictionToCorrection(null)) const [correction, setCorrection] = useState<CorrectionState>(() => predictionToCorrection(null))
const [hasManualCorrection, setHasManualCorrection] = useState(false) const [hasManualCorrection, setHasManualCorrection] = useState(false)
const [negativeExample, setNegativeExample] = useState(false)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [analysisProgress, setAnalysisProgress] = useState(0) const [analysisProgress, setAnalysisProgress] = useState(0)
const [analysisStep, setAnalysisStep] = useState('') const [analysisStep, setAnalysisStep] = useState('')
@ -2181,6 +2230,7 @@ export default function TrainingTab(props: {
setSample(annotationToTrainingSample(item)) setSample(annotationToTrainingSample(item))
setCorrection(annotationToCorrectionState(item)) setCorrection(annotationToCorrectionState(item))
setHasManualCorrection(!item.accepted) setHasManualCorrection(!item.accepted)
setNegativeExample(Boolean(item.negative))
setEditingFeedback({ setEditingFeedback({
sampleId: item.sampleId, sampleId: item.sampleId,
@ -2419,6 +2469,8 @@ export default function TrainingTab(props: {
}, [labels.people, labels.bodyParts, labels.objects, labels.clothing]) }, [labels.people, labels.bodyParts, labels.objects, labels.clothing])
const correctionBoxes = correction.boxes ?? [] const correctionBoxes = correction.boxes ?? []
const isNegativeCorrection =
negativeExample && !correctionHasRelevantContent(correction)
const visibleBoxes = [ const visibleBoxes = [
...correctionBoxes.map((box, index) => ({ box, index, isDraft: false })), ...correctionBoxes.map((box, index) => ({ box, index, isDraft: false })),
@ -2629,6 +2681,8 @@ export default function TrainingTab(props: {
? { ? {
trainCount: Number(data.detector.trainCount ?? 0), trainCount: Number(data.detector.trainCount ?? 0),
valCount: Number(data.detector.valCount ?? 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), requiredTrain: Number(data.detector.requiredTrain ?? 20),
requiredVal: Number(data.detector.requiredVal ?? 3), requiredVal: Number(data.detector.requiredVal ?? 3),
datasetReady: Boolean(data.detector.datasetReady), datasetReady: Boolean(data.detector.datasetReady),
@ -2714,6 +2768,7 @@ export default function TrainingTab(props: {
setSample(nextSample) setSample(nextSample)
setCorrection(nextCorrection) setCorrection(nextCorrection)
setHasManualCorrection(Boolean(opts?.manualCorrection)) setHasManualCorrection(Boolean(opts?.manualCorrection))
setNegativeExample(false)
const initiallyExpandedSection: CorrectionSectionKey | null = const initiallyExpandedSection: CorrectionSectionKey | null =
nextCorrection.sexPosition && nextCorrection.sexPosition !== 'unknown' nextCorrection.sexPosition && nextCorrection.sexPosition !== 'unknown'
@ -2998,6 +3053,7 @@ export default function TrainingTab(props: {
feedbackCount: Number(data?.feedbackCount ?? 0), feedbackCount: Number(data?.feedbackCount ?? 0),
acceptedCount: Number(data?.acceptedCount ?? 0), acceptedCount: Number(data?.acceptedCount ?? 0),
correctedCount: Number(data?.correctedCount ?? 0), correctedCount: Number(data?.correctedCount ?? 0),
negativeCount: Number(data?.negativeCount ?? 0),
sampleCount: Number(data?.sampleCount ?? 0), sampleCount: Number(data?.sampleCount ?? 0),
boxCount: Number(data?.boxCount ?? 0), boxCount: Number(data?.boxCount ?? 0),
modelAvailable: Boolean(data?.modelAvailable), modelAvailable: Boolean(data?.modelAvailable),
@ -3437,7 +3493,12 @@ export default function TrainingTab(props: {
]) ])
const saveFeedback = useCallback( const saveFeedback = useCallback(
async (accepted: boolean) => { async (
accepted: boolean,
options?: {
negative?: boolean
}
) => {
if (!sample) return if (!sample) return
setSaving(true) setSaving(true)
@ -3449,16 +3510,28 @@ export default function TrainingTab(props: {
.map(normalizeBox) .map(normalizeBox)
.filter((box) => box.label && box.w > 0 && box.h > 0) .filter((box) => box.label && box.w > 0 && box.h > 0)
const correctionPayload: CorrectionState = { const negative = options?.negative ?? isNegativeCorrection
const correctionPayload: CorrectionState = negative
? {
sexPosition: 'unknown',
peoplePresent: [],
bodyPartsPresent: [],
objectsPresent: [],
clothingPresent: [],
boxes: [],
}
: {
...correction, ...correction,
peoplePresent: peopleLabelsFromBoxes(normalizedBoxes, labelsRef.current), peoplePresent: peopleLabelsFromBoxes(normalizedBoxes, labelsRef.current),
boxes: normalizedBoxes, boxes: normalizedBoxes,
} }
const effectiveAccepted = negative ? false : accepted
const payload = { const payload = {
sampleId: sample.sampleId, sampleId: sample.sampleId,
accepted, accepted: effectiveAccepted,
correction: accepted && correctionPayload.boxes.length === 0 negative,
correction: effectiveAccepted && correctionPayload.boxes.length === 0
? undefined ? undefined
: correctionPayload, : correctionPayload,
} }
@ -3497,8 +3570,9 @@ export default function TrainingTab(props: {
item.answeredAt === editingFeedback?.answeredAt item.answeredAt === editingFeedback?.answeredAt
? { ? {
...item, ...item,
accepted, accepted: effectiveAccepted,
correction: accepted ? undefined : correctionPayload, negative,
correction: effectiveAccepted ? undefined : correctionPayload,
} }
: item : item
) )
@ -3507,10 +3581,14 @@ export default function TrainingTab(props: {
setMessage( setMessage(
wasEditingFeedback wasEditingFeedback
? accepted ? negative
? 'Negativbeispiel aktualisiert.'
: effectiveAccepted
? 'Feedback aktualisiert.' ? 'Feedback aktualisiert.'
: 'Korrektur aktualisiert.' : 'Korrektur aktualisiert.'
: accepted : negative
? 'Negativbeispiel gespeichert.'
: effectiveAccepted
? 'Feedback gespeichert.' ? 'Feedback gespeichert.'
: 'Korrektur gespeichert.' : 'Korrektur gespeichert.'
) )
@ -3540,6 +3618,7 @@ export default function TrainingTab(props: {
sample, sample,
correction, correction,
editingFeedback, editingFeedback,
isNegativeCorrection,
loadNext, loadNext,
loadTrainingStatus, loadTrainingStatus,
loadNextImportedQueuedSample, loadNextImportedQueuedSample,
@ -3571,6 +3650,7 @@ export default function TrainingTab(props: {
setSample(null) setSample(null)
setCorrection(predictionToCorrection(null)) setCorrection(predictionToCorrection(null))
setHasManualCorrection(false) setHasManualCorrection(false)
setNegativeExample(false)
setDrawingBox(null) setDrawingBox(null)
setBoxInteraction(null) setBoxInteraction(null)
setTouchMagnifier(null) setTouchMagnifier(null)
@ -3685,6 +3765,7 @@ export default function TrainingTab(props: {
setSample(null) setSample(null)
setCorrection(predictionToCorrection(null)) setCorrection(predictionToCorrection(null))
setNegativeExample(false)
setTrainingStatus({ setTrainingStatus({
feedbackCount: 0, feedbackCount: 0,
requiredCount, requiredCount,
@ -4242,12 +4323,19 @@ export default function TrainingTab(props: {
0, 0,
Number(detector?.requiredVal ?? 3) - Number(detector?.valCount ?? 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 const statusText = trainingRunning
? shownTrainingStep || 'Training läuft…' ? shownTrainingStep || 'Training läuft…'
: !feedbackReady : !feedbackReady
? `${Math.max(0, requiredCount - feedbackCount)} Feedback fehlen noch` ? `${Math.max(0, requiredCount - feedbackCount)} Feedback fehlen noch`
: !detectorReady : !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 : canStartTraining
? 'Bereit zum Trainieren' ? 'Bereit zum Trainieren'
: 'Noch nicht trainingsbereit' : 'Noch nicht trainingsbereit'
@ -4431,7 +4519,10 @@ export default function TrainingTab(props: {
</div> </div>
<div className="mt-3 grid grid-cols-2 gap-2 text-[10px]"> <div className="mt-3 grid grid-cols-2 gap-2 text-[10px]">
<div className="rounded-lg bg-white/70 px-2 py-1.5 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10"> <div
className="rounded-lg bg-white/70 px-2 py-1.5 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10"
title={`${trainingStatus?.detector?.positiveTrainCount ?? 0} positive Trainingsbeispiele`}
>
<div className="font-semibold opacity-60"> <div className="font-semibold opacity-60">
Dauer Dauer
</div> </div>
@ -4442,7 +4533,10 @@ export default function TrainingTab(props: {
</div> </div>
</div> </div>
<div className="rounded-lg bg-white/70 px-2 py-1.5 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10"> <div
className="rounded-lg bg-white/70 px-2 py-1.5 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10"
title={`${trainingStatus?.detector?.positiveValCount ?? 0} positive Validierungsbeispiele`}
>
<div className="font-semibold opacity-60"> <div className="font-semibold opacity-60">
Feedback Feedback
</div> </div>
@ -4515,7 +4609,7 @@ export default function TrainingTab(props: {
: !feedbackReady : !feedbackReady
? `Noch zu wenig Feedback: ${feedbackCount}/${requiredCount}.` ? `Noch zu wenig Feedback: ${feedbackCount}/${requiredCount}.`
: !detectorReady : !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.' : 'Training ist aktuell nicht verfügbar.'
} }
> >
@ -5759,6 +5853,22 @@ export default function TrainingTab(props: {
</span> </span>
</Button> </Button>
<Button
size="md"
variant={isNegativeCorrection ? 'primary' : 'soft'}
color="blue"
disabled={uiLocked || frameBusy || !sample}
onClick={() => void saveFeedback(false, { negative: true })}
className="col-span-2 w-full justify-center px-2 text-xs sm:text-sm"
title="Speichert das Bild ausdrücklich ohne relevante Labels oder Boxen als YOLO-Negativbeispiel."
>
<span className="inline-flex items-center gap-1.5">
<XCircleIcon className="h-3.5 w-3.5" aria-hidden="true" />
<span className="sm:hidden">Negativbeispiel</span>
<span className="hidden sm:inline">Keine relevanten Inhalte & weiter</span>
</span>
</Button>
<div className="col-span-2 hidden lg:block"> <div className="col-span-2 hidden lg:block">
<Button <Button
size="md" size="md"