6 Commits
v0.2.0 ... main

Author SHA1 Message Date
Claude
ee82b161eb Recognizer v0.3.6: HDR-friendly empty + lower threshold + debug dump
User reports all 34 cells classified as 미인식 with score 0.00 even
when the grid was correctly cropped. Multiple compounding issues:

1. _is_empty required mean<60 (dark) AND std<14. HDR/bright captures
   produce pinkish empty slots with mean ~150-180, so even empty cells
   fell through to template matching. Drop the mean check; uniformity
   alone (std<18 grayscale, std<22 per-channel) is the real signal.

2. Score 0.00 across the board strongly suggests templates list was
   empty (only path that returns exactly 0.0). Track per-bucket load
   counts (slabs_ok/fail, artifacts_ok/fail) and surface them in the
   GUI status bar so a CDN failure is immediately visible. Currently
   no signal at all on download failure.

3. min_score 0.55 was tuned against simulator-clean renders. Real game
   captures have decorative cell borders, stack-count badges in
   corners, HDR shader effects. Lower to 0.35 and inset cell crops by
   16% on each side before matching to skip the decorative frame.

4. Add 디버그 저장 button + dump_debug() that saves screenshot.png,
   bbox_crop.png, cells/<row>-<col>.png, and report.txt with top-3
   matches per cell to %LOCALAPPDATA%/sephiria_inv/debug/<timestamp>/.
   Lets us iterate on tuning from real captures without round-tripping
   raw screenshots through chat each time.
2026-05-16 03:18:29 +09:00
Claude
3c17405a6b Strip astral-plane emojis from Tk button labels
Python 3.7's bundled Tcl/Tk on Windows is UCS-2 only and refuses
characters above U+FFFF. The button labels contained game-controller,
desktop, folder and refresh emojis (U+1F3AE, U+1F5A5, U+1F4C2, U+1F501),
so App.__init__ raised TclError and gui.main caught it, exited 1, and
the user saw 'nothing happened'.

Replace with plain Korean text. Per CLAUDE.md these emojis should not
have been added in the first place.
2026-05-16 03:04:13 +09:00
Claude
915b5c9f45 Diagnostic v2: log on module import, dual log paths, ship debug.bat
v0.3.3 wrapper only logged once it reached _main(). User reports CMD
window flashes shut and no log file created — meaning Python likely
never reached our code. Two fixes:

1. Move first log write to module top (before any project import) and
   write to BOTH the exe directory AND %LOCALAPPDATA%/sephiria_inv/.
   Either log existing proves Python booted; neither existing means
   PyInstaller bootloader itself failed.

2. Add run-debug.bat that runs the exe with stdout/stderr captured to
   sephiria_inv_console.log and pauses, so the window does not close
   before the user can read it.
2026-05-16 02:58:23 +09:00
Claude
a70499edfa Wrap startup in try/except with file log + Tk messagebox
--noconsole exes silently exit on import-time errors, so when v0.3.2
crashed during startup the user just saw nothing happen. This wrapper:
- Catches BaseException at module-import and main() level
- Writes traceback to %LOCALAPPDATA%/sephiria_inv/startup.log
- Pops a Tk messagebox-equivalent window with the traceback
- Falls back gracefully if Tk itself is unavailable

Also build with --console (no --noconsole) so prints/tracebacks are
visible in real time. Once we know what is failing we can re-enable
windowed mode.
2026-05-16 02:38:51 +09:00
Claude
1f2024e85f Fix AttributeError on startup: resolve App.slot_var via winfo_toplevel
ScreenshotFrame previously read master.master.slot_var, but
master is the Notebook and master.master is the inner padding Frame
inside App._build, not App. Use winfo_toplevel() so we always reach
the App where slot_var lives.

Reported as: AttributeError: 'Frame' object has no attribute 'slot_var'
at gui.py:401 during run.py startup.
2026-05-15 21:04:20 +09:00
Claude
2e23ad5d2f v0.3.0: game-window picker + NCC recognition + artifacts + ?-merged
- window_capture.py: enumerate top-level windows (pygetwindow) and
  capture a specific one via PrintWindow PW_RENDERFULLCONTENT (works
  on non-focused windows). Linux falls back to mss region grab.
- recognizer.py: replace MAE matcher with NCC over numpy vectors.
  Each rotatable slab generates 4 templates (0/90/180/270). Adds 248
  artifact templates and an empty-cell heuristic (low mean/std-dev).
  Cells below confidence floor are tagged "unknown" — likely merged
  "?" boxes.
