Add segmented fallback for unmatched beats
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -44,7 +44,25 @@ if ($pythonVersion -notmatch "3\.(1[1-9]|[2-9]\d)") {
|
||||
|
||||
# ---- 2. Create venv ---------------------------------------------------------
|
||||
if (Test-Path $VENV_DIR) {
|
||||
$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
|
||||
|
||||
Reference in New Issue
Block a user