Import map screenshots into the board, and edit entities from the map

The existing clipboard button now routes images: a map-table shot goes to the
vision pipeline, anything else to the text OCR path as before. The decision
runs in a worker, since even the cheap pre-filter costs ~0.3s and solve()
takes 10-20s. solve() rejecting counts as "not a map" and falls through to
OCR, because it is the reliable verdict (0 false positives over 122 text
screenshots) where the pre-filter lets ~6% through; reporting a failure there
would mean a text screenshot never got read at all.

Grid first, units second. The one modal confirms or fixes the geometry only:
the screenshot with the reconstructed lattice drawn over it, plus four
draggable handles on one cell's corners. Four corners pin a homography
exactly (8 DOF, 2 equations each), and dragging any of them refits the whole
grid live. Detection deliberately does not run until this is accepted --
every unit position is expressed in grid coordinates, so detecting against a
grid about to be dragged would only be thrown away.

Once accepted the screenshot is rectified into board space and drawn as the
map's backdrop. Pre-warping is what makes it drawable at all: cairo has no
projective transform, but a rectified image places with a plain scale and
translate. Detected units then appear as proposals ON the map, drawn hollow
-- the same shape the map already uses for "this might be where it is", which
is exactly what a proposal is. Clicking one offers accept (with the detected
type or a corrected one) or reject; the header gains accept-all and
remove-screenshot, and removing the screenshot drops every proposal never
accepted, since they were only ever readings of it.

Separately, right-clicking any entity now opens an edit menu: change type,
change id, change position, delete. Which actions appear follows what the
entity actually has -- only Target/Ally carry a TargetType, Spotter's id is
an int, and the Nest is singular so it cannot be deleted. Changing an
existing target's type or id had no UI at all before this.

Also fixes warp_to_map, which composed only the lattice homography and
dropped the discrete (si,sj,du,dv) mapping that pins lattice indices to named
cells, so every automatically solved screenshot landed in the wrong place. It
happened to test fine because manual solutions have an identity mapping.
While there, the same routine had an off-by-one for a negative axis sign
(si*u+du runs from col+1 down to col across a cell, so floor() named the
neighbour); both now go through one shared GridSolution.grid_of.

Verified end to end through the real widgets on a fixture: grid phase yields
no proposals, four handles, a drag refits and still names cells correctly,
reset restores, a degenerate drag survives, accept warps to a 2000x1000
overlay, detection then yields proposals that hit-test, accept and reject
correctly, and removing the screenshot keeps accepted units only.

Completes the FEnigma rename in app.py (APP_ID, window title, class).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dominik Moritz Roth 2026-08-10 21:41:56 +02:00
parent ff3b89c41b
commit 3c80f93203
5 changed files with 1044 additions and 21 deletions

View File

