ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java OCR图文识别实战:Tess4J与ONNX预处理调优

Java OCR图文识别实战:Tess4J与ONNX预处理调优

简介:围绕 Java 实现图文 OCR 识别这一常见需求,文档以方案对比为主线,面向需要在中后台系统、票据或图片文字提取场景落地文字识别的开发者,尤其适合具备一定 Java 基础、希望快速选型的中级工程师。内容梳理三条技术路线:调用云端 OCR 接口的个人申请方案,准确率高、响应快,但存在每月请求次数限制与后续收费的不确定性;基于 Tesseract 的开源方案,免费但需先将图片写入 PDF 再逐页渲染识别,准确率偏低且易出现乱码;以及在前者基础上调整页面旋转角度、合并中英文语言库的优化拓展方案。文中配有 APPID、API KEY、SECRET KEY 初始化,连接与 socket 超时、HTTP 代理、log4j 日志配置,以及 PDFRenderer 按 300 DPI 渲染、chi_sim 语言设置等可直接参考的代码片段,并逐条列出各方案优缺点,便于按准确率、成本与请求量权衡取舍。压缩包仅含 1 个 docx,约 12KB,篇幅精炼、便于检索,已有 273 人学习。

1. 一张歪着拍的发票,把 Java 端 OCR 的短板全暴露了

把一套在扫描件上跑得不错的 JAVA-OCR 图文识别链路,直接搬到手机拍照和传真件场景,识别率往往会大幅下降。同一张报销单,扫描件能把“壹万贰仟元整”完整读出来,换成倾斜十五度、带阴影、短边不到 1200 像素的照片,输出的却是被拆成三行的碎片,金额字段直接错位。多数人第一反应是模型不行、语言包不够、要换更强的引擎,但把图片拆开看,问题几乎都出在输入端:透视畸变没有纠正、光照不均导致全局二值化把浅色字抹平、分辨率不足以让 12 号字凑够笔画。

这篇内容讲的就是这条链路:用 Java 把图片和 PDF 归一化成可识别的位图,做一套顺序正确的预处理,再选择 Tess4J 本地引擎或 ONNX 中文检测识别模型做推理,最后把识别结果落成能入库的字段。适合手上已经有一个 Java 服务、需要把票据、合同、证照、工单截图里的文字批量取出来的开发者;也适合已经上了 OCR 但对准确率不满意、想搞清楚每个参数在干什么的人。全程只讲能复现的代码和参数,不讲引擎内部论文。

2. 三条路线怎么选:Tess4J、ONNX 模型、商业接口的 Java 接入边界

选型这一步比写代码更影响最终效果。JAVA-OCR 图文识别的可选路线其实只有三条,而且它们的差别不在“准不准”,在于你能不能接受它出错的方式。下面先把三条路线的适用范围讲清楚,再给出两条本地路线的可运行代码。

2.1 本地引擎、深度学习模型、商业接口各自适用的场景

Tess4J 是对 Tesseract 的 JNA 封装,本质是一个跑了十几年的传统 + LSTM 混合引擎。它的优势是部署极轻:一个 jar 加一个几十兆的语言包,纯 CPU 跑,无网络依赖,数据不出机器,许可证对商业项目友好。缺点是它对版面极其敏感,双栏排版、无线框表格、文字压背景图这几种情况,它倾向于按行硬拼,输出顺序错乱;中文印刷体在图片干净时能到 90% 上下,一旦有噪点就掉得很快。

深度学习路线是“文本检测 + 文本识别”两段式:检测模型(DBNet 一类)先输出一堆文本框,识别模型(CRNN/SVTR 一类)再逐框解码。它对密集小字、弯曲文本、复杂背景的鲁棒性明显好于传统引擎,中文行文本是它的强项。代价是你得自己管模型文件、自己写前后处理,模型转换和字典对齐是主要工作量。JVM 侧跑推理有两条常见路径,一是用 ONNX Runtime 的 Java 绑定直接喂张量,二是用 DJL 这类高层封装,前者可控性更好,后者上手快。

