返回

Python Windows窗口画面捕捉教程:mss与WinAPI详解

windows

用 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 的 ctypespywin32 库调用 Windows 原生 API 来实现截图。

原理和作用:

这种方法直接操控 Windows 图形设备接口 (GDI)。大致流程是:

  1. 找到目标窗口的句柄 (HWND)。可以用 FindWindowWFindWindowExW
  2. 获取窗口的设备上下文 (DC) - GetWindowDCGetDC
  3. 创建一个与窗口 DC 兼容的内存 DC (CreateCompatibleDC)。
  4. 创建一个与窗口大小一致的位图 (Bitmap) (CreateCompatibleBitmap),并在内存 DC 中选中它 (SelectObject)。
  5. 使用 BitBlt (Bit Block Transfer) 函数将窗口 DC 的内容复制到位图所在的内存 DC 中。这个是核心步骤。
  6. 从内存 DC 中的位图提取像素数据。这步比较麻烦,可能需要用到 GetDIBits
  7. 释放所有获取的 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),并留意潜在的安全和隐私风险。