@ -1,4 +1,4 @@
"""FeNigma: GTK4/libadwaita app entry point. """FEnigma: GTK4/libadwaita app entry point.
Layout: a row of category dropdowns on top (+ a universal clipboard-fetch Layout: a row of category dropdowns on top (+ a universal clipboard-fetch
button), the map/grid filling the center. Coordinates can be set by exact button), the map/grid filling the center. Coordinates can be set by exact
@ -14,6 +14,8 @@ from __future__ import annotations
import io import io
import json import json
import tempfile
from pathlib import Path
import gi import gi
@ -24,14 +26,25 @@ gi.require_version("Gdk", "4.0")
from gi.repository import Adw, Gdk, Gio, GLib, Gtk # noqa: E402 from gi.repository import Adw, Gdk, Gio, GLib, Gtk # noqa: E402
from PIL import Image # noqa: E402 from PIL import Image # noqa: E402
from . import ballistics, icons, ocr, solver # noqa: E402 from . import ballistics, icons, map_import, ocr, solver # noqa: E402
from .coord_dialog import CoordDialog # noqa: E402 from .coord_dialog import CoordDialog # noqa: E402
from .firing_panel import FiringPanel # noqa: E402 from .firing_panel import FiringPanel # noqa: E402
from .grid_fix_dialog import GridFixDialog # noqa: E402
from .grid_widget import COLS, ROWS, GridCanvas # noqa: E402 from .grid_widget import COLS, ROWS, GridCanvas # noqa: E402
from .models import Board, Location, Target, TargetType # noqa: E402 from .models import ( # noqa: E402
Ally,
Board,
Coord,
Location,
Nest,
ReferencePoint,
Spotter,
Target,
TargetType,
)
from .shells import Shell # noqa: E402 from .shells import Shell # noqa: E402
APP_ID = "eu.dominik-roth.FeNigma" APP_ID = "eu.dominik-roth.FEnigma"
def _apply_location(obj, location: Location) -> None: def _apply_location(obj, location: Location) -> None:
@ -45,6 +58,21 @@ def _apply_location(obj, location: Location) -> None:
obj.location.clues = location.clues obj.location.clues = location.clues
def _idle(fn, *args):
"""Hand a worker's result to the UI thread. GLib.idle_add's callback must
return False or it is called forever."""
GLib.idle_add(lambda: (fn(*args), False)[1])
def _coord_from_proposal(p) -> Coord | None:
"""map_vision reports "K8" plus sub-cell 0..9 in each axis, matching
Coord's own convention (see GridSolution.lattice_to_grid)."""
try:
return Coord(X=p.label[0], Y=int(p.label[1:]), x=p.sub_x, y=p.sub_y)
except (ValueError, IndexError):
return None
def _row( def _row(
name: str, name: str,
*, *,
@ -224,12 +252,14 @@ def _install_css() -> None:
class MainWindow(Adw.ApplicationWindow): class MainWindow(Adw.ApplicationWindow):
def __init__(self, app: Adw.Application) -> None: def __init__(self, app: Adw.Application) -> None:
super().__init__(application=app, title="FeNigma") super().__init__(application=app, title="FEnigma")
self.set_default_size(1100, 750) self.set_default_size(1100, 750)
_install_css() _install_css()
self.board = Board() self.board = Board()
self._clipboard_watch_handler = None self._clipboard_watch_handler = None
self._import_job = None # in-flight map_import.ImportJob, if any
self.screenshot_import = None # the map screenshot currently on the board
self.toast_overlay = Adw.ToastOverlay() self.toast_overlay = Adw.ToastOverlay()
self.set_content(self.toast_overlay) self.set_content(self.toast_overlay)
@ -256,10 +286,16 @@ class MainWindow(Adw.ApplicationWindow):
clear_btn.connect("clicked", lambda _b: self._clear_board()) clear_btn.connect("clicked", lambda _b: self._clear_board())
header.pack_start(clear_btn) header.pack_start(clear_btn)
clip_btn = Gtk.Button(icon_name="edit-paste-symbolic") self._clip_btn = Gtk.Button(icon_name="edit-paste-symbolic")
clip_btn.set_tooltip_text("Fetch screenshot or text from clipboard (Ctrl+P)") self._clip_btn.set_tooltip_text("Fetch screenshot or text from clipboard (Ctrl+P)")
clip_btn.connect("clicked", lambda _b: self._fetch_clipboard()) self._clip_btn.connect("clicked", lambda _b: self._fetch_clipboard())
header.pack_start(clip_btn) header.pack_start(self._clip_btn)
# Shown INSIDE the paste button while the map-vision worker runs (solve()
# takes 10-20s, so it has to be visible that something is happening).
# Taking the button's place rather than sitting next to it keeps the
# header from shifting sideways every time a screenshot is read.
self._import_spinner = Gtk.Spinner(spinning=True)
self._watch_btn = Gtk.ToggleButton(icon_name="media-playback-start-symbolic") self._watch_btn = Gtk.ToggleButton(icon_name="media-playback-start-symbolic")
self._watch_btn.set_tooltip_text( self._watch_btn.set_tooltip_text(
@ -296,6 +332,23 @@ class MainWindow(Adw.ApplicationWindow):
scout_btn.connect("clicked", lambda _b: self._add_scout_flight()) scout_btn.connect("clicked", lambda _b: self._add_scout_flight())
header.pack_start(scout_btn) header.pack_start(scout_btn)
# Screenshot-import actions, last in the row and only present while
# there is an imported screenshot to act on: their own separator is
# hidden with them so no divider dangles on an empty group.
self._import_sep = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL, visible=False)
header.pack_start(self._import_sep)
self._accept_all_btn = Gtk.Button(icon_name="object-select-symbolic", visible=False)
self._accept_all_btn.set_tooltip_text("Accept every proposed unit from the screenshot")
self._accept_all_btn.connect("clicked", lambda _b: self._accept_all_proposals())
header.pack_start(self._accept_all_btn)
self._drop_shot_btn = Gtk.Button(icon_name="edit-delete-symbolic", visible=False)
self._drop_shot_btn.set_tooltip_text(
"Remove the imported screenshot (drops unconfirmed units)")
self._drop_shot_btn.connect("clicked", lambda _b: self._remove_screenshot())
header.pack_start(self._drop_shot_btn)
firing_btn = Gtk.Button(icon_name="sidebar-show-right-symbolic") firing_btn = Gtk.Button(icon_name="sidebar-show-right-symbolic")
firing_btn.set_tooltip_text("Firing commands") firing_btn.set_tooltip_text("Firing commands")
firing_btn.connect("clicked", lambda _b: self._toggle_firing_panel()) firing_btn.connect("clicked", lambda _b: self._toggle_firing_panel())
@ -321,6 +374,7 @@ class MainWindow(Adw.ApplicationWindow):
on_toggle_hide_dead_map=self._on_toggle_hide_dead_map, on_toggle_hide_dead_map=self._on_toggle_hide_dead_map,
) )
self.canvas.on_select = self._set_selection self.canvas.on_select = self._set_selection
self.canvas.on_proposal_click = self._open_proposal_menu
self.canvas.on_hover_change = self._on_map_hover_change self.canvas.on_hover_change = self._on_map_hover_change
self.canvas.on_cursor_move = self._on_cursor_move self.canvas.on_cursor_move = self._on_cursor_move
self.canvas.on_right_click = self._on_map_right_click self.canvas.on_right_click = self._on_map_right_click
@ -462,15 +516,225 @@ class MainWindow(Adw.ApplicationWindow):
self.toast("Clipboard has no image. Copy a screenshot first.") self.toast("Clipboard has no image. Copy a screenshot first.")
return return
png = texture.save_to_png_bytes().get_data()
# A clipboard image is either a typewriter/field-log screenshot (text,
# OCR) or a shot of the map table (geometry, map_vision). Deciding
# which happens in the import worker, and the text path resumes here
# if it turns out not to be a map, so the same button covers both.
self._start_map_import(png, lambda: self._ocr_png(png, on_parsed))
def _ocr_png(self, png: bytes, on_parsed) -> None:
try: try:
pil_image = Image.open(io.BytesIO(texture.save_to_png_bytes().get_data())) info = ocr.run(Image.open(io.BytesIO(png)))
info = ocr.run(pil_image)
except Exception as exc: # OCR/parsing hiccups shouldn't crash the app except Exception as exc: # OCR/parsing hiccups shouldn't crash the app
self.toast(f"OCR failed: {exc}") self.toast(f"OCR failed: {exc}")
return return
on_parsed(info) on_parsed(info)
def _start_map_import(self, png: bytes, not_a_map) -> None:
"""Try to read the clipboard image as a map screenshot, off-thread.
solve() takes 10-20s, far too long for the UI thread, so it runs in a
worker (see map_import.ImportJob) and comes back through GLib.idle_add.
`not_a_map` is called instead when the gate says this is text.
"""
if self._import_job is not None and not self._import_job.cancelled:
self.toast("Still reading the previous screenshot.")
return
tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
tmp.write(png)
tmp.close()
path = Path(tmp.name)
def done(result, error):
self._import_job = None
self._set_import_busy(False)
path.unlink(missing_ok=True)
if result is None:
# solve() rejecting is the authoritative "not a map" verdict:
# it accepts none of the 122 writer screenshots, while the
# cheap gate lets ~6% through. So a rejection always falls
# through to the text path rather than being reported as a
# failure -- otherwise a text screenshot that trips the gate
# never gets OCR'd at all. The reason is still surfaced when
# the gate thought it was a map, because then it probably was
# one and the user wants to know why it didn't take.
if error != map_import.NOT_A_MAP:
self.toast(f"Couldn't read the grid ({error}), trying as text.")
not_a_map()
return
self._on_map_import_ready(result)
self._import_job = map_import.ImportJob(schedule=_idle)
self._set_import_busy(True)
self._import_job.start(path, done)
def _set_import_busy(self, busy: bool) -> None:
self._clip_btn.set_sensitive(not busy)
if busy:
self._clip_btn.set_child(self._import_spinner)
self.toast("Reading map screenshot…")
else:
self._clip_btn.set_child(None)
self._clip_btn.set_icon_name("edit-paste-symbolic")
def _on_map_import_ready(self, imp) -> None:
"""Grid first, units second.
The only thing to confirm here is the geometry: it is what every unit
position is expressed in, so it has to be right before detection is
worth running at all. Units come back afterwards as proposals ON the
map, where they can be judged against the screenshot they came from.
"""
GridFixDialog(
image=imp.image,
solution=imp.solution,
on_accept=lambda sol: self._accept_grid(imp, sol),
on_discard=lambda: self.toast("Screenshot discarded."),
).present(self)
def _accept_grid(self, imp, solution) -> None:
"""Grid confirmed: rectify the screenshot onto the board, then detect."""
imp.solution = solution
self.screenshot_import = imp
imp.build_overlay()
self.canvas.set_screenshot(imp.overlay, imp.px_per_km)
self._refresh_proposals()
self._start_marker_detection(imp)
def _start_marker_detection(self, imp) -> None:
def done(result, error):
self._import_job = None
self._set_import_busy(False)
if result is None:
self.toast(f"Unit detection failed: {error}.")
return
self._refresh_proposals()
n = len(result.proposals)
self.toast(f"{n} unit(s) proposed, right-click one to accept it."
if n else "No units found in the screenshot.")
self._import_job = map_import.ImportJob(schedule=_idle)
self._set_import_busy(True)
self._import_job.find_markers(imp, done)
def _refresh_proposals(self) -> None:
imp = self.screenshot_import
pairs = []
if imp is not None:
for p in imp.proposals:
coord = _coord_from_proposal(p)
if coord is not None:
pairs.append((p, coord))
self.canvas.set_proposals(pairs)
self._update_import_actions()
def _update_import_actions(self) -> None:
"""The screenshot-specific header buttons only exist while there is a
screenshot to act on."""
imp = self.screenshot_import
self._import_sep.set_visible(imp is not None)
self._accept_all_btn.set_visible(imp is not None)
self._drop_shot_btn.set_visible(imp is not None)
self._accept_all_btn.set_sensitive(bool(imp is not None and imp.pending()))
def _accept_proposal(self, proposal, type_=None) -> None:
coord = _coord_from_proposal(proposal)
if coord is None:
return
if type_ is None:
type_ = icons.target_type_from_icon(proposal.unit) or TargetType.UNKNOWN
if proposal.side == "friendly":
self.board.add_ally(type_, coord)
else:
self.board.add_target(type_, coord)
proposal.accepted = True
def _accept_all_proposals(self) -> None:
imp = self.screenshot_import
if imp is None:
return
pending = imp.pending()
for proposal in pending:
self._accept_proposal(proposal)
self._refresh()
self._refresh_proposals()
self.toast(f"Accepted {len(pending)} unit(s).")
def _remove_screenshot(self) -> None:
"""Dropping the screenshot also drops every proposal never accepted:
they were only ever readings OF that screenshot, so without it there is
nothing left to judge them against."""
imp = self.screenshot_import
if imp is None:
return
dropped = len(imp.pending())
imp.drop_unaccepted()
self.screenshot_import = None
self.canvas.set_screenshot(None, 0)
self._refresh_proposals()
self.toast(f"Screenshot removed, {dropped} unconfirmed unit(s) dropped."
if dropped else "Screenshot removed.")
def _open_proposal_menu(self, proposal, x: float, y: float) -> None:
"""Right-click on a detected-but-unconfirmed unit: accept it (with the
detected type, or a corrected one) or reject it."""
popover = self._popover_at(x, y)
def page():
return Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2,
margin_top=6, margin_bottom=6, margin_start=6, margin_end=6)
def button(box, label, handler, *, css="flat"):
btn = Gtk.Button(label=label, css_classes=[css])
if btn.get_child() is not None:
btn.get_child().set_xalign(0.0)
btn.connect("clicked", lambda _b: handler())
box.append(btn)
detected = icons.target_type_from_icon(proposal.unit)
def accept(type_=None):
popover.popdown()
self._accept_proposal(proposal, type_)
self._refresh()
self._refresh_proposals()
def reject():
popover.popdown()
proposal.rejected = True
self._refresh_proposals()
def show_main():
box = page()
lbl = Gtk.Label(xalign=0, margin_start=4, margin_bottom=2)
side = "friendly" if proposal.side == "friendly" else "hostile"
lbl.set_markup(
f"<b>{GLib.markup_escape_text(proposal.coord)}</b> — {side}, "
f"{detected.value if detected else 'type unknown'}")
box.append(lbl)
box.append(Gtk.Separator(margin_top=2, margin_bottom=2))
button(box, f"Accept as {detected.value if detected else TargetType.UNKNOWN.value}",
accept, css="suggested-action")
button(box, "Accept as…", show_type)
button(box, "Reject", reject, css="destructive-action")
popover.set_child(box)
def show_type():
box = page()
scroller = Gtk.ScrolledWindow(propagate_natural_height=True,
max_content_height=340,
hscrollbar_policy=Gtk.PolicyType.NEVER)
inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
for t in TargetType:
button(inner, t.value, lambda t=t: accept(t))
scroller.set_child(inner)
box.append(scroller)
popover.set_child(box)
show_main()
popover.popup()
def _on_toggle_clipboard_watch(self, btn: Gtk.ToggleButton) -> None: def _on_toggle_clipboard_watch(self, btn: Gtk.ToggleButton) -> None:
"""Auto-watch toggle: while on, every clipboard change that looks """Auto-watch toggle: while on, every clipboard change that looks
like an image (not e.g. text copied elsewhere) is OCR'd and merged like an image (not e.g. text copied elsewhere) is OCR'd and merged
@ -1038,10 +1302,8 @@ class MainWindow(Adw.ApplicationWindow):
) )
self.toast(f"Click the map to place {target.name}, Esc to cancel.") self.toast(f"Click the map to place {target.name}, Esc to cancel.")
def _on_map_right_click(self, coord, x: float, y: float) -> None: def _popover_at(self, x: float, y: float) -> Gtk.Popover:
"""Right-click anywhere on the map: quick-add a Target or Strike """A popover anchored to a point on the canvas, self-unparenting."""
right there, no dialog, for when you already know exactly where
you're pointing and don't need to type coordinates."""
popover = Gtk.Popover() popover = Gtk.Popover()
popover.set_parent(self.canvas) popover.set_parent(self.canvas)
# NOT Gdk.Rectangle(x=..., y=..., ...): verified directly that this # NOT Gdk.Rectangle(x=..., y=..., ...): verified directly that this
@ -1054,6 +1316,174 @@ class MainWindow(Adw.ApplicationWindow):
rect.x, rect.y, rect.width, rect.height = int(x), int(y), 1, 1 rect.x, rect.y, rect.width, rect.height = int(x), int(y), 1, 1
popover.set_pointing_to(rect) popover.set_pointing_to(rect)
popover.connect("closed", lambda _p: popover.unparent()) popover.connect("closed", lambda _p: popover.unparent())
return popover
def _on_map_right_click(self, coord, x: float, y: float, obj=None, point=None) -> None:
"""Right-click on the map. On an entity that's the edit menu for it;
on empty map it's the quick-add menu."""
if isinstance(obj, map_import.Proposal):
self._open_proposal_menu(obj, x, y)
return
if obj is not None:
self._set_selection(obj, point)
self._open_entity_menu(obj, x, y)
return
self._open_quick_add_menu(coord, x, y)
def _open_entity_menu(self, obj, x: float, y: float) -> None:
"""Right-click on a marker: everything you'd want to fix about the
thing you're pointing at, without hunting for it in a side list.
Which actions appear is driven by what the entity actually has, not by
a fixed menu: only Target/Ally carry a TargetType, only some have an
editable id (Nest has no id at all, Spotter's is an int), and the Nest
is singular so it can't be deleted. Type and id are edited in place by
swapping the popover's contents rather than opening a dialog, since
both are one click / a few keystrokes and a modal for that is heavier
than the edit.
"""
popover = self._popover_at(x, y)
def page():
return Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2,
margin_top=6, margin_bottom=6, margin_start=6, margin_end=6)
def button(box, label, handler, *, css="flat"):
btn = Gtk.Button(label=label)
btn.add_css_class(css)
btn.set_halign(Gtk.Align.FILL)
if btn.get_child() is not None:
btn.get_child().set_xalign(0.0)
btn.connect("clicked", lambda _b: handler())
box.append(btn)
return btn
def heading(box, text):
lbl = Gtk.Label(xalign=0, margin_start=4, margin_bottom=2)
lbl.set_markup(f"<b>{GLib.markup_escape_text(text)}</b>")
box.append(lbl)
def show_main():
box = page()
where = obj.coord.label() if getattr(obj, "coord", None) else "unplaced"
heading(box, f"{obj.name}{where}")
box.append(Gtk.Separator(margin_top=2, margin_bottom=2))
if hasattr(obj, "type"):
button(box, f"Change type ({obj.type.value})", show_type)
if self._id_field_of(obj) is not None:
button(box, "Change ID", show_id)
button(box, "Change position (click the map)", change_position)
if not isinstance(obj, Nest):
button(box, "Delete", delete, css="destructive-action")
popover.set_child(box)
def show_type():
box = page()
heading(box, "Type")
scroller = Gtk.ScrolledWindow(propagate_natural_height=True,
max_content_height=340,
hscrollbar_policy=Gtk.PolicyType.NEVER)
inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
for t in TargetType:
label = f"{t.value}" if t is obj.type else f" {t.value}"
button(inner, label, lambda t=t: set_type(t))
scroller.set_child(inner)
box.append(scroller)
popover.set_child(box)
def set_type(t):
obj.type = t
self._refresh()
popover.popdown()
self.toast(f"{obj.name} is now a {t.value}.")
def show_id():
box = page()
heading(box, "ID")
entry = Gtk.Entry(text=str(self._id_of(obj)), activates_default=True)
entry.set_width_chars(16)
box.append(entry)
button(box, "Apply", lambda: set_id(entry.get_text()), css="suggested-action")
entry.connect("activate", lambda _e: set_id(entry.get_text()))
popover.set_child(box)
entry.grab_focus()
def set_id(text):
field = self._id_field_of(obj)
text = text.strip()
if not text:
self.toast("An ID can't be empty.")
return
if field == "id" and isinstance(obj, Spotter):
# Spotter ids are ints and its name is derived from them, so a
# non-integer would silently break Spotter#N naming and the
# clue references that match on it.
if not text.isdigit():
self.toast("A Spotter's ID has to be a number.")
return
value = int(text)
if any(s is not obj and s.id == value for s in self.board.spotters):
self.toast(f"Spotter#{value} already exists.")
return
else:
value = text
old = obj.name
setattr(obj, field, value)
self._refresh()
popover.popdown()
self.toast(f"{old} renamed to {obj.name}.")
def change_position():
popover.popdown()
if isinstance(obj, Target):
self._start_target_placement(obj)
return
self.canvas.start_placement(
lambda c: self._apply_and_refresh(obj, Location.from_coord(c)))
self.toast(f"Click the map to place {obj.name}, Esc to cancel.")
def delete():
popover.popdown()
name = obj.name
if self.canvas.selected is obj:
self._set_selection(None)
if isinstance(obj, Target):
self.board.remove_target(obj)
elif isinstance(obj, Ally):
self.board.remove_ally(obj)
elif isinstance(obj, Spotter):
self.board.remove_spotter(obj)
elif isinstance(obj, ReferencePoint):
self.board.remove_reference_point(obj)
else:
self.toast(f"{name} can't be removed.")
return
self._refresh()
self.toast(f"{name} removed.")
show_main()
popover.popup()
@staticmethod
def _id_field_of(obj) -> str | None:
"""Which attribute holds this entity's editable id, if any."""
if isinstance(obj, ReferencePoint):
return "rp_name"
if isinstance(obj, (Target, Ally, Spotter)):
return "id"
return None
def _id_of(self, obj):
field = self._id_field_of(obj)
return getattr(obj, field) if field else ""
def _open_quick_add_menu(self, coord, x: float, y: float) -> None:
"""Right-click on empty map: quick-add a Target or Strike
right there, no dialog, for when you already know exactly where
you're pointing and don't need to type coordinates."""
if coord is None:
return
popover = self._popover_at(x, y)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2, box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2,
margin_top=6, margin_bottom=6, margin_start=6, margin_end=6) margin_top=6, margin_bottom=6, margin_start=6, margin_end=6)
@ -1148,7 +1578,7 @@ class MainWindow(Adw.ApplicationWindow):
rebuild() rebuild()
class FeNigmaApp(Adw.Application): class FEnigmaApp(Adw.Application):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__(application_id=APP_ID) super().__init__(application_id=APP_ID)
@ -1160,7 +1590,7 @@ class FeNigmaApp(Adw.Application):
def main() -> int: def main() -> int:
app = FeNigmaApp() app = FEnigmaApp()
return app.run(None) return app.run(None)

