简介:面向Windows 10/11用户的多区域定时OCR文字识别小工具,支持在屏幕上自由框选多个目标区域,按设定间隔自动截取图像并完成文字识别,识别结果持续追加写入文本文件,解决股票行情、数据面板等固定区域文字需反复手动截屏抄录的低效问题。exe单文件免安装运行,无Python等环境依赖,双击即可启动,配合json配置可自定义识别间隔与输出路径,使用门槛低。资源仅3个文件,其中exe为主程序、json为配置文件、docx为图文版使用说明,压缩包整体约92MB,结构精简即下即用。目前已有85人学习/下载,适合需要定时监控固定屏幕区域的办公与数据采集场景,拿到即可部署使用,文档还覆盖多区域框选、按键操作与控制面板等完整操作细节。
1. 多个区域框选 + 定时 OCR:一个被很多人低估的桌面自动化工具
运营每天九点半打开内部系统,盯三个固定位置的数字,手工记到表格里;仓库管理员每隔一小时看一眼监控看板上的出货量;测试人员反复核对页面上的状态文案有没有变。这些场景本质上是同一件事:屏幕上几个固定区域的内容,需要被定时、自动地读出来,落成结构化数据。多个区域框选 + 定时 OCR 识别解决的就是这件事,它不碰业务系统、不改页面代码,像一个人坐在屏幕前帮你截图认字。这个方案的难点往往不在 OCR 引擎,而在区域坐标标定是否可靠、定时调度是否真的准点、以及识别结果是否值得信任。
2. 选型:为什么是 Python + 本地 OCR,而不是 VBA、C# 或百度云 API
2.1 三条实现路线,为什么把 VBA 和 C# 排在后面
先看常见的替代方案。VBA 调百度云 OCR 用来批量识别 Excel 里的合同图片确实很常见,但拿到桌面区域定时轮询这个场景就不太好使:Excel 的定时器粒度太粗,Application.OnTime 最小间隔和稳定性都不够,而且截取屏幕上任意区域需要调用 Windows GDI 接口,VBA 写起来非常绕。C# 用 System.Drawing 的 Graphics.CopyFromScreen 截图没有障碍,配合 System.Timers.Timer 做调度也顺手,但每次调整框选位置都要重新编译,运维的人不一定会动 Visual Studio。Java 调用百度 OCR 擅长处理合同、单据这类整页文档,适合服务端批处理,塞进桌面小工具里就显得重。
Python 路线的优势在于三个环节都有现成轮子:PIL 的 ImageGrab 负责截图,OpenCV 或 tkinter 负责画框选交互,RapidOCR / PaddleOCR 本地负责识别。服务端 OCR API 的网络往返在毫秒到秒级不稳定,本地引擎单张区域识别通常是几十到几百毫秒,这对“每隔几分钟轮询一次”的节奏更健康,也不怕断网。标题里的“多个区域”意味着框选交互要做成可视化工具,而不是手改坐标文本,这点 Python 生态同样比其它语言更顺手。
2.2 本地 OCR 引擎怎么挑:识别速度、中文支持与部署体积
OCR 引擎的选择直接决定后面所有参数怎么调。Tesseract 需要单独下载中文语言包,5.0 版本之后识别中文印刷体的准确率有所提升,但遇到小字号、抗锯齿字体、深色背景浅色文字时,它的置信度打分不够稳定,一行普通文本里混入几个错字很常见。PaddleOCR 的中文识别率在开源引擎里属于第一梯队,但它完整装下来依赖 PaddlePaddle 框架,部署体积偏大;RapidOCR 把 PaddleOCR 的模型转成 ONNX 推理,接口几乎一致,部署干净很多,也是我在做这类小工具时的默认选择。
引擎选型可以按两个维度判断。第一,目标区域里的文字是否包含中文:纯数字和英文用 Tesseract 也够,但中文场景直接上 RapidOCR 或 PaddleOCR。第二,硬件条件:如果目标机器是几年前的办公电脑,CPU 推理为主,RapidOCR 的 ONNX Runtime 版表现更稳;如果有独立显卡且愿意折腾 CUDA,PaddleOCR 的 GPU 推理能跑到更低的延迟。需要提醒的是,办公电脑上做区域 OCR,大多数时候 CPU 就够了,别一上来就上 GPU 配置,后面调优部分会算一笔资源账。
2.3 配置文件与坐标存储:让“框选一次、永久复用”成为可能
多个区域框选的核心是坐标持久化。不能把坐标写死在代码里,否则每次新增区域都要改源码。常见的做法是把每个区域存成 INI 或 JSON,区域名用语义化标识,比如 stock_price、order_count、status_text,而不是 region_1、region_2。这样后续对接定时任务和结果落盘时,字段名直接作为数据的 key,省去一层映射。
配置结构我一般会包含四类信息:区域唯一 ID、区域名、左上角和右下角坐标、以及该区域的识别备注(比如“带百分号后缀”)。坐标统一存屏幕像素绝对值,不按窗口相对位置存,因为大多数业务系统窗口打开后位置固定,相对坐标反而会在窗口移动时产生偏移。保存配置后,主程序启动时读取一次,每次 OCR 任务直接从内存取坐标截图,不重复解析文件。
3. 落地实现:区域框选工具与定时识别主程序
3.1 第一步:用 OpenCV 写一个能记住区域的框选工具
框选工具要做的事有两件:让用户在全屏截图上用鼠标拖出矩形,然后把矩形坐标写进配置文件。用 OpenCV 的 setMouseCallback 实现拖动画框,按回车保存当前框,按 Q 退出。第一次跑的时候,先把整个屏幕截下来显示在窗口中,之后在这个窗口上画框,坐标换算成实际屏幕像素。
import cv2 import configparser from PIL import ImageGrab CONFIG_PATH = "regions.ini" # 全局状态:当前正在画的矩形、已确认的矩形列表、当前区域名 current_rect = [] confirmed_rects = [] region_name = "" # 鼠标回调:按下记录起点,拖动更新终点,释放完成一次框选 def on_mouse(event, x, y, flags, param): global current_rect if event == cv2.EVENT_LBUTTONDOWN: current_rect = [x, y, x, y] elif event == cv2.EVENT_MOUSEMOVE and current_rect: current_rect[2], current_rect[3] = x, y elif event == cv2.EVENT_LBUTTONUP: current_rect[2], current_rect[3] = x, y confirmed_rects.append(tuple(current_rect)) print(f"已框选: {current_rect}") # 截全屏并显示,注意先做 DPI 感知,见 5.1 的坑 import ctypes ctypes.windll.shcore.SetProcessDpiAwareness(1) screen = ImageGrab.grab() screen.save("_screen_base.png") img = cv2.imread("_screen_base.png") cv2.namedWindow("region_selector", cv2.WINDOW_NORMAL) cv2.setMouseCallback("region_selector", on_mouse) while True: display = img.copy() for r in confirmed_rects: cv2.rectangle(display, (r[0], r[1]), (r[2], r[3]), (0, 255, 0), 2) if current_rect: cv2.rectangle(display, (current_rect[0], current_rect[1]), (current_rect[2], current_rect[3]), (0, 0, 255), 1) cv2.imshow("region_selector", display) key = cv2.waitKey(1) & 0xFF if key == ord("q"): break elif key == ord("s"): if not region_name: region_name = input("输入区域名: ").strip() if confirmed_rects: save_to_config(region_name, confirmed_rects[-1]) print(f"区域 {region_name} 已保存") region_name = ""这段代码的关键在鼠标回调的三种事件状态:按下记录起点,拖动实时更新终点,抬起时把一次完整的矩形存入列表。框选完成后按 S 触发保存,每按一次 S 只保存最近一个矩形,区域名通过控制台输入。保存函数里需要把 OpenCV 窗口的坐标直接作为屏幕像素写入配置,因为窗口是按全屏截图的原始尺寸显示的,不存在缩放。
保存配置的函数逻辑比较简单,但有一个参数需要强调:坐标必须存 int 类型,PIL 截图时如果传入 float,某些版本的 Pillow 会直接抛 TypeError。从 OpenCV 回调拿到的坐标本身就是 int,但如果后面做了缩放处理,记得转回 int。
def save_to_config(name, rect): config = configparser.ConfigParser() if os.path.exists(CONFIG_PATH): config.read(CONFIG_PATH) section = f"region_{len(config.sections()) + 1}" config[section] = { "name": name, "x1": str(rect[0]), "y1": str(rect[1]), "x2": str(rect[2]), "y2": str(rect[3]), } with open(CONFIG_PATH, "w", encoding="utf-8") as f: config.write(f)这里的 section 命名用固定前缀加自增序号,真正的业务标识在 name 字段里。后续 OCR 程序读取配置时,不要依赖 section 的序号,而是遍历所有 section 取 name 字段作为结果集的 key,这样即使中间删掉一个区域,其它区域的对应关系也不会错位。框选工具本身不负责识别,它只输出一个 regions.ini,这是把“标定”和“识别”解耦的关键设计。
3.2 第二步:定时识别主程序,把截图、推理和结果落盘串起来
主程序的核心是三个循环嵌套:最外层是定时调度,中间层遍历所有区域,内层是单区域的截图加识别。截图用 PIL 的 ImageGrab.grab 传入 bbox,识别用 RapidOCR,结果统一写到 CSV 文件里,每次运行追加一行。
import configparser import csv import os from datetime import datetime from PIL import ImageGrab from rapidocr_onnxruntime import RapidOCR import schedule import time CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "regions.ini") CSV_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ocr_results.csv") engine = RapidOCR() config = configparser.ConfigParser() config.read(CONFIG_PATH, encoding="utf-8") def read_regions(): regions = [] for section in config.sections(): name = config[section]["name"] x1 = int(config[section]["x1"]) y1 = int(config[section]["y1"]) x2 = int(config[section]["x2"]) y2 = int(config[section]["y2"]) regions.append({"name": name, "bbox": (x1, y1, x2, y2)}) return regions def ocr_region(engine, bbox): img = ImageGrab.grab(bbox=bbox) # 转成 numpy 数组喂给引擎,避免中间落盘 result, _ = engine(img) if not result: return "" # 单区域默认只取识别框里得分最高的第一行文本 line = "".join([item[1] for item in result]) return line.strip() def job_once(): regions = read_regions() row = {"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")} for region in regions: try: row[region["name"]] = ocr_region(engine, region["bbox"]) except Exception as e: row[region["name"]] = f"ERROR: {e}" with open(CSV_PATH, "a", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=list(row.keys())) if os.path.getsize(CSV_PATH) == 0: writer.writeheader() writer.writerow(row) print(row) # 每 5 分钟执行一次;也支持每天固定时刻执行 schedule.every(5).minutes.do(job_once) # schedule.every().day.at("09:30").do(job_once) while True: schedule.run_pending() time.sleep(1)这段代码有几个参数值得说明。read_regions 每次任务执行时重新读配置文件,意味着改完框选不用重启主程序,下一次轮询自动生效。这在实际操作中非常有用,改一个区域位置不用杀掉常驻进程。CSV 的表头是动态生成的,因为区域列表可能变化,直接用字典的 key 作为 fieldnames,首次写入时输出表头。
RapidOCR 的返回值是一个列表,每个元素是 [坐标框, 文本, 置信度] 的结构。直接取所有行的文本拼成一行,适合大多数单行文本区域。如果区域是多行文本,这个拼接策略会丢换行符,后面调优一节会单独说多行区域怎么处理。最后一行是调度主循环,schedule.run_pending() 非阻塞地检查到期任务,sleep(1) 让 CPU 占用几乎为零。
3.3 第三步:接入 Windows 任务计划程序,还是常驻运行?
定时有两种常见做法:脚本自己用 schedule 循环,或者交给 Windows 任务计划程序按计划调用。前者的优点是改时间只看一处代码,缺点是进程必须一直活着,电脑重启后需要设开机自启;后者更符合 Windows 原生的运维习惯,但要注意工作目录和 Python 环境的配置,避坑章节会具体讲。
我的建议分情况。如果你的机器就是一台固定工位的办公电脑,用 schedule 常驻最省事,因为脚本里可以通过 schedule.every().day.at("09:30") 精确控制每天的时刻,还能在同进程里做多个任务,互不干扰。如果你要定时到任务计划程序里,最安全的调用方式是写一个批处理脚本,先 cd 到项目目录再启动 Python:
@echo off cd /d D:\path\to\project D:\python39\python.exe ocr_task.py >> ocr_stdout.log 2>&1批处理里的 cd /d 必须写,否则任务计划程序默认的工作目录通常是 system32,脚本里用相对路径读 regions.ini 会直接翻车。日志重定向也建议加上,OCR 脚本一旦异常,没有日志几乎没法排查。这块是血泪经验,依赖越大越要在入口做好日志兜底。
4. 参数调优:置信度、截图缩放与定时周期怎么定
4.1 OCR 置信度过滤:别让低分结果污染数据
RapidOCR 返回的每个识别项都带置信度,默认情况下引擎会丢掉低于阈值的框,但这个阈值比较宽松。实际跑起来会发现,一个区域里的数字偶尔因为反光、遮挡识别出莫名其妙的字符,置信度还不低。这时候需要在业务层再加一道过滤。
result, elapse = engine(img) if not result: return "" filtered = [item for item in result if item[2] >= 0.6] if not filtered: return "" # 0.6 是经验值:常规屏幕截图的中等字号文本,置信度普遍在 0.85 以上 # 低于 0.6 的识别结果多数是误检框置信度阈值 0.6 是经验值。常规屏幕截图里,清晰文本的置信度普遍在 0.85 到 0.99 之间;0.6 以下的结果大多是边缘误检、表格线干扰、或者杂质阴影。不需要把阈值提到 0.9,因为那些真正值得警惕的坏样本往往也是高置信度错识——准确说,是对“O”和“0”这类相似字符自信地犯错。置信度过滤能挡掉一部分低质量框,但挡不住高置信度的语义错误,后者靠 5.4 的后处理兜底。
调阈值时建议先跑一轮原始结果,把置信度列打印出来看一眼分布,再定阈值。不同字号、不同背景下的置信度分布差异很大,固定 0.6 不一定最优。
4.2 截图缩放与图像预处理:小字号的救星
OCR 引擎训练时见过的文字尺寸有一定范围。办公系统里常见的 12 号、14 号字没问题,但有些看板把关键数字放在很小的角标里,直接识别时字体笔画粘连,识别率明显下降。这类问题的常规解法是把目标区域截图放大 2 倍再喂给引擎。
from PIL import Image import numpy as np def ocr_region_with_scale(engine, bbox, scale=2): img = ImageGrab.grab(bbox=bbox) if scale != 1: w, h = img.size img = img.resize((w * scale, h * scale), Image.LANCZOS) result, _ = engine(np.array(img)) ...scale 参数取 2 还是 3,取决于原字号。2 倍放大能解决大多数 9 到 12 号字的识别问题,放大到 3 倍时推理时间会明显变长,收益反而递减。还有一个细节:放大前先转成 RGB,避免某些区域截图是调色板模式时 resize 报错。灰度化是否必要要看背景——纯色背景可以灰度化加速,花哨的渐变背景保留彩色反而有助于文本区域检测。
预处理这块很容易做过头,不要又放大又锐化又二值化。RapidOCR 这类深度学习引擎对原始图像的适应性比传统 OCR 强得多,过度的图像增强经常把抗锯齿边缘弄得更糟。我一般只做放大,顶多加一步转灰度,二值化留给验证码这类极端场景。
4.3 定时周期与资源占用:别把轮询做成性能杀手
定时周期的设置遵循一个原则:间隔时间大于单次全部分区识别的耗时。假设有 10 个区域,每个区域截图加推理耗时约 0.3 秒,一轮下来约 3 秒,那么定时周期设在 10 秒以上比较合理。schedule 库的任务触发是串行的,一个任务没跑完,不会同时开另一个线程跑,所以实际耗时决定了最短可行间隔。
资源占用上,进程常驻内存大约 300 到 500MB,因为 ONNX Runtime 的推理模型加载后常驻内存,这是正常现象。CPU 占用在非识别时段几乎为零,识别瞬间会有一个峰值。如果这是一台老电脑,把定时周期放宽到 5 分钟以上,比任何代码优化都管用。另外,截图用的是 ImageGrab.grab,它依赖 Windows GDI,在远程桌面断开或锁屏状态下可能截到黑屏,这个场景放在避坑章里细讲。
5. 实战避坑:框选坐标漂移、黑屏截图与调度失灵的 5 个常见问题
5.1 现象:框选和识别位置对不上,越往右下偏移越严重
在 125% 或 150% 缩放的 Windows 屏幕上,框选时选中的位置和实际识别的内容不一致,而且偏移动量随坐标增大而放大。原因是 Python 进程默认不感知系统 DPI 缩放,OpenCV 的窗口坐标和 PIL 的屏幕像素坐标处于不同的坐标系里。解决方法是 Python 启动时显式声明进程 DPI 感知,在 import cv2 之前调用。
import ctypes ctypes.windll.shcore.SetProcessDpiAwareness(1)这个调用放在程序入口最前面,越早越好。SetProcessDpiAwareness 的参数 1 表示系统 DPI 感知,可以让坐标保持物理像素一致。注意有些环境已经是 Per-Monitor DPI Aware V2,这个调用会失败抛错,常见做法是包一层 try/except 忽略异常。这个坑非常隐蔽,配置文件和系统缩放的组合不同,表现形态还不一样,建议先在目标机器上确认缩放比例再决定是否处理。
5.2 现象:窗口最小化后识别到黑屏或上一秒的旧画面
ImageGrab.grab 截取的是当前屏幕缓冲内容,目标窗口最小化之后,该区域要么是桌面背景,要么是上一次绘制残留。屏幕内容由 GPU 合成,普通 GDI 抓屏拿不到后台窗口的画面。情景是:操作人员把目标窗口最小化去做别的事,定时任务照样跑,但记录下来的全是没有意义的桌面文本。解决思路有两种:一是从业务上去约束,提示用户目标窗口保持可见,哪怕被其它窗口遮挡大多数情况下也能截到;二是改用 PrintWindow API 抓取指定窗口的后台图像,但因为很多 Win32 程序基于 GPU 加速渲染,PrintWindow 也不一定可靠,需要逐台机器验证。对多数内部系统页面来说,只要窗口不被最小化到任务栏,抓屏内容就是正常的。每一步方案都要在目标机器上实测,别想当然。
5.3 现象:任务计划程序定时跑,脚本一执行就报错,手动跑却正常
任务计划程序调用 Python 脚本最常见的三个坑:工作目录错误、解释器路径错误、环境变量缺失。工作目录错误最典型,表现是脚本找不到 regions.ini 或者找不到 Python 模块。解决方法是脚本内部所有路径用绝对路径,基于file动态计算,不要用相对路径;bat 里 cd /d 到项目目录;任务计划程序里“起始于”字段也要填项目目录。第二个坑是解释器路径,任务计划默认用系统 PATH 找 python,如果系统里装过多个 Python 版本,可能调用了错误版本导致 import 失败。在 bat 里写死解释器绝对路径,一劳永逸。Python 里所有输出重定向到日志文件,排查时直接看日志。
5.4 现象:识别结果里 0 和 O、1 和 I 混在一起,导致数据对不上
屏幕字体里数字 0 和字母 O 字形几乎一致,OCR 模型本身也无法保证区分。如果目标区域是订单号、库存数量这类对字符要求严格的数据,需要业务层做后校验。常见做法是对结果做上下文约束:纯数字区域允许出现的位置用正则清洗,比如把夹杂在数字中间的 O 替换为 0、I 替换为 1;带单位后缀的区域先剥离单位再校验。这类问题没有通用解法,只能针对区域语义写规则,本质上是在 OCR 之上再做一层领域清洗。
5.5 现象:改了区域,老配置文件却一直生效,新框选完全不触发
出现这个情况通常有两个原因。第一,主程序常驻运行,但 read_regions 读的是加载到内存里的 config 对象,代码里写的是每次 job 重新config.read(),修改配置文件时,主程序确实会读到新值;但如果改成启动时读一次、后续复用,那就需要重启进程。第二,两个进程同时操作同一份 ini,框选工具保存时是整体重写文件,没考虑到主程序正在读取,可能读到半截配置导致解析异常。解决方案是框选工具保存时先写临时文件再 os.replace 覆盖原文件,保证读的一方要么看到旧文件、要么看到新文件,不会读到中间态。这在 Windows 上尤其重要,因为文件占用问题很常见。
6. 进阶:只在内容变化时记录与告警,让这套工具真正省心
6.1 基于结果哈希的变动检测:把轮询从“全量记录”变成“变化才记”
很多业务场景里,定时识别的目的不是留档,而是监测内容变化。比如盯一个价格数字有没有跳动、状态文案有没有从“处理中”变成“已完成”。这时候每次都写 CSV 会产生大量噪音数据,更合理的做法是把本轮 OCR 结果和上一轮比较,发现变化才记录并触发通知。实现上可以对拼接后的文本做 MD5 哈希,作为区域的指纹:
import hashlib last_hashes = {} def detect_change(region_name, text): h = hashlib.md5(text.encode("utf-8")).hexdigest() if last_hashes.get(region_name) != h: last_hashes[region_name] = h return True return False配合告警时,通常是在检测到变化后通过企业微信机器人、钉钉机器人的 webhook 推一条消息。webhook 本身不是这套工具的必要组件,但“变化才通知”能显著减少人工盯屏幕的负担。实际使用中,价格类数字频繁小幅变动时,需要给文本比较加上一个“稳定次数”的缓冲,连续两次 OCR 结果一致才判定变化,避免闪烁或渲染抖动造成的假告警。
6.2 用历史截图回放验证识别稳定性:上生产前的最后一道检查
新写好的框选配置和参数阈值,不建议直接上生产跑。拿一段真实历史视频或连续截图的回放来验证是最稳妥的做法。具体操作是:用录屏软件录下目标系统一个完整业务周期(至少覆盖一次内容变化),然后写一个回放脚本,把视频帧按定时任务的节奏喂给 OCR 逻辑,统计识别准确率。回放脚本和真实 OCR 程序共用区域配置和预处理函数,只是把截图来源从 ImageGrab 换成视频帧。
我在实际项目中吃过一次亏:框选区域时把目标文本框略窄了一些,原以为只多了一点边缘无关紧要,结果从视频回放中发现识别结果偶尔会拼接进相邻列的字符。看单张静态截图完全看不出问题,因为相邻字符只在特定业务状态才出现。回放验证的另一个好处是能复现“变化告警”的触发逻辑,验证告警是否只在真实变化时发出。这一条经验值得沉淀为习惯:任何 OCR 类的自动化工具,第一版上线前都要用历史数据回放压一遍,别拿生产环境当测试场。
这类工具的边界也很清楚:它识别的是屏幕渲染后的像素,所以页面分辨率变化、窗口布局调整、字号缩放都会影响结果,运行环境越固定越好。做完这套方案后,我给自己定的规矩是:凡是每周要重复超过三次的“看屏幕记数据”工作,就值得花半天做一个框选加定时识别;但凡是需要识别结果直接写回业务系统的场景,OCR 只能作为辅助输入,必须有人工确认环节兜底,识别结果永远只是建议。希望帮到你。
本文还有配套的精品资源,点击获取