OCR: multi-word RP names, bare-name targets, FO-report triangulation

Three related fixes/additions, found together while working through a
batch of new intel formats:

1. squash_span_content() only handled the 'Enemy X Y' and 'Type#N'
   shapes, so a plain multi-word bold name ('The Mole', "Dockmaster's
   House") passed through untouched and got silently truncated at the
   first space by every downstream single-token assumption
   (_RP_HEADER_RE, the (\S+) clue-reference capture). Added a generic
   fallback: collapse any multi-word bold span into one alphanumeric
   token, UNLESS any word contains a digit, that's very likely a
   coordinate span ('C9 7:9') instead of a name, and squashing THAT the
   same way corrupted it into garbage ('C979') rather than a name, a
   real regression caught immediately by testing against nest/spotter
   parsing before committing.

2. A bare '<Name>:' header (no 'Reference Point'/'Enemy' keyword, no
   digit id, e.g. 'HMS Rockingham:') was previously dropped entirely,
   nothing recognized it at all. Added _BARE_NAME_HEADER_RE as the
   last-resort header check (colon required, not optional like every
   other header regex, nothing else anchors this match). Resolves to a
   Target (TargetType.UNKNOWN), not a Reference Point, a named thing
   giving its own clues is being spotted, not a fixed landmark.

3. Forward-observer reports ('FO#5 Audio report on HMS Rockingham:
   2.24km From I8 6:9'): each FO's position is a literal one-off
   coordinate, not a name referencing some known entity, and isn't
   meant to be tracked as a real board entity. parse_fo_reports()
   triangulates immediately using a throwaway scratch Board (reusing
   solve_location()'s exact geometry/priority) and keeps only the
   resulting coordinate, discarding every ephemeral FO position
   afterward, nothing leaks into the real board.

Also added a 'convert to Target/Reference Point' action (RP and
Target rows both), since bare-name-header classification is a guess
that can land in the wrong bucket, this fixes it without losing the
position/clues already worked out.

Verified against the exact reported examples plus the full existing
regression sweep (standard blocks, Enemy names, Listening
Post/Coastal Battery, Marine Garrison, nest/spotter parsing) and
through the real GTK merge flow.
This commit is contained in:
Dominik Moritz Roth 2026-08-09 16:51:30 +02:00
parent 202219abf2
commit c273f57435
2 changed files with 177 additions and 3 deletions

View File

@ -57,9 +57,10 @@ def _row(
on_toggle_show_geo=None, on_toggle_show_geo=None,
alive=None, alive=None,
on_toggle_alive=None, on_toggle_alive=None,
on_convert=None,
) -> Gtk.Widget: ) -> Gtk.Widget:
"""One list entry: a label plus input/screenshot/geo/hide[/alive][/remove] """One list entry: a label plus input/screenshot/geo/hide[/alive]
action buttons.""" [/convert][/remove] action buttons."""
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
box.set_margin_top(4) box.set_margin_top(4)
box.set_margin_bottom(4) box.set_margin_bottom(4)
@ -112,6 +113,15 @@ def _row(
hide_btn.connect("clicked", lambda _b: on_toggle_hidden()) hide_btn.connect("clicked", lambda _b: on_toggle_hidden())
box.append(hide_btn) box.append(hide_btn)
if on_convert is not None:
convert_btn = Gtk.Button(
icon_name="object-flip-horizontal-symbolic",
tooltip_text="Wrong bucket? Convert to a Target/Reference Point",
)
convert_btn.add_css_class("flat")
convert_btn.connect("clicked", lambda _b: on_convert())
box.append(convert_btn)
if on_remove is not None: if on_remove is not None:
rm_btn = Gtk.Button(icon_name="user-trash-symbolic", tooltip_text="Remove") rm_btn = Gtk.Button(icon_name="user-trash-symbolic", tooltip_text="Remove")
rm_btn.add_css_class("flat") rm_btn.add_css_class("flat")
@ -795,6 +805,7 @@ class MainWindow(Adw.ApplicationWindow):
on_toggle_hidden=lambda rp=rp: self._toggle_hidden(rp, rebuild), on_toggle_hidden=lambda rp=rp: self._toggle_hidden(rp, rebuild),
show_geo=rp.show_geo_desc, show_geo=rp.show_geo_desc,
on_toggle_show_geo=lambda rp=rp: self._toggle_show_geo(rp, rebuild), on_toggle_show_geo=lambda rp=rp: self._toggle_show_geo(rp, rebuild),
on_convert=lambda rp=rp: self._convert_rp_to_target(rp, rebuild),
)) ))
box.append(Gtk.Separator()) box.append(Gtk.Separator())
@ -817,6 +828,21 @@ class MainWindow(Adw.ApplicationWindow):
self._refresh() self._refresh()
rebuild() rebuild()
def _convert_rp_to_target(self, rp, rebuild) -> None:
"""OCR guesses which bucket a named thing belongs in (a fixed
landmark vs. an actual contact), sometimes it guesses wrong,
this fixes it without losing the position/clues already worked
out, rather than deleting and re-typing it from scratch."""
if self.canvas.selected is rp:
self._set_selection(None)
self.board.remove_reference_point(rp)
new_target = self.board.add_target(TargetType.UNKNOWN, rp.location, id_=rp.rp_name)
new_target.hidden = rp.hidden
new_target.show_geo_desc = rp.show_geo_desc
self._refresh()
rebuild()
self.toast(f"{rp.name} converted to {new_target.name}.")
# -- Targets ----------------------------------------------------------------- # -- Targets -----------------------------------------------------------------
def _build_targets_popover(self, rebuild) -> Gtk.Widget: def _build_targets_popover(self, rebuild) -> Gtk.Widget:
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
@ -849,6 +875,7 @@ class MainWindow(Adw.ApplicationWindow):
on_toggle_show_geo=lambda t=t: self._toggle_show_geo(t, rebuild), on_toggle_show_geo=lambda t=t: self._toggle_show_geo(t, rebuild),
alive=t.alive, alive=t.alive,
on_toggle_alive=lambda t=t: self._toggle_alive(t, rebuild), on_toggle_alive=lambda t=t: self._toggle_alive(t, rebuild),
on_convert=lambda t=t: self._convert_target_to_rp(t, rebuild),
)) ))
box.append(Gtk.Separator()) box.append(Gtk.Separator())
@ -868,6 +895,20 @@ class MainWindow(Adw.ApplicationWindow):
self.board.add_target(type_ or TargetType.UNKNOWN, location, id_) self.board.add_target(type_ or TargetType.UNKNOWN, location, id_)
self._refresh() self._refresh()
def _convert_target_to_rp(self, target, rebuild) -> None:
"""The other direction of _convert_rp_to_target: an actual
contact that was actually a fixed landmark. Reuses the target's
own name as the new RP's name, so it stays recognizable."""
if self.canvas.selected is target:
self._set_selection(None)
self.board.remove_target(target)
new_rp = self.board.add_reference_point(target.location, name=target.name)
new_rp.hidden = target.hidden
new_rp.show_geo_desc = target.show_geo_desc
self._refresh()
rebuild()
self.toast(f"{target.name} converted to {new_rp.name}.")
# -- Scout Flights ------------------------------------------------------------ # -- Scout Flights ------------------------------------------------------------
def _build_scout_flights_popover(self, rebuild) -> Gtk.Widget: def _build_scout_flights_popover(self, rebuild) -> Gtk.Widget:
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)

