8 Commits
v0.1.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
Claude
e388c965bc Add screenshot-driven inventory recognition (v0.2.0)
- New ScreenshotFrame: capture screen / load PNG, two-click bbox,
  threaded template matching, editable preview grid for corrections
- ManualFrame kept as second tab for users who prefer typing counts
- capture.py: screen grab via mss (cross-platform)
- requirements: add mss>=6.0 for screen capture support

Closes the gap from v0.1.0 where users had to manually count
every slab — now they aim, click two corners, and edit any
mis-recognized cell.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 22:31:27 +09:00
tkrmagid
610e979fbb Lower Pillow/requests pins for Python 3.7 compat (used by Windows cross-build) 2026-05-13 22:18:44 +09:00
13 changed files with 5467 additions and 238 deletions

View File

@@ -29,6 +29,16 @@
python -m sephiria_inv python -m sephiria_inv
``` ```
GUI 는 두 가지 탭으로 구성됩니다.
**스크린샷 탭 (권장)**
1. 게임을 인벤토리 화면 상태로 띄워둔다
2. `화면 캡처` 버튼을 누른다 (또는 `이미지 열기…` 로 미리 찍어둔 PNG)
3. 인벤토리 격자의 좌상단/우하단을 한 번씩 클릭해 영역을 지정한다
4. 자동으로 템플릿 매칭이 돌면서 각 셀의 석판을 인식한다
5. 오인식된 셀이 있으면 클릭해서 직접 교체하고 `이 구성으로 계산` 을 누른다
**수동 선택 탭**
좌측 카탈로그에서 보유한 석판 옆 `+` 버튼으로 개수를 올리고 좌측 카탈로그에서 보유한 석판 옆 `+` 버튼으로 개수를 올리고
`최적 배치 계산`을 누르면 됩니다. 결과 이미지는 `이미지 저장…` 으로 `최적 배치 계산`을 누르면 됩니다. 결과 이미지는 `이미지 저장…` 으로
PNG 저장이 가능합니다. PNG 저장이 가능합니다.
@@ -44,15 +54,15 @@ python -m sephiria_inv --cli \
-s harvesting:2 -s binary_star -s thorn -s sheen -s base \ -s harvesting:2 -s binary_star -s thorn -s sheen -s base \
--slots 24 --seed 7 -o layout.png --slots 24 --seed 7 -o layout.png
# 스크린샷에서 인식 (베타) # 스크린샷에서 인식 (CLI - bbox 수동 지정)
python -m sephiria_inv --cli \ python -m sephiria_inv --cli \
--screenshot ./game.png --bbox 320,180,1024,720 \ --screenshot ./game.png --bbox 320,180,1024,720 \
--slots 34 -o layout.png --slots 34 -o layout.png
``` ```
`--bbox` 는 인벤토리 격자 영역의 픽셀 좌표(left,top,right,bottom)입니다. CLI 에서는 `--bbox left,top,right,bottom` 으로 격자 영역을 직접 줘야 합니다.
정확도는 스크린샷 해상도/UI 스타일에 따라 다릅니다 — 잘못 인식되는 셀이 GUI 에서는 두 번 클릭으로 같은 일을 할 수 있습니다. 인식 정확도는 스크린샷
있으면 GUI에서 보정해 주세요. 해상도/UI 스타일에 따라 다르므로 잘못된 셀은 GUI 에서 클릭해 교정하세요.
## 포터블 EXE 빌드 ## 포터블 EXE 빌드

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,2 +1,6 @@
Pillow>=10.0 Pillow>=9.0
requests>=2.31 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__": 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)

43
sephiria_inv/capture.py Normal file
View File

