Files
Sylpheed/tools/re-capture/screen_id.py
Sylpheed RE agent 6aa31e52ba tools: reject degenerate captures in screen_id instead of classifying them
Every statistic in screen_id is an AREA FRACTION, so a capture that is not a game
frame still produces clean numbers.  Measured 2026-08-26: a guard shot came back
10x710 -- a sliver -- and classified as `menu` with green=0.0000, white=0.0157.
The guard passed, the fixed key sequence went out anyway, and the run loaded a
TUTORIAL instead of the save's Stage 02.

This is the second time this failure has been paid for.  bin/screenshot's own
header records the first (2026-08-18): a second window of class "xenia_canary"
meant grabs came back as slivers and "a whole session's screen ids were noise".
That fix hardened the CAPTURE side only, so the same failure still reached the
oracles by any other path.  Reject it at the point the answer is consumed too:
features() now returns None below 640x360 and classify() reports `none`.

Verified: the 10x710 sliver -> `none`; readyroom, flight and the briefing capture
all still classify as before.
2026-08-26 22:55:58 +00:00

157 lines
6.9 KiB
Python
Executable File

#!/usr/bin/env python3
"""Identify which game screen a screenshot shows, without fixed pixel positions.
The nav scripts used to test named pixels ("648,221 is white"). That works only
while the game image sits at a known place on the root window, and it does not:
xenia's GTK window has a menu bar, so on some displays the image is ~25 px lower
and every constant reads the wrong row. The failure is silent and expensive — a
run sat on a plainly visible MAIN MENU for 300 s reporting "no main menu", and a
later one missed the title screen so the attract movie looped for ten minutes.
So identify screens by WHOLE-IMAGE statistics instead, which no vertical shift
(or scale, or letterbox) can move:
green fraction of pixels that are the game's green UI text/HUD colour
white fraction of near-white pixels
mean per-channel mean
Measured on known-good captures of each screen (1280x720, no menu bar):
title green 0.11% white 7.4% mean (62, 75, 84) blue-ish, bright
main menu green 0.04% white 3.5% mean (25, 38, 70) dark, strongly blue
in flight green 1.2% white 3.4% mean (54, 39, 37) green HUD everywhere
movie green 0% white varies no green at all
Usage: screen_id.py <png> [--json]
Prints one of: title | menu | briefing | readyroom | flight | other, plus the features.
"""
import json
import subprocess
import sys
# Downscaled first: the features are area fractions, so 320x180 gives the same
# answer for a fraction of the work (a full-size pure-python pass costs seconds,
# and these oracles are polled once a second).
W, H = 320, 180
def frame_size(path):
"""(w, h) of the capture, or None if it cannot be read."""
out = subprocess.run(["identify", "-format", "%w %h", path],
capture_output=True, text=True).stdout.split()
try:
return int(out[0]), int(out[1])
except (IndexError, ValueError):
return None
# A capture that is not a plausible game frame must be REJECTED, not classified.
# MEASURED 2026-08-26: a guard shot came back 10x710 -- a sliver -- and every
# statistic below is an area fraction, so the sliver classified cleanly as
# `menu`. The guard passed, the fixed key sequence was issued anyway, and the run
# loaded a TUTORIAL instead of the save's Stage 02.
#
# This is the SECOND time this has been paid for. `bin/screenshot`'s own header
# records the first (2026-08-18): a second window of class "xenia_canary" meant
# grabs came back as slivers and "a whole session's screen ids were noise". That
# fix hardened the CAPTURE side only, so the same failure still reaches the
# oracles by any other path. Reject it here too, where the answer is consumed.
MIN_W, MIN_H = 640, 360
def features(path):
size = frame_size(path)
if size is None or size[0] < MIN_W or size[1] < MIN_H:
return None
raw = subprocess.run(
["convert", path, "-alpha", "off", "-resize", f"{W}x{H}!", "-depth", "8",
"rgb:-"], capture_output=True).stdout
n = len(raw) // 3
if not n:
return None
green = white = 0
sr = sg = sb = 0
for i in range(0, n * 3, 3):
r, g, b = raw[i], raw[i + 1], raw[i + 2]
sr += r; sg += g; sb += b
if g > 130 and g - r > 45 and g - b > 45:
green += 1
if r > 230 and g > 230 and b > 230:
white += 1
return {"green": green / n, "white": white / n,
"r": sr / n, "g": sg / n, "b": sb / n}
def classify(f):
if f is None:
return "none" # unreadable OR not a plausible frame -- never a screen
# Flight first: the HUD paints far more green than any menu.
# (READY ROOM is checked before the menu rule because it is also dark-ish
# and strongly blue, and would otherwise be swallowed by it.)
if f["green"] > 0.004:
return "flight"
# The READY ROOM is far the brightest blue of any 2D screen: measured
# (12.7, 36.5, 133.5) on two independent captures, byte-identical because
# the screen is static. The next-bluest thing on the load path is the save
# list at (16.7, 41.7, 81.0), and the mission briefing is cyan rather than
# blue at (4.6, 47.7, 54.5), so a floor of b > 110 separates all three with
# room to spare. Worth having as its own class: `launch_mission.sh` used a
# fixed `sleep 28` for this transition and its next press was swallowed,
# which put one run in OPTIONS and the next in BRIEFINGS.
if f["b"] > 110 and f["r"] < 40 and f["b"] - f["g"] > 60:
return "readyroom"
# The mission BRIEFING map is cyan, not blue, and without this rule it falls
# straight through to the menu test below (dark, b - r > 30, r < 45, little
# white -- all true of it). MEASURED 2026-08-26: that mislabel made
# `wait_screen.sh readyroom` report NEVER REACHED READY ROOM on a run that
# had in fact navigated LOAD GAME -> slot 01 -> YES and was sitting on the
# briefing three screens further on, which is the worst kind of failure --
# a successful route scored as a failed one.
#
# What separates them is that cyan has blue and green nearly equal, while
# the menu's blue runs far ahead of its green:
#
# briefing (1.4, 45.2, 49.6) (5.5, 49.1, 54.3) (4.6, 47.7, 54.5) b-g ~ 5
# main menu (25, 38, 70) (13, 26, 59) b-g ~ 32
#
# The r < 20 floor keeps the title screen (52.8, 66.1, 74.5, b-g 8.4) out;
# the ready room (b-g ~ 97) and flight (green rule) are already claimed above.
if f["r"] < 20 and f["g"] > 30 and f["b"] - f["g"] < 20:
return "briefing"
# The main menu is dark and strongly blue. TWO measured signatures, not one:
#
# white 3.5% mean (25, 38, 70) the 2026-07 reference at the top
# white 0.03% mean (13, 26, 59) measured 2026-08-18, twice, on the menu
# reached from the boot title
#
# The second one classified as "other" under the original rule's
# `0.015 < white` floor, which is worse than it sounds: a script that waits
# for "menu" then never sees it reports the navigation as FAILED while the
# menu is plainly on screen. Both variants are dark, strongly blue and
# essentially green-free, so key on that and let the near-white fraction be
# anything below the title's 3.8%.
if f["b"] - f["r"] > 30 and f["r"] < 45 and f["white"] < 0.075:
return "menu"
# The title is brighter, still blue-ish, and carries the green PRESS A text.
if f["green"] > 0.0004 and f["b"] > 60 and f["white"] > 0.03:
return "title"
return "other"
def main():
path = sys.argv[1]
f = features(path)
name = classify(f)
if "--json" in sys.argv:
print(json.dumps({"screen": name, **(f or {})}))
elif f:
print(f"{name} green={f['green']:.4f} white={f['white']:.4f} "
f"mean=({f['r']:.1f},{f['g']:.1f},{f['b']:.1f})")
else:
print(name)
return 0
if __name__ == "__main__":
raise SystemExit(main())