☰
Python屏幕操作实战:截图、DPI坐标与区域变化监测
2026/10/31 13:21:32 网站建设 项目流程

1. 日志第10篇的转折点:我为啥停下了刷语法题

写这个系列写到第10篇,节奏其实变了。前面9篇基本都是零散语法、数据结构、文件读写这些东西,像在背单词。到了这一篇,我把手里那个"book管理小脚本"先丢到一边,转去做一件更具体的事:用 Python 去操作屏幕,也就是标题里说的 scre_python——我自己的理解是 screen 相关的那一摊,截图、取像素、区域监测、把屏幕上的东西变成程序能处理的数据。说白了,就是让 Python 长一双眼睛。

这个方向对我来说是有实际驱动的。日常用电脑,总有那么些重复动作:等某个界面出现某个状态、盯着一块区域看它有没有变化、把某个窗口的内容定时存下来。手动做,人会走神;让程序做,就得先解决"程序怎么看到屏幕"这件事。语法题刷得再多,碰到屏幕这块照样抓瞎,因为屏幕操作夹在一堆系统层面的变量中间,文档上不写,报错也不告诉你原因。

这篇日志我打算把第10次学习里真正跑通的东西、踩到的坑、以及那些"文档不会提但你一定会遇到"的细节完整摊开。适合两类人看:一类是刚学完 Python 基础,想找个能看见效果的项目练手的;另一类是想做屏幕监测、自动化采集,结果卡在坐标系或者性能上的。代码我都给到能直接跑的程度,参数也会解释清楚为啥是那个值,不做"复制粘贴能跑就行"的处理。

1.1 第9篇留下的那个半成品脚本

第9篇结尾我留了个半成品:每隔几秒截一次屏,存成带时间戳的图片文件,想用来观察某个界面的变化。当时跑起来了,但问题一堆。跑十分钟,磁盘多了几百张几乎一模一样的图;想只截某个区域,坐标怎么填都对不上;CPU 占用蹭蹭往上走,风扇开始叫。那时候我以为是自己代码写得糙,第10篇重新梳理了一遍,才发现问题根本不在代码风格,而在我对"屏幕"这个东西的理解是错的。

屏幕不是一个固定尺寸的画布。它有缩放比例,有多个显示器,有系统级的 DPI 感知策略,还有窗口叠加的顺序。程序眼里的屏幕和你眼睛看到的屏幕,很可能不是同一张图。这个认知一旦建立,后面很多莫名其妙的 bug 就有了统一的解释。所以这篇日志的第一件事,是把"屏幕"这个概念在程序视角下重新定义一遍,而不是急着写截图代码。

1.2 屏幕操作真正难的地方:三个看不见的变量

我把第10篇里遇到的坑归了三类,后面所有章节基本都围绕它们展开。第一类是缩放。笔记本默认 150% 缩放的时候,逻辑分辨率是 1280×720,物理像素是 1920×1080,两个数字差着一大截,截图库拿到哪个完全取决于它有没有声明 DPI 感知。第二类是坐标系原点。单屏的时候左上角是 (0,0),接了副屏、副屏还放在主屏左边,原点就可能变成负数。第三类是时间。连续截图不是越快越好,帧率、内存、磁盘三者之间是个此消彼长的关系,得按用途定策略。

这三个变量单拎出来都不复杂,叠在一起就很容易让人怀疑人生。我第10篇里有整整一个下午在跟"截图只有左上角一小块"较劲,最后发现就是缩放的问题。所以这篇日志的结构不是按"知识点"排的,是按"我实际撞墙的顺序"排的:先解决看得见的问题(库选型和最小可运行),再解决对不上的问题(坐标和缩放),然后解决跑不久的问题(性能),最后才是让程序理解画面(像素和差异检测)。照着这个顺序走,能少绕不少路。

2. 截图库的三条路线:实测之后的取舍