View File

@ -0,0 +1,278 @@
"""The one modal in the map-import flow: confirm or fix the detected grid.
Nothing else belongs here. Unit detection happens *after* this dialog closes,
because every unit position is expressed in grid coordinates -- detecting
against a grid the user is about to drag would only be thrown away.
The correction handles are the four corners of one cell, not of the whole
screenshot. A homography has 8 degrees of freedom and each dragged corner
contributes 2, so four corners of a single known cell pin it exactly, and a
cell near the frame centre is the one whose corners are easiest to place
accurately by eye. Dragging any handle refits the whole grid immediately, so
the feedback is the entire reconstructed lattice moving, not just a dot.
"""
from __future__ import annotations
import math
import cairo
import gi
import numpy as np
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gtk # noqa: E402
from . import map_vision # noqa: E402
HANDLE_R = 9.0 # drawn radius of a corner handle, widget px
GRAB_R = 22.0 # how close a press has to be to grab one
def _surface_from_bgr(img):
"""A cairo surface over a numpy BGR image.
cairo's RGB24 is a 32-bit pixel laid out as B,G,R,x in memory on a
little-endian machine, which is exactly BGRA, so the converted array can
back the surface directly with no per-pixel work. The array is kept alive
by the caller holding it: create_for_data does not copy.
"""
import cv2
bgra = cv2.cvtColor(img, cv2.COLOR_BGR2BGRA)
bgra = np.ascontiguousarray(bgra)
h, w = bgra.shape[:2]
surface = cairo.ImageSurface.create_for_data(
memoryview(bgra), cairo.FORMAT_RGB24, w, h, w * 4)
return surface, bgra
class GridFixDialog(Adw.Dialog):
"""Shows the screenshot with the reconstructed grid drawn over it, plus
four draggable corner handles. on_accept(solution) gets whatever grid is
on screen when Accept is pressed."""
def __init__(self, *, image, solution, on_accept, on_discard=None):
super().__init__(title="Check the detected grid",
content_width=900, content_height=760)
self._image = image
self._auto = solution
self._sol = solution
self._on_accept = on_accept
self._on_discard = on_discard
self._surface, self._keepalive = _surface_from_bgr(image)
self._dragging = None # index of the handle being dragged
self._drag_from = None # its position when the drag began
quad = map_vision.centre_cell_quad(solution, image.shape)
# (label, [4 pixel corners], [4 grid corners]); the pixel corners move
# with the mouse, the grid corners are what they are supposed to BE and
# never change -- that pairing is the correspondence set refitted from.
self._label = quad[0] if quad else None
self._px = list(quad[1]) if quad else []
self._grid = list(quad[2]) if quad else []
view = Adw.ToolbarView()
view.add_top_bar(Adw.HeaderBar())
self.set_child(view)
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10,
margin_top=10, margin_bottom=10, margin_start=10, margin_end=10)
self._area = Gtk.DrawingArea(vexpand=True, hexpand=True)
self._area.set_draw_func(self._draw)
drag = Gtk.GestureDrag()
drag.connect("drag-begin", self._on_drag_begin)
drag.connect("drag-update", self._on_drag_update)
drag.connect("drag-end", lambda *_a: setattr(self, "_dragging", None))
self._area.add_controller(drag)
outer.append(self._area)
cell = self._label or "?"
self._hint = Gtk.Label(xalign=0, css_classes=["dim-label"], wrap=True)
self._hint.set_label(
f"Grid solved from {solution.votes} label read(s). "
f"If it is off, drag the four handles onto the corners of cell {cell}."
if self._px else
f"Grid solved from {solution.votes} label read(s)."
)
outer.append(self._hint)
buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8,
halign=Gtk.Align.END)
discard = Gtk.Button(label="Discard", css_classes=["pill"])
discard.connect("clicked", lambda _b: self._discard())
buttons.append(discard)
if self._px:
reset = Gtk.Button(label="Reset", css_classes=["pill"],
tooltip_text="Back to the automatically detected grid")
reset.connect("clicked", lambda _b: self._reset())
buttons.append(reset)
accept = Gtk.Button(label="Use this grid",
css_classes=["pill", "suggested-action"])
accept.connect("clicked", lambda _b: self._accept())
buttons.append(accept)
outer.append(buttons)
view.set_content(outer)
# -- geometry ------------------------------------------------------------
def _fit(self):
"""(scale, ox, oy) letterboxing the screenshot into the drawing area."""
w, h = self._area.get_width(), self._area.get_height()
ih, iw = self._image.shape[:2]
if not w or not h:
return 1.0, 0.0, 0.0
s = min(w / iw, h / ih)
return s, (w - iw * s) / 2, (h - ih * s) / 2
def _to_widget(self, p):
s, ox, oy = self._fit()
return p[0] * s + ox, p[1] * s + oy
def _to_image(self, x, y):
s, ox, oy = self._fit()
return (x - ox) / s, (y - oy) / s
def _refit(self):
"""Rebuild the grid from the four handle positions.
A bad drag (two handles on top of each other) makes the homography
degenerate; keep the previous grid rather than crash, the next drag
update recovers.
"""
try:
self._sol = map_vision.solution_from_correspondences(
list(zip(self._grid, self._px)))
except (ValueError, np.linalg.LinAlgError):
pass
self._area.queue_draw()
def _reset(self):
quad = map_vision.centre_cell_quad(self._auto, self._image.shape)
if quad:
self._px = list(quad[1])
self._sol = self._auto
self._area.queue_draw()
# -- input ---------------------------------------------------------------
def _on_drag_begin(self, _gesture, x, y):
self._dragging = None
best = GRAB_R
for i, p in enumerate(self._px):
wx, wy = self._to_widget(p)
d = ((wx - x) ** 2 + (wy - y) ** 2) ** 0.5
if d < best:
best, self._dragging = d, i
if self._dragging is not None:
self._drag_from = self._px[self._dragging]
def _on_drag_update(self, _gesture, dx, dy):
if self._dragging is None:
return
s, _ox, _oy = self._fit()
if s <= 0:
return
fx, fy = self._drag_from
self._px[self._dragging] = (fx + dx / s, fy + dy / s)
self._refit()
def _accept(self):
self.close()
self._on_accept(self._sol)
def _discard(self):
self.close()
if self._on_discard is not None:
self._on_discard()
# -- drawing -------------------------------------------------------------
def _draw(self, _area, cr, width, height):
cr.set_source_rgb(0.08, 0.08, 0.08)
cr.paint()
s, ox, oy = self._fit()
cr.save()
cr.translate(ox, oy)
cr.scale(s, s)
cr.set_source_surface(self._surface, 0, 0)
cr.get_source().set_filter(cairo.FILTER_GOOD)
cr.paint()
cr.restore()
self._draw_grid(cr)
for i, p in enumerate(self._px):
wx, wy = self._to_widget(p)
# new_path() before every arc: cairo's arc() joins the current point
# to the arc's start, and _draw_grid leaves one behind at the last
# cell name it drew. Without this, the first handle gets a stray
# line reaching across the whole screenshot from that label.
cr.new_path()
cr.set_source_rgb(1.0, 0.85, 0.1)
cr.arc(wx, wy, HANDLE_R, 0, 2 * math.pi)
cr.fill_preserve()
cr.set_source_rgb(0.1, 0.1, 0.1)
cr.set_line_width(2.0)
cr.stroke()
if i == self._dragging:
cr.new_path()
cr.set_source_rgb(1.0, 1.0, 1.0)
cr.arc(wx, wy, HANDLE_R + 4, 0, 2 * math.pi)
cr.set_line_width(1.5)
cr.stroke()
def _draw_grid(self, cr):
"""Every in-range cell the grid puts inside the frame, with its name
drawn where the game draws it. A wrong grid is obvious precisely
because those names land off the painted labels."""
sol = self._sol
h, w = self._image.shape[:2]
inv = np.linalg.inv(sol.H)
corners = inv @ np.array([[0, w, w, 0], [0, 0, h, h], [1, 1, 1, 1.0]])
if np.any(np.abs(corners[2]) < 1e-9):
return
ij = corners[:2] / corners[2]
cr.set_line_width(1.6)
cr.select_font_face("Sans", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD)
L2G = sol.lattice_to_grid()
for i in range(int(np.floor(ij[0].min())) - 1, int(np.ceil(ij[0].max())) + 2):
for j in range(int(np.floor(ij[1].min())) - 1, int(np.ceil(ij[1].max())) + 2):
g = L2G @ np.array([i, j, 1.0])
col, row = int(round(g[0])), int(round(g[1]))
if not (0 <= col < map_vision.COLS and 1 <= row <= map_vision.ROWS):
continue
quad = sol.H @ np.array([[i, i + 1, i + 1, i],
[j, j, j + 1, j + 1], [1, 1, 1, 1.0]])
if np.any(np.abs(quad[2]) < 1e-9):
continue
pts = [self._to_widget(p) for p in (quad[:2] / quad[2]).T]
cr.new_path()
cr.set_source_rgba(1.0, 1.0, 0.2, 0.75)
cr.move_to(*pts[0])
for p in pts[1:]:
cr.line_to(*p)
cr.close_path()
cr.stroke()
# The game pads a cell's label in from its top-left corner by a
# fixed fraction of the cell, which is also how the solver finds
# labels in the first place (see map_vision.PAD_L/PAD_T).
lx = i + (map_vision.PAD_L if sol.si > 0 else 1 - map_vision.PAD_L)
ly = j + (map_vision.PAD_T if sol.sj > 0 else 1 - map_vision.PAD_T)
t = sol.H @ np.array([lx, ly, 1.0])
if abs(t[2]) < 1e-9:
continue
tx, ty = self._to_widget((t[0] / t[2], t[1] / t[2]))
side = float(np.hypot(pts[1][0] - pts[0][0], pts[1][1] - pts[0][1]))
cr.set_font_size(max(9.0, min(30.0, side * 0.16)))
name = f"{map_vision.LARGE_X[col]}{row}"
cr.move_to(tx, ty)
cr.set_source_rgba(0, 0, 0, 0.8)
cr.text_path(name)
cr.set_line_width(3.0)
cr.stroke()
cr.move_to(tx, ty)
cr.set_source_rgb(0.3, 1.0, 0.3)
cr.show_text(name)

