feat(calibrate): region-select mode as default capture

Window-level PrintWindow/mss returns blank on GPU-accelerated apps
(TradeStation, some trading terminals). Switch to full-desktop screenshot
+ drag-rectangle as the default method — reliable across all apps.

- Title prompt still asked (needed by 'atm run' to locate window at runtime).
- Captured region saved to logs/calibrate_capture_<ts>.png for verification.
- Old window-capture path kept as fallback if region-select is cancelled.
- Minor: silence pyright windll attr warning.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-04-16 06:29:16 +00:00
parent 602fdbbc6e
commit cabe1634bc
2 changed files with 109 additions and 4 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -110,7 +110,7 @@ def _capture_via_printwindow(win):
"""Use Win32 PrintWindow with PW_RENDERFULLCONTENT. Reliable for GPU-accelerated apps.""" """Use Win32 PrintWindow with PW_RENDERFULLCONTENT. Reliable for GPU-accelerated apps."""
import win32gui # type: ignore[import-untyped] import win32gui # type: ignore[import-untyped]
import win32ui # type: ignore[import-untyped] import win32ui # type: ignore[import-untyped]
from ctypes import windll from ctypes import windll # type: ignore[attr-defined]
from PIL import Image from PIL import Image
hwnd = win._hWnd # pygetwindow exposes this hwnd = win._hWnd # pygetwindow exposes this
@@ -155,6 +155,96 @@ def _image_looks_blank(pil_img, dark_thresh: int = 10) -> bool:
return arr.std() < 3.0 return arr.std() < 3.0
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.
"""
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)
full_img = Image.frombytes("RGB", shot.size, shot.rgb)
root = tk.Tk()
root.title("Select chart region")
# Use most of the screen but leave OS chrome visible so user can close it
screen_w = root.winfo_screenwidth()
screen_h = root.winfo_screenheight()
orig_w, orig_h = full_img.size
avail_w = max(400, screen_w - 40)
avail_h = max(300, screen_h - 120)
scale = min(avail_w / orig_w, avail_h / orig_h, 1.0)
disp_w, disp_h = int(orig_w * scale), int(orig_h * scale)
root.geometry(f"{disp_w}x{disp_h + 40}+10+10")
scaled = full_img.resize((disp_w, disp_h))
tkimg = ImageTk.PhotoImage(scaled)
status = tk.Label(
root, bg="#e74c3c", fg="white", font=("Arial", 12, "bold"),
text="DRAG a rectangle around the full chart area (including M2D MAPS strip). Enter = OK, Esc = cancel",
pady=6,
)
status.pack(fill="x")
canvas = tk.Canvas(
root, width=disp_w, height=disp_h, cursor="crosshair",
highlightthickness=0, bg="#000",
)
canvas.pack(fill="both", expand=True)
canvas.create_image(0, 0, anchor="nw", image=tkimg)
state: dict = {"x0": 0, "y0": 0, "x1": 0, "y1": 0, "rect_id": None, "started": False}
result: dict = {"img": None}
def on_press(e):
state["x0"], state["y0"] = e.x, e.y
state["x1"], state["y1"] = e.x, e.y
if state["rect_id"] is not None:
canvas.delete(state["rect_id"])
state["rect_id"] = canvas.create_rectangle(
e.x, e.y, e.x, e.y, outline="#ff3838", width=3,
)
def on_drag(e):
state["x1"], state["y1"] = e.x, e.y
if state["rect_id"] is not None:
canvas.coords(state["rect_id"], state["x0"], state["y0"], e.x, e.y)
def on_ok(_=None):
if state["x0"] is None:
return
x0 = int(min(state["x0"], state["x1"]) / scale)
y0 = int(min(state["y0"], state["y1"]) / scale)
x1 = int(max(state["x0"], state["x1"]) / scale)
y1 = int(max(state["y0"], state["y1"]) / scale)
if x1 - x0 < 100 or y1 - y0 < 100:
return # too small, ignore
result["img"] = full_img.crop((x0, y0, x1, y1))
root.destroy()
def on_cancel(_=None):
root.destroy()
canvas.bind("<Button-1>", on_press)
canvas.bind("<B1-Motion>", on_drag)
canvas.bind("<ButtonRelease-1>", lambda e: None) # keep rect visible until Enter
root.bind("<Return>", on_ok)
root.bind("<KP_Enter>", on_ok)
root.bind("<Escape>", on_cancel)
root.focus_force()
root.mainloop()
if result["img"] is None:
raise ValueError("Region selection cancelled")
return result["img"]
def _capture_window(title_substr: str): def _capture_window(title_substr: str):
"""Capture a window by title substring. Tries PrintWindow first (reliable for GPU """Capture a window by title substring. Tries PrintWindow first (reliable for GPU
apps), falls back to mss. If multiple windows match, picks the largest by area. apps), falls back to mss. If multiple windows match, picks the largest by area.
@@ -290,7 +380,7 @@ def run_calibration(out_dir: Path, screenshot: Path | None = None) -> Path:
root0 = tk.Tk(); root0.withdraw() root0 = tk.Tk(); root0.withdraw()
window_title = simpledialog.askstring( window_title = simpledialog.askstring(
"Window title", "Window title",
"Window title substring used by 'atm run' to locate the chart:", "Window title substring used by 'atm run' to locate the chart at runtime:",
parent=root0, parent=root0,
initialvalue=screenshot.stem, initialvalue=screenshot.stem,
) or screenshot.stem ) or screenshot.stem
@@ -299,14 +389,29 @@ def run_calibration(out_dir: Path, screenshot: Path | None = None) -> Path:
root0 = tk.Tk(); root0.withdraw() root0 = tk.Tk(); root0.withdraw()
window_title = simpledialog.askstring( window_title = simpledialog.askstring(
"Window title", "Window title",
"Substring of the chart window title (e.g. 'DIA' or 'TradeStation'):", "Substring of the chart window title (used at runtime by 'atm run' to find it).\n"
"Example: 'TradeStation' or 'DIA'.",
parent=root0, parent=root0,
) or "" ) or ""
root0.destroy() root0.destroy()
if not window_title: if not window_title:
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).
# Fallback: try window capture if region-select is cancelled.
try:
pil_img = _capture_fullscreen_region()
except ValueError:
pil_img = _capture_window(window_title) pil_img = _capture_window(window_title)
# Persist captured frame for debugging + as reference corpus.
try:
debug_path = Path("logs") / f"calibrate_capture_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
debug_path.parent.mkdir(parents=True, exist_ok=True)
pil_img.save(debug_path)
except Exception:
pass
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 2. Launch interactive wizard on the image # 2. Launch interactive wizard on the image
# ------------------------------------------------------------------ # ------------------------------------------------------------------