Python 能截屏的库不止一个,网上搜出来最常见的三个是PIL.ImageGrab、pyautogui和mss。我一开始图省事用了pyautogui,因为它同时还能模拟键鼠,一个库干两件事。跑了两天之后换成了mss,原因不是它"更好",而是它更适合我这种"高频、固定区域、只要像素"的场景。这个取舍过程值得写下来,因为很多人选库是看 star 数,实际上应该看你的使用频率和对返回格式的要求。

先给结论:单次、低频、随手截一张,ImageGrab最省事;需要顺带做点击拖拽这类交互,pyautogui一站式;要循环截、要指定显示器、要拿原始字节自己处理,mss最合适。三个库的安装都很轻,pip install pillow pyautogui mss一条命令搞定,不存在装不上的问题,所以试错成本很低,我建议都装上跑一遍再决定。

2.1 三个库的性能与返回格式对照

我在同一台机器、同一个全屏区域(1920×1080)、连续截 100 次取平均的粗略结果如下。这不是严谨 benchmark,只是给自己选型一个依据,不同机器上数字会有出入,但量级关系基本稳定。

库单次耗时(约)返回类型多显示器支持主要短板
PIL.ImageGrab25–40 msPIL Image 对象弱,参数写起来别扭高频调用时开销明显
pyautogui30–50 msPIL Image 对象一般依赖链长,首次导入慢
mss8–15 ms自带 ScreenShot 对象(含 raw 字节)好,monitors 列表直接给出API 略底层,得自己转格式

差距主要来自底层实现。mss走的是系统原生抓屏接口,拿到的就是原始像素缓冲区,格式转换那一步可以推迟到你真正需要的时候再做;另外两个库默认就把结果包装成 PIL Image,多了一层构造开销。单次看不出来,循环一千次就是几秒和几十秒的差别。

mss的返回对象有三个关键属性:rgb是 RGB 顺序的原始字节,size是 (宽, 高),还有一个bgra是另一种通道顺序。这两个字节串的区别我当初研究了好一会儿——bgra在某些系统上取用更快,因为它更接近底层缓冲的原生顺序。如果你只是存图或者做差分,用rgb就好,通道顺序直观;如果追求极致速度、自己做缓冲区拼接,可以试bgra,但记得后面的处理逻辑要跟着改通道顺序。

2.2 最小可运行代码与第一张图

先上一个能立刻看到效果的版本,用mss截主屏存成 png:

import mss import mss.tools with mss.mss() as sct: # monitors[0] 是所有显示器拼起来的虚拟全屏,monitors[1] 起才是单个显示器 monitor = sct.monitors[1] shot = sct.grab(monitor) mss.tools.to_png(shot.rgb, shot.size, output="first_shot.png") print("尺寸:", shot.size)

这段代码里有几个细节,第一次跑很容易忽略。with语句不是可选的,mss实例内部持有系统资源,不用上下文管理器容易留下垃圾。monitors[1]而不是[0],是因为[0]代表把所有屏幕拼起来的那个虚拟矩形,如果你只有一个屏幕,它和[1]看起来一样,接了副屏就完全不同了。还有to_png的两个参数,第一个必须是rgb字节串,传错了会得到一张颜色错乱的图,这个错误不报异常,只是结果不对,排查起来很费劲。

我第一张图跑出来是偏色的,红蓝对调。原因就是传了bgra进去。这件事让我记住一个原则:截图这类操作,结果不正确往往不报错,你得靠肉眼看,或者靠一个已知颜色的区域去校验。我后来在代码里固定放了一块校验区域(比如任务栏某个位置),每次跑完先比对那一个像素的颜色对不对,能提前发现通道顺序、缩放这类问题。

2.3 为什么把 mss 定为主力,另外两个留作备胎

主力选mss,理由前面说了:快、多屏支持清楚、返回格式适合二次处理。但另外两个我并没有卸掉。ImageGrab我留着做"临时验证",因为它一行就能拿到 PIL Image,配合 PIL 的各种裁剪、缩放、保存方法非常顺手,写一次性脚本的时候不想折腾格式转换。pyautogui留着是因为有些场景光看不够,还得动——比如监测到某个状态之后要点一下确认,这时候截图和点击在同一个库里,代码更连贯。

