diff --git a/src/fenigma/app.py b/src/fenigma/app.py
index 48fdb76..61cb0a3 100644
--- a/src/fenigma/app.py
+++ b/src/fenigma/app.py
@@ -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
button), the map/grid filling the center. Coordinates can be set by exact
@@ -14,6 +14,8 @@ from __future__ import annotations
import io
import json
+import tempfile
+from pathlib import Path
import gi
@@ -24,14 +26,25 @@ gi.require_version("Gdk", "4.0")
from gi.repository import Adw, Gdk, Gio, GLib, Gtk # 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 .firing_panel import FiringPanel # noqa: E402
+from .grid_fix_dialog import GridFixDialog # 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
-APP_ID = "eu.dominik-roth.FeNigma"
+APP_ID = "eu.dominik-roth.FEnigma"
def _apply_location(obj, location: Location) -> None:
@@ -45,6 +58,21 @@ def _apply_location(obj, location: Location) -> None:
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(
name: str,
*,
@@ -224,12 +252,14 @@ def _install_css() -> None:
class MainWindow(Adw.ApplicationWindow):
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)
_install_css()
self.board = Board()
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.set_content(self.toast_overlay)
@@ -256,10 +286,16 @@ class MainWindow(Adw.ApplicationWindow):
clear_btn.connect("clicked", lambda _b: self._clear_board())
header.pack_start(clear_btn)
- clip_btn = Gtk.Button(icon_name="edit-paste-symbolic")
- clip_btn.set_tooltip_text("Fetch screenshot or text from clipboard (Ctrl+P)")
- clip_btn.connect("clicked", lambda _b: self._fetch_clipboard())
- header.pack_start(clip_btn)
+ self._clip_btn = Gtk.Button(icon_name="edit-paste-symbolic")
+ self._clip_btn.set_tooltip_text("Fetch screenshot or text from clipboard (Ctrl+P)")
+ self._clip_btn.connect("clicked", lambda _b: self._fetch_clipboard())
+ 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.set_tooltip_text(
@@ -296,6 +332,23 @@ class MainWindow(Adw.ApplicationWindow):
scout_btn.connect("clicked", lambda _b: self._add_scout_flight())
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.set_tooltip_text("Firing commands")
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,
)
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_cursor_move = self._on_cursor_move
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.")
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:
- pil_image = Image.open(io.BytesIO(texture.save_to_png_bytes().get_data()))
- info = ocr.run(pil_image)
+ info = ocr.run(Image.open(io.BytesIO(png)))
except Exception as exc: # OCR/parsing hiccups shouldn't crash the app
self.toast(f"OCR failed: {exc}")
return
-
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"{GLib.markup_escape_text(proposal.coord)} — {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:
"""Auto-watch toggle: while on, every clipboard change that looks
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.")
- def _on_map_right_click(self, coord, x: float, y: float) -> None:
- """Right-click anywhere on the 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."""
+ def _popover_at(self, x: float, y: float) -> Gtk.Popover:
+ """A popover anchored to a point on the canvas, self-unparenting."""
popover = Gtk.Popover()
popover.set_parent(self.canvas)
# 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
popover.set_pointing_to(rect)
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"{GLib.markup_escape_text(text)}")
+ 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,
margin_top=6, margin_bottom=6, margin_start=6, margin_end=6)
@@ -1148,7 +1578,7 @@ class MainWindow(Adw.ApplicationWindow):
rebuild()
-class FeNigmaApp(Adw.Application):
+class FEnigmaApp(Adw.Application):
def __init__(self) -> None:
super().__init__(application_id=APP_ID)
@@ -1160,7 +1590,7 @@ class FeNigmaApp(Adw.Application):
def main() -> int:
- app = FeNigmaApp()
+ app = FEnigmaApp()
return app.run(None)
diff --git a/src/fenigma/grid_fix_dialog.py b/src/fenigma/grid_fix_dialog.py
new file mode 100644
index 0000000..1e53b93
--- /dev/null
+++ b/src/fenigma/grid_fix_dialog.py
@@ -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)
diff --git a/src/fenigma/grid_widget.py b/src/fenigma/grid_widget.py
index 7f16654..1549845 100644
--- a/src/fenigma/grid_widget.py
+++ b/src/fenigma/grid_widget.py
@@ -11,6 +11,7 @@ from collections import namedtuple
import cairo
import gi
+import numpy as np
gi.require_version("Gtk", "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
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
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_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_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
+ # 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
# floored, or None off the map/off the widget entirely. Redrawn
# 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:
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):
"""Returns (obj, coord) of the nearest marker within range, or
(None, None), coord disambiguates which candidate of an
@@ -591,6 +684,11 @@ class GridCanvas(Gtk.DrawingArea):
callback(coord)
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)
self.set_selected(hit, coord)
if self.on_select is not None:
@@ -603,9 +701,17 @@ class GridCanvas(Gtk.DrawingArea):
if self.on_right_click is None:
return
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))
- if coord is not None:
- self.on_right_click(coord, x, y)
+ if coord is None and hit is None:
+ return
+ self.on_right_click(coord, x, y, hit, point)
# -- drawing ----------------------------------------------------------------
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.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_geo_overlays(cr, view)
self._draw_firing_arrows(cr, view)
@@ -720,6 +831,8 @@ class GridCanvas(Gtk.DrawingArea):
selected=is_selected, coord=candidate,
extra_line=getattr(obj, "requested_time", None))
+ self._draw_proposals(cr, view, width, height)
+
for sf in self.board.scout_flights:
if sf.hidden:
continue # hidden means gone from the map, not just darkened, no selection to reinstate it
diff --git a/src/fenigma/icons.py b/src/fenigma/icons.py
index 171b53a..2f71d44 100644
--- a/src/fenigma/icons.py
+++ b/src/fenigma/icons.py
@@ -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:
"""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,
diff --git a/src/fenigma/map_import.py b/src/fenigma/map_import.py
new file mode 100644
index 0000000..b023e1d
--- /dev/null
+++ b/src/fenigma/map_import.py
@@ -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