简介面向文档数字化与OCR入门实践的素材包适合希望用开源工具或在线服务完成图片文字提取的开发者、学生及办公自动化人员可辅助理解票据类图像的识别流程与脚本组织方式。包内共3个文件以2个Python脚本为主另有1个jpg格式的收据图像样例压缩包约2.03MB脚本分别承担演示与测试用途图片可作为识别输入便于快速搭建最小可运行示例并核对输出结果。已有192人学习说明该素材对OCR基础实践具有一定参考价值。两个脚本与样例图像配合可帮助读者观察从图像读取、文字识别到结果验证的基本链路也便于在此基础上替换图片、调整参数或接入其他OCR引擎对于需要理解OCR脚本结构、准备课程示例或做小型文档识别试验的读者这份轻量素材能减少前期搜集与整理成本。1. 扫描件里的字复制不出来OCR 在文档处理链路上到底卡在哪接手一份两百页的扫描合同模板第一个动作往往不是写业务代码而是确认里面的字能不能被选中。选中不了全文检索、条款比对、字段抽取这些下游动作全都无从谈起OCR 补的正是这一环。它不是一个“调个接口把图传上去”的动作而是一条链图像质量、倾斜校正、版面切块、引擎识别、置信度过滤、结构化回写任何一环掉链子最后落到数据库里的都是废文本。这里拆的是一套能直接跑起来的方案本地开源引擎兜底、在线服务补位单图和批量 PDF 两条链路都给到可抄的代码参数取值的理由和翻车点一并写清。适合正在做文档归档、合同与发票入库、课件素材转文本的从业者也适合手上压着一批扫描件、只想先把它们变成可搜索文本的人。2. 选型三条 OCR 路线与一套锁版本的最小环境2.1 三条路线的分水岭传统引擎、深度学习本地、在线接口OCR 引擎按原理大致分三档选错档位比调参调一天更浪费时间。第一档是传统引擎代表是 Tesseract 5内部已经是 LSTM 结构按语种加载语言包中文要单独装chi_sim。它的优势是极轻、纯离线、对干净印刷体够用短板是对低对比度、倾斜、复杂版面非常敏感同一张图换个扫描仪可能就差十几个点。第二档是深度学习路线检测框 方向分类 文字识别三段式PP-OCR 系列、RapidOCR 的 onnx 版本、EasyOCR 都属于这一类模型体积十几兆到上百兆中文场景准确率明显更高而且会返回每个文本框的坐标和置信度这对后面重建版面至关重要。第三档是在线 OCR 接口省事但数据要出域、按次计费、有并发限流适合做兜底而不是主力。维度传统引擎Tesseract 5深度学习本地在线 OCR 接口中文印刷体准确率中等干净扫描件可用高复杂版面也稳高是否离线完全离线完全离线需要联网首次部署成本低一个系统包中要拉模型文件极低一个密钥是否返回坐标返回词级框返回行级四点框视接口而定单页耗时CPU0.3~1 秒0.5~2 秒网络往返 0.5~3 秒数据是否出域否否是我的默认组合是深度学习本地引擎跑主链路传统引擎跑降级和对照在线接口只在本地置信度普遍偏低时做抽样复核。这么配的理由很实际——批量任务里 95% 的页面是正常的本地跑零成本剩下 5% 的疑难页才值得付出网络和费用代价。2.2 为什么预处理和版面重建比换引擎更值得投入引擎是常量预处理是变量。同一批 300dpi 扫描件不做倾斜校正直接喂进去和做过切边、倾斜校正之后再喂中文识别错误率能差 5 到 15 个百分点这个差距比换一个引擎带来的提升还大。原因在于检测模型对行方向的假设是水平的输入倾斜 3 度以上检测框就会开始粘连识别阶段拿到的是一条斜着的长条图模型等于在看一堆错位的笔画。另一个容易被忽略的点是输出顺序。绝大多数引擎返回的是“按检测框位置”排的文本不是按阅读逻辑排的。双栏排版会被左右串行表格会被拆成一行行散句页眉页脚会夹在正文中间。所以真正决定可用性的不是识别率而是版面重建拿到每个框的坐标之后先按 y 聚类成行再按 x 排序遇到跨栏宽度的空隙就断开遇到重复出现在页首页尾的短文本就判为页眉页脚丢掉。这套逻辑写起来不到一百行但它是把“一堆文字”变成“一份文档”的分水岭。还有一条边界要说清楚二值化对传统引擎有增益对深度学习引擎反而经常是负收益。深度学习模型训练时吃的是彩色或灰度原图你把图强行二值化等于给它喂了分布外的数据笔画断裂和噪点会被当成字符的一部分。所以我一般会给两类引擎准备两条预处理分支传统走灰度 自适应二值化深度学习走灰度 轻度去噪 缩放不做二值化。2.3 最小可跑环境把版本钉死别让依赖漂移OCR 这套东西最烦的是依赖漂移。同一个脚本上个月跑得好好的系统更新了引擎主版本识别结果就变了你还以为是数据问题。所以第一步是锁版本。# 系统层引擎本体和中文语言包必须确认 chi_sim 已装 apt-get install -y tesseract-ocr tesseract-ocr-chi-sim tesseract-ocr-chi-sim-vert tesseract --version # 记下主版本号5.x 的 LSTM 行为和 4.x 有差异 tesseract --list-langs # 输出里必须能看到 chi_sim没有就是语言包没装对 # Python 层全部固定到具体版本 pip install pytesseract0.3.10 \ opencv-python4.9.0.80 \ pymupdf1.24.2 \ rapidocr-onnxruntime1.3.24 \ numpy1.26.4这段命令里有几个参数值得展开。tesseract-ocr-chi-sim是简体中文横排模型chi_sim-vert是竖排古籍或者日式排版才需要后者普通合同发票不装也行装了会多占几十兆内存。pytesseract只是壳真正干活的是系统里的引擎二进制所以它的版本要和引擎版本对齐着记在文档里。rapidocr-onnxruntime用的是 onnx 推理后端不依赖深度学习框架装完就能用CPU 上单页一秒左右很适合塞进现有的 Python 服务里。numpy钉在 1.26 是因为 2.x 之后有几个老库的 ABI 不兼容OCR 链路里用到的图像库容易被牵连。提示装完先跑一次空转自检把tesseract --list-langs的输出和 Python 侧rapidocr的模型加载日志都打到启动日志里出问题时能第一时间排除环境因素省掉半天排查。3. 单图链路预处理、识别、行重建与后处理四段代码3.1 预处理缩放、倾斜校正与分支二值化预处理的目标不是把图变漂亮而是把图变成引擎训练时见过的那种图。三个动作按顺序来先限制最长边再纠正倾斜最后按引擎类型决定要不要二值化。import cv2 import numpy as np def preprocess(path, target_long_side2000, for_traditionalTrue): # 用 imdecode 走字节流避免 OpenCV 在中文路径下读文件失败 img cv2.imdecode(np.fromfile(path, dtypenp.uint8), cv2.IMREAD_COLOR) h, w img.shape[:2] # 1) 限制最长边太大拖慢推理太小丢小字号 scale target_long_side / max(h, w) if scale 1: img cv2.resize(img, None, fxscale, fyscale, interpolationcv2.INTER_AREA) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 2) 倾斜校正Otsu 取前景最小外接矩形拿角度 _, bw cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU) coords cv2.findNonZero(bw) angle cv2.minAreaRect(coords)[-1] angle -(90 angle) if angle -45 else -angle # 角度太小没必要转太大说明前景框取错了直接放弃校正 if abs(angle) 0.3 or abs(angle) 15: angle 0.0 if angle: m cv2.getRotationMatrix2D((gray.shape[1] / 2, gray.shape[0] / 2), angle, 1.0) gray cv2.warpAffine(gray, m, (gray.shape[1], gray.shape[0]), flagscv2.INTER_CUBIC, borderModecv2.BORDER_REPLICATE) if not for_traditional: # 深度学习分支只做轻度去噪保留灰度层次 gray cv2.fastNlMeansDenoising(gray, None, 7, 7, 21) return gray, angle # 3) 传统引擎分支自适应二值化blockSize 必须大于笔画宽度 bw cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 31, 15) return bw, angletarget_long_side2000是个折中值。A4 纸 300dpi 大约是 2480×3508压到 2000 长边之后字符高度还在 30 像素上下正好落在检测模型的舒适区压到 1200 以下正文小字会糊成一团识别率断崖式下跌。blockSize31要和字号匹配它必须显著大于笔画宽度否则二值化会把笔画内部也判成背景字就空心C15是常数项偏大会把浅色水印也留成前景偏小会把浅印的文字直接抹掉扫描件偏灰时往上调到 20 左右。abs(angle) 0.3这个门槛是防止把本来正的图转歪——最小外接矩形对细长前景很敏感一页纸的边缘噪点就能让它给出个 0.1 度的假角度。3.2 识别调用词级结果与行级结果怎么取传统引擎用词级输出深度学习引擎用行级四点框输出两种结果结构不一样后处理要分开写。import pytesseract from pytesseract import Output def ocr_words(bw): # oem 3 表示走默认 LSTM 引擎psm 6 假定输入是一整块统一排版的文本 data pytesseract.image_to_data( bw, langchi_simeng, config--oem 3 --psm 6, output_typeOutput.DICT ) words [] for i, txt in enumerate(data[text]): txt txt.strip() conf int(float(data[conf][i])) if not txt or conf 0: # conf 为 -1 的是非文字块直接丢 continue words.append({ text: txt, conf: conf, box: (data[left][i], data[top][i], data[width][i], data[height][i]), }) return words--psm 6是“假设整张图是一块统一的文本块”适合裁好的正文区域如果喂进去的是整页包含表格和图片的版式用--psm 3让引擎自己做页面分割但准确率会不稳。langchi_simeng里英文放后面是因为中英混排时中文模型对数字和字母的处理已经够用多挂一个语言包主要是为了识别金额里的货币符号和编号里的字母串。conf的取值范围是 0 到 100负值表示这个框不是文字很多新手没过滤这一项最后文本里混进一堆空的“区块”标识。深度学习这边换一套写法结果里直接带四点框和分数from rapidocr_onnxruntime import RapidOCR engine RapidOCR() def ocr_lines(img_path): result, _ engine(img_path) # result 结构[[四点坐标, 文本, 置信度], ...]无文本时返回 None if not result: return [] return [{box: r[0], text: r[1], conf: float(r[2])} for r in result]engine()内部已经做完检测、方向分类、识别三步box是左上、右上、右下、左下四个点用它做行重建比矩形框精确得多——倾斜文本的矩形框会互相重叠四点框不会。3.3 行重建把散框拼回可读的段落拿到带坐标的框之后必须重排否则双栏文档会左右串行、表格会散架。def rebuild_paragraphs(items, y_tol12, gap_ratio2.2): # 先按 y 排再按 x 排让同一行的框挨在一起 items sorted(items, keylambda it: (it[box][0][1], it[box][0][0])) rows, cur [], [] for it in items: y it[box][0][1] if cur and abs(y - cur[-1][box][0][1]) y_tol: rows.append(cur) cur [] cur.append(it) if cur: rows.append(cur) lines [] for row in rows: row.sort(keylambda it: it[box][0][0]) heights [it[box][2][1] - it[box][0][1] for it in row] avg_h sum(heights) / len(heights) text, prev_right , None for it in row: x_left it[box][0][0] # 横向空隙超过 2.2 个字高判定为分栏或表格列插入分隔 if prev_right is not None and (x_left - prev_right) gap_ratio * avg_h: text | text it[text] prev_right it[box][1][0] lines.append(text) return linesy_tol12是判定“同一行”的纵向容差扫描件有轻微抖动取 10 到 15 之间比较稳纸张被压出弧度时局部行会歪全局固定容差不够用稳妥做法是按页面高度自适应比如取平均字高的 0.5 倍。gap_ratio2.2是分栏判定阈值取太小会把正常字间距判成分栏取太大则跨栏的两段被拼成一句这个值我在合同和论文两类文档上试过1.8 到 2.5 之间差异不大2.2 是个安全的默认值。输出里用竖线做列分隔是为了后面拆表格时有个锚点。3.4 后处理置信度过滤与字段抽取识别完的原始文本脏得很全角半角混用、中文之间夹空格、数字被识别成形近字必须过一遍清洗和校验。import re from decimal import Decimal DATE_RE re.compile(r(20\d{2})[年./-](\d{1,2})[月./-](\d{1,2})) AMOUNT_RE re.compile(r[¥]\s*(\d{1,3}(?:,\d{3})*(?:\.\d{1,2})?)) def clean_text(t): t re.sub(r(?[\u4e00-\u9fa5])\s(?[\u4e00-\u9fa5]), , t) # 中文之间空格 t t.replace(, :).replace(, ,) return t.strip() def extract_fields(lines, conf_threshold60): fields {} for line in lines: if any(tok_conf conf_threshold for tok_conf in [0]): # 占位见下方说明 continue if m : DATE_RE.search(line): fields.setdefault(date, f{m.group(1)}-{int(m.group(2)):02d}-{int(m.group(3)):02d}) if m : AMOUNT_RE.search(line): fields.setdefault(amount, Decimal(m.group(1).replace(,, ))) return fieldsconf_threshold60是血泪经验值。低于 60 的行基本都是形近字误识比如“己/已/巳”“未/末”这些字在合同条款里一错就是语义级事故与其让它混进正文不如整行标记出来转人工。清洗函数里那条正则只删中文之间的空格不动英文之间的空格因为英文单词间的空格是必需的一刀切会把“Total Amount”粘成一个词。金额抽取先去掉千分位逗号再转Decimal用浮点数做金额比较迟早在对账时翻车。日期统一补零成 ISO 格式是为了后面入库时能直接按字符串排序。4. 批量 PDF先判文本层再做 300dpi 渲染与断点续跑4.1 先判断有没有文本层别对电子版 PDF 白跑 OCR批量任务里最常见的浪费是对着电子生成的 PDF 跑 OCR——那些文件的文字本来就在直接抽取又快又准跑 OCR 反而引入错字。import fitz def has_text_layer(doc, sample_pages5): pages doc[:sample_pages] if doc.page_count sample_pages else doc chars sum(len(p.get_text(text).strip()) for p in pages) return (chars / len(pages)) 200 # 平均每页超过 200 字基本可判定为文本型 PDFsample_pages5是抽样策略只翻前几页避免为了判断把整本 500 页读完。200这个阈值不是绝对的封面页和目录页字数少会把平均值拉低稳妥做法是抽样页里只要有一半以上超过阈值就判为文本型。这里要提醒一个坑有些扫描件被做过“伪文本层”PDF 里确实有文字但那是别人用低质量 OCR 灌进去的字全是错的这种情况靠字数判断不出来得抽一页看看文本内容是不是通顺。4.2 渲染参数与并发300dpi 是甜点进程数别拉满import os import fitz from multiprocessing import Pool def render_page(args): pdf_path, page_no, dpi args doc fitz.open(pdf_path) # 每个进程各自打开避免句柄共享 pix doc[page_no].get_pixmap( matrixfitz.Matrix(dpi / 72, dpi / 72), # 72 是 PDF 的基准分辨率 colorspacefitz.csRGB, alphaFalse, # 关掉透明通道省内存 ) png pix.tobytes(png) doc.close() return page_no, png def render_all(pdf_path, dpi300): doc fitz.open(pdf_path) n doc.page_count doc.close() workers min(8, os.cpu_count() or 1) with Pool(workers) as pool: for page_no, png in pool.imap_unordered( render_page, [(pdf_path, i, dpi) for i in range(n)] ): yield page_no, pngdpi300是这套链路的甜点值。A4 在 300dpi 下约 2480×3508正文字符高度 35 到 45 像素正好是中文检测模型的最优区间降到 200 以下六号字和脚注会明显掉点提到 400 以上识别率提升不到一个点耗时和内存却翻倍。alphaFalse这一项别省带透明通道的图在转灰度时会和白色背景做混合边缘发灰二值化之后笔画会毛。进程数取min(8, cpu_count)并且要在引擎侧把 onnxruntime 的线程数显式设成 1否则每个进程都在内部开满线程CPU 上下文切换会把收益吃干净表面看是并发实际比单进程还慢。4.3 回写文本、坐标 JSON 与可检索 PDFimport json import os def save_page_result(out_dir, doc_id, page_no, lines): page_dir os.path.join(out_dir, doc_id) os.makedirs(page_dir, exist_okTrue) payload { page: page_no, lines: [{text: ln, conf: round(c, 2)} for ln, c in lines], } # 先写临时文件再改名避免进程中断留下半个 JSON tmp os.path.join(page_dir, fp{page_no:04d}.json.tmp) with open(tmp, w, encodingutf-8) as f: json.dump(payload, f, ensure_asciiFalse, indent1) os.replace(tmp, os.path.join(page_dir, fp{page_no:04d}.json))页级落盘的粒度选得比文档级细是刻意的。整本处理中途崩掉文档级落盘等于全废页级落盘只丢当前页。os.replace在多数文件系统上是原子操作用它替代直接写目标文件能避免“文件存在但内容是半截”的脏状态——这种脏文件在断点续跑时最坑程序看到文件存在就跳过结果那一页永远是空的。需要可检索 PDF 时思路是把识别出的文本按坐标写回原页的文本层让阅读器能选中和搜索。现成方案有 OCRmyPDF 这类工具直接命令行跑一条就出双层 PDF要自己控制精度就用page.insert_textbox按框位置逐行写入字号取框高的 0.8 倍左右颜色设为白色或透明视觉上不污染原图搜索时又能命中。4.4 断点续跑与失败重试def run_batch(pdf_path, doc_id, out_dir, dpi300): doc fitz.open(pdf_path) total doc.page_count doc.close() for page_no, png in render_all(pdf_path, dpi): done os.path.join(out_dir, doc_id, fp{page_no:04d}.json) if os.path.exists(done): # 断点续跑的核心存在即跳过 continue try: lines [(it[text], it[conf]) for it in ocr_lines_bytes(png)] save_page_result(out_dir, doc_id, page_no, lines) except Exception as e: # 失败页单独记日志不中断整批 with open(os.path.join(out_dir, failed.log), a, encodingutf-8) as f: f.write(f{doc_id}\t{page_no}\t{type(e).__name__}: {e}\n) return total跳过判断放在渲染之后、识别之前看着有点反直觉——渲染也费时间为什么不提前判断因为渲染后的 PNG 还要在内存里传给识别函数提前判断就得把页数列表读两遍。这个顺序的代价是多花几秒渲染收益是代码简单、不会出现“跳过判断和实际处理错位”的诡异 bug。失败页写日志而不抛异常是因为批量任务里坏页是常态一页损坏整批退出跑一晚上回来发现停在第 37 页这种体验谁试谁知道。5. 避坑识别率上不去的五个现场排查5.1 输出全是方块或乱码现象识别结果是一串□□□或者拉丁字母乱码明明原图清清楚楚。原因基本只有一个——引擎没找到中文语言包或者lang参数写错。Tesseract 在找不到指定语种时会静默降级到默认英文模型不报错直接把中文识别成一堆无意义的符号。解决先跑tesseract --list-langs确认chi_sim在列表里再检查代码里的langchi_simeng拼写注意这里是下划线不是横线chi-sim会加载失败。容器化部署时语言包经常被漏装Dockerfile 里要显式加一句装语言包的指令别指望基础镜像自带。5.2 加了预处理识别率反而下降现象原本能识别的页面加了一步二值化之后错误率暴涨笔画断裂、标点消失。原因是被二值化了两次或者给深度学习引擎喂了二值图。很多人写流程时在预处理里转了一次灰度并二值化识别函数内部又调了一次cv2.cvtColor虽然不会报错但自适应阈值在一块已经很干净的图上会做出过度分割。更常见的是把二值图送进深度学习引擎模型没见过这种分布识别置信度普遍掉到 0.5 以下。解决预处理函数加一个显式开关在日志里打印当前走的是哪条分支二值化只给传统引擎用。5.3 表格内容全变成一串散句现象一张三列六行的表格识别出来是十八行独立的文本列之间的关系完全丢失。原因是引擎只做文字检测不做结构理解输出的顺序按框的纵坐标排同一行的三个单元格被当成一句话拼在一起。解决的思路是在 3.3 的行重建阶段保留横坐标遇到超过 2.2 个字高的横向空隙就插入分隔符然后在后处理里按分隔符的列数一致性做校验——连续多行的分隔符数量和位置接近才能判定为表格区域。要更精确就上专门的表格识别模型但那属于另一条技术栈普通归档需求用坐标切分够了。5.4 竖排文字和旋转页识别成天书现象古籍影印件、竖排的证书、被旋转 90 度扫描的附件识别出来全是单字乱序。原因是检测模型默认文本行是水平的遇到竖排会按列切成一个个单字读序完全错。Tesseract 有竖排语言包chi_sim_vert但只在明确是竖排时才能挂挂着它跑横排反而更差。更通用的做法是先做方向检测用引擎自带的方向分类能力判断页面朝向旋转回 0 度再进主链路整本 PDF 里混排的情况按页记录方向标记回写时再转回去。5.5 批量跑到一半内存爆掉现象单个文件测试正常一批两百页跑十几分钟之后进程被系统杀掉日志里没有任何异常。原因是把整本 PDF 渲染出的图全堆在内存里一张 300dpi 的 A4 转成位图接近 25MB两百页就是 5GB再加上模型本身占的内存必然被 OOM Killer 干掉。解决改成生成器逐页产出、逐页释放Pool.imap_unordered本身就带惰性求值的特性只要不在外面套一层list()就不会全量展开同时给渲染出来的图设一个尺寸上限超大页先降采样再识别。6. 进阶用 50 张样本和置信度校验把 OCR 变成可交付结果调 OCR 最怕的是“感觉变好了”。改了一个参数看了两张样例觉得准确率上去了全量跑完才发现另一类文档崩了。所以我一般会先建一个小回归集从真实数据里挑 50 张覆盖打印件、扫描件、翻拍件、带表格、带印章、中英混排六类每张人工校对出一份标准文本之后每次改参数都跑一遍算字符错误率。def cer(pred, truth): # 字符错误率 编辑距离 / 标准文本长度中文按字算不用分词 n, m len(pred), len(truth) dp list(range(m 1)) for i in range(1, n 1): prev, dp[0] dp[0], i for j in range(1, m 1): cur dp[j] dp[j] min(dp[j] 1, dp[j - 1] 1, prev (pred[i - 1] ! truth[j - 1])) prev cur return dp[m] / max(m, 1)这个函数跑 50 张、每张几百字耗时不到一秒完全可以塞进 CI 里当门禁cer均值超过上一个版本就报警。实践下来印刷体在这个样本集上能压到 1.5% 以下翻拍件在 4% 左右带印章遮挡的能到 8%超过 10% 就说明预处理分支选错了。第二个技巧是利用置信度做分层处理而不是一刀切。我的做法是按页统计置信度分布中位数高于 0.9 的页直接入库0.7 到 0.9 之间的页做字段级校验金额和日期必须过正则和校验位过不了就退回重识别或转在线接口低于 0.7 的页整体转人工。这套分层让自动化率从 70% 提到 92%人工只处理那 8%成本降得比换引擎明显。第三个是词典兜底。中文里形近字错误集中在有限的几组把业务领域的专有名词、公司名后缀、常见职务做成一份白名单识别完成后对置信度偏低的词做一次近似匹配替换能修掉相当一部分错误。这招对固定模板的文档比如合同、发票、证书效果特别好换到自由文本上就慎用容易把本来正确的词改错。最后说个我自己踩过的坑。早期我做批量任务时为了省事把置信度阈值设成 0想着反正后面有人工复核结果人工看到的是一堆 0.3 分的垃圾行混在正文里反而比全人工更慢。从那以后我每次上新链路都强制先在小样本上把置信度分布打出来看一眼再决定阈值往哪放这个习惯一直保留到现在。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?