这个"主力+备胎"的组合是我目前觉得最舒服的。全押一个库,要么性能不够,要么写起来别扭;三个都用,代码里 import 一堆。分工明确之后,每次动手之前先问自己一句"这次是高频连续还是低频一次性",答案就出来了。

3. 抓来的图和屏幕对不上:一次完整的坐标排错

这一段是我第10篇里花时间最多的部分,也是我觉得最值得写清楚的部分。现象很具体:我想截屏幕上 (200, 150) 到 (800, 600) 这个矩形区域,代码写的是{"left":200, "top":150, "width":600, "height":450},跑出来保存的图里,内容明显是屏幕左上角的一块放大版,右下角的部分根本没截到。改小坐标能对上,改大就错,错得还有规律。

我当时的排查顺序是:先怀疑宽高参数理解错了,改成只截一个正方形试试,还是不对;再怀疑是不是多显示器的问题,拔掉副屏,依旧;最后才想到缩放。整个过程大概三个小时,其中前两个小时都在错误的方向上。写出来是想说明:这类问题的正确排查顺序应该是先看系统缩放,再看显示器数量,最后才看代码,因为前两者是环境问题,代码再对也没用。

3.1 现象拆解:截图区域整体缩水了一圈

仔细看那张错误的图,规律是:实际截到的内容,比我指定的区域"大"了,也就是物理像素范围超出了我的预期。我的坐标是按逻辑像素写的,而截图接口拿的是物理像素。系统缩放 150% 的时候,逻辑坐标 800 对应物理坐标 1200,我把 800 当成物理宽度传进去,相当于只截了逻辑上 533 宽的区域——但显示的图又被拉伸回正常大小,看起来就像"放大"了。

这个换算关系用一个表说清楚。假设显示器物理分辨率 1920×1080,系统缩放设为不同值:

系统缩放逻辑分辨率逻辑坐标 (800,600) 对应的物理坐标
100%1920×1080(800, 600)
125%1536×864(1000, 750)
150%1280×720(1200, 900)
200%960×540(1600, 1200)

看这张表就明白了,缩放不是 100% 的时候,同一个逻辑坐标在不同缩放下的物理位置完全不同。写死坐标的脚本换台机器就废,根本原因是没处理这个换算。

3.2 让进程声明 DPI 感知:一行代码解决大半问题

Python 进程在 Windows 上默认是"DPI 不感知"的,系统会把它的窗口(如果有)和它拿到的屏幕信息都按逻辑像素缩放后给它,所以你看到的分辨率是 1280×720 而不是 1920×1080。解决方式是启动时声明感知级别:

import ctypes import sys def enable_dpi_awareness(): if sys.platform != "win32": return try: # 2 表示 per-monitor DPI aware,多显示器各自缩放时最准确 ctypes.windll.shcore.SetProcessDpiAwareness(2) except Exception: # 老系统没有 shcore,退回 user32 的旧接口 ctypes.windll.user32.SetProcessDPIAware() enable_dpi_awareness() import mss # 注意:必须在声明感知之后再导入 with mss.mss() as sct: print(sct.monitors[1]) # 这时拿到的应该是 1920x1080 的物理尺寸

有两个点必须强调。第一,SetProcessDpiAwareness必须在任何 GUI 相关库导入之前调用,如果你先 import 了mss或者 PIL 里的相关模块,再调用就不生效了。我一开始把这段放在文件末尾,怎么调都没变化,折腾了很久。第二,参数2是"每显示器感知",适合多屏且各屏缩放不同的情况;如果只有一块屏,参数1(系统级感知)也够用。老系统上shcore可能不存在,所以要 try/except 退回旧接口。

这一步做完,截图区域基本就对了。但"基本"两个字很重要,多显示器场景下还有后续问题。

3.3 多显示器下的原点偏移

接上副屏之后又出了新状况:副屏放在主屏左边的时候,副屏的left是负数。因为 Windows 的虚拟桌面坐标系是以主屏左上角为 (0,0) 向右下延伸的,副屏在左边,它的区域就是left = -1920。这时候如果你按"左边从 0 开始"的思路写坐标,截出来的是主屏的内容,副屏根本碰不到。

