diff --git a/sephiria_inv/gui.py b/sephiria_inv/gui.py index edbaffa..118df3a 100644 --- a/sephiria_inv/gui.py +++ b/sephiria_inv/gui.py @@ -413,6 +413,7 @@ class ScreenshotFrame(ttk.Frame): 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( @@ -506,6 +507,8 @@ class ScreenshotFrame(ttk.Frame): # 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, @@ -515,6 +518,27 @@ class ScreenshotFrame(ttk.Frame): 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(): @@ -541,10 +565,22 @@ class ScreenshotFrame(ttk.Frame): 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)}\n" + f"미인식 {kind_counts.get('unknown', 0)}" + f"{tpl_line}\n" "셀을 클릭하면 종류/값을 교정할 수 있습니다. 끝나면 [이 구성으로 계산]." ) self.status["text"] = msg diff --git a/sephiria_inv/recognizer.py b/sephiria_inv/recognizer.py index 4a5db94..11e0f31 100644 --- a/sephiria_inv/recognizer.py +++ b/sephiria_inv/recognizer.py @@ -80,17 +80,24 @@ _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: @@ -100,9 +107,13 @@ def _build_templates(*, include_artifacts: bool = True) -> List[_Template]: 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 @@ -117,24 +128,51 @@ def warm_templates(*, include_artifacts: bool = True) -> int: 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 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) - 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( cell: Image.Image, templates: List[_Template], *, - min_score: float = 0.55, + 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 - feat = _to_feat(cell) + 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 @@ -148,6 +186,27 @@ def _classify( 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( @@ -156,7 +215,7 @@ def recognize_image( *, slot_num: int = 34, include_artifacts: bool = True, - min_score: float = 0.55, + min_score: float = 0.35, ) -> List[CellResult]: """Slice img[bbox] into a 6-col grid and classify each cell. @@ -184,6 +243,59 @@ def recognize_image( 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],