From c5526332f0b9971f78eacf97bb0c29f086bf437e Mon Sep 17 00:00:00 2001 From: Arti Zirk Date: Sat, 31 Oct 2026 23:12:25 +0200 Subject: [PATCH] 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. --- harness/badapple/clip_from_video.py | 23 ++++++++++++++++++++--- harness/udpstream/vidsend.py | 8 +++++++- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/harness/badapple/clip_from_video.py b/harness/badapple/clip_from_video.py index 23d7a9f..e473eb6 100755 --- a/harness/badapple/clip_from_video.py +++ b/harness/badapple/clip_from_video.py @@ -42,7 +42,7 @@ def pack_row(pixels) -> bytes: def frames(video, out_dir, fps=15, width=ROW_PIXELS, height=960, threshold=127, - invert=False): + invert=False, block=1): """Yield ``(frame_no, packed_row_bytes)`` for every video frame.""" import numpy as np @@ -61,7 +61,18 @@ def frames(video, out_dir, fps=15, width=ROW_PIXELS, height=960, threshold=127, if len(buf) < size: break gray = np.frombuffer(buf, dtype=np.uint8).reshape(height, width) - yield n, np.packbits(gray > threshold, axis=1, bitorder="little") + 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() @@ -84,6 +95,8 @@ def main(argv=None) -> int: 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) @@ -95,6 +108,9 @@ def main(argv=None) -> int: 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) @@ -108,7 +124,8 @@ def main(argv=None) -> int: 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): + 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()) diff --git a/harness/udpstream/vidsend.py b/harness/udpstream/vidsend.py index 9858c45..0c6cc78 100644 --- a/harness/udpstream/vidsend.py +++ b/harness/udpstream/vidsend.py @@ -710,6 +710,11 @@ def main(argv=None) -> int: help="stop after N frames (0 = until interrupted)") ap.add_argument("--pattern", default="bars", choices=sorted(PATTERNS), help="test picture (default bars)") + ap.add_argument("--keyframe-every", type=int, default=KEYFRAME_EVERY, + metavar="N", + help="force a full frame every N frames (default %(default)s; " + "a keyframe repaints all 1,280 tiles, so a short cadence " + "is a large per-frame cost at high rates)") ap.add_argument("--chunk", type=int, default=CHUNK, help="bytes of frame per datagram (default %d)" % CHUNK) ap.add_argument("--dst", type=parse_mac, default=BROADCAST, @@ -788,7 +793,8 @@ def main(argv=None) -> int: fps=args.fps, frames=args.frames or None, chunk=args.chunk, dst=args.dst, report_every=report_every, - status_wait=args.status_wait) + status_wait=args.status_wait, + every=args.keyframe_every) return Sender(transport, src=src, receive=receive, rows=args.rows, stretch=args.stretch, fps=args.fps, frames=args.frames or None, pattern=args.pattern,