This commit is contained in:
Linrador 2026-06-14 22:27:43 +02:00
parent 9f3ae43f8a
commit 56b63223f9
5 changed files with 543 additions and 289 deletions

View File

@ -11,6 +11,17 @@ import (
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"`
@ -136,31 +147,46 @@ func isKnownPositionLabel(label string) bool {
}
}
func positionSeverityWeight(label string) float64 {
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 1.00
case "cowgirl", "reverse_cowgirl":
return 0.98
case "missionary", "prone_bone":
return 0.95
case "blowjob", "cunnilingus", "69", "facesitting", "boobjob":
return 0.94
return explicitRatingRank{Rank: 3, Weight: 0.98}
case "toy_play":
return 0.88
case "handjob", "fingering":
return 0.84
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 0.78
return explicitRatingRank{Rank: 8, Weight: 0.86}
case "standing":
return 0.42
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 0.00
return explicitRatingRank{}
}
}
func positionSeverityWeight(label string) float64 {
return positionExplicitRatingRank(label).Weight
}
func bodyPartSeverityWeight(label string) float64 {
label = strings.ToLower(strings.TrimSpace(label))
@ -199,25 +225,39 @@ func objectSeverityWeight(label string) float64 {
}
}
func clothingSeverityWeight(label string) float64 {
func clothingExplicitRatingRank(label string) explicitRatingRank {
label = strings.ToLower(strings.TrimSpace(label))
switch label {
case "lingerie":
return 0.50
case "panties", "bra":
return 0.44
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 0.30
case "stockings", "heels":
return 0.28
case "skirt", "dress", "hotpants", "croptop":
return 0.22
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 0.00
return explicitRatingRank{}
}
}
func clothingSeverityWeight(label string) float64 {
return clothingExplicitRatingRank(label).Weight
}
func personContextWeight(label string) float64 {
if !isPersonSegmentLabel(label) {
return 0
@ -437,26 +477,6 @@ func contextualSegmentSeverityWeightFromSet(set ratingSignalSet) float64 {
return 0
}
var labelParts []string
if set.HasPosition {
labelParts = append(labelParts, "position:x")
}
if set.HasBody {
labelParts = append(labelParts, "body:x")
}
if set.HasObject {
labelParts = append(labelParts, "object:x")
}
if set.HasClothing {
labelParts = append(labelParts, "clothing:x")
}
if set.HasPerson {
labelParts = append(labelParts, "person:x")
}
_ = labelParts
var score float64
if set.HasPosition {
@ -484,7 +504,9 @@ func contextualSegmentSeverityWeightFromSet(set ratingSignalSet) float64 {
// Standing ohne echten Kontext klar schwächer halten.
if set.Position < 0.60 && !set.HasBody && !set.HasObject && !set.HasClothing {
score = math.Min(score, 0.32)
// 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)
@ -655,6 +677,37 @@ func addRatingLabelsFromSegment(labels map[string]bool, label string) {
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
@ -1256,119 +1309,103 @@ func ratingLinearGate(value float64, start float64, full float64) float64 {
return ratingClamp01((value - start) / (full - start))
}
func ratingExplicitContextBonus(
bodyEffectiveWeighted float64,
clothingEffectiveWeighted float64,
objectEffectiveWeighted float64,
videoMinutes float64,
) float64 {
if videoMinutes <= 0 {
type ratingTimeRange struct {
Start float64
End float64
}
func ratingCoveredSeconds(ranges []ratingTimeRange, durationSec float64) float64 {
if len(ranges) == 0 || durationSec <= 0 {
return 0
}
bodyDensity := bodyEffectiveWeighted / videoMinutes
clothingDensity := clothingEffectiveWeighted / videoMinutes
objectDensity := objectEffectiveWeighted / videoMinutes
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
}
bonus :=
0.060*ratingSaturatingEvidence(bodyDensity, 1.8) +
0.035*ratingSaturatingEvidence(clothingDensity, 1.5) +
0.015*ratingSaturatingEvidence(objectDensity, 2.2)
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
})
return math.Min(bonus, 0.10)
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 ratingBlendWithAction(base, action, actionWeight float64) float64 {
actionWeight = ratingClamp01(actionWeight)
return ratingClamp01((1-actionWeight)*base + actionWeight*action)
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 ratingPositionActionScore(
durationNorm float64,
longestNorm float64,
densityNorm float64,
coverageNorm float64,
peakNorm float64,
) float64 {
return ratingClamp01(
0.38*durationNorm +
0.24*longestNorm +
0.18*densityNorm +
0.12*coverageNorm +
0.08*peakNorm,
)
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 ratingContextActionScore(
durationNorm float64,
longestNorm float64,
coverageNorm float64,
densityNorm float64,
peakNorm float64,
) float64 {
return ratingClamp01(
0.40*durationNorm +
0.25*longestNorm +
0.20*coverageNorm +
0.10*densityNorm +
0.05*peakNorm,
)
func ratingCoverageComponent(coveredSeconds, durationSec float64) float64 {
if coveredSeconds <= 0 || durationSec <= 0 {
return 0
}
return ratingSaturatingEvidence(ratingClamp01(coveredSeconds/durationSec), 0.12)
}
func ratingExcellenceBonus(
peakQuality float64,
positionEffectiveWeighted float64,
positionDensity float64,
weightedCoverageRatio float64,
totalFlagged float64,
longest float64,
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,
segmentsPerMinute float64,
n int,
positionScore float64,
) float64 {
// Kein pauschaler Score-Shift:
// Der Bonus darf nur greifen, wenn wirklich mehrere starke Signale vorhanden sind.
if n < 2 {
return 0
}
if positionEffectiveWeighted <= 0 {
return 0
}
if totalFlagged < 12.0 {
return 0
}
if peakQuality < 0.68 {
return 0
}
if avgConfidence < 0.42 {
return 0
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)
}
peakGate := ratingLinearGate(peakQuality, 0.68, 0.90)
positionGate := ratingLinearGate(positionDensity, 2.20, 7.00)
coverageGate := ratingLinearGate(weightedCoverageRatio, 0.045, 0.160)
durationGate := ratingLinearGate(totalFlagged, 12.0, 90.0)
longestGate := ratingLinearGate(longest, 8.0, 45.0)
confGate := ratingLinearGate(avgConfidence, 0.42, 0.78)
frequencyGate := ratingLinearGate(segmentsPerMinute, 0.25, 1.00)
bonus :=
0.030*peakGate +
0.022*positionGate +
0.016*coverageGate +
0.012*durationGate +
0.012*longestGate +
0.010*confGate +
0.006*frequencyGate
// Maximal +8.5 Scorepunkte.
// Dadurch werden starke 72-79 Scores 5-Sterne-fähig,
// aber mittelmäßige Scores springen nicht einfach pauschal hoch.
if bonus > 0.085 {
bonus = 0.085
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 bonus
return ratingClamp01(raw)
}
func starsFromHighlightScore(score float64) int {
@ -1434,34 +1471,39 @@ func computeHighlightRatingWithUsername(segments []aiSegmentMeta, durationSec fl
videoMinutes := math.Max(durationSec/60.0, 0.25)
var totalFlagged float64
var totalWeighted float64
var positionRanges []ratingTimeRange
var positionWeighted float64
var positionExplicitSum float64
var positionEvidenceWeight float64
var peakPositionExplicitness float64
var peakPositionConfidence float64
var positionEffectiveWeighted float64
var positionFlagged float64
var peakQuality float64
var qualityDurationSum float64
var qualityDurationWeight float64
var contextFlagged float64
var contextRanges []ratingTimeRange
var contextWeighted float64
var contextEffectiveWeighted float64
var contextPeakQuality float64
var contextQualityDurationSum float64
var contextQualityDurationWeight 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
@ -1492,25 +1534,41 @@ func computeHighlightRatingWithUsername(segments []aiSegmentMeta, durationSec fl
effectiveDur := ratingEffectiveDurationSeconds(segDur)
weightedDur := segDur * quality
effectiveWeightedDur := effectiveDur * 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 {
contextFlagged += segDur
contextRanges = append(contextRanges, segmentRange)
contextWeighted += weightedDur
contextEffectiveWeighted += effectiveWeightedDur
contextQualityDurationSum += quality * effectiveDur
contextQualityDurationWeight += effectiveDur
contextExplicitSum += sev * effectiveDur
contextEvidenceWeight += effectiveDur
contextConfSum += conf * effectiveDur
contextConfWeightSum += effectiveDur
contextN++
if quality > contextPeakQuality {
contextPeakQuality = quality
if sev > peakContextExplicitness {
peakContextExplicitness = sev
}
if conf > peakContextConfidence {
peakContextConfidence = conf
}
if segDur > contextLongest {
contextLongest = segDur
@ -1519,16 +1577,16 @@ func computeHighlightRatingWithUsername(segments []aiSegmentMeta, durationSec fl
continue
}
totalFlagged += segDur
totalWeighted += weightedDur
positionRanges = append(positionRanges, segmentRange)
positionWeighted += weightedDur
positionExplicitSum += set.Position * effectiveDur
positionEvidenceWeight += effectiveDur
positionEffectiveWeighted += effectiveWeightedDur
positionFlagged += segDur
qualityDurationSum += quality * effectiveDur
qualityDurationWeight += effectiveDur
if quality > peakQuality {
peakQuality = quality
if set.Position > peakPositionExplicitness {
peakPositionExplicitness = set.Position
}
if conf > peakPositionConfidence {
peakPositionConfidence = conf
}
confSum += conf * effectiveDur
@ -1546,58 +1604,71 @@ func computeHighlightRatingWithUsername(segments []aiSegmentMeta, durationSec fl
}
}
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
contextAvgQuality := contextQualityDurationSum / contextQualityDurationWeight
contextDensity := contextEffectiveWeighted / videoMinutes
qualityNorm := ratingSmoothStep(
ratingLinearGate(contextAvgQuality, 0.08, 0.55),
avgContextExplicitness := contextExplicitSum / contextEvidenceWeight
contextQuality := ratingClamp01(
(0.75*avgContextExplicitness + 0.25*peakContextExplicitness) / 0.72,
)
peakNorm := ratingSmoothStep(
ratingLinearGate(contextPeakQuality, 0.12, 0.62),
)
durationNorm := ratingSaturatingEvidence(contextFlagged, 150.0)
longestNorm := ratingSaturatingEvidence(contextLongest, 60.0)
densityNorm := ratingSaturatingEvidence(contextDensity, 2.5)
coverageNorm := ratingSaturatingEvidence(contextWeightedCoverageRatio, 0.08)
durationScore := ratingDurationComponent(contextFlagged, contextLongest)
strengthScore := ratingStrengthComponent(contextAvgConfidence, peakContextConfidence)
coverageScore := ratingCoverageComponent(contextFlagged, durationSec)
raw :=
0.28*qualityNorm +
0.12*peakNorm +
0.25*durationNorm +
0.15*longestNorm +
0.15*coverageNorm +
0.05*densityNorm
0.30*contextQuality +
0.25*durationScore +
0.15*strengthScore +
0.12*coverageScore +
0.12*clothingScore +
0.06*contextScore
actionRaw := ratingContextActionScore(
durationNorm,
longestNorm,
coverageNorm,
densityNorm,
peakNorm,
)
raw = ratingBlendWithAction(raw, actionRaw, 0.35)
actionRaw := ratingActionComponent(durationScore, coverageScore, strengthScore)
// Context can produce useful 1-3 star ratings, but never the 4-5 star
// range reserved for sustained position detections.
if contextFlagged < 20.0 {
raw = math.Min(raw, 0.44)
}
raw = math.Min(raw, 0.64)
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)
@ -1609,13 +1680,13 @@ func computeHighlightRatingWithUsername(segments []aiSegmentMeta, durationSec fl
r.AvgConfidence = ratingRound(contextAvgConfidence, 3)
appLogf(
"✅ %s context rating score=%.1f stars=%d segs=%d flagged=%.1f density=%.2f coverage=%.4f longest=%.1f avgConf=%.3f",
"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,
contextDensity,
contextQuality,
r.WeightedCoverageRatio,
r.LongestSegmentSeconds,
r.AvgConfidence,
@ -1624,97 +1695,76 @@ func computeHighlightRatingWithUsername(segments []aiSegmentMeta, durationSec fl
return r
}
coverageRatio := ratingClamp01(totalFlagged / durationSec)
weightedCoverageRatio := ratingClamp01(totalWeighted / durationSec)
positionFlagged := ratingCoveredSeconds(positionRanges, durationSec)
coverageRatio := ratingClamp01(positionFlagged / durationSec)
weightedCoverageRatio := ratingClamp01(positionWeighted / durationSec)
segmentsPerMinute := float64(n) / videoMinutes
avgConfidence := confSum / confWeightSum
avgPositionQuality := qualityDurationSum / qualityDurationWeight
positionDensity := positionEffectiveWeighted / videoMinutes
avgQualityNorm := ratingSmoothStep(
ratingLinearGate(avgPositionQuality, 0.45, 0.95),
avgPositionExplicitness := positionExplicitSum / positionEvidenceWeight
positionScore := ratingClamp01(
0.75*avgPositionExplicitness + 0.25*peakPositionExplicitness,
)
peakNorm := ratingSmoothStep(
ratingLinearGate(peakQuality, 0.42, 0.95),
)
positionDurationNorm := ratingSaturatingEvidence(positionFlagged, 65.0)
longestNorm := ratingSaturatingEvidence(longest, 35.0)
positionDensityNorm := ratingSaturatingEvidence(positionDensity, 3.2)
coverageNorm := ratingSaturatingEvidence(weightedCoverageRatio, 0.075)
positionVarietyNorm := ratingLinearGate(float64(len(uniquePositions)), 1, 4)
durationScore := ratingDurationComponent(positionFlagged, longest)
strengthScore := ratingStrengthComponent(avgConfidence, peakPositionConfidence)
coverageScore := ratingCoverageComponent(positionFlagged, durationSec)
varietyScore := ratingLinearGate(float64(len(uniquePositions)), 1, 4)
// Position quality is the primary signal. Actual position duration and the
// longest continuous segment decide whether a detection is brief or strong.
raw :=
0.30*avgQualityNorm +
0.10*peakNorm +
0.26*positionDurationNorm +
0.16*longestNorm +
0.10*positionDensityNorm +
0.06*coverageNorm +
0.02*positionVarietyNorm
0.36*positionScore +
0.27*durationScore +
0.14*strengthScore +
0.10*coverageScore +
0.06*contextScore +
0.04*clothingScore +
0.03*varietyScore
actionRaw := ratingPositionActionScore(
positionDurationNorm,
longestNorm,
positionDensityNorm,
coverageNorm,
peakNorm,
)
raw = ratingBlendWithAction(raw, actionRaw, 0.30)
actionRaw := ratingActionComponent(durationScore, coverageScore, strengthScore)
explicitContextBonus := ratingExplicitContextBonus(
bodyEffectiveWeighted,
clothingEffectiveWeighted,
objectEffectiveWeighted,
videoMinutes,
)
// Sehr wenig Material nicht überbewerten.
if totalFlagged < 5.0 && n <= 1 {
raw = math.Min(raw, 0.44)
}
excellenceBonus := ratingExcellenceBonus(
peakQuality,
positionEffectiveWeighted,
positionDensity,
weightedCoverageRatio,
totalFlagged,
// Short or weak detections cannot reach high ratings on rank alone.
raw = ratingApplyPositionEvidenceCaps(
raw,
positionFlagged,
longest,
avgConfidence,
segmentsPerMinute,
n,
positionScore,
)
raw = ratingClamp01(raw + explicitContextBonus + excellenceBonus)
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(totalFlagged, 2)
r.WeightedFlaggedSeconds = ratingRound(totalWeighted, 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(
"✅ %s rating score=%.1f stars=%d contextBonus=%.1f excellenceBonus=%.1f segs=%d flagged=%.1f posFlagged=%.1f posDensity=%.2f coverage=%.4f longest=%.1f avgConf=%.3f",
"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,
ratingRound(explicitContextBonus*100, 1),
ratingRound(excellenceBonus*100, 1),
r.PositionScore,
r.ClothingScore,
r.Segments,
r.FlaggedSeconds,
ratingRound(positionFlagged, 1),
positionDensity,
r.DurationScore,
r.StrengthScore,
r.WeightedCoverageRatio,
r.LongestSegmentSeconds,
r.AvgConfidence,

View File

@ -46,22 +46,22 @@ func TestComputeHighlightRatingSpansAllStars(t *testing.T) {
{
name: "several long high quality positions",
segments: []aiSegmentMeta{
ratingTestSegment("position:doggy", 20, 25, 0.82),
ratingTestSegment("position:cowgirl", 120, 25, 0.82),
ratingTestSegment("position:missionary", 220, 25, 0.82),
ratingTestSegment("position:blowjob", 320, 25, 0.82),
ratingTestSegment("position:cowgirl", 20, 25, 0.82),
ratingTestSegment("position:reverse_cowgirl", 120, 25, 0.82),
ratingTestSegment("position:doggy", 220, 25, 0.82),
ratingTestSegment("position:toy_play", 320, 25, 0.82),
},
want: 4,
},
{
name: "exceptional sustained and varied positions",
segments: []aiSegmentMeta{
ratingTestSegment("position:doggy", 10, 40, 0.90),
ratingTestSegment("position:cowgirl", 100, 40, 0.90),
ratingTestSegment("position:missionary", 190, 40, 0.90),
ratingTestSegment("position:blowjob", 280, 40, 0.90),
ratingTestSegment("position:cunnilingus", 370, 40, 0.90),
ratingTestSegment("position:reverse_cowgirl", 460, 40, 0.90),
ratingTestSegment("position:cowgirl", 10, 40, 0.90),
ratingTestSegment("position:reverse_cowgirl", 100, 40, 0.90),
ratingTestSegment("position:doggy", 190, 40, 0.90),
ratingTestSegment("position:toy_play", 280, 40, 0.90),
ratingTestSegment("position:fingering", 370, 40, 0.90),
ratingTestSegment("position:missionary", 460, 40, 0.90),
},
want: 5,
},
@ -77,6 +77,141 @@ func TestComputeHighlightRatingSpansAllStars(t *testing.T) {
}
}
func TestExplicitPositionRanking(t *testing.T) {
labels := []string{
"cowgirl",
"reverse_cowgirl",
"doggy",
"toy_play",
"fingering",
"missionary",
"prone_bone",
"spooning",
"standing",
"69",
"blowjob",
}
previousWeight := 2.0
for index, label := range labels {
ranked := positionExplicitRatingRank(label)
wantRank := index + 1
if ranked.Rank != wantRank {
t.Fatalf("%s rank = %d, want %d", label, ranked.Rank, wantRank)
}
if ranked.Weight <= 0 || ranked.Weight >= previousWeight {
t.Fatalf(
"%s weight = %.2f, want a positive value below %.2f",
label,
ranked.Weight,
previousWeight,
)
}
previousWeight = ranked.Weight
}
for _, alias := range []string{"doggystyle", "standing_doggy"} {
if ranked := positionExplicitRatingRank(alias); ranked.Rank != 3 {
t.Fatalf("%s rank = %d, want alias rank 3", alias, ranked.Rank)
}
}
for _, grouped := range []string{"facesitting", "handjob", "cunnilingus", "boobjob"} {
if ranked := positionExplicitRatingRank(grouped); ranked.Rank != positionExplicitRatingRank("69").Rank &&
ranked.Rank != positionExplicitRatingRank("blowjob").Rank {
t.Fatalf("%s has unexpected grouped rank %d", grouped, ranked.Rank)
}
}
}
func TestExplicitClothingRanking(t *testing.T) {
labels := []string{
"lingerie",
"stockings",
"skirt",
"hotpants",
"bikini",
"bra",
"panties",
"croptop",
"heels",
"dress",
}
previousWeight := 2.0
for index, label := range labels {
ranked := clothingExplicitRatingRank(label)
wantRank := index + 1
if ranked.Rank != wantRank {
t.Fatalf("%s rank = %d, want %d", label, ranked.Rank, wantRank)
}
if ranked.Weight <= 0 || ranked.Weight >= previousWeight {
t.Fatalf(
"%s weight = %.2f, want a positive value below %.2f",
label,
ranked.Weight,
previousWeight,
)
}
previousWeight = ranked.Weight
}
}
func TestComputeHighlightRatingReportsBestExplicitRanks(t *testing.T) {
got := computeHighlightRating([]aiSegmentMeta{
ratingTestSegment("combo:position:standing+clothing:dress", 10, 20, 0.90),
ratingTestSegment("combo:position:cowgirl+clothing:panties", 80, 20, 0.90),
ratingTestSegment("combo:position:doggy+clothing:lingerie", 150, 20, 0.90),
}, 300)
if got.BestPosition != "cowgirl" || got.BestPositionRank != 1 {
t.Fatalf(
"best position = %q rank %d, want cowgirl rank 1",
got.BestPosition,
got.BestPositionRank,
)
}
if got.BestClothing != "lingerie" || got.BestClothingRank != 1 {
t.Fatalf(
"best clothing = %q rank %d, want lingerie rank 1",
got.BestClothing,
got.BestClothingRank,
)
}
}
func TestComputeHighlightRatingFollowsExplicitRank(t *testing.T) {
positionScore := func(label string) float64 {
return computeHighlightRating([]aiSegmentMeta{
ratingTestSegment("position:"+label, 10, 30, 0.85),
}, 300).Score
}
clothingScore := func(label string) float64 {
return computeHighlightRating([]aiSegmentMeta{
ratingTestSegment("clothing:"+label, 10, 90, 0.90),
}, 300).Score
}
if !(positionScore("cowgirl") > positionScore("doggy") &&
positionScore("doggy") > positionScore("toy_play") &&
positionScore("toy_play") > positionScore("missionary") &&
positionScore("missionary") > positionScore("standing") &&
positionScore("standing") > positionScore("blowjob")) {
t.Fatal("position score does not follow the explicit ranking")
}
if !(clothingScore("lingerie") > clothingScore("stockings") &&
clothingScore("stockings") > clothingScore("hotpants") &&
clothingScore("hotpants") > clothingScore("bikini") &&
clothingScore("bikini") > clothingScore("panties") &&
clothingScore("panties") > clothingScore("dress")) {
t.Fatal("clothing score does not follow the explicit ranking")
}
}
func TestComputeHighlightRatingRewardsPositionDuration(t *testing.T) {
short := computeHighlightRating([]aiSegmentMeta{
ratingTestSegment("position:doggy", 10, 8, 0.80),
@ -122,6 +257,67 @@ func TestComputeHighlightRatingActionPrefersSustainedActivity(t *testing.T) {
}
}
func TestComputeHighlightRatingDoesNotDoubleCountOverlaps(t *testing.T) {
got := computeHighlightRating([]aiSegmentMeta{
ratingTestSegment("position:cowgirl", 0, 30, 0.90),
ratingTestSegment("position:doggy", 10, 30, 0.90),
}, 100)
if got.FlaggedSeconds != 40 {
t.Fatalf("flagged seconds = %.1f, want 40.0", got.FlaggedSeconds)
}
if got.CoverageRatio != 0.4 {
t.Fatalf("coverage ratio = %.4f, want 0.4000", got.CoverageRatio)
}
}
func TestComputeHighlightRatingReportsIndependentComponents(t *testing.T) {
got := computeHighlightRating([]aiSegmentMeta{
ratingTestSegment("combo:position:cowgirl+body:pussy+clothing:lingerie", 10, 40, 0.90),
ratingTestSegment("combo:position:doggy+object:dildo+clothing:stockings", 100, 40, 0.90),
}, 300)
components := map[string]float64{
"position": got.PositionScore,
"clothing": got.ClothingScore,
"duration": got.DurationScore,
"strength": got.StrengthScore,
"coverage": got.CoverageScore,
"context": got.ContextScore,
"variety": got.VarietyScore,
"action": got.ActionScore,
}
for name, value := range components {
if value <= 0 || value > 100 {
t.Fatalf("%s component = %.1f, want value in (0, 100]", name, value)
}
}
}
func TestComputeHighlightRatingStrengthFollowsConfidence(t *testing.T) {
weak := computeHighlightRating([]aiSegmentMeta{
ratingTestSegment("position:doggy", 10, 45, 0.45),
}, 300)
strong := computeHighlightRating([]aiSegmentMeta{
ratingTestSegment("position:doggy", 10, 45, 0.90),
}, 300)
if strong.StrengthScore <= weak.StrengthScore {
t.Fatalf(
"strength should follow confidence: strong=%.1f weak=%.1f",
strong.StrengthScore,
weak.StrengthScore,
)
}
if strong.Score <= weak.Score {
t.Fatalf(
"rating should reward stronger evidence: strong=%.1f weak=%.1f",
strong.Score,
weak.Score,
)
}
}
func TestComputeHighlightRatingRatesContextWithoutPosition(t *testing.T) {
clothingOnly := computeHighlightRating([]aiSegmentMeta{
ratingTestSegment("clothing:lingerie", 10, 60, 0.90),
@ -134,9 +330,9 @@ func TestComputeHighlightRatingRatesContextWithoutPosition(t *testing.T) {
ratingTestSegment("combo:body:penis+object:dildo", 240, 45, 0.90),
}, 600)
if clothingOnly.Stars != 2 {
if clothingOnly.Stars != 3 {
t.Fatalf(
"clothing-only stars = %d, want 2 (score %.1f)",
"clothing-only stars = %d, want 3 (score %.1f)",
clothingOnly.Stars,
clothingOnly.Score,
)
@ -210,10 +406,10 @@ func TestComputeHighlightRatingExplicitSignalsBoostPositions(t *testing.T) {
func TestComputeHighlightRatingExplicitContextCanElevateStrongPositions(t *testing.T) {
positions := []aiSegmentMeta{
ratingTestSegment("position:doggy", 20, 25, 0.82),
ratingTestSegment("position:cowgirl", 120, 25, 0.82),
ratingTestSegment("position:missionary", 220, 25, 0.82),
ratingTestSegment("position:blowjob", 320, 25, 0.82),
ratingTestSegment("position:cowgirl", 20, 25, 0.82),
ratingTestSegment("position:reverse_cowgirl", 120, 25, 0.82),
ratingTestSegment("position:doggy", 220, 25, 0.82),
ratingTestSegment("position:toy_play", 320, 25, 0.82),
}
positionOnly := computeHighlightRating(positions, 600)
withExplicitContext := computeHighlightRating(append(

View File

@ -1481,11 +1481,8 @@ func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) {
continue
}
// Ab hier existiert das erste echte extrahierte Bild.
// Dieses bleibt als Overlay-Background für Analyse/Speichern/weitere Frames.
if previewURL == "" {
previewURL = "/api/training/frame?id=" + url.QueryEscape(id)
}
// Nach jeder erfolgreichen Extraktion das aktuell verarbeitete Frame zeigen.
previewURL = "/api/training/frame?id=" + url.QueryEscape(id)
trainingPublishAnalysisStepWithPreview(
requestID,

View File

@ -138,6 +138,17 @@ export const RATING_VALUE_LABELS: Record<string, string> = {
stars: 'Sterne',
score: 'Score',
actionScore: 'Action',
bestPosition: 'Top-Position',
bestPositionRank: 'Positionsrang',
bestClothing: 'Top-Kleidung',
bestClothingRank: 'Kleidungsrang',
positionScore: 'Position',
clothingScore: 'Kleidung',
durationScore: 'Dauer',
strengthScore: 'Stärke',
coverageScore: 'Abdeckung',
contextScore: 'Kontext',
varietyScore: 'Vielfalt',
confidence: 'Konfidenz',
probability: 'Wahrscheinlichkeit',
nsfw: 'NSFW',

View File

@ -767,13 +767,13 @@ function TrainingStageOverlay(props: {
}) {
const progress = clampPercent(props.progress ?? 0)
const isTraining = props.mode === 'training'
const hasBackground = !isTraining && Boolean(props.backgroundUrl)
const hasBackground = Boolean(props.backgroundUrl)
const visible = props.visible ?? true
const [backgroundVisible, setBackgroundVisible] = useState(false)
useEffect(() => {
if (!props.backgroundUrl || isTraining) {
if (!props.backgroundUrl) {
setBackgroundVisible(false)
return
}
@ -785,7 +785,7 @@ function TrainingStageOverlay(props: {
})
return () => window.cancelAnimationFrame(frame)
}, [props.backgroundUrl, isTraining])
}, [props.backgroundUrl])
const title = isTraining ? 'Training läuft…' : 'Analyse läuft…'
const fallbackText = isTraining
@ -5685,9 +5685,9 @@ export default function TrainingTab(props: {
progress={stageOverlayProgress}
visible={stageOverlayIsVisible}
backgroundUrl={
stageOverlayMode === 'analysis'
? loadingPreviewBackgroundUrl
: undefined
stageOverlayMode === 'training'
? imageSrc
: loadingPreviewBackgroundUrl
}
/>
) : null}