正确的做法是不要自己算坐标,直接问mss要:

with mss.mss() as sct: for idx, mon in enumerate(sct.monitors): print(idx, mon) # 输出类似: # 0 {'left': -1920, 'top': 0, 'width': 3840, 'height': 1080} # 1 {'left': 0, 'top': 0, 'width': 1920, 'height': 1080} # 2 {'left': -1920, 'top': 0, 'width': 1920, 'height': 1080}

monitors[0]是全部拼起来的虚拟区域,它的left可能是负数,宽度是两块屏之和。monitors[1]是主屏,monitors[2]起是副屏。想截副屏就传monitors[2],不要自己拼坐标。这个设计很省心,我后来所有代码里凡是要截整块屏,一律从monitors列表里取,从不硬编码。

3.4 一个可复用的区域换算小函数

如果确实需要在"逻辑坐标"和"物理坐标"之间换算(比如你的坐标是从别的工具里量出来的,那些工具可能是逻辑像素),可以包一个函数:

def scale_region(region, scale=1.5): """把逻辑坐标区域按缩放比转成物理像素区域""" return { "left": int(region["left"] * scale), "top": int(region["top"] * scale), "width": int(region["width"] * scale), "height": int(region["height"] * scale), }

这个函数很笨,但够用。真正麻烦的是"scale 从哪来"。最稳的办法是拿sct.monitors[1]的width除以系统报告的逻辑宽度,两者一比就是缩放比。不过一旦你按 3.2 的方式声明了 DPI 感知,其实大部分时候不需要换算,因为拿到的直接就是物理像素。这个函数我留着,专门应付"坐标来源是别的软件"这种情况。

4. 从截一张到连着截:帧率、内存和落盘的取舍

单张截图跑通之后,我自然就想让它连续采。第9篇那个"每隔几秒存一张"的脚本就是这么来的,结果跑出了三个问题:图堆成山、内存往上走、CPU 吃满一个核。第10篇重新设计了一遍,核心思路是先想清楚你要这张图干什么,再决定存不存、存多久、存成什么。

如果你的目的是"看某个区域有没有变化",那根本不需要存图,甚至不需要完整的图,只要那个区域的一小片像素就够了。如果你的目的是"事后回看",那要考虑压缩和清理策略。如果目的是"实时判断然后触发动作",那图在内存里过一遍就该丢掉。这三种目的对应三种完全不同的写法,混在一起写就是第9篇那个四不像。

4.1 三种循环写法的实测对比

我试了三种写法,都在同一台机器上跑 60 秒,目标区域 800×450。

第一种是定时 sleep:

import time import mss import mss.tools region = {"left": 200, "top": 150, "width": 800, "height": 450} with mss.mss() as sct: end = time.time() + 60 i = 0 while time.time() < end: shot = sct.grab(region) mss.tools.to_png(shot.rgb, shot.size, output=f"frame_{i:05d}.png") i += 1 time.sleep(0.1) print("总帧数:", i)

这种写法看着合理,实际帧率很不稳。grab本身有耗时,to_png有耗时,sleep又有调度误差,三者叠加下来,"每 0.1 秒一帧"实际上可能是 0.13 秒一帧,跑一个小时就差出好几分钟。如果你的判断逻辑依赖时间戳精确,这个误差会累积成大问题。

第二种是按需计算下次时间点,用绝对时间对齐:

import time interval = 0.1 next_t = time.time() with mss.mss() as sct: end = next_t + 60 while time.time() < end: shot = sct.grab(region) # 处理逻辑 next_t += interval sleep_for = next_t - time.time() if sleep_for > 0: time.sleep(sleep_for)

这种写法把误差控制在一帧以内,长时间跑下来总帧数更接近理论值。代价是如果某一帧处理超时了,后面的帧会追赶,可能出现连发。

第三种是不存图、只处理,我在实际项目里最终选的就是这种:

