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.
This commit is contained in:
arti committed 2026-10-31 23:12:25 +02:00
1 parent 9139322892
commit c5526332f0
2 files changed
+27 -4

No files matched your search

+20 -3
View File
@@ -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())
+7 -1
View File
@@ -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,