Python屏幕操作实战:截图、DPI坐标与区域变化监测

发布时间:2026/10/31 13:21:36
Python屏幕操作实战:截图、DPI坐标与区域变化监测 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截主屏存成 pngimport 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, outputfirst_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, scale1.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。第一种是定时 sleepimport 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, outputfframe_{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, dtypenp.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, tol12): 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 元素tol10到15足够带渐变或者阴影的得放宽到25甚至40但放宽之后误判率会上升。这里没有通用答案得拿实际截图统计一下同一位置的取值分布——连续采 100 帧看这个像素的 R/G/B 三个通道各自的最大最小值阈值取范围的一半左右比较稳。还有一个反直觉的点不要用亮度或者灰度做判断。很多教程建议先转灰度再比较省计算。但灰度会丢掉颜色信息红色和绿色在灰度上可能非常接近判断指示灯是红还是绿这种需求直接失效。除非你的目标本来就是明暗变化否则老老实实比 RGB。5.3 区域差分用两张图相减找变化整区域判断变化最直接的办法是两帧相减再统计。注意数据类型要先转成有符号的否则相减会下溢变成一个巨大的正数import numpy as np def frame_diff(a, b, threshold20): # a, b 都是 uint8 的 (h, w, 3) 数组 diff np.abs(a.astype(np.int16) - b.astype(np.int16)) mask diff.max(axis2) 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, encodingutf-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_okTrue) os.makedirs(shots, exist_okTrue) prev None with mss.mss() as sct: while True: shot sct.grab(region) cur np.frombuffer(shot.rgb, dtypenp.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(axis2) threshold ratio mask.sum() / mask.size if ratio min_ratio: ts time.strftime(%Y%m%d_%H%M%S) with open(flogs/{ts}.txt, w, encodingutf-8) as lf: lf.write(f{ts} ratio{ratio:.4f}\n) mss.tools.to_png(shot.rgb, shot.size, outputfshots/{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的实际精度问题我是写到那一段才想起来补测的。看来记录不只是留档逼着自己把话说清楚本身就是一次复盘。