OCR: listening-post audio intel, compass-direction distances, multi-word ids

New shapes:
- 'Listening Post#1 at K6 7:8 ...': a named anchor given inline rather
  than as its own block, parse_named_at_coord() picks up any
  '<Name>#<id> at <Coord>' anywhere in the text, RP-shaped like
  everything else that resolves to a name+coord.
- 'Distance 6.28km South-East from X': a listening post gives distance
  readings with an approximate 8-point compass direction instead of a
  precise degree bearing, different word order too (the direction sits
  between the distance and 'from', no separate Bearing keyword). New
  TargetType.COASTAL_BATTERY for what these turned out to report on.
- 'Coastal Battery#2' / 'Listening Post#1': multi-word type names with
  a real digit id already attached (unlike the Enemy case, nothing to
  invent), squash_multiword_ids() collapses the embedded space so
  _NAMED_HEADER_RE and every from-<ref> clue pattern see the single
  token they expect.

Also: squash_bold_spans(), prompted by the observation that a rich-text
paste already tells us exactly where a multi-word name starts and ends
via its own <b>...</b> wrapping, no need to guess from capitalization
the way squash_enemy_names()/squash_multiword_ids() do. Runs before
strip_html() while the tags are still there, using each span's own
content as an authoritative boundary. Those two whole-document regexes
stay as the fallback for plain OCR text, which never has markup to
lean on, but squash_multiword_ids() first shipped with a real bug this
caught: 'Distance 6.28km South-East from Listening Post#1' matched
'East from ListeningPost' as if it were one multi-word name, backward
through the lowercase connector word 'from'. Fixed by requiring
Title Case on every word in that whole-document fallback path (the
scoped bold-span path never had this problem, it can't reach past a
span's own boundary). Verified end to end against the exact example
text, including through the real _merge_targets()/_merge_reference_points()
app flow, plus the full existing regression sweep (Enemy names, train
intel, standard blocks, calibration target).
This commit is contained in:
Dominik Moritz Roth 2026-08-09 13:13:48 +02:00
parent a522963855
commit b48a4eb759
2 changed files with 116 additions and 2 deletions

View File

@ -44,6 +44,7 @@ class TargetType(Enum):
HOSTILE_ARTILLERY = "Hostile Artillery" HOSTILE_ARTILLERY = "Hostile Artillery"
HOSTILE_TANK = "Hostile Tank" HOSTILE_TANK = "Hostile Tank"
PILLBOX = "Pillbox" # armoured emplacement, fixed position PILLBOX = "Pillbox" # armoured emplacement, fixed position
COASTAL_BATTERY = "Coastal Battery" # heavy fixed emplacement, reported by listening posts
ENEMY = "Enemy" # ad-hoc installation named directly in the intel text ENEMY = "Enemy" # ad-hoc installation named directly in the intel text
# ("Enemy Signal Station", "Enemy Field Command"), not one of the # ("Enemy Signal Station", "Enemy Field Command"), not one of the
# game's fixed unit types, its id is the rest of that name with # game's fixed unit types, its id is the rest of that name with

View File

@ -231,6 +231,60 @@ def squash_enemy_names(text: str) -> str:
return _ENEMY_NAME_RE.sub(lambda m: "Enemy#" + "".join(m.group(0).split()[1:]), text) 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+([A-T])\s*({_DIGIT_CLASS}{{1,2}})\s+({_DIGIT_CLASS})"
rf"\s*[:;.,]\s*({_DIGIT_CLASS})",
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()
try:
coord = Coord(X=letter.upper(), Y=int(_fix_digits(y)), x=int(_fix_digits(x)), y=int(_fix_digits(yy)))
except ValueError:
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
_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])")
@ -253,6 +307,21 @@ _CLUE_INLINE_RE = re.compile(
rf"(\d{{1,3}})\s*°?\s*,\s*([\d.]+)\s*k?m{_GAP}from\s+(\S+)", re.IGNORECASE 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) _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,
}
_COMPASS_WORD_RE = r"(North(?:[-\s]?East|[-\s]?West)?|South(?:[-\s]?East|[-\s]?West)?|East|West)"
_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) _CLUE_DISTANCE_RE = re.compile(rf"Distance\s*([\d.]+)\s*k?m?{_GAP}from\s+(\S+)", re.IGNORECASE)
_TYPE_BY_SHORT = {t.short: t for t in TargetType} _TYPE_BY_SHORT = {t.short: t for t in TargetType}
@ -301,6 +370,9 @@ _CLUE_PATTERNS = (
(_CLUE_COMBINED_RE, lambda m: (float(m.group(1)), float(m.group(2)), m.group(3))), (_CLUE_COMBINED_RE, lambda m: (float(m.group(1)), float(m.group(2)), m.group(3))),
(_CLUE_INLINE_RE, lambda m: (float(m.group(1)), float(m.group(2)), m.group(3))), (_CLUE_INLINE_RE, lambda m: (float(m.group(1)), float(m.group(2)), m.group(3))),
(_CLUE_BEARING_RE, lambda m: (float(m.group(1)), None, m.group(2))), (_CLUE_BEARING_RE, lambda m: (float(m.group(1)), None, m.group(2))),
(_CLUE_DISTANCE_COMPASS_RE, lambda m: (
_COMPASS_BEARINGS[re.sub(r"[-\s]", "", m.group(2)).upper()], float(m.group(1)), m.group(3)
)),
(_CLUE_DISTANCE_RE, lambda m: (None, float(m.group(1)), m.group(2))), (_CLUE_DISTANCE_RE, lambda m: (None, float(m.group(1)), m.group(2))),
) )
@ -330,7 +402,8 @@ def parse_clues_from_text(text: str) -> list[Clue]:
"""Parse every Bearing/Distance clue found in free-form text, used """Parse every Bearing/Distance clue found in free-form text, used
for manually-typed descriptions in the coord dialog, sharing the for manually-typed descriptions in the coord dialog, sharing the
exact same clue grammar as the OCR'd intel blocks.""" exact same clue grammar as the OCR'd intel blocks."""
return _parse_all_clues(squash_enemy_names(text)) text = strip_html(squash_bold_spans(text))
return _parse_all_clues(squash_enemy_names(squash_multiword_ids(text)))
def _resolve_target_type(type_word: str) -> TargetType | None: def _resolve_target_type(type_word: str) -> TargetType | None:
@ -549,6 +622,42 @@ def parse_destroyed(text: str) -> set[tuple[TargetType, str]]:
return destroyed 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)
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_BREAK_RE = re.compile(r"(?i)<br\s*/?>|</(?:p|div|li|tr)>")
_HTML_TAG_RE = re.compile(r"<[^>]+>") _HTML_TAG_RE = re.compile(r"<[^>]+>")
@ -581,7 +690,8 @@ class ParsedInfo:
def parse_text(text: str) -> ParsedInfo: def parse_text(text: str) -> ParsedInfo:
text = squash_enemy_names(strip_html(text)) text = strip_html(squash_bold_spans(text))
text = squash_enemy_names(squash_multiword_ids(text))
info = ParsedInfo() info = ParsedInfo()
for raw_line in text.splitlines(): for raw_line in text.splitlines():
@ -624,6 +734,9 @@ def parse_text(text: str) -> ParsedInfo:
for entry in parse_train_intel(text): for entry in parse_train_intel(text):
info.reference_points[entry["name"]] = (entry["raw"], entry["clues"], entry["coord"]) 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"])
info.destroyed = parse_destroyed(text) info.destroyed = parse_destroyed(text)
return info return info