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