拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

采集后端选型:DXGI与GDI的7倍差距和300倍静息优势

采集后端选型:DXGI与GDI的7倍差距和300倍静息优势

01-采集后端选型:DXGI与GDI的7倍差距和300倍静息优势

作者:黒漂技术佬

远程桌面这套东西,说白了就三件事:把这边屏幕"拍"下来、压缩了传过去、那边再"画"出来。看着平平无奇,可光是第一步"怎么把屏幕拍下来",在 Windows 上就藏着一条分水岭。这一步选错,后面编码再怎么优化都救不回来——因为采集本身是整条链路里最硬的一笔固定开销。

本篇我们就专门聊采集后端:为什么项目最终选了 DXGI 桌面复制当主力,又为什么必须留着 GDI 当备胎,以及那个看起来很玄乎的"静止时快 300 倍"到底是怎么来的。

一、先弄清楚:屏幕采集到底在"采"什么

新手容易误以为远程桌面是"每隔几毫秒截一次全屏图"。这话不算错,但漏了个关键细节:Windows 根本不提供"把当前桌面画到一张图里"这种现成接口。你得自己绕到显卡/系统的底层去拿。

而绕的路有两条,走的完全是两套机制:

  • DXGI Desktop Duplication(桌面复制):这是 Windows 8 之后,系统基于显卡驱动直接给你的能力。你可以理解成"系统自己也在记着桌面的每一帧变化,你只要来领就行"。
  • GDI 抓取:走的是更古老、更通用的图形设备接口。相当于你用一套"兼容性最好但最笨"的办法,让系统把桌面重新画一遍给你。

项目里这两位分别对应dxcam(封装了 DXGI)和mss(封装了 GDI)。名字不重要,重要的是它们背后的原理差异。

二、DXGI 桌面复制:不是"截图",是"领变化"

DXGI 桌面复制的思路很聪明。Windows 的桌面合成器(DWM)本身每时每刻都在把各个窗口合成为最终画面,它天然就知道"屏幕上一帧和上一帧哪里变了"。桌面复制 API 就是把这个能力开放给你:你拿走当前这一帧,而且系统很明确地告诉你"这一帧有没有新内容"。

它的好处是:

  1. 不经过 CPU 重绘。直接从显卡的合成结果里拿,几乎零拷贝。
  2. 它知道"没变化"。当桌面静止时,系统压根没有新东西产生,于是这个 API 会直接告诉你"哥们儿,没新帧,别等了"。

这两点决定了它的性能特征:变的时候快,不变的时候更快。我们后面用数据说话。

三、GDI 抓取:最稳的"老实人"

GDI 那一套是上世纪就有的老接口,几乎所有 Windows 程序都能用,兼容性拉满。它的工作方式朴素得多:你申请一张图,系统用 GDI 把桌面"画"到这张图上返回给你。

问题也正出在这"画"字上:

  • 它每次都要走一遍合成,不管屏幕变没变。
  • 它不知道也没兴趣知道"有没有变化",你问它要,它就画一张给你。

所以 GDI 的开销是"恒定"的——你每秒问 60 次,它就老老实实画 60 次,哪怕屏幕上啥都没动。

四、实测对照:7 倍与 300 倍是怎么来的

项目在 Windows 11 上做过一轮实打实的采集性能验证(双显示器、主屏 2560×1440)。结果非常刺眼:

后端有新帧时(画面在动)画面静止时
dxcam(DXGI)✅ 首选4.65 ms0.11 ms
mss(GDI) 回落33 ms33 ms(无论变没变都要付)

把这两个数摆一块:

  • 画面在动时:4.65 ms 对 33 ms,dxcam 快约7 倍。
  • 画面静止时:0.11 ms 对 33 ms,差距拉到约300 倍。

一倍是快,七倍是爽,三百倍……那已经不是同一个量级的物种了。

五、为什么静止时差距能拉到 300 倍

关键就在前面说的那句话:DXGI知道"没变化"。

项目源码里,dxcam 的抓帧函数长这样(示意):

