5 Commits

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
4 changed files with 295 additions and 15 deletions

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

114
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 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
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(_main())

View File

@@ -398,7 +398,7 @@ class ScreenshotFrame(ttk.Frame):
def __init__(self, master, on_confirmed) -> None: def __init__(self, master, on_confirmed) -> None:
super().__init__(master, padding=6) super().__init__(master, padding=6)
self.on_confirmed = on_confirmed self.on_confirmed = on_confirmed
self.slot_var = master.master.slot_var # type: ignore[attr-defined] self.slot_var = master.winfo_toplevel().slot_var # type: ignore[attr-defined]
self.image: Optional[Image.Image] = None self.image: Optional[Image.Image] = None
self.bbox: Optional[Tuple[int, int, int, int]] = None self.bbox: Optional[Tuple[int, int, int, int]] = None
self.cells: List[CellResult] = [] self.cells: List[CellResult] = []
@@ -409,11 +409,12 @@ class ScreenshotFrame(ttk.Frame):
ctl = ttk.Frame(self) ctl = ttk.Frame(self)
ctl.pack(fill="x") ctl.pack(fill="x")
ttk.Button(ctl, text="🎮 게임 창 선택…", command=self._pick_window).pack(side="left") 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._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._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._reselect_bbox).pack(side="left", padx=4)
ttk.Button(ctl, text="✅ 이 구성으로 계산", command=self._confirm).pack(side="right") 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.status = ttk.Label(
self, self,
@@ -506,6 +507,8 @@ class ScreenshotFrame(ttk.Frame):
# First call may download a lot — keep artifacts on (user wants them) # First call may download a lot — keep artifacts on (user wants them)
warm_templates(include_artifacts=True) warm_templates(include_artifacts=True)
self._templates_warmed = True self._templates_warmed = True
from .recognizer import load_stats
self._tpl_stats = load_stats()
slot_num = int(round(self.slot_var.get())) slot_num = int(round(self.slot_var.get()))
cells = recognize_image( cells = recognize_image(
self.image, self.bbox, self.image, self.bbox,
@@ -515,6 +518,27 @@ class ScreenshotFrame(ttk.Frame):
except Exception as e: except Exception as e:
self.after(0, lambda: messagebox.showerror("인식 실패", str(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: def _show_cells(self, cells: List[CellResult]) -> None:
self.cells = cells self.cells = cells
for c in self.preview_inner.winfo_children(): for c in self.preview_inner.winfo_children():
@@ -541,10 +565,22 @@ class ScreenshotFrame(ttk.Frame):
kind_counts[effective["kind"]] = kind_counts.get(effective["kind"], 0) + 1 kind_counts[effective["kind"]] = kind_counts.get(effective["kind"], 0) + 1
self._make_cell(y, x, slot_id, effective) 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 = ( msg = (
f"석판 {kind_counts.get('slab', 0)} · 아티팩트 {kind_counts.get('artifact', 0)} · " 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('empty', 0)} · 합쳐진(?) {kind_counts.get('merged', 0)} · "
f"미인식 {kind_counts.get('unknown', 0)}\n" f"미인식 {kind_counts.get('unknown', 0)}"
f"{tpl_line}\n"
"셀을 클릭하면 종류/값을 교정할 수 있습니다. 끝나면 [이 구성으로 계산]." "셀을 클릭하면 종류/값을 교정할 수 있습니다. 끝나면 [이 구성으로 계산]."
) )
self.status["text"] = msg self.status["text"] = msg

View File

@@ -80,17 +80,24 @@ _TEMPLATE_CACHE: List[_Template] = []
_CACHE_BUILT = False _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]: def _build_templates(*, include_artifacts: bool = True) -> List[_Template]:
"""Build (and cache) the full template list. Lazy because download is slow.""" """Build (and cache) the full template list. Lazy because download is slow."""
global _CACHE_BUILT global _CACHE_BUILT
if _CACHE_BUILT and _TEMPLATE_CACHE: if _CACHE_BUILT and _TEMPLATE_CACHE:
return _TEMPLATE_CACHE return _TEMPLATE_CACHE
out: List[_Template] = [] out: List[_Template] = []
s_ok = s_fail = a_ok = a_fail = 0
# Slabs: 4 rotations for rotatable, 1 otherwise # Slabs: 4 rotations for rotatable, 1 otherwise
for s in SLABS: for s in SLABS:
img = fetch_slab_image(s.image) img = fetch_slab_image(s.image)
if img is None: if img is None:
s_fail += 1
continue continue
s_ok += 1
base = _on_dark(img) base = _on_dark(img)
rotations = (0, 1, 2, 3) if s.rotate else (0,) rotations = (0, 1, 2, 3) if s.rotate else (0,)
for r in rotations: for r in rotations:
@@ -100,9 +107,13 @@ def _build_templates(*, include_artifacts: bool = True) -> List[_Template]:
for a in ARTIFACTS: for a in ARTIFACTS:
img = fetch_artifact_image(a.image) img = fetch_artifact_image(a.image)
if img is None: if img is None:
a_fail += 1
continue continue
a_ok += 1
base = _on_dark(img) base = _on_dark(img)
out.append(_Template("artifact", a.value, 0, _to_feat(base))) 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.clear()
_TEMPLATE_CACHE.extend(out) _TEMPLATE_CACHE.extend(out)
_CACHE_BUILT = True _CACHE_BUILT = True
@@ -117,24 +128,51 @@ def warm_templates(*, include_artifacts: bool = True) -> int:
return len(_build_templates(include_artifacts=include_artifacts)) 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 ---------- # ---------- cell classification ----------
def _is_empty(cell: Image.Image) -> bool: def _is_empty(cell: Image.Image) -> bool:
"""Heuristic: empty slots are dark and ~uniform.""" """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) g = np.asarray(cell.convert("L"), dtype=np.float32)
return bool(g.mean() < 60.0 and g.std() < 14.0) 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( def _classify(
cell: Image.Image, cell: Image.Image,
templates: List[_Template], templates: List[_Template],
*, *,
min_score: float = 0.55, min_score: float = 0.35,
) -> Tuple[str, Optional[str], int, float]: ) -> Tuple[str, Optional[str], int, float]:
"""Return (kind, value, rotation, score).""" """Return (kind, value, rotation, score)."""
if _is_empty(cell): if _is_empty(cell):
return "empty", None, 0, 1.0 return "empty", None, 0, 1.0
feat = _to_feat(cell) inner = _inset(cell)
feat = _to_feat(inner)
# Stack template features into a matrix for one big dot-product # Stack template features into a matrix for one big dot-product
if not templates: if not templates:
return "unknown", None, 0, 0.0 return "unknown", None, 0, 0.0
@@ -148,6 +186,27 @@ def _classify(
return t.kind, t.value, t.rotation, best 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 ---------- # ---------- public API ----------
def recognize_image( def recognize_image(
@@ -156,7 +215,7 @@ def recognize_image(
*, *,
slot_num: int = 34, slot_num: int = 34,
include_artifacts: bool = True, include_artifacts: bool = True,
min_score: float = 0.55, min_score: float = 0.35,
) -> List[CellResult]: ) -> List[CellResult]:
"""Slice img[bbox] into a 6-col grid and classify each cell. """Slice img[bbox] into a 6-col grid and classify each cell.
@@ -184,6 +243,59 @@ def recognize_image(
return out 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( def recognize_file(
path: str, path: str,
bbox: Tuple[int, int, int, int], bbox: Tuple[int, int, int, int],