diff --git a/src/atm/calibrate.py b/src/atm/calibrate.py index a1cd115..13530ac 100644 --- a/src/atm/calibrate.py +++ b/src/atm/calibrate.py @@ -158,16 +158,19 @@ def _image_looks_blank(pil_img, dark_thresh: int = 10) -> bool: def _capture_fullscreen_region(): """Grab the entire virtual desktop and let the user drag a rectangle. - Returns a cropped PIL RGB Image. The underlying desktop-snapshot is taken - BEFORE the Tk overlay opens, so what the user sees = what gets saved. + Returns (cropped PIL RGB Image, (origin_x, origin_y, width, height)) in + virtual-desktop coordinates. Runtime capture crops the same region. """ import mss # type: ignore[import-untyped] import tkinter as tk from PIL import Image, ImageTk with mss.mss() as sct: - shot = sct.grab(sct.monitors[0]) # virtual screen (all monitors) + virt = sct.monitors[0] # virtual screen (all monitors combined) + shot = sct.grab(virt) full_img = Image.frombytes("RGB", shot.size, shot.rgb) + virt_left = int(virt["left"]) + virt_top = int(virt["top"]) root = tk.Tk() root.title("Select chart region") @@ -203,6 +206,7 @@ def _capture_fullscreen_region(): result: dict = {"img": None} def on_press(e): + state["started"] = True state["x0"], state["y0"] = e.x, e.y state["x1"], state["y1"] = e.x, e.y if state["rect_id"] is not None: @@ -217,7 +221,7 @@ def _capture_fullscreen_region(): canvas.coords(state["rect_id"], state["x0"], state["y0"], e.x, e.y) def on_ok(_=None): - if state["x0"] is None: + if not state["started"]: return x0 = int(min(state["x0"], state["x1"]) / scale) y0 = int(min(state["y0"], state["y1"]) / scale) @@ -226,6 +230,8 @@ def _capture_fullscreen_region(): if x1 - x0 < 100 or y1 - y0 < 100: return # too small, ignore result["img"] = full_img.crop((x0, y0, x1, y1)) + # Convert back to virtual-desktop absolute coordinates. + result["region"] = (virt_left + x0, virt_top + y0, x1 - x0, y1 - y0) root.destroy() def on_cancel(_=None): @@ -242,7 +248,7 @@ def _capture_fullscreen_region(): root.mainloop() if result["img"] is None: raise ValueError("Region selection cancelled") - return result["img"] + return result["img"], result["region"] def _capture_window(title_substr: str): @@ -375,6 +381,7 @@ def run_calibration(out_dir: Path, screenshot: Path | None = None) -> Path: # 1. Acquire image + window title # ------------------------------------------------------------------ pil_img: "Image.Image" + chart_region: tuple[int, int, int, int] | None = None if screenshot is not None: pil_img = Image.open(screenshot).convert("RGB") root0 = tk.Tk(); root0.withdraw() @@ -398,9 +405,10 @@ def run_calibration(out_dir: Path, screenshot: Path | None = None) -> Path: raise ValueError("Window title is required to proceed.") # Default: region-select on full desktop (reliable with GPU-rendered apps). - # Fallback: try window capture if region-select is cancelled. + # Returns (cropped image, virtual-desktop region) so runtime capture can + # re-crop the same area without needing pygetwindow to find the chart. try: - pil_img = _capture_fullscreen_region() + pil_img, chart_region = _capture_fullscreen_region() except ValueError: pil_img = _capture_window(window_title) @@ -433,6 +441,10 @@ def run_calibration(out_dir: Path, screenshot: Path | None = None) -> Path: } data.setdefault("options", {}) + if chart_region is not None: + cx, cy, cw, ch = chart_region + data["chart_window_region"] = {"x": cx, "y": cy, "w": cw, "h": ch} + return write_config(data, configs_dir) diff --git a/src/atm/config.py b/src/atm/config.py index bc40c19..3c1856b 100644 --- a/src/atm/config.py +++ b/src/atm/config.py @@ -94,6 +94,7 @@ class Config: canary: CanaryRegion discord: DiscordCfg telegram: TelegramCfg + chart_window_region: ROI | None = None # virtual-desktop absolute region; when set, runtime uses full-desktop capture + crop debounce_depth: int = 1 loop_interval_s: float = 5.0 heartbeat_min: int = 30 @@ -149,6 +150,9 @@ class Config: chat_id=str(data["telegram"]["chat_id"]), ) opts = data.get("options", {}) + region = None + if "chart_window_region" in data: + region = ROI(**data["chart_window_region"]) return cls( window_title=data["window_title"], dot_roi=roi, @@ -158,6 +162,7 @@ class Config: canary=canary, discord=discord, telegram=telegram, + chart_window_region=region, debounce_depth=int(opts.get("debounce_depth", 1)), loop_interval_s=float(opts.get("loop_interval_s", 5.0)), heartbeat_min=int(opts.get("heartbeat_min", 30)), diff --git a/src/atm/main.py b/src/atm/main.py index 248412d..d74d611 100644 --- a/src/atm/main.py +++ b/src/atm/main.py @@ -277,17 +277,37 @@ def _build_capture(cfg, capture_stub: bool = False): return _stub_capture - # Windows live path + # Live path — prefer region-based capture if calibrate saved one try: import mss # type: ignore[import-untyped] - import pygetwindow as gw # type: ignore[import-untyped] except ImportError as exc: sys.exit( - f"Live screen capture requires 'mss' and 'pygetwindow' (Windows only). " + f"Live screen capture requires 'mss' (Windows). " f"Use --capture-stub or set ATM_STUB_CAPTURE=1 for testing on Linux. " f"Missing: {exc}" ) + if cfg.chart_window_region is not None: + reg = cfg.chart_window_region + + def _region_capture(): + with mss.mss() as sct: + import cv2 # type: ignore[import-untyped] + import numpy as np + mon = {"top": reg.y, "left": reg.x, "width": reg.w, "height": reg.h} + img = sct.grab(mon) + frame = np.array(img) + return cv2.cvtColor(frame, cv2.COLOR_BGRA2BGR) + + return _region_capture + + # Legacy: window-title lookup via pygetwindow (unreliable for GPU-rendered apps; + # kept as fallback when calibrate was run without region-select). + try: + import pygetwindow as gw # type: ignore[import-untyped] + except ImportError as exc: + sys.exit(f"Fallback window capture needs pygetwindow: {exc}") + def _live_capture(): wins = gw.getWindowsWithTitle(cfg.window_title) if not wins: @@ -297,10 +317,8 @@ def _build_capture(cfg, capture_stub: bool = False): import cv2 # type: ignore[import-untyped] import numpy as np mon = { - "top": win.top, - "left": win.left, - "width": win.width, - "height": win.height, + "top": win.top, "left": win.left, + "width": win.width, "height": win.height, } img = sct.grab(mon) frame = np.array(img)