Files
Sylpheed/tools/re-capture/focus_from_capture.py
sylph-decoder 0bc729639d re: initial focus is TUTORIAL x3 / NEW GAME x3, and never anything else
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
2026-08-29 17:42:03 +00:00

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())