OCR: parse fire-support requests ('Marine Garrison#1 pinned!')
New shape: '<Name>#<id> pinned!' followed by '<Shell> Shells requested on <coord>' and 'Requested before - <T-time> -'. Different from every existing coord shape (no 'Grid' keyword), so it needed its own extractor (_extract_requested_on_coord), plus new ones for the shell code and the deadline string. New TargetType.MARINE_GARRISON, its multi-word name + real digit id already works through the existing squash_multiword_ids() pipeline (same as Coastal Battery/Listening Post) with no new header regex needed. Target gained a requested_time field (raw string, this app doesn't track a game clock to compare it against), persisted through save/load and shown on the map below the coord label. The requested shell sets target.shell directly rather than staying a suggestion, matching how a manually-picked shell already works. info.targets' value tuple grew from 3 to 5 elements (raw, clues, coord, shell, requested_time); updated both call sites in app.py that unpack it. Verified end to end: parse -> merge -> save/load round trip -> map draw.
This commit is contained in:
parent
eedea3ea19
commit
202219abf2
@ -539,7 +539,7 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
|
|
||||||
def _merge_targets(self, info: "ocr.ParsedInfo", *, toast: bool = True) -> list[str]:
|
def _merge_targets(self, info: "ocr.ParsedInfo", *, toast: bool = True) -> list[str]:
|
||||||
changed = []
|
changed = []
|
||||||
for (target_type, target_id), (raw, clues, coord) in info.targets.items():
|
for (target_type, target_id), (raw, clues, coord, shell, requested_time) in info.targets.items():
|
||||||
existing = next(
|
existing = next(
|
||||||
(t for t in self.board.targets if t.type == target_type and t.id == target_id), None
|
(t for t in self.board.targets if t.type == target_type and t.id == target_id), None
|
||||||
)
|
)
|
||||||
@ -549,6 +549,13 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
existing = self.board.add_target(
|
existing = self.board.add_target(
|
||||||
target_type, Location(coord=coord, desc_raw=raw, clues=clues), id_=target_id
|
target_type, Location(coord=coord, desc_raw=raw, clues=clues), id_=target_id
|
||||||
)
|
)
|
||||||
|
# A fire-support request's shell/deadline are current-state
|
||||||
|
# facts from THIS report, not provenance to preserve like
|
||||||
|
# desc_raw/clues, a later re-read should just overwrite them.
|
||||||
|
if shell is not None:
|
||||||
|
existing.shell = shell
|
||||||
|
if requested_time is not None:
|
||||||
|
existing.requested_time = requested_time
|
||||||
changed.append(existing.name)
|
changed.append(existing.name)
|
||||||
|
|
||||||
# "SupplyCache#1 Destroyed." etc. Mark it dead if we already know
|
# "SupplyCache#1 Destroyed." etc. Mark it dead if we already know
|
||||||
@ -606,8 +613,12 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
if result is None:
|
if result is None:
|
||||||
self.toast(f"{target.name} not found in screenshot.")
|
self.toast(f"{target.name} not found in screenshot.")
|
||||||
return
|
return
|
||||||
raw, clues, coord = result
|
raw, clues, coord, shell, requested_time = result
|
||||||
self._merge_parsed_location(target, raw, clues, coord)
|
self._merge_parsed_location(target, raw, clues, coord)
|
||||||
|
if shell is not None:
|
||||||
|
target.shell = shell
|
||||||
|
if requested_time is not None:
|
||||||
|
target.requested_time = requested_time
|
||||||
self._refresh()
|
self._refresh()
|
||||||
self.toast(f"{target.name} set from screenshot.")
|
self.toast(f"{target.name} set from screenshot.")
|
||||||
|
|
||||||
|
|||||||
@ -326,7 +326,8 @@ class GridCanvas(Gtk.DrawingArea):
|
|||||||
self._draw_marker(cr, obj.coord.as_fraction(), CATEGORY_COLOR[category],
|
self._draw_marker(cr, obj.coord.as_fraction(), CATEGORY_COLOR[category],
|
||||||
obj.name, cell_w, cell_h, grid_h, width, height,
|
obj.name, cell_w, cell_h, grid_h, width, height,
|
||||||
dim=(category == "target" and not obj.alive) or obj.hidden,
|
dim=(category == "target" and not obj.alive) or obj.hidden,
|
||||||
selected=(obj is self.selected), coord=obj.coord)
|
selected=(obj is self.selected), coord=obj.coord,
|
||||||
|
extra_line=getattr(obj, "requested_time", None))
|
||||||
|
|
||||||
for category, obj in self.board.ambiguous_entities_all():
|
for category, obj in self.board.ambiguous_entities_all():
|
||||||
if self._excluded_from_map(obj):
|
if self._excluded_from_map(obj):
|
||||||
@ -337,7 +338,8 @@ class GridCanvas(Gtk.DrawingArea):
|
|||||||
self._draw_marker(cr, candidate.as_fraction(), color,
|
self._draw_marker(cr, candidate.as_fraction(), color,
|
||||||
f"{obj.name}? ({i + 1})", cell_w, cell_h, grid_h, width, height,
|
f"{obj.name}? ({i + 1})", cell_w, cell_h, grid_h, width, height,
|
||||||
hollow=True, dim=obj.hidden or (category == "target" and not obj.alive),
|
hollow=True, dim=obj.hidden or (category == "target" and not obj.alive),
|
||||||
selected=is_selected, coord=candidate)
|
selected=is_selected, coord=candidate,
|
||||||
|
extra_line=getattr(obj, "requested_time", None))
|
||||||
|
|
||||||
for sf in self.board.scout_flights:
|
for sf in self.board.scout_flights:
|
||||||
if sf.hidden:
|
if sf.hidden:
|
||||||
@ -360,7 +362,7 @@ class GridCanvas(Gtk.DrawingArea):
|
|||||||
|
|
||||||
def _draw_marker(self, cr, point_km, color, label, cell_w, cell_h, grid_h,
|
def _draw_marker(self, cr, point_km, color, label, cell_w, cell_h, grid_h,
|
||||||
canvas_width, canvas_height, *, hollow=False, dim=False,
|
canvas_width, canvas_height, *, hollow=False, dim=False,
|
||||||
selected=False, coord=None) -> None:
|
selected=False, coord=None, extra_line=None) -> None:
|
||||||
x, y = self._km_to_px(point_km, cell_w, cell_h, grid_h)
|
x, y = self._km_to_px(point_km, cell_w, cell_h, grid_h)
|
||||||
r, g, b = color
|
r, g, b = color
|
||||||
alpha = 0.45 if dim else 1.0
|
alpha = 0.45 if dim else 1.0
|
||||||
@ -414,6 +416,13 @@ class GridCanvas(Gtk.DrawingArea):
|
|||||||
cr.show_text(coord_text)
|
cr.show_text(coord_text)
|
||||||
cr.set_font_size(11)
|
cr.set_font_size(11)
|
||||||
|
|
||||||
|
if extra_line:
|
||||||
|
cr.set_font_size(9)
|
||||||
|
cr.set_source_rgba(*COORD_LABEL, alpha)
|
||||||
|
cr.move_to(label_x, label_y + (24 if coord_text else 12))
|
||||||
|
cr.show_text(extra_line)
|
||||||
|
cr.set_font_size(11)
|
||||||
|
|
||||||
def _draw_firing_arrows(self, cr, cell_w, cell_h, grid_h) -> None:
|
def _draw_firing_arrows(self, cr, cell_w, cell_h, grid_h) -> None:
|
||||||
"""Red arrow(s) Nest -> Target, for whatever's hovered or selected.
|
"""Red arrow(s) Nest -> Target, for whatever's hovered or selected.
|
||||||
Points at exactly the hovered/selected candidate when one is known
|
Points at exactly the hovered/selected candidate when one is known
|
||||||
|
|||||||
@ -45,6 +45,7 @@ class TargetType(Enum):
|
|||||||
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
|
COASTAL_BATTERY = "Coastal Battery" # heavy fixed emplacement, reported by listening posts
|
||||||
|
MARINE_GARRISON = "Marine Garrison" # allied unit, requests fire support (see Target.requested_time)
|
||||||
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
|
||||||
@ -309,6 +310,11 @@ class Target:
|
|||||||
shell: Shell | None = None
|
shell: Shell | None = None
|
||||||
# Which gun this target is assigned to, if any, "unassigned" | "left" | "right".
|
# Which gun this target is assigned to, if any, "unassigned" | "left" | "right".
|
||||||
assignment: str = "unassigned"
|
assignment: str = "unassigned"
|
||||||
|
# In-game clock deadline from a "Requested before - T10:31:41 -" style
|
||||||
|
# report (e.g. a Marine Garrison's fire support request), kept as the
|
||||||
|
# raw string as printed, this app doesn't track a game clock to compare
|
||||||
|
# it against, it's shown as-is for the player's own reference.
|
||||||
|
requested_time: str | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
@ -563,6 +569,7 @@ class Board:
|
|||||||
"powder_charges": t.powder_charges,
|
"powder_charges": t.powder_charges,
|
||||||
"shell": t.shell.name if t.shell is not None else None,
|
"shell": t.shell.name if t.shell is not None else None,
|
||||||
"assignment": t.assignment,
|
"assignment": t.assignment,
|
||||||
|
"requested_time": t.requested_time,
|
||||||
}
|
}
|
||||||
for t in self.targets
|
for t in self.targets
|
||||||
],
|
],
|
||||||
@ -619,6 +626,7 @@ class Board:
|
|||||||
powder_charges=t.get("powder_charges"),
|
powder_charges=t.get("powder_charges"),
|
||||||
shell=Shell[t["shell"]] if t.get("shell") else None,
|
shell=Shell[t["shell"]] if t.get("shell") else None,
|
||||||
assignment=t.get("assignment", "unassigned"),
|
assignment=t.get("assignment", "unassigned"),
|
||||||
|
requested_time=t.get("requested_time"),
|
||||||
)
|
)
|
||||||
for t in data.get("targets", [])
|
for t in data.get("targets", [])
|
||||||
]
|
]
|
||||||
|
|||||||
@ -31,6 +31,7 @@ import pytesseract
|
|||||||
from PIL import Image, ImageFilter
|
from PIL import Image, ImageFilter
|
||||||
|
|
||||||
from .models import Clue, Coord, TargetType
|
from .models import Clue, Coord, TargetType
|
||||||
|
from .shells import Shell
|
||||||
|
|
||||||
# --- preprocessing -----------------------------------------------------------
|
# --- preprocessing -----------------------------------------------------------
|
||||||
|
|
||||||
@ -152,6 +153,47 @@ def _extract_grid_coord(text: str) -> Coord | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# A fire-support request gives its coord differently again: "<Shell>
|
||||||
|
# Shells requested on <coord>", no "Grid" keyword. Same shape otherwise.
|
||||||
|
_REQUESTED_ON_COORD_RE = re.compile(
|
||||||
|
rf"requested\s+on\s+([A-T])\s*({_DIGIT_CLASS}{{1,2}})\s+({_DIGIT_CLASS})\s*[:;.,]\s*({_DIGIT_CLASS})",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# "SMK Shells requested on ..." names the shell by its short code, one of
|
||||||
|
# the Shell enum's own member names.
|
||||||
|
_SHELL_REQUEST_RE = re.compile(r"([A-Za-z]+)\s+Shells?\s+requested", re.IGNORECASE)
|
||||||
|
# "Requested before - T10:31:41 -": an in-game clock deadline, kept as
|
||||||
|
# the raw string, this app doesn't track a game clock to compare it
|
||||||
|
# against.
|
||||||
|
_REQUESTED_BEFORE_RE = re.compile(r"Requested\s+before\s*-\s*(T?\d{1,2}:\d{2}:\d{2})\s*-", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_requested_on_coord(text: str) -> Coord | None:
|
||||||
|
m = _REQUESTED_ON_COORD_RE.search(text)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
letter, y, x, yy = m.groups()
|
||||||
|
try:
|
||||||
|
return Coord(X=letter.upper(), Y=int(_fix_digits(y)), x=int(_fix_digits(x)), y=int(_fix_digits(yy)))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_shell_request(text: str) -> Shell | None:
|
||||||
|
m = _SHELL_REQUEST_RE.search(text)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return Shell[m.group(1).upper()]
|
||||||
|
except KeyError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_requested_time(text: str) -> str | None:
|
||||||
|
m = _REQUESTED_BEFORE_RE.search(text)
|
||||||
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
|
||||||
def _extract_coord(line: str) -> Coord | None:
|
def _extract_coord(line: str) -> Coord | None:
|
||||||
m = _COORD_RE.search(line)
|
m = _COORD_RE.search(line)
|
||||||
if not m:
|
if not m:
|
||||||
@ -422,8 +464,9 @@ def _resolve_target_type(type_word: str) -> TargetType | None:
|
|||||||
|
|
||||||
def parse_intel_blocks(text: str) -> list[dict]:
|
def parse_intel_blocks(text: str) -> list[dict]:
|
||||||
"""Parse 'field intelligence' blocks into a list of dicts with keys
|
"""Parse 'field intelligence' blocks into a list of dicts with keys
|
||||||
kind ('rp' | 'named'), name, type_word, id, raw, clues, coord. Entries
|
kind ('rp' | 'named'), name, type_word, id, raw, clues, coord, shell,
|
||||||
with neither a clue nor a grid coord are dropped (nothing to store).
|
requested_time. Entries with none of clue/coord/shell/requested_time
|
||||||
|
are dropped (nothing to store).
|
||||||
|
|
||||||
Clues are extracted once per block, from the whole joined block text,
|
Clues are extracted once per block, from the whole joined block text,
|
||||||
at flush time, not accumulated line-by-line while scanning. That's
|
at flush time, not accumulated line-by-line while scanning. That's
|
||||||
@ -431,7 +474,9 @@ def parse_intel_blocks(text: str) -> list[dict]:
|
|||||||
"from Spotter#1" on separate lines) or two clues on one line
|
"from Spotter#1" on separate lines) or two clues on one line
|
||||||
("Bearing X from A & Bearing Y from B") both resolve correctly. A
|
("Bearing X from A & Bearing Y from B") both resolve correctly. A
|
||||||
target can also carry an absolute grid ref directly ("Grid Q3 9:0")
|
target can also carry an absolute grid ref directly ("Grid Q3 9:0")
|
||||||
instead of/alongside clues."""
|
instead of/alongside clues, or a fire-support request's own coord
|
||||||
|
shape ("SMK Shells requested on J6 8:3"), plus that request's shell
|
||||||
|
and deadline if given."""
|
||||||
entries: list[dict] = []
|
entries: list[dict] = []
|
||||||
current: dict | None = None
|
current: dict | None = None
|
||||||
|
|
||||||
@ -440,8 +485,11 @@ def parse_intel_blocks(text: str) -> list[dict]:
|
|||||||
if current is not None:
|
if current is not None:
|
||||||
joined = "\n".join(current["raw"])
|
joined = "\n".join(current["raw"])
|
||||||
current["clues"] = _parse_all_clues(joined)
|
current["clues"] = _parse_all_clues(joined)
|
||||||
current["coord"] = _extract_grid_coord(joined)
|
current["coord"] = _extract_grid_coord(joined) or _extract_requested_on_coord(joined)
|
||||||
if current["clues"] or current["coord"] is not None:
|
current["shell"] = _extract_shell_request(joined)
|
||||||
|
current["requested_time"] = _extract_requested_time(joined)
|
||||||
|
if (current["clues"] or current["coord"] is not None
|
||||||
|
or current["shell"] is not None or current["requested_time"] is not None):
|
||||||
current["raw"] = joined
|
current["raw"] = joined
|
||||||
entries.append(current)
|
entries.append(current)
|
||||||
current = None
|
current = None
|
||||||
@ -690,8 +738,12 @@ class ParsedInfo:
|
|||||||
spotters: dict[int, Coord] = field(default_factory=dict)
|
spotters: dict[int, Coord] = field(default_factory=dict)
|
||||||
# name -> (raw description, clues, absolute coord if given directly)
|
# name -> (raw description, clues, absolute coord if given directly)
|
||||||
reference_points: dict[str, tuple[str, list[Clue], Coord | None]] = field(default_factory=dict)
|
reference_points: dict[str, tuple[str, list[Clue], Coord | None]] = field(default_factory=dict)
|
||||||
# (type, id) -> (raw description, clues, absolute coord if given directly)
|
# (type, id) -> (raw description, clues, absolute coord if given
|
||||||
targets: dict[tuple[TargetType, str], tuple[str, list[Clue], Coord | None]] = field(default_factory=dict)
|
# directly, requested shell if a fire-support request named one,
|
||||||
|
# requested-before deadline string if given)
|
||||||
|
targets: dict[
|
||||||
|
tuple[TargetType, str], tuple[str, list[Clue], Coord | None, Shell | None, str | None]
|
||||||
|
] = field(default_factory=dict)
|
||||||
# (type, id) of targets reported destroyed
|
# (type, id) of targets reported destroyed
|
||||||
destroyed: set[tuple[TargetType, str]] = field(default_factory=set)
|
destroyed: set[tuple[TargetType, str]] = field(default_factory=set)
|
||||||
|
|
||||||
@ -730,7 +782,7 @@ def parse_text(text: str) -> ParsedInfo:
|
|||||||
if (TargetType.UNKNOWN, "1") not in info.targets and _fuzzy_contains(line, TARGET_IS_AT_KEYWORD):
|
if (TargetType.UNKNOWN, "1") not in info.targets and _fuzzy_contains(line, TARGET_IS_AT_KEYWORD):
|
||||||
coord = _extract_coord(line)
|
coord = _extract_coord(line)
|
||||||
if coord is not None:
|
if coord is not None:
|
||||||
info.targets[(TargetType.UNKNOWN, "1")] = (line, [], coord)
|
info.targets[(TargetType.UNKNOWN, "1")] = (line, [], coord, None, None)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for entry in parse_intel_blocks(text):
|
for entry in parse_intel_blocks(text):
|
||||||
@ -740,7 +792,9 @@ def parse_text(text: str) -> ParsedInfo:
|
|||||||
target_type = _resolve_target_type(entry["type_word"])
|
target_type = _resolve_target_type(entry["type_word"])
|
||||||
if target_type is None:
|
if target_type is None:
|
||||||
continue
|
continue
|
||||||
info.targets[(target_type, entry["id"])] = (entry["raw"], entry["clues"], entry["coord"])
|
info.targets[(target_type, entry["id"])] = (
|
||||||
|
entry["raw"], entry["clues"], entry["coord"], entry["shell"], entry["requested_time"]
|
||||||
|
)
|
||||||
|
|
||||||
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"])
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user