Answers the port's ask: ptbtneff01 is ANIMATED while a button is focused, not drawn once and held. The existing page said 'the ring SPINS' from one frame at a large angle, which is equally consistent with a static draw at a fixed angle. No angle is quoted anywhere. The 360-bin angular estimator written for this FAILED its own control -- a synthetic 30 deg came back as 0 deg (peak 0.596) while 90/180/270 came back exactly -- so it was not used. What settles it needs no angle: total annulus brightness is conserved to 0.4 % while individual angular bins swing by 24, i.e. brightness moving AROUND the ring, which excludes a pulse. The temporal-std map is a clean annulus, falling to ~1 both inside and outside the stroke, which excludes positional jitter. Period from the profile's autocorrelation: eight evenly spaced peaks, mean 2.177 s over nine revolutions. Even spacing is the internal check a drifting instrument cannot pass. That is 120 units = 60 frames = 2.00 s at a true 30 Hz. Also measured, same run: the ring is the ONLY moving thing on the settled main menu -- temporal std is exactly 0.000 on every unfocused button, the labels and the footer. And the ring's centre, located from the std map at game (520.7, 339.7), matches the declared leaf offset's prediction of (521, 340). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KNR5Y79D1T4bBr6gJQaWFP
110 lines
4.6 KiB
Python
110 lines
4.6 KiB
Python
#!/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())
|