Python Windows窗口画面捕捉教程:mss与WinAPI详解
2025-04-14 09:50:05
用 Python 持续捕捉其他 Windows 应用窗口画面
需要用图像识别库来分析某个应用程序的画面?那第一步就是得把这个应用的画面,一帧一帧、持续不断地“输入”到你的 Python 脚本里。关键是,我们通常只有普通用户权限,没法直接动这个应用内部的东西。那在 Windows 10 环境下,这事儿该怎么搞定呢?
问题来了
咱们的目标挺明确:在 Windows 10 上运行一个 Python 脚本,这个脚本能实时地、连续地捕捉另一个正在运行的应用程序窗口的图像(也就是一帧帧的画面),然后把这些图像交给图像识别库处理。难点在于,我们不能修改目标应用程序,也不能调用它的内部接口,只能像普通用户一样看到它的窗口。
为什么会这样?
这事儿不简单,主要是因为操作系统(Windows)通常不会给一个普通程序提供直接读取另一个程序“内部画面缓冲区”的权限。这样做主要是为了安全和稳定。每个应用程序绘制自己的窗口内容,是由操作系统的图形子系统(比如 GDI、DirectX)管理的。我们要捕捉画面,本质上就是要读取屏幕上特定区域(那个窗口所在区域)的像素数据。这得通过操作系统提供的一些接口来实现,模拟“截图”这个动作,但要做到连续、高效。
搞定它的几种方法
好消息是,办法还是有的。主要思路就是利用 Windows 提供的屏幕捕捉相关的 API。下面介绍几种在 Python 里实现这个目标的方法。
方法一:使用 mss
库 (快、靓、正)
mss
(Python MSS) 是一个专门用来截图的库,跨平台,而且速度相当快,非常适合需要连续捕捉画面的场景。它底层会调用操作系统相关的、优化过的截图 API。
原理和作用:
mss
库直接跟操作系统的底层 API 打交道。在 Windows 上,它可能会使用 GDI 的 BitBlt
或者更新、更高效的 Desktop Duplication API(如果可用)。这让它能非常迅速地抓取屏幕或指定区域的像素数据,并将其转换成 Python 能直接处理的格式(比如原始字节流或 NumPy 数组,后者尤其方便图像处理库使用)。对于捕捉特定窗口,我们需要先确定窗口的位置和大小,然后告诉 mss
只抓取那个区域。
代码示例:
首先,你得安装 mss
和一个能帮你找到窗口信息的库,比如 pygetwindow
。
pip install mss pygetwindow opencv-python numpy
然后,可以这样写代码:
import time
import cv2
import mss
import numpy as np
import pygetwindow as gw
# --- 配置区 ---
TARGET_WINDOW_TITLE = "计算器" # 目标窗口的标题,你需要改成你实际要捕捉的应用的标题
FRAME_RATE_LIMIT = 10 # 限制帧率,比如每秒最多捕捉10帧,避免CPU占用过高
# --- 配置区结束 ---
print("正在查找窗口...")
target_windows = gw.getWindowsWithTitle(TARGET_WINDOW_TITLE)
if not target_windows:
print(f"错误:找不到标题包含 '{TARGET_WINDOW_TITLE}' 的窗口!")
print("请确保目标应用已打开且窗口标题匹配。")
exit()
# 假设找到的第一个窗口是我们要的
window = target_windows[0]
print(f"找到窗口: '{window.title}'")
# 激活窗口并置于前台(可选,有时有助于确保捕捉到的是最新画面)
# 注意:这会打断用户当前操作,谨慎使用
# try:
# window.activate()
# time.sleep(0.5) # 等待窗口响应
# except gw.PyGetWindowException as e:
# print(f"激活窗口时出错(可能是最小化了?):{e}")
# # 如果窗口最小化,先恢复
# if window.isMinimized:
# print("窗口已最小化,尝试恢复...")
# window.restore()
# time.sleep(0.5) # 等待恢复
# 使用 mss 进行捕捉
sct = mss.mss()
# 记录上一帧处理的时间
last_frame_time = time.time()
print("\n开始捕捉画面... 按 Ctrl+C 停止。")
try:
while True:
current_time = time.time()
# 帧率控制
if current_time - last_frame_time < 1.0 / FRAME_RATE_LIMIT:
# time.sleep(0.01) # 短暂休眠一下,避免空转浪费CPU
continue
last_frame_time = current_time
# 获取窗口的最新位置和尺寸(窗口可能移动或改变大小)
if not window.isActive or window.isMinimized:
# print(f"警告:窗口 '{window.title}' 未激活或已最小化,跳过此次捕捉。")
# 可以选择在这里暂停,或者继续尝试捕捉(如果窗口可见但未激活)
# 这里我们简单地跳过,防止获取到不正确的区域或出错
active_windows = gw.getWindowsWithTitle(TARGET_WINDOW_TITLE)
if not active_windows or active_windows[0].isMinimized:
print(f"窗口 '{TARGET_WINDOW_TITLE}' 似乎关闭或持续最小化,停止捕捉。")
break
window = active_windows[0] # 更新窗口对象引用
# continue # 如果只是非激活,有时仍可捕捉,看情况决定是否跳过
# 检查窗口句柄是否仍然有效(可选,增加健壮性)
if not window._hWnd:
print(f"窗口 '{TARGET_WINDOW_TITLE}' 句柄失效,可能已关闭,停止捕捉。")
break
# 定义捕捉区域 (注意:坐标系可能因DPI设置而异)
# box = window.box 返回 (left, top, right, bottom)
capture_box = {
'left': window.left,
'top': window.top,
'width': window.width,
'height': window.height
}
# 进行截图
sct_img = sct.grab(capture_box)
# 将截图数据转换为 NumPy 数组 (BGRA格式)
frame = np.array(sct_img)
# 可选:转换为 OpenCV 更常用的 BGR 格式
# frame_bgr = cv2.cvtColor(frame, cv2.COLOR_BGRA2BGR)
# 在这里,你可以将 frame (或者 frame_bgr) 交给你的图像识别库处理
# --- 在下方添加你的图像识别代码 ---
# 例如,用OpenCV显示画面:
cv2.imshow(f"Capturing '{window.title}'", frame)
# print(f"成功捕捉一帧,尺寸: {frame.shape}") # 打印帧信息,用于调试
# --- 图像识别代码结束 ---
# 检测是否按下了 'q' 键或者窗口关闭了,用于退出循环 (OpenCV窗口显示时)
if cv2.waitKey(1) & 0xFF == ord('q'):
break
except KeyboardInterrupt:
print("\n收到停止信号 (Ctrl+C),正在退出...")
except Exception as e:
print(f"\n捕捉过程中发生错误: {e}")
finally:
# 清理资源
cv2.destroyAllWindows()
print("程序已退出。")
进阶使用技巧:
- 多显示器:
mss
可以指定捕捉哪个显示器 (monitor=N
)。如果窗口跨显示器,计算边界框时要小心。 - 性能优化:
mss
本身很快。如果处理不过来,瓶颈往往在后续的图像处理。考虑降低FRAME_RATE_LIMIT
,或者在图像处理部分做优化(比如异步处理)。mss
实例 (sct
) 可以复用,避免每次循环都创建。 - 窗口查找的鲁棒性: 窗口标题可能变化。
pygetwindow
可以用更复杂的条件查找,或者直接用窗口句柄 (HWND)。如果应用启动慢,可以在脚本开始加个等待或重试逻辑。 - 隐藏窗口/后台窗口: 捕捉不可见窗口或后台窗口可能失败,或捕捉到空白/旧内容。确保窗口是可见的(不一定需要激活)。有些应用在后台时可能停止渲染,也会导致问题。
安全建议:
- 屏幕捕捉本质上能看到你屏幕上的所有内容。运行此类脚本时,注意不要意外捕捉到包含密码、私密信息的窗口。
- 如果你要捕捉的是游戏窗口,注意某些反作弊系统可能会检测屏幕捕捉行为,极端情况下可能导致账号被封禁。确保你了解目标应用的规定。
方法二:调用 Windows API (硬核玩家)
如果你不满足于用现成库,或者对性能有极致追求(虽然 mss
已经很不错了),可以直接用 Python 的 ctypes
或 pywin32
库调用 Windows 原生 API 来实现截图。
原理和作用:
这种方法直接操控 Windows 图形设备接口 (GDI)。大致流程是:
- 找到目标窗口的句柄 (HWND)。可以用
FindWindowW
或FindWindowExW
。 - 获取窗口的设备上下文 (DC) -
GetWindowDC
或GetDC
。 - 创建一个与窗口 DC 兼容的内存 DC (
CreateCompatibleDC
)。 - 创建一个与窗口大小一致的位图 (Bitmap) (
CreateCompatibleBitmap
),并在内存 DC 中选中它 (SelectObject
)。 - 使用
BitBlt
(Bit Block Transfer) 函数将窗口 DC 的内容复制到位图所在的内存 DC 中。这个是核心步骤。 - 从内存 DC 中的位图提取像素数据。这步比较麻烦,可能需要用到
GetDIBits
。 - 释放所有获取的 GDI 资源(DC、Bitmap等),比如用
DeleteDC
,ReleaseDC
,DeleteObject
。这一步至关重要,否则会导致 GDI 资源泄露,系统会越来越慢甚至崩溃!
代码示例 (使用 pywin32
)
你需要先安装 pywin32
: pip install pywin32
import win32gui
import win32ui
import win32con
import win32api
import numpy as np
import cv2
import time
import pygetwindow as gw # 仍然推荐用它来找窗口句柄
# --- 配置区 ---
TARGET_WINDOW_TITLE = "计算器" # 目标窗口的标题
FRAME_RATE_LIMIT = 10 # 帧率限制
# --- 配置区结束 ---
print("正在查找窗口...")
target_windows = gw.getWindowsWithTitle(TARGET_WINDOW_TITLE)
if not target_windows:
print(f"错误:找不到标题包含 '{TARGET_WINDOW_TITLE}' 的窗口!")
exit()
window = target_windows[0]
hwnd = window._hWnd # 获取窗口句柄
print(f"找到窗口句柄: {hwnd}")
# 获取窗口大小信息 (排除标题栏和边框,只取客户区)
# rect = win32gui.GetClientRect(hwnd)
# client_width = rect[2] - rect[0]
# client_height = rect[3] - rect[1]
# print(f"窗口客户区尺寸: {client_width}x{client_height}")
# 获取完整窗口大小 (包括边框和标题栏) - 这通常是我们想要的"所见即所得"
# 需要先激活窗口,否则GetWindowRect可能返回不准确(特别是最小化时)
# try:
# if window.isMinimized: window.restore()
# if not window.isActive: window.activate()
# time.sleep(0.1) # 给点反应时间
# except Exception as e:
# print(f"尝试激活或恢复窗口失败: {e}")
# # exit() # 可以选择退出,或尝试继续
# 记录上一帧处理的时间
last_frame_time = time.time()
print("\n开始使用 Win32 API 捕捉画面... 按 Ctrl+C 停止。")
try:
while True:
current_time = time.time()
# 帧率控制
if current_time - last_frame_time < 1.0 / FRAME_RATE_LIMIT:
# time.sleep(0.01)
continue
last_frame_time = current_time
# 每次循环重新获取窗口尺寸和位置,因为它可能变动
try:
window_rect = win32gui.GetWindowRect(hwnd) # 返回屏幕坐标 (left, top, right, bottom)
width = window_rect[2] - window_rect[0]
height = window_rect[3] - window_rect[1]
if width <= 0 or height <= 0: # 窗口可能已经关闭或尺寸不正常
print("窗口尺寸无效,可能已关闭。停止捕捉。")
break
except win32ui.error as e: # 窗口可能已经销毁
print(f"获取窗口尺寸时出错 (句柄可能失效: {hwnd}):{e}")
break
# --- 开始核心截图逻辑 ---
# 1. 获取设备上下文 (DC)
# 用 GetWindowDC 获取包括非客户区(边框、标题栏)的整个窗口DC
# 用 GetDC 只获取客户区 DC
wDC = win32gui.GetWindowDC(hwnd)
if not wDC:
# print(f"警告:获取窗口 DC 失败 (句柄: {hwnd}),跳过此帧。") # 可能是临时问题
# 最好检查窗口是否还存在
if not win32gui.IsWindow(hwnd):
print(f"窗口 (句柄: {hwnd}) 已不存在,停止捕捉。")
break
continue
try:
# 2. 创建内存 DC
dcObj = win32ui.CreateDCFromHandle(wDC)
cDC = dcObj.CreateCompatibleDC()
# 3. 创建位图对象
dataBitMap = win32ui.CreateBitmap()
dataBitMap.CreateCompatibleBitmap(dcObj, width, height)
# 4. 将位图选入内存 DC
cDC.SelectObject(dataBitMap)
# 5. 使用 BitBlt 复制屏幕 DC 到内存 DC
# 参数: (目标x, 目标y, 宽度, 高度, 源DC, 源x, 源y, 光栅操作码)
# SRCCOPY 表示直接复制
cDC.BitBlt((0, 0), (width, height), dcObj, (0, 0), win32con.SRCCOPY)
# --- 从位图中提取数据 ---
# 方法 A: 使用 GetBitmapBits (较简单,返回 BGRA 数据)
# bmpinfo = dataBitMap.GetInfo() # 有需要可以用这个获取详细信息
bmpstr = dataBitMap.GetBitmapBits(True) # True 返回 bytes 对象
# 将 bytes 数据转成 numpy 数组
frame_raw = np.frombuffer(bmpstr, dtype='uint8')
# 位图数据是 BGRA 格式,需要 reshape 成 (height, width, 4)
# 注意:如果宽度不是4的倍数,GetBitmapBits 可能会有填充,导致 reshape 问题
# 但现代 GDI 操作通常处理对齐,这里先假设没问题
if frame_raw.size == width * height * 4:
frame = frame_raw.reshape((height, width, 4))
else:
print(f"警告:获取的位图数据大小 ({frame_raw.size}) 与预期 ({width*height*4}) 不符,可能存在对齐问题或错误。跳过此帧。")
# 需要更复杂的处理来应对 stride/padding 问题
# 此处简单跳过
frame = None # 标记为无效帧
# --- 释放 GDI 资源 ---
dcObj.DeleteDC() # 删除内存 DC
cDC.DeleteDC() # ...实际上上面 dcObj.DeleteDC() 可能已经包含了 cDC? (pywin32封装后行为可能不同,安全起见都调一下或者查文档确认)
# 更标准的做法是释放 CreateDCFromHandle 创建的对象本身
finally: # 确保资源在任何情况下都被释放
win32gui.ReleaseDC(hwnd, wDC) # 释放窗口 DC
win32gui.DeleteObject(dataBitMap.GetHandle()) # 删除位图对象
# --- 资源释放结束 ---
# 现在 `frame` (如果有效) 是一个 NumPy 数组 (BGRA),可以处理了
if frame is not None:
# --- 在下方添加你的图像识别代码 ---
cv2.imshow(f"Capturing '{window.title}' (Win32 API)", frame)
# --- 图像识别代码结束 ---
# 检测退出
if cv2.waitKey(1) & 0xFF == ord('q'):
break
except KeyboardInterrupt:
print("\n收到停止信号 (Ctrl+C),正在退出...")
except Exception as e:
print(f"\n捕捉过程中发生严重错误: {e}")
finally:
cv2.destroyAllWindows()
# 确保最后的句柄引用还在的话,做最后的清理(虽然循环内已经做了)
# if 'wDC' in locals() and wDC: win32gui.ReleaseDC(hwnd, wDC)
# if 'dataBitMap' in locals() and dataBitMap.GetHandle(): win32gui.DeleteObject(dataBitMap.GetHandle())
print("程序已退出。")
注意事项:
- 复杂度高: 直接调用 WinAPI 代码量大,逻辑复杂,容易出错,特别是资源管理。
- 资源泄露风险: 如果 GDI 对象(DC, Bitmap等)没有正确释放,程序会消耗越来越多系统资源,最终导致系统不稳定。
try...finally
结构是必须的。 - 性能: 理论上直接调 API 可能比通用库有微小性能优势(少了封装层),但也可能因为 Python 调用 C 的开销而抵消。实际性能需要测试。对于极高帧率,可能需要用 C++ 写核心捕捉逻辑再由 Python 调用。
- DPI 缩放: 在高 DPI 显示器上,窗口的像素尺寸和 GDI 函数报告的尺寸可能需要根据 DPI 缩放因子进行转换,否则截图区域可能不正确。
pywin32
可能需要配合 DPI 感知设置 (SetProcessDPIAwareness
) 来正确处理。
安全建议:
与 mss
方法相同:注意隐私保护,警惕反作弊检测。直接调用 WinAPI 不会改变这些基本风险。
总结一下思路
要在 Python 中持续捕捉 Windows 应用窗口画面,推荐首选 mss
库,它兼顾了性能和易用性。通过配合 pygetwindow
找到目标窗口并确定其位置大小,mss
就能高效地抓取该区域的图像帧,方便后续用 OpenCV、Pillow 等库进行处理。如果对性能有极致要求或者想深入理解底层机制,可以挑战直接调用 Windows GDI API,但务必小心处理资源释放,避免挖坑。无论哪种方法,控制好捕捉帧率(FRAME_RATE_LIMIT
),并留意潜在的安全和隐私风险。