Mark trailer title cards as graphics

This commit is contained in:
Melbar
2026-05-09 18:48:24 +02:00
parent f3c3a9cfd4
commit 3b42c5d018
3 changed files with 54 additions and 7 deletions
+1 -1
View File
@@ -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. | | 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. | | 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. | | 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 ### Algorithmische Details
+7
View File
@@ -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 Umschnitt passt. So kann ein Beat aus Frage/Antwort-Shots vollständig erfasst
werden, ohne Szenen willkürlich zusammenzukleben. 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 ## Reranking-Pipeline
Vor dem teuren Frame-Refine wird der gesamte Kandidatenpool mit einer Vor dem teuren Frame-Refine wird der gesamte Kandidatenpool mit einer
+46 -6
View File
@@ -30,6 +30,8 @@ from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from PIL import Image, ImageStat
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Frame-rate / timecode helpers # Frame-rate / timecode helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -388,15 +390,20 @@ class BeatRow:
trailer_clip: Path | None = None trailer_clip: Path | None = None
source_clip: Path | None = None source_clip: Path | None = None
compare_clip: Path | None = None compare_clip: Path | None = None
is_graphic: bool = False
@property @property
def status(self) -> str: def status(self) -> str:
if self.is_graphic:
return "GFX"
if not self.matched: if not self.matched:
return "MAN." return "MAN."
return "OK" if self.confirmed else "?" return "OK" if self.confirmed else "?"
@property @property
def status_de(self) -> str: def status_de(self) -> str:
if self.is_graphic:
return "Titel/Grafik"
if not self.matched: if not self.matched:
return "Kein Treffer" return "Kein Treffer"
return "Bestätigt" if self.confirmed else "Vorläufig" return "Bestätigt" if self.confirmed else "Vorläufig"
@@ -426,6 +433,26 @@ def collect_rows(
clips_dir.mkdir(parents=True, exist_ok=True) clips_dir.mkdir(parents=True, exist_ok=True)
force_beats = _forced_beats() 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] = [] rows: list[BeatRow] = []
for beat in beats: for beat in beats:
bid = beat["beat_id"] bid = beat["beat_id"]
@@ -534,6 +561,7 @@ def collect_rows(
): ):
compare_clip = cmp4 compare_clip = cmp4
is_graphic = (rec is None and is_dark_title_card(trailer_still))
rows.append(BeatRow( rows.append(BeatRow(
bid=bid, bid=bid,
trailer_in_s=beat["start_s"], trailer_out_s=beat["end_s"], trailer_in_s=beat["start_s"], trailer_out_s=beat["end_s"],
@@ -553,6 +581,7 @@ def collect_rows(
trailer_clip=trailer_clip, trailer_clip=trailer_clip,
source_clip=source_clip, source_clip=source_clip,
compare_clip=compare_clip, compare_clip=compare_clip,
is_graphic=is_graphic,
)) ))
return rows return rows
@@ -569,6 +598,7 @@ def render_markdown(
) -> str: ) -> str:
matched = sum(1 for r in rows if r.matched) matched = sum(1 for r in rows if r.matched)
confirmed = sum(1 for r in rows if r.confirmed) 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: list[str] = []
out.append("# Cutter-Report — manuelles Nachschneiden") out.append("# Cutter-Report — manuelles Nachschneiden")
@@ -587,11 +617,12 @@ def render_markdown(
out.append("|--------|-----------|") out.append("|--------|-----------|")
out.append("| `OK` | Bestätigt durch CV-Analyse — übernehmen |") out.append("| `OK` | Bestätigt durch CV-Analyse — übernehmen |")
out.append("| `?` | Vorläufig — korrekte Szene, Phase im NLE prüfen |") 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("| `MAN.` | Kein automatischer Treffer — manuell setzen |")
out.append("") out.append("")
out.append( out.append(
f"**{len(rows)}** Beats gesamt · **{matched}** automatisch (**{confirmed}** bestätigt)" 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("") out.append("")
@@ -644,10 +675,14 @@ def render_markdown(
f" (scene {seg.get('scene_id', '?')})" f" (scene {seg.get('scene_id', '?')})"
) )
else: 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: 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"- ⚠ 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: if r.phase:
out.append(f"- **Phase**: {r.phase}") out.append(f"- **Phase**: {r.phase}")
if r.composition: if r.composition:
@@ -666,7 +701,7 @@ def render_markdown(
out.append("| Trailer | Source |") out.append("| Trailer | Source |")
out.append("|:---:|:---:|") out.append("|:---:|:---:|")
t_cell = f"![Trailer {r.bid}]({t_uri})" if t_uri else "_(kein Still)_" 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(f"| {t_cell} | {s_cell} |")
out.append("") 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.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.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.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 */ /* Beat cards */
.beats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(600px, 1fr)); gap: 32px; } .beats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(600px, 1fr)); gap: 32px; }
@@ -884,6 +920,10 @@ def render_html(
'<tr><td><span class="badge q">?</span></td>' '<tr><td><span class="badge q">?</span></td>'
'<td>Vorläufig — Phase und Aktion im NLE visuell prüfen</td></tr>' '<td>Vorläufig — Phase und Aktion im NLE visuell prüfen</td></tr>'
) )
parts.append(
'<tr><td><span class="badge gfx">GFX</span></td>'
'<td>Titel-/Grafikkarte — als Trailer-Grafik übernehmen, nicht im Spielfilm suchen</td></tr>'
)
parts.append( parts.append(
'<tr><td><span class="badge man">MAN.</span></td>' '<tr><td><span class="badge man">MAN.</span></td>'
'<td>Kein Treffer — manuell suchen oder Schwarzbild einfügen</td></tr>' '<td>Kein Treffer — manuell suchen oder Schwarzbild einfügen</td></tr>'
@@ -911,7 +951,7 @@ def render_html(
str(s.get("scene_id", "?")) for s in r.segments str(s.get("scene_id", "?")) for s in r.segments
)) ))
scene = "+".join(all_scenes) scene = "+".join(all_scenes)
bcls = {"OK": "ok", "?": "q", "MAN.": "man"}[r.status] bcls = {"OK": "ok", "?": "q", "GFX": "gfx", "MAN.": "man"}[r.status]
parts.append( parts.append(
f'<tr>' f'<tr>'
f'<td class="num"><a href="#beat-{r.bid:02d}">{r.bid:02d}</a></td>' f'<td class="num"><a href="#beat-{r.bid:02d}">{r.bid:02d}</a></td>'
@@ -932,7 +972,7 @@ def render_html(
ti = smpte(r.trailer_in_s, trailer_fps) ti = smpte(r.trailer_in_s, trailer_fps)
to = smpte(r.trailer_out_s, trailer_fps) to = smpte(r.trailer_out_s, trailer_fps)
dur = r.trailer_out_s - r.trailer_in_s 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'<div class="beat" id="beat-{r.bid:02d}">') parts.append(f'<div class="beat" id="beat-{r.bid:02d}">')