Fix ally marker detection; expand TargetType icons and pickers

map_vision.py: marker shape test only matched diamonds, so friendly
(rectangle) markers could never be detected regardless of color match.
diamonds() now takes a per-side ideal shape (diamond for hostile, full
rectangle for friendly) with fill-ratio bands measured off real markers.

Merged tests/fixtures/map_shots/more/ into the main fixture set: 2
screenshots that solve fine (now 15.png/16.png, with hand-transcribed
ground truth) and 8 that are too low native resolution for the label
reader (same class as the existing 12.png) into too_hard/ as
17.png-24.png, with an explanatory README entry. Updated map_vision.py's
docstring numbers (9/12 solve, 104/131 points correct) to match.

TargetType: expanded from 11 to 44 members to cover every icon in
assets/icons/targets/{enemy,friendly}/, including 7 friendly-only types
(King, Police, General, Hospital, Fort, Civil-Military, Mechanized
Anti-Tank) with no enemy equivalent. UNKNOWN/ENEMY stay icon-less by
design (both are literal words the game's OCR'd text uses, confirmed via
ocr.py's _TYPE_BY_SHORT, so neither can be dropped without breaking real
parsing) and draw the same plain-dot fallback the map itself uses.

icons.py: collapsed the icon lookup into one canonical table
(_TARGET_ICON: TargetType -> (enemy_basename, friendly_basename), one
explicit row per type) instead of a basename table plus two exception
dicts layered on top -- with a startup assertion that every TargetType
has a row. Added build_target_type_grid(), an icon-grid picker (icon +
name, same idea as the existing Shell picker) that replaces the old
plain-text dropdown/list everywhere a type is chosen, and only offers
types the given side actually has real art for.

coord_dialog.py: Add/Edit Target and Add Ally now use the icon grid
instead of Adw.ComboRow. Fixed a resulting horizontal-scroll bug (an
unbreakable long word was blowing out cell width) and locked the
coordinate pickers back to 5 columns.

app.py: right-click quick-add now offers Spotter/RP alongside
Target/Ally/Strike, opens a real modal (not a Popover, which turned out
unreliable for a wide multi-row grid) to ask for a type instead of
silently defaulting to UNKNOWN, and doesn't repeat the coordinate on
every row. The type grid listens for "clicked" rather than "toggled" --
a grouped ToggleButton doesn't emit "toggled" when you click the one
that's already active, which meant confirming the pre-selected default
type silently did nothing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Dominik Moritz Roth 2026-08-10 23:49:46 +02:00
parent 702cbff1b6
commit 136492b197
17 changed files with 651 additions and 118 deletions

View File

