From f1173eaceea802b0f3ea5564c669aecea3137192 Mon Sep 17 00:00:00 2001 From: Melbar Date: Sat, 2 May 2026 11:46:33 +0200 Subject: [PATCH] Add segmented fallback for unmatched beats --- README.md | 7 ++ cli.py | 175 +++++++++++++++++++++++++++++++++++++++++++++++++ setup_venv.ps1 | 20 +++++- 3 files changed, 201 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index db9405a..4786391 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,13 @@ Insel; der HTML-Report setzt diese Source-Segmente frame-lockend zusammen und füllt nur echte Zwischenlücken mit Schwarz. Dadurch können per Blende verbundene Trailer-Einstellungen innerhalb eines Beats getrennt gematcht werden, ohne die globale Scene Detection aggressiver oder beat-spezifisch zu kuratieren. +Falls ein kompletter Beat keinen belastbaren Einzelclip ergibt, versucht der +Matcher dieselbe Segmentlogik automatisch als Fallback: sichtbare Inseln werden +einzeln global gesucht und anschließend wieder zu einem Beat-Ergebnis +zusammengesetzt. Sehr kurze Inseln dürfen zusätzlich in den Source-Szenen +benachbarter bereits gematchter Beats lokal nach ihrer Bewegungsphase suchen. +Das ist weiterhin nur ein allgemeiner Continuity-Anker, kein manueller Override +für bestimmte Beat-Nummern oder Szenen. Zusätzlich werden sehr dunkle, kontrastarme oder noch nicht sauber auf-/abgeblendete Referenzframes aus Score, Inhalts-Reranking, Phasen-Alignment und Motion-Templates herausgenommen. Blenden sollen bestimmen, diff --git a/cli.py b/cli.py index 5105d5f..ecb6436 100644 --- a/cli.py +++ b/cli.py @@ -208,6 +208,22 @@ def _normalize_cached_results(beats: list, results: list, cfg) -> list: normalized = [] for result in results: beat = beats_by_id.get(result.beat_id) + if getattr(result, "segments", ()): + segment_duration = sum(max(0.0, float(s.duration_s)) for s in result.segments) + weighted_score = ( + sum(max(0.0, float(s.duration_s)) * float(s.match_score) for s in result.segments) + / segment_duration + if segment_duration > 0 else result.match_score + ) + if weighted_score < cfg.cv.deep_scan.provisional_match_threshold: + continue + if beat is not None and beat.duration_s > 0: + coverage = segment_duration / beat.duration_s + if coverage < cfg.cv.deep_scan.min_duration_coverage: + continue + normalized.append(replace(result, match_score=weighted_score)) + continue + if result.match_score < cfg.cv.deep_scan.provisional_match_threshold: continue @@ -482,6 +498,9 @@ def _attach_visual_segments(results: list, beats: list, cfg) -> list: if beat is None: expanded.append(result) continue + if getattr(result, "segments", ()): + expanded.append(result) + continue islands = _reference_scoreable_segments(beat, cfg) if len(islands) <= 1: @@ -539,6 +558,161 @@ def _attach_visual_segments(results: list, beats: list, cfg) -> list: return expanded +def _match_unmatched_visual_segments(results: list, beats: list, cached: list, cfg) -> list: + """Create segmented provisional matches when a whole beat has no single match.""" + from dataclasses import replace + from src.core.models import MatchResult, MatchSegment + from src.cv.frame_extractor import get_video_info + from src.cv.global_scan import run_global_scan + + matched_ids = {r.beat_id for r in results} + expanded = list(results) + try: + fps = float(get_video_info(cfg.paths.source_movie)["fps"]) or cfg.export.edl_frame_rate + except Exception: + fps = cfg.export.edl_frame_rate + + for beat in beats: + if beat.beat_id in matched_ids: + continue + + islands = _reference_scoreable_segments(beat, cfg) + if not islands: + continue + + segments: list[MatchSegment] = [] + for start_s, end_s in islands: + segment_beat = replace( + beat, + start_s=beat.start_s + start_s, + end_s=beat.start_s + end_s, + ) + continuity = _continuity_seed_in_points( + beat.beat_id, + [b if b.beat_id != beat.beat_id else segment_beat for b in beats], + cached + expanded, + cfg, + ) + segment_matches = run_global_scan( + [segment_beat], + cfg, + seed_in_points=continuity, + ) + if not segment_matches: + local_segment = _local_same_scene_segment_match( + segment_beat, + beat, + start_s, + cached + expanded, + cfg, + ) + if local_segment is not None: + segments.append(local_segment) + continue + seg = segment_matches[0] + seg_dur = min(max(0.0, end_s - start_s), max(0.0, seg.duration_s)) + segments.append( + MatchSegment( + trailer_offset_s=start_s, + duration_s=seg_dur, + scene_id=seg.scene_id, + in_point_s=seg.in_point_s, + out_point_s=seg.in_point_s + seg_dur, + match_score=seg.match_score, + is_confirmed=seg.is_confirmed, + ) + ) + + if not segments: + continue + + first = segments[0] + total_segment_duration = sum(max(0.0, s.duration_s) for s in segments) + score = ( + sum(max(0.0, s.duration_s) * s.match_score for s in segments) / total_segment_duration + if total_segment_duration > 0 else min(s.match_score for s in segments) + ) + expanded.append( + MatchResult( + beat_id=beat.beat_id, + scene_id=first.scene_id, + source_path=cfg.paths.source_movie, + in_point_s=first.in_point_s, + out_point_s=first.out_point_s, + in_point_frame=int(max(0.0, first.in_point_s) * fps), + match_score=score, + is_confirmed=all(s.is_confirmed for s in segments), + segments=tuple(segments), + ) + ) + + return expanded + + +def _local_same_scene_segment_match(segment_beat, beat, segment_offset_s: float, cached: list, cfg): + """Find a short trailer island inside scenes adjacent to neighbouring beat matches.""" + from src.core.models import MatchSegment + from src.cv.frame_extractor import open_video + from src.cv.global_scan import _content_alignment_score, _content_alignment_templates + + scenes = _load_scene_cache_light(cfg) + if not scenes: + return None + + by_id = {r.beat_id: r for r in cached} + scene_ids: list[int] = [] + for neighbour_id in (beat.beat_id - 1, beat.beat_id + 1): + result = by_id.get(neighbour_id) + if result is None: + continue + ids = [getattr(s, "scene_id", result.scene_id) for s in getattr(result, "segments", ())] or [result.scene_id] + for scene_id in ids: + if scene_id not in scene_ids: + scene_ids.append(scene_id) + + if not scene_ids: + return None + + templates = _content_alignment_templates(segment_beat, cfg) + if not templates: + return None + + min_score = min( + cfg.cv.deep_scan.provisional_content_threshold * 0.70, + cfg.cv.deep_scan.provisional_match_threshold, + ) + step_s = max(1.0 / cfg.export.edl_frame_rate, 0.04) + best: tuple[float, float, int] | None = None + with open_video(cfg.paths.source_movie) as cap: + for scene_id in scene_ids: + scene = next((s for s in scenes if int(s["scene_id"]) == int(scene_id)), None) + if scene is None: + continue + start_s = max(0.0, float(scene["start_s"]) - 0.25) + end_s = max(start_s, float(scene["end_s"]) - max(0.04, segment_beat.duration_s) + 0.25) + t = start_s + while t <= end_s: + score = _content_alignment_score(cap, t, templates, cfg) + if best is None or score > best[0]: + best = (score, t, int(scene_id)) + t = round(t + step_s, 6) + + if best is None or best[0] < min_score: + return None + + score, in_point_s, scene_id = best + duration_s = max(0.0, min(segment_beat.duration_s, segment_beat.end_s - segment_beat.start_s)) + return MatchSegment( + trailer_offset_s=segment_offset_s, + duration_s=duration_s, + scene_id=scene_id, + in_point_s=in_point_s, + out_point_s=in_point_s + duration_s, + match_score=score, + is_confirmed=score >= cfg.cv.deep_scan.match_threshold, + ) + + def cmd_match(args: argparse.Namespace, cfg) -> list: from src.pipeline.matcher import run_matching from dataclasses import replace @@ -562,6 +736,7 @@ def cmd_match(args: argparse.Namespace, cfg) -> list: force_reindex=args.force_reindex, seed_in_points=seed_in_points, ) + results = _match_unmatched_visual_segments(results, beats, cached, cfg) results = _attach_visual_segments(results, beats, cfg) # A targeted one-beat match should improve the cache without deleting diff --git a/setup_venv.ps1 b/setup_venv.ps1 index dac843d..4134791 100644 --- a/setup_venv.ps1 +++ b/setup_venv.ps1 @@ -44,7 +44,25 @@ if ($pythonVersion -notmatch "3\.(1[1-9]|[2-9]\d)") { # ---- 2. Create venv --------------------------------------------------------- if (Test-Path $VENV_DIR) { - Write-Host "Virtual environment already exists at '$VENV_DIR'. Skipping creation." -ForegroundColor Yellow + $existingVenvPython = Join-Path $VENV_DIR "Scripts\python.exe" + $venvOk = $false + if (Test-Path $existingVenvPython) { + try { + $existingVersion = & $existingVenvPython --version 2>&1 + $venvOk = $LASTEXITCODE -eq 0 -and $existingVersion -match "3\.(1[1-9]|[2-9]\d)" + } catch { + $venvOk = $false + } + } + + if ($venvOk) { + Write-Host "Virtual environment already exists at '$VENV_DIR'. Skipping creation." -ForegroundColor Yellow + } else { + Write-Host "Existing virtual environment is not usable. Recreating '$VENV_DIR' ..." -ForegroundColor Yellow + Remove-Item -LiteralPath $VENV_DIR -Recurse -Force + & $PROJECT_PYTHON -m venv $VENV_DIR + Write-Host "Done." -ForegroundColor Green + } } else { Write-Host "Creating virtual environment in '$VENV_DIR' ..." -ForegroundColor Green & $PROJECT_PYTHON -m venv $VENV_DIR