updated negative feedback
This commit is contained in:
parent
56b63223f9
commit
2311857661
@ -37,7 +37,8 @@ def count_yolo_samples(dataset_root: Path, split: str) -> int:
|
||||
continue
|
||||
|
||||
label_path = labels_dir / f"{image_path.stem}.txt"
|
||||
if label_path.exists() and label_path.stat().st_size > 0:
|
||||
# Eine vorhandene, leere Labeldatei ist ein gültiges YOLO-Negativbeispiel.
|
||||
if label_path.exists() and label_path.is_file():
|
||||
count += 1
|
||||
|
||||
return count
|
||||
@ -258,4 +259,4 @@ def main():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
|
||||
@ -99,6 +99,7 @@ type trainingUncertainCandidate struct {
|
||||
type TrainingFeedbackRequest struct {
|
||||
SampleID string `json:"sampleId"`
|
||||
Accepted bool `json:"accepted"`
|
||||
Negative bool `json:"negative,omitempty"`
|
||||
Correction *TrainingCorrection `json:"correction,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
@ -107,6 +108,7 @@ type TrainingFeedbackUpdateRequest struct {
|
||||
SampleID string `json:"sampleId"`
|
||||
AnsweredAt string `json:"answeredAt"`
|
||||
Accepted bool `json:"accepted"`
|
||||
Negative bool `json:"negative,omitempty"`
|
||||
Correction *TrainingCorrection `json:"correction,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
@ -126,6 +128,7 @@ type TrainingAnnotation struct {
|
||||
AnsweredAt string `json:"answeredAt"`
|
||||
Prediction TrainingPrediction `json:"prediction"`
|
||||
Accepted bool `json:"accepted"`
|
||||
Negative bool `json:"negative,omitempty"`
|
||||
Correction *TrainingCorrection `json:"correction,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
@ -176,6 +179,7 @@ type TrainingStatsResponse struct {
|
||||
FeedbackCount int `json:"feedbackCount"`
|
||||
AcceptedCount int `json:"acceptedCount"`
|
||||
CorrectedCount int `json:"correctedCount"`
|
||||
NegativeCount int `json:"negativeCount"`
|
||||
SampleCount int `json:"sampleCount"`
|
||||
BoxCount int `json:"boxCount"`
|
||||
ModelAvailable bool `json:"modelAvailable"`
|
||||
@ -333,12 +337,17 @@ func trainingFilterAnnotations(
|
||||
for _, item := range items {
|
||||
switch cleanFilter {
|
||||
case "accepted":
|
||||
if !item.Accepted {
|
||||
if !item.Accepted || item.Negative {
|
||||
continue
|
||||
}
|
||||
|
||||
case "corrected":
|
||||
if item.Accepted {
|
||||
if item.Accepted || item.Negative {
|
||||
continue
|
||||
}
|
||||
|
||||
case "negative":
|
||||
if !item.Negative {
|
||||
continue
|
||||
}
|
||||
}
|
||||
@ -365,6 +374,9 @@ func trainingAnnotationMatchesQuery(item TrainingAnnotation, cleanQuery string)
|
||||
item.Notes,
|
||||
effective.SexPosition,
|
||||
}
|
||||
if item.Negative {
|
||||
parts = append(parts, "negative negativ leer keine labels")
|
||||
}
|
||||
|
||||
parts = append(parts, effective.PeoplePresent...)
|
||||
parts = append(parts, effective.BodyPartsPresent...)
|
||||
@ -1875,6 +1887,10 @@ func trainingFrameHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func trainingDetectorBoxesForAnnotation(sample *TrainingSample, req TrainingFeedbackRequest) []TrainingBox {
|
||||
if req.Negative {
|
||||
return []TrainingBox{}
|
||||
}
|
||||
|
||||
boxes := []TrainingBox{}
|
||||
|
||||
if req.Correction != nil {
|
||||
@ -1905,6 +1921,17 @@ func trainingDetectorBoxesForAnnotation(sample *TrainingSample, req TrainingFeed
|
||||
return boxes
|
||||
}
|
||||
|
||||
func trainingNegativeCorrection() *TrainingCorrection {
|
||||
return &TrainingCorrection{
|
||||
SexPosition: "unknown",
|
||||
PeoplePresent: []string{},
|
||||
BodyPartsPresent: []string{},
|
||||
ObjectsPresent: []string{},
|
||||
ClothingPresent: []string{},
|
||||
Boxes: []TrainingBox{},
|
||||
}
|
||||
}
|
||||
|
||||
func trainingFeedbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
@ -1922,6 +1949,10 @@ func trainingFeedbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
trainingWriteError(w, http.StatusBadRequest, "sampleId missing")
|
||||
return
|
||||
}
|
||||
if req.Negative {
|
||||
req.Accepted = false
|
||||
req.Correction = trainingNegativeCorrection()
|
||||
}
|
||||
|
||||
root, err := trainingRootDir()
|
||||
if err != nil {
|
||||
@ -1946,6 +1977,7 @@ func trainingFeedbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
AnsweredAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Prediction: sample.Prediction,
|
||||
Accepted: req.Accepted,
|
||||
Negative: req.Negative,
|
||||
Correction: req.Correction,
|
||||
Notes: strings.TrimSpace(req.Notes),
|
||||
}
|
||||
@ -1962,8 +1994,8 @@ func trainingFeedbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
detectorBoxes := trainingDetectorBoxesForAnnotation(sample, req)
|
||||
|
||||
if len(detectorBoxes) > 0 {
|
||||
if err := trainingWriteDetectorSample(root, sample, detectorBoxes); err != nil {
|
||||
if req.Negative || len(detectorBoxes) > 0 {
|
||||
if err := trainingWriteDetectorSample(root, sample, detectorBoxes, req.Negative); err != nil {
|
||||
appLogln("⚠️ detector sample write failed:", err)
|
||||
}
|
||||
}
|
||||
@ -1987,6 +2019,10 @@ func trainingFeedbackUpdateHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
req.SampleID = strings.TrimSpace(req.SampleID)
|
||||
req.AnsweredAt = strings.TrimSpace(req.AnsweredAt)
|
||||
if req.Negative {
|
||||
req.Accepted = false
|
||||
req.Correction = trainingNegativeCorrection()
|
||||
}
|
||||
|
||||
if req.SampleID == "" {
|
||||
trainingWriteError(w, http.StatusBadRequest, "sampleId missing")
|
||||
@ -2039,6 +2075,7 @@ func trainingFeedbackUpdateHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
updated := old
|
||||
updated.Accepted = req.Accepted
|
||||
updated.Negative = req.Negative
|
||||
updated.Notes = strings.TrimSpace(req.Notes)
|
||||
|
||||
if req.Accepted {
|
||||
@ -2073,12 +2110,13 @@ func trainingFeedbackUpdateHandler(w http.ResponseWriter, r *http.Request) {
|
||||
detectorBoxes := trainingDetectorBoxesForAnnotation(sample, TrainingFeedbackRequest{
|
||||
SampleID: req.SampleID,
|
||||
Accepted: req.Accepted,
|
||||
Negative: req.Negative,
|
||||
Correction: req.Correction,
|
||||
Notes: req.Notes,
|
||||
})
|
||||
|
||||
if len(detectorBoxes) > 0 {
|
||||
if err := trainingWriteDetectorSample(root, sample, detectorBoxes); err != nil {
|
||||
if req.Negative || len(detectorBoxes) > 0 {
|
||||
if err := trainingWriteDetectorSample(root, sample, detectorBoxes, req.Negative); err != nil {
|
||||
appLogln("⚠️ detector sample update failed:", err)
|
||||
}
|
||||
}
|
||||
@ -2161,7 +2199,7 @@ func trainingHasDetectorTrainingData(imagesDir string, labelsDir string) bool {
|
||||
id := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name()))
|
||||
labelPath := filepath.Join(labelsDir, id+".txt")
|
||||
|
||||
if fileExistsNonEmpty(labelPath) {
|
||||
if fileExists(labelPath) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
@ -2227,17 +2265,23 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels)
|
||||
valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels)
|
||||
positiveTrainCount := trainingCountPositiveDetectorSamples(detectorTrainImages, detectorTrainLabels)
|
||||
positiveValCount := trainingCountPositiveDetectorSamples(detectorValImages, detectorValLabels)
|
||||
|
||||
if !fileExistsNonEmpty(detectorDatasetYAML) ||
|
||||
trainCount < minDetectorTrainCount ||
|
||||
valCount < minDetectorValCount {
|
||||
valCount < minDetectorValCount ||
|
||||
positiveTrainCount == 0 ||
|
||||
positiveValCount == 0 {
|
||||
trainingWriteError(
|
||||
w,
|
||||
http.StatusBadRequest,
|
||||
fmt.Sprintf(
|
||||
"Zu wenige YOLO26-Box-Labels. Train=%d, Val=%d. Benötigt: mindestens %d Train und %d Val.",
|
||||
"Zu wenige YOLO26-Beispiele. Train=%d (%d positiv), Val=%d (%d positiv). Benötigt: mindestens %d Train, %d Val und je ein positives Beispiel.",
|
||||
trainCount,
|
||||
positiveTrainCount,
|
||||
valCount,
|
||||
positiveValCount,
|
||||
minDetectorTrainCount,
|
||||
minDetectorValCount,
|
||||
),
|
||||
@ -2256,15 +2300,17 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) {
|
||||
"message": "Training gestartet.",
|
||||
"training": trainingGetJobStatus(),
|
||||
"detector": map[string]any{
|
||||
"trainCount": trainCount,
|
||||
"valCount": valCount,
|
||||
"requiredTrain": minDetectorTrainCount,
|
||||
"requiredVal": minDetectorValCount,
|
||||
"datasetYAML": detectorDatasetYAML,
|
||||
"usesSceneCLIP": false,
|
||||
"usesSceneKNN": false,
|
||||
"source": "yolo26_detector",
|
||||
"detectsPosition": true,
|
||||
"trainCount": trainCount,
|
||||
"valCount": valCount,
|
||||
"positiveTrainCount": positiveTrainCount,
|
||||
"positiveValCount": positiveValCount,
|
||||
"requiredTrain": minDetectorTrainCount,
|
||||
"requiredVal": minDetectorValCount,
|
||||
"datasetYAML": detectorDatasetYAML,
|
||||
"usesSceneCLIP": false,
|
||||
"usesSceneKNN": false,
|
||||
"source": "yolo26_detector",
|
||||
"detectsPosition": true,
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -2337,17 +2383,23 @@ func trainingRunJob(ctx context.Context, root string, count int) {
|
||||
|
||||
trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels)
|
||||
valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels)
|
||||
positiveTrainCount := trainingCountPositiveDetectorSamples(detectorTrainImages, detectorTrainLabels)
|
||||
positiveValCount := trainingCountPositiveDetectorSamples(detectorValImages, detectorValLabels)
|
||||
|
||||
fmt.Printf(
|
||||
"🔎 detector data: train=%d val=%d yaml=%v\n",
|
||||
"🔎 detector data: train=%d (%d positive) val=%d (%d positive) yaml=%v\n",
|
||||
trainCount,
|
||||
positiveTrainCount,
|
||||
valCount,
|
||||
positiveValCount,
|
||||
fileExistsNonEmpty(detectorDatasetYAML),
|
||||
)
|
||||
|
||||
if fileExistsNonEmpty(detectorDatasetYAML) &&
|
||||
trainCount >= minDetectorTrainCount &&
|
||||
valCount >= minDetectorValCount {
|
||||
valCount >= minDetectorValCount &&
|
||||
positiveTrainCount > 0 &&
|
||||
positiveValCount > 0 {
|
||||
trainingSetJobStatus(func(s *TrainingJobStatus) {
|
||||
s.Progress = 15
|
||||
s.Step = "YOLO26 Detector wird trainiert…"
|
||||
@ -2398,9 +2450,11 @@ func trainingRunJob(ctx context.Context, root string, count int) {
|
||||
} else {
|
||||
detectorStatus = "skipped_no_detector_data"
|
||||
detectorOutput = fmt.Sprintf(
|
||||
"YOLO26 Detector übersprungen: zu wenige Box-Labels. Train=%d, Val=%d. Benötigt: mindestens %d Train und %d Val.",
|
||||
"YOLO26 Detector übersprungen: zu wenige Beispiele. Train=%d (%d positiv), Val=%d (%d positiv). Benötigt: mindestens %d Train, %d Val und je ein positives Beispiel.",
|
||||
trainCount,
|
||||
positiveTrainCount,
|
||||
valCount,
|
||||
positiveValCount,
|
||||
minDetectorTrainCount,
|
||||
minDetectorValCount,
|
||||
)
|
||||
@ -2545,7 +2599,9 @@ func trainingBuildStats(root string) (*TrainingStatsResponse, error) {
|
||||
|
||||
stats.FeedbackCount++
|
||||
|
||||
if annotation.Accepted {
|
||||
if annotation.Negative {
|
||||
stats.NegativeCount++
|
||||
} else if annotation.Accepted {
|
||||
stats.AcceptedCount++
|
||||
} else {
|
||||
stats.CorrectedCount++
|
||||
@ -2643,6 +2699,10 @@ func trainingBuildStats(root string) (*TrainingStatsResponse, error) {
|
||||
}
|
||||
|
||||
func trainingEffectiveCorrection(annotation TrainingAnnotation) TrainingCorrection {
|
||||
if annotation.Negative {
|
||||
return *trainingNegativeCorrection()
|
||||
}
|
||||
|
||||
if annotation.Correction != nil {
|
||||
return *annotation.Correction
|
||||
}
|
||||
@ -2761,11 +2821,15 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels)
|
||||
valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels)
|
||||
positiveTrainCount := trainingCountPositiveDetectorSamples(detectorTrainImages, detectorTrainLabels)
|
||||
positiveValCount := trainingCountPositiveDetectorSamples(detectorValImages, detectorValLabels)
|
||||
|
||||
datasetReady := fileExistsNonEmpty(detectorDatasetYAML)
|
||||
detectorDataReady := datasetReady &&
|
||||
trainCount >= minDetectorTrainCount &&
|
||||
valCount >= minDetectorValCount
|
||||
valCount >= minDetectorValCount &&
|
||||
positiveTrainCount > 0 &&
|
||||
positiveValCount > 0
|
||||
|
||||
canTrain := feedbackCount >= minTrainingFeedbackCount && detectorDataReady
|
||||
|
||||
@ -2791,10 +2855,12 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
"detectsClothing": true,
|
||||
"detectsBoxes": true,
|
||||
|
||||
"trainCount": trainCount,
|
||||
"valCount": valCount,
|
||||
"requiredTrain": minDetectorTrainCount,
|
||||
"requiredVal": minDetectorValCount,
|
||||
"trainCount": trainCount,
|
||||
"valCount": valCount,
|
||||
"positiveTrainCount": positiveTrainCount,
|
||||
"positiveValCount": positiveValCount,
|
||||
"requiredTrain": minDetectorTrainCount,
|
||||
"requiredVal": minDetectorValCount,
|
||||
|
||||
"datasetReady": datasetReady,
|
||||
"datasetYAML": detectorDatasetYAML,
|
||||
@ -2955,16 +3021,17 @@ func trainingOverallConfidence(
|
||||
// Viele "Passt so"-Antworten bedeuten, dass die Vorhersagen brauchbar sind.
|
||||
// Bei 4/229 ist dieser Teil bewusst sehr niedrig.
|
||||
agreementScore := 0.0
|
||||
if feedbackCount > 0 {
|
||||
agreementScore = clamp01(float64(acceptedCount) / float64(feedbackCount))
|
||||
decisionCount := acceptedCount + correctedCount
|
||||
if decisionCount > 0 {
|
||||
agreementScore = clamp01(float64(acceptedCount) / float64(decisionCount))
|
||||
}
|
||||
|
||||
// Korrekturquote als zusätzlicher Dämpfer.
|
||||
// 98% korrigiert soll die Gesamt-Confidence sichtbar drücken,
|
||||
// aber nicht alle gesammelten Daten entwerten.
|
||||
correctionRate := 0.0
|
||||
if feedbackCount > 0 {
|
||||
correctionRate = clamp01(float64(correctedCount) / float64(feedbackCount))
|
||||
if decisionCount > 0 {
|
||||
correctionRate = clamp01(float64(correctedCount) / float64(decisionCount))
|
||||
}
|
||||
|
||||
correctionPenalty := 1.0 - math.Min(0.45, correctionRate*0.45)
|
||||
@ -3065,6 +3132,42 @@ func trainingCountDetectorSamples(imagesDir string, labelsDir string) int {
|
||||
|
||||
count := 0
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(e.Name()))
|
||||
if !imageExts[ext] {
|
||||
continue
|
||||
}
|
||||
|
||||
id := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name()))
|
||||
labelPath := filepath.Join(labelsDir, id+".txt")
|
||||
|
||||
if fileExists(labelPath) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
func trainingCountPositiveDetectorSamples(imagesDir string, labelsDir string) int {
|
||||
imageExts := map[string]bool{
|
||||
".jpg": true,
|
||||
".jpeg": true,
|
||||
".png": true,
|
||||
".webp": true,
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(imagesDir)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
count := 0
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
@ -4057,39 +4160,11 @@ func trainingApplyDetectorToPrediction(pred TrainingPrediction, det TrainingDete
|
||||
return pred
|
||||
}
|
||||
|
||||
func trainingWriteDetectorSample(root string, sample *TrainingSample, boxes []TrainingBox) error {
|
||||
if sample == nil {
|
||||
return errors.New("sample missing")
|
||||
}
|
||||
|
||||
classMap, err := trainingDetectorClassMap()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
srcFrame := filepath.Join(root, "frames", sample.SampleID+".jpg")
|
||||
if _, err := os.Stat(srcFrame); err != nil {
|
||||
return appErrorf("frame missing: %w", err)
|
||||
}
|
||||
|
||||
// Stabiler 80/20 Split: gleicher sampleID landet immer im gleichen Split.
|
||||
split := trainingStableSplit(sample.SampleID)
|
||||
|
||||
imgDir := filepath.Join(root, "detector", "dataset", "images", split)
|
||||
lblDir := filepath.Join(root, "detector", "dataset", "labels", split)
|
||||
|
||||
if err := os.MkdirAll(imgDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(lblDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dstFrame := filepath.Join(imgDir, sample.SampleID+".jpg")
|
||||
if err := copyFile(srcFrame, dstFrame); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
func trainingDetectorLabelContent(
|
||||
boxes []TrainingBox,
|
||||
classMap map[string]int,
|
||||
allowEmpty bool,
|
||||
) ([]byte, error) {
|
||||
var lines []string
|
||||
|
||||
for _, box := range boxes {
|
||||
@ -4128,11 +4203,60 @@ func trainingWriteDetectorSample(root string, sample *TrainingSample, boxes []Tr
|
||||
}
|
||||
|
||||
if len(lines) == 0 {
|
||||
return errors.New("no valid detector boxes")
|
||||
if allowEmpty {
|
||||
return []byte{}, nil
|
||||
}
|
||||
return nil, errors.New("no valid detector boxes")
|
||||
}
|
||||
|
||||
return []byte(strings.Join(lines, "\n") + "\n"), nil
|
||||
}
|
||||
|
||||
func trainingWriteDetectorSample(
|
||||
root string,
|
||||
sample *TrainingSample,
|
||||
boxes []TrainingBox,
|
||||
allowEmpty bool,
|
||||
) error {
|
||||
if sample == nil {
|
||||
return errors.New("sample missing")
|
||||
}
|
||||
|
||||
classMap, err := trainingDetectorClassMap()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
labelContent, err := trainingDetectorLabelContent(boxes, classMap, allowEmpty)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
srcFrame := filepath.Join(root, "frames", sample.SampleID+".jpg")
|
||||
if _, err := os.Stat(srcFrame); err != nil {
|
||||
return appErrorf("frame missing: %w", err)
|
||||
}
|
||||
|
||||
// Stabiler 80/20 Split: gleicher sampleID landet immer im gleichen Split.
|
||||
split := trainingStableSplit(sample.SampleID)
|
||||
|
||||
imgDir := filepath.Join(root, "detector", "dataset", "images", split)
|
||||
lblDir := filepath.Join(root, "detector", "dataset", "labels", split)
|
||||
|
||||
if err := os.MkdirAll(imgDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(lblDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dstFrame := filepath.Join(imgDir, sample.SampleID+".jpg")
|
||||
if err := copyFile(srcFrame, dstFrame); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
labelPath := filepath.Join(lblDir, sample.SampleID+".txt")
|
||||
return os.WriteFile(labelPath, []byte(strings.Join(lines, "\n")+"\n"), 0644)
|
||||
return os.WriteFile(labelPath, labelContent, 0644)
|
||||
}
|
||||
|
||||
func trainingDeleteDetectorSample(root string, sampleID string) {
|
||||
@ -4160,11 +4284,13 @@ func trainingEnsureDetectorValidationSample(root string) error {
|
||||
valLabels := filepath.Join(root, "detector", "dataset", "labels", "val")
|
||||
|
||||
currentVal := trainingCountDetectorSamples(valImages, valLabels)
|
||||
if currentVal >= minDetectorValCount {
|
||||
currentPositiveVal := trainingCountPositiveDetectorSamples(valImages, valLabels)
|
||||
if currentVal >= minDetectorValCount && currentPositiveVal > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if trainingCountDetectorSamples(trainImages, trainLabels) < minDetectorTrainCount {
|
||||
if trainingCountDetectorSamples(trainImages, trainLabels) < minDetectorTrainCount ||
|
||||
trainingCountPositiveDetectorSamples(trainImages, trainLabels) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -4181,10 +4307,19 @@ func trainingEnsureDetectorValidationSample(root string) error {
|
||||
}
|
||||
|
||||
copied := 0
|
||||
needed := minDetectorValCount - currentVal
|
||||
needed := max(0, minDetectorValCount-currentVal)
|
||||
needsPositive := currentPositiveVal == 0
|
||||
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
iID := strings.TrimSuffix(entries[i].Name(), filepath.Ext(entries[i].Name()))
|
||||
jID := strings.TrimSuffix(entries[j].Name(), filepath.Ext(entries[j].Name()))
|
||||
|
||||
return fileExistsNonEmpty(filepath.Join(trainLabels, iID+".txt")) &&
|
||||
!fileExistsNonEmpty(filepath.Join(trainLabels, jID+".txt"))
|
||||
})
|
||||
|
||||
for _, e := range entries {
|
||||
if copied >= needed {
|
||||
if copied >= needed && !needsPositive {
|
||||
break
|
||||
}
|
||||
|
||||
@ -4202,14 +4337,14 @@ func trainingEnsureDetectorValidationSample(root string) error {
|
||||
srcImage := filepath.Join(trainImages, e.Name())
|
||||
srcLabel := filepath.Join(trainLabels, id+".txt")
|
||||
|
||||
if !fileExistsNonEmpty(srcImage) || !fileExistsNonEmpty(srcLabel) {
|
||||
if !fileExistsNonEmpty(srcImage) || !fileExists(srcLabel) {
|
||||
continue
|
||||
}
|
||||
|
||||
dstImage := filepath.Join(valImages, e.Name())
|
||||
dstLabel := filepath.Join(valLabels, id+".txt")
|
||||
|
||||
if fileExistsNonEmpty(dstImage) && fileExistsNonEmpty(dstLabel) {
|
||||
if fileExistsNonEmpty(dstImage) && fileExists(dstLabel) {
|
||||
continue
|
||||
}
|
||||
|
||||
@ -4221,6 +4356,9 @@ func trainingEnsureDetectorValidationSample(root string) error {
|
||||
}
|
||||
|
||||
copied++
|
||||
if fileExistsNonEmpty(srcLabel) {
|
||||
needsPositive = false
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
133
backend/training_test.go
Normal file
133
backend/training_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
@ -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 ? (
|
||||
@ -1275,4 +1333,4 @@ export default function TrainingFeedbackHistoryModal(props: {
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 = {
|
||||
...correction,
|
||||
peoplePresent: peopleLabelsFromBoxes(normalizedBoxes, labelsRef.current),
|
||||
boxes: normalizedBoxes,
|
||||
}
|
||||
const negative = options?.negative ?? isNegativeCorrection
|
||||
const correctionPayload: CorrectionState = negative
|
||||
? {
|
||||
sexPosition: 'unknown',
|
||||
peoplePresent: [],
|
||||
bodyPartsPresent: [],
|
||||
objectsPresent: [],
|
||||
clothingPresent: [],
|
||||
boxes: [],
|
||||
}
|
||||
: {
|
||||
...correction,
|
||||
peoplePresent: peopleLabelsFromBoxes(normalizedBoxes, labelsRef.current),
|
||||
boxes: normalizedBoxes,
|
||||
}
|
||||
const effectiveAccepted = negative ? false : accepted
|
||||
|
||||
const payload = {
|
||||
sampleId: sample.sampleId,
|
||||
accepted,
|
||||
correction: accepted && correctionPayload.boxes.length === 0
|
||||
accepted: effectiveAccepted,
|
||||
negative,
|
||||
correction: effectiveAccepted && correctionPayload.boxes.length === 0
|
||||
? undefined
|
||||
: correctionPayload,
|
||||
}
|
||||
@ -3497,8 +3570,9 @@ export default function TrainingTab(props: {
|
||||
item.answeredAt === editingFeedback?.answeredAt
|
||||
? {
|
||||
...item,
|
||||
accepted,
|
||||
correction: accepted ? undefined : correctionPayload,
|
||||
accepted: effectiveAccepted,
|
||||
negative,
|
||||
correction: effectiveAccepted ? undefined : correctionPayload,
|
||||
}
|
||||
: item
|
||||
)
|
||||
@ -3507,12 +3581,16 @@ export default function TrainingTab(props: {
|
||||
|
||||
setMessage(
|
||||
wasEditingFeedback
|
||||
? accepted
|
||||
? 'Feedback aktualisiert.'
|
||||
: 'Korrektur aktualisiert.'
|
||||
: accepted
|
||||
? 'Feedback gespeichert.'
|
||||
: 'Korrektur gespeichert.'
|
||||
? negative
|
||||
? 'Negativbeispiel aktualisiert.'
|
||||
: effectiveAccepted
|
||||
? 'Feedback aktualisiert.'
|
||||
: 'Korrektur aktualisiert.'
|
||||
: negative
|
||||
? 'Negativbeispiel gespeichert.'
|
||||
: effectiveAccepted
|
||||
? 'Feedback gespeichert.'
|
||||
: 'Korrektur gespeichert.'
|
||||
)
|
||||
|
||||
await loadTrainingStatus()
|
||||
@ -3540,6 +3618,7 @@ export default function TrainingTab(props: {
|
||||
sample,
|
||||
correction,
|
||||
editingFeedback,
|
||||
isNegativeCorrection,
|
||||
loadNext,
|
||||
loadTrainingStatus,
|
||||
loadNextImportedQueuedSample,
|
||||
@ -3571,6 +3650,7 @@ export default function TrainingTab(props: {
|
||||
setSample(null)
|
||||
setCorrection(predictionToCorrection(null))
|
||||
setHasManualCorrection(false)
|
||||
setNegativeExample(false)
|
||||
setDrawingBox(null)
|
||||
setBoxInteraction(null)
|
||||
setTouchMagnifier(null)
|
||||
@ -3685,6 +3765,7 @@ export default function TrainingTab(props: {
|
||||
|
||||
setSample(null)
|
||||
setCorrection(predictionToCorrection(null))
|
||||
setNegativeExample(false)
|
||||
setTrainingStatus({
|
||||
feedbackCount: 0,
|
||||
requiredCount,
|
||||
@ -4242,12 +4323,19 @@ export default function TrainingTab(props: {
|
||||
0,
|
||||
Number(detector?.requiredVal ?? 3) - Number(detector?.valCount ?? 0)
|
||||
)
|
||||
const missingPositiveTrain = Number(detector?.positiveTrainCount ?? 0) < 1
|
||||
const missingPositiveVal = Number(detector?.positiveValCount ?? 0) < 1
|
||||
const positiveSamplesMissing = missingPositiveTrain || missingPositiveVal
|
||||
const statusText = trainingRunning
|
||||
? shownTrainingStep || 'Training läuft…'
|
||||
: !feedbackReady
|
||||
? `${Math.max(0, requiredCount - feedbackCount)} Feedback fehlen noch`
|
||||
: !detectorReady
|
||||
? `YOLO-Boxen fehlen: ${missingTrain} Train, ${missingVal} Val`
|
||||
? missingTrain > 0 || missingVal > 0
|
||||
? `YOLO-Beispiele fehlen: ${missingTrain} Train, ${missingVal} Val`
|
||||
: positiveSamplesMissing
|
||||
? 'Je ein positives Beispiel in Train und Val erforderlich'
|
||||
: 'YOLO-Datensatz noch nicht bereit'
|
||||
: canStartTraining
|
||||
? 'Bereit zum Trainieren'
|
||||
: 'Noch nicht trainingsbereit'
|
||||
@ -4431,7 +4519,10 @@ export default function TrainingTab(props: {
|
||||
</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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user