- gui.py: new ScreenshotFrame with [게임 창 선택] button → window
  picker dialog → bbox crop → recognize → editable preview grid with
  per-cell CellEditor that handles slab / artifact / merged(?) / empty.
  Merged cells let user pick which two slabs got combined + a level.
- artifacts.py + bundled _artifacts.json (248 entries from
  WhiteDog1004/sephiria) for matching and rendering.
- renderer.py: factored CDN fetch into _fetch_image; added
  fetch_artifact_image().
- requirements.txt: + numpy, pygetwindow (Win), pywin32 (Win).
- docker-build-cmd.sh: upgrade PyInstaller to 5.x inside cdrx
  container so numpy DLL manifest reads work.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 09:36:49 +09:00
11 changed files with 5162 additions and 362 deletions

14
docker-build-cmd.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/bash
set -e
cd /src
pip install --upgrade 'pyinstaller<6' 2>&1 | tail -3
pyinstaller --clean -y --onefile --noconsole --name sephiria_inv \
--add-data 'sephiria_inv/_artifacts.json;sephiria_inv' \
--hidden-import sephiria_inv --hidden-import sephiria_inv.gui \
--hidden-import sephiria_inv.recognizer --hidden-import sephiria_inv.artifacts \
--hidden-import sephiria_inv.window_capture --hidden-import sephiria_inv.capture \
--hidden-import sephiria_inv.screenshot --hidden-import sephiria_inv.slabs \
--hidden-import sephiria_inv.solver --hidden-import sephiria_inv.renderer \
--hidden-import mss --hidden-import mss.windows --hidden-import numpy \
--hidden-import pygetwindow --hidden-import PIL.ImageTk \
-p . run.py

View File

@@ -1,3 +1,6 @@
Pillow>=9.0
requests>=2.25
mss>=6.0
numpy>=1.21,<2.0
pygetwindow>=0.0.9 ; sys_platform == "win32"
pywin32>=305 ; sys_platform == "win32"

22
run-debug.bat Normal file
View File

@@ -0,0 +1,22 @@
@echo off
REM Run the exe with output captured. Window does not auto-close.
setlocal
cd /d "%~dp0"
echo Running sephiria_inv.exe ...
echo.
sephiria_inv.exe > sephiria_inv_console.log 2>&1
set EC=%errorlevel%
echo.
echo === sephiria_inv.exe exited with code %EC% ===
echo.
echo --- sephiria_inv_console.log ---
if exist sephiria_inv_console.log type sephiria_inv_console.log
echo --- end console.log ---
echo.
echo --- sephiria_inv_startup.log ---
if exist sephiria_inv_startup.log type sephiria_inv_startup.log
echo --- end startup.log ---
echo.
echo Press any key to close this window.
pause > nul
endlocal

116
run.py
View File

