diff --git a/backend/rating.go b/backend/rating.go index 4a29ebe..09b8d2b 100644 --- a/backend/rating.go +++ b/backend/rating.go @@ -11,6 +11,17 @@ import ( type aiRatingMeta struct { Score float64 `json:"score"` ActionScore float64 `json:"actionScore"` + BestPosition string `json:"bestPosition,omitempty"` + BestPositionRank int `json:"bestPositionRank,omitempty"` + BestClothing string `json:"bestClothing,omitempty"` + BestClothingRank int `json:"bestClothingRank,omitempty"` + PositionScore float64 `json:"positionScore"` + ClothingScore float64 `json:"clothingScore"` + DurationScore float64 `json:"durationScore"` + StrengthScore float64 `json:"strengthScore"` + CoverageScore float64 `json:"coverageScore"` + ContextScore float64 `json:"contextScore"` + VarietyScore float64 `json:"varietyScore"` Stars int `json:"stars"` Segments int `json:"segments"` SegmentsPerMinute float64 `json:"segmentsPerMinute"` @@ -136,31 +147,46 @@ func isKnownPositionLabel(label string) bool { } } -func positionSeverityWeight(label string) float64 { +type explicitRatingRank struct { + Rank int + Weight float64 +} + +func positionExplicitRatingRank(label string) explicitRatingRank { label = strings.ToLower(strings.TrimSpace(label)) switch label { + case "cowgirl": + return explicitRatingRank{Rank: 1, Weight: 1.00} + case "reverse_cowgirl": + return explicitRatingRank{Rank: 2, Weight: 0.99} case "doggy", "doggystyle", "standing_doggy": - return 1.00 - case "cowgirl", "reverse_cowgirl": - return 0.98 - case "missionary", "prone_bone": - return 0.95 - case "blowjob", "cunnilingus", "69", "facesitting", "boobjob": - return 0.94 + return explicitRatingRank{Rank: 3, Weight: 0.98} case "toy_play": - return 0.88 - case "handjob", "fingering": - return 0.84 + return explicitRatingRank{Rank: 4, Weight: 0.96} + case "fingering": + return explicitRatingRank{Rank: 5, Weight: 0.94} + case "missionary": + return explicitRatingRank{Rank: 6, Weight: 0.92} + case "prone_bone": + return explicitRatingRank{Rank: 7, Weight: 0.90} case "spooning": - return 0.78 + return explicitRatingRank{Rank: 8, Weight: 0.86} case "standing": - return 0.42 + return explicitRatingRank{Rank: 9, Weight: 0.56} + case "69", "facesitting": + return explicitRatingRank{Rank: 10, Weight: 0.54} + case "blowjob", "handjob", "cunnilingus", "boobjob": + return explicitRatingRank{Rank: 11, Weight: 0.52} default: - return 0.00 + return explicitRatingRank{} } } +func positionSeverityWeight(label string) float64 { + return positionExplicitRatingRank(label).Weight +} + func bodyPartSeverityWeight(label string) float64 { label = strings.ToLower(strings.TrimSpace(label)) @@ -199,25 +225,39 @@ func objectSeverityWeight(label string) float64 { } } -func clothingSeverityWeight(label string) float64 { +func clothingExplicitRatingRank(label string) explicitRatingRank { label = strings.ToLower(strings.TrimSpace(label)) switch label { case "lingerie": - return 0.50 - case "panties", "bra": - return 0.44 + return explicitRatingRank{Rank: 1, Weight: 0.62} + case "stockings": + return explicitRatingRank{Rank: 2, Weight: 0.57} + case "skirt": + return explicitRatingRank{Rank: 3, Weight: 0.52} + case "hotpants": + return explicitRatingRank{Rank: 4, Weight: 0.47} case "bikini": - return 0.30 - case "stockings", "heels": - return 0.28 - case "skirt", "dress", "hotpants", "croptop": - return 0.22 + return explicitRatingRank{Rank: 5, Weight: 0.44} + case "bra": + return explicitRatingRank{Rank: 6, Weight: 0.41} + case "panties": + return explicitRatingRank{Rank: 7, Weight: 0.36} + case "croptop": + return explicitRatingRank{Rank: 8, Weight: 0.31} + case "heels": + return explicitRatingRank{Rank: 9, Weight: 0.26} + case "dress": + return explicitRatingRank{Rank: 10, Weight: 0.22} default: - return 0.00 + return explicitRatingRank{} } } +func clothingSeverityWeight(label string) float64 { + return clothingExplicitRatingRank(label).Weight +} + func personContextWeight(label string) float64 { if !isPersonSegmentLabel(label) { return 0 @@ -437,26 +477,6 @@ func contextualSegmentSeverityWeightFromSet(set ratingSignalSet) float64 { return 0 } - var labelParts []string - - if set.HasPosition { - labelParts = append(labelParts, "position:x") - } - if set.HasBody { - labelParts = append(labelParts, "body:x") - } - if set.HasObject { - labelParts = append(labelParts, "object:x") - } - if set.HasClothing { - labelParts = append(labelParts, "clothing:x") - } - if set.HasPerson { - labelParts = append(labelParts, "person:x") - } - - _ = labelParts - var score float64 if set.HasPosition { @@ -484,7 +504,9 @@ func contextualSegmentSeverityWeightFromSet(set ratingSignalSet) float64 { // Standing ohne echten Kontext klar schwächer halten. if set.Position < 0.60 && !set.HasBody && !set.HasObject && !set.HasClothing { - score = math.Min(score, 0.32) + // Den Rang innerhalb der schwachen Positionen erhalten, statt + // Standing, 69 und orale Positionen auf denselben Wert zu kappen. + score = math.Min(score, 0.10+0.40*set.Position) } return ratingClamp01(score) @@ -655,6 +677,37 @@ func addRatingLabelsFromSegment(labels map[string]bool, label string) { labels[normalized] = true } +func updateBestExplicitRatingRanks( + label string, + bestPosition *string, + bestPositionRank *int, + bestClothing *string, + bestClothingRank *int, +) { + labels := map[string]bool{} + addRatingLabelsFromSegment(labels, label) + + for normalized := range labels { + switch { + case strings.HasPrefix(normalized, "position:"): + raw := strings.TrimPrefix(normalized, "position:") + ranked := positionExplicitRatingRank(raw) + if ranked.Rank > 0 && (*bestPositionRank == 0 || ranked.Rank < *bestPositionRank) { + *bestPosition = raw + *bestPositionRank = ranked.Rank + } + + case strings.HasPrefix(normalized, "clothing:"): + raw := strings.TrimPrefix(normalized, "clothing:") + ranked := clothingExplicitRatingRank(raw) + if ranked.Rank > 0 && (*bestClothingRank == 0 || ranked.Rank < *bestClothingRank) { + *bestClothing = raw + *bestClothingRank = ranked.Rank + } + } + } +} + func ratingSignalSetFromLabels(labels map[string]bool) ratingSignalSet { var set ratingSignalSet @@ -1256,119 +1309,103 @@ func ratingLinearGate(value float64, start float64, full float64) float64 { return ratingClamp01((value - start) / (full - start)) } -func ratingExplicitContextBonus( - bodyEffectiveWeighted float64, - clothingEffectiveWeighted float64, - objectEffectiveWeighted float64, - videoMinutes float64, -) float64 { - if videoMinutes <= 0 { +type ratingTimeRange struct { + Start float64 + End float64 +} + +func ratingCoveredSeconds(ranges []ratingTimeRange, durationSec float64) float64 { + if len(ranges) == 0 || durationSec <= 0 { return 0 } - bodyDensity := bodyEffectiveWeighted / videoMinutes - clothingDensity := clothingEffectiveWeighted / videoMinutes - objectDensity := objectEffectiveWeighted / videoMinutes + valid := make([]ratingTimeRange, 0, len(ranges)) + for _, item := range ranges { + start := math.Max(0, math.Min(item.Start, durationSec)) + end := math.Max(0, math.Min(item.End, durationSec)) + if end > start { + valid = append(valid, ratingTimeRange{Start: start, End: end}) + } + } + if len(valid) == 0 { + return 0 + } - bonus := - 0.060*ratingSaturatingEvidence(bodyDensity, 1.8) + - 0.035*ratingSaturatingEvidence(clothingDensity, 1.5) + - 0.015*ratingSaturatingEvidence(objectDensity, 2.2) + sort.SliceStable(valid, func(i, j int) bool { + if valid[i].Start != valid[j].Start { + return valid[i].Start < valid[j].Start + } + return valid[i].End < valid[j].End + }) - return math.Min(bonus, 0.10) + total := 0.0 + current := valid[0] + + for _, item := range valid[1:] { + if item.Start <= current.End { + if item.End > current.End { + current.End = item.End + } + continue + } + + total += current.End - current.Start + current = item + } + + return total + current.End - current.Start } -func ratingBlendWithAction(base, action, actionWeight float64) float64 { - actionWeight = ratingClamp01(actionWeight) - return ratingClamp01((1-actionWeight)*base + actionWeight*action) +func ratingStrengthComponent(avgConfidence, peakConfidence float64) float64 { + avg := ratingSmoothStep(ratingLinearGate(avgConfidence, 0.35, 0.90)) + peak := ratingSmoothStep(ratingLinearGate(peakConfidence, 0.50, 0.95)) + return ratingClamp01(0.75*avg + 0.25*peak) } -func ratingPositionActionScore( - durationNorm float64, - longestNorm float64, - densityNorm float64, - coverageNorm float64, - peakNorm float64, -) float64 { - return ratingClamp01( - 0.38*durationNorm + - 0.24*longestNorm + - 0.18*densityNorm + - 0.12*coverageNorm + - 0.08*peakNorm, - ) +func ratingDurationComponent(totalSeconds, longestSeconds float64) float64 { + total := ratingSaturatingEvidence(totalSeconds, 55.0) + longest := ratingSaturatingEvidence(longestSeconds, 30.0) + return ratingClamp01(0.62*total + 0.38*longest) } -func ratingContextActionScore( - durationNorm float64, - longestNorm float64, - coverageNorm float64, - densityNorm float64, - peakNorm float64, -) float64 { - return ratingClamp01( - 0.40*durationNorm + - 0.25*longestNorm + - 0.20*coverageNorm + - 0.10*densityNorm + - 0.05*peakNorm, - ) +func ratingCoverageComponent(coveredSeconds, durationSec float64) float64 { + if coveredSeconds <= 0 || durationSec <= 0 { + return 0 + } + return ratingSaturatingEvidence(ratingClamp01(coveredSeconds/durationSec), 0.12) } -func ratingExcellenceBonus( - peakQuality float64, - positionEffectiveWeighted float64, - positionDensity float64, - weightedCoverageRatio float64, - totalFlagged float64, - longest float64, +func ratingActionComponent(duration, coverage, strength float64) float64 { + return ratingClamp01(0.50*duration + 0.30*coverage + 0.20*strength) +} + +func ratingApplyPositionEvidenceCaps( + raw float64, + positionSeconds float64, + longestSeconds float64, avgConfidence float64, - segmentsPerMinute float64, - n int, + positionScore float64, ) float64 { - // Kein pauschaler Score-Shift: - // Der Bonus darf nur greifen, wenn wirklich mehrere starke Signale vorhanden sind. - if n < 2 { - return 0 - } - if positionEffectiveWeighted <= 0 { - return 0 - } - if totalFlagged < 12.0 { - return 0 - } - if peakQuality < 0.68 { - return 0 - } - if avgConfidence < 0.42 { - return 0 + switch { + case positionSeconds < 5: + raw = math.Min(raw, 0.21) + case positionSeconds < 12: + raw = math.Min(raw, 0.44) + case positionSeconds < 30: + raw = math.Min(raw, 0.67) } - peakGate := ratingLinearGate(peakQuality, 0.68, 0.90) - positionGate := ratingLinearGate(positionDensity, 2.20, 7.00) - coverageGate := ratingLinearGate(weightedCoverageRatio, 0.045, 0.160) - durationGate := ratingLinearGate(totalFlagged, 12.0, 90.0) - longestGate := ratingLinearGate(longest, 8.0, 45.0) - confGate := ratingLinearGate(avgConfidence, 0.42, 0.78) - frequencyGate := ratingLinearGate(segmentsPerMinute, 0.25, 1.00) - - bonus := - 0.030*peakGate + - 0.022*positionGate + - 0.016*coverageGate + - 0.012*durationGate + - 0.012*longestGate + - 0.010*confGate + - 0.006*frequencyGate - - // Maximal +8.5 Scorepunkte. - // Dadurch werden starke 72-79 Scores 5-Sterne-fähig, - // aber mittelmäßige Scores springen nicht einfach pauschal hoch. - if bonus > 0.085 { - bonus = 0.085 + if positionSeconds < 75 || + longestSeconds < 18 || + avgConfidence < 0.55 || + positionScore < 0.65 { + raw = math.Min(raw, 0.84) + } + if avgConfidence < 0.50 && positionScore < 0.65 { + raw = math.Min(raw, 0.21) } - return bonus + return ratingClamp01(raw) } func starsFromHighlightScore(score float64) int { @@ -1434,34 +1471,39 @@ func computeHighlightRatingWithUsername(segments []aiSegmentMeta, durationSec fl videoMinutes := math.Max(durationSec/60.0, 0.25) - var totalFlagged float64 - var totalWeighted float64 + var positionRanges []ratingTimeRange + var positionWeighted float64 + var positionExplicitSum float64 + var positionEvidenceWeight float64 + var peakPositionExplicitness float64 + var peakPositionConfidence float64 - var positionEffectiveWeighted float64 - var positionFlagged float64 - var peakQuality float64 - var qualityDurationSum float64 - var qualityDurationWeight float64 - - var contextFlagged float64 + var contextRanges []ratingTimeRange var contextWeighted float64 - var contextEffectiveWeighted float64 - var contextPeakQuality float64 - var contextQualityDurationSum float64 - var contextQualityDurationWeight float64 + var contextExplicitSum float64 + var contextEvidenceWeight float64 + var peakContextExplicitness float64 + var peakContextConfidence float64 var contextLongest float64 var contextConfSum float64 var contextConfWeightSum float64 var contextN int + var bodyEffectiveWeighted float64 var clothingEffectiveWeighted float64 var objectEffectiveWeighted float64 + var clothingExplicitSum float64 + var clothingEvidenceWeight float64 var longest float64 var confSum float64 var confWeightSum float64 var n int uniquePositions := map[string]bool{} + bestPosition := "" + bestPositionRank := 0 + bestClothing := "" + bestClothingRank := 0 for _, s := range segments { segDur := s.DurationSeconds @@ -1492,25 +1534,41 @@ func computeHighlightRatingWithUsername(segments []aiSegmentMeta, durationSec fl effectiveDur := ratingEffectiveDurationSeconds(segDur) weightedDur := segDur * quality - effectiveWeightedDur := effectiveDur * quality + segmentRange := ratingTimeRange{ + Start: s.StartSeconds, + End: s.StartSeconds + segDur, + } set := ratingSignalSetFromLabel(s.Label) + updateBestExplicitRatingRanks( + s.Label, + &bestPosition, + &bestPositionRank, + &bestClothing, + &bestClothingRank, + ) bodyEffectiveWeighted += effectiveDur * set.Body * confWeight clothingEffectiveWeighted += effectiveDur * set.Clothing * confWeight objectEffectiveWeighted += effectiveDur * set.Object * confWeight + if set.HasClothing { + clothingExplicitSum += (set.Clothing / clothingExplicitRatingRank("lingerie").Weight) * effectiveDur + clothingEvidenceWeight += effectiveDur + } if !set.HasPosition { - contextFlagged += segDur + contextRanges = append(contextRanges, segmentRange) contextWeighted += weightedDur - contextEffectiveWeighted += effectiveWeightedDur - contextQualityDurationSum += quality * effectiveDur - contextQualityDurationWeight += effectiveDur + contextExplicitSum += sev * effectiveDur + contextEvidenceWeight += effectiveDur contextConfSum += conf * effectiveDur contextConfWeightSum += effectiveDur contextN++ - if quality > contextPeakQuality { - contextPeakQuality = quality + if sev > peakContextExplicitness { + peakContextExplicitness = sev + } + if conf > peakContextConfidence { + peakContextConfidence = conf } if segDur > contextLongest { contextLongest = segDur @@ -1519,16 +1577,16 @@ func computeHighlightRatingWithUsername(segments []aiSegmentMeta, durationSec fl continue } - totalFlagged += segDur - totalWeighted += weightedDur + positionRanges = append(positionRanges, segmentRange) + positionWeighted += weightedDur + positionExplicitSum += set.Position * effectiveDur + positionEvidenceWeight += effectiveDur - positionEffectiveWeighted += effectiveWeightedDur - positionFlagged += segDur - qualityDurationSum += quality * effectiveDur - qualityDurationWeight += effectiveDur - - if quality > peakQuality { - peakQuality = quality + if set.Position > peakPositionExplicitness { + peakPositionExplicitness = set.Position + } + if conf > peakPositionConfidence { + peakPositionConfidence = conf } confSum += conf * effectiveDur @@ -1546,58 +1604,71 @@ func computeHighlightRatingWithUsername(segments []aiSegmentMeta, durationSec fl } } + clothingScore := 0.0 + if clothingEvidenceWeight > 0 { + avgClothingExplicitness := ratingClamp01(clothingExplicitSum / clothingEvidenceWeight) + clothingDensity := clothingEffectiveWeighted / videoMinutes + clothingScore = ratingClamp01( + 0.70*avgClothingExplicitness + + 0.30*ratingSaturatingEvidence(clothingDensity, 1.8), + ) + } + + bodyContext := ratingSaturatingEvidence(bodyEffectiveWeighted/videoMinutes, 1.8) + objectContext := ratingSaturatingEvidence(objectEffectiveWeighted/videoMinutes, 2.2) + contextScore := ratingClamp01(0.78*bodyContext + 0.22*objectContext) + if n == 0 { if contextN == 0 { appLogf("⚠️ %s rating result is zero because all segments were skipped", ratingLogSubject(username)) return r } + contextFlagged := ratingCoveredSeconds(contextRanges, durationSec) contextCoverageRatio := ratingClamp01(contextFlagged / durationSec) contextWeightedCoverageRatio := ratingClamp01(contextWeighted / durationSec) contextSegmentsPerMinute := float64(contextN) / videoMinutes contextAvgConfidence := contextConfSum / contextConfWeightSum - contextAvgQuality := contextQualityDurationSum / contextQualityDurationWeight - contextDensity := contextEffectiveWeighted / videoMinutes - - qualityNorm := ratingSmoothStep( - ratingLinearGate(contextAvgQuality, 0.08, 0.55), + avgContextExplicitness := contextExplicitSum / contextEvidenceWeight + contextQuality := ratingClamp01( + (0.75*avgContextExplicitness + 0.25*peakContextExplicitness) / 0.72, ) - peakNorm := ratingSmoothStep( - ratingLinearGate(contextPeakQuality, 0.12, 0.62), - ) - durationNorm := ratingSaturatingEvidence(contextFlagged, 150.0) - longestNorm := ratingSaturatingEvidence(contextLongest, 60.0) - densityNorm := ratingSaturatingEvidence(contextDensity, 2.5) - coverageNorm := ratingSaturatingEvidence(contextWeightedCoverageRatio, 0.08) + durationScore := ratingDurationComponent(contextFlagged, contextLongest) + strengthScore := ratingStrengthComponent(contextAvgConfidence, peakContextConfidence) + coverageScore := ratingCoverageComponent(contextFlagged, durationSec) raw := - 0.28*qualityNorm + - 0.12*peakNorm + - 0.25*durationNorm + - 0.15*longestNorm + - 0.15*coverageNorm + - 0.05*densityNorm + 0.30*contextQuality + + 0.25*durationScore + + 0.15*strengthScore + + 0.12*coverageScore + + 0.12*clothingScore + + 0.06*contextScore - actionRaw := ratingContextActionScore( - durationNorm, - longestNorm, - coverageNorm, - densityNorm, - peakNorm, - ) - raw = ratingBlendWithAction(raw, actionRaw, 0.35) + actionRaw := ratingActionComponent(durationScore, coverageScore, strengthScore) - // Context can produce useful 1-3 star ratings, but never the 4-5 star - // range reserved for sustained position detections. if contextFlagged < 20.0 { raw = math.Min(raw, 0.44) } - raw = math.Min(raw, 0.64) + semanticCeiling := 0.52 + 0.12*math.Max( + contextQuality, + math.Max(clothingScore, contextScore), + ) + raw = math.Min(raw, semanticCeiling) score := ratingRound(ratingClamp01(raw)*100, 1) r.Score = score r.ActionScore = ratingRound(actionRaw*100, 1) + r.BestPosition = bestPosition + r.BestPositionRank = bestPositionRank + r.BestClothing = bestClothing + r.BestClothingRank = bestClothingRank + r.ClothingScore = ratingRound(clothingScore*100, 1) + r.DurationScore = ratingRound(durationScore*100, 1) + r.StrengthScore = ratingRound(strengthScore*100, 1) + r.CoverageScore = ratingRound(coverageScore*100, 1) + r.ContextScore = ratingRound(contextScore*100, 1) r.Stars = starsFromHighlightScore(score) r.Segments = contextN r.SegmentsPerMinute = ratingRound(contextSegmentsPerMinute, 2) @@ -1609,13 +1680,13 @@ func computeHighlightRatingWithUsername(segments []aiSegmentMeta, durationSec fl r.AvgConfidence = ratingRound(contextAvgConfidence, 3) appLogf( - "✅ %s context rating score=%.1f stars=%d segs=%d flagged=%.1f density=%.2f coverage=%.4f longest=%.1f avgConf=%.3f", + "rating %s context score=%.1f stars=%d segs=%d flagged=%.1f quality=%.2f coverage=%.4f longest=%.1f avgConf=%.3f", ratingLogSubject(username), r.Score, r.Stars, r.Segments, r.FlaggedSeconds, - contextDensity, + contextQuality, r.WeightedCoverageRatio, r.LongestSegmentSeconds, r.AvgConfidence, @@ -1624,97 +1695,76 @@ func computeHighlightRatingWithUsername(segments []aiSegmentMeta, durationSec fl return r } - coverageRatio := ratingClamp01(totalFlagged / durationSec) - weightedCoverageRatio := ratingClamp01(totalWeighted / durationSec) + positionFlagged := ratingCoveredSeconds(positionRanges, durationSec) + coverageRatio := ratingClamp01(positionFlagged / durationSec) + weightedCoverageRatio := ratingClamp01(positionWeighted / durationSec) segmentsPerMinute := float64(n) / videoMinutes avgConfidence := confSum / confWeightSum - avgPositionQuality := qualityDurationSum / qualityDurationWeight - - positionDensity := positionEffectiveWeighted / videoMinutes - - avgQualityNorm := ratingSmoothStep( - ratingLinearGate(avgPositionQuality, 0.45, 0.95), + avgPositionExplicitness := positionExplicitSum / positionEvidenceWeight + positionScore := ratingClamp01( + 0.75*avgPositionExplicitness + 0.25*peakPositionExplicitness, ) - peakNorm := ratingSmoothStep( - ratingLinearGate(peakQuality, 0.42, 0.95), - ) - positionDurationNorm := ratingSaturatingEvidence(positionFlagged, 65.0) - longestNorm := ratingSaturatingEvidence(longest, 35.0) - positionDensityNorm := ratingSaturatingEvidence(positionDensity, 3.2) - coverageNorm := ratingSaturatingEvidence(weightedCoverageRatio, 0.075) - positionVarietyNorm := ratingLinearGate(float64(len(uniquePositions)), 1, 4) + durationScore := ratingDurationComponent(positionFlagged, longest) + strengthScore := ratingStrengthComponent(avgConfidence, peakPositionConfidence) + coverageScore := ratingCoverageComponent(positionFlagged, durationSec) + varietyScore := ratingLinearGate(float64(len(uniquePositions)), 1, 4) - // Position quality is the primary signal. Actual position duration and the - // longest continuous segment decide whether a detection is brief or strong. raw := - 0.30*avgQualityNorm + - 0.10*peakNorm + - 0.26*positionDurationNorm + - 0.16*longestNorm + - 0.10*positionDensityNorm + - 0.06*coverageNorm + - 0.02*positionVarietyNorm + 0.36*positionScore + + 0.27*durationScore + + 0.14*strengthScore + + 0.10*coverageScore + + 0.06*contextScore + + 0.04*clothingScore + + 0.03*varietyScore - actionRaw := ratingPositionActionScore( - positionDurationNorm, - longestNorm, - positionDensityNorm, - coverageNorm, - peakNorm, - ) - raw = ratingBlendWithAction(raw, actionRaw, 0.30) + actionRaw := ratingActionComponent(durationScore, coverageScore, strengthScore) - explicitContextBonus := ratingExplicitContextBonus( - bodyEffectiveWeighted, - clothingEffectiveWeighted, - objectEffectiveWeighted, - videoMinutes, - ) - - // Sehr wenig Material nicht überbewerten. - if totalFlagged < 5.0 && n <= 1 { - raw = math.Min(raw, 0.44) - } - - excellenceBonus := ratingExcellenceBonus( - peakQuality, - positionEffectiveWeighted, - positionDensity, - weightedCoverageRatio, - totalFlagged, + // Short or weak detections cannot reach high ratings on rank alone. + raw = ratingApplyPositionEvidenceCaps( + raw, + positionFlagged, longest, avgConfidence, - segmentsPerMinute, - n, + positionScore, ) - raw = ratingClamp01(raw + explicitContextBonus + excellenceBonus) - score := ratingRound(raw*100, 1) r.Score = score r.ActionScore = ratingRound(actionRaw*100, 1) + r.BestPosition = bestPosition + r.BestPositionRank = bestPositionRank + r.BestClothing = bestClothing + r.BestClothingRank = bestClothingRank + r.PositionScore = ratingRound(positionScore*100, 1) + r.ClothingScore = ratingRound(clothingScore*100, 1) + r.DurationScore = ratingRound(durationScore*100, 1) + r.StrengthScore = ratingRound(strengthScore*100, 1) + r.CoverageScore = ratingRound(coverageScore*100, 1) + r.ContextScore = ratingRound(contextScore*100, 1) + r.VarietyScore = ratingRound(varietyScore*100, 1) r.Stars = starsFromHighlightScore(score) r.Segments = n r.SegmentsPerMinute = ratingRound(segmentsPerMinute, 2) - r.FlaggedSeconds = ratingRound(totalFlagged, 2) - r.WeightedFlaggedSeconds = ratingRound(totalWeighted, 2) + r.FlaggedSeconds = ratingRound(positionFlagged, 2) + r.WeightedFlaggedSeconds = ratingRound(positionWeighted, 2) r.CoverageRatio = ratingRound(coverageRatio, 4) r.WeightedCoverageRatio = ratingRound(weightedCoverageRatio, 4) r.LongestSegmentSeconds = ratingRound(longest, 2) r.AvgConfidence = ratingRound(avgConfidence, 3) appLogf( - "✅ %s rating score=%.1f stars=%d contextBonus=%.1f excellenceBonus=%.1f segs=%d flagged=%.1f posFlagged=%.1f posDensity=%.2f coverage=%.4f longest=%.1f avgConf=%.3f", + "rating %s score=%.1f stars=%d position=%.1f clothing=%.1f segs=%d flagged=%.1f duration=%.1f strength=%.1f coverage=%.4f longest=%.1f avgConf=%.3f", ratingLogSubject(username), r.Score, r.Stars, - ratingRound(explicitContextBonus*100, 1), - ratingRound(excellenceBonus*100, 1), + r.PositionScore, + r.ClothingScore, r.Segments, r.FlaggedSeconds, - ratingRound(positionFlagged, 1), - positionDensity, + r.DurationScore, + r.StrengthScore, r.WeightedCoverageRatio, r.LongestSegmentSeconds, r.AvgConfidence, diff --git a/backend/rating_test.go b/backend/rating_test.go index c781fd0..2c01536 100644 --- a/backend/rating_test.go +++ b/backend/rating_test.go @@ -46,22 +46,22 @@ func TestComputeHighlightRatingSpansAllStars(t *testing.T) { { name: "several long high quality positions", segments: []aiSegmentMeta{ - ratingTestSegment("position:doggy", 20, 25, 0.82), - ratingTestSegment("position:cowgirl", 120, 25, 0.82), - ratingTestSegment("position:missionary", 220, 25, 0.82), - ratingTestSegment("position:blowjob", 320, 25, 0.82), + ratingTestSegment("position:cowgirl", 20, 25, 0.82), + ratingTestSegment("position:reverse_cowgirl", 120, 25, 0.82), + ratingTestSegment("position:doggy", 220, 25, 0.82), + ratingTestSegment("position:toy_play", 320, 25, 0.82), }, want: 4, }, { name: "exceptional sustained and varied positions", segments: []aiSegmentMeta{ - ratingTestSegment("position:doggy", 10, 40, 0.90), - ratingTestSegment("position:cowgirl", 100, 40, 0.90), - ratingTestSegment("position:missionary", 190, 40, 0.90), - ratingTestSegment("position:blowjob", 280, 40, 0.90), - ratingTestSegment("position:cunnilingus", 370, 40, 0.90), - ratingTestSegment("position:reverse_cowgirl", 460, 40, 0.90), + ratingTestSegment("position:cowgirl", 10, 40, 0.90), + ratingTestSegment("position:reverse_cowgirl", 100, 40, 0.90), + ratingTestSegment("position:doggy", 190, 40, 0.90), + ratingTestSegment("position:toy_play", 280, 40, 0.90), + ratingTestSegment("position:fingering", 370, 40, 0.90), + ratingTestSegment("position:missionary", 460, 40, 0.90), }, want: 5, }, @@ -77,6 +77,141 @@ func TestComputeHighlightRatingSpansAllStars(t *testing.T) { } } +func TestExplicitPositionRanking(t *testing.T) { + labels := []string{ + "cowgirl", + "reverse_cowgirl", + "doggy", + "toy_play", + "fingering", + "missionary", + "prone_bone", + "spooning", + "standing", + "69", + "blowjob", + } + + previousWeight := 2.0 + for index, label := range labels { + ranked := positionExplicitRatingRank(label) + wantRank := index + 1 + + if ranked.Rank != wantRank { + t.Fatalf("%s rank = %d, want %d", label, ranked.Rank, wantRank) + } + if ranked.Weight <= 0 || ranked.Weight >= previousWeight { + t.Fatalf( + "%s weight = %.2f, want a positive value below %.2f", + label, + ranked.Weight, + previousWeight, + ) + } + + previousWeight = ranked.Weight + } + + for _, alias := range []string{"doggystyle", "standing_doggy"} { + if ranked := positionExplicitRatingRank(alias); ranked.Rank != 3 { + t.Fatalf("%s rank = %d, want alias rank 3", alias, ranked.Rank) + } + } + for _, grouped := range []string{"facesitting", "handjob", "cunnilingus", "boobjob"} { + if ranked := positionExplicitRatingRank(grouped); ranked.Rank != positionExplicitRatingRank("69").Rank && + ranked.Rank != positionExplicitRatingRank("blowjob").Rank { + t.Fatalf("%s has unexpected grouped rank %d", grouped, ranked.Rank) + } + } +} + +func TestExplicitClothingRanking(t *testing.T) { + labels := []string{ + "lingerie", + "stockings", + "skirt", + "hotpants", + "bikini", + "bra", + "panties", + "croptop", + "heels", + "dress", + } + + previousWeight := 2.0 + for index, label := range labels { + ranked := clothingExplicitRatingRank(label) + wantRank := index + 1 + + if ranked.Rank != wantRank { + t.Fatalf("%s rank = %d, want %d", label, ranked.Rank, wantRank) + } + if ranked.Weight <= 0 || ranked.Weight >= previousWeight { + t.Fatalf( + "%s weight = %.2f, want a positive value below %.2f", + label, + ranked.Weight, + previousWeight, + ) + } + + previousWeight = ranked.Weight + } +} + +func TestComputeHighlightRatingReportsBestExplicitRanks(t *testing.T) { + got := computeHighlightRating([]aiSegmentMeta{ + ratingTestSegment("combo:position:standing+clothing:dress", 10, 20, 0.90), + ratingTestSegment("combo:position:cowgirl+clothing:panties", 80, 20, 0.90), + ratingTestSegment("combo:position:doggy+clothing:lingerie", 150, 20, 0.90), + }, 300) + + if got.BestPosition != "cowgirl" || got.BestPositionRank != 1 { + t.Fatalf( + "best position = %q rank %d, want cowgirl rank 1", + got.BestPosition, + got.BestPositionRank, + ) + } + if got.BestClothing != "lingerie" || got.BestClothingRank != 1 { + t.Fatalf( + "best clothing = %q rank %d, want lingerie rank 1", + got.BestClothing, + got.BestClothingRank, + ) + } +} + +func TestComputeHighlightRatingFollowsExplicitRank(t *testing.T) { + positionScore := func(label string) float64 { + return computeHighlightRating([]aiSegmentMeta{ + ratingTestSegment("position:"+label, 10, 30, 0.85), + }, 300).Score + } + clothingScore := func(label string) float64 { + return computeHighlightRating([]aiSegmentMeta{ + ratingTestSegment("clothing:"+label, 10, 90, 0.90), + }, 300).Score + } + + if !(positionScore("cowgirl") > positionScore("doggy") && + positionScore("doggy") > positionScore("toy_play") && + positionScore("toy_play") > positionScore("missionary") && + positionScore("missionary") > positionScore("standing") && + positionScore("standing") > positionScore("blowjob")) { + t.Fatal("position score does not follow the explicit ranking") + } + + if !(clothingScore("lingerie") > clothingScore("stockings") && + clothingScore("stockings") > clothingScore("hotpants") && + clothingScore("hotpants") > clothingScore("bikini") && + clothingScore("bikini") > clothingScore("panties") && + clothingScore("panties") > clothingScore("dress")) { + t.Fatal("clothing score does not follow the explicit ranking") + } +} + func TestComputeHighlightRatingRewardsPositionDuration(t *testing.T) { short := computeHighlightRating([]aiSegmentMeta{ ratingTestSegment("position:doggy", 10, 8, 0.80), @@ -122,6 +257,67 @@ func TestComputeHighlightRatingActionPrefersSustainedActivity(t *testing.T) { } } +func TestComputeHighlightRatingDoesNotDoubleCountOverlaps(t *testing.T) { + got := computeHighlightRating([]aiSegmentMeta{ + ratingTestSegment("position:cowgirl", 0, 30, 0.90), + ratingTestSegment("position:doggy", 10, 30, 0.90), + }, 100) + + if got.FlaggedSeconds != 40 { + t.Fatalf("flagged seconds = %.1f, want 40.0", got.FlaggedSeconds) + } + if got.CoverageRatio != 0.4 { + t.Fatalf("coverage ratio = %.4f, want 0.4000", got.CoverageRatio) + } +} + +func TestComputeHighlightRatingReportsIndependentComponents(t *testing.T) { + got := computeHighlightRating([]aiSegmentMeta{ + ratingTestSegment("combo:position:cowgirl+body:pussy+clothing:lingerie", 10, 40, 0.90), + ratingTestSegment("combo:position:doggy+object:dildo+clothing:stockings", 100, 40, 0.90), + }, 300) + + components := map[string]float64{ + "position": got.PositionScore, + "clothing": got.ClothingScore, + "duration": got.DurationScore, + "strength": got.StrengthScore, + "coverage": got.CoverageScore, + "context": got.ContextScore, + "variety": got.VarietyScore, + "action": got.ActionScore, + } + for name, value := range components { + if value <= 0 || value > 100 { + t.Fatalf("%s component = %.1f, want value in (0, 100]", name, value) + } + } +} + +func TestComputeHighlightRatingStrengthFollowsConfidence(t *testing.T) { + weak := computeHighlightRating([]aiSegmentMeta{ + ratingTestSegment("position:doggy", 10, 45, 0.45), + }, 300) + strong := computeHighlightRating([]aiSegmentMeta{ + ratingTestSegment("position:doggy", 10, 45, 0.90), + }, 300) + + if strong.StrengthScore <= weak.StrengthScore { + t.Fatalf( + "strength should follow confidence: strong=%.1f weak=%.1f", + strong.StrengthScore, + weak.StrengthScore, + ) + } + if strong.Score <= weak.Score { + t.Fatalf( + "rating should reward stronger evidence: strong=%.1f weak=%.1f", + strong.Score, + weak.Score, + ) + } +} + func TestComputeHighlightRatingRatesContextWithoutPosition(t *testing.T) { clothingOnly := computeHighlightRating([]aiSegmentMeta{ ratingTestSegment("clothing:lingerie", 10, 60, 0.90), @@ -134,9 +330,9 @@ func TestComputeHighlightRatingRatesContextWithoutPosition(t *testing.T) { ratingTestSegment("combo:body:penis+object:dildo", 240, 45, 0.90), }, 600) - if clothingOnly.Stars != 2 { + if clothingOnly.Stars != 3 { t.Fatalf( - "clothing-only stars = %d, want 2 (score %.1f)", + "clothing-only stars = %d, want 3 (score %.1f)", clothingOnly.Stars, clothingOnly.Score, ) @@ -210,10 +406,10 @@ func TestComputeHighlightRatingExplicitSignalsBoostPositions(t *testing.T) { func TestComputeHighlightRatingExplicitContextCanElevateStrongPositions(t *testing.T) { positions := []aiSegmentMeta{ - ratingTestSegment("position:doggy", 20, 25, 0.82), - ratingTestSegment("position:cowgirl", 120, 25, 0.82), - ratingTestSegment("position:missionary", 220, 25, 0.82), - ratingTestSegment("position:blowjob", 320, 25, 0.82), + ratingTestSegment("position:cowgirl", 20, 25, 0.82), + ratingTestSegment("position:reverse_cowgirl", 120, 25, 0.82), + ratingTestSegment("position:doggy", 220, 25, 0.82), + ratingTestSegment("position:toy_play", 320, 25, 0.82), } positionOnly := computeHighlightRating(positions, 600) withExplicitContext := computeHighlightRating(append( diff --git a/backend/training.go b/backend/training.go index e1cd15b..af52cee 100644 --- a/backend/training.go +++ b/backend/training.go @@ -1481,11 +1481,8 @@ func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) { continue } - // Ab hier existiert das erste echte extrahierte Bild. - // Dieses bleibt als Overlay-Background für Analyse/Speichern/weitere Frames. - if previewURL == "" { - previewURL = "/api/training/frame?id=" + url.QueryEscape(id) - } + // Nach jeder erfolgreichen Extraktion das aktuell verarbeitete Frame zeigen. + previewURL = "/api/training/frame?id=" + url.QueryEscape(id) trainingPublishAnalysisStepWithPreview( requestID, diff --git a/frontend/src/aiLabels.ts b/frontend/src/aiLabels.ts index f7d23a7..da38a5b 100644 --- a/frontend/src/aiLabels.ts +++ b/frontend/src/aiLabels.ts @@ -138,6 +138,17 @@ export const RATING_VALUE_LABELS: Record = { stars: 'Sterne', score: 'Score', actionScore: 'Action', + bestPosition: 'Top-Position', + bestPositionRank: 'Positionsrang', + bestClothing: 'Top-Kleidung', + bestClothingRank: 'Kleidungsrang', + positionScore: 'Position', + clothingScore: 'Kleidung', + durationScore: 'Dauer', + strengthScore: 'Stärke', + coverageScore: 'Abdeckung', + contextScore: 'Kontext', + varietyScore: 'Vielfalt', confidence: 'Konfidenz', probability: 'Wahrscheinlichkeit', nsfw: 'NSFW', diff --git a/frontend/src/components/ui/TrainingTab.tsx b/frontend/src/components/ui/TrainingTab.tsx index b32acb7..5530974 100644 --- a/frontend/src/components/ui/TrainingTab.tsx +++ b/frontend/src/components/ui/TrainingTab.tsx @@ -767,13 +767,13 @@ function TrainingStageOverlay(props: { }) { const progress = clampPercent(props.progress ?? 0) const isTraining = props.mode === 'training' - const hasBackground = !isTraining && Boolean(props.backgroundUrl) + const hasBackground = Boolean(props.backgroundUrl) const visible = props.visible ?? true const [backgroundVisible, setBackgroundVisible] = useState(false) useEffect(() => { - if (!props.backgroundUrl || isTraining) { + if (!props.backgroundUrl) { setBackgroundVisible(false) return } @@ -785,7 +785,7 @@ function TrainingStageOverlay(props: { }) return () => window.cancelAnimationFrame(frame) - }, [props.backgroundUrl, isTraining]) + }, [props.backgroundUrl]) const title = isTraining ? 'Training läuft…' : 'Analyse läuft…' const fallbackText = isTraining @@ -5685,9 +5685,9 @@ export default function TrainingTab(props: { progress={stageOverlayProgress} visible={stageOverlayIsVisible} backgroundUrl={ - stageOverlayMode === 'analysis' - ? loadingPreviewBackgroundUrl - : undefined + stageOverlayMode === 'training' + ? imageSrc + : loadingPreviewBackgroundUrl } /> ) : null} @@ -6276,4 +6276,4 @@ export default function TrainingTab(props: { /> ) -} \ No newline at end of file +}