nsfwapp/backend/rating.go
2026-05-05 14:05:56 +02:00

875 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 45 Sterne kippen.
if positionEffectiveWeighted <= 0 {
raw = math.Min(raw, 0.58)
}
// 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
}