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:
parent
202219abf2
commit
c273f57435
@ -57,9 +57,10 @@ def _row(
|
||||
on_toggle_show_geo=None,
|
||||
alive=None,
|
||||
on_toggle_alive=None,
|
||||
on_convert=None,
|
||||
) -> Gtk.Widget:
|
||||
"""One list entry: a label plus input/screenshot/geo/hide[/alive][/remove]
|
||||
action buttons."""
|
||||
"""One list entry: a label plus input/screenshot/geo/hide[/alive]
|
||||
[/convert][/remove] action buttons."""
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||||
box.set_margin_top(4)
|
||||
box.set_margin_bottom(4)
|
||||
@ -112,6 +113,15 @@ def _row(
|
||||
hide_btn.connect("clicked", lambda _b: on_toggle_hidden())
|
||||
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:
|
||||
rm_btn = Gtk.Button(icon_name="user-trash-symbolic", tooltip_text="Remove")
|
||||
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),
|
||||
show_geo=rp.show_geo_desc,
|
||||
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())
|
||||
@ -817,6 +828,21 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
self._refresh()
|
||||
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 -----------------------------------------------------------------
|
||||
def _build_targets_popover(self, rebuild) -> Gtk.Widget:
|
||||
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),
|
||||
alive=t.alive,
|
||||
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())
|
||||
@ -868,6 +895,20 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
self.board.add_target(type_ or TargetType.UNKNOWN, location, id_)
|
||||
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 ------------------------------------------------------------
|
||||
def _build_scout_flights_popover(self, rebuild) -> Gtk.Widget:
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||
|
||||
@ -30,7 +30,8 @@ import numpy as np
|
||||
import pytesseract
|
||||
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
|
||||
|
||||
# --- 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.
|
||||
_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
|
||||
# Type#N id ("Enemy Signal Station:", "Bearing 034 from Enemy Signal
|
||||
# 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
|
||||
|
||||
|
||||
# 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}$")
|
||||
_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": []}
|
||||
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)
|
||||
|
||||
@ -700,6 +809,19 @@ def _squash_span_content(content: str) -> str:
|
||||
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
|
||||
|
||||
|
||||
@ -802,6 +924,17 @@ def parse_text(text: str) -> ParsedInfo:
|
||||
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
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user