"""Text OCR pipeline: screenshot -> cleaned-up text -> parsed board info. Only the text sub-pipeline is implemented. There's no image/icon recognition sub-pipeline yet (spotting markers, ship icons, etc.), that's a separate future pipeline, out of scope here. Preprocessing matters more than the regexes: the typewriter photo has an uneven vignette (in-game light falloff) that a single global threshold can't handle, it either loses faint corners or blobs-out dark ones. We flatten that by dividing by a heavily blurred copy of itself (crude local background normalization) before thresholding, which recovers text in the darkened areas reliably. Parsing is deliberately fuzzy: OCR on an in-game screenshot will misread the odd character (keywords slightly garbled, '0'/'O', '1'/'I'/'l', '5'/'S', '8'/'B' confused, stray specks turning ':' into ';' or '.'). We match keywords by similarity rather than exact spelling, and normalize digit-shaped letters before parsing numbers, so a couple of misrecognized characters don't drop an otherwise-good line. """ from __future__ import annotations import difflib import html import re from dataclasses import dataclass, field import numpy as np import pytesseract from PIL import Image, ImageFilter from . import solver from .models import Board, Clue, Coord, Location, TargetType from .shells import Shell # --- preprocessing ----------------------------------------------------------- _BLUR_RADIUS = 35 _THRESHOLD = 200 def preprocess(image: Image.Image) -> Image.Image: gray = image.convert("L") arr = np.asarray(gray, dtype=np.float32) background = np.asarray(gray.filter(ImageFilter.GaussianBlur(_BLUR_RADIUS)), dtype=np.float32) normalized = np.clip(arr / (background + 1e-3) * 255.0, 0, 255).astype(np.uint8) normalized_img = Image.fromarray(normalized) return normalized_img.point(lambda p: 255 if p > _THRESHOLD else 0) def ocr_text(image: Image.Image) -> str: return pytesseract.image_to_string(preprocess(image), config="--psm 6") # --- fuzzy keyword matching --------------------------------------------------- NEST_KEYWORD = "IRON NEST" SPOTTER_KEYWORD = "SPOTTER" TARGET_IS_AT_KEYWORD = "Target is at" _FUZZY_THRESHOLD = 0.65 def _fuzzy_locate(line: str, keyword: str, threshold: float = _FUZZY_THRESHOLD) -> tuple[int, int] | None: """Span of the best approximate match of `keyword` in `line`, or None.""" line_u = line.upper() kw = keyword.upper() n = len(kw) best_ratio = 0.0 best_span = None for size in range(max(n - 2, 1), n + 3): for start in range(0, max(len(line_u) - size, 0) + 1): end = start + size ratio = difflib.SequenceMatcher(None, line_u[start:end], kw).ratio() if ratio > best_ratio: best_ratio = ratio best_span = (start, end) return best_span if best_ratio >= threshold else None def _fuzzy_contains(line: str, keyword: str, threshold: float = _FUZZY_THRESHOLD) -> bool: """True if some run of characters in `line` approximately matches `keyword`.""" return _fuzzy_locate(line, keyword, threshold) is not None # --- coordinate / id extraction, tolerant of digit<->letter OCR mixups -------- _DIGIT_CLASS = r"[0-9OoIiLlSsBbZzGg]" _DIGIT_FIX = str.maketrans({ "O": "0", "o": "0", "I": "1", "i": "1", "L": "1", "l": "1", "S": "5", "s": "5", "B": "8", "b": "8", "Z": "2", "z": "2", "G": "6", "g": "6", }) # The full-precision coordinate shape ('H3 5:5', 'Q4 4:2'), one big-grid # letter, one big-grid number, then the sub-grid x:y pair. Every coord # regex in this module that captures a full coordinate (not just the # large-cell letter+number, see _LARGE_GRID_ONLY_RE) embeds this exact # fragment rather than restating it, so its four capture groups are # always (letter, big_y, x, y) in that order, matching what # _coord_from_groups() below expects. _COORD_FRAGMENT = rf"([A-T])\s*({_DIGIT_CLASS}{{1,2}})\s+({_DIGIT_CLASS})\s*[:;.,]\s*({_DIGIT_CLASS})" _COORD_RE = re.compile(_COORD_FRAGMENT) def _coord_from_groups(letter: str, y: str, x: str, yy: str) -> Coord | None: """Build a Coord from a _COORD_FRAGMENT match's four groups, digit- fixing each numeric one, None on an out-of-range result rather than raising (OCR garbage can still parse as *a* number, just not a valid grid position).""" try: return Coord(X=letter.upper(), Y=int(_fix_digits(y)), x=int(_fix_digits(x)), y=int(_fix_digits(yy))) except ValueError: return None # No literal '#' required, it's just as OCR-corruptible as anything else # (missing entirely, or misread as e.g. 'H'). We instead anchor to *where* # the fuzzy keyword match ended and take the first run of digit-shaped # characters after that, skipping over whatever separator survived. _ID_RE = re.compile(rf"({_DIGIT_CLASS}+)") # Not seen in a real screenshot yet, so no extraction for these, add a # keyword + extractor here (plus a field below and a case in parse_text) # once we know the format: # - reference points # - targets def _fix_digits(s: str) -> str: return s.translate(_DIGIT_FIX) def _fix_id_digits(raw: str) -> str: """Like _fix_digits, but for id-length runs specifically: also collapses a 2-character run where one char is a genuine digit and the other a look-alike letter that maps to the *same* digit, e.g. '1l' or 'S5' both fix to '11'/'55', but a real 2-digit id wouldn't plausibly render as one numeral plus one letter of the identical value; that shape is the signature of OCR ghosting a single thin glyph twice (seen repeatedly: 'AmmoCache#l1' -> #1, 'HostileTank#S5' -> #5). A genuine two-digit id (both chars already real digits, e.g. '11') is left alone.""" fixed = _fix_digits(raw) if len(raw) == 2 and len(set(fixed)) == 1: has_digit = any(c.isdigit() for c in raw) has_letter = any(c.isalpha() for c in raw) if has_digit and has_letter: return fixed[0] return fixed # A target can also be spotted with an absolute grid ref directly # ("Target#10 Spotted. Grid Q3 9:0") instead of/alongside bearing/distance # clues, same coordinate shape as _COORD_RE, just anchored after "Grid". _GRID_COORD_RE = re.compile(rf"Grid\s+{_COORD_FRAGMENT}", re.IGNORECASE) def _extract_grid_coord(text: str) -> Coord | None: m = _GRID_COORD_RE.search(text) if not m: return None return _coord_from_groups(*m.groups()) # A fire-support request gives its coord differently again: " # Shells requested on ", no "Grid" keyword. Same shape otherwise. _REQUESTED_ON_COORD_RE = re.compile(rf"requested\s+on\s+{_COORD_FRAGMENT}", re.IGNORECASE) # "SMK Shells requested on ..." names the shell by its short code, one of # the Shell enum's own member names. _SHELL_REQUEST_RE = re.compile(r"([A-Za-z]+)\s+Shells?\s+requested", re.IGNORECASE) # "Requested before - T10:31:41 -": an in-game clock deadline, kept as # the raw string, this app doesn't track a game clock to compare it # against. _REQUESTED_BEFORE_RE = re.compile(r"Requested\s+before\s*-\s*(T?\d{1,2}:\d{2}:\d{2})\s*-", re.IGNORECASE) def _extract_requested_on_coord(text: str) -> Coord | None: m = _REQUESTED_ON_COORD_RE.search(text) if not m: return None return _coord_from_groups(*m.groups()) def _extract_shell_request(text: str) -> Shell | None: m = _SHELL_REQUEST_RE.search(text) if not m: return None try: return Shell[m.group(1).upper()] except KeyError: return None def _extract_requested_time(text: str) -> str | None: m = _REQUESTED_BEFORE_RE.search(text) return m.group(1) if m else None # A second, unrelated fire-support-request grammar, seen from Infantry # under attack ("taking fire") rather than a pinned Marine Garrison: # Infantry#1 taking fire from id1! Requesting SMK Shell on our # position at J6 2:7 before 10:38:57! # Infantry#3 taking fire! Requesting HE Shell at bearing 239°, # distance 10.76km from our position, J6 2:5, by 10:38:18 or we # will be overrun! # Differs from the Marine Garrison shape in every particular: the shell # word order is reversed ("Requesting X Shell", not "X Shells requested"), # the deadline has no "Requested"/dashes, just a bare "before"/"by