View File

@ -11,6 +11,7 @@ from collections import namedtuple
import cairo import cairo
import gi import gi
import numpy as np
gi.require_version("Gtk", "4.0") gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "4.0") gi.require_version("Gdk", "4.0")
@ -30,6 +31,9 @@ MARGIN_BOTTOM = 30
LABEL_PAD = 8 # gap between a marker and its name label LABEL_PAD = 8 # gap between a marker and its name label
HOVER_RADIUS_PX = 12 HOVER_RADIUS_PX = 12
# An imported screenshot is a backdrop, not the subject: slightly transparent so
# the grid lines and markers drawn over it stay legible.
SCREENSHOT_ALPHA = 0.88
OVERLAY_RAY_LENGTH_KM = 30.0 # long enough to cross the 20x10 map from any origin OVERLAY_RAY_LENGTH_KM = 30.0 # long enough to cross the 20x10 map from any origin
MIN_ZOOM = 1.0 # the whole 20x10 map fits, the default MIN_ZOOM = 1.0 # the whole 20x10 map fits, the default
@ -212,9 +216,26 @@ class GridCanvas(Gtk.DrawingArea):
self.on_select = None # callback(obj | None, point | None), fired on click self.on_select = None # callback(obj | None, point | None), fired on click
self.on_hover_change = None # callback(obj | None, point | None), fired on hover change self.on_hover_change = None # callback(obj | None, point | None), fired on hover change
self.on_cursor_move = None # callback((col, row) km | None), fired on every motion/leave self.on_cursor_move = None # callback((col, row) km | None), fired on every motion/leave
self.on_right_click = None # callback(Coord, x, y), fired on right-click (unless placing) # callback(proposal, x, y): fired when an imported screenshot's pending
# proposal is clicked with either button. A proposal exists only to be
# accepted or rejected, so plain clicking it offers that rather than
# selecting something the board doesn't contain yet.
self.on_proposal_click = None
# callback(Coord, x, y, obj, point): fired on right-click unless placing.
# obj/point are the entity under the cursor when there is one (same
# hit test as left-click selection), so the handler can offer actions
# on that entity instead of the place-something-here menu.
self.on_right_click = None
self.hide_dead_from_map = False # off by default; toggled from the firing panel toolbar self.hide_dead_from_map = False # off by default; toggled from the firing panel toolbar
# An imported map screenshot, rectified into board space, drawn under
# everything else, plus the units detected in it as [(proposal, Coord)].
# Proposals are kept separate from board entities on purpose: they are
# not on the board until accepted, so nothing that walks the board can
# see them, and they get their own hit test.
self._screenshot = None # (cairo surface, backing array, px_per_km)
self.proposals = []
# Which large cell the cursor is currently over, (col, row) both # Which large cell the cursor is currently over, (col, row) both
# floored, or None off the map/off the widget entirely. Redrawn # floored, or None off the map/off the widget entirely. Redrawn
# only when this actually changes cell (not on every pixel of # only when this actually changes cell (not on every pixel of
@ -510,6 +531,78 @@ class GridCanvas(Gtk.DrawingArea):
for candidate in obj.location.potential_coords: for candidate in obj.location.potential_coords:
yield obj, candidate yield obj, candidate
# -- imported screenshot ---------------------------------------------------
def set_screenshot(self, bgra, px_per_km: int) -> None:
"""Show a rectified map screenshot as the board's backdrop.
`bgra` covers the whole board (COLS x ROWS km at px_per_km), transparent
wherever the screenshot didn't reach, so a partial view of the table
doesn't blank out the rest of the map. Pre-warping into board space is
what makes this drawable at all: cairo has no projective transform, but
once the image is rectified a plain scale and translate places it.
"""
if bgra is None:
self._screenshot = None
self.queue_draw()
return
buf = np.ascontiguousarray(bgra)
h, w = buf.shape[:2]
surface = cairo.ImageSurface.create_for_data(
memoryview(buf), cairo.FORMAT_ARGB32, w, h, w * 4)
# The array must outlive the surface: create_for_data does not copy.
self._screenshot = (surface, buf, px_per_km)
self.queue_draw()
def has_screenshot(self) -> bool:
return self._screenshot is not None
def set_proposals(self, proposals) -> None:
"""proposals is [(proposal, Coord)]; the widget only reads the Coord and
the proposal's accepted/rejected flags, so it stays ignorant of
map_import's own coordinate format."""
self.proposals = list(proposals)
self.queue_draw()
def _pending_proposals(self):
return [(p, c) for p, c in self.proposals if p.pending]
def hit_test_proposal(self, view: _View, x: float, y: float):
"""The pending proposal nearest the cursor within range, or None."""
best, best_dist = None, HOVER_RADIUS_PX
for p, coord in self._pending_proposals():
px, py = self._km_to_px(view, coord.as_fraction())
dist = math.hypot(px - x, py - y)
if dist < best_dist:
best_dist, best = dist, p
return best
def _draw_screenshot(self, cr, view) -> None:
surface, buf, px_per_km = self._screenshot
# Board space runs col 0..COLS rightward and row 0..ROWS upward, so the
# image's top-left pixel is (col 0, row ROWS) -- the top-left corner.
x0, y0 = self._km_to_px(view, (0, ROWS))
x1, y1 = self._km_to_px(view, (COLS, 0))
ih, iw = buf.shape[:2]
if iw <= 0 or ih <= 0:
return
cr.save()
cr.translate(x0, y0)
cr.scale((x1 - x0) / iw, (y1 - y0) / ih)
cr.set_source_surface(surface, 0, 0)
cr.get_source().set_filter(cairo.FILTER_GOOD)
cr.paint_with_alpha(SCREENSHOT_ALPHA)
cr.restore()
def _draw_proposals(self, cr, view, width, height) -> None:
"""Detected-but-unconfirmed units. Drawn hollow, the same shape the map
already uses for "this might be where it is", because that is exactly
what a proposal is until the user accepts it."""
for p, coord in self._pending_proposals():
color = CATEGORY_COLOR["ally" if p.side == "friendly" else "target"]
self._draw_marker(cr, view, coord.as_fraction(), color,
f"? {coord.label()}", width, height,
hollow=True, coord=coord)
def _hit_test(self, view: _View, x: float, y: float): def _hit_test(self, view: _View, x: float, y: float):
"""Returns (obj, coord) of the nearest marker within range, or """Returns (obj, coord) of the nearest marker within range, or
(None, None), coord disambiguates which candidate of an (None, None), coord disambiguates which candidate of an
@ -591,6 +684,11 @@ class GridCanvas(Gtk.DrawingArea):
callback(coord) callback(coord)
return return
proposal = self.hit_test_proposal(view, x, y)
if proposal is not None and self.on_proposal_click is not None:
self.on_proposal_click(proposal, x, y)
return
hit, coord = self._hit_test(view, x, y) hit, coord = self._hit_test(view, x, y)
self.set_selected(hit, coord) self.set_selected(hit, coord)
if self.on_select is not None: if self.on_select is not None:
@ -603,9 +701,17 @@ class GridCanvas(Gtk.DrawingArea):
if self.on_right_click is None: if self.on_right_click is None:
return return
view = self._view(self.get_width(), self.get_height()) view = self._view(self.get_width(), self.get_height())
# A pending proposal wins over a board entity underneath it: it is the
# thing the user is being asked to decide about, and it disappears as
# soon as they do, so whatever it overlaps becomes reachable again.
hit = self.hit_test_proposal(view, x, y)
point = None
if hit is None:
hit, point = self._hit_test(view, x, y)
coord = solver.point_to_coord(self._px_to_km(view, x, y)) coord = solver.point_to_coord(self._px_to_km(view, x, y))
if coord is not None: if coord is None and hit is None:
self.on_right_click(coord, x, y) return
self.on_right_click(coord, x, y, hit, point)
# -- drawing ---------------------------------------------------------------- # -- drawing ----------------------------------------------------------------
def _draw(self, _area, cr, width, height) -> None: def _draw(self, _area, cr, width, height) -> None:
@ -692,6 +798,11 @@ class GridCanvas(Gtk.DrawingArea):
cr.rectangle(MARGIN_LEFT + view.pad_x, MARGIN_TOP + view.pad_y, view.grid_w, view.grid_h) cr.rectangle(MARGIN_LEFT + view.pad_x, MARGIN_TOP + view.pad_y, view.grid_w, view.grid_h)
cr.clip() cr.clip()
# Under everything: the imported screenshot is the backdrop the rest of
# the map is drawn on top of.
if self._screenshot is not None:
self._draw_screenshot(cr, view)
self._draw_hover_subgrid(cr, view) self._draw_hover_subgrid(cr, view)
self._draw_geo_overlays(cr, view) self._draw_geo_overlays(cr, view)
self._draw_firing_arrows(cr, view) self._draw_firing_arrows(cr, view)
@ -720,6 +831,8 @@ class GridCanvas(Gtk.DrawingArea):
selected=is_selected, coord=candidate, selected=is_selected, coord=candidate,
extra_line=getattr(obj, "requested_time", None)) extra_line=getattr(obj, "requested_time", None))
self._draw_proposals(cr, view, width, height)
for sf in self.board.scout_flights: for sf in self.board.scout_flights:
if sf.hidden: if sf.hidden:
continue # hidden means gone from the map, not just darkened, no selection to reinstate it continue # hidden means gone from the map, not just darkened, no selection to reinstate it

