Add WCX title detection

This commit is contained in:
2026-07-21 19:32:49 +02:00
parent 8fe68d684e
commit a60ef87ddc
3 changed files with 1274 additions and 0 deletions

769
scripts/detect_wcx_title.py Executable file
View File

@ -0,0 +1,769 @@
#!/usr/bin/env python3
"""
Extract diagnostic frames from a video for future WCX title detection.
This script never renames, moves, deletes, or modifies the input video.
Example:
detect_wcx_title.py /path/to/video.mp4 \
--output-dir /tmp/wcx-title-frames
detect_wcx_title.py /path/to/video.mp4 \
--output-dir /tmp/wcx-diagnostic \
--diagnose-layout
"""
import argparse
import shlex
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Callable, Sequence
FRAME_TIMESTAMPS = tuple(range(6, 16))
DIAGNOSTIC_TIMESTAMPS = (8, 10, 12)
NORMALIZED_WIDTH = 640
NORMALIZED_HEIGHT = 360
DEFAULT_ENDINGS = ("mp4", "avi", "mkv", "mov", "m4v", "webm")
IGNORED_FILENAMES = {".ds_store", "thumbs.db", "desktop.ini"}
def parse_arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Extract diagnostic PNG frames at 6 through 15 seconds "
"without modifying the input video."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""Examples:
scripts/detect_wcx_title.py /path/to/video.mp4 \
--output-dir /tmp/wcx-diagnostic --diagnose-layout
scripts/detect_wcx_title.py /storage/disk1/X --batch
scripts/detect_wcx_title.py /storage/disk1/X \
--batch --recursive --ending mp4,avi""",
)
parser.add_argument(
"input_path",
type=Path,
help="Video file, or a directory when --batch is used.",
)
parser.add_argument(
"--output-dir",
type=Path,
help="Directory in which the extracted PNG frames will be written.",
)
parser.add_argument(
"--diagnose-layout",
action="store_true",
help=(
"Extract normalized frames at 8.0, 10.0, and 12.0 seconds "
"and report layout metrics."
),
)
parser.add_argument(
"--batch",
action="store_true",
help="Classify video files in a directory without writing PNG files.",
)
parser.add_argument(
"--recursive",
action="store_true",
help="Search subdirectories in batch mode.",
)
parser.add_argument(
"--ending",
action="append",
help=(
"Video extension for batch mode; may be comma-separated or "
"repeated."
),
)
return parser.parse_args()
def prepare_output_directory(output_dir: Path) -> None:
try:
output_dir.mkdir(parents=True, exist_ok=True)
except OSError as error:
raise RuntimeError(
f"Could not create output directory {output_dir}: {error}"
) from error
if not output_dir.is_dir():
raise RuntimeError(
f"Output path is not a directory: {output_dir}"
)
def parse_endings(values: Sequence[str] | None) -> set[str]:
if values is None:
return set(DEFAULT_ENDINGS)
endings = {
ending.strip().lower().removeprefix(".")
for value in values
for ending in value.split(",")
if ending.strip()
}
if not endings:
raise ValueError("--ending must contain at least one extension")
return endings
def is_ignored_file(path: Path) -> bool:
return (
path.name.startswith("._")
or path.name.lower() in IGNORED_FILENAMES
)
def is_video_file(path: Path, endings: set[str]) -> bool:
return (
path.is_file()
and not is_ignored_file(path)
and path.suffix.lower().removeprefix(".") in endings
)
def find_video_files(
directory: Path,
endings: set[str],
recursive: bool,
) -> list[Path]:
directory = directory.resolve()
candidates = directory.rglob("*") if recursive else directory.iterdir()
return sorted(
(
path
for path in candidates
if is_video_file(path, endings)
),
key=lambda path: str(path),
)
def extract_frame(
ffmpeg: str,
video_file: Path,
output_file: Path,
timestamp: int,
) -> None:
command = [
ffmpeg,
"-hide_banner",
"-loglevel",
"error",
"-ss",
f"{timestamp:.1f}",
"-i",
str(video_file),
"-frames:v",
"1",
"-n",
str(output_file),
]
print(f"Running: {shlex.join(command)}")
try:
result = subprocess.run(
command,
capture_output=True,
text=True,
)
except OSError as error:
raise RuntimeError(
f"Could not run ffmpeg for {timestamp:.1f} seconds: {error}"
) from error
if result.returncode != 0:
detail = result.stderr.strip() or "ffmpeg returned no error message"
raise RuntimeError(
f"ffmpeg could not extract the frame at "
f"{timestamp:.1f} seconds: {detail}"
)
if not output_file.is_file():
raise RuntimeError(
f"ffmpeg reported success but did not create the frame at "
f"{timestamp:.1f} seconds: {output_file}"
)
print(f"Created: {output_file}")
def is_nearly_black(red: int, green: int, blue: int) -> bool:
return red <= 30 and green <= 30 and blue <= 30
def is_red(red: int, green: int, blue: int) -> bool:
return (
red >= 140
and red >= green * 1.5
and red >= blue * 1.5
)
def is_bright(red: int, green: int, blue: int) -> bool:
return red >= 180 and green >= 180 and blue >= 180
def calculate_region_ratio(
rgb_data: bytes,
width: int,
x_start: int,
x_end: int,
y_start: int,
y_end: int,
predicate,
) -> float:
matching_pixels = 0
total_pixels = (x_end - x_start) * (y_end - y_start)
for y_position in range(y_start, y_end):
row_offset = y_position * width * 3
for x_position in range(x_start, x_end):
offset = row_offset + x_position * 3
if predicate(
rgb_data[offset],
rgb_data[offset + 1],
rgb_data[offset + 2],
):
matching_pixels += 1
return matching_pixels / total_pixels
def calculate_layout_metrics(
rgb_data: bytes,
width: int = NORMALIZED_WIDTH,
height: int = NORMALIZED_HEIGHT,
) -> dict[str, float]:
expected_bytes = width * height * 3
if len(rgb_data) != expected_bytes:
raise ValueError(
f"Expected {expected_bytes} RGB bytes for {width}x{height}, "
f"received {len(rgb_data)}"
)
edge_width = max(1, int(width * 0.10))
edge_height = max(1, int(height * 0.10))
band_height = max(1, int(height * 0.15))
return {
"black_ratio_total": calculate_region_ratio(
rgb_data, width, 0, width, 0, height, is_nearly_black
),
"black_ratio_top": calculate_region_ratio(
rgb_data, width, 0, width, 0, edge_height, is_nearly_black
),
"black_ratio_bottom": calculate_region_ratio(
rgb_data,
width,
0,
width,
height - edge_height,
height,
is_nearly_black,
),
"black_ratio_left": calculate_region_ratio(
rgb_data, width, 0, edge_width, 0, height, is_nearly_black
),
"black_ratio_right": calculate_region_ratio(
rgb_data,
width,
width - edge_width,
width,
0,
height,
is_nearly_black,
),
"red_ratio_top": calculate_region_ratio(
rgb_data, width, 0, width, 0, band_height, is_red
),
"bright_ratio_top": calculate_region_ratio(
rgb_data, width, 0, width, 0, band_height, is_bright
),
"bright_ratio_bottom": calculate_region_ratio(
rgb_data,
width,
0,
width,
height - band_height,
height,
is_bright,
),
}
def calculate_wcx_layout_score(metrics: dict[str, float]) -> int:
conditions = (
0.20 <= metrics["black_ratio_total"] <= 0.60,
metrics["black_ratio_top"] >= 0.45,
metrics["black_ratio_bottom"] >= 0.50,
metrics["black_ratio_left"] >= 0.75,
metrics["black_ratio_right"] >= 0.75,
metrics["red_ratio_top"] >= 0.05,
metrics["bright_ratio_top"] >= 0.02,
metrics["bright_ratio_bottom"] >= 0.03,
)
return sum(conditions)
def select_best_timestamp(
timestamp_scores: Sequence[tuple[float, int]],
) -> tuple[float, int]:
if not timestamp_scores:
raise ValueError("At least one timestamp and score is required")
return min(
timestamp_scores,
key=lambda item: (-item[1], item[0]),
)
def classify_wcx_score(score: int) -> str:
if not 0 <= score <= 8:
raise ValueError(f"WCX layout score must be between 0 and 8: {score}")
if score == 8:
return "wcx"
if score >= 6:
return "uncertain"
return "not_wcx"
def split_rgb24_frames(
rgb_data: bytes,
width: int = NORMALIZED_WIDTH,
height: int = NORMALIZED_HEIGHT,
frame_count: int = len(DIAGNOSTIC_TIMESTAMPS),
) -> tuple[bytes, ...]:
frame_size = width * height * 3
expected_bytes = frame_size * frame_count
actual_bytes = len(rgb_data)
if actual_bytes < expected_bytes:
raise ValueError(
f"RGB24 buffer is too short: expected {expected_bytes} bytes, "
f"received {actual_bytes}"
)
if actual_bytes > expected_bytes:
raise ValueError(
f"RGB24 buffer is too long: expected {expected_bytes} bytes, "
f"received {actual_bytes}"
)
return tuple(
rgb_data[offset:offset + frame_size]
for offset in range(0, expected_bytes, frame_size)
)
def analyze_rgb24_frames(
rgb_data: bytes,
) -> tuple[
tuple[tuple[float, dict[str, float], int], ...],
float,
int,
str,
]:
frames = split_rgb24_frames(rgb_data)
analyses = tuple(
(
float(timestamp),
metrics,
calculate_wcx_layout_score(metrics),
)
for timestamp, frame in zip(
DIAGNOSTIC_TIMESTAMPS,
frames,
)
for metrics in (calculate_layout_metrics(frame),)
)
best_timestamp, best_score = select_best_timestamp(
tuple(
(timestamp, score)
for timestamp, _metrics, score in analyses
)
)
return (
analyses,
best_timestamp,
best_score,
classify_wcx_score(best_score),
)
def extract_batch_rgb24(
ffmpeg: str,
video_file: Path,
) -> bytes:
filter_graph = (
"[0:v]setpts=PTS-STARTPTS,"
"fps=1/2:start_time=0,"
"scale=640:360:force_original_aspect_ratio=decrease,"
"pad=640:360:(ow-iw)/2:(oh-ih)/2:black"
)
command = [
ffmpeg,
"-hide_banner",
"-loglevel",
"error",
"-ss",
f"{DIAGNOSTIC_TIMESTAMPS[0]:.1f}",
"-t",
"5.0",
"-i",
str(video_file),
"-filter_complex",
filter_graph,
"-frames:v",
str(len(DIAGNOSTIC_TIMESTAMPS)),
"-c:v",
"rawvideo",
"-pix_fmt",
"rgb24",
"-f",
"rawvideo",
"pipe:1",
]
try:
result = subprocess.run(
command,
capture_output=True,
)
except OSError as error:
raise RuntimeError(f"Could not run ffmpeg: {error}") from error
if result.returncode != 0:
detail = (
result.stderr.decode("utf-8", errors="replace").strip()
or "ffmpeg returned no error message"
)
raise RuntimeError(f"ffmpeg could not analyze the video: {detail}")
try:
split_rgb24_frames(result.stdout)
except ValueError as error:
raise RuntimeError(f"Invalid RGB24 output from ffmpeg: {error}") from error
return result.stdout
def analyze_video_for_batch(
ffmpeg: str,
video_file: Path,
) -> tuple[float, int, str]:
_analyses, best_timestamp, best_score, classification = (
analyze_rgb24_frames(
extract_batch_rgb24(ffmpeg, video_file)
)
)
return best_timestamp, best_score, classification
def process_batch(
video_files: Sequence[Path],
analyzer: Callable[[Path], tuple[float, int, str]],
) -> int:
counts = {
"processed": 0,
"wcx": 0,
"uncertain": 0,
"not_wcx": 0,
"errors": 0,
}
for video_file in video_files:
counts["processed"] += 1
try:
timestamp, score, classification = analyzer(video_file)
except (OSError, RuntimeError, ValueError) as error:
counts["errors"] += 1
print(f"error - - {video_file}: {error}")
continue
counts[classification] += 1
print(
f"{classification:<10} {score}/8 "
f"{timestamp:>4.1f} {video_file}"
)
print()
print(f"processed: {counts['processed']}")
print(f"wcx: {counts['wcx']}")
print(f"uncertain: {counts['uncertain']}")
print(f"not_wcx: {counts['not_wcx']}")
print(f"errors: {counts['errors']}")
return 1 if counts["errors"] else 0
def diagnose_layout(
ffmpeg: str,
video_file: Path,
output_dir: Path,
) -> None:
normalized_files = tuple(
output_dir / f"normalized-{timestamp:04.1f}.png"
for timestamp in DIAGNOSTIC_TIMESTAMPS
)
for output_file in normalized_files:
if output_file.exists():
raise RuntimeError(
f"Output file already exists and will not be overwritten: "
f"{output_file}"
)
filter_graph = (
"[0:v]setpts=PTS-STARTPTS,"
"fps=1/2:start_time=0,"
"scale=640:360:force_original_aspect_ratio=decrease,"
"pad=640:360:(ow-iw)/2:(oh-ih)/2:black,"
"split=4[normalized_raw][frame_8_input]"
"[frame_10_input][frame_12_input];"
"[frame_8_input]select=eq(n\\,0)[frame_8];"
"[frame_10_input]select=eq(n\\,1)[frame_10];"
"[frame_12_input]select=eq(n\\,2)[frame_12]"
)
command = [
ffmpeg,
"-hide_banner",
"-loglevel",
"error",
"-n",
"-ss",
f"{DIAGNOSTIC_TIMESTAMPS[0]:.1f}",
"-t",
"5.0",
"-i",
str(video_file),
"-filter_complex",
filter_graph,
"-map",
"[frame_8]",
"-frames:v",
"1",
"-c:v",
"png",
str(normalized_files[0]),
"-map",
"[frame_10]",
"-frames:v",
"1",
"-c:v",
"png",
str(normalized_files[1]),
"-map",
"[frame_12]",
"-frames:v",
"1",
"-c:v",
"png",
str(normalized_files[2]),
"-map",
"[normalized_raw]",
"-frames:v",
str(len(DIAGNOSTIC_TIMESTAMPS)),
"-c:v",
"rawvideo",
"-pix_fmt",
"rgb24",
"-f",
"rawvideo",
"pipe:1",
]
print(f"Running: {shlex.join(command)}")
try:
result = subprocess.run(
command,
capture_output=True,
)
except OSError as error:
raise RuntimeError(
f"Could not run ffmpeg for diagnostic frames: {error}"
) from error
if result.returncode != 0:
detail = (
result.stderr.decode("utf-8", errors="replace").strip()
or "ffmpeg returned no error message"
)
raise RuntimeError(
f"ffmpeg could not extract the diagnostic frames: {detail}"
)
for output_file in normalized_files:
if not output_file.is_file():
raise RuntimeError(
f"ffmpeg reported success but did not create: {output_file}"
)
print(f"Created: {output_file}")
try:
analyses, best_timestamp, best_score, classification = (
analyze_rgb24_frames(result.stdout)
)
except ValueError as error:
raise RuntimeError(f"Invalid RGB24 output from ffmpeg: {error}") from error
for index, (timestamp, metrics, score) in enumerate(analyses):
if index:
print()
print(f"timestamp: {timestamp:.1f}")
print(f"wcx_layout_score: {score}/8")
print(f"normalized_size: {NORMALIZED_WIDTH}x{NORMALIZED_HEIGHT}")
for name, ratio in metrics.items():
print(f"{name}: {ratio:.6f}")
print()
print(f"best_timestamp: {best_timestamp:.1f}")
print(f"best_wcx_layout_score: {best_score}/8")
print(f"classification: {classification}")
def main() -> int:
args = parse_arguments()
if args.batch:
if args.diagnose_layout:
print(
"Error: --batch cannot be combined with --diagnose-layout.",
file=sys.stderr,
)
return 2
if args.output_dir is not None:
print(
"Error: --batch cannot be combined with --output-dir.",
file=sys.stderr,
)
return 2
if not args.input_path.is_dir():
print(
f"Error: Batch directory does not exist: {args.input_path}",
file=sys.stderr,
)
return 2
try:
endings = parse_endings(args.ending)
video_files = find_video_files(
args.input_path,
endings=endings,
recursive=args.recursive,
)
except (OSError, ValueError) as error:
print(f"Error: {error}", file=sys.stderr)
return 2
if not video_files:
print(
f"No matching video files found in: "
f"{args.input_path.resolve()}"
)
return process_batch((), lambda _path: (0.0, 0, "not_wcx"))
ffmpeg = shutil.which("ffmpeg")
if ffmpeg is None:
print("Error: ffmpeg was not found in PATH.", file=sys.stderr)
return 1
return process_batch(
video_files,
lambda video_file: analyze_video_for_batch(
ffmpeg,
video_file,
),
)
if args.recursive:
print(
"Error: --recursive requires --batch.",
file=sys.stderr,
)
return 2
if args.ending is not None:
print(
"Error: --ending requires --batch.",
file=sys.stderr,
)
return 2
if args.output_dir is None:
print(
"Error: --output-dir is required unless --batch is used.",
file=sys.stderr,
)
return 2
if not args.input_path.is_file():
print(
f"Error: Video file does not exist: {args.input_path}",
file=sys.stderr,
)
return 1
ffmpeg = shutil.which("ffmpeg")
if ffmpeg is None:
print(
"Error: ffmpeg was not found in PATH.",
file=sys.stderr,
)
return 1
try:
prepare_output_directory(args.output_dir)
if args.diagnose_layout:
diagnose_layout(
ffmpeg=ffmpeg,
video_file=args.input_path,
output_dir=args.output_dir,
)
else:
for timestamp in FRAME_TIMESTAMPS:
output_file = (
args.output_dir
/ f"frame-{timestamp:04.1f}.png"
)
extract_frame(
ffmpeg=ffmpeg,
video_file=args.input_path,
output_file=output_file,
timestamp=timestamp,
)
except RuntimeError as error:
print(f"Error: {error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())