diff --git a/backend/training.go b/backend/training.go index 945c807..f1bbf04 100644 --- a/backend/training.go +++ b/backend/training.go @@ -443,6 +443,9 @@ func trainingNextHandler(w http.ResponseWriter, r *http.Request) { forceNew := r.URL.Query().Get("force") == "1" || strings.EqualFold(r.URL.Query().Get("force"), "true") + preferUncertain := strings.EqualFold(r.URL.Query().Get("mode"), "uncertain") || + strings.EqualFold(r.URL.Query().Get("sampleMode"), "uncertain") + root, err := trainingRootDir() if err != nil { trainingWriteError(w, http.StatusInternalServerError, err.Error()) @@ -452,7 +455,7 @@ func trainingNextHandler(w http.ResponseWriter, r *http.Request) { refreshPrediction := r.URL.Query().Get("refresh") == "1" || strings.EqualFold(r.URL.Query().Get("refresh"), "true") - if !forceNew { + if !forceNew && !preferUncertain { var startedAtMs int64 if refreshPrediction { @@ -476,9 +479,22 @@ func trainingNextHandler(w http.ResponseWriter, r *http.Request) { } } - startedAtMs := publishAnalysisStarted("", 4, "Neues Trainingsbild wird vorbereitet…") + startedAtMs := publishAnalysisStarted("", 4, func() string { + if preferUncertain { + return "Unsichere Prediction wird gesucht…" + } + + return "Neues Trainingsbild wird vorbereitet…" + }()) + + var sample *TrainingSample + + if preferUncertain { + sample, err = trainingCreateUncertainNextSampleWithProgress(startedAtMs) + } else { + sample, err = trainingCreateNextSampleWithProgress(startedAtMs) + } - sample, err := trainingCreateNextSampleWithProgress(startedAtMs) if err != nil { publishAnalysisError(startedAtMs, "", "Trainingsbild konnte nicht erstellt werden.", err) trainingWriteError(w, http.StatusInternalServerError, err.Error()) @@ -1824,6 +1840,153 @@ func trainingCreateNextSample() (*TrainingSample, error) { return sample, nil } +func trainingCreateUncertainNextSampleWithProgress(startedAtMs int64) (*TrainingSample, error) { + const attempts = 8 + + root, err := trainingRootDir() + if err != nil { + return nil, err + } + + var best *TrainingSample + bestScore := -1.0 + errs := []string{} + + for i := 0; i < attempts; i++ { + publishAnalysisStep( + startedAtMs, + i+1, + attempts+1, + "", + fmt.Sprintf("Unsicherer Kandidat %d/%d wird analysiert…", i+1, attempts), + ) + + sample, err := trainingCreateNextSample() + if err != nil { + errs = append(errs, err.Error()) + continue + } + + score := trainingPredictionUncertaintyScore(sample.Prediction) + + if score > bestScore { + if best != nil { + trainingDeleteSampleFiles(root, best.SampleID) + } + + best = sample + bestScore = score + } else { + trainingDeleteSampleFiles(root, sample.SampleID) + } + } + + if best == nil { + if len(errs) > 0 { + return nil, errors.New(strings.Join(errs, "; ")) + } + + return nil, errors.New("keine unsicheren Trainingskandidaten gefunden") + } + + publishAnalysisStep( + startedAtMs, + attempts+1, + attempts+1, + best.SourceFile, + fmt.Sprintf("Unsicherer Kandidat gewählt · Score %.0f%%", bestScore*100), + ) + + return best, nil +} + +func trainingDeleteSampleFiles(root string, sampleID string) { + sampleID = strings.TrimSpace(sampleID) + if sampleID == "" || strings.Contains(sampleID, "/") || strings.Contains(sampleID, "\\") { + return + } + + _ = os.Remove(filepath.Join(root, "samples", sampleID+".json")) + _ = os.Remove(filepath.Join(root, "frames", sampleID+".jpg")) +} + +func trainingPredictionUncertaintyScore(pred TrainingPrediction) float64 { + if !pred.ModelAvailable { + return 0.10 + } + + scores := []float64{} + + addScore := func(score float64) { + if math.IsNaN(score) || math.IsInf(score, 0) { + return + } + + if score <= 0 { + return + } + + scores = append(scores, clamp01(score)) + } + + if strings.TrimSpace(pred.SexPosition) != "" && + strings.TrimSpace(pred.SexPosition) != "unknown" { + addScore(pred.SexPositionScore) + } + + for _, box := range pred.Boxes { + addScore(box.Score) + } + + if len(pred.Boxes) == 0 { + for _, item := range pred.BodyPartsPresent { + addScore(item.Score) + } + + for _, item := range pred.ObjectsPresent { + addScore(item.Score) + } + + for _, item := range pred.ClothingPresent { + addScore(item.Score) + } + } + + if len(scores) == 0 { + // Modell ist verfügbar, erkennt aber nichts. + // Das kann ein nützliches Hard-Negative oder ein False-Negative sein. + return 0.35 + } + + sum := 0.0 + + for _, score := range scores { + // Höchste Unsicherheit ungefähr im mittleren Bereich. + // 0.55 ist absichtlich etwas niedriger als 0.75, + // damit Low/Mid-Confidence-Fälle bevorzugt werden. + distance := math.Abs(score - 0.55) + uncertainty := 1.0 - distance/0.55 + sum += clamp01(uncertainty) + } + + avg := sum / float64(len(scores)) + + // Viele Boxen mit mittlerer Confidence sind besonders wertvoll. + boxBonus := math.Min(0.12, float64(len(pred.Boxes))*0.025) + + // Sehr niedrige Confidence soll ebenfalls auftauchen, + // aber nicht alle Ergebnisse dominieren. + lowConfidenceBonus := 0.0 + for _, score := range scores { + if score >= 0.25 && score <= 0.75 { + lowConfidenceBonus = 0.08 + break + } + } + + return clamp01(avg + boxBonus + lowConfidenceBonus) +} + func trainingCreateNextSampleWithProgress(startedAtMs int64) (*TrainingSample, error) { publishAnalysisStep(startedAtMs, 1, 4, "", "Video wird ausgewählt…") diff --git a/frontend/src/components/ui/TrainingTab.tsx b/frontend/src/components/ui/TrainingTab.tsx index 26fdfd1..e6b3300 100644 --- a/frontend/src/components/ui/TrainingTab.tsx +++ b/frontend/src/components/ui/TrainingTab.tsx @@ -184,6 +184,8 @@ type TrainingNotice = { progress?: number } +type TrainingSampleMode = 'random' | 'uncertain' + function trainingNoticeClass(kind: TrainingNoticeKind) { switch (kind) { case 'success': @@ -2042,6 +2044,7 @@ export default function TrainingTab(props: { const [boxLabel, setBoxLabel] = useState('') const [activeBoxIndex, setActiveBoxIndex] = useState(null) const [imageReloadKey, setImageReloadKey] = useState(0) + const [trainingSampleMode, setTrainingSampleMode] = useState('random') const [expandedCorrectionSections, setExpandedCorrectionSections] = useState({ sexPosition: false, people: false, @@ -2293,15 +2296,20 @@ export default function TrainingTab(props: { forceNew?: boolean refreshPrediction?: boolean preserveNotice?: boolean + mode?: TrainingSampleMode }) => { + const mode = opts?.mode ?? trainingSampleMode + const uncertainMode = mode === 'uncertain' && !opts?.refreshPrediction setLoading(true) setAnalysisProgress(8) setAnalysisStep( opts?.refreshPrediction ? 'Aktuelles Bild wird neu analysiert…' - : opts?.forceNew - ? 'Neues Trainingsbild wird gesucht…' - : 'Trainingsbild wird geladen…' + : uncertainMode + ? 'Unsichere Prediction wird gesucht…' + : opts?.forceNew + ? 'Neues Trainingsbild wird gesucht…' + : 'Trainingsbild wird geladen…' ) if (!opts?.preserveNotice) { @@ -2314,11 +2322,16 @@ export default function TrainingTab(props: { if (opts?.forceNew) params.set('force', '1') if (opts?.refreshPrediction) params.set('refresh', '1') + if (uncertainMode) params.set('mode', 'uncertain') const url = `/api/training/next${params.toString() ? `?${params.toString()}` : ''}` setAnalysisProgress(25) - setAnalysisStep('Bild wird vorbereitet…') + setAnalysisStep( + uncertainMode + ? 'Mehrere Kandidaten werden bewertet…' + : 'Bild wird vorbereitet…' + ) const res = await fetch(url, { cache: 'no-store' }) const data = await res.json().catch(() => null) @@ -2371,7 +2384,7 @@ export default function TrainingTab(props: { setAnalysisStep('') }, 500) } - }, []) + }, [trainingSampleMode]) const reloadCurrentImage = useCallback(async () => { setDrawingBox(null) @@ -3431,6 +3444,64 @@ export default function TrainingTab(props: { +
+ +
+ {trainingRunning || feedbackCount < requiredCount ? (
@@ -4394,7 +4465,11 @@ export default function TrainingTab(props: { disabled={uiLocked || frameBusy || !sample} onClick={() => void loadNext({ forceNew: true })} className="w-full justify-center px-2 text-xs sm:text-sm" - title="Dieses Bild nicht bewerten und ein anderes laden." + title={ + trainingSampleMode === 'uncertain' + ? 'Dieses Bild nicht bewerten und eine andere unsichere Prediction laden.' + : 'Dieses Bild nicht bewerten und ein anderes laden.' + } > Skip Überspringen