商业 OCR 接口准确率最高,尤其是手写体和特殊版面,接入成本也最低——一个 HTTP 调用就完事。但它有三个硬约束:按次计费导致批量场景成本不可控、数据要出你的机器、QPS 和超时由对方决定。常见做法是把它当兜底而不是主力:本地引擎置信度低于阈值的那部分图片,才转发出去。

维度Tess4J 本地引擎ONNX 中文检测识别模型商业 OCR 接口
单张耗时(4 核 CPU,量级参考)0.3~1.5 秒0.2~0.8 秒0.3~1 秒,另加网络往返
干净印刷体中文中上高高
手写体差中高
复杂背景 / 弯曲文本差好好
部署与运维成本低中极低
数据是否出域否否是
参数可控程度高高低

我的取舍习惯是:日处理量在几万张以内、图片以扫描件和截图为主、对数据出域敏感,就走本地路线,先用 Tess4J 打通链路,再把识别层抽象成接口,后面换 ONNX 模型时只替换实现类。如果手写票据占比超过三成,本地路线的投入产出比就很差,直接用接口兜底更省事。

2.2 用 Tess4J 在本地跑通第一张中文图

先确认三件事到位:依赖引入、语言包落盘、本地库能加载。语言包只需要chi_sim.traineddata,如果票据里有大量数字和英文编号,再补一个eng.traineddata,识别时用chi_sim+eng组合,纯数字字段的准确率会好一截。