View File

@ -49,6 +49,27 @@ _TARGET_ICON_BASENAME = {
} }
def target_type_from_icon(basename: str | None) -> TargetType | None:
"""Inverse of _TARGET_ICON_BASENAME, for the map-vision marker classifier,
which names what it matched by icon file rather than by TargetType.
Not injective: MECHANIZED and TANK share Armor_Mechanized.png, so that one
resolves to MECHANIZED and the user retypes it if it was a Tank (map
right-click -> Change type). Icons with no TargetType at all give None,
which callers treat as UNKNOWN.
"""
if not basename:
return None
name = basename if basename.lower().endswith(".png") else f"{basename}.png"
for prefix in ("Enemy_", "Friendly_"):
if name.startswith(prefix):
name = name[len(prefix):]
for type_, base in _TARGET_ICON_BASENAME.items():
if base == name:
return type_
return None
def target_icon_path(target_type: TargetType, is_ally: bool = False) -> Path | None: def target_icon_path(target_type: TargetType, is_ally: bool = False) -> Path | None:
"""Icon file for a Target or Ally's type, or None if there isn't a """Icon file for a Target or Ally's type, or None if there isn't a
good one. `is_ally` picks the Friendly_ set over the Enemy_ one, good one. `is_ally` picks the Friendly_ set over the Enemy_ one,

