875 lines
19 KiB
Go
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 4–5 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
|
||
}
|