import time import mss import numpy as np with mss.mss() as sct: end = time.time() + 60 frames = 0 while time.time() < end: raw = sct.grab(region).rgb arr = np.frombuffer(raw, dtype=np.uint8).reshape(450, 800, 3) # 只取一小块做判断,其余丢掉 sample = arr[100:120, 200:260] frames += 1

这个版本 60 秒能跑几千帧,因为没有任何落盘和格式转换开销。三种写法的差别不在代码长短,在于你要什么。我第9篇的错误就是用在"监测"场景的代码里加了"存档"的动作,两件事混在一起。

4.2 内存为什么悄悄地涨

第9篇跑久了内存上涨,我一开始以为是mss的锅,后来发现是自己写的问题。grab每次返回的对象里带着原始字节,如果你把 PIL Image 存进一个全局 list 里"留着备用",那就是几百兆直接压在那。更隐蔽的是某些图像差异计算里,numpy 转换会产生临时数组,如果循环里不断 append 到列表,numpy 数组本身不会被自动回收。

排查内存问题的土办法很有效:循环里每 100 帧打印一次len(obj_list)和进程内存占用,如果两者同步上升,那就是引用没释放。我后来加的规则很简单:循环体内不保留任何跨帧的数组引用,需要比较的两帧只用相邻的两帧,用完就让它被覆盖。内存曲线立刻就平了。

还有一个容易忽略的点是to_png的调用频率。PNG 压缩是 CPU 密集操作,一张 800×450 的图压一次大概 3-8 ms,连续调用既费 CPU 也让内存里短暂堆积多个压缩缓冲区。如果只是自己看,其实 JPEG 更快,或者干脆存 numpy 的.npy,读回来还不用解码。

4.3 落盘策略:什么该存,什么只该记差异

我现在的做法是分层。第一层是原始采样,不落盘,只在内存里保留最近两帧用于比较。第二层是变化事件,检测到差异超过阈值的时候,记一条日志(时间戳、差异值、变化区域的外接框),这个日志很小,一天也就几十 KB。第三层是证据截图,只在变化发生时存一张,文件名带上时间戳和差异值。

这样跑一天,磁盘上最多几十张图,而不是几万张。回看的时候也有重点,因为存下来的都是"有事发生"的时刻。这个设计后来被我复用到好几个地方,核心思想就是:存储是有成本的,只存判断依据不足的那部分。

5. 让程序看懂画面:取点、容差和区域差分

截图只是拿到数据,真正有用的是从数据里读出信息。这一段讲三个从简到繁的办法:取单点像素、小区域均值、整区域差分。三者适用场景不同,复杂度也不同,我建议按顺序都试一遍,你会发现很多需求其实第一个就够了。

5.1 单点取样:最省事的判断手段

如果你的判断目标很明确,比如"某个按钮变绿了""某个指示灯亮了",那完全不需要截一整块,取一个像素就够。mss拿到原始字节之后,直接按索引算位置:

import mss with mss.mss() as sct: shot = sct.grab(sct.monitors[1]) # 取 (500, 300) 处的像素,rgb 是连续字节,每像素 3 字节 idx = (300 * shot.size[0] + 500) * 3 r, g, b = shot.rgb[idx], shot.rgb[idx + 1], shot.rgb[idx + 2] print(r, g, b)

这个索引计算法看着原始,但速度极快,适合每秒几十次的判断。代价是脆弱:UI 一改版、窗口一挪、分辨率一变,坐标全废。所以我的做法是把这些坐标写成配置,跑之前先打印出来核对,改动之后也要重新核对。

取点这件事有个坑我踩过:取的可能是亚像素或者抗锯齿边缘。文字、圆角、渐变区域的采样值会随渲染状态轻微波动,同一个"绿色按钮",这一帧是 (34,177,76),下一帧可能是 (33,176,75)。如果你用==判断,几乎每次都失败。所以必须带容差,见下一节。

5.2 颜色比较的正确姿势:别用等号

颜色匹配我用了两种判定,简单场景用逐通道差值,复杂场景用欧氏距离。逐通道的条件是三个通道的差都小于阈值,实现简单,返回布尔值直观:

def color_close(c1, c2, tol=12): return all(abs(a - b) <= tol for a, b in zip(c1, c2))

