
我在做项目时遇到过一个很现实的痛点业务方丢过来一堆扫描版表格里面有成绩单、订单明细、财务报表要求系统自动把里面的数据抽出来还得能落到 Excel 里给客户下载。光做文字 OCR 远远不够因为表格的难点在于结构一行一列怎么对、跨行跨列怎么合并都比单纯识别一串字符麻烦得多。当时我选了 Java PaddleOCR 这条技术路线跑通之后效果不错还能直接导出 HTML 和 Excel。这篇文章就把这套方案完整拆开讲一遍包括 Python 侧的识别服务怎么封装、Java 侧怎么调用和解析、HTML 和 Excel 导出的细节以及我在真实项目里踩过的坑。这篇内容适合有 Java 后端基础、想在自己的系统里集成表格识别能力的开发者。不管你是要做合同识别、票据结构化还是想把老旧纸质表格电子化都可以参考这套实现思路。1. 为什么Java 表格识别这么折腾先想清楚方案再动手1.1 表格识别重点不是 OCR而是恢复结构先说清楚一个概念常规 OCR 解决的是图里有哪几个字表格识别解决的是这些字分别属于哪一行哪一列。比如下面这张成绩表学号姓名语文数学总分001张三9095185002李四8892180如果只做 OCR拿到的是一堆散落的文字学号姓名001张三……你根本不知道张三是姓名列还是语文列。表格识别要做两件事第一检测出表格区域第二根据表格线或者行列分布把每个单元格的文字映射到对应的行和列上。PaddleOCR 的 PP-Structure 系列模型做的就是这个事它输出的标准格式是一段 HTML 表格里面有table、tr、td这些结构化标签数据天然就是对齐的。这也是我为什么在标题里强调支持导出 HTML 和 Excel。HTML 是 PaddleOCR 表格识别结果的天然载体拿到 HTML 之后转 Excel 只是另一层解析工作。整个链路其实是图片 → 表格结构识别 → HTML → Excel。1.2 Java 集成 PaddleOCR 的三条路线对比PaddleOCR 官方主力语言是 PythonJava 生态里没有官方维护的、同样好用的表格识别 SDK。所以Java 怎么调 PaddleOCR这个问题本质上是一个跨语言集成问题。我梳理过三条路线各有优劣方案实现方式优点缺点适合场景REST 服务Python 封装识别服务Java 通过 HTTP 调用语言解耦模型常驻内存识别快需要多部署一个 Python 服务生产环境首选本地进程调用Java 用 ProcessBuilder 启动 Python 脚本不需要额外服务每次启动 Python 解释器代价大模型重复加载一次性脚本、离线工具JNI/JavaCPP 直调通过 JNI 绑定 C 推理库性能最高编译复杂依赖不好维护极少见不推荐我最终选了 REST 服务方案核心考量有三个。第一模型首次加载要好几秒如果每次识别都重新加载一次体验完全不可接受REST 方案里 Python 进程常驻模型只加载一次后续请求只花推理时间。第二Java 和 Python 通过 HTTP JSON 通信数据结构一目了然出了问题时也好排查。第三后续如果要把识别服务拆分出去、水平扩容REST 天然支持。2. 搭建识别服务Python 侧把 PaddleOCR 包装成可用接口2.1 环境安装与模型下载含 GPU 版本选择先说安装。PaddleOCR 3.x 之后的版本体验好了很多核心就一个命令pip install paddleocr如果你要用 GPU 推理需要先安装匹配 CUDA 版本的 paddlepaddle-gpu# CUDA 11.8 示例 python -m pip install paddlepaddle-gpu3.0.0 -i https://www.paddlepaddle.org.cn/packages/stable/cu118/这里有个很关键的教训GPU 版本必须和本机 CUDA 版本匹配否则安装好后一跑就报错。我之前在一台 CUDA 版本是 12.0 的机器上强行装了 cu118 的包运行时报了一堆算子不匹配的错误查了半天才发现是版本不匹配。装完之后第一次加载模型时 PaddleOCR 会从官方模型仓库自动下载模型文件。如果下载速度不理想可以设置环境变量把模型下载源指到国内镜像或者手动把模型文件放到本地的 paddle 缓存目录。缓存目录一般在用户目录下的.paddlex/official_models里手动放模型的时候注意按模型名建目录目录名必须和默认一致否则识别不到。建议第一次做的时候先用 CPU 版本把整条链路跑通确认识别结果没问题之后再考虑上 GPU。CPU 版在普通服务器上识别一张表格大概 1-3 秒GPU 能压缩到几百毫秒但引入的依赖复杂度一下子高很多。链路通了之后再优化性能是我比较推荐的做法。2.2 Flask 接口封装与返回结构约定我用 Flask 做了一个极简的识别接口。核心逻辑不多最要紧的是模型全局唯一不能每次请求都PPStructureV3()一次否则模型重复加载接口必然超时。# -*- coding: utf-8 -*- from flask import Flask, request, jsonify from paddleocr import PPStructureV3 import os import uuid app Flask(__name__) engine None def get_engine(): global engine if engine is None: engine PPStructureV3(tableTrue, ocrTrue, langch) return engine app.route(/table/recognize, methods[POST]) def recognize(): file request.files.get(image) if file is None: return jsonify({code: 400, msg: image is required}) suffix os.path.splitext(file.filename)[-1] tmp_path f/tmp/{uuid.uuid4().hex}{suffix} file.save(tmp_path) try: result get_engine().predict(tmp_path) tables extract_tables(result) return jsonify({code: 0, tables: tables}) except Exception as e: return jsonify({code: 500, msg: str(e)}) finally: if os.path.exists(tmp_path): os.remove(tmp_path) if __name__ __main__: app.run(host0.0.0.0, port8866, debugFalse)这里有个细节大家务必注意debugFalse不能省。Flask 的 debug 模式会启动两个进程一个 reloader 进程 一个业务进程模型会被加载两次既占资源又容易出奇怪问题。我第一次联调时就是开着 debug结果发现显存直接翻倍排查了很久才反应过来。2.3 解析不同版本 PPStructure 输出的兼容技巧PPStructureV3的返回结构在不同版本里不太一样。有的版本直接能拿到region[res][html]有的版本 res 字段嵌套层级不同还有的版本字段名从rec变成了别的名字。如果你照着网上旧教程写解析代码很可能在新版本上拿到空结果。我写了一个递归遍历函数来兼容这种情况核心思路是把整个返回结果当成一棵树深度优先查找type table的节点再去它的res下面找html字段def extract_tables(result): tables [] def walk(node): if isinstance(node, dict): if node.get(type) table: res node.get(res) if isinstance(res, dict): html res.get(html) if html: tables.append({ html: html, score: node.get(score, 1.0) }) for v in node.values(): walk(v) elif isinstance(node, list): for item in node: walk(item) walk(result) return tables递归遍历的好处是不管返回结构怎么变只要table节点上带着html就能被找出来。我在真实项目里拿这个函数兼容过 PaddleOCR 2.x 和 3.x 两套输出基本不用改。这个 Python 服务跑起来之后你可以先用 curl 快速验证curl -X POST http://localhost:8866/table/recognize \ -F imagetest_table.png看到返回 JSON 里有html字段说明识别服务已经通了接下来就是 Java 侧的事了。3. Java 侧调用上传图片、拿回 HTML3.1 Spring Boot 项目的 HTTP 调用封装Java 侧我用 Spring Boot 标准做法接收前端上传的图片然后调用 Python 识别服务把结果里的 HTML 解析出来。调用 Python 服务这一层我用RestTemplate就够用Service public class TableRecognizeService { private final RestTemplate restTemplate new RestTemplate(); public ListString recognizeTable(MultipartFile file) throws IOException { // 构造 multipart 请求体 HttpHeaders headers new HttpHeaders(); headers.setContentType(MediaType.MULTIPART_FORM_DATA); // 注意这里用 LinkedMultiValueMap保证文件字段名和 Python 端 request.files.get(image) 对应 LinkedMultiValueMapString, Object body new LinkedMultiValueMap(); body.add(image, new ByteArrayResource(file.getBytes()) { Override public String getFilename() { return file.getOriginalFilename(); } }); HttpEntityLinkedMultiValueMapString, Object requestEntity new HttpEntity(body, headers); ResponseEntityMap response restTemplate.postForEntity( http://localhost:8866/table/recognize, requestEntity, Map.class); MapString, Object responseBody response.getBody(); if (responseBody null || !Integer.valueOf(0).equals(responseBody.get(code))) { throw new RuntimeException(识别服务调用失败: responseBody); } SuppressWarnings(unchecked) ListMapString, Object tables (ListMapString, Object) responseBody.get(tables); ListString htmlList new ArrayList(); for (MapString, Object table : tables) { htmlList.add((String) table.get(html)); } return htmlList; } }这段代码的核心点是把图片转成ByteArrayResource塞进 multipart 请求体字段名image要和 Python 端的request.files.get(image)严格对应。很多人第一次联调失败就是因为字段名对不上Python 端收到None。3.2 HTML 落地与文件命名规范拿到 HTML 之后可以直接落盘。PaddleOCR 返回的 HTML 是完整的htmlbodytable.../table/body/html结构保存的时候务必用 UTF-8 编码否则中文会出现乱码public void saveHtml(String html, String outputDir, String fileName) throws IOException { Files.createDirectories(Paths.get(outputDir)); Path path Paths.get(outputDir, fileName .html); Files.write(path, html.getBytes(StandardCharsets.UTF_8)); log.info(HTML saved to {}, path); }文件命名我建议带上业务 ID 和时间戳例如order_20250101_1001.html不然并发识别时很容易互相覆盖。如果有一次识别多页的需求建议在后面追加页码后缀_page1、_page2和 Python 服务返回的多张表对应。3.3 联调时最容易踩的拦路坑第一个坑是图片上传大小限制。Spring Boot 默认 multipart 文件大小限制是 1MB扫描件动辄几 MB很容易在还没进入业务逻辑之前就被拦截了。需要在application.yml里调大spring: servlet: multipart: max-file-size: 20MB max-request-size: 20MB第二个坑是超时设置。RestTemplate默认没有超时时间如果 Python 服务卡住Java 线程会一直挂着久而久之线程池就满了。我建议显式设置连接超时和读取超时SimpleClientHttpRequestFactory factory new SimpleClientHttpRequestFactory(); factory.setConnectTimeout(5000); // 连接超时 5 秒 factory.setReadTimeout(30000); // 读取超时 30 秒 RestTemplate restTemplate new RestTemplate(factory);识别一张复杂大表可能要跑 5-10 秒读取超时设 30 秒比较稳妥。第三个坑是响应体直接反序列化成Map时类型丢失。PaddleOCR 返回的html里包含大量、、/这些字符如果 JSON 序列化时没有处理好特殊字符Java 端收到之后解析会报错。我在 Python 端的jsonify已经处理过转义Java 这边的RestTemplate默认用 Jackson 处理正常情况下没问题。如果你用了其他 HTTP 库建议响应体先拿原始字符串再手动转 JSON一步到位避免坑。4. 从 HTML 到 Excel表格数据提取与写入4.1 用 Jsoup 提取 table 结构含合并单元格处理拿到 HTML 之后接下来的任务就是把它转成 Excel。这里有两个选择一是把 HTML 原样塞进 Excel用 POI 的 HtmlDocumentFacade二是先用 Jsoup 解析表格把行列数据抽出来再写入 Excel。我强烈建议用第二种方式因为这样你可以控制表头样式、列宽、合并单元格做出来的 Excel 干净整洁。Jsoup 解析的核心目标是把table里的每一行每一列变成二维数据。麻烦在于合并单元格因为td colspan2和td rowspan2会导致同一行的单元格数量不一致如果只是简单遍历tr td后面的列会错位。我的做法是维护一个已占位矩阵模拟渲染表格的过程public static ListListString parseHtmlTable(String html) { Document doc Jsoup.parse(html); Element table doc.selectFirst(table); if (table null) { throw new IllegalArgumentException(no table found in html); } Elements rows table.select(tr); ListListString data new ArrayList(); boolean[][] occupied new boolean[rows.size()][32]; // 假设最多32列可根据图片调整 for (int r 0; r rows.size(); r) { Elements cells rows.get(r).select(th, td); ListString rowData new ArrayList(); int colIndex 0; for (Element cell : cells) { // 跳过被 rowspan 占用的位置 while (colIndex occupied[r].length occupied[r][colIndex]) { colIndex; } int colspan parseAttr(cell, colspan); int rowspan parseAttr(cell, rowspan); String text cell.text().trim(); // 占位标记后续行/列被合并单元格占用 for (int i 0; i colspan; i) { for (int j 0; j rowspan; j) { if (r j occupied.length colIndex i occupied[r].length) { occupied[r j][colIndex i] true; } } } rowData.add(text); colIndex colspan; } data.add(rowData); } return data; } private static int parseAttr(Element cell, String attr) { String val cell.attr(attr); if (val null || val.isEmpty()) { return 1; } try { return Math.max(1, Integer.parseInt(val)); } catch (NumberFormatException e) { return 1; } }这段逻辑其实就是按浏览器渲染表格的规则回放一遍先把合并单元格占用的位置标记出来后面的单元格自然就能排到正确位置。代码不算复杂但效果很稳我拿它处理过带大量合并单元格的报销单列对齐基本不会错。4.2 用 POI 写入 xlsx 并处理合并区域数据提取出来之后写入 Excel。我这里用 Apache POI 的 XSSFWorkbook因为 EasyExcel 在处理合并单元格和样式时不够灵活public static void exportToExcel(ListListString tableData, String outputPath) throws IOException { XSSFWorkbook workbook new XSSFWorkbook(); XSSFSheet sheet workbook.createSheet(识别结果); // 表头样式加粗、灰色背景 CellStyle headerStyle workbook.createCellStyle(); Font headerFont workbook.createFont(); headerFont.setBold(true); headerStyle.setFont(headerFont); headerStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); int rowIndex 0; for (ListString rowData : tableData) { XSSFRow row sheet.createRow(rowIndex); for (int col 0; col rowData.size(); col) { XSSFCell cell row.createCell(col); cell.setCellValue(rowData.get(col)); if (rowIndex 0) { cell.setCellStyle(headerStyle); } } rowIndex; } // 合并单元格根据第一个单元格内容判断哪些位置应该是跨行跨列的示例略 // sheet.addMergedRegion(new CellRangeAddress(0, 1, 0, 1)); // 列宽自适应粗略处理按第一行数据长度设置 for (int col 0; col tableData.get(0).size(); col) { sheet.autoSizeColumn(col); } try (FileOutputStream fos new FileOutputStream(outputPath)) { workbook.write(fos); } workbook.close(); }关于autoSizeColumn有个提醒数据量大的时候这个方法会遍历每一列的所有行计算宽度比较耗时而且它对中文宽度的估算有时候不准会显得列宽偏窄。建议最后统一做一次列宽度设置比如按最大值加个 padding。合并单元格的处理如果你希望 Excel 里保留 HTML 里colspan/rowspan的样式可以在解析parseHtmlTable的同时额外记录每个单元格的合并信息起始行、起始列、跨行数、跨列数然后调用sheet.addMergedRegion(new CellRangeAddress(...))对应合并。我一般会把合并信息封装到一个CellSpan对象里一并返回这样数据和样式都不丢。4.3 导出的常见问题换行、特殊符号、公式注入这里有几个坑是你不遇到一次根本不会想到的问题。换行符问题OCR 识别出来的文本里经常有换行符尤其是单元格里有多行内容的场景。直接写进 Excel 的话需要把单元格的换行样式打开否则文本虽然有换行符号显示时却是连续的CellStyle wrapStyle workbook.createCellStyle(); wrapStyle.setWrapText(true); cell.setCellStyle(wrapStyle);如果你根本不想要换行可以在写入前用text.replaceAll(\\s, ).trim()把多余的空白压掉。具体场景具体处理如果是保留原格式的就开启 wrapText如果是做数据入库的就压成单行。公式注入问题如果字段值是以、、-、开头的字符串写入 Excel 时可能被当成公式执行轻则显示错误重则有注入风险。比如某个单元格内容恰好是11POI 会把它按公式处理导出后在 Excel 里显示2而不是11。处理方式是凡是读取到的文本以这些字符开头我都在前面加一个单引号String cellValue text; if (text.startsWith() || text.startsWith() || text.startsWith(-) || text.startsWith()) { cellValue text; }这在处理财务报表、订单明细这类数据时尤其重要因为原表格里完全可能出现-10%或者0.5这样的值。空行空列问题PaddleOCR 识别出的表格偶尔会在行数据里夹带空行。我在写入 POI 之前会先过滤掉全为空字符串的行避免导出后出现大量空白行客户体验很差。5. 真实项目中的性能调优与部署建议5.1 模型常驻、连接复用与超时控制识别性能的第一瓶颈在 Python 侧的模型常驻问题。千万不能在每次请求里创建PPStructureV3实例模型的权重加载和引擎初始化可能要好几秒必须全局复用。我上面给的 Flask 代码里用了单例模式就是为了保证模型只初始化一次。Java 侧并发调用时要注意控制请求速率。如果同一个 Python 服务实例同时处理 10 个请求Flasks 自带的多线程模型会在模型推理层产生排队而且显存占用会成倍增加。我当时用了一个简单的信号量控制并发数private final Semaphore semaphore new Semaphore(4); // 最多同时4个识别请求 public ListString recognizeTable(MultipartFile file) throws Exception { semaphore.acquire(); try { return doRecognize(file); } finally { semaphore.release(); } }这样既能充分利用 GPU又不至于把服务打挂。如果压测发现 4 个并发还是不够优先加 Python 服务实例而不是无限调大并发数。5.2 图片压缩与分块识别PaddleOCR 对输入图片的尺寸有推荐范围最长边 960-1920 像素是效果比较好的区间。但实际业务里扫描件的分辨率经常是 300dpi、4000×3000 像素这种级别。直接把原图喂进去结果就是推理时间暴增、显存吃紧甚至直接 OOM。我的处理策略是在调用 Python 服务之前Java 侧先对图片做一个预处理public static BufferedImage resizeIfTooLarge(BufferedImage src, int maxSide) { int width src.getWidth(); int height src.getHeight(); int longestSide Math.max(width, height); if (longestSide maxSide) { return src; } double scale (double) maxSide / longestSide; int targetWidth (int) (width * scale); int targetHeight (int) (height * scale); BufferedImage resized new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB); Graphics2D g resized.createGraphics(); g.drawImage(src, 0, 0, targetWidth, targetHeight, null); g.dispose(); return resized; }最长边限制在 2000 左右识别精度几乎不受影响但速度能快好几倍。如果原图里包含多张表格、需要分块识别的场景可以先做版面切分再把每一块单独送入识别服务。这个方案比较复杂一般需求用不到我这里只提一个思路。5.3 生产部署容器化与 GPU 调度Python 识别服务和 Java 应用在生产环境通常是两个独立部署单元。Java 应用继续走原来的发布流程Python 服务用 Docker 单独部署。我用的 Dockerfile 大致长这样FROM paddlepaddle/paddle:3.0.0-gpu-cuda11.8-cudnn8.6-trt8.5 RUN pip install paddleocr flask WORKDIR /app COPY app.py . EXPOSE 8866 CMD [python, app.py]如果只用 CPU基础镜像可以直接选python:3.10-slim然后pip install paddleocr就行。GPU 服务部署时注意给容器透传 GPUdocker run -d --gpus all -p 8866:8866 table-ocr-service生产环境建议在 Python 服务前面加一层 gunicorn gevent 或多 worker避免 Flask 自带的开发服务器扛不住并发。我实际验证过 gunicorn 配置gunicorn -w 2 -b 0.0.0.0:8866 -t 120 app:app这里-w的 worker 数量要结合显存来定一个 worker 加载一份模型显存里的占用是叠加的。2GB 显存跑一个 worker 都紧巴巴的8GB 显存开 2 个 worker 比较稳妥。worker 太多导致显存溢出是部署阶段最容易踩的坑。6. 我踩过的坑和现在的稳定用法最后把这些年用 PaddleOCR 做表格识别实践里踩过的坑做个集中梳理希望能帮大家省下一些排查时间。模型版本锁定不要盲目追新。PaddleOCR 迭代速度很快小版本升级也可能带来 API 变化。线上服务建议把版本号固定下来pip install paddleocr3.0.0如果你用的是 2.x 的老项目也不要轻易升级到 3.x因为底层调用链完全变了。模型文件也一样建议下载到本地保存一份不要依赖运行时自动下载。我遇到过服务器重装之后模型自动下载失败导致服务起不来的情况后来就改成每次部署时把模型目录整个打包带走。识别结果一定要人工抽检。PaddleOCR 的表格识别准确率在规整的印刷表格上很高但遇到复杂表头、竖排文字、手写体、模糊扫描件时仍然会有结构错位和文字识别错误。我的建议是在系统里加一个人工复核环节识别完成后把 HTML 渲染成预览图让业务人员快速过一遍或者抽样比对几个关键字段。不要盲目相信 OCR 的准确率尤其是财务报表这种错一个数字影响很大的场景。HTML 里的样式信息要注意过滤。PaddleOCR 生成的 HTML 里除了table结构还包含大量的内联样式比如单元格的边框、字体、背景色等。如果你只是需要数据直接把这些样式丢掉就行Jsoup 解析时只取text()天然会忽略标签和样式。如果你要还原原表格的视觉样式那就要把 style 解析出来这个工作量会大很多。我一般默认只导出数据样式层面的需求需要单独跟业务方确认。现在我在项目里的稳定用法是这样的Java 应用通过 Spring Boot 对外提供上传接口图片先压缩到最长边 2000 像素以内再通过 RestTemplate 调用 Python 识别服务Python 服务用 PPStructureV3 单例模型做表格识别返回 JSON 里的 HTML 字段Java 拿到 HTML 后先落一份原始 HTML 存档再用 Jsoup 解析出表格数据和合并单元格信息最后用 POI 写入带样式的 xlsx。整条链路里最容易出问题的地方反而不是 OCR 本身而是两个服务之间的字段约定和文件传输格式所以联调时一定要先约定好接口文档再动手写代码。这套方案在普通的 2 核 4G 服务器上CPU 模式单张表格识别时间在 1-3 秒上了 GPU 之后基本能控制在 500 毫秒以内。对于大多数内部系统来说已经完全够用了。如果你也在做类似的需求可以直接照着这套架构搭一遍。