"""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 # "Reported active in grid D10": only the large-grid cell, no sub-grid # x:y at all, unlike every other coord shape in this file. Tried last # (after _extract_grid_coord, which requires the full x:y and so is # strictly more precise when both would otherwise match). No sub- # position is given, so it defaults to the cell's rough middle (5:5, # there's no exact center on a 0-9 grid) rather than leaving it unset. _LARGE_GRID_ONLY_RE = re.compile(rf"grid\s+([A-T])\s*({_DIGIT_CLASS}{{1,2}})\b", re.IGNORECASE) def _extract_large_grid_only_coord(text: str) -> Coord | None: m = _LARGE_GRID_ONLY_RE.search(text) if not m: return None letter, y = m.groups() try: return Coord(X=letter.upper(), Y=int(_fix_digits(y)), x=5, y=5) except ValueError: return None def _extract_coord(line: str) -> Coord | None: m = _COORD_RE.search(line) if not m: return None return _coord_from_groups(*m.groups()) 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. # # This '' shape ('AmmoCache#3', 'HostileTank#3') is # what a "Type#id" reference/header looks like anywhere in this module, # _NAMED_HEADER_RE, _REF_NAMED_RE, and _DESTROYED_RE below all embed this # same fragment rather than restating it. _TYPE_ID_FRAGMENT = rf"([A-Za-z]+)[^A-Za-z0-9\s]{{1,2}}({_DIGIT_CLASS}+)" _NAMED_HEADER_RE = re.compile(rf"^{_TYPE_ID_FRAGMENT}\s*:?\s*(.*)$") # Last-resort header: a name with no keyword and no id at all, just # ':' after squashing ('HMS Rockingham:' -> 'HMSRockingham:'). The # colon is REQUIRED here (unlike every other header regex's optional # one), nothing else anchors this match, so an optional colon would # false-positive on an ordinary clue-continuation line's leading word. _BARE_NAME_HEADER_RE = re.compile(r"^([A-Za-z][A-Za-z0-9]*)\s*:\s*(.*)$") # Ad-hoc enemy installations are named in plain English rather than given a # Type#N id ("Enemy Signal Station:", "Bearing 034 from Enemy Signal # Station"), which breaks two assumptions everywhere else in this module: # headers/references are always a single whitespace-free token, and an id # is always digit-shaped. squash_enemy_names() fixes both up front by # collapsing 'Enemy' + however many Title Case words follow it into one # token in our own 'Type#id' shape ('Enemy#SignalStation'), before # anything else tries to parse the text, so every from- clue pattern # (which just captures \S+) and _NAMED_HEADER_RE both work unmodified # except NAMED_HEADER_RE's id group is digit-only, so a second header # regex below handles the now-squashed, letter-only id. _ENEMY_NAME_RE = re.compile(r"\bEnemy(?:[ \t]+[A-Z][a-zA-Z]*){1,4}\b") _ENEMY_HEADER_RE = re.compile(r"^(Enemy)#([A-Za-z]+)\s*:?\s*(.*)$", re.IGNORECASE) def squash_enemy_names(text: str) -> str: """'Enemy Signal Station' -> 'Enemy#SignalStation', anywhere it appears, header or reference alike. The word-separator inside a name is deliberately [ \\t]+, not \\s+: it must not cross a newline, or a header right at the end of a line ('...Enemy Signal Station') would swallow the next line's 'Bearing ...' clue into the same 'name'.""" return _ENEMY_NAME_RE.sub(lambda m: "Enemy#" + "".join(m.group(0).split()[1:]), text) # Some named things have a multi-word type ("Coastal Battery#2", # "Listening Post#1") but, unlike the Enemy case above, already come with # a real digit id attached, nothing to invent. Only the embedded space # needs collapsing so _NAMED_HEADER_RE and every from- clue pattern # see the single token they expect ('CoastalBattery#2'). Requires 2+ # words specifically, a single-word id like 'AmmoCache#3' already works # and shouldn't be touched here. Each word must be Title Case, same # reasoning as _ENEMY_NAME_RE: without it, ordinary lowercase prose right # before some unrelated '#N' ("...South-East from Listening Post#1" -> # matching backward from "Post#1" through "from") gets swallowed into the # 'name' too, an all-lowercase connector word is never actually part of # one of these names. _MULTIWORD_ID_RE = re.compile(rf"\b([A-Z][a-zA-Z]*(?:[ \t]+[A-Z][a-zA-Z]*){{1,3}})\s*#\s*({_DIGIT_CLASS}+)") def squash_multiword_ids(text: str) -> str: """'Coastal Battery#2' -> 'CoastalBattery#2', run before squash_enemy_names() (order doesn't actually matter, the two patterns can't overlap: this one requires a literal '#'+digits, the Enemy one requires no digits at all).""" return _MULTIWORD_ID_RE.sub(lambda m: "".join(m.group(1).split()) + "#" + m.group(2), text) # A named anchor can also be given inline, all on one line, rather than as # its own block: "Listening Post#1 at K6 7:8 audio reports on:". RP-shaped # same as everything else that resolves to a name + coord, searched for # anywhere rather than tied to a section keyword, this shape is generic # enough (not specific to "Listening Post") to catch whatever else turns # up named this way. _NAMED_AT_COORD_RE = re.compile(rf"([A-Za-z]+)#({_DIGIT_CLASS}+)\s+at\s+{_COORD_FRAGMENT}", re.IGNORECASE) def parse_named_at_coord(text: str) -> list[dict]: """Parse every '# at ' anchor into an RP-shaped entry. Call after squash_multiword_ids(), so a multi-word name here is already a single 'Name#id' token by the time this regex sees it.""" entries = [] for m in _NAMED_AT_COORD_RE.finditer(text): word, num, letter, y, x, yy = m.groups() coord = _coord_from_groups(letter, y, x, yy) if coord is None: continue entries.append({ "kind": "rp", "name": f"{word}#{_fix_id_digits(num)}", "type_word": None, "id": None, "raw": m.group(0).strip(), "clues": [], "coord": coord, }) return entries # Forward-observer reports: several standalone one-liners, each an # independent FO position (a literal one-off coordinate right there in # the line, not a name referencing some other known entity) giving one # bearing/distance reading on a named target: # FO#5 Audio report on HMS Rockingham: 2.24km From I8 6:9 . . . # FO#4 Eyes on HMS Rockingham: 087° From G7 6:7 . . . # FO Eyes on HMS Rockingham: 099° From C9 1:2 . . . # ('FO' alone, no '#N', for exactly one report is fine, still its own # distinct position.) Distance tried before bearing, same reasoning as # _CLUE_PATTERNS elsewhere: an ambiguous run of digits could otherwise # let the bearing pattern's optional '°' swallow part of a distance # reading if bearing ran first. _FO_TARGET_NAME = r"([A-Za-z][A-Za-z0-9'\s]*?)" _FO_DISTANCE_RE = re.compile( rf"FO(?:#({_DIGIT_CLASS}+))?\s+(?:Audio\s+report\s+on|Eyes\s+on)\s+{_FO_TARGET_NAME}\s*:\s*" rf"([\d.]+)\s*k?m\s+From\s+{_COORD_FRAGMENT}", re.IGNORECASE, ) _FO_BEARING_RE = re.compile( rf"FO(?:#({_DIGIT_CLASS}+))?\s+(?:Audio\s+report\s+on|Eyes\s+on)\s+{_FO_TARGET_NAME}\s*:\s*" rf"({_DIGIT_CLASS}{{1,3}})\s*°?\s+From\s+{_COORD_FRAGMENT}", re.IGNORECASE, ) def parse_fo_reports(text: str) -> dict[str, tuple[str, Coord | None]]: """An FO position isn't tracked as a real board entity, nothing to re-reference later, no ongoing observation post, just a one-off fix, so this triangulates immediately: for each named target, build a throwaway scratch Board with just that target's FO positions as (never-persisted) reference points, run the exact same solve_location() geometry/priority the real board uses, keep only the resulting Coord (or None, if the readings under- or over-determine it in a way solve_location() can't resolve), then discard the scratch board and every ephemeral FO entity in it. Returns target_name -> (raw report text, resolved coord or None).""" 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) # target_key -> list of (fo_name, fo_coord, bearing_deg, distance_km, raw_line) groups: dict[str, list[tuple[str, Coord, float | None, float | None, str]]] = {} def collect(m, is_distance: bool) -> None: span = m.span() if overlaps(span): return consumed.append(span) fo_num, target_name, value, letter, y, x, yy = m.groups() fo_name = f"FO#{_fix_id_digits(fo_num)}" if fo_num else "FO" coord = _coord_from_groups(letter, y, x, yy) if coord is None: return target_key = "".join(re.sub(r"[^A-Za-z0-9]", "", w) for w in target_name.split()) bearing = None if is_distance else float(_fix_digits(value)) distance = float(_fix_digits(value)) if is_distance else None groups.setdefault(target_key, []).append((fo_name, coord, bearing, distance, m.group(0).strip())) for m in _FO_DISTANCE_RE.finditer(text): collect(m, is_distance=True) for m in _FO_BEARING_RE.finditer(text): collect(m, is_distance=False) results: dict[str, tuple[str, Coord | None]] = {} for target_key, readings in groups.items(): scratch = Board() clues = [] placed: set[str] = set() for fo_name, coord, bearing, distance, _raw in readings: if fo_name not in placed: placed.add(fo_name) scratch.add_reference_point(coord, name=fo_name) clues.append(Clue(reference=fo_name, bearing_deg=bearing, distance_km=distance)) raw_text = "\n".join(r[4] for r in readings) result = solver.solve_location(Location.from_desc(raw_text, clues), scratch) results[target_key] = (raw_text, result.coord) return results _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) # A listening post gives distance readings by ear, an approximate 8-point # compass direction instead of a precise degree bearing: "Distance 6.28km # South-East from Listening Post#1". Word order differs from every other # clue shape too (the direction sits between the distance and 'from', # there's no separate 'Bearing' keyword at all), so this needs its own # pattern rather than reusing _CLUE_COMBINED_RE with a looser bearing group. _COMPASS_BEARINGS = { "NORTH": 0.0, "NORTHEAST": 45.0, "EAST": 90.0, "SOUTHEAST": 135.0, "SOUTH": 180.0, "SOUTHWEST": 225.0, "WEST": 270.0, "NORTHWEST": 315.0, # 16-point compass, only ever seen written with a space rather than a # hyphen ('North Northwest', not 'North-Northwest'), unlike the # 8-point diagonals above. "NORTHNORTHEAST": 22.5, "EASTNORTHEAST": 67.5, "EASTSOUTHEAST": 112.5, "SOUTHSOUTHEAST": 157.5, "SOUTHSOUTHWEST": 202.5, "WESTSOUTHWEST": 247.5, "WESTNORTHWEST": 292.5, "NORTHNORTHWEST": 337.5, } # Longest/most specific alternatives first: alternation tries each in # order and stops at the first that matches, so 'North Northwest' must # reach the 16-point alternative before the bare 'North' one, or the # latter would win and strand ' Northwest' unmatched. _COMPASS_WORD_RE = ( r"(North[-\s]?North[-\s]?East|North[-\s]?East|East[-\s]?North[-\s]?East|" r"South[-\s]?South[-\s]?East|South[-\s]?East|East[-\s]?South[-\s]?East|" r"South[-\s]?South[-\s]?West|South[-\s]?West|West[-\s]?South[-\s]?West|" r"North[-\s]?North[-\s]?West|North[-\s]?West|West[-\s]?North[-\s]?West|" r"North|South|East|West)" ) # Half the width of one 16-point compass sector (360/16 = 22.5 deg each), # a compass WORD names a whole sector, not a single ray. _COMPASS_TOLERANCE_DEG = 11.25 def _compass_to_bearing(word: str) -> float: return _COMPASS_BEARINGS[re.sub(r"[-\s]", "", word).upper()] _CLUE_DISTANCE_COMPASS_RE = re.compile( rf"Distance\s*([\d.]+)\s*k?m?\s*{_COMPASS_WORD_RE}{_GAP}from\s+(\S+)", re.IGNORECASE ) _CLUE_DISTANCE_RE = re.compile(rf"Distance\s*([\d.]+)\s*k?m?{_GAP}from\s+(\S+)", re.IGNORECASE) # A different clue grammar entirely, reference FIRST then a colon then # the bare reading, no 'Bearing'/'Distance' keyword and no 'from' at all: # Spotter#2: 4.04km # Spotter#3: 298° # Spotter#1: West # Distance tried before bearing/compass, same reasoning as _CLUE_PATTERNS # generally: an ambiguous run of digits could otherwise let the bearing # pattern's optional '°' swallow part of a distance reading. _CLUE_REF_DISTANCE_RE = re.compile(rf"(\S+)\s*:\s*([\d.]+)\s*k?m", re.IGNORECASE) _CLUE_REF_BEARING_RE = re.compile(rf"(\S+)\s*:\s*({_DIGIT_CLASS}{{1,3}})\s*°", re.IGNORECASE) _CLUE_REF_COMPASS_RE = re.compile(rf"(\S+)\s*:\s*{_COMPASS_WORD_RE}\b", re.IGNORECASE) # 'Spotter#2: 4.04km' has the exact same 'Word#digits:' shape as a real # block header ('AmmoCache#3:'), genuinely indistinguishable from one by # shape alone. The deciding signal is what follows the colon: a real # header's trailing content is never JUST a bare clue reading with # nothing else, so _NAMED_HEADER_RE's own inline-content group gets # checked against this before deciding it's actually a new header. _BARE_CLUE_VALUE_RE = re.compile( rf"^(?:[\d.]+\s*k?m|{_DIGIT_CLASS}{{1,3}}\s*°|{_COMPASS_WORD_RE})\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, and "CoastalBattery" for what's just a HostileArtillery # under a different name, treat both as the same type rather than # dropping the target or inventing a redundant enum member for it. _TYPE_WORD_ALIASES = {"AmmoCache": "SupplyCache", "CoastalBattery": "HostileArtillery"} _REF_NAMED_RE = re.compile(rf"^{_TYPE_ID_FRAGMENT}") 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'. 'Enemy#SignalStation' (squash_enemy_names() already normalized the separator, and its id is letters, not digits) is passed through as-is rather than falling into the digit-fixing path below, which would otherwise stop at the first letter outside _DIGIT_CLASS and truncate it (e.g. down to 'Enemy#Sig').""" token = re.match(r"\S+", raw.strip()) token = token.group(0) if token else raw.strip() enemy = _ENEMY_HEADER_RE.match(token) if enemy: return f"{enemy.group(1)}#{enemy.group(2)}" 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. Extract functions return # (bearing_deg, distance_km, reference, bearing_tolerance_deg), the last # one None except for the compass-word shapes. _CLUE_PATTERNS = ( (_CLUE_COMBINED_RE, lambda m: (float(m.group(1)), float(m.group(2)), m.group(3), None)), (_CLUE_INLINE_RE, lambda m: (float(m.group(1)), float(m.group(2)), m.group(3), None)), (_CLUE_BEARING_RE, lambda m: (float(m.group(1)), None, m.group(2), None)), (_CLUE_DISTANCE_COMPASS_RE, lambda m: ( _compass_to_bearing(m.group(2)), float(m.group(1)), m.group(3), None )), (_CLUE_DISTANCE_RE, lambda m: (None, float(m.group(1)), m.group(2), None)), # reference-first shape: ': ', no keyword, no 'from' (_CLUE_REF_DISTANCE_RE, lambda m: (None, float(m.group(2)), m.group(1), None)), (_CLUE_REF_BEARING_RE, lambda m: (float(_fix_digits(m.group(2))), None, m.group(1), None)), (_CLUE_REF_COMPASS_RE, lambda m: ( _compass_to_bearing(m.group(2)), None, m.group(1), _COMPASS_TOLERANCE_DEG )), ) 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, tolerance = extract(m) clues.append(Clue(reference=_clean_reference(ref), bearing_deg=bearing, distance_km=distance, bearing_tolerance_deg=tolerance)) 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.""" text = strip_html(squash_bold_spans(text)) return _parse_all_clues(squash_enemy_names(squash_multiword_ids(text))) _ALLY_PREFIX_RE = re.compile(r"^(Friendly|Hostile)", re.IGNORECASE) def _resolve_target_type(type_word: str) -> tuple[TargetType | None, bool]: """(TargetType, is_ally). A leading 'Friendly'/'Hostile' word is stripped off the type word first ('FriendlyTank' -> ally, TANK; 'HostileTank' or bare 'Tank' -> not ally, TANK, an explicit 'Hostile' and no prefix at all mean the same thing, not-ally is the default). What's left is matched exactly against the type word (after aliasing), falling back to fuzzy (OCR can garble the type word itself, e.g. 'AmmoCoche').""" is_ally = False prefix_m = _ALLY_PREFIX_RE.match(type_word) if prefix_m: is_ally = prefix_m.group(1).lower() == "friendly" type_word = type_word[prefix_m.end():] type_word = _TYPE_WORD_ALIASES.get(type_word, type_word) if type_word in _TYPE_BY_SHORT: return _TYPE_BY_SHORT[type_word], is_ally 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), is_ally 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, shell, requested_time. Entries with none of clue/coord/shell/requested_time 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, or a fire-support request's own coord shape ("SMK Shells requested on J6 8:3"), plus that request's shell and deadline if given.""" 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) or _extract_requested_on_coord(joined) or _extract_large_grid_only_coord(joined) ) current["shell"] = _extract_shell_request(joined) current["requested_time"] = _extract_requested_time(joined) if (current["clues"] or current["coord"] is not None or current["shell"] is not None or current["requested_time"] 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 # Tried before _NAMED_HEADER_RE: its digit class overlaps plain # letters (S/B/Z/G/O/I/L), so on a squashed 'Enemy#SignalStation' # header it would otherwise partial-match ('Sig' -> digit-fixed # into the bogus id '516') before this more specific check ever # gets a look. enemy_m = next((m for c in candidates if (m := _ENEMY_HEADER_RE.match(c))), None) if enemy_m: flush() type_word, letter_id, inline = enemy_m.groups() current = {"kind": "named", "name": f"{type_word}#{letter_id}", "type_word": type_word, "id": letter_id, "raw": [line], "clues": []} continue named_m = next((m for c in candidates if (m := _NAMED_HEADER_RE.match(c))), None) if named_m: type_word, num, inline = named_m.groups() if _BARE_CLUE_VALUE_RE.match(inline.strip()): # Not actually a header, a ': ' clue line # for whatever block is already open (see # _BARE_CLUE_VALUE_RE), append rather than start a new # block over it. if current is not None: current["raw"].append(line) continue flush() num = _fix_id_digits(num) current = {"kind": "named", "name": f"{type_word}#{num}", "type_word": type_word, "id": num, "raw": [line], "clues": []} continue # Last resort: a bare name with none of the above (no 'Reference # Point'/'Enemy' keyword, no digit id), just ':' - # ('HMS Rockingham:'). Requiring the colon (not optional, unlike # the other header regexes) matters here specifically: without # any keyword or id anchoring the match, an optional colon would # false-positive on an ordinary clue-continuation line's first # word ('Distance 6.14km...' -> 'Distance' read as a header). # Treated as a Target (TargetType.UNKNOWN, whose short form IS # 'Target'), not a Reference Point, a named thing giving its own # clues is being spotted, not a fixed landmark spotters aim off # of, same reasoning as the "Target is at-" calibration line. bare_m = next((m for c in candidates if (m := _BARE_NAME_HEADER_RE.match(c))), None) if bare_m: flush() name = bare_m.group(1) current = {"kind": "named", "name": f"Target#{name}", "type_word": "Target", "id": name, "raw": [line], "clues": []} continue if current is not None: current["raw"].append(line) flush() return entries # Train-arrival intel is a different shape entirely, a station's absolute # grid ref, the rail's bearing from it, and a handful of waypoints given # only as a distance along that same bearing, plus a T=HH:MM:SS timestamp # on every line that this app makes no attempt to use, there's no game # clock to compare it against, so it's simply ignored: # ARRIVAL STATION: # Valle de Mula MainStation: J6 0:4 # Estimated arrival: T=10:16:50 # TRACK ALIGNMENT: # Rail line runs straight. Bearing 090° from MainStation. # FINAL APPROACH: # Waypoint A - 6.00km from station: T=10:06:50 # Waypoint B - 4.00km from station: T=10:10:10 # The station and each waypoint all come out RP-shaped (kind='rp'), same # as parse_intel_blocks()'s entries, so app.py can merge them the exact # same way, no separate code path needed on that side. _ARRIVAL_STATION_KEYWORD = "ARRIVAL STATION" _TRACK_ALIGNMENT_KEYWORD = "TRACK ALIGNMENT" _FINAL_APPROACH_KEYWORD = "FINAL APPROACH" _STATION_LINE_RE = re.compile(rf"(\S+):\s*{_COORD_FRAGMENT}") _WAYPOINT_RE = re.compile(r"Waypoint\s+(\S+)\s*-\s*([\d.]+)\s*k?m\s+from\s+(\S+)", re.IGNORECASE) def _section(text: str, start_keyword: str, end_keywords: tuple[str, ...]) -> str | None: """Text between a fuzzy match of `start_keyword` and whichever comes first: a block-end '.' line, one of `end_keywords`, or the end of the text. None if `start_keyword` isn't found at all. Scopes each train-intel sub-parser to just its own section, so e.g. a coincidental ':' + coord shape elsewhere in the text can't be mistaken for the arrival station line.""" lines = text.splitlines() start = next((i + 1 for i, line in enumerate(lines) if _fuzzy_contains(line.strip(), start_keyword)), None) if start is None: return None collected = [] for line in lines[start:]: stripped = line.strip() if _BLOCK_END_RE.match(stripped) or any(_fuzzy_contains(stripped, k) for k in end_keywords): break collected.append(line) return "\n".join(collected) def parse_train_intel(text: str) -> list[dict]: """Parse a train-arrival intel block into RP-shaped entries: the named station itself (absolute grid ref) plus one entry per waypoint, each resolved via bearing (the rail's alignment) + distance (that waypoint's distance along it) from the station, solve_location()'s simplest case, one clue with both. Returns [] if this doesn't look like a train intel block at all (no ARRIVAL STATION section).""" station_block = _section(text, _ARRIVAL_STATION_KEYWORD, (_TRACK_ALIGNMENT_KEYWORD, _FINAL_APPROACH_KEYWORD)) if station_block is None: return [] station_m = _STATION_LINE_RE.search(station_block) if station_m is None: return [] station_name, letter, y, x, yy = station_m.groups() station_coord = _coord_from_groups(letter, y, x, yy) if station_coord is None: return [] entries = [{ "kind": "rp", "name": station_name, "type_word": None, "id": None, "raw": station_block.strip(), "clues": [], "coord": station_coord, }] track_block = _section(text, _TRACK_ALIGNMENT_KEYWORD, (_FINAL_APPROACH_KEYWORD,)) track_clues = _parse_all_clues(track_block) if track_block else [] bearing = next((c.bearing_deg for c in track_clues if c.bearing_deg is not None), None) if bearing is None: return entries # the station alone is still useful even without the rail's bearing approach_block = _section(text, _FINAL_APPROACH_KEYWORD, ()) if approach_block is None: return entries for m in _WAYPOINT_RE.finditer(approach_block): letter_id, distance, ref_word = m.groups() ref = station_name if ref_word.strip(".:").lower() == "station" else _clean_reference(ref_word) entries.append({ "kind": "rp", "name": f"Waypoint {letter_id}", "type_word": None, "id": None, "raw": m.group(0).strip(), "clues": [Clue(reference=ref, bearing_deg=bearing, distance_km=float(_fix_digits(distance)))], "coord": None, }) 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"{_TYPE_ID_FRAGMENT}\s*Destroyed", re.IGNORECASE) # Same digit-vs-letter-id split as the header regexes: an ad-hoc "Enemy # X Y" installation's id (after squash_enemy_names()) is letters, not # digits, so it needs its own pattern, _DESTROYED_RE's digit class won't # match it at all ("Enemy#SignalStation Destroyed" was silently dropped). _ENEMY_DESTROYED_RE = re.compile(r"(Enemy)#([A-Za-z]+)\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, _is_ally = _resolve_target_type(type_word) if target_type is None: continue destroyed.add((target_type, _fix_id_digits(num))) for m in _ENEMY_DESTROYED_RE.finditer(text): type_word, letter_id = m.groups() target_type, _is_ally = _resolve_target_type(type_word) if target_type is None: continue destroyed.add((target_type, letter_id)) return destroyed _BOLD_SPAN_RE = re.compile(r"(.*?)", re.IGNORECASE | re.DOTALL) # Same two shapes squash_enemy_names()/squash_multiword_ids() look for, # but anchored to fullmatch just one bold span's own content rather than # searched for across the whole document. That's strictly safer: those # two functions have to *guess* where a multi-word name ends using # capitalization (and got that wrong once already, swallowing 'from' into # a name it bordered), where here the closing tag itself is the # actual boundary, nothing to guess. _ENEMY_SPAN_RE = re.compile(r"Enemy(?:\s+[A-Za-z]+){1,4}$", re.IGNORECASE) _MULTIWORD_ID_SPAN_RE = re.compile(r"([A-Za-z]+(?:\s+[A-Za-z]+){1,3})\s*#\s*(\d+)$") def _squash_span_content(content: str) -> str: if _ENEMY_SPAN_RE.match(content): words = content.split() return "Enemy#" + "".join(words[1:]) multiword_id = _MULTIWORD_ID_SPAN_RE.match(content) if multiword_id: return "".join(multiword_id.group(1).split()) + "#" + multiword_id.group(2) words = content.split() if len(words) > 1 and not any(re.search(r"\d", w) for w in words): # Neither shape above, but still a multi-word span with no digits # in it, a plain multi-word name ('The Mole', "Dockmaster's # House"): still one logical name, collapse it into one # alphanumeric token the same way every reference elsewhere is # matched (_RP_HEADER_RE, the (\S+) clue-reference capture), # stripping punctuation too (apostrophes etc.), those aren't in # _RP_HEADER_RE's word-character class and would otherwise # truncate the match. The no-digits guard matters: a multi-word # bold span can just as easily be a coordinate ('C9 7:9'), and # squashing THAT the same way corrupts it instead of a name. return "".join(re.sub(r"[^A-Za-z0-9]", "", w) for w in words) return content def squash_bold_spans(text: str) -> str: """Rich-text pastes wrap each atomic value/name in its own '...' ('Enemy Signal Station', 'Listening Post#1'), collapse a multi-word one down to our single-token 'Type#id' shape using that tag boundary as ground truth, before the tags themselves get stripped. Must run before strip_html(), it needs the tags still there to know a span's extent. A no-op on plain OCR text, which never has '' in it to begin with, squash_enemy_names()/ squash_multiword_ids() (run afterward regardless) are what handle that case, guessing from capitalization since there's no markup left to lean on.""" return _BOLD_SPAN_RE.sub(lambda m: "" + _squash_span_content(m.group(1)) + "", text) _HTML_BREAK_RE = re.compile(r"(?i)|") _HTML_TAG_RE = re.compile(r"<[^>]+>") def strip_html(text: str) -> str: """Drop markup from rich-text clipboard pastes (e.g. '145°'), leaving plain text the rest of the pipeline can parse. Block-ish closing tags become newlines first so pastes that rely on markup rather than real line breaks don't get run together; harmless no-op on plain OCR text with no '<' in it.""" text = _HTML_BREAK_RE.sub("\n", text) text = _HTML_TAG_RE.sub("", text) return html.unescape(text) @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, requested shell if a fire-support request named one, # requested-before deadline string if given) targets: dict[ tuple[TargetType, str], tuple[str, list[Clue], Coord | None, Shell | None, str | None] ] = field(default_factory=dict) # Same shape as targets, for a 'Friendly'-prefixed type word (see # _resolve_target_type()), a separate collection entirely, not a # flag on a target, an ally's id doesn't share a namespace with a # same-typed hostile target and it's never fired on. allies: 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.allies or self.destroyed) def parse_text(text: str) -> ParsedInfo: text = strip_html(squash_bold_spans(text)) text = squash_enemy_names(squash_multiword_ids(text)) 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 # The game's opening calibration order gives the target as plain # prose ("Target is at- Q4 4:2") rather than the usual "Target#N:" # block shape, there's only ever one of these, so it's stored under # a fixed id ("1") rather than one parsed from the text. if (TargetType.UNKNOWN, "1") not in info.targets and _fuzzy_contains(line, TARGET_IS_AT_KEYWORD): coord = _extract_coord(line) if coord is not None: info.targets[(TargetType.UNKNOWN, "1")] = (line, [], coord, None, None) continue 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, is_ally = _resolve_target_type(entry["type_word"]) if target_type is None: continue if is_ally: info.allies[(target_type, entry["id"])] = (entry["raw"], entry["clues"], entry["coord"]) else: info.targets[(target_type, entry["id"])] = ( entry["raw"], entry["clues"], entry["coord"], entry["shell"], entry["requested_time"] ) for entry in parse_train_intel(text): info.reference_points[entry["name"]] = (entry["raw"], entry["clues"], entry["coord"]) for entry in parse_named_at_coord(text): info.reference_points[entry["name"]] = (entry["raw"], entry["clues"], entry["coord"]) for target_key, (raw, coord) in parse_fo_reports(text).items(): key = (TargetType.UNKNOWN, target_key) if key in info.targets: old_raw, old_clues, old_coord, shell, requested_time = info.targets[key] info.targets[key] = ( f"{old_raw}\n{raw}", old_clues, old_coord if old_coord is not None else coord, shell, requested_time, ) else: info.targets[key] = (raw, [], coord, None, None) info.destroyed = parse_destroyed(text) return info def run(image: Image.Image) -> ParsedInfo: return parse_text(ocr_text(image))