Files
vt1200/harness/badapple/clip_from_video.py
T
arti c5526332f0 feat(badapple): stylise the clip into blocks, and let the sender widen its keyframe cadence
Two knobs for the 60 fps attempt, both measured on Bad Apple:

clip_from_video.py --block N averages each NxN block, thresholds the average and
draws the block flat.  The point is destination words: at 60 fps the clip changes
80 tiles/frame unstylised, 54 at 8x8 and 29 at 16x16, against the ~44-tile budget
that MAME's ~3.4 us/word allows in 16.7 ms.  A side effect worth knowing: with
16-pixel blocks every 32x32 tile is one of exactly 2^4 combinations, so the 256
entry dictionary represents the stylised clip with zero error (measured: 16
entries used, "reconstruction 0 bit(s)", PSNR inf, mean delta 141 B against 496 B
for the unstylised clip).

vidsend.py --keyframe-every N exposes the cadence that TileSender already had as
a constructor argument.  A keyframe repaints all 1,280 tiles (~264 ms of MAME CPU
at the measured 0.22 ms/tile), so the default 60-frame cadence costs ~4.4 ms on
average per frame at 60 fps -- a tenth of the budget spent on repairs.  The
13,167-frame stylised clip ships 220 keyframes at the default; 600 measured 6 in
the same window.

Measured on the stylised 60 fps cache (13,167 frames, 16 dictionary entries):
  --fps 60, default cadence   643 of 805 accepted (80%), drops=8
  --fps 45                     484 of 520 accepted, drops=0, sender PASS
  --fps 60, --keyframe-every 600  451 of 519 accepted, drops=5
So the stylised clip roughly doubles acceptance against the same 60 fps send
(40% -> 80%) but MAME still caps the clean rate around 36-45 fps.
2026-10-31 23:12:25 +02:00

142 lines
5.6 KiB
Python
Executable File

#!/usr/bin/env python3
"""Turn a video into the raw 1-bpp frames ``make_tiles.py --clip`` takes.
Each output file is one frame: whole 160-byte rows, whole 32-row tile bands,
top-aligned, **LSB first** -- bit 0 of a byte is the row's leftmost pixel, the
order the terminal's scanout reads (``dwt/screenshot.py`` decodes live VRAM with
the same rule). An MSB-first packer is self-consistent with an MSB-first
checker and mirrors every eight pixels on a display, which is why the order is
spelled out here rather than left to ffmpeg.
The screen is 1280 px wide and the tile grid is 32 rows tall, so a 4:3 source
scaled to 1280x960 fills 30 of them and the last two rows stay black. Pass
``--height 1024`` to fill the screen and accept 4:3 being stretched.
Requires ``ffmpeg`` on PATH and numpy.
clip_from_video.py bad-apple.mp4 frames/ --fps 15
clip_from_video.py bad-apple.mp4 frames/ --fps 12 --threshold 100 --invert
"""
from __future__ import annotations
import argparse
import os
import shutil
import subprocess
import sys
ROW_PIXELS = 1280
ROW_BYTES = ROW_PIXELS // 8 # 160
def pack_row(pixels) -> bytes:
"""Pack one row of 0/1 pixels, LSB first (pixel 0 -> bit 0 of byte 0)."""
if not pixels or len(pixels) % 8:
raise ValueError("a row is a positive multiple of 8 pixels, got %d"
% len(pixels))
out = bytearray(len(pixels) // 8)
for i, value in enumerate(pixels):
if value:
out[i >> 3] |= 1 << (i & 7)
return bytes(out)
def frames(video, out_dir, fps=15, width=ROW_PIXELS, height=960, threshold=127,
invert=False, block=1):
"""Yield ``(frame_no, packed_row_bytes)`` for every video frame."""
import numpy as np
vf = ("fps=%s,scale=%d:%d:flags=lanczos,format=gray"
% (fps, width, height))
if invert:
vf += ",negate"
cmd = ["ffmpeg", "-v", "error", "-i", video, "-vf", vf,
"-f", "rawvideo", "-"]
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, bufsize=10 ** 8)
size = width * height
n = 0
try:
while True:
buf = proc.stdout.read(size)
if len(buf) < size:
break
gray = np.frombuffer(buf, dtype=np.uint8).reshape(height, width)
if block > 1:
# Stylise: average each block, threshold the average, then draw
# the block flat. Fewer, larger shapes mean fewer changed tiles
# per frame -- measured on Bad Apple at 60 fps: 80 changed tiles
# unstylised, 54 at 8x8, 29 at 16x16, against a budget of ~44.
h, w = height // block, width // block
small = gray.reshape(h, block, w, block).mean(axis=(1, 3))
bits = np.repeat(np.repeat(small > threshold, block, axis=0),
block, axis=1)
else:
bits = gray > threshold
yield n, np.packbits(bits, axis=1, bitorder="little")
n += 1
finally:
proc.stdout.close()
proc.wait()
def main(argv=None) -> int:
ap = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("video", help="source video (anything ffmpeg reads)")
ap.add_argument("out", help="directory for the *.bin frames")
ap.add_argument("--fps", type=float, default=15.0,
help="frames per second to extract (default %(default)s)")
ap.add_argument("--width", type=int, default=ROW_PIXELS,
help="pixels per row (default %(default)s)")
ap.add_argument("--height", type=int, default=960,
help="rows to extract: 960 fills 30 of the 32 tile bands, "
"1024 fills the screen (default %(default)s)")
ap.add_argument("--threshold", type=int, default=127,
help="grey level above which a pixel is ink "
"(default %(default)s)")
ap.add_argument("--block", type=int, default=1,
help="stylise to NxN blocks (default 1 = no stylisation)")
ap.add_argument("--invert", action="store_true",
help="treat bright pixels as paper (negate first)")
args = ap.parse_args(argv)
if shutil.which("ffmpeg") is None:
print("error: ffmpeg is not on PATH", file=sys.stderr)
return 2
if args.width % 8 or args.width > ROW_PIXELS or args.width < 8:
print("error: --width must be a multiple of 8, at most %d" % ROW_PIXELS,
file=sys.stderr)
return 2
if args.block < 1 or args.block > 64:
print("error: --block must be 1..64", file=sys.stderr)
return 2
if args.height % 32 or args.height > 1024:
print("error: --height must be a multiple of 32, at most 1024",
file=sys.stderr)
return 2
os.makedirs(args.out, exist_ok=True)
for name in os.listdir(args.out):
if name.endswith(".bin"):
os.remove(os.path.join(args.out, name))
written = 0
for n, packed in frames(args.video, args.out, fps=args.fps,
width=args.width, height=args.height,
threshold=args.threshold, invert=args.invert,
block=args.block):
path = os.path.join(args.out, "%05d.bin" % n)
with open(path, "wb") as handle:
handle.write(packed.tobytes())
written += 1
if written % 100 == 0:
print(" %d frames" % written, file=sys.stderr)
print("%d frames of %dx%d at %s fps -> %s"
% (written, args.width, args.height, args.fps, args.out))
return 0
if __name__ == "__main__":
sys.exit(main())