Add segmented fallback for unmatched beats

This commit is contained in:
Melbar
2026-05-02 11:46:33 +02:00
parent 535176c144
commit f1173eacee
3 changed files with 201 additions and 1 deletions
+7
View File
@@ -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,
+175
View File
@@ -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
+19 -1
View File
@@ -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