feat(capture): save calibration region; runtime crops same area

Problem: region-select crop is in its own coord-frame, but runtime capture
used pygetwindow+mss with the window's coords → ROI coords mismatch, chart
not captured correctly.

Fix: region-select now returns (image, virtual-desktop region). Wizard saves
'chart_window_region' in config. At runtime, _build_capture() prefers region
mode: grabs the same virtual-desktop rectangle, so ROI coords line up.

Users who calibrated before this commit must re-run 'atm calibrate' OR add
chart_window_region to their TOML manually. If window moves, canary will
detect drift and auto-pause.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-04-16 06:38:02 +00:00
parent c314fe0584
commit 562df0c395
3 changed files with 49 additions and 14 deletions

View File

@@ -158,16 +158,19 @@ def _image_looks_blank(pil_img, dark_thresh: int = 10) -> bool:
def _capture_fullscreen_region(): def _capture_fullscreen_region():
"""Grab the entire virtual desktop and let the user drag a rectangle. """Grab the entire virtual desktop and let the user drag a rectangle.
Returns a cropped PIL RGB Image. The underlying desktop-snapshot is taken Returns (cropped PIL RGB Image, (origin_x, origin_y, width, height)) in
BEFORE the Tk overlay opens, so what the user sees = what gets saved. virtual-desktop coordinates. Runtime capture crops the same region.
""" """
import mss # type: ignore[import-untyped] import mss # type: ignore[import-untyped]
import tkinter as tk import tkinter as tk
from PIL import Image, ImageTk from PIL import Image, ImageTk
with mss.mss() as sct: 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) full_img = Image.frombytes("RGB", shot.size, shot.rgb)
virt_left = int(virt["left"])
virt_top = int(virt["top"])
root = tk.Tk() root = tk.Tk()
root.title("Select chart region") root.title("Select chart region")
@@ -203,6 +206,7 @@ def _capture_fullscreen_region():
result: dict = {"img": None} result: dict = {"img": None}
def on_press(e): def on_press(e):
state["started"] = True
state["x0"], state["y0"] = e.x, e.y state["x0"], state["y0"] = e.x, e.y
state["x1"], state["y1"] = e.x, e.y state["x1"], state["y1"] = e.x, e.y
if state["rect_id"] is not None: 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) canvas.coords(state["rect_id"], state["x0"], state["y0"], e.x, e.y)
def on_ok(_=None): def on_ok(_=None):
if state["x0"] is None: if not state["started"]:
return return
x0 = int(min(state["x0"], state["x1"]) / scale) x0 = int(min(state["x0"], state["x1"]) / scale)
y0 = int(min(state["y0"], state["y1"]) / 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: if x1 - x0 < 100 or y1 - y0 < 100:
return # too small, ignore return # too small, ignore
result["img"] = full_img.crop((x0, y0, x1, y1)) 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() root.destroy()
def on_cancel(_=None): def on_cancel(_=None):
@@ -242,7 +248,7 @@ def _capture_fullscreen_region():
root.mainloop() root.mainloop()
if result["img"] is None: if result["img"] is None:
raise ValueError("Region selection cancelled") raise ValueError("Region selection cancelled")
return result["img"] return result["img"], result["region"]
def _capture_window(title_substr: str): 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 # 1. Acquire image + window title
# ------------------------------------------------------------------ # ------------------------------------------------------------------
pil_img: "Image.Image" pil_img: "Image.Image"
chart_region: tuple[int, int, int, int] | None = None
if screenshot is not None: if screenshot is not None:
pil_img = Image.open(screenshot).convert("RGB") pil_img = Image.open(screenshot).convert("RGB")
root0 = tk.Tk(); root0.withdraw() 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.") raise ValueError("Window title is required to proceed.")
# Default: region-select on full desktop (reliable with GPU-rendered apps). # 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: try:
pil_img = _capture_fullscreen_region() pil_img, chart_region = _capture_fullscreen_region()
except ValueError: except ValueError:
pil_img = _capture_window(window_title) pil_img = _capture_window(window_title)
@@ -433,6 +441,10 @@ def run_calibration(out_dir: Path, screenshot: Path | None = None) -> Path:
} }
data.setdefault("options", {}) 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) return write_config(data, configs_dir)

View File

@@ -94,6 +94,7 @@ class Config:
canary: CanaryRegion canary: CanaryRegion
discord: DiscordCfg discord: DiscordCfg
telegram: TelegramCfg telegram: TelegramCfg
chart_window_region: ROI | None = None # virtual-desktop absolute region; when set, runtime uses full-desktop capture + crop
debounce_depth: int = 1 debounce_depth: int = 1
loop_interval_s: float = 5.0 loop_interval_s: float = 5.0
heartbeat_min: int = 30 heartbeat_min: int = 30
@@ -149,6 +150,9 @@ class Config:
chat_id=str(data["telegram"]["chat_id"]), chat_id=str(data["telegram"]["chat_id"]),
) )
opts = data.get("options", {}) opts = data.get("options", {})
region = None
if "chart_window_region" in data:
region = ROI(**data["chart_window_region"])
return cls( return cls(
window_title=data["window_title"], window_title=data["window_title"],
dot_roi=roi, dot_roi=roi,
@@ -158,6 +162,7 @@ class Config:
canary=canary, canary=canary,
discord=discord, discord=discord,
telegram=telegram, telegram=telegram,
chart_window_region=region,
debounce_depth=int(opts.get("debounce_depth", 1)), debounce_depth=int(opts.get("debounce_depth", 1)),
loop_interval_s=float(opts.get("loop_interval_s", 5.0)), loop_interval_s=float(opts.get("loop_interval_s", 5.0)),
heartbeat_min=int(opts.get("heartbeat_min", 30)), heartbeat_min=int(opts.get("heartbeat_min", 30)),

View File

@@ -277,17 +277,37 @@ def _build_capture(cfg, capture_stub: bool = False):
return _stub_capture return _stub_capture
# Windows live path # Live path — prefer region-based capture if calibrate saved one
try: try:
import mss # type: ignore[import-untyped] import mss # type: ignore[import-untyped]
import pygetwindow as gw # type: ignore[import-untyped]
except ImportError as exc: except ImportError as exc:
sys.exit( 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"Use --capture-stub or set ATM_STUB_CAPTURE=1 for testing on Linux. "
f"Missing: {exc}" 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(): def _live_capture():
wins = gw.getWindowsWithTitle(cfg.window_title) wins = gw.getWindowsWithTitle(cfg.window_title)
if not wins: if not wins:
@@ -297,10 +317,8 @@ def _build_capture(cfg, capture_stub: bool = False):
import cv2 # type: ignore[import-untyped] import cv2 # type: ignore[import-untyped]
import numpy as np import numpy as np
mon = { mon = {
"top": win.top, "top": win.top, "left": win.left,
"left": win.left, "width": win.width, "height": win.height,
"width": win.width,
"height": win.height,
} }
img = sct.grab(mon) img = sct.grab(mon)
frame = np.array(img) frame = np.array(img)