欧氏距离更适合比较"整体接近程度",也方便排序:

def color_distance(c1, c2): return sum((a - b) ** 2 for a, b in zip(c1, c2)) ** 0.5

阈值的取法很讲究。我实测的经验值是:纯色块的 UI 元素,tol=10到15足够;带渐变或者阴影的,得放宽到25甚至40,但放宽之后误判率会上升。这里没有通用答案,得拿实际截图统计一下同一位置的取值分布——连续采 100 帧,看这个像素的 R/G/B 三个通道各自的最大最小值,阈值取范围的一半左右比较稳。

还有一个反直觉的点:不要用亮度或者灰度做判断。很多教程建议先转灰度再比较,省计算。但灰度会丢掉颜色信息,红色和绿色在灰度上可能非常接近,判断"指示灯是红还是绿"这种需求直接失效。除非你的目标本来就是明暗变化,否则老老实实比 RGB。

5.3 区域差分:用两张图相减找变化

整区域判断变化,最直接的办法是两帧相减再统计。注意数据类型要先转成有符号的,否则相减会下溢变成一个巨大的正数:

import numpy as np def frame_diff(a, b, threshold=20): # a, b 都是 uint8 的 (h, w, 3) 数组 diff = np.abs(a.astype(np.int16) - b.astype(np.int16)) mask = diff.max(axis=2) > threshold # 任一通道超过阈值就算变化 changed = int(mask.sum()) total = mask.size return changed / total, mask

这个函数返回变化像素的占比和掩码。占比可以用来做阈值判断,掩码可以用来找变化区域的外接框。threshold的取值同样要实测,我一般在 15 到 30 之间调,太低会被噪点触发,太高会漏掉细微变化。

np.abs那一步的astype(np.int16)是关键。我第一次写的时候忘了转,255 - 0得到255没问题,但0 - 255在 uint8 下会变成1(溢出),导致暗变亮的区域完全检测不到。这个 bug 很隐蔽,因为结果不是全错,是一半对一半错,看起来像"某些区域不敏感"。加一句类型转换就解决了,但找这个原因花了我不少时间。

6. 一个小工具落地:屏幕区域变化记录器

把前面这些拼起来,我做了个能实际用的小工具,功能是:盯住一个指定区域,变化超过阈值就记一条日志并存一张截图,其余时间静默。这个工具不复杂,但把坐标、性能、颜色、落盘四个问题全串了一遍,很适合作为学习日志的收尾项目。

6.1 需求拆解与目录结构

需求很朴素,但拆开有五个点要定:盯着哪块区域(配置化)、多久看一次(间隔)、怎么算变化(阈值)、变化了做什么(存图+日志)、跑多久(时长或者手动停)。目录我这样组织,简单但够用:

screen_watch/ ├── config.json # 区域、间隔、阈值 ├── watch.py # 主程序 ├── logs/ # 变化日志 └── shots/ # 证据截图

配置文件单独拆出来,是因为区域坐标总是要调的。每次改代码再跑太麻烦,改 json 重跑就行。这个习惯我是被逼出来的——之前坐标写死在代码里,改一次重启一次,后来干脆全外置。

6.2 核心逻辑与逐段说明

主程序不长,关键在顺序:

import json import os import time import ctypes import sys def enable_dpi(): if sys.platform == "win32": try: ctypes.windll.shcore.SetProcessDpiAwareness(2) except Exception: ctypes.windll.user32.SetProcessDPIAware() enable_dpi() import mss import numpy as np with open("config.json", encoding="utf-8") as f: cfg = json.load(f) region = cfg["region"] interval = cfg["interval"] threshold = cfg["threshold"] min_ratio = cfg["min_change_ratio"] os.makedirs("logs", exist_ok=True) os.makedirs("shots", exist_ok=True) prev = None with mss.mss() as sct: while True: shot = sct.grab(region) cur = np.frombuffer(shot.rgb, dtype=np.uint8).reshape( shot.size[1], shot.size[0], 3 ) if prev is not None: diff = np.abs(cur.astype(np.int16) - prev.astype(np.int16)) mask = diff.max(axis=2) > threshold ratio = mask.sum() / mask.size if ratio > min_ratio: ts = time.strftime("%Y%m%d_%H%M%S") with open(f"logs/{ts}.txt", "w", encoding="utf-8") as lf: lf.write(f"{ts} ratio={ratio:.4f}\n") mss.tools.to_png(shot.rgb, shot.size, output=f"shots/{ts}.png") prev = cur time.sleep(interval)

