nsfwapp/backend/rating.go
2026-05-06 21:05:08 +02:00

875 lines
19 KiB
Go

// 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
}