FEnigma/src/fenigma/ocr.py
Dominik Roth 3955fa42c7 Drop TargetType.HOSTILE_*, track allies as their own Ally collection
Two related changes:

1. TargetType.HOSTILE_ARTILLERY/HOSTILE_TANK renamed to ARTILLERY/TANK,
   dropping the baked-in hostility assumption from the type name
   itself (a type describes the unit kind now, not an allegiance).
   Migration entries added for both, plus the already-existing
   COASTAL_BATTERY one, so old save files still load.

2. A friendly contact ('FriendlyTank#1:', detected by stripping a
   leading 'Friendly'/'Hostile' word off the type word before matching
   it, see ocr.py's _resolve_target_type()) is NOT a Target with a flag
   flipped, it's tracked as a new, entirely separate Ally
   (Board.allies), with its own id namespace: an ally Tank#1 and a
   hostile Target Tank#1 are two unrelated things that happen to share
   an id, not a collision (verified directly, see the rendered
   screenshot both coexisting). Ally intentionally has none of Target's
   firing-relevant fields (shell/powder_charges/assignment/alive),
   allies are never fired on. 'Hostile' and no prefix at all both mean
   a regular (non-ally) Target, not-ally is the default.

   Wired through: Board.add_ally/remove_ally, placed_entities_all()/
   ambiguous_entities_all() (new 'ally' category, cyan on the map,
   distinct from every other category's color), solver.resolve_board()
   (allies' own clues resolve too), find_by_name() (an ally can be a
   clue reference target), save/load round-trip, a new 'Allies' header
   popover mirroring Targets' (position/hide/geo-overlay/remove, no
   shell/charge/alive controls), and ParsedInfo.allies as a same-shaped
   but separate dict from ParsedInfo.targets, merged by a new
   _merge_allies() alongside _merge_targets() in _merge_all().

Verified: full test suite (added a dedicated OCR test for the Friendly/
Hostile/bare-prefix routing), a GTK smoke test round-tripping an ally
through save/load and the popover build, and a rendered screenshot
showing an ally Tank#1 and a hostile Target Tank#1 both on the map at
once with the same id, distinct colors, no collision.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 20:23:42 +02:00

1042 lines
47 KiB
Python

"""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: "<Shell>
# Shells requested on <coord>", 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 '<word><junk><digits>' 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
# '<Name>:' 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-<ref> 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-<ref> 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 '<Name>#<id> at <Coord>' 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: '<ref>: <value>', 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 '<ref>: <value>' 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 '<Name>:' -
# ('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 "<Type>#<id>" 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"<b>(.*?)</b>", 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 </b> 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
'<b>...</b>' ('<b>Enemy Signal Station</b>', '<b>Listening
Post#1</b>'), 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 '<b>' 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: "<b>" + _squash_span_content(m.group(1)) + "</b>", text)
_HTML_BREAK_RE = re.compile(r"(?i)<br\s*/?>|</(?:p|div|li|tr)>")
_HTML_TAG_RE = re.compile(r"<[^>]+>")
def strip_html(text: str) -> str:
"""Drop markup from rich-text clipboard pastes (e.g. '<b>145</b>°'),
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))