From e567a5cdfc5bcd3848aef1e23767076d48526517 Mon Sep 17 00:00:00 2001 From: voson Date: Sun, 26 Jul 2026 12:20:54 +0800 Subject: [PATCH] Skip draft vision when Dota 2 is not the foreground window. Prevents Alt-Tab desktop frames from advancing confirm streaks or burning strategy_tail polls. Co-authored-by: Cursor --- CHANGELOG.md | 2 ++ DESIGN.md | 1 + capture.py | 47 +++++++++++++++++++++++++++++++++++++++++++++++ config.json | 1 + draft_session.py | 32 +++++++++++++++++++++++++------- gsi_watch.py | 29 +++++++++++++++++++++++++---- 6 files changed, 101 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68507a1..9b3af70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ - GSI 全量落盘:默认把每包 POST 追加到 `samples/raw//gsi.jsonl` (`gsi.dump_payloads` / `--dump-gsi` / `--no-dump-gsi`)。 +- 前台窗口检测:`gsi.require_foreground`(默认 true)。选将跟踪时若前台不是 + `dota2.exe` 则跳过本帧截屏/识别,且不消耗 `strategy_tail_polls`;切回后继续。 ### Changed diff --git a/DESIGN.md b/DESIGN.md index d601022..1bb34bd 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -180,6 +180,7 @@ climperor/ | `gsi.revise_gain` | 选人阶段改判所需分数增益 | 0.15 | | `gsi.strategy_tail_polls` | 决策阶段继续视觉轮询 | 8 | | `gsi.strategy_gsi_wait` | 视觉结束后等待本人 GSI 英雄 | 3.0 | +| `gsi.require_foreground` | 仅当前台为 `dota2.exe` 时截屏识别 | true | | `gsi.dump_payloads` | 全量 GSI JSONL 落盘 | true | --- diff --git a/capture.py b/capture.py index 685bbd2..a5fbc1a 100644 --- a/capture.py +++ b/capture.py @@ -21,6 +21,53 @@ import numpy as np RAW_DIR = Path(__file__).parent / "samples" / "raw" GSI_JSONL = "gsi.jsonl" +DOTA_EXE = "dota2.exe" + + +def is_dota_foreground() -> bool: + """True when the foreground process is dota2.exe. + + Uses Win32 GetForegroundWindow + QueryFullProcessImageNameW. On non-Windows + or if the probe fails, returns True so recognition is not blocked. + """ + if sys.platform != "win32": + return True + try: + import ctypes + from ctypes import wintypes + + user32 = ctypes.WinDLL("user32", use_last_error=True) + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + + hwnd = user32.GetForegroundWindow() + if not hwnd: + return False + + pid = wintypes.DWORD() + user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) + if not pid.value: + return False + + PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid.value) + if not handle: + return False + try: + buf = ctypes.create_unicode_buffer(32768) + size = wintypes.DWORD(len(buf)) + # QueryFullProcessImageNameW(hProcess, dwFlags, lpExeName, lpdwSize) + QueryFullProcessImageNameW = kernel32.QueryFullProcessImageNameW + QueryFullProcessImageNameW.argtypes = [ + wintypes.HANDLE, wintypes.DWORD, wintypes.LPWSTR, ctypes.POINTER(wintypes.DWORD) + ] + QueryFullProcessImageNameW.restype = wintypes.BOOL + if not QueryFullProcessImageNameW(handle, 0, buf, ctypes.byref(size)): + return False + return Path(buf.value).name.lower() == DOTA_EXE + finally: + kernel32.CloseHandle(handle) + except Exception: + return True def raw_dir_for_match(match_id: str | None = None) -> Path: diff --git a/config.json b/config.json index b223ab6..f9164ff 100644 --- a/config.json +++ b/config.json @@ -118,6 +118,7 @@ "dump_selection_every": 0, "strategy_tail_polls": 8, "strategy_gsi_wait": 3.0, + "require_foreground": true, "capture_interval": 1.0, "target_slots": 10, "dump_payloads": true diff --git a/draft_session.py b/draft_session.py index 997e0f6..20efa78 100644 --- a/draft_session.py +++ b/draft_session.py @@ -34,7 +34,7 @@ import time import mss -from capture import grab_frame, raw_dir_for_match, save_frame +from capture import grab_frame, is_dota_foreground, raw_dir_for_match, save_frame from grid import bans, hero_table, read_grid from modes import detect_mode, load_mode_templates from recognize import recognize_image @@ -74,6 +74,8 @@ class DraftSession: # this many polls - catches the last reveal without hanging forever self.strategy_tail = g.get("strategy_tail_polls", 8) self.gsi_wait = g.get("strategy_gsi_wait", 3.0) + # skip grab/recognize when Dota is not the foreground window + self.require_foreground = bool(g.get("require_foreground", True)) self.role_templates = load_role_templates() self.mode_templates = load_mode_templates() self.hero_names = {h["key"]: h["name_loc"] for h in hero_table()} @@ -104,6 +106,7 @@ class DraftSession: vision_done = False strategy_polls = 0 freeze_at = 0.0 + skipped_fg = False with mss.MSS() as sct: while True: @@ -116,6 +119,26 @@ class DraftSession: break elapsed = time.monotonic() - started + + # Vision finished: only wait on GSI / short timeout (no grab). + if vision_done: + gsi_hero = (gsi_fn() or {}).get("hero") if gsi_fn else None + if gsi_hero or (elapsed - freeze_at) >= self.gsi_wait: + break + time.sleep(self.poll_interval) + continue + + # Desktop / other apps must not burn strategy_tail or confirm streaks. + if self.require_foreground and not is_dota_foreground(): + if not skipped_fg: + self.log("[draft] Dota 2 not foreground - skipping frames") + skipped_fg = True + time.sleep(self.poll_interval) + continue + if skipped_fg: + self.log("[draft] Dota 2 foreground again - resuming vision") + skipped_fg = False + frame = grab_frame(sct) last_frame = frame polls += 1 @@ -123,7 +146,7 @@ class DraftSession: # Keep reading in strategy until the roster is full - the last # pick is often revealed on the same tick selection ends, and # a player who already locked may only see strategy UI. - do_vision = not vision_done and ( + do_vision = ( state == HERO_SELECTION or (state == STRATEGY_TIME and strategy_polls < self.strategy_tail) ) @@ -177,11 +200,6 @@ class DraftSession: freeze_at = elapsed self.log("[draft] vision done - waiting on GSI for self hero") - if vision_done: - gsi_hero = (gsi_fn() or {}).get("hero") if gsi_fn else None - if gsi_hero or (elapsed - freeze_at) >= self.gsi_wait: - break - time.sleep(self.poll_interval) if best and not vision_done: diff --git a/gsi_watch.py b/gsi_watch.py index 63cfaa0..655af98 100644 --- a/gsi_watch.py +++ b/gsi_watch.py @@ -30,7 +30,7 @@ from pathlib import Path import mss -from capture import append_gsi_payload, grab_frame, raw_dir_for_match, save_frame +from capture import append_gsi_payload, grab_frame, is_dota_foreground, raw_dir_for_match, save_frame from common import ROOT, load_config, load_template_library from draft_session import HERO_SELECTION, DraftSession, describe, gsi_slot, loc from recognize import recognize_image @@ -155,13 +155,32 @@ class Watcher: def _capture_only(self, match_id: str, state: str) -> None: """No calibration yet: just bank a few frames to calibrate from later.""" out = raw_dir_for_match(match_id) + require_fg = bool(self.cfg.get("gsi", {}).get("require_foreground", True)) print(f"[run] capture-only -> {out} (state={state})", flush=True) + saved = 0 + skipped_fg = False with mss.MSS() as sct: - for attempt in range(1, 4): + # Retry until 3 usable frames or a short deadline so Alt-Tab does not + # bank desktop screenshots for calibration. + deadline = time.monotonic() + max(self.interval * 12, 15.0) + while saved < 3 and time.monotonic() < deadline: + if require_fg and not is_dota_foreground(): + if not skipped_fg: + print("[run] Dota 2 not foreground - waiting to capture", flush=True) + skipped_fg = True + time.sleep(self.interval) + continue + if skipped_fg: + print("[run] Dota 2 foreground again - capturing", flush=True) + skipped_fg = False + saved += 1 path = save_frame(grab_frame(sct), out, prefix="draft") - print(f"[run] capture-only {attempt}/3: {path}", flush=True) + print(f"[run] capture-only {saved}/3: {path}", flush=True) time.sleep(self.interval) - print("[run] no calibrated slots - run calibrate.py on one of these frames", flush=True) + if saved == 0: + print("[run] capture-only got no frames (Dota never foreground)", flush=True) + else: + print("[run] no calibrated slots - run calibrate.py on one of these frames", flush=True) return None def report_session(self, summary: dict) -> None: @@ -175,6 +194,8 @@ class Watcher: def run_once(self) -> dict | None: """One snapshot of whatever is on screen right now, for manual checks.""" + if not is_dota_foreground(): + print("[run] warning: Dota 2 is not the foreground window", flush=True) frame = grab_frame() out = raw_dir_for_match(self.last_match_id) if not self.calibrated: