Both hand attempts at this address managed exactly one filter and then lost the counter, because every reading of the HUD costs a human round trip. ob_read.py does it by template correlation over three fixed digit cells, and ob_hunt.py uses that to run the whole method unattended: confirm the HUD on both sides of the 0.9 s scan, then filter on each following transition, labelling the first as the one it selected on and the rest as verification. Two things measured rather than assumed: * Normalisation is the point, not a nicety. The plate is translucent, so an explosion turns it orange - on the 008 reference frame a cyan-stroke mask finds 6 of ~70 stroke columns and would silently read nothing. Per-cell mean/std normalisation reads it correctly at 0.843. * The accept rule is two-sided because the margin is narrow: over 12 cells from four frames of known value the correct digit scores 0.843..1.000 and the best WRONG digit reaches 0.789 (0 and 8 are similar outlines). So a floor of 0.80 AND a 0.05 margin over the runner-up, against a smallest observed correct margin of 0.093. A bare threshold fitted between those two numbers would be fitted to twelve samples. Templates exist for 0 1 2 4 8 - the digits actually seen. Anything else reads as "?" and callers must treat a "?" as unknown, never as a value. Rejects both negatives tested: GAME OVER scores 0.05-0.18, the main menu 0.07-0.14. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
86 lines
3.6 KiB
Python
Executable File
86 lines
3.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Read the HUD's `REMAINING OB` counter off a screenshot.
|
|
|
|
Finding the counter in RAM needs the HUD value at the moment of every scan and
|
|
every filter, and the corpus's rule needs it again at a *later* transition. Doing
|
|
that by eye costs a round trip per reading, which is why the last two attempts
|
|
each managed one filter and then missed the next step of the counter.
|
|
|
|
Three digits, fixed cells, matched against templates by **normalised** cross-
|
|
correlation. The normalisation is the point rather than a nicety: the plate is
|
|
translucent, so an explosion turns the whole thing red and a nearby tracer washes
|
|
it out. A hue mask keyed on the digits' cyan silently fails on exactly those
|
|
frames — measured: the `008` reference frame has an orange flash across it and a
|
|
cyan mask finds 6 of the ~70 stroke columns. Subtracting each cell's own mean and
|
|
dividing by its own standard deviation makes the match indifferent to both.
|
|
|
|
The cells and the template strip were measured from the cyan-stroke column
|
|
profile of clean frames: strokes at x 1109-1132, 1139-1162, 1169-1191 and rows
|
|
236-269, so the cells are those boxes with a small margin.
|
|
|
|
**Templates exist for 0 1 2 4 8 only** — those are the digits the counter has
|
|
actually shown in captured frames. Anything else reads as `?`, and a reading with
|
|
a `?` in it is not a number: callers must treat it as "unknown", never as a
|
|
value. Add a digit by pasting a new 29x39 cell into `ob_digits.png` and extending
|
|
DIGITS; the file is a plain grey strip in DIGITS order.
|
|
|
|
Usage: ob_read.py <png> [--json] -> "004" | "0?2" | "none"
|
|
"""
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
import numpy as np
|
|
from PIL import Image
|
|
|
|
CELLS = [(1106, 233, 1135, 272), (1136, 233, 1165, 272), (1166, 233, 1195, 272)]
|
|
DIGITS = "01248"
|
|
STRIP = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ob_digits.png")
|
|
# MEASURED over 12 cells from four frames whose value is known by eye, rather
|
|
# than guessed: the correct digit scores 0.843 .. 1.000 and the best WRONG digit
|
|
# scores up to 0.789 — these are outline glyphs on a translucent plate, so 0 and
|
|
# 8 look a lot alike to a correlator. A bare threshold between 0.789 and 0.843
|
|
# would be fitted to twelve samples, so accept on TWO conditions instead: a floor
|
|
# well below the worst correct match, AND a margin over the runner-up. The
|
|
# smallest correct-to-runner-up margin observed is 0.093.
|
|
THRESHOLD = 0.80
|
|
MARGIN = 0.05
|
|
|
|
|
|
def _norm(a):
|
|
a = a.astype(np.float64)
|
|
s = a.std()
|
|
return (a - a.mean()) / s if s > 1e-6 else a * 0.0
|
|
|
|
|
|
def _templates():
|
|
strip = np.asarray(Image.open(STRIP).convert("L"))
|
|
w = strip.shape[1] // len(DIGITS)
|
|
return {d: _norm(strip[:, i * w:(i + 1) * w]) for i, d in enumerate(DIGITS)}
|
|
|
|
|
|
def read(path):
|
|
"""Return (text, per-digit best scores)."""
|
|
im = Image.open(path).convert("L")
|
|
tmpl = _templates()
|
|
out, scores = "", []
|
|
for box in CELLS:
|
|
c = _norm(np.asarray(im.crop(box)))
|
|
ranked = sorted(((float((c * t).mean()), d) for d, t in tmpl.items()
|
|
if t.shape == c.shape), reverse=True)
|
|
best, bd = ranked[0]
|
|
runner = ranked[1][0] if len(ranked) > 1 else -2.0
|
|
scores.append((round(best, 3), round(best - runner, 3)))
|
|
out += bd if best >= THRESHOLD and best - runner >= MARGIN else "?"
|
|
return out, scores
|
|
|
|
|
|
if __name__ == "__main__":
|
|
txt, sc = read(sys.argv[1])
|
|
if "--json" in sys.argv:
|
|
print(json.dumps({"text": txt, "scores": sc,
|
|
"value": int(txt) if txt.isdigit() else None}))
|
|
else:
|
|
print(f"{txt} scores={sc}")
|
|
sys.exit(0 if txt.isdigit() else 1)
|