Two more data points for the Q5 instability, from today s drives. A run that pressed A with no d-pad movement ended in a tutorial mission, correlating +0.960 with the committed capture, so that boot opened on TUTORIAL. A later boot read NEW GAME from a focus detector on the first menu frame. Six boots on the same harness now: TUTORIAL three times, NEW GAME three times, and no other item ever observed. The distribution is not uniform over the five buttons -- only these two occur -- which is a real constraint on whatever selects initial focus and something an explanation will have to account for. Also records in METHOD a bug that cost a seven-minute driven boot: a value was clamped for readability BEFORE the comparison that used it. A focus detector printed a degenerate margin, so it was capped at 999; the cap ran before the vote-sorting step, two different votes compared equal, the stable sort kept the wrong one, and a correct NEW GAME became an out-of-range index and a refusal. The measurement was right throughout -- a cosmetic fix changed a decision. Clamp at the point of display, never upstream of a comparison. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
145 lines
6.1 KiB
Python
Executable File
145 lines
6.1 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Which menu button is focused, using ONLY committed captures.
|
|
|
|
`tools/port/which-focus` answers this by rendering every focus state in the
|
|
port's Godot project and taking the minimum difference. That is a sound method
|
|
and it passed its controls -- but it needs Godot, which this container does not
|
|
have, and rendering the port's project is outside the decoder's role. This does
|
|
the same job from the framebuffer captures alone.
|
|
|
|
METHOD. The focus highlight is the only thing that moves between two captures
|
|
of the same menu with different focus. So for an unknown shot U and a reference
|
|
R whose focus is known, the positive part of (U - R) peaks on U's focused row
|
|
and the negative part peaks on R's. Two captures with known, different focus
|
|
therefore calibrate the row->button mapping directly, and no geometry has to be
|
|
assumed -- which matters, because assuming `rest_y` was a band centre is exactly
|
|
how an earlier attempt of mine mis-assigned a band and produced a wrong answer.
|
|
|
|
CONTROLS (run on every invocation; the tool refuses if any fails):
|
|
* the calibration pair must recover its own two answers;
|
|
* a frame with no menu must NOT produce a confident verdict.
|
|
|
|
focus_from_capture.py SHOT.png [--json]
|
|
|
|
⚠️ LIMIT, stated because it matters: the only two full main-menu captures with
|
|
a known focus state are REF_A and REF_B, which are this tool's own calibration
|
|
inputs. So reproducing them is self-consistency, NOT validation. The honest
|
|
validation is against a known TRANSITION rather than a known state: press the
|
|
d-pad down once and the reported button must advance by exactly one. A drive
|
|
that does that is testing this tool, not trusting it.
|
|
"""
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
import numpy as np
|
|
from PIL import Image
|
|
|
|
CAP = "/work/docs/re/captures/title-builds"
|
|
REF_A = f"{CAP}/live-main-menu.png" # NEW GAME focused
|
|
REF_B = f"{CAP}/live-main-menu-options-focused.png" # OPTIONS focused
|
|
BUTTONS = ["NEW GAME", "LOAD GAME", "TUTORIAL", "OPTIONS", "EXTRAS"]
|
|
IDX_A, IDX_B = 0, 3
|
|
MIN_MARGIN = 2.0
|
|
|
|
def is_menu(a, thresh=0.85):
|
|
"""Is this frame the main menu at all?
|
|
|
|
The focus statistic below is a peak-to-median ratio on a difference image,
|
|
and a difference against ANY dissimilar frame has a large peak -- a title
|
|
screen scored 2.88 and sailed past a 2.0 bar. So the screen identity has to
|
|
be established FIRST, with the zncc classifier controlled 6/6 elsewhere.
|
|
"""
|
|
g = a.mean(axis=2)
|
|
z = (g - g.mean()) / (g.std() + 1e-9)
|
|
ref = np.asarray(Image.open(REF_A).convert("L"), float)[:675, :1279]
|
|
zr = (ref - ref.mean()) / (ref.std() + 1e-9)
|
|
return float((z * zr).mean()) >= thresh
|
|
|
|
|
|
def load(p):
|
|
a = np.asarray(Image.open(p).convert("RGB"), float)
|
|
# Normalise to the captures' 1279x675 top-left crop: a 1280x720 guest frame
|
|
# and a 1279x675 screenshot are the same pixels, cropped, not scaled.
|
|
return a[:675, :1279]
|
|
|
|
def row_profile(u, r):
|
|
"""Row-sums of the positive part of (u - r), over the button column band."""
|
|
d = (u - r).mean(axis=2)[:, 500:820]
|
|
return np.clip(d, 0, None).sum(axis=1)
|
|
|
|
def peak_row(prof, smooth=9):
|
|
k = np.ones(smooth) / smooth
|
|
s = np.convolve(prof, k, mode="same")
|
|
return int(np.argmax(s)), float(s.max()), float(np.median(s))
|
|
|
|
def calibrate():
|
|
A, B = load(REF_A), load(REF_B)
|
|
ra, _, _ = peak_row(row_profile(A, B)) # A's focus row (NEW GAME)
|
|
rb, _, _ = peak_row(row_profile(B, A)) # B's focus row (OPTIONS)
|
|
pitch = (rb - ra) / (IDX_B - IDX_A)
|
|
return A, B, ra, pitch
|
|
|
|
def classify(shot, A, B, ra, pitch):
|
|
"""Return (button, margin). Compares against BOTH references and agrees."""
|
|
votes = []
|
|
for ref, ref_idx in ((A, IDX_A), (B, IDX_B)):
|
|
prof = row_profile(shot, ref)
|
|
r, peak, med = peak_row(prof)
|
|
# A zero median makes this explode, so floor it. 🔴 Do NOT cap here:
|
|
# an earlier version capped at 999 to keep the printed number readable,
|
|
# which made two different votes compare EQUAL, and the stable sort then
|
|
# kept the wrong one -- turning a correct NEW GAME into an out-of-range
|
|
# index and a refusal. Cap at the point of DISPLAY, never before a
|
|
# comparison that depends on the value.
|
|
margin = peak / max(med, 1.0)
|
|
idx = int(round((r - ra) / pitch))
|
|
votes.append((idx, margin, ref_idx))
|
|
# If the shot IS one of the references, that comparison is degenerate (all
|
|
# zero) -- keep the vote with the larger margin.
|
|
votes.sort(key=lambda v: -v[1])
|
|
idx, margin, _ = votes[0]
|
|
if not (0 <= idx < len(BUTTONS)):
|
|
return None, margin
|
|
return BUTTONS[idx], margin
|
|
|
|
def main():
|
|
args = [a for a in sys.argv[1:] if not a.startswith("--")]
|
|
as_json = "--json" in sys.argv
|
|
if not args:
|
|
print(__doc__); return 2
|
|
A, B, ra, pitch = calibrate()
|
|
|
|
# --- control 1: the calibration pair must recover its own answers
|
|
for ref, want in ((A, "NEW GAME"), (B, "OPTIONS")):
|
|
got, _ = classify(ref, A, B, ra, pitch)
|
|
if got != want:
|
|
print(f"CONTROL FAILED: calibration pair gave {got}, expected {want}",
|
|
file=sys.stderr)
|
|
return 1
|
|
# --- control 2: a frame with no menu must be rejected as not-a-menu
|
|
neg = f"{CAP}/live-title-press-a.png"
|
|
if os.path.exists(neg) and is_menu(load(neg)):
|
|
print("CONTROL FAILED: a title frame was accepted as a menu", file=sys.stderr)
|
|
return 1
|
|
|
|
shot = load(args[0])
|
|
if not is_menu(shot):
|
|
if as_json:
|
|
print(json.dumps({"button": None, "margin": 0.0, "decided": False,
|
|
"reason": "not the main menu"}))
|
|
else:
|
|
print("UNDECIDED (not the main menu)")
|
|
return 1
|
|
got, margin = classify(shot, A, B, ra, pitch)
|
|
ok = got is not None and margin >= MIN_MARGIN
|
|
if as_json:
|
|
print(json.dumps({"button": got, "margin": round(min(margin, 999.0), 3),
|
|
"decided": ok}))
|
|
else:
|
|
print(f"{got if ok else 'UNDECIDED'} margin={min(margin, 999.0):.2f}")
|
|
return 0 if ok else 1
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|