This commit is contained in:
Linrador 2026-07-13 11:49:46 +02:00
parent 4484fb978d
commit d538a664cf
20 changed files with 1873 additions and 507 deletions

View File

@ -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_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_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_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")) _BATCH = int(os.environ.get("YOLO_BATCH", "16"))
_IMGSZ = int(os.environ.get("YOLO_IMGSZ", "640")) _IMGSZ = int(os.environ.get("YOLO_IMGSZ", "640"))
_HALF = os.environ.get("YOLO_HALF", "0").lower() in {"1", "true", "yes", "on"} _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) 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: def is_person_like_label(label: str) -> bool:
clean = str(label or "").strip().lower() clean = str(label or "").strip().lower()
return clean == "person" or clean.startswith("person_") 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) 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: def pose_person_geometry(person: dict) -> dict:
box = normalized_box(person.get("box")) box = normalized_box(person.get("box"))
box_center_point = box_center(box) if box else None 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_dx = 0.0
torso_dy = 0.0 torso_dy = 0.0
torso_len = 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: if hip and shoulder:
torso_dx = abs(hip[0] - shoulder[0]) raw_dx = hip[0] - shoulder[0]
torso_dy = abs(hip[1] - shoulder[1]) raw_dy = hip[1] - shoulder[1]
torso_len = math.sqrt(torso_dx * torso_dx + torso_dy * torso_dy) 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) hip_width = point_distance(left_hip, right_hip)
knee_width = point_distance(left_knee, right_knee) knee_width = point_distance(left_knee, right_knee)
knees_below_hips = bool(knee and hip and knee[1] > hip[1] + 0.045) 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( knees_wide = bool(
knee_width > 0 knee_width > 0
and knee_width >= max(0.11, hip_width * 1.12) and knee_width >= max(0.11, hip_width * 1.12)
) )
straddling = knees_below_hips and knees_wide 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_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 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_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) 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 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) bent_or_kneeling = all_fours or (knees_below_hips and not straddling)
return { return {
@ -1222,6 +1330,15 @@ def pose_person_geometry(person: dict) -> dict:
"hip": hip, "hip": hip,
"shoulder": shoulder, "shoulder": shoulder,
"knee": knee, "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, "lying": lying,
"upright": upright, "upright": upright,
"straddling": straddling, "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)) 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: def append_prediction_source(prediction: dict, source: str) -> None:
source = str(source or "").strip() source = str(source or "").strip()
if not source: if not source:
@ -1272,9 +1397,6 @@ def fuse_hybrid_position_scores(
best_score = 0.0 best_score = 0.0
best_has_pose = False best_has_pose = False
best_has_context = False best_has_context = False
best_pose_position = ""
best_pose_score = 0.0
best_pose_has_context = False
for label in labels: for label in labels:
pose_score = clamp01(pose_scores.get(label, 0.0)) pose_score = clamp01(pose_scores.get(label, 0.0))
@ -1284,10 +1406,15 @@ def fuse_hybrid_position_scores(
score = 0.0 score = 0.0
if has_pose: if has_pose:
score = pose_score if has_context and context_score >= _POSE_CONFIRMING_CONTEXT_MIN_SCORE:
if has_context: score = pose_score
boost = clamp01(context_score * _POSITION_CONTEXT_BOOST_WEIGHT) boost = clamp01(context_score * _POSITION_CONTEXT_BOOST_WEIGHT)
score = clamp01(1 - (1 - score) * (1 - boost)) 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: elif context_score >= _POSITION_CONTEXT_MIN_SCORE:
score = min(_POSITION_CONTEXT_MAX_SCORE, context_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_pose = has_pose
best_has_context = has_context 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 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) gap = box_gap(left_box, right_box)
overlap = box_overlap_ratio(left_box, right_box) overlap = box_overlap_ratio(left_box, right_box)
horizontal_overlap = box_horizontal_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 close = gap <= 0.12 or overlap >= 0.08
if not close: 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 bottom = right if top is left else left
top_above = top["center"][1] <= bottom["center"][1] - 0.055 top_above = top["center"][1] <= bottom["center"][1] - 0.055
strong_stack = horizontal_overlap >= 0.35 and (top_above or overlap >= 0.22) horizontal_stack = horizontal_overlap >= 0.35 and (top_above or overlap >= 0.22)
top_has_rider_shape = ( lateral_stack = vertical_overlap >= 0.35 and overlap >= 0.12
top["straddling"] strong_stack = horizontal_stack or lateral_stack
or top["knees_wide"]
or (top["upright"] and top["knees_below_hips"])
)
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("cowgirl", 0.20)
add("reverse_cowgirl", 0.17) add("reverse_cowgirl", 0.17)
if bottom["lying"]: if base["lying"]:
add("cowgirl", 0.12) add("cowgirl", 0.12)
add("reverse_cowgirl", 0.10) add("reverse_cowgirl", 0.10)
if top["straddling"]: if rider["straddling"]:
add("cowgirl", 0.08) add("cowgirl", 0.08)
add("reverse_cowgirl", 0.06) 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"]: if strong_stack and (bottom["lying"] or (axes_parallel and top_above)):
add("missionary", 0.14) if not top_has_strong_rider_shape or axes_parallel:
if not top["straddling"]: 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) add("missionary", 0.08)
both_lying = left["lying"] and right["lying"] both_lying = left["lying"] and right["lying"]
same_level = abs(left_center[1] - right_center[1]) <= 0.15 same_level = abs(left_center[1] - right_center[1]) <= 0.15
side_by_side = abs(left_center[0] - right_center[0]) >= 0.10 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) add("spooning", 0.18)
if overlap >= 0.10: if overlap >= 0.10:
add("prone_bone", 0.07) add("prone_bone", 0.07)

File diff suppressed because it is too large Load Diff

View File

@ -115,12 +115,16 @@ func buildAnalyzeVideoMAEClips(
func predictVideoMAEPositionClipsForAnalyze( func predictVideoMAEPositionClipsForAnalyze(
ctx context.Context, ctx context.Context,
clips []analyzeVideoMAEClipReqItem, clips []analyzeVideoMAEClipReqItem,
onProgress func(current int, total int),
) ([]analyzeVideoMAEClipPrediction, error) { ) ([]analyzeVideoMAEClipPrediction, error) {
if len(clips) == 0 { if len(clips) == 0 {
return []analyzeVideoMAEClipPrediction{}, nil return []analyzeVideoMAEClipPrediction{}, nil
} }
if !trainingRecognitionEnabled() { if !trainingRecognitionEnabled() {
if onProgress != nil {
onProgress(len(clips), len(clips))
}
return []analyzeVideoMAEClipPrediction{}, nil return []analyzeVideoMAEClipPrediction{}, nil
} }
@ -192,10 +196,16 @@ func predictVideoMAEPositionClipsForAnalyze(
} }
if !parsed.Available { if !parsed.Available {
if onProgress != nil {
onProgress(len(clips), len(clips))
}
return out, nil return out, nil
} }
out = append(out, parsed.Predictions...) out = append(out, parsed.Predictions...)
if onProgress != nil {
onProgress(end, len(clips))
}
} }
return out, nil return out, nil
@ -207,6 +217,7 @@ func applyVideoMAEPositionClipsForAnalyze(
duration float64, duration float64,
highlightHits []analyzeHit, highlightHits []analyzeHit,
positionEvidence []analyzePositionEvidence, positionEvidence []analyzePositionEvidence,
onProgress func(current int, total int),
) ([]analyzeHit, []analyzePositionEvidence) { ) ([]analyzeHit, []analyzePositionEvidence) {
if !analyzeVideoMAEEnabled() { if !analyzeVideoMAEEnabled() {
return highlightHits, positionEvidence return highlightHits, positionEvidence
@ -217,9 +228,16 @@ func applyVideoMAEPositionClipsForAnalyze(
return highlightHits, positionEvidence 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 err != nil {
if ctx.Err() == nil { if ctx.Err() == nil {
if onProgress != nil {
onProgress(len(clips), len(clips))
}
appLogln("VideoMAE Clip-Analyse übersprungen:", err) appLogln("VideoMAE Clip-Analyse übersprungen:", err)
} }
return highlightHits, positionEvidence return highlightHits, positionEvidence
@ -227,7 +245,7 @@ func applyVideoMAEPositionClipsForAnalyze(
for _, pred := range predictions { for _, pred := range predictions {
label := strings.ToLower(strings.TrimSpace(pred.SexPosition)) label := strings.ToLower(strings.TrimSpace(pred.SexPosition))
if isNoSexPositionLabel(label) || !isKnownPositionLabel(label) { if !isAnalyzeTimelinePositionLabel(label) {
continue continue
} }
@ -250,16 +268,10 @@ func applyVideoMAEPositionClipsForAnalyze(
source = "videomae" source = "videomae"
} }
highlightHits = append(highlightHits, analyzeHit{
Time: pred.Time,
Label: "position:" + label,
Score: score,
Start: start,
End: end,
})
positionEvidence = append(positionEvidence, analyzePositionEvidence{ positionEvidence = append(positionEvidence, analyzePositionEvidence{
Time: pred.Time, Time: pred.Time,
Start: start,
End: end,
Label: label, Label: label,
Score: score, Score: score,
Source: source, Source: source,

View File

@ -172,15 +172,17 @@ func ensureAnalyzeAllGoalsForVideoCtxForce(
return false, skipAnalysisBecauseBadSpriteError(reason) return false, skipAnalysisBecauseBadSpriteError(reason)
} }
hits, analyzeStartedAtMs, analyzeTotalFrames, err := analyzeVideoFromFrames(actx, videoPath) hits, analyzeStartedAtMs, _, err := analyzeVideoFromFrames(actx, videoPath)
if err != nil { if err != nil {
return false, err 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 := buildAnalyzeSegmentsForGoal(hits, durationSec)
segments = prepareAIRatingSegments(segments) segments = prepareAIRatingSegments(segments)
publishAnalyzePersistProgress(analyzeStartedAtMs, analyzeFile, 0.45, "")
rating := computeHighlightRatingForVideo(segments, durationSec, videoPath) rating := computeHighlightRatingForVideo(segments, durationSec, videoPath)
@ -195,17 +197,22 @@ func ensureAnalyzeAllGoalsForVideoCtxForce(
} }
if err := writeVideoAIForFile(actx, videoPath, meta.sourceURL, ai); err != nil { if err := writeVideoAIForFile(actx, videoPath, meta.sourceURL, ai); err != nil {
publishAnalysisError(analyzeStartedAtMs, analyzeFile, "Speichern fehlgeschlagen", err)
return false, err return false, err
} }
analysisReady := hasAIAnalysisForAllOutputGoals(videoPath, requiredAnalyzeGoals()) analysisReady := hasAIAnalysisForAllOutputGoals(videoPath, requiredAnalyzeGoals())
if !analysisReady { 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. // Direkt nach erfolgreicher Analyse löschen.
// Wichtig: hier rating übergeben, nicht nil. // Wichtig: hier rating übergeben, nicht nil.
autoDeleteLowRatedDownloadAfterAnalysis(actx, videoPath, rating) autoDeleteLowRatedDownloadAfterAnalysis(actx, videoPath, rating)
publishAnalyzePersistProgress(analyzeStartedAtMs, analyzeFile, 1, "")
publishAnalysisFinished(analyzeStartedAtMs, analyzeProgressTotal, analyzeFile, "Analyse abgeschlossen")
return true, nil return true, nil
} }
@ -1587,15 +1594,17 @@ func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string
return out, nil return out, nil
} }
hits, analyzeStartedAtMs2, analyzeTotalFrames2, aerr := analyzeVideoFromFrames(ctx, videoPath) hits, analyzeStartedAtMs2, _, aerr := analyzeVideoFromFrames(ctx, videoPath)
if aerr != nil { if aerr != nil {
return out, 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 := buildAnalyzeSegmentsForGoal(hits, durationSec)
segments = prepareAIRatingSegments(segments) segments = prepareAIRatingSegments(segments)
publishAnalyzePersistProgress(analyzeStartedAtMs2, analyzeFile2, 0.45, "")
rating := computeHighlightRatingForVideo(segments, durationSec, videoPath) 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 { if werr := writeVideoAIForFile(ctx, videoPath, sourceURL, ai); werr != nil {
publishAnalysisError(analyzeStartedAtMs2, analyzeFile2, "Speichern fehlgeschlagen", werr)
return out, nil return out, nil
} }
out.AnalyzeReady = hasAIAnalysisForOutputGoal(videoPath, "highlights") 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 return out, nil
} }

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -91,6 +91,14 @@ func resolveMyFreeCamsURL(m WatchedModelLite) string {
return fmt.Sprintf("https://www.myfreecams.com/#%s", user) 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". // 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. // Wenn nach kurzer Zeit keine Output-Datei existiert (oder 0 Bytes), wird abgebrochen und der Job wieder entfernt.
func startMyFreeCamsAutoStartWorker(store *ModelStore) { func startMyFreeCamsAutoStartWorker(store *ModelStore) {
@ -116,8 +124,7 @@ func startMyFreeCamsAutoStartWorker(store *ModelStore) {
for { for {
select { select {
case <-scanTicker.C: case <-scanTicker.C:
s := getSettings() if !myFreeCamsAutostartCanRun() {
if !s.UseProviderAPIs || !s.UseMyFreeCamsAPI || !s.AutoStartAddedDownloads || isAutostartPaused() {
queue = queue[:0] queue = queue[:0]
queued = map[string]bool{} queued = map[string]bool{}
@ -390,8 +397,9 @@ func startMyFreeCamsAutoStartWorker(store *ModelStore) {
pendingAutoStartMu.Unlock() pendingAutoStartMu.Unlock()
case <-startTicker.C: case <-startTicker.C:
s := getSettings() if !myFreeCamsAutostartCanRun() {
if !s.UseProviderAPIs || !s.UseMyFreeCamsAPI || !s.AutoStartAddedDownloads || isAutostartPaused() { queue = queue[:0]
queued = map[string]bool{}
continue continue
} }
if len(queue) == 0 { if len(queue) == 0 {
@ -407,6 +415,12 @@ func startMyFreeCamsAutoStartWorker(store *ModelStore) {
queue = queue[1:] queue = queue[1:]
delete(queued, it.userKey) delete(queued, it.userKey)
if !myFreeCamsAutostartCanRun() {
queue = queue[:0]
queued = map[string]bool{}
continue
}
if isJobRunningForURL(it.url) { if isJobRunningForURL(it.url) {
continue continue
} }

View File

@ -405,6 +405,49 @@ func removeManualPendingAutoStartItemForProvider(provider, modelKey string) erro
return nil 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) { func loadPendingAutoStartItems(userKey string) ([]PendingAutoStartItem, error) {
path := pendingAutoStartFilePath(userKey) path := pendingAutoStartFilePath(userKey)

View File

@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"errors"
"os" "os"
"strings" "strings"
"sync" "sync"
@ -315,7 +316,7 @@ func (pq *PostWorkQueue) workerLoop(id int) {
func() { func() {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
_ = r appLogln("postwork task panic:", task.Key, r)
} }
pq.mu.Lock() pq.mu.Lock()
@ -357,7 +358,13 @@ func (pq *PostWorkQueue) workerLoop(id int) {
pq.mu.Unlock() pq.mu.Unlock()
if task.Run != nil { 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)
}
}
} }
}() }()
} }

View File

@ -1603,11 +1603,26 @@ func recordStopAll(w http.ResponseWriter, r *http.Request) {
} }
type stopAllResp struct { type stopAllResp struct {
OK bool `json:"ok"` OK bool `json:"ok"`
Stopped int `json:"stopped"` Stopped int `json:"stopped"`
IDs []string `json:"ids,omitempty"` 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() jobsMu.RLock()
toStop := make([]*RecordJob, 0, len(jobs)) toStop := make([]*RecordJob, 0, len(jobs))
ids := make([]string, 0, len(jobs)) ids := make([]string, 0, len(jobs))
@ -1638,9 +1653,11 @@ func recordStopAll(w http.ResponseWriter, r *http.Request) {
} }
respondJSON(w, stopAllResp{ respondJSON(w, stopAllResp{
OK: true, OK: true,
Stopped: len(toStop), Stopped: len(toStop),
IDs: ids, IDs: ids,
AutostartPaused: true,
PendingRemoved: pendingRemoved,
}) })
} }

View File

@ -1210,6 +1210,7 @@ func main() {
startPostWorkStatusRefresher() startPostWorkStatusRefresher()
startEnrichStatusRefresher() startEnrichStatusRefresher()
startCleanupTaskOnceOnStartup(appCtx)
startPostworkLeftoverScanOnStartup() startPostworkLeftoverScanOnStartup()
appGo("generated-garbage-collector", startGeneratedGarbageCollector) appGo("generated-garbage-collector", startGeneratedGarbageCollector)

View File

@ -90,8 +90,6 @@ func startPostworkLeftoverScanOnStartup() {
finishCleanupJob(jobID, summary, nil) finishCleanupJob(jobID, summary, nil)
} }
runPostworkLeftoverScan("startup")
ticker := time.NewTicker(postworkLeftoverScanInterval) ticker := time.NewTicker(postworkLeftoverScanInterval)
defer ticker.Stop() defer ticker.Stop()

View File

@ -67,6 +67,16 @@ type cleanupReq struct {
BelowMB *int `json:"belowMB,omitempty"` 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 { type cleanupTaskJob struct {
State CleanupTaskState State CleanupTaskState
Cancel context.CancelFunc Cancel context.CancelFunc
@ -75,6 +85,8 @@ type cleanupTaskJob struct {
var cleanupJobsMu sync.Mutex var cleanupJobsMu sync.Mutex
var cleanupJobs = map[string]*cleanupTaskJob{} var cleanupJobs = map[string]*cleanupTaskJob{}
const startupCleanupInitialDelay = 8 * time.Second
func createCleanupJob(jobID string, text string, cancel context.CancelFunc) CleanupTaskState { func createCleanupJob(jobID string, text string, cancel context.CancelFunc) CleanupTaskState {
st := CleanupTaskState{ st := CleanupTaskState{
ID: jobID, 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) // /api/settings/cleanup (POST)
// - löscht kleine Dateien < threshold MB (mp4/ts; skip .part/.tmp; skip keep-Ordner) // - löscht kleine Dateien < threshold MB (mp4/ts; skip .part/.tmp; skip keep-Ordner)
// - räumt Orphans (preview/thumbs + generated) auf // - räumt Orphans (preview/thumbs + generated) auf
func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) { func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodPost: 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 var req cleanupReq
if r.Body != nil { if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&req) _ = json.NewDecoder(r.Body).Decode(&req)
} }
if req.BelowMB != nil {
mb = *req.BelowMB cfg, err := cleanupTaskConfigFromSettings(&req, "Wartet…", "Räume auf…")
} if err != nil {
if mb < 0 { http.Error(w, err.Error(), http.StatusBadRequest)
mb = 0 return
} }
jobID := newTaskID("cleanup") st := startCleanupTask(context.Background(), "cleanup", cfg)
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)
writeJSON(w, http.StatusOK, st) writeJSON(w, http.StatusOK, st)
return return

View File

@ -1311,7 +1311,10 @@ const trainingPoseReliableMinQuality = 0.45
const trainingPositionContextMinScore = 0.22 const trainingPositionContextMinScore = 0.22
const trainingPositionContextMaxScore = 0.44 const trainingPositionContextMaxScore = 0.44
const trainingPositionContextBoostWeight = 0.60 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") 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) { func trainingFeedbackHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
@ -3492,6 +3537,7 @@ func trainingFeedbackHandler(w http.ResponseWriter, r *http.Request) {
req.Accepted = false req.Accepted = false
req.Correction = trainingNegativeCorrection() req.Correction = trainingNegativeCorrection()
} }
req.Correction = trainingNormalizeCorrectionForStorage(req.Correction)
root, err := trainingRootDir() root, err := trainingRootDir()
if err != nil { if err != nil {
@ -3520,6 +3566,7 @@ func trainingFeedbackHandler(w http.ResponseWriter, r *http.Request) {
Correction: req.Correction, Correction: req.Correction,
Notes: strings.TrimSpace(req.Notes), Notes: strings.TrimSpace(req.Notes),
} }
annotation = trainingStripPosePersonsForNoSexPosition(annotation)
if !annotation.Accepted && annotation.Correction == nil { if !annotation.Accepted && annotation.Correction == nil {
trainingWriteError(w, http.StatusBadRequest, "correction missing") trainingWriteError(w, http.StatusBadRequest, "correction missing")
@ -3569,6 +3616,7 @@ func trainingFeedbackUpdateHandler(w http.ResponseWriter, r *http.Request) {
req.Accepted = false req.Accepted = false
req.Correction = trainingNegativeCorrection() req.Correction = trainingNegativeCorrection()
} }
req.Correction = trainingNormalizeCorrectionForStorage(req.Correction)
if req.SampleID == "" { if req.SampleID == "" {
trainingWriteError(w, http.StatusBadRequest, "sampleId missing") trainingWriteError(w, http.StatusBadRequest, "sampleId missing")
@ -3629,6 +3677,7 @@ func trainingFeedbackUpdateHandler(w http.ResponseWriter, r *http.Request) {
} else { } else {
updated.Correction = req.Correction updated.Correction = req.Correction
} }
updated = trainingStripPosePersonsForNoSexPosition(updated)
items[matchIndex] = updated items[matchIndex] = updated
@ -4874,19 +4923,25 @@ func trainingEffectiveCorrection(annotation TrainingAnnotation) TrainingCorrecti
} }
if annotation.Correction != nil { if annotation.Correction != nil {
return *annotation.Correction correction := trainingNormalizeCorrectionForStorage(annotation.Correction)
return *correction
} }
p := annotation.Prediction p := annotation.Prediction
sexPosition := normalizeSexPositionLabel(p.SexPosition)
posePersons := p.Persons
if isNoSexPositionLabel(sexPosition) {
posePersons = []TrainingPosePerson{}
}
return TrainingCorrection{ return TrainingCorrection{
SexPosition: p.SexPosition, SexPosition: sexPosition,
PeoplePresent: trainingScoredLabelsToStrings(p.PeoplePresent), PeoplePresent: trainingScoredLabelsToStrings(p.PeoplePresent),
BodyPartsPresent: trainingScoredLabelsToStrings(p.BodyPartsPresent), BodyPartsPresent: trainingScoredLabelsToStrings(p.BodyPartsPresent),
ObjectsPresent: trainingScoredLabelsToStrings(p.ObjectsPresent), ObjectsPresent: trainingScoredLabelsToStrings(p.ObjectsPresent),
ClothingPresent: trainingScoredLabelsToStrings(p.ClothingPresent), ClothingPresent: trainingScoredLabelsToStrings(p.ClothingPresent),
Boxes: p.Boxes, Boxes: p.Boxes,
PosePersons: p.Persons, PosePersons: posePersons,
} }
} }
@ -6830,9 +6885,6 @@ func trainingFuseHybridPositionScores(
bestPositionScore := 0.0 bestPositionScore := 0.0
bestHasPose := false bestHasPose := false
bestHasContext := false bestHasContext := false
bestPosePosition := ""
bestPoseScore := 0.0
bestPoseHasContext := false
for label := range labels { for label := range labels {
poseScore := clamp01(poseScores[label]) poseScore := clamp01(poseScores[label])
@ -6843,12 +6895,21 @@ func trainingFuseHybridPositionScores(
hasContext := contextScore > 0 hasContext := contextScore > 0
if hasPose { if hasPose {
score = poseScore if hasContext && contextScore >= trainingPoseConfirmingContextMinScore {
score = poseScore
// Kontext boostet die Pose, bleibt aber bewusst Nebeninformation. // Kontext boostet die Pose nur, wenn er dieselbe Position wirklich stützt.
if hasContext {
boost := clamp01(contextScore * trainingPositionContextBoostWeight) boost := clamp01(contextScore * trainingPositionContextBoostWeight)
score = clamp01(1 - (1-score)*(1-boost)) 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 { } else if contextScore >= trainingPositionContextMinScore {
// Reiner Box-/Szenen-Kontext darf eine unsichere Prediction liefern, // Reiner Box-/Szenen-Kontext darf eine unsichere Prediction liefern,
@ -6863,20 +6924,6 @@ func trainingFuseHybridPositionScores(
bestHasContext = hasContext 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 return bestPosition, clamp01(bestPositionScore), bestHasPose, bestHasContext
@ -6991,6 +7038,27 @@ func trainingBoxHorizontalOverlapRatio(a TrainingBox, b TrainingBox) float64 {
return clamp01((right - left) / minWidth) 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 { func trainingBoxesByLabel(boxes []TrainingBox, labels ...string) []TrainingBox {
wanted := map[string]bool{} wanted := map[string]bool{}
for _, label := range labels { for _, label := range labels {
@ -7261,6 +7329,15 @@ type trainingPosePersonGeometry struct {
box TrainingBox box TrainingBox
hasBox bool hasBox bool
center trainingPosePoint center trainingPosePoint
torsoAngle float64
hasTorsoAxis bool
axisX float64
axisY float64
perpX float64
perpY float64
bodyLong float64
bodyCross float64
elongated bool
lying bool lying bool
upright bool upright bool
straddling bool straddling bool
@ -7328,6 +7405,64 @@ func trainingPosePointDistance(a trainingPosePoint, okA bool, b trainingPosePoin
return math.Sqrt(dx*dx + dy*dy) 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 { func trainingPosePersonGeometryFor(person TrainingPosePerson) trainingPosePersonGeometry {
box, hasBox := trainingNormalizedBox(person.Box) box, hasBox := trainingNormalizedBox(person.Box)
center := trainingPosePoint{x: 0.5, y: 0.5} center := trainingPosePoint{x: 0.5, y: 0.5}
@ -7353,34 +7488,94 @@ func trainingPosePersonGeometryFor(person TrainingPosePerson) trainingPosePerson
torsoDX := 0.0 torsoDX := 0.0
torsoDY := 0.0 torsoDY := 0.0
torsoLen := 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 { if okHip && okShoulder {
torsoDX = math.Abs(hip.x - shoulder.x) rawDX := hip.x - shoulder.x
torsoDY = math.Abs(hip.y - shoulder.y) rawDY := hip.y - shoulder.y
torsoLen = math.Sqrt(torsoDX*torsoDX + torsoDY*torsoDY) 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) hipWidth := trainingPosePointDistance(leftHip, okLeftHip, rightHip, okRightHip)
kneeWidth := trainingPosePointDistance(leftKnee, okLeftKnee, rightKnee, okRightKnee) kneeWidth := trainingPosePointDistance(leftKnee, okLeftKnee, rightKnee, okRightKnee)
kneesBelowHips := okKnee && okHip && knee.y > hip.y+0.045 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) kneesWide := kneeWidth > 0 && kneeWidth >= math.Max(0.11, hipWidth*1.12)
straddling := kneesBelowHips && kneesWide 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 torsoHorizontal := torsoLen >= 0.07 && torsoDX >= torsoDY*1.15
torsoVertical := torsoLen >= 0.07 && torsoDY >= torsoDX*1.15 torsoVertical := torsoLen >= 0.07 && torsoDY >= torsoDX*1.15
boxHorizontal := hasBox && box.W >= box.H*1.05 boxHorizontal := hasBox && box.W >= box.H*1.05
boxVertical := hasBox && box.H >= box.W*1.25 boxVertical := hasBox && box.H >= box.W*1.25
lying := torsoHorizontal || boxHorizontal lying := torsoHorizontal || boxHorizontal || (hasTorsoAxis && elongated && !boxVertical)
upright := torsoVertical || boxVertical upright := torsoVertical || boxVertical
allFours := torsoHorizontal && kneesBelowHips allFours := kneesBelowHips && !straddling && (torsoHorizontal || (hasTorsoAxis && elongated))
bentOrKneeling := allFours || (kneesBelowHips && !straddling) bentOrKneeling := allFours || (kneesBelowHips && !straddling)
return trainingPosePersonGeometry{ return trainingPosePersonGeometry{
box: box, box: box,
hasBox: hasBox, hasBox: hasBox,
center: center, center: center,
torsoAngle: torsoAngle,
hasTorsoAxis: hasTorsoAxis,
axisX: axisX,
axisY: axisY,
perpX: perpX,
perpY: perpY,
bodyLong: bodyLong,
bodyCross: bodyCross,
elongated: elongated,
lying: lying, lying: lying,
upright: upright, upright: upright,
straddling: straddling, straddling: straddling,
@ -7425,6 +7620,7 @@ func trainingAddPosePairGeometryScores(
gap := trainingBoxGap(left.box, right.box) gap := trainingBoxGap(left.box, right.box)
overlap := trainingBoxOverlapRatio(left.box, right.box) overlap := trainingBoxOverlapRatio(left.box, right.box)
horizontalOverlap := trainingBoxHorizontalOverlapRatio(left.box, right.box) horizontalOverlap := trainingBoxHorizontalOverlapRatio(left.box, right.box)
verticalOverlap := trainingBoxVerticalOverlapRatio(left.box, right.box)
close := gap <= 0.12 || overlap >= 0.08 close := gap <= 0.12 || overlap >= 0.08
if !close { if !close {
continue continue
@ -7438,28 +7634,73 @@ func trainingAddPosePairGeometryScores(
} }
topAbove := top.center.y <= bottom.center.y-0.055 topAbove := top.center.y <= bottom.center.y-0.055
strongStack := horizontalOverlap >= 0.35 && (topAbove || overlap >= 0.22) horizontalStack := horizontalOverlap >= 0.35 && (topAbove || overlap >= 0.22)
topHasRiderShape := top.straddling || lateralStack := verticalOverlap >= 0.35 && overlap >= 0.12
top.kneesWide || strongStack := horizontalStack || lateralStack
(top.upright && top.kneesBelowHips)
if strongStack && topHasRiderShape { axisAlignment, hasAxisAlignment := trainingPoseAxisAlignment(left, right)
add("cowgirl", 0.20) axesParallel := hasAxisAlignment && axisAlignment >= 0.74
add("reverse_cowgirl", 0.17) axesCrossed := hasAxisAlignment && axisAlignment <= 0.56
if bottom.lying { hasStrongRiderShape := func(g trainingPosePersonGeometry) bool {
add("cowgirl", 0.12) return g.straddling ||
add("reverse_cowgirl", 0.10) (g.kneesWide && g.kneesBelowHips && (g.upright || axesCrossed))
} }
if top.straddling { hasWeakRiderShape := func(g trainingPosePersonGeometry) bool {
add("cowgirl", 0.08) return g.kneesWide && g.kneesBelowHips
add("reverse_cowgirl", 0.06) }
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 { if strongStack && riderHasStrongShape && !axesParallel {
add("missionary", 0.14) add("cowgirl", 0.20)
if !top.straddling { 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) add("missionary", 0.08)
} }
} }
@ -7467,7 +7708,8 @@ func trainingAddPosePairGeometryScores(
bothLying := left.lying && right.lying bothLying := left.lying && right.lying
sameLevel := math.Abs(left.center.y-right.center.y) <= 0.15 sameLevel := math.Abs(left.center.y-right.center.y) <= 0.15
sideBySide := math.Abs(left.center.x-right.center.x) >= 0.10 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) add("spooning", 0.18)
if overlap >= 0.10 { if overlap >= 0.10 {
add("prone_bone", 0.07) add("prone_bone", 0.07)

View File

@ -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) { func TestNoSexPositionAliasesTreatUnknownAsNoPosition(t *testing.T) {
for _, value := range []string{"Unknown", "unknown", "unbekannt", "none", "no_position"} { for _, value := range []string{"Unknown", "unknown", "unbekannt", "none", "no_position"} {
if !isNoSexPositionLabel(value) { 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) { func TestTrainingApplyPoseToPredictionKeepsUnreliablePoseOutOfContext(t *testing.T) {
pred := TrainingPrediction{ pred := TrainingPrediction{
SexPosition: trainingNoSexPositionLabel, 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) { func TestTrainingApplyPoseToPredictionBoostsPoseWithBoxContext(t *testing.T) {
pred := TrainingPrediction{ pred := TrainingPrediction{
ModelAvailable: true, 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) { func TestTrainingFilterPosePersonsByContextDropsUnannotatedPeople(t *testing.T) {
persons := []TrainingPosePerson{ persons := []TrainingPosePerson{
{ {

View File

@ -2771,6 +2771,10 @@ export default function App() {
// Nur automatische Starts in pending-autostart schieben. // Nur automatische Starts in pending-autostart schieben.
// Manuelle UI-Starts müssen sofort direkt starten. // Manuelle UI-Starts müssen sofort direkt starten.
if (!immediate && !recSettingsRef.current.autoStartAddedDownloads) {
return true
}
if (!immediate) { if (!immediate) {
if ( if (
provider === 'chaturbate' && provider === 'chaturbate' &&
@ -3159,6 +3163,13 @@ export default function App() {
: 0 : 0
const percent = Math.round(progress * 100) 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 = const state =
phase === 'error' phase === 'error'
@ -3172,9 +3183,7 @@ export default function App() {
? 'Analyse Fehler' ? 'Analyse Fehler'
: phase === 'done' : phase === 'done'
? 'Analyse fertig' ? 'Analyse fertig'
: total > 0 : runningLabel
? `Analyse ${percent}%`
: String(msg?.message ?? '').trim() || 'Analyse läuft'
window.dispatchEvent( window.dispatchEvent(
new CustomEvent('finished-downloads:postwork', { new CustomEvent('finished-downloads:postwork', {
@ -3450,6 +3459,8 @@ export default function App() {
try { try {
await apiJSON('/api/record/stop-all', { method: 'POST' }) await apiJSON('/api/record/stop-all', { method: 'POST' })
void loadJobs() void loadJobs()
void loadPendingAutoStarts()
void loadAutostartState().catch(() => {})
} catch (e: any) { } catch (e: any) {
notify.error('Alle stoppen fehlgeschlagen', e?.message ?? String(e)) notify.error('Alle stoppen fehlgeschlagen', e?.message ?? String(e))
} }

View File

@ -1557,14 +1557,17 @@ export default function Downloads({
}, [pending]) }, [pending])
const totalCount = downloadJobRows.length + postworkRows.length + pendingRows.length const totalCount = downloadJobRows.length + postworkRows.length + pendingRows.length
const stopAllTargetCount = stoppableIds.length + pendingRows.length
const stopAll = useCallback(async () => { const stopAll = useCallback(async () => {
if (stopAllBusy) return if (stopAllBusy) return
if (stoppableIds.length === 0) return if (stopAllTargetCount === 0) return
setStopAllBusy(true) setStopAllBusy(true)
try { try {
markStopRequested(stoppableIds) if (stoppableIds.length > 0) {
markStopRequested(stoppableIds)
}
if (onStopAllJobs) { if (onStopAllJobs) {
await onStopAllJobs() await onStopAllJobs()
@ -1574,7 +1577,7 @@ export default function Downloads({
} finally { } finally {
setStopAllBusy(false) setStopAllBusy(false)
} }
}, [stopAllBusy, stoppableIds, markStopRequested, onStopAllJobs, onStopJob]) }, [stopAllBusy, stopAllTargetCount, stoppableIds, markStopRequested, onStopAllJobs, onStopJob])
const rowClassName = useCallback((r: DownloadRow) => { const rowClassName = useCallback((r: DownloadRow) => {
if (r.kind === 'pending') { if (r.kind === 'pending') {
@ -1678,16 +1681,16 @@ export default function Downloads({
<Button <Button
size="sm" size="sm"
variant="primary" variant="primary"
disabled={stopAllBusy || stoppableIds.length === 0} disabled={stopAllBusy || stopAllTargetCount === 0}
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
void stopAll() void stopAll()
}} }}
className="w-full justify-center" className="w-full justify-center"
title={stoppableIds.length === 0 ? 'Nichts zu stoppen' : 'Alle laufenden stoppen'} title={stopAllTargetCount === 0 ? 'Nichts zu stoppen' : 'Alle laufenden und wartenden stoppen'}
> >
{stopAllBusy ? 'Stoppe…' : `Alle stoppen (${stoppableIds.length})`} {stopAllBusy ? 'Stoppe…' : `Alle stoppen (${stopAllTargetCount})`}
</Button> </Button>
</div> </div>
</div> </div>
@ -1752,15 +1755,15 @@ export default function Downloads({
<Button <Button
size="sm" size="sm"
variant="primary" variant="primary"
disabled={stopAllBusy || stoppableIds.length === 0} disabled={stopAllBusy || stopAllTargetCount === 0}
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
void stopAll() void stopAll()
}} }}
title={stoppableIds.length === 0 ? 'Nichts zu stoppen' : 'Alle laufenden stoppen'} title={stopAllTargetCount === 0 ? 'Nichts zu stoppen' : 'Alle laufenden und wartenden stoppen'}
> >
{stopAllBusy ? 'Stoppe alle…' : `Alle stoppen (${stoppableIds.length})`} {stopAllBusy ? 'Stoppe alle…' : `Alle stoppen (${stopAllTargetCount})`}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -161,6 +161,31 @@ const keyFor = (j: RecordJob) => {
return stableFromOutput || String((j as any)?.output || '') return stableFromOutput || String((j as any)?.output || '')
} }
function updateStringSet(
prev: Set<string>,
values: Iterable<string>,
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 isTrashOutput = (output?: string) => {
const p = norm(String(output ?? '')) const p = norm(String(output ?? ''))
// match: ".../.trash/file.ext" oder "...\ .trash\file.ext" // match: ".../.trash/file.ext" oder "...\ .trash\file.ext"
@ -1685,7 +1710,7 @@ function samePostworkBadge(a: FinishedPostworkBadge, b: FinishedPostworkBadge):
function analysisProgressPercent(label?: string): number | null { function analysisProgressPercent(label?: string): number | null {
const s = String(label ?? '').trim() 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) { if (percentMatch) {
const n = Number(percentMatch[1]) const n = Number(percentMatch[1])
if (!Number.isFinite(n)) return null if (!Number.isFinite(n)) return null
@ -1725,8 +1750,22 @@ function runningStepStateLabel(step: FinishedPostworkStepSummary): string {
} }
function normalizeAnalysisProgressLabel(label?: string): string | undefined { function normalizeAnalysisProgressLabel(label?: string): string | undefined {
const raw = String(label ?? '').trim()
const percent = analysisProgressPercent(label) const percent = analysisProgressPercent(label)
if (percent == null) return 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}%` return `Analyse ${percent}%`
} }
@ -2316,6 +2355,7 @@ export default function FinishedDownloads({
const [bulkDeletedSuccessKeys, setBulkDeletedSuccessKeys] = useState<Set<string>>(() => new Set()) const [bulkDeletedSuccessKeys, setBulkDeletedSuccessKeys] = useState<Set<string>>(() => new Set())
const [keepingKeys, setKeepingKeys] = useState<Set<string>>(() => new Set()) const [keepingKeys, setKeepingKeys] = useState<Set<string>>(() => new Set())
const [keptSuccessKeys, setKeptSuccessKeys] = useState<Set<string>>(() => new Set()) const [keptSuccessKeys, setKeptSuccessKeys] = useState<Set<string>>(() => new Set())
const [bulkKeptSuccessKeys, setBulkKeptSuccessKeys] = useState<Set<string>>(() => new Set())
const [removingKeys, setRemovingKeys] = useState<Set<string>>(() => new Set()) const [removingKeys, setRemovingKeys] = useState<Set<string>>(() => new Set())
const [hiddenFiles, setHiddenFiles] = useState<Set<string>>(() => new Set()) const [hiddenFiles, setHiddenFiles] = useState<Set<string>>(() => new Set())
const [restoredJobsByKey, setRestoredJobsByKey] = useState<Record<string, RecordJob>>({}) const [restoredJobsByKey, setRestoredJobsByKey] = useState<Record<string, RecordJob>>({})
@ -3163,12 +3203,22 @@ export default function FinishedDownloads({
return next return next
}, [bulkDeletedSuccessKeys, deletedSuccessKeys]) }, [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 = const autoPageSizeSuspended =
bulkBusy || bulkBusy ||
deletingKeys.size > 0 || deletingKeys.size > 0 ||
visibleDeletedSuccessKeys.size > 0 || visibleDeletedSuccessKeys.size > 0 ||
keepingKeys.size > 0 || keepingKeys.size > 0 ||
keptSuccessKeys.size > 0 || visibleKeptSuccessKeys.size > 0 ||
removingKeys.size > 0 removingKeys.size > 0
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@ -3592,6 +3642,26 @@ export default function FinishedDownloads({
return aliases return aliases
}, [fileAliasesFor]) }, [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( const removeRowNow = useCallback(
(key: string, file?: string) => { (key: string, file?: string) => {
cancelRemoveTimer(key) cancelRemoveTimer(key)
@ -3645,8 +3715,10 @@ export default function FinishedDownloads({
markDeletedSuccess(key, false) markDeletedSuccess(key, false)
markBulkDeletedSuccess(key, false) markBulkDeletedSuccess(key, false)
markKeptSuccess(key, false) markKeptSuccess(key, false)
markDeleting(key, false) markDeletingAliases(key, file, false)
markKeeping(key, false) markKeepingAliases(key, file, false)
markBulkDeletedSuccessAliases(key, file, false)
markBulkKeptSuccessAliases(key, file, false)
markDeleted(key) markDeleted(key)
markRemoving(key, false) markRemoving(key, false)
@ -3659,8 +3731,10 @@ export default function FinishedDownloads({
markDeletedSuccess, markDeletedSuccess,
markBulkDeletedSuccess, markBulkDeletedSuccess,
markKeptSuccess, markKeptSuccess,
markDeleting, markDeletingAliases,
markKeeping, markKeepingAliases,
markBulkDeletedSuccessAliases,
markBulkKeptSuccessAliases,
markDeleted, markDeleted,
markRemoving, markRemoving,
refreshHoverPreviewFromPointer, refreshHoverPreviewFromPointer,
@ -3694,6 +3768,7 @@ export default function FinishedDownloads({
setBulkDeletedSuccessKeys(removeKeyAliases) setBulkDeletedSuccessKeys(removeKeyAliases)
setKeepingKeys(removeKeyAliases) setKeepingKeys(removeKeyAliases)
setKeptSuccessKeys(removeKeyAliases) setKeptSuccessKeys(removeKeyAliases)
setBulkKeptSuccessKeys(removeKeyAliases)
if (fileAliases.size > 0) { if (fileAliases.size > 0) {
setHiddenFiles((prev) => { setHiddenFiles((prev) => {
@ -4560,20 +4635,17 @@ export default function FinishedDownloads({
if (selectedDeleteItems.length === 0) return if (selectedDeleteItems.length === 0) return
const selectedDeleteKeys = selectedDeleteItems.map((item) => item.key)
const selectedDeleteFiles = selectedDeleteItems.map((item) => item.file) 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<string>() const deletedKeys = new Set<string>()
const failedKeys = new Set<string>() const failedKeys = new Set<string>()
const failedAliases = new Set<string>()
const deletedItems: Array<{ file: string; key: string }> = [] const deletedItems: Array<{ file: string; key: string }> = []
const clearDeleting = (keys: Iterable<string>) => { const clearDeleting = (keys: Iterable<string>) => {
setDeletingKeys((prev) => { setDeletingKeys((prev) => updateStringSet(prev, keys, false))
const next = new Set(prev)
for (const key of keys) {
next.delete(key)
}
return next
})
} }
const handleResult = (item: any) => { const handleResult = (item: any) => {
@ -4587,29 +4659,27 @@ export default function FinishedDownloads({
selectedItem?.key || selectedItem?.key ||
fileToKeyRef.current.get(file) || fileToKeyRef.current.get(file) ||
file file
const aliases = rowKeyAliasesFor(key, file)
if (item?.ok) { if (item?.ok) {
if (!deletedKeys.has(key)) { if (!deletedKeys.has(key)) {
deletedKeys.add(key) deletedKeys.add(key)
deletedItems.push({ key, file }) deletedItems.push({ key, file })
markDeletedRowSuccess(key) markDeletedRowSuccess(key)
markBulkDeletedSuccess(key, true) markBulkDeletedSuccessAliases(key, file, true)
clearDeleting(aliases)
} }
return return
} }
failedKeys.add(key) 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. // Sofort Overlay anzeigen, noch bevor der Bulk-Request läuft.
setDeletingKeys((prev) => { setDeletingKeys((prev) => updateStringSet(prev, selectedDeleteAliases, true))
const next = new Set(prev)
for (const key of selectedDeleteKeys) {
next.add(key)
}
return next
})
setBulkBusy(true) setBulkBusy(true)
@ -4671,11 +4741,12 @@ export default function FinishedDownloads({
for (const item of selectedDeleteItems) { for (const item of selectedDeleteItems) {
if (!deletedKeys.has(item.key) && !failedKeys.has(item.key)) { if (!deletedKeys.has(item.key) && !failedKeys.has(item.key)) {
failedKeys.add(item.key) failedKeys.add(item.key)
for (const alias of aliasesForDeleteItem(item)) failedAliases.add(alias)
} }
} }
if (failedKeys.size > 0) { if (failedKeys.size > 0) {
clearDeleting(failedKeys) clearDeleting(failedAliases)
} }
const deletedCount = deletedKeys.size const deletedCount = deletedKeys.size
@ -4689,7 +4760,11 @@ export default function FinishedDownloads({
} }
} catch (e: any) { } catch (e: any) {
// Bei komplettem Fehler alle zuvor markierten Overlays wieder entfernen. // 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) { if (deletedKeys.size > 0) {
clearSelection() clearSelection()
@ -4709,8 +4784,9 @@ export default function FinishedDownloads({
clearSelection, clearSelection,
emitCountHint, emitCountHint,
markDeletedRowSuccess, markDeletedRowSuccess,
markBulkDeletedSuccess, markBulkDeletedSuccessAliases,
removeRowsTogether, removeRowsTogether,
rowKeyAliasesFor,
notify, notify,
]) ])
@ -4737,19 +4813,16 @@ export default function FinishedDownloads({
if (selectedKeepItems.length === 0) return if (selectedKeepItems.length === 0) return
const selectedKeepFiles = selectedKeepItems.map((item) => item.file) 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<string>() const keptKeys = new Set<string>()
const failedKeys = new Set<string>() const failedKeys = new Set<string>()
const failedAliases = new Set<string>()
const keptItems: Array<{ file: string; key: string }> = [] const keptItems: Array<{ file: string; key: string }> = []
const clearKeeping = (keys: Iterable<string>) => { const clearKeeping = (keys: Iterable<string>) => {
setKeepingKeys((prev) => { setKeepingKeys((prev) => updateStringSet(prev, keys, false))
const next = new Set(prev)
for (const key of keys) {
next.delete(key)
}
return next
})
} }
const handleKeepSuccess = (file: string) => { const handleKeepSuccess = (file: string) => {
@ -4761,21 +4834,18 @@ export default function FinishedDownloads({
selectedItem?.key || selectedItem?.key ||
fileToKeyRef.current.get(cleanFile) || fileToKeyRef.current.get(cleanFile) ||
cleanFile cleanFile
const aliases = rowKeyAliasesFor(key, cleanFile)
if (keptKeys.has(key)) return if (keptKeys.has(key)) return
keptKeys.add(key) keptKeys.add(key)
keptItems.push({ key, file: cleanFile }) keptItems.push({ key, file: cleanFile })
markKeptRowSuccess(key) markKeptRowSuccess(key)
markBulkKeptSuccessAliases(key, cleanFile, true)
clearKeeping(aliases)
} }
setKeepingKeys((prev) => { setKeepingKeys((prev) => updateStringSet(prev, selectedKeepAliases, true))
const next = new Set(prev)
for (const key of selectedKeepKeys) {
next.add(key)
}
return next
})
setBulkBusy(true) setBulkBusy(true)
try { try {
@ -4809,7 +4879,9 @@ export default function FinishedDownloads({
} }
failedKeys.add(key) 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) { if (results.length === 0) {
@ -4826,11 +4898,12 @@ export default function FinishedDownloads({
for (const item of selectedKeepItems) { for (const item of selectedKeepItems) {
if (!keptKeys.has(item.key) && !failedKeys.has(item.key)) { if (!keptKeys.has(item.key) && !failedKeys.has(item.key)) {
failedKeys.add(item.key) failedKeys.add(item.key)
for (const alias of aliasesForKeepItem(item)) failedAliases.add(alias)
} }
} }
if (failedKeys.size > 0) { if (failedKeys.size > 0) {
clearKeeping(failedKeys) clearKeeping(failedAliases)
} }
const keptCount = keptKeys.size const keptCount = keptKeys.size
@ -4843,7 +4916,11 @@ export default function FinishedDownloads({
notify.success?.('Keep erfolgreich', `${keptCount} Downloads behalten.`) notify.success?.('Keep erfolgreich', `${keptCount} Downloads behalten.`)
} }
} catch (e: any) { } catch (e: any) {
clearKeeping(selectedKeepKeys.filter((key) => !keptKeys.has(key))) clearKeeping(
selectedKeepItems
.filter((item) => !keptKeys.has(item.key))
.flatMap(aliasesForKeepItem)
)
if (keptKeys.size > 0) { if (keptKeys.size > 0) {
clearSelection() clearSelection()
@ -4861,10 +4938,12 @@ export default function FinishedDownloads({
selectedFiles, selectedFiles,
selectedJobsForBulk, selectedJobsForBulk,
markKeptRowSuccess, markKeptRowSuccess,
markBulkKeptSuccessAliases,
removeRowsTogether, removeRowsTogether,
clearSelection, clearSelection,
emitCountHint, emitCountHint,
includeKeep, includeKeep,
rowKeyAliasesFor,
notify, notify,
]) ])
@ -6831,7 +6910,7 @@ export default function FinishedDownloads({
inlinePlay={inlinePlay} inlinePlay={inlinePlay}
deletingKeys={deletingKeys} deletingKeys={deletingKeys}
deletedSuccessKeys={visibleDeletedSuccessKeys} deletedSuccessKeys={visibleDeletedSuccessKeys}
keptSuccessKeys={keptSuccessKeys} keptSuccessKeys={visibleKeptSuccessKeys}
keepingKeys={keepingKeys} keepingKeys={keepingKeys}
removingKeys={removingKeys} removingKeys={removingKeys}
swipeRefs={swipeRefs} swipeRefs={swipeRefs}
@ -6914,7 +6993,7 @@ export default function FinishedDownloads({
assetNonceForJob={assetNonceForJob} assetNonceForJob={assetNonceForJob}
deletingKeys={deletingKeys} deletingKeys={deletingKeys}
deletedSuccessKeys={visibleDeletedSuccessKeys} deletedSuccessKeys={visibleDeletedSuccessKeys}
keptSuccessKeys={keptSuccessKeys} keptSuccessKeys={visibleKeptSuccessKeys}
keepingKeys={keepingKeys} keepingKeys={keepingKeys}
removingKeys={removingKeys} removingKeys={removingKeys}
modelsByKey={modelsByKey} modelsByKey={modelsByKey}
@ -6967,7 +7046,7 @@ export default function FinishedDownloads({
formatBytes={formatBytes} formatBytes={formatBytes}
deletingKeys={deletingKeys} deletingKeys={deletingKeys}
deletedSuccessKeys={visibleDeletedSuccessKeys} deletedSuccessKeys={visibleDeletedSuccessKeys}
keptSuccessKeys={keptSuccessKeys} keptSuccessKeys={visibleKeptSuccessKeys}
keepingKeys={keepingKeys} keepingKeys={keepingKeys}
removingKeys={removingKeys} removingKeys={removingKeys}
registerTeaserHost={registerTeaserHost} registerTeaserHost={registerTeaserHost}

View File

@ -1982,6 +1982,7 @@ function updatePosePersonQuality(person: TrainingPosePerson): TrainingPosePerson
function predictionToCorrection(sample: TrainingSample | null): CorrectionState { function predictionToCorrection(sample: TrainingSample | null): CorrectionState {
const p = sample?.prediction const p = sample?.prediction
const sexPosition = normalizeSexPositionValue(p?.sexPosition)
const boxes = (p?.boxes ?? []) const boxes = (p?.boxes ?? [])
.map((box) => ({ .map((box) => ({
@ -1995,25 +1996,30 @@ function predictionToCorrection(sample: TrainingSample | null): CorrectionState
.filter((box) => box.label && box.w > 0 && box.h > 0) .filter((box) => box.label && box.w > 0 && box.h > 0)
return { return {
sexPosition: normalizeSexPositionValue(p?.sexPosition), sexPosition,
peoplePresent: (p?.peoplePresent ?? []).map((x) => x.label), peoplePresent: (p?.peoplePresent ?? []).map((x) => x.label),
bodyPartsPresent: (p?.bodyPartsPresent ?? []).map((x) => x.label), bodyPartsPresent: (p?.bodyPartsPresent ?? []).map((x) => x.label),
objectsPresent: (p?.objectsPresent ?? []).map((x) => x.label), objectsPresent: (p?.objectsPresent ?? []).map((x) => x.label),
clothingPresent: (p?.clothingPresent ?? []).map((x) => x.label), clothingPresent: (p?.clothingPresent ?? []).map((x) => x.label),
boxes, boxes,
posePersons: clonePosePersons(p?.persons), posePersons: isNoSexPositionValue(sexPosition)
? []
: clonePosePersons(p?.persons),
} }
} }
function cloneCorrectionState(value: CorrectionState): CorrectionState { function cloneCorrectionState(value: CorrectionState): CorrectionState {
const sexPosition = normalizeSexPositionValue(value.sexPosition)
return { return {
sexPosition: value.sexPosition, sexPosition,
peoplePresent: [...value.peoplePresent], peoplePresent: [...value.peoplePresent],
bodyPartsPresent: [...value.bodyPartsPresent], bodyPartsPresent: [...value.bodyPartsPresent],
objectsPresent: [...value.objectsPresent], objectsPresent: [...value.objectsPresent],
clothingPresent: [...value.clothingPresent], clothingPresent: [...value.clothingPresent],
boxes: (value.boxes ?? []).map((box) => ({ ...box })), 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) { if (item.correction) {
const sexPosition = normalizeSexPositionValue(item.correction.sexPosition)
return { return {
...item.correction, ...item.correction,
sexPosition: normalizeSexPositionValue(item.correction.sexPosition), sexPosition,
posePersons: clonePosePersons(item.correction.posePersons ?? item.prediction.persons), posePersons: isNoSexPositionValue(sexPosition)
? []
: clonePosePersons(item.correction.posePersons ?? item.prediction.persons),
} }
} }
@ -7218,11 +7227,16 @@ export default function TrainingTab(props: {
const normalizedBoxes = (correction.boxes ?? []) const normalizedBoxes = (correction.boxes ?? [])
.map(normalizeBox) .map(normalizeBox)
.filter((box) => box.label && box.w > 0 && box.h > 0) .filter((box) => box.label && box.w > 0 && box.h > 0)
const normalizedSexPosition = normalizeSexPositionValue(correction.sexPosition)
const posePersonsForFeedback = isNoSexPositionValue(normalizedSexPosition)
? []
: clonePosePersons(correction.posePersons)
const feedbackCorrection = { const feedbackCorrection = {
...correction, ...correction,
sexPosition: normalizedSexPosition,
boxes: normalizedBoxes, boxes: normalizedBoxes,
posePersons: clonePosePersons(correction.posePersons), posePersons: posePersonsForFeedback,
} }
const negative = const negative =
options?.negative ?? options?.negative ??
@ -7239,9 +7253,10 @@ export default function TrainingTab(props: {
} }
: { : {
...correction, ...correction,
sexPosition: normalizedSexPosition,
peoplePresent: peopleLabelsFromBoxes(normalizedBoxes, labelsRef.current), peoplePresent: peopleLabelsFromBoxes(normalizedBoxes, labelsRef.current),
boxes: normalizedBoxes, boxes: normalizedBoxes,
posePersons: clonePosePersons(correction.posePersons), posePersons: posePersonsForFeedback,
} }
const effectiveAccepted = negative ? false : accepted const effectiveAccepted = negative ? false : accepted
setSavingOverlayText( setSavingOverlayText(
@ -12473,9 +12488,13 @@ export default function TrainingTab(props: {
setHasManualCorrection(true) setHasManualCorrection(true)
} }
const nextSexPosition = normalizeSexPositionValue(value)
return { return {
...p, ...p,
sexPosition: value, sexPosition: nextSexPosition,
posePersons: isNoSexPositionValue(nextSexPosition)
? []
: p.posePersons,
} }
}) })
} }
@ -12677,9 +12696,13 @@ export default function TrainingTab(props: {
setHasManualCorrection(true) setHasManualCorrection(true)
} }
const nextSexPosition = normalizeSexPositionValue(value)
return { return {
...p, ...p,
sexPosition: value, sexPosition: nextSexPosition,
posePersons: isNoSexPositionValue(nextSexPosition)
? []
: p.posePersons,
} }
}) })
} }