1777 lines
41 KiB
Go
1777 lines
41 KiB
Go
// backend\rating.go
|
|
|
|
package main
|
|
|
|
import (
|
|
"math"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
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"`
|
|
FlaggedSeconds float64 `json:"flaggedSeconds"`
|
|
WeightedFlaggedSeconds float64 `json:"weightedFlaggedSeconds"`
|
|
CoverageRatio float64 `json:"coverageRatio"`
|
|
WeightedCoverageRatio float64 `json:"weightedCoverageRatio"`
|
|
LongestSegmentSeconds float64 `json:"longestSegmentSeconds"`
|
|
AvgConfidence float64 `json:"avgConfidence"`
|
|
}
|
|
|
|
const (
|
|
// Normales Zusammenziehen sehr kurzer Pausen.
|
|
ratingMaxSilentGapSec = 4.5
|
|
|
|
// Größere Lücken dürfen überbrückt werden,
|
|
// wenn beide Seiten stark genug / thematisch ähnlich sind.
|
|
ratingBridgeStrongGapSec = 12.0
|
|
|
|
ratingMinSegmentDurationSec = 2.5
|
|
)
|
|
|
|
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 ratingSaturatingEvidence(value, scale float64) float64 {
|
|
if value <= 0 || scale <= 0 {
|
|
return 0
|
|
}
|
|
return ratingClamp01(1 - math.Exp(-value/scale))
|
|
}
|
|
|
|
func ratingEffectiveDurationSeconds(seconds float64) float64 {
|
|
if seconds <= 0 {
|
|
return 0
|
|
}
|
|
|
|
// Lange Segmente zählen, aber nicht linear unendlich stark.
|
|
const kneeSeconds = 26.0
|
|
return kneeSeconds * math.Log1p(seconds/kneeSeconds)
|
|
}
|
|
|
|
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:")
|
|
label = strings.TrimPrefix(label, "person:")
|
|
|
|
return strings.TrimSpace(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:")
|
|
label = strings.TrimPrefix(label, "person:")
|
|
|
|
switch label {
|
|
case "person",
|
|
"person_unknown",
|
|
"person_male",
|
|
"person_female",
|
|
"male_person",
|
|
"female_person",
|
|
"people_male",
|
|
"people_female":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isKnownPositionLabel(label string) bool {
|
|
label = strings.ToLower(strings.TrimSpace(label))
|
|
if isNoSexPositionLabel(label) {
|
|
return false
|
|
}
|
|
|
|
switch label {
|
|
case "missionary",
|
|
"doggy",
|
|
"doggystyle",
|
|
"cowgirl",
|
|
"reverse_cowgirl",
|
|
"cunnilingus",
|
|
"prone_bone",
|
|
"standing",
|
|
"standing_doggy",
|
|
"spooning",
|
|
"facesitting",
|
|
"handjob",
|
|
"blowjob",
|
|
"boobjob",
|
|
"toy_play",
|
|
"fingering",
|
|
"69":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
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 explicitRatingRank{Rank: 3, Weight: 0.98}
|
|
case "toy_play":
|
|
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 explicitRatingRank{Rank: 8, Weight: 0.86}
|
|
case "standing":
|
|
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 explicitRatingRank{}
|
|
}
|
|
}
|
|
|
|
func positionSeverityWeight(label string) float64 {
|
|
return positionExplicitRatingRank(label).Weight
|
|
}
|
|
|
|
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.66
|
|
case "tongue":
|
|
return 0.46
|
|
default:
|
|
return 0.00
|
|
}
|
|
}
|
|
|
|
func objectSeverityWeight(label string) float64 {
|
|
label = strings.ToLower(strings.TrimSpace(label))
|
|
|
|
switch label {
|
|
case "dildo", "vibrator", "strapon", "buttplug":
|
|
return 0.86
|
|
case "handcuffs", "blindfold", "collar":
|
|
return 0.58
|
|
case "shower":
|
|
return 0.42
|
|
case "towel":
|
|
return 0.28
|
|
default:
|
|
return 0.00
|
|
}
|
|
}
|
|
|
|
func clothingExplicitRatingRank(label string) explicitRatingRank {
|
|
label = strings.ToLower(strings.TrimSpace(label))
|
|
|
|
switch label {
|
|
case "lingerie":
|
|
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 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 explicitRatingRank{}
|
|
}
|
|
}
|
|
|
|
func clothingSeverityWeight(label string) float64 {
|
|
return clothingExplicitRatingRank(label).Weight
|
|
}
|
|
|
|
func personContextWeight(label string) float64 {
|
|
if !isPersonSegmentLabel(label) {
|
|
return 0
|
|
}
|
|
|
|
// Personen sind Kontext, aber nie alleiniger Rating-Treiber.
|
|
return 0.18
|
|
}
|
|
|
|
func detectorSeverityWeight(label string) float64 {
|
|
label = strings.ToLower(strings.TrimSpace(label))
|
|
|
|
if isPersonSegmentLabel(label) {
|
|
return personContextWeight(label)
|
|
}
|
|
|
|
if w := bodyPartSeverityWeight(label); w > 0 {
|
|
return w
|
|
}
|
|
if w := objectSeverityWeight(label); w > 0 {
|
|
return w
|
|
}
|
|
if w := clothingSeverityWeight(label); w > 0 {
|
|
return w
|
|
}
|
|
if isKnownPositionLabel(label) {
|
|
return positionSeverityWeight(label)
|
|
}
|
|
|
|
return 0.00
|
|
}
|
|
|
|
type ratingSignalSet struct {
|
|
Position float64
|
|
Body float64
|
|
Object float64
|
|
Clothing float64
|
|
Person float64
|
|
|
|
HasPosition bool
|
|
HasBody bool
|
|
HasObject bool
|
|
HasClothing bool
|
|
HasPerson bool
|
|
}
|
|
|
|
func normalizeRatingSignalLabel(label string) string {
|
|
label = strings.ToLower(strings.TrimSpace(label))
|
|
if isNoSexPositionLabel(label) {
|
|
return ""
|
|
}
|
|
|
|
if strings.HasPrefix(label, "combo:") {
|
|
return label
|
|
}
|
|
|
|
if strings.HasPrefix(label, "detector:") {
|
|
return normalizeRatingSignalLabel(strings.TrimPrefix(label, "detector:"))
|
|
}
|
|
|
|
if strings.HasPrefix(label, "position:") {
|
|
raw := strings.TrimPrefix(label, "position:")
|
|
if isKnownPositionLabel(raw) && positionSeverityWeight(raw) > 0 {
|
|
return "position:" + raw
|
|
}
|
|
return ""
|
|
}
|
|
|
|
if strings.HasPrefix(label, "body:") {
|
|
raw := strings.TrimPrefix(label, "body:")
|
|
if bodyPartSeverityWeight(raw) > 0 {
|
|
return "body:" + raw
|
|
}
|
|
return ""
|
|
}
|
|
|
|
if strings.HasPrefix(label, "object:") {
|
|
raw := strings.TrimPrefix(label, "object:")
|
|
if objectSeverityWeight(raw) > 0 {
|
|
return "object:" + raw
|
|
}
|
|
return ""
|
|
}
|
|
|
|
if strings.HasPrefix(label, "clothing:") {
|
|
raw := strings.TrimPrefix(label, "clothing:")
|
|
if clothingSeverityWeight(raw) > 0 {
|
|
return "clothing:" + raw
|
|
}
|
|
return ""
|
|
}
|
|
|
|
if strings.HasPrefix(label, "person:") {
|
|
raw := strings.TrimPrefix(label, "person:")
|
|
if isPersonSegmentLabel(raw) {
|
|
return "person:" + normalizeSegmentLabel(raw)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
raw := normalizeSegmentLabel(label)
|
|
if isNoSexPositionLabel(raw) {
|
|
return ""
|
|
}
|
|
|
|
if isPersonSegmentLabel(raw) {
|
|
return "person:" + raw
|
|
}
|
|
if isKnownPositionLabel(raw) && positionSeverityWeight(raw) > 0 {
|
|
return "position:" + raw
|
|
}
|
|
if bodyPartSeverityWeight(raw) > 0 {
|
|
return "body:" + raw
|
|
}
|
|
if objectSeverityWeight(raw) > 0 {
|
|
return "object:" + raw
|
|
}
|
|
if clothingSeverityWeight(raw) > 0 {
|
|
return "clothing:" + raw
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func ratingSignalSetAddLabel(set *ratingSignalSet, label string) {
|
|
label = normalizeRatingSignalLabel(label)
|
|
if label == "" {
|
|
return
|
|
}
|
|
|
|
switch {
|
|
case strings.HasPrefix(label, "position:"):
|
|
raw := strings.TrimPrefix(label, "position:")
|
|
w := positionSeverityWeight(raw)
|
|
if w > set.Position {
|
|
set.Position = w
|
|
}
|
|
if w > 0 {
|
|
set.HasPosition = true
|
|
}
|
|
|
|
case strings.HasPrefix(label, "body:"):
|
|
raw := strings.TrimPrefix(label, "body:")
|
|
w := bodyPartSeverityWeight(raw)
|
|
if w > set.Body {
|
|
set.Body = w
|
|
}
|
|
if w > 0 {
|
|
set.HasBody = true
|
|
}
|
|
|
|
case strings.HasPrefix(label, "object:"):
|
|
raw := strings.TrimPrefix(label, "object:")
|
|
w := objectSeverityWeight(raw)
|
|
if w > set.Object {
|
|
set.Object = w
|
|
}
|
|
if w > 0 {
|
|
set.HasObject = true
|
|
}
|
|
|
|
case strings.HasPrefix(label, "clothing:"):
|
|
raw := strings.TrimPrefix(label, "clothing:")
|
|
w := clothingSeverityWeight(raw)
|
|
if w > set.Clothing {
|
|
set.Clothing = w
|
|
}
|
|
if w > 0 {
|
|
set.HasClothing = true
|
|
}
|
|
|
|
case strings.HasPrefix(label, "person:"):
|
|
raw := strings.TrimPrefix(label, "person:")
|
|
w := personContextWeight(raw)
|
|
if w > set.Person {
|
|
set.Person = w
|
|
}
|
|
if w > 0 {
|
|
set.HasPerson = true
|
|
}
|
|
}
|
|
}
|
|
|
|
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 ratingSignalSetHasInterestingContent(set ratingSignalSet) bool {
|
|
return set.HasPosition || set.HasBody || set.HasObject || set.HasClothing
|
|
}
|
|
|
|
func contextualSegmentSeverityWeight(label string) float64 {
|
|
set := ratingSignalSetFromLabel(label)
|
|
return contextualSegmentSeverityWeightFromSet(set)
|
|
}
|
|
|
|
func comboSegmentSeverityWeight(label string) float64 {
|
|
set := ratingSignalSetFromLabel(label)
|
|
return contextualSegmentSeverityWeightFromSet(set)
|
|
}
|
|
|
|
func contextualSegmentSeverityWeightFromSet(set ratingSignalSet) float64 {
|
|
if !ratingSignalSetHasInterestingContent(set) {
|
|
return 0
|
|
}
|
|
|
|
var score float64
|
|
|
|
if set.HasPosition {
|
|
// Position soll das Hauptkriterium sein.
|
|
// Sexuelle Körperteile und explizite Kleidung verstärken die Position.
|
|
score =
|
|
0.82*set.Position +
|
|
0.12*set.Body +
|
|
0.08*set.Clothing +
|
|
0.015*set.Object +
|
|
0.005*set.Person
|
|
|
|
if set.HasBody {
|
|
score += 0.030
|
|
}
|
|
if set.HasClothing {
|
|
score += 0.020
|
|
}
|
|
if set.HasObject {
|
|
score += 0.006
|
|
}
|
|
if set.HasPerson {
|
|
score += 0.002
|
|
}
|
|
|
|
// Standing ohne echten Kontext klar schwächer halten.
|
|
if set.Position < 0.60 && !set.HasBody && !set.HasObject && !set.HasClothing {
|
|
// 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)
|
|
}
|
|
|
|
score =
|
|
0.54*set.Body +
|
|
0.28*set.Clothing +
|
|
0.16*set.Object +
|
|
0.02*set.Person
|
|
|
|
if set.HasBody && set.HasClothing {
|
|
score += 0.05
|
|
}
|
|
if set.HasBody && set.HasObject {
|
|
score += 0.04
|
|
}
|
|
if set.HasClothing && set.HasObject {
|
|
score += 0.03
|
|
}
|
|
if set.HasPerson && (set.HasBody || set.HasObject || set.HasClothing) {
|
|
score += 0.015
|
|
}
|
|
|
|
if set.HasClothing && !set.HasBody && !set.HasObject {
|
|
score = math.Min(score, 0.38)
|
|
}
|
|
|
|
score = math.Min(score, 0.72)
|
|
|
|
return ratingClamp01(score)
|
|
}
|
|
|
|
func segmentSeverityWeight(label string) float64 {
|
|
label = strings.ToLower(strings.TrimSpace(label))
|
|
if label == "" {
|
|
return 0
|
|
}
|
|
|
|
if strings.HasPrefix(label, "combo:") {
|
|
return comboSegmentSeverityWeight(label)
|
|
}
|
|
|
|
normalized := normalizeRatingSignalLabel(label)
|
|
if normalized == "" {
|
|
return 0
|
|
}
|
|
|
|
set := ratingSignalSetFromLabel(normalized)
|
|
sev := contextualSegmentSeverityWeightFromSet(set)
|
|
if sev > 0 {
|
|
return sev
|
|
}
|
|
|
|
raw := normalizeSegmentLabel(normalized)
|
|
if raw == "" {
|
|
return 0
|
|
}
|
|
|
|
return detectorSeverityWeight(raw)
|
|
}
|
|
|
|
func ratingSegmentSeverity(label string) float64 {
|
|
return segmentSeverityWeight(label)
|
|
}
|
|
|
|
func ratingSignalPriority(label string) int {
|
|
label = normalizeRatingSignalLabel(label)
|
|
|
|
switch {
|
|
case strings.HasPrefix(label, "position:"):
|
|
return 100
|
|
case strings.HasPrefix(label, "body:"):
|
|
return 80
|
|
case strings.HasPrefix(label, "object:"):
|
|
return 60
|
|
case strings.HasPrefix(label, "clothing:"):
|
|
return 40
|
|
case strings.HasPrefix(label, "person:"):
|
|
return 20
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func buildRatingComboLabel(labels map[string]bool) string {
|
|
if len(labels) == 0 {
|
|
return ""
|
|
}
|
|
|
|
parts := make([]string, 0, len(labels))
|
|
set := ratingSignalSet{}
|
|
|
|
for label := range labels {
|
|
normalized := normalizeRatingSignalLabel(label)
|
|
if normalized == "" {
|
|
continue
|
|
}
|
|
|
|
ratingSignalSetAddLabel(&set, normalized)
|
|
parts = append(parts, normalized)
|
|
}
|
|
|
|
if !ratingSignalSetHasInterestingContent(set) {
|
|
return ""
|
|
}
|
|
|
|
sort.SliceStable(parts, func(i, j int) bool {
|
|
pi := ratingSignalPriority(parts[i])
|
|
pj := ratingSignalPriority(parts[j])
|
|
|
|
if pi != pj {
|
|
return pi > pj
|
|
}
|
|
|
|
wi := ratingSegmentSeverity(parts[i])
|
|
wj := ratingSegmentSeverity(parts[j])
|
|
|
|
if wi != wj {
|
|
return wi > wj
|
|
}
|
|
|
|
return parts[i] < parts[j]
|
|
})
|
|
|
|
deduped := make([]string, 0, len(parts))
|
|
seen := map[string]bool{}
|
|
|
|
for _, part := range parts {
|
|
if part == "" || seen[part] {
|
|
continue
|
|
}
|
|
seen[part] = true
|
|
deduped = append(deduped, part)
|
|
}
|
|
|
|
if len(deduped) == 0 {
|
|
return ""
|
|
}
|
|
|
|
if len(deduped) == 1 {
|
|
// Person alleine wird oben schon verhindert.
|
|
return deduped[0]
|
|
}
|
|
|
|
return "combo:" + strings.Join(deduped, "+")
|
|
}
|
|
|
|
func addRatingLabelsFromSegment(labels map[string]bool, label string) {
|
|
label = strings.ToLower(strings.TrimSpace(label))
|
|
if label == "" {
|
|
return
|
|
}
|
|
|
|
if strings.HasPrefix(label, "combo:") {
|
|
raw := strings.TrimPrefix(label, "combo:")
|
|
for _, part := range strings.Split(raw, "+") {
|
|
addRatingLabelsFromSegment(labels, part)
|
|
}
|
|
return
|
|
}
|
|
|
|
normalized := normalizeRatingSignalLabel(label)
|
|
if normalized == "" {
|
|
return
|
|
}
|
|
|
|
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
|
|
|
|
for label := range labels {
|
|
ratingSignalSetAddLabel(&set, label)
|
|
}
|
|
|
|
return set
|
|
}
|
|
|
|
func ratingBridgeStrengthFromSet(set ratingSignalSet) float64 {
|
|
strength := 0.0
|
|
|
|
if set.HasPosition && set.Position > strength {
|
|
strength = set.Position
|
|
}
|
|
if set.HasBody && set.Body > strength {
|
|
strength = set.Body
|
|
}
|
|
if set.HasObject && set.Object > strength {
|
|
strength = set.Object
|
|
}
|
|
if set.HasClothing && set.Clothing > strength {
|
|
strength = set.Clothing
|
|
}
|
|
|
|
return strength
|
|
}
|
|
|
|
func ratingPositionSetFromLabels(labels map[string]bool) map[string]bool {
|
|
out := map[string]bool{}
|
|
|
|
for label := range labels {
|
|
normalized := normalizeRatingSignalLabel(label)
|
|
if strings.HasPrefix(normalized, "position:") {
|
|
out[normalized] = true
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func ratingPositionSetsConflict(a map[string]bool, b map[string]bool) bool {
|
|
ap := ratingPositionSetFromLabels(a)
|
|
bp := ratingPositionSetFromLabels(b)
|
|
|
|
if len(ap) == 0 || len(bp) == 0 {
|
|
return false
|
|
}
|
|
|
|
for label := range ap {
|
|
if bp[label] {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func ratingLabelsBridgeCompatible(a map[string]bool, b map[string]bool) bool {
|
|
if ratingPositionSetsConflict(a, b) {
|
|
return false
|
|
}
|
|
|
|
for la := range a {
|
|
na := normalizeRatingSignalLabel(la)
|
|
if na == "" {
|
|
continue
|
|
}
|
|
|
|
for lb := range b {
|
|
nb := normalizeRatingSignalLabel(lb)
|
|
if nb == "" {
|
|
continue
|
|
}
|
|
|
|
if na == nb {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
as := ratingSignalSetFromLabels(a)
|
|
bs := ratingSignalSetFromLabels(b)
|
|
|
|
// Wichtig:
|
|
// Unterschiedliche Positionen NICHT pauschal verbinden.
|
|
// Vorher hat "Doggy" + "Reverse Cowgirl" als kompatibel gegolten,
|
|
// nur weil beide irgendeine Position waren.
|
|
|
|
if as.HasBody && bs.HasBody {
|
|
return true
|
|
}
|
|
if as.HasObject && bs.HasObject {
|
|
return true
|
|
}
|
|
if as.HasClothing && bs.HasClothing {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func mergeRatingActivitySegments(
|
|
segments []aiSegmentMeta,
|
|
maxSilentGapSec float64,
|
|
minDurationSec float64,
|
|
) []aiSegmentMeta {
|
|
if len(segments) == 0 {
|
|
return nil
|
|
}
|
|
|
|
valid := make([]aiSegmentMeta, 0, len(segments))
|
|
|
|
for _, s := range segments {
|
|
if strings.TrimSpace(s.Label) == "" {
|
|
continue
|
|
}
|
|
|
|
start := s.StartSeconds
|
|
end := s.EndSeconds
|
|
|
|
if end <= start && s.DurationSeconds > 0 {
|
|
end = start + s.DurationSeconds
|
|
}
|
|
if end <= start {
|
|
continue
|
|
}
|
|
|
|
labels := map[string]bool{}
|
|
addRatingLabelsFromSegment(labels, s.Label)
|
|
|
|
if buildRatingComboLabel(labels) == "" {
|
|
continue
|
|
}
|
|
|
|
s.StartSeconds = start
|
|
s.EndSeconds = end
|
|
s.DurationSeconds = end - start
|
|
|
|
if ratingSegmentSeverity(s.Label) <= 0 {
|
|
continue
|
|
}
|
|
|
|
valid = append(valid, s)
|
|
}
|
|
|
|
if len(valid) == 0 {
|
|
return nil
|
|
}
|
|
|
|
sort.SliceStable(valid, func(i, j int) bool {
|
|
if valid[i].StartSeconds != valid[j].StartSeconds {
|
|
return valid[i].StartSeconds < valid[j].StartSeconds
|
|
}
|
|
if valid[i].EndSeconds != valid[j].EndSeconds {
|
|
return valid[i].EndSeconds < valid[j].EndSeconds
|
|
}
|
|
return valid[i].Label < valid[j].Label
|
|
})
|
|
|
|
type segmentBounds struct {
|
|
start float64
|
|
end float64
|
|
ok bool
|
|
}
|
|
|
|
type activityBlock struct {
|
|
start float64
|
|
end float64
|
|
scoreSum float64
|
|
scoreWeight float64
|
|
labels map[string]bool
|
|
positionWeights map[string]float64
|
|
positionBounds map[string]segmentBounds
|
|
positionRanges map[string][]segmentBounds
|
|
marker float64
|
|
markerWeight float64
|
|
}
|
|
|
|
var addRatingPositionSignal func(
|
|
weights map[string]float64,
|
|
bounds map[string]segmentBounds,
|
|
ranges map[string][]segmentBounds,
|
|
label string,
|
|
weight float64,
|
|
start float64,
|
|
end float64,
|
|
)
|
|
|
|
addRatingPositionSignal = func(
|
|
weights map[string]float64,
|
|
bounds map[string]segmentBounds,
|
|
ranges map[string][]segmentBounds,
|
|
label string,
|
|
weight float64,
|
|
start float64,
|
|
end float64,
|
|
) {
|
|
label = strings.ToLower(strings.TrimSpace(label))
|
|
if label == "" {
|
|
return
|
|
}
|
|
|
|
if strings.HasPrefix(label, "combo:") {
|
|
raw := strings.TrimPrefix(label, "combo:")
|
|
for _, part := range strings.Split(raw, "+") {
|
|
addRatingPositionSignal(weights, bounds, ranges, part, weight, start, end)
|
|
}
|
|
return
|
|
}
|
|
|
|
normalized := normalizeRatingSignalLabel(label)
|
|
if !strings.HasPrefix(normalized, "position:") {
|
|
return
|
|
}
|
|
|
|
if end < start {
|
|
end = start
|
|
}
|
|
|
|
weights[normalized] += weight
|
|
|
|
b := bounds[normalized]
|
|
if !b.ok {
|
|
b = segmentBounds{
|
|
start: start,
|
|
end: end,
|
|
ok: true,
|
|
}
|
|
} else {
|
|
if start < b.start {
|
|
b.start = start
|
|
}
|
|
if end > b.end {
|
|
b.end = end
|
|
}
|
|
}
|
|
|
|
bounds[normalized] = b
|
|
if end > start {
|
|
ranges[normalized] = append(ranges[normalized], segmentBounds{
|
|
start: start,
|
|
end: end,
|
|
ok: true,
|
|
})
|
|
}
|
|
}
|
|
|
|
effectiveRatingLabelsForBlock := func(
|
|
labels map[string]bool,
|
|
positionWeights map[string]float64,
|
|
) (map[string]bool, string) {
|
|
out := map[string]bool{}
|
|
|
|
bestPosition := ""
|
|
bestWeight := -1.0
|
|
|
|
for label := range labels {
|
|
normalized := normalizeRatingSignalLabel(label)
|
|
if normalized == "" {
|
|
continue
|
|
}
|
|
|
|
if strings.HasPrefix(normalized, "position:") {
|
|
w := positionWeights[normalized]
|
|
if w <= 0 {
|
|
w = ratingSegmentSeverity(normalized)
|
|
}
|
|
|
|
if w > bestWeight {
|
|
bestWeight = w
|
|
bestPosition = normalized
|
|
}
|
|
|
|
continue
|
|
}
|
|
|
|
out[normalized] = true
|
|
}
|
|
|
|
if bestPosition != "" {
|
|
out[bestPosition] = true
|
|
}
|
|
|
|
return out, bestPosition
|
|
}
|
|
|
|
segmentMarker := func(s aiSegmentMeta) float64 {
|
|
if s.PreviewSeconds > 0 {
|
|
return s.PreviewSeconds
|
|
}
|
|
if s.MarkerSeconds > 0 {
|
|
return s.MarkerSeconds
|
|
}
|
|
if s.EndSeconds > s.StartSeconds {
|
|
return (s.StartSeconds + s.EndSeconds) / 2
|
|
}
|
|
return s.StartSeconds
|
|
}
|
|
|
|
segmentMarkerWeight := func(s aiSegmentMeta) float64 {
|
|
conf := ratingClamp01(s.Score)
|
|
if conf <= 0 {
|
|
conf = 0.50
|
|
}
|
|
|
|
sev := ratingSegmentSeverity(s.Label)
|
|
if sev <= 0 {
|
|
sev = 0.50
|
|
}
|
|
|
|
return conf * sev
|
|
}
|
|
|
|
newBlock := func(s aiSegmentMeta) activityBlock {
|
|
labels := map[string]bool{}
|
|
addRatingLabelsFromSegment(labels, s.Label)
|
|
|
|
dur := s.EndSeconds - s.StartSeconds
|
|
conf := ratingClamp01(s.Score)
|
|
if conf <= 0 {
|
|
conf = 0.50
|
|
}
|
|
|
|
positionWeights := map[string]float64{}
|
|
positionBounds := map[string]segmentBounds{}
|
|
positionRanges := map[string][]segmentBounds{}
|
|
|
|
addRatingPositionSignal(
|
|
positionWeights,
|
|
positionBounds,
|
|
positionRanges,
|
|
s.Label,
|
|
conf*math.Max(1, dur),
|
|
s.StartSeconds,
|
|
s.EndSeconds,
|
|
)
|
|
|
|
marker := segmentMarker(s)
|
|
markerWeight := segmentMarkerWeight(s)
|
|
|
|
return activityBlock{
|
|
start: s.StartSeconds,
|
|
end: s.EndSeconds,
|
|
scoreSum: conf * dur,
|
|
scoreWeight: dur,
|
|
labels: labels,
|
|
positionWeights: positionWeights,
|
|
positionBounds: positionBounds,
|
|
positionRanges: positionRanges,
|
|
marker: marker,
|
|
markerWeight: markerWeight,
|
|
}
|
|
}
|
|
|
|
coveredDuration := func(ranges []segmentBounds, start, end float64) float64 {
|
|
if len(ranges) == 0 || end <= start {
|
|
return 0
|
|
}
|
|
|
|
clipped := make([]segmentBounds, 0, len(ranges))
|
|
for _, r := range ranges {
|
|
rangeStart := math.Max(start, r.start)
|
|
rangeEnd := math.Min(end, r.end)
|
|
if rangeEnd > rangeStart {
|
|
clipped = append(clipped, segmentBounds{
|
|
start: rangeStart,
|
|
end: rangeEnd,
|
|
ok: true,
|
|
})
|
|
}
|
|
}
|
|
if len(clipped) == 0 {
|
|
return 0
|
|
}
|
|
|
|
sort.SliceStable(clipped, func(i, j int) bool {
|
|
if clipped[i].start != clipped[j].start {
|
|
return clipped[i].start < clipped[j].start
|
|
}
|
|
return clipped[i].end < clipped[j].end
|
|
})
|
|
|
|
total := 0.0
|
|
currentStart := clipped[0].start
|
|
currentEnd := clipped[0].end
|
|
|
|
for _, r := range clipped[1:] {
|
|
if r.start <= currentEnd {
|
|
if r.end > currentEnd {
|
|
currentEnd = r.end
|
|
}
|
|
continue
|
|
}
|
|
|
|
total += currentEnd - currentStart
|
|
currentStart = r.start
|
|
currentEnd = r.end
|
|
}
|
|
|
|
return total + currentEnd - currentStart
|
|
}
|
|
|
|
finishBlock := func(b activityBlock) (aiSegmentMeta, bool) {
|
|
dur := b.end - b.start
|
|
if dur <= 0 {
|
|
return aiSegmentMeta{}, false
|
|
}
|
|
|
|
effectiveLabels, bestPosition := effectiveRatingLabelsForBlock(b.labels, b.positionWeights)
|
|
label := buildRatingComboLabel(effectiveLabels)
|
|
if label == "" {
|
|
return aiSegmentMeta{}, false
|
|
}
|
|
|
|
sev := ratingSegmentSeverity(label)
|
|
|
|
start := b.start
|
|
end := b.end
|
|
|
|
// Wenn der finale Block eine Position trägt, soll der Block
|
|
// wirklich auf die echte Positions-Zeit gekürzt werden —
|
|
// nicht nur am Anfang, sondern auch am Ende.
|
|
if bestPosition != "" {
|
|
if bounds, ok := b.positionBounds[bestPosition]; ok && bounds.ok {
|
|
if bounds.start > start {
|
|
start = bounds.start
|
|
}
|
|
if bounds.end < end {
|
|
end = bounds.end
|
|
}
|
|
}
|
|
}
|
|
|
|
if end < start {
|
|
end = start
|
|
}
|
|
|
|
dur = end - start
|
|
if dur <= 0 {
|
|
return aiSegmentMeta{}, false
|
|
}
|
|
|
|
ratingDuration := dur
|
|
if bestPosition != "" {
|
|
if evidenceDuration := coveredDuration(
|
|
b.positionRanges[bestPosition],
|
|
start,
|
|
end,
|
|
); evidenceDuration > 0 {
|
|
ratingDuration = evidenceDuration
|
|
}
|
|
}
|
|
|
|
// Kurze Segmente dürfen bleiben, wenn sie stark genug sind.
|
|
if ratingDuration < minDurationSec && sev < 0.72 {
|
|
return aiSegmentMeta{}, false
|
|
}
|
|
|
|
score := 0.0
|
|
if b.scoreWeight > 0 {
|
|
score = b.scoreSum / b.scoreWeight
|
|
}
|
|
if score <= 0 {
|
|
score = 0.50
|
|
}
|
|
|
|
marker := b.marker
|
|
if marker < start || marker > end {
|
|
marker = (start + end) / 2
|
|
}
|
|
|
|
return aiSegmentMeta{
|
|
Label: label,
|
|
Score: ratingClamp01(score),
|
|
RatingIntensity: ratingClamp01(sev * ratingConfidenceWeight(score)),
|
|
StartSeconds: start,
|
|
EndSeconds: end,
|
|
DurationSeconds: ratingDuration,
|
|
AutoSelected: true,
|
|
Position: segmentPositionFromAnalyzeLabel(label),
|
|
Tags: segmentTagsFromAnalyzeLabel(label),
|
|
MarkerSeconds: marker,
|
|
PreviewSeconds: marker,
|
|
}, true
|
|
}
|
|
|
|
out := make([]aiSegmentMeta, 0, len(valid))
|
|
cur := newBlock(valid[0])
|
|
|
|
for i := 1; i < len(valid); i++ {
|
|
n := valid[i]
|
|
|
|
gap := n.StartSeconds - cur.end
|
|
nextLabels := map[string]bool{}
|
|
addRatingLabelsFromSegment(nextLabels, n.Label)
|
|
positionConflict := ratingPositionSetsConflict(cur.labels, nextLabels)
|
|
|
|
// A short gap may connect supporting signals or the same position, but
|
|
// must not turn consecutive different positions into one long segment.
|
|
shouldBridge := gap <= maxSilentGapSec && !positionConflict
|
|
|
|
if !shouldBridge && !positionConflict && gap <= ratingBridgeStrongGapSec {
|
|
curSet := ratingSignalSetFromLabels(cur.labels)
|
|
nextSet := ratingSignalSetFromLabels(nextLabels)
|
|
|
|
curBridgeStrength := ratingBridgeStrengthFromSet(curSet)
|
|
nextBridgeStrength := ratingBridgeStrengthFromSet(nextSet)
|
|
|
|
curDur := cur.end - cur.start
|
|
nextDur := n.EndSeconds - n.StartSeconds
|
|
|
|
shouldBridge =
|
|
ratingLabelsBridgeCompatible(cur.labels, nextLabels) &&
|
|
curBridgeStrength >= 0.65 &&
|
|
nextBridgeStrength >= 0.65 &&
|
|
(curDur >= 8 || nextDur >= 8)
|
|
}
|
|
|
|
if !shouldBridge {
|
|
if finished, ok := finishBlock(cur); ok {
|
|
out = append(out, finished)
|
|
}
|
|
|
|
cur = newBlock(n)
|
|
continue
|
|
}
|
|
|
|
if n.StartSeconds < cur.start {
|
|
cur.start = n.StartSeconds
|
|
}
|
|
if n.EndSeconds > cur.end {
|
|
cur.end = n.EndSeconds
|
|
}
|
|
|
|
dur := n.EndSeconds - n.StartSeconds
|
|
if dur > 0 {
|
|
conf := ratingClamp01(n.Score)
|
|
if conf <= 0 {
|
|
conf = 0.50
|
|
}
|
|
|
|
cur.scoreSum += conf * dur
|
|
cur.scoreWeight += dur
|
|
|
|
addRatingPositionSignal(
|
|
cur.positionWeights,
|
|
cur.positionBounds,
|
|
cur.positionRanges,
|
|
n.Label,
|
|
conf*math.Max(1, dur),
|
|
n.StartSeconds,
|
|
n.EndSeconds,
|
|
)
|
|
}
|
|
|
|
nextMarkerWeight := segmentMarkerWeight(n)
|
|
if nextMarkerWeight > cur.markerWeight {
|
|
cur.markerWeight = nextMarkerWeight
|
|
cur.marker = segmentMarker(n)
|
|
}
|
|
|
|
addRatingLabelsFromSegment(cur.labels, n.Label)
|
|
}
|
|
|
|
if finished, ok := finishBlock(cur); ok {
|
|
out = append(out, finished)
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func prepareAIRatingSegments(segments []aiSegmentMeta) []aiSegmentMeta {
|
|
return mergeRatingActivitySegments(
|
|
segments,
|
|
ratingMaxSilentGapSec,
|
|
ratingMinSegmentDurationSec,
|
|
)
|
|
}
|
|
|
|
func ratingConfidenceWeight(conf float64) float64 {
|
|
conf = ratingClamp01(conf)
|
|
|
|
// Confidence soll relevant sein, aber schlechte Scores nicht komplett töten.
|
|
n := ratingSmoothStep((conf - 0.25) / 0.70)
|
|
|
|
return 0.58 + 0.42*n
|
|
}
|
|
|
|
func ratingLinearGate(value float64, start float64, full float64) float64 {
|
|
if full <= start {
|
|
if value >= full {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
return ratingClamp01((value - start) / (full - start))
|
|
}
|
|
|
|
type ratingTimeRange struct {
|
|
Start float64
|
|
End float64
|
|
}
|
|
|
|
func ratingCoveredSeconds(ranges []ratingTimeRange, durationSec float64) float64 {
|
|
if len(ranges) == 0 || durationSec <= 0 {
|
|
return 0
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
})
|
|
|
|
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 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 ratingDurationComponent(totalSeconds, longestSeconds float64) float64 {
|
|
total := ratingSaturatingEvidence(totalSeconds, 55.0)
|
|
longest := ratingSaturatingEvidence(longestSeconds, 30.0)
|
|
return ratingClamp01(0.62*total + 0.38*longest)
|
|
}
|
|
|
|
func ratingCoverageComponent(coveredSeconds, durationSec float64) float64 {
|
|
if coveredSeconds <= 0 || durationSec <= 0 {
|
|
return 0
|
|
}
|
|
return ratingSaturatingEvidence(ratingClamp01(coveredSeconds/durationSec), 0.12)
|
|
}
|
|
|
|
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,
|
|
positionScore float64,
|
|
) float64 {
|
|
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)
|
|
}
|
|
|
|
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 ratingClamp01(raw)
|
|
}
|
|
|
|
func starsFromHighlightScore(score float64) int {
|
|
switch {
|
|
case score < 22:
|
|
return 1
|
|
case score < 45:
|
|
return 2
|
|
case score < 68:
|
|
return 3
|
|
case score < 85:
|
|
return 4
|
|
default:
|
|
return 5
|
|
}
|
|
}
|
|
|
|
func ratingUsernameFromVideoPath(videoPath string) string {
|
|
id := strings.TrimSpace(assetIDFromVideoPath(videoPath))
|
|
if id == "" {
|
|
return ""
|
|
}
|
|
|
|
user := strings.ToLower(strings.TrimSpace(modelNameFromFilename(id)))
|
|
if user != "" && user != "unknown" {
|
|
return user
|
|
}
|
|
|
|
// Fallback nur, wenn die ID nicht wie ein kompletter Dateiname mit Timestamp aussieht.
|
|
if !strings.Contains(id, "__") {
|
|
return strings.ToLower(strings.TrimSpace(id))
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func ratingLogSubject(username string) string {
|
|
username = strings.ToLower(strings.TrimSpace(username))
|
|
if username == "" || username == "unknown" {
|
|
return "[rating]"
|
|
}
|
|
|
|
return "[" + username + "]"
|
|
}
|
|
|
|
func computeHighlightRating(segments []aiSegmentMeta, durationSec float64) *aiRatingMeta {
|
|
return computeHighlightRatingWithUsername(segments, durationSec, "")
|
|
}
|
|
|
|
func computeHighlightRatingForVideo(segments []aiSegmentMeta, durationSec float64, videoPath string) *aiRatingMeta {
|
|
return computeHighlightRatingWithUsername(segments, durationSec, ratingUsernameFromVideoPath(videoPath))
|
|
}
|
|
|
|
func computeHighlightRatingWithUsername(segments []aiSegmentMeta, durationSec float64, username string) *aiRatingMeta {
|
|
r := &aiRatingMeta{
|
|
Score: 0,
|
|
Stars: 1,
|
|
}
|
|
|
|
if durationSec <= 0 || len(segments) == 0 {
|
|
return r
|
|
}
|
|
|
|
videoMinutes := math.Max(durationSec/60.0, 0.25)
|
|
|
|
var positionRanges []ratingTimeRange
|
|
var positionWeighted float64
|
|
var positionExplicitSum float64
|
|
var positionEvidenceWeight float64
|
|
var peakPositionExplicitness float64
|
|
var peakPositionConfidence float64
|
|
|
|
var contextRanges []ratingTimeRange
|
|
var contextWeighted 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
|
|
if segDur <= 0 {
|
|
segDur = s.EndSeconds - s.StartSeconds
|
|
}
|
|
if segDur <= 0 {
|
|
continue
|
|
}
|
|
|
|
sev := ratingSegmentSeverity(s.Label)
|
|
if sev <= 0 {
|
|
appLogf("🧪 [rating] skip zero severity label=%q normalized=%q", s.Label, normalizeRatingSignalLabel(s.Label))
|
|
continue
|
|
}
|
|
|
|
conf := ratingClamp01(s.Score)
|
|
if conf <= 0 {
|
|
conf = 0.50
|
|
}
|
|
|
|
confWeight := ratingConfidenceWeight(conf)
|
|
quality := sev * confWeight
|
|
|
|
if quality <= 0 {
|
|
continue
|
|
}
|
|
|
|
effectiveDur := ratingEffectiveDurationSeconds(segDur)
|
|
weightedDur := segDur * 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 {
|
|
contextRanges = append(contextRanges, segmentRange)
|
|
contextWeighted += weightedDur
|
|
contextExplicitSum += sev * effectiveDur
|
|
contextEvidenceWeight += effectiveDur
|
|
contextConfSum += conf * effectiveDur
|
|
contextConfWeightSum += effectiveDur
|
|
contextN++
|
|
|
|
if sev > peakContextExplicitness {
|
|
peakContextExplicitness = sev
|
|
}
|
|
if conf > peakContextConfidence {
|
|
peakContextConfidence = conf
|
|
}
|
|
if segDur > contextLongest {
|
|
contextLongest = segDur
|
|
}
|
|
|
|
continue
|
|
}
|
|
|
|
positionRanges = append(positionRanges, segmentRange)
|
|
positionWeighted += weightedDur
|
|
positionExplicitSum += set.Position * effectiveDur
|
|
positionEvidenceWeight += effectiveDur
|
|
|
|
if set.Position > peakPositionExplicitness {
|
|
peakPositionExplicitness = set.Position
|
|
}
|
|
if conf > peakPositionConfidence {
|
|
peakPositionConfidence = conf
|
|
}
|
|
|
|
confSum += conf * effectiveDur
|
|
confWeightSum += effectiveDur
|
|
n++
|
|
|
|
if segDur > longest {
|
|
longest = segDur
|
|
}
|
|
|
|
labels := map[string]bool{}
|
|
addRatingLabelsFromSegment(labels, s.Label)
|
|
for position := range ratingPositionSetFromLabels(labels) {
|
|
uniquePositions[position] = true
|
|
}
|
|
}
|
|
|
|
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
|
|
avgContextExplicitness := contextExplicitSum / contextEvidenceWeight
|
|
contextQuality := ratingClamp01(
|
|
(0.75*avgContextExplicitness + 0.25*peakContextExplicitness) / 0.72,
|
|
)
|
|
durationScore := ratingDurationComponent(contextFlagged, contextLongest)
|
|
strengthScore := ratingStrengthComponent(contextAvgConfidence, peakContextConfidence)
|
|
coverageScore := ratingCoverageComponent(contextFlagged, durationSec)
|
|
|
|
raw :=
|
|
0.30*contextQuality +
|
|
0.25*durationScore +
|
|
0.15*strengthScore +
|
|
0.12*coverageScore +
|
|
0.12*clothingScore +
|
|
0.06*contextScore
|
|
|
|
actionRaw := ratingActionComponent(durationScore, coverageScore, strengthScore)
|
|
|
|
if contextFlagged < 20.0 {
|
|
raw = math.Min(raw, 0.44)
|
|
}
|
|
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)
|
|
r.FlaggedSeconds = ratingRound(contextFlagged, 2)
|
|
r.WeightedFlaggedSeconds = ratingRound(contextWeighted, 2)
|
|
r.CoverageRatio = ratingRound(contextCoverageRatio, 4)
|
|
r.WeightedCoverageRatio = ratingRound(contextWeightedCoverageRatio, 4)
|
|
r.LongestSegmentSeconds = ratingRound(contextLongest, 2)
|
|
r.AvgConfidence = ratingRound(contextAvgConfidence, 3)
|
|
|
|
appLogf(
|
|
"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,
|
|
contextQuality,
|
|
r.WeightedCoverageRatio,
|
|
r.LongestSegmentSeconds,
|
|
r.AvgConfidence,
|
|
)
|
|
|
|
return r
|
|
}
|
|
|
|
positionFlagged := ratingCoveredSeconds(positionRanges, durationSec)
|
|
coverageRatio := ratingClamp01(positionFlagged / durationSec)
|
|
weightedCoverageRatio := ratingClamp01(positionWeighted / durationSec)
|
|
segmentsPerMinute := float64(n) / videoMinutes
|
|
avgConfidence := confSum / confWeightSum
|
|
avgPositionExplicitness := positionExplicitSum / positionEvidenceWeight
|
|
positionScore := ratingClamp01(
|
|
0.75*avgPositionExplicitness + 0.25*peakPositionExplicitness,
|
|
)
|
|
durationScore := ratingDurationComponent(positionFlagged, longest)
|
|
strengthScore := ratingStrengthComponent(avgConfidence, peakPositionConfidence)
|
|
coverageScore := ratingCoverageComponent(positionFlagged, durationSec)
|
|
varietyScore := ratingLinearGate(float64(len(uniquePositions)), 1, 4)
|
|
|
|
raw :=
|
|
0.36*positionScore +
|
|
0.27*durationScore +
|
|
0.14*strengthScore +
|
|
0.10*coverageScore +
|
|
0.06*contextScore +
|
|
0.04*clothingScore +
|
|
0.03*varietyScore
|
|
|
|
actionRaw := ratingActionComponent(durationScore, coverageScore, strengthScore)
|
|
|
|
// Short or weak detections cannot reach high ratings on rank alone.
|
|
raw = ratingApplyPositionEvidenceCaps(
|
|
raw,
|
|
positionFlagged,
|
|
longest,
|
|
avgConfidence,
|
|
positionScore,
|
|
)
|
|
|
|
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(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(
|
|
"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,
|
|
r.PositionScore,
|
|
r.ClothingScore,
|
|
r.Segments,
|
|
r.FlaggedSeconds,
|
|
r.DurationScore,
|
|
r.StrengthScore,
|
|
r.WeightedCoverageRatio,
|
|
r.LongestSegmentSeconds,
|
|
r.AvgConfidence,
|
|
)
|
|
|
|
return r
|
|
}
|