@ -917,14 +917,15 @@ class MainWindow(Adw.ApplicationWindow):
return changed
def _open_coord_dialog(
self, *, title, on_submit, show_id=False, show_type=False, id_placeholder=None,
initial_location=None, initial_id=None, initial_type=None,
self, *, title, on_submit, show_id=False, show_type=False, is_ally=False,
id_placeholder=None, initial_location=None, initial_id=None, initial_type=None,
):
dialog = CoordDialog(
title=title,
on_submit=on_submit,
show_id=show_id,
show_type=show_type,
is_ally=is_ally,
id_placeholder=id_placeholder,
initial_location=initial_location,
initial_id=initial_id,
@ -1117,6 +1118,7 @@ class MainWindow(Adw.ApplicationWindow):
on_submit=lambda loc, id_, type_: self._add_target(loc, id_, type_),
show_id=True,
show_type=True,
is_ally=False,
), close))
return box
@ -1186,6 +1188,7 @@ class MainWindow(Adw.ApplicationWindow):
on_submit=lambda loc, id_, type_: self._add_ally(loc, id_, type_),
show_id=True,
show_type=True,
is_ally=True,
), close))
return box
@ -1380,14 +1383,19 @@ class MainWindow(Adw.ApplicationWindow):
def show_type():
box = page()
heading(box, "Type")
# propagate_natural_WIDTH too, not just height: without it a
# ScrolledWindow doesn't grow to fit a wide child (the 5-column
# icon grid), which squeezes the popover down to a
# near-unusable sliver -- same bug the icon grid's own
# docstring and _open_quick_add_menu's identical scroller ran
# into first.
scroller = Gtk.ScrolledWindow(propagate_natural_height=True,
propagate_natural_width=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)
scroller.set_child(icons.build_target_type_grid(
obj.type, lambda t: set_type(t), is_ally=isinstance(obj, Ally),
))
box.append(scroller)
popover.set_child(box)
@ -1477,37 +1485,108 @@ class MainWindow(Adw.ApplicationWindow):
field = self._id_field_of(obj)
return getattr(obj, field) if field else ""
def _open_type_dialog(self, title: str, is_ally: bool, on_pick) -> None:
"""A real top-level modal (Adw.Dialog, same shape as CoordDialog)
showing the TargetType icon grid, for contexts that need "pick a
type" on its own with no coordinate/id fields alongside it.
Deliberately NOT a Gtk.Popover: a transient popover anchored to a
screen point re-derives its own size/position from its content
every time that content is swapped in, and a wide multi-row icon
grid nested inside a ScrolledWindow inside a Popover turned out
unreliable there in practice -- sometimes rendering squeezed down
to a near-unusable sliver, sometimes not registering clicks at
all. A dialog has a fixed, predictable content_width/height and
the exact same grid+scroller construction CoordDialog already
uses successfully for its own Type section, so reusing that shape
here sidesteps the whole class of popover-sizing bug rather than
debugging it further."""
dialog = Adw.Dialog(title=title, content_width=420, content_height=520)
toolbar_view = Adw.ToolbarView()
dialog.set_child(toolbar_view)
toolbar_view.add_top_bar(Adw.HeaderBar())
def pick(t):
dialog.close()
on_pick(t)
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL,
margin_top=16, margin_bottom=16, margin_start=20, margin_end=20)
# selected=None: this is a pick-one-and-close picker, not an editor
# showing a current value, so nothing starts pre-highlighted.
outer.append(icons.build_target_type_grid(None, pick, is_ally=is_ally))
scroller = Gtk.ScrolledWindow(child=outer, hscrollbar_policy=Gtk.PolicyType.NEVER)
toolbar_view.set_content(scroller)
dialog.present(self)
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."""
"""Right-click on empty map: quick-add anything right there, no
coordinate dialog, for when you already know exactly where you're
pointing. Target/Ally still ask for a type (via _open_type_dialog)
rather than silently defaulting to TargetType.UNKNOWN -- picking
the wrong generic type and having to notice and fix it later is
worse than one extra click now.
The coordinate is shown once, as this menu's own heading, not
repeated on every row ("Add target at K5 4:3", "Add ally at K5
4:3", ...) -- it's the same point for every option here (that's
the whole premise of a menu anchored to where you clicked), so
repeating it back on each row is just noise, not information.
"""
if coord is None:
return
popover = self._popover_at(x, y)
location = Location.from_coord(coord)
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)
heading = Gtk.Label(xalign=0, margin_start=4, margin_bottom=2)
heading.set_markup(f"<b>{GLib.markup_escape_text(coord.label())}</b>")
box.append(heading)
def add_target(_b):
self.board.add_target(TargetType.UNKNOWN, coord)
def row(label, handler):
btn = Gtk.Button(label=label)
btn.add_css_class("flat")
btn.connect("clicked", lambda _b: handler())
box.append(btn)
def pick_target_type(t):
self.board.add_target(t, location)
self._refresh()
def pick_ally_type(t):
self.board.add_ally(t, location)
self._refresh()
def add_target():
popover.popdown()
self._open_type_dialog("Add target", is_ally=False, on_pick=pick_target_type)
def add_ally():
popover.popdown()
self._open_type_dialog("Add ally", is_ally=True, on_pick=pick_ally_type)
def add_spotter():
self.board.add_spotter(location)
self._refresh()
popover.popdown()
def add_strike(_b):
def add_rp():
self.board.add_reference_point(location)
self._refresh()
popover.popdown()
def add_strike():
target = self.board.add_target(TargetType.STRIKE, coord)
self.board.reorder_target(target, 0) # new strikes go to the front of the list
self._refresh()
popover.popdown()
target_btn = Gtk.Button(label=f"Add target at {coord.label()}")
target_btn.add_css_class("flat")
target_btn.connect("clicked", add_target)
box.append(target_btn)
strike_btn = Gtk.Button(label=f"Add strike at {coord.label()}")
strike_btn.add_css_class("flat")
strike_btn.connect("clicked", add_strike)
box.append(strike_btn)
row("Add target", add_target)
row("Add ally", add_ally)
row("Add spotter", add_spotter)
row("Add reference point", add_rp)
row("Add strike", add_strike)
popover.set_child(box)
popover.popup()
@ -1555,6 +1634,7 @@ class MainWindow(Adw.ApplicationWindow):
initial_location=target.location,
show_id=True,
show_type=True,
is_ally=False,
id_placeholder=f"ID (current: {target.id})",
initial_id=target.id,
initial_type=target.type,

View File

@ -23,7 +23,7 @@ gi.require_version("Adw", "1")
gi.require_version("Gdk", "4.0")
from gi.repository import Adw, Gdk, Gtk # noqa: E402
from . import ocr
from . import icons, ocr
from .models import LARGE_X, Coord, Location, TargetType
@ -37,6 +37,7 @@ class CoordDialog(Adw.Dialog):
on_submit: Callable[[Location, str | None, TargetType | None], None],
show_id: bool = False,
show_type: bool = False,
is_ally: bool = False,
id_placeholder: str | None = None,
initial_location: Location | None = None,
initial_id: str | None = None,
@ -46,10 +47,12 @@ class CoordDialog(Adw.Dialog):
self._on_submit = on_submit
self._show_id = show_id
self._show_type = show_type
self._is_ally = is_ally
self._id_placeholder = id_placeholder or "ID (blank = auto)"
self._initial_location = initial_location or Location()
self._initial_id = initial_id
self._initial_type = initial_type
self._type_val = initial_type or TargetType.UNKNOWN
toolbar_view = Adw.ToolbarView()
self.set_child(toolbar_view)
@ -91,7 +94,14 @@ class CoordDialog(Adw.Dialog):
flow.set_homogeneous(True)
flow.set_row_spacing(4)
flow.set_column_spacing(4)
flow.set_max_children_per_line(10)
# Fixed at 5, not a min/max range: the dialog's width now varies
# with whatever else is in it (e.g. the Type grid, see
# build_target_type_grid), and a FlowBox reflows to fit whatever
# width it's given -- letting it range up to 10 made X (A-T) jump
# to 7-wide rows whenever the dialog happened to be wider, which
# read as broken rather than deliberate. 5 was the one that looked
# right at the dialog's normal size.
flow.set_max_children_per_line(5)
flow.set_min_children_per_line(5)
buttons = []
@ -130,8 +140,8 @@ class CoordDialog(Adw.Dialog):
spacing=16,
margin_top=16,
margin_bottom=16,
margin_start=16,
margin_end=16,
margin_start=20,
margin_end=20,
)
initial = self._initial_location.coord
@ -175,24 +185,29 @@ class CoordDialog(Adw.Dialog):
self._stage_widgets = [Y_group, x_group, y_group, None]
self.row_id = None
self.row_type = None
if self._show_id or self._show_type:
extra_group = Adw.PreferencesGroup(title="Identity")
identity_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16)
if self._show_id:
extra_group = Adw.PreferencesGroup(title="Identity")
self.row_id = Adw.EntryRow(title=self._id_placeholder)
if self._initial_id is not None:
self.row_id.set_text(str(self._initial_id))
extra_group.add(self.row_id)
identity_box.append(extra_group)
if self._show_type:
self.row_type = Adw.ComboRow(
title="Type",
model=Gtk.StringList.new([t.short for t in TargetType]),
)
if self._initial_type is not None:
self.row_type.set_selected(list(TargetType).index(self._initial_type))
extra_group.add(self.row_type)
outer.append(extra_group)
self._stage_widgets[3] = extra_group
# An icon grid (same idea as the Shell picker), not a plain
# text dropdown -- with ~35 types now, seeing the actual
# marker art is the difference between recognizing the
# right one and reading a wall of similar-sounding names.
identity_box.append(self._picker_group(
"Type",
icons.build_target_type_grid(
self._type_val, lambda t: setattr(self, "_type_val", t),
is_ally=self._is_ally,
),
))
outer.append(identity_box)
self._stage_widgets[3] = identity_box
submit = Gtk.Button(label="Set coordinates")
submit.add_css_class("suggested-action")
@ -201,7 +216,9 @@ class CoordDialog(Adw.Dialog):
submit.connect("clicked", self._on_submit_clicked)
outer.append(submit)
self._exact_scroller = Gtk.ScrolledWindow(child=outer)
self._exact_scroller = Gtk.ScrolledWindow(
child=outer, hscrollbar_policy=Gtk.PolicyType.NEVER
)
self._exact_content = outer
return self._exact_scroller
@ -262,7 +279,7 @@ class CoordDialog(Adw.Dialog):
def _id_and_type(self) -> tuple[str | None, TargetType | None]:
id_ = self.row_id.get_text().strip() or None if self.row_id is not None else None
type_ = list(TargetType)[self.row_type.get_selected()] if self.row_type is not None else None
type_ = self._type_val if self._show_type else None
return id_, type_
def _on_submit_clicked(self, _button: Gtk.Button) -> None:

View File

@ -15,7 +15,9 @@ import gi
gi.require_version("Gdk", "4.0")
gi.require_version("GdkPixbuf", "2.0")
gi.require_version("Gtk", "4.0")
from gi.repository import Gdk, GdkPixbuf, Gtk # noqa: E402
from gi.repository import Gdk, GdkPixbuf, Gtk, Pango # noqa: E402
import cairo
from .models import TargetType
from .shells import Shell
@ -28,30 +30,103 @@ _css_loaded = False
NEST_ICON_PATH = _ICONS_DIR / "nest" / "IronNest.png"
STRIKE_ICON_PATH = _ICONS_DIR / "misc" / "Crosshair.png"
# TargetType -> the shared basename suffix, after 'Enemy_'/'Friendly_',
# for whichever of the game's own unit icons fits best. Best-effort
# guesses (flagged the same way the Shell descriptions were): the game
# doesn't have a dedicated icon for every one of our types, TANK reuses
# the Armor_Mechanized artwork, and FDC/PILLBOX/ENEMY only exist on the
# enemy side at all (see target_icon_path()'s fallback). No entry means
# no icon exists worth drawing, the caller falls back to a plain dot
# (only UNKNOWN now, STRIKE has its own crosshair below).
_TARGET_ICON_BASENAME = {
TargetType.SUPPLY_CACHE: "Ammunition Cache.png",
TargetType.FDC: "Fire Direction Center.png",
TargetType.INFANTRY: "Infantry.png",
TargetType.MECHANIZED: "Armor_Mechanized.png",
TargetType.ARTILLERY: "Field Artillery.png",
TargetType.TANK: "Armor_Mechanized.png",
TargetType.PILLBOX: "Heavy_Gun_Bunker.png",
TargetType.MARINE_GARRISON: "Marine.png",
TargetType.ENEMY: "Base.png",
# TargetType -> (enemy_basename, friendly_basename), after 'Enemy_'/
# 'Friendly_'. Either half is None where the game draws no icon for that
# type on that side at all -- that's not rare enough on either side to
# treat as an exception list bolted onto a shared-name table (the earlier
# shape of this code: one basename table plus two separate patch dicts for
# "actually the friendly filename differs" and "actually this side has
# none at all", which was easy to update inconsistently and silently do
# the wrong thing for one side). One explicit pair per type, covering
# EVERY TargetType, is the actual shape of the data: a basename shared by
# both sides, a basename that differs (a genuine filename mismatch in the
# source assets, not a difference in what's drawn, see assets/icons/
# README.md), or a basename that exists on only one side.
#
# UNKNOWN/ENEMY are deliberately (None, None): generic/ad-hoc, not a unit
# the game draws specific art for (see their comments on TargetType).
# STRIKE isn't in this table at all: its crosshair isn't an Enemy_/
# Friendly_ file, it's handled as a special case in target_icon_path().
# completeness of this table (every TargetType except STRIKE has a row) is
# asserted below, not just hoped for.
_TARGET_ICON = {
TargetType.UNKNOWN: (None, None),
TargetType.ENEMY: (None, None),
TargetType.ANTI_AIR: ("AA.png", "AA.png"),
TargetType.ANTI_TANK: ("AntiTank.png", "AntiTank.png"),
TargetType.ARTILLERY: ("Field Artillery.png", "Field Artillery.png"),
TargetType.ARTILLERY_OBSERVER: ("Field Artillery Observer.png", "Field Artillery Observer.png"),
TargetType.HEAVY_GUN_TURRET: ("Heavy_Gun_Turret.png", None),
TargetType.INFANTRY: ("Infantry.png", "Infantry.png"),
TargetType.INFANTRY_MECHANIZED: ("Infantry_mechanized.png", "Infantry_Mechanized.png"), # case differs
TargetType.MECH_ANTI_TANK: (None, "Mech_AntiTank.png"), # friendly-only
TargetType.MECHANIZED: ("Armor_Mechanized.png", "Armor_Mechanized.png"),
TargetType.PILLBOX: ("Heavy_Gun_Bunker.png", None),
TargetType.TANK: ("Armor_Mechanized.png", "Armor_Mechanized.png"), # shares MECHANIZED's art, see TargetType
TargetType.BASE: ("Base.png", "Military Base.png"), # name differs
TargetType.COMMANDER: ("Commander.png", "Commander.png"),
TargetType.FDC: ("Fire Direction Center.png", None),
TargetType.FORT: (None, "Fort.png"), # friendly-only
TargetType.GENERAL: (None, "General.png"), # friendly-only
TargetType.KING: (None, "King.png"), # friendly-only
TargetType.MARINE_GARRISON: ("Marine.png", "Marine.png"),
TargetType.POLICE: (None, "Police.png"), # friendly-only
TargetType.SUPPLY_CACHE: ("Ammunition Cache.png", "Ammunition Cache.png"),
TargetType.UNDERGROUND_FORT: ("Underground Fort.png", None),
TargetType.EMERGENCY_MEDICAL: ("Emergency Medical Operation.png", "Emergency Medical Operation.png"),
TargetType.HOSPITAL: (None, "Hospital.png"), # friendly-only
TargetType.MEDICAL: ("Medical.png", "Medical.png"),
TargetType.MEDICAL_FACILITY: ("Medical Treatment Facility.png", "Medical Treatment Facility.png"),
TargetType.CIVIL_MILITARY: (None, "CivilMilitary.png"), # friendly-only
TargetType.CIVILIAN: ("Civ.png", "Civilian.png"), # name differs
TargetType.CIVIL_RIOTING: ("Civil Rioting.png", "Civil Rioting.png"),
TargetType.RIOTING: ("Rioting.png", None),
TargetType.TV_RADIO_PROPAGANDA: ("TV and Radio Propaganda.png", "TV and Radio Propaganda.png"),
TargetType.PORT: ("Port.png", "Port.png"),
TargetType.SHIP: ("Ship.png", None),
TargetType.SHIP_ENGINE: ("Ship_Engine.png", None),
TargetType.SHIP_FDC: ("Ship_FDC.png", None),
TargetType.SHIP_STRIPE: ("Ship_Stripe.png", "Ship_stripe.png"), # case differs
TargetType.SHIP_TURRET: ("Ship_Turret.png", None),
TargetType.TRAIN_LOCOMOTIVE: ("Train_Locomotive.png", None),
TargetType.TRAIN_STATION: ("Train_Station.png", "Train_Station.png"),
TargetType.TRAIN_TRANSPORT: ("Train_Transport.png", None),
TargetType.RECON: ("Recon.png", "Reconnaissance.png"), # name differs
TargetType.RECON_LISTENING: ("Recon_Listening.png", "Recon_Listening.png"),
}
assert {*_TARGET_ICON} | {TargetType.STRIKE} == {*TargetType}, (
"every TargetType needs a row in _TARGET_ICON (STRIKE is the one "
"deliberate exception, see the comment above it)"
)
def _icon_for_side(target_type: TargetType, is_ally: bool) -> Path | None:
"""This SIDE's own icon for target_type specifically, with no
cross-side fallback -- used both by target_icon_path() (which adds
the fallback back on top) and by _has_own_icon() (which needs to know
whether this side has real art of its own, not whether *some* art is
available after falling back)."""
entry = _TARGET_ICON.get(target_type)
if entry is None:
return None
basename = entry[1 if is_ally else 0]
if basename is None:
return None
folder, prefix = ("friendly", "Friendly_") if is_ally else ("enemy", "Enemy_")
path = _ICONS_DIR / "targets" / folder / f"{prefix}{basename}"
return path if path.exists() else None
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.
"""Inverse of _TARGET_ICON, 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
@ -64,31 +139,26 @@ def target_type_from_icon(basename: str | None) -> TargetType | None:
for prefix in ("Enemy_", "Friendly_"):
if name.startswith(prefix):
name = name[len(prefix):]
for type_, base in _TARGET_ICON_BASENAME.items():
if base == name:
for type_, (enemy_basename, friendly_basename) in _TARGET_ICON.items():
if name in (enemy_basename, friendly_basename):
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,
falling back to Enemy_ if that particular basename has no friendly
version (the two sets aren't the same size, see assets/icons/
README.md). STRIKE (a planned impact point, not a unit) gets its
own crosshair rather than a unit icon, it doesn't fit the Enemy_/
Friendly_ naming scheme at all."""
good one. `is_ally` picks the friendly side of _TARGET_ICON over the
enemy one, falling back to the enemy icon if this particular type has
no friendly art of its own at all (the two sets aren't the same size,
see assets/icons/README.md). STRIKE (a planned impact point, not a
unit) gets its own crosshair rather than a unit icon, it doesn't fit
the Enemy_/Friendly_ naming scheme at all."""
if target_type is TargetType.STRIKE:
return STRIKE_ICON_PATH
basename = _TARGET_ICON_BASENAME.get(target_type)
if basename is None:
return None
if is_ally:
friendly = _ICONS_DIR / "targets" / "friendly" / f"Friendly_{basename}"
if friendly.exists():
return friendly
enemy = _ICONS_DIR / "targets" / "enemy" / f"Enemy_{basename}"
return enemy if enemy.exists() else None
own = _icon_for_side(target_type, is_ally)
if own is not None:
return own
return _icon_for_side(target_type, is_ally=False) if is_ally else None
def _ensure_icon_button_css() -> None:
@ -200,10 +270,10 @@ def _shell_cell(s: Shell) -> Gtk.Widget:
return cell
def _build_shell_grid(make_button) -> Gtk.Widget:
"""Shared grid layout: rows of up to _GRID_COLUMNS buttons, one per
Shell, each built by `make_button(shell) -> Gtk.Widget`. Used by
both the popover picker and the inline radio-style grid below.
def _build_icon_grid(items, columns, make_button) -> Gtk.Widget:
"""Shared grid layout: rows of up to `columns` buttons, one per item in
`items`, each built by `make_button(item) -> Gtk.Widget`. Used by every
icon-grid picker in this module (shells, target types).
A plain nested Gtk.Box grid, not a Gtk.FlowBox, on purpose, after
two FlowBox attempts both broke in different ways: a ScrolledWindow
@ -213,24 +283,28 @@ def _build_shell_grid(make_button) -> Gtk.Widget:
no fixed allocation yet) turned out to mean 'fit every child on one
line', ignoring max_children_per_line entirely, so min/max-content-
width on a ScrolledWindow around it never actually took effect
(verified directly: it kept ballooning out to fit every shell in a
single row regardless of what those properties were set to). Shell
is a small, fixed, known set, there's no real need for FlowBox's
(verified directly: it kept ballooning out to fit every item in a
single row regardless of what those properties were set to). Each of
these sets is small and fixed, there's no real need for FlowBox's
dynamic reflow-to-fewer-columns behavior here, a manual grid of
fixed-size rows has a fully deterministic natural width (columns *
cell width, nothing else involved) and sidesteps the whole class of
bug."""
shells = list(Shell)
items = list(items)
grid = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4,
margin_top=8, margin_bottom=8, margin_start=8, margin_end=8)
for start in range(0, len(shells), _GRID_COLUMNS):
for start in range(0, len(items), columns):
row_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4, homogeneous=True)
for s in shells[start:start + _GRID_COLUMNS]:
row_box.append(make_button(s))
for it in items[start:start + columns]:
row_box.append(make_button(it))
grid.append(row_box)
return grid
def _build_shell_grid(make_button) -> Gtk.Widget:
return _build_icon_grid(Shell, _GRID_COLUMNS, make_button)
def build_shell_popover(on_pick) -> Gtk.Popover:
"""Popover with a grid of every Shell (icon + blast radius under it,
full description as a tooltip), replacing a plain text dropdown/list
@ -321,3 +395,166 @@ def build_shell_button(selected: Shell, on_pick, *, show_label: bool = True, ico
render(selected)
btn.set_popover(build_shell_popover(handle_pick))
return btn
# ---- TargetType icon-grid picker, same idea as the Shell picker above ----
_TYPE_GRID_ICON_WIDTH = 40 # smaller than the shell grid's: ~35 types vs 9 shells,
_TYPE_GRID_COLUMNS = 5 # needs to fit a lot more cells in the same dialog width
# Mirrors grid_widget.py's CATEGORY_COLOR["target"]/["ally"] (the colors the
# map itself draws the plain-dot fallback in). Duplicated rather than
# imported: grid_widget.py already imports this module for icon lookups, an
# import the other way would be circular. Unlike that module's palette,
# these two are not theme-swapped live -- the picker is a modal dialog, not
# the persistent map, redrawing it on a theme change isn't worth the wiring.
_DOT_COLOR = {False: (0.92, 0.30, 0.28), True: (0.30, 0.85, 0.85)}
def _plain_dot(is_ally: bool, width: int) -> Gtk.Widget:
"""The same 'plain dot' fallback the map itself draws for a type with
no dedicated icon (see grid_widget.py's _icon_for), so a type with no
icon reads as 'this type has no special marker' rather than as a
rendering gap in the picker."""
area = Gtk.DrawingArea()
area.set_content_width(width)
area.set_content_height(width)
def draw(_area, cr, w, h):
cr.set_source_rgb(*_DOT_COLOR[is_ally])
cr.arc(w / 2, h / 2, min(w, h) * 0.32, 0, 2 * 3.141592653589793)
cr.fill()
area.set_draw_func(draw)
return area
def target_type_icon_image(target_type: "TargetType", is_ally: bool = False, width: int = _TYPE_GRID_ICON_WIDTH) -> Gtk.Widget:
"""A widget showing target_type's icon (friendly or enemy art per
`is_ally`), scaled to `width` px wide. Falls back to the same plain dot
the map itself draws for the types with no dedicated icon (UNKNOWN,
ENEMY -- see icons.py's _TARGET_ICON_BASENAME comment; STRIKE always
has its crosshair), so every cell in the grid stays the same size
whether or not it has real art."""
path = target_icon_path(target_type, is_ally=is_ally)
if path is not None and path.exists():
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(str(path), width, -1, True)
picture = Gtk.Picture.new_for_pixbuf(pixbuf)
picture.set_content_fit(Gtk.ContentFit.CONTAIN)
picture.set_can_shrink(True)
picture.set_size_request(pixbuf.get_width(), pixbuf.get_height())
return picture
return _plain_dot(is_ally, width)
def _has_own_icon(t: "TargetType", is_ally: bool) -> bool:
"""Whether THIS side specifically has real art for t -- as opposed to
target_icon_path() quietly handing back the other side's icon because
this side has none of its own (see its own docstring). Used to keep
that cross-side fallback out of the picker grids entirely: showing a
red diamond as an option for 'Add ally', or offering 'King'/'Police'/
etc. (friendly-only, see _TARGET_ICON) as an enemy type, reads as a
real option of the wrong side rather than a missing-icon placeholder.
UNKNOWN and ENEMY are the exception: deliberately generic/icon-less on
BOTH sides (see their comments on TargetType), always offered
regardless."""
if t in (TargetType.UNKNOWN, TargetType.ENEMY):
return True
return _icon_for_side(t, is_ally) is not None
def available_target_types(is_ally: bool = False):
"""TargetType members worth offering in a picker for this side.
STRIKE is never offered: it's not a unit type at all (a planned
impact point, not a contact), it's always created through its own
dedicated "Add strike" action (see app.py's _open_quick_add_menu),
never by picking a type from this generic grid -- there's no such
thing as a Strike-typed Ally either, offering it there is just
confusing, not merely unlikely.
Otherwise: each side only offers types it actually has its own art
for (see _has_own_icon / _TARGET_ICON) -- some types are enemy-only
and some are friendly-only (King, Police, a friendly hospital, ...),
the game simply doesn't draw an installation of every kind on both
sides."""
return [t for t in TargetType if t is not TargetType.STRIKE and _has_own_icon(t, is_ally)]
def _target_type_label(t: "TargetType", is_ally: bool) -> str:
"""Display text for a picker cell/tooltip. TargetType.ENEMY's own
value is literally 'Enemy' (it's the word the game's OCR'd text uses
for an ad-hoc *hostile* installation, see TargetType's own comment) --
exactly right in the enemy picker, but confusing in the Ally one,
where the very same generic/ad-hoc-named-unit case reads as 'Enemy'
is somehow a kind of Ally. Cosmetic only: the underlying TargetType
stored on the entity is still ENEMY either way, only the label shown
while picking it changes."""
if is_ally and t is TargetType.ENEMY:
return "Ally"
return t.value
def _target_type_cell(t: "TargetType", is_ally: bool) -> Gtk.Widget:
"""Icon + name, both a FIXED size regardless of how long the name is --
a real cell size that varies with its label text (three-line names next
to one-line ones) makes every row in the grid a different height, which
reads as broken/uneven rather than a grid. One line, ellipsized, with
the full name in the button's tooltip (see build_target_type_grid)
covers the names a single line can't fit."""
cell = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2,
margin_top=4, margin_bottom=4, margin_start=2, margin_end=2)
cell.append(target_type_icon_image(t, is_ally=is_ally))
name_label = Gtk.Label(label=_target_type_label(t, is_ally), wrap=False, single_line_mode=True,
justify=Gtk.Justification.CENTER, width_chars=9,
max_width_chars=9, ellipsize=Pango.EllipsizeMode.END)
name_label.add_css_class("caption")
name_label.add_css_class("dim-label")
cell.append(name_label)
return cell
def build_target_type_grid(selected: "TargetType | None", on_pick, *, is_ally: bool = False) -> Gtk.Widget:
"""Inline radio-style grid of every available TargetType (icon + name
below, same idea as build_shell_grid), replacing the old plain-text
dropdown/list. `is_ally` both picks the friendly icon set over the
enemy one and restricts the offered types to ones with real friendly
art (see available_target_types). Exactly one cell is ever
highlighted (`selected`, or none if `selected` is None or not offered
on this side).
`on_pick(target_type)` fires on every click, including a click on the
already-selected cell -- deliberately listening for "clicked", not
"toggled": a ToggleButton in a radio group doesn't emit "toggled" when
you click the one that's already active (nothing about its state
changed), which meant clicking the pre-selected default -- usually
exactly the type someone wants, e.g. plain "Target" -- silently did
nothing. "clicked" fires every time regardless, so confirming the
default now works the same as picking anything else.
See _target_type_cell for why the name label is single-line and
ellipsized rather than wrapped: an unbounded label size, besides
making uneven-height rows, could also (being inside a homogeneous
row) stretch every cell in that row wide enough to force the whole
dialog into horizontal scrolling -- coord_dialog.py's ScrolledWindow
has hscrollbar_policy=NEVER as a backstop against that same failure."""
_ensure_icon_button_css()
leader: Gtk.ToggleButton | None = None
def make_button(t: "TargetType") -> Gtk.Widget:
nonlocal leader
btn = Gtk.ToggleButton(child=_target_type_cell(t, is_ally))
btn.add_css_class("flat")
btn.add_css_class(_ICON_BUTTON_CSS_CLASS)
btn.set_tooltip_text(_target_type_label(t, is_ally))
if leader is None:
leader = btn
else:
btn.set_group(leader)
if t is selected:
btn.set_active(True)
btn.connect("clicked", lambda _b, t=t: on_pick(t))
return btn
return _build_icon_grid(available_target_types(is_ally), _TYPE_GRID_COLUMNS, make_button)

