From cabe1634bcb68e7130a3056783fbd158684c749a Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Thu, 16 Apr 2026 06:29:16 +0000 Subject: [PATCH] feat(calibrate): region-select mode as default capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_.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) --- calibrate_capture_20260416_092557.png | Bin 0 -> 1563 bytes src/atm/calibrate.py | 113 +++++++++++++++++++++++++- 2 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 calibrate_capture_20260416_092557.png diff --git a/calibrate_capture_20260416_092557.png b/calibrate_capture_20260416_092557.png new file mode 100644 index 0000000000000000000000000000000000000000..d4d01e0f0128534462c94b4029c1fd7ed58d05a4 GIT binary patch literal 1563 zcmV+$2ITpPP)000~a0ssI2X{94{000HzNkl{!8P{{xXInLI< zT72@;-R*Z(L`5?=!NKD6EFQt7tTMK@ox^01OhR~7x%fAS*T^CZ@Z=Rd2UX3kiJnref>?t;Cv@@f6^f+~i?01!;5dqT;OipyVOQ_uKafp&Ns&`xUn^c;HRJhsc@@D_rD8|QkQe#2?go3Oqy!P4+D&u%P+ zKYu2RFGt!xWXa~xi7rlBvlYX_!gM-r2WYzq)$jm~{xT~a(+x}^$>h+HwXZTl;VT%w zM``-L9@6F)M#3_Y)?><1TQHiN898dbPUD(;!$WpKIF&K6NqFwK{rWS;A|YU)9j5Y3 z=gf~c;IJ1&RMJ&rT3+Dl#ste2Xgs(Cs}aSbFSUDrf@p<0Jl@ol-1Q!86$D9tsYZ=+ zt%e&c!#YIOL?1oK3ok&Y(9Dpq(=F7!LFgaS^2tG(+^1YZwCLYV7ck$*YYhgARaZEg zoyx2&qQ^;&X6qqgA@3nuQyO!BMe^_U5Wv`Pp{;7vW%|}Ckk(3#z+i{k+@I_7daj;_ z3rGm&tGV@jV}fgz|545TiUn5`D@>&MI9DqLMS`oxxg9&U953U(neN1xkw$SZS1g1%+HV*sNV~4P6dhp^z^RC67tDsGFUX z>{*cF$!K#MrbyJuQ;o@3l8q;!GRW?g#`%?db=`N1Ze01R5b|9KyZfwq;*aSvw7x{H>^!#khe zPaJGY?W=E8CXOrP>gWQjyo8GHmH3H35|5A8^nHOcQa5Bt=by(%gPt#I*?5ct=_U%= zo>PRMezbwwqCjf14YJ>mPLWC>Q&$nGuZ19B`bzg%2Sc_9a>jkJ`w|qU^}4PSU7w@t;UD-8DxAlPgmCwC&N+Stpd~#eW>YKW3Y>DDHQ2H~tIefcWoYth zfF^jAgB`B2E;{lNZ#)E_o!9PG98$%@bF&Y=|1ln{9Kj;Cfc5W2%FSR&o3^Pzxd(zT z?oU6uNr~*fD=3R#P9l}dW$?#uzFE)9hrU$Bc2^>{9p#p25$`C(UvyMCM1rdvBEeM- zk>DzaNN|-yB)G~U5?tjF3H~47WQDXJ#rHJ>26YeIH7~^9O~Si~e*s-ZLYy(0Lv8>7 N002ovPDHLkV1n!z0hj;) literal 0 HcmV?d00001 diff --git a/src/atm/calibrate.py b/src/atm/calibrate.py index 48a4df9..a1cd115 100644 --- a/src/atm/calibrate.py +++ b/src/atm/calibrate.py @@ -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("", on_press) + canvas.bind("", on_drag) + canvas.bind("", lambda e: None) # keep rect visible until Enter + root.bind("", on_ok) + root.bind("", on_ok) + root.bind("", 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