import net.sourceforge.tess4j.Tesseract; import net.sourceforge.tess4j.ITessAPI; import net.sourceforge.tess4j.Word; import javax.imageio.ImageIO; import java.io.File; import java.util.List; public class OcrOnce { public static void main(String[] args) throws Exception { // tessdata 必须指向“目录”,不是 .traineddata 文件本身 File dataDir = new File("/opt/ocr/tessdata"); Tesseract t = new Tesseract(); t.setDatapath(dataDir.getAbsolutePath()); t.setLanguage("chi_sim+eng"); // 中文为主,英文数字兜底 t.setPageSegMode(6); // PSM 6:假定整页是一整块文本 t.setOcrEngineMode(1); // OEM 1:只用 LSTM,速度更快 // 保留词间空格,否则中文会被拆成单个字,后处理更麻烦 t.setVariable("preserve_interword_spaces", "1"); // 大图先降采样上限,避免单页耗时失控 t.setVariable("user_defined_dpi", "300"); var img = ImageIO.read(new File("/opt/ocr/in/sample.png")); String text = t.doOCR(img); System.out.println(text); // 逐词置信度,用来做字段级质量分流 List<Word> words = t.getWords(img, ITessAPI.TessPageIteratorLevel.RIL_WORD); for (Word w : words) { if (w.getConfidence() < 60) { System.out.println("低置信:" + w.getText() + " -> " + w.getConfidence()); } } } }

这段代码的关键不在doOCR那一行,而在几个 setter 上。setDatapath接收的是包含语言包的目录,传错成文件路径会抛“请设置 TESSDATA_PREFIX”的错,这是新手最容易卡住的一步。setPageSegMode(6)把一个页面当成单一文本块处理,适合票面、合同正文这类排版规整的图;如果图片是零散的界面截图,用 6 会把不相关的区域强行拼成一段,这时候应该换 11(稀疏文本)。setOcrEngineMode(1)跳过传统引擎的融合打分,只用 LSTM,速度提升明显,中文场景下准确率基本不降。

getWords返回的置信度是 0 到 100 的整数,直接用整页平均分做质量判断意义不大,因为一页里总有几个字是低的。做法是按行聚合,一行里超过三成词低于 60 就把整行标记为“待人工复核”,这条标记比识别文本本身更有业务价值。

2.3 把中文检测识别模型导出 ONNX,在 JVM 里做前向

模型路线的第一步是拿到两个 ONNX 文件:检测模型(输入1x3xHxW的归一化图像,输出一张概率图)和识别模型(输入3x48x320的行图,输出时间步 × 字符集维度的 logits)。字符集字典要和训练时用的那份完全一致,少一个字符后面整个索引全错位,这类错位不会报异常,只会输出一堆看起来很像字的乱码,属于典型的黑匣子故障。

import ai.onnxruntime.*; import java.nio.FloatBuffer; import java.util.Collections; public class DetRunner { public static void main(String[] args) throws Exception { OrtEnvironment env = OrtEnvironment.getEnvironment(); OrtSession.SessionOptions opts = new OrtSession.SessionOptions(); // 推理线程数和 Web 容器线程数错开,别让两者抢同一批核 opts.setIntraOpNumThreads(Math.max(1, Runtime.getRuntime().availableProcessors() / 2)); opts.setOptimizationLevel(OrtSession.SessionOptions.OptLevel.ALL_OPT); try (OrtSession session = env.createSession("/opt/ocr/model/det.onnx", opts)) { // NCHW:1 x 3 x H x W,像素需按 ImageNet 均值方差标准化 long[] shape = new long[]{1, 3, 960, 960}; float[] data = new float[1 * 3 * 960 * 960]; // 由预处理阶段填充 FloatBuffer buf = FloatBuffer.wrap(data); OnnxTensor input = OnnxTensor.createTensor(env, buf, shape); OrtSession.Result res = session.run(Collections.singletonMap("x", input)); // 输出是概率图,后续要做二值化 + 轮廓提取 + unclip 才能得到文本框 float[][][][] prob = (float[][][][]) res.get(0).getValue(); System.out.println("输出维度: " + prob.length + "x" + prob[0].length); } } }

前向只占整条链路的两成工作量,剩下八成在两个后处理上。检测这边,概率图按box_thresh(常见 0.5~0.6)二值化后提轮廓,再按unclip_ratio(1.5~2.0)做外扩,因为检测模型给出的是文字核心区,不外扩会把字的边缘切掉,识别端就会读成半个字。识别这边,输出要按 CTC 规则解码:每个时间步取 argmax,合并相邻重复,去掉空白位。这两步的代码量不小,好在逻辑是纯数组操作,用 Java 写性能足够。

给识别层定义一个接口,把 Tess4J 和 ONNX 两条实现都挂上去,业务层只依赖接口。这样切换引擎时,上层的预处理、超时、置信度分流全都不用动。这个抽象在项目初期多花半天,后期能省掉大量返工。

3. Spring Boot 里的识别链路:从 PDF、拍照图到结构化文本

单机能跑通和能上线是两件事。上线要处理的是格式混杂、单页耗时不可控、部分图片根本识别不出来这几种情况。这一章按数据流向拆成三段:入口归一化、预处理、执行层。

3.1 输入归一化:PDF、扫描件、手机照片统一成 BufferedImage

入口第一个坑是用文件扩展名判断类型。用户上传的.jpg里装的可能是 PDF,也可能是 HEIC,扩展名一点都不可信。判断要靠魔数:PDF 的文件头是25 50 44 46(即%PDF),JPEG 是FF D8 FF,PNG 是89 50 4E 47。

public BufferedImage normalize(byte[] bytes) throws IOException { String magic = toHex(bytes, 4); if (magic.startsWith("25504446")) { // %PDF try (PDDocument doc = PDDocument.load(bytes)) { PDFRenderer renderer = new PDFRenderer(doc); // 300 DPI:低分辨率扫描件常见的 150 DPI 会把小字号模糊到无法识别 return renderer.renderImageWithDPI(0, 300f, ImageType.RGB); } } if (magic.startsWith("FFD8FF") || magic.startsWith("89504E47")) { // JPEG / PNG BufferedImage raw = ImageIO.read(new ByteArrayInputStream(bytes)); // 手机照片要按 EXIF Orientation 旋转,否则整页横躺 return applyExifOrientation(raw, bytes); } if (magic.startsWith("49492A00") || magic.startsWith("4D4D002A")) { // TIFF,可能多页 try (ImageInputStream iis = ImageIO.createImageInputStream(new ByteArrayInputStream(bytes))) { Iterator<ImageReader> it = ImageIO.getImageReaders(iis); ImageReader reader = it.next(); reader.setInput(iis); return reader.read(0); // 多页按页循环,别只读第一页 } } throw new IllegalArgumentException("不支持的输入格式: " + magic); }

PDF 渲染的 DPI 是这段里最该纠结的参数。150 DPI 渲染出来,12 号字只剩十几个像素高,笔画粘连;300 DPI 是公认的起点,A4 页面渲染出来约 2480×3508,单张占内存 35MB 左右;再往上到 600 DPI,准确率提升已经很小,耗时和内存却翻倍,除非原件本身就是极小字号的合同扫描。

EXIF 旋转这一步经常被跳过。手机拍照时传感器方向固定,方向信息写在 EXIF 里,ImageIO.read不会自动应用它,结果就是整页文字旋转 90 度。Tesseract 对旋转 90 度的中文页几乎完全失效,输出的是一堆无关字符,而且不会报错。要么用元数据解析库读 Orientation 标签手动转,要么交给前端在上传前用 canvas 矫正。多页 TIFF 也一样,reader.read(0)只拿到第一页,剩下的页会被静默丢弃,业务方会以为系统漏识别了。

3.2 预处理流水线:灰度、纠偏、缩放、二值化的顺序不能乱

预处理的顺序是有讲究的,调换任意两步都会让效果变差。正确顺序是:灰度 → 轻度去噪 → 纠偏 → 缩放 → 二值化。原因很直接:纠偏需要在灰度图上算角度,二值化之后的黑白边缘全是锯齿,角度算出来会偏;缩放在二值化之前做,是双三次插值,字边缘是平滑过渡,二值化之后再缩放就是最近邻插值,笔画直接断成点;二值化放最后,是因为前面的插值运算需要连续的灰度值。

public static Mat preprocess(Mat src) { Mat gray = new Mat(); if (src.channels() == 3) { Imgproc.cvtColor(src, gray, Imgproc.COLOR_BGR2GRAY); } else { src.copyTo(gray); } // 1) 纠偏:先估角度,只在小角度区间内转,超范围说明不是歪而是版面复杂 double angle = estimateSkew(gray); if (Math.abs(angle) > 0.3 && Math.abs(angle) < 15) { Mat m = Imgproc.getRotationMatrix2D( new Point(gray.cols() / 2.0, gray.rows() / 2.0), angle, 1.0); Imgproc.warpAffine(gray, gray, m, gray.size(), Imgproc.INTER_CUBIC, Core.BORDER_REPLICATE); } // 2) 缩放:短边拉到 1600 左右,小字识别率提升最明显的一步 double scale = 1600.0 / Math.min(gray.cols(), gray.rows()); if (scale > 1.05) { Imgproc.resize(gray, gray, new Size(), scale, scale, Imgproc.INTER_CUBIC); } // 3) 局部对比度增强,对付光照不均和浅色字 Mat clahe = new Mat(); Imgproc.createCLAHE(2.0, new Size(8, 8)).apply(gray, clahe); // 4) 自适应二值化:blockSize 必须是奇数,C 是偏移量 Mat bin = new Mat(); Imgproc.adaptiveThreshold(clahe, bin, 255, Imgproc.ADAPTIVE_THRESH_GAUSSIAN_C, Imgproc.THRESH_BINARY, 31, 15); // 5) 中值滤波去椒盐噪点,核 3 足够,再大就把细笔画抹掉了 Imgproc.medianBlur(bin, bin, 3); return bin; }

estimateSkew的实现思路是:对灰度图做 Canny 边缘检测,用霍夫变换取直线,统计所有直线的角度做中位数。只在 -15° 到 15° 之间生效,是因为超过这个范围多半是版面本身有斜线装饰或表格框线,强行旋转会把正常页面转歪。

createCLAHE(2.0, new Size(8, 8))里的 2.0 是裁剪限幅,值越大对比度拉得越猛,浅色字越清楚,但背景噪点也会被一起放大;8×8 是分块网格,图越大网格应该越细。这一步在传真件和手机拍照上收益明显,在干净的扫描件上反而是负担,可以直接跳过。

adaptiveThreshold的两个参数是调优重点。blockSize决定多大的邻域算一个局部阈值,31 适合 12 到 16 号字,字更小就降到 21,字更大或版面很空就升到 51;它必须是奇数。C是从局部均值里减掉的常数,越大越保守,背景噪点越少但浅色字越容易丢,15 是中间值,噪点多的传真件可以调到 20,浅色印刷的合同可以降到 8。

有一种情况不要二值化:低对比度的彩色底纹上印黑字,二值化会把底纹和字一起判成黑或者一起判成白。这种图保留灰度,只做 CLAHE 增强,直接喂给识别引擎,效果反而更好。

3.3 识别执行层:超时、并发、置信度过滤

识别是 CPU 密集型任务,放进 Web 容器的公共线程池里会拖垮其他接口。做法是独立线程池,核心线程数取 CPU 核数的一半,队列必须有界。

@Bean("ocrPool") public ExecutorService ocrPool() { int cores = Runtime.getRuntime().availableProcessors(); return new ThreadPoolExecutor( Math.max(2, cores / 2), // 核心线程:留给 IO 和 GC 的余量 Math.max(2, cores / 2), 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(64), // 有界队列,防止堆积把内存吃光 new ThreadPoolExecutor.AbortPolicy() // 满了直接拒绝,由调用方降级 ); } public OcrResult recognize(BufferedImage img) { Future<OcrResult> f = ocrPool.submit(() -> doRecognize(img)); try { // 单页 8 秒上限,超过基本可以判定是超大图或引擎卡死 return f.get(8, TimeUnit.SECONDS); } catch (TimeoutException e) { f.cancel(true); return OcrResult.timeout(); // 返回明确状态,不要把异常抛到上层 } catch (Exception e) { return OcrResult.failed(e.getMessage()); } }

超时值定在 8 秒不是拍脑袋:300 DPI 的 A4 页面在 4 核机器上用 Tess4J 大约 1 到 2 秒,用 ONNX 模型 0.5 到 1 秒,超出 8 秒的一定是异常输入,比如分辨率上万像素的扫描大图或者带 20 页的 PDF。对 PDF 加一条页码上限(比如只处理前 5 页),能挡掉大部分超时。

结果对象至少要有四个字段:全文文本、逐行文本加行框、整页平均置信度、耗时。行框(bbox)是后面做版面重排和字段定位的基础,一开始就存下来,别等用的时候再补。置信度低于 60 的行单独存一个数组,前端把这些行高亮出来让业务人员确认,这个设计比“提高准确率”更能解决实际问题。

4. 参数调优:把识别率从六成推到九成的六个旋钮

预处理和链路都通了之后,剩下的提升全在参数上。这一章把影响最大的几个参数列出来,附带我实际调过的取值区间。要说明的是,同一组参数在不同来源的图片上表现差别很大,所以要按图片类型分组调,不要指望一套参数打天下。

4.1 DPI 与短边像素:先定分辨率再谈算法

分辨率是所有参数的前置条件。字高低于 20 像素,任何引擎都会大幅掉点,因为汉字的偏旁在 20 像素以下根本凑不出笔画。300 DPI 下 10.5 磅的宋体大约对应 43 像素高,是比较舒服的区间。

输入类型建议渲染 DPI / 缩放目标说明
电子版 PDF(文字层可提取)不走 OCR,直接提文字层有文字层却去渲染识别,是纯浪费
扫描件 PDF300 DPI原件低于 200 DPI 时上采样到 300,收益有限但仍有
手机拍照短边 1600~2000 像素低于 1200 明显掉点,高于 2500 收益递减
界面截图原始分辨率不缩放截图本身就是像素对齐的,缩放反而糊
传真件短边 2000 像素 + 去噪二值传真件重点是去椒盐噪点

判断电子版 PDF 有没有文字层很简单:用 PDFBox 的PDFTextStripper抽一次,如果抽出来的字符数超过阈值,直接返回,整条 OCR 链路都不用走。这一步在很多项目里被漏掉,导致大量本该毫秒返回的文档跑了几秒钟推理。

4.2 二值化窗口与偏移量:blockSize 和 C 怎么试

这两个参数没有解析解,只能网格搜索。我的做法是准备 30 张标注好的人工样本,blockSize取 21、31、41、51,C取 8、12、15、20,跑 16 组,用字符准确率(编辑距离归一化)排序。通常在 31/15 附近有峰值,但传真件会往 41/20 偏,浅色印刷会往 21/8 偏。

要注意blockSize超过图像短边的三分之一时会退化,局部窗口里几乎全是背景,算出来的阈值失去意义。所以对短边只有 600 像素的小图,blockSize别超过 51。

二值化不是必选项。有阴影的手机拍照,先做背景估计再减除,比直接自适应二值化效果好:把图片做一次大核的形态学闭运算得到背景,用原图减背景,再归一化。这套操作在 OpenCV 里就是morphologyEx加subtract加normalize,代码不长,对不均匀光照的效果比调C好得多。

4.3 PSM 与 OEM:版面先验比模型更值钱

PSM(页面分割模式)是很多人只设一次就再也不动的参数,但它对结果的影响比换语言包大得多。核心逻辑是:你比引擎更清楚这张图的版面,把这个先验告诉它。

PSM 值含义适用场景
3全自动分割不知道版面时的默认值,多栏文档容易乱序
4单列、变宽文本单栏合同、文章正文
6单一文本块票面、卡证、表单主体区域
7单行文本已经切成单行的字段图
8单个词极短的字段,如金额、编号
11稀疏文本界面截图、海报、零散标注
13原始行(不做分割)需要自己控制行序时用

实践里最有效的组合是“先切块,再对每块用 7 或 6”。与其让引擎在整页上猜版面,不如先用检测模型或投影法把页面切成列和块,然后逐块识别。这块内容在最后一章展开。

OEM 一般固定用 1(仅 LSTM)。0 是传统引擎加 LSTM 融合,在中文上并没有优势,耗时还更长。

4.4 语言包、白名单与置信度阈值

语言包用chi_sim+eng而不是单chi_sim,主要是为了数字和字母。纯中文模型遇到“¥1,280.00”这种混排时会倾向于把数字也当成汉字部件,识别成奇怪的字。多挂一个eng后,数字准确率提升明显,代价是耗时增加约 30%。

白名单参数tessedit_char_whitelist要慎用。它只在“确定这一行只可能包含某类字符”时才有意义,比如金额字段只可能是数字和.,¥,身份证号只可能是数字加 X。设了白名单之后,引擎会把所有形似的字符强行映射到白名单里,如果实际内容超出了白名单范围,输出的是看起来很像正确结果的错误值——这种错误比识别失败更危险,因为下游没有任何信号能发现它。所以白名单只用在二次识别的字段级裁剪图上,绝不用在整页。

置信度阈值分两级。一级是整页平均,低于 50 的页直接进人工队列,不再往下走字段抽取;二级是行级,低于 60 的行标记复核。阈值不要设得太高,卡到 75 会把大量本来正确的行也推给人工,反而增加工作量。这个数字应该用两周的实际数据回标一次再定。

5. 避坑与排查:Java 侧调 OCR 最常翻车的五个现场

下面这五条都是线上真实会遇到的问题,按“现象 → 原因 → 解决”写,方便直接对照排。

5.1 tessdata 打进 jar 之后就找不到了

现象:本地 IDE 跑得好好的,打成可执行 jar 部署后启动就抛异常,提示找不到语言包或者TESSDATA_PREFIX未设置。

原因:Tess4J 底层是本地库,它要的是一个文件系统上的绝对路径,不能直接读 jar 包里的 classpath 资源。setDatapath("classpath:/tessdata")这种写法是不生效的。

解决:启动时把 classpath 里的语言包复制到临时目录,再把那个目录路径传给setDatapath。复制逻辑放在@PostConstruct里,只执行一次,并校验目标文件大小是否与资源一致,避免上一次复制中断留下半个文件。

@PostConstruct public void prepareTessdata() throws IOException { Path dir = Files.createTempDirectory("tessdata-"); try (InputStream in = getClass().getResourceAsStream("/tessdata/chi_sim.traineddata")) { Files.copy(in, dir.resolve("chi_sim.traineddata"), StandardCopyOption.REPLACE_EXISTING); } this.tessdataPath = dir.toAbsolutePath().toString(); }

5.2 识别结果中文被空格拆成单个字

现象:输出是“壹 万 贰 仟 元 整”,每个汉字之间插了空格,导致后面所有基于字符串的正则全部匹配失败。

原因:Tesseract 的分词逻辑是按西文词边界设计的,中文没有空格,它会在每次低置信度的字符切换处插一个空格。即使设了preserve_interword_spaces=1也只能减轻,不能根除。

解决:加一层后处理,用正则把“汉字 + 空白 + 汉字”之间的空白去掉,但要保留数字和字母之间的空格,否则“¥ 1 280”会被粘成“¥1280”改变语义。

private static final Pattern CJK_GAP = Pattern.compile("(?<=[\\u4e00-\\u9fa5])\\s+(?=[\\u4e00-\\u9fa5])"); private static final Pattern MULTI_SPACE = Pattern.compile("[ \\t]{2,}"); public static String tidy(String raw) { String s = CJK_GAP.matcher(raw).replaceAll(""); s = MULTI_SPACE.matcher(s).replaceAll(" "); return s.replace('\u00A0', ' ').trim(); // 顺手处理不间断空格 }

5.3 容器里 JNA 加载本地库失败

现象:容器启动后第一次调用识别就报UnsatisfiedLinkError,提示找不到libtesseract或者 JNA 的临时库文件无法加载。

原因通常有三种:基础镜像里没有装本地库;容器的/tmp挂载了noexec选项,JNA 解压出来的.so无法执行;glibc 版本和编译时的目标不一致。

解决:基础镜像里显式安装本地库,不要指望 jar 自带;JNA 的解压目录通过-Djna.tmpdir=/opt/jna指到一个可执行的路径;启动健康检查里加一次“空图识别”的探测,把问题暴露在启动阶段而不是第一个真实请求上。另外java.io.tmpdir也最好指到同一个位置,两个参数经常需要一起改。

5.4 大图 OOM 与线程池打满

现象:并发上来之后,GC 频率陡增,接着出现OutOfMemoryError: Java heap space,或者大量请求超时。

原因:BufferedImage的内存占用等于宽 × 高 × 4 字节,300 DPI 的 A4 单张就是 35MB;预处理里每个Mat又是一份拷贝,一条链路走下来同一张图在内存里可能同时存在五六个副本。并发 10 就是几百兆,加上堆里还有别的对象,默认堆很快就撑不住。

解决:三件事一起做。限制单页像素上限(比如 4000 万像素),超出的先降采样;所有Mat用完立刻release(),用 try-finally 包住,绝不依赖 GC;并发数硬性限制在 CPU 核数的一半,队列有界,满了直接拒绝并返回“稍后重试”。堆参数用-XX:MaxRAMPercentage=70让容器感知,别写死-Xmx。

5.5 表格和多栏文档被读成一行流

现象:两栏排版的 PDF 识别出来,左栏第一行和右栏第一行被拼在了一起,阅读顺序完全是乱的。

原因:PSM 3 的自动分割在多栏场景下按水平投影切行,同一水平高度上的左右两栏会被认为是同一行。表格同理,单元格之间的空白不足以让它识别出列结构。

解决:不要指望引擎自己还原版面。先用检测模型拿到所有文本框的坐标,按中心点 y 坐标聚类成行(容差取文本框高度的 0.6 倍左右),行内按 x 排序,再按“先左栏后右栏”的规则重排。表格另算:先用横竖线检测提取单元格,再逐格识别。这套逻辑在最后一章给最小实现。

6. 进阶落地:把「识字」升级成「可入库的字段」

识别出文本只是中间结果,业务要的是“金额 1280.00、开票日期 2024-03-11、购买方某某公司”这样的字段。从文本到字段这一段,靠正则硬怼在简单票面上能work,票面一换就崩。

6.1 先切块再识别,别让引擎猜版面

做法是把识别拆成两步:检测模型输出所有文本框的四边形坐标,然后按几何关系重建成阅读顺序。核心是按 y 聚类成行。

// boxes 已按检测模型输出排好,含 x、y、w、h public List<List<TextBox>> groupIntoLines(List<TextBox> boxes, double toleranceRatio) { boxes.sort(Comparator.comparingDouble(b -> b.y + b.h / 2.0)); List<List<TextBox>> lines = new ArrayList<>(); for (TextBox b : boxes) { double cy = b.y + b.h / 2.0; boolean placed = false; for (List<TextBox> line : lines) { double refCy = line.get(0).y + line.get(0).h / 2.0; // 容差取行高的 0.6 倍,太小会把同一行拆开,太大会把相邻行合并 if (Math.abs(cy - refCy) < line.get(0).h * toleranceRatio) { line.add(b); placed = true; break; } } if (!placed) { List<TextBox> nl = new ArrayList<>(); nl.add(b); lines.add(nl); } } lines.forEach(l -> l.sort(Comparator.comparingDouble(b -> b.x))); return lines; }

toleranceRatio取 0.6 是个经验值:小于 0.4 时,上下标和不同字号的混排会被拆成两行;大于 0.8 时,行距紧的表格会把相邻两行合并。多栏文档还要在聚类前先按 x 做一次区间切分,否则左右栏仍然会串到同一行里。

6.2 字段抽取与校验:正则兜底 + 置信度分流

有了阅读顺序,字段抽取就有了上下文。做法是关键词锚定加正则约束:在“金额”“合计”“小写”这类锚点附近的两行内找符合金额格式的串,而不是全页扫。金额格式用[¥¥]?\\s?\\d{1,3}(,\\d{3})*(\\.\\d{1,2})?,日期用\\d{4}[-年/]\\d{1,2}[-月/]\\d{1,2},找到后统一转成标准格式再入库。

校验这一步别省。金额做一次大小写一致性检查——阿拉伯数字和中文大写能对上才放行,对不上就打标;日期做一次合法性检查,2024-02-31这种必须拦下来;税号做长度和校验位检查。这些规则写起来不到一百行,能挡掉大部分静默错值。

分流策略上,我给每个字段算一个综合置信度:取该字段所在文本框的识别置信度,乘以正则匹配的完整度,再乘以校验是否通过。三项都满分的直接入库,缺一项的进待复核表,附上原图裁剪区域,业务人员扫一眼就能确认。这套分流让自动入库率从 40% 提到 85% 左右,剩下 15% 才是真正需要人看的。

说个我自己踩过的坑:早期为了追求自动化率,把置信度阈值一路往下调,结果错值混进了业务表,事后清理数据花的时间远超省下的审核时间。后来把阈值钉死,宁可多推一部分给人工,也不让错值无声无息地流下去。OCR 这条链路上,「识别不出来」是可控的失败,「识别错了还通过校验」才是真正的事故。

希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进