Python+PaddleOCR:打造PDF选区识别与批量截图文字提取工具 其实动手做这个工具的起因特别朴素有一堆PDF是扫描件文字没法直接复制平时写东西又要频繁从截图里抠文字。网上现成的OCR软件不少但要么收费要么捆绑安装一堆东西要么隐私上心里没底。干脆自己用Python写一个把“PDF里框选区域识别文字”和“批量截图提取文字”这两件事合并到一个工具里顺便再支持手动框选和全自动批处理两种模式。用下来的感受是这个工具完全可以替代那些杀鸡用牛刀的商业软件日常办公识别精度够用跑批量任务也不卡壳。这篇文章就把整个工具的完整实现思路写透从需求分析、OCR引擎选型、界面交互设计到核心代码实现、踩坑记录和排查方案全部分享出来。适合有一定Python基础、想自己折腾效率工具的人参考也适合完全不懂OCR原理但想直接抄作业的读者。1. 整体设计与思路拆解一个工具解决两种高频场景1.1 先想清楚这个工具到底要解决什么动手写代码前我先把需求拆成了两个独立又关联的部分。第一个场景是PDF选区识别。很多PDF本质上是图片扫描件、打印后转换的文档、某些电子书里面的文字无法被选中、复制和搜索。传统办法是装Adobe Acrobat或者福昕的高级版用它们的OCR功能整页识别。但如果你只需要识别某一页的某一块区域整页识别就显得又慢又浪费资源。比如合同PDF里只想提取乙方开户行那一栏或者论文扫描页里只想识别摘要部分选区OCR就是更精准的做法。第二个场景是截图批量处理。日常工作中经常要处理一批截图比如从网页、聊天记录、系统界面里截下来的图里面的文字往往需要录入到文档或表格。单张截图用微信或QQ的OCR还算方便但数量一多就崩溃。批量处理意味着把一张张图交给工具自动跑完OCR最后把结果统一导出成TXT或者CSV整个过程不需要人盯着挨个点。这两个场景的共同底层能力就是OCR区别在于任务组织方式不同一个需要手动框选目标区域另一个需要自动遍历整个文件夹。所以我在设计上把OCR引擎、界面交互、批处理调度这三层剥离开每一层都可以独立测试和替换。1.2 技术选型OCR引擎和GUI方案怎么定OCR引擎我对比过三套方案Tesseract、PaddleOCR、EasyOCR。Tesseract是老牌开源方案识别英文效果不错但中文识别需要下载语言包而且对复杂排版、低清图片的抵抗力偏弱。EasyOCR基于深度学习安装简单但运行速度偏慢内存占用也偏高。PaddleOCR是百度开源的那套中文识别精度在开源引擎里排第一梯队速度也快而且提供了非常细粒度的参数控制适合做二次封装。我最终选了PaddleOCR。它不是最容易安装的依赖PaddlePaddle框架但识别效果和速度综合起来最优。实际测试里普通扫描件的印刷体中文PaddleOCR的识别准确率能到95%以上而Tesseract在这个场景差不多85%左右。速度上我用CPU跑单张1080P截图大约0.8秒到1.2秒完全能接受。GUI方案选了PySide6即Qt for Python而不是Tkinter。管是在交互上方便很多橡皮筋框选、缩放图片、状态栏显示进度这些Tkinter做起来非常别扭Qt有现成的图形视图框架QGraphicsView直接支持鼠标框选和图像缩放省掉大量底层代码。架构上用模块化设计把OCR引擎封装成独立类GUI只负责展示和交互批处理调度单独写。将来如果PaddleOCR效果不满意了或者想换成云端OCR API只需要改一个类其他部分不用动。2. 核心功能拆解三种交互模式的设计逻辑2.1 手动模式针对单个PDF页面的精准选区手动模式完成的功能是这样加载PDF后把指定页面渲染成图片展示在一个可缩放的画布上然后用鼠标拖拽框选一个矩形区域框选结束后自动对矩形内的图像做OCR识别识别结果展示在右侧文本区一键复制。选区逻辑其实分三个层次。第一个层次是PDF页面的栅格化需要把PDF指定页转成高分辨率的图像分辨率不够的话OCR精度会明显下降。我实测下来PDF渲染到200 DPI左右时中文识别精度和速度达到一个平衡点150 DPI会漏字300 DPI虽然更准但渲染时间长、内存占用暴涨批量处理时尤其明显。第二个层次是框选坐标换算界面上的坐标要转换成图片上的实际像素坐标因为画布可能被缩放。第三个层次是图像裁剪和预处理裁剪完的矩形区域需要从BGR彩色图转成RGBPaddleOCR的输入要求再做适度放大和对比度增强。这里有个细节值得单独说选区OCR不只是裁剪图片那么简单更准确的做法是原始渲染时先按200 DPI输出裁剪后再对裁剪区域做2倍放大传给OCR引擎。放大这个动作对精度提升帮助相当大因为很多扫描件的字号偏小直接识别会丢笔画放大后明显改善。2.2 批量模式自动遍历文件夹的无脑流水线批量模式是真正解放生产力的部分。用户只需要指定两个路径图片文件夹和输出文件夹点击开始按钮后程序自动遍历文件夹内所有PNG、JPG、BMP格式文件逐张识别并生成同名TXT文件或者统一汇总成一个CSV。批量模式里的核心问题有两个调度策略和进度反馈。调度策略上单线程顺序处理是最稳的虽然慢一点但内存占用低、不会出现资源竞争。实测中如果开多线程并行OCRCPU占用会瞬间拉满界面会卡死而且PaddleOCR本身不是线程安全的同一时间多个线程调用会报错或内存泄漏。进度反馈用信号槽机制Qt的信号槽把每一张图片的识别进度发送到界面状态栏同时在输出目录实时生成结果文件即使中途手动停止已经识别的内容也不会丢。全自动模式还应该支持“递归遍历子文件夹”的选项我实测过项目里三层目录嵌套的情况递归遍历直接省掉人工拼接路径的麻烦。另外还加入了一个“失败续跑”的逻辑正在处理的文件名写入日志文件如果程序崩溃重启后从日志里过滤掉已经完成的文件避免重复劳动。2.3 模式对比什么时候用哪个模式适用场景交互方式输出形式PDF手动选区扫描PDF单页局部文字提取鼠标框选单条文本图片手动识别单张截图快速识别拖入图片自动识别单条文本文件夹全自动大量截图/扫描件统一转文字一键批处理逐个TXT/汇总CSV我实际使用频率最高的是文件夹全自动模式和PDF手动选区模式。日常从电子书里摘录段落用PDF手动选区处理几十张网页截图用全自动模式。单张图片手动识别这个功能用得少但加上了也没有成本所以一并保留。3. 实操过程环境搭建与核心代码实现3.1 环境准备依赖包安装与版本选择我的环境是Windows 11 Python 3.10核心依赖包版本如下pip install paddlepaddle2.6.1 pip install paddleocr2.7.3 pip install pymupdf1.24.3 # 注意现在import时要用fitz pip install pyside66.6.3.1 pip install opencv-python4.9.0.80 pip install pyinstaller6.3.0有几个版本上的坑值得提前说。PaddleOCR 2.7.3版本会自动拉取依赖的PaddlePaddle但如果先装paddleocr再装paddlepaddle版本容易冲突建议先装paddlepaddle再装paddleocr。PyMuPDF这个库导入时用的是import fitz不是import pymupdf很多新手在这卡住。PySide6和OpenCV之间没有直接冲突但如果你用的是conda环境建议用pip而不是conda install来装OpenCVconda源里的版本有时会跟Qt插件兼容出问题。GPU版本要不要装如果你的电脑有NVIDIA显卡装paddlepaddle-gpu效果会更好批量处理时速度至少提升3到5倍。但CPU版本paddlepaddle对绝大多数场景够用了而且免去了CUDA和cuDNN的环境配置痛苦。我建议先跑CPU版本确认工具流程没问题后再升级到GPU版本。3.2 核心代码一OCR引擎封装类把PaddleOCR封装成独立类这个类的职责很单一接收图片路径或内存中的图片数组返回识别文本结果。import numpy as np from paddleocr import PaddleOCR import time class OCREngine: def __init__(self, langch, use_gpuFalse): self.engine PaddleOCR( use_angle_clsTrue, # 启用方向分类器识别竖排或旋转文字 langlang, # ch代表中文en代表英文 show_logFalse, # 关掉日志输出避免刷屏 use_gpuuse_gpu ) self.last_time 0.0 def recognize_image(self, img_array): 识别传入的图片数组BGR格式 返回: 识别出的纯文本 start time.time() # PaddleOCR需要RGB格式OpenCV读出来是BGR rgb_img cv2.cvtColor(img_array, cv2.COLOR_BGR2RGB) result self.engine.ocr(rgb_img, clsTrue) self.last_time time.time() - start if not result or not result[0]: return lines [] for line in result[0]: # line是一个列表最后一个元素是(文本, 置信度) text line[1][0] confidence line[1][1] if confidence 0.6: # 置信度低于0.6的文本通常是误识别 lines.append(text) return \n.join(lines) def recognize_file(self, image_path): img cv2.imread(image_path) if img is None: return return self.recognize_image(img)这里有个经验use_angle_clsTrue一定要开因为很多截图或扫描件里的文字不是完全水平的尤其是从PDF里复制的扫描件经常有轻微的倾斜角度。方向分类器的作用是先判断文字方向再做校正对识别准确性影响很大。关闭该项之前倾斜超过15度的图片识别基本没法看开启之后明显好多了。3.3 核心代码二PDF渲染成图像PDF页面渲染成图片用PyMuPDFfitz来实现。这里面有一个关键参数需要反复测试确定渲染的DPIdots per inch。import fitz class PDFRenderer: def __init__(self, pdf_path): self.doc fitz.open(pdf_path) self.page_count self.doc.page_count def render_page(self, page_index, dpi200) - np.ndarray: 将指定的PDF页面渲染成图像返回BGR格式的numpy数组 page self.doc[page_index] zoom dpi / 72.0 # PDF坐标系基准是72DPI mat fitz.Matrix(zoom, zoom) pix page.get_pixmap(matrixmat, alphaFalse) # get_pixmap返回的是RGB数据 img_array np.frombuffer(pix.samples, dtypenp.uint8) img_array img_array.reshape(pix.height, pix.width, pix.n) # 转为BGR格式供OpenCV处理 img_bgr cv2.cvtColor(img_array, cv2.COLOR_RGB2BGR) return img_bgr def close(self): if self.doc: self.doc.close()DPI的选择逻辑是这样的PDF内部坐标的基准是72DPI如果要用200 DPI渲染就把缩放系数设为200/72≈2.78。DPI越高图片越大识别越准但内存占用也越大。实测中一个A4纸大小的页面200 DPI渲染出来大约是1654×2339像素内存占用约15MB传给PaddleOCR处理需要大约0.5到1秒300 DPI大约2480×3508像素内存占用35MB处理时间翻倍。对于大多数扫描件200 DPI已经完全够用不需要盲目追求高DPI。3.4 核心代码三PDF选区OCR的坐标换算逻辑这是整个手动模式最绕的地方。界面上的画布QGraphicsView会显示缩放后的页面图像用户用鼠标框选的矩形是画布坐标需要换算回图片原始像素坐标再从原图裁剪区域交给OCR引擎。def get_scaled_rect(self, view_rect: QRectF) - tuple: 将视图坐标系中的矩形转换为原始图片像素坐标 # 图片加载到场景后缩放比例 图片显示尺寸 / 图片原始尺寸 # QGraphicsView默认的transform会处理缩放所以直接用 # mapToScene可以把视图坐标映射到场景坐标 scene_rect self.graphics_view.mapToScene(view_rect) # 场景坐标再转成图片像素坐标 # 图片在场景中的坐标位置由self.pixmap_item.pos()决定 # 缩放比例是self.scale_factor # 实际上更简单的方式记录当前缩放系数 x int(round(scene_rect.x() / self.current_scale)) y int(round(scene_rect.y() / self.current_scale)) w int(round(scene_rect.width() / self.current_scale)) h int(round(scene_rect.height() / self.current_scale)) # 边界安全限制 x max(0, x); y max(0, y) x min(x, self.current_img_width - 1) y min(y, self.current_img_height - 1) w min(w, self.current_img_width - x) h min(h, self.current_img_height - y) return x, y, w, h但上面这个写法依赖current_scale这个变量是否与QGraphicsView的实际缩放状态一致容易因为细节不一致产生偏差。更靠谱的做法是利用QGraphicsPixmapItem的mapFromScene方法直接把场景坐标映射到图片像素坐标def get_pixmap_rect(self, scene_rect: QRectF) - tuple: # 场景坐标转换为图片项局部坐标即原始像素坐标 local_top_left self.pixmap_item.mapFromScene(scene_rect.topLeft()) local_bottom_right self.pixmap_item.mapFromScene(scene_rect.bottomRight()) x int(round(local_top_left.x())) y int(round(local_top_left.y())) w int(round(local_bottom_right.x() - local_top_left.x())) h int(round(local_bottom_right.y() - local_top_left.y())) return max(x, 0), max(y, 0), w, h这个方法利用了Qt的变换矩阵机制只要pixmap_item没有额外的位移或旋转映射结果就是精确的原始图片坐标。这个逻辑值得仔细理解因为框选偏差1到2个像素在屏幕上看不出来但对OCR结果影响明显框选范围包含了一部分背景噪声识别结果里会多出莫名其妙的字符。3.5 核心代码四批量扫描文件夹并逐张识别批量处理的核心逻辑不需要太多花哨设计就是一个循环加实时进度反馈。def batch_process(self, input_dir: str, output_dir: str, recursive: bool True): 批量识别指定文件夹下的所有图片 if not os.path.exists(output_dir): os.makedirs(output_dir) exts (.png, .jpg, .jpeg, .bmp) if recursive: files [os.path.join(dp, f) for dp, _, filenames in os.walk(input_dir) for f in filenames if f.lower().endswith(exts)] else: files [os.path.join(input_dir, f) for f in os.listdir(input_dir) if f.lower().endswith(exts)] files.sort() # 排序保证输出顺序稳定 results [] failed_list [] total len(files) for idx, file_path in enumerate(files, start1): try: text self.ocr_engine.recognize_file(file_path) base_name os.path.splitext(os.path.basename(file_path))[0] txt_path os.path.join(output_dir, f{base_name}.txt) with open(txt_path, w, encodingutf-8) as f: f.write(text) results.append(text) # 发送信号更新UI self.progress_signal.emit(idx, total, base_name, 成功) except Exception as e: failed_list.append((file_path, str(e))) self.progress_signal.emit(idx, total, base_name, f失败: {e}) # 汇总CSV导出 csv_path os.path.join(output_dir, _汇总结果.csv) with open(csv_path, w, encodingutf-8-sig, newline) as f: writer csv.writer(f) writer.writerow([文件名, 识别文本, 状态]) for file_path, ok, text in zip(files, [path not in [x[0] for x in failed_list] for path in files], results): writer.writerow([os.path.basename(file_path), text, 成功 if ok else 失败])这里有一个非常实用的细节CSV导出用utf-8-sig而不是utf-8编码。原因是Excel打开UTF-8编码的CSV文件时中文会全部变成乱码因为Excel默认按GBK解析CSV。加上了utf-8-sig的BOM头后Excel就能正确识别编码直接双击打开就是正常的表格。这个锅我背过后来专门在代码里加了这个编码。3.6 界面交互QGraphicsView框选和按钮布局GUI部分我用PySide6的QMainWindow作为主窗口。左半部分是一个QGraphicsView用来显示PDF渲染出来的页面或拖入的截图右半部分是QTextEdit显示识别结果底部是一个按钮行分别是“加载PDF”“上一页”“下一页”“框选识别”“批量模式”“导出结果”。框选交互参考了画图软件的橡皮筋框选逻辑鼠标按下记录起点拖动时绘制一个半透明的QGraphicsRectItem作为框选可视化松开鼠标时触发OCR识别。class PDFViewer(QGraphicsView): selection_finished Signal(QRectF) def __init__(self, parentNone): super().__init__(parent) self.scene QGraphicsScene(self) self.setScene(self.scene) self.pixmap_item None self.current_scale 1.0 self.drawing False self.start_point QPointF() self.rect_item None def mousePressEvent(self, event): if event.button() Qt.LeftButton: self.drawing True self.start_point self.mapToScene(event.position().toPoint()) self.rect_item QGraphicsRectItem() self.rect_item.setPen(QPen(Qt.red, 2)) self.rect_item.setBrush(QBrush(QColor(255, 0, 0, 30))) self.scene.addItem(self.rect_item) super().mousePressEvent(event) def mouseMoveEvent(self, event): if self.drawing and self.rect_item is not None: current_point self.mapToScene(event.position().toPoint()) rect QRectF(self.start_point, current_point).normalized() self.rect_item.setRect(rect) super().mouseMoveEvent(event) def mouseReleaseEvent(self, event): if self.drawing: self.drawing False if self.rect_item: current_point self.mapToScene(event.position().toPoint()) rect QRectF(self.start_point, current_point).normalized() # 过滤太小或反向的选区 if rect.width() 5 and rect.height() 5: self.selection_finished.emit(rect) self.scene.removeItem(self.rect_item) self.rect_item None super().mouseReleaseEvent(event)代码本身不复杂但要注意几个细节。第一是normalized()方法它保证矩形无论鼠标往哪个方向拖拽都能得到正确的左上右下坐标不然反向拖拽时矩形会出现负数宽高。第二是加一个最小尺寸过滤即框选小于5×5像素时不触发OCR因为误触鼠标就会框出一个很小的选区白白触发一次识别浪费时间。4. 常见问题与排查技巧实录4.1 PaddleOCR初始化报错依赖版本冲突PaddleOCR 2.7.3版本对PaddlePaddle的版本要求比较敏感常见的报错是终端提示缺少paddlex或者shapely库。我碰到的两次分别是AttributeError: module paddle has no attribute device和ImportError: cannot import name detect_support from paddle。根据经验这类错误大概率是PaddlePaddle版本过新或过旧导致的。建议严格按照paddlepaddle2.6.1配合paddleocr2.7.3来安装不要直接装最新版。另外建议装完以后运行一段简单的初始化测试确认OCR引擎能正常加载python -c from paddleocr import PaddleOCR; ocr PaddleOCR(use_angle_clsTrue, langch, show_logFalse); print(OK)如果这个能跑通后面基本不会有大问题。注意首次运行时会自动下载模型文件到~/.paddleocr/目录需要联网下载失败时后续会一直报模型文件缺失的错误。这个坑很容易让人怀疑自己的代码写错了但其实只是模型没下全。4.2 识别结果为空或明显乱码识别不出文字或者吐出一堆乱码首先检查图片本身的清晰度和分辨率。如果图片分辨率过低比如从聊天记录里长按保存的模糊缩略图再好的OCR引擎也无能为力。可以先做一个简单的预处理转灰度图、二值化、放大2倍这三个操作能显著提升低质量图片的识别率。def preprocess_image(img): gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 适度放大让笔画更完整 gray cv2.resize(gray, None, fx2, fy2, interpolationcv2.INTER_CUBIC) # 自适应阈值二值化增强对比度 binary cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 11, 2) # 转回三通道PaddleOCR输入需要3通道 return cv2.cvtColor(binary, cv2.COLOR_GRAY2BGR)但这里有一个前提预处理不是万能的。如果原始图片模糊到人眼看不清文字程序预处理也不可能从无中生有。我把预处理逻辑设置为默认关闭因为有时二值化反而会把浅色文字洗掉做成一个可选项更灵活。4.3 批量处理过程中程序卡死或内存暴涨最典型的原因就是PaddleOCR在循环中被反复实例化。如果循环体里每次迭代都调用一次PaddleOCR()构造函数每张图片都会加载一次模型到内存四五张图之后内存就爆了。正确做法是在批量处理开始前实例化一次引擎对象整个循环复用同一个实例。如果确实需要并行处理建议用multiprocessing而不是多线程。每个子进程独立加载一份模型配合队列进行任务分发。实测下来4进程并行处理100张图片耗时从单线程的90秒降到30秒左右但内存占用也涨了4倍需要根据电脑配置权衡。我没把多进程集成到工具里因为批处理场景下单线程的90秒已经能接受而且界面不会卡死多进程带来的收益不值得那点复杂度。4.4 打包exe后运行报错找不到模型文件用PyInstaller打包时如果直接打包~/.paddleocr/目录下的模型文件不会被包含进去换到别的电脑上运行就会报错。我试过在代码里写逻辑复制模型目录到程序同级的models/文件夹然后设置PaddleOCR的模型路径参数。PaddleOCR支持通过传入det_model_dir和rec_model_dir指定模型位置。但更省事的方式是直接让程序在首次运行时检查本地模型目录是否存在若不存在则提示用户运行一次初始化下载。打包时把模型文件目录通过--add-data参数加进去这样程序启动时就不用联网下载了。pyinstaller --add-data C:/Users/xxx/.paddleocr/whl/det/ch/ch_PP-OCRv4_det_infer;./paddleocr/whl/det/ch/ch_PP-OCRv4_det_infer --add-data ...这个打包配置写起来比较繁琐而且每个PaddleOCR版本模型路径不同。我后来采取了更简单的方式打包时不包含模型首次运行时调用一次识别来触发模型下载。对于个人工具来说这个方法是最省心的。4.5 常见问题速查表问题现象原因分析解决方案初始化引擎时崩溃PaddleOCR和PaddlePaddle版本不匹配严格按paddlepaddle 2.6.1 paddleocr 2.7.3安装识别结果全为空图片分辨率过低 或 模型没下载成功查~/.paddleocr/目录是否有模型文件尝试放大图片框选了区域但识别范围不对坐标换算用了错误的缩放系数改用pixmap_item.mapFromScene做坐标映射批量处理内存暴涨OCR引擎在循环中被重复实例化实例化一次循环中复用程序打包后模型加载失败模型文件没有被带入打包用--add-data添加或首次运行下载模型CSV文件Excel打开乱码CSV编码是UTF-8无BOM改为utf-8-sig编码导出5. 几个能直接提升体验的细节优化说到体验优化有几个细节虽然不是核心功能但实际用起来感受差异很大。第一个是拖拽导入图片的支持直接从文件管理器里把一张截图拖进窗口就自动识别省掉了点击按钮选文件的步骤。这个用Qt的dragEnterEvent和dropEvent实现大概十几行代码但对日常使用频率的影响很大。第二个是识别结果自动去噪。PaddleOCR返回的每一项结果除了文本外还有坐标和置信度如果直接拼接所有文本可能会把图片里的水印、页码、印章这些无关内容也一并输出。我加了一个简单的过滤规则置信度低于0.6的行直接丢弃如果识别出的文本里包含大量纯数字且长度少于4的字符通常属于页码或日期可选过滤。这个开关默认关闭因为不同场景需求不同。第三个是识别记录的自动保存。每完成一次选区识别就自动把结果追加到notes.txt文件带时间戳和来源页码。这样即使是临时的一次识别事后也能找到历史记录不用每次手动复制保存。6. 工具验证方法与效果评估工具写完后我拿三类真实素材做了测试。第一类是PDF扫描版图书的第32页页面内容是中文正文加少量英文摘要手动框选正文段落识别300字左右的文本错别字3处准确率约99%。第二类是100张网页截图包含中文新闻、表格和导航栏文字全自动批量识别总共耗时1分40秒平均单张1秒输出文本基本可直接使用。第三类是倾斜拍摄的文档照片开启方向分类器后原本完全乱码的识别结果恢复到了约85%准确率说明角度校正功能确实有效。这个验证流程很重要因为它不是单纯“能跑就行”而是用真实数据验证了工具在实际场景中的可用程度。对准确率的评估也让我知道什么时候可以完全依赖工具输出什么时候需要人工校对。制作这个工具的过程中我最大的体会是OCR工具链本身已经足够成熟真正的门槛在于把交互逻辑做好。PaddleOCR只是负责识别文字而工具的竞争力其实体现在选区的精准性、批量流程的稳定性、异常状况的兜底处理这些看似不起眼的细节上。如果只追求“能跑”一个命令行脚本几十行就搞定了但要做得顺手界面交互和异常处理才是花时间最多的地方。另外一个很实用的建议是如果桌面图形界面对你来说太重了可以只用这个项目的核心OCR封装类和PDF渲染类在命令行里调用效果完全一样。批量处理场景下命令行版本可能比GUI版本更高效。我的工具在设计时就考虑到了这一点所以核心逻辑全部独立成类GUI只是薄薄的一层壳。后续我还在考虑加上简体中文英文混合识别时自动切换语言、表格区域识别后直接输出成Excel以及通过模板记忆经常需要框选的PDF区域位置让重复性任务进一步自动化。