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.
918 lines
37 KiB
Python
918 lines
37 KiB
Python
"""Tkinter GUI for the Sephiria inventory optimizer.
|
||
|
||
Two input modes via Notebook:
|
||
- "게임창 캡처": list visible Windows top-level windows, pick the Sephiria
|
||
one, capture its full client area (PrintWindow, works even when not
|
||
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.
|
||
|
||
Both modes feed a basket → solver → renderer pipeline shown on the right.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
import threading
|
||
import tkinter as tk
|
||
from tkinter import filedialog, messagebox, ttk
|
||
from typing import Dict, List, Optional, Tuple
|
||
|
||
from PIL import Image, ImageTk
|
||
|
||
from .artifacts import ARTIFACTS, ARTIFACTS_BY_VALUE
|
||
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
|
||
|
||
|
||
# ---------- 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):
|
||
def __init__(self) -> None:
|
||
super().__init__()
|
||
self.title("Sephiria Inventory Optimizer")
|
||
self.geometry("1320x820")
|
||
self.minsize(1100, 640)
|
||
|
||
self.slot_var = tk.IntVar(value=34)
|
||
self.basket: List[str] = []
|
||
self.summary_var = tk.StringVar(value="선택된 석판: 0개")
|
||
self.score_var = tk.StringVar(value="score: -")
|
||
self.solving = False
|
||
self.preview_image: Optional[ImageTk.PhotoImage] = None
|
||
self.last_solution = None
|
||
self._build()
|
||
|
||
def _build(self) -> None:
|
||
root = ttk.Frame(self, padding=8)
|
||
root.pack(fill="both", expand=True)
|
||
root.columnconfigure(0, weight=2)
|
||
root.columnconfigure(1, weight=3)
|
||
root.rowconfigure(0, weight=1)
|
||
|
||
nb = ttk.Notebook(root)
|
||
nb.grid(row=0, column=0, sticky="nsew", padx=(0, 6))
|
||
self.screenshot = ScreenshotFrame(nb, on_confirmed=self._on_screenshot_confirmed)
|
||
nb.add(self.screenshot, text="게임창/스크린샷")
|
||
self.manual = ManualFrame(nb, on_changed=self._on_manual_changed)
|
||
nb.add(self.manual, text="수동 선택")
|
||
self.nb = nb
|
||
|
||
right = ttk.Frame(root)
|
||
right.grid(row=0, column=1, sticky="nsew")
|
||
right.columnconfigure(0, weight=1)
|
||
right.rowconfigure(2, weight=1)
|
||
|
||
ctl = ttk.LabelFrame(right, text="옵션", padding=8)
|
||
ctl.grid(row=0, column=0, sticky="ew")
|
||
ttk.Label(ctl, text="슬롯 수").grid(row=0, column=0, sticky="w")
|
||
slot_scale = ttk.Scale(ctl, from_=18, to=60, orient="horizontal",
|
||
variable=self.slot_var, command=self._on_slot)
|
||
slot_scale.grid(row=0, column=1, sticky="ew", padx=6)
|
||
ctl.columnconfigure(1, weight=1)
|
||
self.slot_label = ttk.Label(ctl, text="34")
|
||
self.slot_label.grid(row=0, column=2, sticky="w")
|
||
|
||
ttk.Button(ctl, text="비우기", command=self._clear_basket).grid(
|
||
row=1, column=0, pady=(8, 0), sticky="w")
|
||
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)
|
||
ttk.Button(ctl, text="이미지 저장…", command=self._save).grid(
|
||
row=1, column=2, pady=(8, 0), sticky="e")
|
||
|
||
summary = ttk.Frame(right)
|
||
summary.grid(row=1, column=0, sticky="ew", pady=(6, 0))
|
||
ttk.Label(summary, textvariable=self.summary_var).pack(side="left")
|
||
ttk.Label(summary, textvariable=self.score_var).pack(side="right")
|
||
|
||
preview_frame = ttk.LabelFrame(right, text="결과 미리보기", padding=4)
|
||
preview_frame.grid(row=2, column=0, sticky="nsew", pady=(6, 0))
|
||
self.preview = ttk.Label(
|
||
preview_frame, anchor="center",
|
||
text="좌측에서 인벤토리를 가져오거나 석판을 추가한 뒤 "
|
||
"'최적 배치 계산'을 눌러주세요.",
|
||
)
|
||
self.preview.pack(fill="both", expand=True)
|
||
|
||
def _on_manual_changed(self) -> None:
|
||
self.basket = self.manual.basket()
|
||
self.summary_var.set(f"선택된 석판: {len(self.basket)}개 (수동)")
|
||
|
||
def _on_screenshot_confirmed(self, basket: List[str]) -> None:
|
||
self.basket = list(basket)
|
||
self.summary_var.set(f"선택된 석판: {len(self.basket)}개 (스크린샷)")
|
||
messagebox.showinfo("확정",
|
||
f"{len(self.basket)}개를 가져왔습니다. '최적 배치 계산'을 누르세요.")
|
||
|
||
def _on_slot(self, _evt) -> None:
|
||
v = int(round(self.slot_var.get()))
|
||
self.slot_var.set(v)
|
||
self.slot_label["text"] = str(v)
|
||
|
||
def _clear_basket(self) -> None:
|
||
self.manual.clear()
|
||
self.basket = []
|
||
self.summary_var.set("선택된 석판: 0개")
|
||
|
||
def _solve(self) -> None:
|
||
if self.solving:
|
||
return
|
||
if not self.basket:
|
||
messagebox.showinfo("안내",
|
||
"먼저 인벤토리를 가져오거나 수동 선택으로 석판을 추가해주세요.")
|
||
return
|
||
slot_num = int(round(self.slot_var.get()))
|
||
basket = list(self.basket)
|
||
if len(basket) > slot_num:
|
||
if not messagebox.askyesno("확인",
|
||
f"슬롯({slot_num})보다 석판({len(basket)})이 많습니다. 초과분 무시?"):
|
||
return
|
||
self.solving = True
|
||
self.solve_btn["state"] = "disabled"
|
||
self.score_var.set("score: 계산 중…")
|
||
threading.Thread(target=self._solve_worker, args=(basket, slot_num), daemon=True).start()
|
||
|
||
def _solve_worker(self, basket: List[str], slot_num: int) -> None:
|
||
try:
|
||
sol = solve(basket, slot_num=slot_num, time_limit=4.0)
|
||
img = render_solution(sol, download=True)
|
||
self.after(0, self._show_result, sol, img)
|
||
except Exception as e:
|
||
self.after(0, lambda: messagebox.showerror("오류", str(e)))
|
||
finally:
|
||
self.after(0, self._solve_done)
|
||
|
||
def _solve_done(self) -> None:
|
||
self.solving = False
|
||
self.solve_btn["state"] = "normal"
|
||
|
||
def _show_result(self, sol, img: Image.Image) -> None:
|
||
self.last_solution = sol
|
||
w = max(self.preview.winfo_width(), 600)
|
||
scale = min(1.0, w / img.width)
|
||
if scale < 1.0:
|
||
img = img.resize((int(img.width * scale), int(img.height * scale)))
|
||
self.preview_image = ImageTk.PhotoImage(img)
|
||
self.preview.configure(image=self.preview_image, text="")
|
||
self.score_var.set(f"score: {sol.score}")
|
||
|
||
def _save(self) -> None:
|
||
if self.last_solution is None:
|
||
messagebox.showinfo("안내", "먼저 계산을 실행하세요.")
|
||
return
|
||
path = filedialog.asksaveasfilename(
|
||
defaultextension=".png", filetypes=[("PNG", "*.png")],
|
||
initialfile="sephiria_layout.png",
|
||
)
|
||
if not path:
|
||
return
|
||
img = render_solution(self.last_solution, download=True)
|
||
img.save(path)
|
||
messagebox.showinfo("완료", f"저장됨: {path}")
|
||
|
||
|
||
def main() -> int:
|
||
try:
|
||
app = App()
|
||
except tk.TclError as e:
|
||
print(f"GUI를 띄울 수 없습니다 ({e}). CLI 모드를 시도하세요:", file=sys.stderr)
|
||
print(" python -m sephiria_inv --help", file=sys.stderr)
|
||
return 1
|
||
app.mainloop()
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|