diff --git a/backend/ai_server.py b/backend/ai_server.py
index df06f7c..1487a4e 100644
--- a/backend/ai_server.py
+++ b/backend/ai_server.py
@@ -248,7 +248,10 @@ _POSE_RELIABLE_MIN_QUALITY = float(os.environ.get("YOLO_POSE_RELIABLE_MIN_QUALIT
_POSITION_CONTEXT_MIN_SCORE = float(os.environ.get("YOLO_POSITION_CONTEXT_MIN_SCORE", "0.22"))
_POSITION_CONTEXT_MAX_SCORE = float(os.environ.get("YOLO_POSITION_CONTEXT_MAX_SCORE", "0.44"))
_POSITION_CONTEXT_BOOST_WEIGHT = float(os.environ.get("YOLO_POSITION_CONTEXT_BOOST_WEIGHT", "0.60"))
-_POSITION_CONTEXT_OVERRIDE_MARGIN = float(os.environ.get("YOLO_POSITION_CONTEXT_OVERRIDE_MARGIN", "0.16"))
+_POSE_CONFIRMING_CONTEXT_MIN_SCORE = float(os.environ.get("YOLO_POSE_CONFIRMING_CONTEXT_MIN_SCORE", "0.14"))
+_POSE_UNCONFIRMED_MAX_SCORE = float(os.environ.get("YOLO_POSE_UNCONFIRMED_MAX_SCORE", "0.38"))
+_POSE_STRONG_UNCONFIRMED_MIN_SCORE = float(os.environ.get("YOLO_POSE_STRONG_UNCONFIRMED_MIN_SCORE", "0.70"))
+_POSE_STRONG_UNCONFIRMED_MAX_SCORE = float(os.environ.get("YOLO_POSE_STRONG_UNCONFIRMED_MAX_SCORE", "0.46"))
_BATCH = int(os.environ.get("YOLO_BATCH", "16"))
_IMGSZ = int(os.environ.get("YOLO_IMGSZ", "640"))
_HALF = os.environ.get("YOLO_HALF", "0").lower() in {"1", "true", "yes", "on"}
@@ -919,6 +922,24 @@ def box_horizontal_overlap_ratio(a: dict, b: dict) -> float:
return clamp01((right - left) / min_width)
+def box_vertical_overlap_ratio(a: dict, b: dict) -> float:
+ a = normalized_box(a)
+ b = normalized_box(b)
+ if not a or not b:
+ return 0.0
+
+ top = max(float(a["y"]), float(b["y"]))
+ bottom = min(float(a["y"]) + float(a["h"]), float(b["y"]) + float(b["h"]))
+ if bottom <= top:
+ return 0.0
+
+ min_height = min(float(a["h"]), float(b["h"]))
+ if min_height <= 0:
+ return 0.0
+
+ return clamp01((bottom - top) / min_height)
+
+
def is_person_like_label(label: str) -> bool:
clean = str(label or "").strip().lower()
return clean == "person" or clean.startswith("person_")
@@ -1170,6 +1191,49 @@ def point_distance(a: tuple[float, float] | None, b: tuple[float, float] | None)
return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2)
+def projected_point_distance(
+ a: tuple[float, float] | None,
+ b: tuple[float, float] | None,
+ axis_x: float,
+ axis_y: float,
+) -> float:
+ if not a or not b:
+ return 0.0
+
+ return abs((a[0] - b[0]) * axis_x + (a[1] - b[1]) * axis_y)
+
+
+def pose_extents_along_axis(
+ person: dict,
+ origin: tuple[float, float],
+ axis_x: float,
+ axis_y: float,
+ perp_x: float,
+ perp_y: float,
+ min_conf: float = 0.20,
+) -> tuple[float, float] | None:
+ long_values: list[float] = []
+ cross_values: list[float] = []
+
+ for point in person.get("keypoints", []) or []:
+ conf = float(point.get("conf") or 0.0)
+ x = float(point.get("x") or 0.0)
+ y = float(point.get("y") or 0.0)
+
+ if conf < min_conf or not is_finite01(x) or not is_finite01(y):
+ continue
+
+ dx = x - origin[0]
+ dy = y - origin[1]
+ long_values.append(dx * axis_x + dy * axis_y)
+ cross_values.append(dx * perp_x + dy * perp_y)
+
+ if len(long_values) < 3:
+ return None
+
+ return abs(max(long_values) - min(long_values)), abs(max(cross_values) - min(cross_values))
+
+
def pose_person_geometry(person: dict) -> dict:
box = normalized_box(person.get("box"))
box_center_point = box_center(box) if box else None
@@ -1189,30 +1253,74 @@ def pose_person_geometry(person: dict) -> dict:
torso_dx = 0.0
torso_dy = 0.0
torso_len = 0.0
+ torso_angle = 0.0
+ has_torso_axis = False
+ axis_x = 0.0
+ axis_y = 1.0
+ perp_x = -1.0
+ perp_y = 0.0
if hip and shoulder:
- torso_dx = abs(hip[0] - shoulder[0])
- torso_dy = abs(hip[1] - shoulder[1])
- torso_len = math.sqrt(torso_dx * torso_dx + torso_dy * torso_dy)
+ raw_dx = hip[0] - shoulder[0]
+ raw_dy = hip[1] - shoulder[1]
+ torso_dx = abs(raw_dx)
+ torso_dy = abs(raw_dy)
+ torso_len = math.sqrt(raw_dx * raw_dx + raw_dy * raw_dy)
+ if torso_len >= 0.07:
+ has_torso_axis = True
+ axis_x = raw_dx / torso_len
+ axis_y = raw_dy / torso_len
+ perp_x = -axis_y
+ perp_y = axis_x
+ torso_angle = math.atan2(axis_y, axis_x)
hip_width = point_distance(left_hip, right_hip)
knee_width = point_distance(left_knee, right_knee)
knees_below_hips = bool(knee and hip and knee[1] > hip[1] + 0.045)
+ if has_torso_axis and knee and hip:
+ knee_projection = (knee[0] - hip[0]) * axis_x + (knee[1] - hip[1]) * axis_y
+ knees_below_hips = knee_projection > 0.045
+
+ if has_torso_axis and left_knee and right_knee:
+ knee_width = projected_point_distance(left_knee, right_knee, perp_x, perp_y)
+ if left_hip and right_hip:
+ hip_width = projected_point_distance(left_hip, right_hip, perp_x, perp_y)
+
knees_wide = bool(
knee_width > 0
and knee_width >= max(0.11, hip_width * 1.12)
)
straddling = knees_below_hips and knees_wide
+ body_long = torso_len
+ body_cross = max(hip_width, knee_width)
+
+ if has_torso_axis:
+ pose_extents = pose_extents_along_axis(person, center, axis_x, axis_y, perp_x, perp_y)
+ if pose_extents:
+ body_long = max(body_long, pose_extents[0])
+ body_cross = max(body_cross, pose_extents[1])
+
+ if box:
+ box_long = abs(axis_x) * float(box["w"]) + abs(axis_y) * float(box["h"])
+ box_cross = abs(perp_x) * float(box["w"]) + abs(perp_y) * float(box["h"])
+ body_long = max(body_long, box_long)
+ body_cross = max(body_cross, box_cross)
+ elif box:
+ body_long = max(float(box["w"]), float(box["h"]))
+ body_cross = min(float(box["w"]), float(box["h"]))
+
+ elongated = body_long >= max(0.18, body_cross * 1.18)
+
torso_horizontal = torso_len >= 0.07 and torso_dx >= torso_dy * 1.15
torso_vertical = torso_len >= 0.07 and torso_dy >= torso_dx * 1.15
box_horizontal = bool(box and float(box["w"]) >= float(box["h"]) * 1.05)
box_vertical = bool(box and float(box["h"]) >= float(box["w"]) * 1.25)
- lying = torso_horizontal or box_horizontal
+ lying = torso_horizontal or box_horizontal or (has_torso_axis and elongated and not box_vertical)
upright = torso_vertical or box_vertical
- all_fours = torso_horizontal and knees_below_hips
+ all_fours = knees_below_hips and not straddling and (torso_horizontal or (has_torso_axis and elongated))
bent_or_kneeling = all_fours or (knees_below_hips and not straddling)
return {
@@ -1222,6 +1330,15 @@ def pose_person_geometry(person: dict) -> dict:
"hip": hip,
"shoulder": shoulder,
"knee": knee,
+ "torso_angle": torso_angle,
+ "has_torso_axis": has_torso_axis,
+ "axis_x": axis_x,
+ "axis_y": axis_y,
+ "perp_x": perp_x,
+ "perp_y": perp_y,
+ "body_long": body_long,
+ "body_cross": body_cross,
+ "elongated": elongated,
"lying": lying,
"upright": upright,
"straddling": straddling,
@@ -1242,6 +1359,14 @@ def combine_position_score(scores: dict[str, float], label: str, score: float) -
scores[label] = clamp01(1 - (1 - current) * (1 - score))
+def pose_axis_alignment(a: dict, b: dict) -> tuple[float, bool]:
+ if not a.get("has_torso_axis") or not b.get("has_torso_axis"):
+ return 0.0, False
+
+ # Körperachsen sind richtungslos: 0° und 180° gelten beide als parallel.
+ return abs(math.cos(float(a.get("torso_angle") or 0.0) - float(b.get("torso_angle") or 0.0))), True
+
+
def append_prediction_source(prediction: dict, source: str) -> None:
source = str(source or "").strip()
if not source:
@@ -1272,9 +1397,6 @@ def fuse_hybrid_position_scores(
best_score = 0.0
best_has_pose = False
best_has_context = False
- best_pose_position = ""
- best_pose_score = 0.0
- best_pose_has_context = False
for label in labels:
pose_score = clamp01(pose_scores.get(label, 0.0))
@@ -1284,10 +1406,15 @@ def fuse_hybrid_position_scores(
score = 0.0
if has_pose:
- score = pose_score
- if has_context:
+ if has_context and context_score >= _POSE_CONFIRMING_CONTEXT_MIN_SCORE:
+ score = pose_score
boost = clamp01(context_score * _POSITION_CONTEXT_BOOST_WEIGHT)
score = clamp01(1 - (1 - score) * (1 - boost))
+ else:
+ max_unconfirmed_score = _POSE_UNCONFIRMED_MAX_SCORE
+ if pose_score >= _POSE_STRONG_UNCONFIRMED_MIN_SCORE:
+ max_unconfirmed_score = _POSE_STRONG_UNCONFIRMED_MAX_SCORE
+ score = min(max_unconfirmed_score, pose_score)
elif context_score >= _POSITION_CONTEXT_MIN_SCORE:
score = min(_POSITION_CONTEXT_MAX_SCORE, context_score)
@@ -1297,21 +1424,6 @@ def fuse_hybrid_position_scores(
best_has_pose = has_pose
best_has_context = has_context
- if has_pose and score > best_pose_score:
- best_pose_position = label
- best_pose_score = score
- best_pose_has_context = has_context
-
- if (
- best_pose_position
- and not best_has_pose
- and best_score <= best_pose_score + _POSITION_CONTEXT_OVERRIDE_MARGIN
- ):
- best_position = best_pose_position
- best_score = best_pose_score
- best_has_pose = True
- best_has_context = best_pose_has_context
-
return best_position, clamp01(best_score), best_has_pose, best_has_context
@@ -1338,6 +1450,7 @@ def add_pose_pair_geometry_scores(scores: dict[str, float], persons: list[dict])
gap = box_gap(left_box, right_box)
overlap = box_overlap_ratio(left_box, right_box)
horizontal_overlap = box_horizontal_overlap_ratio(left_box, right_box)
+ vertical_overlap = box_vertical_overlap_ratio(left_box, right_box)
close = gap <= 0.12 or overlap >= 0.08
if not close:
@@ -1349,34 +1462,79 @@ def add_pose_pair_geometry_scores(scores: dict[str, float], persons: list[dict])
bottom = right if top is left else left
top_above = top["center"][1] <= bottom["center"][1] - 0.055
- strong_stack = horizontal_overlap >= 0.35 and (top_above or overlap >= 0.22)
- top_has_rider_shape = (
- top["straddling"]
- or top["knees_wide"]
- or (top["upright"] and top["knees_below_hips"])
- )
+ horizontal_stack = horizontal_overlap >= 0.35 and (top_above or overlap >= 0.22)
+ lateral_stack = vertical_overlap >= 0.35 and overlap >= 0.12
+ strong_stack = horizontal_stack or lateral_stack
- if strong_stack and top_has_rider_shape:
+ axis_alignment, has_axis_alignment = pose_axis_alignment(left, right)
+ axes_parallel = has_axis_alignment and axis_alignment >= 0.74
+ axes_crossed = has_axis_alignment and axis_alignment <= 0.56
+
+ def has_strong_rider_shape(geometry: dict) -> bool:
+ return bool(
+ geometry["straddling"]
+ or (
+ geometry["knees_wide"]
+ and geometry["knees_below_hips"]
+ and (geometry["upright"] or axes_crossed)
+ )
+ )
+
+ def has_weak_rider_shape(geometry: dict) -> bool:
+ return bool(geometry["knees_wide"] and geometry["knees_below_hips"])
+
+ left_has_strong_rider_shape = has_strong_rider_shape(left)
+ right_has_strong_rider_shape = has_strong_rider_shape(right)
+ top_has_strong_rider_shape = has_strong_rider_shape(top)
+ top_has_weak_rider_shape = has_weak_rider_shape(top)
+
+ rider = top
+ base = bottom
+ rider_has_strong_shape = top_has_strong_rider_shape
+ rider_has_weak_shape = top_has_weak_rider_shape
+ if left_has_strong_rider_shape != right_has_strong_rider_shape:
+ if left_has_strong_rider_shape:
+ rider = left
+ base = right
+ rider_has_strong_shape = True
+ rider_has_weak_shape = has_weak_rider_shape(left)
+ else:
+ rider = right
+ base = left
+ rider_has_strong_shape = True
+ rider_has_weak_shape = has_weak_rider_shape(right)
+
+ if strong_stack and rider_has_strong_shape and not axes_parallel:
add("cowgirl", 0.20)
add("reverse_cowgirl", 0.17)
- if bottom["lying"]:
+ if base["lying"]:
add("cowgirl", 0.12)
add("reverse_cowgirl", 0.10)
- if top["straddling"]:
+ if rider["straddling"]:
add("cowgirl", 0.08)
add("reverse_cowgirl", 0.06)
+ elif strong_stack and rider_has_weak_shape and not has_axis_alignment:
+ # Ohne verwertbare Achsen bleibt Cowgirl nur ein schwaches Signal.
+ # Sobald die Achsen parallel sind, sieht die Szene eher nach
+ # Missionary/Überlagerung aus und soll nicht in Cowgirl kippen.
+ add("cowgirl", 0.08)
+ add("reverse_cowgirl", 0.06)
- if strong_stack and bottom["lying"]:
- add("missionary", 0.14)
- if not top["straddling"]:
+ if strong_stack and (bottom["lying"] or (axes_parallel and top_above)):
+ if not top_has_strong_rider_shape or axes_parallel:
+ add("missionary", 0.14)
+ if axes_parallel:
+ add("missionary", 0.08)
+ if not top["straddling"] and not top_has_strong_rider_shape:
add("missionary", 0.08)
both_lying = left["lying"] and right["lying"]
same_level = abs(left_center[1] - right_center[1]) <= 0.15
side_by_side = abs(left_center[0] - right_center[0]) >= 0.10
- if both_lying and (same_level or side_by_side):
+ parallel_side_by_side = axes_parallel and left["elongated"] and right["elongated"] and close and not strong_stack
+ if (both_lying and (same_level or side_by_side)) or parallel_side_by_side:
add("spooning", 0.18)
if overlap >= 0.10:
add("prone_bone", 0.07)
diff --git a/backend/analyze.go b/backend/analyze.go
index 856e1c9..c661b74 100644
--- a/backend/analyze.go
+++ b/backend/analyze.go
@@ -95,10 +95,15 @@ const (
analyzePositionClipWindowSeconds = 3.0
analyzePositionClipMinScore = 0.22
analyzePositionClipMinFrames = 2
+ analyzePositionConflictMargin = 0.08
+ analyzePositionMinStableSeconds = 8.0
+ analyzePositionShortMaxScore = 0.62
)
type analyzePositionEvidence struct {
Time float64
+ Start float64
+ End float64
Label string
Score float64
Source string
@@ -108,6 +113,18 @@ type analyzePositionEvidence struct {
HasClip bool
}
+func isAnalyzeContextOnlyPositionLabel(label string) bool {
+ return false
+}
+
+func isAnalyzeTimelinePositionLabel(label string) bool {
+ label = strings.ToLower(strings.TrimSpace(label))
+ label = strings.TrimPrefix(label, "position:")
+ return !isNoSexPositionLabel(label) &&
+ isKnownPositionLabel(label) &&
+ !isAnalyzeContextOnlyPositionLabel(label)
+}
+
func analyzeVideoFrameFilter(intervalSeconds int) string {
if intervalSeconds <= 0 {
intervalSeconds = 1
@@ -251,7 +268,7 @@ func trainingPredictionToHighlightResults(pred TrainingPrediction) []NsfwFrameRe
best := map[string]float64{}
sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition))
- if !isNoSexPositionLabel(sexPosition) && isKnownPositionLabel(sexPosition) {
+ if isAnalyzeTimelinePositionLabel(sexPosition) {
addHighlightResult(best, "position:"+sexPosition, pred.SexPositionScore)
}
@@ -280,7 +297,7 @@ func trainingPredictionToHighlightResults(pred TrainingPrediction) []NsfwFrameRe
}
// Kombis nur erzeugen, wenn wirklich Position + Zusatz vorhanden ist.
- if !isNoSexPositionLabel(sexPosition) && isKnownPositionLabel(sexPosition) {
+ if isAnalyzeTimelinePositionLabel(sexPosition) {
positionScore := pred.SexPositionScore
if positionScore <= 0 {
positionScore = 1
@@ -975,7 +992,7 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute)
defer cancel()
- hits, analyzeStartedAtMs, analyzeTotalFrames, err := analyzeVideoFromFrames(ctx, outPath)
+ hits, analyzeStartedAtMs, _, err := analyzeVideoFromFrames(ctx, outPath)
if err != nil {
logAnalyzeError("analyze-frames", file, outPath, err)
@@ -989,12 +1006,10 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) {
return
}
- // Wichtig: publishAnalysisFinished erst NACH writeVideoAIForFile feuern,
- // damit refreshDoneMetaForFile im Frontend das Rating bereits in der Datei vorfindet.
- defer publishAnalysisFinished(analyzeStartedAtMs, analyzeTotalFrames, file, "Analyse abgeschlossen")
-
appLogf("🧪 [analyze] hits file=%q count=%d", file, len(hits))
+ publishAnalyzePersistProgress(analyzeStartedAtMs, file, 0.1, "")
+
durationSec, derr := durationSecondsForAnalyze(ctx, outPath)
if derr != nil {
logAnalyzeError("duration-after-analyze", file, outPath, derr)
@@ -1002,6 +1017,7 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) {
if durationSec <= 0 {
err := appErrorf("videolänge konnte nach analyse nicht bestimmt werden")
logAnalyzeError("duration-invalid", file, outPath, err)
+ publishAnalysisError(analyzeStartedAtMs, file, "Analyse fehlgeschlagen", err)
respondJSON(w, analyzeVideoResp{
OK: false,
@@ -1019,6 +1035,8 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) {
segments = prepareAIRatingSegments(segments)
appLogf("🧪 [analyze] rating segments file=%q count=%d", file, len(segments))
+ publishAnalyzePersistProgress(analyzeStartedAtMs, file, 0.45, "")
+
rating := computeHighlightRatingForVideo(segments, durationSec, outPath)
ai := &aiAnalysisMeta{
@@ -1033,6 +1051,7 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) {
if err := writeVideoAIForFile(ctx, outPath, "", ai); err != nil {
logAnalyzeError("write-meta-ai", file, outPath, err)
+ publishAnalysisError(analyzeStartedAtMs, file, "Speichern fehlgeschlagen", err)
respondJSON(w, analyzeVideoResp{
OK: false,
@@ -1047,6 +1066,8 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) {
}
autoDeleteLowRatedDownloadAfterAnalysis(ctx, outPath, rating)
+ publishAnalyzePersistProgress(analyzeStartedAtMs, file, 1, "")
+ publishAnalysisFinished(analyzeStartedAtMs, analyzeProgressTotal, file, "Analyse abgeschlossen")
appLogf(
"✅ [analyze] done file=%q hits=%d segments=%d rating=%.1f stars=%d",
@@ -1125,7 +1146,7 @@ func normalizeHighlightSignalLabel(label string) string {
case strings.HasPrefix(label, "position:"):
raw := strings.TrimPrefix(label, "position:")
- if isNoSexPositionLabel(raw) || !isKnownPositionLabel(raw) {
+ if !isAnalyzeTimelinePositionLabel(raw) {
return ""
}
return "position:" + raw
@@ -1135,7 +1156,7 @@ func normalizeHighlightSignalLabel(label string) string {
return ""
}
- if isKnownPositionLabel(label) {
+ if isAnalyzeTimelinePositionLabel(label) {
return "position:" + label
}
@@ -1352,7 +1373,7 @@ func buildCombinedHighlightHitFromPrediction(pred TrainingPrediction, t float64)
sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition))
- if !isNoSexPositionLabel(sexPosition) && isKnownPositionLabel(sexPosition) {
+ if isAnalyzeTimelinePositionLabel(sexPosition) {
positionScore := pred.SexPositionScore
if positionScore <= 0 {
positionScore = 0.35
@@ -1550,7 +1571,7 @@ func buildHighlightHitsFromPrediction(pred TrainingPrediction, t float64) []anal
best := map[string]highlightSignal{}
sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition))
- if !isNoSexPositionLabel(sexPosition) && isKnownPositionLabel(sexPosition) {
+ if isAnalyzeTimelinePositionLabel(sexPosition) {
addHighlightSignal(best, "position:"+sexPosition, pred.SexPositionScore)
}
@@ -1631,6 +1652,61 @@ func appendHighlightHitsFromPrediction(
return append(hits, next...)
}
+func analyzeHitWithoutRawPosition(hit analyzeHit) (analyzeHit, bool) {
+ label := strings.ToLower(strings.TrimSpace(hit.Label))
+ if label == "" {
+ return analyzeHit{}, false
+ }
+
+ if strings.HasPrefix(label, "position:") {
+ return analyzeHit{}, false
+ }
+
+ if !strings.HasPrefix(label, "combo:") {
+ hit.Label = label
+ return hit, true
+ }
+
+ parts := []string{}
+ for _, part := range strings.Split(strings.TrimPrefix(label, "combo:"), "+") {
+ part = strings.ToLower(strings.TrimSpace(part))
+ if part == "" || strings.HasPrefix(part, "position:") {
+ continue
+ }
+ parts = append(parts, part)
+ }
+
+ switch len(parts) {
+ case 0:
+ return analyzeHit{}, false
+ case 1:
+ hit.Label = parts[0]
+ default:
+ hit.Label = "combo:" + strings.Join(parts, "+")
+ }
+
+ return hit, true
+}
+
+func appendVideoFrameHighlightHitsFromPrediction(
+ hits []analyzeHit,
+ pred TrainingPrediction,
+ t float64,
+) []analyzeHit {
+ next := appendHighlightHitsFromPrediction(nil, pred, t)
+ if len(next) == 0 {
+ return hits
+ }
+
+ for _, hit := range next {
+ if stripped, ok := analyzeHitWithoutRawPosition(hit); ok {
+ hits = append(hits, stripped)
+ }
+ }
+
+ return hits
+}
+
func analyzePositionEvidenceFromPrediction(
pred TrainingPrediction,
t float64,
@@ -1640,7 +1716,7 @@ func analyzePositionEvidenceFromPrediction(
}
label := strings.ToLower(strings.TrimSpace(pred.SexPosition))
- if isNoSexPositionLabel(label) || !isKnownPositionLabel(label) {
+ if !isAnalyzeTimelinePositionLabel(label) {
return analyzePositionEvidence{}, false
}
@@ -1665,6 +1741,8 @@ func analyzePositionEvidenceFromPrediction(
return analyzePositionEvidence{
Time: t,
+ Start: t,
+ End: t,
Label: label,
Score: score,
Source: source,
@@ -1677,16 +1755,16 @@ func analyzePositionEvidenceFromPrediction(
func analyzePositionEvidenceWeight(item analyzePositionEvidence) float64 {
weight := 1.0
- if item.HasClip && item.HasPose {
- weight = 1.28
+ if item.HasClip && (item.HasPose || item.HasContext) {
+ weight = 1.70
} else if item.HasClip {
- weight = 1.18
+ weight = 1.55
} else if item.HasPose && item.HasContext {
- weight = 1.15
+ weight = 1.05
} else if item.HasPose {
- weight = 1.0
+ weight = 0.76
} else if item.HasContext {
- weight = 0.72
+ weight = 0.82
}
if item.PersonCount >= 2 {
@@ -1696,6 +1774,336 @@ func analyzePositionEvidenceWeight(item analyzePositionEvidence) float64 {
return weight
}
+type analyzePositionAggregate struct {
+ Label string
+ WeightedSum float64
+ WeightSum float64
+ Count int
+ PoseCount int
+ ContextCount int
+ ClipCount int
+ Start float64
+ End float64
+ Marker float64
+ BestScore float64
+}
+
+type analyzePositionCandidate struct {
+ Agg *analyzePositionAggregate
+ Score float64
+}
+
+type analyzePositionLedgerEntry struct {
+ Start float64
+ End float64
+ Center float64
+ Candidates []analyzePositionCandidate
+ Winner analyzePositionCandidate
+ Conflict bool
+}
+
+func analyzePositionEvidenceSpan(item analyzePositionEvidence) (float64, float64) {
+ start := item.Start
+ end := item.End
+
+ if start == 0 && end == 0 {
+ start = item.Time
+ end = item.Time
+ }
+ if start < 0 {
+ start = item.Time
+ }
+ if end < 0 {
+ end = item.Time
+ }
+ if start > end {
+ start, end = end, start
+ }
+
+ return start, end
+}
+
+func analyzePositionAggregateScore(agg *analyzePositionAggregate, requiredFrames int) (float64, bool) {
+ if agg == nil || agg.WeightSum <= 0 {
+ return 0, false
+ }
+
+ minCount := requiredFrames
+ if agg.ClipCount > 0 {
+ // Ein VideoMAE-Clip steht bereits für ein Zeitfenster und muss nicht
+ // zusätzlich noch mehrere Einzel-Frames derselben Position haben.
+ minCount = 1
+ } else if agg.PoseCount > 0 && agg.ContextCount == 0 {
+ // Pose-only braucht etwas mehr zeitliche Stabilität.
+ if minCount < 3 {
+ minCount = 3
+ }
+ }
+
+ if agg.Count < minCount {
+ return 0, false
+ }
+
+ avg := clamp01(agg.WeightedSum / agg.WeightSum)
+ stability := clamp01(float64(agg.Count) / math.Max(float64(requiredFrames), 3))
+ sourceBonus := 0.0
+ if agg.ClipCount > 0 && (agg.PoseCount > 0 || agg.ContextCount > 0) {
+ sourceBonus = 0.07
+ } else if agg.ClipCount > 0 {
+ sourceBonus = 0.06
+ } else if agg.PoseCount > 0 && agg.ContextCount > 0 {
+ sourceBonus = 0.04
+ }
+
+ score := clamp01(avg*(0.86+0.14*stability) + sourceBonus)
+ if agg.ClipCount == 0 && agg.PoseCount == 0 {
+ score = math.Min(score, trainingPositionContextMaxScore)
+ }
+ if agg.ClipCount == 0 && agg.ContextCount == 0 {
+ score = math.Min(score, trainingPoseStrongUnconfirmedMaxScore)
+ }
+
+ if score < analyzePositionClipMinScore {
+ return 0, false
+ }
+
+ return score, true
+}
+
+func analyzePositionCandidateConflict(best, other analyzePositionCandidate) bool {
+ if best.Agg == nil || other.Agg == nil {
+ return false
+ }
+ if best.Agg.Label == other.Agg.Label {
+ return false
+ }
+
+ // VideoMAE darf bei gleicher Groessenordnung als zeitliches Hauptsignal
+ // gewinnen. Wenn aber zwei Clip-Positionen fast gleich stark sind, ist
+ // das echter Konflikt und wird lieber nicht hart segmentiert.
+ if best.Agg.ClipCount > 0 {
+ if other.Agg.ClipCount > 0 {
+ return other.Score >= best.Score-analyzePositionConflictMargin
+ }
+ return false
+ }
+
+ if other.Agg.ClipCount > 0 {
+ return true
+ }
+
+ return other.Score >= best.Score-analyzePositionConflictMargin
+}
+
+func analyzePositionLedgerCenters(evidence []analyzePositionEvidence) []float64 {
+ centers := make([]float64, 0, len(evidence)*3)
+
+ for _, item := range evidence {
+ start, end := analyzePositionEvidenceSpan(item)
+ center := item.Time
+ if center < start || center > end {
+ center = (start + end) / 2
+ }
+
+ centers = append(centers, center)
+
+ if item.HasClip && end > start {
+ centers = append(centers, start, end)
+ }
+ }
+
+ sort.Float64s(centers)
+
+ out := centers[:0]
+ for _, center := range centers {
+ if len(out) == 0 || math.Abs(center-out[len(out)-1]) > 0.20 {
+ out = append(out, center)
+ }
+ }
+
+ return out
+}
+
+func analyzePositionCandidatesForWindow(
+ evidence []analyzePositionEvidence,
+ windowStart float64,
+ windowEnd float64,
+ requiredFrames int,
+) []analyzePositionCandidate {
+ byLabel := map[string]*analyzePositionAggregate{}
+
+ for _, item := range evidence {
+ itemStart, itemEnd := analyzePositionEvidenceSpan(item)
+ if itemEnd < windowStart-0.001 || itemStart > windowEnd+0.001 {
+ continue
+ }
+
+ label := strings.ToLower(strings.TrimSpace(item.Label))
+ if !isAnalyzeTimelinePositionLabel(label) {
+ continue
+ }
+
+ score := clamp01(item.Score)
+ if score <= 0 {
+ continue
+ }
+
+ agg := byLabel[label]
+ if agg == nil {
+ agg = &analyzePositionAggregate{
+ Label: label,
+ Start: itemStart,
+ End: itemEnd,
+ Marker: item.Time,
+ }
+ byLabel[label] = agg
+ }
+
+ weight := analyzePositionEvidenceWeight(item)
+ agg.WeightedSum += score * weight
+ agg.WeightSum += weight
+ agg.Count++
+ if item.HasPose {
+ agg.PoseCount++
+ }
+ if item.HasContext {
+ agg.ContextCount++
+ }
+ if item.HasClip {
+ agg.ClipCount++
+ }
+ if itemStart < agg.Start {
+ agg.Start = itemStart
+ }
+ if itemEnd > agg.End {
+ agg.End = itemEnd
+ }
+ if score > agg.BestScore {
+ agg.BestScore = score
+ agg.Marker = item.Time
+ }
+ }
+
+ candidates := make([]analyzePositionCandidate, 0, len(byLabel))
+ for _, agg := range byLabel {
+ score, ok := analyzePositionAggregateScore(agg, requiredFrames)
+ if ok {
+ candidates = append(candidates, analyzePositionCandidate{
+ Agg: agg,
+ Score: score,
+ })
+ }
+ }
+
+ sort.SliceStable(candidates, func(i, j int) bool {
+ if candidates[i].Score != candidates[j].Score {
+ return candidates[i].Score > candidates[j].Score
+ }
+ if candidates[i].Agg.ClipCount != candidates[j].Agg.ClipCount {
+ return candidates[i].Agg.ClipCount > candidates[j].Agg.ClipCount
+ }
+ return candidates[i].Agg.Label < candidates[j].Agg.Label
+ })
+
+ return candidates
+}
+
+func stabilizeAnalyzePositionHits(in []analyzeHit, duration float64) []analyzeHit {
+ if len(in) == 0 {
+ return []analyzeHit{}
+ }
+
+ out := make([]analyzeHit, 0, len(in))
+ for _, hit := range in {
+ label := strings.ToLower(strings.TrimSpace(hit.Label))
+ position := strings.TrimPrefix(label, "position:")
+ if !isAnalyzeTimelinePositionLabel(position) {
+ continue
+ }
+
+ start := hit.Start
+ end := hit.End
+ if end < start {
+ start, end = end, start
+ }
+ if duration > 0 {
+ start = math.Max(0, math.Min(duration, start))
+ end = math.Max(0, math.Min(duration, end))
+ }
+
+ span := end - start
+ if duration >= 60 &&
+ span < analyzePositionMinStableSeconds &&
+ hit.Score < analyzePositionShortMaxScore {
+ continue
+ }
+
+ hit.Label = "position:" + position
+ hit.Start = start
+ hit.End = end
+ out = append(out, hit)
+ }
+
+ return mergeAnalyzeHits(out)
+}
+
+func buildAnalyzePositionLedger(
+ evidence []analyzePositionEvidence,
+ duration float64,
+ requiredFrames int,
+) []analyzePositionLedgerEntry {
+ if len(evidence) == 0 {
+ return []analyzePositionLedgerEntry{}
+ }
+
+ halfWindow := analyzePositionClipWindowSeconds / 2
+ if halfWindow <= 0 {
+ halfWindow = math.Max(1, float64(analyzeVideoFrameIntervalSeconds))
+ }
+
+ centers := analyzePositionLedgerCenters(evidence)
+ ledger := make([]analyzePositionLedgerEntry, 0, len(centers))
+
+ for _, center := range centers {
+ windowStart := math.Max(0, center-halfWindow)
+ windowEnd := center + halfWindow
+ if duration > 0 {
+ windowEnd = math.Min(duration, windowEnd)
+ }
+ if windowEnd <= windowStart {
+ windowEnd = windowStart + math.Max(1, float64(analyzeVideoFrameIntervalSeconds))
+ if duration > 0 {
+ windowEnd = math.Min(duration, windowEnd)
+ }
+ }
+
+ entry := analyzePositionLedgerEntry{
+ Start: windowStart,
+ End: windowEnd,
+ Center: center,
+ }
+ entry.Candidates = analyzePositionCandidatesForWindow(
+ evidence,
+ windowStart,
+ windowEnd,
+ requiredFrames,
+ )
+
+ if len(entry.Candidates) > 0 {
+ if len(entry.Candidates) > 1 &&
+ analyzePositionCandidateConflict(entry.Candidates[0], entry.Candidates[1]) {
+ entry.Conflict = true
+ } else {
+ entry.Winner = entry.Candidates[0]
+ }
+ }
+
+ ledger = append(ledger, entry)
+ }
+
+ return ledger
+}
+
func buildClipPositionHitsFromEvidence(
evidence []analyzePositionEvidence,
duration float64,
@@ -1719,162 +2127,74 @@ func buildClipPositionHitsFromEvidence(
requiredFrames = 1
}
- halfWindow := analyzePositionClipWindowSeconds / 2
- if halfWindow <= 0 {
- halfWindow = math.Max(1, float64(analyzeVideoFrameIntervalSeconds))
+ ledger := buildAnalyzePositionLedger(evidence, duration, requiredFrames)
+ hits := make([]analyzeHit, 0, len(ledger))
+
+ var cur analyzeHit
+ hasCur := false
+ flush := func() {
+ if !hasCur {
+ return
+ }
+ if cur.End <= cur.Start {
+ cur.End = cur.Start + math.Max(1, float64(analyzeVideoFrameIntervalSeconds))
+ if duration > 0 {
+ cur.End = math.Min(duration, cur.End)
+ }
+ }
+ cur.Time = (cur.Start + cur.End) / 2
+ hits = append(hits, cur)
+ hasCur = false
}
- type aggregate struct {
- Label string
- WeightedSum float64
- WeightSum float64
- Count int
- PoseCount int
- ContextCount int
- ClipCount int
- Start float64
- End float64
- Marker float64
- BestScore float64
- }
-
- hits := make([]analyzeHit, 0, len(evidence))
-
- for _, center := range evidence {
- windowStart := center.Time - halfWindow
- windowEnd := center.Time + halfWindow
- if windowStart < 0 {
- windowStart = 0
- }
- if duration > 0 && windowEnd > duration {
- windowEnd = duration
- }
- if windowEnd <= windowStart {
- windowEnd = windowStart + math.Max(1, float64(analyzeVideoFrameIntervalSeconds))
- if duration > 0 && windowEnd > duration {
- windowEnd = duration
- }
- }
-
- byLabel := map[string]*aggregate{}
-
- for _, item := range evidence {
- if item.Time < windowStart-0.001 || item.Time > windowEnd+0.001 {
- continue
- }
-
- label := strings.ToLower(strings.TrimSpace(item.Label))
- if isNoSexPositionLabel(label) || !isKnownPositionLabel(label) {
- continue
- }
-
- score := clamp01(item.Score)
- if score <= 0 {
- continue
- }
-
- agg := byLabel[label]
- if agg == nil {
- agg = &aggregate{
- Label: label,
- Start: item.Time,
- End: item.Time,
- Marker: item.Time,
- }
- byLabel[label] = agg
- }
-
- weight := analyzePositionEvidenceWeight(item)
- agg.WeightedSum += score * weight
- agg.WeightSum += weight
- agg.Count++
- if item.HasPose {
- agg.PoseCount++
- }
- if item.HasContext {
- agg.ContextCount++
- }
- if item.HasClip {
- agg.ClipCount++
- }
- if item.Time < agg.Start {
- agg.Start = item.Time
- }
- if item.Time > agg.End {
- agg.End = item.Time
- }
- if score > agg.BestScore {
- agg.BestScore = score
- agg.Marker = item.Time
- }
- }
-
- var best *aggregate
- bestScore := 0.0
-
- for _, agg := range byLabel {
- if agg.WeightSum <= 0 || agg.Count < requiredFrames {
- continue
- }
-
- avg := clamp01(agg.WeightedSum / agg.WeightSum)
- stability := clamp01(float64(agg.Count) / math.Max(float64(requiredFrames), 3))
- sourceBonus := 0.0
- if agg.PoseCount > 0 && agg.ContextCount > 0 {
- sourceBonus = 0.04
- } else if agg.ClipCount > 0 && (agg.PoseCount > 0 || agg.ContextCount > 0) {
- sourceBonus = 0.04
- } else if agg.ClipCount > 0 {
- sourceBonus = 0.03
- } else if agg.PoseCount > 0 {
- sourceBonus = 0.02
- }
-
- score := clamp01(avg*(0.86+0.14*stability) + sourceBonus)
- if agg.PoseCount == 0 && agg.ClipCount == 0 {
- score = math.Min(score, trainingPositionContextMaxScore)
- }
-
- if score < analyzePositionClipMinScore {
- continue
- }
-
- if score > bestScore {
- best = agg
- bestScore = score
- }
- }
-
- if best == nil {
+ for _, entry := range ledger {
+ if entry.Conflict || entry.Winner.Agg == nil {
+ flush()
continue
}
- start := math.Max(0, best.Start-halfWindow)
- end := best.End + halfWindow
- if duration > 0 {
- end = math.Min(duration, end)
- }
- if end <= start {
- end = math.Min(duration, start+math.Max(1, float64(analyzeVideoFrameIntervalSeconds)))
+ label := "position:" + entry.Winner.Agg.Label
+ if hasCur &&
+ cur.Label == label &&
+ entry.Start <= cur.End+analyzeHitContinuationGapSeconds() {
+ if entry.End > cur.End {
+ cur.End = entry.End
+ }
+ if entry.Winner.Score > cur.Score {
+ cur.Score = entry.Winner.Score
+ }
+ continue
}
- hits = append(hits, analyzeHit{
- Time: best.Marker,
- Label: "position:" + best.Label,
- Score: bestScore,
- Start: start,
- End: end,
- })
+ flush()
+ cur = analyzeHit{
+ Time: entry.Center,
+ Label: label,
+ Score: entry.Winner.Score,
+ Start: entry.Start,
+ End: entry.End,
+ }
+ hasCur = true
}
+ flush()
- return mergeAnalyzeHits(hits)
+ return stabilizeAnalyzePositionHits(mergeAnalyzeHits(hits), duration)
}
func analyzeVideoFromFrames(ctx context.Context, outPath string) ([]analyzeHit, int64, int, error) {
return analyzeVideoFromFramesForGoal(ctx, outPath)
}
-const analyzeProgressTotal = 1000
+const (
+ analyzeProgressTotal = 1000
+ analyzeProgressExtractEnd = 450
+ analyzeProgressInferenceStart = analyzeProgressExtractEnd
+ analyzeProgressInferenceEnd = 850
+ analyzeProgressVideoMAEStart = analyzeProgressInferenceEnd
+ analyzeProgressVideoMAEEnd = 950
+ analyzeProgressPersistStart = analyzeProgressVideoMAEEnd
+ analyzeProgressPersistEnd = 990
+)
func logAnalyzeError(stage string, file string, outPath string, err error) {
if err == nil {
@@ -1901,20 +2221,13 @@ func publishAnalyzeExtractProgress(
progress float64,
message string,
) {
- progress = math.Max(0, math.Min(1, progress))
-
- current := int(math.Round(progress * 0.5 * analyzeProgressTotal))
-
- message = strings.TrimSpace(message)
- if message == "" || strings.EqualFold(message, "Analyse") || strings.Contains(message, "Frames werden extrahiert") {
- message = analyzeGlobalPercentMessageFromCurrent(current, analyzeProgressTotal)
- }
-
- publishAnalysisStep(
+ publishAnalyzePhaseProgress(
startedAtMs,
- current,
- analyzeProgressTotal,
file,
+ "Analyse",
+ 0,
+ analyzeProgressExtractEnd,
+ progress,
message,
)
}
@@ -1952,22 +2265,81 @@ func publishAnalyzeInferenceProgress(
ratio := float64(currentFrame) / float64(totalFrames)
ratio = math.Max(0, math.Min(1, ratio))
- current := int(math.Round((0.5 + ratio*0.5) * analyzeProgressTotal))
-
- message = strings.TrimSpace(message)
- if message == "" || strings.EqualFold(message, "Analyse") {
- message = analyzeGlobalPercentMessageFromCurrent(current, analyzeProgressTotal)
- }
-
- publishAnalysisStep(
+ publishAnalyzePhaseProgress(
startedAtMs,
- current,
- analyzeProgressTotal,
file,
+ "Analyse",
+ analyzeProgressInferenceStart,
+ analyzeProgressInferenceEnd,
+ ratio,
message,
)
}
+func publishAnalyzeVideoMAEProgress(startedAtMs int64, file string, currentClip int, totalClips int) {
+ if totalClips <= 0 {
+ totalClips = 1
+ }
+
+ ratio := float64(currentClip) / float64(totalClips)
+ publishAnalyzePhaseProgress(
+ startedAtMs,
+ file,
+ "Analyse",
+ analyzeProgressVideoMAEStart,
+ analyzeProgressVideoMAEEnd,
+ ratio,
+ "",
+ )
+}
+
+func publishAnalyzePersistProgress(startedAtMs int64, file string, progress float64, message string) {
+ publishAnalyzePhaseProgress(
+ startedAtMs,
+ file,
+ "Speichern",
+ analyzeProgressPersistStart,
+ analyzeProgressPersistEnd,
+ progress,
+ message,
+ )
+}
+
+func publishAnalyzePhaseProgress(
+ startedAtMs int64,
+ file string,
+ phase string,
+ rangeStart int,
+ rangeEnd int,
+ progress float64,
+ message string,
+) {
+ progress = math.Max(0, math.Min(1, progress))
+
+ if rangeEnd < rangeStart {
+ rangeEnd = rangeStart
+ }
+
+ current := rangeStart + int(math.Round(progress*float64(rangeEnd-rangeStart)))
+ if current < 0 {
+ current = 0
+ }
+ if current > analyzeProgressTotal {
+ current = analyzeProgressTotal
+ }
+
+ message = strings.TrimSpace(message)
+ if message == "" || strings.EqualFold(message, "Analyse") || strings.Contains(message, "Frames werden extrahiert") {
+ phase = strings.TrimSpace(phase)
+ if phase == "" {
+ phase = "Analyse"
+ }
+ message = fmt.Sprintf("%s %d%%", phase, analyzeGlobalPercentFromCurrent(current, analyzeProgressTotal))
+ }
+
+ publishAnalysisStep(startedAtMs, current, analyzeProgressTotal, file, message)
+}
+
func analyzeGlobalPercentFromCurrent(current int, total int) int {
if total <= 0 {
total = analyzeProgressTotal
@@ -2041,8 +2413,6 @@ func analyzeVideoFromFramesForGoal(
}
for p := *lastPercent + 1; p <= nextPercent; p++ {
- label := fmt.Sprintf("Analyse %d%%", p)
-
if extractPhase {
ratio := float64(p) / 50.0
if ratio < 0 {
@@ -2056,7 +2426,7 @@ func analyzeVideoFromFramesForGoal(
startedAtMs,
file,
ratio,
- label,
+ "",
)
} else {
inferenceCurrent := current
@@ -2072,7 +2442,7 @@ func analyzeVideoFromFramesForGoal(
file,
inferenceCurrent,
inferenceTotal,
- label,
+ "",
)
}
}
@@ -2174,7 +2544,7 @@ func analyzeVideoFromFramesForGoal(
file,
0,
total,
- "Analyse 50%",
+ "",
)
if ctx.Err() != nil {
@@ -2236,7 +2606,7 @@ func analyzeVideoFromFramesForGoal(
file,
0,
total,
- "Analyse 50%",
+ "",
)
break
@@ -2263,7 +2633,7 @@ func analyzeVideoFromFramesForGoal(
)
}
- highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, sample.Time)
+ highlightHits = appendVideoFrameHighlightHitsFromPrediction(highlightHits, pred, sample.Time)
if item, ok := analyzePositionEvidenceFromPrediction(pred, sample.Time); ok {
positionEvidence = append(positionEvidence, item)
}
@@ -2304,7 +2674,13 @@ func analyzeVideoFromFramesForGoal(
durationSec,
highlightHits,
positionEvidence,
+ func(current int, total int) {
+ publishAnalyzeVideoMAEProgress(startedAtMs, file, current, total)
+ },
)
+ if ctx.Err() != nil {
+ return failCancelled()
+ }
highlightHits = append(
highlightHits,
@@ -2328,7 +2704,7 @@ func analyzeVideoFromFramesForGoal(
return failCancelled()
}
- highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, sample.Time)
+ highlightHits = appendVideoFrameHighlightHitsFromPrediction(highlightHits, pred, sample.Time)
if item, ok := analyzePositionEvidenceFromPrediction(pred, sample.Time); ok {
positionEvidence = append(positionEvidence, item)
}
@@ -2369,7 +2745,13 @@ func analyzeVideoFromFramesForGoal(
durationSec,
highlightHits,
positionEvidence,
+ func(current int, total int) {
+ publishAnalyzeVideoMAEProgress(startedAtMs, file, current, total)
+ },
)
+ if ctx.Err() != nil {
+ return failCancelled()
+ }
highlightHits = append(
highlightHits,
@@ -2531,7 +2913,7 @@ func segmentPositionFromAnalyzeLabel(label string) string {
part = strings.TrimSpace(part)
part = strings.TrimPrefix(part, "position:")
- if isKnownPositionLabel(part) {
+ if isAnalyzeTimelinePositionLabel(part) {
return part
}
}
@@ -2561,7 +2943,7 @@ func segmentTagsFromAnalyzeLabel(label string) []string {
if strings.HasPrefix(part, "position:") {
pos := strings.TrimPrefix(part, "position:")
- if pos == "" || !isKnownPositionLabel(pos) {
+ if !isAnalyzeTimelinePositionLabel(pos) {
continue
}
@@ -2844,8 +3226,14 @@ func isAllowedAnalyzeSegmentLabel(label string) bool {
if isNoSexPositionLabel(raw) {
return false
}
+ if strings.HasPrefix(label, "position:") && !isAnalyzeTimelinePositionLabel(label) {
+ return false
+ }
+ if isAnalyzeContextOnlyPositionLabel(raw) {
+ return false
+ }
- return shouldAutoSelectAnalyzeHit(raw) || isKnownPositionLabel(raw)
+ return shouldAutoSelectAnalyzeHit(raw) || isAnalyzeTimelinePositionLabel(raw)
}
func buildLabelContinuitySegmentsFromAnalyzeHits(
diff --git a/backend/analyze_videomae.go b/backend/analyze_videomae.go
index 0716ec5..30df8c9 100644
--- a/backend/analyze_videomae.go
+++ b/backend/analyze_videomae.go
@@ -115,12 +115,16 @@ func buildAnalyzeVideoMAEClips(
func predictVideoMAEPositionClipsForAnalyze(
ctx context.Context,
clips []analyzeVideoMAEClipReqItem,
+ onProgress func(current int, total int),
) ([]analyzeVideoMAEClipPrediction, error) {
if len(clips) == 0 {
return []analyzeVideoMAEClipPrediction{}, nil
}
if !trainingRecognitionEnabled() {
+ if onProgress != nil {
+ onProgress(len(clips), len(clips))
+ }
return []analyzeVideoMAEClipPrediction{}, nil
}
@@ -192,10 +196,16 @@ func predictVideoMAEPositionClipsForAnalyze(
}
if !parsed.Available {
+ if onProgress != nil {
+ onProgress(len(clips), len(clips))
+ }
return out, nil
}
out = append(out, parsed.Predictions...)
+ if onProgress != nil {
+ onProgress(end, len(clips))
+ }
}
return out, nil
@@ -207,6 +217,7 @@ func applyVideoMAEPositionClipsForAnalyze(
duration float64,
highlightHits []analyzeHit,
positionEvidence []analyzePositionEvidence,
+ onProgress func(current int, total int),
) ([]analyzeHit, []analyzePositionEvidence) {
if !analyzeVideoMAEEnabled() {
return highlightHits, positionEvidence
@@ -217,9 +228,16 @@ func applyVideoMAEPositionClipsForAnalyze(
return highlightHits, positionEvidence
}
- predictions, err := predictVideoMAEPositionClipsForAnalyze(ctx, clips)
+ if onProgress != nil {
+ onProgress(0, len(clips))
+ }
+
+ predictions, err := predictVideoMAEPositionClipsForAnalyze(ctx, clips, onProgress)
if err != nil {
if ctx.Err() == nil {
+ if onProgress != nil {
+ onProgress(len(clips), len(clips))
+ }
appLogln("VideoMAE Clip-Analyse übersprungen:", err)
}
return highlightHits, positionEvidence
@@ -227,7 +245,7 @@ func applyVideoMAEPositionClipsForAnalyze(
for _, pred := range predictions {
label := strings.ToLower(strings.TrimSpace(pred.SexPosition))
- if isNoSexPositionLabel(label) || !isKnownPositionLabel(label) {
+ if !isAnalyzeTimelinePositionLabel(label) {
continue
}
@@ -250,16 +268,10 @@ func applyVideoMAEPositionClipsForAnalyze(
source = "videomae"
}
- highlightHits = append(highlightHits, analyzeHit{
- Time: pred.Time,
- Label: "position:" + label,
- Score: score,
- Start: start,
- End: end,
- })
-
positionEvidence = append(positionEvidence, analyzePositionEvidence{
Time: pred.Time,
+ Start: start,
+ End: end,
Label: label,
Score: score,
Source: source,
diff --git a/backend/assets_generate.go b/backend/assets_generate.go
index d49fa0b..aeec87d 100644
--- a/backend/assets_generate.go
+++ b/backend/assets_generate.go
@@ -172,15 +172,17 @@ func ensureAnalyzeAllGoalsForVideoCtxForce(
return false, skipAnalysisBecauseBadSpriteError(reason)
}
- hits, analyzeStartedAtMs, analyzeTotalFrames, err := analyzeVideoFromFrames(actx, videoPath)
+ hits, analyzeStartedAtMs, _, err := analyzeVideoFromFrames(actx, videoPath)
if err != nil {
return false, err
}
- defer publishAnalysisFinished(analyzeStartedAtMs, analyzeTotalFrames, filepath.Base(videoPath), "Analyse abgeschlossen")
+ analyzeFile := filepath.Base(videoPath)
+ publishAnalyzePersistProgress(analyzeStartedAtMs, analyzeFile, 0.1, "")
segments := buildAnalyzeSegmentsForGoal(hits, durationSec)
segments = prepareAIRatingSegments(segments)
+ publishAnalyzePersistProgress(analyzeStartedAtMs, analyzeFile, 0.45, "")
rating := computeHighlightRatingForVideo(segments, durationSec, videoPath)
@@ -195,17 +197,22 @@ func ensureAnalyzeAllGoalsForVideoCtxForce(
}
if err := writeVideoAIForFile(actx, videoPath, meta.sourceURL, ai); err != nil {
+ publishAnalysisError(analyzeStartedAtMs, analyzeFile, "Speichern fehlgeschlagen", err)
return false, err
}
analysisReady := hasAIAnalysisForAllOutputGoals(videoPath, requiredAnalyzeGoals())
if !analysisReady {
- return false, nil
+ err := appErrorf("analyse-meta konnte nicht verifiziert werden")
+ publishAnalysisError(analyzeStartedAtMs, analyzeFile, "Analyse fehlgeschlagen", err)
+ return false, err
}
// Direkt nach erfolgreicher Analyse löschen.
// Wichtig: hier rating übergeben, nicht nil.
autoDeleteLowRatedDownloadAfterAnalysis(actx, videoPath, rating)
+ publishAnalyzePersistProgress(analyzeStartedAtMs, analyzeFile, 1, "")
+ publishAnalysisFinished(analyzeStartedAtMs, analyzeProgressTotal, analyzeFile, "Analyse abgeschlossen")
return true, nil
}
@@ -1587,15 +1594,17 @@ func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string
return out, nil
}
- hits, analyzeStartedAtMs2, analyzeTotalFrames2, aerr := analyzeVideoFromFrames(ctx, videoPath)
+ hits, analyzeStartedAtMs2, _, aerr := analyzeVideoFromFrames(ctx, videoPath)
if aerr != nil {
return out, nil
}
- defer publishAnalysisFinished(analyzeStartedAtMs2, analyzeTotalFrames2, filepath.Base(videoPath), "Analyse abgeschlossen")
+ analyzeFile2 := filepath.Base(videoPath)
+ publishAnalyzePersistProgress(analyzeStartedAtMs2, analyzeFile2, 0.1, "")
segments := buildAnalyzeSegmentsForGoal(hits, durationSec)
segments = prepareAIRatingSegments(segments)
+ publishAnalyzePersistProgress(analyzeStartedAtMs2, analyzeFile2, 0.45, "")
rating := computeHighlightRatingForVideo(segments, durationSec, videoPath)
@@ -1609,9 +1618,21 @@ func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string
}
if werr := writeVideoAIForFile(ctx, videoPath, sourceURL, ai); werr != nil {
+ publishAnalysisError(analyzeStartedAtMs2, analyzeFile2, "Speichern fehlgeschlagen", werr)
return out, nil
}
out.AnalyzeReady = hasAIAnalysisForOutputGoal(videoPath, "highlights")
+ if out.AnalyzeReady {
+ publishAnalyzePersistProgress(analyzeStartedAtMs2, analyzeFile2, 1, "")
+ publishAnalysisFinished(analyzeStartedAtMs2, analyzeProgressTotal, analyzeFile2, "Analyse abgeschlossen")
+ } else {
+ publishAnalysisError(
+ analyzeStartedAtMs2,
+ analyzeFile2,
+ "Analyse fehlgeschlagen",
+ appErrorf("analyse-meta konnte nicht verifiziert werden"),
+ )
+ }
return out, nil
}
diff --git a/backend/dist/nsfwapp-linux-amd64 b/backend/dist/nsfwapp-linux-amd64
index 02fc904..e1a3a1b 100644
Binary files a/backend/dist/nsfwapp-linux-amd64 and b/backend/dist/nsfwapp-linux-amd64 differ
diff --git a/backend/dist/nsfwapp.exe b/backend/dist/nsfwapp.exe
index b748ca3..ab9b4f0 100644
Binary files a/backend/dist/nsfwapp.exe and b/backend/dist/nsfwapp.exe differ
diff --git a/backend/dist/nsfwapp_amd64.deb b/backend/dist/nsfwapp_amd64.deb
index e197049..907720b 100644
Binary files a/backend/dist/nsfwapp_amd64.deb and b/backend/dist/nsfwapp_amd64.deb differ
diff --git a/backend/myfreecams_autostart.go b/backend/myfreecams_autostart.go
index 604da0c..902027a 100644
--- a/backend/myfreecams_autostart.go
+++ b/backend/myfreecams_autostart.go
@@ -91,6 +91,14 @@ func resolveMyFreeCamsURL(m WatchedModelLite) string {
return fmt.Sprintf("https://www.myfreecams.com/#%s", user)
}
+func myFreeCamsAutostartCanRun() bool {
+ s := getSettings()
+ return s.UseProviderAPIs &&
+ s.UseMyFreeCamsAPI &&
+ s.AutoStartAddedDownloads &&
+ !isAutostartPaused()
+}
+
// Startet watched MyFreeCams Models (ohne API) "best-effort".
// Wenn nach kurzer Zeit keine Output-Datei existiert (oder 0 Bytes), wird abgebrochen und der Job wieder entfernt.
func startMyFreeCamsAutoStartWorker(store *ModelStore) {
@@ -116,8 +124,7 @@ func startMyFreeCamsAutoStartWorker(store *ModelStore) {
for {
select {
case <-scanTicker.C:
- s := getSettings()
- if !s.UseProviderAPIs || !s.UseMyFreeCamsAPI || !s.AutoStartAddedDownloads || isAutostartPaused() {
+ if !myFreeCamsAutostartCanRun() {
queue = queue[:0]
queued = map[string]bool{}
@@ -390,8 +397,9 @@ func startMyFreeCamsAutoStartWorker(store *ModelStore) {
pendingAutoStartMu.Unlock()
case <-startTicker.C:
- s := getSettings()
- if !s.UseProviderAPIs || !s.UseMyFreeCamsAPI || !s.AutoStartAddedDownloads || isAutostartPaused() {
+ if !myFreeCamsAutostartCanRun() {
+ queue = queue[:0]
+ queued = map[string]bool{}
continue
}
if len(queue) == 0 {
@@ -407,6 +415,12 @@ func startMyFreeCamsAutoStartWorker(store *ModelStore) {
queue = queue[1:]
delete(queued, it.userKey)
+ if !myFreeCamsAutostartCanRun() {
+ queue = queue[:0]
+ queued = map[string]bool{}
+ continue
+ }
+
if isJobRunningForURL(it.url) {
continue
}
diff --git a/backend/pending_autostart.go b/backend/pending_autostart.go
index c1561a4..fa8f932 100644
--- a/backend/pending_autostart.go
+++ b/backend/pending_autostart.go
@@ -405,6 +405,49 @@ func removeManualPendingAutoStartItemForProvider(provider, modelKey string) erro
return nil
}
+func removeAllPendingAutoStartItemsForProvider(provider string) (int, error) {
+ provider = strings.ToLower(strings.TrimSpace(provider))
+ if provider == "" {
+ return 0, nil
+ }
+
+ userKeys, err := listPendingAutoStartUserKeys()
+ if err != nil {
+ return 0, err
+ }
+
+ keys := append([]string{pendingAutoStartGlobalUserKey}, userKeys...)
+ removed := 0
+
+ for _, userKey := range keys {
+ items, err := loadPendingAutoStartItems(userKey)
+ if err != nil {
+ return removed, err
+ }
+
+ changed := false
+ next := make([]PendingAutoStartItem, 0, len(items))
+
+ for _, it := range items {
+ if pendingProviderFromURL(it.URL) == provider {
+ removed++
+ changed = true
+ continue
+ }
+
+ next = append(next, it)
+ }
+
+ if changed {
+ if err := savePendingAutoStartItems(userKey, next); err != nil {
+ return removed, err
+ }
+ }
+ }
+
+ return removed, nil
+}
+
func loadPendingAutoStartItems(userKey string) ([]PendingAutoStartItem, error) {
path := pendingAutoStartFilePath(userKey)
diff --git a/backend/postwork.go b/backend/postwork.go
index 64e9c4d..5c088a3 100644
--- a/backend/postwork.go
+++ b/backend/postwork.go
@@ -3,6 +3,7 @@ package main
import (
"context"
+ "errors"
"os"
"strings"
"sync"
@@ -315,7 +316,7 @@ func (pq *PostWorkQueue) workerLoop(id int) {
func() {
defer func() {
if r := recover(); r != nil {
- _ = r
+ appLogln("postwork task panic:", task.Key, r)
}
pq.mu.Lock()
@@ -357,7 +358,13 @@ func (pq *PostWorkQueue) workerLoop(id int) {
pq.mu.Unlock()
if task.Run != nil {
- _ = task.Run(ctx)
+ if err := task.Run(ctx); err != nil {
+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
+ appLogln("postwork task abgebrochen:", task.Key, err)
+ } else {
+ appLogln("postwork task fehlgeschlagen:", task.Key, err)
+ }
+ }
}
}()
}
diff --git a/backend/record.go b/backend/record.go
index 1248732..965beea 100644
--- a/backend/record.go
+++ b/backend/record.go
@@ -1603,11 +1603,26 @@ func recordStopAll(w http.ResponseWriter, r *http.Request) {
}
type stopAllResp struct {
- OK bool `json:"ok"`
- Stopped int `json:"stopped"`
- IDs []string `json:"ids,omitempty"`
+ OK bool `json:"ok"`
+ Stopped int `json:"stopped"`
+ IDs []string `json:"ids,omitempty"`
+ AutostartPaused bool `json:"autostartPaused"`
+ PendingRemoved int `json:"pendingRemoved,omitempty"`
}
+ setAutostartPaused(true)
+
+ pendingRemoved := 0
+ pendingAutoStartMu.Lock()
+ for _, provider := range []string{"mfc", "chaturbate"} {
+ if n, err := removeAllPendingAutoStartItemsForProvider(provider); err != nil {
+ appLogln("⚠️ [stop-all] pending cleanup failed:", provider, err)
+ } else {
+ pendingRemoved += n
+ }
+ }
+ pendingAutoStartMu.Unlock()
+
jobsMu.RLock()
toStop := make([]*RecordJob, 0, len(jobs))
ids := make([]string, 0, len(jobs))
@@ -1638,9 +1653,11 @@ func recordStopAll(w http.ResponseWriter, r *http.Request) {
}
respondJSON(w, stopAllResp{
- OK: true,
- Stopped: len(toStop),
- IDs: ids,
+ OK: true,
+ Stopped: len(toStop),
+ IDs: ids,
+ AutostartPaused: true,
+ PendingRemoved: pendingRemoved,
})
}
diff --git a/backend/server.go b/backend/server.go
index b6a3bc4..5ca0a4d 100644
--- a/backend/server.go
+++ b/backend/server.go
@@ -1210,6 +1210,7 @@ func main() {
startPostWorkStatusRefresher()
startEnrichStatusRefresher()
+ startCleanupTaskOnceOnStartup(appCtx)
startPostworkLeftoverScanOnStartup()
appGo("generated-garbage-collector", startGeneratedGarbageCollector)
diff --git a/backend/startup_leftovers.go b/backend/startup_leftovers.go
index b7abbf5..7296921 100644
--- a/backend/startup_leftovers.go
+++ b/backend/startup_leftovers.go
@@ -90,8 +90,6 @@ func startPostworkLeftoverScanOnStartup() {
finishCleanupJob(jobID, summary, nil)
}
- runPostworkLeftoverScan("startup")
-
ticker := time.NewTicker(postworkLeftoverScanInterval)
defer ticker.Stop()
diff --git a/backend/tasks_cleanup.go b/backend/tasks_cleanup.go
index 38da912..4d362b1 100644
--- a/backend/tasks_cleanup.go
+++ b/backend/tasks_cleanup.go
@@ -67,6 +67,16 @@ type cleanupReq struct {
BelowMB *int `json:"belowMB,omitempty"`
}
+type cleanupTaskConfig struct {
+ DoneAbs string
+ RecordAbs string
+ BelowMB int
+ DeleteLowRated bool
+ MaxStars int
+ QueuedText string
+ RunningText string
+}
+
type cleanupTaskJob struct {
State CleanupTaskState
Cancel context.CancelFunc
@@ -75,6 +85,8 @@ type cleanupTaskJob struct {
var cleanupJobsMu sync.Mutex
var cleanupJobs = map[string]*cleanupTaskJob{}
+const startupCleanupInitialDelay = 8 * time.Second
+
func createCleanupJob(jobID string, text string, cancel context.CancelFunc) CleanupTaskState {
st := CleanupTaskState{
ID: jobID,
@@ -183,149 +195,193 @@ func finishCleanupJob(jobID string, text string, err error) {
})
}
+func cleanupTaskConfigFromSettings(req *cleanupReq, queuedText string, runningText string) (cleanupTaskConfig, error) {
+ s := getSettings()
+
+ doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
+ if err != nil || strings.TrimSpace(doneAbs) == "" {
+ return cleanupTaskConfig{}, fmt.Errorf("doneDir auflösung fehlgeschlagen")
+ }
+
+ recordAbs, err := resolvePathRelativeToApp(s.RecordDir)
+ if err != nil || strings.TrimSpace(recordAbs) == "" {
+ recordAbs = strings.TrimSpace(s.RecordDir)
+ }
+
+ mb := int(s.AutoDeleteSmallDownloadsBelowMB)
+ if req != nil && req.BelowMB != nil {
+ mb = *req.BelowMB
+ }
+ if mb < 0 {
+ mb = 0
+ }
+
+ return cleanupTaskConfig{
+ DoneAbs: doneAbs,
+ RecordAbs: recordAbs,
+ BelowMB: mb,
+ DeleteLowRated: s.AutoDeleteLowRatedDownloads,
+ MaxStars: clampAutoDeleteRatingStars(s.AutoDeleteLowRatedDownloadsMaxStars),
+ QueuedText: strings.TrimSpace(queuedText),
+ RunningText: strings.TrimSpace(runningText),
+ }, nil
+}
+
+func startCleanupTask(parentCtx context.Context, jobPrefix string, cfg cleanupTaskConfig) CleanupTaskState {
+ if parentCtx == nil {
+ parentCtx = context.Background()
+ }
+
+ jobPrefix = strings.TrimSpace(jobPrefix)
+ if jobPrefix == "" {
+ jobPrefix = "cleanup"
+ }
+
+ queuedText := strings.TrimSpace(cfg.QueuedText)
+ if queuedText == "" {
+ queuedText = "Wartet…"
+ }
+
+ runningText := strings.TrimSpace(cfg.RunningText)
+ if runningText == "" {
+ runningText = "Räume auf…"
+ }
+
+ jobID := newTaskID(jobPrefix)
+ ctx, cancel := context.WithCancel(parentCtx)
+ st := createCleanupJob(jobID, queuedText, cancel)
+
+ go func(jobID string, ctx context.Context, cancel context.CancelFunc, cfg cleanupTaskConfig, runningText string) {
+ defer cancel()
+
+ if err := acquireExclusiveTask(ctx); err != nil {
+ finishCleanupJob(jobID, "", err)
+ return
+ }
+ defer releaseExclusiveTask()
+
+ updateCleanupJobState(jobID, func(st *CleanupTaskState) {
+ st.Queued = false
+ st.Running = true
+ st.StartedAt = time.Now()
+ st.Text = runningText
+ })
+
+ resp := cleanupResp{}
+
+ if cfg.BelowMB > 0 || cfg.DeleteLowRated {
+ threshold := int64(cfg.BelowMB) * 1024 * 1024
+ if err := cleanupSmallFiles(ctx, jobID, cfg.DoneAbs, threshold, cfg.DeleteLowRated, cfg.MaxStars, &resp); err != nil {
+ finishCleanupJob(jobID, "", err)
+ return
+ }
+ }
+
+ // Neuer Cleanup-Abschnitt: alten 100%-Fortschritt vom Small-Files-Scan zurücksetzen.
+ updateCleanupJobState(jobID, func(st *CleanupTaskState) {
+ st.Queued = false
+ st.Running = true
+ if st.StartedAt.IsZero() {
+ st.StartedAt = time.Now()
+ }
+ st.Done = 0
+ st.Total = 0
+ st.CurrentFile = ""
+ st.Text = "Räume Record-Reste auf…"
+ st.Error = ""
+ st.FinishedAt = nil
+ })
+
+ if err := cleanupRecordDirOrphanAVFiles(ctx, jobID, cfg.RecordAbs, &resp); err != nil {
+ finishCleanupJob(jobID, "", err)
+ return
+ }
+
+ select {
+ case <-ctx.Done():
+ finishCleanupJob(jobID, "", ctx.Err())
+ return
+ default:
+ }
+
+ updateCleanupJobState(jobID, func(st *CleanupTaskState) {
+ st.Queued = false
+ st.Running = true
+ st.Done = 0
+ st.Total = 0
+ st.CurrentFile = ""
+ st.Text = "Räume Generated-Assets auf…"
+ st.Error = ""
+ st.FinishedAt = nil
+ })
+
+ gcStats := triggerGeneratedGarbageCollectorSync()
+
+ resp.GeneratedOrphansChecked = gcStats.Checked
+ resp.GeneratedOrphansRemoved = gcStats.Removed
+ resp.GeneratedOrphansRemovedBytes = gcStats.RemovedBytes
+ resp.GeneratedOrphansRemovedBytesHuman = formatBytesSI(gcStats.RemovedBytes)
+
+ resp.DeletedBytes += gcStats.RemovedBytes
+ resp.DeletedBytesHuman = formatBytesSI(resp.DeletedBytes)
+
+ if resp.DeletedFiles > 0 || resp.GeneratedOrphansRemoved > 0 {
+ notifyDoneChanged()
+ }
+
+ finishCleanupJob(
+ jobID,
+ cleanupProgressText(resp),
+ nil,
+ )
+ }(jobID, ctx, cancel, cfg, runningText)
+
+ return st
+}
+
+func startCleanupTaskOnceOnStartup(parentCtx context.Context) {
+ if parentCtx == nil {
+ parentCtx = context.Background()
+ }
+
+ appGo("startup-cleanup", func() {
+ timer := time.NewTimer(startupCleanupInitialDelay)
+ defer timer.Stop()
+
+ select {
+ case <-parentCtx.Done():
+ return
+ case <-timer.C:
+ }
+
+ cfg, err := cleanupTaskConfigFromSettings(nil, "Wartet…", "Räume auf…")
+ if err != nil {
+ appLogln("⚠️ [startup-cleanup] cleanup config failed:", err)
+ return
+ }
+
+ startCleanupTask(parentCtx, "cleanup-startup", cfg)
+ })
+}
+
// /api/settings/cleanup (POST)
// - löscht kleine Dateien < threshold MB (mp4/ts; skip .part/.tmp; skip keep-Ordner)
// - räumt Orphans (preview/thumbs + generated) auf
func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
- s := getSettings()
-
- doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
- if err != nil || strings.TrimSpace(doneAbs) == "" {
- http.Error(w, "doneDir auflösung fehlgeschlagen", http.StatusBadRequest)
- return
- }
-
- recordAbs, err := resolvePathRelativeToApp(s.RecordDir)
- if err != nil || strings.TrimSpace(recordAbs) == "" {
- recordAbs = strings.TrimSpace(s.RecordDir)
- }
-
- mb := int(s.AutoDeleteSmallDownloadsBelowMB)
- deleteLowRated := s.AutoDeleteLowRatedDownloads
- maxStars := clampAutoDeleteRatingStars(s.AutoDeleteLowRatedDownloadsMaxStars)
-
var req cleanupReq
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&req)
}
- if req.BelowMB != nil {
- mb = *req.BelowMB
- }
- if mb < 0 {
- mb = 0
+
+ cfg, err := cleanupTaskConfigFromSettings(&req, "Wartet…", "Räume auf…")
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
}
- jobID := newTaskID("cleanup")
- ctx, cancel := context.WithCancel(context.Background())
-
- st := CleanupTaskState{
- ID: jobID,
- Queued: true,
- Running: false,
- Cancellable: true,
- QueuedAt: time.Now(),
- StartedAt: time.Time{},
- FinishedAt: nil,
- Text: "Wartet…",
- Error: "",
- CurrentFile: "",
- Done: 0,
- Total: 0,
- }
-
- cleanupJobsMu.Lock()
- cleanupJobs[jobID] = &cleanupTaskJob{
- State: st,
- Cancel: cancel,
- }
- cleanupJobsMu.Unlock()
-
- publishTaskState()
-
- go func(jobID string, ctx context.Context, doneAbs string, recordAbs string, mb int, deleteLowRated bool, maxStars int) {
- if err := acquireExclusiveTask(ctx); err != nil {
- finishCleanupJob(jobID, "", err)
- return
- }
- defer releaseExclusiveTask()
-
- updateCleanupJobState(jobID, func(st *CleanupTaskState) {
- st.Queued = false
- st.Running = true
- st.StartedAt = time.Now()
- st.Text = "Räume auf…"
- })
-
- resp := cleanupResp{}
-
- if mb > 0 || deleteLowRated {
- threshold := int64(mb) * 1024 * 1024
- if err := cleanupSmallFiles(ctx, jobID, doneAbs, threshold, deleteLowRated, maxStars, &resp); err != nil {
- finishCleanupJob(jobID, "", err)
- return
- }
- }
-
- // Neuer Cleanup-Abschnitt: alten 100%-Fortschritt vom Small-Files-Scan zurücksetzen.
- updateCleanupJobState(jobID, func(st *CleanupTaskState) {
- st.Queued = false
- st.Running = true
- if st.StartedAt.IsZero() {
- st.StartedAt = time.Now()
- }
- st.Done = 0
- st.Total = 0
- st.CurrentFile = ""
- st.Text = "Räume Record-Reste auf…"
- st.Error = ""
- st.FinishedAt = nil
- })
-
- if err := cleanupRecordDirOrphanAVFiles(ctx, jobID, recordAbs, &resp); err != nil {
- finishCleanupJob(jobID, "", err)
- return
- }
-
- select {
- case <-ctx.Done():
- finishCleanupJob(jobID, "", ctx.Err())
- return
- default:
- }
-
- updateCleanupJobState(jobID, func(st *CleanupTaskState) {
- st.Queued = false
- st.Running = true
- st.Done = 0
- st.Total = 0
- st.CurrentFile = ""
- st.Text = "Räume Generated-Assets auf…"
- st.Error = ""
- st.FinishedAt = nil
- })
-
- gcStats := triggerGeneratedGarbageCollectorSync()
-
- resp.GeneratedOrphansChecked = gcStats.Checked
- resp.GeneratedOrphansRemoved = gcStats.Removed
- resp.GeneratedOrphansRemovedBytes = gcStats.RemovedBytes
- resp.GeneratedOrphansRemovedBytesHuman = formatBytesSI(gcStats.RemovedBytes)
-
- resp.DeletedBytes += gcStats.RemovedBytes
- resp.DeletedBytesHuman = formatBytesSI(resp.DeletedBytes)
-
- if resp.DeletedFiles > 0 || resp.GeneratedOrphansRemoved > 0 {
- notifyDoneChanged()
- }
-
- finishCleanupJob(
- jobID,
- cleanupProgressText(resp),
- nil,
- )
- }(jobID, ctx, doneAbs, recordAbs, mb, deleteLowRated, maxStars)
+ st := startCleanupTask(context.Background(), "cleanup", cfg)
writeJSON(w, http.StatusOK, st)
return
diff --git a/backend/training.go b/backend/training.go
index 119b731..2a858c2 100644
--- a/backend/training.go
+++ b/backend/training.go
@@ -1311,7 +1311,10 @@ const trainingPoseReliableMinQuality = 0.45
const trainingPositionContextMinScore = 0.22
const trainingPositionContextMaxScore = 0.44
const trainingPositionContextBoostWeight = 0.60
-const trainingPositionContextOverrideMargin = 0.16
+const trainingPoseConfirmingContextMinScore = 0.14
+const trainingPoseUnconfirmedMaxScore = 0.38
+const trainingPoseStrongUnconfirmedMinScore = 0.70
+const trainingPoseStrongUnconfirmedMaxScore = 0.46
var errTrainingCancelled = errors.New("training cancelled")
@@ -3471,6 +3474,48 @@ func trainingNegativeCorrection() *TrainingCorrection {
}
}
+func trainingNormalizeCorrectionForStorage(correction *TrainingCorrection) *TrainingCorrection {
+ if correction == nil {
+ return nil
+ }
+
+ normalized := *correction
+ normalized.SexPosition = normalizeSexPositionLabel(normalized.SexPosition)
+ if isNoSexPositionLabel(normalized.SexPosition) {
+ normalized.SexPosition = trainingNoSexPositionLabel
+ normalized.PosePersons = []TrainingPosePerson{}
+ }
+
+ return &normalized
+}
+
+func trainingAnnotationEffectiveSexPosition(annotation TrainingAnnotation) string {
+ if annotation.Negative {
+ return trainingNoSexPositionLabel
+ }
+
+ if annotation.Correction != nil {
+ return normalizeSexPositionLabel(annotation.Correction.SexPosition)
+ }
+
+ return normalizeSexPositionLabel(annotation.Prediction.SexPosition)
+}
+
+func trainingStripPosePersonsForNoSexPosition(annotation TrainingAnnotation) TrainingAnnotation {
+ if !isNoSexPositionLabel(trainingAnnotationEffectiveSexPosition(annotation)) {
+ return annotation
+ }
+
+ annotation.Prediction.Persons = nil
+ if annotation.Correction != nil {
+ correction := trainingNormalizeCorrectionForStorage(annotation.Correction)
+ correction.PosePersons = []TrainingPosePerson{}
+ annotation.Correction = correction
+ }
+
+ return annotation
+}
+
func trainingFeedbackHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
@@ -3492,6 +3537,7 @@ func trainingFeedbackHandler(w http.ResponseWriter, r *http.Request) {
req.Accepted = false
req.Correction = trainingNegativeCorrection()
}
+ req.Correction = trainingNormalizeCorrectionForStorage(req.Correction)
root, err := trainingRootDir()
if err != nil {
@@ -3520,6 +3566,7 @@ func trainingFeedbackHandler(w http.ResponseWriter, r *http.Request) {
Correction: req.Correction,
Notes: strings.TrimSpace(req.Notes),
}
+ annotation = trainingStripPosePersonsForNoSexPosition(annotation)
if !annotation.Accepted && annotation.Correction == nil {
trainingWriteError(w, http.StatusBadRequest, "correction missing")
@@ -3569,6 +3616,7 @@ func trainingFeedbackUpdateHandler(w http.ResponseWriter, r *http.Request) {
req.Accepted = false
req.Correction = trainingNegativeCorrection()
}
+ req.Correction = trainingNormalizeCorrectionForStorage(req.Correction)
if req.SampleID == "" {
trainingWriteError(w, http.StatusBadRequest, "sampleId missing")
@@ -3629,6 +3677,7 @@ func trainingFeedbackUpdateHandler(w http.ResponseWriter, r *http.Request) {
} else {
updated.Correction = req.Correction
}
+ updated = trainingStripPosePersonsForNoSexPosition(updated)
items[matchIndex] = updated
@@ -4874,19 +4923,25 @@ func trainingEffectiveCorrection(annotation TrainingAnnotation) TrainingCorrecti
}
if annotation.Correction != nil {
- return *annotation.Correction
+ correction := trainingNormalizeCorrectionForStorage(annotation.Correction)
+ return *correction
}
p := annotation.Prediction
+ sexPosition := normalizeSexPositionLabel(p.SexPosition)
+ posePersons := p.Persons
+ if isNoSexPositionLabel(sexPosition) {
+ posePersons = []TrainingPosePerson{}
+ }
return TrainingCorrection{
- SexPosition: p.SexPosition,
+ SexPosition: sexPosition,
PeoplePresent: trainingScoredLabelsToStrings(p.PeoplePresent),
BodyPartsPresent: trainingScoredLabelsToStrings(p.BodyPartsPresent),
ObjectsPresent: trainingScoredLabelsToStrings(p.ObjectsPresent),
ClothingPresent: trainingScoredLabelsToStrings(p.ClothingPresent),
Boxes: p.Boxes,
- PosePersons: p.Persons,
+ PosePersons: posePersons,
}
}
@@ -6830,9 +6885,6 @@ func trainingFuseHybridPositionScores(
bestPositionScore := 0.0
bestHasPose := false
bestHasContext := false
- bestPosePosition := ""
- bestPoseScore := 0.0
- bestPoseHasContext := false
for label := range labels {
poseScore := clamp01(poseScores[label])
@@ -6843,12 +6895,21 @@ func trainingFuseHybridPositionScores(
hasContext := contextScore > 0
if hasPose {
- score = poseScore
+ if hasContext && contextScore >= trainingPoseConfirmingContextMinScore {
+ score = poseScore
- // Kontext boostet die Pose, bleibt aber bewusst Nebeninformation.
- if hasContext {
+ // Kontext boostet die Pose nur, wenn er dieselbe Position wirklich stützt.
boost := clamp01(contextScore * trainingPositionContextBoostWeight)
score = clamp01(1 - (1-score)*(1-boost))
+ } else {
+ // Unbestätigte Pose bleibt nutzbar, dominiert aber nicht mehr gegen
+ // stärkere Box-/Szenen-Signale. Das reduziert False-Positives bei
+ // falsch erkannten oder schlecht sitzenden Skeletten.
+ maxUnconfirmedScore := trainingPoseUnconfirmedMaxScore
+ if poseScore >= trainingPoseStrongUnconfirmedMinScore {
+ maxUnconfirmedScore = trainingPoseStrongUnconfirmedMaxScore
+ }
+ score = math.Min(maxUnconfirmedScore, poseScore)
}
} else if contextScore >= trainingPositionContextMinScore {
// Reiner Box-/Szenen-Kontext darf eine unsichere Prediction liefern,
@@ -6863,20 +6924,6 @@ func trainingFuseHybridPositionScores(
bestHasContext = hasContext
}
- if hasPose && score > bestPoseScore {
- bestPosePosition = label
- bestPoseScore = score
- bestPoseHasContext = hasContext
- }
- }
-
- if bestPosePosition != "" &&
- !bestHasPose &&
- bestPositionScore <= bestPoseScore+trainingPositionContextOverrideMargin {
- bestPosition = bestPosePosition
- bestPositionScore = bestPoseScore
- bestHasPose = true
- bestHasContext = bestPoseHasContext
}
return bestPosition, clamp01(bestPositionScore), bestHasPose, bestHasContext
@@ -6991,6 +7038,27 @@ func trainingBoxHorizontalOverlapRatio(a TrainingBox, b TrainingBox) float64 {
return clamp01((right - left) / minWidth)
}
+func trainingBoxVerticalOverlapRatio(a TrainingBox, b TrainingBox) float64 {
+ a, okA := trainingNormalizedBox(a)
+ b, okB := trainingNormalizedBox(b)
+ if !okA || !okB {
+ return 0
+ }
+
+ top := math.Max(a.Y, b.Y)
+ bottom := math.Min(a.Y+a.H, b.Y+b.H)
+ if bottom <= top {
+ return 0
+ }
+
+ minHeight := math.Min(a.H, b.H)
+ if minHeight <= 0 {
+ return 0
+ }
+
+ return clamp01((bottom - top) / minHeight)
+}
+
func trainingBoxesByLabel(boxes []TrainingBox, labels ...string) []TrainingBox {
wanted := map[string]bool{}
for _, label := range labels {
@@ -7261,6 +7329,15 @@ type trainingPosePersonGeometry struct {
box TrainingBox
hasBox bool
center trainingPosePoint
+ torsoAngle float64
+ hasTorsoAxis bool
+ axisX float64
+ axisY float64
+ perpX float64
+ perpY float64
+ bodyLong float64
+ bodyCross float64
+ elongated bool
lying bool
upright bool
straddling bool
@@ -7328,6 +7405,64 @@ func trainingPosePointDistance(a trainingPosePoint, okA bool, b trainingPosePoin
return math.Sqrt(dx*dx + dy*dy)
}
+func trainingPoseProjectedDistance(a trainingPosePoint, okA bool, b trainingPosePoint, okB bool, axisX float64, axisY float64) float64 {
+ if !okA || !okB {
+ return 0
+ }
+
+ return math.Abs((a.x-b.x)*axisX + (a.y-b.y)*axisY)
+}
+
+func trainingPoseAxisAlignment(a trainingPosePersonGeometry, b trainingPosePersonGeometry) (float64, bool) {
+ if !a.hasTorsoAxis || !b.hasTorsoAxis {
+ return 0, false
+ }
+
+ // Körperachsen sind richtungslos: 0° und 180° gelten beide als parallel.
+ return math.Abs(math.Cos(a.torsoAngle - b.torsoAngle)), true
+}
+
+func trainingPoseExtentsAlongAxis(person TrainingPosePerson, origin trainingPosePoint, axisX float64, axisY float64, perpX float64, perpY float64) (float64, float64, bool) {
+ minLong := 0.0
+ maxLong := 0.0
+ minCross := 0.0
+ maxCross := 0.0
+ count := 0
+
+ for _, point := range person.Keypoints {
+ if point.Conf < trainingPoseKeypointMinConfidence ||
+ !trainingIsFinite01(point.X) ||
+ !trainingIsFinite01(point.Y) {
+ continue
+ }
+
+ dx := point.X - origin.x
+ dy := point.Y - origin.y
+ long := dx*axisX + dy*axisY
+ cross := dx*perpX + dy*perpY
+
+ if count == 0 {
+ minLong = long
+ maxLong = long
+ minCross = cross
+ maxCross = cross
+ } else {
+ minLong = math.Min(minLong, long)
+ maxLong = math.Max(maxLong, long)
+ minCross = math.Min(minCross, cross)
+ maxCross = math.Max(maxCross, cross)
+ }
+
+ count++
+ }
+
+ if count < 3 {
+ return 0, 0, false
+ }
+
+ return math.Abs(maxLong - minLong), math.Abs(maxCross - minCross), true
+}
+
func trainingPosePersonGeometryFor(person TrainingPosePerson) trainingPosePersonGeometry {
box, hasBox := trainingNormalizedBox(person.Box)
center := trainingPosePoint{x: 0.5, y: 0.5}
@@ -7353,34 +7488,94 @@ func trainingPosePersonGeometryFor(person TrainingPosePerson) trainingPosePerson
torsoDX := 0.0
torsoDY := 0.0
torsoLen := 0.0
+ torsoAngle := 0.0
+ hasTorsoAxis := false
+ axisX := 0.0
+ axisY := 1.0
+ perpX := -1.0
+ perpY := 0.0
if okHip && okShoulder {
- torsoDX = math.Abs(hip.x - shoulder.x)
- torsoDY = math.Abs(hip.y - shoulder.y)
- torsoLen = math.Sqrt(torsoDX*torsoDX + torsoDY*torsoDY)
+ rawDX := hip.x - shoulder.x
+ rawDY := hip.y - shoulder.y
+ torsoDX = math.Abs(rawDX)
+ torsoDY = math.Abs(rawDY)
+ torsoLen = math.Sqrt(rawDX*rawDX + rawDY*rawDY)
+ if torsoLen >= 0.07 {
+ hasTorsoAxis = true
+ axisX = rawDX / torsoLen
+ axisY = rawDY / torsoLen
+ perpX = -axisY
+ perpY = axisX
+ torsoAngle = math.Atan2(axisY, axisX)
+ }
}
hipWidth := trainingPosePointDistance(leftHip, okLeftHip, rightHip, okRightHip)
kneeWidth := trainingPosePointDistance(leftKnee, okLeftKnee, rightKnee, okRightKnee)
kneesBelowHips := okKnee && okHip && knee.y > hip.y+0.045
+ if hasTorsoAxis && okKnee && okHip {
+ kneeProjection := (knee.x-hip.x)*axisX + (knee.y-hip.y)*axisY
+ kneesBelowHips = kneeProjection > 0.045
+ }
+
+ if hasTorsoAxis && okLeftKnee && okRightKnee {
+ kneeWidth = trainingPoseProjectedDistance(leftKnee, okLeftKnee, rightKnee, okRightKnee, perpX, perpY)
+
+ if okLeftHip && okRightHip {
+ hipWidth = trainingPoseProjectedDistance(leftHip, okLeftHip, rightHip, okRightHip, perpX, perpY)
+ }
+ }
+
kneesWide := kneeWidth > 0 && kneeWidth >= math.Max(0.11, hipWidth*1.12)
straddling := kneesBelowHips && kneesWide
+ bodyLong := torsoLen
+ bodyCross := math.Max(hipWidth, kneeWidth)
+
+ if hasTorsoAxis {
+ if poseLong, poseCross, ok := trainingPoseExtentsAlongAxis(person, center, axisX, axisY, perpX, perpY); ok {
+ bodyLong = math.Max(bodyLong, poseLong)
+ bodyCross = math.Max(bodyCross, poseCross)
+ }
+
+ if hasBox {
+ boxLong := math.Abs(axisX)*box.W + math.Abs(axisY)*box.H
+ boxCross := math.Abs(perpX)*box.W + math.Abs(perpY)*box.H
+ bodyLong = math.Max(bodyLong, boxLong)
+ bodyCross = math.Max(bodyCross, boxCross)
+ }
+ } else if hasBox {
+ bodyLong = math.Max(box.W, box.H)
+ bodyCross = math.Min(box.W, box.H)
+ }
+
+ elongated := bodyLong >= math.Max(0.18, bodyCross*1.18)
+
torsoHorizontal := torsoLen >= 0.07 && torsoDX >= torsoDY*1.15
torsoVertical := torsoLen >= 0.07 && torsoDY >= torsoDX*1.15
boxHorizontal := hasBox && box.W >= box.H*1.05
boxVertical := hasBox && box.H >= box.W*1.25
- lying := torsoHorizontal || boxHorizontal
+ lying := torsoHorizontal || boxHorizontal || (hasTorsoAxis && elongated && !boxVertical)
upright := torsoVertical || boxVertical
- allFours := torsoHorizontal && kneesBelowHips
+ allFours := kneesBelowHips && !straddling && (torsoHorizontal || (hasTorsoAxis && elongated))
bentOrKneeling := allFours || (kneesBelowHips && !straddling)
return trainingPosePersonGeometry{
box: box,
hasBox: hasBox,
center: center,
+ torsoAngle: torsoAngle,
+ hasTorsoAxis: hasTorsoAxis,
+ axisX: axisX,
+ axisY: axisY,
+ perpX: perpX,
+ perpY: perpY,
+ bodyLong: bodyLong,
+ bodyCross: bodyCross,
+ elongated: elongated,
lying: lying,
upright: upright,
straddling: straddling,
@@ -7425,6 +7620,7 @@ func trainingAddPosePairGeometryScores(
gap := trainingBoxGap(left.box, right.box)
overlap := trainingBoxOverlapRatio(left.box, right.box)
horizontalOverlap := trainingBoxHorizontalOverlapRatio(left.box, right.box)
+ verticalOverlap := trainingBoxVerticalOverlapRatio(left.box, right.box)
close := gap <= 0.12 || overlap >= 0.08
if !close {
continue
@@ -7438,28 +7634,73 @@ func trainingAddPosePairGeometryScores(
}
topAbove := top.center.y <= bottom.center.y-0.055
- strongStack := horizontalOverlap >= 0.35 && (topAbove || overlap >= 0.22)
- topHasRiderShape := top.straddling ||
- top.kneesWide ||
- (top.upright && top.kneesBelowHips)
+ horizontalStack := horizontalOverlap >= 0.35 && (topAbove || overlap >= 0.22)
+ lateralStack := verticalOverlap >= 0.35 && overlap >= 0.12
+ strongStack := horizontalStack || lateralStack
- if strongStack && topHasRiderShape {
- add("cowgirl", 0.20)
- add("reverse_cowgirl", 0.17)
+ axisAlignment, hasAxisAlignment := trainingPoseAxisAlignment(left, right)
+ axesParallel := hasAxisAlignment && axisAlignment >= 0.74
+ axesCrossed := hasAxisAlignment && axisAlignment <= 0.56
- if bottom.lying {
- add("cowgirl", 0.12)
- add("reverse_cowgirl", 0.10)
- }
- if top.straddling {
- add("cowgirl", 0.08)
- add("reverse_cowgirl", 0.06)
+ hasStrongRiderShape := func(g trainingPosePersonGeometry) bool {
+ return g.straddling ||
+ (g.kneesWide && g.kneesBelowHips && (g.upright || axesCrossed))
+ }
+ hasWeakRiderShape := func(g trainingPosePersonGeometry) bool {
+ return g.kneesWide && g.kneesBelowHips
+ }
+
+ leftHasStrongRiderShape := hasStrongRiderShape(left)
+ rightHasStrongRiderShape := hasStrongRiderShape(right)
+ topHasStrongRiderShape := hasStrongRiderShape(top)
+ topHasWeakRiderShape := hasWeakRiderShape(top)
+
+ rider := top
+ base := bottom
+ riderHasStrongShape := topHasStrongRiderShape
+ riderHasWeakShape := topHasWeakRiderShape
+ if leftHasStrongRiderShape != rightHasStrongRiderShape {
+ if leftHasStrongRiderShape {
+ rider = left
+ base = right
+ riderHasStrongShape = true
+ riderHasWeakShape = hasWeakRiderShape(left)
+ } else {
+ rider = right
+ base = left
+ riderHasStrongShape = true
+ riderHasWeakShape = hasWeakRiderShape(right)
}
}
- if strongStack && bottom.lying {
- add("missionary", 0.14)
- if !top.straddling {
+ if strongStack && riderHasStrongShape && !axesParallel {
+ add("cowgirl", 0.20)
+ add("reverse_cowgirl", 0.17)
+
+ if base.lying {
+ add("cowgirl", 0.12)
+ add("reverse_cowgirl", 0.10)
+ }
+ if rider.straddling {
+ add("cowgirl", 0.08)
+ add("reverse_cowgirl", 0.06)
+ }
+ } else if strongStack && riderHasWeakShape && !hasAxisAlignment {
+ // Ohne verwertbare Achsen bleibt Cowgirl nur ein schwaches Signal.
+ // Sobald die Achsen parallel sind, sieht die Szene eher nach
+ // Missionary/Überlagerung aus und soll nicht in Cowgirl kippen.
+ add("cowgirl", 0.08)
+ add("reverse_cowgirl", 0.06)
+ }
+
+ if strongStack && (bottom.lying || (axesParallel && topAbove)) {
+ if !topHasStrongRiderShape || axesParallel {
+ add("missionary", 0.14)
+ }
+ if axesParallel {
+ add("missionary", 0.08)
+ }
+ if !top.straddling && !topHasStrongRiderShape {
add("missionary", 0.08)
}
}
@@ -7467,7 +7708,8 @@ func trainingAddPosePairGeometryScores(
bothLying := left.lying && right.lying
sameLevel := math.Abs(left.center.y-right.center.y) <= 0.15
sideBySide := math.Abs(left.center.x-right.center.x) >= 0.10
- if bothLying && (sameLevel || sideBySide) {
+ parallelSideBySide := axesParallel && left.elongated && right.elongated && close && !strongStack
+ if (bothLying && (sameLevel || sideBySide)) || parallelSideBySide {
add("spooning", 0.18)
if overlap >= 0.10 {
add("prone_bone", 0.07)
diff --git a/backend/training_test.go b/backend/training_test.go
index 26c6711..fbde0fa 100644
--- a/backend/training_test.go
+++ b/backend/training_test.go
@@ -132,6 +132,79 @@ func TestTrainingEffectiveCorrectionClearsNegativeAnnotation(t *testing.T) {
}
}
+func TestTrainingEffectiveCorrectionClearsPosePersonsForUnknownCorrection(t *testing.T) {
+ effective := trainingEffectiveCorrection(TrainingAnnotation{
+ Correction: &TrainingCorrection{
+ SexPosition: "Unknown",
+ Boxes: []TrainingBox{
+ {Label: "person_female", X: 0, Y: 0, W: 1, H: 1},
+ },
+ PosePersons: []TrainingPosePerson{
+ {Label: "person", Score: 0.9},
+ },
+ },
+ })
+
+ if effective.SexPosition != trainingNoSexPositionLabel {
+ t.Fatalf("sex position = %q, want %s", effective.SexPosition, trainingNoSexPositionLabel)
+ }
+ if len(effective.PosePersons) != 0 {
+ t.Fatalf("pose persons = %d, want 0 for unknown sex position", len(effective.PosePersons))
+ }
+ if len(effective.Boxes) != 1 {
+ t.Fatalf("boxes = %d, want detector boxes preserved", len(effective.Boxes))
+ }
+}
+
+func TestTrainingEffectiveCorrectionClearsPosePersonsForNoPositionPrediction(t *testing.T) {
+ effective := trainingEffectiveCorrection(TrainingAnnotation{
+ Accepted: true,
+ Prediction: TrainingPrediction{
+ SexPosition: "unknown",
+ Persons: []TrainingPosePerson{
+ {Label: "person", Score: 0.9},
+ },
+ },
+ })
+
+ if effective.SexPosition != trainingNoSexPositionLabel {
+ t.Fatalf("sex position = %q, want %s", effective.SexPosition, trainingNoSexPositionLabel)
+ }
+ if len(effective.PosePersons) != 0 {
+ t.Fatalf("pose persons = %d, want 0 for unknown sex position", len(effective.PosePersons))
+ }
+}
+
+func TestTrainingStripPosePersonsForNoSexPositionBeforeStorage(t *testing.T) {
+ annotation := trainingStripPosePersonsForNoSexPosition(TrainingAnnotation{
+ Prediction: TrainingPrediction{
+ SexPosition: "keine",
+ Persons: []TrainingPosePerson{
+ {Label: "person", Score: 0.9},
+ },
+ },
+ Correction: &TrainingCorrection{
+ SexPosition: "Unknown",
+ PosePersons: []TrainingPosePerson{
+ {Label: "person", Score: 0.8},
+ },
+ },
+ })
+
+ if len(annotation.Prediction.Persons) != 0 {
+ t.Fatalf("stored prediction persons = %d, want 0", len(annotation.Prediction.Persons))
+ }
+ if annotation.Correction == nil {
+ t.Fatal("correction missing")
+ }
+ if len(annotation.Correction.PosePersons) != 0 {
+ t.Fatalf("stored correction pose persons = %d, want 0", len(annotation.Correction.PosePersons))
+ }
+ if annotation.Correction.SexPosition != trainingNoSexPositionLabel {
+ t.Fatalf("stored correction sex position = %q, want %s", annotation.Correction.SexPosition, trainingNoSexPositionLabel)
+ }
+}
+
func TestNoSexPositionAliasesTreatUnknownAsNoPosition(t *testing.T) {
for _, value := range []string{"Unknown", "unknown", "unbekannt", "none", "no_position"} {
if !isNoSexPositionLabel(value) {
@@ -303,6 +376,85 @@ func TestTrainingApplyPoseToPredictionUsesOcclusionTolerantCowgirlGeometry(t *te
}
}
+func TestTrainingPosePairGeometryDoesNotTreatParallelAxesAsCowgirl(t *testing.T) {
+ positionSet := stringSet(defaultTrainingLabelsFromJSON().SexPositions)
+ scores := map[string]float64{}
+
+ pose := TrainingPosePrediction{
+ Available: true,
+ Persons: []TrainingPosePerson{
+ {
+ Label: "person",
+ Score: 0.74,
+ Box: TrainingBox{X: 0.30, Y: 0.10, W: 0.30, H: 0.42},
+ Keypoints: trainingTestPoseKeypoints(map[string]TrainingKeypoint{
+ "left_shoulder": {X: 0.40, Y: 0.18, Conf: 0.92},
+ "right_shoulder": {X: 0.50, Y: 0.18, Conf: 0.92},
+ "left_hip": {X: 0.40, Y: 0.38, Conf: 0.92},
+ "right_hip": {X: 0.50, Y: 0.38, Conf: 0.92},
+ "left_knee": {X: 0.32, Y: 0.58, Conf: 0.90},
+ "right_knee": {X: 0.58, Y: 0.58, Conf: 0.90},
+ }),
+ },
+ {
+ Label: "person",
+ Score: 0.72,
+ Box: TrainingBox{X: 0.31, Y: 0.24, W: 0.30, H: 0.42},
+ Keypoints: trainingTestPoseKeypoints(map[string]TrainingKeypoint{
+ "left_shoulder": {X: 0.40, Y: 0.30, Conf: 0.92},
+ "right_shoulder": {X: 0.50, Y: 0.30, Conf: 0.92},
+ "left_hip": {X: 0.40, Y: 0.50, Conf: 0.92},
+ "right_hip": {X: 0.50, Y: 0.50, Conf: 0.92},
+ "left_knee": {X: 0.39, Y: 0.62, Conf: 0.90},
+ "right_knee": {X: 0.51, Y: 0.62, Conf: 0.90},
+ }),
+ },
+ },
+ }
+
+ trainingAddPosePairGeometryScores(scores, positionSet, pose)
+
+ if got := scores["cowgirl"]; got > 0 {
+ t.Fatalf("cowgirl score = %.3f, want 0 for parallel body axes", got)
+ }
+ if got := scores["reverse_cowgirl"]; got > 0 {
+ t.Fatalf("reverse_cowgirl score = %.3f, want 0 for parallel body axes", got)
+ }
+ if got := scores["missionary"]; got <= 0 {
+ t.Fatalf("missionary score = %.3f, want > 0 for stacked parallel body axes", got)
+ }
+}
+
+func TestTrainingPosePersonGeometryUsesBodyAxisForAllFours(t *testing.T) {
+ person := TrainingPosePerson{
+ Label: "person",
+ Score: 0.84,
+ Box: TrainingBox{X: 0.38, Y: 0.22, W: 0.24, H: 0.58},
+ Keypoints: trainingTestPoseKeypoints(map[string]TrainingKeypoint{
+ "left_shoulder": {X: 0.45, Y: 0.30, Conf: 0.92},
+ "right_shoulder": {X: 0.55, Y: 0.30, Conf: 0.92},
+ "left_hip": {X: 0.45, Y: 0.50, Conf: 0.92},
+ "right_hip": {X: 0.55, Y: 0.50, Conf: 0.92},
+ "left_knee": {X: 0.46, Y: 0.68, Conf: 0.90},
+ "right_knee": {X: 0.54, Y: 0.68, Conf: 0.90},
+ }),
+ }
+
+ geometry := trainingPosePersonGeometryFor(person)
+ if !geometry.kneesBelowHips {
+ t.Fatalf("kneesBelowHips = false, want true")
+ }
+ if geometry.straddling {
+ t.Fatalf("straddling = true, want false")
+ }
+ if !geometry.elongated {
+ t.Fatalf("elongated = false, want true")
+ }
+ if !geometry.allFours {
+ t.Fatalf("allFours = false, want true for body-axis aligned bent pose")
+ }
+}
+
func TestTrainingApplyPoseToPredictionKeepsUnreliablePoseOutOfContext(t *testing.T) {
pred := TrainingPrediction{
SexPosition: trainingNoSexPositionLabel,
@@ -369,6 +521,45 @@ func TestTrainingApplyPoseToPredictionUsesBoxContextWithoutPose(t *testing.T) {
}
}
+func TestTrainingFuseHybridPositionScoresCapsUnconfirmedPose(t *testing.T) {
+ positionSet := stringSet(defaultTrainingLabelsFromJSON().SexPositions)
+ poseScores := map[string]float64{}
+ contextScores := map[string]float64{}
+
+ trainingCombinePositionScore(poseScores, positionSet, "doggy", 0.82)
+
+ gotPosition, gotScore, gotPose, gotContext := trainingFuseHybridPositionScores(poseScores, contextScores)
+ if gotPosition != "doggy" {
+ t.Fatalf("position = %q, want doggy", gotPosition)
+ }
+ if !gotPose || gotContext {
+ t.Fatalf("signals pose=%v context=%v, want pose only", gotPose, gotContext)
+ }
+ if gotScore > trainingPoseStrongUnconfirmedMaxScore {
+ t.Fatalf("score = %.3f, want capped <= %.3f", gotScore, trainingPoseStrongUnconfirmedMaxScore)
+ }
+}
+
+func TestTrainingFuseHybridPositionScoresPrefersStrongContextOverUnconfirmedPose(t *testing.T) {
+ positionSet := stringSet(defaultTrainingLabelsFromJSON().SexPositions)
+ poseScores := map[string]float64{}
+ contextScores := map[string]float64{}
+
+ trainingCombinePositionScore(poseScores, positionSet, "doggy", 0.62)
+ trainingCombinePositionScore(contextScores, positionSet, "blowjob", trainingPositionContextMaxScore)
+
+ gotPosition, gotScore, gotPose, gotContext := trainingFuseHybridPositionScores(poseScores, contextScores)
+ if gotPosition != "blowjob" {
+ t.Fatalf("position = %q, want blowjob", gotPosition)
+ }
+ if gotPose || !gotContext {
+ t.Fatalf("signals pose=%v context=%v, want context only", gotPose, gotContext)
+ }
+ if gotScore < trainingPositionContextMinScore {
+ t.Fatalf("score = %.3f, want context score >= %.3f", gotScore, trainingPositionContextMinScore)
+ }
+}
+
func TestTrainingApplyPoseToPredictionBoostsPoseWithBoxContext(t *testing.T) {
pred := TrainingPrediction{
ModelAvailable: true,
@@ -450,6 +641,108 @@ func TestBuildClipPositionHitsFromEvidenceCapsContextOnlyScore(t *testing.T) {
}
}
+func TestAppendVideoFrameHighlightHitsFromPredictionOmitsRawPosition(t *testing.T) {
+ hits := appendVideoFrameHighlightHitsFromPrediction(nil, TrainingPrediction{
+ ModelAvailable: true,
+ SexPosition: "doggy",
+ SexPositionScore: 0.85,
+ ObjectsPresent: []TrainingScoredLabel{
+ {Label: "vibrator", Score: 0.90},
+ },
+ }, 4)
+
+ if len(hits) == 0 {
+ t.Fatal("expected non-position video frame highlight")
+ }
+
+ for _, hit := range hits {
+ if strings.Contains(hit.Label, "position:") {
+ t.Fatalf("hit label = %q, want raw position removed", hit.Label)
+ }
+ }
+}
+
+func TestBuildClipPositionHitsFromEvidencePrefersVideoMAEClipOverFramePose(t *testing.T) {
+ hits := buildClipPositionHitsFromEvidence([]analyzePositionEvidence{
+ {Time: 1, Label: "doggy", Score: 0.60, HasPose: true, PersonCount: 2},
+ {Time: 2, Label: "doggy", Score: 0.60, HasPose: true, PersonCount: 2},
+ {Time: 3, Label: "doggy", Score: 0.60, HasPose: true, PersonCount: 2},
+ {Time: 2, Label: "cowgirl", Score: 0.58, HasClip: true},
+ }, 8)
+
+ if len(hits) == 0 {
+ t.Fatal("expected VideoMAE-backed position hit")
+ }
+ if hits[0].Label != "position:cowgirl" {
+ t.Fatalf("label = %q, want position:cowgirl", hits[0].Label)
+ }
+}
+
+func TestBuildClipPositionHitsFromEvidenceUsesVideoMAEClipSpan(t *testing.T) {
+ hits := buildClipPositionHitsFromEvidence([]analyzePositionEvidence{
+ {Time: 12, Start: 10, End: 14, Label: "cowgirl", Score: 0.62, HasClip: true},
+ }, 20)
+
+ if len(hits) == 0 {
+ t.Fatal("expected VideoMAE clip ledger hit")
+ }
+ if hits[0].Label != "position:cowgirl" {
+ t.Fatalf("label = %q, want position:cowgirl", hits[0].Label)
+ }
+ if hits[0].Start > 10 || hits[0].End < 14 {
+ t.Fatalf("span = %.1f..%.1f, want to cover clip 10..14", hits[0].Start, hits[0].End)
+ }
+}
+
+func TestBuildClipPositionHitsFromEvidenceKeepsStableToyPlayTimelinePosition(t *testing.T) {
+ hits := buildClipPositionHitsFromEvidence([]analyzePositionEvidence{
+ {Time: 12, Start: 10, End: 24, Label: "toy_play", Score: 0.90, HasClip: true},
+ }, 120)
+
+ if len(hits) == 0 {
+ t.Fatal("expected stable toy_play timeline position")
+ }
+ if hits[0].Label != "position:toy_play" {
+ t.Fatalf("label = %q, want position:toy_play", hits[0].Label)
+ }
+}
+
+func TestBuildClipPositionHitsFromEvidenceDropsShortWeakPositionFlipInLongVideo(t *testing.T) {
+ hits := buildClipPositionHitsFromEvidence([]analyzePositionEvidence{
+ {Time: 10, Start: 8, End: 28, Label: "missionary", Score: 0.70, HasClip: true},
+ {Time: 32, Start: 30, End: 34, Label: "cowgirl", Score: 0.46, HasClip: true},
+ {Time: 35, Start: 34, End: 38, Label: "toy_play", Score: 0.46, HasClip: true},
+ {Time: 50, Start: 36, End: 64, Label: "missionary", Score: 0.72, HasClip: true},
+ }, 120)
+
+ if len(hits) == 0 {
+ t.Fatal("expected stable missionary position hits")
+ }
+ for _, hit := range hits {
+ if hit.Label == "position:cowgirl" {
+ t.Fatalf("hits = %+v, want short weak cowgirl flip omitted", hits)
+ }
+ if hit.Label == "position:toy_play" {
+ t.Fatalf("hits = %+v, want short weak toy_play flip omitted", hits)
+ }
+ }
+}
+
+func TestBuildClipPositionHitsFromEvidenceDropsCloseFrameConflict(t *testing.T) {
+ hits := buildClipPositionHitsFromEvidence([]analyzePositionEvidence{
+ {Time: 1, Label: "doggy", Score: 0.52, HasPose: true, HasContext: true, PersonCount: 2},
+ {Time: 2, Label: "doggy", Score: 0.52, HasPose: true, HasContext: true, PersonCount: 2},
+ {Time: 3, Label: "doggy", Score: 0.52, HasPose: true, HasContext: true, PersonCount: 2},
+ {Time: 1, Label: "cowgirl", Score: 0.50, HasPose: true, HasContext: true, PersonCount: 2},
+ {Time: 2, Label: "cowgirl", Score: 0.50, HasPose: true, HasContext: true, PersonCount: 2},
+ {Time: 3, Label: "cowgirl", Score: 0.50, HasPose: true, HasContext: true, PersonCount: 2},
+ }, 8)
+
+ if len(hits) != 0 {
+ t.Fatalf("hits = %+v, want no hard position for close conflict", hits)
+ }
+}
+
func TestTrainingFilterPosePersonsByContextDropsUnannotatedPeople(t *testing.T) {
persons := []TrainingPosePerson{
{
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 5c4c899..2e8f81e 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -2771,6 +2771,10 @@ export default function App() {
// Nur automatische Starts in pending-autostart schieben.
// Manuelle UI-Starts müssen sofort direkt starten.
+ if (!immediate && !recSettingsRef.current.autoStartAddedDownloads) {
+ return true
+ }
+
if (!immediate) {
if (
provider === 'chaturbate' &&
@@ -3159,6 +3163,13 @@ export default function App() {
: 0
const percent = Math.round(progress * 100)
+ const message = String(msg?.message ?? '').trim()
+ const runningLabel =
+ total > 0
+ ? /\d{1,3}%/.test(message)
+ ? message
+ : `Analyse ${percent}%`
+ : message || 'Analyse läuft'
const state =
phase === 'error'
@@ -3172,9 +3183,7 @@ export default function App() {
? 'Analyse Fehler'
: phase === 'done'
? 'Analyse fertig'
- : total > 0
- ? `Analyse ${percent}%`
- : String(msg?.message ?? '').trim() || 'Analyse läuft'
+ : runningLabel
window.dispatchEvent(
new CustomEvent('finished-downloads:postwork', {
@@ -3450,6 +3459,8 @@ export default function App() {
try {
await apiJSON('/api/record/stop-all', { method: 'POST' })
void loadJobs()
+ void loadPendingAutoStarts()
+ void loadAutostartState().catch(() => {})
} catch (e: any) {
notify.error('Alle stoppen fehlgeschlagen', e?.message ?? String(e))
}
diff --git a/frontend/src/components/ui/Downloads.tsx b/frontend/src/components/ui/Downloads.tsx
index 572cdf8..ba46b8c 100644
--- a/frontend/src/components/ui/Downloads.tsx
+++ b/frontend/src/components/ui/Downloads.tsx
@@ -1557,14 +1557,17 @@ export default function Downloads({
}, [pending])
const totalCount = downloadJobRows.length + postworkRows.length + pendingRows.length
+ const stopAllTargetCount = stoppableIds.length + pendingRows.length
const stopAll = useCallback(async () => {
if (stopAllBusy) return
- if (stoppableIds.length === 0) return
+ if (stopAllTargetCount === 0) return
setStopAllBusy(true)
try {
- markStopRequested(stoppableIds)
+ if (stoppableIds.length > 0) {
+ markStopRequested(stoppableIds)
+ }
if (onStopAllJobs) {
await onStopAllJobs()
@@ -1574,7 +1577,7 @@ export default function Downloads({
} finally {
setStopAllBusy(false)
}
- }, [stopAllBusy, stoppableIds, markStopRequested, onStopAllJobs, onStopJob])
+ }, [stopAllBusy, stopAllTargetCount, stoppableIds, markStopRequested, onStopAllJobs, onStopJob])
const rowClassName = useCallback((r: DownloadRow) => {
if (r.kind === 'pending') {
@@ -1678,16 +1681,16 @@ export default function Downloads({
@@ -1752,15 +1755,15 @@ export default function Downloads({
diff --git a/frontend/src/components/ui/FinishedDownloads.tsx b/frontend/src/components/ui/FinishedDownloads.tsx
index 17379fb..7219a77 100644
--- a/frontend/src/components/ui/FinishedDownloads.tsx
+++ b/frontend/src/components/ui/FinishedDownloads.tsx
@@ -161,6 +161,31 @@ const keyFor = (j: RecordJob) => {
return stableFromOutput || String((j as any)?.output || '')
}
+function updateStringSet(
+ prev: Set,
+ values: Iterable,
+ enabled: boolean
+) {
+ let changed = false
+ const next = new Set(prev)
+
+ for (const value of values) {
+ const clean = String(value || '').trim()
+ if (!clean) continue
+
+ if (enabled) {
+ if (!next.has(clean)) {
+ next.add(clean)
+ changed = true
+ }
+ } else if (next.delete(clean)) {
+ changed = true
+ }
+ }
+
+ return changed ? next : prev
+}
+
const isTrashOutput = (output?: string) => {
const p = norm(String(output ?? ''))
// match: ".../.trash/file.ext" oder "...\ .trash\file.ext"
@@ -1685,7 +1710,7 @@ function samePostworkBadge(a: FinishedPostworkBadge, b: FinishedPostworkBadge):
function analysisProgressPercent(label?: string): number | null {
const s = String(label ?? '').trim()
- const percentMatch = s.match(/^analyse\s+(\d{1,3})%$/i)
+ const percentMatch = s.match(/^(?:analyse|videomae|speichern|finalisieren)\s+(\d{1,3})%$/i)
if (percentMatch) {
const n = Number(percentMatch[1])
if (!Number.isFinite(n)) return null
@@ -1725,8 +1750,22 @@ function runningStepStateLabel(step: FinishedPostworkStepSummary): string {
}
function normalizeAnalysisProgressLabel(label?: string): string | undefined {
+ const raw = String(label ?? '').trim()
const percent = analysisProgressPercent(label)
if (percent == null) return label
+
+ const phaseMatch = raw.match(/^(videomae|speichern|finalisieren)\s+\d{1,3}%$/i)
+ if (phaseMatch) {
+ const phase = phaseMatch[1].toLowerCase()
+ const prefix =
+ phase === 'videomae'
+ ? 'VideoMAE'
+ : phase === 'speichern'
+ ? 'Speichern'
+ : 'Finalisieren'
+ return `${prefix} ${percent}%`
+ }
+
return `Analyse ${percent}%`
}
@@ -2316,6 +2355,7 @@ export default function FinishedDownloads({
const [bulkDeletedSuccessKeys, setBulkDeletedSuccessKeys] = useState>(() => new Set())
const [keepingKeys, setKeepingKeys] = useState>(() => new Set())
const [keptSuccessKeys, setKeptSuccessKeys] = useState>(() => new Set())
+ const [bulkKeptSuccessKeys, setBulkKeptSuccessKeys] = useState>(() => new Set())
const [removingKeys, setRemovingKeys] = useState>(() => new Set())
const [hiddenFiles, setHiddenFiles] = useState>(() => new Set())
const [restoredJobsByKey, setRestoredJobsByKey] = useState>({})
@@ -3163,12 +3203,22 @@ export default function FinishedDownloads({
return next
}, [bulkDeletedSuccessKeys, deletedSuccessKeys])
+ const visibleKeptSuccessKeys = useMemo(() => {
+ if (bulkKeptSuccessKeys.size === 0) return keptSuccessKeys
+
+ const next = new Set(keptSuccessKeys)
+ for (const key of bulkKeptSuccessKeys) {
+ next.add(key)
+ }
+ return next
+ }, [bulkKeptSuccessKeys, keptSuccessKeys])
+
const autoPageSizeSuspended =
bulkBusy ||
deletingKeys.size > 0 ||
visibleDeletedSuccessKeys.size > 0 ||
keepingKeys.size > 0 ||
- keptSuccessKeys.size > 0 ||
+ visibleKeptSuccessKeys.size > 0 ||
removingKeys.size > 0
// -----------------------------------------------------------------------------
@@ -3592,6 +3642,26 @@ export default function FinishedDownloads({
return aliases
}, [fileAliasesFor])
+ const markDeletingAliases = useCallback((key: string, file: string | undefined, value: boolean) => {
+ const aliases = rowKeyAliasesFor(key, file)
+ setDeletingKeys((prev) => updateStringSet(prev, aliases, value))
+ }, [rowKeyAliasesFor])
+
+ const markKeepingAliases = useCallback((key: string, file: string | undefined, value: boolean) => {
+ const aliases = rowKeyAliasesFor(key, file)
+ setKeepingKeys((prev) => updateStringSet(prev, aliases, value))
+ }, [rowKeyAliasesFor])
+
+ const markBulkDeletedSuccessAliases = useCallback((key: string, file: string | undefined, value: boolean) => {
+ const aliases = rowKeyAliasesFor(key, file)
+ setBulkDeletedSuccessKeys((prev) => updateStringSet(prev, aliases, value))
+ }, [rowKeyAliasesFor])
+
+ const markBulkKeptSuccessAliases = useCallback((key: string, file: string | undefined, value: boolean) => {
+ const aliases = rowKeyAliasesFor(key, file)
+ setBulkKeptSuccessKeys((prev) => updateStringSet(prev, aliases, value))
+ }, [rowKeyAliasesFor])
+
const removeRowNow = useCallback(
(key: string, file?: string) => {
cancelRemoveTimer(key)
@@ -3645,8 +3715,10 @@ export default function FinishedDownloads({
markDeletedSuccess(key, false)
markBulkDeletedSuccess(key, false)
markKeptSuccess(key, false)
- markDeleting(key, false)
- markKeeping(key, false)
+ markDeletingAliases(key, file, false)
+ markKeepingAliases(key, file, false)
+ markBulkDeletedSuccessAliases(key, file, false)
+ markBulkKeptSuccessAliases(key, file, false)
markDeleted(key)
markRemoving(key, false)
@@ -3659,8 +3731,10 @@ export default function FinishedDownloads({
markDeletedSuccess,
markBulkDeletedSuccess,
markKeptSuccess,
- markDeleting,
- markKeeping,
+ markDeletingAliases,
+ markKeepingAliases,
+ markBulkDeletedSuccessAliases,
+ markBulkKeptSuccessAliases,
markDeleted,
markRemoving,
refreshHoverPreviewFromPointer,
@@ -3694,6 +3768,7 @@ export default function FinishedDownloads({
setBulkDeletedSuccessKeys(removeKeyAliases)
setKeepingKeys(removeKeyAliases)
setKeptSuccessKeys(removeKeyAliases)
+ setBulkKeptSuccessKeys(removeKeyAliases)
if (fileAliases.size > 0) {
setHiddenFiles((prev) => {
@@ -4560,20 +4635,17 @@ export default function FinishedDownloads({
if (selectedDeleteItems.length === 0) return
- const selectedDeleteKeys = selectedDeleteItems.map((item) => item.key)
const selectedDeleteFiles = selectedDeleteItems.map((item) => item.file)
+ const aliasesForDeleteItem = (item: { key: string; file: string }) =>
+ Array.from(rowKeyAliasesFor(item.key, item.file))
+ const selectedDeleteAliases = selectedDeleteItems.flatMap(aliasesForDeleteItem)
const deletedKeys = new Set()
const failedKeys = new Set()
+ const failedAliases = new Set()
const deletedItems: Array<{ file: string; key: string }> = []
const clearDeleting = (keys: Iterable) => {
- setDeletingKeys((prev) => {
- const next = new Set(prev)
- for (const key of keys) {
- next.delete(key)
- }
- return next
- })
+ setDeletingKeys((prev) => updateStringSet(prev, keys, false))
}
const handleResult = (item: any) => {
@@ -4587,29 +4659,27 @@ export default function FinishedDownloads({
selectedItem?.key ||
fileToKeyRef.current.get(file) ||
file
+ const aliases = rowKeyAliasesFor(key, file)
if (item?.ok) {
if (!deletedKeys.has(key)) {
deletedKeys.add(key)
deletedItems.push({ key, file })
markDeletedRowSuccess(key)
- markBulkDeletedSuccess(key, true)
+ markBulkDeletedSuccessAliases(key, file, true)
+ clearDeleting(aliases)
}
return
}
failedKeys.add(key)
- clearDeleting([key])
+ for (const alias of aliases) failedAliases.add(alias)
+ markBulkDeletedSuccessAliases(key, file, false)
+ clearDeleting(aliases)
}
// Sofort Overlay anzeigen, noch bevor der Bulk-Request läuft.
- setDeletingKeys((prev) => {
- const next = new Set(prev)
- for (const key of selectedDeleteKeys) {
- next.add(key)
- }
- return next
- })
+ setDeletingKeys((prev) => updateStringSet(prev, selectedDeleteAliases, true))
setBulkBusy(true)
@@ -4671,11 +4741,12 @@ export default function FinishedDownloads({
for (const item of selectedDeleteItems) {
if (!deletedKeys.has(item.key) && !failedKeys.has(item.key)) {
failedKeys.add(item.key)
+ for (const alias of aliasesForDeleteItem(item)) failedAliases.add(alias)
}
}
if (failedKeys.size > 0) {
- clearDeleting(failedKeys)
+ clearDeleting(failedAliases)
}
const deletedCount = deletedKeys.size
@@ -4689,7 +4760,11 @@ export default function FinishedDownloads({
}
} catch (e: any) {
// Bei komplettem Fehler alle zuvor markierten Overlays wieder entfernen.
- clearDeleting(selectedDeleteKeys.filter((key) => !deletedKeys.has(key)))
+ clearDeleting(
+ selectedDeleteItems
+ .filter((item) => !deletedKeys.has(item.key))
+ .flatMap(aliasesForDeleteItem)
+ )
if (deletedKeys.size > 0) {
clearSelection()
@@ -4709,8 +4784,9 @@ export default function FinishedDownloads({
clearSelection,
emitCountHint,
markDeletedRowSuccess,
- markBulkDeletedSuccess,
+ markBulkDeletedSuccessAliases,
removeRowsTogether,
+ rowKeyAliasesFor,
notify,
])
@@ -4737,19 +4813,16 @@ export default function FinishedDownloads({
if (selectedKeepItems.length === 0) return
const selectedKeepFiles = selectedKeepItems.map((item) => item.file)
- const selectedKeepKeys = selectedKeepItems.map((item) => item.key)
+ const aliasesForKeepItem = (item: { key: string; file: string }) =>
+ Array.from(rowKeyAliasesFor(item.key, item.file))
+ const selectedKeepAliases = selectedKeepItems.flatMap(aliasesForKeepItem)
const keptKeys = new Set()
const failedKeys = new Set()
+ const failedAliases = new Set()
const keptItems: Array<{ file: string; key: string }> = []
const clearKeeping = (keys: Iterable) => {
- setKeepingKeys((prev) => {
- const next = new Set(prev)
- for (const key of keys) {
- next.delete(key)
- }
- return next
- })
+ setKeepingKeys((prev) => updateStringSet(prev, keys, false))
}
const handleKeepSuccess = (file: string) => {
@@ -4761,21 +4834,18 @@ export default function FinishedDownloads({
selectedItem?.key ||
fileToKeyRef.current.get(cleanFile) ||
cleanFile
+ const aliases = rowKeyAliasesFor(key, cleanFile)
if (keptKeys.has(key)) return
keptKeys.add(key)
keptItems.push({ key, file: cleanFile })
markKeptRowSuccess(key)
+ markBulkKeptSuccessAliases(key, cleanFile, true)
+ clearKeeping(aliases)
}
- setKeepingKeys((prev) => {
- const next = new Set(prev)
- for (const key of selectedKeepKeys) {
- next.add(key)
- }
- return next
- })
+ setKeepingKeys((prev) => updateStringSet(prev, selectedKeepAliases, true))
setBulkBusy(true)
try {
@@ -4809,7 +4879,9 @@ export default function FinishedDownloads({
}
failedKeys.add(key)
- clearKeeping([key])
+ for (const alias of rowKeyAliasesFor(key, file)) failedAliases.add(alias)
+ markBulkKeptSuccessAliases(key, file, false)
+ clearKeeping(rowKeyAliasesFor(key, file))
}
if (results.length === 0) {
@@ -4826,11 +4898,12 @@ export default function FinishedDownloads({
for (const item of selectedKeepItems) {
if (!keptKeys.has(item.key) && !failedKeys.has(item.key)) {
failedKeys.add(item.key)
+ for (const alias of aliasesForKeepItem(item)) failedAliases.add(alias)
}
}
if (failedKeys.size > 0) {
- clearKeeping(failedKeys)
+ clearKeeping(failedAliases)
}
const keptCount = keptKeys.size
@@ -4843,7 +4916,11 @@ export default function FinishedDownloads({
notify.success?.('Keep erfolgreich', `${keptCount} Downloads behalten.`)
}
} catch (e: any) {
- clearKeeping(selectedKeepKeys.filter((key) => !keptKeys.has(key)))
+ clearKeeping(
+ selectedKeepItems
+ .filter((item) => !keptKeys.has(item.key))
+ .flatMap(aliasesForKeepItem)
+ )
if (keptKeys.size > 0) {
clearSelection()
@@ -4861,10 +4938,12 @@ export default function FinishedDownloads({
selectedFiles,
selectedJobsForBulk,
markKeptRowSuccess,
+ markBulkKeptSuccessAliases,
removeRowsTogether,
clearSelection,
emitCountHint,
includeKeep,
+ rowKeyAliasesFor,
notify,
])
@@ -6831,7 +6910,7 @@ export default function FinishedDownloads({
inlinePlay={inlinePlay}
deletingKeys={deletingKeys}
deletedSuccessKeys={visibleDeletedSuccessKeys}
- keptSuccessKeys={keptSuccessKeys}
+ keptSuccessKeys={visibleKeptSuccessKeys}
keepingKeys={keepingKeys}
removingKeys={removingKeys}
swipeRefs={swipeRefs}
@@ -6914,7 +6993,7 @@ export default function FinishedDownloads({
assetNonceForJob={assetNonceForJob}
deletingKeys={deletingKeys}
deletedSuccessKeys={visibleDeletedSuccessKeys}
- keptSuccessKeys={keptSuccessKeys}
+ keptSuccessKeys={visibleKeptSuccessKeys}
keepingKeys={keepingKeys}
removingKeys={removingKeys}
modelsByKey={modelsByKey}
@@ -6967,7 +7046,7 @@ export default function FinishedDownloads({
formatBytes={formatBytes}
deletingKeys={deletingKeys}
deletedSuccessKeys={visibleDeletedSuccessKeys}
- keptSuccessKeys={keptSuccessKeys}
+ keptSuccessKeys={visibleKeptSuccessKeys}
keepingKeys={keepingKeys}
removingKeys={removingKeys}
registerTeaserHost={registerTeaserHost}
diff --git a/frontend/src/components/ui/TrainingTab.tsx b/frontend/src/components/ui/TrainingTab.tsx
index 6c34e7b..6b31ba6 100644
--- a/frontend/src/components/ui/TrainingTab.tsx
+++ b/frontend/src/components/ui/TrainingTab.tsx
@@ -1982,6 +1982,7 @@ function updatePosePersonQuality(person: TrainingPosePerson): TrainingPosePerson
function predictionToCorrection(sample: TrainingSample | null): CorrectionState {
const p = sample?.prediction
+ const sexPosition = normalizeSexPositionValue(p?.sexPosition)
const boxes = (p?.boxes ?? [])
.map((box) => ({
@@ -1995,25 +1996,30 @@ function predictionToCorrection(sample: TrainingSample | null): CorrectionState
.filter((box) => box.label && box.w > 0 && box.h > 0)
return {
- sexPosition: normalizeSexPositionValue(p?.sexPosition),
+ sexPosition,
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,
- posePersons: clonePosePersons(p?.persons),
+ posePersons: isNoSexPositionValue(sexPosition)
+ ? []
+ : clonePosePersons(p?.persons),
}
}
function cloneCorrectionState(value: CorrectionState): CorrectionState {
+ const sexPosition = normalizeSexPositionValue(value.sexPosition)
return {
- sexPosition: value.sexPosition,
+ sexPosition,
peoplePresent: [...value.peoplePresent],
bodyPartsPresent: [...value.bodyPartsPresent],
objectsPresent: [...value.objectsPresent],
clothingPresent: [...value.clothingPresent],
boxes: (value.boxes ?? []).map((box) => ({ ...box })),
- posePersons: clonePosePersons(value.posePersons),
+ posePersons: isNoSexPositionValue(sexPosition)
+ ? []
+ : clonePosePersons(value.posePersons),
}
}
@@ -3969,10 +3975,13 @@ function annotationToCorrectionState(item: TrainingAnnotation): CorrectionState
}
if (item.correction) {
+ const sexPosition = normalizeSexPositionValue(item.correction.sexPosition)
return {
...item.correction,
- sexPosition: normalizeSexPositionValue(item.correction.sexPosition),
- posePersons: clonePosePersons(item.correction.posePersons ?? item.prediction.persons),
+ sexPosition,
+ posePersons: isNoSexPositionValue(sexPosition)
+ ? []
+ : clonePosePersons(item.correction.posePersons ?? item.prediction.persons),
}
}
@@ -7218,11 +7227,16 @@ export default function TrainingTab(props: {
const normalizedBoxes = (correction.boxes ?? [])
.map(normalizeBox)
.filter((box) => box.label && box.w > 0 && box.h > 0)
+ const normalizedSexPosition = normalizeSexPositionValue(correction.sexPosition)
+ const posePersonsForFeedback = isNoSexPositionValue(normalizedSexPosition)
+ ? []
+ : clonePosePersons(correction.posePersons)
const feedbackCorrection = {
...correction,
+ sexPosition: normalizedSexPosition,
boxes: normalizedBoxes,
- posePersons: clonePosePersons(correction.posePersons),
+ posePersons: posePersonsForFeedback,
}
const negative =
options?.negative ??
@@ -7239,9 +7253,10 @@ export default function TrainingTab(props: {
}
: {
...correction,
+ sexPosition: normalizedSexPosition,
peoplePresent: peopleLabelsFromBoxes(normalizedBoxes, labelsRef.current),
boxes: normalizedBoxes,
- posePersons: clonePosePersons(correction.posePersons),
+ posePersons: posePersonsForFeedback,
}
const effectiveAccepted = negative ? false : accepted
setSavingOverlayText(
@@ -12473,9 +12488,13 @@ export default function TrainingTab(props: {
setHasManualCorrection(true)
}
+ const nextSexPosition = normalizeSexPositionValue(value)
return {
...p,
- sexPosition: value,
+ sexPosition: nextSexPosition,
+ posePersons: isNoSexPositionValue(nextSexPosition)
+ ? []
+ : p.posePersons,
}
})
}
@@ -12677,9 +12696,13 @@ export default function TrainingTab(props: {
setHasManualCorrection(true)
}
+ const nextSexPosition = normalizeSexPositionValue(value)
return {
...p,
- sexPosition: value,
+ sexPosition: nextSexPosition,
+ posePersons: isNoSexPositionValue(nextSexPosition)
+ ? []
+ : p.posePersons,
}
})
}