#!/usr/bin/env python3 """Does it ANIMATE, or does it switch? — compare a film, never two stills. tools/motion-census [--interval 0.05] [--rows] [--roi x,y,w,h] tools/motion-census --selftest Point it at a directory of PNGs captured in order during a REAL run — the port's `--film`, or a capture harness's frame dump. It reports where the picture moves and where it is frozen, plus how many distinct states it ever took. Why this exists --------------- 🔴 A human played the port and said *"the logos just switch, there is no animation at all."* Every check either agent had said the splash was correct. Three instruments agreed, and all three were blind in the same way: * a **frozen sweep** — step the clock by hand, render a still per unit. That proves the renderer CAN draw pose N. It never runs the animation. * a **settled comparison** — correlate the resting pose against a capture. A screen that is frozen 84 % of the time matches a settled reference perfectly; that is what being frozen MEANS. * an **achieved-fps counter** — frames DRAWN per second. Drawing the same pixels 25 times a second scores identically to animating at 25 fps. The common defect: **every one measured throughput or a pose, and none measured CHANGE.** So this measures change and nothing else. measured 2026-09-02 on the boot splash: moving 1.30 s of 7.95 s (16.4 %), publisher frozen for 3.20 s, developer for 2.40 s, 26 distinct luma values. What the numbers mean --------------------- A fade is ONE LONG RUN of small non-zero deltas. A switch is isolated one-frame spikes with flat nothing between them. The `distinct states` count is the blunt version of the same question: a 45-unit ramp cannot be drawn in 26 states. ⚠️ It cannot tell you the animation is CORRECT — only that something moved. A wrong ramp that moves every frame passes here. Pair it with a comparison against the oracle; this is the liveness half, which is the half that was missing. """ from __future__ import annotations import sys from pathlib import Path try: from PIL import Image _BACKEND = "pillow" except ImportError: # 🔴 FALLBACK, NOT A SECOND IMPLEMENTATION. The port's container has no # Pillow and no pip, so the tool could not run at all there -- and a tool the # port cannot run is a check the port does not have, which is how this class # of defect survived in the first place. # # This shims only the three Pillow calls used below (open+convert, crop, # resize+getdata, and new+save for the selftest) onto ImageMagick. The census # arithmetic, the MOVED floor and the GRID are untouched, so the numbers are # the tool's and not a re-derivation. # # `-grayscale Rec601Luma` rather than `-colorspace Gray`: Rec601 is what # Pillow's `.convert("L")` uses, and IM7's `-colorspace Gray` linearises # first, which would shift every value. Verified to round-trip a flat # rgb(100,100,100) to exactly 100 on this build. # # ⚠️ The --selftest is what makes this safe to trust: it drives the SAME # fade / switch / frozen discrimination through whichever backend is active, # so a shim that distorted the pixels would fail its own control. import subprocess _BACKEND = "imagemagick" class _IMImage: def __init__(self, path=None, size=None, value=None): self._path, self._size, self._value = path, size, value self._crop = None def convert(self, _mode): return self def crop(self, box): x0, y0, x1, y1 = box self._crop = (x1 - x0, y1 - y0, x0, y0) return self def resize(self, grid): self._grid = grid return self def getdata(self): cmd = ["convert", self._path] if self._crop: cmd += ["-crop", "%dx%d+%d+%d" % self._crop, "+repage"] cmd += ["-grayscale", "Rec601Luma", "-resize", "%dx%d!" % self._grid, "-depth", "8", "gray:-"] out = subprocess.run(cmd, capture_output=True).stdout return list(out) def save(self, path): subprocess.run(["convert", "-size", "%dx%d" % self._size, "xc:rgb(%d,%d,%d)" % ((self._value,) * 3), "-grayscale", "Rec601Luma", str(path)], check=True) class Image: # noqa: F811 - deliberate stand-in, same call surface @staticmethod def open(path): return _IMImage(path=str(path)) @staticmethod def new(_mode, size, value): return _IMImage(size=size, value=int(value)) # Below this, two frames are the same picture. Chosen as a floor, not tuned: PNG # frames of an unchanged scene differ by exactly 0.000, so anything above noise # works and a bigger number would only hide small fades. MOVED = 0.05 # Downsample before comparing. A fade moves every pixel a little, so it survives # scaling; scaling also stops one stray cursor pixel reading as motion. GRID = (160, 90) def load(path: Path, roi=None) -> list[int]: im = Image.open(path).convert("L") if roi: x, y, w, h = roi im = im.crop((x, y, x + w, y + h)) return list(im.resize(GRID).getdata()) def census(frames: list[Path], interval: float, roi=None): prev, rows = None, [] for i, f in enumerate(frames): px = load(f, roi) lum = sum(px) / len(px) delta = 0.0 if prev is None else sum(abs(a - b) for a, b in zip(px, prev)) / len(px) rows.append((i * interval, delta, lum)) prev = px return rows def segments(rows): segs, cur, start = [], None, 0.0 for t, delta, _ in rows[1:]: state = "MOVING" if delta > MOVED else "static" if state != cur: if cur is not None: segs.append((cur, start, t)) cur, start = state, t if cur is not None: segs.append((cur, start, rows[-1][0])) return segs def report(rows, interval: float, show_rows: bool) -> int: total = rows[-1][0] segs = segments(rows) if show_rows: print(f"{'t(s)':>7} {'Δ prev':>8} {'luma':>7} bar") for t, delta, lum in rows: print(f"{t:>7.2f} {delta:>8.3f} {lum:>7.2f} {'#' * min(60, int(delta * 6))}") print() print(f"{len(rows)} frames @{interval}s = {total:.2f}s\n") print(f"{'state':<8} {'from':>7} {'to':>7} {'dur':>7} luma") for state, a, b in segs: lums = [l for t, _, l in rows if a <= t <= b] flag = " <-- FROZEN" if state == "static" and (b - a) > 0.5 else "" print(f"{state:<8} {a:>7.2f} {b:>7.2f} {b-a:>7.2f} " f"{min(lums):.2f}..{max(lums):.2f}{flag}") moving = sum(b - a for s, a, b in segs if s == "MOVING") pct = 100 * moving / total if total else 0.0 states = len({round(l, 2) for _, _, l in rows}) print(f"\nmoving {moving:.2f}s of {total:.2f}s = {pct:.1f}%") print(f"distinct luma states: {states}") longest = max((b - a for s, a, b in segs if s == "static"), default=0.0) print(f"longest frozen stretch: {longest:.2f}s") print() if pct < 50: print("🔴 This is a SWITCH, not an animation. Most of the run is one still") print(" picture. A frozen sweep and a settled comparison both pass on this.") return 1 print("moves for most of its length — liveness only; correctness is a separate") print("question this tool cannot answer.") return 0 def selftest() -> int: """Synthetic controls, executed. A detector that cannot tell a fade from a switch would report the same green line on both, and its verdict on the real film would mean nothing.""" import tempfile ok = 0 with tempfile.TemporaryDirectory() as d: root = Path(d) def write(name, values): sub = root / name sub.mkdir() for i, v in enumerate(values): Image.new("L", (320, 180), int(v)).save(sub / f"f{i:04d}.png") return sorted(sub.glob("*.png")) # A fade: every frame differs from the last. fade = write("fade", [10 + i * 4 for i in range(40)]) rows = census(fade, 0.05) pct = 100 * sum(b - a for s, a, b in segments(rows) if s == "MOVING") / rows[-1][0] good = pct > 90 print(f" {'a real fade reads as MOVING':<40} {pct:5.1f}% {'✅' if good else '🔴'}") ok |= 0 if good else 1 # A switch: one value, then another, held. This is the shape the port # actually produced, and the case the old instruments could not see. sw = write("switch", [10] * 20 + [200] * 20) rows = census(sw, 0.05) pct = 100 * sum(b - a for s, a, b in segments(rows) if s == "MOVING") / rows[-1][0] good = pct < 20 print(f" {'a switch reads as STATIC':<40} {pct:5.1f}% {'✅' if good else '🔴'}") ok |= 0 if good else 1 # Fully frozen: the degenerate case must not read as motion. fr = write("frozen", [42] * 30) rows = census(fr, 0.05) pct = 100 * sum(b - a for s, a, b in segments(rows) if s == "MOVING") / rows[-1][0] good = pct == 0 print(f" {'a frozen film reads as 0% motion':<40} {pct:5.1f}% {'✅' if good else '🔴'}") ok |= 0 if good else 1 print() print("the census separates a fade from a switch" if not ok else "🔴 the census cannot tell a fade from a switch — it asserts nothing") return ok def main(argv: list[str]) -> int: if "--selftest" in argv: return selftest() args = [a for a in argv if not a.startswith("--")] if not args: sys.exit(__doc__.split("\n\n")[1]) interval = 0.05 roi = None for i, a in enumerate(argv): if a == "--interval" and i + 1 < len(argv): interval = float(argv[i + 1]) if a == "--roi" and i + 1 < len(argv): roi = tuple(int(v) for v in argv[i + 1].split(",")) d = Path(args[0]) frames = sorted(d.glob("*.png")) if len(frames) < 3: sys.exit(f"motion-census: {d} holds {len(frames)} PNG(s) — nothing to compare.\n" " Exit 2: the harness is broken, not the animation.") return report(census(frames, interval, roi), interval, "--rows" in argv) if __name__ == "__main__": sys.exit(main(sys.argv[1:]))