简介:《JAVA-OCR图文识别解决方案》是一份面向Java后端开发者的OCR接入选型参考文档,聚焦图文识别在业务中的落地难题,对三种常见技术路线做横向比较。内容以可直接运行的代码片段为主线:方案一基于云端通用文字识别接口,完整演示APPID、API KEY、SECRET KEY的配置方式,以及超时、代理、log4j日志等参数调优;方案二与方案三基于Tesseract开源引擎,分别给出PDF渲染后识别、图片转PDF后识别的实现,并逐条标注识别准确率、乱码、图片方向限制等实际缺陷。压缩包仅含1个docx文件,约12KB,体量轻便,适合当作技术选型速查表随取随用。目前已有273人学习下载。读者可据此快速判断免费额度、请求频次与识别精度之间的取舍,直接复用示例代码搭建验证环境,减少在接口对接与踩坑排查上的时间投入。
1. 从一张拍歪的报销单说起:JAVA-OCR 图文识别方案的三条路线
上周有同事把一张四百多块的票根拍歪了,供应商编号那串字母数字人工敲了三遍全错。这类活交给 JAVA-OCR 图文识别方案最省事:图片丢进去,吐出一段结构化文本。手上这份解决方案文档拆了三套实现:一套是云厂商的 OCR SDK,准确率高、速度快,但每月有额度;另外两套是 Tesseract 离线链路,完全免费,代价是图片得先塞进 PDF 里。如果你的技术栈是 Java,做的是报销票据、证照录入、档案扫描件归档这类业务,这三条路基本覆盖了绝大多数落地场景。麻烦在于选错路线,后面几个月都在补丁上叠补丁——所以先把三者的边界摸清楚,再动手写代码。
2. 云端接口第一次跑通:AipOcr 初始化、超时与 basicGeneral 调用
云 SDK 这条路胜在省事,坑也集中在「省事」二字上:密钥怎么管、超时设多少、返回结构里哪些字段是真能用的、额度耗尽怎么兜底。这一章按一次完整调用顺序走一遍。
2.1 密钥三件套与环境变量的取舍
原文档把 APP_ID / API_KEY / SECRET_KEY 写成public static final String硬编码在类里,这是最典型的翻车起点:代码一提交到仓库,密钥就等于公开了,而这类云接口的密钥通常和账号计费绑定,泄露后跑出来的量全记在你头上。我一般会把三件套挪到环境变量或者配置中心,本地开发用.env加启动参数注入,容器环境走 Secret 挂载。
import org.json.JSONObject; import java.util.HashMap; public class CloudOcrDemo { // 从环境变量读取,避免密钥进代码仓库 private static final String APP_ID = System.getenv("OCR_APP_ID"); private static final String API_KEY = System.getenv("OCR_API_KEY"); private static final String SECRET_KEY = System.getenv("OCR_SECRET_KEY"); public static void main(String[] args) { // 初始化客户端,构造参数顺序固定:appId、apiKey、secretKey AipOcr client = new AipOcr(APP_ID, API_KEY, SECRET_KEY); // 建连超时 2s:只覆盖 TCP 握手,不要指望它管住整个识别耗时 client.setConnectionTimeoutInMillis(2000); // 读超时 60s:大图上传 + 服务端识别,这里给短了必然 SocketTimeoutException client.setSocketTimeoutInMillis(60000); String imagePath = "D:/ocr/samples/invoice_001.jpg"; // 第二个参数是可选参数字典,先传空,后面按需填 JSONObject res = client.basicGeneral(imagePath, new HashMap<String, String>()); System.out.println(res.toString(2)); } }两个超时参数含义完全不同,别混着调。setConnectionTimeoutInMillis(2000)是客户端到服务端建立连接的时间,正常内网出去 100~300ms 就够,2000ms 给的是抖动余量;如果你的机器出口网络不稳定,频繁抛连接超时,往 3000~5000 调,但别超过 5s,否则一个坏请求会长时间占着线程。setSocketTimeoutInMillis(60000)是数据发完之后等服务端返回结果的等待时间,票据类大图上传加识别,正常在 2~8s 区间,60s 是给极端情况留的。我在生产里踩过最典型的一次是把它压到 5000ms,白天没事,晚高峰批量跑的时候一批图被服务端排队,全部读超时,日志里只有一行SocketTimeoutException,排查了半天才定位到是参数太紧。
basicGeneral的第二个入参是HashMap<String, String>,用来传可选项,比如是否返回识别概率、是否只识别某类区域之类。不使用这些可选项时可以传空 Map,但不要把第一个参数直接为null,SDK 里对入参的判空并不总是友好,容易在内部抛NullPointerException而不是明确告诉你图没传。
2.2 basicGeneral 的返回结构:别只打印 toString(2)
原文档最后一行是System.out.println(res.toString(2)),调试够用,接业务就完全不够了。这个接口返回的是一个 JSON 对象,核心是三个字段:
| 字段 | 类型 | 用途 | 备注 |
|---|---|---|---|
log_id | long | 单次请求的唯一标识 | 出问题找服务方排查时唯一能对上的凭据,必须落日志 |
words_result_num | int | 识别到的文本行数 | 用来判断是空图还是识别失败 |
words_result | array | 每行文本,元素含words字段 | 行序基本按从上到下、从左到右 |
处理逻辑我一般这么写:先看words_result_num是不是 0,是 0 就直接判为「无有效文字」,不要往下走后续解析;再看words_result数组,把每行的words抽出来按行拼接。注意这个接口返回的是纯文本行,没有坐标信息,如果你后面要做区域抽取(比如只取右下角金额),就得换用带位置返回的接口,额度和耗时另算,具体以你实际用的 SDK 版本为准,不要凭记忆写死方法名。
JSONObject res = client.basicGeneral(imagePath, new HashMap<String, String>()); long logId = res.getLong("log_id"); int lineCount = res.getInt("words_result_num"); if (lineCount == 0) { System.err.println("未识别到文字, log_id=" + logId); return; } JSONArray lines = res.getJSONArray("words_result"); StringBuilder sb = new StringBuilder(); for (int i = 0; i < lines.length(); i++) { sb.append(lines.getJSONObject(i).getString("words")).append("\n"); } String fullText = sb.toString();log_id单独立即写进日志,别等到出问题才想起没记。线上的纠错成本很高,因为这个 ID 只在当次响应里出现一次,丢了就要用户重新传图复现。
2.3 每月 1000 次额度怎么分配
原文档写得很实在:个人申请暂时免费,每月 1000 次请求,部分接口只有 200 或 500 次,后期可能收费。这个量级放在个人项目绰绰有余,放到一个日活几百的业务上,半天就打完了。所以额度管理不是「以后再说」,而是从第一版就要设计进去。
我的做法是三层削峰。第一层是入参前置校验:图片小于一定尺寸、纯色背景、明显模糊的,本地直接判否,不浪费一次调用。第二层是结果缓存:以图片内容的 MD5 加上接口名做 key,把返回文本缓存到本地或 Redis,同一张图重复提交直接命中缓存,票据场景里重复提交的比例比想象中高得多。第三层是降级链:额度用到 80% 时告警,到 95% 时自动切到 Tesseract 离线链路,识别质量会掉,但系统不至于趴下。
// 额度保护:先查缓存,未命中再走云端 String md5 = DigestUtils.md5Hex(Files.readAllBytes(Paths.get(imagePath))); String cacheKey = "ocr:cloud:" + md5; String cached = redis.get(cacheKey); if (cached != null) { return cached; // 命中缓存,零额度消耗 } if (quotaUsedThisMonth() > 950) { return offlineOcr(imagePath); // 额度告急,降级到本地 } String text = callCloudOcr(imagePath); redis.setex(cacheKey, 7 * 24 * 3600, text); // 缓存 7 天 quotaCounter.increment(); return text;这套组合拳下来,实际云端调用量通常能压到原始请求量的三分之一以下。缓存过期时间别设太长,票据内容可能有更正,七天是个平衡点。
3. 离线链路:PDFBox 渲染 300 DPI 喂给 Tesseract chi_sim
云接口解决的是「准」,Tesseract 解决的是「免费和可控」。这条路的技术含量比调用 SDK 高得多,因为你要自己把 PDF 每一页渲染成位图,再交给识别引擎,中间任何一环参数不对,结果就是一堆乱码。
3.1 依赖与语言包:setDatapath 到底指向哪个目录
先补依赖。Tesseract 的 Java 封装用 tess4j,PDF 渲染用 PDFBox,两个坐标不能省:
<dependencies> <!-- tess4j 封装了 Tesseract 的 JNI 调用 --> <dependency> <groupId>net.sourceforge.tess4j</groupId> <artifactId>tess4j</artifactId> <version>5.8.0</version> </dependency> <!-- PDFBox 负责把 PDF 页渲染成 BufferedImage --> <dependency> <groupId>org.apache.pdfbox</groupId> <artifactId>pdfbox</artifactId> <version>2.0.30</version> </dependency> </dependencies>语言包是这条路上最容易出错的地方。chi_sim.traineddata需要单独下载,放到一个目录下,这个目录就是setDatapath的入参值。原文档写的是tesseract.setDatapath("E:\\projectO\\language-library-language"),这里要守住一个语义:setDatapath 指向的是包含 traineddata 文件的父目录,不是文件本身。目录结构长这样:
tessdata/ ├── chi_sim.traineddata # 简体中文 ├── chi_sim_vert.traineddata # 竖排中文,票据里偶尔用得上 ├── eng.traineddata # 英文与数字 └── osd.traineddata # 方向检测,做旋转判断时要它ITesseract tesseract = new Tesseract(); // 指向 tessdata 目录,末尾不要带斜杠 tesseract.setDatapath("D:/ocr/tessdata"); // 只设中文时,英文和数字会明显退化 tesseract.setLanguage("chi_sim");语言包必须和 tess4j 版本大致对齐。新版训练出来的 traineddata 喂给旧版引擎,加载时会静默降级甚至直接报Could not initialize Tesseract,日志里只有一行英文,很容易被当成路径问题查半天。我一般会在启动时做一次自检:拿一张内置的小图跑一遍doOCR,跑通再对外提供服务,跑不通直接启动失败,把问题暴露在部署阶段而不是业务阶段。
3.2 300 DPI 与 ImageType:渲染参数怎么定
pdfRenderer.renderImageWithDPI(page, 300, ImageType.RGB)这个调用里,两个参数都有讲究。
DPI 决定渲染位图的分辨率。Tesseract 官方给的经验区间是字符的 x-height(小写字母 x 的高度)落在 30~100 像素之间识别效果最好。A4 纸上 10pt 的正文,在 300 DPI 下渲染出来 x-height 大约 40 像素,正好落在甜点区;200 DPI 下只有 27 像素左右,小字号容易糊成一团;600 DPI 下清晰度提升有限,但单页内存从 30MB 量级直接翻到 120MB 量级,多页并发时 GC 压力肉眼可见。
| DPI | A4 单页像素 | 单页内存(RGB) | 相对耗时 | 适用场景 |
|---|---|---|---|---|
| 150 | 1240×1754 | 约 6 MB | 0.5x | 只做版面判断,不做文字识别 |
| 200 | 1654×2339 | 约 11 MB | 0.8x | 大字号标题、扫描质量好的档案 |
| 300 | 2480×3508 | 约 25 MB | 1.0x | 通用票据、合同正文 |
| 600 | 4960×7016 | 约 100 MB | 2.2x | 极小字号、印章压字 |
ImageType 选RGB还是GRAY,看输入质量。扫描件本身就是灰度,用GRAY能省三分之二内存,识别结果基本无损;手机拍的彩色票据建议先用RGB渲染再做灰度化,因为 PDFBox 在渲染阶段做色彩空间转换比你自己在 BufferedImage 上转要稳。
3.3 从 PDDocument 到 doOCR 的完整流程
把上面的点串起来,方案二的完整实现如下。注意PDDocument必须保证关闭,否则文件句柄会泄漏,跑批量任务时几十页之后就开始报「文件被占用」。
import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.rendering.ImageType; import org.apache.pdfbox.rendering.PDFRenderer; import net.sourceforge.tess4j.ITesseract; import net.sourceforge.tess4j.Tesseract; import java.awt.image.BufferedImage; import java.io.File; public class OfflineOcrDemo { public static String ocrPdf(String pdfPath, String tessdataPath) throws Exception { ITesseract tesseract = new Tesseract(); tesseract.setDatapath(tessdataPath); tesseract.setLanguage("chi_sim"); // 识别模式:3 表示全自动分页,不假设固定版面 tesseract.setPageSegMode(3); // 引擎模式:1 是 LSTM + 传统引擎混合,兼顾速度与准确率 tesseract.setOcrEngineMode(1); StringBuilder all = new StringBuilder(); // try-with-resources 保证 PDDocument 一定被关闭 try (PDDocument document = PDDocument.load(new File(pdfPath))) { PDFRenderer renderer = new PDFRenderer(document); int pages = document.getNumberOfPages(); for (int page = 0; page < pages; page++) { // 逐页渲染,不要一次性把所有页渲进内存 BufferedImage image = renderer.renderImageWithDPI(page, 300, ImageType.RGB); String text = tesseract.doOCR(image); all.append("--- page ").append(page + 1).append(" ---\n"); all.append(text).append("\n"); // 主动释放大图引用,给 GC 机会 image.flush(); image = null; } } return all.toString(); } }逐行说明几个关键点。setPageSegMode(3)是 PSM_AUTO,让引擎自己判断版面结构;票据这类单栏文本用 3 没问题,如果是双栏排版或者带表格的合同,改成 6(假定为单个统一文本块)反而更稳。setOcrEngineMode(1)走 LSTM 加传统引擎的混合模式,速度比纯 LSTM 慢一点,但中文识别率明显好;如果你的场景全是清晰印刷体,用 0(纯 LSTM)能省将近一半时间。
image.flush()这句在小样本测试里看不出价值,但跑五百页的档案时会救命。BufferedImage 底层持有的像素数组不受 JVM 堆直接管理,不主动释放的话,堆外内存涨得比堆还快,最后的表现是OutOfMemoryError: Java heap space报得莫名其妙。
4. 图片必须先转 PDF?方案三的旋转补偿、坐标与混合语言
方案三乍看有点绕:一张 JPG,先建成 PDF,再渲染回 BufferedImage,然后才识别。多绕这一圈不是吃饱了撑的,核心价值在于旋转信息有地方存。
4.1 建页、drawImage 坐标与 setRotation(270)
PDF 的页面对象带一个/Rotate属性,PDFBox 在渲染时会自动把这个旋转角应用到输出位图上。所以只要在 PDF 里把setRotation(270)写进去,后面renderImageWithDPI出来的图就已经是摆正的了,不用自己写AffineTransform去做图像旋转——这是方案三相对方案二最实质的改进。
import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.PDPageContentStream; import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; public static void imageToPdf(String imagePath, String pdfPath, int rotation) throws Exception { try (PDDocument document = new PDDocument()) { PDPage page = new PDPage(); // 旋转角只接受 0/90/180/270,其它值会被规范化 page.setRotation(rotation); document.addPage(page); PDImageXObject pdImage = PDImageXObject.createFromFile(imagePath, document); try (PDPageContentStream cs = new PDPageContentStream(document, page)) { // 关键:按页面可用尺寸等比缩放,不要直接用 pdImage.getWidth() float pageW = page.getMediaBox().getWidth(); float pageH = page.getMediaBox().getHeight(); float scale = Math.min(pageW / pdImage.getWidth(), pageH / pdImage.getHeight()); float drawW = pdImage.getWidth() * scale; float drawH = pdImage.getHeight() * scale; // 坐标原点是页面左下角,不是左上角 float x = (pageW - drawW) / 2; float y = (pageH - drawH) / 2; cs.drawImage(pdImage, x, y, drawW, drawH); } document.save(pdfPath); } }原文档里写的是contentStream.drawImage(pdImage, 0, 0, pdImage.getWidth(), pdImage.getHeight()),这行代码在手机拍照场景下几乎必翻车:一张 3000×4000 像素的照片,直接用像素数当 PDF 点数画上去,A4 页面才 595×842 点,结果就是画面被裁掉大半,你只能识别到左上角那一小块。必须按页面尺寸等比缩放,这是方案三最需要修的一处。
坐标原点的坑同样值得单独拎出来说。PDF 的坐标系原点在左下角,而绝大多数图像处理库(包括你熟悉的屏幕坐标)原点在左上角。drawImage的 x、y 指的是图片左下角落在页面上的位置,不是左上角。第一次写的时候我按左上角思维算偏移,图片整个跑到页面外面去了,渲染出来是白纸,排查了半小时才反应过来。
4.2 为什么绕这一圈:旋转属性的持久化
有人会问,直接tesseract.doOCR(bufferedImage)也行,干嘛非转 PDF?区别在两处。
一是旋转信息的持久化。你可以在内存里转 BufferedImage,也可以把旋转角记在一个临时变量里传下去,但这两种做法都要求整条链路在同一个进程、同一次调用里完成。一旦中间要落盘缓存、要异步排队、要交给另一个服务处理,旋转角就丢了。写进 PDF 的/Rotate属性则随文件走,谁拿到都能还原正确方向。
二是复用渲染链路。方案二本来就是「PDF 进、文本出」,方案三把「图片进」也统一到这条链路上,一个入口两种输入,代码分支少一半。工程上这种统一的价值往往比性能优化更大。
代价也很明确:多一次磁盘往返,多一次图片编解码,单张图的处理时间大约增加 15%~25%。批量场景下这个开销可以接受,实时接口里就要掂量。
4.3 eng+chi_sim 与四方向试识别
方案三把语言设成eng+chi_sim,这个改动的收益比想象中大。纯chi_sim模型对英文和数字的处理是弱项,票据上的编号、金额、日期全是数字和字母,只挂中文模型时经常出现0认成O、1认成l、小数点直接吞掉。挂上英文模型之后,数字和小数点的准确率提升非常明显。
副作用是耗时翻倍。Tesseract 对每种语言分别跑一遍识别再合并结果,两个语言包就是两倍时间。而且中英混排时,两种模型会对同一片区域给出不同解释,引擎按置信度择优,偶尔会出现中文段落里混进几个莫名其妙的英文单词。我的处理办法是按业务字段分流:整页识别用eng+chi_sim,只提取纯数字字段(金额、税号)时单独裁 ROI 出来,只用eng跑一遍,速度和准确率都更好。
方向判断这块,osd.traineddata能直接给出页面方向,但它对短文本和低质量图片的判断很不稳定,实测准确率不到七成。更土但更靠谱的办法是四方向穷举打分:
public static int detectBestRotation(ITesseract tess, BufferedImage src) throws Exception { int[] candidates = {0, 90, 180, 270}; int bestAngle = 0; double bestScore = -1; for (int angle : candidates) { BufferedImage rotated = rotate(src, angle); String text = tess.doOCR(rotated); double score = scoreText(text); // 中文占比 + 有效字符数 + 词典命中 if (score > bestScore) { bestScore = score; bestAngle = angle; } } return bestAngle; } // 打分函数:正文里中文字符越多、标点越规整,方向越可能正确 private static double scoreText(String text) { if (text == null || text.isEmpty()) return 0; int cjk = 0, valid = 0; for (char c : text.toCharArray()) { if (c >= 0x4E00 && c <= 0x9FFF) cjk++; if (!Character.isWhitespace(c)) valid++; } return valid == 0 ? 0 : (double) cjk / valid * Math.log(valid + 1); }打分函数里那个Math.log(valid + 1)是防作弊的:只算中文占比的话,一段只识别出三个汉字的错误方向也可能拿到满分,乘上一个随有效字符数增长的对数项,能保证「字多且中文多」的方向胜出。四个方向全量识别太慢,生产里我会先裁中间区域跑,确认方向后再整页跑,耗时能砍到四分之一。
5. 避坑与排查:五条把结果搞成乱码的血泪记录
离线链路的问题有个共同特点:不报错,只是结果不对。日志干干净净,输出全是垃圾,这种黑匣子最难查。下面五条是我和同事实际踩过的,按「现象 → 原因 → 解决」记下来。
5.1 输出全是问号和方块
现象:doOCR正常返回,但打印出来是??????或者一排方框,一个汉字都没有。
原因:setDatapath指错了层级。原文档示例是E:\projectO\language-library-language,很多人会直接指到chi_sim.traineddata文件本身,或者指到 tessdata 的上一级。Tesseract 找不到语言包时不会抛异常,它会退化成一个空模型,输出自然全是占位符。
解决:启动时打印new File(tessdataPath, "chi_sim.traineddata").exists()的结果,false 就直接失败退出。同时在 IDE 的控制台输出编码上检查一遍,Windows 默认 GBK,System.out打中文也可能变问号,这种是显示问题不是识别问题,用-Dfile.encoding=UTF-8启动即可排除干扰。
5.2 中文段落里夹杂成串的英文乱词
现象:整段中文里随机蹦出ithfhen1这种碎片,位置不固定。
原因:语种设成了eng+chi_sim,英文模型把中文笔画误判成了拉丁字母。中英混排模型不是「谁像谁」,而是两套模型各跑一遍再合并,合并策略在低质量图片上很容易出错。
解决:不要全局挂双语。整页识别用chi_sim,把含数字和字母的区域(编号、金额、日期)先按坐标裁出来,单独用eng跑一遍再合并结果。多写二十行 ROI 逻辑,准确率的提升比调任何参数都明显。
5.3 PDF 渲染出来只剩左上角一块
现象:明明是一整页的票据,识别结果只有开头两行,后面的内容完全丢失。
原因:drawImage直接用了图片的像素宽高作为 PDF 点数。3000 像素宽的图往 595 点宽的页面上画,超出部分直接被裁掉,渲染回来的位图自然只有左上角。
解决:按page.getMediaBox()的宽高算等比缩放系数,见 4.1 的代码。另外注意页面本身如果设了setRotation,getMediaBox()拿到的是未旋转的原始尺寸,横图竖放时宽高要对调,否则缩放系数算出来是错的。
5.4 同一张图云端 99%,本地只有六成
现象:把云端跑得完美的图喂给 Tesseract,识别率断崖式下跌,尤其小字和浅色字。
原因:三个因素叠加。渲染 DPI 不够(200 以下)、图片是彩色带底纹、没有做二值化。Tesseract 内部虽然有自适应阈值,但对浅灰底加细笔画的票据很不友好。
解决:按这个顺序做预处理——先转灰度,再判断对比度,对比度低于阈值时做直方图均衡,最后用 Otsu 法二值化。实测这套下来,票据类图片的字符准确率能提升 15~25 个百分点。别上太复杂的去噪算法,中值滤波做一个 3×3 的就够,过度平滑会把细笔画吃掉,反而更差。
5.5 本地跑得好,服务器上启动就报错
现象:开发机一切正常,部署到容器里启动直接抛UnsatisfiedLinkError,或者报Tesseract couldn't load any languages。
原因:tess4j 依赖本地动态库,jar 包里带了几个平台的 so/dll,但不一定匹配你的运行环境。常见诱因有三个:基础镜像缺少 libc 相关依赖、java.io.tmpdir没有写权限导致 native 库解压失败、容器是 Alpine 用的 musl libc 而 so 是 glibc 编译的。
解决:基础镜像换成完整版 Debian 或 Ubuntu,别用精简版;启动参数里显式指定-Djava.io.tmpdir=/data/tmp并保证该目录可写;构建阶段在镜像里跑一次tesseract --version验证二进制可用。这三步做完,容器化部署基本不会再出问题。
6. 精度验证与调优:拿五十张样本把字准率压到可接受区间
离线 OCR 最怕的是「感觉还行」。你随便挑几张图跑一下,看着像那么回事就上线了,等业务方反馈字段错了才发现问题。我现在的习惯是:动手调参之前,先攒一个五十张的小样本集,覆盖清晰扫描件、手机拍照、带印章遮挡、小字号、纯数字表格这五类,每类十张。每张图人工标注出关键字段的真值,存成一份 CSV。这份东西就是后面所有优化的裁判,没有它,调参全是玄学。
评估别用「整段相似度」这种粗糙指标,业务方关心的是字段对不对。按字段算准确率,金额、日期、编号各算各的,最后取加权平均。整页文本的字准率可以用编辑距离算,但只作参考:
| 指标 | 计算方式 | 达标线 | 说明 |
|---|---|---|---|
| 字段完全匹配率 | 字段值逐字符相等 | ≥ 92% | 最贴近业务体感 |
| 字符准确率 | 1 - 编辑距离 / 真值长度 | ≥ 96% | 反映整体识别质量 |
| 空结果率 | 返回空文本的图片占比 | ≤ 1% | 高了说明预处理有问题 |
有了这张表,调参就有方向了。改 DPI、改 PSM、加二值化,每次都跑一遍全量样本,指标涨了就留下,跌了就回滚,不要凭感觉叠加改动。我吃过一次亏:一口气改了语言包、DPI 和预处理三处,指标涨了 8 个点,但根本不知道是哪一处起的作用,后来换了个批次的数据,发现其中一处改动其实是负收益,只是被另外两处的收益盖住了。
后处理是性价比最高的一环,很多人忽略。识别出来的文本用正则做一遍结构校验,金额字段强制匹配\d+(\.\d{1,2})?,日期字段统一成yyyy-MM-dd,编号字段按业务规则校验位数和校验位。校验不过的走人工复核队列,别硬塞进数据库。这套机制跑起来之后,即便 OCR 本身的准确率只有九成,端到端的入库准确率也能做到 99% 以上。
从那以后我每次接 OCR 相关的活,都强制先把这五十张样本和字段真值表建起来,再动一行代码。没有度量就没法优化,这话在图像识别上比在任何地方都成立。希望帮到你。
本文还有配套的精品资源,点击获取