mirror of
https://git.k-space.ee/arti/vt1200.git
synced 2026-11-04 12:33:18 +02:00
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.
145 lines
5.5 KiB
Python
Executable File
145 lines
5.5 KiB
Python
Executable File
#!/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())
|