"""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 re from dataclasses import dataclass, field import numpy as np import pytesseract from PIL import Image, ImageFilter from .models import Clue, Coord, TargetType # --- 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" _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", }) _SEP = r"\s*[-–—]\s*" _COORD_RE = re.compile( rf"([A-T])\s*({_DIGIT_CLASS}{{1,2}})\s+({_DIGIT_CLASS})\s*[:;.,]\s*({_DIGIT_CLASS})" ) # 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+([A-T])\s*({_DIGIT_CLASS}{{1,2}})\s+({_DIGIT_CLASS})\s*[:;.,]\s*({_DIGIT_CLASS})", re.IGNORECASE, ) def _extract_grid_coord(text: str) -> Coord | None: m = _GRID_COORD_RE.search(text) if not m: return None letter, y, x, yy = m.groups() 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 def _extract_coord(line: str) -> Coord | None: m = _COORD_RE.search(line) if not m: return None letter, y, x, yy = m.groups() 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 # out-of-range numbers -> not actually a coordinate def _extract_leading_id(remainder: str) -> int | None: """First digit-shaped run in `remainder` (text after the keyword match).""" m = _ID_RE.search(remainder) if not m: return None try: return int(_fix_id_digits(m.group(1))) except ValueError: return None # --- "field intelligence" blocks: relative Bearing/Distance descriptions ------ # # Target#5 Spotted. 088, 12.10km from Spotter#1 # Reference Point Alpha: # Bearing 094 from Spotter#1 # Distance 13.26km from Spotter#2 # . # AmmoCache#3: # Bearing 217 & Distance 10.48km from AmmoCache#2 # # Each named entity is a block of one or more clue lines, terminated by a # blank line or a lone '.'. Degree signs, colons, and the 'km' unit are all # treated as optional/lossy — OCR drops them unpredictably. _RP_HEADER_RE = re.compile(r"Reference\s+Point\s+([A-Za-z][\w-]*)\s*:?", re.IGNORECASE) # '#' isn't required literally — same reasoning as the spotter-id fix: OCR # drops it or renders it as noise (seen: '€'). Up to 2 junk characters # between the type word and its digits is enough slack without risking a # false match elsewhere. # ^ the junk-class run is REQUIRED (1-2 chars, not 0-2): the digit class # deliberately overlaps the alphabet (g/s/i/l/o/... look like digits), so # with a 0-width separator allowed, "Bearing" backtracks into itself — # word="Bearin", "digit"=its own trailing 'g' — and falsely matches as a # header. Requiring real punctuation between word and digits (true of # every observed header: '#', a misread substitute, ...) rules that out, # and also stops a bare "Word 094" clue line (space only) from matching. _NAMED_HEADER_RE = re.compile(rf"^([A-Za-z]+)[^A-Za-z0-9\s]{{1,2}}({_DIGIT_CLASS}+)\s*:?\s*(.*)$") _BLOCK_END_RE = re.compile(r"^[.\s]{1,6}$") _LEADING_NOISE_RE = re.compile(r"^[^A-Za-z]{1,3}(?=[A-Za-z])") # Reference capture is (\S+), not (.+): references are always a single # token with no spaces, and being non-greedy this way is what lets # finditer() find more than one clue per line/block — "Bearing 118 from # Spotter#2 & Bearing 125 from Spotter#1" needs two separate matches, and # a greedy (.+) would let the first one swallow the rest of the string. # # _GAP sits right before "from": plain whitespace normally, but an OCR # line-wrap can drop a stray junk token right at the break ("Bearing 125°" # / "P; from Spotter#1") — and that junk can itself contain a letter (the # 'P' above), so this isn't just non-alnum noise like the header-bullet # case; tolerate any single short token, not just punctuation. _GAP = r"[\s]*(?:\S{1,3}\s*)?" _CLUE_COMBINED_RE = re.compile( rf"Bearing\s*(\d{{1,3}})\s*°?\s*&\s*Distance\s*([\d.]+)\s*k?m?{_GAP}from\s+(\S+)", re.IGNORECASE ) _CLUE_INLINE_RE = re.compile( rf"(\d{{1,3}})\s*°?\s*,\s*([\d.]+)\s*k?m{_GAP}from\s+(\S+)", re.IGNORECASE ) _CLUE_BEARING_RE = re.compile(rf"Bearing\s*(\d{{1,3}})\s*°?{_GAP}from\s+(\S+)", re.IGNORECASE) _CLUE_DISTANCE_RE = re.compile(rf"Distance\s*([\d.]+)\s*k?m?{_GAP}from\s+(\S+)", re.IGNORECASE) _TYPE_BY_SHORT = {t.short: t for t in TargetType} # The game's typewriter has used "AmmoCache" for what's now modeled as # SupplyCache — treat it as the same type rather than dropping the target. _TYPE_WORD_ALIASES = {"AmmoCache": "SupplyCache"} _REF_NAMED_RE = re.compile(rf"^([A-Za-z]+)[^A-Za-z0-9\s]{{1,2}}({_DIGIT_CLASS}+)") def _clean_reference(raw: str) -> str: """Leading name-shaped token, dropping trailing OCR noise (stray dots, double spaces, etc.) — reference names never contain spaces. Named references ('Spotter#1', 'AmmoCache#2') get their digit part fixed up and their separator normalized to '#'; plain word references ('Alpha') are left untouched — don't run digit-fixing over them or real letters like the 'l' in 'Alpha' get corrupted into '1'.""" token = re.match(r"\S+", raw.strip()) token = token.group(0) if token else raw.strip() named = _REF_NAMED_RE.match(token) if named: word, num = named.groups() word = _TYPE_WORD_ALIASES.get(word, word) return f"{word}#{_fix_id_digits(num)}" return re.sub(r"[^\w-]+$", "", token) # trim trailing punctuation off a plain name # Priority order matters: try the "both bearing and distance" shape before # the single-value shapes, so e.g. "Bearing 217 & Distance 10.48km from X" # becomes one clue, not a spurious extra bearing-only one from the same # text. `\s` already matches a literal newline, so this bridges an OCR # line-wrap ("...Bearing 125°" / "from Spotter#1" split across two lines) # without needing the caller to rejoin anything. _CLUE_PATTERNS = ( (_CLUE_COMBINED_RE, lambda m: (float(m.group(1)), float(m.group(2)), m.group(3))), (_CLUE_INLINE_RE, lambda m: (float(m.group(1)), float(m.group(2)), m.group(3))), (_CLUE_BEARING_RE, lambda m: (float(m.group(1)), None, m.group(2))), (_CLUE_DISTANCE_RE, lambda m: (None, float(m.group(1)), m.group(2))), ) def _parse_all_clues(text: str) -> list[Clue]: """Every Bearing/Distance clue found anywhere in `text` — a block can have several (one per clue line, or more than one on a single line joined with '&').""" clues: list[Clue] = [] consumed: list[tuple[int, int]] = [] def overlaps(span: tuple[int, int]) -> bool: return any(span[0] < e and span[1] > s for s, e in consumed) for pattern, extract in _CLUE_PATTERNS: for m in pattern.finditer(text): span = m.span() if overlaps(span): continue consumed.append(span) bearing, distance, ref = extract(m) clues.append(Clue(reference=_clean_reference(ref), bearing_deg=bearing, distance_km=distance)) return clues def parse_clues_from_text(text: str) -> list[Clue]: """Parse every Bearing/Distance clue found in free-form text — used for manually-typed descriptions in the coord dialog, sharing the exact same clue grammar as the OCR'd intel blocks.""" return _parse_all_clues(text) def _resolve_target_type(type_word: str) -> TargetType | None: """Exact match on the type word (after aliasing), falling back to fuzzy (OCR can garble the type word itself, e.g. 'AmmoCoche').""" type_word = _TYPE_WORD_ALIASES.get(type_word, type_word) if type_word in _TYPE_BY_SHORT: return _TYPE_BY_SHORT[type_word] best, best_ratio = None, 0.0 for short, target_type in _TYPE_BY_SHORT.items(): ratio = difflib.SequenceMatcher(None, type_word.upper(), short.upper()).ratio() if ratio > best_ratio: best, best_ratio = target_type, ratio return best if best_ratio >= _FUZZY_THRESHOLD else None def parse_intel_blocks(text: str) -> list[dict]: """Parse 'field intelligence' blocks into a list of dicts with keys kind ('rp' | 'named'), name, type_word, id, raw, clues, coord. Entries with neither a clue nor a grid coord are dropped (nothing to store). Clues are extracted once per block, from the whole joined block text, at flush time — not accumulated line-by-line while scanning. That's what lets a clue split across an OCR line-wrap ("...Bearing 125°" / "from Spotter#1" on separate lines) or two clues on one line ("Bearing X from A & Bearing Y from B") both resolve correctly. A target can also carry an absolute grid ref directly ("Grid Q3 9:0") instead of/alongside clues.""" entries: list[dict] = [] current: dict | None = None def flush(): nonlocal current if current is not None: joined = "\n".join(current["raw"]) current["clues"] = _parse_all_clues(joined) current["coord"] = _extract_grid_coord(joined) if current["clues"] or current["coord"] is not None: current["raw"] = joined entries.append(current) current = None for raw_line in text.splitlines(): line = raw_line.strip() if not line: continue # blank lines are just visual spacing here, not a section boundary if _BLOCK_END_RE.match(line): flush() continue # The '.' block-separator bullet often survives OCR as 1-3 stray # junk characters glued onto the *next* line ('i AmmoCache#1:', # '* AmmoCache#2:') instead of its own line, which would otherwise # defeat the column-0-anchored header regexes below. Try the line # as-is first, and only if that fails, retry with its first # whitespace-delimited token stripped (covers symbol junk *and* # a misread bullet that happened to OCR as a stray letter) — but # only when that leading token is bullet-length (<=3 chars), else # a genuine wrapped clue continuation like "from Spotter#2" gets # its "from" stripped and "Spotter#2" misread as a new header. first_token_m = re.match(r"\S+", line) strip_first_token = ( re.sub(r"^\S+\s+", "", line, count=1) if first_token_m and len(first_token_m.group(0)) <= 3 else line ) candidates = (line, _LEADING_NOISE_RE.sub("", line), strip_first_token) rp_m = next((m for c in candidates if (m := _RP_HEADER_RE.search(c))), None) if rp_m: flush() current = {"kind": "rp", "name": rp_m.group(1), "type_word": None, "id": None, "raw": [line], "clues": []} continue named_m = next((m for c in candidates if (m := _NAMED_HEADER_RE.match(c))), None) if named_m: flush() type_word, num, inline = named_m.groups() num = _fix_id_digits(num) current = {"kind": "named", "name": f"{type_word}#{num}", "type_word": type_word, "id": num, "raw": [line], "clues": []} continue if current is not None: current["raw"].append(line) flush() return entries # Destruction reports are standalone one-liners, not tied to a block: # "SupplyCache#2 Destroyed. Additional Requisition Granted." # "Direct Hit! HostileTank#3 Destroyed." # Just need "#" immediately followed by "Destroyed" — the # "Direct Hit!" prefix (or its absence) doesn't matter, search() finds # the name+Destroyed pair anywhere in the line either way. _DESTROYED_RE = re.compile( rf"([A-Za-z]+)[^A-Za-z0-9\s]{{1,2}}({_DIGIT_CLASS}+)\s*Destroyed", re.IGNORECASE ) def parse_destroyed(text: str) -> set[tuple[TargetType, str]]: destroyed = set() for m in _DESTROYED_RE.finditer(text): type_word, num = m.groups() target_type = _resolve_target_type(type_word) if target_type is None: continue destroyed.add((target_type, _fix_id_digits(num))) return destroyed @dataclass class ParsedInfo: nest_coord: Coord | None = None spotters: dict[int, Coord] = field(default_factory=dict) # name -> (raw description, clues, absolute coord if given directly) reference_points: dict[str, tuple[str, list[Clue], Coord | None]] = field(default_factory=dict) # (type, id) -> (raw description, clues, absolute coord if given directly) targets: dict[tuple[TargetType, str], tuple[str, list[Clue], Coord | None]] = field(default_factory=dict) # (type, id) of targets reported destroyed destroyed: set[tuple[TargetType, str]] = field(default_factory=set) def is_empty(self) -> bool: return not (self.nest_coord or self.spotters or self.reference_points or self.targets or self.destroyed) def parse_text(text: str) -> ParsedInfo: info = ParsedInfo() for raw_line in text.splitlines(): line = raw_line.strip() if not line: continue if info.nest_coord is None and _fuzzy_contains(line, NEST_KEYWORD): coord = _extract_coord(line) if coord is not None: info.nest_coord = coord continue spotter_span = _fuzzy_locate(line, SPOTTER_KEYWORD) if spotter_span is not None: spotter_id = _extract_leading_id(line[spotter_span[1]:]) coord = _extract_coord(line) if spotter_id is not None and coord is not None: info.spotters[spotter_id] = coord for entry in parse_intel_blocks(text): if entry["kind"] == "rp": info.reference_points[entry["name"]] = (entry["raw"], entry["clues"], entry["coord"]) continue target_type = _resolve_target_type(entry["type_word"]) if target_type is None: continue info.targets[(target_type, entry["id"])] = (entry["raw"], entry["clues"], entry["coord"]) info.destroyed = parse_destroyed(text) return info def run(image: Image.Image) -> ParsedInfo: return parse_text(ocr_text(image))