From 5679d19b306b61cc224c0ef9108745233e4b527b Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Wed, 6 May 2026 21:05:08 +0200 Subject: [PATCH] bugfixes --- backend/__pycache__/ai_server.cpython-314.pyc | Bin 20147 -> 19096 bytes backend/ai_server.py | 59 +- backend/analyze.go | 679 +++++++++++------- backend/assets_generate.go | 28 +- backend/rating.go | 4 +- backend/training.go | 38 +- frontend/src/App.tsx | 28 +- .../src/components/ui/RecorderSettings.tsx | 22 +- frontend/src/components/ui/TrainingTab.tsx | 236 ++---- 9 files changed, 552 insertions(+), 542 deletions(-) diff --git a/backend/__pycache__/ai_server.cpython-314.pyc b/backend/__pycache__/ai_server.cpython-314.pyc index 49e37460f58a08163ec04ed0f491ff2e38a78709..6265ae864dac9ed41f6fc259f04cb4107e6d9101 100644 GIT binary patch delta 3574 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 YsQqS}1_Wk34cxS2GqCaqPxNp99~|@vQUCw| delta 4571 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 diff --git a/backend/ai_server.py b/backend/ai_server.py index eefee16..7351092 100644 --- a/backend/ai_server.py +++ b/backend/ai_server.py @@ -123,18 +123,10 @@ CLOTHING_LABELS = LABEL_GROUPS["clothing"] POSITION_LABELS = set() PERSON_LABELS = { - "person", "person_male", "person_female", - "person_unknown", - "male_person", - "female_person", } -MALE_LABELS = {"person_male", "male_person"} -FEMALE_LABELS = {"person_female", "female_person"} -UNKNOWN_PERSON_LABELS = {"person", "person_unknown"} - _MODEL_PATH = "" _MODEL_ERROR = "" _LABEL_ERROR = "" @@ -161,12 +153,9 @@ def empty_prediction(source: str = "model_missing") -> dict: return { "modelAvailable": False, "source": source, - "peopleCount": 0, - "maleCount": 0, - "femaleCount": 0, - "unknownCount": 0, "sexPosition": "unknown", "sexPositionScore": 0.0, + "peoplePresent": [], "bodyPartsPresent": [], "objectsPresent": [], "clothingPresent": [], @@ -248,13 +237,6 @@ def load_label_groups_safe() -> None: PERSON_LABELS = { label for label in LABEL_GROUPS["people"] if label - } | { - "person", - "person_male", - "person_female", - "person_unknown", - "male_person", - "female_person", } @@ -307,6 +289,7 @@ def prediction_from_result(result) -> dict: names = result.names or {} boxes_out = [] + people_present = [] body_parts = [] objects = [] clothing = [] @@ -333,6 +316,21 @@ def prediction_from_result(result) -> dict: w = max(0.0, min(1.0 - x, w)) h = max(0.0, min(1.0 - y, h)) + is_person = label in PERSON_LABELS + is_body = label in BODY_LABELS + is_object = label in OBJECT_LABELS + is_clothing = label in CLOTHING_LABELS + is_position = label in POSITION_LABELS + + if is_position: + if score > sex_position_score: + sex_position = label + sex_position_score = score + continue + + if not (is_person or is_body or is_object or is_clothing): + continue + boxes_out.append({ "label": label, "score": score, @@ -342,33 +340,24 @@ def prediction_from_result(result) -> dict: "h": h, }) - if label in BODY_LABELS: + if is_person: + best_score(people_present, label, score) + + if is_body: best_score(body_parts, label, score) - if label in OBJECT_LABELS: + if is_object: best_score(objects, label, score) - if label in CLOTHING_LABELS: + if is_clothing: best_score(clothing, label, score) - if label in POSITION_LABELS and score > sex_position_score: - sex_position = label - sex_position_score = score - - people_count = sum(1 for box in boxes_out if box["label"] in PERSON_LABELS) - male_count = sum(1 for box in boxes_out if box["label"] in MALE_LABELS) - female_count = sum(1 for box in boxes_out if box["label"] in FEMALE_LABELS) - unknown_count = sum(1 for box in boxes_out if box["label"] in UNKNOWN_PERSON_LABELS) - return { "modelAvailable": True, "source": f"yolo-server:{Path(_MODEL_PATH).name}", - "peopleCount": people_count, - "maleCount": male_count, - "femaleCount": female_count, - "unknownCount": unknown_count, "sexPosition": sex_position, "sexPositionScore": sex_position_score, + "peoplePresent": people_present, "bodyPartsPresent": body_parts, "objectsPresent": objects, "clothingPresent": clothing, diff --git a/backend/analyze.go b/backend/analyze.go index 76ce244..1a8b0d7 100644 --- a/backend/analyze.go +++ b/backend/analyze.go @@ -54,8 +54,13 @@ type videoFrameSample struct { const ( analyzeSegmentMergeGapSeconds = 8.0 - nsfwThresholdModerate = 0.35 - nsfwThresholdStrong = 0.60 + + // Ein Label darf kurz verschwinden, ohne dass ein neues Segment entsteht. + // Bei 3s Frame-Intervall heißt das: ein fehlender Frame wird überbrückt. + analyzeLabelInvisibleGraceSeconds = 3.0 + + nsfwThresholdModerate = 0.35 + nsfwThresholdStrong = 0.60 // Video-Modus: extrahiert 1 Frame alle N Sekunden. // 1 = jeder Sekunde, 3 = alle 3 Sekunden, 5 = alle 5 Sekunden. @@ -109,26 +114,15 @@ func shouldAutoSelectAnalyzeHit(label string) bool { return false } - autoSelectedAILabelsOnce.Do(func() { - autoSelectedAILabelsCache = autoSelectedAILabelSet() - }) - - _, ok := autoSelectedAILabelsCache[label] + labels := autoSelectedAILabelSet() + _, ok := labels[label] return ok } var nsfwIgnoredLabels = map[string]struct{}{ // Personen sollen nicht als interessante Segmente auftauchen. - "person": {}, - "person_male": {}, - "person_female": {}, - "person_unknown": {}, - "male_person": {}, - "female_person": {}, - - // Falls dein Detector irgendwann diese Varianten liefert: - "people_male": {}, - "people_female": {}, + "person_male": {}, + "person_female": {}, } func isIgnoredNSFWLabel(label string) bool { @@ -1084,26 +1078,43 @@ func appendNSFWHitFromPrediction( t float64, ) []analyzeHit { if !pred.ModelAvailable { + appLogln("⚠️ nsfw: modelAvailable=false bei", t) return hits } nsfwResults := trainingPredictionToNSFWResults(pred) - bestLabel, bestScore := pickBestNSFWResult(nsfwResults) - if bestLabel == "" { + if len(nsfwResults) == 0 { return hits } - if bestScore < nsfwThresholdForLabel(bestLabel) { - return hits + for _, r := range nsfwResults { + label := strings.ToLower(strings.TrimSpace(r.Label)) + if label == "" || label == "unknown" { + continue + } + if isIgnoredNSFWLabel(label) || isPersonSegmentLabel(label) { + continue + } + + score := r.Score + if score <= 0 { + score = 1 + } + + if score < nsfwThresholdForLabel(label) { + continue + } + + hits = append(hits, analyzeHit{ + Time: t, + Label: label, + Score: score, + Start: t, + End: t, + }) } - return append(hits, analyzeHit{ - Time: t, - Label: bestLabel, - Score: bestScore, - Start: t, - End: t, - }) + return hits } type highlightSignal struct { @@ -1351,15 +1362,14 @@ func buildCombinedHighlightHitFromPrediction(pred TrainingPrediction, t float64) addHighlightSignal(best, "detector:"+label, box.Score) } - if len(best) < 2 { - return analyzeHit{}, false - } - signals := make([]highlightSignal, 0, len(best)) groupSeen := map[string]bool{} nonPositionCount := 0 hasPosition := false + var bestSingle highlightSignal + var bestSingleQuality float64 + for _, sig := range best { if sig.Label == "" { continue @@ -1369,27 +1379,62 @@ func buildCombinedHighlightHitFromPrediction(pred TrainingPrediction, t float64) hasPosition = true } else { nonPositionCount++ + + sev := segmentSeverityWeight(sig.Label) + quality := sig.Score * sev + + if quality > bestSingleQuality { + bestSingle = sig + bestSingleQuality = quality + } } groupSeen[sig.Group] = true signals = append(signals, sig) } - // Nur echte interessante Kombis: - // - Position + mindestens ein weiteres Signal - // - oder mindestens zwei Nicht-Positions-Signale - // - oder mindestens zwei unterschiedliche Signalgruppen + // Fallback: starke Einzel-Treffer sollen auch Highlights werden. + // Sonst verschwinden viele explizite Stellen, wenn sie nicht zufällig + // im selben Frame mit Position/Object/Clothing kombiniert werden. + returnSingleIfGoodEnough := func() (analyzeHit, bool) { + if bestSingle.Label == "" { + return analyzeHit{}, false + } + + sev := segmentSeverityWeight(bestSingle.Label) + + // Nur wirklich relevante Einzel-Signale übernehmen. + if sev < 0.65 { + return analyzeHit{}, false + } + if bestSingle.Score < 0.35 { + return analyzeHit{}, false + } + if bestSingleQuality < 0.32 { + return analyzeHit{}, false + } + + return analyzeHit{ + Time: t, + Label: bestSingle.Label, + Score: bestSingle.Score, + Start: t, + End: t, + }, true + } + + // Combo nur, wenn wirklich genug Kontext da ist. if len(signals) < 2 { - return analyzeHit{}, false + return returnSingleIfGoodEnough() } if hasPosition && nonPositionCount < 1 { - return analyzeHit{}, false + return returnSingleIfGoodEnough() } if !hasPosition && nonPositionCount < 2 { - return analyzeHit{}, false + return returnSingleIfGoodEnough() } if len(groupSeen) < 2 && len(signals) < 3 { - return analyzeHit{}, false + return returnSingleIfGoodEnough() } sort.SliceStable(signals, func(i, j int) bool { @@ -1464,17 +1509,84 @@ func buildCombinedHighlightHitFromPrediction(pred TrainingPrediction, t float64) }, true } +func buildHighlightHitsFromPrediction(pred TrainingPrediction, t float64) []analyzeHit { + if !pred.ModelAvailable { + return nil + } + + best := map[string]highlightSignal{} + + sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition)) + if sexPosition != "" && sexPosition != "unknown" && isKnownPositionLabel(sexPosition) { + addHighlightSignal(best, "position:"+sexPosition, pred.SexPositionScore) + } + + addHighlightSignalsFromScoredLabels(best, "body", pred.BodyPartsPresent) + addHighlightSignalsFromScoredLabels(best, "object", pred.ObjectsPresent) + addHighlightSignalsFromScoredLabels(best, "clothing", pred.ClothingPresent) + + for _, box := range pred.Boxes { + label := strings.ToLower(strings.TrimSpace(box.Label)) + if label == "" || label == "unknown" { + continue + } + if isIgnoredNSFWLabel(label) { + continue + } + if !shouldAutoSelectAnalyzeHit(label) { + continue + } + + addHighlightSignal(best, "detector:"+label, box.Score) + } + + out := make([]analyzeHit, 0, len(best)) + + for _, sig := range best { + label := strings.ToLower(strings.TrimSpace(sig.Label)) + if label == "" { + continue + } + + // Schwache Positions-Kontexte wie standing/sitting nicht alleine als Segment anzeigen. + if sig.Group == "position" && segmentSeverityWeight(label) < 0.70 { + continue + } + + out = append(out, analyzeHit{ + Time: t, + Label: label, + Score: sig.Score, + Start: t, + End: t, + }) + } + + sort.SliceStable(out, func(i, j int) bool { + wi := segmentSeverityWeight(out[i].Label) * out[i].Score + wj := segmentSeverityWeight(out[j].Label) * out[j].Score + + if wi != wj { + return wi > wj + } + + return out[i].Label < out[j].Label + }) + + return out +} + func appendHighlightHitsFromPrediction( hits []analyzeHit, pred TrainingPrediction, t float64, ) []analyzeHit { - hit, ok := buildCombinedHighlightHitFromPrediction(pred, t) - if !ok { + next := buildHighlightHitsFromPrediction(pred, t) + if len(next) == 0 { return hits } - return append(hits, hit) + return append(hits, next...) } func analyzeVideoFromFrames(ctx context.Context, outPath, goal string) ([]analyzeHit, error) { @@ -2191,18 +2303,27 @@ func segmentTagsFromAnalyzeLabel(label string) []string { return out } +func analyzeHitContinuationGapSeconds() float64 { + // Treffer bei 0s und 6s sollen bei 3s Sampling noch zusammengehören: + // 0s erkannt, 3s nicht erkannt, 6s wieder erkannt. + return float64(analyzeVideoFrameIntervalSeconds) + analyzeLabelInvisibleGraceSeconds + 0.25 +} + func mergeAnalyzeHits(in []analyzeHit) []analyzeHit { if len(in) == 0 { return []analyzeHit{} } - cp := make([]analyzeHit, 0, len(in)) + maxGap := analyzeHitContinuationGapSeconds() + + byLabel := map[string][]analyzeHit{} + for _, h := range in { label := strings.ToLower(strings.TrimSpace(h.Label)) - if label == "" { + if label == "" || label == "unknown" { continue } - if isIgnoredNSFWLabel(label) { + if isIgnoredNSFWLabel(label) || isPersonSegmentLabel(label) { continue } @@ -2221,62 +2342,84 @@ func mergeAnalyzeHits(in []analyzeHit) []analyzeHit { } } + if start > end { + start, end = end, start + } + h.Label = label h.Start = start h.End = end - cp = append(cp, h) - } - if len(cp) == 0 { - return []analyzeHit{} - } - - sort.Slice(cp, func(i, j int) bool { - if cp[i].Start != cp[j].Start { - return cp[i].Start < cp[j].Start - } - if cp[i].End != cp[j].End { - return cp[i].End < cp[j].End - } - return cp[i].Label < cp[j].Label - }) - - out := make([]analyzeHit, 0, len(cp)) - cur := cp[0] - - for i := 1; i < len(cp); i++ { - n := cp[i] - - // Direkt aufeinanderfolgende Treffer mit gleichem Label immer zusammenfassen. - // Sobald ein anderes Label dazwischen liegt, wird automatisch nicht gemergt. - sameLabel := sameAnalyzeSegmentLabel(cur.Label, n.Label) - gap := n.Start - cur.End - - if sameLabel && gap >= -0.25 && gap <= analyzeSegmentMergeGapSeconds { - cur.Label = preferAnalyzeSegmentLabel(cur.Label, n.Label) - if n.Start < cur.Start { - cur.Start = n.Start - } - if n.End > cur.End { - cur.End = n.End - } - if n.Score > cur.Score { - cur.Score = n.Score - } - cur.Time = (cur.Start + cur.End) / 2 + key := normalizeSegmentLabel(label) + if key == "" { continue } - out = append(out, cur) - cur = n + byLabel[key] = append(byLabel[key], h) } - out = append(out, cur) + out := make([]analyzeHit, 0, len(in)) + + for _, items := range byLabel { + if len(items) == 0 { + continue + } + + sort.SliceStable(items, func(i, j int) bool { + if items[i].Start != items[j].Start { + return items[i].Start < items[j].Start + } + if items[i].End != items[j].End { + return items[i].End < items[j].End + } + return items[i].Label < items[j].Label + }) + + cur := items[0] + + for i := 1; i < len(items); i++ { + n := items[i] + gap := n.Start - cur.End + + if gap >= -0.25 && gap <= maxGap { + cur.Label = preferAnalyzeSegmentLabel(cur.Label, n.Label) + + if n.Start < cur.Start { + cur.Start = n.Start + } + if n.End > cur.End { + cur.End = n.End + } + if n.Score > cur.Score { + cur.Score = n.Score + } + + cur.Time = (cur.Start + cur.End) / 2 + continue + } + + out = append(out, cur) + cur = n + } + + out = append(out, cur) + } + + sort.SliceStable(out, func(i, j int) bool { + if out[i].Start != out[j].Start { + return out[i].Start < out[j].Start + } + if out[i].End != out[j].End { + return out[i].End < out[j].End + } + return normalizeSegmentLabel(out[i].Label) < normalizeSegmentLabel(out[j].Label) + }) + return out } func inferAnalyzePointSpanSeconds(hits []analyzeHit, duration float64) float64 { - const fallback = 3.0 + const fallback = 10.0 if len(hits) < 2 { return fallback @@ -2338,8 +2481,8 @@ func inferAnalyzePointSpanSeconds(hits []analyzeHit, duration float64) float64 { // aber wir deckeln, damit Sparse-Hits nicht riesig werden. span := median * 0.90 - if span < 2 { - span = 2 + if span < 6 { + span = 6 } if span > 12 { span = 12 @@ -2387,17 +2530,56 @@ func expandAnalyzePointToSpan(t, span, duration float64) (float64, float64) { return start, end } -func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegmentMeta { +type analyzeLabelSegmentPoint struct { + Label string + Start float64 + End float64 + Score float64 +} + +func isAllowedAnalyzeSegmentLabel(label string) bool { + label = strings.ToLower(strings.TrimSpace(label)) + if label == "" || label == "unknown" { + return false + } + if isIgnoredNSFWLabel(label) || isPersonSegmentLabel(label) { + return false + } + + if strings.HasPrefix(label, "combo:") { + for _, part := range segmentLabelParts(label) { + if isAllowedAnalyzeSegmentLabel(part) { + return true + } + } + return false + } + + raw := normalizeSegmentLabel(label) + if raw == "" || raw == "unknown" { + return false + } + + return shouldAutoSelectAnalyzeHit(raw) || isKnownPositionLabel(raw) +} + +func buildLabelContinuitySegmentsFromAnalyzeHits( + hits []analyzeHit, + duration float64, +) []aiSegmentMeta { if len(hits) == 0 || duration <= 0 { return []aiSegmentMeta{} } - pointSpan := inferAnalyzePointSpanSeconds(hits, duration) + sampleSpan := math.Max(1.0, float64(analyzeVideoFrameIntervalSeconds)) + halfSample := sampleSpan / 2.0 + maxGap := analyzeHitContinuationGapSeconds() - out := make([]aiSegmentMeta, 0, len(hits)) + byLabel := map[string][]analyzeLabelSegmentPoint{} for _, hit := range hits { - if !shouldAutoSelectAnalyzeHit(hit.Label) { + label := strings.ToLower(strings.TrimSpace(hit.Label)) + if !isAllowedAnalyzeSegmentLabel(label) { continue } @@ -2420,201 +2602,174 @@ func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegme start, end = end, start } + if start < 0 { + start = hit.Time + } + if end < 0 { + end = start + } + start = math.Max(0, math.Min(start, duration)) end = math.Max(0, math.Min(end, duration)) - // Wichtig: - // Einzelne AI-Treffer sind oft Punkt-Treffer: Start == End. - // Für Segmente und Rating brauchen sie aber eine kleine Dauer. - if end <= start { - marker := hit.Time - if marker < 0 { - marker = start - } - - start, end = expandAnalyzePointToSpan(marker, pointSpan, duration) - } - - if end <= start { + key := normalizeSegmentLabel(label) + if key == "" { continue } - label := strings.ToLower(strings.TrimSpace(hit.Label)) + score := hit.Score + if score <= 0 { + score = 1 + } - out = append(out, aiSegmentMeta{ - Label: label, - StartSeconds: start, - EndSeconds: end, - DurationSeconds: end - start, - Score: hit.Score, - AutoSelected: true, - Position: segmentPositionFromAnalyzeLabel(label), - Tags: segmentTagsFromAnalyzeLabel(label), + byLabel[key] = append(byLabel[key], analyzeLabelSegmentPoint{ + Label: label, + Start: start, + End: end, + Score: score, }) } - if len(out) == 0 { - return []aiSegmentMeta{} + out := make([]aiSegmentMeta, 0) + + for _, points := range byLabel { + if len(points) == 0 { + continue + } + + sort.SliceStable(points, func(i, j int) bool { + if points[i].Start != points[j].Start { + return points[i].Start < points[j].Start + } + if points[i].End != points[j].End { + return points[i].End < points[j].End + } + return points[i].Label < points[j].Label + }) + + curLabel := points[0].Label + curStartMarker := points[0].Start + curEndMarker := points[0].End + curScoreSum := points[0].Score + curScoreCount := 1 + + for i := 1; i < len(points); i++ { + n := points[i] + + gap := n.Start - curEndMarker + + if gap >= -0.25 && gap <= maxGap { + curLabel = preferAnalyzeSegmentLabel(curLabel, n.Label) + + if n.Start < curStartMarker { + curStartMarker = n.Start + } + if n.End > curEndMarker { + curEndMarker = n.End + } + + curScoreSum += n.Score + curScoreCount++ + continue + } + + segment := makeLabelContinuitySegment( + curLabel, + curStartMarker, + curEndMarker, + curScoreSum, + curScoreCount, + halfSample, + duration, + ) + + if segment.DurationSeconds > 0 { + out = append(out, segment) + } + + curLabel = n.Label + curStartMarker = n.Start + curEndMarker = n.End + curScoreSum = n.Score + curScoreCount = 1 + } + + segment := makeLabelContinuitySegment( + curLabel, + curStartMarker, + curEndMarker, + curScoreSum, + curScoreCount, + halfSample, + duration, + ) + + if segment.DurationSeconds > 0 { + out = append(out, segment) + } } - sort.Slice(out, func(i, j int) bool { + sort.SliceStable(out, func(i, j int) bool { if out[i].StartSeconds != out[j].StartSeconds { return out[i].StartSeconds < out[j].StartSeconds } if out[i].EndSeconds != out[j].EndSeconds { return out[i].EndSeconds < out[j].EndSeconds } - return out[i].Label < out[j].Label + return normalizeSegmentLabel(out[i].Label) < normalizeSegmentLabel(out[j].Label) }) - merged := make([]aiSegmentMeta, 0, len(out)) - cur := out[0] + return out +} - for i := 1; i < len(out); i++ { - n := out[i] +func makeLabelContinuitySegment( + label string, + startMarker float64, + endMarker float64, + scoreSum float64, + scoreCount int, + halfSample float64, + duration float64, +) aiSegmentMeta { + label = strings.ToLower(strings.TrimSpace(label)) - gap := n.StartSeconds - cur.EndSeconds - if gap < 0 { - gap = 0 - } + start := startMarker - halfSample + end := endMarker + halfSample - if sameAnalyzeSegmentLabel(cur.Label, n.Label) && gap <= analyzeSegmentMergeGapSeconds { - cur.Label = preferAnalyzeSegmentLabel(cur.Label, n.Label) - if n.StartSeconds < cur.StartSeconds { - cur.StartSeconds = n.StartSeconds - } - if n.EndSeconds > cur.EndSeconds { - cur.EndSeconds = n.EndSeconds - } - cur.DurationSeconds = cur.EndSeconds - cur.StartSeconds - if n.Score > cur.Score { - cur.Score = n.Score - } - cur.AutoSelected = cur.AutoSelected || n.AutoSelected - continue - } - - merged = append(merged, cur) - cur = n + if start < 0 { + start = 0 + } + if duration > 0 && end > duration { + end = duration } - merged = append(merged, cur) - return mergeAdjacentAISegments(merged, analyzeSegmentMergeGapSeconds) + if end <= start { + end = math.Min(duration, start+math.Max(1, halfSample*2)) + } + + score := 0.0 + if scoreCount > 0 { + score = scoreSum / float64(scoreCount) + } + + return aiSegmentMeta{ + Label: label, + StartSeconds: start, + EndSeconds: end, + DurationSeconds: math.Max(0, end-start), + Score: score, + AutoSelected: true, + Position: segmentPositionFromAnalyzeLabel(label), + Tags: segmentTagsFromAnalyzeLabel(label), + } +} + +func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegmentMeta { + return buildLabelContinuitySegmentsFromAnalyzeHits(hits, duration) } func buildHighlightSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegmentMeta { - if len(hits) == 0 || duration <= 0 { - return []aiSegmentMeta{} - } - - pointSpan := inferAnalyzePointSpanSeconds(hits, duration) - - out := make([]aiSegmentMeta, 0, len(hits)) - - for _, hit := range hits { - label := strings.ToLower(strings.TrimSpace(hit.Label)) - if label == "" || label == "unknown" { - continue - } - - if isIgnoredNSFWLabel(label) { - continue - } - - start := hit.Start - end := hit.End - - if start < 0 && end < 0 { - start = hit.Time - end = hit.Time - } else { - if start < 0 { - start = hit.Time - } - if end < 0 { - end = hit.Time - } - } - - if start > end { - start, end = end, start - } - - start = math.Max(0, math.Min(start, duration)) - end = math.Max(0, math.Min(end, duration)) - - if end <= start { - marker := hit.Time - if marker < 0 { - marker = start - } - - start, end = expandAnalyzePointToSpan(marker, pointSpan, duration) - } - - if end <= start { - continue - } - - out = append(out, aiSegmentMeta{ - Label: label, - StartSeconds: start, - EndSeconds: end, - DurationSeconds: end - start, - Score: hit.Score, - AutoSelected: true, - Position: segmentPositionFromAnalyzeLabel(label), - Tags: segmentTagsFromAnalyzeLabel(label), - }) - } - - if len(out) == 0 { - return []aiSegmentMeta{} - } - - sort.Slice(out, func(i, j int) bool { - if out[i].StartSeconds != out[j].StartSeconds { - return out[i].StartSeconds < out[j].StartSeconds - } - if out[i].EndSeconds != out[j].EndSeconds { - return out[i].EndSeconds < out[j].EndSeconds - } - return out[i].Label < out[j].Label - }) - - merged := make([]aiSegmentMeta, 0, len(out)) - cur := out[0] - - for i := 1; i < len(out); i++ { - n := out[i] - - gap := n.StartSeconds - cur.EndSeconds - if gap < 0 { - gap = 0 - } - - if sameAnalyzeSegmentLabel(cur.Label, n.Label) && gap <= analyzeSegmentMergeGapSeconds { - cur.Label = preferAnalyzeSegmentLabel(cur.Label, n.Label) - if n.StartSeconds < cur.StartSeconds { - cur.StartSeconds = n.StartSeconds - } - if n.EndSeconds > cur.EndSeconds { - cur.EndSeconds = n.EndSeconds - } - cur.DurationSeconds = cur.EndSeconds - cur.StartSeconds - if n.Score > cur.Score { - cur.Score = n.Score - } - cur.AutoSelected = cur.AutoSelected || n.AutoSelected - continue - } - - merged = append(merged, cur) - cur = n - } - - merged = append(merged, cur) - return mergeAdjacentAISegments(merged, analyzeSegmentMergeGapSeconds) + return buildLabelContinuitySegmentsFromAnalyzeHits(hits, duration) } func buildAnalyzeSegmentsForGoal( diff --git a/backend/assets_generate.go b/backend/assets_generate.go index e83cb19..7e3d582 100644 --- a/backend/assets_generate.go +++ b/backend/assets_generate.go @@ -1433,29 +1433,13 @@ func hasAIAnalysisForOutputGoal(outPath string, goal string) bool { return false } + rawHits, hasHits := aiMap["hits"].([]any) + rawSegs, hasSegs := aiMap["segments"].([]any) + // Wichtig: - // Auch eine Analyse ohne Treffer ist fertig, solange sie gespeichert wurde. - if rawGoal, ok := aiMap["goal"].(string); ok && normalizeAIGoal(rawGoal) == goal { - return true - } - - if rawMode, ok := aiMap["mode"].(string); ok && strings.TrimSpace(rawMode) != "" { - return true - } - - if _, ok := aiMap["hits"]; ok { - return true - } - - if _, ok := aiMap["segments"]; ok { - return true - } - - if _, ok := aiMap["analyzedAtUnix"]; ok { - return true - } - - return false + // Eine leere Analyse zählt NICHT mehr als fertig. + // Sonst bleibt ein kaputter 0-Treffer-Run für immer gecached. + return (hasHits && len(rawHits) > 0) || (hasSegs && len(rawSegs) > 0) } func hasAIAnalysisForAllOutputGoals(outPath string, goals []string) bool { diff --git a/backend/rating.go b/backend/rating.go index d423a52..f44ae13 100644 --- a/backend/rating.go +++ b/backend/rating.go @@ -847,9 +847,9 @@ func computeNSFWRating(segments []aiSegmentMeta, durationSec float64) *aiRatingM 0.04*confNorm // Sicherheits-Caps: - // Ohne Positionssignal soll Kontext alleine nicht auf 4–5 Sterne kippen. + // Ohne Positionssignal soll Kontext alleine nicht auf 5 Sterne kippen. if positionEffectiveWeighted <= 0 { - raw = math.Min(raw, 0.58) + raw = math.Min(raw, 0.72) } // Sehr kurze/spärliche Treffer nicht überbewerten. diff --git a/backend/training.go b/backend/training.go index da25643..945c807 100644 --- a/backend/training.go +++ b/backend/training.go @@ -50,12 +50,9 @@ type TrainingPrediction struct { ModelAvailable bool `json:"modelAvailable"` Source string `json:"source,omitempty"` - PeopleCount int `json:"peopleCount"` - MaleCount int `json:"maleCount"` - FemaleCount int `json:"femaleCount"` - UnknownCount int `json:"unknownCount"` SexPosition string `json:"sexPosition"` SexPositionScore float64 `json:"sexPositionScore"` + PeoplePresent []TrainingScoredLabel `json:"peoplePresent"` BodyPartsPresent []TrainingScoredLabel `json:"bodyPartsPresent"` ObjectsPresent []TrainingScoredLabel `json:"objectsPresent"` ClothingPresent []TrainingScoredLabel `json:"clothingPresent"` @@ -63,11 +60,8 @@ type TrainingPrediction struct { } type TrainingCorrection struct { - PeopleCount int `json:"peopleCount"` - MaleCount int `json:"maleCount"` - FemaleCount int `json:"femaleCount"` - UnknownCount int `json:"unknownCount"` SexPosition string `json:"sexPosition"` + PeoplePresent []string `json:"peoplePresent"` BodyPartsPresent []string `json:"bodyPartsPresent"` ObjectsPresent []string `json:"objectsPresent"` ClothingPresent []string `json:"clothingPresent"` @@ -1271,11 +1265,8 @@ func trainingEffectiveCorrection(annotation TrainingAnnotation) TrainingCorrecti p := annotation.Prediction return TrainingCorrection{ - PeopleCount: p.PeopleCount, - MaleCount: p.MaleCount, - FemaleCount: p.FemaleCount, - UnknownCount: p.UnknownCount, SexPosition: p.SexPosition, + PeoplePresent: trainingScoredLabelsToStrings(p.PeoplePresent), BodyPartsPresent: trainingScoredLabelsToStrings(p.BodyPartsPresent), ObjectsPresent: trainingScoredLabelsToStrings(p.ObjectsPresent), ClothingPresent: trainingScoredLabelsToStrings(p.ClothingPresent), @@ -2034,12 +2025,9 @@ func trainingPredictionFromDetector(det TrainingDetectorPrediction) TrainingPred pred := TrainingPrediction{ ModelAvailable: det.Available, Source: det.Source, - PeopleCount: 0, - MaleCount: 0, - FemaleCount: 0, - UnknownCount: 0, SexPosition: "unknown", SexPositionScore: 0, + PeoplePresent: []TrainingScoredLabel{}, BodyPartsPresent: []TrainingScoredLabel{}, ObjectsPresent: []TrainingScoredLabel{}, ClothingPresent: []TrainingScoredLabel{}, @@ -2133,17 +2121,6 @@ func trainingPredictionFromDetector(det TrainingDetectorPrediction) TrainingPred } if peopleSet[label] { - switch label { - case "person_male", "male_person": - pred.MaleCount++ - - case "person_female", "female_person": - pred.FemaleCount++ - - case "person", "person_unknown": - pred.UnknownCount++ - } - visibleBoxes = append(visibleBoxes, box) continue } @@ -2158,9 +2135,9 @@ func trainingPredictionFromDetector(det TrainingDetectorPrediction) TrainingPred pred.SexPositionScore = bestSexPositionScore } - pred.PeopleCount = pred.MaleCount + pred.FemaleCount + pred.UnknownCount pred.Boxes = visibleBoxes + pred.PeoplePresent = trainingScoredLabelsFromDetectorBoxes(visibleBoxes, grouped.People) pred.BodyPartsPresent = trainingScoredLabelsFromDetectorBoxes(visibleBoxes, grouped.BodyParts) pred.ObjectsPresent = trainingScoredLabelsFromDetectorBoxes(visibleBoxes, grouped.Objects) pred.ClothingPresent = trainingScoredLabelsFromDetectorBoxes(visibleBoxes, grouped.Clothing) @@ -2526,12 +2503,9 @@ func trainingEmptyPrediction(source string) TrainingPrediction { return TrainingPrediction{ ModelAvailable: false, Source: source, - PeopleCount: 0, - MaleCount: 0, - FemaleCount: 0, - UnknownCount: 0, SexPosition: "unknown", SexPositionScore: 0, + PeoplePresent: []TrainingScoredLabel{}, BodyPartsPresent: []TrainingScoredLabel{}, ObjectsPresent: []TrainingScoredLabel{}, ClothingPresent: []TrainingScoredLabel{}, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2c4235e..b0b96c3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2627,23 +2627,23 @@ export default function App() { const onlineLikedCount = headerStats.onlineLikedCount const runningTabCount = useMemo(() => { - const activeDownloads = jobs.filter((j) => { + return jobs.filter((j) => { + const status = String((j as any)?.status ?? '').trim().toLowerCase() + const phase = String((j as any)?.phase ?? '').trim().toLowerCase() + + // Nacharbeit / Postwork nicht mitzählen if (isPostworkJobForCount(j)) return false - if (isTerminalJobStatusForCount((j as any)?.status)) return false + + // Fertige/abgebrochene/fehlgeschlagene Jobs nicht mitzählen + if (isTerminalJobStatusForCount(status)) return false + + // Sobald endedAt gesetzt ist, ist die aktive Aufnahme vorbei if (Boolean((j as any)?.endedAt)) return false - return true + + // Nur wirklich laufende Downloads zählen + return status === 'running' && (phase === '' || phase === 'recording') }).length - - const activePostwork = jobs.filter((j) => { - if (!isPostworkJobForCount(j)) return false - if (isTerminalJobStatusForCount((j as any)?.status)) return false - return true - }).length - - const pendingCount = pendingWatchedRooms.length - - return activeDownloads + activePostwork + pendingCount - }, [jobs, pendingWatchedRooms]) + }, [jobs]) const tabs: TabItem[] = [ { diff --git a/frontend/src/components/ui/RecorderSettings.tsx b/frontend/src/components/ui/RecorderSettings.tsx index efae4f5..c51f094 100644 --- a/frontend/src/components/ui/RecorderSettings.tsx +++ b/frontend/src/components/ui/RecorderSettings.tsx @@ -299,6 +299,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { const [saving, setSaving] = useState(false) const [saveSuccessUntilMs, setSaveSuccessUntilMs] = useState(0) const saveSuccessTimerRef = useRef(null) + const appLogPreRef = useRef(null) const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null) const [msg, setMsg] = useState(null) const [err, setErr] = useState(null) @@ -317,6 +318,15 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { type SaveUiState = 'idle' | 'saving' | 'success' | 'error' const saveUiState: SaveUiState = saving ? 'saving' : saveSucceeded ? 'success' : saveFailed ? 'error' : 'idle' + function scrollAppLogToBottom() { + window.requestAnimationFrame(() => { + const el = appLogPreRef.current + if (!el) return + + el.scrollTop = el.scrollHeight + }) + } + const saveButton = (() => { if (saveUiState === 'saving') { return { @@ -749,6 +759,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { }) } finally { setAppLogLoading(false) + scrollAppLogToBottom() } } @@ -757,6 +768,12 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { void loadAppLog() }, [appLogOpen]) + useEffect(() => { + if (!appLogOpen) return + + scrollAppLogToBottom() + }, [appLogOpen, appLog?.log, appLogLoading]) + async function browse(target: 'record' | 'done' | 'ffmpeg') { setErr(null) setMsg(null) @@ -1743,7 +1760,10 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { ) : null} -
+                  
                     {appLogLoading
                       ? 'Log wird geladen…'
                       : appLog?.log?.trim()
diff --git a/frontend/src/components/ui/TrainingTab.tsx b/frontend/src/components/ui/TrainingTab.tsx
index 817c900..ba9bd8f 100644
--- a/frontend/src/components/ui/TrainingTab.tsx
+++ b/frontend/src/components/ui/TrainingTab.tsx
@@ -65,12 +65,9 @@ type TrainingPrediction = {
   modelAvailable: boolean
   source?: string
 
-  peopleCount: number
-  maleCount: number
-  femaleCount: number
-  unknownCount: number
   sexPosition: string
   sexPositionScore: number
+  peoplePresent: ScoredLabel[]
   bodyPartsPresent: ScoredLabel[]
   objectsPresent: ScoredLabel[]
   clothingPresent: ScoredLabel[]
@@ -97,11 +94,8 @@ type TrainingLabels = {
 }
 
 type CorrectionState = {
-  peopleCount: number
-  maleCount: number
-  femaleCount: number
-  unknownCount: number
   sexPosition: string
+  peoplePresent: string[]
   bodyPartsPresent: string[]
   objectsPresent: string[]
   clothingPresent: string[]
@@ -604,11 +598,6 @@ function toggleArrayValue(arr: string[], value: string) {
     : [...arr, value]
 }
 
-function safeCount(value: unknown) {
-  const n = Number(value)
-  return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0
-}
-
 function clamp01(v: number) {
   if (!Number.isFinite(v)) return 0
   return Math.max(0, Math.min(1, v))
@@ -668,32 +657,35 @@ function uniqStrings(values: string[]) {
   return out
 }
 
+function peopleLabelsFromBoxes(boxes: TrainingBox[], labels: TrainingLabels) {
+  return uniqStrings(
+    boxes
+      .map((box) => String(box.label || '').trim())
+      .filter((label) => labels.people.includes(label))
+  )
+}
+
 function predictionToCorrection(sample: TrainingSample | null): CorrectionState {
   const p = sample?.prediction
 
-  const maleCount = safeCount(p?.maleCount)
-  const femaleCount = safeCount(p?.femaleCount)
-  const unknownCount = safeCount(p?.unknownCount)
+  const boxes = (p?.boxes ?? [])
+    .map((box) => ({
+      label: String(box.label || '').trim(),
+      score: box.score,
+      x: clamp01(Number(box.x)),
+      y: clamp01(Number(box.y)),
+      w: clamp01(Number(box.w)),
+      h: clamp01(Number(box.h)),
+    }))
+    .filter((box) => box.label && box.w > 0 && box.h > 0)
 
   return {
-    peopleCount: maleCount + femaleCount + unknownCount,
-    maleCount,
-    femaleCount,
-    unknownCount,
     sexPosition: p?.sexPosition || 'unknown',
+    peoplePresent: (p?.peoplePresent ?? []).map((x) => x.label),
     bodyPartsPresent: (p?.bodyPartsPresent ?? []).map((x) => x.label),
     objectsPresent: (p?.objectsPresent ?? []).map((x) => x.label),
     clothingPresent: (p?.clothingPresent ?? []).map((x) => x.label),
-    boxes: (p?.boxes ?? [])
-      .map((box) => ({
-        label: String(box.label || '').trim(),
-        score: box.score,
-        x: clamp01(Number(box.x)),
-        y: clamp01(Number(box.y)),
-        w: clamp01(Number(box.w)),
-        h: clamp01(Number(box.h)),
-      }))
-      .filter((box) => box.label && box.w > 0 && box.h > 0),
+    boxes,
   }
 }
 
@@ -706,39 +698,12 @@ function applyBoxLabelToCorrection(
   if (!clean) return state
 
   if (labels.people.includes(clean)) {
-    if (clean === 'person_male' || clean === 'male_person') {
-      return {
-        ...state,
-        maleCount: safeCount(state.maleCount) + 1,
-        unknownCount: 0,
-        peopleCount:
-          safeCount(state.maleCount) + 1 + safeCount(state.femaleCount),
-      }
+    return {
+      ...state,
+      peoplePresent: state.peoplePresent.includes(clean)
+        ? state.peoplePresent
+        : [...state.peoplePresent, clean],
     }
-
-    if (clean === 'person_female' || clean === 'female_person') {
-      return {
-        ...state,
-        femaleCount: safeCount(state.femaleCount) + 1,
-        unknownCount: 0,
-        peopleCount:
-          safeCount(state.maleCount) + safeCount(state.femaleCount) + 1,
-      }
-    }
-
-    if (clean === 'person' || clean === 'person_unknown') {
-      return {
-        ...state,
-        unknownCount: safeCount(state.unknownCount) + 1,
-        peopleCount:
-          safeCount(state.maleCount) +
-          safeCount(state.femaleCount) +
-          safeCount(state.unknownCount) +
-          1,
-      }
-    }
-
-    return state
   }
 
   if (labels.bodyParts.includes(clean)) {
@@ -786,6 +751,13 @@ function removeBoxLabelFromCorrection(
   // Badge nur abwählen, wenn keine weitere Box mit diesem Label existiert.
   if (remainingBoxesWithSameLabel) return state
 
+  if (labels.people.includes(clean)) {
+    return {
+      ...state,
+      peoplePresent: state.peoplePresent.filter((x) => x !== clean),
+    }
+  }
+
   if (labels.bodyParts.includes(clean)) {
     return {
       ...state,
@@ -822,42 +794,11 @@ function removeBoxFromCorrection(
 
   const removedLabel = String(removed.label || '').trim()
 
-  let next: CorrectionState = {
+  const next: CorrectionState = {
     ...state,
     boxes: boxes.filter((_, i) => i !== index),
   }
 
-  if (removedLabel === 'person_male' || removedLabel === 'male_person') {
-    next = {
-      ...next,
-      maleCount: Math.max(0, safeCount(next.maleCount) - 1),
-      unknownCount: 0,
-    }
-  }
-
-  if (removedLabel === 'person_female' || removedLabel === 'female_person') {
-    next = {
-      ...next,
-      femaleCount: Math.max(0, safeCount(next.femaleCount) - 1),
-      unknownCount: 0,
-    }
-  }
-
-  if (removedLabel === 'person' || removedLabel === 'person_unknown') {
-    next = {
-      ...next,
-      unknownCount: Math.max(0, safeCount(next.unknownCount) - 1),
-    }
-  }
-
-  next = {
-    ...next,
-    peopleCount:
-      safeCount(next.maleCount) +
-      safeCount(next.femaleCount) +
-      safeCount(next.unknownCount),
-  }
-
   return removeBoxLabelFromCorrection(next, removedLabel, labels)
 }
 
@@ -890,52 +831,9 @@ function changeBoxLabelInCorrection(
     }),
   }
 
-  // Alte Personen-Zählung entfernen.
-  if (oldLabel === 'person_male' || oldLabel === 'male_person') {
-    next = {
-      ...next,
-      maleCount: Math.max(0, safeCount(next.maleCount) - 1),
-      unknownCount: 0,
-    }
-  }
-
-  if (oldLabel === 'person_female' || oldLabel === 'female_person') {
-    next = {
-      ...next,
-      femaleCount: Math.max(0, safeCount(next.femaleCount) - 1),
-      unknownCount: 0,
-    }
-  }
-
-  if (oldLabel === 'person' || oldLabel === 'person_unknown') {
-    next = {
-      ...next,
-      unknownCount: Math.max(0, safeCount(next.unknownCount) - 1),
-    }
-  }
-
-  next = {
-    ...next,
-    peopleCount:
-      safeCount(next.maleCount) +
-      safeCount(next.femaleCount) +
-      safeCount(next.unknownCount),
-  }
-
-  // Alte Bodypart/Object/Clothing-Badges entfernen, falls keine weitere Box damit existiert.
   next = removeBoxLabelFromCorrection(next, oldLabel, labels)
-
-  // Neues Label anwenden.
   next = applyBoxLabelToCorrection(next, cleanNextLabel, labels)
 
-  next = {
-    ...next,
-    peopleCount:
-      safeCount(next.maleCount) +
-      safeCount(next.femaleCount) +
-      safeCount(next.unknownCount),
-  }
-
   return next
 }
 
@@ -1503,8 +1401,12 @@ function CollapsibleLabelSection(props: {
   singleDrawMode?: boolean
   gridClassName?: string
 }) {
+  const cleanDrawLabel = String(props.drawLabel || '').trim()
+  const hasDrawLabelInSection =
+    cleanDrawLabel !== '' && props.values.includes(cleanDrawLabel)
+
   const activeCount = props.singleDrawMode
-    ? Math.max(props.selected.length, props.drawLabel ? 1 : 0)
+    ? Math.max(props.selected.length, hasDrawLabelInSection ? 1 : 0)
     : props.selected.length
 
   const hasActiveItems = activeCount > 0
@@ -2262,12 +2164,13 @@ export default function TrainingTab(props: {
   }, [sample?.prediction.boxes, labels.people])
 
   const selectedPeopleLabels = useMemo(() => {
-    return uniqStrings(
-      correctionBoxes
-        .map((box) => String(box.label || '').trim())
-        .filter((label) => labels.people.includes(label))
-    )
-  }, [correctionBoxes, labels.people])
+    return correction.peoplePresent
+  }, [correction.peoplePresent])
+
+  const drawLabelForSection = useCallback((values: string[]) => {
+    const clean = String(boxLabel || '').trim()
+    return values.includes(clean) ? clean : ''
+  }, [boxLabel])
 
   const imageSrc = useMemo(() => {
     if (!sample?.frameUrl) return ''
@@ -2445,18 +2348,11 @@ export default function TrainingTab(props: {
       setCorrection(nextCorrection)
       setHasManualCorrection(false)
 
-      const currentLabels = labelsRef.current
-      const personLabels = new Set(currentLabels.people)
-
-      const hasPersonBoxes = nextCorrection.boxes.some((box) =>
-        personLabels.has(box.label)
-      )
-
       setExpandedCorrectionSections({
         sexPosition:
           Boolean(nextCorrection.sexPosition) &&
           nextCorrection.sexPosition !== 'unknown',
-        people: hasPersonBoxes || nextCorrection.peopleCount > 0,
+        people: nextCorrection.peoplePresent.length > 0,
         bodyParts: nextCorrection.bodyPartsPresent.length > 0,
         objects: nextCorrection.objectsPresent.length > 0,
         clothing: nextCorrection.clothingPresent.length > 0,
@@ -2805,19 +2701,14 @@ export default function TrainingTab(props: {
       setMessage(null)
 
       try {
-        const maleCount = safeCount(correction.maleCount)
-        const femaleCount = safeCount(correction.femaleCount)
-        const unknownCount = safeCount(correction.unknownCount)
+        const normalizedBoxes = (correction.boxes ?? [])
+          .map(normalizeBox)
+          .filter((box) => box.label && box.w > 0 && box.h > 0)
 
         const correctionPayload: CorrectionState = {
           ...correction,
-          maleCount,
-          femaleCount,
-          unknownCount,
-          peopleCount: maleCount + femaleCount + unknownCount,
-          boxes: (correction.boxes ?? [])
-            .map(normalizeBox)
-            .filter((box) => box.label && box.w > 0 && box.h > 0),
+          peoplePresent: peopleLabelsFromBoxes(normalizedBoxes, labelsRef.current),
+          boxes: normalizedBoxes,
         }
 
         const payload = {
@@ -3230,10 +3121,7 @@ export default function TrainingTab(props: {
 
     setCorrection((prev) => ({
       ...prev,
-      maleCount: 0,
-      femaleCount: 0,
-      unknownCount: 0,
-      peopleCount: 0,
+      peoplePresent: [],
       bodyPartsPresent: [],
       objectsPresent: [],
       clothingPresent: [],
@@ -4647,7 +4535,7 @@ export default function TrainingTab(props: {
                       toggleMobileCorrectionSection('people', expanded)
                     }
                     onToggle={() => {}}
-                    drawLabel={boxLabel}
+                    drawLabel={drawLabelForSection(labels.people)}
                     onDrawLabelChange={setBoxLabel}
                     disabled={uiLocked}
                     singleDrawMode
@@ -4680,7 +4568,7 @@ export default function TrainingTab(props: {
                         }
                       })
                     }
-                    drawLabel={boxLabel}
+                    drawLabel={drawLabelForSection(labels.bodyParts)}
                     onDrawLabelChange={setBoxLabel}
                     disabled={uiLocked}
                   />
@@ -4711,7 +4599,7 @@ export default function TrainingTab(props: {
                         }
                       })
                     }
-                    drawLabel={boxLabel}
+                    drawLabel={drawLabelForSection(labels.objects)}
                     onDrawLabelChange={setBoxLabel}
                     disabled={uiLocked}
                   />
@@ -4742,7 +4630,7 @@ export default function TrainingTab(props: {
                         }
                       })
                     }
-                    drawLabel={boxLabel}
+                    drawLabel={drawLabelForSection(labels.clothing)}
                     onDrawLabelChange={setBoxLabel}
                     disabled={uiLocked}
                   />
@@ -4838,7 +4726,7 @@ export default function TrainingTab(props: {
                 }))
               }
               onToggle={() => {}}
-              drawLabel={boxLabel}
+              drawLabel={drawLabelForSection(labels.people)}
               onDrawLabelChange={setBoxLabel}
               disabled={uiLocked}
               singleDrawMode
@@ -4868,7 +4756,7 @@ export default function TrainingTab(props: {
                   }
                 })
               }
-              drawLabel={boxLabel}
+              drawLabel={drawLabelForSection(labels.bodyParts)}
               onDrawLabelChange={setBoxLabel}
               disabled={uiLocked}
             />
@@ -4896,7 +4784,7 @@ export default function TrainingTab(props: {
                   }
                 })
               }
-              drawLabel={boxLabel}
+              drawLabel={drawLabelForSection(labels.objects)}
               onDrawLabelChange={setBoxLabel}
               disabled={uiLocked}
             />
@@ -4924,7 +4812,7 @@ export default function TrainingTab(props: {
                   }
                 })
               }
-              drawLabel={boxLabel}
+              drawLabel={drawLabelForSection(labels.clothing)}
               onDrawLabelChange={setBoxLabel}
               disabled={uiLocked}
             />