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,
|
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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user