View File

@ -29,12 +29,15 @@ Shape of the solution:
therefore selects scale, axis assignment, direction, phase and anchor
together.
Measured on the 10 fixtures in tests/fixtures/map_shots: solves 7 of them,
Measured on the 12 fixtures in tests/fixtures/map_shots: solves 9 of them,
with 100% of each solved shot's annotated points landing in the correct cell
(85 of 112 overall) and a residual spread of 0.005-0.033 cells. The other
(104 of 131 overall) and a residual spread of 0.005-0.033 cells. The other
three are rejected rather than guessed at, and no fixture has ever produced
a plausible-but-wrong grid. Rejection is a supported outcome -- a silently
misplaced target is far worse than a refusal.
misplaced target is far worse than a refusal. A further 11 screenshots sit
in tests/fixtures/map_shots/too_hard/, kept for the record but excluded from
the evaluation set: see its README.md for why each one is a legitimate
rejection rather than a bug.
Over the 122 typewriter screenshots this was checked against, solve()
accepted none, which is what makes it safe to route clipboard images through
@ -744,12 +747,29 @@ def solve_path(path):
_ICON_BANK: dict = {}
ICON_SIZE = 64
DIAMOND_IOU = 0.64 # blob-vs-ideal-diamond overlap needed to be a marker.
MARKER_IOU = 0.64 # blob-vs-ideal-shape overlap needed to be a marker.
# Swept against verified counts: 0.64 keeps every shot
# confirmed correct by hand (5/2/2/3 markers) while cutting
# ribbon+hatching false positives from 43 to 2 on the worst
# fixture. Loosening to 0.50 regains one real marker on one
# shot but quadruples the false positives.
#
# The ideal shape depends on SIDE: hostile markers are
# diamonds, friendly ones are rectangles (see
# `_ideal_shape`) -- the game draws the two factions with
# different marker geometry, not just different colours.
# Fitting only the diamond shape used to mean no cyan
# rectangle could ever pass this gate, however good its
# colour match.
FILL_RANGE = { # min/max of (blob area / bbox area); the diamond and
# rectangle marker families genuinely differ here (a diamond covers half
# its bounding box, a solid rectangle covers nearly all of it), so one
# shared band would either admit hatching as "diamonds" or reject real
# rectangles as "not filled enough". Measured off real markers: diamonds
# 0.30-0.85, rectangles (minus their border and X-crossing) 0.75-0.97.
"diamond": (0.30, 0.85),
"rect": (0.55, 0.97),
}
SYMBOL_KEEP = 0.52 # central fraction of the marker that carries the symbol
@ -852,16 +872,32 @@ def marker_masks(img):
for m in (hostile, friendly)]
def diamonds(mask, cell_px):
def _ideal_mask(shape, w, h):
"""The filled-in silhouette a marker of this SHAPE should have, inscribed
in a w x h bounding box: a diamond for hostile markers, the full box
itself for friendly ones (they are drawn as solid rectangles, so their
own bounding box IS their ideal silhouette)."""
if shape == "rect":
return np.ones((h, w), bool)
ideal = np.zeros((h, w), np.uint8)
cv2.fillConvexPoly(ideal, np.array(
[[w // 2, 0], [w - 1, h // 2], [w // 2, h - 1], [0, h // 2]], np.int32), 1)
return ideal.astype(bool)
def diamonds(mask, cell_px, shape="diamond"):
"""Marker-sized, marker-shaped blobs.
The markers scale with the map, so a solved grid tells us how big one
must be (~0.14 of a cell). A diamond also fills about half its bounding
box, which rejects the long thin territory hatching and front-line
ribbons that share the markers' colours.
must be (~0.14 of a cell). SHAPE selects which silhouette a blob must
match -- "diamond" for hostile markers, "rect" for friendly ones, which
the game draws as solid rectangles rather than diamonds. Matching the
shape (not just the size and colour) rejects the long thin territory
hatching and front-line ribbons that share the markers' colours.
"""
want = 0.14 * cell_px
lo, hi = 0.55 * want, 1.9 * want
fill_lo, fill_hi = FILL_RANGE[shape]
n, lab, stats, cent = cv2.connectedComponentsWithStats(mask, 8)
out = []
for i in range(1, n):
@ -870,33 +906,33 @@ def diamonds(mask, cell_px):
continue
if not (0.55 <= w / h <= 1.8):
continue
if not (0.30 <= a / float(w * h) <= 0.85):
if not (fill_lo <= a / float(w * h) <= fill_hi):
continue
# Actually test for a DIAMOND. A bounding-box fill ratio near 0.5 is
# not enough: a chunk of the territory hatching or of a front-line
# Actually test the SHAPE. A bounding-box fill ratio alone is not
# enough: a chunk of the territory hatching or of a front-line
# ribbon hits the same ratio and the same colour, which is where the
# tens of spurious markers came from. Compare the blob against an
# ideal diamond inscribed in its own bounding box.
# tens of spurious markers came from. Compare the blob against the
# ideal silhouette inscribed in its own bounding box.
blob = (lab[y:y + h, x:x + w] == i)
ideal = np.zeros((h, w), np.uint8)
cv2.fillConvexPoly(ideal, np.array(
[[w // 2, 0], [w - 1, h // 2], [w // 2, h - 1], [0, h // 2]], np.int32), 1)
ideal = ideal.astype(bool)
ideal = _ideal_mask(shape, w, h)
union = int(np.logical_or(blob, ideal).sum())
if union == 0:
continue
if int(np.logical_and(blob, ideal).sum()) / union < DIAMOND_IOU:
if int(np.logical_and(blob, ideal).sum()) / union < MARKER_IOU:
continue
out.append((float(cent[i][0]), float(cent[i][1]), (int(x), int(y), int(w), int(h))))
return out
MARKER_SHAPE = {"hostile": "diamond", "friendly": "rect"}
def find_markers(img, sol):
"""-> list of dicts: side, unit, label, sub_x, sub_y, coord, centre, box."""
cell = max(sol.steps)
found = []
for side, mask in zip(("hostile", "friendly"), marker_masks(img)):
for (cx, cy, box) in diamonds(mask, cell):
for (cx, cy, box) in diamonds(mask, cell, MARKER_SHAPE[side]):
c = sol.cell_of(cx, cy)
if c is None:
continue

View File

@ -44,21 +44,76 @@ class TargetType(Enum):
collection from Target (see Board.allies), not this same type with
a flag flipped, targets and allies don't share an id-namespace or a
firing-relevant shape (no shell/powder_charges/assignment)."""
# Declaration order is also picker order (icons.build_target_type_grid
# and the old plain-text dropdown both just iterate TargetType), so
# it's grouped by category, most-reached-for category first, and
# alphabetical by value within a category -- not the order these were
# added to the codebase.
# -- Generic / non-unit -------------------------------------------
UNKNOWN = "Target" # generic contact, spotted but not yet identified; default choice
SUPPLY_CACHE = "Supply Cache"
FDC = "FDC" # Fire Direction Center, coordinates counter-battery fire
INFANTRY = "Infantry" # ground troops
MECHANIZED = "Mechanized" # armored/vehicle unit
ARTILLERY = "Artillery" # "Coastal Battery" is just this, see _TYPE_WORD_ALIASES in ocr.py
TANK = "Tank"
PILLBOX = "Pillbox" # armoured emplacement, fixed position
MARINE_GARRISON = "Marine Garrison" # requests fire support (see Target.requested_time)
ENEMY = "Enemy" # ad-hoc installation named directly in the intel text
# ("Enemy Signal Station", "Enemy Field Command"), not one of the
# game's fixed unit types, its id is the rest of that name with
# spaces stripped, see ocr.py's squash_enemy_names()
STRIKE = "Strike" # a planned impact point, not an enemy contact
# -- Ground combat units -------------------------------------------
ANTI_AIR = "Anti-Air"
ANTI_TANK = "Anti-Tank"
ARTILLERY = "Artillery" # "Coastal Battery" is just this, see _TYPE_WORD_ALIASES in ocr.py
ARTILLERY_OBSERVER = "Field Artillery Observer"
HEAVY_GUN_TURRET = "Heavy Gun Turret"
INFANTRY = "Infantry" # ground troops
INFANTRY_MECHANIZED = "Mechanized Infantry"
MECH_ANTI_TANK = "Mechanized Anti-Tank" # friendly-only, no Enemy_ art (icons.py's
# _FRIENDLY_ONLY_BASENAME), unlike ANTI_TANK which both sides draw
MECHANIZED = "Mechanized" # armored/vehicle unit
PILLBOX = "Pillbox" # armoured emplacement, fixed position
TANK = "Tank"
# -- Command & installations ----------------------------------------
BASE = "Base"
COMMANDER = "Commander"
FDC = "FDC" # Fire Direction Center, coordinates counter-battery fire
FORT = "Fort" # friendly-only; UNDERGROUND_FORT is the enemy-side equivalent concept
GENERAL = "General" # friendly-only, no Enemy_ art
KING = "King" # friendly-only, no Enemy_ art
MARINE_GARRISON = "Marine Garrison" # requests fire support (see Target.requested_time)
POLICE = "Police" # friendly-only, no Enemy_ art
SUPPLY_CACHE = "Supply Cache"
UNDERGROUND_FORT = "Underground Fort"
# -- Medical ----------------------------------------------------------
EMERGENCY_MEDICAL = "Emergency Medical Operation"
HOSPITAL = "Hospital" # friendly-only; MEDICAL_FACILITY is the enemy-side equivalent concept
MEDICAL = "Medical"
MEDICAL_FACILITY = "Medical Treatment Facility"
# -- Civil --------------------------------------------------------------
CIVIL_MILITARY = "CivilMilitary" # friendly-only, no Enemy_ art
CIVILIAN = "Civilian"
CIVIL_RIOTING = "Civil Rioting"
RIOTING = "Rioting"
TV_RADIO_PROPAGANDA = "TV and Radio Propaganda"
# -- Naval ------------------------------------------------------------
PORT = "Port"
SHIP = "Ship"
SHIP_ENGINE = "Ship Engine"
SHIP_FDC = "Ship FDC"
SHIP_STRIPE = "Ship (Stripe)"
SHIP_TURRET = "Ship Turret"
# -- Rail -----------------------------------------------------------
TRAIN_LOCOMOTIVE = "Train Locomotive"
TRAIN_STATION = "Train Station"
TRAIN_TRANSPORT = "Train Transport"
# -- Reconnaissance ---------------------------------------------------
RECON = "Recon"
RECON_LISTENING = "Recon (Listening)"
@property
def short(self) -> str:
"""Compact form used in item names, e.g. 'SupplyCache' / 'FDC'."""

BIN
tests/fixtures/map_shots/15.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
tests/fixtures/map_shots/16.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
tests/fixtures/map_shots/too_hard/17.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 791 KiB

BIN
tests/fixtures/map_shots/too_hard/18.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
tests/fixtures/map_shots/too_hard/19.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
tests/fixtures/map_shots/too_hard/20.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

BIN
tests/fixtures/map_shots/too_hard/21.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

BIN
tests/fixtures/map_shots/too_hard/22.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

BIN
tests/fixtures/map_shots/too_hard/23.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

BIN
tests/fixtures/map_shots/too_hard/24.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

@ -10,5 +10,14 @@ them would mean guessing:
- `12.png` — 710x594 native. Too few pixels per cell for the label glyphs to
correlate; measured, the best label score stays ~0.44 at every working
resolution, so it is not a tuning problem.
- `17.png`-`24.png` — same class as `12.png`: native resolution too low (605-1789px
wide, vs. 1392-2400px for the fixtures that do solve) for the label glyphs to
correlate once warped to the canonical cell size; measured, the best label
score stays ~0.45-0.52 at every working resolution tried (including the
2400px retry pass), well short of LABEL_ACCEPT (0.62) and inside the
documented "wrong read" band (0.40-0.56), not close enough to call it a
tuning problem. Two of the ten screenshots this batch came from (now
`15.png`/`16.png` in the main set) were high enough resolution to solve --
the same camera distance/game zoom just wasn't consistent across the batch.
"Too zoomed in" and "too low resolution" are legitimate hard rejections.

View File

@ -636,5 +636,104 @@
1659
]
]
}
},
"15.png": [
[
"N8",
140,
859
],
[
"N9",
153,
2
],
[
"O8",
1042,
859
],
[
"P8",
1949,
859
]
],
"16.png": [
[
"J5",
62,
573
],
[
"J6",
80,
289
],
[
"J7",
97,
31
],
[
"K5",
370,
572
],
[
"K6",
374,
289
],
[
"K7",
377,
31
],
[
"L5",
678,
572
],
[
"L6",
667,
289
],
[
"L7",
657,
31
],
[
"M5",
985,
572
],
[
"M6",
959,
288
],
[
"M7",
936,
31
],
[
"N5",
1293,
571
],
[
"N6",
1252,
288
],
[
"N7",
1215,
31
]
]
}