FEnigma/src/fenigma/grid_fix_dialog.py
Dominik Roth 3c80f93203 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>
2026-08-10 21:41:56 +02:00

279 lines
11 KiB
Python

"""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)