prev = cur这行看起来平淡,其实是整个设计的关键。它只保留上一帧,不积累历史,内存恒定。注意这里是引用赋值,下一轮cur被重新创建,prev指向旧对象,旧对象自然被回收,不会泄漏。

min_change_ratio的意思是"变化像素占全区域的比例超过多少才算真变化"。这个参数专门用来过滤噪点。我实测在动态背景(比如有光标闪烁)下,0.002到0.005比较合适,纯静态界面可以低到0.001。

6.3 参数怎么调:给几个实测参考值

调参这件事没法一概而论,但我可以把我的起点值列出来,你从这些值往上往下试,比从零猜要快。

参数建议起点调大的后果调小的后果
interval0.5 秒响应变慢,可能漏掉短暂变化CPU 占用上升,噪点判定变敏感
threshold25漏掉细微颜色变化噪点触发频繁
min_change_ratio0.003小面积变化检测不到轻微抖动就报警

这三者是有联动的。间隔越短,相邻两帧越接近,噪声影响越小,阈值可以调低;间隔越长,画面自然变化越大,阈值得调高。我最初把间隔设成 0.05 秒、阈值设成 15,结果日志刷屏,因为光标闪烁都被算作变化。后来改到 0.5 秒 + 25 + 0.003,一天下来日志只有几十条,基本都是真正想抓的事件。

7. 写完这篇日志之后的几点真实体会

第 10 篇写完,回头看,最有价值的不是哪段代码,而是那个"先看环境再看代码"的排查顺序。屏幕相关的问题几乎都是环境问题:缩放、多屏、权限、系统版本。代码写错反而是少数情况,而且代码错了通常有异常,环境错了往往只是结果不对,连报错都没有。

还有一个体会是关于"取点"和"取图"的选择。我一开始总想着拿全图再说,后来发现绝大多数需求其实只需要极少数像素。取点让代码从几百行降到几十行,从每秒几帧变成每秒几百帧,稳定性也更好。这个转向是这次学习里最大的收获,虽然听起来很朴素。

7.1 三个反复出现的低级错误

第一个是忘了在 import 之前声明 DPI。这个错误我犯了不止一次,因为代码顺序看着无所谓,实际影响巨大。现在我的模板文件里前几行固定就是这段,先写死再说。

第二个是字节序和通道顺序混淆。rgb和bgra传错,图能生成,就是颜色不对。判断标准我现在改成"先存一张,肉眼确认颜色",确认一次之后就不容易错了。

第三个是数值类型溢出。图像相减、求和、均值,每一步都可能溢出。我的习惯是所有涉及相减的地方统一转int16或int32,求完再转回uint8保存。多写一行转换,省掉几个小时排查。

7.2 接下来想试的方向

下一步想往两个方向走。一是把"区域监测"和"条件触发"接起来,比如监测到某个状态就自动做一次记录,形成一个闭环。二是试试不依赖固定坐标的方案,用颜色或者形状去定位目标,这样界面一动脚本不用跟着改。后者难度明显高一截,但价值也大。第 11 篇大概会从模板匹配这一类的基础方法写起,跑通了再来记一笔。

写日志这件事本身也有点意思。第 1 篇到第 5 篇,我记的基本是"今天学了什么语法",回头翻完全没营养。从第 8 篇开始改成"今天撞了什么墙、怎么出来的",翻起来才有用。这次这一篇尤其明显,写的过程中又发现了两个自己没注意到的细节——比如time.sleep的实际精度问题,我是写到那一段才想起来补测的。看来记录不只是留档,逼着自己把话说清楚,本身就是一次复盘。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询