// backend\rating.go package main import ( "math" "sort" "strings" ) type aiRatingMeta struct { Score float64 `json:"score"` Stars int `json:"stars"` Segments int `json:"segments"` SegmentsPerMinute float64 `json:"segmentsPerMinute"` FlaggedSeconds float64 `json:"flaggedSeconds"` WeightedFlaggedSeconds float64 `json:"weightedFlaggedSeconds"` CoverageRatio float64 `json:"coverageRatio"` WeightedCoverageRatio float64 `json:"weightedCoverageRatio"` LongestSegmentSeconds float64 `json:"longestSegmentSeconds"` AvgConfidence float64 `json:"avgConfidence"` } func ratingClamp01(v float64) float64 { if v < 0 { return 0 } if v > 1 { return 1 } return v } func ratingRound(v float64, places int) float64 { p := math.Pow(10, float64(places)) return math.Round(v*p) / p } func ratingSmoothStep(v float64) float64 { v = ratingClamp01(v) return v * v * (3 - 2*v) } func ratingSoftCap(value, knee float64) float64 { if value <= 0 || knee <= 0 { return 0 } return value / (value + knee) } func ratingEffectiveDurationSeconds(seconds float64) float64 { if seconds <= 0 { return 0 } // Lange Segmente zählen weiter, aber mit abnehmendem Zusatznutzen. // Dadurch kippen falsche oder zu breite Merges nicht sofort auf 5 Sterne. const kneeSeconds = 24.0 return kneeSeconds * math.Log1p(seconds/kneeSeconds) } func segmentSeverityWeight(label string) float64 { label = strings.ToLower(strings.TrimSpace(label)) if label == "" { return 0.50 } // ------------------------- // Kombi-Highlights // ------------------------- if strings.HasPrefix(label, "combo:") { return comboSegmentSeverityWeight(label) } // ------------------------- // Sexpositionen // ------------------------- if strings.HasPrefix(label, "position:") { pos := strings.TrimPrefix(label, "position:") return positionSeverityWeight(pos) } // ------------------------- // Body / Objects / Clothing // ------------------------- if strings.HasPrefix(label, "body:") { body := strings.TrimPrefix(label, "body:") return bodyPartSeverityWeight(body) } if strings.HasPrefix(label, "object:") { obj := strings.TrimPrefix(label, "object:") return objectSeverityWeight(obj) } if strings.HasPrefix(label, "clothing:") { clothing := strings.TrimPrefix(label, "clothing:") return clothingSeverityWeight(clothing) } if strings.HasPrefix(label, "detector:") { det := strings.TrimPrefix(label, "detector:") return detectorSeverityWeight(det) } // ------------------------- // Direkte YOLO-Labels // ------------------------- return detectorSeverityWeight(label) } func isPersonSegmentLabel(label string) bool { label = strings.ToLower(strings.TrimSpace(label)) label = strings.TrimPrefix(label, "detector:") label = strings.TrimPrefix(label, "body:") label = strings.TrimPrefix(label, "object:") label = strings.TrimPrefix(label, "clothing:") label = strings.TrimPrefix(label, "position:") switch label { case "person", "person_male", "person_female", "person_unknown", "male_person", "female_person", "people_male", "people_female": return true default: return false } } func detectorSeverityWeight(label string) float64 { label = strings.ToLower(strings.TrimSpace(label)) switch label { // Personen werden vor dem Rating komplett herausgefiltert. // Dieser Fallback bleibt nur für alte Pfade. case "person", "person_male", "person_female", "person_unknown", "people_male", "people_female": return 0.00 // bodyParts aus detecton_labels.json case "pussy", "vulva": return 1.00 case "penis": return 0.95 case "anus": return 0.90 case "breasts": return 0.80 case "ass", "buttocks": return 0.65 case "tongue": return 0.45 // objects aus detecton_labels.json case "dildo", "vibrator", "strapon", "buttplug": return 0.85 case "handcuffs", "blindfold", "collar": return 0.55 case "shower": return 0.40 case "towel": return 0.25 // clothing aus detecton_labels.json case "lingerie": return 0.60 case "panties", "bra": return 0.55 case "bikini": return 0.35 case "stockings", "heels": return 0.35 case "skirt", "dress", "hotpants", "croptop": return 0.30 // optionale alte/alias Labels, falls noch in alten Metas vorhanden case "female_genitalia_exposed": return 1.00 case "male_genitalia_exposed": return 0.95 case "anus_exposed": return 0.90 case "female_breast_exposed", "breast_exposed": return 0.80 case "buttocks_exposed": return 0.65 default: return 0.50 } } func bodyPartSeverityWeight(label string) float64 { label = strings.ToLower(strings.TrimSpace(label)) switch label { case "pussy", "vulva": return 1.00 case "penis": return 0.95 case "anus": return 0.90 case "breasts": return 0.80 case "ass", "buttocks": return 0.65 case "tongue": return 0.45 case "mouth", "face": return 0.45 case "hands": return 0.35 default: return 0.50 } } func objectSeverityWeight(label string) float64 { label = strings.ToLower(strings.TrimSpace(label)) switch label { case "dildo", "vibrator", "strapon", "buttplug", "sex_toy": return 0.85 case "handcuffs", "blindfold", "collar": return 0.55 case "shower": return 0.40 case "towel": return 0.25 default: return 0.45 } } func clothingSeverityWeight(label string) float64 { label = strings.ToLower(strings.TrimSpace(label)) switch label { case "nude", "naked": return 0.90 case "lingerie": return 0.60 case "panties", "bra", "underwear": return 0.55 case "bikini", "swimwear": return 0.35 case "stockings", "heels": return 0.35 case "skirt", "dress", "hotpants", "croptop": return 0.30 default: return 0.30 } } func positionSeverityWeight(label string) float64 { label = strings.ToLower(strings.TrimSpace(label)) switch label { // sehr explizit / klar sexuelle Handlung case "doggy", "doggystyle", "standing_doggy": return 1.00 case "cowgirl", "reverse_cowgirl": return 0.98 case "missionary": return 0.95 case "prone_bone": return 0.95 case "blowjob", "cunnilingus", "oral", "69", "facesitting": return 0.94 // explizit, aber etwas niedriger case "toy_play": return 0.88 case "handjob", "fingering": return 0.84 case "spooning": return 0.78 // Pose/Kontext, aber nicht stark genug alleine case "standing", "sitting": return 0.42 case "other": return 0.45 case "unknown", "": return 0.00 default: return 0.00 } } type ratingSignalSet struct { Position float64 Body float64 Object float64 Clothing float64 HasPosition bool HasBody bool HasObject bool HasClothing bool } func isKnownPositionLabel(label string) bool { label = strings.ToLower(strings.TrimSpace(label)) switch label { case "doggy", "doggystyle", "standing_doggy", "cowgirl", "reverse_cowgirl", "missionary", "prone_bone", "blowjob", "cunnilingus", "oral", "69", "facesitting", "toy_play", "handjob", "fingering", "spooning", "standing", "sitting", "other": return true default: return false } } func ratingSignalSetAddLabel(set *ratingSignalSet, label string) { label = strings.ToLower(strings.TrimSpace(label)) if label == "" || isPersonSegmentLabel(label) { return } switch { case strings.HasPrefix(label, "position:"): raw := strings.TrimPrefix(label, "position:") if !isKnownPositionLabel(raw) { return } w := positionSeverityWeight(raw) if w > set.Position { set.Position = w } if w > 0 { set.HasPosition = true } case strings.HasPrefix(label, "body:"): w := bodyPartSeverityWeight(strings.TrimPrefix(label, "body:")) if w > set.Body { set.Body = w } if w > 0 { set.HasBody = true } case strings.HasPrefix(label, "object:"): w := objectSeverityWeight(strings.TrimPrefix(label, "object:")) if w > set.Object { set.Object = w } if w > 0 { set.HasObject = true } case strings.HasPrefix(label, "clothing:"): w := clothingSeverityWeight(strings.TrimPrefix(label, "clothing:")) if w > set.Clothing { set.Clothing = w } if w > 0 { set.HasClothing = true } case strings.HasPrefix(label, "detector:"): raw := strings.TrimPrefix(label, "detector:") // Detector-Labels sind meistens Body/Object/Clothing, nicht Position. switch { case bodyPartSeverityWeight(raw) >= 0.65: ratingSignalSetAddLabel(set, "body:"+raw) case objectSeverityWeight(raw) >= 0.50: ratingSignalSetAddLabel(set, "object:"+raw) case clothingSeverityWeight(raw) >= 0.50: ratingSignalSetAddLabel(set, "clothing:"+raw) case isKnownPositionLabel(raw): ratingSignalSetAddLabel(set, "position:"+raw) } default: // Direkte alte YOLO-Labels sinnvoll einsortieren. // Wichtig: Position zuletzt prüfen, sonst greift positionSeverityWeight(default=0.60) // für fast alles. switch { case bodyPartSeverityWeight(label) >= 0.65: ratingSignalSetAddLabel(set, "body:"+label) case objectSeverityWeight(label) >= 0.50: ratingSignalSetAddLabel(set, "object:"+label) case clothingSeverityWeight(label) >= 0.50: ratingSignalSetAddLabel(set, "clothing:"+label) case isKnownPositionLabel(label): ratingSignalSetAddLabel(set, "position:"+label) } } } func ratingSignalSetFromLabel(label string) ratingSignalSet { label = strings.ToLower(strings.TrimSpace(label)) var set ratingSignalSet if strings.HasPrefix(label, "combo:") { raw := strings.TrimPrefix(label, "combo:") for _, part := range strings.Split(raw, "+") { ratingSignalSetAddLabel(&set, part) } return set } ratingSignalSetAddLabel(&set, label) return set } func contextualSegmentSeverityWeight(label string) float64 { set := ratingSignalSetFromLabel(label) hasAny := set.HasPosition || set.HasBody || set.HasObject || set.HasClothing if !hasAny { return 0 } var score float64 if set.HasPosition { // Position ist der Hauptanker. score = 0.72*set.Position + 0.14*set.Body + 0.09*set.Object + 0.05*set.Clothing // Echte Kombi-Boni. if set.HasBody { score += 0.04 } if set.HasObject { score += 0.035 } if set.HasClothing { score += 0.015 } // Schwache Positionslabels wie standing/sitting sollen ohne Kontext niedrig bleiben. if set.Position < 0.60 && !set.HasBody && !set.HasObject { score = math.Min(score, 0.45) } return ratingClamp01(score) } // Ohne Position: Kontext darf zählen, aber gedeckelt. score = 0.58*set.Body + 0.28*set.Object + 0.14*set.Clothing if set.HasBody && set.HasObject { score += 0.08 } if set.HasBody && set.HasClothing { score += 0.03 } // Kleidung alleine niemals stark bewerten. if set.HasClothing && !set.HasBody && !set.HasObject { score = math.Min(score, 0.32) } // Keine Position => nicht höher als "mittel-hoch". score = math.Min(score, 0.68) return ratingClamp01(score) } func comboSegmentSeverityWeight(label string) float64 { raw := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(label)), "combo:") if raw == "" { return 0.00 } parts := strings.Split(raw, "+") var weights []float64 hasPersonContext := false for _, part := range parts { part = strings.TrimSpace(part) if part == "" { continue } if isPersonSegmentLabel(part) { hasPersonContext = true continue } weight := 0.0 switch { case strings.HasPrefix(part, "position:"): weight = positionSeverityWeight(strings.TrimPrefix(part, "position:")) case strings.HasPrefix(part, "body:"): weight = bodyPartSeverityWeight(strings.TrimPrefix(part, "body:")) case strings.HasPrefix(part, "object:"): weight = objectSeverityWeight(strings.TrimPrefix(part, "object:")) case strings.HasPrefix(part, "clothing:"): weight = clothingSeverityWeight(strings.TrimPrefix(part, "clothing:")) case strings.HasPrefix(part, "detector:"): det := strings.TrimPrefix(part, "detector:") if isPersonSegmentLabel(det) { hasPersonContext = true continue } weight = detectorSeverityWeight(det) default: if isPersonSegmentLabel(part) { hasPersonContext = true continue } weight = detectorSeverityWeight(part) } if weight > 0 { weights = append(weights, weight) } } // Nur Person in der Combo => kein Rating-/Highlight-Gewicht. if len(weights) == 0 { return 0.00 } var sum float64 var maxWeight float64 for _, weight := range weights { sum += weight if weight > maxWeight { maxWeight = weight } } avg := sum / float64(len(weights)) // Kombi = stärkstes Signal plus leichter Kontext-Boost. combined := 0.70*maxWeight + 0.30*avg // Person macht das Highlight verständlicher, soll aber nicht stark boosten. if hasPersonContext { combined += 0.03 } // Echte Combos sollen sichtbar relevant bleiben, // aber nur wenn mindestens ein Nicht-Personen-Signal existiert. if combined < 0.60 { combined = 0.60 } if combined > 1.00 { combined = 1.00 } return combined } func normalizeSegmentLabel(label string) string { label = strings.ToLower(strings.TrimSpace(label)) label = strings.TrimPrefix(label, "detector:") label = strings.TrimPrefix(label, "body:") label = strings.TrimPrefix(label, "object:") label = strings.TrimPrefix(label, "clothing:") label = strings.TrimPrefix(label, "position:") return strings.TrimSpace(label) } func mergeAdjacentAISegments(segments []aiSegmentMeta, maxGapSec float64) []aiSegmentMeta { if len(segments) == 0 { return nil } // Pro normalisiertem Label separat mergen. // Dadurch verhindern andere Labels zwischen zwei Treffern nicht mehr das Zusammenführen. byLabel := make(map[string][]aiSegmentMeta) for _, s := range segments { if strings.TrimSpace(s.Label) == "" { continue } if s.DurationSeconds <= 0 { s.DurationSeconds = s.EndSeconds - s.StartSeconds } if s.EndSeconds <= s.StartSeconds { continue } key := normalizeSegmentLabel(s.Label) if key == "" { continue } byLabel[key] = append(byLabel[key], s) } out := make([]aiSegmentMeta, 0, len(segments)) for _, items := range byLabel { sort.SliceStable(items, func(i, j int) bool { if items[i].StartSeconds != items[j].StartSeconds { return items[i].StartSeconds < items[j].StartSeconds } if items[i].EndSeconds != items[j].EndSeconds { return items[i].EndSeconds < items[j].EndSeconds } return items[i].Label < items[j].Label }) cur := items[0] for i := 1; i < len(items); i++ { n := items[i] gap := n.StartSeconds - cur.EndSeconds if gap >= -0.25 && gap <= maxGapSec { oldDur := cur.DurationSeconds if oldDur <= 0 { oldDur = cur.EndSeconds - cur.StartSeconds } newDur := n.DurationSeconds if newDur <= 0 { newDur = n.EndSeconds - n.StartSeconds } totalDur := oldDur + newDur 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 cur.AutoSelected = cur.AutoSelected || n.AutoSelected if totalDur > 0 { cur.Score = ((cur.Score * oldDur) + (n.Score * newDur)) / totalDur } else if n.Score > cur.Score { cur.Score = n.Score } continue } out = append(out, cur) cur = n } out = append(out, cur) } 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 normalizeSegmentLabel(out[i].Label) < normalizeSegmentLabel(out[j].Label) }) return out } func ratingConfidenceWeight(conf float64) float64 { conf = ratingClamp01(conf) // Unterhalb ~0.30 soll Confidence kaum boosten. // Oberhalb ~0.95 ist praktisch gesättigt. n := ratingSmoothStep((conf - 0.30) / 0.65) return 0.60 + 0.40*n } func starsFromNSFWScore(score float64) int { switch { case score < 18: return 1 case score < 38: return 2 case score < 60: return 3 case score < 80: return 4 default: return 5 } } func computeNSFWRating(segments []aiSegmentMeta, durationSec float64) *aiRatingMeta { segments = mergeAdjacentAISegments(segments, 5.0) r := &aiRatingMeta{ Score: 0, Stars: 1, } if durationSec <= 0 || len(segments) == 0 { return r } videoMinutes := math.Max(durationSec/60.0, 0.25) var totalFlagged float64 var totalWeighted float64 var totalEffectiveWeighted float64 var positionEffectiveWeighted float64 var contextEffectiveWeighted float64 var peakQuality float64 var longest float64 var confSum float64 var n int for _, s := range segments { if isPersonSegmentLabel(s.Label) { continue } segDur := s.DurationSeconds if segDur <= 0 { segDur = s.EndSeconds - s.StartSeconds } if segDur <= 0 { continue } sev := contextualSegmentSeverityWeight(s.Label) if sev <= 0 { continue } conf := ratingClamp01(s.Score) confWeight := ratingConfidenceWeight(conf) quality := sev * confWeight if quality <= 0 { continue } effectiveDur := ratingEffectiveDurationSeconds(segDur) weightedDur := segDur * quality effectiveWeightedDur := effectiveDur * quality totalFlagged += segDur totalWeighted += weightedDur totalEffectiveWeighted += effectiveWeightedDur set := ratingSignalSetFromLabel(s.Label) if set.HasPosition { positionEffectiveWeighted += effectiveWeightedDur } else { contextEffectiveWeighted += effectiveWeightedDur } if quality > peakQuality { peakQuality = quality } confSum += conf n++ if segDur > longest { longest = segDur } } if n == 0 { return r } coverageRatio := ratingClamp01(totalFlagged / durationSec) weightedCoverageRatio := ratingClamp01(totalWeighted / durationSec) segmentsPerMinute := float64(n) / videoMinutes avgConfidence := confSum / float64(n) positionEffectiveWeightedPerMinute := positionEffectiveWeighted / videoMinutes contextEffectiveWeightedPerMinute := contextEffectiveWeighted / videoMinutes // Position ist der Haupttreiber. // Kontext ohne Position zählt mit, wird aber schwächer normalisiert. peakNorm := ratingSmoothStep(peakQuality) positionDensityNorm := ratingSoftCap(positionEffectiveWeightedPerMinute, 6.0) contextDensityNorm := ratingSoftCap(contextEffectiveWeightedPerMinute, 10.0) coverageNorm := ratingSoftCap(weightedCoverageRatio, 0.22) frequencyNorm := ratingSoftCap(segmentsPerMinute, 1.40) longestNorm := ratingSoftCap(longest, 28.0) confNorm := ratingSmoothStep((avgConfidence - 0.35) / 0.60) raw := 0.34*peakNorm + 0.28*positionDensityNorm + 0.14*coverageNorm + 0.10*contextDensityNorm + 0.06*longestNorm + 0.04*frequencyNorm + 0.04*confNorm // Sicherheits-Caps: // Ohne Positionssignal soll Kontext alleine nicht auf 5 Sterne kippen. if positionEffectiveWeighted <= 0 { raw = math.Min(raw, 0.72) } // Sehr kurze/spärliche Treffer nicht überbewerten. if totalFlagged < 4.0 && n <= 1 { raw = math.Min(raw, 0.42) } score := ratingRound(ratingClamp01(raw)*100, 1) r.Score = score r.Stars = starsFromNSFWScore(score) r.Segments = n r.SegmentsPerMinute = ratingRound(segmentsPerMinute, 2) r.FlaggedSeconds = ratingRound(totalFlagged, 2) r.WeightedFlaggedSeconds = ratingRound(totalWeighted, 2) r.CoverageRatio = ratingRound(coverageRatio, 4) r.WeightedCoverageRatio = ratingRound(weightedCoverageRatio, 4) r.LongestSegmentSeconds = ratingRound(longest, 2) r.AvgConfidence = ratingRound(avgConfidence, 3) return r }