feat(badapple): measure the 60 fps wire budget before changing any format

Spec 11's first task: decide from the clip, not from assumptions, whether the
raw-tile scheme (no dictionary, tag + bitmap per changed tile) fits a 60 fps
frame.  measure_wire.py extracts at 1280x960/60 fps with the hard threshold the
design requires, diffs each frame on the 40x30 picture grid, and totals the
payload a TILES_DELTA would carry:

    4 + 3*count + 128*m

Full clip, 13,167 frames: mean 16,049 B/frame, p50 14,755, p95 33,353, max
51,523; 22.5% of frames exceed the 21 KB one-frame budget; 87% of changed tiles
are neither solid black nor white, so they carry a 128-byte bitmap (the design
assumed a third); mean 140 changed tiles/frame, max 1,173 at a cut.  Link duty
at 60 fps would be 72% of the measured 1.27 MB/s.

Two consequences for the design, recorded here rather than discovered later:
the raw-tile scheme is borderline rather than comfortable, and the tile churn at
60 fps (140) is nearly the same as at 15 fps (165) -- motion between adjacent
60 fps frames is mostly sub-tile, so sending faster does not divide the wire by
four.
This commit is contained in:
arti committed 2026-10-31 22:36:34 +02:00
1 parent 8de6d94e84
commit 1bb3643144
2 files changed
+145

No files matched your search

+1
View File
@@ -12,6 +12,7 @@ harness/badapple/
├── demo.sh one command: wait for the OS, load, send
├── rdebug_load.py payload image → terminal RAM over rdebug (TCP 771)
├── clip_from_video.py video → raw 1-bpp frames for make_tiles.py
├── measure_wire.py what the 60 fps raw-tile wire form would cost per frame
└── tests/ unit tests for the two Python tools
```
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Measure the raw-tile wire cost of a clip at the source's own frame rate.
This is the spec's first task (`docs/superpowers/specs/2026-10-31-badapple-60p-
design.md` §11): before any payload or wire change, find out what the proposed
form actually costs on the real clip.
For every frame it diffs the extracted 1-bpp picture against the previous one on
the 40x30 picture grid and totals the payload a `KIND_TILES_DELTA` would carry:
4 + 3*count + 128*m count = changed tiles, m = tiles that are neither
solid black nor solid white
It reports mean/p50/p95/max wire, how many frames break the 21 KB one-frame
budget, and where the big changes (cuts) are. No dither, hard threshold -- the
design's §8.7 constraint, because dither makes nearly every tile dirty.
measure_wire.py /tmp/opencode/badapple-real/hd60.mp4 --limit 600
measure_wire.py clip.mp4 # every frame
"""
from __future__ import annotations
import argparse
import os
import subprocess
import sys
TILE = 32
COLS = 40 # 1280 px
ROWS = 30 # 960 px: the picture occupies the top 30 of 32 tile rows
PIXELS_PER_TILE = TILE * TILE
BUDGET = 21 * 1024 # one 60 fps frame of wire, spec §6
MAX_PAYLOAD = 32 * 1450 # 32-descriptor ring x 1450-byte datagrams
def frames(video, fps, width, height, threshold):
"""Yield a boolean picture per video frame, LSB-first is irrelevant here."""
import numpy as np
cmd = ["ffmpeg", "-v", "error", "-i", video,
"-vf", "fps=%s,scale=%d:%d:flags=lanczos,format=gray"
% (fps, width, height),
"-f", "rawvideo", "-"]
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, bufsize=10 ** 8)
size = width * height
try:
while True:
buf = proc.stdout.read(size)
if len(buf) < size:
break
gray = np.frombuffer(buf, dtype=np.uint8).reshape(height, width)
yield gray > threshold
finally:
# Stop ffmpeg before closing its pipe, or it reports a broken pipe as
# an error when a --limit run stops reading early.
proc.kill()
proc.wait()
def measure(video, fps, width, height, threshold, limit=None):
import numpy as np
prev = None
n = 0
wires, changed, bitmaps, keys = [], [], [], []
for pic in frames(video, fps, width, height, threshold):
pic = pic[:ROWS * TILE, :COLS * TILE]
if prev is not None and prev.shape == pic.shape:
diff = pic ^ prev
dirty = diff.reshape(ROWS, TILE, COLS, TILE).any(axis=(1, 3))
count = int(dirty.sum())
if count:
tiles = pic.reshape(ROWS, TILE, COLS, TILE).transpose(0, 2, 1, 3)
tiles = tiles.reshape(ROWS, COLS, PIXELS_PER_TILE)
solid_black = ~tiles.any(axis=2)
solid_white = tiles.all(axis=2)
m = int((dirty & ~solid_black & ~solid_white).sum())
else:
m = 0
wire = 4 + 3 * count + 128 * m
wires.append(wire)
changed.append(count)
bitmaps.append(m)
keys.append((n, count, wire))
prev = pic
n += 1
if limit and n >= limit:
break
return n, wires, changed, bitmaps, keys
def main(argv=None) -> int:
ap = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("video")
ap.add_argument("--fps", type=float, default=60.0)
ap.add_argument("--width", type=int, default=1280)
ap.add_argument("--height", type=int, default=960)
ap.add_argument("--threshold", type=int, default=127)
ap.add_argument("--limit", type=int, default=None,
help="frames to read (default: all)")
args = ap.parse_args(argv)
n, wires, changed, bitmaps, keys = measure(
args.video, args.fps, args.width, args.height, args.threshold,
args.limit)
if not wires:
print("no frames measured", file=sys.stderr)
return 1
wires_sorted = sorted(wires)
def pct(p):
return wires_sorted[min(len(wires_sorted) - 1, int(len(wires_sorted) * p))]
over = sum(1 for w in wires if w > BUDGET)
too_big = sum(1 for w in wires if w > MAX_PAYLOAD)
mean = sum(wires) / len(wires)
print("frames %d at %.0f fps (delta frames measured: %d)"
% (n, args.fps, len(wires)))
print("wire/frame mean %6.0f B p50 %6.0f p95 %6.0f max %6d"
% (mean, pct(0.50), pct(0.95), wires_sorted[-1]))
print("changed tiles/frame mean %5.1f max %5d (grid %dx%d = %d)"
% (sum(changed) / len(changed), max(changed), COLS, ROWS, COLS * ROWS))
print("bitmap tiles/frame mean %5.1f max %5d (%.0f%% of changed)"
% (sum(bitmaps) / len(bitmaps), max(bitmaps),
100.0 * sum(bitmaps) / max(1, sum(changed))))
print("frames over the 21 KB budget: %d / %d (%.1f%%)"
% (over, len(wires), 100.0 * over / len(wires)))
print("frames over one payload (32 datagrams, %d B): %d"
% (MAX_PAYLOAD, too_big))
print("wire as a share of the link at this rate: %.0f%% (%.0f KB/s of 1.27 MB/s)"
% (100.0 * mean * args.fps / (1.27 * 1024 * 1024),
mean * args.fps / 1024))
big = sorted(keys, key=lambda k: -k[1])[:10]
print("largest changes (frame, changed tiles, wire):")
for frame_no, count, wire in big:
print(" %6d %5d tiles %7d B" % (frame_no, count, wire))
return 0
if __name__ == "__main__":
sys.exit(main())