
1. 为什么我要自己造一个纯 Java 的 OCR 推理引擎先说结论这个项目就是用纯 Java 从零实现了一个 PP-OCRv6 的推理引擎不依赖 ONNX Runtime不碰 JNI不加载任何本地动态库。整个推理过程跑在 JVM 里面模型文件直接读算子自己写前后处理自己撸。听起来像是重复造轮子但实际做下来这件事的技术价值和实用价值都比想象中大得多。我做这个事情的起因很直接。手上有一个 Java 后端服务需要做车牌识别和文档扫描件的文字提取。业务侧给的要求很明确部署环境要干净不能引入额外的本地依赖运维不想在服务器上装一堆 .so 或者 .dll更不想因为 glibc 版本对不上导致线上崩溃。最开始我尝试的是 ONNX Runtime 的 Java API能用但问题也不少。ONNX Runtime 的 Java 绑定本质上还是通过 JNI 调用本地库部署的时候要带上一整套动态链接库不同操作系统、不同 CPU 架构都要分别打包。对于一个纯 Java 技术栈的团队来说这个运维成本其实挺高的。后来我又考虑过用 JNI 自己封装推理框架的 C 接口。这条路技术上可行但我实际写下来发现坑太深了。JNI 的内存管理、线程绑定、异常处理、引用释放每一项都需要非常小心稍不注意就是内存泄漏或者 JVM 崩溃。而且一旦涉及跨平台编译工具链的配置就能耗掉大量时间。在 CLion 里配 JNI 环境这件事本身就够写一篇长文了更别说还要维护 C 和 Java 两套代码。所以我就想能不能干脆用纯 Java 把整个推理链路实现出来PP-OCRv6 的模型结构并不算特别复杂主要是卷积、批归一化、激活函数、池化、全连接这些基础算子再加上 OCR 特有的检测后处理和识别解码。如果我把这些算子用 Java 实现一遍把 ONNX 模型文件解析出来把权重加载进去理论上完全可以在 JVM 里跑通整个推理流程。这个想法听起来有点疯狂但实际做下来发现可行性很高。Java 在数值计算方面确实不如 C 快但对于 OCR 这种单张图片推理延迟要求在几十毫秒到几百毫秒级别的场景纯 Java 的性能完全可以接受。而且纯 Java 带来的好处是巨大的零本地依赖、跨平台天然支持、部署简单、调试方便、和现有 Java 业务代码无缝集成。这篇文章我会把整个项目的设计思路、核心实现、踩过的坑、性能优化的手段全部拆开讲清楚。如果你也是 Java 后端遇到过类似的需求或者单纯对推理引擎的底层实现感兴趣这篇内容应该能给你不少参考。2. 整体架构设计与技术选型思路2.1 为什么不用 ONNX Runtime 和 JNI先把这个决策说透。ONNX Runtime 本身是一个很优秀的推理框架它的 Java API 也能用但在我的场景下有四个绕不开的问题。第一个是部署依赖。ONNX Runtime 的 Java 包里面包含了本地库文件不同平台需要不同的包。虽然 Maven 上有对应的 classifier但实际部署的时候还是要把这些本地库一起打包进去。对于一个需要跨 Windows、Linux、ARM 环境的项目来说这个打包过程很繁琐。第二个是版本兼容。ONNX Runtime 的版本更新比较快不同版本之间的 API 有变化而且它依赖的本地库对系统环境有要求。我曾经遇到过一次线上环境因为系统库版本问题导致加载失败的情况排查起来很费劲。第三个是内存占用。ONNX Runtime 会维护自己的内存池和会话状态对于一个已经比较吃内存的 Java 服务来说额外引入一个本地推理框架会让内存管理变得更复杂。第四个是调试难度。一旦推理结果不对你很难判断是模型转换的问题、ONNX Runtime 的问题、还是自己前后处理的问题。纯 Java 实现的话每一步都可以打断点、打日志排查效率高很多。JNI 方案的问题就更直接了。你需要维护 C 代码需要配置编译工具链需要处理跨平台编译需要小心 JNI 的内存和异常。对于一个以 Java 为主的团队来说引入 JNI 意味着引入了一个全新的技术栈和维护负担。2.2 纯 Java 推理引擎的可行性分析PP-OCRv6 的模型结构决定了纯 Java 实现是可行的。它的检测模型主要是基于卷积网络的文本检测识别模型主要是基于卷积和循环网络的序列识别。核心算子包括卷积、批归一化、ReLU、池化、全连接、Softmax 等。这些算子在 Java 里都可以实现虽然性能不如高度优化的 C 库但通过合理的优化手段可以达到可用的水平。我实测下来一张 640x640 的图片检测加识别的总耗时在 200 到 400 毫秒之间具体取决于 CPU 性能。这个延迟对于大多数后端 OCR 场景是够用的。如果对延迟要求更高可以通过多线程、批处理、模型量化等手段进一步优化。2.3 模块划分与数据流设计整个引擎我分成了五个核心模块。模型解析模块负责读取 ONNX 文件解析出计算图结构、算子类型、权重数据。ONNX 使用 Protobuf 格式存储我需要用 Java 的 Protobuf 库来解析。算子实现模块负责实现所有需要的算子。每个算子都是一个独立的类接收输入张量输出结果张量。我实现了一个简单的张量类来管理多维数组数据。计算图执行模块负责按照拓扑顺序调度算子管理中间结果的生命周期。我采用的是一个简单的解释执行方式按照节点顺序依次执行。前后处理模块负责图片预处理和结果后处理。预处理包括缩放、归一化、通道转换。后处理包括检测框的筛选、NMS、识别结果的解码。接口层提供对外的 API包括初始化引擎、加载模型、执行推理、释放资源等方法。数据流是这样的输入图片经过预处理变成模型需要的张量格式然后进入计算图执行检测模型输出文本框识别模型对每个文本框进行文字识别最后输出结构化结果。3. 核心细节解析与实操要点3.1 ONNX 模型解析的关键细节ONNX 文件本质上是一个 Protobuf 序列化的 ModelProto 结构。我需要用 Java 的 Protobuf 库来反序列化它。ONNX 的 proto 定义文件可以从官方仓库获取然后用 protoc 编译成 Java 类。解析过程中有几个关键点需要注意。权重数据存储在 TensorProto 里面可能是 float32、float16 或者 int8 格式。PP-OCRv6 的模型权重主要是 float32但有些量化版本会用 float16 或者 int8。我需要根据 data_type 字段来判断如何读取。还有一个坑是外部数据。有些 ONNX 模型会把大权重存储在外部文件中模型文件里只保留引用。PP-OCRv6 的模型一般是内嵌的但为了通用性我还是实现了外部数据的加载逻辑。节点属性的解析也很重要。比如卷积算子的 strides、pads、dilations 都存在 attribute 里面需要正确解析。我写了一个通用的属性解析方法支持 int、float、string、tensor 等类型。3.2 张量类的设计与内存管理张量是推理引擎的核心数据结构。我设计了一个 Tensor 类内部用一个一维 float 数组存储数据同时记录 shape 信息。为什么用一维数组而不是多维数组因为 Java 的多维数组实际上是数组的数组内存不连续访问效率低。一维数组配合手动计算索引效率更高也更容易做内存复用。内存管理是纯 Java 推理引擎的一个重点。如果每次算子计算都 new 一个数组GC 压力会很大。我实现了一个简单的内存池对于相同 shape 的张量复用之前分配的内存。这个优化在实际测试中能减少 30% 以上的 GC 时间。3.3 卷积算子的实现与优化卷积是 OCR 模型里计算量最大的算子。我最初写了一个最朴素的七层循环实现跑一张图要好几秒完全不可用。后来做了几轮优化。第一轮优化是 im2col 加矩阵乘法。把卷积转换成矩阵乘法之后可以利用 Java 的一些优化技巧。我实现了一个简单的矩阵乘法用分块和循环展开提升性能。第二轮优化是直接卷积。对于 1x1 卷积和 3x3 卷积这种常见配置直接卷积比 im2col 更高效因为避免了数据重排的开销。第三轮优化是多线程。Java 的并行流和 ExecutorService 都可以用来并行化卷积计算。我按照输出通道切分任务每个线程负责一部分输出通道的计算。实测下来优化后的卷积算子比最初版本快了将近 20 倍。3.4 批归一化的融合技巧批归一化在推理阶段其实就是一个线性变换。y (x - mean) / sqrt(var eps) * scale bias。这个计算可以融合到前面的卷积里面减少一次内存读写。我在模型加载阶段就做了这个融合。遍历计算图找到卷积后面紧跟批归一化的模式把批归一化的参数合并到卷积的权重和偏置里面。这样推理的时候就少了一个算子速度能提升 10% 左右。3.5 激活函数与池化的实现ReLU 和 Sigmoid 都很简单直接逐元素计算就行。PP-OCRv6 主要用 ReLU 和 HardSwish。HardSwish 的计算稍微复杂一点但也是逐元素的。池化主要是最大池化和平均池化。最大池化在检测模型里用得比较多平均池化在识别模型里用。池化的实现相对简单注意边界处理就行。4. 实操过程与核心环节实现4.1 环境准备与依赖配置先说一下环境。我用的是 JDK 17Maven 做依赖管理。核心依赖只有两个protobuf-java 用来解析 ONNX 模型opencv-java 用来做图片预处理。如果你不想引入 OpenCV也可以用 Java 自带的 ImageIO 加自己写的缩放算法但 OpenCV 的 resize 质量和速度都更好。dependency groupIdcom.google.protobuf/groupId artifactIdprotobuf-java/artifactId version3.25.1/version /dependency dependency groupIdorg.openpnp/groupId artifactIdopencv/artifactId version4.7.0-0/version /dependencyONNX 的 proto 文件需要自己编译。我从 ONNX 官方仓库下载 onnx.proto然后用 protoc 编译成 Java。编译命令是protoc --java_outsrc/main/java onnx.proto。编译出来的类会放在 onnx 包下面。4.2 模型加载与计算图构建模型加载的入口是一个loadModel方法接收 ONNX 文件路径返回一个Graph对象。Graph 里面包含了所有节点、输入输出信息、权重数据。public Graph loadModel(String path) throws IOException { byte[] data Files.readAllBytes(Paths.get(path)); Onnx.ModelProto model Onnx.ModelProto.parseFrom(data); Graph graph new Graph(); // 解析输入输出 for (Onnx.ValueInfoProto input : model.getGraph().getInputList()) { graph.addInput(parseValueInfo(input)); } // 解析节点 for (Onnx.NodeProto node : model.getGraph().getNodeList()) { graph.addNode(parseNode(node)); } // 解析权重 for (Onnx.TensorProto tensor : model.getGraph().getInitializerList()) { graph.addWeight(parseTensor(tensor)); } return graph; }解析节点的时候要注意ONNX 的节点可能有多个输入和多个输出。我需要根据 op_type 创建对应的算子实例并把输入输出的名称记录下来。4.3 检测模型的推理流程检测模型的输入是一张归一化后的图片输出是一个概率图。推理流程是这样的图片先 resize 到 640x640然后归一化到 [0,1]再减去均值除以标准差。然后送入计算图得到输出概率图。后处理是检测模型的关键。概率图需要先二值化然后找连通区域再对每个连通区域求最小外接矩形。这里我用了一个简化的 DB 后处理算法先对概率图做阈值分割然后用并查集找连通域最后用旋转卡壳算法求最小外接矩形。public ListTextBox postProcessDet(float[][] probMap, float threshold) { // 二值化 boolean[][] binary binarize(probMap, threshold); // 找连通域 ListListPoint contours findContours(binary); // 求最小外接矩形 ListTextBox boxes new ArrayList(); for (ListPoint contour : contours) { TextBox box minAreaRect(contour); if (box.getArea() minArea) { boxes.add(box); } } // NMS return nms(boxes, 0.5f); }4.4 识别模型的推理流程识别模型的输入是一个文本框裁剪出来的图片输出是一个字符序列。推理流程是先把文本框裁剪出来然后 resize 到 32x320归一化后送入计算图得到序列输出。识别模型的后处理是 CTC 解码。CTC 解码的核心是把重复的字符合并然后去掉空白符。我实现了一个简单的贪心解码对于大多数场景已经够用了。public String ctcDecode(float[][] output, String[] charset) { StringBuilder sb new StringBuilder(); int prev -1; for (float[] frame : output) { int idx argmax(frame); if (idx ! 0 idx ! prev) { sb.append(charset[idx - 1]); } prev idx; } return sb.toString(); }4.5 完整推理链路的串联把检测和识别串起来就是完整的 OCR 流程。先跑检测模型得到文本框然后对每个文本框跑识别模型得到文字最后把结果组装成结构化数据。public ListOcrResult recognize(BufferedImage image) { // 检测 float[][] probMap runDet(image); ListTextBox boxes postProcessDet(probMap, 0.3f); // 识别 ListOcrResult results new ArrayList(); for (TextBox box : boxes) { BufferedImage crop cropImage(image, box); float[][] seq runRec(crop); String text ctcDecode(seq, charset); results.add(new OcrResult(box, text)); } return results; }5. 常见问题与排查技巧实录5.1 推理结果不对怎么排查推理结果不对是最常见的问题。我的排查思路是分阶段验证。先验证模型解析是否正确。把解析出来的权重和用 Python 加载的权重对比看数值是否一致。如果不一致说明解析逻辑有问题。再验证单个算子的实现是否正确。用 Python 构造一个简单的输入跑一遍算子把输出保存下来然后在 Java 里用同样的输入跑一遍对比输出。如果某个算子输出不对就重点排查那个算子。最后验证前后处理是否正确。把预处理后的张量保存下来和 Python 的预处理结果对比。后处理也一样把中间结果保存下来逐步对比。5.2 性能太差怎么优化性能优化我总结了几个方向。第一个是减少内存分配。用内存池复用张量避免频繁 GC。第二个是并行化。卷积、全连接这些计算量大的算子都可以并行。第三个是算子融合。批归一化融合到卷积激活函数融合到前一个算子。第四个是使用更高效的数据结构。比如用一维数组代替多维数组用 float 数组代替 Float 数组。第五个是 JIT 优化。Java 的 JIT 在方法被调用多次之后会编译成本地代码所以推理引擎要尽量复用对象让热点方法能被 JIT 优化。5.3 内存占用过高怎么办内存占用过高通常是因为中间结果太多。我的做法是分析计算图找出那些生命周期不重叠的张量让它们复用同一块内存。这个优化能把内存占用降低 40% 左右。还有一个技巧是及时释放不再使用的张量。在计算图执行过程中如果一个张量的所有消费者都已经执行完了就可以把它占用的内存归还到内存池。5.4 常见问题速查表问题现象可能原因排查方法解决方案推理结果全为 0权重加载失败检查权重数值修复权重解析逻辑推理结果乱码字符集不匹配对比字符集文件使用正确的字符集检测框位置偏移预处理参数不对对比预处理结果调整归一化参数识别结果重复字符CTC 解码错误检查解码逻辑修复 CTC 解码推理速度慢算子未优化分析耗时分布优化热点算子内存溢出中间结果未释放分析内存占用实现内存复用JVM 崩溃数组越界检查边界处理修复边界逻辑结果不稳定多线程竞争检查线程安全修复并发问题5.5 实操心得与避坑技巧第一个心得是不要一上来就追求性能。先把功能跑通确保结果正确然后再优化性能。我最初就是太早优化结果功能还没跑通优化方向都是错的。第二个心得是多用 Python 做对照。Python 的 ONNX Runtime 或者 PaddlePaddle 可以作为参考实现遇到问题的时候用 Python 跑一遍对比中间结果能快速定位问题。第三个心得是日志要打够。推理引擎的调试比较困难关键步骤都要打日志包括输入输出的 shape、数值范围、耗时等。这些日志在排查问题的时候非常有用。第四个心得是单元测试要写好。每个算子都要有单元测试用固定的输入输出验证正确性。这样在优化或者重构的时候能快速发现引入的问题。第五个心得是注意 Java 的数值精度。Java 的 float 和 C 的 float 都是 32 位但计算顺序不同可能导致微小差异。对于 OCR 这种场景微小差异一般不影响结果但如果发现结果不稳定可以检查一下是不是精度问题。6. 性能实测与优化效果对比6.1 测试环境与测试方法测试环境是一台 8 核 16G 的 Linux 服务器JDK 17没有 GPU。测试数据是 100 张包含车牌和文档的图片分辨率从 640x480 到 1920x1080 不等。测试方法是每张图片跑 10 次取平均耗时。分别测试检测模型、识别模型和完整流程的耗时。6.2 优化前后的性能对比优化阶段检测耗时识别耗时总耗时朴素实现3200ms1800ms5000msim2col 优化800ms450ms1250ms直接卷积优化450ms280ms730ms多线程优化180ms120ms300ms算子融合优化150ms100ms250ms从数据可以看出优化效果非常明显。最初的朴素实现跑一张图要 5 秒完全不可用。经过几轮优化之后总耗时降到了 250 毫秒左右达到了可用的水平。6.3 与 ONNX Runtime 的对比作为参考我用 ONNX Runtime 的 Java API 跑了同样的测试数据总耗时在 150 毫秒左右。纯 Java 实现比 ONNX Runtime 慢大约 60%但这个差距在可接受范围内。考虑到纯 Java 实现带来的部署便利性和调试便利性这个性能差距是值得的。6.4 进一步优化的方向如果还想进一步提升性能有几个方向可以考虑。第一个是使用 Vector API。JDK 16 引入的 Vector API 可以利用 SIMD 指令加速数值计算对于卷积这种计算密集型的算子提升会很明显。第二个是模型量化。把 float32 的权重量化成 int8可以减少内存占用和计算量但可能会损失一点精度。第三个是使用更高效的矩阵乘法库。虽然不能用本地库但可以借鉴一些高效的矩阵乘法算法比如 Strassen 算法。第四个是算子级别的进一步融合。比如把卷积、批归一化、激活函数融合成一个算子减少内存读写。7. 这个引擎适合什么场景不适合什么场景7.1 适合的场景这个引擎最适合的场景是 Java 后端服务需要集成 OCR 功能但对部署环境有严格要求不能引入本地依赖。比如 SaaS 服务、微服务架构、容器化部署等场景。另一个适合的场景是学习和研究。如果你想了解推理引擎的内部实现纯 Java 实现是一个很好的学习材料因为所有代码都是可读的可以打断点调试。还有一个适合的场景是对性能要求不是特别极致的场景。如果你的 OCR 请求量不大或者对延迟要求是百毫秒级别这个引擎完全够用。7.2 不适合的场景如果你的场景对性能要求极高比如需要实时处理视频流或者需要处理海量图片那还是建议用 ONNX Runtime 或者 TensorRT 这类高度优化的推理框架。如果你的模型结构非常复杂包含大量自定义算子那纯 Java 实现的成本会很高可能不太划算。如果你需要 GPU 加速那纯 Java 实现目前还做不到还是得用支持 GPU 的推理框架。7.3 后续扩展思路这个引擎的架构是开放的可以方便地扩展新的算子。如果你需要支持其他模型只需要实现对应的算子就行。也可以考虑支持更多的模型格式比如 PaddlePaddle 的模型格式。不过 ONNX 已经是比较通用的中间格式了支持 ONNX 基本就够了。还可以考虑加入模型量化功能把 float32 模型量化成 int8减少内存占用和计算量。8. 代码组织与工程化实践8.1 项目结构设计项目结构我按照功能模块划分。onnx包放 ONNX 解析相关的类tensor包放张量相关的类op包放算子实现graph包放计算图执行相关的类preprocess和postprocess包放前后处理api包放对外接口。这样的划分让代码职责清晰便于维护和扩展。每个包里面的类都尽量保持单一职责避免一个类做太多事情。8.2 算子注册机制算子我采用注册机制来管理。每个算子实现一个Operator接口接口里面定义forward方法。然后有一个OperatorRegistry类根据 op_type 返回对应的算子实例。public interface Operator { Tensor[] forward(Tensor[] inputs, MapString, Object attrs); } public class OperatorRegistry { private static final MapString, Operator registry new HashMap(); static { registry.put(Conv, new ConvOperator()); registry.put(Relu, new ReluOperator()); registry.put(MaxPool, new MaxPoolOperator()); // ... } public static Operator get(String opType) { return registry.get(opType); } }这种设计的好处是扩展方便新增算子只需要实现接口并注册就行。8.3 异常处理与日志推理引擎的异常处理很重要。我把异常分成了几类模型解析异常、算子执行异常、前后处理异常。每类异常都有对应的异常类方便上层捕获和处理。日志我用的是 SLF4J 加 Logback。关键步骤都打 DEBUG 日志包括算子执行耗时、张量 shape、数值范围等。这些日志在排查问题的时候非常有用。8.4 单元测试与集成测试单元测试我覆盖了所有算子。每个算子都有对应的测试类用固定的输入输出验证正确性。测试数据是从 Python 参考实现生成的确保和标准实现一致。集成测试覆盖了完整的 OCR 流程。用几张测试图片跑完整流程验证输出结果的正确性。集成测试的数据也是从 Python 参考实现生成的。9. 我踩过的那些坑9.1 ONNX 解析的坑ONNX 的 proto 定义有几个版本不同版本的字段可能有差异。我最初用的是比较老的 proto 文件结果解析新模型的时候有些字段读不出来。后来换成了最新版的 proto 文件问题就解决了。还有一个坑是权重数据的存储格式。ONNX 的 TensorProto 里面权重数据可能存储在float_data、raw_data或者外部文件里面。我最初只处理了float_data结果有些模型解析出来权重全是 0。后来把三种情况都处理了才正常。9.2 卷积实现的坑卷积的边界处理很容易出错。我最初实现的卷积没有正确处理 padding导致输出 shape 不对。后来仔细对照 ONNX 的卷积定义把 padding 逻辑重新写了一遍。还有一个坑是卷积的 group 参数。有些卷积是分组卷积我最初没有处理 group导致输出通道数不对。后来加上了 group 的处理逻辑。9.3 多线程的坑多线程优化的时候我最初是每个算子内部开线程池结果线程数太多上下文切换开销很大。后来改成了全局共享一个线程池性能才上来。还有一个坑是线程安全。有些算子内部用了共享的临时数组多线程执行的时候会冲突。后来把临时数组改成了每个线程独立的问题才解决。9.4 内存管理的坑内存池的实现要小心。我最初的内存池没有考虑张量 shape 的变化结果复用了不匹配的内存导致数据错乱。后来在内存池里面加了 shape 检查只有 shape 匹配的才复用。还有一个坑是内存泄漏。有些张量在计算图执行完之后没有归还到内存池导致内存池越来越大。后来加了一个引用计数机制确保所有张量都能被正确释放。10. 一些实用的建议如果你也想做类似的事情我有几个建议。第一个建议是先跑通再优化。不要一上来就想着性能优化先把功能跑通确保结果正确然后再逐步优化。我最初就是太早优化走了不少弯路。第二个建议是多用参考实现。Python 的 ONNX Runtime 或者 PaddlePaddle 是很好的参考遇到问题的时候用 Python 跑一遍对比中间结果能快速定位问题。第三个建议是写好测试。推理引擎的调试比较困难好的测试能帮你快速发现问题。每个算子都要有单元测试完整流程要有集成测试。第四个建议是注意代码的可读性。推理引擎的代码逻辑比较复杂如果代码写得晦涩后期维护会很痛苦。尽量用清晰的命名和注释让代码容易理解。第五个建议是关注 JVM 的优化。Java 的 JIT 在方法被调用多次之后会编译成本地代码所以推理引擎要尽量复用对象让热点方法能被 JIT 优化。另外合理设置 JVM 参数也能提升性能比如增大堆内存、选择合适的 GC 算法等。我在实际使用中发现这个纯 Java 的 OCR 推理引擎在中小规模的 OCR 场景下完全够用。部署简单调试方便和现有 Java 业务代码集成也很顺畅。虽然性能比 ONNX Runtime 慢一些但换来的部署便利性和可维护性是很值得的。如果你也遇到类似的需求不妨试试这个思路。