From 3b42c5d0180b6fcb855fda047f7998b671521423 Mon Sep 17 00:00:00 2001 From: Melbar Date: Sat, 9 May 2026 18:48:24 +0200 Subject: [PATCH] Mark trailer title cards as graphics --- README.md | 2 +- docs/ALGORITHM.md | 7 +++++ scripts/generate_cutter_report.py | 52 +++++++++++++++++++++++++++---- 3 files changed, 54 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a420e9f..adfcdcd 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ wenn sich das zugrundeliegende Match geändert hat. | Source-Clip zeigt richtige Szene, aber falsche Bewegungsphase | `python cli.py rematch --beat N --refine` — schiebt den Inpoint frame-genau aus dem Bildinhalt. | | Score zu niedrig, andere Szene wäre richtig | `python cli.py match --beat N --vision` — vollständiger Re-Match nur für diesen Beat mit Vision-Phasenprüfung. | | Match offensichtlich falsche Szene | `python cli.py rematch --beat N --threshold 0.50` — Schwelle absenken, neuer globaler Scan nur für diesen Beat. | -| Beat ist Schwarzbild / Logo / Titel und sollte gar nicht matchen | nichts tun, der Status `MAN.` im `CUTTER_REPORT.md` ist korrekt. | +| Beat ist Schwarzbild / Logo / Titel und sollte gar nicht matchen | nichts tun, der Status `GFX` im `CUTTER_REPORT.md` ist korrekt. | ### Algorithmische Details diff --git a/docs/ALGORITHM.md b/docs/ALGORITHM.md index 69555e1..7eff0cb 100644 --- a/docs/ALGORITHM.md +++ b/docs/ALGORITHM.md @@ -245,6 +245,13 @@ nur wenn die relative Source-Grenze zeitlich zu einem erkannten Trailer- Umschnitt passt. So kann ein Beat aus Frage/Antwort-Shots vollständig erfasst werden, ohne Szenen willkürlich zusammenzukleben. +## Titel- und Grafikbeats + +Dunkle Trailerkarten mit deutlich isoliertem Text werden im Cutter-Report als +`GFX` markiert, wenn es keinen Source-Treffer gibt. Diese Beats sind keine +fehlgeschlagenen Matches: Der Cutter soll die Trailer-Grafik beziehungsweise +eine NLE-Titelkarte übernehmen und nicht im Spielfilm nach einem Bild suchen. + ## Reranking-Pipeline Vor dem teuren Frame-Refine wird der gesamte Kandidatenpool mit einer diff --git a/scripts/generate_cutter_report.py b/scripts/generate_cutter_report.py index 177908f..c2cea67 100644 --- a/scripts/generate_cutter_report.py +++ b/scripts/generate_cutter_report.py @@ -30,6 +30,8 @@ from dataclasses import dataclass, field from datetime import datetime from pathlib import Path +from PIL import Image, ImageStat + # --------------------------------------------------------------------------- # Frame-rate / timecode helpers # --------------------------------------------------------------------------- @@ -388,15 +390,20 @@ class BeatRow: trailer_clip: Path | None = None source_clip: Path | None = None compare_clip: Path | None = None + is_graphic: bool = False @property def status(self) -> str: + if self.is_graphic: + return "GFX" if not self.matched: return "MAN." return "OK" if self.confirmed else "?" @property def status_de(self) -> str: + if self.is_graphic: + return "Titel/Grafik" if not self.matched: return "Kein Treffer" return "Bestätigt" if self.confirmed else "Vorläufig" @@ -426,6 +433,26 @@ def collect_rows( clips_dir.mkdir(parents=True, exist_ok=True) force_beats = _forced_beats() + def is_dark_title_card(path: Path | None) -> bool: + if path is None or not path.exists(): + return False + try: + image = Image.open(path).convert("L").resize((160, 90)) + except Exception: + return False + stat = ImageStat.Stat(image) + mean = float(stat.mean[0]) + extrema = image.getextrema() + if mean > 55.0 or extrema[1] < 90: + return False + pixels = list(image.getdata()) + bright = sum(1 for p in pixels if p >= 92) + mid = sum(1 for p in pixels if 30 <= p < 92) + total = max(1, len(pixels)) + bright_ratio = bright / total + mid_ratio = mid / total + return 0.004 <= bright_ratio <= 0.18 and mid_ratio <= 0.35 + rows: list[BeatRow] = [] for beat in beats: bid = beat["beat_id"] @@ -534,6 +561,7 @@ def collect_rows( ): compare_clip = cmp4 + is_graphic = (rec is None and is_dark_title_card(trailer_still)) rows.append(BeatRow( bid=bid, trailer_in_s=beat["start_s"], trailer_out_s=beat["end_s"], @@ -553,6 +581,7 @@ def collect_rows( trailer_clip=trailer_clip, source_clip=source_clip, compare_clip=compare_clip, + is_graphic=is_graphic, )) return rows @@ -569,6 +598,7 @@ def render_markdown( ) -> str: matched = sum(1 for r in rows if r.matched) confirmed = sum(1 for r in rows if r.confirmed) + graphic = sum(1 for r in rows if r.is_graphic) out: list[str] = [] out.append("# Cutter-Report — manuelles Nachschneiden") @@ -587,11 +617,12 @@ def render_markdown( out.append("|--------|-----------|") out.append("| `OK` | Bestätigt durch CV-Analyse — übernehmen |") out.append("| `?` | Vorläufig — korrekte Szene, Phase im NLE prüfen |") + out.append("| `GFX` | Titel-/Grafikkarte — nicht aus dem Spielfilm matchen |") out.append("| `MAN.` | Kein automatischer Treffer — manuell setzen |") out.append("") out.append( f"**{len(rows)}** Beats gesamt · **{matched}** automatisch (**{confirmed}** bestätigt)" - f" · **{len(rows) - matched}** manuell." + f" · **{graphic}** Grafik/Titel · **{len(rows) - matched - graphic}** manuell." ) out.append("") @@ -644,10 +675,14 @@ def render_markdown( f" (scene {seg.get('scene_id', '?')})" ) else: - out.append("- **Source** : — (manuell setzen)") + if r.is_graphic: + out.append("- **Source** : — (Titel-/Grafikkarte, nicht aus Source matchen)") + else: + out.append("- **Source** : — (manuell setzen)") if r.score > 0 and r.score < 0.65: out.append(f"- ⚠ Score {r.score:.3f} unter 0.65 — visuell prüfen") - out.append(f"- **Rematch**: `python cli.py rematch --beat {r.bid}`") + if not r.is_graphic: + out.append(f"- **Rematch**: `python cli.py rematch --beat {r.bid}`") if r.phase: out.append(f"- **Phase**: {r.phase}") if r.composition: @@ -666,7 +701,7 @@ def render_markdown( out.append("| Trailer | Source |") out.append("|:---:|:---:|") t_cell = f"![Trailer {r.bid}]({t_uri})" if t_uri else "_(kein Still)_" - s_cell = f"![Source {r.bid}]({s_uri})" if s_uri else "_(MAN.)_" + s_cell = f"![Source {r.bid}]({s_uri})" if s_uri else f"_({r.status})_" out.append(f"| {t_cell} | {s_cell} |") out.append("") @@ -772,6 +807,7 @@ table.ov tr:hover { background: rgba(255, 255, 255, 0.05); } .badge.ok { background: var(--ok-bg); color: var(--ok); border: 1px solid rgba(74, 222, 128, 0.2); } .badge.q { background: var(--q-bg); color: var(--q); border: 1px solid rgba(251, 191, 36, 0.2); } .badge.man { background: var(--man-bg); color: var(--man); border: 1px solid rgba(248, 113, 113, 0.2); } +.badge.gfx { background: rgba(96, 165, 250, 0.12); color: #93c5fd; border: 1px solid rgba(147, 197, 253, 0.24); } /* Beat cards */ .beats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(600px, 1fr)); gap: 32px; } @@ -884,6 +920,10 @@ def render_html( '?' 'Vorläufig — Phase und Aktion im NLE visuell prüfen' ) + parts.append( + 'GFX' + 'Titel-/Grafikkarte — als Trailer-Grafik übernehmen, nicht im Spielfilm suchen' + ) parts.append( 'MAN.' 'Kein Treffer — manuell suchen oder Schwarzbild einfügen' @@ -911,7 +951,7 @@ def render_html( str(s.get("scene_id", "?")) for s in r.segments )) scene = "+".join(all_scenes) - bcls = {"OK": "ok", "?": "q", "MAN.": "man"}[r.status] + bcls = {"OK": "ok", "?": "q", "GFX": "gfx", "MAN.": "man"}[r.status] parts.append( f'' f'{r.bid:02d}' @@ -932,7 +972,7 @@ def render_html( ti = smpte(r.trailer_in_s, trailer_fps) to = smpte(r.trailer_out_s, trailer_fps) dur = r.trailer_out_s - r.trailer_in_s - bcls = {"OK": "ok", "?": "q", "MAN.": "man"}[r.status] + bcls = {"OK": "ok", "?": "q", "GFX": "gfx", "MAN.": "man"}[r.status] parts.append(f'
')