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>
This commit is contained in:
18
README.md
18
README.md
@@ -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 빌드
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
Pillow>=9.0
|
Pillow>=9.0
|
||||||
requests>=2.25
|
requests>=2.25
|
||||||
|
mss>=6.0
|
||||||
|
|||||||
43
sephiria_inv/capture.py
Normal file
43
sephiria_inv/capture.py
Normal 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
|
||||||
@@ -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):
|
The main window has a notebook with two input modes:
|
||||||
1. Tier-grouped slab catalog with [+] [−] count controls
|
- "수동": pick each slab and its count from the tier-grouped catalog.
|
||||||
2. Current basket summary
|
- "스크린샷": grab the current screen (or load a PNG), click two corners of
|
||||||
3. Slot count slider + Solve button + result preview
|
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
|
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, simpledialog, ttk
|
||||||
from typing import Dict, List
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from PIL import Image, ImageTk
|
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 .slabs import SLABS, SLABS_BY_VALUE, TIER_LABEL, TIER_ORDER
|
||||||
from .solver import solve
|
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("<Button-1>", 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("<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")
|
||||||
|
|
||||||
|
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(
|
||||||
|
"<Configure>",
|
||||||
|
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("<Button-1>", 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("<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 +480,16 @@ 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
|
# LEFT: notebook with manual + screenshot input
|
||||||
left = ttk.LabelFrame(root, text="석판 목록 (보유 개수)", padding=6)
|
nb = ttk.Notebook(root)
|
||||||
left.grid(row=0, column=0, sticky="nsew", padx=(0, 6))
|
nb.grid(row=0, column=0, sticky="nsew", padx=(0, 6))
|
||||||
self._build_catalog(left)
|
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 = 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,16 +498,14 @@ 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)
|
||||||
@@ -93,100 +523,46 @@ 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:
|
# -------------------------------------------------------- callbacks
|
||||||
canvas = tk.Canvas(parent, highlightthickness=0)
|
def _on_manual_changed(self) -> None:
|
||||||
scroll = ttk.Scrollbar(parent, orient="vertical", command=canvas.yview)
|
self.basket = self.manual.basket()
|
||||||
inner = ttk.Frame(canvas)
|
self.summary_var.set(f"선택된 석판: {len(self.basket)}개 (수동)")
|
||||||
inner.bind(
|
|
||||||
"<Configure>",
|
def _on_screenshot_confirmed(self, basket: List[str]) -> None:
|
||||||
lambda e: canvas.configure(scrollregion=canvas.bbox("all")),
|
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 _on_slot(self, _evt) -> None:
|
||||||
def _wheel(e):
|
|
||||||
canvas.yview_scroll(int(-e.delta / 120), "units")
|
|
||||||
|
|
||||||
canvas.bind_all("<MouseWheel>", _wheel)
|
|
||||||
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(
|
||||||
"확인",
|
"확인",
|
||||||
@@ -217,7 +593,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 +606,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:
|
||||||
@@ -246,7 +620,8 @@ def main() -> int:
|
|||||||
try:
|
try:
|
||||||
app = App()
|
app = App()
|
||||||
except tk.TclError as e:
|
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)
|
print(" python -m sephiria_inv --help", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
app.mainloop()
|
app.mainloop()
|
||||||
|
|||||||
Reference in New Issue
Block a user