@@ -1,6 +1,116 @@
"""PyInstaller entry shim."""
"""PyInstaller entry shim with aggressive startup diagnostics.
Writes a log entry the moment this module is loaded (before any project
imports). Logs to two locations:
1. Next to the exe (sys.executable directory) -- usually the user's
Downloads folder; trivially findable.
2. %LOCALAPPDATA%/sephiria_inv/startup.log
Either log existing tells us Python started. Neither existing means the
PyInstaller bootloader died before Python ran (DLL/runtime issue).
"""
from __future__ import annotations
import os
import sys
import traceback
from datetime import datetime
def _candidate_log_paths():
paths = []
# 1. Next to the exe (most discoverable for the user)
try:
exe_dir = os.path.dirname(os.path.abspath(sys.executable))
if exe_dir:
paths.append(os.path.join(exe_dir, "sephiria_inv_startup.log"))
except Exception:
pass
# 2. LOCALAPPDATA / home
try:
base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
folder = os.path.join(base, "sephiria_inv")
os.makedirs(folder, exist_ok=True)
paths.append(os.path.join(folder, "startup.log"))
except Exception:
pass
return paths
def _write_log(msg: str) -> list:
written = []
stamp = datetime.now().isoformat()
for path in _candidate_log_paths():
try:
with open(path, "a", encoding="utf-8") as fh:
fh.write(f"\n=== {stamp} ===\n{msg}\n")
written.append(path)
except Exception:
pass
return written
def _env_dump() -> str:
lines = [
f"python: {sys.version}",
f"executable: {sys.executable}",
f"argv: {sys.argv}",
f"frozen: {getattr(sys, 'frozen', False)}",
f"_MEIPASS: {getattr(sys, '_MEIPASS', '<none>')}",
f"cwd: {os.getcwd()}",
f"PATH head: {os.environ.get('PATH', '')[:200]}",
f"LOCALAPPDATA: {os.environ.get('LOCALAPPDATA', '<none>')}",
]
return "\n".join(lines)
# === MODULE-IMPORT BREADCRUMB ===
# This runs the moment Python loads the entry script. If this never appears
# in either log, the PyInstaller bootloader failed before Python started.
_BOOT_LOGS = _write_log("BOOT: run.py loaded\n" + _env_dump())
def _show_error(title: str, body: str) -> None:
try:
import tkinter as tk
from tkinter import scrolledtext
root = tk.Tk()
root.title(title)
root.geometry("780x460")
txt = scrolledtext.ScrolledText(root, wrap="word")
txt.pack(fill="both", expand=True)
txt.insert("1.0", body)
tk.Button(root, text="Close", command=root.destroy).pack(pady=4)
root.mainloop()
except Exception:
pass
def _main() -> int:
try:
from sephiria_inv.__main__ import main
except BaseException:
tb = traceback.format_exc()
logs = _write_log("IMPORT FAIL\n" + tb + "\n--env--\n" + _env_dump())
_show_error(
"sephiria_inv: import failed",
f"Failed to import sephiria_inv.__main__\n\nLogs: {logs}\n\n{tb}",
)
return 11
try:
return int(main() or 0)
except SystemExit:
raise
except BaseException:
tb = traceback.format_exc()
logs = _write_log("RUNTIME FAIL\n" + tb)
_show_error(
"sephiria_inv: runtime error",
f"Crashed during main()\n\nLogs: {logs}\n\n{tb}",
)
return 12
from sephiria_inv.__main__ import main
if __name__ == "__main__":
raise SystemExit(main())
raise SystemExit(_main())

3901
sephiria_inv/_artifacts.json Normal file

File diff suppressed because it is too large Load Diff

58
sephiria_inv/artifacts.py Normal file
View File

