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
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

View File

@ -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,
),
@ -2258,6 +2302,8 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) {
"detector": map[string]any{
"trainCount": trainCount,
"valCount": valCount,
"positiveTrainCount": positiveTrainCount,
"positiveValCount": positiveValCount,
"requiredTrain": minDetectorTrainCount,
"requiredVal": minDetectorValCount,
"datasetYAML": detectorDatasetYAML,
@ -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
@ -2793,6 +2857,8 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) {
"trainCount": trainCount,
"valCount": valCount,
"positiveTrainCount": positiveTrainCount,
"positiveValCount": positiveValCount,
"requiredTrain": minDetectorTrainCount,
"requiredVal": minDetectorValCount,
@ -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

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
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 (
<span
className={[
'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(' ')}
>
{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" />
) : (
<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>
)
}
@ -646,7 +672,11 @@ function FeedbackListItem(props: {
</div>
</div>
<StatusBadge accepted={props.item.accepted} short />
<StatusBadge
accepted={props.item.accepted}
negative={props.item.negative}
short
/>
</div>
<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>
<StatusBadge accepted={props.item.accepted} />
<StatusBadge
accepted={props.item.accepted}
negative={props.item.negative}
/>
</div>
<div className="mt-2.5 flex flex-wrap gap-1.5">
@ -850,7 +883,11 @@ function ResultLabelsCard(props: {
</div>
</div>
<StatusBadge accepted={props.selected.accepted} short />
<StatusBadge
accepted={props.selected.accepted}
negative={props.selected.negative}
short
/>
</div>
<div className="space-y-2 p-3">
@ -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: {
</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
compact
label="Geladen"
@ -1033,6 +1073,14 @@ export default function TrainingFeedbackHistoryModal(props: {
tone="amber"
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>
@ -1119,7 +1167,7 @@ export default function TrainingFeedbackHistoryModal(props: {
/>
</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
active={filter === 'all'}
onClick={() => setFilter('all')}
@ -1140,6 +1188,13 @@ export default function TrainingFeedbackHistoryModal(props: {
>
Korrigiert
</FilterButton>
<FilterButton
active={filter === 'negative'}
onClick={() => setFilter('negative')}
>
Negativ
</FilterButton>
</div>
</div>
@ -1223,7 +1278,10 @@ export default function TrainingFeedbackHistoryModal(props: {
Zurück
</button>
<StatusBadge accepted={selected.accepted} />
<StatusBadge
accepted={selected.accepted}
negative={selected.negative}
/>
</div>
{props.onEditItem ? (

View File

@ -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: {
/>
</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="text-[9px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Feedback
@ -1745,6 +1761,15 @@ function TrainingStatsModal(props: {
</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="text-[9px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Boxen
@ -1758,7 +1783,7 @@ function TrainingStatsModal(props: {
</div>
{/* 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="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Feedback
@ -1795,6 +1820,18 @@ function TrainingStatsModal(props: {
</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="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
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<TrainingSample | null>(null)
const [correction, setCorrection] = useState<CorrectionState>(() => 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 = {
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,10 +3581,14 @@ export default function TrainingTab(props: {
setMessage(
wasEditingFeedback
? accepted
? negative
? 'Negativbeispiel aktualisiert.'
: effectiveAccepted
? 'Feedback aktualisiert.'
: 'Korrektur aktualisiert.'
: accepted
: negative
? 'Negativbeispiel gespeichert.'
: effectiveAccepted
? 'Feedback gespeichert.'
: 'Korrektur gespeichert.'
)
@ -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: {
</div>
<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">
Dauer
</div>
@ -4442,7 +4533,10 @@ export default function TrainingTab(props: {
</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">
Feedback
</div>
@ -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: {
</span>
</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">
<Button
size="md"