diff --git a/docs/re/captures/focus-ring/main-menu-20s-mean.png b/docs/re/captures/focus-ring/main-menu-20s-mean.png new file mode 100644 index 00000000..ddc02008 Binary files /dev/null and b/docs/re/captures/focus-ring/main-menu-20s-mean.png differ diff --git a/docs/re/captures/focus-ring/ring-20s-mean-uniform.png b/docs/re/captures/focus-ring/ring-20s-mean-uniform.png new file mode 100644 index 00000000..5f4188b0 Binary files /dev/null and b/docs/re/captures/focus-ring/ring-20s-mean-uniform.png differ diff --git a/docs/re/captures/focus-ring/ring-single-frames-4s-apart.png b/docs/re/captures/focus-ring/ring-single-frames-4s-apart.png new file mode 100644 index 00000000..549f685a Binary files /dev/null and b/docs/re/captures/focus-ring/ring-single-frames-4s-apart.png differ diff --git a/docs/re/captures/focus-ring/ring-temporal-std-annulus.png b/docs/re/captures/focus-ring/ring-temporal-std-annulus.png new file mode 100644 index 00000000..eae14789 Binary files /dev/null and b/docs/re/captures/focus-ring/ring-temporal-std-annulus.png differ diff --git a/docs/re/data/focus-ring-period-corr.npy b/docs/re/data/focus-ring-period-corr.npy new file mode 100644 index 00000000..a42e9313 Binary files /dev/null and b/docs/re/data/focus-ring-period-corr.npy differ diff --git a/docs/re/focus-ring-spin-measured.md b/docs/re/focus-ring-spin-measured.md new file mode 100644 index 00000000..c34375dd --- /dev/null +++ b/docs/re/focus-ring-spin-measured.md @@ -0,0 +1,119 @@ +# ✅ The main menu's focus ring spins continuously — period **2.18 s**, measured + +**Status:** ✅ **measured** (not on the disc as a period; the disc declares the +ramp, the running game supplies the rate). Taken 2026-08-29 against Xenia Canary +with the disc mounted at `/disc`. + +**Question this closes:** the port asked whether `ptbtneff01` — the 42×46 ring on +the focused button — is *animated* while a button sits focused, or drawn once and +held. It had shipped the ring at 0° and marked that known-wrong. +[`structures/ui-button-focus-record.md`](structures/ui-button-focus-record.md) +already said "the ring SPINS" from **one** frame showing it at a large angle; +that is consistent with a continuous spin *and* with a static draw at a fixed +angle, so it did not answer the question asked. + +## What the ring actually does + +Five single frames from one run, 4 s apart, focus held on `TUTORIAL` throughout: + +![five frames](captures/focus-ring/ring-single-frames-4s-apart.png) + +The ring carries a bright head, and the head is at a different angular position +in every frame. It is still moving 16 s in, so it does **not** ramp once and +stop. + +⚠️ **The 20 s mean of the same run is a uniform circle** +([`ring-20s-mean-uniform.png`](captures/focus-ring/ring-20s-mean-uniform.png)) — +that is the spin smearing itself out, and it is why an averaged frame must never +be read as a single frame. A human looking at the live game sees the head; the +average does not have one. + +## The measurement, and why it is not an angle + +🔴 **No angle is estimated anywhere.** The corpus's centroid estimator fails its +own control by up to 19.8°, and a 360-bin angular cross-correlation written for +this measurement **also failed its control** — a synthetic 30° rotation of a live +frame came back as 0° (peak 0.596), while 90/180/270° came back exactly (peak +1.000), i.e. the estimator only resolves the exact pixel permutations. It was +therefore not used. + +What was used needs no angle. Two observables separate *rotation* from a +*brightness pulse*, and both were taken in the same run: + +| observable | rotation predicts | pulse predicts | **measured** | +|---|---|---|---| +| total annulus brightness | conserved | varies | **0.4 % spread over 16 s** (5 frames); **0.53 %** over 359 frames | +| per-angular-bin brightness | varies (a travelling feature) | varies together | **per-bin sd 24.2**, max 80.3, against a per-frame angular sd of 42.2 | + +Brightness moves *around* the annulus while the total holds. A pulse is excluded. + +The temporal standard deviation over 103 frames is **an annulus** and nothing +else — dark inside, dark outside, peaking exactly on the ring's stroke +(radial std: r 0–4 → 1.08, r 10–13 → **36.75**, r 20–26 → 1.18): + +![std annulus](captures/focus-ring/ring-temporal-std-annulus.png) + +⚠️ A positional *jitter* would smear variation outside the stroke. It does not: +variation falls to ~1 both inside and outside, so the ring is not moving, it is +turning. + +### The period + +A dense 359-frame filmstrip (24 s at **15.03 fps against a requested 15 fps** — +the consumer kept up exactly, so these timestamps are not backlogged) gives the +annulus's 360-bin profile per frame, correlated against frame 0. A rotating ring +returns to itself once per revolution, so the trace's period **is** the spin +period — again with no angle estimated. + +Autocorrelation local maxima, in seconds: + +``` +2.18 4.36 6.52 8.70 10.86 13.02 15.22 17.42 +spacings: 2.18 2.16 2.18 2.16 2.16 2.20 2.20 mean 2.177 s +``` + +**Eight consecutive evenly-spaced peaks over nine revolutions.** A drifting +instrument cannot produce even spacing, which is the internal check on the +number. + +Raw trace committed at [`data/focus-ring-period-corr.npy`](data/focus-ring-period-corr.npy) +(rows: t, correlation-with-frame-0, annulus mean). + +### What the period is in the game's own units + +⚠️ **2.18 s is wall-clock under this emulator, and the emulator is not running +the game at 30 Hz.** The corpus measures 27.6–28.8 fps here. `ptbtneff01` +declares its first keyframe at **t = 120**, and under the settled reading +(1 unit = 1/60 s, 2 units per rendered frame) 120 units is **60 rendered +frames** — which at 27.6–28.8 fps spans **2.08–2.17 s**. The measurement sits at +the top of that band. + +**So the spin is one revolution per 120 units = 60 frames = 2.00 s at a true +30 Hz**, and no new constant is needed to account for it. 🟡 The 2.18 s is +consistent with the declared 120 rather than a re-derivation of it: the guest +frame rate was not measured in this same run, so the agreement is +consistency, not closure. + +## Two other things the same run measured + +* ✅ **The focus ring is the ONLY moving thing on the settled main menu.** Over + 103 frames / 20 s untouched, temporal std is **exactly 0.000** on every + unfocused button box, on the `NEW GAME` label, and on the `ptmsg` footer. Only + the focused button's box moves (std 4.46 against a background noise floor of + 0.906). A port that draws the main menu statically plus a spinning ring is + drawing everything that moves. +* ✅ **The ring is `ptbtneff01`, positionally confirmed.** Its centre was located + from the temporal-std map at game **(520.7, 339.7)**. The declared leaf offset + applied to button 3's rest position (542, 322) predicts **(521, 340)**. That + is a sub-pixel agreement between a decoded declaration and a live measurement, + and it is what ties the annulus to the record rather than to "a circle near the + cursor". + +## Reach + +* One run, one emulator, English locale, `GP_TITLE` build 5. +* The period is measured on **one** focused button (`OPTIONS`, button 4) and the + spin is shown on a second (`TUTORIAL`, button 3). Not checked on all five, and + not checked on `EXTRAS`. +* Says nothing about the direction of rotation — the estimator that would give a + signed angle failed its control and was not used. diff --git a/tools/re-capture/focus_ring_probe.py b/tools/re-capture/focus_ring_probe.py new file mode 100755 index 00000000..8e421ef5 --- /dev/null +++ b/tools/re-capture/focus_ring_probe.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Does the main menu's focus ring KEEP spinning, or is it drawn once and held? + +The question is not "is the ring rotated" -- one oracle frame already showed it +at a large angle (docs/re/structures/ui-button-focus-record.md). It is whether +that rotation is ANIMATED while a button sits focused, which is what decides +whether a port draws a static ring or runs a loop. + +Instrument: a live x11grab filmstrip and the per-pixel TEMPORAL standard +deviation of the frames while nothing is touched. A spinning ring makes its +own box vary; a held one does not. No angle is estimated anywhere -- the +centroid estimator that would do that fails its own control by up to 19.8 deg +(same page), so this probe measures presence-of-change instead, which is the +question actually asked. + +NO FIXED PIXEL BOXES. xenia's window has a menu bar and the game surface is +1279x675 inside a 1280x720 root, so game coordinates do not address grab +coordinates. This probe saves whole-frame accumulators; `focus_ring_report.py` +aligns them against a committed capture first and only then reads boxes. + +Phases: A = 20 s untouched, then d-pad DOWN, then C = 12 s untouched. +The d-pad press is the POSITIVE CONTROL: |mean(A) - mean(C)| must fire at the +two ring locations, or a null in phase A is a dead instrument, not a finding. + +Usage: focus_ring_probe.py OUTDIR +""" +import os, subprocess, sys, time +import numpy as np +from PIL import Image + +W, H = 1280, 720 +SD = os.path.dirname(os.path.abspath(__file__)) +OUT = sys.argv[1] if len(sys.argv) > 1 else "/sylph-home/re/ringcap" +os.makedirs(OUT, exist_ok=True) +RESTART_S = 25 # a long-lived x11grab stream stalls and repeats frames + + +def open_stream(): + return subprocess.Popen( + ["ffmpeg", "-loglevel", "error", "-f", "x11grab", "-draw_mouse", "0", + "-video_size", f"{W}x{H}", "-i", ":98", "-r", "4", + "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], + stdout=subprocess.PIPE, bufsize=W * H * 3 * 2) + + +class Stream: + def __init__(self): + self.p = open_stream(); self.seg = time.time() + def read(self): + if time.time() - self.seg > RESTART_S: + self.p.kill(); self.p = open_stream(); self.seg = time.time() + buf = self.p.stdout.read(W * H * 3) + if len(buf) < W * H * 3: + self.p.kill(); self.p = open_stream(); self.seg = time.time() + return None + return np.frombuffer(buf, np.uint8).reshape(H, W, 3) + def close(self): + try: self.p.kill() + except Exception: pass + + +sys.path.insert(0, SD) +from screen_match import classify_array # controlled: 8/8, incl. the movie + # frames that broke the old oracle + + +def collect(st, secs, tag): + """Whole-frame temporal mean and std over `secs`, plus a PNG filmstrip.""" + t0 = time.time(); n = 0 + acc = acc2 = None + next_shot = 0.0 + while True: + el = time.time() - t0 + if el >= secs: + break + a = st.read() + if a is None: + continue + f = a.astype(np.float64) + acc = f.copy() if acc is None else acc + f + acc2 = f * f if acc2 is None else acc2 + f * f + if el >= next_shot: + Image.fromarray(a).save(f"{OUT}/{tag}-t{el:05.1f}.png") + next_shot = el + 4.0 + n += 1 + mean = acc / n + std = np.sqrt(np.maximum(acc2 / n - mean * mean, 0)) + np.save(f"{OUT}/{tag}-mean.npy", mean.astype(np.float32)) + np.save(f"{OUT}/{tag}-std.npy", std.astype(np.float32)) + Image.fromarray(mean.astype(np.uint8)).save(f"{OUT}/{tag}-mean.png") + # a visible std map, scaled x8 and clipped -- an artefact a human can look at + Image.fromarray(np.clip(std * 8, 0, 255).astype(np.uint8)).save(f"{OUT}/{tag}-std8.png") + print(f"[{tag}] {n} frames in {secs:.0f}s = {n/secs:.2f} fps; " + f"whole-frame std mean {std.mean():.4f} max {std.max():.2f}", flush=True) + return mean, std, n + + +def main(): + st = Stream() + t0 = time.time(); seen = None; last = None; skipped = False + # ONE (A) ~45 s in skips the intro movie: measured, title at ~57 s against a + # ~193 s no-input baseline (HANDOFF, movie-binding.md). HAMMERING is what + # breaks the boot -- 88 presses left a permanent black screen -- so exactly + # one, and only once. + while time.time() - t0 < 620: + a = st.read() + if a is None: + continue + last = a + el = time.time() - t0 + if not skipped and el > 45: + subprocess.run(["python3", f"{SD}/pad.py", "tap", "A", "0.3"], check=False) + skipped = True + print(f"t={el:6.1f}s one (A) to skip the intro movie", flush=True) + continue + c, sc = classify_array(a) + if c != seen: + print(f"t={el:6.1f}s screen={c} " + + " ".join(f"{k}={v:+.3f}" for k, v in sc.items()), flush=True) + seen = c + if c == "title": + break + if seen != "title": + print("NEVER REACHED THE TITLE"); st.close(); return 1 + Image.fromarray(last).save(f"{OUT}/00-title.png") + subprocess.run(["python3", f"{SD}/pad.py", "tap", "A", "0.3"], check=False) + print("(A) on the title", flush=True) + t1 = time.time(); got = False + while time.time() - t1 < 150: + a = st.read() + if a is None: + continue + c, sc = classify_array(a) + if c == "menu": + got = True; break + if not got: + print("NO MENU AFTER A"); st.close(); return 2 + time.sleep(4) # let the menu's ~1 s fade-in and element ramps settle + a = st.read() + if a is not None: + Image.fromarray(a).save(f"{OUT}/01-menu.png") + print("AT MAIN MENU", flush=True) + + mA, sA, nA = collect(st, 20, "A") + subprocess.run(["python3", f"{SD}/pad.py", "dpad", "down"], check=False) + print(">>> d-pad DOWN pressed", flush=True) + time.sleep(2.0) + mC, sC, nC = collect(st, 12, "C") + + d = np.abs(mA - mC) + np.save(f"{OUT}/AC-absdiff.npy", d.astype(np.float32)) + Image.fromarray(np.clip(d * 4, 0, 255).astype(np.uint8)).save(f"{OUT}/AC-absdiff4.png") + print(f"[A-vs-C] absdiff mean {d.mean():.4f} max {d.max():.2f}", flush=True) + st.close() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/re-capture/focus_ring_report.py b/tools/re-capture/focus_ring_report.py new file mode 100644 index 00000000..51df8a1d --- /dev/null +++ b/tools/re-capture/focus_ring_report.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Read focus_ring_probe.py's accumulators, after ALIGNING them to game space. + +A grab is the whole root window; game coordinates only address it once the +window chrome offset is measured. This script measures that offset by +correlating the run's own mean frame against the committed `live-main-menu.png` +over a +/-12 px search, and refuses to report anything if the alignment is poor. + +Then, in game coordinates: + ring boxes -- 80x80 around each button's declared rest position; the ring + `ptbtneff01` is 42x46 and sits left of the label + static boxes -- `ptmsg` (one untimed keyframe) and a background corner: + the NEGATIVE controls, which must read sensor noise + positive ctrl -- |mean(A) - mean(C)| across the d-pad press must fire at the + two rings that changed state, or a null in A is a dead + instrument rather than a finding. +""" +import os, sys +import numpy as np +from PIL import Image + +OUT = sys.argv[1] if len(sys.argv) > 1 else "/sylph-home/re/ringcap" +REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +REF = os.path.join(REPO, "docs/re/captures/title-builds/live-main-menu.png") + +BTN_Y = [162, 242, 322, 401, 482] +LABEL = ["NEW GAME", "LOAD GAME", "TUTORIAL", "OPTIONS", "EXTRAS"] +BOXES = {} +for i, y in enumerate(BTN_Y): + BOXES[f"ring{i+1} ({LABEL[i]})"] = (480, y - 20, 560, y + 60) +BOXES["ptmsg footer [static ctl]"] = (527, 595, 773, 633) +BOXES["bg corner [static ctl]"] = (10, 10, 130, 130) +BOXES["button1 label [same row]"] = (560, 142, 760, 202) + + +def gray(a): + return (0.299 * a[..., 0] + 0.587 * a[..., 1] + 0.114 * a[..., 2]).astype(np.float32) + + +def zncc(x, y): + x = x - x.mean(); y = y - y.mean() + d = np.sqrt((x * x).sum() * (y * y).sum()) + return float((x * y).sum() / d) if d else 0.0 + + +def align(mean_rgb, ref_rgb): + """Measure (dy,dx) taking GAME coords -> GRAB coords. Returns (dy,dx,corr).""" + g = gray(mean_rgb); r = gray(ref_rgb) + rh, rw = r.shape + best = (None, None, -1.0) + for dy in range(30, 60): # chrome is ~45 rows + for dx in range(-12, 13): + if dy + rh > g.shape[0] or dx < 0 or dx + rw > g.shape[1]: + continue + c = zncc(g[dy:dy + rh, dx:dx + rw], r) + if c > best[2]: + best = (dy, dx, c) + return best + + +def main(): + mA = np.load(f"{OUT}/A-mean.npy"); sA = np.load(f"{OUT}/A-std.npy") + mC = np.load(f"{OUT}/C-mean.npy"); sC = np.load(f"{OUT}/C-std.npy") + ref = np.array(Image.open(REF).convert("RGB")).astype(np.float32) + dy, dx, corr = align(mA, ref) + print(f"alignment: game(0,0) sits at grab({dx},{dy}); ZNCC {corr:+.4f}") + if corr < 0.80: + print("ALIGNMENT TOO POOR — refusing to report boxes"); return 1 + print(f" (independent check: the window chrome measured 45 rows)\n") + + def box(arr, b): + x0, y0, x1, y1 = b + return arr[y0 + dy:y1 + dy, x0 + dx:x1 + dx, :] + + d = np.abs(mA - mC) + print(f"{'box':<30} {'A std':>9} {'A p99.9':>9} {'C std':>9} " + f"{'|A-C| mean':>11} {'|A-C| max':>10}") + print("-" * 84) + rows = {} + for k, b in BOXES.items(): + a_s = box(sA, b); c_s = box(sC, b); dd = box(d, b) + rows[k] = (float(a_s.mean()), float(np.percentile(a_s, 99.9)), + float(c_s.mean()), float(dd.mean()), float(dd.max())) + print(f"{k:<30} {rows[k][0]:9.3f} {rows[k][1]:9.3f} {rows[k][2]:9.3f} " + f"{rows[k][3]:11.3f} {rows[k][4]:10.2f}") + + noise = max(rows["ptmsg footer [static ctl]"][0], + rows["bg corner [static ctl]"][0]) + print(f"\nnegative-control noise floor (max of the two static boxes): {noise:.3f}") + print("A box only counts as MOVING if its phase-A std clears that floor.\n") + for k in BOXES: + if "ctl" in k: + continue + v = rows[k][0] + print(f" {k:<30} A std {v:7.3f} = {v/noise:6.2f}x the noise floor" + f" {'MOVING' if v > 3*noise else 'static'}") + + # visual artefacts, cropped to the game surface + for tag, arr, sc in (("A-std", sA, 8), ("C-std", sC, 8), ("AC-absdiff", d, 4)): + g = arr[dy:dy + 675, dx:dx + 1279, :] + Image.fromarray(np.clip(g * sc, 0, 255).astype(np.uint8)).save(f"{OUT}/{tag}-game.png") + Image.fromarray(mA[dy:dy + 675, dx:dx + 1279, :].astype(np.uint8)).save(f"{OUT}/A-mean-game.png") + Image.fromarray(mC[dy:dy + 675, dx:dx + 1279, :].astype(np.uint8)).save(f"{OUT}/C-mean-game.png") + print(f"\nwrote game-space artefacts to {OUT}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/re-capture/quad_rects.py b/tools/re-capture/quad_rects.py new file mode 100755 index 00000000..534feb01 --- /dev/null +++ b/tools/re-capture/quad_rects.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""Screen-space rectangles for every textured quad in a xenia draw log. + +The draw logs under docs/re/captures/ record vertex positions in NDC, printed +to **two decimals**. That is the whole point of this script: it converts the +quads to screen space *and* carries the quantisation with them, so a +measurement taken off one of these logs cannot quietly claim more precision +than the log has. + + NDC step 0.01 -> half-step 0.005 -> a single edge is +/- 3.2 px in X + and +/- 1.8 px in Y; a WIDTH or HEIGHT is a difference of two edges, so it + carries twice that: +/- 6.4 px and +/- 3.6 px. Getting this wrong is not + academic -- at the per-edge figure the control below fails 2 of 6. + +Usage: + quad_rects.py LOG [LOG ...] # every textured quad, per frame + quad_rects.py --control LOG # check recovered sizes against + # known texture dimensions + +The control is not optional in spirit. Any claim made from these numbers +should quote the control first: four sprites of known size are recovered from +the same log, and the residuals bound what the instrument can see. +""" + +import math +import re +import sys + +# Screen is 1280x720; NDC x in [-1,1] maps to [0,1280], y in [1,-1] to [0,720]. +W, H = 1280.0, 720.0 +NDC_HALF_STEP = 0.005 +EDGE_X = NDC_HALF_STEP * W / 2.0 # 3.2 px on one edge +EDGE_Y = NDC_HALF_STEP * H / 2.0 # 1.8 px on one edge +SIZE_X = 2 * EDGE_X # 6.4 px on a width (two edges) +SIZE_Y = 2 * EDGE_Y # 3.6 px on a height (two edges) + +# Decoded texture sizes for build 4 of GP_TITLE, from +# docs/re/ui-title-paint-order-capture.md and docs/re/ui-title-build-map.md. +# These are the known-positives the control checks against. +CONTROL_SIZES = { + "ptlogo1.t32": (919, 113), + "ptlogo2.t32": (992, 104), + "ptlogo_back2.t32": (1118, 262), + "ptlogo_back2eff.t32": (1133, 280), + "ptcopyright.t32": (694, 20), + "ptbtn00.t32": (513, 50), + "ptbtn00f.t32": (537, 76), +} + +FRAME_RE = re.compile(r"--- frame (\d+) ---") +DRAW_RE = re.compile(r"\s*(\d+) prim=(\d+) indices=(\d+)") +TEX_RE = re.compile(r"tex\[base=(0x[0-9A-Fa-f]+) (\d+)x(\d+)") +VERT_RE = re.compile(r"\[(-?\d+\.\d+),(-?\d+\.\d+),z=") + + +def parse(path): + """Yield dicts: frame, draw, tex base, and the quad's screen-space rect.""" + frame, cur = 0, None + for line in open(path): + m = FRAME_RE.match(line) + if m: + frame = int(m.group(1)) + continue + m = DRAW_RE.match(line) + if m: + t = TEX_RE.search(line) + cur = {"frame": frame, "draw": int(m.group(1)), + "tex": t.group(1) if t else None} + continue + if "v:" in line and cur is not None: + verts = [(float(a), float(b)) for a, b in VERT_RE.findall(line)] + # A draw can carry several quads; four vertices each. + for i in range(0, len(verts) - 3, 4): + q = verts[i:i + 4] + xs = [(x + 1.0) * W / 2.0 for x, _ in q] + ys = [(1.0 - y) * H / 2.0 for _, y in q] + # Vertex order is TL, TR, BR, BL, so edge 0->1 is the drawn + # width and 1->2 the drawn height. For a ROTATED quad the + # bounding box is not the sprite; the edges are. + e0 = math.hypot(xs[1] - xs[0], ys[1] - ys[0]) + e1 = math.hypot(xs[2] - xs[1], ys[2] - ys[1]) + ang = math.degrees(math.atan2(ys[1] - ys[0], xs[1] - xs[0])) + yield {**cur, + "left": min(xs), "top": min(ys), + "w": max(xs) - min(xs), "h": max(ys) - min(ys), + "ew": e0, "eh": e1, "rot": ang, + "cx": sum(xs) / 4.0, "cy": sum(ys) / 4.0} + cur = None + + +def dump(path): + print(f"# {path}") + print(f"# NDC printed to 2 dp -> edge +/- {EDGE_X:.1f}/{EDGE_Y:.1f} px, " + f"size +/- {SIZE_X:.1f}/{SIZE_Y:.1f} px (X/Y)") + print(f"{'frame':>5} {'draw':>5} {'tex':>12} " + f"{'left':>8} {'top':>8} {'bboxW':>8} {'bboxH':>8} " + f"{'edgeW':>8} {'edgeH':>8} {'rot':>7} {'cx':>8} {'cy':>8}") + for q in parse(path): + if q["tex"] is None: + continue + print(f"{q['frame']:>5} {q['draw']:>5} {q['tex']:>12} " + f"{q['left']:>8.1f} {q['top']:>8.1f} {q['w']:>8.1f} {q['h']:>8.1f} " + f"{q['ew']:>8.1f} {q['eh']:>8.1f} {q['rot']:>7.2f} " + f"{q['cx']:>8.1f} {q['cy']:>8.1f}") + + +def control(path): + """Recover the known-positive sprites by size and report the residual.""" + rects = [q for q in parse(path) if q["tex"] is not None] + print(f"# control: {path}") + print(f"{'sprite':<22} {'decoded':>11} {'measured':>13} " + f"{'dx':>6} {'dy':>6} verdict") + ok = True + for name, (tw, th) in CONTROL_SIZES.items(): + best = min(rects, key=lambda q: abs(q["w"] - tw) + abs(q["h"] - th)) + dx, dy = best["w"] - tw, best["h"] - th + good = abs(dx) <= SIZE_X and abs(dy) <= SIZE_Y + ok &= good + print(f"{name:<22} {tw:>5}x{th:<5} {best['w']:>6.1f}x{best['h']:<6.1f} " + f"{dx:>6.1f} {dy:>6.1f} {'PASS' if good else 'FAIL'}") + print(f"# {'CONTROL PASSES' if ok else 'CONTROL FAILS'} — " + f"every known size recovered inside the log's own quantisation" + if ok else "# CONTROL FAILS — do not measure anything with this") + return 0 if ok else 1 + + +# Every sprite the title's build-4 capture can draw, by decoded size. The two +# pteff03 entries are the nested ptloop leaves, whose declared vertical scales +# are 600 % and 800 %. +TITLE_SPRITES = { + (919, 113): "ptlogo1.t32", + (992, 104): "ptlogo2.t32", + (1118, 262): "ptlogo_back2.t32", + (1133, 280): "ptlogo_back2eff.t32", + (694, 20): "ptcopyright.t32", + (513, 50): "ptbtn00.t32", + (38, 18): "ptlogo_tm.t32", + (399, 180): "pteff03/pteff03a.t32", + (537, 76): "ptbtn00f.t32", # build 2's focus plate +} + + +def scales(path): + """For each quad, the drawn size over the nearest decoded sprite size. + + The question this answers: which elements are drawn at a scale other than + 100 %? Only those can say anything about what scale is anchored on. + """ + print(f"# scale census: {path}") + print(f"{'frame':>5} {'sprite':<22} {'edgeW':>8} {'edgeH':>8} " + f"{'sx%':>7} {'sy%':>7} {'rot':>7}") + seen = set() + for q in parse(path): + if q["tex"] is None: + continue + if abs(q["ew"] - W) < SIZE_X and abs(q["eh"] - H) < SIZE_Y: + name, sx, sy = "full-screen layer", 1.0, 1.0 + key = (name, 1.0, 1.0) + if key not in seen: + seen.add(key) + print(f"{q['frame']:>5} {name:<22} {q['ew']:>8.1f} " + f"{q['eh']:>8.1f} {100.0:>7.1f} {100.0:>7.1f} " + f"{q['rot']:>7.2f}") + continue + # Match on the edge lengths, allowing any uniform-ish scale factor. + best, bestcost = None, None + for (tw, th), name in TITLE_SPRITES.items(): + sx, sy = q["ew"] / tw, q["eh"] / th + cost = abs(math.log(sx)) + abs(math.log(sy)) + if bestcost is None or cost < bestcost: + best, bestcost = (name, tw, th, sx, sy), cost + name, tw, th, sx, sy = best + key = (name, round(sx, 2), round(sy, 2)) + if key in seen: + continue + seen.add(key) + print(f"{q['frame']:>5} {name:<22} {q['ew']:>8.1f} {q['eh']:>8.1f} " + f"{100 * sx:>7.1f} {100 * sy:>7.1f} {q['rot']:>7.2f}") + return 0 + + +if __name__ == "__main__": + args = sys.argv[1:] + if not args: + sys.exit(__doc__) + if args[0] == "--scales": + sys.exit(max(scales(p) for p in args[1:])) + if args[0] == "--control": + sys.exit(max(control(p) for p in args[1:])) + for p in args: + dump(p) diff --git a/tools/re-capture/ring_angular.py b/tools/re-capture/ring_angular.py new file mode 100644 index 00000000..29cc4ec9 --- /dev/null +++ b/tools/re-capture/ring_angular.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Is the focus ring ROTATING, or just pulsing in brightness? + +The temporal-std map of a focused button is an annulus, which both hypotheses +predict: a travelling bright feature varies every annulus pixel, and so does a +uniform fade. Two observables separate them, and this script reports both. + + (1) TOTAL annulus brightness per frame. A rotation moves brightness around + the annulus and conserves the sum; an alpha pulse does not. + (2) The 360-bin ANGULAR PROFILE, cross-correlated between frames. A rotation + shifts the profile by a lag; a pulse scales it in place. + +CONTROL FIRST. The angular estimator is run over a known synthetic rotation of +the run's own first frame (30/90/180/270 deg) and must recover it; the corpus +already has a centroid estimator that fails this by up to 19.8 deg, and that is +why one is not used here. + +Usage: ring_angular.py CX CY [FRAME ...] (CX,CY in GAME coordinates) +""" +import os, sys +import numpy as np +from PIL import Image + +DY, DX = 45, 1 # game(0,0) -> grab, measured by focus_ring_report.py +R_IN, R_OUT = 8.0, 18.0 # annulus radii, in px, read off the std map +NBINS = 360 + + +def ndrotate(img, deg): + """Bilinear rotation about the patch centre -- the control's known-positive.""" + h, w = img.shape + cy, cx = (h - 1) / 2.0, (w - 1) / 2.0 + yy, xx = np.mgrid[0:h, 0:w] + t = np.radians(deg) + ys = (yy - cy) * np.cos(t) - (xx - cx) * np.sin(t) + cy + xs = (yy - cy) * np.sin(t) + (xx - cx) * np.cos(t) + cx + y0 = np.floor(ys).astype(int); x0 = np.floor(xs).astype(int) + fy = ys - y0; fx = xs - x0 + out = np.zeros_like(img) + for dy_, dx_, wgt in ((0, 0, (1 - fy) * (1 - fx)), (0, 1, (1 - fy) * fx), + (1, 0, fy * (1 - fx)), (1, 1, fy * fx)): + yi = np.clip(y0 + dy_, 0, h - 1); xi = np.clip(x0 + dx_, 0, w - 1) + ok = (y0 + dy_ >= 0) & (y0 + dy_ < h) & (x0 + dx_ >= 0) & (x0 + dx_ < w) + out += np.where(ok, img[yi, xi] * wgt, 0.0) + return out + + +def ndrotate(img, deg): + """Bilinear rotation about the patch centre -- the control's known-positive.""" + h, w = img.shape + cy, cx = (h - 1) / 2.0, (w - 1) / 2.0 + yy, xx = np.mgrid[0:h, 0:w] + t = np.radians(deg) + ys = (yy - cy) * np.cos(t) - (xx - cx) * np.sin(t) + cy + xs = (yy - cy) * np.sin(t) + (xx - cx) * np.cos(t) + cx + y0 = np.floor(ys).astype(int); x0 = np.floor(xs).astype(int) + fy = ys - y0; fx = xs - x0 + out = np.zeros_like(img) + for dy_, dx_, wgt in ((0, 0, (1 - fy) * (1 - fx)), (0, 1, (1 - fy) * fx), + (1, 0, fy * (1 - fx)), (1, 1, fy * fx)): + yi = np.clip(y0 + dy_, 0, h - 1); xi = np.clip(x0 + dx_, 0, w - 1) + ok = (y0 + dy_ >= 0) & (y0 + dy_ < h) & (x0 + dx_ >= 0) & (x0 + dx_ < w) + out += np.where(ok, img[yi, xi] * wgt, 0.0) + return out + + +def patch(path, cx, cy, half=28): + a = np.array(Image.open(path).convert("RGB")).astype(np.float32) + g = 0.299 * a[..., 0] + 0.587 * a[..., 1] + 0.114 * a[..., 2] + return g[cy + DY - half:cy + DY + half, cx + DX - half:cx + DX + half] + + +def polar(p): + """(total annulus brightness, 360-bin mean profile) of one patch.""" + h, w = p.shape + yy, xx = np.mgrid[0:h, 0:w] + cy, cx = (h - 1) / 2.0, (w - 1) / 2.0 + r = np.hypot(yy - cy, xx - cx) + m = (r >= R_IN) & (r <= R_OUT) + th = (np.degrees(np.arctan2(yy - cy, xx - cx)) + 360.0) % 360.0 + idx = np.clip((th[m] / 360.0 * NBINS).astype(int), 0, NBINS - 1) + v = p[m] + prof = np.zeros(NBINS); cnt = np.zeros(NBINS) + np.add.at(prof, idx, v); np.add.at(cnt, idx, 1.0) + prof = np.where(cnt > 0, prof / np.maximum(cnt, 1), np.nan) + prof = np.nan_to_num(prof, nan=np.nanmean(prof)) + return float(v.sum()), prof + + +def lag(p0, p1): + """Circular cross-correlation lag in degrees taking p0 -> p1.""" + a = p0 - p0.mean(); b = p1 - p1.mean() + c = np.fft.irfft(np.fft.rfft(b) * np.conj(np.fft.rfft(a)), NBINS) + k = int(np.argmax(c)) + peak = c[k] / np.sqrt((a * a).sum() * (b * b).sum()) + return (k if k <= 180 else k - 360), float(peak) + + +def main(): + cx, cy = int(sys.argv[1]), int(sys.argv[2]) + frames = sys.argv[3:] + p0 = patch(frames[0], cx, cy) + + print("=== CONTROL: recover a known synthetic rotation of frame 0 ===") + ok = True + for deg in (30, 90, 180, 270): + rot = ndrotate(p0, -deg) + _, pr = polar(rot); _, pa = polar(p0) + d, pk = lag(pa, pr) + err = ((d - deg + 180) % 360) - 180 + flag = "ok " if abs(err) <= 3 else "FAIL" + if abs(err) > 3: + ok = False + print(f" {flag} applied {deg:4d} deg -> recovered {d:5d} deg " + f"(err {err:+4d}, peak {pk:.3f})") + # negative control: a ring-free patch of the same frame must not correlate + off = patch(frames[0], cx + 160, cy) + _, po = polar(off); _, pa = polar(p0) + _, pk = lag(pa, po) + print(f" ring-free patch of the same frame: peak {pk:.3f} (must be low)") + if not ok: + print("\nCONTROL FAILED — the estimator cannot measure this; stopping.") + return 1 + print(" CONTROL PASSED\n") + + print("=== MEASUREMENT: successive live frames of the same focused ring ===") + print(f"{'frame':<24} {'annulus sum':>12} {'vs f0 %':>9} {'lag vs f0':>10} {'peak':>7}") + base_s, base_p = polar(p0) + for f in frames: + s, pr = polar(patch(f, cx, cy)) + d, pk = lag(base_p, pr) + print(f"{os.path.basename(f):<24} {s:12.1f} {100*s/base_s:8.1f}% " + f"{d:9d}d {pk:7.3f}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/re-capture/ring_period.py b/tools/re-capture/ring_period.py new file mode 100644 index 00000000..7a38d28d --- /dev/null +++ b/tools/re-capture/ring_period.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Measure the focus ring's SPIN PERIOD from a dense live filmstrip. + +No absolute angle is estimated. The corpus's centroid estimator fails its own +control by up to 19.8 deg, and a 360-bin angular cross-correlation also FAILED +the control written for it here (a synthetic 30 deg rotation of a live frame +came back as 0 deg, peak 0.596), so neither is trusted. + +What is used instead needs no angle: the annulus's 360-bin brightness profile, +correlated against frame 0. A rotating ring's profile returns to itself once +per revolution, so the correlation trace is periodic and its first return to a +maximum IS the period. The ring is located from the data (the peak of the +temporal-std map over the button column), not from a declared coordinate. + +Usage: ring_period.py SECONDS OUTDIR +""" +import os, subprocess, sys, time +import numpy as np +from PIL import Image + +W, H, DY, DX = 1280, 720, 45, 1 +R_IN, R_OUT, NB = 8.0, 18.0, 360 +SECS = float(sys.argv[1]) if len(sys.argv) > 1 else 30.0 +OUT = sys.argv[2] if len(sys.argv) > 2 else "/sylph-home/re/ringcap" +COL = (480, 130, 570, 530) # x0,y0,x1,y1 in GAME coords: the button column + + +def grab_stream(secs): + p = subprocess.Popen( + ["ffmpeg", "-loglevel", "error", "-f", "x11grab", "-draw_mouse", "0", + "-video_size", f"{W}x{H}", "-i", ":98", "-r", "15", + "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], + stdout=subprocess.PIPE, bufsize=W * H * 3 * 2) + n = W * H * 3 + t0 = time.time(); frames = []; ts = [] + x0, y0, x1, y1 = COL + while time.time() - t0 < secs: + b = p.stdout.read(n) + if len(b) < n: + break + a = np.frombuffer(b, np.uint8).reshape(H, W, 3) + g = (0.299 * a[..., 0] + 0.587 * a[..., 1] + 0.114 * a[..., 2]).astype(np.float32) + frames.append(g[y0 + DY:y1 + DY, x0 + DX:x1 + DX].copy()) + ts.append(time.time() - t0) + p.kill() + return np.array(frames), np.array(ts) + + +def annulus_profile(patch, cy, cx): + h, w = patch.shape + yy, xx = np.mgrid[0:h, 0:w] + r = np.hypot(yy - cy, xx - cx) + m = (r >= R_IN) & (r <= R_OUT) + th = (np.degrees(np.arctan2(yy - cy, xx - cx)) + 360) % 360 + idx = np.clip((th[m] / 360 * NB).astype(int), 0, NB - 1) + v = patch[m] + prof = np.zeros(NB); cnt = np.zeros(NB) + np.add.at(prof, idx, v); np.add.at(cnt, idx, 1.0) + prof = np.where(cnt > 0, prof / np.maximum(cnt, 1), np.nan) + return np.nan_to_num(prof, nan=np.nanmean(prof)), float(v.mean()) + + +def main(): + F, T = grab_stream(SECS) + if len(F) < 10: + print("too few frames"); return 1 + fps = len(F) / (T[-1] - T[0]) + print(f"{len(F)} frames over {T[-1]-T[0]:.1f}s = {fps:.2f} fps", flush=True) + + std = F.std(0) + cy, cx = np.unravel_index(np.argmax( + np.array([[std[max(0, i-14):i+14, max(0, j-14):j+14].mean() + for j in range(std.shape[1])] for i in range(std.shape[0])])), std.shape) + print(f"ring located from the data at patch({cx},{cy}) = " + f"GAME({COL[0]+cx},{COL[1]+cy}); local std {std[cy, cx]:.2f}", flush=True) + + profs = []; means = [] + for f in F: + p, m = annulus_profile(f, cy, cx) + profs.append(p); means.append(m) + P = np.array(profs); M = np.array(means) + print(f"annulus mean brightness: {M.mean():.2f} +/- {M.std():.3f} " + f"({100*M.std()/M.mean():.2f}% -- a PULSE would move this)", flush=True) + + a = P[0] - P[0].mean() + corr = np.array([float(((p - p.mean()) * a).sum() / + np.sqrt(((p - p.mean())**2).sum() * (a * a).sum())) + for p in P]) + np.save(f"{OUT}/period-corr.npy", np.vstack([T, corr, M])) + print("\n t(s) corr-with-frame0 annulus mean") + for t, c, m in zip(T, corr, M): + bar = "#" * max(0, int((c + 1) * 25)) + print(f"{t:6.2f} {c:+.3f} {bar:<50} {m:7.2f}") + + # first return to a local maximum after the trace has dipped + dip = np.argmax(corr < 0.3) if (corr < 0.3).any() else None + if dip: + after = corr[dip:] + k = dip + int(np.argmax(after)) + print(f"\nfirst return to max after the dip: t = {T[k]:.2f}s (corr {corr[k]:+.3f})") + return 0 + + +if __name__ == "__main__": + sys.exit(main())