@@ -0,0 +1,58 @@
"""Artifact catalog parsed from WhiteDog1004/sephiria's artifacts.json.
Each entry exposes the fields we actually need for icon matching and
rendering: value (canonical key), Korean label, tier, and the CDN image URL.
The full effect text / description is kept opaque — the matcher only cares
about the image, and effects are not applied to the optimizer (slab effects
only, per existing design).
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from importlib import resources
from typing import Dict, List, Optional
@dataclass(frozen=True)
class Artifact:
value: str
ko_label: str
eng_label: str
tier: str # common / advanced / rare / legend / solid
image: str # CDN URL
level: int # max level (0 = unique / non-leveling)
def _load() -> List[Artifact]:
try:
text = resources.files(__package__).joinpath("_artifacts.json").read_text(
encoding="utf-8"
)
except Exception:
return []
data = json.loads(text)
out: List[Artifact] = []
for row in data:
if row.get("disabled"):
continue
out.append(
Artifact(
value=row["value"],
ko_label=row.get("label_kor") or row["value"],
eng_label=row.get("label_eng") or row["value"],
tier=row.get("tier") or "common",
image=row.get("image") or "",
level=int(row.get("level") or 0),
)
)
return out
ARTIFACTS: List[Artifact] = _load()
ARTIFACTS_BY_VALUE: Dict[str, Artifact] = {a.value: a for a in ARTIFACTS}
def get(value: str) -> Optional[Artifact]:
return ARTIFACTS_BY_VALUE.get(value)

File diff suppressed because it is too large Load Diff

318
sephiria_inv/recognizer.py Normal file
View File

@@ -0,0 +1,318 @@
"""Cell-level recognition over the inventory grid.
Pipeline given a cropped inventory image:
1. Slice into 6-col rows per generate_grid_config().
2. Per cell, classify: empty / slab / artifact / unknown.
- "empty" = low std-dev / dark uniform pixels
- "slab" = best NCC match across all slabs × 4 rotations
- "artifact"= best NCC match across all artifacts (no rotation)
- "unknown" = nothing matched above the confidence floor →
likely a merged "?" slab box, surfaced to the user.
NCC (normalized cross-correlation) is used instead of MAE because it's
invariant to brightness/contrast shifts — the in-game render has subtle
shader effects (bloom, vignette) that MAE penalizes harshly.
Templates are fetched via renderer.fetch_slab_image / fetch_artifact_image
on first call and cached on disk.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple
import numpy as np
from PIL import Image
from .artifacts import ARTIFACTS
from .renderer import fetch_slab_image, fetch_artifact_image
from .slabs import GRID_COLS, SLABS, SLABS_BY_VALUE, generate_grid_config
# ---------- types ----------
@dataclass
class CellResult:
slot_id: str # "<row>-<col>"
row: int
col: int
kind: str # "empty" | "slab" | "artifact" | "unknown"
value: Optional[str] # slab/artifact value, or None
rotation: int # 0/1/2/3 for slabs; 0 otherwise
score: float # NCC in [-1, 1] — higher is better
# ---------- template prep ----------
_TEMPLATE_SIZE = 64 # work at 64x64 — small enough to be fast, big enough to discriminate
def _on_dark(img: Image.Image) -> Image.Image:
"""Composite a possibly-transparent template onto a dark bag-slot color."""
if img.mode != "RGBA":
return img.convert("RGB")
bg = Image.new("RGBA", img.size, (38, 22, 42, 255))
bg.alpha_composite(img)
return bg.convert("RGB")
def _to_feat(img: Image.Image) -> np.ndarray:
"""Resize to fixed size, grayscale, mean-subtract, unit-normalize. Returns 1-D float vector."""
g = img.convert("L").resize((_TEMPLATE_SIZE, _TEMPLATE_SIZE), Image.BILINEAR)
a = np.asarray(g, dtype=np.float32).reshape(-1)
a = a - a.mean()
n = np.linalg.norm(a)
if n < 1e-6:
return a # all zeros — uniform cell
return a / n
@dataclass
class _Template:
kind: str # "slab" | "artifact"
value: str
rotation: int # for slabs
feat: np.ndarray
_TEMPLATE_CACHE: List[_Template] = []
_CACHE_BUILT = False
_LAST_LOAD_STATS: Dict[str, int] = {"slabs_ok": 0, "slabs_fail": 0,
"artifacts_ok": 0, "artifacts_fail": 0}
def _build_templates(*, include_artifacts: bool = True) -> List[_Template]:
"""Build (and cache) the full template list. Lazy because download is slow."""
global _CACHE_BUILT
if _CACHE_BUILT and _TEMPLATE_CACHE:
return _TEMPLATE_CACHE
out: List[_Template] = []
s_ok = s_fail = a_ok = a_fail = 0
# Slabs: 4 rotations for rotatable, 1 otherwise
for s in SLABS:
img = fetch_slab_image(s.image)
if img is None:
s_fail += 1
continue
s_ok += 1
base = _on_dark(img)
rotations = (0, 1, 2, 3) if s.rotate else (0,)
for r in rotations:
rotated = base if r == 0 else base.rotate(-90 * r, expand=False)
out.append(_Template("slab", s.value, r, _to_feat(rotated)))
if include_artifacts:
for a in ARTIFACTS:
img = fetch_artifact_image(a.image)
if img is None:
a_fail += 1
continue
a_ok += 1
base = _on_dark(img)
out.append(_Template("artifact", a.value, 0, _to_feat(base)))
_LAST_LOAD_STATS.update({"slabs_ok": s_ok, "slabs_fail": s_fail,
"artifacts_ok": a_ok, "artifacts_fail": a_fail})
_TEMPLATE_CACHE.clear()
_TEMPLATE_CACHE.extend(out)
_CACHE_BUILT = True
return _TEMPLATE_CACHE
def warm_templates(*, include_artifacts: bool = True) -> int:
"""Force-download all icons. Returns total template count.
Call once from GUI before recognition to avoid stalls per cell.
"""
return len(_build_templates(include_artifacts=include_artifacts))
def load_stats() -> Dict[str, int]:
"""Return last template load counts: slabs_ok, slabs_fail, artifacts_ok, artifacts_fail."""
return dict(_LAST_LOAD_STATS)
# ---------- cell classification ----------
def _is_empty(cell: Image.Image) -> bool:
"""Heuristic: empty slots are uniform color (any brightness).
Drops the dark-only assumption so HDR / bright-monitor captures with
pinkish slot backgrounds still detect as empty. Uniformity is the
actual invariant — empty slots have low std-dev whatever the hue.
"""
g = np.asarray(cell.convert("L"), dtype=np.float32)
rgb = np.asarray(cell.convert("RGB"), dtype=np.float32)
chan_std = float(rgb.reshape(-1, 3).std(axis=0).mean())
return bool(g.std() < 18.0 and chan_std < 22.0)
def _inset(cell: Image.Image, ratio: float = 0.16) -> Image.Image:
"""Trim decorative borders / corner badges before template matching.
The in-game slot has chunky frame ornaments and a stack-count badge in
a corner. Templates are clean icons. Cropping ~16% off every side
aligns the comparable inner art and removes the badge area in most
games.
"""
w, h = cell.size
dx = int(w * ratio)
dy = int(h * ratio)
return cell.crop((dx, dy, w - dx, h - dy))
def _classify(
cell: Image.Image,
templates: List[_Template],
*,
min_score: float = 0.35,
) -> Tuple[str, Optional[str], int, float]:
"""Return (kind, value, rotation, score)."""
if _is_empty(cell):
return "empty", None, 0, 1.0
inner = _inset(cell)
feat = _to_feat(inner)
# Stack template features into a matrix for one big dot-product
if not templates:
return "unknown", None, 0, 0.0
M = np.stack([t.feat for t in templates], axis=0) # (N, D)
scores = M @ feat # NCC since both are mean-subtracted unit norm
idx = int(np.argmax(scores))
best = float(scores[idx])
if best < min_score:
return "unknown", None, 0, best
t = templates[idx]
return t.kind, t.value, t.rotation, best
def _classify_with_top(
cell: Image.Image,
templates: List[_Template],
*,
top_k: int = 3,
) -> Tuple[str, Optional[str], int, float, List[Tuple[str, str, int, float]]]:
"""Like _classify but also returns the top-k matches for debug dumps."""
if _is_empty(cell):
return "empty", None, 0, 1.0, []
if not templates:
return "unknown", None, 0, 0.0, []
feat = _to_feat(_inset(cell))
M = np.stack([t.feat for t in templates], axis=0)
scores = M @ feat
order = np.argsort(-scores)[:top_k]
top = [(templates[i].kind, templates[i].value, templates[i].rotation,
float(scores[i])) for i in order]
kind, value, rot, score = _classify(cell, templates)
return kind, value, rot, score, top
# ---------- public API ----------
def recognize_image(
img: Image.Image,
bbox: Tuple[int, int, int, int],
*,
slot_num: int = 34,
include_artifacts: bool = True,
min_score: float = 0.35,
) -> List[CellResult]:
"""Slice img[bbox] into a 6-col grid and classify each cell.
bbox is in source-image pixel coords.
"""
L, T, R, B = bbox
crop = img.crop((L, T, R, B)).convert("RGB")
grid = generate_grid_config(slot_num)
if not grid:
return []
rows = len(grid)
cell_w = (R - L) // GRID_COLS
cell_h = (B - T) // rows
templates = _build_templates(include_artifacts=include_artifacts)
out: List[CellResult] = []
for row in grid:
y = row["rows"]
for x in range(row["cols"]):
cx0 = x * cell_w
cy0 = y * cell_h
cell = crop.crop((cx0, cy0, cx0 + cell_w, cy0 + cell_h))
kind, value, rot, score = _classify(cell, templates, min_score=min_score)
out.append(CellResult(f"{y}-{x}", y, x, kind, value, rot, score))
return out
def dump_debug(
img: Image.Image,
bbox: Tuple[int, int, int, int],
out_dir: str,
*,
slot_num: int = 34,
include_artifacts: bool = True,
) -> str:
"""Save full screenshot, bbox crop, every cell crop and a top-3 match
report to out_dir. Returns the path to the report file. Used to iterate
on recognizer tuning from real captures.
"""
import os
os.makedirs(out_dir, exist_ok=True)
img.save(os.path.join(out_dir, "screenshot.png"))
L, T, R, B = bbox
crop = img.crop((L, T, R, B)).convert("RGB")
crop.save(os.path.join(out_dir, "bbox_crop.png"))
grid = generate_grid_config(slot_num)
if not grid:
return out_dir
rows = len(grid)
cell_w = (R - L) // GRID_COLS
cell_h = (B - T) // rows
templates = _build_templates(include_artifacts=include_artifacts)
stats = load_stats()
lines = [
f"bbox: {bbox}",
f"grid: {len(grid)} rows x {GRID_COLS} cols, slot_num={slot_num}",
f"cell px: {cell_w} x {cell_h}",
f"templates loaded: total={len(templates)} stats={stats}",
"",
]
cells_dir = os.path.join(out_dir, "cells")
os.makedirs(cells_dir, exist_ok=True)
for row in grid:
y = row["rows"]
for x in range(row["cols"]):
cx0 = x * cell_w
cy0 = y * cell_h
cell = crop.crop((cx0, cy0, cx0 + cell_w, cy0 + cell_h))
cell.save(os.path.join(cells_dir, f"{y}-{x}.png"))
kind, value, rot, score, top = _classify_with_top(cell, templates)
top_s = ", ".join(f"{k}:{v}@r{r}={s:.3f}" for k, v, r, s in top)
lines.append(
f" {y}-{x}: kind={kind} value={value} rot={rot} score={score:.3f} | top: {top_s}"
)
report = os.path.join(out_dir, "report.txt")
with open(report, "w", encoding="utf-8") as fh:
fh.write("\n".join(lines))
return report
def recognize_file(
path: str,
bbox: Tuple[int, int, int, int],
*,
slot_num: int = 34,
include_artifacts: bool = True,
min_score: float = 0.55,
) -> List[CellResult]:
img = Image.open(path)
return recognize_image(
img, bbox,
slot_num=slot_num,
include_artifacts=include_artifacts,
min_score=min_score,
)
def slab_values_from(results: List[CellResult]) -> List[str]:
"""Helper: just the slab values, ignoring artifacts/empty/unknown."""
return [r.value for r in results if r.kind == "slab" and r.value]