View File

@ -30,7 +30,8 @@ import numpy as np
import pytesseract import pytesseract
from PIL import Image, ImageFilter from PIL import Image, ImageFilter
from .models import Clue, Coord, TargetType from . import solver
from .models import Board, Clue, Coord, Location, TargetType
from .shells import Shell from .shells import Shell
# --- preprocessing ----------------------------------------------------------- # --- preprocessing -----------------------------------------------------------
@ -249,6 +250,13 @@ _RP_HEADER_RE = re.compile(r"Reference\s+Point\s+([A-Za-z][\w-]*)\s*:?", re.IGNO
# and also stops a bare "Word 094" clue line (space only) from matching. # 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*(.*)$") _NAMED_HEADER_RE = re.compile(rf"^([A-Za-z]+)[^A-Za-z0-9\s]{{1,2}}({_DIGIT_CLASS}+)\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 # Ad-hoc enemy installations are named in plain English rather than given a
# Type#N id ("Enemy Signal Station:", "Bearing 034 from Enemy Signal # Type#N id ("Enemy Signal Station:", "Bearing 034 from Enemy Signal
# Station"), which breaks two assumptions everywhere else in this module: # Station"), which breaks two assumptions everywhere else in this module:
@ -327,6 +335,88 @@ def parse_named_at_coord(text: str) -> list[dict]:
return entries 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_COORD = rf"([A-T])\s*({_DIGIT_CLASS}{{1,2}})\s+({_DIGIT_CLASS})\s*[:;.,]\s*({_DIGIT_CLASS})"
_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+{_FO_COORD}",
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+{_FO_COORD}",
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"
try:
coord = Coord(X=letter.upper(), Y=int(_fix_digits(y)), x=int(_fix_digits(x)), y=int(_fix_digits(yy)))
except ValueError:
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}$") _BLOCK_END_RE = re.compile(r"^[.\s]{1,6}$")
_LEADING_NOISE_RE = re.compile(r"^[^A-Za-z]{1,3}(?=[A-Za-z])") _LEADING_NOISE_RE = re.compile(r"^[^A-Za-z]{1,3}(?=[A-Za-z])")
@ -549,6 +639,25 @@ def parse_intel_blocks(text: str) -> list[dict]:
"id": num, "raw": [line], "clues": []} "id": num, "raw": [line], "clues": []}
continue 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: if current is not None:
current["raw"].append(line) current["raw"].append(line)
@ -700,6 +809,19 @@ def _squash_span_content(content: str) -> str:
multiword_id = _MULTIWORD_ID_SPAN_RE.match(content) multiword_id = _MULTIWORD_ID_SPAN_RE.match(content)
if multiword_id: if multiword_id:
return "".join(multiword_id.group(1).split()) + "#" + multiword_id.group(2) 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 return content
@ -802,6 +924,17 @@ def parse_text(text: str) -> ParsedInfo:
for entry in parse_named_at_coord(text): for entry in parse_named_at_coord(text):
info.reference_points[entry["name"]] = (entry["raw"], entry["clues"], entry["coord"]) 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) info.destroyed = parse_destroyed(text)
return info return info