defgrab(self,force:bool=False)->Optional[np.ndarray]:# new_frame_only=not force:# force=False 时只领"新产生的帧"# 画面没变化 -> 系统压根没新帧 -> 直接返回 Nonereturnself._cam.grab(new_frame_only=notforce)

也就是说,当你盯着屏幕发呆、文档半天没动时,dxcam 去系统领帧,系统说"没有新东西",它立刻返回None——整个过程的代价只有 0.11 ms,因为根本没做实质性的拷贝和搬运。

而 mss 不管这些:你grab()一次,它就老老实实把桌面重画一遍塞给你,雷打不动 33 ms。

为什么"静止"对远程桌面至关重要?

因为真实办公场景里,屏幕绝大部分时间是静止的。你看文档、想思路、读代码,可能好几秒画面都不带动一下。在这种"大部分静止、偶尔更新"的负载下,DXGI 的 300 倍优势会被真实流量放大成肉眼可见的体验差异:采集线程几乎不占 CPU,整台被控机安安静静,只有你真的动了一下鼠标、敲了一个字,它才"醒来"工作。

六、DXGI 的两个硬限制,逼出 mss 回落

既然 DXGI 这么香,能不能只用它、把 GDI 删了?不能。因为 DXGI 桌面复制有两个系统级的硬限制,项目源码的注释里写得很直白:

  1. 每个显示器只允许有一个复制器。也就是说,同一块屏幕,谁先占了桌面复制这个能力,别人就用不了。如果你开着 OBS、录屏软件、某些截图工具,甚至 Xbox Game Bar,它们可能已经在用这个复制器了,你的程序再去初始化就会失败。
  2. RDP 远程会话下不支持。如果你的被控机本身是通过 Windows 远程桌面(RDP)连上去的,在那种会话里桌面复制 API 直接不可用。

这两个限制意味着:DXGI 不是总能成功初始化。你没法赌用户的环境永远干净,所以项目必须把 mss(GDI) 当备胎留着——dxcam 初始化失败,自动回落 mss,功能不受影响,只是采集慢约 7 倍。

这正是源码里make_capturer的逻辑:backend取auto时先试 dxcam,抛异常就 log 一句"dxcam 不可用,回落 mss",然后建 mss 采集器。用户几乎无感,顶多看到启动日志里多一行提示。

七、拒绝访问(0x80070005)到底是什么意思

dxcam 初始化失败时,最常见的错误码是_E_ACCESSDENIED = -2147024891,也就是十六进制的0x80070005——经典的"拒绝访问"。

光抛一个数字给用户,等于没说。项目源码专门写了个_dxgi_hint函数,把这个冰冷的 HRESULT 翻译成人话:

  • 原因通常是:该显示器已被别的程序占用桌面复制器(Windows 限制每个显示器同时只能有一个)。
  • 常见占用者:OBS、录屏/截图工具、其他远程桌面客户端、Xbox Game Bar。
  • 另一个可能:会话处于锁定状态,或显示器已关闭。
  • 排查建议:关掉上述程序后重试,也可以先跑自检看是否恢复。
  • 最关键的一句:不影响使用,会自动回落到 mss/GDI,只是采集慢约 7 倍。

这点很关键。新手看到"拒绝访问"四个字容易慌,以为程序挂了。其实它只是换了个更慢但稳的采集方式继续干活。把原因和后果讲清楚,焦虑就消了一大半。

八、processor_backend=“numpy”:绕开 opencv 的小心思

dxcam 创建时有一行很容易被忽略的参数:

