diff --git a/backend/__pycache__/ai_server.cpython-314.pyc b/backend/__pycache__/ai_server.cpython-314.pyc index 49e3746..6265ae8 100644 Binary files a/backend/__pycache__/ai_server.cpython-314.pyc and b/backend/__pycache__/ai_server.cpython-314.pyc differ 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}
             />