From 1bb3643144c493204fcf2e154fbbff825edcde21 Mon Sep 17 00:00:00 2001 From: Arti Zirk Date: Sat, 31 Oct 2026 22:36:34 +0200 Subject: [PATCH] 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. --- harness/badapple/README.md | 1 + harness/badapple/measure_wire.py | 144 +++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100755 harness/badapple/measure_wire.py diff --git a/harness/badapple/README.md b/harness/badapple/README.md index 4bcdc43..c6b5f27 100644 --- a/harness/badapple/README.md +++ b/harness/badapple/README.md @@ -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 ``` diff --git a/harness/badapple/measure_wire.py b/harness/badapple/measure_wire.py new file mode 100755 index 0000000..4521e31 --- /dev/null +++ b/harness/badapple/measure_wire.py @@ -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())