View File

@@ -62,22 +62,18 @@ def _local_path(slab_image: str) -> str:
return os.path.join(CACHE_DIR, os.path.basename(slab_image))
def fetch_slab_image(slab_image: str, timeout: float = 10.0) -> Optional[Image.Image]:
"""Return a PIL Image for the slab, downloading + caching if needed.
Returns None if download fails — caller draws a placeholder.
"""
def _fetch_image(rel_or_url: str, timeout: float = 10.0) -> Optional[Image.Image]:
"""Fetch an image from the CDN. Accepts a full URL or a 'slabs/foo.png' path."""
_ensure_cache_dir()
path = _local_path(slab_image)
path = _local_path(rel_or_url)
if os.path.exists(path):
try:
return Image.open(path).convert("RGBA")
except Exception:
pass
try:
import requests # lazy import; allow renderer use without network if cached
url = f"{CDN_BASE}/{slab_image.lstrip('/')}"
import requests
url = rel_or_url if rel_or_url.startswith("http") else f"{CDN_BASE}/{rel_or_url.lstrip('/')}"
r = requests.get(url, timeout=timeout)
if r.status_code != 200:
return None
@@ -88,6 +84,16 @@ def fetch_slab_image(slab_image: str, timeout: float = 10.0) -> Optional[Image.I
return None
def fetch_slab_image(slab_image: str, timeout: float = 10.0) -> Optional[Image.Image]:
"""Return a PIL Image for the slab. Caches under CACHE_DIR."""
return _fetch_image(slab_image, timeout=timeout)
def fetch_artifact_image(url: str, timeout: float = 10.0) -> Optional[Image.Image]:
"""Return a PIL Image for an artifact (full URL from artifacts.json)."""
return _fetch_image(url, timeout=timeout)
# ---------------------------------------------------------------------------
# Font
# ---------------------------------------------------------------------------

View File

@@ -1,122 +1,42 @@
"""Recognize slabs from a screenshot of the in-game inventory.
"""Backward-compatible thin wrapper over the new recognizer.
Approach: template matching against the cached CDN images. Given a screenshot
and the inventory bounding box, we divide it into a grid and compare each cell
against every slab template (resized to the cell). Mean absolute error in RGB
picks the best match; cells above a threshold are treated as empty.
The old API exposed `Recognition` (slot_id, value, score) and `recognize()`
returning slabs only. Existing CLI code (`__main__.py`) and tests use that
surface, so we keep it working by delegating to recognizer.py.
This is a best-effort fallback. Accuracy depends heavily on the screenshot
resolution and the slab images matching the in-game render style. The CDN
images are the same pixel-art assets the game uses, so accuracy is usually
fine when the screenshot is sharp.
New code should call `recognizer.recognize_image()` / `recognize_file()`
directly for richer (kind, rotation, artifact) results.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import List, Optional, Tuple
from PIL import Image
from .renderer import fetch_slab_image
from .slabs import GRID_COLS, SLABS, generate_grid_config
from .recognizer import recognize_file
@dataclass
class Recognition:
slot_id: str
value: Optional[str] # None = empty
score: float # lower = better match
def _mae(a: Image.Image, b: Image.Image) -> float:
"""Mean absolute error in RGB. Both images must be the same size."""
if a.size != b.size:
b = b.resize(a.size)
a_rgb = a.convert("RGB")
b_rgb = b.convert("RGB")
pa = list(a_rgb.getdata())
pb = list(b_rgb.getdata())
n = len(pa)
if n == 0:
return 1e9
total = 0
for (ar, ag, ab), (br, bg, bb) in zip(pa, pb):
total += abs(ar - br) + abs(ag - bg) + abs(ab - bb)
return total / (n * 3)
def _alpha_composite_on_dark(img: Image.Image) -> Image.Image:
"""Slab templates are RGBA on transparent. Composite onto dark BG for fairer compare."""
if img.mode != "RGBA":
return img.convert("RGB")
bg = Image.new("RGBA", img.size, (50, 30, 50, 255))
bg.alpha_composite(img)
return bg.convert("RGB")
value: Optional[str] # slab value, or None if empty/unknown/artifact
score: float # NCC score in [-1, 1]; higher = better
def recognize(
screenshot_path: str,
bbox: Tuple[int, int, int, int],
slot_num: int = 34,
empty_threshold: float = 35.0,
empty_threshold: float = 35.0, # ignored; kept for arg-compat
) -> List[Recognition]:
"""Recognize slabs in the inventory area of a screenshot.
Args:
screenshot_path: Path to the game screenshot (PNG/JPG).
bbox: (left, top, right, bottom) pixel coords of the inventory grid.
Must enclose only the slot grid, not the surrounding UI.
slot_num: Total slot count (18..60). Used to compute row layout.
empty_threshold: MAE above this counts as empty.
Returns:
List of Recognition entries, one per slot in row-major order.
"""
img = Image.open(screenshot_path).convert("RGB")
left, top, right, bottom = bbox
img = img.crop((left, top, right, bottom))
grid = generate_grid_config(slot_num)
if not grid:
return []
rows = len(grid)
cell_w = (right - left) // GRID_COLS
cell_h = (bottom - top) // rows
template_size = (min(cell_w, cell_h), min(cell_w, cell_h))
# Pre-load and downscale templates
templates: List[Tuple[str, Image.Image]] = []
for slab in SLABS:
t = fetch_slab_image(slab.image)
if t is None:
continue
t = _alpha_composite_on_dark(t).resize(template_size)
templates.append((slab.value, t))
results: List[Recognition] = []
for row in grid:
y = row["rows"]
for x in range(row["cols"]):
cx0 = x * cell_w
cy0 = y * cell_h
cell = img.crop((cx0, cy0, cx0 + cell_w, cy0 + cell_h)).resize(template_size)
best_value: Optional[str] = None
best_score = 1e9
for v, t in templates:
s = _mae(cell, t)
if s < best_score:
best_score = s
best_value = v
if best_score > empty_threshold:
results.append(Recognition(f"{y}-{x}", None, best_score))
else:
results.append(Recognition(f"{y}-{x}", best_value, best_score))
return results
"""Recognize slabs in the inventory area of a screenshot (slabs only)."""
cells = recognize_file(screenshot_path, bbox, slot_num=slot_num)
out: List[Recognition] = []
for c in cells:
v = c.value if c.kind == "slab" else None
out.append(Recognition(c.slot_id, v, c.score))
return out
def recognized_values(recognitions: List[Recognition]) -> List[str]:
"""Helper: extract just the non-empty slab values."""
return [r.value for r in recognitions if r.value is not None]

View File

@@ -0,0 +1,163 @@
"""Game window enumeration + capture.
On Windows we use pygetwindow to list visible top-level windows by title
and pywin32 (PrintWindow) to grab a single window — that works even when
the window is partially covered or not focused, which a regular mss screen
grab does not.
On non-Windows we degrade gracefully: list_windows() returns [] and
capture_window() falls back to a full-screen grab via mss. This is mostly
so the GUI imports cleanly during dev on Linux — actual gameplay
recognition is a Windows-only feature.
"""
from __future__ import annotations
import platform
import sys
from dataclasses import dataclass
from typing import List, Optional
from PIL import Image
@dataclass
class WindowInfo:
handle: int # HWND on Windows, 0 elsewhere
title: str
left: int
top: int
width: int
height: int
def _is_windows() -> bool:
return platform.system() == "Windows"
def list_windows() -> List[WindowInfo]:
"""Return visible top-level windows with a title. Windows-only; [] otherwise."""
if not _is_windows():
return []
try:
import pygetwindow as gw # type: ignore
except Exception:
return []
out: List[WindowInfo] = []
for w in gw.getAllWindows():
try:
if not w.title:
continue
if w.width <= 50 or w.height <= 50:
continue
if not w.visible:
continue
except Exception:
continue
try:
out.append(WindowInfo(
handle=int(w._hWnd) if hasattr(w, "_hWnd") else 0,
title=w.title,
left=w.left, top=w.top,
width=w.width, height=w.height,
))
except Exception:
continue
return out
def find_sephiria() -> Optional[WindowInfo]:
"""Best-effort: pick the first window whose title contains 'Sephiria' / '세피리아'."""
for w in list_windows():
t = w.title.lower()
if "sephiria" in t or "세피리아" in w.title:
return w
return None
def capture_window(info: WindowInfo) -> Image.Image:
"""Capture a single window into a PIL RGB image.
On Windows uses PrintWindow with PW_RENDERFULLCONTENT (works on hidden
windows / behind-other-windows). On other OSes falls back to an mss
region grab of the window's bounding rectangle on the primary monitor.
"""
if _is_windows() and info.handle:
try:
return _capture_window_win(info)
except Exception:
pass
# Fallback: mss region grab
return _capture_region_mss(info.left, info.top, info.width, info.height)
def _capture_window_win(info: WindowInfo) -> Image.Image:
import ctypes
from ctypes import wintypes
hwnd = info.handle
user32 = ctypes.windll.user32
gdi32 = ctypes.windll.gdi32
# Use the actual client/window rect — pygetwindow's width/height can
# include shadow/DWM regions; GetWindowRect is more reliable.
rect = wintypes.RECT()
user32.GetWindowRect(hwnd, ctypes.byref(rect))
width = rect.right - rect.left
height = rect.bottom - rect.top
if width <= 0 or height <= 0:
raise RuntimeError("window has zero size")
hwndDC = user32.GetWindowDC(hwnd)
mfcDC = gdi32.CreateCompatibleDC(hwndDC)
bitmap = gdi32.CreateCompatibleBitmap(hwndDC, width, height)
gdi32.SelectObject(mfcDC, bitmap)
PW_RENDERFULLCONTENT = 0x00000002
ok = user32.PrintWindow(hwnd, mfcDC, PW_RENDERFULLCONTENT)
# Read pixels
class BITMAPINFOHEADER(ctypes.Structure):
_fields_ = [
("biSize", wintypes.DWORD),
("biWidth", ctypes.c_long),
("biHeight", ctypes.c_long),
("biPlanes", wintypes.WORD),
("biBitCount", wintypes.WORD),
("biCompression", wintypes.DWORD),
("biSizeImage", wintypes.DWORD),
("biXPelsPerMeter", ctypes.c_long),
("biYPelsPerMeter", ctypes.c_long),
("biClrUsed", wintypes.DWORD),
("biClrImportant", wintypes.DWORD),
]
class BITMAPINFO(ctypes.Structure):
_fields_ = [("bmiHeader", BITMAPINFOHEADER), ("bmiColors", wintypes.DWORD * 3)]
bmi = BITMAPINFO()
bmi.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER)
bmi.bmiHeader.biWidth = width
bmi.bmiHeader.biHeight = -height # top-down
bmi.bmiHeader.biPlanes = 1
bmi.bmiHeader.biBitCount = 32
bmi.bmiHeader.biCompression = 0 # BI_RGB
buffer = ctypes.create_string_buffer(width * height * 4)
gdi32.GetDIBits(mfcDC, bitmap, 0, height, buffer, ctypes.byref(bmi), 0)
gdi32.DeleteObject(bitmap)
gdi32.DeleteDC(mfcDC)
user32.ReleaseDC(hwnd, hwndDC)
img = Image.frombuffer("RGBA", (width, height), buffer, "raw", "BGRA", 0, 1)
return img.convert("RGB")
def _capture_region_mss(left: int, top: int, width: int, height: int) -> Image.Image:
import mss
with mss.mss() as sct:
region = {"left": left, "top": top, "width": width, "height": height}
shot = sct.grab(region)
img = Image.frombytes("RGB", shot.size, shot.bgra, "raw", "BGRX")
return img