From e388c965bc605952515dbc470453189f9c270085 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 22:31:27 +0900 Subject: [PATCH] Add screenshot-driven inventory recognition (v0.2.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 18 +- requirements.txt | 1 + sephiria_inv/capture.py | 43 +++ sephiria_inv/gui.py | 597 ++++++++++++++++++++++++++++++++-------- 4 files changed, 544 insertions(+), 115 deletions(-) create mode 100644 sephiria_inv/capture.py diff --git a/README.md b/README.md index 1d45379..c2df079 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,16 @@ python -m sephiria_inv ``` +GUI 는 두 가지 탭으로 구성됩니다. + +**스크린샷 탭 (권장)** +1. 게임을 인벤토리 화면 상태로 띄워둔다 +2. `화면 캡처` 버튼을 누른다 (또는 `이미지 열기…` 로 미리 찍어둔 PNG) +3. 인벤토리 격자의 좌상단/우하단을 한 번씩 클릭해 영역을 지정한다 +4. 자동으로 템플릿 매칭이 돌면서 각 셀의 석판을 인식한다 +5. 오인식된 셀이 있으면 클릭해서 직접 교체하고 `이 구성으로 계산` 을 누른다 + +**수동 선택 탭** 좌측 카탈로그에서 보유한 석판 옆 `+` 버튼으로 개수를 올리고 `최적 배치 계산`을 누르면 됩니다. 결과 이미지는 `이미지 저장…` 으로 PNG 저장이 가능합니다. @@ -44,15 +54,15 @@ python -m sephiria_inv --cli \ -s harvesting:2 -s binary_star -s thorn -s sheen -s base \ --slots 24 --seed 7 -o layout.png -# 스크린샷에서 인식 (베타) +# 스크린샷에서 인식 (CLI - bbox 수동 지정) python -m sephiria_inv --cli \ --screenshot ./game.png --bbox 320,180,1024,720 \ --slots 34 -o layout.png ``` -`--bbox` 는 인벤토리 격자 영역의 픽셀 좌표(left,top,right,bottom)입니다. -정확도는 스크린샷 해상도/UI 스타일에 따라 다릅니다 — 잘못 인식되는 셀이 -있으면 GUI에서 보정해 주세요. +CLI 에서는 `--bbox left,top,right,bottom` 으로 격자 영역을 직접 줘야 합니다. +GUI 에서는 두 번 클릭으로 같은 일을 할 수 있습니다. 인식 정확도는 스크린샷 +해상도/UI 스타일에 따라 다르므로 잘못된 셀은 GUI 에서 클릭해 교정하세요. ## 포터블 EXE 빌드 diff --git a/requirements.txt b/requirements.txt index 350694f..8f5ff69 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ Pillow>=9.0 requests>=2.25 +mss>=6.0 diff --git a/sephiria_inv/capture.py b/sephiria_inv/capture.py new file mode 100644 index 0000000..cdd1f46 --- /dev/null +++ b/sephiria_inv/capture.py @@ -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 diff --git a/sephiria_inv/gui.py b/sephiria_inv/gui.py index 1ab6a4f..296aab4 100644 --- a/sephiria_inv/gui.py +++ b/sephiria_inv/gui.py @@ -1,50 +1,478 @@ -"""Tkinter GUI for picking slabs and rendering the optimal layout. +"""Tkinter GUI for the Sephiria inventory optimizer. -Layout (left-to-right): - 1. Tier-grouped slab catalog with [+] [−] count controls - 2. Current basket summary - 3. Slot count slider + Solve button + result preview +The main window has a notebook with two input modes: + - "수동": pick each slab and its count from the tier-grouped catalog. + - "스크린샷": grab the current screen (or load a PNG), click two corners of + the inventory area, and let the app recognize the slabs by template + matching against the cached CDN images. Mis-recognized cells can be + edited inline before solving. -The preview is rendered to a temp PNG via renderer and shown via PhotoImage. +Both modes feed into the same basket → solver → renderer pipeline on the right. """ from __future__ import annotations import os import sys -import tempfile import threading import tkinter as tk -from tkinter import filedialog, messagebox, ttk -from typing import Dict, List +from tkinter import filedialog, messagebox, simpledialog, ttk +from typing import Dict, List, Optional from PIL import Image, ImageTk -from .renderer import render_solution +from .renderer import fetch_slab_image, render_solution from .slabs import SLABS, SLABS_BY_VALUE, TIER_LABEL, TIER_ORDER from .solver import solve +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _slab_thumb(value: str, size: int = 48) -> Optional[ImageTk.PhotoImage]: + """Return a Tk image for the given slab (downloaded + cached).""" + slab = SLABS_BY_VALUE.get(value) + if not slab: + return None + img = fetch_slab_image(slab.image) + if img is None: + return None + img = img.resize((size, size)) + return ImageTk.PhotoImage(img) + + +# --------------------------------------------------------------------------- +# Crop dialog (two-click bbox) +# --------------------------------------------------------------------------- + + +class CropDialog(tk.Toplevel): + """Show an image and let the user click two corners of the inventory area.""" + + 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] = None + self._clicks: List[tuple] = [] + + # scale to fit + w, h = img.size + self.scale = min(1.0, self.MAX_DISPLAY / max(w, h)) + disp_w, disp_h = int(w * self.scale), int(h * self.scale) + self.disp_img = img.resize((disp_w, disp_h)) + self.tk_img = ImageTk.PhotoImage(self.disp_img) + + info = ttk.Label( + self, + text="인벤토리 그리드의 좌상단을 먼저 클릭하고, " + "우하단을 다시 클릭하세요. (그리드 바깥의 UI 프레임은 빼고)", + ) + info.pack(padx=8, pady=(8, 4)) + + self.canvas = tk.Canvas( + self, width=disp_w, height=disp_h, highlightthickness=0 + ) + self.canvas.pack(padx=8, pady=4) + self.canvas.create_image(0, 0, anchor="nw", image=self.tk_img) + self.canvas.bind("", self._on_click) + + btns = ttk.Frame(self) + btns.pack(fill="x", padx=8, pady=(4, 8)) + ttk.Button(btns, text="처음부터 다시", command=self._reset).pack(side="left") + ttk.Button(btns, text="취소", command=self._cancel).pack(side="right") + + self.protocol("WM_DELETE_WINDOW", self._cancel) + self.grab_set() + self.focus_set() + + def _on_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 + ) + # convert back to original-image coords + 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() + + +# --------------------------------------------------------------------------- +# Slab picker dialog (used by editable recognition grid) +# --------------------------------------------------------------------------- + + +class SlabPicker(tk.Toplevel): + """Modal slab picker — returns value via .selected, None if cancelled.""" + + def __init__(self, parent: tk.Tk, current: Optional[str] = None) -> None: + super().__init__(parent) + self.title("석판 선택") + self.transient(parent) + self.selected: Optional[str] = current + + wrap = ttk.Frame(self, padding=8) + wrap.pack(fill="both", expand=True) + ttk.Label(wrap, text="석판을 선택하세요. (빈칸으로 두려면 '비우기')").pack( + anchor="w" + ) + + canvas = tk.Canvas(wrap, height=400, highlightthickness=0) + scroll = ttk.Scrollbar(wrap, orient="vertical", command=canvas.yview) + inner = ttk.Frame(canvas) + inner.bind("", + 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") + + for tier in sorted({s.tier for s in SLABS}, 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)) + row = ttk.Frame(inner) + row.pack(fill="x") + col = 0 + for s in SLABS: + if s.tier != tier: + continue + b = ttk.Button(row, text=s.ko_label, width=8, + command=lambda v=s.value: self._pick(v)) + b.grid(row=0, column=col % 6, sticky="w", padx=2, pady=2) + if col % 6 == 5: + row = ttk.Frame(inner) + row.pack(fill="x") + col = 0 + else: + col += 1 + + btns = ttk.Frame(self) + btns.pack(fill="x", padx=8, pady=8) + ttk.Button(btns, text="비우기 (빈칸)", + command=lambda: self._pick("")).pack(side="left") + ttk.Button(btns, text="취소", command=self.destroy).pack(side="right") + + self.grab_set() + self.focus_set() + + def _pick(self, value: str) -> None: + self.selected = value if value else None + self.destroy() + + +# --------------------------------------------------------------------------- +# Screenshot input frame +# --------------------------------------------------------------------------- + + +class ScreenshotFrame(ttk.Frame): + """Capture → crop → recognize → editable preview → confirm.""" + + def __init__(self, master, on_confirmed) -> None: + super().__init__(master, padding=6) + self.on_confirmed = on_confirmed + self.slot_var = master.master.slot_var # type: ignore[attr-defined] + self.image: Optional[Image.Image] = None + self.bbox: Optional[tuple] = None + self.recognitions: List["Recognition"] = [] # noqa: F821 (forward ref) + self._thumbs: List[ImageTk.PhotoImage] = [] # keep refs + + controls = ttk.Frame(self) + controls.pack(fill="x") + ttk.Button(controls, text="🖥 현재 화면 캡처", + command=self._capture).pack(side="left") + ttk.Button(controls, text="📂 스크린샷 파일 열기…", + command=self._open_file).pack(side="left", padx=4) + ttk.Button(controls, text="🔁 다시 영역 지정", + command=self._reselect_bbox).pack(side="left", padx=4) + ttk.Button(controls, text="✅ 이 목록으로 솔버 실행", + command=self._confirm).pack(side="right") + + self.status = ttk.Label( + self, + text="화면을 캡처하거나 PNG 파일을 열면 인벤토리 영역을 지정한 뒤 " + "자동으로 석판을 인식합니다.", + wraplength=460, justify="left", + ) + self.status.pack(fill="x", pady=(6, 4)) + + # editable preview grid (scrollable) + self.preview_canvas = tk.Canvas(self, height=420, 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( + "", + lambda e: self.preview_canvas.configure( + scrollregion=self.preview_canvas.bbox("all") + ), + ) + + # ----------------------------------------------- handlers + def _capture(self) -> None: + try: + from .capture import capture_screen + self.update_idletasks() + top = self.winfo_toplevel() + top.withdraw() + top.update() + self.image = capture_screen(monitor=1) + top.deiconify() + self._pick_bbox_and_recognize() + except Exception as e: + messagebox.showerror("캡처 실패", str(e)) + self.winfo_toplevel().deiconify() + + def _open_file(self) -> None: + path = filedialog.askopenfilename( + title="스크린샷 PNG 선택", + filetypes=[("Images", "*.png *.jpg *.jpeg *.webp"), ("All files", "*.*")], + ) + 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() + + 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.status["text"] = "석판 인식 중…" + self.update_idletasks() + threading.Thread(target=self._recognize_thread, daemon=True).start() + + def _recognize_thread(self) -> None: + try: + from .screenshot import recognize + slot_num = int(round(self.slot_var.get())) + # save image to temp + call recognizer on file path (recognize expects path) + import tempfile + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: + self.image.save(f.name) # type: ignore[union-attr] + tmp = f.name + try: + recs = recognize(tmp, self.bbox, slot_num=slot_num) + finally: + try: + os.unlink(tmp) + except OSError: + pass + self.after(0, self._show_recognitions, recs) + except Exception as e: + self.after(0, lambda: messagebox.showerror("인식 실패", str(e))) + + def _show_recognitions(self, recs: list) -> None: + self.recognitions = recs + for child in self.preview_inner.winfo_children(): + child.destroy() + self._thumbs.clear() + + # group into 6-wide grid + from .slabs import GRID_COLS + slot_to_rec = {r.slot_id: r for r in recs} + slot_num = int(round(self.slot_var.get())) + from .slabs import generate_grid_config + grid = generate_grid_config(slot_num) + for row_cfg in grid: + y = row_cfg["rows"] + for x in range(GRID_COLS): + if x >= row_cfg["cols"]: + # filler + blank = ttk.Frame(self.preview_inner, width=64, height=64) + blank.grid(row=y, column=x, padx=2, pady=2) + continue + slot_id = f"{y}-{x}" + rec = slot_to_rec.get(slot_id) + value = rec.value if rec else None + self._make_cell(y, x, slot_id, value, rec) + + recognised = sum(1 for r in recs if r.value) + self.status["text"] = ( + f"인식된 석판 {recognised}개 / 슬롯 {len(recs)}개. " + "잘못 인식된 칸은 셀을 클릭해서 수정하세요." + ) + + def _make_cell(self, y: int, x: int, slot_id: str, value: Optional[str], + rec) -> None: + frame = tk.Frame( + self.preview_inner, bd=1, relief="solid", width=72, height=78, + bg="#2c1a2a", + ) + frame.grid(row=y, column=x, padx=2, pady=2) + frame.grid_propagate(False) + score_txt = f"{rec.score:.0f}" if rec else "-" + if value: + thumb = _slab_thumb(value, size=48) + if thumb is not None: + self._thumbs.append(thumb) + lbl = tk.Label(frame, image=thumb, bg="#2c1a2a") + else: + slab = SLABS_BY_VALUE.get(value) + lbl = tk.Label(frame, text=slab.ko_label if slab else value, + fg="#fff", bg="#2c1a2a") + else: + lbl = tk.Label(frame, text="(빈칸)", fg="#888", bg="#2c1a2a") + lbl.pack(expand=True) + slab = SLABS_BY_VALUE.get(value) if value else None + cap = tk.Label( + frame, + text=f"{slab.ko_label if slab else '—'} · {score_txt}", + fg="#cfcfcf", bg="#2c1a2a", font=("TkDefaultFont", 8), + ) + cap.pack(fill="x") + + def click(_e=None, sid=slot_id): + self._edit_cell(sid) + for w in (frame, lbl, cap): + w.bind("", click) + + def _edit_cell(self, slot_id: str) -> None: + cur: Optional[str] = None + for r in self.recognitions: + if r.slot_id == slot_id: + cur = r.value + break + dlg = SlabPicker(self.winfo_toplevel(), current=cur) + self.winfo_toplevel().wait_window(dlg) + # apply + for r in self.recognitions: + if r.slot_id == slot_id: + r.value = dlg.selected + break + self._show_recognitions(self.recognitions) + + def _confirm(self) -> None: + if not self.recognitions: + messagebox.showinfo("안내", "먼저 화면을 캡처하고 영역을 지정하세요.") + return + basket = [r.value for r in self.recognitions if r.value] + self.on_confirmed(basket) + + +# --------------------------------------------------------------------------- +# Manual input frame (tier-grouped +/-) +# --------------------------------------------------------------------------- + + +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("", + 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("1280x800") - self.minsize(1000, 600) + self.geometry("1320x820") + self.minsize(1100, 640) - # counts: value -> int - self.counts: Dict[str, int] = {s.value: 0 for s in SLABS} - self.count_labels: Dict[str, tk.Label] = {} + 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.slot_var = tk.IntVar(value=34) self.solving = False - self.preview_image: ImageTk.PhotoImage | None = None + self.preview_image: Optional[ImageTk.PhotoImage] = None self.last_solution = None self._build() - # -------------------------------------------------------------- layout def _build(self) -> None: root = ttk.Frame(self, padding=8) root.pack(fill="both", expand=True) @@ -52,12 +480,16 @@ class App(tk.Tk): root.columnconfigure(1, weight=3) root.rowconfigure(0, weight=1) - # Left: catalog - left = ttk.LabelFrame(root, text="석판 목록 (보유 개수)", padding=6) - left.grid(row=0, column=0, sticky="nsew", padx=(0, 6)) - self._build_catalog(left) + # LEFT: notebook with manual + screenshot input + nb = ttk.Notebook(root) + nb.grid(row=0, column=0, sticky="nsew", padx=(0, 6)) + self.manual = ManualFrame(nb, on_changed=self._on_manual_changed) + nb.add(self.manual, text="수동 선택") + self.screenshot = ScreenshotFrame(nb, on_confirmed=self._on_screenshot_confirmed) + nb.add(self.screenshot, text="스크린샷") + self.nb = nb - # Right: controls + preview + # RIGHT right = ttk.Frame(root) right.grid(row=0, column=1, sticky="nsew") right.columnconfigure(0, weight=1) @@ -66,16 +498,14 @@ class App(tk.Tk): 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_change, - ) + 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).grid( + 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) @@ -93,100 +523,46 @@ class App(tk.Tk): preview_frame.grid(row=2, column=0, sticky="nsew", pady=(6, 0)) self.preview = ttk.Label( preview_frame, anchor="center", - text="좌측에서 석판을 추가하고 '최적 배치 계산'을 눌러주세요.", + text="좌측에서 석판을 추가하거나 스크린샷을 분석한 뒤 " + "'최적 배치 계산'을 눌러주세요.", ) self.preview.pack(fill="both", expand=True) - def _build_catalog(self, parent: tk.Widget) -> None: - canvas = tk.Canvas(parent, highlightthickness=0) - scroll = ttk.Scrollbar(parent, orient="vertical", command=canvas.yview) - inner = ttk.Frame(canvas) - inner.bind( - "", - lambda e: canvas.configure(scrollregion=canvas.bbox("all")), + # -------------------------------------------------------- callbacks + 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)}개 (스크린샷)") + self.nb.select(0) # back to first tab? Keep on screenshot tab actually + messagebox.showinfo( + "확정", f"{len(self.basket)}개를 가져왔습니다. '최적 배치 계산'을 누르세요." ) - 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 _wheel(e): - canvas.yview_scroll(int(-e.delta / 120), "units") - - canvas.bind_all("", _wheel) - canvas.bind_all("", lambda e: canvas.yview_scroll(-1, "units")) - canvas.bind_all("", 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: + 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 _expand_basket(self) -> List[str]: - basket: List[str] = [] - for v, n in self.counts.items(): - basket.extend([v] * n) - return basket + def _clear_basket(self) -> None: + self.manual.clear() + self.basket = [] + self.summary_var.set("선택된 석판: 0개") def _solve(self) -> None: if self.solving: return - basket = self._expand_basket() - if not basket: - messagebox.showinfo("안내", "먼저 보유한 석판을 추가하세요.") + 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( "확인", @@ -217,7 +593,6 @@ class App(tk.Tk): def _show_result(self, sol, img: Image.Image) -> None: self.last_solution = sol - # fit preview width w = max(self.preview.winfo_width(), 600) scale = min(1.0, w / img.width) if scale < 1.0: @@ -231,8 +606,7 @@ class App(tk.Tk): messagebox.showinfo("안내", "먼저 계산을 실행하세요.") return path = filedialog.asksaveasfilename( - defaultextension=".png", - filetypes=[("PNG", "*.png")], + defaultextension=".png", filetypes=[("PNG", "*.png")], initialfile="sephiria_layout.png", ) if not path: @@ -246,7 +620,8 @@ def main() -> int: try: app = App() except tk.TclError as e: - print(f"GUI를 띄울 수 없습니다 ({e}). CLI 모드를 시도하세요:", file=sys.stderr) + print(f"GUI를 띄울 수 없습니다 ({e}). CLI 모드를 시도하세요:", + file=sys.stderr) print(" python -m sephiria_inv --help", file=sys.stderr) return 1 app.mainloop()