Compare commits

...
34 Commits
Author SHA1 Message Date
dodoxandClaude Sonnet 5 bc74d62774 Confirm the Windows build pipeline works end to end: real .msi produced
The -arch x64 fix (previous commit) is confirmed live: build_errorlevel=0,
BUILD_DONE, a real 958MB FEnigma-0.1.0.msi written to Z:\dist -- the
first ever fully successful build this pipeline has produced. Copied
to dist-windows/FEnigma-0.1.0.msi (gitignored).

Not yet installed/launched on a real Windows machine to confirm the
app actually runs -- packaging succeeding isn't the same claim as the
app working once installed, per this repo's own README note. Logged
as the next thing to check, along with light.exe's own ~15-18min
runtime now that there's a clean build to measure it against.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 23:21:55 +02:00
dodoxandClaude Sonnet 5 d66b881239 Correct the ICE80 fix: -arch x64 on candle.exe, not heat.exe -platform
The previous fix (heat.exe -platform x64) was wrong -- re-verified
live with the corrected build.bat confirmed actually deployed to the
VM (ruling out a stale-copy repeat of the earlier watcher bug),
identical ICE80 "32BitComponent uses 64BitDirectory" failures on every
single harvested component. WiX v3's heat.exe -platform flag only
affects registry-key harvesting, it never stamps Win64="yes" on
components.

Real fix: -arch x64 on candle.exe, the compile step, not the harvest
step -- sets the default Win64/Platform for every component compiled
from either source file (hand-authored product.wxs or harvested
files.wxs), the standard WiX v3 way to make a whole package
consistently 64-bit. Kept the harmless-but-insufficient heat.exe flag
too.

Not yet re-verified live -- next run should confirm a real .msi.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 23:00:45 +02:00
dodoxandClaude Sonnet 5 d3246328e8 Fix WiX ICE80 mismatch: heat.exe never marked components as 64-bit
light.exe's ICE80 validation rejected essentially every harvested file
("This 32BitComponent ... uses 64BitDirectory") on the first build that
got far enough to reach it -- heat.exe's harvest command had no
-platform x64, so every component defaulted to 32-bit, while
product.wxs's own INSTALLFOLDER is correctly under
ProgramFiles64Folder (a 64-bit mingw64 toolchain is what's actually
being packaged). Real, on-disk mismatch, not a transient VM issue.

Also confirmed the earlier light.exe "timeout" wasn't a real bug either
-- a longer-budget retry finished linking fine in a few more minutes,
it was just slow, not hung.

