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
|
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
|
||||||
|
|||||||
@ -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
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
|
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 ? (
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user