updated rating

This commit is contained in:
Linrador 2026-06-13 23:22:15 +02:00
parent c6e518105b
commit 9f3ae43f8a
5 changed files with 161 additions and 42 deletions

View File

@ -107,6 +107,7 @@ type aiSegmentMeta struct {
EndSeconds float64 `json:"endSeconds"`
DurationSeconds float64 `json:"durationSeconds"`
Score float64 `json:"score,omitempty"`
RatingIntensity float64 `json:"ratingIntensity,omitempty"`
AutoSelected bool `json:"autoSelected,omitempty"`
Position string `json:"position,omitempty"`
Tags []string `json:"tags,omitempty"`

View File

@ -10,6 +10,7 @@ import (
type aiRatingMeta struct {
Score float64 `json:"score"`
ActionScore float64 `json:"actionScore"`
Stars int `json:"stars"`
Segments int `json:"segments"`
SegmentsPerMinute float64 `json:"segmentsPerMinute"`
@ -1130,6 +1131,7 @@ func mergeRatingActivitySegments(
return aiSegmentMeta{
Label: label,
Score: ratingClamp01(score),
RatingIntensity: ratingClamp01(sev * ratingConfidenceWeight(score)),
StartSeconds: start,
EndSeconds: end,
DurationSeconds: ratingDuration,
@ -1276,6 +1278,43 @@ func ratingExplicitContextBonus(
return math.Min(bonus, 0.10)
}
func ratingBlendWithAction(base, action, actionWeight float64) float64 {
actionWeight = ratingClamp01(actionWeight)
return ratingClamp01((1-actionWeight)*base + actionWeight*action)
}
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 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 ratingExcellenceBonus(
peakQuality float64,
positionEffectiveWeighted float64,
@ -1539,6 +1578,15 @@ func computeHighlightRatingWithUsername(segments []aiSegmentMeta, durationSec fl
0.15*coverageNorm +
0.05*densityNorm
actionRaw := ratingContextActionScore(
durationNorm,
longestNorm,
coverageNorm,
densityNorm,
peakNorm,
)
raw = ratingBlendWithAction(raw, actionRaw, 0.35)
// Context can produce useful 1-3 star ratings, but never the 4-5 star
// range reserved for sustained position detections.
if contextFlagged < 20.0 {
@ -1549,6 +1597,7 @@ func computeHighlightRatingWithUsername(segments []aiSegmentMeta, durationSec fl
score := ratingRound(ratingClamp01(raw)*100, 1)
r.Score = score
r.ActionScore = ratingRound(actionRaw*100, 1)
r.Stars = starsFromHighlightScore(score)
r.Segments = contextN
r.SegmentsPerMinute = ratingRound(contextSegmentsPerMinute, 2)
@ -1606,6 +1655,15 @@ func computeHighlightRatingWithUsername(segments []aiSegmentMeta, durationSec fl
0.06*coverageNorm +
0.02*positionVarietyNorm
actionRaw := ratingPositionActionScore(
positionDurationNorm,
longestNorm,
positionDensityNorm,
coverageNorm,
peakNorm,
)
raw = ratingBlendWithAction(raw, actionRaw, 0.30)
explicitContextBonus := ratingExplicitContextBonus(
bodyEffectiveWeighted,
clothingEffectiveWeighted,
@ -1635,6 +1693,7 @@ func computeHighlightRatingWithUsername(segments []aiSegmentMeta, durationSec fl
score := ratingRound(raw*100, 1)
r.Score = score
r.ActionScore = ratingRound(actionRaw*100, 1)
r.Stars = starsFromHighlightScore(score)
r.Segments = n
r.SegmentsPerMinute = ratingRound(segmentsPerMinute, 2)

View File

@ -98,6 +98,30 @@ func TestComputeHighlightRatingRewardsPositionDuration(t *testing.T) {
}
}
func TestComputeHighlightRatingActionPrefersSustainedActivity(t *testing.T) {
briefPeak := computeHighlightRating([]aiSegmentMeta{
ratingTestSegment("position:doggy", 10, 8, 0.95),
}, 600)
sustained := computeHighlightRating([]aiSegmentMeta{
ratingTestSegment("position:doggy", 10, 45, 0.55),
}, 600)
if sustained.ActionScore <= briefPeak.ActionScore {
t.Fatalf(
"sustained action should outrank a brief peak: sustained=%.1f brief=%.1f",
sustained.ActionScore,
briefPeak.ActionScore,
)
}
if sustained.Score <= briefPeak.Score {
t.Fatalf(
"rating should follow sustained action: sustained=%.1f brief=%.1f",
sustained.Score,
briefPeak.Score,
)
}
}
func TestComputeHighlightRatingRatesContextWithoutPosition(t *testing.T) {
clothingOnly := computeHighlightRating([]aiSegmentMeta{
ratingTestSegment("clothing:lingerie", 10, 60, 0.90),
@ -268,4 +292,19 @@ func TestPrepareAIRatingSegmentsMergesSamePositionAcrossShortGap(t *testing.T) {
segments[0].DurationSeconds,
)
}
if segments[0].RatingIntensity <= 0 {
t.Fatalf("rating intensity = %.3f, want a positive heatcurve value", segments[0].RatingIntensity)
}
wantIntensity := ratingClamp01(
ratingSegmentSeverity(segments[0].Label) *
ratingConfidenceWeight(segments[0].Score),
)
if diff := segments[0].RatingIntensity - wantIntensity; diff < -0.0001 || diff > 0.0001 {
t.Fatalf(
"rating intensity = %.4f, want %.4f",
segments[0].RatingIntensity,
wantIntensity,
)
}
}

View File

@ -137,6 +137,7 @@ export const AI_LABEL_FALLBACKS: Record<string, string> = {
export const RATING_VALUE_LABELS: Record<string, string> = {
stars: 'Sterne',
score: 'Score',
actionScore: 'Action',
confidence: 'Konfidenz',
probability: 'Wahrscheinlichkeit',
nsfw: 'NSFW',

View File

@ -77,6 +77,18 @@ function readScore(value: unknown): number {
return 0.35
}
function readOptionalScore(value: unknown): number | null {
if (value == null || value === '') return null
const n = Number(value)
if (!Number.isFinite(n)) return null
if (n >= 0 && n <= 1) return clamp(n, 0, 1)
if (n > 1 && n <= 100) return clamp(n / 100, 0, 1)
return null
}
type WeightedDetectedLabel = {
label: string
scoreWeight: number
@ -115,16 +127,34 @@ function weightedLabelFromValue(value: unknown): WeightedDetectedLabel | null {
if (group === 'position') {
return {
label: prettyAiLabel(normalized),
scoreWeight: 1.0,
priority: 2,
scoreWeight: 0.82,
priority: 4,
source: 'position',
}
}
if (group === 'body') {
return {
label: prettyAiLabel(normalized),
scoreWeight: 0.50,
priority: 3,
source: 'other',
}
}
if (group === 'object') {
return {
label: prettyAiLabel(normalized),
scoreWeight: 0.15,
priority: 2,
source: 'other',
}
}
if (group === 'clothing') {
return {
label: prettyAiLabel(normalized),
scoreWeight: 0.58,
scoreWeight: 0.28,
priority: 1,
source: 'clothing',
}
@ -237,17 +267,22 @@ export function buildVideoHeatmapSegments(
? (ai?.segments ?? ai?.Segments)
: []
for (const item of segments) {
const appendItems = (items: unknown[]) => {
for (const item of items) {
if (!isPlainObject(item)) continue
const detected = readWeightedLabel(item)
if (!detected) continue
const ratingIntensity = readOptionalScore(
item.ratingIntensity ?? item.RatingIntensity
)
const rawScore = readScore(
item.score ?? item.Score ?? item.confidence
)
const weightedScore = clamp(rawScore * detected.scoreWeight, 0, 1)
const intensity =
ratingIntensity ??
clamp(rawScore * detected.scoreWeight, 0, 1)
addSegment(
out,
@ -256,37 +291,21 @@ export function buildVideoHeatmapSegments(
item.time ?? item.Time ?? item.at ?? item.timestamp,
durationSec,
detected.label,
weightedScore,
intensity,
detected.source
)
}
}
appendItems(segments)
// Raw hits are the source of the condensed segments and would otherwise
// paint the same activity twice. Keep them only for legacy metadata.
if (out.length === 0) {
const hits = Array.isArray(ai?.hits ?? ai?.Hits)
? (ai?.hits ?? ai?.Hits)
: []
for (const item of hits) {
if (!isPlainObject(item)) continue
const detected = readWeightedLabel(item)
if (!detected) continue
const rawScore = readScore(
item.score ?? item.Score ?? item.confidence
)
const weightedScore = clamp(rawScore * detected.scoreWeight, 0, 1)
addSegment(
out,
item.startSeconds ?? item.StartSeconds ?? item.start ?? item.Start,
item.endSeconds ?? item.EndSeconds ?? item.end ?? item.End,
item.time ?? item.Time ?? item.at ?? item.timestamp,
durationSec,
detected.label,
weightedScore,
detected.source
)
appendItems(hits)
}
return mergeHeatmapSegments(out, durationSec)