简介:这是一份面向Spring Boot开发者的OCR集成示例工程,演示在Java Web应用中快速接入光学字符识别能力,适用于证件扫描、票据自动化、文档数字化等场景,适合熟悉Spring Boot基础但希望掌握OCR落地方案的初中级开发者。项目以Tesseract或云服务调用为主线,完整涵盖依赖引入、控制器接口编写、图片上传、识别结果解析等关键环节,并对Tesseract本地引擎与阿里云/腾讯云API两种主流方式分别说明。压缩包共7个文件,仅9KB大小,以Java源文件为主,配合Maven构建脚本、XML及属性配置文件,结构精简,可直接导入IDE运行调试。已有410人学习下载,属于轻量级参考demo。通过这份素材,可以直观理解Spring Boot与OCR库/API的整合方式,包括RESTful接口设计、文件上传处理、结果后处理、异步优化与缓存策略,为后续在业务系统中嵌入证件识别、票据解析等功能提供可复用的代码骨架。
1. 为什么要在 SpringBoot 里自己集成 OCR 引擎:云 API 之外的本地方案
手头有个内部系统需要识别一批扫描件里的编号和日期,数据敏感不能出内网,云 API 这条路直接被掐掉。最后在 SpringBoot 工程里集成了本地 OCR 引擎,几张关键代码把识别服务挂成了接口,一周内上线。这篇文章讲的 SpringBoot 集成 OCR 功能 demo,就是这套本地方案的完整复现:它不依赖任何外部服务,内网部署也能跑,适合数据敏感、识别量不大、精度要求不是天花板级的内部工具。如果你也在评估“本地识别”和“云端识别”怎么选,这个工程能让你少走弯路。
2. OCR 方案选型:本地引擎与云端接口的取舍,以及 Tesseract 的适用边界
2.1 三条常见路径对比:云 API、开源引擎、深度学习框架
做 OCR 选型时,摆在我面前的基本是三条路:调云 API、用开源引擎、跑深度学习框架。这里不聊那些花哨的评测数据,就说实际项目里的体会。
第一条是云 API。各大小平台都有现成的文字识别接口,注册个账号、拿 key、发 HTTP 请求,结果就回来了。优点是识别率确实高,尤其对印刷体、常见票据模板的识别效果能到 98% 以上,而且接口稳定、并发不用自己操心。缺点也明显:一是数据出内网的问题,内部系统里过一遍身份证号、合同编号这类信息,不少合规部门直接摇头;二是每张图片都要计费,识别量一旦上去,成本从几厘钱到几分钱不等,一年下来也是一笔开销;三是离线环境完全没法用,机房断网或者搭私有云的场景直接出局。
第二条是 PaddleOCR 之类的深度学习方案。识别精度相比传统 OCR 是质的提升,尤其对复杂版面、倾斜文字、手写体的处理能力很强。但代价是部署体积大、需要 Python 推理环境,在纯 Java 的 SpringBoot 工程里集成,要么起一个独立的推理服务,要么走 JNI 调底层库,无论哪种方式,对大多数人来说维护成本都不低。如果是几十万张以上、版面复杂的识别任务,这条路值得认真考虑,但对“一个内部工具需要识别扫描件编号”这种场景来说,重了。
第三条是 Tesseract。它是个开源 OCR 引擎,有现成的 Java 封装库(典型的是 tess4j),通过 JNA 调用本地引擎,不需要额外起服务,也不需要 Python 环境。识别精度在纯印刷体、清晰扫描件上能到 90% 以上,对内部编号、日期、单据号码这类结构化内容足够用。缺点是遇到复杂版面或模糊图片时识别率明显下降,需要配合图像预处理来补救。
2.2 为什么 demo 场景选 Tesseract:成本、离线、可复现
这个 demo 的核心诉求有三个:能跑、能离线跑、代码能看懂。Tesseract 在这三点上都把成本压到了最低。
先说能跑。SpringBoot 工程里引入一个 tess4j 依赖,配置一下语言包路径,就能拿到识别结果。整个过程不需要安装额外的第三方服务,Windows 和 Linux 都能跑,唯一的“外部依赖”就是 OCR 引擎本身的原生库和语言包。这比调云 API 少了一层网络请求,也比起独立推理服务少了进程间通信的复杂度。
再说离线。之前那个内部系统部署在隔离网段里,任何外呼请求都被安全策略挡掉。云 API 这条路一开始就被否了,Tesseract 全本地跑,数据不出服务器,安全评估那边直接过关。
最后说可复现。网上关于 Tesseract 的教程很多,踩坑记录也丰富,语言包、训练数据、参数调整的文档非常完整。对一个 demo 项目来说,遇到问题能找到对应解法,比选一个“看起来很高级但没有参考资料”的方案要稳妥得多。我自己当时搜了一圈,发现基于 tess4j 的 SpringBoot 集成案例虽然零散,但核心步骤就那么几步——引依赖、配路径、调接口、做预处理。把这四步打通,剩下的都是优化空间。
2.3 识别能力的边界:什么时候该换 PaddleOCR
Tesseract 不是万能的,这个必须承认。用下来我总结了三个明显短板。
第一,对倾斜文字的识别效果差。扫描件如果放歪了超过 15 度,Tesseract 基本就是乱码一片,必须先做图像旋转矫正。PaddleOCR 内置了文本检测方向分类器,对倾斜的容忍度要高得多。
第二,对低对比度图片的鲁棒性差。深色背景上的浅色文字、带水印的截图、模糊的传真件,Tesseract 经常识别出一堆无意义字符,这时必须靠预处理手段把对比度拉高,而 PaddleOCR 的预处理管线本身就更完善。
第三,中英文混排的版面分析能力弱。Tesseract 对纯中文或纯英文的识别效果尚可,一旦中英文混排、行内夹杂数字和特殊符号,识别结果经常需要人工二次校对。
所以我的建议是:如果你的图片质量可控——扫描清晰、无明显倾斜、打印体文字为主——Tesseract 完全够用,成本最低;如果图片来源复杂、质量不可控、识别率要求高,别在 Tesseract 上死磕,直接上 PaddleOCR 更省时间。这个 demo 解决的是前一类场景。
3. 搭建可运行的 SpringBoot OCR 工程:依赖、配置与本地环境
3.1 工程结构与基础依赖
我习惯先搭一个最小工程再逐层加东西。这个 demo 的工程结构大致如下:
├── pom.xml ├── src │ └── main │ ├── java │ │ └── com/example/ocr │ │ ├── OcrApplication.java │ │ ├── config │ │ │ └── TesseractConfig.java │ │ ├── controller │ │ │ └── OcrController.java │ │ ├── service │ │ │ ├── OcrService.java │ │ │ └── impl │ │ │ └── OcrServiceImpl.java │ │ └── util │ │ └── ImagePreprocessor.java │ └── resources │ └── application.yml第一步先引入关键依赖。pom.xml 里除了 SpringBoot 基础依赖,需要额外加两个东西:tess4j 用于 Java 调用 Tesseract,以及 commons-io 用于处理上传文件的临时存储。
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>net.sourceforge.tess4j</groupId> <artifactId>tess4j</artifactId> <version>5.11.0</version> </dependency> <dependency> <groupId>commons-io</groupId> <artifactId>commons-io</artifactId> <version>2.15.1</version> </dependency> </dependencies>这里有个容易踩的坑:tess4j 的版本选择。每个版本对应的 Tesseract 原生库版本不同,对语言包格式的兼容性也有差异。我用的 5.x 版本对应 Tesseract 5.x 引擎和 .traineddata 语言包格式,如果你之前从旧教程里复制了 4.x 的语言包,识别时会报“Failed loading language”的错误。选版本的时候顺手确认一下语言包是否配套,能省不少排查时间。
还需要注意 tess4j 本身会传递引一些原生库依赖(如 leptonica),如果下载依赖慢,检查一下 Maven 仓库配置。这点在国内网络环境下经常遇到,属于环境问题不是代码问题。
3.2 Tesseract 环境的安装与语言包
Tesseract 不是纯 Java 实现,tess4j 只是封装层,底层还是要依赖系统里的 Tesseract 引擎或者通过 JNA 直接加载原生库。这里有个选择:一种是本机装好 Tesseract 软件,tess4j 通过系统命令调用;另一种是不装软件,只把语言包和 dll/so 原生库文件放到工程里,tess4j 通过 JNA 直接加载。这个 demo 我用的是第二种方式,好处是部署时不用在目标机器上单独安装软件,把资源目录拷过去就行。
具体做法是在工程的src/main/resources下建一个tessdata目录,把需要的语言包放进去。中文识别需要chi_sim.traineddata和eng.traineddata,英文识别只需要eng.traineddata。语言包的下载来源可以从各开源仓库或 Tesseract 官方托管处找,注意版本要和 tess4j 的引擎版本匹配,否则加载会报版本不兼容。
# 查看已安装的 tess4j 对应的引擎版本 # 方式:解压 tess4j 的 jar,查看依赖的 native 库版本 # 确认后下载对应版本的 chi_sim.traineddata这一步没有太多代码逻辑,但环境准备决定了后续能不能顺利识别。如果启动时报找不到语言包,先检查 resources 目录下有没有打进 classpath,再检查 tessdata 目录名是否拼写正确。我当时就因为在 Windows 下把目录命名为tessdata而实际打包后变成了tessData,折腾了半小时。
3.3 核心配置类与 Bean 装配
SpringBoot 集成第三方库的通用做法是把客户端封装成 Bean,通过配置类统一管理初始化参数。Tesseract 的配置类如下:
@Configuration public class TesseractConfig { @Bean public Tesseract tesseract() { Tesseract tesseract = new Tesseract(); // 语言包所在目录优先从 classpath 读取 // 再吃一层容错:尝试从外部文件系统加载 try { String tessdataPath = new ClassPathResource("tessdata").getFile().getAbsolutePath(); tesseract.setDatapath(tessdataPath); } catch (IOException e) { // 兜底:从当前目录下的 tessdata 加载,方便外部部署时覆盖 tesseract.setDatapath("tessdata"); } // 中文简体 + 英文混合识别 tesseract.setLanguage("chi_sim+eng"); // 设置为默认页面分割模式,后续可按图片类型调整 tesseract.setPageSegMode(3); // 保留默认变量,如需精细调整可在 setVariable 中指定 return tesseract; } }配置说明:
setDatapath指定语言包目录。用 classpath 路径的好处是语言包跟随 jar 一起打包,部署简单;但如果你想在外部修改语言包而不重新打包,就得支持文件系统路径。我这里的写法是 classpath 优先、文件系统兜底,两套环境都能跑。注意new ClassPathResource().getFile()在打成 jar 包后大概率抛异常,因为 jar 内资源不是文件系统路径,所以兜底分支非常重要。setLanguage("chi_sim+eng")表示中英文混合识别。注意加号连接多个语言包,它们会被 Tesseract 引擎联合加载。如果只识别中文数字,可以只写chi_sim;只识别英文保单号,可以只写eng。语言配置不是越多越好,语言包越多,加载时间越长,识别速度也越慢。setPageSegMode(3)是 Tesseract 的页面分割模式参数。模式 3 表示“全自动页面分割,但没有方向检测”,适合规范排版的扫描件。模式 6 表示“将图片视为单一文本块”,适合文字集中、无多栏布局的图片。这个参数对不同图片形态的适配性差异很大,后面会展开讲。
3.4 文件上传接口与识别服务
工程的核心是把图片上传进来,调用 Tesseract 识别,把文本结果返回给调用方。Controller 层负责接收文件,Service 层负责业务逻辑和异常处理。
@RestController @RequestMapping("/api/ocr") public class OcrController { private final OcrService ocrService; public OcrController(OcrService ocrService) { this.ocrService = ocrService; } @PostMapping("/recognize") public OcrResult recognize(@RequestParam("file") MultipartFile file) { if (file == null || file.isEmpty()) { throw new IllegalArgumentException("上传文件不能为空"); } return ocrService.recognize(file); } }@Service public class OcrServiceImpl implements OcrService { private final Tesseract tesseract; public OcrServiceImpl(Tesseract tesseract) { this.tesseract = tesseract; } @Override public OcrResult recognize(MultipartFile file) { File tempFile = null; try { // 把 MultipartFile 落盘为临时文件,Tesseract 原生库需要真实文件路径 tempFile = File.createTempFile("ocr_", ".tmp"); file.transferTo(tempFile); // 这里做一次简单的类型判断,避免非图片文件走到引擎层 String contentType = file.getContentType(); if (contentType == null || !contentType.startsWith("image/")) { throw new IllegalArgumentException("只支持图片文件"); } // 先做预处理,返回处理后的临时文件 File processedFile = ImagePreprocessor.preprocess(tempFile); // 调用 Tesseract 识别 long startTime = System.currentTimeMillis(); String result = tesseract.doOCR(processedFile); long costTime = System.currentTimeMillis() - startTime; return new OcrResult(result, costTime); } catch (TesseractException e) { throw new RuntimeException("OCR 识别失败: " + e.getMessage(), e); } catch (IOException e) { throw new RuntimeException("文件处理失败: " + e.getMessage(), e); } finally { // 清理临时文件,避免磁盘被识别任务占满 if (tempFile != null && tempFile.exists()) { tempFile.delete(); } } } }逻辑说明:
File.createTempFile是必须的一步。Tesseract 的原生库读写文件时依赖真实文件路径,不能直接操作 MultipartFile 的字节流。把上传文件落盘后再识别,是最稳妥的方式。临时文件命名加个ocr_前缀便于后续排查时识别。文件类型检查不能省。如果传入的是伪装成图片的恶意文件,或者是不支持的格式(如 PDF),Tesseract 不会优雅报错,而是抛一堆底层异常,日志很难看。提前拦截一下,把错误信息变成人类可读的文本,对接口调用方友好得多。
tesseract.doOCR()是同步阻塞调用,图片越大耗时越长。第一版代码先跑通同步流程,后面优化再改异步。临时文件清理放在 finally 里执行,避免识别任务堆积把磁盘写满。这里有个细节:如果图片预处理生成了另一个临时文件,也要记得清理,我上面代码只删了原文件,是个简化写法,后面踩坑章节会补充完整版。
4. 图像预处理与识别参数:从“能跑”到“认得准”
4.1 灰度化、二值化与降噪的阈值选择
Tesseract 对输入图像质量很敏感。同样一张发票扫描件,直接丢进去识别可能只有 70% 的准确率,但做一遍预处理后能到 90% 以上。这个提升不是玄学,而是 Tesseract 自身对图像对比度、噪声干扰的容忍度有限。
预处理的第一件事是灰度化。彩色图片包含的信息量远大于文字识别所需,灰度化可以减少后续处理的运算量,同时避免彩色背景对二值化阈值的干扰。
第二件事是二值化——把灰度图变成黑白图。Tesseract 的核心算法基于字符笔画和背景的对比,二值化后对比度最清晰,识别也最稳定。这一步的关键是选阈值,常见算法有全局阈值(Otsu)和自适应阈值。Otsu 适合背景均匀、光照一致的图片;自适应阈值适合光照不均、背景有渐变的图片。
下面是我在 demo 里写的预处理工具类,用的是纯 Java + ImageIO,不依赖 OpenCV,方便直接跑:
public class ImagePreprocessor { // 灰度化 + 二值化 + 缩放,一次性处理 public static File preprocess(File sourceFile) throws IOException { BufferedImage image = ImageIO.read(sourceFile); if (image == null) { throw new IOException("无法读取图片文件"); } // 1. 转灰度 BufferedImage grayImage = toGrayscale(image); // 2. 二值化:默认用 Otsu 阈值 BufferedImage binaryImage = binarize(grayImage); // 3. 可选:把超大图缩放到合理尺寸 // 超过 4000px 的图先缩放到 2000px 内,再识别速度翻倍 if (binaryImage.getWidth() > 2000 || binaryImage.getHeight() > 2000) { binaryImage = resize(binaryImage, 2000); } // 写成新文件 File tempFile = File.createTempFile("ocr_processed_", ".png"); ImageIO.write(binaryImage, "png", tempFile); return tempFile; } private static BufferedImage toGrayscale(BufferedImage source) { BufferedImage gray = new BufferedImage(source.getWidth(), source.getHeight(), BufferedImage.TYPE_BYTE_GRAY); Graphics2D g = gray.createGraphics(); g.drawImage(source, 0, 0, null); g.dispose(); return gray; } private static BufferedImage binarize(BufferedImage source) { int width = source.getWidth(); int height = source.getHeight(); // 先遍历像素直方图,计算 Otsu 阈值 int[] histogram = new int[256]; for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { int gray = source.getRGB(x, y) & 0xFF; histogram[gray]++; } } // 计算 Otsu 阈值(简化实现) int total = width * height; double sumAll = 0; for (int i = 0; i < 256; i++) { sumAll += i * histogram[i]; } double sumB = 0; int weightB = 0; double maxVariance = 0; int threshold = 128; for (int i = 0; i < 256; i++) { weightB += histogram[i]; if (weightB == 0) continue; int weightF = total - weightB; if (weightF == 0) break; sumB += i * histogram[i]; double meanB = sumB / weightB; double meanF = (sumAll - sumB) / weightF; double variance = (double) weightB * weightF * (meanB - meanF) * (meanB - meanF); if (variance > maxVariance) { maxVariance = variance; threshold = i; } } // 应用阈值 BufferedImage binary = new BufferedImage(width, height, BufferedImage.TYPE_BYTE_BINARY); for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { int gray = source.getRGB(x, y) & 0xFF; binary.setRGB(x, y, gray < threshold ? 0x000000 : 0xFFFFFF); } } return binary; } private static BufferedImage resize(BufferedImage source, int maxSize) { int width = source.getWidth(); int height = source.getHeight(); double scale = Math.min(1.0, (double) maxSize / Math.max(width, height)); int newWidth = (int) (width * scale); int newHeight = (int) (height * scale); BufferedImage resized = new BufferedImage(newWidth, newHeight, source.getType()); Graphics2D g = resized.createGraphics(); g.drawImage(source, 0, 0, newWidth, newHeight, null); g.dispose(); return resized; } }逻辑说明:
灰度化用
Graphics2D.drawImage把原图绘制到灰色模式下,一步到位,省去手动遍历像素的复杂度。TYPE_BYTE_GRAY会生成单通道灰度图,Tesseract 处理这种图比彩色图快很多。二值化实现了 Otsu 算法。Otsu 的核心思想是选取一个阈值,使得前景和背景的类间方差最大。代码里遍历所有灰度级,计算每一级的类间方差,取最大值对应的灰度为阈值。对于光照均匀的扫描件,Otsu 的效果稳定可靠;光照不均的图要换自适应阈值,后面会说。
二值化后
TYPE_BYTE_BINARY是黑白二值图。注意这里setRGB的性能不是最优的,对超大图会比较慢,但对 demo 场景足够。如果你要处理上千张图的批任务,建议改用 Raster 批量写入。缩放的目的是控制识别时间。Tesseract 是 CPU 密集型运算,识别 5000px 宽的大图比 2000px 宽要慢 4-5 倍,而精度提升却很有限。通用经验是:文字高度在 30px 以上时,识别率已经比较稳定;超过 2000px 宽度的图片,先缩放到 2000px 以内是划算的。
这个预处理工具类直接解决了我在 2.3 里提到的低对比度问题。加了它之后,同一张暗光扫描件的识别率从 60% 提升到了 90% 左右。
4.2 识别参数调优:语言、PSM、白名单
Tesseract 的识别参数是可控性很强的,调好参数比训练模型更立竿见影。我这个 demo 里默认配置是chi_sim+eng加 PSM 模式 3,但在真实场景里,参数往往要根据图片特征做针对性调整。
页面分割模式(PSM)是最常改的参数。PSM 3 是全自动分割,适合没把握的复杂图片;PSM 6 是把整张图当成一个文本块,适合证件照、简洁的票据;PSM 7 是把图片当成一行文本,适合纯一行字的场景,比如银行回单上的单号。改 PSM 的方式很简单:
// 在 TesseractConfig 中为特定场景创建多个实例 tesseract.setPageSegMode(6); // 单文本块模式,识别简洁票据PSM 参数选错会导致严重的识别质量问题。比如把一张多栏的发票用 PSM 6 识别,Tesseract 会把两栏文字当成一个连续文本,输出结果完全乱套。反过来,如果图片只有一行字却用 PSM 3,可能被当成多段落处理,反而错过换行信息。我的建议是:识别前先根据业务场景预期确定 PSM,不要图省事一个模式走天下。
另一个常用参数是白名单(whitelist)。识别保单号、车牌号这类纯数字内容时,限制字符集能大幅提升准确率。Tesseract 的setVariable接口支持配置字符白名单:
tesseract.setLanguage("eng"); // 车牌号场景只需要英文数字 tesseract.setPageSegMode(7); // 一行文字模式 tesseract.setVariable("tessedit_char_whitelist", "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789");白名单的作用是排除无关字符的干扰。比如车牌号里的 “0” 和 “O”、“8” 和 “B” 容易混淆,如果白名单里同时包含数字和字母,Tesseract 会结合上下文判断;如果白名单只含数字,那字母 “O” 会被强制识别成 0,这既可能是好事也可能坏事——真实世界里的 “O” 也会被误判。所以白名单要谨慎设置,只对字符集确实受限的场景使用。
引擎变量里值得调的还有:
preserve_interword_spaces:设为 1 时保留词间空格,对需要保留格式的文本有用,默认是 0 所以英文单词会粘在一起。tessedit_do_invert:是否自动反转图片黑白。如果扫描件是白字黑底(底片效果),Tesseract 有时能自动处理。
这些参数不是越多越好,每增加一个变量就多一层不确定性。demo 里保持默认,按场景微调。
4.3 返回精度与置信度筛选
doOCR返回的是纯文本字符串。但真实的识别任务里,文本里可能混着低置信度的“幻觉字符”——Tesseract 对自己没把握的区域会猜一个最可能的字符,有时猜得离谱。如果你直接拿结果入库,脏数据就进来了。
Tesseract 的 Java API 提供了获取置信度的方式,通过ResultIterator遍历每个识别区域:
// 获取带置信度的识别结果 ITessAPI.TessPageIteratorLevel level = ITessAPI.TessPageIteratorLevel.RIL_WORD; try (ResultIterator iterator = tesseract.getResultIterator()) { float confidence = iterator.confidence(level); String word = iterator.getUTF8Text(level); System.out.printf("词: %s, 置信度: %.2f%%%n", word, confidence); }置信度阈值一般设置在 50-70 之间。低于阈值的结果建议标记为“人工确认”,不要直接入库。我习惯的做法是:把置信度和识别文本一起返回给前端,前端在低置信度的区域加黄色标记,提醒人工校对。这样即使识别率不够完美,也能保证数据不静默出错。
这里有个容易忽略的细节:getResultIterator必须在doOCR之后立刻调用,且每次doOCR之间是串行的。如果你用同一个 Tesseract 实例处理并发请求,结果迭代器会互相覆盖,后面讲并发问题会细说。
5. 脱离 Demo 化的常见坑:环境、内存与并发问题排查
5.1 识别结果是一串无意义符号或乱码
现象:识别结果不是乱码,而是一串完全无意义的英文字母和数字,比如把中文文字识别成 “aixd” 之类。
原因:最常见的是语言包没有正确加载。运行时如果找不到chi_sim.traineddata,Tesseract 不会报错,而是静默回退到默认英文识别,导致把中文字符当成英文字母形状去猜测。
解决:检查语言包文件是否存在于 DataPath 指定的目录中,文件名必须是chi_sim.traineddata这种格式,不能加前缀或改大小写。再用代码验证当前实际加载了哪些语言:
// 启动时打印实际使用的 DataPath 和可用语言包 String dataPath = tesseract.getDatapath(); File dataDir = new File(dataPath); String[] languages = dataDir.list((dir, name) -> name.endsWith(".traineddata")); System.out.println("DataPath: " + dataPath); System.out.println("语言包: " + Arrays.toString(languages));5.2 Windows 路径下 tessdata 加载失败
现象:在本地 Windows 开发环境识别正常,部署到 Linux 服务器后报java.io.IOException: Cannot find tessdata path。
原因:我在 3.3 里写了new ClassPathResource("tessdata").getFile().getAbsolutePath(),这个方法在 Windows 上能拿到绝对路径,因为开发环境是目录结构;但打包成 jar 后,classpath 资源在 jar 包里面,getFile()会抛异常。我在配置类里加了兜底,但很多教程的写法没有兜底,直接报错。
解决:用临时目录把 classpath 里的语言包释放出来,或者直接用外部文件系统路径。
// 更好的做法:启动时把语言包解压到临时目录 String tmpDir = System.getProperty("java.io.tmpdir") + "/tessdata"; File tessdataDir = new File(tmpDir); if (!tessdataDir.exists()) { tessdataDir.mkdirs(); // 从 classpath 复制语言包到临时目录 extractResource("/tessdata/chi_sim.traineddata", new File(tmpDir, "chi_sim.traineddata")); extractResource("/tessdata/eng.traineddata", new File(tmpDir, "eng.traineddata")); } tesseract.setDatapath(tessdataDir.getAbsolutePath());这个做法把语言包的加载从“依赖运行环境有文件”变成“运行时自动准备”,部署时少一个配置项。代价是每次启动要复制一次文件,消耗不大可以接受。
5.3 并发识别导致内存暴涨或崩溃
现象:接口刚上线时单线程测试一切正常,压测时同时来了几个请求,CPU 飙到 100%,内存飙升,偶尔 OOM。
原因:Tesseract 实例不是线程安全的。每个doOCR调用都会加载语言模型到内存,默认的 Tesseract 配置会在整个生命周期复用模型,但同时有多个线程调用时,引擎内部状态会互相污染,表现为内存和 CPU 异常高涨。
解决:用信号量限制并发数,或者为每个线程创建独立实例。我选的是简单有效的限流方案:
@Service public class OcrServiceImpl implements OcrService { // 限制同时最多 2 个识别任务 private final Semaphore semaphore = new Semaphore(2); private final Tesseract tesseract; @Override public OcrResult recognize(MultipartFile file) { try { semaphore.acquire(); // 执行识别逻辑 return doRecognize(file); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException("识别任务被中断", e); } finally { semaphore.release(); } } }并发数设置建议根据 CPU 核数来定。经验值是CPU 核数 - 1,因为 Tesseract 单次识别就吃满一个核,如果服务器是 4 核,同时 3 个识别任务是合理上限。超过这个数,任务排队比并行更划算,因为并行反而因上下文切换拖慢速度。
5.4 图片太暗导致识别结果为空或错误
现象:输入一张灰色背景、深灰色文字的扫描件,返回结果是空字符串或者只有零星几个字符。
原因:原图对比度过低,字符笔画与背景的灰度差太小,二值化时把前景和背景统一变成了黑色或白色,文字信息丢失。
解决:预处理时先用Scale或直方图均衡化拉伸对比度,再走二值化。我在 ImagePreprocessor 里选的 Otsu 阈值自动计算,但对对比度极低的图,需要先做一步对比度增强:
// 对比度线性拉伸 private static BufferedImage enhanceContrast(BufferedImage source) { int width = source.getWidth(); int height = source.getHeight(); // 找出像素灰度最小值 min 和最大值 max int min = 255, max = 0; for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { int gray = source.getRGB(x, y) & 0xFF; min = Math.min(min, gray); max = Math.max(max, gray); } } int range = max - min; if (range < 30) { // 对比度太低,拉伸到 [0, 255] BufferedImage enhanced = new BufferedImage(width, height, BufferedImage.TYPE_BYTE_GRAY); for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { int gray = source.getRGB(x, y) & 0xFF; int newGray = (int) ((gray - min) * 255.0 / range); enhanced.setRGB(x, y, newGray); } } return enhanced; } return source; }这段代码的逻辑是:如果图像灰度范围小于 30,说明对比度极差,做一次线性拉伸把范围扩大到 0-255,再交给二值化。加了这一步后,暗光场景的识别率明显回升。如果你怕这个逻辑太暴力,也可以用gamma校正来提亮中间调,但线性拉伸最简单直观。
5.5 临时文件堆积导致磁盘满
现象:连续识别几百张图后,服务器/tmp目录下积累了大量ocr_开头的临时文件,磁盘空间被吃掉几个 G。
原因:我在 3.4 里只删了原始上传文件,但预处理生成的ocr_processed_*.png没有清理;加上如果识别抛异常中断在 preprocess 和 doOCR 之间,原文件也不会被清理。再有就是File.createTempFile创建的文件默认不会被自动回收,需要程序自己负责删除。
解决:把清理逻辑覆盖处理文件和失败路径。
File tempFile = null; File processedFile = null; try { tempFile = File.createTempFile("ocr_", ".tmp"); file.transferTo(tempFile); processedFile = ImagePreprocessor.preprocess(tempFile); String result = tesseract.doOCR(processedFile); return new OcrResult(result, ...); } catch (...) { ... } finally { // 两个临时文件都清理 deleteQuietly(tempFile); deleteQuietly(processedFile); }这里我踩过一次雷:当时只删tempFile,不删processedFile,跑了一晚上批处理后/tmp被写满了,差点把系统搞挂。从那以后,我处理任何文件类操作都会把“创建了什么临时资源”列个清单,在 finally 里逐个清干净。识别任务频繁时还可以加一个定时清理任务兜底,但那是后话。
6. 把 Demo 变成可用的工程工具:异步识别与批处理技巧
6.1 异步化改造:先返回任务 ID 再出结果
第一版接口是同步的,一张 2MB 的扫描件识别耗时 3 到 5 秒,HTTP 连接要保持 5 秒,调用方体验很差。更麻烦的是,如果识别任务多,同步接口很容易把 Tomcat 的线程池占满。
改造思路是标准答案:提交任务后立刻返回taskId,后台线程池执行识别,调用方用taskId轮询结果或等回调。这个方案在 demo 里加的核心点:
- 线程池管理识别任务的执行(
@Async或手动ExecutorService) - 任务状态存储(内存 Map 足够,不用引 Redis)
- 一个查询接口返回识别状态和结果
异步化的好处是接口响应从 5 秒降到 50 毫秒,调用方不会被长连接拖死,而且并发控制可以直接用线程池的大小决定,比Semaphore更优雅。
6.2 批量处理时的倾斜校正技巧
批处理时最大的敌人是图片倾斜。扫描仪的进纸稍微歪一点,文字就倾斜 5-10 度,Tesseract 的识别率会断崖式下跌。批量场景下不可能手动调整每张图,我一般用 OpenCV 的霍夫变换检测文本行的倾斜角度,然后做旋转校正。这里不展开算法细节,只给思路:把图像灰度化后做边缘检测,检测到的主直线角度就是文字的倾斜角,用旋转矩阵把图片转正,再交给识别。这套流程在 demo 里可以作为预处理阶段的选项,对倾斜明显的老扫描件效果立竿见影。
6.3 自定义词库的最后一公里
如果识别内容是特定领域的专有名词,比如设备型号、产品编号,Tesseract 的词库错误会频繁出现。好在它支持用户自定义词库,把常见词汇加到白名单或词典中,可以明显改善识别倾向。我有一次做一个化工单据识别项目,里面全是XX-XX-XX格式的材料编号,Tesseract 默认词库完全不认识,加了两百个自定义词之后,识别率从 75% 提升到了 88%。
做法是把需要的词写进文本文件,用 Tesseract 的word_list变量加载:
tesseract.setVariable("user_words_suffix", "user-words"); // 把 user-words.txt 放在 tessdata 目录下,内容每行一个词这个用法是网上最容易找不到资料的角落,但实际项目里靠它救过不少次。
回到最开头那个内部系统。第一次把识别接口挂上去时,我们用的是同步调用、不预处理、默认参数,验收时准确率只有七成。后来加了 Otsu 预处理、调了 PSM、按场景配了语言包和字符集,准确率上了九成,又做了异步化和并发限流,才敢把流程跑起来。从那以后我每次搭识别类功能,都会强制走一遍“原始图识别一次 → 预处理后识别一次 → 对比结果”的验证流程,用两次识别差异来判断预处理是否有效,而不是凭感觉调参数。这个方法简单但极其好用,识别类项目的坑大多出在“以为图片没问题”。希望这篇工程笔记能帮你在集成 OCR 时把该踩的坑提前避开。
本文还有配套的精品资源,点击获取