简介:JAVA-OCR图文识别解决方案.docx 面向需要在 Java 项目里接入图片文字识别能力的后端开发者与初学者,围绕识别准确率、使用成本与部署门槛三项权衡梳理出三条可选技术路线。文档先对比个人申请的云服务接口方案,说明 APPID、API_KEY 与 SECRET_KEY 的配置方式、连接超时与代理参数设置、log4j 日志格式定制,以及 basicGeneral 调用与结果输出示例,同时提醒免费额度与每月请求次数上限;随后介绍基于 Tesseract 的开源免费做法,涵盖语言库路径设置、chi_sim 语言包指定、PDF 逐页按 300DPI 渲染成图像再识别的完整流程;最后给出图片转 PDF 并设置页面旋转角度的优化思路,并对易出现乱码、图片必须正向等限制条件作出说明。资源包共 1 个 docx 文件,体积约 12KB,篇幅紧凑、代码片段集中,适合作为技术选型对照与快速验证的参考材料。目前已有 273 人学习,便于读者按准确率、成本与场景限制快速判断落地方式。
1. 一张模糊的入库单:Java 侧做 OCR 到底解决什么问题
仓库管理员拍了一张入库单发到群里,照片是斜的、有反光、表格线还被订书钉压断了一截,业务方要求后端十分钟内把物料编码和数量提取出来写进系统——这种需求落到 Java 服务上,就是 JAVA-OCR 图文识别解决方案要处理的事。它主要解决工程链路问题,而非算法竞赛题:图片解码、预处理、版面切分、识别、后处理、置信度兜底,每一段都可能成为瓶颈。这篇笔记按我实际落地的顺序,把选型、最小可运行代码、参数怎么调、上线后在哪里出问题逐项说明,适合手里有 Java 服务、被临时要求“顺手识别一下图片”的同学,也适合已经在跑 OCR 但可用率卡在七成上不去的人。
2. 选型先定死:本地引擎、自建推理侧车、云端接口三条路的边界
动手写第一行代码之前,最该花时间的是选型。我见过太多项目一开始直接选用最贵的方案,结果发现日均调用量只有两百张,白白多背了一套运维成本;也见过反过来,为了省事把一个只能跑英文的本地引擎塞进中文票据流程,最后靠正则硬凑到上线前一刻。下面这三条路线覆盖了绝大多数 Java 团队的真实选择,我按自己使用时的判断顺序排列。
2.1 三条路线摆到同一张表上:准确率、延迟、体积、成本
先把结论摊开,后面的讨论都围绕这张表展开。
| 维度 | 本地引擎(Tess4J 封装的 Tesseract) | 自建推理侧车(HTTP 服务) | 云端通用识别接口 |
|---|---|---|---|
| 中文印刷体准确率 | 中等,规整文档可用 | 较高,复杂版面更稳 | 较高,长尾场景覆盖好 |
| 手写体 | 基本不可用 | 视模型而定,一般也弱 | 相对最好 |
| 单页延迟 | 200ms~1.5s,取决于分辨率 | 80ms~600ms,有 GPU 更快 | 200ms~1.2s,含网络 |
| 部署体积 | 约 20~60MB 语言包 | 数百 MB 模型 + 运行时 | 0,但要求外网可达 |
| 离线可用 | 是 | 是 | 否 |
| 单次成本 | 纯 CPU | 主要来自机器折旧 | 按调用量计费 |
| 可调参数粒度 | 很高,PSM、白名单都能改 | 高,但改模型要重新训练或转换 | 低,只能改调用参数 |
| 与 Java 集成难度 | 低,一个 Maven 依赖 | 中,多一个服务要部署和监控 | 低,但要做密钥与超时治理 |
这张表说明的是:不用争论哪个引擎最好,而应根据业务对“离线、成本、准确率”这三件事的容忍度来做组合。我的默认起点是本地引擎跑基线,把整条链路(解码、预处理、切分、后处理、结果落库)先打通,等基线跑出来的 badcase 有了统计,再决定要不要引入更强的识别能力补位。如果一开始就用最强的识别能力,你根本不知道这 95% 的准确率里有多少是自己的预处理贡献的,以后成本涨了想换回去也换不动。
2.2 为什么我一般先用 Tess4J 立基线,而不是直接上深度学习
理由有三个,都很实际。
第一是链路可验证。Tess4J 是 Tesseract 的 JNA 封装,纯 Java 侧调用,不需要额外进程,不需要模型转换,直接在 Spring Boot 里写一个@Service就能跑起来。这意味着你可以用半天时间先把“图片进、文本出”这条通路跑通,然后立刻拿真实业务图去测,看问题出在识别本身,还是出在图太模糊、版面太乱、字段位置不固定。很多团队把准确率问题误判成模型问题,其实是预处理没做。
第二是参数可解释。Tesseract 的 PSM(页面分割模式)、OEM(引擎模式)、字符白名单这些参数,对结果的影响是线性可感知的。你把 PSM 从 3 改成 6,票据主体的识别结果肉眼可见地变整齐;你加一个数字白名单,金额字段里的O立刻不再冒充0。这种确定性对排查问题非常友好。深度学习模型更多时候是个难以解释内部过程的组件,效果不好时你只能换模型或加数据,很难定位到“是哪一步理解错了”。
第三是成本底线。哪怕最终方案是自建推理侧车,本地引擎也值得保留作为降级通道。侧车挂了、GPU 被别的任务占满、模型服务重启,本地引擎还能顶着,识别率掉一点但不至于整个功能不可用。这个降级通道在线上救过我两次,属于“平时看不出价值、出问题时决定影响范围”的配置。
它的边界也要说清楚:手写体、严重倾斜、印章覆盖、竖排古文、低分辨率截图,这几类场景下它的表现会明显下降,不要强行调整参数,直接进入下一节的分流策略。
2.3 中文场景的补位方案:把识别引擎做成 HTTP 侧车
Java 直接跑深度学习推理不是不能做,但工程上不太划算:模型格式转换、native 库依赖、GPU 驱动版本,每一样都能消耗掉一整个迭代周期。更常见的做法是把识别能力封装成一个独立的 HTTP 服务,Java 只负责编排——限流、超时、重试、降级、结果合并。这个侧车用什么语言写不重要,重要的是它和主服务之间只有一层 HTTP 契约。
// Java 17,侧车调用的最小封装:超时 + 熔断计数 + 降级到本地引擎 public class OcrSidecarClient { private final HttpClient http = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(2)) .build(); private final AtomicInteger consecutiveFailures = new AtomicInteger(); private static final int FAIL_THRESHOLD = 5; // 连续失败 5 次打开熔断 private final LocalTess4jEngine fallback; // 2.2 里那套本地引擎 public String recognize(byte[] imageBytes) { if (consecutiveFailures.get() >= FAIL_THRESHOLD) { return fallback.recognize(imageBytes); // 熔断期直接走降级 } try { String payload = "{\"image_base64\":\"" + Base64.getEncoder().encodeToString(imageBytes) + "\"}"; HttpRequest req = HttpRequest.newBuilder(URI.create("http://127.0.0.1:8866/ocr")) .timeout(Duration.ofSeconds(5)) // 必须小于上游的读超时 .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(payload)) .build(); HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString()); if (resp.statusCode() != 200) { throw new IllegalStateException("sidecar status " + resp.statusCode()); } consecutiveFailures.set(0); return extractText(resp.body()); } catch (Exception e) { consecutiveFailures.incrementAndGet(); return fallback.recognize(imageBytes); // 单次失败也降级,不把异常抛给业务 } } }这段代码的关键不在 HTTP 调用本身,而在三个数字。connectTimeout给 2 秒,是因为侧车部署在同机或同可用区,超过 2 秒连不上基本就是进程挂了,继续等没有意义。请求超时给 5 秒,必须严格小于上游调用方(比如网关或前端轮询)的读超时,否则主服务还没返回、上游已经超时重试,一张图会被识别两次。熔断阈值给 5 次连续失败,是因为 OCR 请求本身有一定比例的 slow case,偶发失败不该立刻切走,但连续 5 次说明侧车确实出问题了。
提示:降级不等于“随便返回一个空字符串”。降级路径也要返回带来源标记的结果,比如
{"text": "...", "engine": "local"},这样后续统计 badcase 时能区分是主力引擎的错还是降级的错。
2.4 一个能直接拿去做决策的选择顺序
把上面的内容整理成可以照着判断的顺序:先看是否允许出网,不允许就只能在本地引擎和自建侧车之间二选一;再看日均调用量和准确率要求,日均小于五千张、字段规整,本地引擎加预处理足够,日均上万或者有手写、印章、复杂表格,就该考虑侧车;最后看团队有没有人能维护模型服务,没有的话,老老实实用本地引擎加后处理,比勉强维护一个跑不稳的侧车更可靠。
3. Tess4J 跑通:依赖、语言包与第一段能出字的代码
这一章把本地引擎从零跑起来。我按自己搭新项目时的顺序写:先放依赖,再放语言包,然后是最小可运行类,最后是那几个真正影响结果的参数。照着走一遍,半小时内应该能在本地看到中文输出。
3.1 Maven 依赖与 tessdata 目录的三种放法
依赖只有一条,但版本要在本地实测过后再固定下来,不要从别的项目复制。
<properties> <!-- 以你们私服能拉到的 5.x 最新小版本为准,写死前先 mvn dependency:tree 看一下 --> <tess4j.version>5.11.0</tess4j.version> </properties> <dependencies> <dependency> <groupId>net.sourceforge.tess4j</groupId> <artifactId>tess4j</artifactId> <version>${tess4j.version}</version> </dependency> </dependencies>这条依赖会带进来 JNA 和若干 native 库,打包体积会明显变大,属于正常现象。真正容易出问题的是语言包(traineddata)放在哪里。
| 放法 | 配置方式 | 适用场景 | 注意点 |
|---|---|---|---|
| 容器镜像内固定目录 | setDatapath("/opt/app/tessdata") | 生产环境 | 目录必须可读,镜像构建时 COPY 进去 |
| 宿主机挂载 | 同上,路径指向挂载点 | 需要热替换语言包 | 挂载权限要确认,容器内用户可能读不到 |
环境变量TESSDATA_PREFIX | 不调setDatapath | 本地开发图省事 | 和其他项目共用环境变量时极易冲突 |
我一般选第一种,路径写死在配置项里,通过@Value("${ocr.tessdata}")注入,不在代码里硬编码。第二种只在需要换语言包做实验时用,生产环境挂载点权限经常是个隐患。
语言包本身按需取用,不要一次性全塞进去:
| 文件 | 用途 | 什么情况下必须 |
|---|---|---|
chi_sim.traineddata | 简体中文主语言包 | 中文场景必须 |
eng.traineddata | 英文与阿拉伯数字 | 强烈建议常备,纯中文识别反而更差 |
osd.traineddata | 方向与脚本检测 | 需要自动判断图片是否倒置时 |
chi_sim_vert.traineddata | 竖排中文 | 只有竖版票据或古籍才需要 |
chi_sim.user-words | 自定义词表 | 有大量业务专有名词时 |
eng一定要带,因为纯chi_sim识别数字和字母串的稳定性明显不如混合模式,而票据里恰恰全是编码、金额、日期。
3.2 最小可运行:从 ImageIO 到 doOCR
先把最简单的版本跑出来,不要一上来就写预处理。
import net.sourceforge.tess4j.ITessAPI; import net.sourceforge.tess4j.ITesseract; import net.sourceforge.tess4j.Tesseract; import net.sourceforge.tess4j.TesseractException; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; public final class OcrBaseline { private static final ITesseract ENGINE = new Tesseract(); static { // 指向 tessdata 的父目录,不是 traineddata 文件本身 ENGINE.setDatapath(System.getProperty("ocr.tessdata", "/opt/app/tessdata")); ENGINE.setLanguage("chi_sim+eng"); ENGINE.setPageSegMode(ITessAPI.TessPageSegMode.PSM_AUTO); ENGINE.setOcrEngineMode(ITessAPI.TessOcrEngineMode.OEM_LSTM_ONLY); // 300 dpi 是 Tesseract 的舒适区,不告诉它就会在日志里反复警告 ENGINE.setVariable("user_defined_dpi", "300"); } public static String recognize(File imageFile) throws IOException, TesseractException { BufferedImage src = ImageIO.read(imageFile); if (src == null) { throw new IOException("ImageIO 无法解码,可能是不支持的格式: " + imageFile.getName()); } return ENGINE.doOCR(src).trim(); } }逐段说清楚。setDatapath传的是包含所有.traineddata的目录,很多人写成文件路径,报错信息只会告诉你“找不到语言包”,不会提示你路径层级错了。setLanguage("chi_sim+eng")中间用加号,表示两个语言包同时加载,顺序会影响优先级。PSM_AUTO让引擎自己判断版面,是安全但不一定最优的默认值,下一节会讲怎么改。OEM_LSTM_ONLY强制走 LSTM 引擎,跳过旧版引擎的兼容逻辑,现代语言包基本都只训练了 LSTM 部分,指定它反而更快。
user_defined_dpi这个参数很容易被忽略。你是先用ImageIO读进来的BufferedImage,图里没有 DPI 元信息,Tesseract 会按默认值处理,缩放比例算错,识别结果就细碎。显式告诉它 300,等于告诉它“这张图的文字高度大概是多少像素”,后面的分割才靠谱。
ImageIO能解码的格式有限,BMP、JPG、PNG、GIF 没问题,HEIC、部分 TIFF、带透明通道的 PNG 变体就不一定了。线上收到的图来源五花八门,读失败的时候不要以为是自己代码写错了,先打印文件头判断格式。
3.3 必调的五个参数:PSM、OEM、dpi、白名单、词间空格
这五个参数决定了同一张图能识别出什么质量的结果,值得单独列出来。
PSM 是最影响结果的一个,它的取值不是越多越好,而是要和你的图片形态对上:
| PSM 值 | 含义 | 我通常在什么场景用 |
|---|---|---|
| 3 | 全自动分页,无方向检测 | 不确定版面时的默认值 |
| 4 | 假定单列、可变行高 | 单列排版的通知、合同正文 |
| 6 | 假定一个统一的文本块 | 票据主体、截图、卡片 |
| 7 | 按单行识别 | 已经切好行的场景,配合切分使用 |
| 8 | 按单词识别 | 字段级精确提取,比如只认金额 |
| 11 | 稀疏文本,不假设连续 | 海报、散落字段、带印章的图 |
| 13 | 原始单行,不做后处理 | 需要拿到原始字符序列时 |
实践中的结论很直接:整图扔进去用 3 或 6,切好行之后用 7,只抠一个字段用 8。用 3 去识别一张只有一行的票据编号,结果往往比用 7 差,因为自动分页会把这一行当成一整块版面去做结构分析,反而引入了噪声。
OEM 一般固定为OEM_LSTM_ONLY,只有在你用了很老的、只含 legacy 数据的语言包时才需要改成OEM_DEFAULT。字符白名单用来把识别范围压死,这在金额、编号、日期字段上收益极大:
// 只认数字和少量符号,能消掉绝大部分 O/0、l/1 的混淆 engine.setVariable("tessedit_char_whitelist", "0123456789.,-/:");注意白名单是“只认这些字符”而不是“优先这些字符”,一旦设错,正常文字会被直接吞掉,调试时记得先关掉它确认基线。
preserve_interword_spaces设成1会保留词间空格,对表格类结果很关键,否则同一行不同列的文字会粘成一串,后面拿正则拆都拆不开。还有一个tessedit_pageseg_mode的同名变量形式,别和setPageSegMode混用,两者冲突时以最后设置的为准,这种冲突排查起来非常费时间。
3.4 线程安全:Tesseract 实例不能跨线程共享
这是新手最容易出问题的地方。Tesseract实例内部持有 native 句柄,不是线程安全的。你在 Spring 里注册成单例@Bean,平时单线程测试一切正常,压测一上来就会出现识别结果串行、文本错乱,甚至直接导致进程崩溃。
常见做法是用ThreadLocal每线程一个实例:
public final class TesseractHolder { private static final ThreadLocal<ITesseract> LOCAL = ThreadLocal.withInitial(() -> { ITesseract t = new Tesseract(); t.setDatapath(TESSDATA_PATH); t.setLanguage("chi_sim+eng"); t.setPageSegMode(ITessAPI.TessPageSegMode.PSM_AUTO); t.setOcrEngineMode(ITessAPI.TessOcrEngineMode.OEM_LSTM_ONLY); t.setVariable("preserve_interword_spaces", "1"); t.setVariable("user_defined_dpi", "300"); return t; }); public static String ocr(BufferedImage img) throws TesseractException { return LOCAL.get().doOCR(img); } /** 线程池销毁前调用,释放 native 句柄 */ public static void clean() { LOCAL.remove(); } }逻辑上很简单:每个线程第一次用到时创建一个实例,之后复用,线程结束时清掉。参数说明两点。第一,ThreadLocal配合固定大小的业务线程池是合适的,但如果项目用了虚拟线程,每个任务一个新线程,就会不断创建 native 实例,内存直接失控,这种场景要换成固定大小的对象池(GenericObjectPool之类),池大小取 CPU 核数左右。第二,clean()必须在@PreDestroy里调用,否则容器优雅停机时 native 句柄不释放,反复重启会看到内存缓慢上涨。
4. 预处理、版面切分与后处理:把可用率从六成拉到八成五
同一张图,直接扔进引擎和先做几步处理再扔进去,结果差距经常是两倍。这一章处理的是最容易看到收益的部分:图像怎么变成引擎喜欢的样子,版面怎么切,结果怎么清洗。
4.1 灰度、Otsu 二值化与放大:三行能写完但参数有讲究
先说结论:绝大多数线上图片的问题不是“识别算法不行”,而是“字太小、对比度太低、噪点太多”。这三件事都能在进引擎之前解决。
灰度化不要图省事用Graphics2D往TYPE_BYTE_GRAY上画,那种转换在色彩空间上不完全等价于加权平均,对彩色印章覆盖的文字会有偏差。自己按 0.299、0.587、0.114 加权更可控:
static int[] toGrayPixels(BufferedImage src) { int w = src.getWidth(), h = src.getHeight(); int[] gray = new int[w * h]; for (int y = 0; y < h; y++) { for (int x = 0; x < w; x++) { int rgb = src.getRGB(x, y); int r = (rgb >> 16) & 0xFF, g = (rgb >> 8) & 0xFF, b = rgb & 0xFF; gray[y * w + x] = (int) (0.299 * r + 0.587 * g + 0.114 * b); } } return gray; }加权系数不是随便定的,它对应人眼对绿色最敏感、蓝色最不敏感的特性。红色印章在灰度化之后会变得很浅,接近背景,反而是好事——它不会干扰黑色文字的识别。
二值化用 Otsu 全局阈值,比自己拍一个 128 稳得多:
static int otsu(int[] gray) { int[] hist = new int[256]; for (int v : gray) hist[v]++; int total = gray.length; double sum = 0; for (int i = 0; i < 256; i++) sum += i * (double) hist[i]; double sumB = 0, maxVar = -1; int wB = 0, best = 128; for (int t = 0; t < 256; t++) { wB += hist[t]; if (wB == 0) continue; int wF = total - wB; if (wF == 0) break; sumB += t * (double) hist[t]; double mB = sumB / wB, mF = (sum - sumB) / wF; double var = (double) wB * wF * (mB - mF) * (mB - mF); if (var > maxVar) { maxVar = var; best = t; } } return best; }Otsu 找的是让类间方差最大的那个阈值,本质是让前景和背景分得最开。它的失效场景要记住两种:图片有大面积渐变阴影时,全局阈值会把一部分背景判成前景;文字和背景灰度接近(比如浅灰底上的深灰字)时,算出来的阈值会把两边都切掉。遇到这两种情况,用局部自适应阈值(按 32×32 分块各自算 Otsu)比调全局参数有效。
第三个动作是放大。Tesseract 对文字高度的舒适区间大概是 20~40 像素,低于这个高度识别率断崖式下降。常见做法是把长边缩放到 1500~2500 像素,缩小用双线性,放大用双三次:
static BufferedImage resizeToComfort(BufferedImage src) { int w = src.getWidth(), h = src.getHeight(); int longSide = Math.max(w, h); if (longSide >= 1500 && longSide <= 2500) return src; // 已经在舒适区,不动 double scale = longSide < 1500 ? 1500.0 / longSide : 2500.0 / longSide; int nw = (int) (w * scale), nh = (int) (h * scale); BufferedImage dst = new BufferedImage(nw, nh, longSide < 1500 ? BufferedImage.TYPE_INT_RGB : BufferedImage.TYPE_INT_RGB); Graphics2D g = dst.createGraphics(); g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, longSide < 1500 ? RenderingHints.VALUE_INTERPOLATION_BICUBIC : RenderingHints.VALUE_INTERPOLATION_BILINEAR); g.drawImage(src, 0, 0, nw, nh, null); g.dispose(); return dst; }放大的代价要算清楚:一张 800×600 的图放大到 1500 长边,像素数涨了将近 3.5 倍,识别耗时基本同比上升。所以这个动作应该只在“图确实太小”时触发,而不是无脑放大。
4.2 倾斜校正:用投影方差找一个角度
倾斜是票据场景最常见的问题,管理员拍照时手一歪就是 5 度。Tesseract 本身能容忍大概 2~3 度的倾斜,再多就开始丢行。
校正的思路不复杂:把二值图按不同角度旋转,每转一次统计每一行的黑色像素数量,得到一条“水平投影曲线”。文字行如果完全水平,投影曲线会呈现明显的波峰波谷,方差大;倾斜时行与行之间的墨迹互相渗透,曲线变得平缓,方差小。所以让方差最大的那个角度,就是校正角度。
static double estimateSkew(BufferedImage bin, double from, double to, double step) { double bestAngle = 0, bestScore = -1; for (double a = from; a <= to; a += step) { double score = rowProjectionVariance(bin, a); if (score > bestScore) { bestScore = score; bestAngle = a; } } return bestAngle; } private static double rowProjectionVariance(BufferedImage bin, double degrees) { double rad = Math.toRadians(degrees); AffineTransform at = AffineTransform.getRotateInstance( rad, bin.getWidth() / 2.0, bin.getHeight() / 2.0); AffineTransformOp op = new AffineTransformOp(at, AffineTransformOp.TYPE_NEAREST_NEIGHBOR); // 最近邻足够,速度优先 BufferedImage rot = op.filter(bin, null); int w = rot.getWidth(), h = rot.getHeight(); long[] rows = new long[h]; for (int y = 0; y < h; y++) { for (int x = 0; x < w; x++) { if ((rot.getRGB(x, y) & 0xFF) < 128) rows[y]++; // 前景像素 } } double mean = 0; for (long v : rows) mean += v; mean /= h; double var = 0; for (long v : rows) var += (v - mean) * (v - mean); return var / h; }参数上,搜索范围给 -8 到 +8 度、步长 0.5 就够用了,一共 33 次旋转。如果图很大(超过 2000 像素长边),先缩小一半再估角度,估出来的角度用在原图上,精度损失可以忽略,速度能快四倍。旋转插值选最近邻,因为这一步只是为了估角度,不追求画质。
估出角度后,用双三次插值在原图上做一次正式旋转,再去二值化。顺序不能反:先二值化再旋转,边缘会产生锯齿,反而影响识别。
4.3 表格切分:水平投影切行、垂直投影切列
票据和报表的最大特点是有表格线。整图扔进引擎,最典型的结果是“同一行的三列内容被识别成三行”,或者“上一行的最后一个字粘到下一行开头”。解决办法是先切再识别。
水平方向切行,用的是和倾斜校正同一套投影思路:
static List<int[]> rowBands(BufferedImage bin, int inkThreshold, int minHeight) { int w = bin.getWidth(), h = bin.getHeight(); int[] ink = new int[h]; for (int y = 0; y < h; y++) { int c = 0; for (int x = 0; x < w; x++) { if ((bin.getRGB(x, y) & 0xFF) < 128) c++; } ink[y] = c; } List<int[]> bands = new ArrayList<>(); int start = -1; for (int y = 0; y < h; y++) { boolean hasInk = ink[y] > inkThreshold; if (hasInk && start < 0) { start = y; } else if (!hasInk && start >= 0) { if (y - start >= minHeight) bands.add(new int[]{start, y}); start = -1; } } if (start >= 0 && h - start >= minHeight) bands.add(new int[]{start, h}); return bands; }inkThreshold是判断“这一行有没有字”的墨迹像素数,太小会把噪点当文字,太大则会过滤掉稀疏的行。我一般取图片宽度的 1%~2%,既宽 2000 像素就取 20~40。minHeight是行高下限,防止把表格边框下的阴影当成行,取 12~20 像素比较合适。
拿到行带之后,每一行单独裁出来,用PSM_SINGLE_LINE(值 7)去识别,最后按行带顺序拼接。这一步的收益非常明显:同一张票据,整图识别的行序错误率能到 30%,切行之后基本消失。
列切分只在“同一行里有多个独立字段、需要分别落库”时才做。方法是拿行带内部的垂直投影,找连续空白列作为分割点,再按业务字段的相对位置(左起第几个块)去映射。这里不要试图纯靠算法理解“哪一列是金额”,位置映射比语义推断可靠得多,而且出问题时好排查。
4.4 后处理:置信度过滤、易混字符与正则兜底
识别出来的原始文本一定带噪声,后处理决定最终能不能用。三个动作按顺序做。
第一步是拿置信度。Tess4J 提供了词级结果:
List<Word> words = engine.getWords(image, ITessAPI.TessPageIteratorLevel.RIL_WORD); for (Word w : words) { if (w.getConfidence() < 60f) { // 低置信度的词单独标记,进入人工复核队列 reviewQueue.add(w.getText()); } }阈值 60 是个经验值,低于它的词错误率明显上升,但不要设得太高,80 以上会把大量正确结果也筛进去,人工复核队列直接爆掉。实践中更有效的做法是“整段平均置信度”加“关键字段单项置信度”双阈值:整段低于 70 分整张打回复核,金额这类关键字段单项低于 85 就单独复核。
第二步是易混字符纠正。有一个前提必须先说清楚:只在字段类型确定的情况下替换,不要全局替换。全角的,。转半角,多余的空白压掉,这些是安全的:
static String normalize(String raw) { return raw.replace('\u3000', ' ') // 全角空格 .replace(',', ',') .replace('。', '.') .replaceAll("[ \\t]{2,}", " ") .replaceAll("[\\r\\n]+", "\n") .trim(); }而O→0、l→1、S→5、B→8这类替换,只应该在“这个字段只可能是数字”的上下文里做。全局做会把正常英文单词改坏,比如SO变成50,这类 bug 上线后极难复现。
第三步是正则兜底。金额、日期、编号这几类字段,用正则从整段文本里定位比依赖位置切分更稳:
private static final Pattern AMOUNT = Pattern.compile("(\\d{1,3}(?:,\\d{3})*|\\d+)(\\.\\d{1,2})?"); private static final Pattern DATE = Pattern.compile("(\\d{4})[-/年.](\\d{1,2})[-/月.](\\d{1,2})日?"); static Optional<String> findAmount(String text) { Matcher m = AMOUNT.matcher(text); return m.find() ? Optional.of(m.group().replace(",", "")) : Optional.empty(); }正则的作用是把“识别出来的文本”转成“业务能接受的字段”,同时把格式统一掉。它不能修意识别错误本身,但能把“19.9O 元”这种情况在数字白名单加持下变成“19.90”,避免下游解析抛异常。兜底逻辑要有明确的失败返回,宁可返回空让业务走人工,也不要用一个瞎猜的默认值。
5. 避坑与排查:Java OCR 上线后最常出问题的五个地方
下面这五条都是我在真实项目里踩过或者帮别人排查过的,按现象、原因、解决的顺序写。如果你正准备上线,建议逐条对照检查一遍。
5.1 第一次调用就报 UnsatisfiedLinkError 或找不到 traineddata
现象:本地开发环境跑得好好的,打成镜像部署到容器里,第一次调用就抛UnsatisfiedLinkError: Unable to load library 'tesseract',或者报错说找不到语言包。
原因通常是两个。一是基础镜像太精简,缺libtesseract相关的系统库和字体,Tess4J 通过 JNA 加载 native 库时找不到依赖。二是tessdata目录虽然是 COPY 进去了,但容器以非 root 用户运行,目录权限是 700,进程读不到。
解决:镜像里显式装系统级的 tesseract 依赖和常用字体(缺字体会导致中文渲染和部分识别异常),构建后用docker run --rm <image> ls -l /opt/app/tessdata和ldd检查一遍。目录权限统一设成 755、文件 644。不要把 tessdata 打进 jar 里用getResourceAsStream去读,native 层要的是文件系统上的真实路径,classpath 资源它读不到。
5.2 中文全变空格,英文却正常
现象:识别结果里英文字母和数字都在,中文位置全是空格或者奇怪的方框。
原因基本锁定在语言包上:chi_sim.traineddata没放进去,或者setLanguage写成了chi_sim +eng带了空格,或者环境变量TESSDATA_PREFIX指向了另一个目录覆盖了代码里的设置。还有一种不常见但确实存在的情况——语言包文件下载不完整,文件存在但内容是截断的,引擎不会报错,只是识别不出东西。
解决:先确认目录下文件的实际大小是否正常(中文包通常几十 MB,几百 KB 的必然是坏的),再打印ENGINE.getLanguage()和ENGINE.getDatapath()确认运行时生效值。排查环境变量冲突最快的办法是在启动脚本里unset TESSDATA_PREFIX,让代码里的setDatapath说了算。
5.3 并发一上来 CPU 打满、P99 从 800ms 涨到 8 秒
现象:单张图测试 800ms,压测到 20 并发时 P99 直接飙到 8 秒,机器 load 打满,同时识别结果开始出现内容错乱。
原因有两层。第一层是实例共享,Tesseract不是线程安全的,多线程共用同一个实例既会错乱也会因为内部锁竞争导致耗时暴涨。第二层是图片尺寸,识别耗时和像素数基本是线性关系,一张 4000×3000 的手机原图,识别一次要好几秒,并发起来直接把 CPU 占满。
解决:实例按线程隔离(见 3.4),然后给入口加一道尺寸限制——超过 2500 长边的一律先缩小,超过 8MB 的直接拒绝并提示重拍。再给 OCR 线程池设一个明确的并发上限,取 CPU 核数的一半到一倍之间,超出的请求排队或者快速失败,不要让它们同时挤进引擎。经验数据是,四核机器上 Tesseract 的合理并发是 4~6,再多吞吐不会上升,延迟只会更差。
5.4 识别出来的文字“有字但顺序乱”
现象:内容都识别对了,但行的顺序是乱的,同一行的两列内容被拆到了不同行,或者表格下半部分跑到了上半部分前面。
原因是没有利用版面信息。PSM_AUTO会根据连通域和文本块做版面分析,遇到表格线、印章、大面积空白时分析结果不稳定,行的排列顺序就跟着变。另外,中文和英文混排时如果preserve_interword_spaces没开,同一行的多个词会粘成一个长串,再拆分时就没有边界依据了。
解决:两条一起做。开preserve_interword_spaces=1;然后改用 TSV 输出,按block_num → par_num → line_num三级排序重建文本,而不是直接用doOCR返回的字符串。Tess4J 里可以通过getWords拿到带层级信息的词对象,自己按坐标(左上角 y 优先、x 次之)排序也是一个可靠的做法。对于明确是表格的图,直接走 4.3 的切分流程,不要让引擎去猜版面。
5.5 跑几小时后 /tmp 塞满、进程被 OOM Kill
现象:服务跑一段时间后容器磁盘告警,/tmp下堆了几万个临时文件;或者进程内存缓慢上涨,最终被内核杀掉。
原因在doOCR(File)这个重载上。它内部会把文件复制到临时目录做处理,某些版本里清理不彻底,长期高频调用就会堆积。同时ThreadLocal里的引擎实例如果线程池频繁重建(比如用了缓增长线程池),native 内存不会及时归还,表现出来就是 RSS 只涨不降。
解决:一律用doOCR(BufferedImage)或doOCR(ByteBuffer),不要传File;把java.io.tmpdir指到一个有配额、有定期清理的目录;线程池用固定大小,配合@PreDestroy调clean()释放 native 资源。上线后加一个简单的监控:定时统计tmpdir下的文件数,超过阈值告警,这个问题早发现比晚发现省太多事。
6. 上线前怎么验证:一套 200 张样本的回归集与三个必须看的指标
OCR 这类功能最怕“演示的时候挺准”和“上线之后全是投诉”之间的落差。我现在的习惯是,功能开发完不急着联调,先花半天从历史数据里捞 200 张真实图片,人工标注出关键字段的正确值,做成一个可以一键跑的回归集。这 200 张要覆盖几类:正常清晰的、倾斜的、有印章覆盖的、拍照模糊的、极小字的,每一类至少 20 张,不然后面的指标没有意义。
指标只看三个,不要贪多:
| 指标 | 计算方式 | 我的经验阈值 |
|---|---|---|
| 字符错误率 CER | 编辑距离 / 标注总字符数 | 印刷体低于 5% 可用 |
| 关键字段准确率 | 字段完全正确的图片数 / 总图片数 | 低于 85% 不要上线 |
| P95 单张耗时 | 从解码到返回结果 | 与业务超时留 3 倍余量 |
CER 用编辑距离算,别用“完全匹配率”,后者太严格,一个字错就整张判错,看不出改进幅度。关键字段准确率才是业务真正关心的——一张图里识别对了 200 个字但金额错了,对业务来说就是零分。
验证的第二个动作是双跑对比。新方案上线前,让它和现有方案(哪怕是人工)并行跑一周,把两边结果不一致的样本单独捞出来看。这一步能捞到大量回归集覆盖不到的长尾问题,比如某种特定打印机的字体、某种特定角度的拍照。不一致的样本里,如果新方案错了超过三成,说明还没准备好。
第三个动作是留后路。识别结果落库时,把原始图片路径、识别文本、置信度、使用的引擎版本一起存下来。这相当于一份关键时的追溯依据——用户投诉说金额识别错了,你能立刻定位到是哪张图、走的哪条路径、置信度多少,而不是对着一行结果干瞪眼。同时低置信度的样本自动进复核队列,人工改一遍的结果反过来就是最好的训练和调参数据。
最后说一个我自己的习惯:每次调完参数准备发布,我都会先把回归集跑一遍,把 CER 和字段准确率写到提交说明里。有一次
本文还有配套的精品资源,点击获取