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:
parent
a522963855
commit
b48a4eb759
@ -44,6 +44,7 @@ class TargetType(Enum):
|
||||
HOSTILE_ARTILLERY = "Hostile Artillery"
|
||||
HOSTILE_TANK = "Hostile Tank"
|
||||
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 Signal Station", "Enemy Field Command"), not one of the
|
||||
# game's fixed unit types, its id is the rest of that name with
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
# 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}$")
|
||||
_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
|
||||
)
|
||||
_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)
|
||||
|
||||
_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_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_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))),
|
||||
)
|
||||
|
||||
@ -330,7 +402,8 @@ 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."""
|
||||
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:
|
||||
@ -549,6 +622,42 @@ def parse_destroyed(text: str) -> set[tuple[TargetType, str]]:
|
||||
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_TAG_RE = re.compile(r"<[^>]+>")
|
||||
|
||||
@ -581,7 +690,8 @@ class 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()
|
||||
|
||||
for raw_line in text.splitlines():
|
||||
@ -624,6 +734,9 @@ def parse_text(text: str) -> ParsedInfo:
|
||||
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"])
|
||||
|
||||
info.destroyed = parse_destroyed(text)
|
||||
return info
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user