This commit is contained in:
Linrador 2026-05-06 21:05:08 +02:00
parent 19859b15be
commit 5679d19b30
9 changed files with 552 additions and 542 deletions

View File

@ -123,18 +123,10 @@ CLOTHING_LABELS = LABEL_GROUPS["clothing"]
POSITION_LABELS = set()
PERSON_LABELS = {
"person",
"person_male",
"person_female",
"person_unknown",
"male_person",
"female_person",
}
MALE_LABELS = {"person_male", "male_person"}
FEMALE_LABELS = {"person_female", "female_person"}
UNKNOWN_PERSON_LABELS = {"person", "person_unknown"}
_MODEL_PATH = ""
_MODEL_ERROR = ""
_LABEL_ERROR = ""
@ -161,12 +153,9 @@ def empty_prediction(source: str = "model_missing") -> dict:
return {
"modelAvailable": False,
"source": source,
"peopleCount": 0,
"maleCount": 0,
"femaleCount": 0,
"unknownCount": 0,
"sexPosition": "unknown",
"sexPositionScore": 0.0,
"peoplePresent": [],
"bodyPartsPresent": [],
"objectsPresent": [],
"clothingPresent": [],
@ -248,13 +237,6 @@ def load_label_groups_safe() -> None:
PERSON_LABELS = {
label for label in LABEL_GROUPS["people"]
if label
} | {
"person",
"person_male",
"person_female",
"person_unknown",
"male_person",
"female_person",
}
@ -307,6 +289,7 @@ def prediction_from_result(result) -> dict:
names = result.names or {}
boxes_out = []
people_present = []
body_parts = []
objects = []
clothing = []
@ -333,6 +316,21 @@ def prediction_from_result(result) -> dict:
w = max(0.0, min(1.0 - x, w))
h = max(0.0, min(1.0 - y, h))
is_person = label in PERSON_LABELS
is_body = label in BODY_LABELS
is_object = label in OBJECT_LABELS
is_clothing = label in CLOTHING_LABELS
is_position = label in POSITION_LABELS
if is_position:
if score > sex_position_score:
sex_position = label
sex_position_score = score
continue
if not (is_person or is_body or is_object or is_clothing):
continue
boxes_out.append({
"label": label,
"score": score,
@ -342,33 +340,24 @@ def prediction_from_result(result) -> dict:
"h": h,
})
if label in BODY_LABELS:
if is_person:
best_score(people_present, label, score)
if is_body:
best_score(body_parts, label, score)
if label in OBJECT_LABELS:
if is_object:
best_score(objects, label, score)
if label in CLOTHING_LABELS:
if is_clothing:
best_score(clothing, label, score)
if label in POSITION_LABELS and score > sex_position_score:
sex_position = label
sex_position_score = score
people_count = sum(1 for box in boxes_out if box["label"] in PERSON_LABELS)
male_count = sum(1 for box in boxes_out if box["label"] in MALE_LABELS)
female_count = sum(1 for box in boxes_out if box["label"] in FEMALE_LABELS)
unknown_count = sum(1 for box in boxes_out if box["label"] in UNKNOWN_PERSON_LABELS)
return {
"modelAvailable": True,
"source": f"yolo-server:{Path(_MODEL_PATH).name}",
"peopleCount": people_count,
"maleCount": male_count,
"femaleCount": female_count,
"unknownCount": unknown_count,
"sexPosition": sex_position,
"sexPositionScore": sex_position_score,
"peoplePresent": people_present,
"bodyPartsPresent": body_parts,
"objectsPresent": objects,
"clothingPresent": clothing,

View File

@ -54,8 +54,13 @@ type videoFrameSample struct {
const (
analyzeSegmentMergeGapSeconds = 8.0
nsfwThresholdModerate = 0.35
nsfwThresholdStrong = 0.60
// Ein Label darf kurz verschwinden, ohne dass ein neues Segment entsteht.
// Bei 3s Frame-Intervall heißt das: ein fehlender Frame wird überbrückt.
analyzeLabelInvisibleGraceSeconds = 3.0
nsfwThresholdModerate = 0.35
nsfwThresholdStrong = 0.60
// Video-Modus: extrahiert 1 Frame alle N Sekunden.
// 1 = jeder Sekunde, 3 = alle 3 Sekunden, 5 = alle 5 Sekunden.
@ -109,26 +114,15 @@ func shouldAutoSelectAnalyzeHit(label string) bool {
return false
}
autoSelectedAILabelsOnce.Do(func() {
autoSelectedAILabelsCache = autoSelectedAILabelSet()
})
_, ok := autoSelectedAILabelsCache[label]
labels := autoSelectedAILabelSet()
_, ok := labels[label]
return ok
}
var nsfwIgnoredLabels = map[string]struct{}{
// Personen sollen nicht als interessante Segmente auftauchen.
"person": {},
"person_male": {},
"person_female": {},
"person_unknown": {},
"male_person": {},
"female_person": {},
// Falls dein Detector irgendwann diese Varianten liefert:
"people_male": {},
"people_female": {},
"person_male": {},
"person_female": {},
}
func isIgnoredNSFWLabel(label string) bool {
@ -1084,26 +1078,43 @@ func appendNSFWHitFromPrediction(
t float64,
) []analyzeHit {
if !pred.ModelAvailable {
appLogln("⚠️ nsfw: modelAvailable=false bei", t)
return hits
}
nsfwResults := trainingPredictionToNSFWResults(pred)
bestLabel, bestScore := pickBestNSFWResult(nsfwResults)
if bestLabel == "" {
if len(nsfwResults) == 0 {
return hits
}
if bestScore < nsfwThresholdForLabel(bestLabel) {
return hits
for _, r := range nsfwResults {
label := strings.ToLower(strings.TrimSpace(r.Label))
if label == "" || label == "unknown" {
continue
}
if isIgnoredNSFWLabel(label) || isPersonSegmentLabel(label) {
continue
}
score := r.Score
if score <= 0 {
score = 1
}
if score < nsfwThresholdForLabel(label) {
continue
}
hits = append(hits, analyzeHit{
Time: t,
Label: label,
Score: score,
Start: t,
End: t,
})
}
return append(hits, analyzeHit{
Time: t,
Label: bestLabel,
Score: bestScore,
Start: t,
End: t,
})
return hits
}
type highlightSignal struct {
@ -1351,15 +1362,14 @@ func buildCombinedHighlightHitFromPrediction(pred TrainingPrediction, t float64)
addHighlightSignal(best, "detector:"+label, box.Score)
}
if len(best) < 2 {
return analyzeHit{}, false
}
signals := make([]highlightSignal, 0, len(best))
groupSeen := map[string]bool{}
nonPositionCount := 0
hasPosition := false
var bestSingle highlightSignal
var bestSingleQuality float64
for _, sig := range best {
if sig.Label == "" {
continue
@ -1369,27 +1379,62 @@ func buildCombinedHighlightHitFromPrediction(pred TrainingPrediction, t float64)
hasPosition = true
} else {
nonPositionCount++
sev := segmentSeverityWeight(sig.Label)
quality := sig.Score * sev
if quality > bestSingleQuality {
bestSingle = sig
bestSingleQuality = quality
}
}
groupSeen[sig.Group] = true
signals = append(signals, sig)
}
// Nur echte interessante Kombis:
// - Position + mindestens ein weiteres Signal
// - oder mindestens zwei Nicht-Positions-Signale
// - oder mindestens zwei unterschiedliche Signalgruppen
// Fallback: starke Einzel-Treffer sollen auch Highlights werden.
// Sonst verschwinden viele explizite Stellen, wenn sie nicht zufällig
// im selben Frame mit Position/Object/Clothing kombiniert werden.
returnSingleIfGoodEnough := func() (analyzeHit, bool) {
if bestSingle.Label == "" {
return analyzeHit{}, false
}
sev := segmentSeverityWeight(bestSingle.Label)
// Nur wirklich relevante Einzel-Signale übernehmen.
if sev < 0.65 {
return analyzeHit{}, false
}
if bestSingle.Score < 0.35 {
return analyzeHit{}, false
}
if bestSingleQuality < 0.32 {
return analyzeHit{}, false
}
return analyzeHit{
Time: t,
Label: bestSingle.Label,
Score: bestSingle.Score,
Start: t,
End: t,
}, true
}
// Combo nur, wenn wirklich genug Kontext da ist.
if len(signals) < 2 {
return analyzeHit{}, false
return returnSingleIfGoodEnough()
}
if hasPosition && nonPositionCount < 1 {
return analyzeHit{}, false
return returnSingleIfGoodEnough()
}
if !hasPosition && nonPositionCount < 2 {
return analyzeHit{}, false
return returnSingleIfGoodEnough()
}
if len(groupSeen) < 2 && len(signals) < 3 {
return analyzeHit{}, false
return returnSingleIfGoodEnough()
}
sort.SliceStable(signals, func(i, j int) bool {
@ -1464,17 +1509,84 @@ func buildCombinedHighlightHitFromPrediction(pred TrainingPrediction, t float64)
}, true
}
func buildHighlightHitsFromPrediction(pred TrainingPrediction, t float64) []analyzeHit {
if !pred.ModelAvailable {
return nil
}
best := map[string]highlightSignal{}
sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition))
if sexPosition != "" && sexPosition != "unknown" && isKnownPositionLabel(sexPosition) {
addHighlightSignal(best, "position:"+sexPosition, pred.SexPositionScore)
}
addHighlightSignalsFromScoredLabels(best, "body", pred.BodyPartsPresent)
addHighlightSignalsFromScoredLabels(best, "object", pred.ObjectsPresent)
addHighlightSignalsFromScoredLabels(best, "clothing", pred.ClothingPresent)
for _, box := range pred.Boxes {
label := strings.ToLower(strings.TrimSpace(box.Label))
if label == "" || label == "unknown" {
continue
}
if isIgnoredNSFWLabel(label) {
continue
}
if !shouldAutoSelectAnalyzeHit(label) {
continue
}
addHighlightSignal(best, "detector:"+label, box.Score)
}
out := make([]analyzeHit, 0, len(best))
for _, sig := range best {
label := strings.ToLower(strings.TrimSpace(sig.Label))
if label == "" {
continue
}
// Schwache Positions-Kontexte wie standing/sitting nicht alleine als Segment anzeigen.
if sig.Group == "position" && segmentSeverityWeight(label) < 0.70 {
continue
}
out = append(out, analyzeHit{
Time: t,
Label: label,
Score: sig.Score,
Start: t,
End: t,
})
}
sort.SliceStable(out, func(i, j int) bool {
wi := segmentSeverityWeight(out[i].Label) * out[i].Score
wj := segmentSeverityWeight(out[j].Label) * out[j].Score
if wi != wj {
return wi > wj
}
return out[i].Label < out[j].Label
})
return out
}
func appendHighlightHitsFromPrediction(
hits []analyzeHit,
pred TrainingPrediction,
t float64,
) []analyzeHit {
hit, ok := buildCombinedHighlightHitFromPrediction(pred, t)
if !ok {
next := buildHighlightHitsFromPrediction(pred, t)
if len(next) == 0 {
return hits
}
return append(hits, hit)
return append(hits, next...)
}
func analyzeVideoFromFrames(ctx context.Context, outPath, goal string) ([]analyzeHit, error) {
@ -2191,18 +2303,27 @@ func segmentTagsFromAnalyzeLabel(label string) []string {
return out
}
func analyzeHitContinuationGapSeconds() float64 {
// Treffer bei 0s und 6s sollen bei 3s Sampling noch zusammengehören:
// 0s erkannt, 3s nicht erkannt, 6s wieder erkannt.
return float64(analyzeVideoFrameIntervalSeconds) + analyzeLabelInvisibleGraceSeconds + 0.25
}
func mergeAnalyzeHits(in []analyzeHit) []analyzeHit {
if len(in) == 0 {
return []analyzeHit{}
}
cp := make([]analyzeHit, 0, len(in))
maxGap := analyzeHitContinuationGapSeconds()
byLabel := map[string][]analyzeHit{}
for _, h := range in {
label := strings.ToLower(strings.TrimSpace(h.Label))
if label == "" {
if label == "" || label == "unknown" {
continue
}
if isIgnoredNSFWLabel(label) {
if isIgnoredNSFWLabel(label) || isPersonSegmentLabel(label) {
continue
}
@ -2221,62 +2342,84 @@ func mergeAnalyzeHits(in []analyzeHit) []analyzeHit {
}
}
if start > end {
start, end = end, start
}
h.Label = label
h.Start = start
h.End = end
cp = append(cp, h)
}
if len(cp) == 0 {
return []analyzeHit{}
}
sort.Slice(cp, func(i, j int) bool {
if cp[i].Start != cp[j].Start {
return cp[i].Start < cp[j].Start
}
if cp[i].End != cp[j].End {
return cp[i].End < cp[j].End
}
return cp[i].Label < cp[j].Label
})
out := make([]analyzeHit, 0, len(cp))
cur := cp[0]
for i := 1; i < len(cp); i++ {
n := cp[i]
// Direkt aufeinanderfolgende Treffer mit gleichem Label immer zusammenfassen.
// Sobald ein anderes Label dazwischen liegt, wird automatisch nicht gemergt.
sameLabel := sameAnalyzeSegmentLabel(cur.Label, n.Label)
gap := n.Start - cur.End
if sameLabel && gap >= -0.25 && gap <= analyzeSegmentMergeGapSeconds {
cur.Label = preferAnalyzeSegmentLabel(cur.Label, n.Label)
if n.Start < cur.Start {
cur.Start = n.Start
}
if n.End > cur.End {
cur.End = n.End
}
if n.Score > cur.Score {
cur.Score = n.Score
}
cur.Time = (cur.Start + cur.End) / 2
key := normalizeSegmentLabel(label)
if key == "" {
continue
}
out = append(out, cur)
cur = n
byLabel[key] = append(byLabel[key], h)
}
out = append(out, cur)
out := make([]analyzeHit, 0, len(in))
for _, items := range byLabel {
if len(items) == 0 {
continue
}
sort.SliceStable(items, func(i, j int) bool {
if items[i].Start != items[j].Start {
return items[i].Start < items[j].Start
}
if items[i].End != items[j].End {
return items[i].End < items[j].End
}
return items[i].Label < items[j].Label
})
cur := items[0]
for i := 1; i < len(items); i++ {
n := items[i]
gap := n.Start - cur.End
if gap >= -0.25 && gap <= maxGap {
cur.Label = preferAnalyzeSegmentLabel(cur.Label, n.Label)
if n.Start < cur.Start {
cur.Start = n.Start
}
if n.End > cur.End {
cur.End = n.End
}
if n.Score > cur.Score {
cur.Score = n.Score
}
cur.Time = (cur.Start + cur.End) / 2
continue
}
out = append(out, cur)
cur = n
}
out = append(out, cur)
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Start != out[j].Start {
return out[i].Start < out[j].Start
}
if out[i].End != out[j].End {
return out[i].End < out[j].End
}
return normalizeSegmentLabel(out[i].Label) < normalizeSegmentLabel(out[j].Label)
})
return out
}
func inferAnalyzePointSpanSeconds(hits []analyzeHit, duration float64) float64 {
const fallback = 3.0
const fallback = 10.0
if len(hits) < 2 {
return fallback
@ -2338,8 +2481,8 @@ func inferAnalyzePointSpanSeconds(hits []analyzeHit, duration float64) float64 {
// aber wir deckeln, damit Sparse-Hits nicht riesig werden.
span := median * 0.90
if span < 2 {
span = 2
if span < 6 {
span = 6
}
if span > 12 {
span = 12
@ -2387,17 +2530,56 @@ func expandAnalyzePointToSpan(t, span, duration float64) (float64, float64) {
return start, end
}
func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegmentMeta {
type analyzeLabelSegmentPoint struct {
Label string
Start float64
End float64
Score float64
}
func isAllowedAnalyzeSegmentLabel(label string) bool {
label = strings.ToLower(strings.TrimSpace(label))
if label == "" || label == "unknown" {
return false
}
if isIgnoredNSFWLabel(label) || isPersonSegmentLabel(label) {
return false
}
if strings.HasPrefix(label, "combo:") {
for _, part := range segmentLabelParts(label) {
if isAllowedAnalyzeSegmentLabel(part) {
return true
}
}
return false
}
raw := normalizeSegmentLabel(label)
if raw == "" || raw == "unknown" {
return false
}
return shouldAutoSelectAnalyzeHit(raw) || isKnownPositionLabel(raw)
}
func buildLabelContinuitySegmentsFromAnalyzeHits(
hits []analyzeHit,
duration float64,
) []aiSegmentMeta {
if len(hits) == 0 || duration <= 0 {
return []aiSegmentMeta{}
}
pointSpan := inferAnalyzePointSpanSeconds(hits, duration)
sampleSpan := math.Max(1.0, float64(analyzeVideoFrameIntervalSeconds))
halfSample := sampleSpan / 2.0
maxGap := analyzeHitContinuationGapSeconds()
out := make([]aiSegmentMeta, 0, len(hits))
byLabel := map[string][]analyzeLabelSegmentPoint{}
for _, hit := range hits {
if !shouldAutoSelectAnalyzeHit(hit.Label) {
label := strings.ToLower(strings.TrimSpace(hit.Label))
if !isAllowedAnalyzeSegmentLabel(label) {
continue
}
@ -2420,201 +2602,174 @@ func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegme
start, end = end, start
}
if start < 0 {
start = hit.Time
}
if end < 0 {
end = start
}
start = math.Max(0, math.Min(start, duration))
end = math.Max(0, math.Min(end, duration))
// Wichtig:
// Einzelne AI-Treffer sind oft Punkt-Treffer: Start == End.
// Für Segmente und Rating brauchen sie aber eine kleine Dauer.
if end <= start {
marker := hit.Time
if marker < 0 {
marker = start
}
start, end = expandAnalyzePointToSpan(marker, pointSpan, duration)
}
if end <= start {
key := normalizeSegmentLabel(label)
if key == "" {
continue
}
label := strings.ToLower(strings.TrimSpace(hit.Label))
score := hit.Score
if score <= 0 {
score = 1
}
out = append(out, aiSegmentMeta{
Label: label,
StartSeconds: start,
EndSeconds: end,
DurationSeconds: end - start,
Score: hit.Score,
AutoSelected: true,
Position: segmentPositionFromAnalyzeLabel(label),
Tags: segmentTagsFromAnalyzeLabel(label),
byLabel[key] = append(byLabel[key], analyzeLabelSegmentPoint{
Label: label,
Start: start,
End: end,
Score: score,
})
}
if len(out) == 0 {
return []aiSegmentMeta{}
out := make([]aiSegmentMeta, 0)
for _, points := range byLabel {
if len(points) == 0 {
continue
}
sort.SliceStable(points, func(i, j int) bool {
if points[i].Start != points[j].Start {
return points[i].Start < points[j].Start
}
if points[i].End != points[j].End {
return points[i].End < points[j].End
}
return points[i].Label < points[j].Label
})
curLabel := points[0].Label
curStartMarker := points[0].Start
curEndMarker := points[0].End
curScoreSum := points[0].Score
curScoreCount := 1
for i := 1; i < len(points); i++ {
n := points[i]
gap := n.Start - curEndMarker
if gap >= -0.25 && gap <= maxGap {
curLabel = preferAnalyzeSegmentLabel(curLabel, n.Label)
if n.Start < curStartMarker {
curStartMarker = n.Start
}
if n.End > curEndMarker {
curEndMarker = n.End
}
curScoreSum += n.Score
curScoreCount++
continue
}
segment := makeLabelContinuitySegment(
curLabel,
curStartMarker,
curEndMarker,
curScoreSum,
curScoreCount,
halfSample,
duration,
)
if segment.DurationSeconds > 0 {
out = append(out, segment)
}
curLabel = n.Label
curStartMarker = n.Start
curEndMarker = n.End
curScoreSum = n.Score
curScoreCount = 1
}
segment := makeLabelContinuitySegment(
curLabel,
curStartMarker,
curEndMarker,
curScoreSum,
curScoreCount,
halfSample,
duration,
)
if segment.DurationSeconds > 0 {
out = append(out, segment)
}
}
sort.Slice(out, func(i, j int) bool {
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 out[i].Label < out[j].Label
return normalizeSegmentLabel(out[i].Label) < normalizeSegmentLabel(out[j].Label)
})
merged := make([]aiSegmentMeta, 0, len(out))
cur := out[0]
return out
}
for i := 1; i < len(out); i++ {
n := out[i]
func makeLabelContinuitySegment(
label string,
startMarker float64,
endMarker float64,
scoreSum float64,
scoreCount int,
halfSample float64,
duration float64,
) aiSegmentMeta {
label = strings.ToLower(strings.TrimSpace(label))
gap := n.StartSeconds - cur.EndSeconds
if gap < 0 {
gap = 0
}
start := startMarker - halfSample
end := endMarker + halfSample
if sameAnalyzeSegmentLabel(cur.Label, n.Label) && gap <= analyzeSegmentMergeGapSeconds {
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
if n.Score > cur.Score {
cur.Score = n.Score
}
cur.AutoSelected = cur.AutoSelected || n.AutoSelected
continue
}
merged = append(merged, cur)
cur = n
if start < 0 {
start = 0
}
if duration > 0 && end > duration {
end = duration
}
merged = append(merged, cur)
return mergeAdjacentAISegments(merged, analyzeSegmentMergeGapSeconds)
if end <= start {
end = math.Min(duration, start+math.Max(1, halfSample*2))
}
score := 0.0
if scoreCount > 0 {
score = scoreSum / float64(scoreCount)
}
return aiSegmentMeta{
Label: label,
StartSeconds: start,
EndSeconds: end,
DurationSeconds: math.Max(0, end-start),
Score: score,
AutoSelected: true,
Position: segmentPositionFromAnalyzeLabel(label),
Tags: segmentTagsFromAnalyzeLabel(label),
}
}
func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegmentMeta {
return buildLabelContinuitySegmentsFromAnalyzeHits(hits, duration)
}
func buildHighlightSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegmentMeta {
if len(hits) == 0 || duration <= 0 {
return []aiSegmentMeta{}
}
pointSpan := inferAnalyzePointSpanSeconds(hits, duration)
out := make([]aiSegmentMeta, 0, len(hits))
for _, hit := range hits {
label := strings.ToLower(strings.TrimSpace(hit.Label))
if label == "" || label == "unknown" {
continue
}
if isIgnoredNSFWLabel(label) {
continue
}
start := hit.Start
end := hit.End
if start < 0 && end < 0 {
start = hit.Time
end = hit.Time
} else {
if start < 0 {
start = hit.Time
}
if end < 0 {
end = hit.Time
}
}
if start > end {
start, end = end, start
}
start = math.Max(0, math.Min(start, duration))
end = math.Max(0, math.Min(end, duration))
if end <= start {
marker := hit.Time
if marker < 0 {
marker = start
}
start, end = expandAnalyzePointToSpan(marker, pointSpan, duration)
}
if end <= start {
continue
}
out = append(out, aiSegmentMeta{
Label: label,
StartSeconds: start,
EndSeconds: end,
DurationSeconds: end - start,
Score: hit.Score,
AutoSelected: true,
Position: segmentPositionFromAnalyzeLabel(label),
Tags: segmentTagsFromAnalyzeLabel(label),
})
}
if len(out) == 0 {
return []aiSegmentMeta{}
}
sort.Slice(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 out[i].Label < out[j].Label
})
merged := make([]aiSegmentMeta, 0, len(out))
cur := out[0]
for i := 1; i < len(out); i++ {
n := out[i]
gap := n.StartSeconds - cur.EndSeconds
if gap < 0 {
gap = 0
}
if sameAnalyzeSegmentLabel(cur.Label, n.Label) && gap <= analyzeSegmentMergeGapSeconds {
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
if n.Score > cur.Score {
cur.Score = n.Score
}
cur.AutoSelected = cur.AutoSelected || n.AutoSelected
continue
}
merged = append(merged, cur)
cur = n
}
merged = append(merged, cur)
return mergeAdjacentAISegments(merged, analyzeSegmentMergeGapSeconds)
return buildLabelContinuitySegmentsFromAnalyzeHits(hits, duration)
}
func buildAnalyzeSegmentsForGoal(

View File

@ -1433,29 +1433,13 @@ func hasAIAnalysisForOutputGoal(outPath string, goal string) bool {
return false
}
rawHits, hasHits := aiMap["hits"].([]any)
rawSegs, hasSegs := aiMap["segments"].([]any)
// Wichtig:
// Auch eine Analyse ohne Treffer ist fertig, solange sie gespeichert wurde.
if rawGoal, ok := aiMap["goal"].(string); ok && normalizeAIGoal(rawGoal) == goal {
return true
}
if rawMode, ok := aiMap["mode"].(string); ok && strings.TrimSpace(rawMode) != "" {
return true
}
if _, ok := aiMap["hits"]; ok {
return true
}
if _, ok := aiMap["segments"]; ok {
return true
}
if _, ok := aiMap["analyzedAtUnix"]; ok {
return true
}
return false
// Eine leere Analyse zählt NICHT mehr als fertig.
// Sonst bleibt ein kaputter 0-Treffer-Run für immer gecached.
return (hasHits && len(rawHits) > 0) || (hasSegs && len(rawSegs) > 0)
}
func hasAIAnalysisForAllOutputGoals(outPath string, goals []string) bool {

View File

@ -847,9 +847,9 @@ func computeNSFWRating(segments []aiSegmentMeta, durationSec float64) *aiRatingM
0.04*confNorm
// Sicherheits-Caps:
// Ohne Positionssignal soll Kontext alleine nicht auf 45 Sterne kippen.
// Ohne Positionssignal soll Kontext alleine nicht auf 5 Sterne kippen.
if positionEffectiveWeighted <= 0 {
raw = math.Min(raw, 0.58)
raw = math.Min(raw, 0.72)
}
// Sehr kurze/spärliche Treffer nicht überbewerten.

View File

@ -50,12 +50,9 @@ type TrainingPrediction struct {
ModelAvailable bool `json:"modelAvailable"`
Source string `json:"source,omitempty"`
PeopleCount int `json:"peopleCount"`
MaleCount int `json:"maleCount"`
FemaleCount int `json:"femaleCount"`
UnknownCount int `json:"unknownCount"`
SexPosition string `json:"sexPosition"`
SexPositionScore float64 `json:"sexPositionScore"`
PeoplePresent []TrainingScoredLabel `json:"peoplePresent"`
BodyPartsPresent []TrainingScoredLabel `json:"bodyPartsPresent"`
ObjectsPresent []TrainingScoredLabel `json:"objectsPresent"`
ClothingPresent []TrainingScoredLabel `json:"clothingPresent"`
@ -63,11 +60,8 @@ type TrainingPrediction struct {
}
type TrainingCorrection struct {
PeopleCount int `json:"peopleCount"`
MaleCount int `json:"maleCount"`
FemaleCount int `json:"femaleCount"`
UnknownCount int `json:"unknownCount"`
SexPosition string `json:"sexPosition"`
PeoplePresent []string `json:"peoplePresent"`
BodyPartsPresent []string `json:"bodyPartsPresent"`
ObjectsPresent []string `json:"objectsPresent"`
ClothingPresent []string `json:"clothingPresent"`
@ -1271,11 +1265,8 @@ func trainingEffectiveCorrection(annotation TrainingAnnotation) TrainingCorrecti
p := annotation.Prediction
return TrainingCorrection{
PeopleCount: p.PeopleCount,
MaleCount: p.MaleCount,
FemaleCount: p.FemaleCount,
UnknownCount: p.UnknownCount,
SexPosition: p.SexPosition,
PeoplePresent: trainingScoredLabelsToStrings(p.PeoplePresent),
BodyPartsPresent: trainingScoredLabelsToStrings(p.BodyPartsPresent),
ObjectsPresent: trainingScoredLabelsToStrings(p.ObjectsPresent),
ClothingPresent: trainingScoredLabelsToStrings(p.ClothingPresent),
@ -2034,12 +2025,9 @@ func trainingPredictionFromDetector(det TrainingDetectorPrediction) TrainingPred
pred := TrainingPrediction{
ModelAvailable: det.Available,
Source: det.Source,
PeopleCount: 0,
MaleCount: 0,
FemaleCount: 0,
UnknownCount: 0,
SexPosition: "unknown",
SexPositionScore: 0,
PeoplePresent: []TrainingScoredLabel{},
BodyPartsPresent: []TrainingScoredLabel{},
ObjectsPresent: []TrainingScoredLabel{},
ClothingPresent: []TrainingScoredLabel{},
@ -2133,17 +2121,6 @@ func trainingPredictionFromDetector(det TrainingDetectorPrediction) TrainingPred
}
if peopleSet[label] {
switch label {
case "person_male", "male_person":
pred.MaleCount++
case "person_female", "female_person":
pred.FemaleCount++
case "person", "person_unknown":
pred.UnknownCount++
}
visibleBoxes = append(visibleBoxes, box)
continue
}
@ -2158,9 +2135,9 @@ func trainingPredictionFromDetector(det TrainingDetectorPrediction) TrainingPred
pred.SexPositionScore = bestSexPositionScore
}
pred.PeopleCount = pred.MaleCount + pred.FemaleCount + pred.UnknownCount
pred.Boxes = visibleBoxes
pred.PeoplePresent = trainingScoredLabelsFromDetectorBoxes(visibleBoxes, grouped.People)
pred.BodyPartsPresent = trainingScoredLabelsFromDetectorBoxes(visibleBoxes, grouped.BodyParts)
pred.ObjectsPresent = trainingScoredLabelsFromDetectorBoxes(visibleBoxes, grouped.Objects)
pred.ClothingPresent = trainingScoredLabelsFromDetectorBoxes(visibleBoxes, grouped.Clothing)
@ -2526,12 +2503,9 @@ func trainingEmptyPrediction(source string) TrainingPrediction {
return TrainingPrediction{
ModelAvailable: false,
Source: source,
PeopleCount: 0,
MaleCount: 0,
FemaleCount: 0,
UnknownCount: 0,
SexPosition: "unknown",
SexPositionScore: 0,
PeoplePresent: []TrainingScoredLabel{},
BodyPartsPresent: []TrainingScoredLabel{},
ObjectsPresent: []TrainingScoredLabel{},
ClothingPresent: []TrainingScoredLabel{},

View File

@ -2627,23 +2627,23 @@ export default function App() {
const onlineLikedCount = headerStats.onlineLikedCount
const runningTabCount = useMemo(() => {
const activeDownloads = jobs.filter((j) => {
return jobs.filter((j) => {
const status = String((j as any)?.status ?? '').trim().toLowerCase()
const phase = String((j as any)?.phase ?? '').trim().toLowerCase()
// Nacharbeit / Postwork nicht mitzählen
if (isPostworkJobForCount(j)) return false
if (isTerminalJobStatusForCount((j as any)?.status)) return false
// Fertige/abgebrochene/fehlgeschlagene Jobs nicht mitzählen
if (isTerminalJobStatusForCount(status)) return false
// Sobald endedAt gesetzt ist, ist die aktive Aufnahme vorbei
if (Boolean((j as any)?.endedAt)) return false
return true
// Nur wirklich laufende Downloads zählen
return status === 'running' && (phase === '' || phase === 'recording')
}).length
const activePostwork = jobs.filter((j) => {
if (!isPostworkJobForCount(j)) return false
if (isTerminalJobStatusForCount((j as any)?.status)) return false
return true
}).length
const pendingCount = pendingWatchedRooms.length
return activeDownloads + activePostwork + pendingCount
}, [jobs, pendingWatchedRooms])
}, [jobs])
const tabs: TabItem[] = [
{

View File

@ -299,6 +299,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const [saving, setSaving] = useState(false)
const [saveSuccessUntilMs, setSaveSuccessUntilMs] = useState<number>(0)
const saveSuccessTimerRef = useRef<number | null>(null)
const appLogPreRef = useRef<HTMLPreElement | null>(null)
const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null)
const [msg, setMsg] = useState<string | null>(null)
const [err, setErr] = useState<string | null>(null)
@ -317,6 +318,15 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
type SaveUiState = 'idle' | 'saving' | 'success' | 'error'
const saveUiState: SaveUiState = saving ? 'saving' : saveSucceeded ? 'success' : saveFailed ? 'error' : 'idle'
function scrollAppLogToBottom() {
window.requestAnimationFrame(() => {
const el = appLogPreRef.current
if (!el) return
el.scrollTop = el.scrollHeight
})
}
const saveButton = (() => {
if (saveUiState === 'saving') {
return {
@ -749,6 +759,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
})
} finally {
setAppLogLoading(false)
scrollAppLogToBottom()
}
}
@ -757,6 +768,12 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
void loadAppLog()
}, [appLogOpen])
useEffect(() => {
if (!appLogOpen) return
scrollAppLogToBottom()
}, [appLogOpen, appLog?.log, appLogLoading])
async function browse(target: 'record' | 'done' | 'ffmpeg') {
setErr(null)
setMsg(null)
@ -1743,7 +1760,10 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
</div>
) : null}
<pre className="max-h-96 overflow-auto whitespace-pre-wrap rounded-xl bg-gray-950 p-3 font-mono text-[11px] leading-relaxed text-gray-100 ring-1 ring-black/10 dark:ring-white/10">
<pre
ref={appLogPreRef}
className="max-h-96 overflow-auto whitespace-pre-wrap rounded-xl bg-gray-950 p-3 font-mono text-[11px] leading-relaxed text-gray-100 ring-1 ring-black/10 dark:ring-white/10"
>
{appLogLoading
? 'Log wird geladen…'
: appLog?.log?.trim()

View File

@ -65,12 +65,9 @@ type TrainingPrediction = {
modelAvailable: boolean
source?: string
peopleCount: number
maleCount: number
femaleCount: number
unknownCount: number
sexPosition: string
sexPositionScore: number
peoplePresent: ScoredLabel[]
bodyPartsPresent: ScoredLabel[]
objectsPresent: ScoredLabel[]
clothingPresent: ScoredLabel[]
@ -97,11 +94,8 @@ type TrainingLabels = {
}
type CorrectionState = {
peopleCount: number
maleCount: number
femaleCount: number
unknownCount: number
sexPosition: string
peoplePresent: string[]
bodyPartsPresent: string[]
objectsPresent: string[]
clothingPresent: string[]
@ -604,11 +598,6 @@ function toggleArrayValue(arr: string[], value: string) {
: [...arr, value]
}
function safeCount(value: unknown) {
const n = Number(value)
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0
}
function clamp01(v: number) {
if (!Number.isFinite(v)) return 0
return Math.max(0, Math.min(1, v))
@ -668,32 +657,35 @@ function uniqStrings(values: string[]) {
return out
}
function peopleLabelsFromBoxes(boxes: TrainingBox[], labels: TrainingLabels) {
return uniqStrings(
boxes
.map((box) => String(box.label || '').trim())
.filter((label) => labels.people.includes(label))
)
}
function predictionToCorrection(sample: TrainingSample | null): CorrectionState {
const p = sample?.prediction
const maleCount = safeCount(p?.maleCount)
const femaleCount = safeCount(p?.femaleCount)
const unknownCount = safeCount(p?.unknownCount)
const boxes = (p?.boxes ?? [])
.map((box) => ({
label: String(box.label || '').trim(),
score: box.score,
x: clamp01(Number(box.x)),
y: clamp01(Number(box.y)),
w: clamp01(Number(box.w)),
h: clamp01(Number(box.h)),
}))
.filter((box) => box.label && box.w > 0 && box.h > 0)
return {
peopleCount: maleCount + femaleCount + unknownCount,
maleCount,
femaleCount,
unknownCount,
sexPosition: p?.sexPosition || 'unknown',
peoplePresent: (p?.peoplePresent ?? []).map((x) => x.label),
bodyPartsPresent: (p?.bodyPartsPresent ?? []).map((x) => x.label),
objectsPresent: (p?.objectsPresent ?? []).map((x) => x.label),
clothingPresent: (p?.clothingPresent ?? []).map((x) => x.label),
boxes: (p?.boxes ?? [])
.map((box) => ({
label: String(box.label || '').trim(),
score: box.score,
x: clamp01(Number(box.x)),
y: clamp01(Number(box.y)),
w: clamp01(Number(box.w)),
h: clamp01(Number(box.h)),
}))
.filter((box) => box.label && box.w > 0 && box.h > 0),
boxes,
}
}
@ -706,39 +698,12 @@ function applyBoxLabelToCorrection(
if (!clean) return state
if (labels.people.includes(clean)) {
if (clean === 'person_male' || clean === 'male_person') {
return {
...state,
maleCount: safeCount(state.maleCount) + 1,
unknownCount: 0,
peopleCount:
safeCount(state.maleCount) + 1 + safeCount(state.femaleCount),
}
return {
...state,
peoplePresent: state.peoplePresent.includes(clean)
? state.peoplePresent
: [...state.peoplePresent, clean],
}
if (clean === 'person_female' || clean === 'female_person') {
return {
...state,
femaleCount: safeCount(state.femaleCount) + 1,
unknownCount: 0,
peopleCount:
safeCount(state.maleCount) + safeCount(state.femaleCount) + 1,
}
}
if (clean === 'person' || clean === 'person_unknown') {
return {
...state,
unknownCount: safeCount(state.unknownCount) + 1,
peopleCount:
safeCount(state.maleCount) +
safeCount(state.femaleCount) +
safeCount(state.unknownCount) +
1,
}
}
return state
}
if (labels.bodyParts.includes(clean)) {
@ -786,6 +751,13 @@ function removeBoxLabelFromCorrection(
// Badge nur abwählen, wenn keine weitere Box mit diesem Label existiert.
if (remainingBoxesWithSameLabel) return state
if (labels.people.includes(clean)) {
return {
...state,
peoplePresent: state.peoplePresent.filter((x) => x !== clean),
}
}
if (labels.bodyParts.includes(clean)) {
return {
...state,
@ -822,42 +794,11 @@ function removeBoxFromCorrection(
const removedLabel = String(removed.label || '').trim()
let next: CorrectionState = {
const next: CorrectionState = {
...state,
boxes: boxes.filter((_, i) => i !== index),
}
if (removedLabel === 'person_male' || removedLabel === 'male_person') {
next = {
...next,
maleCount: Math.max(0, safeCount(next.maleCount) - 1),
unknownCount: 0,
}
}
if (removedLabel === 'person_female' || removedLabel === 'female_person') {
next = {
...next,
femaleCount: Math.max(0, safeCount(next.femaleCount) - 1),
unknownCount: 0,
}
}
if (removedLabel === 'person' || removedLabel === 'person_unknown') {
next = {
...next,
unknownCount: Math.max(0, safeCount(next.unknownCount) - 1),
}
}
next = {
...next,
peopleCount:
safeCount(next.maleCount) +
safeCount(next.femaleCount) +
safeCount(next.unknownCount),
}
return removeBoxLabelFromCorrection(next, removedLabel, labels)
}
@ -890,52 +831,9 @@ function changeBoxLabelInCorrection(
}),
}
// Alte Personen-Zählung entfernen.
if (oldLabel === 'person_male' || oldLabel === 'male_person') {
next = {
...next,
maleCount: Math.max(0, safeCount(next.maleCount) - 1),
unknownCount: 0,
}
}
if (oldLabel === 'person_female' || oldLabel === 'female_person') {
next = {
...next,
femaleCount: Math.max(0, safeCount(next.femaleCount) - 1),
unknownCount: 0,
}
}
if (oldLabel === 'person' || oldLabel === 'person_unknown') {
next = {
...next,
unknownCount: Math.max(0, safeCount(next.unknownCount) - 1),
}
}
next = {
...next,
peopleCount:
safeCount(next.maleCount) +
safeCount(next.femaleCount) +
safeCount(next.unknownCount),
}
// Alte Bodypart/Object/Clothing-Badges entfernen, falls keine weitere Box damit existiert.
next = removeBoxLabelFromCorrection(next, oldLabel, labels)
// Neues Label anwenden.
next = applyBoxLabelToCorrection(next, cleanNextLabel, labels)
next = {
...next,
peopleCount:
safeCount(next.maleCount) +
safeCount(next.femaleCount) +
safeCount(next.unknownCount),
}
return next
}
@ -1503,8 +1401,12 @@ function CollapsibleLabelSection(props: {
singleDrawMode?: boolean
gridClassName?: string
}) {
const cleanDrawLabel = String(props.drawLabel || '').trim()
const hasDrawLabelInSection =
cleanDrawLabel !== '' && props.values.includes(cleanDrawLabel)
const activeCount = props.singleDrawMode
? Math.max(props.selected.length, props.drawLabel ? 1 : 0)
? Math.max(props.selected.length, hasDrawLabelInSection ? 1 : 0)
: props.selected.length
const hasActiveItems = activeCount > 0
@ -2262,12 +2164,13 @@ export default function TrainingTab(props: {
}, [sample?.prediction.boxes, labels.people])
const selectedPeopleLabels = useMemo(() => {
return uniqStrings(
correctionBoxes
.map((box) => String(box.label || '').trim())
.filter((label) => labels.people.includes(label))
)
}, [correctionBoxes, labels.people])
return correction.peoplePresent
}, [correction.peoplePresent])
const drawLabelForSection = useCallback((values: string[]) => {
const clean = String(boxLabel || '').trim()
return values.includes(clean) ? clean : ''
}, [boxLabel])
const imageSrc = useMemo(() => {
if (!sample?.frameUrl) return ''
@ -2445,18 +2348,11 @@ export default function TrainingTab(props: {
setCorrection(nextCorrection)
setHasManualCorrection(false)
const currentLabels = labelsRef.current
const personLabels = new Set(currentLabels.people)
const hasPersonBoxes = nextCorrection.boxes.some((box) =>
personLabels.has(box.label)
)
setExpandedCorrectionSections({
sexPosition:
Boolean(nextCorrection.sexPosition) &&
nextCorrection.sexPosition !== 'unknown',
people: hasPersonBoxes || nextCorrection.peopleCount > 0,
people: nextCorrection.peoplePresent.length > 0,
bodyParts: nextCorrection.bodyPartsPresent.length > 0,
objects: nextCorrection.objectsPresent.length > 0,
clothing: nextCorrection.clothingPresent.length > 0,
@ -2805,19 +2701,14 @@ export default function TrainingTab(props: {
setMessage(null)
try {
const maleCount = safeCount(correction.maleCount)
const femaleCount = safeCount(correction.femaleCount)
const unknownCount = safeCount(correction.unknownCount)
const normalizedBoxes = (correction.boxes ?? [])
.map(normalizeBox)
.filter((box) => box.label && box.w > 0 && box.h > 0)
const correctionPayload: CorrectionState = {
...correction,
maleCount,
femaleCount,
unknownCount,
peopleCount: maleCount + femaleCount + unknownCount,
boxes: (correction.boxes ?? [])
.map(normalizeBox)
.filter((box) => box.label && box.w > 0 && box.h > 0),
peoplePresent: peopleLabelsFromBoxes(normalizedBoxes, labelsRef.current),
boxes: normalizedBoxes,
}
const payload = {
@ -3230,10 +3121,7 @@ export default function TrainingTab(props: {
setCorrection((prev) => ({
...prev,
maleCount: 0,
femaleCount: 0,
unknownCount: 0,
peopleCount: 0,
peoplePresent: [],
bodyPartsPresent: [],
objectsPresent: [],
clothingPresent: [],
@ -4647,7 +4535,7 @@ export default function TrainingTab(props: {
toggleMobileCorrectionSection('people', expanded)
}
onToggle={() => {}}
drawLabel={boxLabel}
drawLabel={drawLabelForSection(labels.people)}
onDrawLabelChange={setBoxLabel}
disabled={uiLocked}
singleDrawMode
@ -4680,7 +4568,7 @@ export default function TrainingTab(props: {
}
})
}
drawLabel={boxLabel}
drawLabel={drawLabelForSection(labels.bodyParts)}
onDrawLabelChange={setBoxLabel}
disabled={uiLocked}
/>
@ -4711,7 +4599,7 @@ export default function TrainingTab(props: {
}
})
}
drawLabel={boxLabel}
drawLabel={drawLabelForSection(labels.objects)}
onDrawLabelChange={setBoxLabel}
disabled={uiLocked}
/>
@ -4742,7 +4630,7 @@ export default function TrainingTab(props: {
}
})
}
drawLabel={boxLabel}
drawLabel={drawLabelForSection(labels.clothing)}
onDrawLabelChange={setBoxLabel}
disabled={uiLocked}
/>
@ -4838,7 +4726,7 @@ export default function TrainingTab(props: {
}))
}
onToggle={() => {}}
drawLabel={boxLabel}
drawLabel={drawLabelForSection(labels.people)}
onDrawLabelChange={setBoxLabel}
disabled={uiLocked}
singleDrawMode
@ -4868,7 +4756,7 @@ export default function TrainingTab(props: {
}
})
}
drawLabel={boxLabel}
drawLabel={drawLabelForSection(labels.bodyParts)}
onDrawLabelChange={setBoxLabel}
disabled={uiLocked}
/>
@ -4896,7 +4784,7 @@ export default function TrainingTab(props: {
}
})
}
drawLabel={boxLabel}
drawLabel={drawLabelForSection(labels.objects)}
onDrawLabelChange={setBoxLabel}
disabled={uiLocked}
/>
@ -4924,7 +4812,7 @@ export default function TrainingTab(props: {
}
})
}
drawLabel={boxLabel}
drawLabel={drawLabelForSection(labels.clothing)}
onDrawLabelChange={setBoxLabel}
disabled={uiLocked}
/>