181
src/fenigma/map_import.py Normal file
View File

@ -0,0 +1,181 @@
"""State and threading for importing a map screenshot.
Deliberately free of any GTK import so it can be exercised headlessly. The
dialog and the map overlay sit on top of this; everything here is plain
Python and numpy.
Two jobs:
* run the vision pipeline OFF the UI thread. `solve()` takes 10-20s, which
would freeze the window, so it runs in a worker and the result is handed
back through a scheduler callback (GLib.idle_add in the app, called
directly in tests). A thread is sufficient rather than a process: the work
is numpy/OpenCV, which releases the GIL and already multithreads
internally.
* hold the review state. Detections arrive as PROPOSALS, not as board
entries: each is accepted or rejected individually (or all at once), the
unit type can be corrected, and dropping the screenshot discards whatever
was never accepted.
"""
from __future__ import annotations
import threading
from dataclasses import dataclass, field
from . import map_vision
# Distinguishable on_done error: this screenshot is typewriter text, so the
# caller should send it down its normal OCR path rather than report a failure.
NOT_A_MAP = "not a map screenshot"
@dataclass
class Proposal:
"""One detected marker awaiting the user's decision."""
side: str # "hostile" | "friendly"
label: str # e.g. "K8"
sub_x: int
sub_y: int
unit: str | None # game unit name, or None when unsure
centre: tuple # pixel centre in the solved image
box: tuple
accepted: bool = False
rejected: bool = False
@property
def coord(self) -> str:
return f"{self.label} {self.sub_x}:{self.sub_y}"
@property
def pending(self) -> bool:
return not (self.accepted or self.rejected)
@dataclass
class ScreenshotImport:
"""An accepted screenshot plus its proposals, as shown over the map."""
solution: object
image: object
proposals: list = field(default_factory=list)
overlay: object = None # BGRA array in map space
px_per_km: int = 0
def set_proposals(self, markers):
self.proposals = [
Proposal(side=m["side"], label=m["label"], sub_x=m["sub_x"],
sub_y=m["sub_y"], unit=m.get("unit"),
centre=m["centre"], box=m["box"]) for m in markers]
return self.proposals
def build_overlay(self, px_per_km=100):
"""Rectify the screenshot into map space, ready to draw under the grid."""
self.overlay, self.px_per_km = map_vision.warp_to_map(
self.image, self.solution, px_per_km=px_per_km)
return self.overlay
def accept_all(self):
for p in self.proposals:
if p.pending:
p.accepted = True
def reject_all(self):
for p in self.proposals:
if p.pending:
p.rejected = True
def accepted(self):
return [p for p in self.proposals if p.accepted]
def pending(self):
return [p for p in self.proposals if p.pending]
def drop_unaccepted(self):
"""Removing the screenshot discards everything never accepted."""
self.proposals = [p for p in self.proposals if p.accepted]
class ImportJob:
"""Runs the vision pipeline in a worker thread.
`on_done(result, error)` is delivered through `schedule`, which the app
sets to GLib.idle_add so the callback lands on the UI thread. Nothing here
may touch a widget.
"""
def __init__(self, schedule=None):
self.schedule = schedule or (lambda fn, *a: fn(*a))
self._cancelled = threading.Event()
self._thread = None
@property
def cancelled(self) -> bool:
return self._cancelled.is_set()
def cancel(self):
"""Ask the worker to stop. The result is simply dropped -- the vision
code is pure and side-effect free, so abandoning it is safe."""
self._cancelled.set()
def looks_like_map(self, img) -> bool:
"""Cheap synchronous routing test (~0.3s), safe to call inline.
Measured: gates in 9 of 10 map screenshots and 6% of 122 writer
screenshots. Its false positives only cost time, because solve() is
the real decision and accepts none of the 122.
"""
return map_vision.looks_like_map(img)
def start(self, path, on_done, gate=True):
"""Run the pipeline for `path`, delivering on_done(result, error).
With `gate` on, the routing test runs in the worker too and a text
screenshot comes back as error NOT_A_MAP. That keeps the whole
map-or-text decision off the UI thread: the gate is only ~0.3s, but
the caller is on the clipboard path, where a hitch is felt.
Only the GRID is solved here. Marker detection is a separate phase
(find_markers) run after the user has confirmed or corrected the grid,
because every marker position is expressed in grid coordinates: finding
them against a grid that's about to be dragged would only be thrown
away and redone.
"""
def work():
if gate and not map_vision.looks_like_map(map_vision.load(path)):
return None, NOT_A_MAP
sol, img, err = map_vision.solve_path(path)
if sol is None:
return None, err
return ScreenshotImport(solution=sol, image=img), None
return self._run(work, on_done, "map-import")
def find_markers(self, imp, on_done):
"""Second phase: detect units against the now-confirmed grid.
Fills imp.proposals and delivers on_done(imp, error). Its own thread,
because the user's grid correction sits between the two phases.
"""
def work():
imp.set_proposals(map_vision.find_markers(imp.image, imp.solution))
return imp, None
return self._run(work, on_done, "map-markers")
def _run(self, work, on_done, name):
"""Run work() in a thread and marshal its (result, error) back.
work() only computes and returns; delivery and the cancellation check
live here, so no phase can deliver into a UI the user has moved on from.
"""
def guarded():
try:
result, error = work()
except Exception as exc: # worker must never die silently
result, error = None, f"{type(exc).__name__}: {exc}"
if not self._cancelled.is_set():
self.schedule(on_done, result, error)
self._cancelled.clear()
self._thread = threading.Thread(target=guarded, daemon=True, name=name)
self._thread.start()
return self._thread