uiautomation+OpenCV+EasyOCR:PC微信自动化三件套实战
如果你写过Windows端自动化多半会有这种体验窗口句柄拿到了控件树也遍历了结果真正有用的东西一个都读不到。我最初盘算用Python给PC微信做一套自动化脚本时第一反应就是uiautomation——标准的Windows UI控件总该能拿到吧。结果第一次调用GetChildren()打印日志我对着满屏的Unknow控件发了好一会儿呆。后来我重新想明白了这件事。PC微信的界面是半自绘半系统的混合体框架、标题栏、部分菜单走的是标准控件但大量核心区域——聊天记录、朋友圈、公众号文章列表——全是自绘渲染UIAutomation根本摸不到。纯靠uiautomation做功能就像隔着毛玻璃去抓里面的东西看得见轮廓使不上劲。所以我才把这套组合放一起uiautomation负责拿标准控件OpenCV负责给固定样式的按钮和图标做模板匹配EasyOCR负责把自绘区域里的动态文字重新读出来。三个工具各管一段拼成一条相对完整的自动化链路。这篇文章就是把我在这个方向上从零折腾到能跑的完整记录包括控件树的实测边界、模板匹配的参数心得、OCR坐标回传的坑以及三者融合时怎么判断该信谁。1. 为什么是uiautomationOpenCVEasyOCR三件套一次选型实录1.1 只靠uiautomation的幻想在上手第一周就破了我最早的项目目标是给PC微信做一个个人用的消息辅助工具核心需求很朴素能自动定位到某个联系人进入聊天窗口读取最后几条消息然后根据规则回复。听起来就是经典的UI自动化三板斧定位、读取、操作。所以我一开始想得很简单uiautomation既然能拿到控件树那聊天消息肯定也是某种Text控件的Name属性取出来就行了。实测下来结果远没有想象中乐观。uiautomation能拿到微信主窗口Class Name比较稳定一般是WeChatMainWndForPC搜索深度设为1就能定位到。但当我遍历整个窗口子控件时得到的结构大致是区域控件类型能否拿到Name实际可用性标题栏TitleBarControl能基本没意义左侧导航ListControl / ListItem部分能联系人列表项偶尔有Name聊天记录区CustomControl自绘拿不到完全不可靠输入框EditControl能可以SetValue/发送键盘搜索框EditControl能可以文本输入朋友圈/公众号内容CustomControl自绘拿不到完全不可靠也就是说uiautomation最大的价值集中在能输入的控件和能定位的骨架上。它告诉我窗口在哪儿、最大最小化状态、输入框能不能操作但对于整个自绘区域它就是个瞎子。聊天记录、公众号推送标题、朋友圈文字这些恰恰是自动化里最需要的信息全部读不到。1.2 三件套的分工逻辑控件为主、图像兜底、OCR补位既然单个工具不完整就只能组合。我给三件套定的角色很简单uiautomation是骨架负责窗口管理和能控件的交互OpenCV是眼睛的静态层专门认那些样式基本不变的按钮、图标、小红点EasyOCR是眼睛的动态层去读自绘区域里会变化的文字。选择OpenCV而不是别的图像识别方案有两点原因一是cv2.matchTemplate做模板匹配轻量、快、完全本地运行对于按钮这类样式固定的东西一张模板图加上阈值判断就够了二是我需要精确定位到像素级坐标模板匹配天然给出匹配矩形的左上角换算出中心点就能点击。选择EasyOCR而不是PaddleOCR或Tesseract主要是看中它的平衡性。EasyOCR的中文模型识别准确率够用部署简单pip install easyocr之后会自动下载模型不需要额外装Paddle这种重依赖推理框架。Tesseract的中文识别率在复杂背景下确实差一截我早期拿公众号封面图去跑十个标题错一半。EasyOCR虽然慢一点但结果干净得多对于识别标题→定位坐标→点击这类场景慢一点能换来准一点值得。所以三件套的分工不是谁好用用谁而是互为补位。遇到一个目标时先问控件树能不能拿到Name能拿到就直接用拿不到就问这个目标的样子是不是固定模板是就上OpenCV样子会变那就截个图丢给EasyOCR读文字。这条降级链我在后面的融合框架里细讲。2. uiautomation控件的边界测量微信哪些界面真正可读2.1 用控件树给微信拍X光片在正式动手写逻辑之前花时间看懂微信的控件树绝对比中途反复试错划算。我用uiautomation写了一个简单的遍历脚本把微信主窗口下所有子控件的类型、Name、Class Name、AutomationId按层级打印出来。这一步像是给窗口拍X光片非常直观地告诉你哪些地方有骨头哪些地方是雾。import uiautomation as auto # 定位微信主窗口searchDepth1 表示只在根层级找 window auto.WindowControl(searchDepth1, ClassNameWeChatMainWndForPC) if not window.Exists(3, 1): print(未找到微信主窗口请确认微信已登录且未被隐藏到托盘) raise SystemExit(1) window.SetActive() def dump_controls(control, depth0): prefix * depth # ControlTypeName 是像 ButtonControl、EditControl 这样的友好名称 # Name 是真正有用的文本属性 print(f{prefix}{control.ControlTypeName} | Name{control.Name} | fClassName{control.ClassName} | AutoId{control.AutomationId}) for child in control.GetChildren(): dump_controls(child, depth 1) dump_controls(window)跑完这个脚本整个窗口的信息就摊在眼前了。注意几个经验第一Class Name比窗口标题稳定得多窗口标题会随聊天对象和未读数变化但类名基本不变第二很多控件的AutomationId是空的所以不要依赖它做定位优先用Name或层级关系第三微信窗口可能有多开的情况WindowControl()默认拿第一个找到的实例如果要做多账号管理需要遍历所有窗口再根据标题或者进程ID区分。2.2 拿不到的控件和拿得到但会变的属性真正写逻辑时你会发现拿不到的区域是大多数。我实测过几类高频需求的可行性聊天消息内容。历史消息区是典型自绘控件uiautomation遍历过去就是一个CustomControl它的子控件不会包含每条消息的文本。想读取最后一条消息内容靠uiautomation是做不到的只能靠截图OCR。这是我在这个项目里最核心的认知转变不要试图去解读自绘区域要把它当图片对待。联系人列表项。稳定性比较差。某些微信版本下左侧联系人列表项是ListItemControl而且Name就是联系人名称或备注名这时候直接遍历找Name是最高效的。但也遇到过升级版本后Name全空的情况只能靠OCR识别列表区域的文字来定位。所以我会把控件查找写成一个可能成功的函数失败就自动降级。输入框。微信的输入框在不同版本下控件类型不完全一样常见的是EditControl或RichEditBoxControl。只要控件存在SetValue不一定每次都灵特别是新版本输入框看起来像编辑控件但内部是自绘的这时直接SendKeys更可靠。我的做法是优先SetValue验证失败退回window.SendKeys。按钮。发送按钮有时候能拿到ButtonControl有时候拿不到。就算能拿到直接Control.Click()在自绘边界处也可能点不准。相比之下截图找按钮位置的方案稳定得多。所以我后来对按钮类操作一律走OpenCV只有对输入框这类标准编辑控件才继续信任uiautomation。这些边界测量给我的结论是uiautomation把窗口的框架交到我手里但内容需要另外两只眼睛来看。听起来多绕了一圈实际跑起来反而是最稳的路线。3. OpenCV模板匹配的正确姿势从截屏到可点击坐标3.1 截图坐标系与全屏坐标的换算OpenCV模板匹配的第一步是截图。项目里我用的方案是用PIL.ImageGrab整屏截图再把截图交给OpenCV处理。整屏截图的好处是坐标系简单截图里的坐标就是屏幕坐标不需要额外的换算。代价是大图处理慢所以模板匹配前一定要限定搜索区域。一个容易踩的坑是颜色通道顺序。ImageGrab.grab()返回的是RGB顺序而OpenCV内部用BGR直接把PIL截图喂给cv2.matchTemplate会导致颜色反了匹配效果大打折扣。正确做法是转一次通道from PIL import ImageGrab import cv2 import numpy as np def grab_screen(bboxNone): # bbox 格式为 (left, top, right, bottom)None 表示全屏 img ImageGrab.grab(bboxbbox) img_bgr cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR) return img_bgr关于Retina/HiDPI屏幕Windows下Python的屏幕坐标一般还是逻辑坐标但不同Python版本和Pillow版本在缩放DPI下表现不一。我遇到过截图区域和实际控件位置错开半个身位的情况最后通过设置Windows进程DPI感知或者直接用SetProcessDpiAwareness解决。如果截图和实际界面坐标对不上优先查DPI问题。匹配结束后要点击一个目标其实就是把模板匹配返回的左上角坐标加上模板宽高的一半得到中心点然后用uiautomation的auto.Click(x, y)执行点击。注意Click接收的是屏幕坐标不是截图坐标。如果之前截图用了bbox区域需要把区域左上角偏移加回去# 假设 region 是 (left, top, right, bottom) left, top, right, bottom region img grab_screen((left, top, right, bottom)) # 在 img 中找到匹配位置 top_left 后 center_in_region (top_left[0] w // 2, top_left[1] h // 2) screen_x left center_in_region[0] screen_y top center_in_region[1]3.2 匹配阈值、多尺度与非极大值抑制cv2.matchTemplate本身就是一个滑动窗口匹配把模板在搜索图上从左上角滑到右下角每个位置算一个相似度分数。我用的是TM_CCOEFF_NORMED这个模式对亮度变化有一定容忍度分数范围在[-1, 1]之间通常取0.8以上就有一定置信度。但实际跑起来单纯一个阈值是远远不够的。比如微信的发送按钮截图模板和实时界面在不同背景色下分数浮动很大。我的做法是三层保证第一限定搜索区域。不要在全屏找先把范围缩小到窗口内部。微信主窗口的位置可以用uiautomation拿到window.BoundingRectangle会返回左、上、右、下四个值裁掉标题栏之后剩下的才是有效区域。这样既减少计算量也避免其他程序里出现相似图标造成误匹配。第二做多尺度匹配。微信窗口缩放后同一个按钮在屏幕上的像素大小会变。固定一张模板只对特定尺寸有效所以我准备了一套多尺度逻辑把模板在0.8到1.2倍之间按步长缩放每个尺度跑一次matchTemplate记录分数最高的一档。import cv2 import numpy as np def match_template_with_scale(gray, template, scale_range(0.8, 1.2), step0.05): best_score -1.0 best_loc None best_scale 1.0 for scale in np.arange(scale_range[0], scale_range[1] step, step): resized cv2.resize(template, None, fxscale, fyscale, interpolationcv2.INTER_LINEAR) if resized.shape[0] gray.shape[0] or resized.shape[1] gray.shape[1]: continue result cv2.matchTemplate(gray, resized, cv2.TM_CCOEFF_NORMED) _, max_val, _, max_loc cv2.minMaxLoc(result) if max_val best_score: best_score max_val best_loc max_loc best_scale scale if best_loc is None: return None, best_score, best_scale h, w template.shape[:2] target_loc (int(best_loc[0] w * best_scale // 2), int(best_loc[1] h * best_scale // 2)) return target_loc, best_score, best_scale第三非极大值抑制。当模板和目标有一定相似但不完全相同的时候matchTemplate会在目标周围冒出一大片高分区。如果直接取minMaxLoc的最高点经常取偏。简单的做法是把分数高于阈值的所有位置收集起来按分数排序然后从高到低逐个检查如果当前结果与已选结果的中心距离太近就跳过。这算一个轻量版NMS不需要依赖额外库。3.3 一组稳定的模板匹配封装把这些思路合并到一个类里是我在实践中反复调整后的最终版本class TemplateMatcher: def __init__(self, template_dir): self.templates {} self.region None # 预先加载所有模板图 for p in Path(template_dir).glob(*.png): self.templates[p.stem] cv2.imread(str(p), cv2.IMREAD_GRAYSCALE) def set_region(self, rect): self.region rect def find(self, name, threshold0.85): template self.templates.get(name) if template is None: return None screen grab_screen(self.region) gray cv2.cvtColor(screen, cv2.COLOR_BGR2GRAY) center, score, scale match_template_with_scale(gray, template) if center is None or score threshold: return None left, top, _, _ self.region if self.region else (0, 0, 0, 0) return (left center[0], top center[1]), score模板文件的管理有几个细节一是模板尽量截取特征边缘清晰的部分比如按钮的文字加边框而不是整块大背景二是不同尺寸屏幕下准备一份2x或3x缩放后的模板三是模板图不要带Alpha通道统一转成灰度减少颜色干扰。这些看起来是小细节却在多台设备上复现时省了很多排查时间。4. EasyOCR的动态文字识别把像素变成能点的坐标4.1 中文模型与识别速度的平衡EasyOCR的优势是几行代码就能跑劣势是模型下载体积不小而且CPU上推理偏慢。我用的是中文英文模型因为微信里会出现中文和英文混排比如联系人备注名里可能带邮箱或者英文缩写。import easyocr # 首次运行会自动下载模型lang_list 里放需要支持的语言 reader easyocr.Reader([ch_sim, en], gpuTrue, verboseFalse)gpuTrue只在有CUDA显卡时有效。公司电脑没有显卡的话退到gpuFalse也能跑但速度差别很大。同样的截图CPU上可能跑2到3秒GPU上能压缩到0.5秒左右。我的项目里OCR不是高频操作只在OCR兜底阶段才会触发所以CPU也勉强能扛但做交互式自动回复时明显感觉卡顿。如果只是识别中文可以不加载英文模型启动速度和推理速度都会快一截。但也要考虑识别目标里是否混有英文。我最终保留了两个模型因为公众号标题这种OCR高频场景里英文和数字出现概率实在太高。4.2 readtext返回结构与坐标回传映射reader.readtext()返回的是列表每个元素是一个三元组(bounding_box_points, text, confidence)。其中bounding_box_points是四个二维坐标点顺序大致是左上、右上、右下、左下。要拿到可点击的坐标一般取四个点的最小包络矩形def locate_text(img, target_text, threshold0.5): result reader.readtext(img, detail1, paragraphFalse) hits [] for bbox_points, text, confidence in result: # 完全匹配不是包含关系 if text.strip() target_text and confidence threshold: xs [p[0] for p in bbox_points] ys [p[1] for p in bbox_points] x_min, x_max int(min(xs)), int(max(xs)) y_min, y_max int(min(ys)), int(max(ys)) center_x (x_min x_max) // 2 center_y (y_min y_max) // 2 hits.append((center_x, center_y, confidence)) return hits坐标回传时有一个容易忽略的坑readtext返回的坐标是传入图片内部的坐标。如果你传进去的是整屏截图坐标就是屏幕坐标可以直接点击如果你传进去的是某个区域截图那么返回坐标需要加上区域左上角的偏移和OpenCV模板匹配那节说的一样。为了统一我把OCR函数也设计成接收区域参数内部统一做偏移计算。另一个坑是paragraphFalse。默认情况下EasyOCR会把语义连续的多行合并成一个段落合并后坐标就变成整个文本框不再区分单行对点击某一行的需求很不友好。显式设置paragraphFalse才能拿到每一行独立的坐标。4.3 真实场景识别聊天列表里的未读名称我在项目里用OCR最典型的一个场景是定位聊天列表里某个带未读消息的联系人。大致流程先通过uiautomation拿联系人列表的矩形范围然后截取这个区域交给OCR识别所有名称再遍历识别结果找到目标名称拿到坐标点击进入聊天。这里的流程比控件查找慢但胜在通用——即使联系人列表项没有暴露Name属性只要文字渲染在屏幕上就能读出来。代码骨架大致是def find_contact_by_ocr(contact_name): window get_wechat_window() # 假定联系人在左侧列表区域这里用窗口宽度的左半部分做裁剪 left, top, right, bottom window.BoundingRectangle region (left, top, int(left (right - left) * 0.3), bottom) img grab_screen(region) centers locate_text(img, contact_name, threshold0.4) if not centers: return None # 多个匹配时取置信度最高的 center_x, center_y, conf centers[0] return (left center_x, top center_y)OCR对微小字符的识别有时会漏字比如把李四识别成李四 带个空格或者把备注名的某个字看错。所以模糊匹配比完全相等更实用。我后来在locate_text里增加了fuzzy参数用编辑距离或者简单的子串判断容忍一到两个字符的误差。这个改动直接让联系人定位的命中率上了一个台阶。5. 三套引擎的融合判断一个降级-兜底-重试框架5.1 优先级设计原则三个工具各有优势但如果同时回报结果该信谁我一开始的做法很幼稚谁先返回就信谁。结果就是有时候ocR读出来的明明白白却因为OpenCV提前匹配到一个相似图标点到错误的位置。后来我把逻辑改成固定的优先级和降级链第一优先uiautomation控件查找。能直接拿到Name属性的目标用代码逻辑匹配最可靠而且带上下文定位到的是空间层级里的节点不是像素坐标。操作控件时还能直接调用Click、SetValue少一步坐标换算。第二优先OpenCV模板匹配。目标是固定样式的图标、按钮时使用。比如发送按钮、扫一扫图标、下拉箭头。模板匹配速度快精度也高一旦命中基本不会错。第三优先EasyOCR文字识别。目标是动态文字时使用。比如公众号文章标题、聊天列表里的联系人名称、卡片消息里的摘要。OCR慢可能误读但它是兜底方案总比拿不到信息强。降级链不一定是单向的。我遇到过控件找到了但SetValue失败的场景最后发现是输入框状态不对反而需要重新点击输入框让焦点落进去。这时候控件的存在不代表控件可用需要加一层验证。我的做法是每执行完一次交互后做一次状态回读比如发送完消息以后截图确认消息气泡出现在聊天区域如果没出现就重试。5.2 融合执行的核心代码骨架整套流程我封装成了一个状态机核心调度逻辑很简单却撑起了大部分功能class WeChatAutomation: def __init__(self): self.window None self.tm TemplateMatcher(templates) self.reader easyocr.Reader([ch_sim, en], gpuFalse, verboseFalse) def ensure_window(self): if self.window is None or not self.window.Exists(0, 0): self.window auto.WindowControl(searchDepth1, ClassNameWeChatMainWndForPC) if not self.window.Exists(3, 1): raise RuntimeError(找不到微信主窗口) self.window.SetActive() def click_target(self, target_type, key, regionNone, threshold0.85): self.ensure_window() if target_type control: ctrl self._find_control(key) if ctrl: ctrl.Click() return True if target_type image: pos, score self.tm.find(key, thresholdthreshold) if pos: auto.Click(*pos) return True if target_type text: centers self._find_text_by_ocr(key, region) if centers: x, y, conf centers[0] auto.Click(x, y) return True return False def _find_control(self, name): # 在窗口下按Name查找控件这里只做顶层的常见控件 for ctrl in self.window.GetChildren(): if ctrl.Name name: return ctrl return None def _find_text_by_ocr(self, text, region): img grab_screen(region) return locate_text(img, text, threshold0.4)这里面一个关键的防御手段是每次交互前都重新ensure_window避免窗口被最小化或切换导致状态失效。微信窗口一旦最小化到托盘很多控件操作会静默失败uiautomation拿到的矩形位置可能停留在最小化前的坐标点击就会点到错误位置。所以在每次动作前确认窗口可见并且是激活状态是整个流程稳定的前提。5.3 消息自动回复的完整流程拆解以自动回复为例把三个引擎串起来的完整流程大概是这样的定位窗口ensure_window()确保微信可见、激活。这一步是uiautomation的看家本领。识别未读消息入口通过截图OCR识别聊天列表区域里的未读联系人名称。如果控件树能读到就优先控件不行就问ocr命中后点击进入会话窗口。读取最后一条消息进入会话后聊天记录区域全是自绘内容直接截取聊天区域图片用OCR读取最后几条消息的文本。这里一般会限定截图区域为窗口下方聊天记录区避免把输入框里的文字也识别进去。规则匹配在本地对识别出的文本做关键词匹配得到回复内容。整个过程完全不依赖云服务。输入并发送定位输入框控件清空之后输入回复文字再用OpenCV匹配发送按钮并点击。输入框交互用uiautomation发送按钮用模板匹配。结果回读发送完成后截图用OCR判断是否出现新气泡。如果没出现多半是按钮没点中或者输入没成功进入重试逻辑。这个流程里步骤2和步骤5是最容易出问题的因为它们跨越了自绘区域和标准控件两个世界。为了稳定我给每次回复都加了人工设置的最小时延和随机抖动避免动作节奏过于机械。6. 实战踩坑记录冻结、误点、慢识别以及账号安全6.1 微信窗口假死与句柄失效的恢复自动化跑久了微信偶尔会进入一种界面还在但控件全部超时的状态。最典型的表现是control.Exists(3, 1)返回False但窗口明明开着或者点击后没反应像死锁了一样。我排查下来多半是以下原因第一窗口失去了焦点且最小化到托盘。这种情况下uiautomation拿到的BoundingRectangle坐标可能是旧的控件树也不刷新。解决方法是先window.Restore()再window.SetActive()。如果还不行直接window.ShowWindow(4)SW_SHOWMAXIMIZED把窗口拉回来。第二高频操作触发了界面的异步加载。特别是切换聊天窗口时如果接下来马上对输入框做SetValue会偶发失败。我的对策是每次操作后加一个time.sleep(0.3)-0.8的随机延迟给UI留出渲染时间。虽然看起来笨但实测下来稳定性提升非常明显。第三uiautomation的句柄缓存失效。长时间挂机后控件对象内部缓存的句柄可能已经失效但Exists()未必立刻反映出来。保险做法是定期重新获取窗口对象而不是一直复用同一个实例。我在自动化脚本里每隔10分钟就重新定位一次窗口整体故障率降了很多。6.2 模板误匹配正样本校准与区域裁剪OpenCV最让人头疼的问题是误匹配。我在聊天窗口里匹配发送按钮时经常会把输入框右侧的 号图标误认成发送按钮两个图标在像素特征上确实有相似度都会得分很高。排查过程是这样的我先用调试模式把所有得分高于阈值的匹配位置都可视化画出来发现高分位置往往集中在两个区域一是真正的发送按钮二是表情符号图标附近的圆角矩形元素。原因在于匹配算法只看像素灰度结构对圆角矩形、颜色相近的元素容易撞车。解决思路有两层。第一层是区域裁剪把模板匹配的搜索范围从整个聊天窗口缩小到输入框右下角区域。这样误匹配在搜索范围之外直接排除。区域裁剪靠uiautomation拿输入框坐标在输入框基础上向右偏移一段距离框住发送按钮的位置。第二层是多模板投票给同一个按钮准备多张不同细节的模板比如发送按钮空态、发送按钮有文字状态匹配时要求至少两模板命中同一个坐标附近才认定成功。这个策略虽然增加了一点计算量但误点率几乎归零。6.3 OCR慢与识别错误的容错策略EasyOCR的慢在长文本场景下尤其明显。有次我截取整个公众号文章列表图片宽度接近窗口全宽文字块又多在CPU上跑了将近6秒。这个速度对高频轮询应用是不可接受的。我做了两个优化。第一个是缩小识别区域不再识别整个列表而是根据滚动条的位置估算当前可见区域只截图这个区域。第二个是降低识别频率未读数量变化不频繁时OCR结果可以缓存5到10秒不必每轮都重新识别。微信消息推送本身有延迟5秒的缓存完全不影响体验。OCR识别错误则是另一类问题。EasyOCR对中文印刷体的识别相对可靠但遇到彩色渐变背景、艺术字标题、表情符号穿插就会漏字或错字。我的容错策略是用关键词模糊匹配而不是精确匹配哪怕OCR把项目周报识别成项目_报或项目周服只要核心词项目出现就算命中。另外在代码里区分匹配和试错OCR识别结果只是候选真正点进去之后还要通过后续状态比如窗口标题变化、聊天记录出现新内容来确认是否点对了目标。如果状态没对上回退重来。6.4 自动化频率的边界与自我保护说一个必须认真对待的点。任何自动化方案在第三方软件上运行都处于能力受限但可以用的灰色地带。我在设计和运行这套脚本前给自己定了几条死规矩也建议打算自己折腾的人照着做只做本地数据处理不涉及任何账号信息的批量导出与传输。识别出来的聊天内容只留在内存和本地日志里用完即走。控制操作频率模拟正常人的交互节奏。我设置的操作为30到60秒一次并且每次随机抖动一两秒绝不写并发、不写快速循环。避免高频只读轮询。比如定时刷新聊天列表这种操作也会给客户端带来压力尽量降到最低频率。以个人工具为界线不对外提供服务不接非本人的任务。这些限制不是技术问题是分寸问题。自动化能做的事和该做的事是两回事尤其是涉及即时通讯软件哪怕只是个人自动化工具也应该把最小干扰当成默认原则。最后分享一个我一直在用的调试技巧把所有关键步骤的截图和OCR原始结果都记录到本地目录功能出错时翻日志比瞎猜快得多。做自动化的时候模块本身不复杂复杂的是现场信息缺失后的盲目试错。有了截图和文本记录很多问题一眼就能定位到是坐标偏移、识别误差还是控件状态不对。我的经验是这套三件套组合真正跑通以后后续做新功能的成本会越来越低。新增一个按钮就是加一张模板图新增一个识别场景就是调一次OCR参数真正费劲的是第一次把三套引擎的协作边界理顺。如果你也卡在uiautomation拿不到内容这一步不妨试试在界面上多给OpenCV和EasyOCR一点信任。我自己就是从那一刻开始才真正把这套自动化从玩具变成了工具。