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."""
import win32gui # 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
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
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):
"""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.
@@ -290,7 +380,7 @@ def run_calibration(out_dir: Path, screenshot: Path | None = None) -> Path:
root0 = tk.Tk(); root0.withdraw()
window_title = simpledialog.askstring(
"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,
initialvalue=screenshot.stem,
) or screenshot.stem
@@ -299,13 +389,28 @@ def run_calibration(out_dir: Path, screenshot: Path | None = None) -> Path:
root0 = tk.Tk(); root0.withdraw()
window_title = simpledialog.askstring(
"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,
) or ""
root0.destroy()
if not window_title:
raise ValueError("Window title is required to proceed.")
pil_img = _capture_window(window_title)
# 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)
# 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