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>
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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, "Civil–Military.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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = "Civil–Military" # 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
|
After Width: | Height: | Size: 3.2 MiB |
BIN
tests/fixtures/map_shots/16.png
vendored
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
tests/fixtures/map_shots/too_hard/17.png
vendored
Normal file
|
After Width: | Height: | Size: 791 KiB |
BIN
tests/fixtures/map_shots/too_hard/18.png
vendored
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
tests/fixtures/map_shots/too_hard/19.png
vendored
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
tests/fixtures/map_shots/too_hard/20.png
vendored
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
tests/fixtures/map_shots/too_hard/21.png
vendored
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
tests/fixtures/map_shots/too_hard/22.png
vendored
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
tests/fixtures/map_shots/too_hard/23.png
vendored
Normal file
|
After Width: | Height: | Size: 570 KiB |
BIN
tests/fixtures/map_shots/too_hard/24.png
vendored
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
9
tests/fixtures/map_shots/too_hard/README.md
vendored
@ -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.
|
||||
|
||||
101
tests/fixtures/map_shots_gt.json
vendored
@ -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
|
||||
]
|
||||
]
|
||||
}
|
||||