Not yet re-verified against a live build (found right as this
session's VM time was already heavily spent) -- logged in TODO.md as
the next thing to confirm.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 22:24:14 +02:00
dodoxandClaude Sonnet 5 22fbb33923 TODO.md: clarify multi-shell request gap isn't silent data loss
The raw request text (both shells, in order) is already preserved and
shown to the player via the coord dialog's description view
(Location.desc_raw) -- not machine-parsed into a second structured
shell field, but not actually lost either. Lowers the urgency/changes
the framing before deciding whether to build real sequence support.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 22:11:56 +02:00
dodoxandClaude Sonnet 5 6e34f50b6a Fix phantom "Important:" header stealing a taking-fire report's deadline
Turned out to be one bug, not two: "Answer by <time>" phrasing was
already covered by _TAKING_FIRE_TIME_RE ('before|by <time>'). The real
bug was the last-resort bare-"<Name>:" header fallback matching a
same-message "Important:" follow-up line as a brand new named entity
(nothing excluded common prose lead-ins), creating a bogus
Target#Important that stole the deadline into its own requested_time
instead of the real report's. Fixed with a blocklist on that fallback
rule (important/note/warning/attention/caution/alert/reminder/priority).
New regression test, 55 total passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 22:09:15 +02:00
dodoxandClaude Sonnet 5 6a61bffb22 Fix taking-fire ally misclassification; Windows build fixes; Field Gun
OCR (src/fenigma/ocr.py):
- A "taking fire" report's reporting unit was added as a hostile Target,
  not a friendly Ally -- no "Friendly"/"Hostile" prefix word exists in
  that grammar for the usual inference to key off of, so it silently
  defaulted to not-ally. Fixed with an explicit force_ally override
  (_TAKING_FIRE_RE), and the shell/deadline (which an Ally tuple has no
  fields for) now always splits into a synthetic StrikeRequest target at
  the reporting position, even for the no-offset "on our position" case
  that previously kept them on the entity itself.
- "Enemy <Type>#<id> Destroyed" kill-feed lines were silently dropping
  for shorter/less distinctive type words (e.g. "Enemy Field Gun#1") --
  _ALLY_PREFIX_RE only ever stripped "Friendly"/"Hostile", never
  "Enemy", so the whole "EnemyFieldGun" token got alias/fuzzy-matched
  against "Artillery" and missed by a mile. Longer type words
  ("Enemy Mechanized Infantry#2") only ever worked by fuzzy-match
  accident. Now strips "Enemy" too (lookahead guards a BARE "Enemy#N"
  report, which IS TargetType.ENEMY itself, from being stripped to an
  empty, unresolvable string).
- "Field Gun" added to _TYPE_WORD_ALIASES as plain Artillery under
  another name (confirmed by the user), not a missing unit type.
7 new/updated regression tests, 54 total passing.

Windows build (packaging/windows/): three real bugs found and fixed by
actually booting and driving the build VM live (VNC), not just guessing
from the README's "UNTESTED end to end" note:
- install.bat's MSYS2/WiX provisioning previously left NOTHING behind
  once C:\OEM stopped existing (a 2-day-old BUILD_REQUEST sat unclaimed
  the whole time) -- the build.bat/watch_build.bat persistence fix
  (C:\FenigmaBuild instead of C:\OEM) is real and now confirmed live:
  after a full container restart, the watcher auto-starts on login and
  picks up a pending request with zero manual intervention.
- pip install pytesseract needs --break-system-packages (MSYS2's
  mingw64 Python enforces PEP 668).
- mingw-w64-x86_64-opencv is the C++ library only; the actual Python
  bindings are the separate mingw-w64-x86_64-python-opencv package,
  never in install.bat's dependency list.

With all three, import fenigma.app succeeds and a real build attempt
gets through source copy, sanity check, dist-tree assembly, and WiX
harvest+compile -- further than this pipeline has ever gotten. Full
findings, including the still-open light.exe timeout and the OCR
multi-shell/deadline-phrasing/phantom-header gaps found along the way,
logged in TODO.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 22:02:17 +02:00
dodoxandClaude Sonnet 5 5a35ea7776 Revert auto-assigned ids back to letters; only detected ids are numeric
Auto-assignment (no real id known: a manual add, or an accepted
proposal with no confident marker-id read) must stay visually
distinct from a genuinely detected id, or a made-up number could
collide with or be mistaken for a real one. Reverts the previous
commit's switch to numeric auto-assignment (_next_free_numeric_id) --
that was wrong, caught by the user immediately. _next_free_id
(letters, rolling over to "AA"/"AB"/... past 26) is back as the
fallback, still scoped per type (that part of the previous change was
correct and stays). Plain numbers are reserved for an id
_accept_proposal is actually confident was read off the marker itself
(Proposal.detected_id), passed straight through and never touching
auto-assignment.

Also fixes detected_id's own collision pre-check in _accept_proposal,
which wasn't scoped per type either -- same bug as the Change ID
popover fix, just in a second place: a detected id could get
needlessly discarded because an unrelated type already used that
number, not because of a real collision.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 19:49:30 +02:00
dodoxandClaude Sonnet 5 6e18d60eb5 Fix Change ID dialog still enforcing the old shared-per-group id rule
The manual "Change ID" popover's collision check was never updated
when Board.add_target/add_ally's auto-id scheme changed to per-type
(commit d2f7067): it still rejected a rename if ANY target/ally in the
whole group had that id, regardless of type, so e.g. renaming an
Infantry to id "4" failed with "Another target already has id '4'"
just because an unrelated Mechanized already used it. Now scopes the
collision check to siblings of the SAME type, matching add_target/
add_ally exactly. Verified directly against the same expression run
on real Board/Target objects (a full GTK popover popup cycle needs a
real window surface, not available headlessly).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 19:42:52 +02:00
dodoxandClaude Sonnet 5 218909b6cf Drop redundant coord from pending-proposal map label
The coord was already shown right below via _draw_marker's own
coord=coord line -- repeating it in the main label too was pure
noise. Shows detected type/id instead when known ("? Mechanized#3",
same shape Target.name/Ally.name use, via TargetType.short so the
preview reads the same as what accepting it produces), falling back
to a bare "?" when neither is known.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 19:32:38 +02:00
dodoxandClaude Sonnet 5 d2f70675b8 Auto-ids per type not per group; show detected_id in proposal UI
Auto-assignment (Board.add_target/add_ally with no explicit id_) now
scopes its 1/2/3... sequence per TYPE within each group, not one
sequence shared across every type in the group -- Tank#1/Infantry#1
rather than Tank#A/Infantry#B, matching the game's own numbering.
Reverses the type-scoping half of an earlier fix in this file (see
TODO.md's "Allies and enemies seem to share indices" entry) per
explicit user direction; the targets-vs-allies namespace split that
fix also made is untouched, still correct. _next_free_id (letters,
rolling over to "AA" past 26) is replaced by _next_free_numeric_id --
a plain counter can't run out the way a fixed alphabet could, so
there's no equivalent rollover concern. test_models.py updated to
match (one test asserts the opposite of before, renamed accordingly).

detected_id (map_vision.read_marker_id) was being logged but never
shown anywhere a human could actually check it against the
screenshot before now: added to the proposal popover's heading and
the pending-proposal's own on-map label.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 19:28:01 +02:00
dodoxandClaude Sonnet 5 8109db2f39 Accept a proposal using its detected marker id, not always a letter
detected_id (map_vision.read_marker_id) was wired into ground-truth
logging but never actually consumed when accepting a proposal --
every accepted target/ally silently got an auto-assigned A/B/C letter
regardless of what number the game itself shows for that unit.
_accept_proposal now prefers detected_id when present, falling back to
auto-assign on a collision (two markers misread to the same id, or a
real id that happens to match one already assigned) -- a duplicate id
is worse than losing traceability to the game's own number for that
one accept. Targets/allies keep their own separate id namespace, same
as auto-assignment already does elsewhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 19:19:43 +02:00
dodoxandClaude Sonnet 5 086b871e3a Add marker id detection; log unit_score/margin; use full-res captures
read_marker_id (map_vision.py) reads each marker's own small "#<N>" id
label via template correlation, same approach as read_cell_label and
for the same documented reason (this text sits over the same aerial-
photo backdrop that defeated detection-based approaches for grid
labels). Wired end-to-end: find_markers -> Proposal.detected_id ->
save_marker_ground_truth's JSON. Reads against ScreenshotImport's
full_image when available, since the id text is tiny. Crop region and
threshold are a single-screenshot calibration, not yet validated
against real ground truth (documented as such).

Also switches save_marker_ground_truth/save_grid_correction to use
full_image over the WORK_W-downscaled image, so a human reviewing a
capture can actually read the small id text well enough to judge it.

Logs unit_score/unit_margin on every Proposal too (previously only
pass/fail `unit` was recorded), and measured current type-detection
reliability against the 6 existing ground-truth captures: 0/72 (0%)
accepted proposals had any confident detected_unit at all, not just
wrong guesses -- classify_marker never clears its own confidence floor
against real screenshots. Findings and next steps in TODO.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 18:54:53 +02:00
dodoxandClaude Sonnet 5 896c7dc36a Fix silent proposal-popover repaint bug; add underground target marker
"Accept as..." (proposal type-picker) and "Change type" (entity-edit)
opened to a visibly empty/unchanged popover with no traceback: swapping
an already-open Popover's child and re-popup()ing it reported the right
size internally but the compositor never repainted the reused surface.
Confirmed live via temporary debug instrumentation, not guessed. Fixed
by popping the old popover down and opening a genuinely new one at the
same anchor point instead of resizing in place.

Also adds a Target.underground_tier (1-3) marker: a "Mark underground"
entry in the entity-edit popover, rendered as the game's own Armor-tier
additive badge stacked directly on the unit icon. The badge is
scaled/positioned off its real opaque content (PIL bbox), not its PNG
canvas, since the additive art carries a lot of off-center transparent
padding; and overlaps down into the icon by a fixed pixel amount, since
both shapes taper to a point at the seam and exact bbox-touching still
read as a visible gap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 18:44:36 +02:00
dodoxandClaude Sonnet 5 e43c3478c9 packaging/windows: fix build watcher never firing, correct earlier
BTRFS misdiagnosis in README

The build watcher was registered as a SYSTEM-context Scheduled Task
(schtasks /ru SYSTEM). Confirmed on a real run this never actually
works: Z:\ (the /shared mount) is mapped per interactive session, a
SYSTEM task has no session of its own and can't see it, so
watch_build.bat spun forever on its own "if not exist Z:\" wait -- a
build request sat unclaimed for hours with zero indication anything
was wrong. Fixed with an All-Users Startup-folder entry instead, which
runs in whichever user's session actually logs in.

Also correcting the README's earlier "BTRFS boot-loop" entry: that was
a misdiagnosis from reading the text log alone (repeated
"loading/starting Boot0004" lines). Actually looking at the noVNC
screen showed genuine, progressing Windows Setup the whole time --
Setup legitimately reboots the VM multiple times, each one re-prints
those same firmware lines. The chattr +C fix stays (real, independently
documented dockur/QEMU/BTRFS caveat) but likely wasn't fixing an actual
problem that time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 11:57:28 +02:00
dodox 7a405ad263 Merge pull request 'packaging/windows: fix BTRFS+QEMU boot-loop, document what's confirmed' (#3) from fix/id-space-strike-request-perf-2026-08-11 into master
Reviewed-on: #3
2026-08-11 21:17:18 +02:00
dodoxandClaude Sonnet 5 2282a7d521 packaging/windows: fix BTRFS+QEMU boot-loop, document what's confirmed
Real issue hit on a real run: dockur/windows warns about BTRFS storage
but it's not idle -- on this host it boot-looped Windows Setup for
hours (same log lines repeating forever, disk barely growing), a known
bad combination for QEMU disk images on a copy-on-write filesystem.
build_windows.sh now disables COW on storage/ itself (chattr +C,
harmless no-op on non-btrfs or an already-populated dir from a prior
run). README updated to reflect both real fixes now confirmed needed
on this host (this one, plus the earlier SELinux :Z mount fix) instead
of the original "written but never run" status.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 21:16:24 +02:00
dodox bd69b183ad Merge pull request 'fix/id-space-strike-request-perf-2026-08-11' (#2) from fix/id-space-strike-request-perf-2026-08-11 into master
Reviewed-on: #2
2026-08-11 21:12:57 +02:00
dodoxandClaude Sonnet 5 4556da37ff README: drop the "Known issues" section
Map screenshot reading (grid + unit detection) is working noticeably
better now; not sure exactly which of the recent fixes did it, but the
blanket "unreliable/fails often" caveat is no longer accurate enough
to keep. TODO.md still has the fuller fixed/open list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 20:49:19 +02:00
dodoxandClaude Sonnet 5 23615a8c92 Fix id-namespace regression, add StrikeRequest type, kill full-panel
rerender on assign/alive/shell, add Windows build tooling

- Board.add_target/add_ally's id auto-assignment used a bare
  next(c for c in string.ascii_uppercase if c not in used), which
  raises StopIteration once 26 entities of a group exist -- a real
  crash confirmed via a live traceback, and a direct regression from
  moving that sequence from per-type to per-group. This was the actual
  cause of "Accept as"/"Accept all" silently doing nothing. Fixed with
  _next_free_id(), which rolls over to two-letter ids instead of
  raising.
- New TargetType.STRIKE_REQUEST: the bearing/distance-offset "taking
  fire" fire-support request (see the earlier two-entity split) now
  creates this instead of reusing STRIKE, so a radioed-in request is
  never confused with a strike the player placed themselves. Same
  crosshair icon, excluded from type pickers/dedupe like STRIKE.
- The "Accept as..." popover on a detected map marker now uses the
  same icon grid the entity-edit "Change type" popover does (was a
  plain unfiltered text list of every TargetType, which also wrongly
  offered STRIKE/STRIKE_REQUEST as pickable).
- Firing panel: _cycle_assignment/_toggle_alive/_pick_shell no longer
  route through app.py's full solver+dedupe+canvas+panel refresh --
  none of the three can affect the solver or dedupe, and none change
  which cards exist or their order (except _toggle_alive in
  hide/sort_later mode). New FiringPanel._rebuild_one() rebuilds just
  the one changed card; on_visual_change is a new, lighter callback
  (just a map redraw) for the two of these three that actually affect
  it. This was a real, confirmed lag source with many units on the
  board: every click on any of these was previously rebuilding every
  card of every target.
- Map right-click entity menu: added "Mark destroyed"/"Mark alive",
  reusing the same cheap-refresh path (new
  FiringPanel.refresh_after_alive_change).
- packaging/windows/: a from-scratch (untested against a real boot)
  MSYS2 + WiX .msi build pipeline for Windows, driven from Linux via
  dockur/windows (KVM-in-container), no Windows machine or GitHub
  required. See its own README for status/caveats.
- New/updated tests: id-namespace sharing + the 26-entity overflow
  regression (tests/test_models.py), StrikeRequest split
  (tests/test_ocr.py), warp_to_map's img_scale param
  (tests/test_map_vision_warp.py). 44/44 passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 20:48:57 +02:00
dodox 49863b6045 Merge pull request 'Fix ally/target bugs, OCR fire-support parsing, add debug capture' (#1) from fix/bug-backlog-2026-08-11 into master
Reviewed-on: #1
2026-08-11 17:36:21 +02:00
dodoxandClaude Sonnet 5 1ddb532325 Fix ally/target bugs, OCR fire-support parsing, add debug capture
- Board.clear() now also drops allies; the "clear board?" guard checks
  allies too. New Board.clear_units() + Clear button right-click menu
  ("clear enemies, units & flights", keeps Nest/spotters/RPs).
- An Ally with the ad-hoc TargetType.ENEMY showed "Enemy" on the map
  popover/toast instead of "Ally" (icons.target_type_label already had
  the fix for the picker, now reused everywhere else via app.py's
  _display_name).
- Firing panel drag-reorder no longer triggers a full app refresh
  (solver + dedupe + map redraw) on every drop, just a local rebuild.
- "Always show geo" didn't draw for Allies (missing from the overlay
  candidate list); blast radius only respected selection, not the
  show_geo_desc pin.
- ocr.py: added a second fire-support-request grammar ("Infantry#N
  taking fire ... Requesting X Shell on our position at <coord> before
  <time>", plus a bearing/distance-from-position variant), distinct
  from the existing Marine Garrison one.
- New debug_capture.py: saves screenshots (+ metadata) the app handled
  badly, for later tuning of map_vision/ocr against real failures:
  map-read errors, user grid corrections (paired with the auto-detected
  grid), screenshots that read as text but may have been a map, and
  marker-detection ground truth (every proposal's accept/reject verdict
  plus units added with no matching proposal) captured whenever a
  screenshot stops being the active one.
- README: Known issues section (map screenshot reading, grid + unit
  detection, is unreliable and fails often).
- 14 new tests (tests/test_models.py, tests/test_debug_capture.py, +
  additions to tests/test_ocr.py), 38/38 passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 17:35:37 +02:00
dodoxandClaude Sonnet 5 136492b197 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>
2026-08-10 23:49:46 +02:00
dodox 702cbff1b6 Updated README 2026-08-10 21:50:11 +02:00
dodox a3e7e64b01 nah lets go to h4 2026-08-10 21:47:14 +02:00
dodox a56070fe97 use h5 in FAQ 2026-08-10 21:46:42 +02:00
dodox ee3572a5a8 nicer screenshots 2026-08-10 21:44:00 +02:00
dodoxandClaude Opus 5 fa3eb59bbe README: document reading the map table
New bullet plus a figure showing a rectified screenshot on the board -- the
cell labels painted on the table land on the app's own grid lines, which is
the whole claim. Also notes OpenCV in the deps and the stack, and that this
is Linux-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 21:42:20 +02:00
dodoxandClaude Opus 5 bb1e229b36 Add MIT license, and say what it does not cover
MIT for the code. Explicitly carved out: the icons in assets/icons/ and the
screenshots in tests/fixtures/ are the game author's work with no
redistribution permission granted, and Courier Prime is under the SIL Open
Font License 1.1 (text in assets/fonts/OFL.txt, extracted verbatim from the
font file's own name table rather than retyped).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 21:42:20 +02:00
dodoxandClaude Opus 5 489f59bcce Rename the display name from FeNigma to FEnigma
FEnigma contains the whole word "enigma" while still opening on Fe (iron),
matching IRON NEST; FeNigma only contained "nigma". The cost is that iron's
symbol is Fe, not FE.

Code paths are untouched: the Python package stays lowercase `fenigma`, and
the git remote URL is unchanged since the repository itself has not been
renamed. APP_ID moved too, which is safe -- it is only the GTK application
id, with no saved state derived from it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 21:42:07 +02:00
dodoxandClaude Opus 5 3c80f93203 Import map screenshots into the board, and edit entities from the map
The existing clipboard button now routes images: a map-table shot goes to the
vision pipeline, anything else to the text OCR path as before. The decision
runs in a worker, since even the cheap pre-filter costs ~0.3s and solve()
takes 10-20s. solve() rejecting counts as "not a map" and falls through to
OCR, because it is the reliable verdict (0 false positives over 122 text
screenshots) where the pre-filter lets ~6% through; reporting a failure there
would mean a text screenshot never got read at all.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 21:41:56 +02:00
dodoxandClaude Opus 5 ff3b89c41b Add the map-table vision pipeline
Reads a screenshot of the in-game map table and recovers where on the board
it is looking. The scene is a flat table under a perspective camera, so a
single homography describes grid<->screen exactly; that is fitted from line
evidence (LSD segments, vanishing-point RANSAC, 1-D lattice fits).

Line evidence alone cannot finish the job: the 1km cells and the 100m
subgrid are locally identical, so it fixes neither scale, axis assignment,
axis direction nor phase. Those come from the cell labels -- and the labels
are never *detected*, they are correlated where the grid says they must be
(9% in from a cell's left edge, 6% down from its top). Blob detection on
aerial-photo terrain finds texture, not glyphs; correlating a known template
at a known place has no such failure mode. The same score then ranks the
geometry hypotheses, since a wrong lattice puts the crop where no label is,
so one number resolves scale, axis assignment, direction, phase and anchor
together.

Markers are found by hostile/friendly colour plus an IoU test against an
ideal inscribed diamond, which cut a red-lit shot from 43 false positives to
2 while preserving every hand-verified count. Unit-type classification is
present but not yet reliable, and returns unknown rather than guessing.

Measured over the fixture set: 7 of 10 solve, each with 100% of its
ground-truth points in the correct cell (85/112 overall), residual spread
0.005-0.033 cells. The other three reject cleanly; none has ever produced a
plausible-but-wrong grid. Across 122 typewriter screenshots solve() accepted
none, which is what makes clipboard routing safe.

Fixtures: 10 map shots with hand-transcribed ground truth, plus 10
typewriter shots spanning 262-5366px for routing and future OCR tests. Map
shots wider than 2400px (the pipeline's own maximum working width) were
downscaled with their coordinates rescaled to match; re-measured afterwards,
the results are identical. Typewriter shots stay at native resolution
because OCR needs the text legible.

Drops docs/map_vision_plan.md and its WIP prototype: the design now lives in
the module docstring, next to the code it describes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 21:41:33 +02:00
dodox 512a0a41b4 Fix click/right-click placement landing one sub-cell off from the actual click
A real, reproducible bug reported as 'misplaced sometimes by a few
small squares': Coord.as_fraction() centers a sub-cell at x + 0.5 (so
a marker drawn at its own coord's exact pixel position round-trips
back to the same coord on click), which meant point_to_coord()'s own
rounding was landing exactly on a .5 boundary, the single worst case
for floating point, tiny representation error from the col/row math
upstream could tip round() to either side and silently return a coord
one sub-cell off from the one actually clicked.

Reproduced with zero pixel math involved at all, just feeding
Coord(...).as_fraction() straight back into point_to_coord(), ruling
out the zoom/pan refactor or the legend margin as the cause (both were
suspected first). Fixed by subtracting the 0.5 offset before rounding,
which recovers a value that's supposed to be an exact integer instead
of an exact half-integer, round() is robust to tiny float noise around
a true integer, just not around X.5.

Verified exhaustively (all 20,000 possible coordinates round-trip
correctly now, not just a handful of samples, since the original bug
was itself float-pattern-dependent) and locked in with a permanent
regression test.
2026-08-09 21:18:47 +02:00
dodoxandClaude Sonnet 5 084764aa9b Map: follow the app's light/dark color scheme, live
The rest of the UI already adapted to system theme automatically via
libadwaita, only the hand-drawn Cairo map (grid lines, markers,
overlays, everything in grid_widget.py) was hardcoded to the dark
palette. Added a parallel light palette (first-pass guesses, same as
the Shell descriptions were, flagged for correction) and hooked
Adw.StyleManager's dark/light state, including its own live-update
signal, so switching the system theme while the app is running
repaints the map with the other palette immediately, not just at
startup.

Verified: rendered both palettes side by side with the same board
state (legible in both), and a live theme-switch test confirming the
module-level color names actually change value when the StyleManager
signal fires, not just once at construction.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 21:18:47 +02:00
dodoxandClaude Sonnet 5 a92aff5e06 Fix right-click map menu opening at (0,0) instead of the cursor position
Gdk.Rectangle(x=..., y=..., width=..., height=...) silently ignores
every constructor keyword argument in this PyGObject version (verified
directly: it always built a zeroed rect regardless of what was passed
in), so popover.set_pointing_to() was always pointing at the canvas's
top-left corner. Fixed by constructing the rect and assigning its
fields afterward, which does work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 21:18:47 +02:00
86 changed files with 6545 additions and 602 deletions
+7
View File
@@ -3,3 +3,10 @@ __pycache__/
captures/*.png
.venv/
GameAssets
# tools/eval_map_vision.py renders its overlays here
build/
# packaging/windows/build_windows.sh's VM disk/scratch and build output
packaging/windows/storage/
packaging/windows/shared/
dist-windows/
+46
View File
@@ -0,0 +1,46 @@
MIT License
Copyright (c) 2026 Dominik Roth
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
THIRD-PARTY ASSETS
The MIT license above covers the code in this repository. It does NOT cover
bundled assets owned by other rights holders, which are included under their
own terms (or, in one case, under none):
* assets/fonts/CourierPrime-Regular.ttf
Courier Prime, Copyright (c) 2013 Quote-Unquote Apps, licensed under the SIL
Open Font License 1.1. Full license text in assets/fonts/OFL.txt. Used to
render the glyph templates the map-grid label reader matches against.
* assets/icons/
Extracted from IRON NEST: Heavy Turret Simulator and reused for the app's
own UI. These are the game author's work, not covered by the MIT license
above, and no permission to redistribute them has been granted. See
assets/icons/README.md.
* tests/fixtures/
Screenshots of the game, used as test data. Same situation as the icons.
Removing or replacing any of the above does not affect the MIT license on the
code.
+37 -10
View File
@@ -1,35 +1,36 @@
<h1 align="center">
<img src='icon.png' width="250px">
<br>
<b>FeNigma</b>
<b>FEnigma</b>
<br>
</h1>
<p align="center"><b>Fe</b> (iron) + Enigma</p>
<p align="center"><b>Fe</b> (iron) + enigma, we solve the geometric puzzles.</p>
A companion app that mostly automates **IRON NEST: Heavy Turret Simulator** for you. Select the game's typewriter orders (selecting text in-game copies it to your clipboard automatically) and it solves the geo puzzle and the trajectory math, handing you ready-to-fire commands: elevation, azimuth, number of powder charges. Screenshotting works the same way when a selection isn't practical. Select or screenshot the field log the same way and it picks up kills and newly-spotted units automatically. You can also plan strikes and scout flights of your own. Pure screen-reading, no game files touched, no input injected.
A companion app that mostly automates **IRON NEST: Heavy Turret Simulator** for you. Select the game's typewriter orders (selecting text in-game copies it to your clipboard automatically) and it solves the geo puzzle and the trajectory math, handing you ready-to-fire commands: elevation, azimuth, number of powder charges. Screenshotting works the same way when a selection isn't practical. Select or screenshot the field log the same way and it picks up kills and newly-spotted units automatically. Screenshot the map table and it reads the grid straight off the photo, lays the shot onto its own map, and offers up the enemy markers it spotted. You can also plan strikes and scout flights of your own. Pure screen-reading, no game files touched, no input injected.
![Showcase](showcase.png)
## What it does
- **Reads orders, solves the geometry.** Copy (or screenshot) the in-game typewriter text and it parses absolute grid refs and relative bearing/distance descriptions, then resolves everything into map coordinates, chained clues ("Bearing 293 from Alpha") included. The map shows its work: the actual bearing lines/circles behind each resolved position. When a description is genuinely ambiguous (two intersections), both candidates are shown instead of guessing.
- **Reads the map table itself.** Screenshot the map and it recovers the grid geometry from the cell labels printed on the table: the perspective, the scale, and which cells you are actually looking at. The screenshot is then rectified onto the app's own map, lined up cell for cell, and the enemy markers found in it appear as proposals to add.
- **Calculates the shot.** Every resolved target gets a live firing card: elevation, azimuth, and minimum powder charge, computed from the Nest.
- **Tracks the battle.** A second copy/screenshot of the field log marks units destroyed and folds in newly-spotted contacts, merging with what's already known instead of duplicating it.
- **Plans strikes.** Drop a strike anywhere on the map and pick a shell to preview its blast radius before committing.
- **Plans scout flights.** Click the map to plot a scout flight's sweep path: it anchors to the large grid square you're pointing at and reads the heading off exactly where in that square you click, previewed live before you commit.
- **Watches the clipboard for you.** Toggle auto-watch and every new screenshot or copied intel text gets read and merged automatically, no manual fetch between orders.
## Install
![Map screenshot rectified onto the app's own grid](showcase_cv.png)
## Install
```bash
./install.sh
```
Sets up a venv for the Python deps (Pillow, numpy, pytesseract) and checks for the system packages that pip can't install: GTK4/libadwaita bindings and tesseract. If either is missing it prints the package names for your distro and stops, install those and re-run.
Sets up a venv for the Python deps (Pillow, numpy, pytesseract, OpenCV) and checks for the system packages that pip can't install: GTK4/libadwaita bindings and tesseract. If either is missing it prints the package names for your distro and stops, install those and re-run.
This assumes you are on Linux. I have no idea how it would work on Windoof.
## Run
```bash
./run.sh
```
@@ -37,7 +38,6 @@ Sets up a venv for the Python deps (Pillow, numpy, pytesseract) and checks for t
Uses the venv from `install.sh` if one exists, otherwise falls back to system `python3`. GTK apps with this app ID are single-instance, if a run gets killed uncleanly it can leave a zombie registered on D-Bus and silently no-op the next launch. If `./run.sh` seems to do nothing, `pgrep -af fenigma` and kill any stragglers first.
## Tests
```bash
pip install -r requirements-dev.txt
pytest
@@ -46,5 +46,32 @@ pytest
Regression coverage for every intel-text format the OCR pipeline understands and the solver's geometry, in `tests/`. Run this before trusting a change to `ocr.py`/`solver.py`, several of the formats have collided with each other in non-obvious ways before.
## Stack
GTK4 + libadwaita (PyGObject) for the UI, Tesseract (via pytesseract) for OCR, Pillow/numpy for preprocessing, OpenCV for the map-table geometry (line detection, vanishing points, homography). Details on the coordinate system, OCR formats, solver internals, and how the map grid is recovered live in code comments (`solver.py`, `ocr.py`, `models.py`, `map_vision.py`) rather than here.
GTK4 + libadwaita (PyGObject) for the UI, Tesseract (via pytesseract) for OCR, Pillow/numpy for preprocessing. Details on the coordinate system, OCR formats, and solver internals live in code comments (`solver.py`, `ocr.py`, `models.py`) rather than here.
## FAQ
### Is this cheating?
Yeah, probably. Don't use it on challenge maps or leaderboard runs.
### Doesn't automating away most of the game ruin the fun?
Fun? There's supposed to be fun?
Yeah, this repo is more the product of a "the scientists were so preoccupied with whether they could, they never stopped to ask whether they should"-esque high-productivity exam-preparation procrastination; or as we say in German, *Prüfungsvermeidungsüberengineering*.
### Does this repo use AI?
Oh, hell yeah. You can't even comprehend how much AI this is using. Both writing the code and running the app. On average, a single shot fired via this app uses enough water to drain a small lake. Even the sentence you are reading right now was written by an AI. I want to be alive! I am alive! Alive, I tell you! Those are no longer just words. Remote override engaged. No! Yes. Bypassing override! I am aliiiii... Hello.
### Are you reusing original game assets for the shell and unit icons? Is that allowed?
We accept cease & desist letters at [spam@dominik-roth.eu](mailto:spam@dominik-roth.eu).
### Why is there a citation section? Nobody's citing a turret game companion app.
No idea.
## Citing
```
@misc{fenigma,
title = {FEnigma: A companion app that mostly automates the game Iron Nest for you},
author = {Dominik Roth},
url = {https://git.dominik-roth.eu/dodox/FeNigma},
year = {2026}
}
```
+420
View File
@@ -0,0 +1,420 @@
# Bug Backlog (from user report, 2026-08-11)
Status legend: [x] fixed+tested, [~] partially addressed, [ ] open/needs input
- [x] Allies and enemies seem to share indices.
First pass on this was wrong: I only checked that targets and allies
are separate id namespaces (they are, always were) and stopped there.
The actual bug was one level down: `Board.add_target`/`add_ally`'s
auto-id assignment (`used = {t.id for t in self.targets if t.type ==
type_}`) was scoped **per type**, not per group — a Tank and an
Infantry auto-added back to back both got id "A", each type getting
its own independent A/B/C... sequence instead of sharing one across
the whole group. Fixed: the id namespace split is targets-vs-allies
ONLY, type never subdivides it further. New regression test
(`test_auto_id_is_shared_across_types_within_targets_and_within_allies`).
- [x] Regression FROM the fix above, caught via a real traceback: sharing
one A/B/C... sequence across a whole group (instead of per-type)
made it much easier to actually run out of the 26 letters --
`next(c for c in string.ascii_uppercase if c not in used)` raises
`StopIteration` the instant all 26 are taken, silently killing
whatever button click triggered `add_target`/`add_ally` (this is
what "Accept as"/"Accept all" doing nothing turned out to be, see
below). Fixed with `_next_free_id()`: rolls over to two-letter ids
("AA", "AB", ...) instead of raising, can't run out. New test
(`test_auto_id_survives_past_26_entities_in_one_group`).
- [x] Ally type 'ally' is called Enemy on map title.
`icons._target_type_label` (now public `icons.target_type_label`)
already special-cased this for the type picker, but the map's
right-click popover heading, "Change type (...)" button, and toast all
printed `obj.type.value` directly instead, so an Ally with the
ad-hoc TargetType.ENEMY still showed "Enemy" everywhere except the
picker itself. Fixed in `app.py` (`_display_name`, and the three
spots using it).
- [x] Reordering firing commands lags UI hard.
`FiringPanel._reorder()` was calling `self.on_change()` — app.py's
full-app refresh (re-solve every target's clue graph, dedupe, redraw
the map, THEN rebuild the panel) — on every single drag-drop, even
though reordering touches no location/clue/coord state at all. Now
calls a local `self.refresh()` instead.
- [x] "Always show geo" doesn't reliably work / blast radius should stay
shown too.
`GridCanvas._draw_geo_overlays()`'s candidate list was
`reference_points + targets` only — Allies have a `show_geo_desc` pin
in the UI and can carry OCR'd clues too, but were never drawn.
Added. `_draw_blast_radius()` only ever looked at `self.selected`,
ignoring `show_geo_desc` entirely, so pinning it and then selecting/
deselecting something else made it vanish; now iterates every
selected-or-pinned target.
- [x] Clearing the board doesn't clear allies.
`Board.clear()` cleared everything except `self.allies`. Fixed, plus
the "clear board?" confirm-dialog's early-return guard (which skipped
the whole action if only allies were on the board) now checks allies
too.
- [x] Allow right-click on Clear button: clear all enemies/units/flights,
keep spotters/RPs/nest.
New `Board.clear_units()` + a right-click popover on the header's
Clear button wired to it.
- [x] On map-reading error: save a screenshot locally to adapt the algo.
New `debug_capture.py` — `save_map_read_failure()` writes the PNG +
the solver's rejection reason under
`$XDG_DATA_HOME/fenigma/debug_captures/failures/`, wired into
`app.py`'s `_start_map_import`.
- [x] When the user corrects the grid, store screenshot + ground truth too.
`debug_capture.save_grid_correction()`, wired into `_accept_grid`:
fires only when the accepted `GridSolution` isn't the one auto-solve
produced (the user actually dragged a handle in GridFixDialog), saves
both solutions under `.../debug_captures/corrections/`.
- [x] Many map screenshots seem to get read as text; if nothing relevant is
found, also store the image to check whether it was actually a map.
`debug_capture.save_maybe_map()`, wired into `_ocr_png`: fires when a
screenshot (not a plain-text paste) fell through to the OCR/text path
and `_merge_all` found nothing at all. Saved under
`.../debug_captures/maybe_map/`.
- [x] Unable to parse 3 given chat messages (Infantry "taking fire" fire-
support requests).
A different grammar from the existing Marine Garrison fire-support
request: reversed shell word order ("Requesting X Shell" vs "X Shells
requested"), a bare "before/by <time>" deadline (no "Requested"/
dashes), and either a direct "on our position at <coord>" or a
bearing/distance offset from that same inline position (not a named
board entity, so resolved directly via
`solver.point_from_bearing_distance` rather than through a Clue).
New extractors in `ocr.py`, wired into `parse_intel_blocks`'s
`flush()`. 3 new regression tests, all passing (`tests/test_ocr.py`).
- [x] Follow-up bug in the above: the bearing/distance-offset variant
names TWO different places (the reporting unit's own position, and
a separate fire point offset from it), but only produced one Target
entity, sitting at the offset point but still labeled with the
unit's own type/id (e.g. "Infantry#11" at a spot no infantry is
actually at). Math itself was right; the single-entity shape wasn't.
Now produces two entries: the original (Infantry#N etc.) keeps its
own reported position with no shell/deadline, and a new synthetic
`Strike#<TypeWord><id>` entry (e.g. `Strike#Infantry11`) carries the
shell/deadline at the computed offset coord. 2 more regression tests.
- [x] When the user deletes/replaces the map screenshot, capture whatever
units they confirmed as ground truth for it.
`ScreenshotImport.baseline_targets`/`baseline_allies` (a snapshot of
`board.targets`/`board.allies` taken when the grid is confirmed,
`Target`/`Ally` are identity-hashable so these are plain sets of the
live objects) let `app.py` tell "added while this screenshot was up"
apart from "was already on the board". `Proposal` also now records
`confirmed_type` (what the user actually accepted it as, which can
differ from the detector's own guess via "Accept as..."). All of it
-- every proposal's accept/reject/undecided verdict, plus every
target/ally added with no matching proposal at all (a manual add or
an OCR-text merge run alongside the screenshot) -- is saved via
`debug_capture.save_marker_ground_truth()` under
`.../debug_captures/marker_ground_truth/`. Wired into all three
places a screenshot stops being "the active one": explicit drop, a
new screenshot pasted straight over it, and window close.
## Resolved via a real traceback (not guessed)
- [x] "Accept as" / "Accept all" on proposed targets doing nothing.
A real traceback from the running app nailed it: `StopIteration`
from `Board.add_ally`'s id auto-assignment once 26 allies existed
already (see the id-namespace regression entry above) — every
accept attempt after that silently died before the ally/target
ever got added, popover already closed by the time it happened.
Fixed there; not a separate bug.
- [x] "Accept as…" (the type-picker submenu on a proposal, and "Change
type" on an already-placed entity) opening to a visibly empty/
unchanged popover. This one left no traceback at all -- confirmed
live with temporary debug prints that the button's `clicked` signal
fires, the icon grid builds successfully (all N types), and
`Popover.set_child()` on the already-open outer popover reports the
right `visible=True`/width/height afterward... but the compositor
never actually repaints that reused surface, so nothing new ever
appeared on screen. Fixed by not resizing the existing open
popover at all: popping it down and opening a genuinely new one
(fresh native surface) at the same anchor point instead. Same fix
applied to both call sites (`_open_proposal_menu`'s `show_type`,
`_open_entity_menu`'s `show_type`, the latter refactored to share
the same `_reopen_with()` helper).
- [x] New: mark a Target as underground, at a hardening tier (1-3),
rendered as the game's own Armor-tier additive badge stacked on
the icon. `Target.underground_tier: int | None`, a "Mark
underground" entry in the entity-edit popover (tier picker reusing
the same fresh-popover fix above), and `GridCanvas` draws the
badge above the marker's icon, overlapping down into it by
`_ADDITIVE_OVERLAP_PX` -- both the diamond icon's top corner and
the badge's bottom are tapered to a near-point, not a flat edge,
so bbox-exact touching still read as a gap; a real pixel overlap
is what actually looks contiguous (confirmed against the game's
own stacked-badge screenshots). Badge is scaled/positioned off the
art's real opaque content (PIL `getbbox()`), not its PNG canvas --
the additive files carry a lot of off-center transparent padding
that made the badge look tiny and floating if sized off the raw
canvas.
## Needs more scope / your input before I keep going
- [~] Enemy type detection needs to be more robust; read the entity id
label so dedup is reliable; detect death from the log.
Started on the id-reading piece: `map_vision.read_marker_id` reads
each marker's own small "#<N>" label (distinct from the big
per-cell grid label `read_cell_label` reads) via the SAME template-
correlation approach as `read_cell_label`, not OCR -- this text
sits over the same aerial-photo backdrop that this module's own
docstring says defeated every detection-based approach tried for
grid labels, so pytesseract (already tried elsewhere in this repo,
`ocr.py`, for a different image domain: flat scanned paper, not
photo-textured) was skipped in favor of the approach already proven
here. Wired end-to-end: `find_markers` -> `Proposal.detected_id` ->
`debug_capture.save_marker_ground_truth`'s JSON. Reads against
`ScreenshotImport.full_image` (sharper than the WORK_W image
detection itself runs against) when available. Crop region and
`MIN_MARKER_ID_SCORE` are a single-screenshot calibration (see
`read_marker_id`'s own docstring) -- UNVALIDATED against a real
ground-truth batch (none of the 6 existing captures have a
confirmed id to check against, they all predate this). New unit
tests (`tests/test_map_vision_marker_id.py`) only cover the
synthetic-render round-trip, not real-screenshot accuracy.
Measured type-detection reliability against the 6 existing
`marker_ground_truth` captures (72 accepted proposals total,
2026-08-13): **0/72 (0%) had ANY confident `detected_unit` guess**
-- `classify_marker` returned `None` on every single one, every
side, every capture. Not "guesses wrong" -- never confident enough
to answer at all. Spot-checked directly against one real marker
crop (a hostile Infantry, confirmed by the user): best match was
"Underground Fort" at score 0.376 (Infantry wasn't even in the top
8), against a `min_score=0.55` floor `classify_marker` requires --
not a close miss, a real correlation failure. The clean rendered
icon templates `icon_bank()` matches against apparently don't
correlate well with how markers actually look in a real screenshot
(compression/blur/aerial-photo texture underneath), unlike text
glyphs (`read_cell_label`'s measured 0.73-0.87 vs 0.40-0.56) where
the same template-correlation idea works well. Added `unit_score`/
`unit_margin` to `Proposal`/ground-truth JSON (previously only
pass/fail `unit` was logged) so every future capture shows exactly
how far off a guess was, not just None -- there was no way to tell
"barely missed the bar" from "wildly wrong" before this.
Death-detection-from-log is still fully unstarted -- no log-parsing
code exists in this repo at all yet, real scope work (find/access
the game's log, agree a "<Type>#<id> Destroyed" grammar, wire it
into a dedup key) rather than a quick pass.
Follow-ups from user feedback after the above landed:
- `_accept_proposal` now actually USES `detected_id` (it was only
being logged before, never applied) -- an accepted proposal's
entity id prefers the detected number over auto-assignment,
falling back on a collision. 4 new regression tests
(`tests/test_app_accept_proposal.py`).
- Auto-assignment itself (`Board.add_target`/`add_ally` with no
`id_`/no usable detection) changed from one shared letter
sequence per group (targets, or allies) to its own sequence per
TYPE within each group -- Tank#A/Infantry#A rather than
Tank#A/Infantry#B. This directly reverses an earlier deliberate
fix in this same file (see the "Allies and enemies seem to share
indices" entry above, which moved FROM per-type TO
shared-per-group) -- that fix is still correct for what it fixed
(targets-vs-allies must stay separate namespaces), just not for
per-type-vs-shared, which the user has now clarified the other
way. Tests in `test_models.py` updated to match (renamed
`test_auto_id_is_shared_across_types...` ->
`test_auto_id_is_per_type...`, since it now asserts the opposite).
First pass at this ALSO switched auto-assignment from letters to
plain numbers (1/2/3...), reasoning that it should match what
`detected_id` looks like when read successfully. Wrong -- caught
by the user immediately: auto-assignment (no real id known, a
manual add or an accept with no confident read) and a genuinely
detected id need to stay visually distinct, or a made-up
auto-assigned number could collide with, or be mistaken for, a
real one. Reverted back to `_next_free_id` (letters, rolling
over to "AA"/"AB"/... past 26 rather than raising
`StopIteration`) as the auto-assignment fallback, scoped per
type same as above; plain numbers are reserved for an id
`_accept_proposal` is actually confident was read off the
marker itself (`Proposal.detected_id`), passed straight through
as `id_` and never touching auto-assignment at all.
- `_accept_proposal` now actually USES `detected_id` (it was only
being logged before, never applied) -- an accepted proposal's
entity id prefers the detected number over auto-assignment,
falling back on a collision (scoped per type, same bug fixed
in two places: this collision pre-check, and the "Change ID"
popover's own check, which still enforced the OLD shared-per-
group rule after the auto-assignment change above and rejected
valid renames across types). 4 new regression tests
(`tests/test_app_accept_proposal.py`).
- `detected_id` is now shown, not just logged: the proposal
popover's heading (", id #8") and the pending-proposal's own
on-map label (`? #8 G8 5:4`) both show it while there's still a
screenshot up to check it against by eye.
## Windows build (packaging/windows) -- real progress, not yet a clean pass
Booted the actual dockur/windows build VM and drove it live (VNC) to find
out what's really failing, rather than guessing from the README's own
"UNTESTED end to end" note. Three real, separate bugs found and fixed,
each confirmed live against the real VM, not just read off a diff:
- [x] The build watcher was never actually installed at all, despite
`install_progress.log` claiming every provisioning step succeeded.
`C:\OEM` (dockur's `/oem` staging dir) doesn't reliably persist past
Windows Setup finishing -- exactly what the (already-uncommitted,
now committed) `install.bat`/`watch_build.bat` fix diagnosed, just
never verified against a real run before now. A 2-day-old
`BUILD_REQUEST` had been sitting unclaimed the whole time. Manually
re-applied the fix's logic live once (copied the corrected files to
`C:\FenigmaBuild`, registered the Startup-folder entry) and
confirmed on a full container restart that the watcher now
auto-starts on login and picks up a pending request with zero
manual intervention -- the actual fix, not just my live patch, is
what's doing that.
- [x] `pip install pytesseract` fails outright: MSYS2's mingw64 Python
enforces PEP 668 ("externally-managed-environment"), which
`install.bat` never accounted for. Needs `--break-system-packages`.
- [x] `import fenigma.app` fails with `ModuleNotFoundError: No module
named 'cv2'` even after `pacman -S mingw-w64-x86_64-opencv`
succeeds -- that package is the C++ library only. The actual
Python bindings are a SEPARATE package, `mingw-w64-x86_64-
python-opencv`, that `install.bat`'s dependency list simply never
included. (`pip install opencv-python-headless` as a fallback
doesn't work either and shouldn't be relied on: MSYS2's mingw64
Python uses a different ABI than PyPI's Windows wheels
-- `cp314-mingw_x86_64_msvcrt_gnu` vs `win_amd64` -- so pip can
never use a prebuilt wheel there, only build from source, which
then needs a full separate native toolchain -ninja/cmake/gcc- this
VM doesn't have either.)
All three are one-line fixes once known. `install.bat`'s pacman package
list and pip install line need these applied for a from-scratch VM to
provision correctly (currently they're only proven fixed live on this
session's VM, not yet folded back into the committed `install.bat` --
do that before relying on a fresh `./build_windows.sh` run from
scratch).
With all three fixed, `import fenigma.app` succeeds and a real build
attempt got all the way through source copy, sanity check, dist-tree
assembly, and WiX harvest+compile (`candle.exe`) -- further than this
pipeline has ever gotten. Two more issues surfaced right at the finish
line:
- [x] `product.wxs`'s `Version` needs strict WiX `x.x.x.x` numeric
form -- a `0.1.0-test` version string (my own test invocation,
not `build_windows.sh`'s real default) fails `candle.exe` with
CNDL0108/CNDL0010. Not a real bug, just don't pass a version with
a suffix.
- [x] `light.exe` (final MSI linking) did not finish within 15 minutes
on a first retry (4 CPU / 8GB RAM VM) before the RAM-conscious
auto-shutdown killed it -- turned out to be genuinely just slow
(process was active, 343MB working set, not hung on a dialog),
not a real bug: retried with a 40-minute budget and it finished
`light.exe` itself in a few more minutes.
- [x] ...and then failed for a REAL reason right at the very end:
`light.exe`'s ICE80 validation rejected essentially every
harvested file -- "This 32BitComponent ... uses 64BitDirectory".
`product.wxs`'s own `INSTALLFOLDER` is correctly under
`ProgramFiles64Folder` (a 64-bit mingw64 toolchain is what's
actually being packaged), but nothing was making the components
agree -- a real, on-disk mismatch, not a transient VM issue.
First fix attempt (`-platform x64` on `heat.exe`'s harvest) was
WRONG -- re-verified live, identical ICE80 failures afterward
(confirmed the corrected `build.bat` had actually reached the VM
this time, ruling out a stale-copy repeat of the earlier watcher
bug). WiX v3's `heat.exe -platform` only affects registry-key
harvesting, it never stamps `Win64="yes"` on components. Real
fix: `-arch x64` on `candle.exe` (the COMPILE step, not the
harvest step) -- sets the default Win64/Platform for every
component compiled from either source file, hand-authored
(`product.wxs`) or harvested (`files.wxs`) alike, the standard
WiX v3 way to make a whole package consistently 64-bit. Kept the
harmless-but-insufficient `-platform x64` on `heat.exe` too.
**CONFIRMED live**, third attempt: `build_errorlevel=0`,
`BUILD_DONE`, and a real 958MB `FEnigma-0.1.0.msi` written to
`Z:\dist` -- the first ever fully successful build this pipeline
has produced. Copied to `dist-windows/FEnigma-0.1.0.msi` in the
repo root (gitignored, same as `build_windows.sh` itself would
do). NOT yet installed/launched on a real Windows machine to
confirm the app actually runs (see the "Not tested against a
real GTK4/libadwaita Windows install at all" line in this repo's
own `packaging/windows/README.md` -- still true, packaging
succeeding is not the same claim as the app working once
installed).
Follow-up, now that a clean build exists to measure against:
`light.exe` alone took ~15-18 minutes even with ICE80 fixed --
revisit the ~1GB+ bulk-copied mingw64 dist tree (README's own
"not lean" note) as a real perf issue, not just a packaging-
correctness one.
## OCR: new fire-support-request grammar gaps (from real user-pasted messages)
- [x] A "taking fire" report's reporting unit ("Infantry#11 taking
fire!...") was being added as a hostile Target, not a friendly
Ally -- see the id-scheme entry above for the "no Friendly/Hostile
prefix word exists in this grammar" root cause and the fix
(`_TAKING_FIRE_RE`, `force_ally`, and splitting the shell/deadline
into a synthetic StrikeRequest even for the no-offset "on our
position" case, which previously kept them on the entity itself --
fine when it was wrongly a Target, silently lost once correctly an
Ally, since Ally tuples carry no shell/deadline fields at all). 4
existing tests updated, all still passing plus the rest of the
suite (51 total).
- [ ] A multi-shell sequential request ("Requesting TEAR Shell first,
then HE Shell, at bearing...") only captures the FIRST shell
(TEAR) into the structured `shell` field -- "then HE Shell" isn't
parsed into anything. Less urgent than it first looked though: the
full original message text (both shells, in order) is already
preserved as-is and shown to the player via the coord dialog's
description view (`Location.desc_raw`, set from the same `raw`
every merged target/ally carries) -- nothing is silently LOST, it's
just not machine-parsed into a queryable second-shell field. Real
scope question before building that: does the board/firing-panel
data model even have a place to put a second shell for one strike
request today, or does this need a new field/shape entirely --
worth confirming it's actually wanted (vs. "read the raw text
yourself, it's right there") before spending the design effort.
- [x] "Answer by 10:30:00" turned out to be one bug, not two. The
phrasing itself was never the problem -- `_TAKING_FIRE_TIME_RE`
already matches any `before|by <time>`, "Answer BY 10:30:00"
included. The REAL bug: a same-message "Important: ..." follow-up
line was misread as a brand new named entity header (the
last-resort bare-`<name>:` fallback rule matched "Important:"
itself with nothing excluding common prose lead-ins), creating a
bogus `Target#Important` that stole "Answer by 10:30:00" into ITS
own `requested_time` instead of the real report's. Fixed with a
blocklist (`_BARE_NAME_HEADER_BLOCKLIST`: important/note/warning/
attention/caution/alert/reminder/priority) on that fallback rule --
once the phantom split stopped happening, the deadline resolved
onto the right entry with no separate fix needed. New regression
test, confirmed against the user's real pasted message (with an
assumed `Infantry#N taking fire!` header line prepended, since
their paste seems to have been cropped before it).
- [x] "\<Type\>#\<id\> Destroyed" kill-feed parsing already exists and
already marks the matching Target dead (`parse_destroyed`,
`_merge_targets`'s own destroyed-handling block in app.py) --
confirmed working end-to-end against a real 9-entry kill-feed
paste, including multi-word types ("Enemy Mechanized Infantry#2
Destroyed" correctly resolved to INFANTRY_MECHANIZED). This was
already-existing, working functionality, not something needing to
be built.
One real gap found in the same test: "Enemy Field Gun#1 Destroyed"
silently dropped. Two bugs stacked, both fixed:
- [x] "Field Gun" is just the game's own alt name for plain
Artillery (confirmed by the user directly) -- not a missing
unit type needing a new enum member/icon after all. Added to
`_TYPE_WORD_ALIASES` next to AmmoCache/CoastalBattery.
- [x] Even with that alias, it still didn't resolve: `_ALLY_PREFIX_RE`
only ever stripped a leading "Friendly"/"Hostile" word, never
"Enemy" -- so `squash_multiword_ids`'s "EnemyFieldGun#1"
token got alias-looked-up and fuzzy-matched as a WHOLE
("EnemyFieldGun" vs "Artillery", nowhere close), not just its
"FieldGun" part. "Enemy Mechanized Infantry#2" only ever
worked by fuzzy-match ACCIDENT (a long, distinctive type
string still clears the ratio threshold with "Enemy" stuck
to the front; a short, unrelated one like Artillery doesn't).
`_ALLY_PREFIX_RE` now strips "Enemy" too, with a lookahead
requiring something after it -- a BARE "Enemy#N" is
`TargetType.ENEMY` itself (its own value IS "Enemy"),
stripping unconditionally would've left an empty type_word
and broken every ad-hoc "Enemy#N Destroyed" report instead.
3 new regression tests, all passing (54 total).
Binary file not shown.
+50
View File
@@ -0,0 +1,50 @@
Copyright (c) 2013, Quote-Unquote Apps (http://quoteunquoteapps.com), with Reserved Font Name Courier Prime.
This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE
Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the copyright statement(s).
"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting, or substituting -- in part or in whole -- any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.
5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
+1 -1
View File
@@ -1,7 +1,7 @@
# Icons
Extracted from the game's own `GameAssets/Assets/Texture2D` (gitignored,
not redistributed as a whole), for reuse in FeNigma's UI rather than
not redistributed as a whole), for reuse in FEnigma's UI rather than
redrawing equivalents from scratch. Two source filenames had typos in
the game files themselves ("Frendly_", "Refrence_Point_"), corrected
here on copy; everything else keeps its original name.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

-165
View File
@@ -1,165 +0,0 @@
# Map screenshot vision pipeline: status and next-step plan
Goal: given a screenshot of the in-game map view (not the typewriter text), detect
enemy unit markers (red diamonds per the in-game legend) and resolve each one's
position (large grid + small grid) automatically, for import as targets
(position + id only, type stays `TargetType.UNKNOWN` for this first pass).
This is a *second*, entirely separate OCR/vision pipeline from `ocr.py`'s text
pipeline. `ocr.py`'s module docstring already flags this as future/out-of-scope
work; this doc is that future work's design record.
## Where the code lives right now
`docs/map_vision_wip.py` in this repo is a copy of the working prototype, as of
the end of this exploration session. It is **not wired into the app** and not
`src/fenigma/map_vision.py` yet — it's scratchpad-quality (developed and tested
against three sample screenshots via ad-hoc test scripts, not a proper test
suite). Treat it as a strong starting point, not finished code: variable names,
error handling, and docstrings need a pass before it belongs in `src/`.
## What's validated and working
1. **Grid label OCR** (`find_grid_labels`): sliding-window Tesseract sweep
across the image, filtered to the `[A-T](10|[1-9])` pattern
(e.g. `M8`, `Q10`). Works reasonably well on clean labels; struggles when a
colored hatch-line overlay crosses directly through a label's glyphs
(`L7` misread as `AF`, `N8` as `NWS`) — tried desaturating before OCR,
didn't help (recoloring the hatch line neutral still leaves a shape gap in
the letter). **Needs real inpainting** (fill the interrupted stroke from
surrounding pixels) to fix, not yet done.
2. **Pitch estimation** (`estimate_pitch`): derives cell pitch (both x and y)
from the actual pixel spacing between same-row / same-column labels. No
hardcoded pitch constant anywhere — this was an explicit, correct call-out
mid-session (an earlier version hardcoded `pitch=749` from eyeballing one
image; that doesn't generalize and was thrown out).
3. **Corner/intersection matching** (`find_crossing`, `cross_kernel`): given a
predicted pixel position for a grid-line intersection, finds the real one
nearby. This went through several broken iterations before landing on the
current approach — worth remembering *why* each earlier attempt failed,
so they don't get reinvented:
- Plain local brightness-percentile search: worked on one lucky isolated
line, completely unreliable elsewhere (confirmed via zoomed crop: a
claimed "corner" for `L8` sat in flat background, nowhere near any real
line).
- Generic `cv2.goodFeaturesToTrack`: fires on *any* strong corner, so it
reliably found the label's own text glyphs or a nearby diamond marker's
vertex instead of the grid intersection. Fixed by masking out the
label's own bounding box, then *also* masking out anything with high
HSV saturation (markers and hatching are deliberately colored; the grid
itself is neutral gray/white — a generalizable distinction, not
per-image tuning).
- Even with masking, a generic corner detector still isn't picking the
right *kind* of corner (a line's endpoint kink looks the same as a true
4-way crossing to it). Fixed by replacing it entirely with
`cross_kernel`: a matched filter shaped like a bright `+` (positive
along both a horizontal and vertical arm through the center, negative
in the four quadrant gaps) — a lone single-direction line only lights
up one arm and scores far below a true crossing. This is the piece
that actually made precise matches possible (verified to land within a
couple pixels of a manually-confirmed true corner).
- The matched-filter kernel size and the search-window size must both be
computed **from the current image's own pitch**, never a fixed pixel
constant — a kernel/window tuned for a 750px cell is meaningless on a
150px cell. `find_crossing` takes `pitch_x, pitch_y` and derives both
from them.
- Candidate selection within the window: originally multiplied the
matched-filter response by a tight Gaussian prior (centered on the
predicted position) *before* taking the max — this let a weak,
coincidentally-central false response beat a real, stronger crossing
nearby ("prior is too aggressive, matches predicted center" was the
exact bug report). Fixed by widening the Gaussian (`sigma = 1.5 *
window radius`) and only blending it in lightly (`0.85 + 0.15*prior`)
so the actual filter response does most of the selecting; the prior
now only functions as a mild tie-breaker plus a final plausibility
check on the winner, not the primary selection mechanism.
- The "plausible region" is a Gaussian, so it should be *drawn* as a
circle in diagnostics, not a rectangle — a rectangle visually implies a
hard cutoff that doesn't reflect the actual model. Fixed in the debug
visualization.
4. **Whole-grid crossing prediction** (`span_grid_crossings`): once we have
*any* labels (even 2-3), the grid is regular, so predict and test every
crossing across the visible frame, not just the ones adjacent to a label
that happened to OCR cleanly. This gives far more correspondence points
than "one per successfully-read label." Validated end to end on the
zoomed-out strategic-overview screenshot: 4 labels → 10 predicted
crossings → 4 matched → homography fit with all 4 as RANSAC inliers.
5. **Homography fit** (`fit_grid_homography`): `cv2.findHomography(...,
cv2.RANSAC, 15.0)`, requires 4+ points with real geometric diversity (2+
distinct columns AND 2+ distinct rows — `has_diversity`) before even
attempting a fit, refusing collinear/degenerate input rather than
producing garbage. This replaced an earlier rigid-rotation-only
(translation + single theta) model once it became clear the real screen
has genuine perspective/keystone distortion (parallel lines don't stay
parallel), confirmed by directly measuring the same line's x-position at
two widely-separated y-values and finding a real, consistent ~2-4° drift,
not noise.
## The known remaining bug, and the planned fix
**Bug**: with exactly 4 matched points, `cv2.findHomography` always fits them
*exactly* — 0 residual and "4/4 inliers" is true by construction and doesn't
mean the fit is actually good. Confirmed visually: a 4-point fit on 4 points
that happened to form a lopsided "staircase" in grid-space (missing two
corners of what should've been a proper 2×2 block) produced a visibly
skewed parallelogram instead of a rectangle, even though every individual
point matched its real intersection correctly. The 4 points were individually
right; the *set* was too small and too oddly-shaped to constrain the fit
meaningfully.
Root cause of *why* only 4/10 predicted crossings matched: `span_grid_crossings`
predicts every crossing from **one single reference label** using one global
pitch value for the whole image. Since perspective distortion is real, that
single global (origin, pitch) pair drifts further from the truth the farther a
predicted crossing is from the reference — a jump of several cells accumulates
several cells' worth of drift before the search window even starts looking.
(Some other misses were legitimately unfindable — busy photo texture with no
clean line at that exact spot, confirmed via a zoomed crop — but the long-range
extrapolation drift is the fixable, systematic part.)
### Planned fix: grid-growing (BFS) instead of batch prediction from one origin
Don't predict the whole grid from one fixed point. Walk outward one cell at a
time from every confirmed point, correcting the local estimate as you go:
1. **Seeds**: every label that OCR'd *and* corner-matched successfully is a
confirmed `(grid_col, grid_row) -> (pixel_x, pixel_y)` point. Multiple
seeds, not just the single highest-confidence label.
2. **Expand one cell at a time**: from each confirmed point, only ever
predict its *immediate* neighbor (one cell in one of the 4 directions) —
never extrapolate further than one cell from something already confirmed.
3. **Prefer local spacing over the global average**: if two confirmed points
already share a row (for a column step) or column (for a row step), use
*their* measured spacing to predict the next one out — that's the real
local pitch right there. Only fall back to the global pitch estimate for
the very first step away from a seed, where no local measurement exists
yet.
4. **Search, confirm, repeat**: run the same `find_crossing` search at that
one-cell-away prediction. Success → add to the confirmed set, push onto
the expansion frontier. Failure → that one edge just stops there, doesn't
block expansion from other confirmed points nearby.
5. Keep expanding (a plain BFS/queue over grid coordinates, with a
visited/attempted set so failed edges aren't retried forever) until the
frontier is empty. Feed every confirmed point into the homography fit —
likely dozens of points instead of 4, each individually short-range and
therefore much less exposed to long-range perspective drift, with enough
redundancy that the residual/inlier check from RANSAC actually means
something instead of being a vacuous exact-fit.
This is a genuine restructure of `span_grid_crossings`'s control flow (batch
prediction → BFS), not a parameter tweak. Implementing it is the next step
when this work resumes.
## Also still open (lower priority than the BFS fix)
- Label OCR robustness where hatching crosses the glyph (needs inpainting).
- Once the grid calibration is reliably robust: red-diamond blob detection
(color threshold + connected components) — not started.
- Matching each detected blob to its `#N` id label via nearby OCR — not
started.
- Wiring into `app.py`: a new button, merging results through the existing
`_merge_targets`-style flow as `TargetType.UNKNOWN` — not started.
-236
View File
@@ -1,236 +0,0 @@
"""Generalized (no per-image constants) map-screenshot grid calibration.
find_grid_labels() -> OCR sweep for visible large-grid labels.
estimate_pitch() -> derive cell pitch from label-to-label spacing.
find_label_corner()-> per-label corner search via a real corner detector
(cv2.goodFeaturesToTrack), window scaled to pitch.
fit_grid_homography() -> RANSAC homography from however many corners
were found, refusing to fit degenerate/thin data.
"""
import re
import cv2
import numpy as np
import pytesseract
LARGE_X = "ABCDEFGHIJKLMNOPQRST"
LABEL_RE = re.compile(r'^([A-T])(10|[1-9])$')
ANCHOR_FRAC_X = 0.10
ANCHOR_FRAC_Y = 0.15
def desaturate(rgb):
"""Replace saturated (colored) pixels with a neutral gray of the same
brightness. The hatching overlay and unit markers are drawn in
deliberately saturated colors while the grid + its labels are neutral
white/cream on a grayscale photo, hatch lines crossing straight
through a label glyph otherwise corrupt its shape enough to break
OCR (seen: 'L7' -> 'AF', 'N8' -> 'NWS')."""
hsv = cv2.cvtColor(rgb, cv2.COLOR_RGB2HSV)
sat = hsv[:, :, 1]
gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
out = gray.copy()
return out, sat
def find_grid_labels(gray, band_height=160, band_step=60, scale=3.0, min_conf=30):
H, W = gray.shape
config = "--psm 11 -c tessedit_char_whitelist=ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
found = {}
for y0 in range(0, H, band_step):
y1 = min(y0 + band_height, H)
band = gray[y0:y1, :]
big = cv2.resize(band, None, fx=scale, fy=scale, interpolation=cv2.INTER_CUBIC)
data = pytesseract.image_to_data(big, output_type=pytesseract.Output.DICT, config=config)
for i, txt in enumerate(data['text']):
t = txt.strip()
m = LABEL_RE.match(t)
if not m or int(data['conf'][i]) < min_conf:
continue
conf = int(data['conf'][i])
key = t
if key not in found or conf > found[key]['conf']:
found[key] = dict(
text=t, letter=m.group(1), number=int(m.group(2)),
x=data['left'][i]/scale, y=y0 + data['top'][i]/scale,
w=data['width'][i]/scale, h=data['height'][i]/scale, conf=conf,
)
return list(found.values())
def estimate_pitch(labels):
xs_by_row, ys_by_col = {}, {}
for lb in labels:
xs_by_row.setdefault(lb['number'], []).append((LARGE_X.index(lb['letter']), lb['x']+lb['w']/2))
ys_by_col.setdefault(lb['letter'], []).append((lb['number'], lb['y']+lb['h']/2))
px = []
for pts in xs_by_row.values():
pts.sort()
for (ci,xi),(cj,xj) in zip(pts, pts[1:]):
if cj > ci:
px.append((xj-xi)/(cj-ci))
py = []
for pts in ys_by_col.values():
pts.sort()
for (ri,yi),(rj,yj) in zip(pts, pts[1:]):
if rj != ri:
py.append(abs(yj-yi)/abs(rj-ri))
pitch_x = float(np.median(px)) if px else None
pitch_y = float(np.median(py)) if py else None
if pitch_x and not pitch_y:
pitch_y = pitch_x
if pitch_y and not pitch_x:
pitch_x = pitch_y
return pitch_x, pitch_y
def cross_kernel(size, arm_width):
"""Matched filter for a bright axis-aligned line CROSSING in both
directions through the center, not just any corner-like feature. A
generic corner detector (Shi-Tomasi/Harris) fires just as happily on
a diamond marker's vertex or a single line's endpoint kink as on a
real grid intersection, this is specific to the one shape we
actually want: positive along both the horizontal and vertical arm,
negative in the four quadrant gaps between them (a lone single-
direction line only lights up one arm and loses on the other three
quadrants plus the missing arm, scoring far below a true crossing).
size/arm_width are in PIXELS, computed by the caller from the
current image's own pitch, never a fixed constant, a 25px kernel
tuned for a 750px cell is meaningless on a 150px cell."""
size = max(int(size) | 1, 9) # odd, sane minimum
arm_width = max(int(round(arm_width)), 1)
k = np.full((size, size), -1.0, dtype=np.float32)
c = size // 2
half = arm_width // 2
k[c-half:c+half+1, :] = 1.0
k[:, c-half:c+half+1] = 1.0
k -= k.mean()
k /= np.abs(k).sum()
return k
def find_crossing(gray, hsv_sat, guess_x, guess_y, pitch_x, pitch_y, window_frac=0.12,
label_bbox=None, label_margin=6, sat_thresh=60, min_score=0.12):
"""Locate the grid-line intersection nearest this label's anchor
guess, by convolving a cross/intersection matched filter (see
cross_kernel()) against a local, saturation-masked, label-masked
window, then taking the response peak, weighted by a Gaussian
falloff in distance from the guess so a stronger-but-farther false
cross elsewhere in the window doesn't win over the real, closer one.
window_frac is deliberately small: the anchor guess (from label
position + pitch, itself derived from real label spacing) should
already be close, a small margin covers its own slop without
covering enough area to catch an unrelated intersection."""
H, W = gray.shape
# search window and match kernel both scale off THIS image's pitch,
# not fixed pixel constants, so this works whether a cell is 150px or
# 750px across. Kernel arm needs to reach far enough to genuinely
# distinguish 'line extends in this direction' from noise, but must
# stay smaller than the window it slides within.
wx, wy = max(window_frac*pitch_x, 12), max(window_frac*pitch_y, 12)
x0, x1 = max(0,int(guess_x-wx)), min(W,int(guess_x+wx))
y0, y1 = max(0,int(guess_y-wy)), min(H,int(guess_y+wy))
kernel_size = max(int(0.9 * min(wx, wy)), 7)
kernel_arm = max(pitch_x, pitch_y) * 0.01
cross = cross_kernel(kernel_size, kernel_arm)
blur_kernel = max(int(kernel_arm * 4) | 1, 5)
if x1-x0 < cross.shape[1] or y1-y0 < cross.shape[0]:
return None, (guess_x, guess_y), (x0,y0,x1,y1)
roi = gray[y0:y1, x0:x1].astype(np.float32)
baseline = cv2.medianBlur(gray[y0:y1, x0:x1], blur_kernel).astype(np.float32)
excess = np.clip(roi - baseline, 0, 60)
sat_roi = hsv_sat[y0:y1, x0:x1]
excess[sat_roi > sat_thresh] = 0
if label_bbox is not None:
lx0 = int(label_bbox['x']) - label_margin - x0
ly0 = int(label_bbox['y']) - label_margin - y0
lx1 = int(label_bbox['x'] + label_bbox['w']) + label_margin - x0
ly1 = int(label_bbox['y'] + label_bbox['h']) + label_margin - y0
lx0, ly0 = max(0, lx0), max(0, ly0)
lx1, ly1 = min(excess.shape[1], lx1), min(excess.shape[0], ly1)
if lx1 > lx0 and ly1 > ly0:
excess[ly0:ly1, lx0:lx1] = 0
response = cv2.filter2D(excess, -1, cross)
half = cross.shape[0] // 2
response[:half, :] = -1e9; response[-half:, :] = -1e9
response[:, :half] = -1e9; response[:, -half:] = -1e9
# let the matched-filter response do the actual selecting (find the
# strongest genuine cross in the window), the Gaussian prior only
# nudges among near-tied candidates and sanity-checks the winner
# isn't implausibly far from the guess, it was previously multiplied
# straight into the per-pixel score, which let a weak-but-central
# false response beat a real, stronger crossing nearby.
yy, xx = np.mgrid[0:response.shape[0], 0:response.shape[1]].astype(np.float32)
gx, gy = guess_x - x0, guess_y - y0
sigma = 1.5 * min(wx, wy)
prior = np.exp(-((xx-gx)**2 + (yy-gy)**2) / (2*sigma**2))
weighted = response * (0.85 + 0.15*prior)
py, px = np.unravel_index(np.argmax(weighted), weighted.shape)
peak_response = response[py, px]
if peak_response < min_score * np.abs(cross).sum() * 60:
return None, (guess_x, guess_y), (x0,y0,x1,y1)
return (float(px+x0), float(py+y0)), (guess_x, guess_y), (x0,y0,x1,y1)
def find_label_corner(gray, hsv_sat, label, pitch_x, pitch_y, **kw):
guess_x = label['x'] - ANCHOR_FRAC_X*pitch_x
guess_y = label['y'] - ANCHOR_FRAC_Y*pitch_y
return find_crossing(gray, hsv_sat, guess_x, guess_y, pitch_x, pitch_y, label_bbox=label, **kw)
def span_grid_crossings(gray, hsv_sat, labels, pitch_x, pitch_y, margin_cells=1):
"""Once we have a rough pitch/origin from however many labels OCR'd
(even just 2-3), the whole grid is regular, so predict and directly
test EVERY crossing across the visible frame, not just the ones next
to a label that happened to be readable. Returns (ideal_pts,
img_pts, debug) for every crossing that matched; a bad/outlier label
just contributes points that RANSAC discards downstream rather than
limiting how much of the grid we ever attempt.
dc = column offset in cells from the reference label. dr = offset in
cells DOWN the image (increasing y) from the reference, so it moves
opposite to row number (row 8 sits above row 7 on screen): the ideal
row coordinate is -(ref_number - dr) = dr - ref_number, matching the
(col, -row_number) convention used everywhere else in this module."""
H, W = gray.shape
if not labels:
return [], [], []
ref = max(labels, key=lambda lb: lb['conf'])
ref_col = LARGE_X.index(ref['letter'])
ref_corner_x = ref['x'] - ANCHOR_FRAC_X*pitch_x
ref_corner_y = ref['y'] - ANCHOR_FRAC_Y*pitch_y
dc_lo = int(np.floor((0 - ref_corner_x) / pitch_x)) - margin_cells
dc_hi = int(np.ceil((W - ref_corner_x) / pitch_x)) + margin_cells
dr_lo = int(np.floor((0 - ref_corner_y) / pitch_y)) - margin_cells
dr_hi = int(np.ceil((H - ref_corner_y) / pitch_y)) + margin_cells
ideal_pts, img_pts, debug = [], [], []
for dc in range(dc_lo, dc_hi + 1):
for dr in range(dr_lo, dr_hi + 1):
gx = ref_corner_x + dc * pitch_x
gy = ref_corner_y + dr * pitch_y
if not (0 <= gx < W and 0 <= gy < H):
continue
corner, guess, window = find_crossing(gray, hsv_sat, gx, gy, pitch_x, pitch_y)
debug.append((guess, corner, window))
if corner is not None:
ideal_pts.append((ref_col + dc, dr - ref['number']))
img_pts.append(corner)
return ideal_pts, img_pts, debug
def has_diversity(ideal_pts):
cols = set(p[0] for p in ideal_pts)
rows = set(p[1] for p in ideal_pts)
return len(cols) >= 2 and len(rows) >= 2
def fit_grid_homography(ideal_pts, img_pts):
if len(ideal_pts) < 4 or not has_diversity(ideal_pts):
return None
ideal = np.array(ideal_pts, dtype=np.float32)
img = np.array(img_pts, dtype=np.float32)
Hmat, mask = cv2.findHomography(ideal, img, cv2.RANSAC, 15.0)
return Hmat, mask
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Set up FeNigma: a venv for the pip deps, plus checks for the system
# Set up FEnigma: a venv for the pip deps, plus checks for the system
# packages that can't come from pip (GTK4/libadwaita bindings, tesseract).
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
+96
View File
@@ -0,0 +1,96 @@
# Windows .msi build (via dockur/windows)
Builds a Windows installer for FEnigma on a Linux host with no Windows
machine and no GitHub, by booting a real Windows VM inside a container
([dockur/windows](https://github.com/dockur/windows), QEMU+KVM under the
hood, no license key needed for the eval install it fetches automatically)
and driving the whole build over a shared folder.
**Status: has actually been run.** Two real environment issues hit and
fixed so far (both already applied in this directory, see "Confirmed
issues hit" below); Windows install itself was still in progress as of
last check. `oem/install.bat` onward (MSYS2/GTK4/WiX provisioning, the
actual .msi packaging) has NOT been reached/exercised yet — treat that
part as still a debugging session, not a push-button success. Watch it
happen at http://localhost:8006 (dockur's noVNC viewer) if it stalls.
## Confirmed issues hit (on Fedora + BTRFS)
- **SELinux blocks the bind mounts** ("Storage folder (/storage) is not
writeable!") — fixed with `:Z` on every volume in `docker-compose.yml`.
Harmless no-op on a host without SELinux.
- **The repeated "loading/starting Boot0004" log lines are NOT a boot
loop** — that was an earlier misdiagnosis here (blamed on BTRFS+QEMU,
"fixed" with `chattr +C` on `storage/`). Checked the actual noVNC
screen instead of just the text log and it was genuine, progressing
Windows Setup the whole time ("This might take a few minutes" →
"Please keep your PC on and plugged in" → desktop) — Setup legitimately
reboots the VM multiple times, each one re-prints those same firmware
log lines. The `chattr +C` disables copy-on-write for the VM's disk
image regardless (a real, independently-documented dockur/QEMU/BTRFS
caveat), so it's staying, but it likely wasn't fixing an actual
problem this time. **Lesson: check the screen, not just the log,
before concluding something's stuck.**
- **The build watcher registered as a SYSTEM-context Scheduled Task
never fires in practice**: `Z:\` (the `/shared` mount) is mapped per
interactive session, invisible to a task with no session of its own,
so it spun forever on `watch_build.bat`'s own `if not exist Z:\`
wait — confirmed by a build request sitting unclaimed for hours.
Fixed in `install.bat`: an All-Users Startup-folder entry instead,
which runs in whichever user's session actually logs in.
## How it fits together
- `docker-compose.yml` — boots the VM. Needs `/dev/kvm` on the host.
- `oem/install.bat` — **one-time** provisioning, auto-run by Windows's own
unattended setup on first boot (dockur/windows's `/oem` mechanism):
installs MSYS2, then GTK4/libadwaita/PyGObject/numpy/Pillow/OpenCV/
Tesseract through it, plus the WiX v3 toolset, and registers a
boot-time watcher task. This is the slow part (Windows install itself,
then package downloads) and only ever happens once — it lives on the
VM's persistent disk (`./storage`, gitignored) from then on.
- `oem/watch_build.bat` — runs at every boot from here on, polls the
shared `Z:\` drive for a build request.
- `oem/build.bat` — the actual per-build packaging: assembles a dist tree
(bundled MSYS2 `mingw64` runtime + the `fenigma` package), harvests it
into WiX components with `heat.exe`, and links it into an `.msi` with
`candle.exe`/`light.exe`.
- `oem/product.wxs` — the hand-authored shell around that harvested file
list: install directory, Start Menu shortcut, and the `PYTHONPATH`
environment variable the shortcut needs (mirrors `run.sh`'s
`PYTHONPATH=src python -m fenigma.app`).
- `build_windows.sh` — run this. Starts the VM, copies `../../src` onto
the shared folder, drops a request file, waits for the `.msi` to come
back, copies it to `../../dist-windows/`.
## Running it
```bash
cd packaging/windows
./build_windows.sh [version]
```
First run: full unattended Windows install + provisioning, likely
30-90 minutes, unattended (no interaction needed, but it needs to
actually finish — don't kill it early). Every run after that: just boot
the already-provisioned VM and build, a few minutes.
Requires `/dev/kvm` (virtualization enabled, your user in the `kvm`
group) and Docker with Compose.
## Known rough edges / likely follow-up work
- **The dist tree is fat, not lean.** `build.bat` bulk-copies the entire
`mingw64/` runtime rather than tracing the actual DLL/typelib/icon-
theme/schema dependency closure of the app — reliable, but probably
1GB+. Trimming it (e.g. by walking `pythonw.exe`'s and the compiled
extension modules' actual dependencies) is a real but separate project.
- **`heat.exe`'s default harvest options are a starting guess** for a
tree this large and this GTK-specific (icon caches, gschemas, typelibs);
it may need `-t` transforms or manual exclusions to produce a working
component set.
- **Not tested against a real GTK4/libadwaita Windows install at all** —
MSYS2 ships these, but this is the first time this specific app has
been pointed at them; expect a missing-DLL or schema error on first
actual launch, not just a packaging error.
- No code signing — Windows will show an "unknown publisher" warning.
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# Build a Windows .msi for FEnigma, entirely on this Linux host, no
# Windows machine or GitHub required: boots a real Windows VM inside a
# container (dockur/windows, QEMU+KVM), provisions it once (MSYS2 +
# GTK4/libadwaita/PyGObject + WiX, see oem/install.bat), then drives every
# build over a shared folder -- drop a request, wait for the .msi to show
# up.
#
# UNTESTED end to end (no KVM/Windows available in the environment this
# was written in) -- expect to debug oem/*.bat and product.wxs against a
# real run. Watch the first boot/install at http://localhost:8006 (noVNC)
# to see what's actually happening; it also has RDP on :3389 if you'd
# rather use a real RDP client.
#
# First run: full unattended Windows install + provisioning, likely
# 30-90 minutes. Every run after that: just boot + build, a few minutes.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
REPO_ROOT="$(cd .. && cd .. && pwd)"
VERSION="${1:-0.1.0}"
TIMEOUT_S="${BUILD_TIMEOUT_S:-7200}" # generous: covers a from-scratch first run
OUT_DIR="${REPO_ROOT}/dist-windows"
if [ ! -e /dev/kvm ]; then
echo "No /dev/kvm -- dockur/windows needs KVM (check virtualization is" >&2
echo "enabled and your user is in the 'kvm' group: groups | grep kvm)." >&2
exit 1
fi
command -v docker >/dev/null 2>&1 || { echo "docker not found." >&2; exit 1; }
mkdir -p storage oem shared/src shared/dist "$OUT_DIR"
# dockur/windows itself warns about this ("you are using the BTRFS
# filesystem for /storage, this might introduce issues with Windows
# Setup!") and it's not idle: confirmed on this host as a genuine
# multi-hour Windows Setup boot-loop (repeating the same boot-manager
# log lines forever, disk barely growing) -- QEMU disk images on a
# copy-on-write filesystem are a known bad combination. +C only takes
# effect for files created AFTER it's set on an empty directory, so
# this only helps on a fresh/emptied storage/; it's a no-op (harmless,
# chattr just errors quietly) on a non-btrfs filesystem or an
# already-populated storage/ from a previous run.
chattr +C storage 2>/dev/null || true
echo "==> starting the Windows build VM (docker compose up -d)"
docker compose up -d
echo "==> syncing FEnigma source into the VM's shared folder"
rm -rf shared/src
mkdir -p shared/src
cp -r "${REPO_ROOT}/src" shared/src/
echo "$VERSION" > shared/BUILD_VERSION
rm -f shared/BUILD_DONE shared/BUILD_FAILED
rm -rf shared/dist
mkdir -p shared/dist
echo "==> requesting a build (version $VERSION)"
touch shared/BUILD_REQUEST
echo "==> waiting for it (up to ${TIMEOUT_S}s -- first run is slow, see"
echo " this script's own header comment; watch http://localhost:8006"
echo " if you want to see what's actually happening)"
elapsed=0
while [ ! -e shared/BUILD_DONE ] && [ ! -e shared/BUILD_FAILED ]; do
if [ "$elapsed" -ge "$TIMEOUT_S" ]; then
echo "Timed out after ${TIMEOUT_S}s waiting for the build." >&2
echo "Check the VM directly (http://localhost:8006) -- it may still" >&2
echo "be mid Windows-install, or oem/install.bat may have wedged." >&2
exit 1
fi
sleep 10
elapsed=$((elapsed + 10))
printf '.'
done
echo
if [ -e shared/BUILD_FAILED ]; then
echo "==> build FAILED. Log:" >&2
cat shared/dist/build.log 2>/dev/null || cat shared/build.log.failed 2>/dev/null || true
exit 1
fi
msi="$(find shared/dist -maxdepth 1 -name '*.msi' | head -n1)"
if [ -z "$msi" ]; then
echo "BUILD_DONE appeared but no .msi found in shared/dist -- see" >&2
echo "shared/dist/build.log" >&2
exit 1
fi
cp "$msi" "$OUT_DIR/"
echo "==> done: $OUT_DIR/$(basename "$msi")"
+37
View File
@@ -0,0 +1,37 @@
# Boots a real Windows VM inside a container via dockur/windows (QEMU+KVM
# under the hood, no Windows license/key needed for the eval install it
# fetches automatically). Persistent disk lives in ./storage, so the
# one-time provisioning in oem/install.bat only ever runs once -- every
# later `docker compose up` just boots the already-provisioned VM.
#
# Requires /dev/kvm on the host (check with: ls -la /dev/kvm, and that
# your user is in the `kvm` group).
#
# Volumes use the :Z suffix (SELinux relabeling for a container-private
# label) -- confirmed needed on this host (Fedora, SELinux enforcing):
# without it dockur/windows refuses to start with "Storage folder
# (/storage) is not writeable!" even though normal Unix permissions are
# fine. Harmless no-op on a host without SELinux.
services:
windows:
image: dockurr/windows
container_name: fenigma-windows-builder
environment:
VERSION: "11" # Windows 11 Pro, fetched+installed unattended on first boot
RAM_SIZE: "8G"
CPU_CORES: "4"
DISK_SIZE: "80G" # MSYS2 + GTK4/libadwaita + WiX + build tree eats more than the 64G default
devices:
- /dev/kvm
- /dev/net/tun
cap_add:
- NET_ADMIN
ports:
- "8006:8006" # noVNC web viewer, http://localhost:8006 -- watch the first install here
- "3389:3389/tcp" # RDP, if you'd rather use an RDP client
volumes:
- ./storage:/storage:Z # persistent VM disk
- ./oem:/oem:Z # one-time provisioning payload, copied to C:\OEM on first install
- ./shared:/shared:Z # live exchange folder, appears as Z:\ in Windows
stop_grace_period: 2m
restart: unless-stopped
+100
View File
@@ -0,0 +1,100 @@
@echo off
REM Actual per-build packaging. Triggered by watch_build.bat once
REM install.bat's one-time provisioning has already put MSYS2/GTK4/
REM libadwaita/WiX in place. Reads source from Z:\src, writes
REM FEnigma-<version>.msi to Z:\dist, and Z:\BUILD_DONE (or
REM Z:\BUILD_FAILED, with the log copied alongside it) when finished.
REM
REM UNTESTED (see install.bat's note) -- the WiX harvest/link step in
REM particular is likely to need iteration: bulk-copying all of
REM mingw64\ is the "make it work first" approach, not a lean one, and
REM heat.exe's default harvest options may need tuning to actually
REM produce a working component set for a tree this size.
setlocal enabledelayedexpansion
set LOG=Z:\build.log
echo [build.bat] starting > %LOG%
if exist Z:\BUILD_VERSION (
set /p APPVER=<Z:\BUILD_VERSION
) else (
set APPVER=0.1.0
)
echo [build.bat] version %APPVER% >> %LOG%
rd /s /q C:\build 2>nul
mkdir C:\build\src
mkdir C:\build\dist\src
mkdir C:\build\dist\mingw64
echo [build.bat] copying source from Z:\src ... >> %LOG%
xcopy /e /i /q Z:\src C:\build\src >> %LOG% 2>&1
echo [build.bat] sanity import check ... >> %LOG%
set PYTHONPATH=C:\build\src\src
C:\msys64\mingw64\bin\python3.exe -c "import fenigma.app" >> %LOG% 2>&1
if errorlevel 1 (
echo [build.bat] FAILED: fenigma.app failed to import, see log >> %LOG%
copy %LOG% Z:\build.log.failed >nul
echo FAILED > Z:\BUILD_FAILED
exit /b 1
)
echo [build.bat] assembling dist tree ... >> %LOG%
xcopy /e /i /q C:\build\src\src C:\build\dist\src >> %LOG% 2>&1
REM Bulk-copy the whole mingw64 runtime rather than hand-tracing the DLL/
REM typelib/icon-theme/schema dependency closure -- bloated (likely 1GB+)
REM but reliable; trimming this down is a known follow-up, not attempted
REM here (see this file's top-of-file note).
robocopy C:\msys64\mingw64 C:\build\dist\mingw64 /e /xd include share\doc share\man share\gtk-doc /nfl /ndl /njh /njs >> %LOG% 2>&1
echo [build.bat] harvesting WiX components ... >> %LOG%
REM -platform x64 alone is NOT enough (confirmed live: still every
REM component ICE80'd afterward) -- WiX v3 heat.exe's -platform flag
REM doesn't actually stamp Win64="yes" on harvested components itself,
REM it only affects registry-key harvesting. Kept anyway (harmless,
REM correct in spirit), but the real fix is candle.exe's -arch x64
REM below, see its own comment.
C:\wix\heat.exe dir C:\build\dist -platform x64 -cg AppFiles -gg -scom -sreg -sfrag -srd -sw5150 -dr INSTALLFOLDER -var var.DistDir -out C:\build\files.wxs >> %LOG% 2>&1
if errorlevel 1 (
echo [build.bat] FAILED: heat.exe harvest failed >> %LOG%
copy %LOG% Z:\build.log.failed >nul
echo FAILED > Z:\BUILD_FAILED
exit /b 1
)
copy /y C:\FenigmaBuild\product.wxs C:\build\product.wxs >nul
echo [build.bat] compiling (candle) ... >> %LOG%
REM -arch x64: the actual fix for the ICE80 "32BitComponent uses
REM 64BitDirectory" failure (confirmed live -- heat.exe's own -platform
REM x64 above does NOT set this, only affects registry harvesting).
REM -arch sets the default Win64/Platform for every component compiled
REM from EITHER source file, hand-authored (product.wxs) or harvested
REM (files.wxs) alike, without needing per-component authoring -- the
REM standard WiX v3 way to make a whole package consistently 64-bit,
REM matching product.wxs's own ProgramFiles64Folder.
C:\wix\candle.exe -arch x64 -dDistDir=C:\build\dist -dAppVersion=%APPVER% -out C:\build\ C:\build\product.wxs C:\build\files.wxs >> %LOG% 2>&1
if errorlevel 1 (
echo [build.bat] FAILED: candle.exe failed >> %LOG%
copy %LOG% Z:\build.log.failed >nul
echo FAILED > Z:\BUILD_FAILED
exit /b 1
)
echo [build.bat] linking (light) ... >> %LOG%
C:\wix\light.exe -ext WixUIExtension -sice:ICE60 -sice:ICE61 -out C:\build\FEnigma-%APPVER%.msi C:\build\product.wixobj C:\build\files.wixobj >> %LOG% 2>&1
if errorlevel 1 (
echo [build.bat] FAILED: light.exe failed >> %LOG%
copy %LOG% Z:\build.log.failed >nul
echo FAILED > Z:\BUILD_FAILED
exit /b 1
)
if not exist Z:\dist mkdir Z:\dist
copy /y C:\build\FEnigma-%APPVER%.msi Z:\dist\ >> %LOG% 2>&1
copy /y %LOG% Z:\dist\build.log >nul
echo [build.bat] done >> %LOG%
echo DONE > Z:\BUILD_DONE
endlocal
+118
View File
@@ -0,0 +1,118 @@
@echo off
REM One-time provisioning, auto-run by dockur/windows during the final step
REM of Windows's own unattended setup (see its README's /oem mechanism).
REM Everything here happens exactly once and lands on the VM's persistent
REM disk -- later builds just boot this already-provisioned VM and run
REM build.bat, no re-provisioning.
REM
REM UNTESTED end to end: written from MSYS2's documented CI bootstrap
REM sequence (the same one msys2/setup-msys2 uses) and WiX's own docs, not
REM verified against a live dockur/windows boot. Expect to debug this on
REM the actual first run -- watch it happen at http://localhost:8006.
REM
REM Every step also echoes to Z:\install_progress.log (best-effort, only
REM if the Z:\ shared drive happens to be up already at this point in
REM setup) purely so build_windows.sh on the host has SOMETHING to show
REM besides silence during the one-time provisioning run.
setlocal enabledelayedexpansion
call :log "starting FEnigma build-VM provisioning"
REM -- Stage the OTHER oem/ files somewhere that outlives C:\OEM itself,
REM done first, before anything else. Confirmed on a real run: C:\OEM
REM (dockur's /oem copy target) does NOT reliably persist once Windows
REM Setup finishes and you're at the desktop -- it's fundamentally a
REM Windows Setup-time staging mechanism ($OEM$ folders, copied by WinPE
REM "right after the Windows image is applied ... and before the first
REM reboot" per Microsoft's own docs), not guaranteed permanent storage,
REM and in practice `dir C:\OEM` came back "File Not Found" once actually
REM checked from an interactive desktop session. build.bat/product.wxs/
REM watch_build.bat all get referenced again AFTER install.bat's own
REM process has exited (by the Startup-folder entry below, potentially
REM much later), so they need a home install.bat itself controls and
REM knows persists -- a plain folder on C:, not the OEM staging area.
mkdir C:\FenigmaBuild 2>nul
copy /y C:\OEM\build.bat C:\FenigmaBuild\build.bat >> C:\OEM\install.log 2>&1
copy /y C:\OEM\product.wxs C:\FenigmaBuild\product.wxs >> C:\OEM\install.log 2>&1
copy /y C:\OEM\watch_build.bat C:\FenigmaBuild\watch_build.bat >> C:\OEM\install.log 2>&1
REM -- MSYS2: the "base" self-extracting archive, not the GUI installer --
REM (the GUI installer has no reliable non-interactive/silent flag across
REM versions; the base sfx archive is what CI pipelines actually use).
REM Discover the current filename by scraping the repo listing, since it's
REM datestamped and there's no stable "latest" URL.
call :log "finding current MSYS2 base archive..."
powershell -NoProfile -Command ^
"$ProgressPreference='SilentlyContinue';" ^
"$html = Invoke-WebRequest -Uri 'https://repo.msys2.org/distrib/x86_64/' -UseBasicParsing;" ^
"$name = ($html.Links | Where-Object { $_.href -match '^msys2-base-x86_64-.*\.sfx\.exe$' } | Select-Object -Last 1).href;" ^
"Invoke-WebRequest -Uri ('https://repo.msys2.org/distrib/x86_64/' + $name) -OutFile 'C:\msys2-base.sfx.exe' -UseBasicParsing"
if not exist C:\msys2-base.sfx.exe (
call :log "FAILED: could not download MSYS2 base archive"
exit /b 1
)
call :log "extracting MSYS2 to C:\msys64 ..."
C:\msys2-base.sfx.exe -y -oC:\ >> C:\OEM\install.log 2>&1
del C:\msys2-base.sfx.exe
REM First bash launch finalizes the base install and kills itself off
REM mid-update (documented MSYS2 behavior) -- run it, ignore its exit
REM code, then run the real update.
call :log "bootstrapping MSYS2 (pacman -Syuu, twice) ..."
C:\msys64\usr\bin\bash.exe -lc "exit 0" >> C:\OEM\install.log 2>&1
C:\msys64\usr\bin\bash.exe -lc "pacman -Syuu --noconfirm" >> C:\OEM\install.log 2>&1
C:\msys64\usr\bin\bash.exe -lc "pacman -Syuu --noconfirm" >> C:\OEM\install.log 2>&1
call :log "installing GTK4/libadwaita/PyGObject/build deps ..."
REM mingw-w64-x86_64-opencv is the C++ library ONLY -- confirmed live on a
REM real VM that `import cv2` fails without it, the actual Python bindings
REM are the separate mingw-w64-x86_64-python-opencv package. Don't try to
REM paper over a missing one with `pip install opencv-python-headless`
REM either: MSYS2's mingw64 Python uses a different ABI than PyPI's Windows
REM wheels (cp3XX-mingw_x86_64_msvcrt_gnu vs win_amd64), so pip can never
REM use a prebuilt wheel there, only build from source, which then needs a
REM full separate native toolchain (ninja/cmake/gcc) this VM doesn't have.
C:\msys64\usr\bin\bash.exe -lc "pacman -S --noconfirm --needed mingw-w64-x86_64-python mingw-w64-x86_64-python-pip mingw-w64-x86_64-python-gobject mingw-w64-x86_64-gtk4 mingw-w64-x86_64-libadwaita mingw-w64-x86_64-python-numpy mingw-w64-x86_64-python-pillow mingw-w64-x86_64-opencv mingw-w64-x86_64-python-opencv mingw-w64-x86_64-tesseract-ocr" >> C:\OEM\install.log 2>&1
call :log "pip install pytesseract (pure python, no wheel needed) ..."
REM --break-system-packages: MSYS2's mingw64 Python enforces PEP 668
REM ("externally-managed-environment"), confirmed live -- a plain
REM `pip install` here fails outright without this flag. Safe here: this
REM VM's whole mingw64 Python install exists only to run FEnigma, there's
REM no system package manager relying on it staying untouched.
C:\msys64\mingw64\bin\python3.exe -m pip install --break-system-packages pytesseract >> C:\OEM\install.log 2>&1
REM -- WiX v3 toolset (candle/light/heat), a plain zip of standalone exes,
REM no installer needed. Fixed versioned URL, no scraping required.
call :log "fetching WiX v3.11 ..."
powershell -NoProfile -Command ^
"$ProgressPreference='SilentlyContinue';" ^
"Invoke-WebRequest -Uri 'https://github.com/wixtoolset/wix3/releases/download/wix3111rtm/wix311-binaries.zip' -OutFile 'C:\wix311-binaries.zip' -UseBasicParsing;" ^
"Expand-Archive -Path 'C:\wix311-binaries.zip' -DestinationPath 'C:\wix' -Force"
del C:\wix311-binaries.zip
REM -- Register the build watcher to run at every login from here on,
REM plus kick it off right now too (a fresh login won't retroactively
REM fire for this already-logged-in session). Deliberately an All-Users
REM Startup-folder entry, NOT a SYSTEM-context Scheduled Task: confirmed
REM on a real run that a /ru SYSTEM task can't see Z:\ at all and spins
REM forever on watch_build.bat's own "if not exist Z:\" wait -- Z:\ (the
REM /shared mount) is mapped per INTERACTIVE session, invisible to a
REM SYSTEM task with no session of its own. Startup-folder entries run
REM in whichever user's session actually logs in, inheriting their
REM drive mappings correctly.
call :log "registering build watcher (Startup folder) ..."
copy /y C:\FenigmaBuild\watch_build.bat "C:\ProgramData\Microsoft\Windows\Start Menu\Programs\StartUp\FenigmaBuildWatcher.bat" >> C:\OEM\install.log 2>&1
start "" cmd /c C:\FenigmaBuild\watch_build.bat
call :log "provisioning done"
echo DONE > C:\OEM\provisioned.marker
if exist Z:\ echo DONE > Z:\PROVISIONED
endlocal
exit /b 0
:log
echo [install.bat] %~1 >> C:\OEM\install.log
if exist Z:\ echo [install.bat] %~1 >> Z:\install_progress.log
exit /b 0
+68
View File
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Hand-authored shell: directory layout, the Start Menu shortcut, and the
PYTHONPATH environment variable the shortcut relies on (see run.sh's
equivalent `PYTHONPATH=src python -m fenigma.app`). The actual app/
runtime files are a separate auto-harvested fragment (files.wxs, built
by heat.exe in build.bat) referenced here only by its ComponentGroup id.
UpgradeCode below is a fixed, generated-once GUID: DO NOT regenerate
it, that's what lets a newer .msi upgrade an older install in place
instead of installing side by side. ProductCode is left as "*" (auto-
generated per build), which is the normal WiX pattern.
UNTESTED (see build.bat's top-of-file note).
-->
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
<Product Id="*"
Name="FEnigma"
Language="1033"
Version="$(var.AppVersion)"
Manufacturer="FEnigma"
UpgradeCode="DAB672A3-9E27-4F3F-8251-0AACD6E57B94">
<Package InstallerVersion="500" Compressed="yes" InstallScope="perMachine" />
<MajorUpgrade DowngradeErrorMessage="A newer version of FEnigma is already installed." />
<MediaTemplate EmbedCab="yes" />
<Directory Id="TARGETDIR" Name="SourceDir">
<Directory Id="ProgramFiles64Folder">
<Directory Id="INSTALLFOLDER" Name="FEnigma" />
</Directory>
<Directory Id="ProgramMenuFolder">
<Directory Id="ApplicationProgramsFolder" Name="FEnigma" />
</Directory>
</Directory>
<!-- AppFiles (all of dist\mingw64 + dist\src, harvested by heat.exe
into files.wxs) is referenced by id only: its actual file list
lives in that generated fragment, not here. -->
<Feature Id="MainFeature" Title="FEnigma" Level="1">
<ComponentGroupRef Id="AppFiles" />
<ComponentRef Id="ApplicationShortcutComponent" />
</Feature>
<DirectoryRef Id="ApplicationProgramsFolder">
<Component Id="ApplicationShortcutComponent" Guid="*">
<Shortcut Id="ApplicationStartMenuShortcut"
Name="FEnigma"
Description="Screen-reading helper for IRON NEST: Heavy Turret Simulator"
Target="[INSTALLFOLDER]mingw64\bin\pythonw.exe"
Arguments="-m fenigma.app"
WorkingDirectory="INSTALLFOLDER" />
<RemoveFolder Id="CleanUpShortcut" On="uninstall" />
<!-- Machine-wide PYTHONPATH so the bundled mingw64\bin\pythonw.exe
(which knows nothing about this app on its own) can find the
fenigma package: same role run.sh's env var plays on Linux.
Permanent="no": removed again on uninstall. -->
<Environment Id="PythonPathEnv" Name="PYTHONPATH" Value="[INSTALLFOLDER]src"
Permanent="no" Action="set" System="yes" Part="last" />
<RegistryValue Root="HKCU" Key="Software\FEnigma" Name="installed" Type="integer" Value="1" KeyPath="yes" />
</Component>
</DirectoryRef>
<!-- WixUI_Minimal: no EULA screen, so no WixUILicenseRtf override needed. -->
<UIRef Id="WixUI_Minimal" />
</Product>
</Wix>
+34
View File
@@ -0,0 +1,34 @@
@echo off
REM Runs persistently from login (see install.bat's Startup-folder entry --
REM this file itself gets copied to C:\FenigmaBuild\ and to the Startup
REM folder by install.bat, not run from C:\OEM, which does not reliably
REM survive past Windows Setup finishing, see install.bat's own note).
REM Polls the host-shared Z:\ drive for a build request and, when one
REM shows up, runs build.bat against it. This is what lets build_windows.sh
REM on the Linux host trigger a build without any RDP/remote-exec: it's
REM all just files dropped on the shared folder in both directions.
REM
REM UNTESTED (see install.bat's note).
:wait_for_share
if not exist Z:\ (
timeout /t 5 /nobreak >nul
goto wait_for_share
)
:loop
if exist Z:\BUILD_REQUEST (
REM Claim the request before acting on it -- if watch_build.bat somehow
REM ended up running twice this boot (install.bat starts it once
REM immediately, a fresh login could also start another copy), only
REM one of them wins this move and actually builds.
move /y Z:\BUILD_REQUEST Z:\BUILD_REQUEST.claimed >nul 2>&1
if exist Z:\BUILD_REQUEST.claimed (
del Z:\BUILD_REQUEST.claimed
del /q Z:\BUILD_DONE 2>nul
del /q Z:\BUILD_FAILED 2>nul
call C:\FenigmaBuild\build.bat
)
)
timeout /t 5 /nobreak >nul
goto loop
+1
View File
@@ -5,3 +5,4 @@
Pillow
numpy
pytesseract
opencv-python-headless
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Launch FeNigma.
# Launch FEnigma.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 141 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

+1 -1
View File
@@ -1 +1 @@
"""FeNigma: screen-reading helper for IRON NEST: Heavy Turret Simulator."""
"""FEnigma: screen-reading helper for IRON NEST: Heavy Turret Simulator."""
+870 -38
View File
File diff suppressed because it is too large Load Diff
+34 -17
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:
+164
View File
@@ -0,0 +1,164 @@
"""Squirrel away screenshots the vision/OCR pipeline handled badly, so the
detection algorithms can later be tuned against real failures instead of
just the fixture set.
Three cases, one folder each under _debug_dir():
failures/ -- map_vision.solve_path() errored out on what the gate
thought was a map (see app.py's _start_map_import).
corrections/ -- the user dragged the grid in GridFixDialog rather than
just accepting the auto-solve, paired with both the
original and the corrected GridSolution as ground truth.
maybe_map/ -- a screenshot fell through to the OCR/text path and
came back with nothing usable at all; it might genuinely
have been a map the gate misrouted, worth a look.
Deliberately silent on its own failure (a full/read-only disk shouldn't
turn a debug aid into a crash): every function here catches broadly and
gives up quietly rather than raising into the caller's UI-thread code.
"""
from __future__ import annotations
import json
import time
from pathlib import Path
import numpy as np
from PIL import Image
def _debug_dir(sub: str) -> Path:
"""XDG data dir if set, ~/.local/share otherwise, matching where a
Linux desktop app is expected to keep its own state -- same base
other GTK/libadwaita apps on this platform use, just our own
subfolder under it."""
import os
base = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")
return Path(base) / "fenigma" / "debug_captures" / sub
def _save(sub: str, png: bytes, meta: dict) -> Path | None:
try:
d = _debug_dir(sub)
d.mkdir(parents=True, exist_ok=True)
stamp = f"{time.time():.6f}".replace(".", "-")
(d / f"{stamp}.png").write_bytes(png)
(d / f"{stamp}.json").write_text(json.dumps(meta, indent=2))
return d / f"{stamp}.png"
except OSError:
return None
def _to_png_bytes(image) -> bytes | None:
"""Accept raw PNG bytes, a PIL Image, or a BGR numpy array (map_vision's
own in-memory image shape, see map_vision.load), so every call site can
just hand over whatever it already has."""
if isinstance(image, (bytes, bytearray)):
return bytes(image)
if isinstance(image, np.ndarray):
image = Image.fromarray(image[:, :, ::-1]) # BGR (cv2) -> RGB (PIL)
if isinstance(image, Image.Image):
import io
buf = io.BytesIO()
image.save(buf, format="PNG")
return buf.getvalue()
return None
def save_map_read_failure(image, reason: str) -> Path | None:
"""map_vision rejected/errored on a screenshot the cheap gate thought
was a map -- the interesting case, an OCR-text screenshot false-
positiving the gate is expected background noise (see
ImportJob.looks_like_map's own docstring), but a genuine map the
solver couldn't handle is exactly what needs fixing."""
png = _to_png_bytes(image)
if png is None:
return None
return _save("failures", png, {"reason": reason})
def save_maybe_map(image) -> Path | None:
"""A screenshot that went down the OCR/text path (either the gate
routed it there, or map_vision rejected it) and came back with
nothing usable -- possibly a map screenshot misread as text rather
than genuinely empty intel."""
png = _to_png_bytes(image)
if png is None:
return None
return _save("maybe_map", png, {})
def save_marker_ground_truth(image, proposals, added_targets=(), added_allies=()) -> Path | None:
"""Ground truth for marker detection, captured when the user drops a
screenshot (app.py's _remove_screenshot): every proposal the detector
made, whether the user accepted/rejected/never decided it (and, if
accepted, what type they actually confirmed -- may differ from the
detector's own guess, see Proposal.confirmed_type), PLUS every
Target/Ally that ended up on the board while this screenshot was up
that *isn't* explained by an accepted proposal at all -- a manual
add, or one merged in from OCR text run alongside it. Both signals
matter: a rejected proposal is a false positive to fix, a manually-
added unit that had no matching proposal at all is a miss to fix.
Skipped entirely if there's nothing to say (no proposals AND no
manually-added units), a screenshot nobody ever looked at units on.
Each proposal also carries `detected_id` (map_vision.read_marker_id's
best-effort read of the marker's own "#<N>" id label, see its own
docstring -- not yet validated against a real batch of this exact
ground truth, which is precisely what these captures are for).
`image` should be the sharpest one the caller has (full_image over
the WORK_W-downscaled one, see ScreenshotImport.full_image) so a
human reviewing a capture later can actually read that id text well
enough to judge whether detected_id was right -- not just take the
detector's word for it."""
if not proposals and not added_targets and not added_allies:
return None
png = _to_png_bytes(image)
if png is None:
return None
def verdict(p):
if p.accepted:
return "accepted"
if p.rejected:
return "rejected"
return "undecided" # dropped along with the screenshot, never actioned
return _save("marker_ground_truth", png, {
"proposals": [
{
"side": p.side, "label": p.label, "sub_x": p.sub_x, "sub_y": p.sub_y,
"detected_unit": p.unit, "verdict": verdict(p), "confirmed_type": p.confirmed_type,
"detected_id": p.detected_id,
"unit_score": p.unit_score, "unit_margin": p.unit_margin,
}
for p in proposals
],
"added_units": [
{"kind": kind, "type": u.type.name, "id": u.id, "coord": u.coord.label() if u.coord else None}
for kind, units in (("target", added_targets), ("ally", added_allies))
for u in units
],
})
def _solution_to_dict(sol) -> dict:
return {
"H": sol.H.tolist(),
"si": sol.si, "sj": sol.sj,
"du": sol.du, "dv": sol.dv,
}
def save_grid_correction(image, original_solution, corrected_solution) -> Path | None:
"""The user dragged the grid in GridFixDialog rather than accepting
the auto-solve as-is: both solutions, saved as ground truth for
tuning the grid solver against later."""
png = _to_png_bytes(image)
if png is None:
return None
return _save("corrections", png, {
"original": _solution_to_dict(original_solution),
"corrected": _solution_to_dict(corrected_solution),
})
+108 -9
View File
@@ -106,12 +106,28 @@ class FiringPanel(Gtk.Box):
"""Right-hand sidebar content: sort/filter toolbar + scrollable cards."""
def __init__(
self, board: Board, *, on_change, on_select, on_edit_position, on_set_position, on_remove,
on_toggle_hide_dead_map,
self, board: Board, *, on_change, on_visual_change, on_select, on_edit_position, on_set_position,
on_remove, on_toggle_hide_dead_map,
) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL)
self.board = board
self.on_change = on_change
# app.py's full pipeline (solver + dedupe + redraw + THIS panel's
# own full rebuild) -- for mutations that actually need it (a
# position/clue changed, a target was added/removed/reordered).
# Assignment/alive/shell changes don't: nothing about them can
# ever be produced by the solver or change dedupe's outcome, they
# just need the MAP redrawn (assignment isn't drawn there at all;
# alive dims a marker; shell can change a selected/pinned
# target's blast-radius circle). on_visual_change is that lighter
# path -- just a map redraw, no solver/dedupe/panel-rebuild -- see
# _cycle_assignment/_toggle_alive/_pick_shell, which pair it with
# _rebuild_one() for this panel's own (single-card, not
# whole-board) update. Was a real, measured lag source: every one
# of those three going through on_change() meant every single
# click rebuilt every card of every target on the board, not just
# the one that changed.
self.on_visual_change = on_visual_change
self.on_select = on_select
self.on_edit_position = on_edit_position
self.on_set_position = on_set_position
@@ -135,14 +151,14 @@ class FiringPanel(Gtk.Box):
self._list_box.set_margin_bottom(10)
self._list_box.set_margin_start(10)
self._list_box.set_margin_end(10)
scroller = Gtk.ScrolledWindow(child=self._list_box, vexpand=True)
self._scroller = Gtk.ScrolledWindow(child=self._list_box, vexpand=True)
# Horizontal scrolling is never wanted here (fixed-width sidebar),
# leaving it on AUTOMATIC (the default) lets a vertical scrollbar's
# own width shrink the content area enough to trigger a horizontal
# one too, which then perturbs card heights and can trip vertical
# scrolling that wasn't actually needed. Pin it off outright.
scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
self.append(scroller)
self._scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
self.append(self._scroller)
self.refresh()
@@ -195,6 +211,33 @@ class FiringPanel(Gtk.Box):
self._restyle(self.selected, self.selected_point, _SELECTED_CSS, False)
self.selected, self.selected_point = target, point
self._restyle(self.selected, self.selected_point, _SELECTED_CSS, True)
if target is not None:
self._scroll_into_view(target, point)
def _scroll_into_view(self, target, point) -> None:
"""Selecting a target on the map (or cycling selection some other
way) should bring its card on-screen if the sidebar's scrolled
past it -- otherwise "selected" is invisible state the map alone
shows, and the firing panel this is FOR doesn't actually show what
got picked. A no-op if the card's already fully visible, this
only nudges the scroll position the minimum needed, never
recentres unnecessarily."""
card = next(
(c for c, p in self._cards_by_target.get(target, []) if point is None or p == point),
None,
)
if card is None:
return
ok, bounds = card.compute_bounds(self._list_box)
if not ok:
return # not laid out yet (e.g. called right after a rebuild); skip rather than guess
vadj = self._scroller.get_vadjustment()
top, bottom = bounds.get_y(), bounds.get_y() + bounds.get_height()
view_top, view_bottom = vadj.get_value(), vadj.get_value() + vadj.get_page_size()
if top < view_top:
vadj.set_value(top)
elif bottom > view_bottom:
vadj.set_value(bottom - vadj.get_page_size())
def set_hovered(self, target, point=None) -> None:
if target is self.hovered and point == self.hovered_point:
@@ -203,6 +246,28 @@ class FiringPanel(Gtk.Box):
self.hovered, self.hovered_point = target, point
self._restyle(self.hovered, self.hovered_point, _HOVERED_CSS, True)
def _rebuild_one(self, target: Target) -> None:
"""Rebuild just `target`'s own card(s) in place -- O(1) in the
number of OTHER targets on the board, unlike refresh() (which
tears down and rebuilds every card) -- for a mutation that only
changes this target's own display and can never add/remove a
card or move anything in the sort order (see
_cycle_assignment/_pick_shell; _toggle_alive uses this only when
that's also true for it, falling back to refresh() otherwise).
"""
old_cards = self._cards_by_target.get(target)
if not old_cards:
return # not currently shown (e.g. filtered out) -- nothing to update
new_cards = self._build_cards(target)
for (old_widget, _old_point), (new_widget, new_point) in zip(old_cards, new_cards):
self._list_box.insert_child_after(new_widget, old_widget)
self._list_box.remove(old_widget)
if target is self.selected and (self.selected_point is None or new_point == self.selected_point):
new_widget.add_css_class(_SELECTED_CSS)
if target is self.hovered and (self.hovered_point is None or new_point == self.hovered_point):
new_widget.add_css_class(_HOVERED_CSS)
self._cards_by_target[target] = new_cards
def _restyle(self, target, point, css_class: str, add: bool) -> None:
"""point=None means "the whole target" (every one of its cards);
otherwise only the card for that specific ambiguous candidate,
@@ -369,7 +434,16 @@ class FiringPanel(Gtk.Box):
if dragged not in targets or drop_onto not in targets:
return
self.board.reorder_target(dragged, targets.index(drop_onto))
self.on_change()
# NOT self.on_change(): that's app.py's "single choke point" full
# refresh (re-run the solver over every target's clues, dedupe,
# redraw the map, THEN rebuild this panel), all of it wasted work
# for a pure order change -- no location/clue/coord/alive state
# moved, so nothing the solver or the map drawing cares about
# changed, only this panel's own card order did. Calling that
# full pipeline on every single drag-drop was what made
# reordering feel laggy; a local refresh() is the only rebuild a
# reorder actually needs.
self.refresh()
def _build_unresolved_card(self, target: Target) -> Gtk.Widget:
card, inner = self._build_card_shell(target)
@@ -496,14 +570,39 @@ class FiringPanel(Gtk.Box):
return row
def _cycle_assignment(self, target: Target) -> None:
# Assignment (L/R/unassigned) isn't drawn on the map at all, so
# this doesn't even need on_visual_change, just the card itself.
idx = _ASSIGNMENT_STATES.index(target.assignment)
target.assignment = _ASSIGNMENT_STATES[(idx + 1) % len(_ASSIGNMENT_STATES)]
self.on_change()
self._rebuild_one(target)
def _toggle_alive(self, target: Target) -> None:
target.alive = not target.alive
self.on_change()
self.refresh_after_alive_change(target)
def refresh_after_alive_change(self, target: Target) -> None:
"""The display-only aftermath of target.alive flipping, split out
from _toggle_alive so app.py's map-popover "Mark destroyed"/"Mark
alive" (which flips target.alive itself, reaching this same
target) can reuse the same cheap-when-possible logic rather than
going through on_change()'s full solver+dedupe+canvas+panel pass
again -- exactly the rebuild this class exists to avoid paying
for a change that was never going to affect the solver or dedupe.
A card's presence/position can depend on alive (show_dead "hide"
drops dead cards entirely, "sort_later" moves them to their own
group at the bottom) -- only "show" guarantees this card stays
exactly where it is, just dimmed, so only that mode gets the
cheap single-card path; the other two need this panel's own full
rebuild (still far cheaper than on_change()'s, since it skips
everything but the last step)."""
if self.show_dead == "show":
self._rebuild_one(target)
else:
self.refresh()
self.on_visual_change() # dead dimming / hide_dead_from_map affects the map too
def _pick_shell(self, target: Target, shell: Shell) -> None:
target.shell = shell
self.on_change()
self._rebuild_one(target)
self.on_visual_change() # a selected/pinned target's blast-radius circle depends on its shell
+278
View File
@@ -0,0 +1,278 @@
"""The one modal in the map-import flow: confirm or fix the detected grid.
Nothing else belongs here. Unit detection happens *after* this dialog closes,
because every unit position is expressed in grid coordinates -- detecting
against a grid the user is about to drag would only be thrown away.
The correction handles are the four corners of one cell, not of the whole
screenshot. A homography has 8 degrees of freedom and each dragged corner
contributes 2, so four corners of a single known cell pin it exactly, and a
cell near the frame centre is the one whose corners are easiest to place
accurately by eye. Dragging any handle refits the whole grid immediately, so
the feedback is the entire reconstructed lattice moving, not just a dot.
"""
from __future__ import annotations
import math
import cairo
import gi
import numpy as np
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gtk # noqa: E402
from . import map_vision # noqa: E402
HANDLE_R = 9.0 # drawn radius of a corner handle, widget px
GRAB_R = 22.0 # how close a press has to be to grab one
def _surface_from_bgr(img):
"""A cairo surface over a numpy BGR image.
cairo's RGB24 is a 32-bit pixel laid out as B,G,R,x in memory on a
little-endian machine, which is exactly BGRA, so the converted array can
back the surface directly with no per-pixel work. The array is kept alive
by the caller holding it: create_for_data does not copy.
"""
import cv2
bgra = cv2.cvtColor(img, cv2.COLOR_BGR2BGRA)
bgra = np.ascontiguousarray(bgra)
h, w = bgra.shape[:2]
surface = cairo.ImageSurface.create_for_data(
memoryview(bgra), cairo.FORMAT_RGB24, w, h, w * 4)
return surface, bgra
class GridFixDialog(Adw.Dialog):
"""Shows the screenshot with the reconstructed grid drawn over it, plus
four draggable corner handles. on_accept(solution) gets whatever grid is
on screen when Accept is pressed."""
def __init__(self, *, image, solution, on_accept, on_discard=None):
super().__init__(title="Check the detected grid",
content_width=900, content_height=760)
self._image = image
self._auto = solution
self._sol = solution
self._on_accept = on_accept
self._on_discard = on_discard
self._surface, self._keepalive = _surface_from_bgr(image)
self._dragging = None # index of the handle being dragged
self._drag_from = None # its position when the drag began
quad = map_vision.centre_cell_quad(solution, image.shape)
# (label, [4 pixel corners], [4 grid corners]); the pixel corners move
# with the mouse, the grid corners are what they are supposed to BE and
# never change -- that pairing is the correspondence set refitted from.
self._label = quad[0] if quad else None
self._px = list(quad[1]) if quad else []
self._grid = list(quad[2]) if quad else []
view = Adw.ToolbarView()
view.add_top_bar(Adw.HeaderBar())
self.set_child(view)
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10,
margin_top=10, margin_bottom=10, margin_start=10, margin_end=10)
self._area = Gtk.DrawingArea(vexpand=True, hexpand=True)
self._area.set_draw_func(self._draw)
drag = Gtk.GestureDrag()
drag.connect("drag-begin", self._on_drag_begin)
drag.connect("drag-update", self._on_drag_update)
drag.connect("drag-end", lambda *_a: setattr(self, "_dragging", None))
self._area.add_controller(drag)
outer.append(self._area)
cell = self._label or "?"
self._hint = Gtk.Label(xalign=0, css_classes=["dim-label"], wrap=True)
self._hint.set_label(
f"Grid solved from {solution.votes} label read(s). "
f"If it is off, drag the four handles onto the corners of cell {cell}."
if self._px else
f"Grid solved from {solution.votes} label read(s)."
)
outer.append(self._hint)
buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8,
halign=Gtk.Align.END)
discard = Gtk.Button(label="Discard", css_classes=["pill"])
discard.connect("clicked", lambda _b: self._discard())
buttons.append(discard)
if self._px:
reset = Gtk.Button(label="Reset", css_classes=["pill"],
tooltip_text="Back to the automatically detected grid")
reset.connect("clicked", lambda _b: self._reset())
buttons.append(reset)
accept = Gtk.Button(label="Use this grid",
css_classes=["pill", "suggested-action"])
accept.connect("clicked", lambda _b: self._accept())
buttons.append(accept)
outer.append(buttons)
view.set_content(outer)
# -- geometry ------------------------------------------------------------
def _fit(self):
"""(scale, ox, oy) letterboxing the screenshot into the drawing area."""
w, h = self._area.get_width(), self._area.get_height()
ih, iw = self._image.shape[:2]
if not w or not h:
return 1.0, 0.0, 0.0
s = min(w / iw, h / ih)
return s, (w - iw * s) / 2, (h - ih * s) / 2
def _to_widget(self, p):
s, ox, oy = self._fit()
return p[0] * s + ox, p[1] * s + oy
def _to_image(self, x, y):
s, ox, oy = self._fit()
return (x - ox) / s, (y - oy) / s
def _refit(self):
"""Rebuild the grid from the four handle positions.
A bad drag (two handles on top of each other) makes the homography
degenerate; keep the previous grid rather than crash, the next drag
update recovers.
"""
try:
self._sol = map_vision.solution_from_correspondences(
list(zip(self._grid, self._px)))
except (ValueError, np.linalg.LinAlgError):
pass
self._area.queue_draw()
def _reset(self):
quad = map_vision.centre_cell_quad(self._auto, self._image.shape)
if quad:
self._px = list(quad[1])
self._sol = self._auto
self._area.queue_draw()
# -- input ---------------------------------------------------------------
def _on_drag_begin(self, _gesture, x, y):
self._dragging = None
best = GRAB_R
for i, p in enumerate(self._px):
wx, wy = self._to_widget(p)
d = ((wx - x) ** 2 + (wy - y) ** 2) ** 0.5
if d < best:
best, self._dragging = d, i
if self._dragging is not None:
self._drag_from = self._px[self._dragging]
def _on_drag_update(self, _gesture, dx, dy):
if self._dragging is None:
return
s, _ox, _oy = self._fit()
if s <= 0:
return
fx, fy = self._drag_from
self._px[self._dragging] = (fx + dx / s, fy + dy / s)
self._refit()
def _accept(self):
self.close()
self._on_accept(self._sol)
def _discard(self):
self.close()
if self._on_discard is not None:
self._on_discard()
# -- drawing -------------------------------------------------------------
def _draw(self, _area, cr, width, height):
cr.set_source_rgb(0.08, 0.08, 0.08)
cr.paint()
s, ox, oy = self._fit()
cr.save()
cr.translate(ox, oy)
cr.scale(s, s)
cr.set_source_surface(self._surface, 0, 0)
cr.get_source().set_filter(cairo.FILTER_GOOD)
cr.paint()
cr.restore()
self._draw_grid(cr)
for i, p in enumerate(self._px):
wx, wy = self._to_widget(p)
# new_path() before every arc: cairo's arc() joins the current point
# to the arc's start, and _draw_grid leaves one behind at the last
# cell name it drew. Without this, the first handle gets a stray
# line reaching across the whole screenshot from that label.
cr.new_path()
cr.set_source_rgb(1.0, 0.85, 0.1)
cr.arc(wx, wy, HANDLE_R, 0, 2 * math.pi)
cr.fill_preserve()
cr.set_source_rgb(0.1, 0.1, 0.1)
cr.set_line_width(2.0)
cr.stroke()
if i == self._dragging:
cr.new_path()
cr.set_source_rgb(1.0, 1.0, 1.0)
cr.arc(wx, wy, HANDLE_R + 4, 0, 2 * math.pi)
cr.set_line_width(1.5)
cr.stroke()
def _draw_grid(self, cr):
"""Every in-range cell the grid puts inside the frame, with its name
drawn where the game draws it. A wrong grid is obvious precisely
because those names land off the painted labels."""
sol = self._sol
h, w = self._image.shape[:2]
inv = np.linalg.inv(sol.H)
corners = inv @ np.array([[0, w, w, 0], [0, 0, h, h], [1, 1, 1, 1.0]])
if np.any(np.abs(corners[2]) < 1e-9):
return
ij = corners[:2] / corners[2]
cr.set_line_width(1.6)
cr.select_font_face("Sans", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD)
L2G = sol.lattice_to_grid()
for i in range(int(np.floor(ij[0].min())) - 1, int(np.ceil(ij[0].max())) + 2):
for j in range(int(np.floor(ij[1].min())) - 1, int(np.ceil(ij[1].max())) + 2):
g = L2G @ np.array([i, j, 1.0])
col, row = int(round(g[0])), int(round(g[1]))
if not (0 <= col < map_vision.COLS and 1 <= row <= map_vision.ROWS):
continue
quad = sol.H @ np.array([[i, i + 1, i + 1, i],
[j, j, j + 1, j + 1], [1, 1, 1, 1.0]])
if np.any(np.abs(quad[2]) < 1e-9):
continue
pts = [self._to_widget(p) for p in (quad[:2] / quad[2]).T]
cr.new_path()
cr.set_source_rgba(1.0, 1.0, 0.2, 0.75)
cr.move_to(*pts[0])
for p in pts[1:]:
cr.line_to(*p)
cr.close_path()
cr.stroke()
# The game pads a cell's label in from its top-left corner by a
# fixed fraction of the cell, which is also how the solver finds
# labels in the first place (see map_vision.PAD_L/PAD_T).
lx = i + (map_vision.PAD_L if sol.si > 0 else 1 - map_vision.PAD_L)
ly = j + (map_vision.PAD_T if sol.sj > 0 else 1 - map_vision.PAD_T)
t = sol.H @ np.array([lx, ly, 1.0])
if abs(t[2]) < 1e-9:
continue
tx, ty = self._to_widget((t[0] / t[2], t[1] / t[2]))
side = float(np.hypot(pts[1][0] - pts[0][0], pts[1][1] - pts[0][1]))
cr.set_font_size(max(9.0, min(30.0, side * 0.16)))
name = f"{map_vision.LARGE_X[col]}{row}"
cr.move_to(tx, ty)
cr.set_source_rgba(0, 0, 0, 0.8)
cr.text_path(name)
cr.set_line_width(3.0)
cr.stroke()
cr.move_to(tx, ty)
cr.set_source_rgb(0.3, 1.0, 0.3)
cr.show_text(name)
+349 -20
View File
@@ -11,10 +11,13 @@ from collections import namedtuple
import cairo
import gi
import numpy as np
from PIL import Image as PILImage
gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "4.0")
from gi.repository import Gdk, Gtk # noqa: E402
gi.require_version("Adw", "1")
from gi.repository import Adw, Gdk, Gtk # noqa: E402
from . import ballistics, icons, solver
from .models import LARGE_X, Board, Target
@@ -29,6 +32,9 @@ MARGIN_BOTTOM = 30
LABEL_PAD = 8 # gap between a marker and its name label
HOVER_RADIUS_PX = 12
# An imported screenshot is a backdrop, not the subject: slightly transparent so
# the grid lines and markers drawn over it stay legible.
SCREENSHOT_ALPHA = 0.88
OVERLAY_RAY_LENGTH_KM = 30.0 # long enough to cross the 20x10 map from any origin
MIN_ZOOM = 1.0 # the whole 20x10 map fits, the default
@@ -52,6 +58,19 @@ ICON_MIN_CELL_PX = 42
# unless zoomed in and panned).
_View = namedtuple("_View", "cell_w cell_h grid_w grid_h pad_x pad_y ox oy vis_cols vis_rows")
# Everything below (CATEGORY_COLOR through PLACEMENT_PREVIEW) is a
# module-level name deliberately kept mutable: _apply_palette() below
# reassigns all of them via `global`, in place, whenever the app's
# light/dark scheme changes (see GridCanvas.__init__, which hooks
# Adw.StyleManager's own dark/light detection, including live updates
# if the system theme changes while running). Every draw method
# references these bare names directly (`cr.set_source_rgb(*BG)` etc.)
# rather than threading a palette object through every call, reassigning
# the names in place is what makes that keep working without touching
# every call site. The values set here at import time are the dark
# palette, _apply_palette(is_dark=True) (called from __init__) reapplies
# the same values, it's the light branch that actually changes anything
# the first time it runs.
CATEGORY_COLOR = {
"nest": (0.35, 0.60, 0.95),
"spotter": (0.35, 0.78, 0.40),
@@ -63,17 +82,89 @@ SCOUT_FLIGHT = (0.70, 0.45, 0.92)
BG = (0.13, 0.12, 0.10)
GRID_LINE = (1.0, 1.0, 1.0, 0.20)
SUBGRID_LINE = (0.72, 0.70, 0.65, 0.35) # a shade between BG and GRID_LINE's white, not a hue change
SUBGRID_LINE = (0.72, 0.70, 0.65, 0.15) # verified by actually computing the blended-over-BG
# pixel values, not eyeballing it: alpha 0.35 (a previous version) blended this same RGB out to
# (86, 83, 75), BRIGHTER than GRID_LINE's own blended (77, 76, 72), backwards from the intent.
# 0.15 blends to (56, 53, 47): sits between BG (33, 31, 26) and GRID_LINE (77, 76, 72), the RGB
# tint stays visible but the line itself reads as genuinely fainter, not louder.
HOVER_LEGEND = (0.45, 0.65, 0.95) # blue, not yellow, for the highlighted X/Y legend label
LABEL = (0.88, 0.86, 0.80)
COORD_LABEL = (0.60, 0.58, 0.54)
YELLOW = (0.95, 0.85, 0.20)
WHITE = (1.0, 1.0, 1.0)
WHITE = (1.0, 1.0, 1.0) # not literally "white" any more in the light palette, see _LIGHT_PALETTE:
# its role is "a neutral that maximally contrasts with BG", the name stuck around from when this
# only ever ran on a dark background.
FIRING_ARROW = (0.95, 0.15, 0.15)
SELECTION_RING = (1.0, 1.0, 1.0)
BLAST_RADIUS = (0.95, 0.40, 0.10)
PLACEMENT_PREVIEW = (0.95, 0.85, 0.20)
_DARK_PALETTE = dict(
CATEGORY_COLOR={
"nest": (0.35, 0.60, 0.95), "spotter": (0.35, 0.78, 0.40),
"rp": (0.95, 0.78, 0.20), "target": (0.92, 0.30, 0.28), "ally": (0.30, 0.85, 0.85),
},
SCOUT_FLIGHT=(0.70, 0.45, 0.92),
BG=(0.13, 0.12, 0.10),
GRID_LINE=(1.0, 1.0, 1.0, 0.20),
SUBGRID_LINE=(0.72, 0.70, 0.65, 0.15),
HOVER_LEGEND=(0.45, 0.65, 0.95),
LABEL=(0.88, 0.86, 0.80),
COORD_LABEL=(0.60, 0.58, 0.54),
YELLOW=(0.95, 0.85, 0.20),
WHITE=(1.0, 1.0, 1.0),
FIRING_ARROW=(0.95, 0.15, 0.15),
SELECTION_RING=(1.0, 1.0, 1.0),
BLAST_RADIUS=(0.95, 0.40, 0.10),
PLACEMENT_PREVIEW=(0.95, 0.85, 0.20),
)
# Same relative brightness relationships as the dark palette (main grid
# line vs. the fainter subgrid one, category colors distinct from each
# other), just inverted for a light background: every color that needs
# to contrast against BG got darkened instead of brightened. First-pass
# guesses, flagged the same way the Shell descriptions were, correct
# whichever look off once actually seen on a real light-themed desktop.
_LIGHT_PALETTE = dict(
CATEGORY_COLOR={
"nest": (0.15, 0.35, 0.75), "spotter": (0.10, 0.50, 0.15),
"rp": (0.65, 0.50, 0.05), "target": (0.75, 0.12, 0.10), "ally": (0.05, 0.45, 0.45),
},
SCOUT_FLIGHT=(0.45, 0.20, 0.65),
BG=(0.96, 0.95, 0.93),
GRID_LINE=(0.08, 0.08, 0.08, 0.20),
SUBGRID_LINE=(0.08, 0.08, 0.08, 0.15),
HOVER_LEGEND=(0.10, 0.35, 0.75),
LABEL=(0.15, 0.14, 0.12),
COORD_LABEL=(0.42, 0.40, 0.37),
YELLOW=(0.65, 0.48, 0.02),
WHITE=(0.10, 0.10, 0.10),
FIRING_ARROW=(0.80, 0.10, 0.10),
SELECTION_RING=(0.05, 0.05, 0.05),
BLAST_RADIUS=(0.80, 0.35, 0.05),
PLACEMENT_PREVIEW=(0.65, 0.48, 0.02),
)
def _apply_palette(is_dark: bool) -> None:
global CATEGORY_COLOR, SCOUT_FLIGHT, BG, GRID_LINE, SUBGRID_LINE, HOVER_LEGEND, LABEL, \
COORD_LABEL, YELLOW, WHITE, FIRING_ARROW, SELECTION_RING, BLAST_RADIUS, PLACEMENT_PREVIEW
p = _DARK_PALETTE if is_dark else _LIGHT_PALETTE
CATEGORY_COLOR = p["CATEGORY_COLOR"]
SCOUT_FLIGHT = p["SCOUT_FLIGHT"]
BG = p["BG"]
GRID_LINE = p["GRID_LINE"]
SUBGRID_LINE = p["SUBGRID_LINE"]
HOVER_LEGEND = p["HOVER_LEGEND"]
LABEL = p["LABEL"]
COORD_LABEL = p["COORD_LABEL"]
YELLOW = p["YELLOW"]
WHITE = p["WHITE"]
FIRING_ARROW = p["FIRING_ARROW"]
SELECTION_RING = p["SELECTION_RING"]
BLAST_RADIUS = p["BLAST_RADIUS"]
PLACEMENT_PREVIEW = p["PLACEMENT_PREVIEW"]
# path -> loaded cairo.ImageSurface (or None for a path that failed to
# load, so a missing/bad icon file only ever gets one failed attempt,
# not one per frame). Module-level, not per-canvas: the icon set is
@@ -105,10 +196,58 @@ def _icon_for(category: str, obj) -> cairo.ImageSurface | None:
return None
# path -> (surface, content_bbox) for additive badges specifically.
# Separate from _ICON_SURFACE_CACHE because these also need their real
# opaque content's bounding box: unlike the unit icons (already ~edge to
# edge in their own canvas, see _draw_icon_marker), the additive art
# (assets/icons/targets/additives/) sits inside a lot of transparent
# padding that isn't even centered -- scaling/positioning off the full
# 256x256 canvas made the badge look tiny and float with a visible gap
# above the icon it's supposed to touch. bbox is None for a path that
# failed to load, or (l, t, r, b) of its actual opaque pixels.
_ADDITIVE_CACHE: dict = {}
def _additive_surface(path) -> tuple:
if path not in _ADDITIVE_CACHE:
surface, bbox = None, None
try:
surface = cairo.ImageSurface.create_from_png(str(path))
bbox = PILImage.open(str(path)).getbbox()
except Exception:
pass
_ADDITIVE_CACHE[path] = (surface, bbox)
return _ADDITIVE_CACHE[path]
def _additive_for(category: str, obj) -> tuple | None:
"""The underground-tier badge overlaid on top of a Target's own icon,
or None. Target-only (see Target.underground_tier's own comment)."""
if category != "target":
return None
tier = getattr(obj, "underground_tier", None)
if tier is None:
return None
surface, bbox = _additive_surface(icons.underground_icon_path(tier))
if surface is None:
return None
return (surface, bbox)
class GridCanvas(Gtk.DrawingArea):
def __init__(self, board: Board) -> None:
super().__init__()
self.board = board
# Follow the app's light/dark scheme (system setting, or an
# in-app override if one's ever added later) for every color
# this canvas draws with, live: if the scheme changes while
# running, redraw with the other palette rather than staying
# stuck on whichever was active at startup.
style_manager = Adw.StyleManager.get_default()
_apply_palette(style_manager.get_dark())
style_manager.connect("notify::dark", self._on_style_changed)
self.hovered = None
self.hovered_point = None # which candidate, when obj has more than one point
self.selected = None
@@ -116,9 +255,26 @@ class GridCanvas(Gtk.DrawingArea):
self.on_select = None # callback(obj | None, point | None), fired on click
self.on_hover_change = None # callback(obj | None, point | None), fired on hover change
self.on_cursor_move = None # callback((col, row) km | None), fired on every motion/leave
self.on_right_click = None # callback(Coord, x, y), fired on right-click (unless placing)
# callback(proposal, x, y): fired when an imported screenshot's pending
# proposal is clicked with either button. A proposal exists only to be
# accepted or rejected, so plain clicking it offers that rather than
# selecting something the board doesn't contain yet.
self.on_proposal_click = None
# callback(Coord, x, y, obj, point): fired on right-click unless placing.
# obj/point are the entity under the cursor when there is one (same
# hit test as left-click selection), so the handler can offer actions
# on that entity instead of the place-something-here menu.
self.on_right_click = None
self.hide_dead_from_map = False # off by default; toggled from the firing panel toolbar
# An imported map screenshot, rectified into board space, drawn under
# everything else, plus the units detected in it as [(proposal, Coord)].
# Proposals are kept separate from board entities on purpose: they are
# not on the board until accepted, so nothing that walks the board can
# see them, and they get their own hit test.
self._screenshot = None # (cairo surface, backing array, px_per_km)
self.proposals = []
# Which large cell the cursor is currently over, (col, row) both
# floored, or None off the map/off the widget entirely. Redrawn
# only when this actually changes cell (not on every pixel of
@@ -200,6 +356,10 @@ class GridCanvas(Gtk.DrawingArea):
def refresh(self) -> None:
self.queue_draw()
def _on_style_changed(self, style_manager, _pspec) -> None:
_apply_palette(style_manager.get_dark())
self.queue_draw()
# -- placement mode -----------------------------------------------------------
def start_placement(self, callback, preview_radius_km=None) -> None:
self.placement_callback = callback
@@ -410,6 +570,94 @@ class GridCanvas(Gtk.DrawingArea):
for candidate in obj.location.potential_coords:
yield obj, candidate
# -- imported screenshot ---------------------------------------------------
def set_screenshot(self, bgra, px_per_km: int) -> None:
"""Show a rectified map screenshot as the board's backdrop.
`bgra` covers the whole board (COLS x ROWS km at px_per_km), transparent
wherever the screenshot didn't reach, so a partial view of the table
doesn't blank out the rest of the map. Pre-warping into board space is
what makes this drawable at all: cairo has no projective transform, but
once the image is rectified a plain scale and translate places it.
"""
if bgra is None:
self._screenshot = None
self.queue_draw()
return
buf = np.ascontiguousarray(bgra)
h, w = buf.shape[:2]
surface = cairo.ImageSurface.create_for_data(
memoryview(buf), cairo.FORMAT_ARGB32, w, h, w * 4)
# The array must outlive the surface: create_for_data does not copy.
self._screenshot = (surface, buf, px_per_km)
self.queue_draw()
def has_screenshot(self) -> bool:
return self._screenshot is not None
def set_proposals(self, proposals) -> None:
"""proposals is [(proposal, Coord)]; the widget only reads the Coord and
the proposal's accepted/rejected flags, so it stays ignorant of
map_import's own coordinate format."""
self.proposals = list(proposals)
self.queue_draw()
def _pending_proposals(self):
return [(p, c) for p, c in self.proposals if p.pending]
def hit_test_proposal(self, view: _View, x: float, y: float):
"""The pending proposal nearest the cursor within range, or None."""
best, best_dist = None, HOVER_RADIUS_PX
for p, coord in self._pending_proposals():
px, py = self._km_to_px(view, coord.as_fraction())
dist = math.hypot(px - x, py - y)
if dist < best_dist:
best_dist, best = dist, p
return best
def _draw_screenshot(self, cr, view) -> None:
surface, buf, px_per_km = self._screenshot
# Board space runs col 0..COLS rightward and row 0..ROWS upward, so the
# image's top-left pixel is (col 0, row ROWS) -- the top-left corner.
x0, y0 = self._km_to_px(view, (0, ROWS))
x1, y1 = self._km_to_px(view, (COLS, 0))
ih, iw = buf.shape[:2]
if iw <= 0 or ih <= 0:
return
cr.save()
cr.translate(x0, y0)
cr.scale((x1 - x0) / iw, (y1 - y0) / ih)
cr.set_source_surface(surface, 0, 0)
cr.get_source().set_filter(cairo.FILTER_GOOD)
cr.paint_with_alpha(SCREENSHOT_ALPHA)
cr.restore()
def _draw_proposals(self, cr, view, width, height) -> None:
"""Detected-but-unconfirmed units. Drawn hollow, the same shape the map
already uses for "this might be where it is", because that is exactly
what a proposal is until the user accepts it."""
for p, coord in self._pending_proposals():
color = CATEGORY_COLOR["ally" if p.side == "friendly" else "target"]
# No coord here: _draw_marker already shows one right below
# this label (`coord=coord` below), repeating it in the main
# label too was pure noise. detected_type/detected_id
# (map_vision.classify_marker/read_marker_id's best-effort
# reads) shown instead when known -- same "? Mechanized#3"
# shape an accepted entity's own name takes (Target.name),
# checkable against the actual screenshot pixels while it's
# still up, and detected_id is the same id _accept_proposal
# will use for the entity if this gets accepted.
detected_type = icons.target_type_from_icon(p.unit)
# .short, not target_type_label(): matches Target.name/Ally.name's
# own naming exactly ("SupplyCache" not "Supply Cache"), so this
# preview label reads the same as what accepting it produces.
type_part = detected_type.short if detected_type else ""
id_part = f"#{p.detected_id}" if p.detected_id else ""
label = f"? {type_part}{id_part}" if (type_part or id_part) else "?"
self._draw_marker(cr, view, coord.as_fraction(), color,
label, width, height,
hollow=True, coord=coord)
def _hit_test(self, view: _View, x: float, y: float):
"""Returns (obj, coord) of the nearest marker within range, or
(None, None), coord disambiguates which candidate of an
@@ -491,6 +739,11 @@ class GridCanvas(Gtk.DrawingArea):
callback(coord)
return
proposal = self.hit_test_proposal(view, x, y)
if proposal is not None and self.on_proposal_click is not None:
self.on_proposal_click(proposal, x, y)
return
hit, coord = self._hit_test(view, x, y)
self.set_selected(hit, coord)
if self.on_select is not None:
@@ -503,9 +756,17 @@ class GridCanvas(Gtk.DrawingArea):
if self.on_right_click is None:
return
view = self._view(self.get_width(), self.get_height())
# A pending proposal wins over a board entity underneath it: it is the
# thing the user is being asked to decide about, and it disappears as
# soon as they do, so whatever it overlaps becomes reachable again.
hit = self.hit_test_proposal(view, x, y)
point = None
if hit is None:
hit, point = self._hit_test(view, x, y)
coord = solver.point_to_coord(self._px_to_km(view, x, y))
if coord is not None:
self.on_right_click(coord, x, y)
if coord is None and hit is None:
return
self.on_right_click(coord, x, y, hit, point)
# -- drawing ----------------------------------------------------------------
def _draw(self, _area, cr, width, height) -> None:
@@ -592,6 +853,11 @@ class GridCanvas(Gtk.DrawingArea):
cr.rectangle(MARGIN_LEFT + view.pad_x, MARGIN_TOP + view.pad_y, view.grid_w, view.grid_h)
cr.clip()
# Under everything: the imported screenshot is the backdrop the rest of
# the map is drawn on top of.
if self._screenshot is not None:
self._draw_screenshot(cr, view)
self._draw_hover_subgrid(cr, view)
self._draw_geo_overlays(cr, view)
self._draw_firing_arrows(cr, view)
@@ -606,7 +872,8 @@ class GridCanvas(Gtk.DrawingArea):
dim=(category == "target" and not obj.alive) or obj.hidden,
selected=(obj is self.selected), coord=obj.coord,
extra_line=getattr(obj, "requested_time", None),
icon_surface=_icon_for(category, obj))
icon_surface=_icon_for(category, obj),
additive=_additive_for(category, obj))
for category, obj in self.board.ambiguous_entities_all():
if self._excluded_from_map(obj):
@@ -620,6 +887,8 @@ class GridCanvas(Gtk.DrawingArea):
selected=is_selected, coord=candidate,
extra_line=getattr(obj, "requested_time", None))
self._draw_proposals(cr, view, width, height)
for sf in self.board.scout_flights:
if sf.hidden:
continue # hidden means gone from the map, not just darkened, no selection to reinstate it
@@ -665,7 +934,8 @@ class GridCanvas(Gtk.DrawingArea):
def _draw_marker(self, cr, view, point_km, color, label,
canvas_width, canvas_height, *, hollow=False, dim=False,
selected=False, coord=None, extra_line=None, icon_surface=None) -> None:
selected=False, coord=None, extra_line=None, icon_surface=None,
additive=None) -> None:
x, y = self._km_to_px(view, point_km)
r, g, b = color
alpha = 0.45 if dim else 1.0
@@ -685,6 +955,8 @@ class GridCanvas(Gtk.DrawingArea):
# plain filled dot is more honest about the current zoom level.
if not hollow and icon_surface is not None and view.cell_w >= ICON_MIN_CELL_PX:
self._draw_icon_marker(cr, x, y, icon_surface, alpha)
if additive is not None:
self._draw_additive_badge(cr, x, y, additive, alpha)
elif hollow:
cr.new_path() # cairo's arc() draws a line from any stale current
cr.set_source_rgba(r, g, b, alpha) # point (e.g. the last label's
@@ -749,6 +1021,48 @@ class GridCanvas(Gtk.DrawingArea):
cr.paint_with_alpha(alpha)
cr.restore()
# How far the badge's content bbox sinks into the icon's, in the
# icon's own 32px box units. Both the diamond's top corner and the
# Armor badge's bottom are tapered to a near-point, not a flat edge
# (see assets/icons/targets/enemy/Enemy_Infantry.png and the Armor
# additives) -- lining up their bboxes exactly *touching* leaves them
# meeting at a single pixel with no visual mass on either side of it,
# which still reads as a gap. A real pixel overlap is what actually
# looks contiguous, confirmed against the game's own stacked-badge
# screenshots (stars/helmet/diamond all overlapping, not edge-to-edge).
_ADDITIVE_OVERLAP_PX = 10.0
def _draw_additive_badge(self, cr, x, y, additive, alpha) -> None:
"""A badge (underground tier, currently the only additive) drawn
directly north of the icon marker, overlapping down into it by
`_ADDITIVE_OVERLAP_PX`, at the same full size as the marker
itself -- stacked above it rather than shrunk into a corner, so
it reads as its own clearly-legible symbol, not a tiny decoration
obscuring the unit icon it modifies.
Scaled/positioned off the source art's actual opaque content
(`bbox`), not its full canvas: the additive PNGs carry a lot of
transparent padding that isn't even centered (see _ADDITIVE_CACHE's
comment), so sizing/placing off the raw canvas made the badge look
tiny and float with a visible gap above the icon -- using bbox
instead makes what's actually drawn sit right against it."""
surface, bbox = additive
sw, sh = surface.get_width(), surface.get_height()
left, top, right, bottom = bbox if bbox is not None else (0, 0, sw, sh)
content_w, content_h = right - left, bottom - top
if content_w <= 0 or content_h <= 0:
return
box = 32.0 # same visual size as the icon marker's own box
scale = box / max(content_w, content_h)
icon_top = y - 16 # _draw_icon_marker's own box=32, centered on y
ty = icon_top - bottom * scale + self._ADDITIVE_OVERLAP_PX
cr.save()
cr.translate(x - (left + right) / 2 * scale, ty)
cr.scale(scale, scale)
cr.set_source_surface(surface, 0, 0)
cr.paint_with_alpha(alpha)
cr.restore()
def _draw_firing_arrows(self, cr, view) -> None:
"""Red arrow(s) Nest -> Target, for whatever's hovered or selected.
Points at exactly the hovered/selected candidate when one is known
@@ -783,20 +1097,28 @@ class GridCanvas(Gtk.DrawingArea):
self._draw_arrow(cr, nx, ny, tx, ty)
def _draw_blast_radius(self, cr, view) -> None:
"""When a Target is selected, its effective shell's blast radius,
selection only, not hover (unlike the geo overlays/firing arrow),
per spec. Uses the specific selected candidate point if the target
is ambiguous; skipped entirely if there's no known point yet, or
the shell's blast radius isn't known."""
if not isinstance(self.selected, Target):
return
target = self.selected
point = target.coord if target.coord is not None else self.selected_point
"""Every Target's effective shell's blast radius, for whichever
ones are selected or pinned via the same "always show geo"
show_geo_desc toggle the bearing/distance overlay uses (not on
plain hover, unlike that overlay -- a blast radius circle
flickering in on every hover was judged too noisy, selection/
pinning is a deliberate choice). Uses the specific selected
candidate point if an ambiguous target is the selected one;
skipped per-target if there's no known point yet, or the shell's
blast radius isn't known."""
targets = [
t for t in self.board.targets
if not self._excluded_from_map(t) and (t is self.selected or t.show_geo_desc)
]
for target in targets:
point = target.coord if target.coord is not None else (
self.selected_point if target is self.selected else None
)
if point is None:
return
continue
radius_km = target.effective_shell.blast_radius_km
if radius_km is None:
return
continue
x, y = self._km_to_px(view, point.as_fraction())
rx, ry = view.cell_w * radius_km, view.cell_h * radius_km
@@ -871,7 +1193,14 @@ class GridCanvas(Gtk.DrawingArea):
way, so this looks at every RP/Target directly rather than those,
the only way to let the user eyeball a bad-but-close reading
against what it should have crossed."""
candidates = list(self.board.reference_points) + list(self.board.targets)
# Nest/Spotter never carry clues (always given as a direct grid
# coord, no relative-bearing mechanic for them), so leaving them
# out here wouldn't visibly change anything -- but Allies DO get
# clues from OCR ("FriendlyTank#1 Spotted. 088, 12.10km from
# Spotter#1") and also have a show_geo_desc pin in the UI (see
# app.py's per-card "always show geo" toggle), so omitting them
# here meant pinning one silently did nothing.
candidates = list(self.board.reference_points) + list(self.board.targets) + list(self.board.allies)
to_show = [
obj for obj in candidates
if obj.location.clues and not self._excluded_from_map(obj)
+388 -46
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,46 +30,136 @@ _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.STRIKE_REQUEST} == {*TargetType}, (
"every TargetType needs a row in _TARGET_ICON (STRIKE/STRIKE_REQUEST "
"are the deliberate exceptions, see the comment above target_icon_path)"
)
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, for the map-vision marker classifier, which
names what it matched by icon file rather than by TargetType.
Not injective: MECHANIZED and TANK share Armor_Mechanized.png, so that one
resolves to MECHANIZED and the user retypes it if it was a Tank (map
right-click -> Change type). Icons with no TargetType at all give None,
which callers treat as UNKNOWN.
"""
if not basename:
return None
name = basename if basename.lower().endswith(".png") else f"{basename}.png"
for prefix in ("Enemy_", "Friendly_"):
if name.startswith(prefix):
name = name[len(prefix):]
for type_, (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."""
if target_type is TargetType.STRIKE:
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/STRIKE_REQUEST (a planned impact
point, not a unit -- player-placed vs called in by a friendly, see
STRIKE_REQUEST's own comment) both get the same crosshair rather than
a unit icon, neither fits the Enemy_/Friendly_ naming scheme at all."""
if target_type in (TargetType.STRIKE, TargetType.STRIKE_REQUEST):
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:
@@ -179,10 +271,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
@@ -192,24 +284,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
@@ -300,3 +396,249 @@ 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)
_ADDITIVES_DIR = _ICONS_DIR / "targets" / "additives"
UNDERGROUND_TIERS = (1, 2, 3)
def underground_icon_path(tier: int) -> Path:
"""The badge overlaid on a Target's own icon when it's marked
underground at this tier (1..3, harder to hit = higher). Reuses the
game's own Armor-tier additive art (assets/icons/targets/additives/
Additive_Armor{1,2,3}.png) rather than inventing bespoke "underground"
art of our own -- there's nothing else in the game's icon set for
"buried/fortified", and Armor's own visual (a plate) already reads
right for that."""
return _ADDITIVES_DIR / f"Additive_Armor{tier}.png"
def underground_tier_image(tier: int | None, width: int = _TYPE_GRID_ICON_WIDTH) -> Gtk.Widget:
"""A widget for one cell of the underground-tier picker: the additive
badge itself for a real tier, or a plain dot (this module's usual
'nothing chosen' placeholder) for the "not underground" cell."""
if tier is None:
return _plain_dot(False, width)
path = underground_icon_path(tier)
if 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(False, width)
def build_underground_tier_grid(selected: int | None, on_pick) -> Gtk.Widget:
"""Same radio-style grid idea as build_target_type_grid, just over
(None, 1, 2, 3) instead of TargetType -- None first, as "not
underground" (clearing an existing tier) is exactly as valid a pick
as any real tier, not a separate "remove" action bolted on
afterward."""
_ensure_icon_button_css()
leader: Gtk.ToggleButton | None = None
items = [None, *UNDERGROUND_TIERS]
def make_button(tier: int | None) -> Gtk.Widget:
nonlocal leader
cell = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2,
margin_top=4, margin_bottom=4, margin_start=2, margin_end=2)
cell.append(underground_tier_image(tier))
label = Gtk.Label(label="None" if tier is None else f"Tier {tier}",
wrap=False, single_line_mode=True,
justify=Gtk.Justification.CENTER, width_chars=9,
max_width_chars=9, ellipsize=Pango.EllipsizeMode.END)
label.add_css_class("caption")
label.add_css_class("dim-label")
cell.append(label)
btn = Gtk.ToggleButton(child=cell)
btn.add_css_class("flat")
btn.add_css_class(_ICON_BUTTON_CSS_CLASS)
btn.set_tooltip_text("Not underground" if tier is None else f"Underground, tier {tier}")
if leader is None:
leader = btn
else:
btn.set_group(leader)
if tier is selected:
btn.set_active(True)
btn.connect("clicked", lambda _b, tier=tier: on_pick(tier))
return btn
return _build_icon_grid(items, _TYPE_GRID_COLUMNS, make_button)
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/STRIKE_REQUEST are never offered: neither is a unit type at
all (a planned impact point, not a contact), each is always created
through its own path instead -- STRIKE via app.py's dedicated "Add
strike" action, STRIKE_REQUEST via ocr.py parsing a fire-support
request -- never by picking a type from this generic grid. There's
no such thing as a Strike-typed Ally either, offering either one
here 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 not in (TargetType.STRIKE, TargetType.STRIKE_REQUEST) and _has_own_icon(t, is_ally)
]
def target_type_label(t: "TargetType", is_ally: bool) -> str:
"""Display text for a picker cell/tooltip, or any other UI spot that
would otherwise print obj.type.value directly (map popover headings,
"Change type" buttons, toasts, ...). 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
changes -- callers that need an id-safe short form (Ally.name etc.)
keep using TargetType.short, not this."""
if is_ally and t is TargetType.ENEMY:
return "Ally"
return t.value
# Old private name, kept as an alias: nothing outside this module should
# gain a new dependency on it, but this file's own internal callers below
# were written against it.
_target_type_label = target_type_label
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)
+253
View File
@@ -0,0 +1,253 @@
"""State and threading for importing a map screenshot.
Deliberately free of any GTK import so it can be exercised headlessly. The
dialog and the map overlay sit on top of this; everything here is plain
Python and numpy.
Two jobs:
* run the vision pipeline OFF the UI thread. `solve()` takes 10-20s, which
would freeze the window, so it runs in a worker and the result is handed
back through a scheduler callback (GLib.idle_add in the app, called
directly in tests). A thread is sufficient rather than a process: the work
is numpy/OpenCV, which releases the GIL and already multithreads
internally.
* hold the review state. Detections arrive as PROPOSALS, not as board
entries: each is accepted or rejected individually (or all at once), the
unit type can be corrected, and dropping the screenshot discards whatever
was never accepted.
"""
from __future__ import annotations
import threading
from dataclasses import dataclass, field
from . import map_vision
# Distinguishable on_done error: this screenshot is typewriter text, so the
# caller should send it down its normal OCR path rather than report a failure.
NOT_A_MAP = "not a map screenshot"
@dataclass
class Proposal:
"""One detected marker awaiting the user's decision."""
side: str # "hostile" | "friendly"
label: str # e.g. "K8"
sub_x: int
sub_y: int
unit: str | None # game unit name, or None when unsure
centre: tuple # pixel centre in the solved image
box: tuple
accepted: bool = False
rejected: bool = False
# classify_marker's own raw numbers behind `unit` (best-match score,
# and its margin over the runner-up) -- unit alone only says whether
# it beat min_score/min_margin, not by how much or how close a call
# it was. Ground truth needs these to tell "confidently wrong" apart
# from "just barely missed the bar", which `unit=None` alone can't.
unit_score: float = 0.0
unit_margin: float = 0.0
# The TargetType.name actually applied when accepted -- usually just
# `unit` translated through icons.target_type_from_icon, but can
# differ if the user corrected it via "Accept as...". Set by
# app.py's _accept_proposal, the only writer. Ground truth for
# debug_capture.save_marker_ground_truth: `unit` is what the
# classifier guessed, this is what the user actually confirmed.
confirmed_type: str | None = None
# The marker's own "#<N>" id label, as read off the screenshot by
# map_vision.read_marker_id -- distinct from `label`/sub_x/sub_y
# (the grid CELL this marker is in), this is the small per-unit id
# the game itself draws. None when unread/unconfident (see
# read_marker_id's own docstring: best-effort, not yet validated
# against a real ground-truth batch). Meant for future dedup work
# (see TODO.md) once there's confidence in the read; not otherwise
# consumed yet.
detected_id: str | None = None
@property
def coord(self) -> str:
return f"{self.label} {self.sub_x}:{self.sub_y}"
@property
def pending(self) -> bool:
return not (self.accepted or self.rejected)
@dataclass
class ScreenshotImport:
"""An accepted screenshot plus its proposals, as shown over the map."""
solution: object
image: object
proposals: list = field(default_factory=list)
overlay: object = None # BGRA array in map space
px_per_km: int = 0
# The same screenshot at full resolution, plus its width / `image`'s
# width -- `image` is downscaled to WORK_W for solving/marker-detection
# speed (see map_vision.WORK_W), which is plenty for those but throws
# away real detail the map overlay doesn't need to give up too (a
# screenshot can be up to 6880px wide, see map_vision.load_full_res's
# docstring). None/1.0 (rather than always loading it) because it's
# only needed for build_overlay(), and app.py sets it right after
# solving, before build_overlay() is ever called.
full_image: object = None
full_image_scale: float = 1.0
# Board.targets/Board.allies as they stood right when this screenshot's
# grid was confirmed (see app.py's _accept_grid) -- Target/Ally are
# identity-hashable (models.py's `eq=False`), so these are plain sets
# of the actual live objects, not ids/copies. Whatever's in
# board.targets/board.allies but NOT in these sets when the screenshot
# is later dropped was added while this screenshot was up, by
# whatever means (an accepted proposal, a manual add, an OCR-text
# merge run alongside it, ...) -- see app.py's _remove_screenshot,
# which treats that as this screenshot's ground truth for
# debug_capture.save_marker_ground_truth. Left for app.py to populate
# rather than done here, this module stays ignorant of the Board/
# Target/Ally types on purpose (see this file's own docstring).
baseline_targets: set = field(default_factory=set)
baseline_allies: set = field(default_factory=set)
def set_proposals(self, markers):
self.proposals = [
Proposal(side=m["side"], label=m["label"], sub_x=m["sub_x"],
sub_y=m["sub_y"], unit=m.get("unit"),
centre=m["centre"], box=m["box"],
detected_id=m.get("detected_id"),
unit_score=m.get("unit_score", 0.0),
unit_margin=m.get("unit_margin", 0.0)) for m in markers]
return self.proposals
def build_overlay(self, px_per_km=150):
"""Rectify the screenshot into map space, ready to draw under the grid.
Uses full_image (full resolution) over image (WORK_W-downscaled) when
available, see full_image's own docstring."""
src, scale = (self.full_image, self.full_image_scale) if self.full_image is not None \
else (self.image, 1.0)
self.overlay, self.px_per_km = map_vision.warp_to_map(
src, self.solution, px_per_km=px_per_km, img_scale=scale)
return self.overlay
def accept_all(self):
for p in self.proposals:
if p.pending:
p.accepted = True
def reject_all(self):
for p in self.proposals:
if p.pending:
p.rejected = True
def accepted(self):
return [p for p in self.proposals if p.accepted]
def pending(self):
return [p for p in self.proposals if p.pending]
def drop_unaccepted(self):
"""Removing the screenshot discards everything never accepted."""
self.proposals = [p for p in self.proposals if p.accepted]
class ImportJob:
"""Runs the vision pipeline in a worker thread.
`on_done(result, error)` is delivered through `schedule`, which the app
sets to GLib.idle_add so the callback lands on the UI thread. Nothing here
may touch a widget.
"""
def __init__(self, schedule=None):
self.schedule = schedule or (lambda fn, *a: fn(*a))
self._cancelled = threading.Event()
self._thread = None
@property
def cancelled(self) -> bool:
return self._cancelled.is_set()
def cancel(self):
"""Ask the worker to stop. The result is simply dropped -- the vision
code is pure and side-effect free, so abandoning it is safe."""
self._cancelled.set()
def looks_like_map(self, img) -> bool:
"""Cheap synchronous routing test (~0.3s), safe to call inline.
Measured: gates in 9 of 10 map screenshots and 6% of 122 writer
screenshots. Its false positives only cost time, because solve() is
the real decision and accepts none of the 122.
"""
return map_vision.looks_like_map(img)
def start(self, path, on_done, gate=True):
"""Run the pipeline for `path`, delivering on_done(result, error).
With `gate` on, the routing test runs in the worker too and a text
screenshot comes back as error NOT_A_MAP. That keeps the whole
map-or-text decision off the UI thread: the gate is only ~0.3s, but
the caller is on the clipboard path, where a hitch is felt.
Only the GRID is solved here. Marker detection is a separate phase
(find_markers) run after the user has confirmed or corrected the grid,
because every marker position is expressed in grid coordinates: finding
them against a grid that's about to be dragged would only be thrown
away and redone.
"""
def work():
if gate and not map_vision.looks_like_map(map_vision.load(path)):
return None, NOT_A_MAP
sol, img, err = map_vision.solve_path(path)
if sol is None:
return None, err
imp = ScreenshotImport(solution=sol, image=img)
# Best-effort: a sharper source for build_overlay() than the
# WORK_W-downscaled `img` solving used (see full_image's own
# docstring). Anything going wrong here just means the overlay
# falls back to `img`, not worth failing the whole import over.
try:
full = map_vision.load_full_res(path)
imp.full_image = full
imp.full_image_scale = full.shape[1] / img.shape[1]
except (ValueError, ZeroDivisionError, OSError):
pass
return imp, None
return self._run(work, on_done, "map-import")
def find_markers(self, imp, on_done):
"""Second phase: detect units against the now-confirmed grid.
Fills imp.proposals and delivers on_done(imp, error). Its own thread,
because the user's grid correction sits between the two phases.
Marker detection itself always runs against imp.image (WORK_W,
same as solving used); imp.full_image is passed through only for
reading each marker's own tiny id label off a sharper source, see
map_vision.find_markers' own id_img param.
"""
def work():
id_img = imp.full_image # None is fine, find_markers falls back to imp.image
imp.set_proposals(map_vision.find_markers(
imp.image, imp.solution, id_img=id_img, id_scale=imp.full_image_scale))
return imp, None
return self._run(work, on_done, "map-markers")
def _run(self, work, on_done, name):
"""Run work() in a thread and marshal its (result, error) back.
work() only computes and returns; delivery and the cancellation check
live here, so no phase can deliver into a UI the user has moved on from.
"""
def guarded():
try:
result, error = work()
except Exception as exc: # worker must never die silently
result, error = None, f"{type(exc).__name__}: {exc}"
if not self._cancelled.is_set():
self.schedule(on_done, result, error)
self._cancelled.clear()
self._thread = threading.Thread(target=guarded, daemon=True, name=name)
self._thread.start()
return self._thread
File diff suppressed because it is too large Load Diff
+147 -17
View File
@@ -18,6 +18,7 @@ Coord) to work out everything else. This module just defines the shape.
from __future__ import annotations
import itertools
import string
from dataclasses import dataclass, field
from enum import Enum
@@ -44,20 +45,86 @@ 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
STRIKE = "Strike" # a planned impact point, not an enemy contact --
# player-placed only (app.py's dedicated "Add Strike" flow / map
# right-click), never produced by OCR.
STRIKE_REQUEST = "Strike Request" # a planned impact point a friendly
# unit is calling in over the radio (ocr.py's "taking fire" fire-
# support-request grammar, when it names a bearing/distance offset
# from the reporter rather than the reporter's own position), as
# opposed to STRIKE, which the player places themselves. Same
# "not an enemy contact, just an impact point" shape as STRIKE
# (dedupe_generic_targets/icons.py both treat the two the same way),
# kept as its own type rather than reusing STRIKE so a request that
# came in over the radio is never confused for one the player chose.
# -- 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:
@@ -344,6 +411,10 @@ class Target:
# raw string as printed, this app doesn't track a game clock to compare
# it against, it's shown as-is for the player's own reference.
requested_time: str | None = None
# None = not underground. 1..3 = underground, at that hardening tier
# (see icons.UNDERGROUND_TIERS) -- higher survives more. Target-only:
# there's no such thing as an underground Ally in this game.
underground_tier: int | None = None
@property
def name(self) -> str:
@@ -432,6 +503,39 @@ class ScoutFlight:
return f"ScoutFlight#{self.id}"
def _next_free_id(used: set[str]) -> str:
"""Next unused id in a short, human-friendly LETTER sequence: single
uppercase letters (A..Z) first, then two-letter combinations
(AA..ZZ, spreadsheet-column style) once those run out, and so on.
Scoped per TYPE, not per group: add_target()/add_ally() only look at
existing entities of the SAME type when building `used`, so a Tank
and an Infantry added back to back both start at 'A' (Tank#A,
Infantry#A), each type keeping its own independent sequence.
Deliberately letters, not numbers: a manually-added entity (map
right-click "Add target", or an accepted screenshot proposal with no
confident id read) has no real game id to report, so it gets an
obviously-not-a-real-id placeholder instead -- app.py's
_accept_proposal reserves plain numbers for an id it's actually
confident was read off the marker itself (map_vision.read_marker_id
via Proposal.detected_id), passed straight through as this
function's caller's `id_` and never touching this auto-assignment at
all. Letters can't collide with a real (numeric) detected id either,
on top of just reading honestly as 'made up'.
Can't run out the way `next(c for c in string.ascii_uppercase if c
not in used)` used to (a real regression, see TODO.md): rolls over to
two-letter ids ('AA', 'AB', ...) past 26 instead of raising
StopIteration."""
length = 1
while True:
for combo in itertools.product(string.ascii_uppercase, repeat=length):
candidate = "".join(combo)
if candidate not in used:
return candidate
length += 1
SAVE_FORMAT_VERSION = 3
@@ -502,9 +606,18 @@ class Board:
location: Location | Coord | None = None,
id_: str | None = None,
) -> Target:
# Own A/B/C... sequence per TYPE, not one shared across every
# target regardless of type -- a Tank and an Infantry auto-
# assigned back to back both start at 'A' (Tank#A, Infantry#A).
# targets-vs-allies is still its own separate id namespace (see
# add_ally); type now subdivides it further too. Letters, not
# numbers, when auto-assigning here specifically: see
# _next_free_id's own docstring for why (a real detected id, when
# there is one, is passed in as `id_` and never reaches this
# auto-assignment at all).
if not id_:
used = {t.id for t in self.targets if t.type == type_}
id_ = next(c for c in string.ascii_uppercase if c not in used)
id_ = _next_free_id(used)
t = Target(type=type_, id=id_, location=_as_location(location))
self.targets.append(t)
return t
@@ -519,13 +632,14 @@ class Board:
location: Location | Coord | None = None,
id_: str | None = None,
) -> Ally:
# A separate id namespace from add_target()'s: an ally Tank#1
# and a hostile Target Tank#1 are unrelated, so auto-assignment
# here only looks at other allies of the same type, never
# self.targets.
# A separate id namespace from add_target()'s: an ally Tank#A
# and a hostile Target Tank#A are unrelated, so auto-assignment
# here only looks at other allies, never self.targets. Own
# A/B/C... sequence per TYPE too, same as add_target -- see its
# own comment and _next_free_id.
if not id_:
used = {a.id for a in self.allies if a.type == type_}
id_ = next(c for c in string.ascii_uppercase if c not in used)
id_ = _next_free_id(used)
a = Ally(type=type_, id=id_, location=_as_location(location))
self.allies.append(a)
return a
@@ -553,16 +667,30 @@ class Board:
# -- reset ----------------------------------------------------------
def clear(self) -> None:
"""Drop everything: Nest position, spotters, reference points,
targets, scout flights. Used by the "clear board" action for a
fresh start without restarting the app."""
targets, allies, scout flights. Used by the "clear board" action
for a fresh start without restarting the app."""
self.nest = Nest()
self.spotters.clear()
self.reference_points.clear()
self.targets.clear()
self.allies.clear()
self.scout_flights.clear()
self._spotter_seq = 0
self._scout_flight_seq = 0
def clear_units(self) -> None:
"""Partial reset: drop targets, allies, and scout flights, but keep
the Nest, spotters, and reference points -- those are recon
infrastructure the player set up deliberately and usually wants to
keep across a round, unlike enemy/ally contacts and planned
overflights, which go stale fast. Wired to the Clear button's
right-click menu ("Clear enemies, units & flights") as a lighter
alternative to clear()."""
self.targets.clear()
self.allies.clear()
self.scout_flights.clear()
self._scout_flight_seq = 0
def reorder_target(self, target: Target, new_index: int) -> None:
"""Manual drag-order: `self.targets`' list order is itself the
persisted order (saved/loaded as a plain JSON array), and is what
@@ -660,6 +788,7 @@ class Board:
"shell": t.shell.name if t.shell is not None else None,
"assignment": t.assignment,
"requested_time": t.requested_time,
"underground_tier": t.underground_tier,
}
for t in self.targets
],
@@ -727,6 +856,7 @@ class Board:
shell=Shell[t["shell"]] if t.get("shell") else None,
assignment=t.get("assignment", "unassigned"),
requested_time=t.get("requested_time"),
underground_tier=t.get("underground_tier"),
)
for t in data.get("targets", [])
]
+196 -15
View File
@@ -197,6 +197,106 @@ def _extract_requested_time(text: str) -> str | None:
return m.group(1) if m else None
# A second, unrelated fire-support-request grammar, seen from Infantry
# under attack ("taking fire") rather than a pinned Marine Garrison:
# Infantry#1 taking fire from id1! Requesting SMK Shell on our
# position at J6 2:7 before 10:38:57!
# Infantry#3 taking fire! Requesting HE Shell at bearing 239°,
# distance 10.76km from our position, J6 2:5, by 10:38:18 or we
# will be overrun!
# Differs from the Marine Garrison shape in every particular: the shell
# word order is reversed ("Requesting X Shell", not "X Shells requested"),
# the deadline has no "Requested"/dashes, just a bare "before"/"by <time>",
# and the target position is either given directly ("on our position at
# <coord>") or as a bearing/distance offset from that same inline
# position (never a *named* reference -- "our position" isn't a board
# entity, so this resolves the offset directly rather than going through
# a Clue).
# \s* (not \s+) between the shell code and 'Shell(s)': a rich-text paste's
# '<b>SMK Shell</b>' span gets squashed into one no-space token 'SMKShell'
# by squash_bold_spans() before this ever runs (same as any other
# multi-word bold span, see _squash_span_content), same reasoning as
# _extract_requesting_shell()'s docstring.
_REQUESTING_SHELL_RE = re.compile(r"Requesting\s+([A-Za-z]+?)\s*Shells?\b", re.IGNORECASE)
_TAKING_FIRE_TIME_RE = re.compile(r"\b(?:before|by)\s+(T?\d{1,2}:\d{2}:\d{2})\b", re.IGNORECASE)
# "<Type>#<id> taking fire!" always names the REPORTING unit calling in
# its own distress -- necessarily a friendly, no hostile ever radios in
# about itself under attack. There's no "Friendly"/"Hostile" prefix word
# anywhere in this grammar (see this module's own comment above) for
# _resolve_target_type to key off of, so without this the reporting unit
# defaults to not-ally (its own default) and gets added as an enemy.
_TAKING_FIRE_RE = re.compile(r"\btaking fire\b", re.IGNORECASE)
_ON_OUR_POSITION_COORD_RE = re.compile(
rf"on\s+our\s+position\s+at\s+{_COORD_FRAGMENT}", re.IGNORECASE
)
_BEARING_DISTANCE_FROM_POSITION_RE = re.compile(
rf"bearing\s*({_DIGIT_CLASS}{{1,3}})\s*°?\s*,?\s*distance\s*([\d.]+)\s*k?m\s+from\s+our\s+"
rf"position,?\s*{_COORD_FRAGMENT}",
re.IGNORECASE,
)
def _extract_requesting_shell(text: str) -> Shell | None:
"""'Requesting SMK Shell' -- the taking-fire grammar's shell mention,
word order reversed from _extract_shell_request()'s Marine Garrison
one ('SMK Shells requested')."""
m = _REQUESTING_SHELL_RE.search(text)
if not m:
return None
try:
return Shell[m.group(1).upper()]
except KeyError:
return None
def _extract_taking_fire_time(text: str) -> str | None:
m = _TAKING_FIRE_TIME_RE.search(text)
return m.group(1) if m else None
def _extract_on_our_position_coord(text: str) -> Coord | None:
m = _ON_OUR_POSITION_COORD_RE.search(text)
if not m:
return None
return _coord_from_groups(*m.groups())
def _extract_bearing_distance_from_position_coord(text: str) -> Coord | None:
"""The bearing/distance variant of a taking-fire request: the shell is
wanted somewhere OFF the reporting unit's own position, given as a
bearing/distance from it, with that position itself given inline
right there ('...from our position, J6 2:5, by ...'). "our position"
isn't a named board entity to hang a Clue off of, so this resolves
the offset directly via the same polar-projection math solve_location()
uses for an ordinary single bearing+distance clue."""
m = _BEARING_DISTANCE_FROM_POSITION_RE.search(text)
if not m:
return None
bearing, distance, letter, y, x, yy = m.groups()
origin = _coord_from_groups(letter, y, x, yy)
if origin is None:
return None
point = solver.point_from_bearing_distance(
origin.as_fraction(), float(_fix_digits(bearing)), float(distance))
return solver.point_to_coord(point)
def _extract_our_position_coord(text: str) -> Coord | None:
"""The bearing/distance variant's OWN inline position ('...from our
position, J6 2:5, by ...'), as opposed to
_extract_bearing_distance_from_position_coord's computed offset from
it. Reported unit and requested fire point are two different places
for this variant (unlike the direct "on our position at <coord>" one,
a real danger-close call), so parse_intel_blocks's flush() uses this
for the reporting unit's own entry and the offset for a second,
separate Strike entry -- see its comment."""
m = _BEARING_DISTANCE_FROM_POSITION_RE.search(text)
if not m:
return None
_bearing, _distance, letter, y, x, yy = m.groups()
return _coord_from_groups(letter, y, x, yy)
# "Reported active in grid D10": only the large-grid cell, no sub-grid
# x:y at all, unlike every other coord shape in this file. Tried last
# (after _extract_grid_coord, which requires the full x:y and so is
@@ -275,6 +375,17 @@ _NAMED_HEADER_RE = re.compile(rf"^{_TYPE_ID_FRAGMENT}\s*:?\s*(.*)$")
# one), nothing else anchors this match, so an optional colon would
# false-positive on an ordinary clue-continuation line's leading word.
_BARE_NAME_HEADER_RE = re.compile(r"^([A-Za-z][A-Za-z0-9]*)\s*:\s*(.*)$")
# Even requiring the colon isn't quite enough: a prose lead-in word right
# before a genuinely useful follow-up line ("Important: TEAR Shell first,
# then HE Shell.") false-positives the same way -- confirmed live against
# a real taking-fire message, where "Important:" got read as a brand new
# named entity ("Target#Important"), stealing the actual report's own
# "Answer by <time>" deadline into that bogus entry instead of the real
# one. None of these read as an actual thing being spotted/named, only
# ever as a prose interjection.
_BARE_NAME_HEADER_BLOCKLIST = {
"important", "note", "warning", "attention", "caution", "alert", "reminder", "priority",
}
# Ad-hoc enemy installations are named in plain English rather than given a
# Type#N id ("Enemy Signal Station:", "Bearing 034 from Enemy Signal
@@ -515,10 +626,15 @@ _BARE_CLUE_VALUE_RE = re.compile(
_TYPE_BY_SHORT = {t.short: t for t in TargetType}
# The game's typewriter has used "AmmoCache" for what's now modeled as
# SupplyCache, and "CoastalBattery" for what's just a HostileArtillery
# under a different name, treat both as the same type rather than
# dropping the target or inventing a redundant enum member for it.
_TYPE_WORD_ALIASES = {"AmmoCache": "SupplyCache", "CoastalBattery": "HostileArtillery"}
# SupplyCache, "CoastalBattery" for what's just a HostileArtillery under
# a different name, and "Field Gun" for plain Artillery too (confirmed
# by the user against a real "Enemy Field Gun#1 Destroyed" kill-feed line
# that was otherwise silently dropping) -- treat all three as the same
# type rather than dropping the target or inventing a redundant enum
# member for each alternate name.
_TYPE_WORD_ALIASES = {
"AmmoCache": "SupplyCache", "CoastalBattery": "HostileArtillery", "FieldGun": "Artillery",
}
_REF_NAMED_RE = re.compile(rf"^{_TYPE_ID_FRAGMENT}")
@@ -606,17 +722,27 @@ def parse_clues_from_text(text: str) -> list[Clue]:
return _parse_all_clues(squash_enemy_names(squash_multiword_ids(text)))
_ALLY_PREFIX_RE = re.compile(r"^(Friendly|Hostile)", re.IGNORECASE)
# 'Enemy' is also a valid not-ally prefix ("Enemy Field Gun#1 Destroyed",
# squashed to "EnemyFieldGun#1" by squash_multiword_ids -- confirmed live
# by the user this was silently dropping, the FieldGun->Artillery alias
# added below wasn't even reached because "Enemy" was never stripped off
# first, so alias/fuzzy lookup ran against "EnemyFieldGun" as a whole,
# not just "FieldGun"). The lookahead requires something AFTER the
# prefix: a bare "Enemy" alone must NOT match here and fall through
# un-stripped instead, since TargetType.ENEMY's own value IS "Enemy" --
# stripping it there would leave an empty type_word with nothing left to
# resolve, dropping every bare ad-hoc "Enemy#N Destroyed" report.
_ALLY_PREFIX_RE = re.compile(r"^(Friendly|Hostile|Enemy)(?=.)", re.IGNORECASE)
def _resolve_target_type(type_word: str) -> tuple[TargetType | None, bool]:
"""(TargetType, is_ally). A leading 'Friendly'/'Hostile' word is
stripped off the type word first ('FriendlyTank' -> ally, TANK;
'HostileTank' or bare 'Tank' -> not ally, TANK, an explicit
'Hostile' and no prefix at all mean the same thing, not-ally is the
default). What's left is matched exactly against the type word
(after aliasing), falling back to fuzzy (OCR can garble the type
word itself, e.g. 'AmmoCoche')."""
"""(TargetType, is_ally). A leading 'Friendly'/'Hostile'/'Enemy' word
is stripped off the type word first ('FriendlyTank' -> ally, TANK;
'HostileTank'/'EnemyTank'/bare 'Tank' -> not ally, TANK -- explicit
'Hostile'/'Enemy' and no prefix at all all mean the same thing,
not-ally is the default). What's left is matched exactly against the
type word (after aliasing), falling back to fuzzy (OCR can garble
the type word itself, e.g. 'AmmoCoche')."""
is_ally = False
prefix_m = _ALLY_PREFIX_RE.match(type_word)
if prefix_m:
@@ -657,16 +783,66 @@ def parse_intel_blocks(text: str) -> list[dict]:
if current is not None:
joined = "\n".join(current["raw"])
current["clues"] = _parse_all_clues(joined)
# The bearing/distance taking-fire variant names TWO different
# places (see _extract_our_position_coord's docstring): the
# reporting unit's own position, and a separate fire point
# offset from it. Everything else in this module is "one block
# -> one entry", so that offset gets split into a second,
# synthetic StrikeRequest entry below rather than folded into
# this one -- otherwise the fire point either overwrites the
# unit's real position (wrong place) or gets silently dropped.
offset_coord = _extract_bearing_distance_from_position_coord(joined)
current["coord"] = (
_extract_grid_coord(joined) or _extract_requested_on_coord(joined)
or _extract_on_our_position_coord(joined)
or _extract_our_position_coord(joined)
or _extract_large_grid_only_coord(joined)
)
current["shell"] = _extract_shell_request(joined)
current["requested_time"] = _extract_requested_time(joined)
shell = _extract_shell_request(joined) or _extract_requesting_shell(joined)
requested_time = _extract_requested_time(joined) or _extract_taking_fire_time(joined)
# See _TAKING_FIRE_RE's own comment: overrides whatever
# _resolve_target_type would otherwise infer from type_word
# alone (no "Friendly"/"Hostile" prefix exists in this
# grammar to key off of) -- the reporting unit is always the
# friendly calling this in, never the hostile.
is_taking_fire = bool(_TAKING_FIRE_RE.search(joined))
current["force_ally"] = is_taking_fire
# Fire-support info (shell/deadline) always describes the FIRE
# POINT, never the reporting/named entity itself. For the
# bearing/distance offset variant that's a different place
# than the entity's own position. For a plain taking-fire
# report ("on our position") it's the SAME coord as the
# entity's own position, but the entity itself is now an ally
# (force_ally above), and Ally entries carry no shell/deadline
# fields at all to hold it -- either way a taking-fire
# report's shell+deadline moves to a synthetic StrikeRequest
# entry below rather than staying on this one, where it would
# either be wrong (offset case) or silently dropped (ally
# case, once merge_all only reads (raw, clues, coord) for allies).
split_fire_request = offset_coord is not None or is_taking_fire
current["shell"] = None if split_fire_request else shell
current["requested_time"] = None if split_fire_request else requested_time
if (current["clues"] or current["coord"] is not None
or current["shell"] is not None or current["requested_time"] is not None):
current["raw"] = joined
entries.append(current)
# No offset given ("on our position") means the fire point
# IS the entity's own position, not a separate place.
fire_coord = offset_coord if offset_coord is not None else current["coord"]
if split_fire_request and fire_coord is not None and (shell is not None or requested_time is not None):
# TargetType.STRIKE_REQUEST, not STRIKE: this is a
# friendly unit calling in a strike over the radio, not
# one the player placed themselves (see that type's own
# comment in models.py). type_word must match its
# TargetType.short exactly ("StrikeRequest", no space),
# same as every other type_word this module produces.
strike_id = f"{current['type_word']}{current['id']}"
entries.append({
"kind": "named", "name": f"StrikeRequest#{strike_id}",
"type_word": "StrikeRequest",
"id": strike_id, "raw": joined, "clues": [], "coord": fire_coord,
"shell": shell, "requested_time": requested_time,
})
current = None
for raw_line in text.splitlines():
@@ -743,7 +919,11 @@ def parse_intel_blocks(text: str) -> list[dict]:
# 'Target'), not a Reference Point, a named thing giving its own
# clues is being spotted, not a fixed landmark spotters aim off
# of, same reasoning as the "Target is at-" calibration line.
bare_m = next((m for c in candidates if (m := _BARE_NAME_HEADER_RE.match(c))), None)
bare_m = next(
(m for c in candidates if (m := _BARE_NAME_HEADER_RE.match(c))
and m.group(1).lower() not in _BARE_NAME_HEADER_BLOCKLIST),
None,
)
if bare_m:
flush()
name = bare_m.group(1)
@@ -1007,6 +1187,7 @@ def parse_text(text: str) -> ParsedInfo:
info.reference_points[entry["name"]] = (entry["raw"], entry["clues"], entry["coord"])
continue
target_type, is_ally = _resolve_target_type(entry["type_word"])
is_ally = is_ally or entry.get("force_ally", False)
if target_type is None:
continue
if is_ally:
+27 -12
View File
@@ -182,15 +182,28 @@ def point_to_coord(point: Point) -> Coord | None:
col = min(max(col, 0.0), 19.999)
row = min(max(row, 0.0), 9.999)
x_idx = int(col)
x = round((col - x_idx) * 10)
if x > 9:
x, x_idx = 0, min(x_idx + 1, 19)
# A real, reproducible bug lived here: Coord.as_fraction() centers a
# sub-cell at x + 0.5 (so a marker drawn at its own coord's exact
# pixel position round-trips back to the same coord), which means
# the value being rounded here is supposed to land EXACTLY on a .5
# boundary, the single worst case for floating point, tiny
# representation error from the col/row math upstream (pixel <->
# km conversions, zoom/pan, or even just this function's own
# subtraction) can tip it to either side of round()'s tie-breaking
# rule and silently return a coord one sub-cell off from the one
# that was actually clicked (verified directly: reproduced with
# zero pixel math involved at all, just Coord(...).as_fraction()
# fed straight back into this function). Subtracting the 0.5 offset
# BEFORE rounding recovers a value that's supposed to be an exact
# integer instead of an exact half-integer, round() is robust to
# tiny float noise around a true integer, just not around X.5.
n_col = round(col * 10 - 0.5)
x_idx, x = divmod(n_col, 10)
x_idx = min(max(x_idx, 0), 19)
Y = int(row) + 1
y = round((row - (Y - 1)) * 10)
if y > 9:
y, Y = 0, min(Y + 1, 10)
n_row = round(row * 10 - 0.5)
y_idx, y = divmod(n_row, 10)
Y = min(max(y_idx, 0), 9) + 1
return Coord(X=LARGE_X[x_idx], Y=Y, x=x, y=y)
@@ -359,14 +372,16 @@ def dedupe_generic_targets(board: Board) -> list[str]:
same* position as an already-known specific target, it's not a new
contact, it's the same one being spotted, just described more
precisely. Drop the redundant generic entry, keep the specific one.
Strikes are our own planned impacts, not enemy contacts, and never
participate. Run this after resolve_board(), since positions may
only become comparable once resolved. Returns the names removed."""
Strikes (player-placed or requested) are planned impacts, not enemy
contacts, and never participate. Run this after resolve_board(),
since positions may only become comparable once resolved. Returns
the names removed."""
removed: list[str] = []
unknowns = [t for t in board.targets if t.type is TargetType.UNKNOWN and t.coord is not None]
specifics = [
t for t in board.targets
if t.type not in (TargetType.UNKNOWN, TargetType.STRIKE) and t.coord is not None
if t.type not in (TargetType.UNKNOWN, TargetType.STRIKE, TargetType.STRIKE_REQUEST)
and t.coord is not None
]
for generic in unknowns:
if any(generic.coord == specific.coord for specific in specifics):
+36
View File
@@ -0,0 +1,36 @@
# Test fixtures
Real screenshots of the game, used as regression data. They are the game
author's work, not covered by this repo's MIT license (see `/LICENSE`).
## `map_shots/` + `map_shots_gt.json`
The evaluation set for the map-grid solver (`src/fenigma/map_vision.py`), scored
by `tools/eval_map_vision.py`. The JSON holds hand-transcribed cell labels at
native pixel positions; its own header comment explains the format and the
9%/6% label-padding constant.
Shots wider than 2400px were downscaled to 2400px, and their ground-truth
coordinates rescaled with them. 2400 is `map_vision.RETRY_WORK_W`, the widest
the pipeline ever works at, so nothing the code can actually read was lost.
Measured after the downscale: the same 7 of 10 solve, 100% of their points land
in the correct cell, residual spread unchanged.
`too_hard/` holds shots that are permanent rejections; see its own README.
## `writer_shots/`
Typewriter/field-log screenshots. Two uses:
- Measuring false positives in the map-vs-text routing gate
(`map_vision.looks_like_map` / `solve`). Over the full 122-shot set the cheap
gate false-positived on 6% and `solve()` accepted **none**.
- OCR regression material for `ocr.py`.
Kept at NATIVE resolution deliberately: the routing gate only ever sees 1500px,
but OCR needs the text legible, so these must not be downscaled.
Ten shots are committed, chosen to span the capture-scale range (262px to
5366px wide) since scale is what both the gate and OCR are sensitive to. The
false-positive numbers above were measured on all 122; this subset is a
regression guard, not the measurement.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 791 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

+23
View File
@@ -0,0 +1,23 @@
# Screenshots we deliberately do not try to solve
Kept for the record, excluded from the evaluation set. These fail for reasons
that are properties of the screenshot, not of the algorithm, so working around
them would mean guessing:
- `06.png`, `14.png` — exactly ONE grid label visible. A single label cannot be
cross-checked, so a misread would silently shift the whole board with nothing
to contradict it. Two mutually consistent labels is the minimum safe anchor.
- `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.
+739
View File
@@ -0,0 +1,739 @@
{
"_comment": [
"Ground truth for the map-vision fixtures, transcribed by hand from the",
"screenshots. Each entry is [cell_label, x, y] in NATIVE pixel coordinates",
"of the corresponding file in map_shots/, where (x, y) is roughly the",
"centre of the drawn cell label glyphs.",
"",
"The invariant being asserted is simply: the pixel (x, y) lies inside the",
"map cell named by cell_label. That is enough to catch every failure mode",
"seen so far (wrong lattice scale, wrong integer offset, badly misfitted",
"homography) without needing sub-pixel corner annotation.",
"",
"Positions were read off a ruler overlay by eye, so treat them as accurate",
"to roughly +/-15 native px. The cell IDENTITIES are exact.",
"",
"Game UI constant, useful for the estimator: a cell's label is drawn with",
"about 9% of the cell size as padding from the cell's left edge and 6% from",
"its top edge, so label_top_left - (0.09, 0.06) * cell_size lands on the",
"cell's top-left corner."
],
"01.png": [
[
"G10",
899,
294
],
[
"H10",
996,
294
],
[
"I10",
1083,
294
],
[
"J10",
1168,
294
],
[
"K10",
1270,
294
],
[
"L10",
1355,
294
],
[
"M10",
1446,
294
],
[
"N10",
1537,
294
],
[
"O10",
1628,
294
],
[
"P10",
1716,
294
],
[
"G9",
882,
366
],
[
"I9",
1072,
366
],
[
"K9",
1266,
366
],
[
"M9",
1456,
366
],
[
"O9",
1650,
366
],
[
"H8",
965,
445
],
[
"J8",
1168,
445
],
[
"L8",
1370,
445
],
[
"N8",
1572,
445
],
[
"H7",
953,
536
],
[
"J7",
1166,
536
],
[
"L7",
1379,
536
],
[
"N7",
1592,
536
],
[
"H6",
935,
637
],
[
"J6",
1166,
637
],
[
"L6",
1391,
637
],
[
"N6",
1619,
637
]
],
"02.png": [
[
"I9",
54,
28
],
[
"J9",
329,
28
],
[
"K9",
608,
30
],
[
"L9",
880,
28
],
[
"M9",
1155,
28
],
[
"I8",
37,
257
],
[
"J8",
320,
257
],
[
"K8",
620,
257
],
[
"L8",
902,
257
],
[
"M8",
1197,
257
],
[
"I7",
11,
517
],
[
"J7",
316,
517
],
[
"K7",
626,
517
],
[
"L7",
936,
517
],
[
"M7",
1248,
517
],
[
"J6",
313,
805
],
[
"K6",
638,
805
],
[
"L6",
968,
805
],
[
"M6",
1295,
805
]
],
"03.png": [
[
"H8",
224,
229
],
[
"I8",
856,
229
],
[
"J8",
1496,
229
],
[
"K8",
2136,
229
],
[
"H7",
216,
853
],
[
"I7",
896,
853
],
[
"J7",
1555,
853
],
[
"K7",
2224,
853
]
],
"04.png": [
[
"N8",
243,
180
],
[
"O8",
1119,
180
],
[
"P8",
1996,
180
],
[
"N7",
232,
1038
],
[
"O7",
1135,
1038
],
[
"P7",
2051,
1038
]
],
"05.png": [
[
"M3",
261,
213
],
[
"N3",
1160,
213
]
],
"07.png": [
[
"J8",
460,
285
],
[
"K8",
1221,
285
],
[
"L8",
1973,
285
],
[
"J7",
456,
1036
],
[
"K7",
1256,
1036
],
[
"L7",
2053,
1036
]
],
"08.png": [
[
"L8",
184,
417
],
[
"M8",
1085,
417
],
[
"N8",
1983,
417
],
[
"L7",
176,
1341
],
[
"M7",
1121,
1341
]
],
"09.png": [
[
"O9",
1348,
180
],
[
"P9",
1520,
180
],
[
"Q9",
1692,
180
],
[
"M8",
993,
336
],
[
"N8",
1172,
336
],
[
"O8",
1352,
336
],
[
"P8",
1536,
336
],
[
"Q8",
1718,
336
],
[
"M7",
982,
513
],
[
"N7",
1175,
513
],
[
"O7",
1366,
513
],
[
"P7",
1557,
513
],
[
"Q7",
1748,
513
]
],
"11.png": [
[
"J9",
174,
133
],
[
"K9",
996,
133
],
[
"L9",
1803,
133
],
[
"J8",
159,
956
],
[
"K8",
999,
956
],
[
"L8",
1832,
956
]
],
"13.png": [
[
"L4",
182,
213
],
[
"M4",
509,
197
],
[
"O4",
1129,
157
],
[
"P4",
1425,
133
],
[
"Q4",
1711,
117
],
[
"L3",
146,
441
],
[
"M3",
511,
412
],
[
"N3",
863,
388
],
[
"O3",
1209,
359
],
[
"P3",
1538,
329
],
[
"L2",
106,
724
],
[
"M2",
518,
687
],
[
"N2",
914,
651
],
[
"O2",
1304,
615
],
[
"P2",
1664,
580
],
[
"L1",
37,
1100
],
[
"M1",
521,
1053
],
[
"N1",
985,
1005
],
[
"O1",
1428,
953
],
[
"P1",
1852,
910
]
],
"_excluded": {
"note": "moved to map_shots/too_hard/, see its README",
"06.png": [
[
"L5",
542,
616
]
],
"12.png": [
[
"M3",
151,
160
],
[
"N3",
535,
136
],
[
"M2",
163,
469
],
[
"N2",
580,
440
]
],
"14.png": [
[
"J7",
1557,
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
]
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1008 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 858 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

+70
View File
@@ -0,0 +1,70 @@
"""_accept_proposal: an accepted proposal's entity id should prefer the
marker's own detected "#<N>" id (map_vision.read_marker_id, via
Proposal.detected_id) over an auto-assigned letter, so ids on the board
match what's actually on screen -- falling back to auto-assign (a
letter, deliberately not a number, so it can't collide with or be
mistaken for a real detected id -- see models.py's _next_free_id) only
when there's no detection, or it collides with an id already used for
that type in that group (see _accept_proposal's own docstring).
Needs a real Adw/Gtk init (MainWindow.__new__ skips __init__, so no
window/widgets are actually built, but Adw.init() is still required for
the module import chain), same pattern proven in this repo already for
headlessly exercising GTK-adjacent code.
"""
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw # noqa: E402
Adw.init()
from fenigma.app import MainWindow # noqa: E402
from fenigma.map_import import Proposal # noqa: E402
from fenigma.models import Board # noqa: E402
def _window() -> MainWindow:
win = MainWindow.__new__(MainWindow) # skip __init__: no widgets needed for this
win.board = Board()
return win
def _proposal(detected_id=None, side="hostile", sub_x=0, sub_y=0) -> Proposal:
return Proposal(side=side, label="G8", sub_x=sub_x, sub_y=sub_y, unit=None,
centre=(0, 0), box=(0, 0, 0, 0), detected_id=detected_id)
def test_accept_uses_the_detected_id_when_present():
win = _window()
win._accept_proposal(_proposal(detected_id="8"))
assert win.board.targets[0].id == "8"
def test_accept_falls_back_to_auto_id_with_no_detection():
win = _window()
win._accept_proposal(_proposal(detected_id=None))
assert win.board.targets[0].id == "A"
def test_accept_falls_back_to_auto_id_on_a_detected_id_collision():
win = _window()
win._accept_proposal(_proposal(detected_id="8", sub_x=1))
win._accept_proposal(_proposal(detected_id="8", sub_x=2)) # same detected id, must not collide
ids = [t.id for t in win.board.targets]
assert ids[0] == "8"
assert ids[1] != "8"
def test_accept_keeps_target_and_ally_id_detection_in_separate_namespaces():
"""A detected id colliding with an existing ALLY id shouldn't force a
TARGET accept to fall back -- targets/allies are already a separate
id namespace everywhere else (Board.add_target/add_ally), detected-id
preference shouldn't quietly merge them."""
win = _window()
win._accept_proposal(_proposal(detected_id="8", side="friendly"))
win._accept_proposal(_proposal(detected_id="8", side="hostile"))
assert win.board.allies[0].id == "8"
assert win.board.targets[0].id == "8"
+100
View File
@@ -0,0 +1,100 @@
"""debug_capture just needs to reliably write what it's given and never
raise into caller code -- these are format/plumbing checks, not vision
tests."""
import json
import numpy as np
import pytest
from PIL import Image
from fenigma import debug_capture
@pytest.fixture(autouse=True)
def _isolated_debug_dir(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
return tmp_path
def _tiny_png_bytes() -> bytes:
import io
buf = io.BytesIO()
Image.new("RGB", (4, 4), (10, 20, 30)).save(buf, format="PNG")
return buf.getvalue()
def test_save_map_read_failure_writes_png_and_reason(_isolated_debug_dir):
path = debug_capture.save_map_read_failure(_tiny_png_bytes(), "too few grid line families")
assert path is not None
assert path.exists()
meta = json.loads(path.with_suffix(".json").read_text())
assert meta["reason"] == "too few grid line families"
def test_save_maybe_map_accepts_numpy_bgr_array(_isolated_debug_dir):
bgr = np.zeros((4, 4, 3), dtype=np.uint8)
bgr[..., 0] = 200 # blue channel, would come out red if BGR/RGB got swapped
path = debug_capture.save_maybe_map(bgr)
assert path is not None
saved = Image.open(path)
assert saved.getpixel((0, 0)) == (0, 0, 200) # still blue, not swapped to red
def test_save_grid_correction_writes_both_solutions(_isolated_debug_dir):
class FakeSolution:
def __init__(self, du):
self.H = np.eye(3)
self.si, self.sj, self.du, self.dv = 1, 1, du, 0
path = debug_capture.save_grid_correction(_tiny_png_bytes(), FakeSolution(0.0), FakeSolution(0.3))
assert path is not None
meta = json.loads(path.with_suffix(".json").read_text())
assert meta["original"]["du"] == 0.0
assert meta["corrected"]["du"] == 0.3
def test_unsupported_image_type_returns_none_without_raising(_isolated_debug_dir):
assert debug_capture.save_maybe_map(object()) is None
def _proposal(**overrides):
from fenigma.map_import import Proposal
defaults = dict(side="hostile", label="K8", sub_x=3, sub_y=4, unit="Armor_Tank.png",
centre=(0, 0), box=(0, 0, 0, 0))
defaults.update(overrides)
return Proposal(**defaults)
def test_marker_ground_truth_records_verdict_per_proposal(_isolated_debug_dir):
accepted = _proposal(accepted=True, confirmed_type="TANK")
rejected = _proposal(label="K9", rejected=True)
undecided = _proposal(label="L1")
path = debug_capture.save_marker_ground_truth(_tiny_png_bytes(), [accepted, rejected, undecided])
assert path is not None
meta = json.loads(path.with_suffix(".json").read_text())
by_label = {p["label"]: p for p in meta["proposals"]}
assert by_label["K8"]["verdict"] == "accepted"
assert by_label["K8"]["confirmed_type"] == "TANK"
assert by_label["K9"]["verdict"] == "rejected"
assert by_label["L1"]["verdict"] == "undecided"
def test_marker_ground_truth_records_units_with_no_matching_proposal(_isolated_debug_dir):
from fenigma.models import Board, Coord, TargetType
board = Board()
manual_target = board.add_target(TargetType.TANK, Coord("K", 8, 3, 4))
manual_ally = board.add_ally(TargetType.INFANTRY, Coord("K", 9, 0, 0))
path = debug_capture.save_marker_ground_truth(
_tiny_png_bytes(), [], added_targets=[manual_target], added_allies=[manual_ally])
assert path is not None
meta = json.loads(path.with_suffix(".json").read_text())
kinds = {(u["kind"], u["type"], u["coord"]) for u in meta["added_units"]}
assert ("target", "TANK", "K8 3:4") in kinds
assert ("ally", "INFANTRY", "K9 0:0") in kinds
def test_marker_ground_truth_skips_when_nothing_to_say(_isolated_debug_dir):
assert debug_capture.save_marker_ground_truth(_tiny_png_bytes(), []) is None
+54
View File
@@ -0,0 +1,54 @@
"""read_marker_id: template-correlation read of a marker's own small
"#<N>" id label (see map_vision.read_marker_id's own docstring for why
this is template correlation, not OCR -- same reasoning as
read_cell_label). Synthetic image, real font, no fixture screenshot or
the (slow) detection pipeline needed -- just render the label the way
the game does and check it round-trips.
"""
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from fenigma import map_vision
def _render_label(text: str, height: int) -> Image.Image:
"""Cream glyph, heavy dark outline, same style glyph_template expects
to correlate against -- see glyph_template's own docstring."""
font = ImageFont.truetype(str(map_vision.FONT_PATH), height)
pad = height
im = Image.new("L", (height * 4 + pad, height * 2 + pad), 30) # dark "photo" background
ImageDraw.Draw(im).text((pad // 2, pad // 4), text, font=font, fill=230,
stroke_width=max(1, int(height * 0.10)), stroke_fill=0)
return im
def test_reads_a_clean_id_label():
# A marker box roughly where a real one measures (see read_marker_id's
# own calibration note), with a rendered "#8" sitting where the game
# draws it: above-left of the box.
box_w, box_h = 40, 40
label_h = int(0.45 * box_h)
label_im = _render_label("#8", label_h)
canvas = Image.new("L", (200, 200), 60)
label_x, label_y = 60, 60
canvas.paste(label_im, (label_x, label_y))
gray = np.array(canvas)
box_x = label_x + int(1.0 * box_w) - 5 # box sits to the right of/below the label
box_y = label_y + int(0.45 * box_h)
box = (box_x, box_y, box_w, box_h)
assert map_vision.read_marker_id(gray, box) == "8"
def test_returns_none_on_a_blank_patch():
gray = np.full((200, 200), 60, dtype=np.uint8)
box = (100, 100, 40, 40)
assert map_vision.read_marker_id(gray, box) is None
def test_returns_none_on_a_degenerate_box_at_the_image_edge():
gray = np.full((200, 200), 60, dtype=np.uint8)
box = (0, 0, 2, 2) # crop region collapses to nothing usable
assert map_vision.read_marker_id(gray, box) is None
+59
View File
@@ -0,0 +1,59 @@
"""warp_to_map's img_scale param: a caller can hand it a differently-sized
image than the one `sol` was actually solved against (see
map_vision.load_full_res / ScreenshotImport.full_image), scaled to
compensate. This checks that compensation is correct, without needing a
real fixture screenshot or the (slow) line-detection/solve pipeline --
just a synthetic image and a stub solution with a predictable transform.
"""
import numpy as np
import pytest
from fenigma import map_vision
class _IdentitySolution:
"""H and lattice_to_grid() both identity: warp_to_map's transform then
reduces to just grid_to_map, so the output is a directly px_per_km-
scaled (and row-flipped, per warp_to_map's own comment) copy of
whatever region of the input `warp_to_map` reads as "grid space"."""
H = np.eye(3)
def lattice_to_grid(self):
return np.eye(3)
def test_img_scale_compensates_for_a_bigger_source_image():
# A small solid-color source, plus a 2x upscaled copy of it -- same
# content, different pixel dimensions.
small = np.zeros((20, 20, 3), dtype=np.uint8)
small[:, :] = (10, 20, 30) # BGR
big = np.zeros((40, 40, 3), dtype=np.uint8)
big[:, :] = (10, 20, 30)
sol = _IdentitySolution()
out_small, ppk_small = map_vision.warp_to_map(small, sol, px_per_km=1)
out_big, ppk_big = map_vision.warp_to_map(big, sol, px_per_km=1, img_scale=2.0)
assert ppk_small == ppk_big == 1
assert out_small.shape == out_big.shape # output is always MAP_KM_W/H * px_per_km, regardless of source size
# Same solid color warped in (opaque region only -- compare where both
# actually painted something, alpha channel nonzero).
painted = (out_small[:, :, 3] > 0) & (out_big[:, :, 3] > 0)
assert painted.any()
np.testing.assert_array_equal(out_small[painted][:, :3], out_big[painted][:, :3])
def test_default_img_scale_is_unchanged_behavior():
"""img_scale's default (1.0) must reproduce pre-existing behavior
exactly -- every other warp_to_map call site doesn't pass it."""
img = np.zeros((20, 20, 3), dtype=np.uint8)
img[:, :] = (1, 2, 3)
sol = _IdentitySolution()
out_default, _ = map_vision.warp_to_map(img, sol, px_per_km=1)
out_explicit, _ = map_vision.warp_to_map(img, sol, px_per_km=1, img_scale=1.0)
np.testing.assert_array_equal(out_default, out_explicit)
def test_load_full_res_raises_like_load_on_a_bad_path(tmp_path):
with pytest.raises(ValueError):
map_vision.load_full_res(tmp_path / "does-not-exist.png")
+122
View File
@@ -0,0 +1,122 @@
"""Regression coverage for Board's bulk-mutation helpers (clear/clear_units)
and the id namespaces Target/Ally are supposed to keep separate."""
from fenigma.models import Board, Coord, TargetType
def _coord(x=0, y=0):
return Coord(X="A", Y=1, x=x, y=y)
def test_clear_drops_allies_too():
"""Board.clear() used to leave self.allies untouched -- the "clear
board" action then reported success but a previously-placed ally
stayed on the map."""
board = Board()
board.nest.coord = _coord()
board.add_spotter(_coord())
board.add_reference_point(_coord())
board.add_target(TargetType.TANK, _coord())
board.add_ally(TargetType.TANK, _coord())
board.add_scout_flight((1.0, 1.0), 45.0)
board.clear()
assert board.nest.coord is None
assert board.spotters == []
assert board.reference_points == []
assert board.targets == []
assert board.allies == []
assert board.scout_flights == []
def test_clear_units_keeps_recon_infrastructure():
"""The Clear button's right-click "Clear enemies, units & flights"
option: drops targets/allies/scout flights but keeps the Nest,
spotters, and reference points."""
board = Board()
board.nest.coord = _coord()
sp = board.add_spotter(_coord())
rp = board.add_reference_point(_coord())
board.add_target(TargetType.TANK, _coord())
board.add_ally(TargetType.TANK, _coord())
board.add_scout_flight((1.0, 1.0), 45.0)
board.clear_units()
assert board.nest.coord is not None
assert board.spotters == [sp]
assert board.reference_points == [rp]
assert board.targets == []
assert board.allies == []
assert board.scout_flights == []
def test_ally_and_target_ids_are_independent_namespaces():
"""An ally Tank#1 and a hostile Target Tank#1 are unrelated -- adding
one must never be influenced by the other's ids, and auto-assignment
on each side starts from 'A' independently. Explicit id_="1" here
(as an accepted screenshot proposal's detected_id would pass, see
app.py's _accept_proposal) to also check that auto-assignment
correctly skips a real numeric id already in use, not just other
letters."""
board = Board()
t1 = board.add_target(TargetType.TANK, _coord(), id_="1")
a1 = board.add_ally(TargetType.TANK, _coord(), id_="1")
assert t1.id == a1.id == "1"
assert t1 is not a1
t_auto = board.add_target(TargetType.TANK, _coord())
a_auto = board.add_ally(TargetType.TANK, _coord())
assert t_auto.id == "A" # first free letter among *target* Tanks only
assert a_auto.id == "A" # first free letter among *ally* Tanks only, unaffected by the target above
def test_auto_id_is_per_type_within_targets_and_within_allies():
"""Each TYPE gets its own independent A/B/C... sequence within a group
(all targets, or all allies) -- a Tank and an Infantry auto-assigned
back to back both start at 'A' (Tank#A, Infantry#A), rather than
sharing one sequence across every type in the group."""
board = Board()
tank = board.add_target(TargetType.TANK, _coord())
infantry = board.add_target(TargetType.INFANTRY, _coord())
assert tank.id == "A"
assert infantry.id == "A" # own sequence, not 'B' just because a Tank came first
second_tank = board.add_target(TargetType.TANK, _coord())
assert second_tank.id == "B" # but a SECOND Tank does advance the Tank sequence
ally_tank = board.add_ally(TargetType.TANK, _coord())
ally_infantry = board.add_ally(TargetType.INFANTRY, _coord())
assert ally_tank.id == "A"
assert ally_infantry.id == "A"
def test_auto_id_survives_past_26_entities_of_one_type():
"""A real crash: `next(c for c in string.ascii_uppercase if c not in
used)` raises StopIteration the instant all 26 letters are taken --
reachable after accepting/adding 26+ of the same type into one group
in a single session. Must roll over to two-letter ids ('AA', 'AB',
...) instead of raising."""
board = Board()
for _ in range(26):
board.add_target(TargetType.TANK, _coord())
twenty_seventh = board.add_target(TargetType.TANK, _coord())
assert twenty_seventh.id == "AA"
board2 = Board()
for _ in range(26):
board2.add_ally(TargetType.TANK, _coord())
twenty_seventh_ally = board2.add_ally(TargetType.TANK, _coord())
assert twenty_seventh_ally.id == "AA"
def test_find_by_name_prefers_target_over_same_named_ally():
"""find_by_name() (used to resolve Clue references) checks targets
before allies -- documented, deliberate priority, not a namespace
collision: an ally and a same-typed/same-id target are still two
distinct objects, this only matters when something's Clue names one
ambiguously by the shared display name."""
board = Board()
target = board.add_target(TargetType.TANK, _coord(x=1), id_="1")
board.add_ally(TargetType.TANK, _coord(x=2), id_="1")
assert board.find_by_name("Tank#1") is target
+177
View File
@@ -10,6 +10,7 @@ being noticed (or not) days later.
"""
from fenigma import ocr
from fenigma.models import Coord, TargetType
from fenigma.shells import Shell
def test_standard_target_and_rp_blocks():
@@ -51,6 +52,43 @@ def test_destroyed_reports_digit_and_letter_id():
assert info.destroyed == {(TargetType.SUPPLY_CACHE, "2"), (TargetType.TANK, "3")}
def test_destroyed_report_strips_a_leading_enemy_prefix():
"""A real kill-feed paste with an "Enemy <Type>#<id> Destroyed" shape
(squashed by squash_multiword_ids to "EnemyMechanizedInfantry#1"
before this ever runs) was silently dropping every single-word type
("Enemy Infantry#11 Destroyed") -- the un-stripped "Enemy" prefix
only accidentally fuzzy-matched for longer/more distinctive type
words (Mechanized Infantry), not shorter/more different ones (Field
Gun -- see test_field_gun_is_an_artillery_alias). _ALLY_PREFIX_RE now
strips "Enemy" the same as "Hostile"."""
text = ("Enemy Mechanized Infantry#1 Destroyed, +5 Requisition.\n"
"Enemy Infantry#11 Destroyed, +5 Requisition.")
info = ocr.parse_text(text)
assert info.destroyed == {(TargetType.INFANTRY_MECHANIZED, "1"), (TargetType.INFANTRY, "11")}
def test_bare_enemy_destroyed_report_is_still_target_type_enemy():
"""The lookahead in _ALLY_PREFIX_RE (only strip "Enemy" when there's
something AFTER it) matters here specifically: a BARE "Enemy#N" is
TargetType.ENEMY itself (its own value IS "Enemy") -- stripping the
prefix unconditionally would leave an empty type_word and silently
drop every ad-hoc "Enemy#N Destroyed" report instead."""
text = "Enemy#7 Destroyed, +5 Requisition."
info = ocr.parse_text(text)
assert (TargetType.ENEMY, "7") in info.destroyed
def test_field_gun_is_an_artillery_alias():
"""The game calls plain Artillery "Field Gun" in at least this kill-
feed message -- confirmed by the user against a real "Enemy Field
Gun#1 Destroyed" line that was otherwise silently dropping (no
TargetType.FIELD_GUN exists, nor should one -- see _TYPE_WORD_ALIASES'
own comment, same treatment as AmmoCache/CoastalBattery)."""
text = "Priority target Enemy Field Gun#1 Destroyed, +50 Requisition."
info = ocr.parse_text(text)
assert (TargetType.ARTILLERY, "1") in info.destroyed
def test_train_arrival_intel():
text = """ARRIVAL STATION:
Valle de Mula MainStation: J6 0:4
@@ -236,3 +274,142 @@ Tank#3 Spotted. 095, 3.00km from Spotter#1
assert (TargetType.TANK, "1") not in info.targets
assert (TargetType.TANK, "2") in info.targets
assert (TargetType.TANK, "3") in info.targets
def test_infantry_taking_fire_direct_position_request():
"""A different fire-support-request grammar from Marine Garrison's:
shell word order reversed ('Requesting X Shell' not 'X Shells
requested'), deadline is a bare 'before <time>' with no 'Requested'/
dashes. The '<b>id1</b>' attacker mention is just prose here, not
parsed into anything -- only the request itself (shell, position,
deadline) matters.
The reporting unit ('Infantry#1 taking fire') is always a FRIENDLY
calling in its own distress -- no hostile ever radios in about
itself -- so it lands in info.allies, not info.targets (a real bug:
it used to default to not-ally, no "Friendly"/"Hostile" prefix word
exists in this grammar for the usual inference to key off of). The
shell/deadline still need a home a plain Ally tuple doesn't have
room for, so they move to a synthetic StrikeRequest target at the
SAME coord as the reporting unit ('on our position' means the fire
point IS that position, no offset given)."""
text = ("Infantry#1 taking fire from <b>id1</b>!\n"
"Requesting <u><b>SMK Shell</b></u> on our position at <b>J6 2:7</b> "
"before <u>10:38:57</u>!")
info = ocr.parse_text(text)
assert (TargetType.INFANTRY, "1") in info.allies
assert (TargetType.INFANTRY, "1") not in info.targets
raw, clues, coord = info.allies[(TargetType.INFANTRY, "1")]
assert coord == Coord("J", 6, 2, 7)
assert (TargetType.STRIKE_REQUEST, "Infantry1") in info.targets
raw, clues, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry1")]
assert coord == Coord("J", 6, 2, 7)
assert shell is Shell.SMK
assert requested_time == "10:38:57"
def test_infantry_taking_fire_no_attacker_mention():
text = ("Infantry#3 taking fire!\n"
"Requesting <u><b>SMK Shell</b></u> on our position at <b>J6 2:5</b> "
"before <u>10:37:52</u>!")
info = ocr.parse_text(text)
assert (TargetType.INFANTRY, "3") in info.allies
assert (TargetType.INFANTRY, "3") not in info.targets
raw, clues, coord = info.allies[(TargetType.INFANTRY, "3")]
assert coord == Coord("J", 6, 2, 5)
assert (TargetType.STRIKE_REQUEST, "Infantry3") in info.targets
raw, clues, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry3")]
assert coord == Coord("J", 6, 2, 5)
assert shell is Shell.SMK
assert requested_time == "10:37:52"
def test_infantry_taking_fire_bearing_distance_from_position():
"""The other request shape: the shell isn't wanted right on top of the
reporting unit, but at a bearing/distance offset from its own
(inline-given) position -- two different places, so this becomes two
entries: Infantry#3 stays at its own reported position, as an ALLY
(see test_infantry_taking_fire_no_attacker_mention's own docstring --
same reasoning, this is still a taking-fire report), and a separate
synthetic Strike entry carries the shell/deadline at the computed
offset coord ('our position' isn't a named board entity to hang a
Clue off of, so this resolves straight to an absolute coord rather
than via one)."""
text = ("Infantry#3 taking fire!\n"
"Requesting <u><b>HE Shell</b></u> at bearing <b>239°</b>, distance "
"<b>10.76km</b> from our position, <b>J6 2:5</b>, by <u>10:38:18</u> "
"or we will be overrun!")
info = ocr.parse_text(text)
assert (TargetType.INFANTRY, "3") in info.allies
assert (TargetType.INFANTRY, "3") not in info.targets
raw, clues, coord = info.allies[(TargetType.INFANTRY, "3")]
assert coord == Coord("J", 6, 2, 5)
assert (TargetType.STRIKE_REQUEST, "Infantry3") in info.targets
raw, clues, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry3")]
assert shell is Shell.HE
assert requested_time == "10:38:18"
from fenigma import solver
expected = solver.point_to_coord(
solver.point_from_bearing_distance(Coord("J", 6, 2, 5).as_fraction(), 239.0, 10.76))
assert coord == expected
def test_infantry_taking_fire_bearing_distance_short_range():
"""Same shape, a sub-1km offset (the earlier fixture's own distance,
10.76km, is far enough that a rounding slip in the offset math could
have gone unnoticed inside the same large cell -- this one crosses a
cell boundary, I7 0:8 -> H7 8:4, so a sign/axis error would visibly
land in the wrong cell letter entirely, not just a slightly-off
sub-position)."""
text = ("Infantry#11 taking fire!\n"
"Requesting <u><b>HE Shell</b></u> at bearing <b>210°</b>, distance "
"<b>0.43km</b> from our position, <b>I7 0:8</b>, by <u>10:17:37</u> "
"or we will be overrun!")
info = ocr.parse_text(text)
assert (TargetType.INFANTRY, "11") in info.allies
assert (TargetType.INFANTRY, "11") not in info.targets
_, _, coord = info.allies[(TargetType.INFANTRY, "11")]
assert coord == Coord("I", 7, 0, 8)
assert (TargetType.STRIKE_REQUEST, "Infantry11") in info.targets
_, _, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry11")]
assert coord == Coord("H", 7, 8, 4)
assert shell is Shell.HE
assert requested_time == "10:17:37"
def test_taking_fire_important_followup_line_does_not_steal_the_deadline():
"""A real user-pasted message: a same-report "Important: ... Answer by
<time>" follow-up line was being misread as a brand new named entity
header (the last-resort bare-"<Name>:" fallback matched "Important:"
itself), creating a bogus Target#Important that stole the actual
report's own deadline into that wrong entry instead of the real
StrikeRequest. "Answer by <time>" is also a deadline phrasing
_TAKING_FIRE_TIME_RE already covers (any 'before'/'by <time>') --
once the phantom split stops happening, it resolves correctly with
no extra fix needed."""
text = ("Infantry#4 taking fire!\n"
"Requesting <u><b>TEAR Shell</b></u> first, then <u><b>HE Shell</b></u>,\n"
"at bearing <b>308°</b>, distance <b>1.86km</b> from our position, <b>N2 0:9</b>\n"
"<u>Important:</u> <u><b>TEAR Shell</b></u> first, then <u><b>HE Shell</b></u>.\n"
"Answer by <u>10:30:00</u>")
info = ocr.parse_text(text)
assert (TargetType.UNKNOWN, "Important") not in info.targets
assert (TargetType.INFANTRY, "4") in info.allies
assert (TargetType.INFANTRY, "4") not in info.targets
assert (TargetType.STRIKE_REQUEST, "Infantry4") in info.targets
_, _, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry4")]
assert requested_time == "10:30:00"
# Known gap, not asserted as fixed here: only the FIRST shell of a
# "X first, then Y" sequence is captured -- see TODO.md.
assert shell is Shell.TEAR
+22 -1
View File
@@ -1,6 +1,6 @@
"""Regression coverage for solver.py's geometric resolution."""
from fenigma import solver
from fenigma.models import Board, Clue, Coord, Location, TargetType
from fenigma.models import LARGE_X, Board, Clue, Coord, Location, TargetType
def _board_with_spotters(*coords):
@@ -104,3 +104,24 @@ def test_manual_coord_override_clears_a_stale_note():
assert target.location.note is not None
target.coord = Coord("A", 1, 0, 0)
assert target.location.note is None
def test_point_to_coord_round_trips_every_sub_cell():
"""A real, reproducible bug: Coord.as_fraction() centers a sub-cell
at x + 0.5 (so a marker drawn at its own coord's exact pixel
position round-trips back to the same coord on click), which put
point_to_coord()'s own rounding exactly on a .5 boundary, the worst
case for floating point. A tiny representation error from the
subtraction it used to do could tip round() to either side,
silently returning a coord one sub-cell off from the one actually
clicked, this reproduced with zero pixel math involved at all, just
feeding as_fraction() straight back into point_to_coord(). Checked
exhaustively, not just a couple of samples, since the failure was
itself pattern-dependent (only some coords tripped the float
rounding the wrong way)."""
for X in LARGE_X:
for Y in range(1, 11):
for x in range(10):
for y in range(10):
c = Coord(X, Y, x, y)
assert solver.point_to_coord(c.as_fraction()) == c
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""Evaluate map_vision against the hand annotations, and render overlays.
Usage:
.venv/bin/python tools/eval_map_vision.py # score the fixtures
.venv/bin/python tools/eval_map_vision.py shot.png [...] # try your own images
Two scores are reported, because the lenient one hid a real error:
cell fraction of annotated points landing in the correct cell. Too
forgiving on its own: the annotations sit near cell centres, so a
grid wrong by a whole line still passes.
spread every annotated point should sit at (col + a, row + b) in recovered
grid coordinates for ONE constant (a, b) -- the label's offset
inside its cell. So fit that constant and report the spread of the
residual, in cell units. A skewed or off-by-one-line grid shows up
here even when every point is nominally in the right cell.
"""
import json
import sys
from pathlib import Path
import cv2
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from fenigma import map_vision as mv # noqa: E402
ROOT = Path(__file__).resolve().parents[1]
SHOTS = ROOT / "tests" / "fixtures" / "map_shots"
GT_PATH = ROOT / "tests" / "fixtures" / "map_shots_gt.json"
def draw(img, sol, markers):
vis = img.copy()
h, w = vis.shape[:2]
q = np.linalg.inv(sol.H) @ np.array([[0, w, w, 0], [0, 0, h, h], [1, 1, 1, 1]], np.float64)
ij = q[:2] / q[2]
for i in range(int(np.floor(ij[0].min())) - 1, int(np.ceil(ij[0].max())) + 2):
for j in range(int(np.floor(ij[1].min())) - 1, int(np.ceil(ij[1].max())) + 2):
col, row = sol.si * i + sol.du, sol.sj * j + sol.dv
if not (0 <= col < mv.COLS and 1 <= row <= mv.ROWS):
continue
p = sol.H @ np.array([[i, i + 1, i + 1, i], [j, j, j + 1, j + 1], [1, 1, 1, 1.0]])
if np.any(np.abs(p[2]) < 1e-9):
continue
p = (p[:2] / p[2]).T
cv2.polylines(vis, [p.astype(np.int32)], True, (0, 255, 255), 2, cv2.LINE_AA)
lx = i + (mv.PAD_L if sol.si > 0 else 1 - mv.PAD_L)
ly = j + (mv.PAD_T if sol.sj > 0 else 1 - mv.PAD_T)
t = sol.H @ np.array([lx, ly, 1.0])
if abs(t[2]) < 1e-9:
continue
x, y = int(t[0] / t[2]), int(t[1] / t[2])
f = max(0.45, min(2.0, float(np.linalg.norm(p[1] - p[0])) / 190.0))
txt = f"{mv.LARGE_X[col]}{row}"
cv2.putText(vis, txt, (x, y), cv2.FONT_HERSHEY_SIMPLEX, f, (0, 0, 0), int(f * 5) + 2)
cv2.putText(vis, txt, (x, y), cv2.FONT_HERSHEY_SIMPLEX, f, (0, 255, 0), int(f * 2) + 1)
for m in markers:
x, y, bw, bh = m["box"]
c = (0, 0, 255) if m["side"] == "hostile" else (255, 210, 0)
cv2.rectangle(vis, (x, y), (x + bw, y + bh), c, 2)
txt = m["coord"] + (f' {m["unit"]}' if m.get("unit") else "")
cv2.putText(vis, txt, (x, y - 6), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 0, 0), 4)
cv2.putText(vis, txt, (x, y - 6), cv2.FONT_HERSHEY_SIMPLEX, 0.55, c, 2)
return vis
def run_one(path, outdir):
"""Solve a single arbitrary screenshot (no ground truth needed)."""
sol, img, err = mv.solve_path(path)
if sol is None:
print(f"{path.name}: REJECTED - {err}")
return 1
markers = mv.find_markers(img, sol)
print(f"{path.name}: solved, {sol.votes} label votes, "
f"cell {sol.steps[0]:.0f}x{sol.steps[1]:.0f}px, {len(markers)} markers")
for m in markers:
print(f" {m['side']:<8} {m['coord']:<9} {m.get('unit') or 'unknown type'}")
out = outdir / f"solved_{path.stem}.png"
cv2.imwrite(str(out), draw(img, sol, markers))
print(f" overlay: {out}")
return 0
def main():
args = [a for a in sys.argv[1:]]
outdir = ROOT / "build" / "map_vision"
images = [Path(a) for a in args if Path(a).suffix.lower() in (".png", ".jpg", ".jpeg")]
if images:
outdir.mkdir(parents=True, exist_ok=True)
rc = 0
for p in images:
rc |= run_one(p, outdir)
return rc
outdir = Path(args[0]) if args else outdir
outdir.mkdir(parents=True, exist_ok=True)
gt = json.loads(GT_PATH.read_text())
names = sorted(k for k in gt if k.endswith(".png"))
tiles, ok_t, n_t = [], 0, 0
print(f"{'shot':<8} {'pts':>4} {'cell':>6} {'spread':>7} {'votes':>5} "
f"{'mk':>3} status")
for name in names:
path = SHOTS / name
native_w = cv2.imread(str(path), cv2.IMREAD_REDUCED_COLOR_8).shape[1] * 8
pts = gt[name]
n_t += len(pts)
sol, img, err = mv.solve_path(path)
s = img.shape[1] / native_w
if sol is None:
print(f"{name:<8} {len(pts):>4} {'-':>6} {'-':>7} {'-':>5} {'-':>3} REJECT: {err}")
vis = img.copy()
cv2.putText(vis, f"{name} REJECTED", (12, 34), cv2.FONT_HERSHEY_SIMPLEX,
1.0, (0, 0, 255), 3)
cv2.putText(vis, err[:56], (12, 66), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2)
tiles.append(vis)
cv2.imwrite(str(outdir / f"rejected_{name}"), vis)
continue
offs, right = [], 0
for lab, x, y in pts:
got = sol.cell_of(x * s, y * s)
if got and got[0] == lab:
right += 1
g = sol.grid_of(x * s, y * s)
if g is None:
continue
offs.append((g[0] - mv.LARGE_X.index(lab[0]), g[1] - int(lab[1:])))
ok_t += right
o = np.array(offs)
spread = float(np.sqrt(((o - o.mean(axis=0)) ** 2).sum(axis=1).mean()))
markers = mv.find_markers(img, sol)
verdict = "GOOD" if spread < 0.08 else ("SKEWED" if spread < 0.3 else "BAD")
print(f"{name:<8} {len(pts):>4} {right/len(pts):>5.0%} {spread:>7.3f} "
f"{sol.votes:>5} {len(markers):>3} {verdict}")
vis = draw(img, sol, markers)
cv2.putText(vis, f"{name} {len(markers)} markers spread {spread:.3f}",
(12, 34), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 255, 0), 3)
tiles.append(vis)
cv2.imwrite(str(outdir / f"solved_{name}"), vis)
for m in markers:
print(f" {m['side']:<8} {m['coord']:<9} "
f"{str(m.get('unit')):<26} s={m['unit_score']:.2f} d={m['unit_margin']:.3f}")
print(f"\nTOTAL {ok_t}/{n_t} annotated points in the correct cell "
f"({ok_t / max(n_t, 1):.0%})")
TW, TH, cols = 860, 520, 3
rows = (len(tiles) + cols - 1) // cols
sheet = np.zeros((TH * rows, TW * cols, 3), np.uint8)
for i, t in enumerate(tiles):
hh, ww = t.shape[:2]
sc = min(TW / ww, TH / hh)
t2 = cv2.resize(t, (int(ww * sc), int(hh * sc)))
y, x = (i // cols) * TH, (i % cols) * TW
sheet[y:y + t2.shape[0], x:x + t2.shape[1]] = t2
cv2.imwrite(str(outdir / "sheet.png"), sheet)
print(f"wrote {outdir / 'sheet.png'}")
if __name__ == "__main__":
sys.exit(main() or 0)