#!/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 [--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)