self._cam=dxcam.create(output_idx=output_idx,output_color="RGB",processor_backend="numpy",# 关键:绕开对 opencv(cv2) 的依赖)

processor_backend控制 dxcam 内部怎么处理拿到的画面帧。默认情况下它可能依赖 opencv(cv2) 来做后处理。但项目用的是"numpy"后端——直接拿 numpy 数组,完全不碰 opencv。

为什么要绕开?因为少一个依赖,就少一堆麻烦:

  • 打包体积更小,依赖树更干净。
  • 不会出现"运行时找不到 cv2"这类只在打包后才暴露的灵异问题(这个项目在 PyInstaller 打包时确实踩过 dxcam 编译扩展的坑,后面工程化那篇会细讲)。

对采集功能本身没影响,输出同样是规整的 RGB numpy 数组,后面差分编码照常进行。这是一个"提前规避风险"的典型取舍。

九、force 抓帧:新 Viewer 进房必须有首屏

最后说一个容易被忽略、但少了它会出大问题的设计:抓帧函数支持force=True。

rgb=cap.grab(force=want_keyframe)

回顾一下:画面完全静止时,dxcam 永远返回None。这在前面的"300 倍优势"里是好事。但它会带来一个副作用——

设想这样一个场景:你的 Viewer(控制端)刚连上来,想立刻看到公司电脑现在的画面。可偏偏此刻公司那台电脑桌面是静止的。如果按"没新帧就返回 None"的默认逻辑,dxcam 会一直返回None,Viewer 永远等不到第一帧,屏幕一片黑,直到你动一下鼠标为止。

这显然不能接受。新连上的 Viewer 必须立刻看到当前画面(首屏)。

所以源码的套路是:当需要关键帧(首帧、或 Viewer 主动请求、或尺寸变化)时,把force设成True。此时 dxcam 的new_frame_only被设为False,它会回退到"上次缓存的那一帧"返回给你——哪怕画面没变化,也能拿到一张当前画面发出去当作首屏关键帧。

一句话总结这个机制:静止时 dxcam 不主动给帧是为了省,但"该给的时候"(新连接进场)必须强制给一张,否则对方看不到东西。这个force开关,就是"省"和"必须给"之间那个阀门。

十、采集在整条链路里到底占多少

前面给的 4.65 ms / 33 ms 是"抓一帧"的纯开销,有人会问:整条链路一帧才 29.7 ms,采集占这点是不是无关紧要?恰恰相反,把这两个数摆进整帧预算里看,结论很有意思:

  • DXGI 路径:抓帧 4.65 ms,占 29.7 ms 整帧预算的约16%。剩下 25 ms 留给差分、atlas、JPEG,绰绰有余,20 fps 轻松达标。
  • GDI 回落路径:抓帧 33 ms,已经超过整帧 29.7 ms 的预算了——也就是说一旦回落到 mss,光抓帧就把帧率天花板按死在 30 fps 以下,再怎么优化编码都补不回来。

这正是"采集是整条链路最硬的固定开销"那句话的含义:它慢,后面全慢;它快,后面才有腾挪空间。所以把采集后端选对,不是"锦上添花",是决定整条链路能不能跑起来的地基。也解释了为什么项目宁愿为 DXGI 的兼容性坑(占用冲突、RDP 不支持)专门写回落逻辑,也不干脆只用 GDI 图省事——用 GDI 的话,这项目的帧率目标从根上就达不到。

还有一个容易混的点:静止时的 0.11 ms 不算进"变动帧预算",它发生在"没新帧"的空轮询里,靠LOOP_SLEEP_IDLE = 0.002这种小步进轮询控制,既不空转烧 CPU,又不拖慢真正的变动帧。所以 DXGI 的 300 倍优势,省的是"安静时的每一秒",而不是"变动时的那一帧"。

十一、小结

采集后端这道选择题,结论其实很朴素:

  • 主力用 DXGI(dxcam):变动时快 7 倍、静止时快 300 倍,办公场景几乎零负担。
  • 备胎留 GDI(mss):因为 DXGI 有"每显示器单复制器"和"RDP 不支持"两个硬限制,环境不干净就得回落,功能不能断;但回落后抓帧 33 ms 会吃光帧率预算,所以它是兜底而非常态。
  • 错误要翻译成人话:0x80070005不是灾难,告诉用户"只是慢点"即可。
  • 少依赖是福:processor_backend="numpy"绕开 opencv。
  • 首屏不能等:force抓帧保证新连上的 Viewer 立刻有画面看。
  • 采集是地基:它占整帧预算 16%(DXGI)或超 100%(GDI),选错后端后面再怎么优化都救不回帧率。
返回列表