Mark trailer title cards as graphics
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"" if t_uri else "_(kein Still)_"
|
t_cell = f"" if t_uri else "_(kein Still)_"
|
||||||
s_cell = f"" if s_uri else "_(MAN.)_"
|
s_cell = f"" 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}">')
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user