ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Python 3.7 文档扫描 OCR 实战:从图像预处理到结构化输出

Python 3.7 文档扫描 OCR 实战:从图像预处理到结构化输出 1. 为什么我要在 Python 3.7 上死磕文档扫描 OCR先亮明背景。我手头有一批纸质合同和票据需要电子化归档数量大概在两千页上下。找现成的商业服务当然省事但数据要过别人的服务器心里总归不踏实而且有些票据格式很偏通用接口识别率并不理想。于是我决定自己搭一套文档扫描 OCR流水线跑在本地一台旧笔记本上环境就锁定Python 3.7——不是我不想用新版本而是这台机器上还跑着几个老项目依赖锁死在 3.7动不了。这个标题看起来简单真做起来你会发现它其实是三件事叠在一起文档扫描把手机或扫描仪拍的歪斜照片矫正成规整的文档图、OCR把图里的文字抠出来、以及后处理把抠出来的文字整理成能用的结构化数据。很多人一上来就pip install一个 OCR 库然后image_to_string结果发现识别出来的东西错得离谱就下结论说开源 OCR 不行。问题往往不在 OCR 引擎本身而在前面那一步——你喂给它的图质量太差了。这篇文章适合谁看如果你手头有纸质文档要批量电子化又不想把数据传出去或者你正在做一个票据识别、证件识别、表单录入的小工具再或者你只是好奇扫描全能王那类 App 背后的原理——那这篇内容应该能帮到你。我会把整条流水线拆开讲包括每一步为什么这么做、参数怎么定、以及我在两千页实测里踩过的那些坑。全程基于 Python 3.7代码可以直接抄。需要提前说明的是下面涉及的具体阈值、核大小这些参数都是我在自己那批文档上试出来的经验值你的场景比如文档底色、字体、拍摄角度不同需要微调。我会把调整的思路也讲清楚而不是只丢一堆数字给你。2. 整条流水线的骨架先想清楚数据怎么流2.1 从一张歪照片到一行可用文字中间到底发生了什么在动手写代码之前我习惯先把数据流画在纸上。文档扫描 OCR 这条链路我最终定下来是五个阶段图像预处理灰度化、去噪、二值化把彩色照片变成干净的黑白图。边缘检测与透视矫正找到文档的四个角把歪斜的四边形拉平成正矩形。版面分析区分文字区域、表格区域、图片区域决定 OCR 的调用顺序。文字识别调用 OCR 引擎逐区域识别。后处理与结构化纠错、合并、按业务规则输出 JSON 或 CSV。这个顺序不是拍脑袋定的。早期我图省事跳过第 2 步直接识别结果一张倾斜 15 度的 A4 纸识别率直接从 95% 掉到 60% 出头。原因很简单主流 OCR 引擎不管是 Tesseract 还是 PaddleOCR在训练时用的都是水平文本行你给它一个斜的它的行分割算法就崩了。所以透视矫正是整条链路里性价比最高的一步花十分钟写代码能省下后面几小时的纠错。2.2 Python 3.7 环境下库的选型与版本坑Python 3.7 是个分水岭版本很多库的新版本已经不支持它了。我在选型时踩的第一个坑就是直接pip install opencv-python装到了最新版结果 import 时报undefined symbol因为新版 OpenCV 编译时用的 C 标准在旧系统上不兼容。最终我锁定的版本组合是这样的库版本用途备注opencv-python4.5.5.64图像预处理、透视变换4.6 开始对 3.7 支持变差numpy1.21.6矩阵运算1.22 要求 Python 3.8pytesseract0.3.10Tesseract 的 Python 封装需另装 Tesseract 引擎Pillow9.5.0图像读写、格式转换10.x 不再支持 3.7paddleocr2.6.1.3中文识别备选安装包较大按需提示装 OpenCV 时如果遇到编译问题优先用pip install opencv-python4.5.5.64指定版本别用--upgrade。我在这上面浪费了整整一个下午。选 Tesseract 还是 PaddleOCR我的经验是纯英文、印刷体、版面规整的文档Tesseract 足够而且轻量中文、混排、有表格的PaddleOCR 的中文模型明显更强。我最后是两个都装了按文档类型分流——英文合同走 Tesseract中文票据走 PaddleOCR。这个分流策略后面会详细讲。3. 图像预处理90% 的识别错误都出在这一步3.1 灰度化与自适应二值化为什么不能用全局阈值拿到一张手机拍的文档照片第一步是灰度化。这个没什么好说的cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)一行搞定。真正有讲究的是二值化。新手最容易犯的错是用全局阈值比如cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY)。为什么不行因为手机拍照必然有光照不均——左上角亮、右下角暗是常态。你用一个固定阈值 127亮的地方文字全糊成一片白暗的地方背景全变成黑。我实测过同一张图全局阈值下识别率 72%换成自适应阈值后直接到 91%。自适应阈值的核心思想是每个像素的阈值由它周围一小块区域的统计量决定而不是全图统一。代码长这样import cv2 import numpy as np def preprocess_image(image_path): img cv2.imread(image_path) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 自适应阈值blockSize 必须是奇数C 是常数修正项 binary cv2.adaptiveThreshold( gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, blockSize31, C10 ) return binaryblockSize31和C10这两个值怎么来的blockSize是计算阈值时考虑的邻域大小太小比如 11会把文字笔画本身也算进去导致笔画中间被掏空太大比如 101就退化成全局阈值了。31 是我在 300 DPI 扫描件上试出来的平衡点。C是从计算出的均值里减去的常数用来抑制噪点10 是个比较稳的起点噪点多就往上加。3.2 去噪与形态学操作别把文字也一起抹掉了二值化之后图上通常还有一堆椒盐噪点小黑点小白点。这时候很多人会直接上高斯模糊结果文字边缘也糊了。我的做法是先中值滤波去椒盐再用形态学开运算去掉细小的孤立噪点# 中值滤波核大小 3 对文字损伤最小 denoised cv2.medianBlur(binary, 3) # 开运算先腐蚀后膨胀去掉小噪点但保留文字主体 kernel np.ones((2, 2), np.uint8) opened cv2.morphologyEx(denoised, cv2.MORPH_OPEN, kernel)这里有个反直觉的点形态学操作的核不能太大。我一开始用了 3x3 的核结果细体字的笔画比如一、十直接被腐蚀没了。后来改成 2x2效果好很多。如果你处理的文档字体特别细甚至可以考虑跳过开运算只做中值滤波。还有一个坑去噪和锐化是矛盾的。去噪越狠文字边缘越钝OCR 越容易把0认成O、把1认成l。我的经验是宁可保留一点噪点也不要过度平滑。判断标准很简单——把处理后的图放大看文字边缘如果笔画还是清晰的黑色实心就对了如果边缘发灰、发虚就是处理过头了。3.3 实测对比不同预处理组合对识别率的影响为了让你有个直观感受我拿同一张中文票据做了组对比实验识别引擎统一用 PaddleOCR结果如下预处理方案识别准确率耗时(ms)原图直接识别58%420仅灰度化67%380灰度全局二值化72%390灰度自适应二值化89%410灰度自适应中值滤波91%430灰度自适应中值开运算90%445可以看到自适应二值化是那个质变点从 72% 跳到 89%。后面的滤波和形态学只是锦上添花甚至开运算因为损伤了细笔画反而掉了 1 个百分点。所以如果你时间有限优先把自适应二值化调好其他可以慢慢来。4. 透视矫正把歪的文档拉正的核心算法4.1 怎么找到文档的四个角透视矫正的前提是找到文档在照片里的四个顶点。主流做法是边缘检测 轮廓查找 多边形逼近。思路是文档通常是一块面积最大的、近似四边形的亮色区域。def find_document_corners(binary_img): # 边缘检测 edges cv2.Canny(binary_img, 50, 150) # 膨胀一下让断开的边缘连起来 edges cv2.dilate(edges, np.ones((3, 3), np.uint8), iterations1) # 找轮廓 contours, _ cv2.findContours( edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) # 按面积排序取最大的几个 contours sorted(contours, keycv2.contourArea, reverseTrue)[:5] for c in contours: # 多边形逼近epsilon 是精度参数 peri cv2.arcLength(c, True) approx cv2.approxPolyDP(c, 0.02 * peri, True) # 如果逼近出 4 个点就认为是文档 if len(approx) 4: return approx.reshape(4, 2) return NoneapproxPolyDP的epsilon参数这里是0.02 * peri是关键。它控制逼近精度太小四边形会被逼近成很多个点太大四边形会被简化成三角形。0.02 倍周长是个经验值对大多数文档都适用。如果你的文档边缘有破损或卷曲可以适当放大到 0.03。4.2 四个点的排序逻辑以及为什么顺序错了图就废了找到四个点之后必须按固定顺序排列通常是左上、右上、右下、左下否则透视变换会把图扭成麻花。排序逻辑我用的是坐标和差法def order_points(pts): rect np.zeros((4, 2), dtypefloat32) s pts.sum(axis1) diff np.diff(pts, axis1) # 左上角xy 最小 rect[0] pts[np.argmin(s)] # 右下角xy 最大 rect[2] pts[np.argmax(s)] # 右上角x-y 最小diff 是 y-x取最小 rect[1] pts[np.argmin(diff)] # 左下角x-y 最大 rect[3] pts[np.argmax(diff)] return rect这个方法的原理很直观在图像坐标系里原点在左上左上角的 xy 一定最小右下角一定最大而右上角的 x 大 y 小所以 x-y 最大注意np.diff算的是 y-x所以取最小。我见过有人用最左的点是左上这种逻辑遇到文档旋转超过 45 度就全乱了。坐标和差法对任意旋转都稳。4.3 透视变换的矩阵计算与输出尺寸确定有了排好序的四个点就可以算变换矩阵了。这里有个细节输出图的宽高要根据四边形的实际边长算不能写死否则文档会被拉伸变形。def four_point_transform(image, pts): rect order_points(pts) (tl, tr, br, bl) rect # 计算输出宽度上下边取最大值 widthA np.linalg.norm(br - bl) widthB np.linalg.norm(tr - tl) maxWidth max(int(widthA), int(widthB)) # 计算输出高度左右边取最大值 heightA np.linalg.norm(tr - br) heightB np.linalg.norm(tl - bl) maxHeight max(int(heightA), int(heightB)) # 目标点 dst np.array([ [0, 0], [maxWidth - 1, 0], [maxWidth - 1, maxHeight - 1], [0, maxHeight - 1] ], dtypefloat32) M cv2.getPerspectiveTransform(rect, dst) warped cv2.warpPerspective(image, M, (maxWidth, maxHeight)) return warped为什么宽高要取上下边、左右边的最大值因为拍照时文档会有透视形变上边和下边长度不一样。如果取最小值文档内容会被裁掉取最大值最多是边缘多留一点白不会丢内容。这个取舍原则在图像处理里很常见——宁可多留不可少留。实测下来透视矫正这一步对倾斜文档的识别率提升是决定性的。我做过测试一张倾斜 20 度的文档不矫正识别率 61%矫正后 93%。而且矫正后的图看起来正了后续的版面分析也好做很多。5. OCR 引擎的调用策略与参数调优5.1 Tesseract 的页面分割模式PSM选错等于白干Tesseract 有一个极其重要但经常被忽略的参数--psmPage Segmentation Mode。它告诉引擎这张图是什么版面结构选错了识别结果天差地别。常用的几种模式PSM 值含义适用场景3全自动分割默认规整的整页文档4假设是一列可变大小的文本单列排版6假设是一个统一的文本块裁剪好的单段文字7把图当成单行文本单行、标题11稀疏文本尽量找文字票据、表单这种零散文字我的经验是整页合同用 3裁剪后的段落用 6票据这种文字零散的用 11。有一次我处理一批发票用默认的 3识别率惨不忍睹换成 11 之后直接起飞。原因是发票上文字分散、有大片空白PSM 3 的分割算法会被空白区域带偏。调用代码import pytesseract def ocr_with_tesseract(image, langeng, psm3): config f--psm {psm} --oem 3 text pytesseract.image_to_string( image, langlang, configconfig ) return text--oem 3是 OCR 引擎模式3 表示默认LSTM 传统引擎混合一般不用改。lang参数要注意中文是chi_sim中英混排是chi_simeng但混排会慢不少按需选。5.2 PaddleOCR 的中文优势与初始化开销PaddleOCR 在中文场景下确实强尤其是对印刷体中文的识别准确率比 Tesseract 高一个档次。但它有个明显的缺点初始化慢。第一次调用要加载模型大概 3-5 秒。如果你要处理几千张图这个开销可以忽略但如果只是偶尔识别一张体验就很差。我的做法是把 PaddleOCR 实例做成全局单例程序启动时初始化一次后续复用from paddleocr import PaddleOCR _ocr_engine None def get_ocr_engine(): global _ocr_engine if _ocr_engine is None: _ocr_engine PaddleOCR( use_angle_clsTrue, # 自动纠正文字方向 langch, show_logFalse ) return _ocr_engine def ocr_with_paddle(image): engine get_ocr_engine() result engine.ocr(image, clsTrue) # result 是嵌套列表逐层拆出文字和置信度 lines [] for line in result[0]: text line[1][0] confidence line[1][1] if confidence 0.6: # 置信度过滤 lines.append(text) return \n.join(lines)use_angle_clsTrue这个参数建议开着它会自动判断文字是不是倒的、旋转的对手机拍的文档很有用。confidence 0.6这个过滤阈值是我试出来的——低于 0.6 的识别结果基本都是错的与其留着添乱不如直接丢掉后面用业务规则补。5.3 双引擎分流什么文档走哪条路前面提到我最后是 Tesseract 和 PaddleOCR 双引擎。分流规则很简单但很实用纯英文、印刷清晰、版面规整→ Tesseract快且准。含中文、中英混排、有表格→ PaddleOCR。票据、发票、表单→ PaddleOCR PSM 11 的思路PaddleOCR 没有 PSM但它的检测模型本身对稀疏文本友好。怎么自动判断走哪条我的做法是先跑一个轻量的语言检测——用 Tesseract 的eng模式快速识别一遍如果识别出的字符里非 ASCII 占比超过 30%就判定为中文文档转 PaddleOCR 重跑。这个先探路再决定的策略比无脑上重引擎要省资源。6. 版面分析与后处理让结果真正能用6.1 按行聚类把零散的文字块拼回段落OCR 引擎返回的往往是零散的文字块位置信息是有的但顺序可能是乱的。尤其是 PaddleOCR它返回的是检测框坐标加文字你需要自己按位置排序。我的做法是按 y 坐标聚类成行再按 x 坐标排序def sort_ocr_results(results, y_threshold10): # results: [(box, (text, conf)), ...] items [] for box, (text, conf) in results: # box 是四个点的坐标取左上角 x min(p[0] for p in box) y min(p[1] for p in box) items.append((x, y, text, conf)) # 先按 y 排序 items.sort(keylambda t: t[1]) # 再把 y 相近的归为同一行 lines [] current_line [] last_y None for item in items: if last_y is None or abs(item[1] - last_y) y_threshold: current_line.append(item) else: current_line.sort(keylambda t: t[0]) lines.append(current_line) current_line [item] last_y item[1] if current_line: current_line.sort(keylambda t: t[0]) lines.append(current_line) # 拼成文本 return \n.join( .join(t[2] for t in line) for line in lines)y_threshold10是判断两个文字块是否在同一行的像素阈值。这个值要跟你的图像分辨率匹配——300 DPI 的扫描件行高大概 40-50 像素阈值设 10-15 比较合适如果是低分辨率图要相应调小。设太大相邻两行会被合并设太小同一行会被拆开。6.2 常见 OCR 错误的纠正规则OCR 出来的文字尤其是数字和英文有几类高频错误。我整理了一份纠错规则用正则批量处理错误类型典型表现纠正规则数字 0 与字母 O1O0 应为 100数字上下文中 O→0数字 1 与字母 l/Il23 应为 123数字上下文中 l/I→1数字 5 与字母 SS00 应为 500数字上下文中 S→5数字 8 与字母 BB8 歧义结合上下文判断小数点丢失1234 应为 12.34金额字段按规则补import re def correct_ocr_text(text): # 在纯数字串里把常见字母替换成数字 def fix_numeric(m): s m.group(0) trans str.maketrans(OolISs, 001155) return s.translate(trans) # 匹配长度3的、由数字和易混字母组成的串 text re.sub(r[0-9OolISs]{3,}, fix_numeric, text) return text这个规则要小心用。比如英文单词 SO 会被误伤成 50。所以我的做法是只在特定字段上应用——比如金额、日期、编号这些明确是数字的字段才跑数字纠错正文段落不跑。这个分字段处理的思路比全局纠错安全得多。6.3 输出结构化数据JSON 还是 CSV最后一步是输出。我的建议是中间结果存 JSON最终交付看用途。JSON 能保留层级和字段名方便后续程序处理CSV 适合直接给业务人员看。import json def build_structured_result(lines, doc_typecontract): result { doc_type: doc_type, raw_text: \n.join(lines), fields: {} } # 按文档类型提取关键字段 if doc_type contract: for line in lines: if 甲方 in line: result[fields][party_a] line.split()[-1].strip() elif 乙方 in line: result[fields][party_b] line.split()[-1].strip() return result字段提取这块简单场景用关键词匹配就够了复杂场景比如表格就得上专门的表格识别模型或者用坐标做单元格切分。我处理的那批合同字段比较固定关键词匹配加正则就搞定了没必要上重型方案。能用简单方法解决的别过度设计这是我踩了很多坑之后的体会。7. 两千页实测那些文档里不会写的坑7.1 内存泄漏PaddleOCR 连续调用会涨内存这个坑我踩得最惨。批量处理到第 800 多张的时候程序突然被系统 kill 了。查了半天发现是 PaddleOCR 在连续调用时内存缓慢上涨每张图涨几 MB累积下来就爆了。解决办法有两个一是分批处理每处理 200 张就重启一次引擎二是显式释放中间变量。我最后用的是分批加重启def batch_process(image_paths, batch_size200): results [] for i in range(0, len(image_paths), batch_size): batch image_paths[i:ibatch_size] engine get_ocr_engine() for path in batch: results.append(process_single(path, engine)) # 处理完一批释放引擎 global _ocr_engine _ocr_engine None import gc gc.collect() return results重启引擎有 3-5 秒开销但相比程序崩溃重跑这点代价完全值得。如果你用的是 Tesseract它每次调用都是独立进程反而没这个问题。7.2 图像方向EXIF 信息导致的躺平文档手机拍的照片很多带 EXIF 方向信息。用 OpenCV 读进来的时候它不读 EXIF所以一张竖着拍的照片读进来可能是横的。我第一次处理时就遇到这个一批图全是躺着的OCR 识别率极低。解决方法是读图时用 Pillow 处理 EXIF再转给 OpenCVfrom PIL import Image, ImageOps import numpy as np def read_image_with_exif(path): img Image.open(path) img ImageOps.exif_transpose(img) # 根据 EXIF 自动旋转 return cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)ImageOps.exif_transpose会自动根据 EXIF 里的方向标记旋转图像。这一行代码解决了我 90% 的文档躺平问题。剩下的 10% 是 EXIF 信息丢失的只能靠 OCR 引擎的use_angle_cls兜底。7.3 处理速度优化从每张 3 秒到每张 0.8 秒两千页如果每张 3 秒就是 100 分钟。我优化到了每张 0.8 秒总耗时降到 27 分钟。几个关键优化点图像降采样如果原图是 4000x3000先缩到 2000x1500 再处理。OCR 对分辨率的需求没那么高2000 宽足够速度却能快一倍多。预处理用 OpenCV 的原地操作很多cv2函数支持dst参数可以避免创建新数组。多进程Python 的 GIL 让多线程在 CPU 密集任务上没用但多进程有效。我用multiprocessing.Pool开了 4 个进程速度提升接近 3 倍。from multiprocessing import Pool def process_wrapper(args): path, doc_type args return process_single(path, doc_type) if __name__ __main__: tasks [(p, contract) for p in image_paths] with Pool(processes4) as pool: results pool.map(process_wrapper, tasks)注意多进程下每个进程会各自初始化一份 OCR 引擎内存占用会翻倍。如果你的机器内存紧张进程数要相应减少。我那台旧笔记本 8G 内存开 4 个进程刚好开 6 个就开始 swap 了。7.4 识别质量的最后一公里人工抽检策略再好的 OCR 也不可能 100% 准确。我的做法是按置信度分层抽检置信度高于 0.9 的抽 5% 人工核对0.7-0.9 的抽 30%低于 0.7 的全部人工核对。这样既保证了质量又不会让人工成本失控。具体实现就是在输出结果里带上置信度然后按阈值分流def split_by_confidence(results, high0.9, mid0.7): high_conf, mid_conf, low_conf [], [], [] for r in results: if r[confidence] high: high_conf.append(r) elif r[confidence] mid: mid_conf.append(r) else: low_conf.append(r) return high_conf, mid_conf, low_conf这套策略跑下来我那两千页文档最终的错误率控制在 0.5% 以内人工核对只花了大概 3 小时。如果全部人工核对至少得 20 小时。把人力用在刀刃上这是批量 OCR 项目里最值钱的经验。8. 写在最后的一点个人体会这套流水线我前后迭代了大概三周从最初能识别就行到后来识别率 95% 以上中间踩的坑基本都写在上面的章节里了。如果让我给正在做类似项目的朋友一句建议那就是别急着调 OCR 引擎的参数先把图像预处理和透视矫正做扎实。我见过太多人把时间花在换引擎、调模型上结果喂进去的图还是歪的、糊的怎么调都没用。另外Python 3.7 虽然老但生态其实够用关键是锁死版本别让pip自作主张升级。我现在的做法是每个项目都配一个requirements.txt把版本号写死换机器时pip install -r requirements.txt一把梭省心。最后分享一个小技巧如果你处理的文档类型比较固定比如都是同一种发票可以把第一张识别正确的图存下来当模板后续的图先跟模板做配准再按模板的坐标去切字段。这样字段提取的准确率能再上一个台阶比纯靠关键词匹配稳得多。这个思路我在处理一批格式统一的票据时用过效果很好值得一试。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表