@@ -0,0 +1,43 @@
"""Screen capture utility.
`capture_screen()` grabs the current screen via mss and returns a PIL RGB
image. Bounding-box selection of the inventory area is done in the GUI by
two clicks (top-left + bottom-right) — far more reliable than heuristic
auto-detection across the many possible game UI scales and DPI settings.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Tuple
from PIL import Image
@dataclass
class GridBBox:
left: int
top: int
right: int
bottom: int
def as_tuple(self) -> Tuple[int, int, int, int]:
return (self.left, self.top, self.right, self.bottom)
def capture_screen(monitor: int = 1) -> Image.Image:
"""Capture the full screen of the given monitor as a PIL RGB image.
monitor=0 = all monitors combined, 1 = primary on most systems. Falls
back to monitor 0 if the requested index is missing.
"""
import mss # lazy: keep mss optional for renderer/CLI-only use
with mss.mss() as sct:
try:
mon = sct.monitors[monitor]
except IndexError:
mon = sct.monitors[0]
shot = sct.grab(mon)
img = Image.frombytes("RGB", shot.size, shot.bgra, "raw", "BGRX")
return img

View File

@@ -1,50 +1,779 @@
"""Tkinter GUI for picking slabs and rendering the optimal layout. """Tkinter GUI for the Sephiria inventory optimizer.
Layout (left-to-right): Two input modes via Notebook:
1. Tier-grouped slab catalog with [+] [] count controls - "게임창 캡처": list visible Windows top-level windows, pick the Sephiria
2. Current basket summary one, capture its full client area (PrintWindow, works even when not
3. Slot count slider + Solve button + result preview focused), click two corners of the bag area, then run the recognizer
(NCC + 4 rotations + artifacts + empty/unknown). Mis-recognized cells
can be fixed by click. Unknown ("?") cells likely correspond to merged
slab boxes — clicking one opens a popup that lets the user describe
which two slabs got merged.
- "수동 선택": existing tier-grouped +/- input.
The preview is rendered to a temp PNG via renderer and shown via PhotoImage. Both modes feed a basket → solver → renderer pipeline shown on the right.
""" """
from __future__ import annotations from __future__ import annotations
import os import os
import sys import sys
import tempfile
import threading import threading
import tkinter as tk import tkinter as tk
from tkinter import filedialog, messagebox, ttk from tkinter import filedialog, messagebox, ttk
from typing import Dict, List from typing import Dict, List, Optional, Tuple
from PIL import Image, ImageTk from PIL import Image, ImageTk
from .renderer import render_solution from .artifacts import ARTIFACTS, ARTIFACTS_BY_VALUE
from .slabs import SLABS, SLABS_BY_VALUE, TIER_LABEL, TIER_ORDER from .recognizer import CellResult, recognize_image, warm_templates
from .renderer import fetch_artifact_image, fetch_slab_image, render_solution
from .slabs import (
GRID_COLS,
SLABS,
SLABS_BY_VALUE,
TIER_LABEL,
TIER_ORDER,
generate_grid_config,
)
from .solver import solve from .solver import solve
# ---------- helpers ----------
def _slab_thumb(value: str, size: int = 48) -> Optional[ImageTk.PhotoImage]:
slab = SLABS_BY_VALUE.get(value)
if not slab:
return None
img = fetch_slab_image(slab.image)
if img is None:
return None
return ImageTk.PhotoImage(img.resize((size, size)))
def _artifact_thumb(value: str, size: int = 48) -> Optional[ImageTk.PhotoImage]:
a = ARTIFACTS_BY_VALUE.get(value)
if not a:
return None
img = fetch_artifact_image(a.image)
if img is None:
return None
return ImageTk.PhotoImage(img.resize((size, size)))
# ---------- window-picker dialog ----------
class WindowPicker(tk.Toplevel):
"""List visible windows; the chosen WindowInfo ends up on .selected."""
def __init__(self, parent: tk.Tk) -> None:
super().__init__(parent)
self.title("게임 창 선택")
self.transient(parent)
self.selected = None
from .window_capture import list_windows, find_sephiria
windows = list_windows()
wrap = ttk.Frame(self, padding=8)
wrap.pack(fill="both", expand=True)
if not windows:
ttk.Label(wrap, text=(
"현재 OS 에서 창 목록을 가져올 수 없습니다.\n"
"(Windows 전용 기능 - 리눅스/맥에서는 전체 화면 캡처를 쓰세요)"
), justify="left").pack(pady=8)
ttk.Button(wrap, text="닫기", command=self.destroy).pack()
self.grab_set()
return
ttk.Label(wrap, text="아래 목록에서 세피리아 게임 창을 더블클릭하세요:") \
.pack(anchor="w")
tree_frame = ttk.Frame(wrap)
tree_frame.pack(fill="both", expand=True, pady=6)
cols = ("title", "size")
self.tree = ttk.Treeview(tree_frame, columns=cols, show="headings", height=14)
self.tree.heading("title", text="창 이름")
self.tree.heading("size", text="크기")
self.tree.column("title", width=440)
self.tree.column("size", width=120, anchor="center")
sb = ttk.Scrollbar(tree_frame, orient="vertical", command=self.tree.yview)
self.tree.configure(yscrollcommand=sb.set)
self.tree.pack(side="left", fill="both", expand=True)
sb.pack(side="right", fill="y")
self._windows = windows
# auto-promote Sephiria-matching entries
auto = find_sephiria()
sel_idx = None
for i, w in enumerate(windows):
self.tree.insert(
"", "end", iid=str(i),
values=(w.title, f"{w.width}×{w.height}"),
)
if auto and w.handle == auto.handle:
sel_idx = i
if sel_idx is not None:
self.tree.selection_set(str(sel_idx))
self.tree.see(str(sel_idx))
self.tree.bind("<Double-Button-1>", lambda _e: self._confirm())
btns = ttk.Frame(wrap)
btns.pack(fill="x", pady=(6, 0))
ttk.Button(btns, text="선택", command=self._confirm).pack(side="left")
ttk.Button(btns, text="취소", command=self.destroy).pack(side="right")
self.grab_set()
self.focus_set()
def _confirm(self) -> None:
sel = self.tree.selection()
if not sel:
return
self.selected = self._windows[int(sel[0])]
self.destroy()
# ---------- crop dialog (two-click bbox) ----------
class CropDialog(tk.Toplevel):
MAX_DISPLAY = 1100
def __init__(self, parent: tk.Tk, img: Image.Image) -> None:
super().__init__(parent)
self.title("가방 영역 선택 — 좌상단과 우하단을 차례로 클릭")
self.transient(parent)
self.img_full = img
self.bbox: Optional[Tuple[int, int, int, int]] = None
self._clicks: List[Tuple[int, int]] = []
w, h = img.size
self.scale = min(1.0, self.MAX_DISPLAY / max(w, h))
disp = img.resize((int(w * self.scale), int(h * self.scale)))
self.tk_img = ImageTk.PhotoImage(disp)
ttk.Label(
self, justify="left",
text=(
"가방(인벤토리) 격자의 좌상단을 먼저 클릭하고, 우하단을 다시 클릭하세요.\n"
"그리드 안쪽 첫 칸의 좌상단 모서리와 마지막 칸의 우하단 모서리로 맞추는 게 정확합니다."
),
).pack(padx=8, pady=(8, 4))
self.canvas = tk.Canvas(
self, width=disp.size[0], height=disp.size[1], highlightthickness=0
)
self.canvas.pack(padx=8, pady=4)
self.canvas.create_image(0, 0, anchor="nw", image=self.tk_img)
self.canvas.bind("<Button-1>", self._click)
bar = ttk.Frame(self)
bar.pack(fill="x", padx=8, pady=(4, 8))
ttk.Button(bar, text="처음부터", command=self._reset).pack(side="left")
ttk.Button(bar, text="취소", command=self._cancel).pack(side="right")
self.protocol("WM_DELETE_WINDOW", self._cancel)
self.grab_set()
self.focus_set()
def _click(self, e: tk.Event) -> None:
self._clicks.append((e.x, e.y))
self.canvas.create_oval(
e.x - 5, e.y - 5, e.x + 5, e.y + 5, outline="#ff5050", width=2
)
if len(self._clicks) == 2:
(x1, y1), (x2, y2) = self._clicks
lx, ty = min(x1, x2), min(y1, y2)
rx, by = max(x1, x2), max(y1, y2)
self.canvas.create_rectangle(lx, ty, rx, by, outline="#50ff80", width=2)
inv = 1.0 / self.scale
self.bbox = (int(lx * inv), int(ty * inv), int(rx * inv), int(by * inv))
self.after(120, self.destroy)
def _reset(self) -> None:
self._clicks.clear()
self.canvas.delete("all")
self.canvas.create_image(0, 0, anchor="nw", image=self.tk_img)
def _cancel(self) -> None:
self.bbox = None
self.destroy()
# ---------- cell editor (slab / artifact / merged / empty) ----------
class CellEditor(tk.Toplevel):
"""Modal editor. Result on .result: dict {kind, value, rotation, merged}."""
def __init__(self, parent: tk.Tk, current: Optional[CellResult]) -> None:
super().__init__(parent)
self.title("셀 수정")
self.transient(parent)
self.result: Optional[dict] = None
cur_kind = current.kind if current else "empty"
cur_val = current.value if current else None
wrap = ttk.Frame(self, padding=8)
wrap.pack(fill="both", expand=True)
ttk.Label(wrap, text="이 셀의 종류:").pack(anchor="w")
self.kind_var = tk.StringVar(value=cur_kind if cur_kind in ("slab", "artifact", "empty", "merged") else "empty")
kind_row = ttk.Frame(wrap)
kind_row.pack(fill="x", pady=(0, 6))
for label, key in [("석판", "slab"), ("아티팩트", "artifact"),
("합쳐진(?)", "merged"), ("빈칸", "empty")]:
ttk.Radiobutton(kind_row, text=label, value=key,
variable=self.kind_var, command=self._render).pack(side="left", padx=2)
self.body = ttk.Frame(wrap)
self.body.pack(fill="both", expand=True)
self._slab_buttons: List[ttk.Button] = []
self._artifact_buttons: List[ttk.Button] = []
self._chosen_value: Optional[str] = cur_val
self._chosen_kind: str = self.kind_var.get()
# merged sub-state
self._merged_a = tk.StringVar(value="")
self._merged_b = tk.StringVar(value="")
self._merged_lvl = tk.IntVar(value=1)
self._render()
bar = ttk.Frame(self)
bar.pack(fill="x", padx=8, pady=(0, 8))
ttk.Button(bar, text="확인", command=self._ok).pack(side="left")
ttk.Button(bar, text="취소", command=self.destroy).pack(side="right")
self.grab_set()
self.focus_set()
def _render(self) -> None:
for c in self.body.winfo_children():
c.destroy()
k = self.kind_var.get()
if k == "slab":
self._build_slab_picker()
elif k == "artifact":
self._build_artifact_picker()
elif k == "merged":
self._build_merged_picker()
else:
ttk.Label(self.body, text="이 칸은 빈칸으로 처리됩니다.").pack(pady=20)
def _build_slab_picker(self) -> None:
sub = ttk.Frame(self.body)
sub.pack(fill="both", expand=True)
cnv = tk.Canvas(sub, height=320, highlightthickness=0)
sb = ttk.Scrollbar(sub, orient="vertical", command=cnv.yview)
inner = ttk.Frame(cnv)
inner.bind("<Configure>", lambda e: cnv.configure(scrollregion=cnv.bbox("all")))
cnv.create_window((0, 0), window=inner, anchor="nw")
cnv.configure(yscrollcommand=sb.set)
cnv.pack(side="left", fill="both", expand=True)
sb.pack(side="right", fill="y")
by_tier: Dict[str, List] = {}
for s in SLABS:
by_tier.setdefault(s.tier, []).append(s)
for tier in sorted(by_tier, key=lambda t: TIER_ORDER.get(t, 99)):
ttk.Label(inner, text=TIER_LABEL.get(tier, tier),
font=("TkDefaultFont", 10, "bold")).pack(anchor="w", pady=(8, 2))
grid = ttk.Frame(inner)
grid.pack(fill="x")
for i, s in enumerate(by_tier[tier]):
b = ttk.Button(grid, text=s.ko_label, width=8,
command=lambda v=s.value: self._pick_slab(v))
b.grid(row=i // 6, column=i % 6, padx=2, pady=2, sticky="w")
def _build_artifact_picker(self) -> None:
sub = ttk.Frame(self.body)
sub.pack(fill="both", expand=True)
ttk.Label(sub, text=(
f"아티팩트 {len(ARTIFACTS)}종. 한글명으로 검색:"
)).pack(anchor="w")
sv = tk.StringVar()
ttk.Entry(sub, textvariable=sv).pack(fill="x", pady=2)
list_frame = ttk.Frame(sub)
list_frame.pack(fill="both", expand=True)
lb = tk.Listbox(list_frame, height=14)
lb.pack(side="left", fill="both", expand=True)
sb = ttk.Scrollbar(list_frame, orient="vertical", command=lb.yview)
sb.pack(side="right", fill="y")
lb.configure(yscrollcommand=sb.set)
items = sorted(ARTIFACTS, key=lambda a: (TIER_ORDER.get(a.tier, 99), a.ko_label))
def refresh(*_a):
lb.delete(0, "end")
q = sv.get().strip()
for a in items:
if not q or q in a.ko_label or q in a.value:
lb.insert("end", f"[{TIER_LABEL.get(a.tier, a.tier)}] {a.ko_label}")
sv.trace_add("write", refresh)
refresh()
def on_pick(_e=None):
sel = lb.curselection()
if not sel:
return
label = lb.get(sel[0])
# match by Korean label
ko = label.split("] ", 1)[1] if "] " in label else label
for a in ARTIFACTS:
if a.ko_label == ko:
self._chosen_value = a.value
self._chosen_kind = "artifact"
return
lb.bind("<<ListboxSelect>>", on_pick)
def _build_merged_picker(self) -> None:
sub = ttk.Frame(self.body, padding=4)
sub.pack(fill="both", expand=True)
ttk.Label(sub, text=(
"합쳐진 박스(?)에 어떤 두 석판이 들어갔는지 고르고, 누적 레벨을 지정하세요.\n"
"선택한 정보로 결과 이미지에 합쳐진 형태가 표시됩니다."
), justify="left").pack(anchor="w", pady=(0, 6))
slab_values = [s.value for s in SLABS]
slab_labels = {s.value: f"{s.ko_label} ({s.value})" for s in SLABS}
def labelled(v: str) -> str:
return slab_labels.get(v, v)
rowA = ttk.Frame(sub); rowA.pack(fill="x", pady=2)
ttk.Label(rowA, text="첫 번째 석판:", width=14).pack(side="left")
ttk.OptionMenu(rowA, self._merged_a, "",
*[""] + [labelled(v) for v in slab_values]).pack(side="left", fill="x", expand=True)
rowB = ttk.Frame(sub); rowB.pack(fill="x", pady=2)
ttk.Label(rowB, text="두 번째 석판:", width=14).pack(side="left")
ttk.OptionMenu(rowB, self._merged_b, "",
*[""] + [labelled(v) for v in slab_values]).pack(side="left", fill="x", expand=True)
rowL = ttk.Frame(sub); rowL.pack(fill="x", pady=2)
ttk.Label(rowL, text="누적 레벨:", width=14).pack(side="left")
ttk.Spinbox(rowL, from_=1, to=10, textvariable=self._merged_lvl,
width=6).pack(side="left")
def _pick_slab(self, value: str) -> None:
self._chosen_value = value
self._chosen_kind = "slab"
def _ok(self) -> None:
k = self.kind_var.get()
if k == "slab":
if not self._chosen_value:
messagebox.showinfo("안내", "석판을 선택하세요.")
return
self.result = {"kind": "slab", "value": self._chosen_value,
"rotation": 0, "merged": None}
elif k == "artifact":
if not self._chosen_value:
messagebox.showinfo("안내", "아티팩트를 선택하세요.")
return
self.result = {"kind": "artifact", "value": self._chosen_value,
"rotation": 0, "merged": None}
elif k == "merged":
def _val_from_label(lbl: str) -> Optional[str]:
if not lbl:
return None
if "(" in lbl and lbl.endswith(")"):
return lbl[lbl.rfind("(") + 1: -1]
return None
a = _val_from_label(self._merged_a.get())
b = _val_from_label(self._merged_b.get())
if not a or not b:
messagebox.showinfo("안내", "두 석판을 모두 선택하세요.")
return
self.result = {"kind": "merged", "value": None, "rotation": 0,
"merged": {"a": a, "b": b, "level": int(self._merged_lvl.get())}}
else:
self.result = {"kind": "empty", "value": None, "rotation": 0, "merged": None}
self.destroy()
# ---------- screenshot/game-window frame ----------
class ScreenshotFrame(ttk.Frame):
def __init__(self, master, on_confirmed) -> None:
super().__init__(master, padding=6)
self.on_confirmed = on_confirmed
self.slot_var = master.winfo_toplevel().slot_var # type: ignore[attr-defined]
self.image: Optional[Image.Image] = None
self.bbox: Optional[Tuple[int, int, int, int]] = None
self.cells: List[CellResult] = []
# per-slot override: dict slot_id -> {kind, value, rotation, merged}
self.overrides: Dict[str, dict] = {}
self._thumbs: List[ImageTk.PhotoImage] = []
self._templates_warmed = False
ctl = ttk.Frame(self)
ctl.pack(fill="x")
ttk.Button(ctl, text="게임 창 선택…", command=self._pick_window).pack(side="left")
ttk.Button(ctl, text="전체 화면 캡처", command=self._capture_screen).pack(side="left", padx=4)
ttk.Button(ctl, text="파일 열기…", command=self._open_file).pack(side="left", padx=4)
ttk.Button(ctl, text="영역 재지정", command=self._reselect_bbox).pack(side="left", padx=4)
ttk.Button(ctl, text="디버그 저장", command=self._save_debug).pack(side="left", padx=4)
ttk.Button(ctl, text="이 구성으로 계산", command=self._confirm).pack(side="right")
self.status = ttk.Label(
self,
text=(
"1) [게임 창 선택] 으로 Sephiria 창을 고르거나, [전체 화면 캡처]/[파일 열기] 로 이미지를 가져옵니다.\n"
"2) 가방 격자의 좌상단/우하단을 두 번 클릭해 영역을 지정합니다.\n"
"3) 자동 인식 후 잘못된 셀은 클릭해서 종류/석판/아티팩트/합쳐진(?) 으로 교정하세요."
),
wraplength=520, justify="left",
)
self.status.pack(fill="x", pady=(6, 4))
self.preview_canvas = tk.Canvas(self, height=440, highlightthickness=0)
self.preview_canvas.pack(fill="both", expand=True)
self.preview_inner = ttk.Frame(self.preview_canvas)
self.preview_canvas.create_window((0, 0), window=self.preview_inner, anchor="nw")
self.preview_inner.bind(
"<Configure>",
lambda e: self.preview_canvas.configure(
scrollregion=self.preview_canvas.bbox("all")
),
)
# ----- input modes -----
def _pick_window(self) -> None:
dlg = WindowPicker(self.winfo_toplevel())
self.winfo_toplevel().wait_window(dlg)
if not dlg.selected:
return
try:
from .window_capture import capture_window
self.image = capture_window(dlg.selected)
except Exception as e:
messagebox.showerror("창 캡처 실패", str(e))
return
self.status["text"] = f"창 캡처 완료: {dlg.selected.title} ({self.image.size[0]}×{self.image.size[1]})"
self._pick_bbox_and_recognize()
def _capture_screen(self) -> None:
try:
from .capture import capture_screen
top = self.winfo_toplevel()
top.withdraw(); top.update()
try:
self.image = capture_screen(monitor=1)
finally:
top.deiconify()
except Exception as e:
self.winfo_toplevel().deiconify()
messagebox.showerror("캡처 실패", str(e))
return
self._pick_bbox_and_recognize()
def _open_file(self) -> None:
path = filedialog.askopenfilename(
title="스크린샷 선택",
filetypes=[("Images", "*.png *.jpg *.jpeg *.webp"), ("All", "*.*")],
)
if not path:
return
try:
self.image = Image.open(path).convert("RGB")
except Exception as e:
messagebox.showerror("열기 실패", str(e))
return
self._pick_bbox_and_recognize()
def _reselect_bbox(self) -> None:
if self.image is None:
messagebox.showinfo("안내", "먼저 캡처 또는 파일 열기를 해주세요.")
return
self._pick_bbox_and_recognize()
# ----- pipeline -----
def _pick_bbox_and_recognize(self) -> None:
assert self.image is not None
dlg = CropDialog(self.winfo_toplevel(), self.image)
self.winfo_toplevel().wait_window(dlg)
if not dlg.bbox:
return
self.bbox = dlg.bbox
self.overrides.clear()
self.status["text"] = "템플릿 준비 + 셀 인식 중…"
self.update_idletasks()
threading.Thread(target=self._recognize_thread, daemon=True).start()
def _recognize_thread(self) -> None:
try:
if not self._templates_warmed:
# First call may download a lot — keep artifacts on (user wants them)
warm_templates(include_artifacts=True)
self._templates_warmed = True
from .recognizer import load_stats
self._tpl_stats = load_stats()
slot_num = int(round(self.slot_var.get()))
cells = recognize_image(
self.image, self.bbox,
slot_num=slot_num, include_artifacts=True,
)
self.after(0, self._show_cells, cells)
except Exception as e:
self.after(0, lambda: messagebox.showerror("인식 실패", str(e)))
def _save_debug(self) -> None:
if self.image is None or not self.bbox:
messagebox.showinfo("안내", "먼저 캡처 + 영역 지정을 해주세요.")
return
import os
from datetime import datetime
from .recognizer import dump_debug
base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
out_dir = os.path.join(base, "sephiria_inv", "debug",
datetime.now().strftime("%Y%m%d-%H%M%S"))
try:
slot_num = int(round(self.slot_var.get()))
report = dump_debug(self.image, self.bbox, out_dir, slot_num=slot_num)
messagebox.showinfo(
"디버그 저장 완료",
f"폴더에 screenshot.png, bbox_crop.png, cells/, report.txt 가 저장됨.\n\n{out_dir}\n\n"
f"report: {report}",
)
except Exception as e:
messagebox.showerror("디버그 저장 실패", str(e))
def _show_cells(self, cells: List[CellResult]) -> None:
self.cells = cells
for c in self.preview_inner.winfo_children():
c.destroy()
self._thumbs.clear()
slot_num = int(round(self.slot_var.get()))
grid = generate_grid_config(slot_num)
slot_to_cell = {c.slot_id: c for c in cells}
kind_counts = {"slab": 0, "artifact": 0, "empty": 0, "unknown": 0, "merged": 0}
for row_cfg in grid:
y = row_cfg["rows"]
for x in range(GRID_COLS):
if x >= row_cfg["cols"]:
tk.Frame(self.preview_inner, width=64, height=80,
bg=self.preview_inner.winfo_toplevel()["bg"]) \
.grid(row=y, column=x, padx=2, pady=2)
continue
slot_id = f"{y}-{x}"
cell = slot_to_cell.get(slot_id)
ov = self.overrides.get(slot_id)
effective = self._effective(cell, ov)
kind_counts[effective["kind"]] = kind_counts.get(effective["kind"], 0) + 1
self._make_cell(y, x, slot_id, effective)
stats = getattr(self, "_tpl_stats", None) or {}
tpl_line = ""
if stats:
total_loaded = stats.get("slabs_ok", 0) + stats.get("artifacts_ok", 0)
total_failed = stats.get("slabs_fail", 0) + stats.get("artifacts_fail", 0)
tpl_line = (
f"\n템플릿: 슬랩 {stats.get('slabs_ok',0)}/{stats.get('slabs_ok',0)+stats.get('slabs_fail',0)} · "
f"아티팩트 {stats.get('artifacts_ok',0)}/{stats.get('artifacts_ok',0)+stats.get('artifacts_fail',0)}"
)
if total_loaded == 0:
tpl_line += " (CDN 다운로드 실패 — 인터넷 연결/방화벽 확인)"
msg = (
f"석판 {kind_counts.get('slab', 0)} · 아티팩트 {kind_counts.get('artifact', 0)} · "
f"빈칸 {kind_counts.get('empty', 0)} · 합쳐진(?) {kind_counts.get('merged', 0)} · "
f"미인식 {kind_counts.get('unknown', 0)}"
f"{tpl_line}\n"
"셀을 클릭하면 종류/값을 교정할 수 있습니다. 끝나면 [이 구성으로 계산]."
)
self.status["text"] = msg
def _effective(self, cell: Optional[CellResult], ov: Optional[dict]) -> dict:
if ov is not None:
return ov
if cell is None:
return {"kind": "empty", "value": None, "rotation": 0, "merged": None, "score": 0.0}
return {
"kind": cell.kind, "value": cell.value,
"rotation": cell.rotation, "merged": None, "score": cell.score,
}
def _make_cell(self, y: int, x: int, slot_id: str, info: dict) -> None:
kind = info.get("kind", "empty")
value = info.get("value")
rot = info.get("rotation", 0) or 0
score = info.get("score", 0.0)
border = {"slab": "#7a4a8a", "artifact": "#6a82c8", "merged": "#c870c0",
"unknown": "#c8a050", "empty": "#3a2a3a"}.get(kind, "#3a2a3a")
frame = tk.Frame(self.preview_inner, bd=2, relief="solid",
width=72, height=82, bg="#2c1a2a",
highlightbackground=border, highlightthickness=2)
frame.grid(row=y, column=x, padx=2, pady=2)
frame.grid_propagate(False)
if kind == "slab" and value:
thumb = _slab_thumb(value, size=44)
if thumb is not None:
if rot:
# generate rotated thumb on the fly
base = fetch_slab_image(SLABS_BY_VALUE[value].image)
if base is not None:
base = base.resize((44, 44)).rotate(-90 * rot, expand=False)
thumb = ImageTk.PhotoImage(base)
self._thumbs.append(thumb)
lbl = tk.Label(frame, image=thumb, bg="#2c1a2a")
else:
lbl = tk.Label(frame, text=SLABS_BY_VALUE[value].ko_label,
fg="#fff", bg="#2c1a2a")
elif kind == "artifact" and value:
thumb = _artifact_thumb(value, size=44)
if thumb is not None:
self._thumbs.append(thumb)
lbl = tk.Label(frame, image=thumb, bg="#2c1a2a")
else:
a = ARTIFACTS_BY_VALUE.get(value)
lbl = tk.Label(frame, text=a.ko_label if a else value,
fg="#9bf", bg="#2c1a2a")
elif kind == "merged":
lbl = tk.Label(frame, text="?", font=("TkDefaultFont", 26, "bold"),
fg="#ffaaff", bg="#2c1a2a")
elif kind == "unknown":
lbl = tk.Label(frame, text="?", font=("TkDefaultFont", 26, "bold"),
fg="#ffd070", bg="#2c1a2a")
else:
lbl = tk.Label(frame, text="·", fg="#666", bg="#2c1a2a")
lbl.pack(expand=True)
caption = ""
if kind == "slab" and value:
caption = SLABS_BY_VALUE[value].ko_label
if rot:
caption += f"{rot}"
elif kind == "artifact" and value:
a = ARTIFACTS_BY_VALUE.get(value)
caption = a.ko_label if a else value
elif kind == "merged":
m = info.get("merged") or {}
la = SLABS_BY_VALUE.get(m.get("a", "")) if m else None
lb = SLABS_BY_VALUE.get(m.get("b", "")) if m else None
caption = f"{la.ko_label if la else '?'}+{lb.ko_label if lb else '?'} L{m.get('level', 1)}" if m else "합쳐진 박스"
elif kind == "unknown":
caption = f"미인식 {score:.2f}"
else:
caption = "빈칸"
tk.Label(frame, text=caption, fg="#cfcfcf", bg="#2c1a2a",
font=("TkDefaultFont", 7)).pack(fill="x")
def on_click(_e=None, sid=slot_id):
self._edit(sid)
for w in (frame, lbl):
w.bind("<Button-1>", on_click)
def _edit(self, slot_id: str) -> None:
cell = next((c for c in self.cells if c.slot_id == slot_id), None)
dlg = CellEditor(self.winfo_toplevel(), cell)
self.winfo_toplevel().wait_window(dlg)
if dlg.result is None:
return
# store override, then re-render
self.overrides[slot_id] = {**dlg.result, "score": 1.0}
self._show_cells(self.cells)
def _confirm(self) -> None:
if not self.cells and not self.overrides:
messagebox.showinfo("안내", "먼저 캡처 + 영역 지정을 해주세요.")
return
basket: List[str] = []
for cell in self.cells:
ov = self.overrides.get(cell.slot_id)
eff = self._effective(cell, ov)
if eff["kind"] == "slab" and eff.get("value"):
basket.append(eff["value"])
elif eff["kind"] == "merged":
m = eff.get("merged") or {}
# treat a merged box as having both its component slabs in the basket
if m.get("a"):
basket.append(m["a"])
if m.get("b"):
basket.append(m["b"])
self.on_confirmed(basket)
# ---------- manual input frame ----------
class ManualFrame(ttk.Frame):
def __init__(self, master, on_changed) -> None:
super().__init__(master, padding=4)
self.counts: Dict[str, int] = {s.value: 0 for s in SLABS}
self.count_labels: Dict[str, tk.Label] = {}
self.on_changed = on_changed
self._build()
def _build(self) -> None:
canvas = tk.Canvas(self, highlightthickness=0)
scroll = ttk.Scrollbar(self, orient="vertical", command=canvas.yview)
inner = ttk.Frame(canvas)
inner.bind("<Configure>",
lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
canvas.create_window((0, 0), window=inner, anchor="nw")
canvas.configure(yscrollcommand=scroll.set)
canvas.pack(side="left", fill="both", expand=True)
scroll.pack(side="right", fill="y")
by_tier: Dict[str, List] = {}
for s in SLABS:
by_tier.setdefault(s.tier, []).append(s)
row = 0
for tier in sorted(by_tier, key=lambda t: TIER_ORDER.get(t, 99)):
ttk.Label(inner, text=TIER_LABEL.get(tier, tier),
font=("TkDefaultFont", 10, "bold")) \
.grid(row=row, column=0, columnspan=4, sticky="w", pady=(8, 2))
row += 1
for slab in by_tier[tier]:
ttk.Label(inner, text=slab.ko_label, width=8).grid(row=row, column=0, sticky="w")
ttk.Button(inner, text="", width=2,
command=lambda v=slab.value: self._change(v, -1)).grid(row=row, column=1)
lbl = ttk.Label(inner, text="0", width=3, anchor="center")
lbl.grid(row=row, column=2)
self.count_labels[slab.value] = lbl
ttk.Button(inner, text="+", width=2,
command=lambda v=slab.value: self._change(v, 1)).grid(row=row, column=3)
row += 1
def _change(self, v: str, d: int) -> None:
self.counts[v] = max(0, self.counts.get(v, 0) + d)
self.count_labels[v]["text"] = str(self.counts[v])
self.on_changed()
def clear(self) -> None:
for v in self.counts:
self.counts[v] = 0
self.count_labels[v]["text"] = "0"
self.on_changed()
def basket(self) -> List[str]:
out: List[str] = []
for v, n in self.counts.items():
out.extend([v] * n)
return out
# ---------- main app ----------
class App(tk.Tk): class App(tk.Tk):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self.title("Sephiria Inventory Optimizer") self.title("Sephiria Inventory Optimizer")
self.geometry("1280x800") self.geometry("1320x820")
self.minsize(1000, 600) self.minsize(1100, 640)
# counts: value -> int self.slot_var = tk.IntVar(value=34)
self.counts: Dict[str, int] = {s.value: 0 for s in SLABS} self.basket: List[str] = []
self.count_labels: Dict[str, tk.Label] = {}
self.summary_var = tk.StringVar(value="선택된 석판: 0개") self.summary_var = tk.StringVar(value="선택된 석판: 0개")
self.score_var = tk.StringVar(value="score: -") self.score_var = tk.StringVar(value="score: -")
self.slot_var = tk.IntVar(value=34)
self.solving = False self.solving = False
self.preview_image: ImageTk.PhotoImage | None = None self.preview_image: Optional[ImageTk.PhotoImage] = None
self.last_solution = None self.last_solution = None
self._build() self._build()
# -------------------------------------------------------------- layout
def _build(self) -> None: def _build(self) -> None:
root = ttk.Frame(self, padding=8) root = ttk.Frame(self, padding=8)
root.pack(fill="both", expand=True) root.pack(fill="both", expand=True)
@@ -52,12 +781,14 @@ class App(tk.Tk):
root.columnconfigure(1, weight=3) root.columnconfigure(1, weight=3)
root.rowconfigure(0, weight=1) root.rowconfigure(0, weight=1)
# Left: catalog nb = ttk.Notebook(root)
left = ttk.LabelFrame(root, text="석판 목록 (보유 개수)", padding=6) nb.grid(row=0, column=0, sticky="nsew", padx=(0, 6))
left.grid(row=0, column=0, sticky="nsew", padx=(0, 6)) self.screenshot = ScreenshotFrame(nb, on_confirmed=self._on_screenshot_confirmed)
self._build_catalog(left) nb.add(self.screenshot, text="게임창/스크린샷")
self.manual = ManualFrame(nb, on_changed=self._on_manual_changed)
nb.add(self.manual, text="수동 선택")
self.nb = nb
# Right: controls + preview
right = ttk.Frame(root) right = ttk.Frame(root)
right.grid(row=0, column=1, sticky="nsew") right.grid(row=0, column=1, sticky="nsew")
right.columnconfigure(0, weight=1) right.columnconfigure(0, weight=1)
@@ -66,23 +797,19 @@ class App(tk.Tk):
ctl = ttk.LabelFrame(right, text="옵션", padding=8) ctl = ttk.LabelFrame(right, text="옵션", padding=8)
ctl.grid(row=0, column=0, sticky="ew") ctl.grid(row=0, column=0, sticky="ew")
ttk.Label(ctl, text="슬롯 수").grid(row=0, column=0, sticky="w") ttk.Label(ctl, text="슬롯 수").grid(row=0, column=0, sticky="w")
slot_scale = ttk.Scale( slot_scale = ttk.Scale(ctl, from_=18, to=60, orient="horizontal",
ctl, from_=18, to=60, orient="horizontal", variable=self.slot_var, command=self._on_slot)
variable=self.slot_var, command=self._on_slot_change,
)
slot_scale.grid(row=0, column=1, sticky="ew", padx=6) slot_scale.grid(row=0, column=1, sticky="ew", padx=6)
ctl.columnconfigure(1, weight=1) ctl.columnconfigure(1, weight=1)
self.slot_label = ttk.Label(ctl, text="34") self.slot_label = ttk.Label(ctl, text="34")
self.slot_label.grid(row=0, column=2, sticky="w") self.slot_label.grid(row=0, column=2, sticky="w")
ttk.Button(ctl, text="모두 비우기", command=self._clear).grid( ttk.Button(ctl, text="비우기", command=self._clear_basket).grid(
row=1, column=0, pady=(8, 0), sticky="w" row=1, column=0, pady=(8, 0), sticky="w")
)
self.solve_btn = ttk.Button(ctl, text="최적 배치 계산", command=self._solve) self.solve_btn = ttk.Button(ctl, text="최적 배치 계산", command=self._solve)
self.solve_btn.grid(row=1, column=1, pady=(8, 0), sticky="ew", padx=6) self.solve_btn.grid(row=1, column=1, pady=(8, 0), sticky="ew", padx=6)
ttk.Button(ctl, text="이미지 저장…", command=self._save).grid( ttk.Button(ctl, text="이미지 저장…", command=self._save).grid(
row=1, column=2, pady=(8, 0), sticky="e" row=1, column=2, pady=(8, 0), sticky="e")
)
summary = ttk.Frame(right) summary = ttk.Frame(right)
summary.grid(row=1, column=0, sticky="ew", pady=(6, 0)) summary.grid(row=1, column=0, sticky="ew", pady=(6, 0))
@@ -93,113 +820,48 @@ class App(tk.Tk):
preview_frame.grid(row=2, column=0, sticky="nsew", pady=(6, 0)) preview_frame.grid(row=2, column=0, sticky="nsew", pady=(6, 0))
self.preview = ttk.Label( self.preview = ttk.Label(
preview_frame, anchor="center", preview_frame, anchor="center",
text="좌측에서 석판을 추가하고 '최적 배치 계산'을 눌러주세요.", text="좌측에서 인벤토리를 가져오거나 석판을 추가한 뒤 "
"'최적 배치 계산'을 눌러주세요.",
) )
self.preview.pack(fill="both", expand=True) self.preview.pack(fill="both", expand=True)
def _build_catalog(self, parent: tk.Widget) -> None: def _on_manual_changed(self) -> None:
canvas = tk.Canvas(parent, highlightthickness=0) self.basket = self.manual.basket()
scroll = ttk.Scrollbar(parent, orient="vertical", command=canvas.yview) self.summary_var.set(f"선택된 석판: {len(self.basket)}개 (수동)")
inner = ttk.Frame(canvas)
inner.bind(
"<Configure>",
lambda e: canvas.configure(scrollregion=canvas.bbox("all")),
)
canvas.create_window((0, 0), window=inner, anchor="nw")
canvas.configure(yscrollcommand=scroll.set)
canvas.pack(side="left", fill="both", expand=True)
scroll.pack(side="right", fill="y")
# mousewheel def _on_screenshot_confirmed(self, basket: List[str]) -> None:
def _wheel(e): self.basket = list(basket)
canvas.yview_scroll(int(-e.delta / 120), "units") self.summary_var.set(f"선택된 석판: {len(self.basket)}개 (스크린샷)")
messagebox.showinfo("확정",
f"{len(self.basket)}개를 가져왔습니다. '최적 배치 계산'을 누르세요.")
canvas.bind_all("<MouseWheel>", _wheel) def _on_slot(self, _evt) -> None:
canvas.bind_all("<Button-4>", lambda e: canvas.yview_scroll(-1, "units"))
canvas.bind_all("<Button-5>", lambda e: canvas.yview_scroll(1, "units"))
# group by tier
by_tier: Dict[str, List] = {t: [] for t in TIER_ORDER}
for s in SLABS:
by_tier.setdefault(s.tier, []).append(s)
row = 0
for tier in sorted(by_tier, key=lambda t: TIER_ORDER.get(t, 99)):
slabs = by_tier[tier]
if not slabs:
continue
ttk.Label(
inner,
text=TIER_LABEL.get(tier, tier),
font=("TkDefaultFont", 10, "bold"),
).grid(row=row, column=0, columnspan=4, sticky="w", pady=(8, 2))
row += 1
for slab in slabs:
ttk.Label(inner, text=slab.ko_label, width=8).grid(
row=row, column=0, sticky="w"
)
ttk.Button(
inner, text="", width=2,
command=lambda v=slab.value: self._change_count(v, -1),
).grid(row=row, column=1)
lbl = ttk.Label(inner, text="0", width=3, anchor="center")
lbl.grid(row=row, column=2)
self.count_labels[slab.value] = lbl
ttk.Button(
inner, text="+", width=2,
command=lambda v=slab.value: self._change_count(v, 1),
).grid(row=row, column=3)
row += 1
# -------------------------------------------------------------- handlers
def _change_count(self, value: str, delta: int) -> None:
n = max(0, self.counts.get(value, 0) + delta)
self.counts[value] = n
self.count_labels[value]["text"] = str(n)
self._update_summary()
def _clear(self) -> None:
for v in self.counts:
self.counts[v] = 0
self.count_labels[v]["text"] = "0"
self._update_summary()
def _update_summary(self) -> None:
total = sum(self.counts.values())
self.summary_var.set(f"선택된 석판: {total}")
def _on_slot_change(self, _evt) -> None:
v = int(round(self.slot_var.get())) v = int(round(self.slot_var.get()))
self.slot_var.set(v) self.slot_var.set(v)
self.slot_label["text"] = str(v) self.slot_label["text"] = str(v)
def _expand_basket(self) -> List[str]: def _clear_basket(self) -> None:
basket: List[str] = [] self.manual.clear()
for v, n in self.counts.items(): self.basket = []
basket.extend([v] * n) self.summary_var.set("선택된 석판: 0개")
return basket
def _solve(self) -> None: def _solve(self) -> None:
if self.solving: if self.solving:
return return
basket = self._expand_basket() if not self.basket:
if not basket: messagebox.showinfo("안내",
messagebox.showinfo("안내", "먼저 보유한 석판을 추가세요.") "먼저 인벤토리를 가져오거나 수동 선택으로 석판을 추가해주세요.")
return return
slot_num = int(round(self.slot_var.get())) slot_num = int(round(self.slot_var.get()))
basket = list(self.basket)
if len(basket) > slot_num: if len(basket) > slot_num:
if not messagebox.askyesno( if not messagebox.askyesno("확인",
"확인", f"슬롯({slot_num})보다 석판({len(basket)})이 많습니다. 초과분 무시?"):
f"슬롯({slot_num})보다 석판({len(basket)})이 많습니다.\n"
"초과분은 무시하고 계산할까요?",
):
return return
self.solving = True self.solving = True
self.solve_btn["state"] = "disabled" self.solve_btn["state"] = "disabled"
self.score_var.set("score: 계산 중…") self.score_var.set("score: 계산 중…")
threading.Thread( threading.Thread(target=self._solve_worker, args=(basket, slot_num), daemon=True).start()
target=self._solve_worker, args=(basket, slot_num), daemon=True
).start()
def _solve_worker(self, basket: List[str], slot_num: int) -> None: def _solve_worker(self, basket: List[str], slot_num: int) -> None:
try: try:
@@ -217,7 +879,6 @@ class App(tk.Tk):
def _show_result(self, sol, img: Image.Image) -> None: def _show_result(self, sol, img: Image.Image) -> None:
self.last_solution = sol self.last_solution = sol
# fit preview width
w = max(self.preview.winfo_width(), 600) w = max(self.preview.winfo_width(), 600)
scale = min(1.0, w / img.width) scale = min(1.0, w / img.width)
if scale < 1.0: if scale < 1.0:
@@ -231,8 +892,7 @@ class App(tk.Tk):
messagebox.showinfo("안내", "먼저 계산을 실행하세요.") messagebox.showinfo("안내", "먼저 계산을 실행하세요.")
return return
path = filedialog.asksaveasfilename( path = filedialog.asksaveasfilename(
defaultextension=".png", defaultextension=".png", filetypes=[("PNG", "*.png")],
filetypes=[("PNG", "*.png")],
initialfile="sephiria_layout.png", initialfile="sephiria_layout.png",
) )
if not path: if not path:

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)) 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]: def _fetch_image(rel_or_url: str, timeout: float = 10.0) -> Optional[Image.Image]:
"""Return a PIL Image for the slab, downloading + caching if needed. """Fetch an image from the CDN. Accepts a full URL or a 'slabs/foo.png' path."""
Returns None if download fails — caller draws a placeholder.
"""
_ensure_cache_dir() _ensure_cache_dir()
path = _local_path(slab_image) path = _local_path(rel_or_url)
if os.path.exists(path): if os.path.exists(path):
try: try:
return Image.open(path).convert("RGBA") return Image.open(path).convert("RGBA")
except Exception: except Exception:
pass pass
try: try:
import requests # lazy import; allow renderer use without network if cached import requests
url = rel_or_url if rel_or_url.startswith("http") else f"{CDN_BASE}/{rel_or_url.lstrip('/')}"
url = f"{CDN_BASE}/{slab_image.lstrip('/')}"
r = requests.get(url, timeout=timeout) r = requests.get(url, timeout=timeout)
if r.status_code != 200: if r.status_code != 200:
return None return None
@@ -88,6 +84,16 @@ def fetch_slab_image(slab_image: str, timeout: float = 10.0) -> Optional[Image.I
return None 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 # 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 The old API exposed `Recognition` (slot_id, value, score) and `recognize()`
and the inventory bounding box, we divide it into a grid and compare each cell returning slabs only. Existing CLI code (`__main__.py`) and tests use that
against every slab template (resized to the cell). Mean absolute error in RGB surface, so we keep it working by delegating to recognizer.py.
picks the best match; cells above a threshold are treated as empty.
This is a best-effort fallback. Accuracy depends heavily on the screenshot New code should call `recognizer.recognize_image()` / `recognize_file()`
resolution and the slab images matching the in-game render style. The CDN directly for richer (kind, rotation, artifact) results.
images are the same pixel-art assets the game uses, so accuracy is usually
fine when the screenshot is sharp.
""" """
from __future__ import annotations from __future__ import annotations
import os
from dataclasses import dataclass from dataclasses import dataclass
from typing import List, Optional, Tuple from typing import List, Optional, Tuple
from PIL import Image from .recognizer import recognize_file
from .renderer import fetch_slab_image
from .slabs import GRID_COLS, SLABS, generate_grid_config
@dataclass @dataclass
class Recognition: class Recognition:
slot_id: str slot_id: str
value: Optional[str] # None = empty value: Optional[str] # slab value, or None if empty/unknown/artifact
score: float # lower = better match score: float # NCC score in [-1, 1]; higher = better
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")
def recognize( def recognize(
screenshot_path: str, screenshot_path: str,
bbox: Tuple[int, int, int, int], bbox: Tuple[int, int, int, int],
slot_num: int = 34, slot_num: int = 34,
empty_threshold: float = 35.0, empty_threshold: float = 35.0, # ignored; kept for arg-compat
) -> List[Recognition]: ) -> List[Recognition]:
"""Recognize slabs in the inventory area of a screenshot. """Recognize slabs in the inventory area of a screenshot (slabs only)."""
cells = recognize_file(screenshot_path, bbox, slot_num=slot_num)
Args: out: List[Recognition] = []
screenshot_path: Path to the game screenshot (PNG/JPG). for c in cells:
bbox: (left, top, right, bottom) pixel coords of the inventory grid. v = c.value if c.kind == "slab" else None
Must enclose only the slot grid, not the surrounding UI. out.append(Recognition(c.slot_id, v, c.score))
slot_num: Total slot count (18..60). Used to compute row layout. return out
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
def recognized_values(recognitions: List[Recognition]) -> List[str]: 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] 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