Java集成PaddleOCR实现表格识别与HTML/Excel导出实战
2026/9/17 15:57:26 网站建设 项目流程

最近在处理一批扫描件的财务表格,要求把图片里的表格“搬”到系统里,能在线预览也要能导出 Excel 给财务去二次加工。手动录入不现实,量一大眼睛就先废了。最开始想直接在 Java 里找现成的表格识别库,试了一圈发现效果都不太理想,直到把 PaddleOCR 拉进来做表格结构还原,才真正把这套流程跑通。这篇内容就完整记录我怎么用 Java 做业务编排、用 PaddleOCR 做表格识别,最后同时导出 HTML 和 Excel 的整个过程,里面有不少从实际项目里踩出来的细节,不是光贴代码那种教程。

1. 项目整体设计与思路拆解

1.1 这个项目到底要解决什么问题

先说业务场景。上游系统拿到的多是 PDF 或者拍照件,里面是带边框、带合并单元格的表格,比如出入库单、对账单、报销明细。目标很直接:上传一张图片,后端自动识别表格内容,返回两个结果——一个能在浏览器里直接打开的 HTML 页面,一个能交给财务继续编辑的 .xlsx 文件。

这个需求拆开看,其实是三件事的叠加:

  • 文字识别:把图片里的中文字符、数字、符号正确识别出来,这是基础,识别错了后面全错。
  • 表格结构还原:光有文字不行,还得知道哪些字在同一行、同一列,哪些单元格跨行跨列,表格的层次结构必须还原出来。
  • 格式输出:识别出来的结构化结果要能转换成我们常用的 HTML 和 Excel 格式,转换过程不能丢坐标、丢合并信息。

如果只是做文字识别,用 Tesseract 或者各种 OCR API 都能凑合。但加上“表格结构还原”这个要求,很多方案就撑不住了。这也是为什么我最终选了 PaddleOCR,它自带的表格识别模型能直接输出表格结构,而不是让我自己去猜测哪块文字属于哪个格子。

1.2 为什么选择 PaddleOCR 而不是 Tesseract 或付费云 API

选型的时候我认真对比过三种路线。

第一是 Tesseract。它在纯文字识别上免费开源,Java 生态里也有 tess4j 这种封装,集成很方便。但表格结构还原能力基本为零,它只负责输出文字和坐标,不会告诉你哪些文字是一个单元格、哪几行该合并。这意味着我还要写大量逻辑去判断表格线、推断单元格边界,而且碰上无边框表格基本就废了。而实际业务里的表格,边框各种各样,手写线、断线、模糊线非常普遍,靠传统的霍夫变换检测直线再推断格子的方案,鲁棒性极差。

第二是付费云 OCR API。识别精度和表格还原能力都不错,接入也简单,调接口就行了。但问题是:敏感数据不能出内网,而且大量调用的话费用不可控,长期下来是一笔不小的成本。如果只是给自己工具用,或者数据合规要求高,这条路走不通。

第三就是 PaddleOCR。它是开源项目,模型可以本地部署,数据不用出服务器。最关键的是它不止做文字识别,还提供了表格结构识别模型(SLANet 系列),能识别有线表甚至部分无线表,直接输出单元格坐标和整个表格的 HTML 结构。这正好把我最头疼的“结构还原”问题解决了。

我的选型结论是:如果只是要文字,Tesseract 够了;如果要省事且数据可以出网,云 API 可以;如果要本地化部署、又要还原表格结构,PaddleOCR 是最务实的方案。

1.3 整体架构:Java 管流程,Python 管推理

PaddleOCR 本身是 Python 库,Java 直接调用它原生接口很别扭。实际生产中我采用的是“前后端分离”的部署模式:

Java 应用(Spring Boot) → 接收图片上传 → 调用 Python 推理服务(HTTP 接口) → Python 服务加载 PaddleOCR 表格识别模型 → 返回 JSON(含表格 HTML 结构 + 单元格坐标) → Java 解析 JSON,生成 Excel/HTML

为什么这么拆?两个原因。

一个是模型生态的天然边界。OCR 推理的核心能力在 Python 侧,PaddleOCR 新版本迭代很快,今天用 PP-OCRv4,明天可能出 v5,Java 侧如果直接耦合模型加载细节,升级成本太高。通过 HTTP 接口隔离,模型怎么升级、换 GPU 还是 CPU,都是 Python 服务内部的改动。

另一个是并发与资源控制。模型推理吃内存、吃显存,不适合跟业务接口挤在一个进程里。单独部署一个推理服务,可以通过线程池、队列控制并发,避免因为一次大图识别把整个业务应用拖垮。

2. 环境准备与 PaddleOCR 推理服务搭建

2.1 安装 PaddleOCR 并下载表格识别模型

我这边部署环境是 Linux + Python 3.9 + CUDA 11.7,PaddleOCR 用的 2.7 版本。安装命令比较简单,但有几个坑需要提前说:

# 建议用虚拟环境,避免污染系统 Python python3 -m venv venv source venv/bin/activate # 安装 paddlepaddle-gpu python -m pip install paddlepaddle-gpu==2.5.2.post117 \ -f https://www.paddlepaddle.org.cn/whl/linux/mkl/avx/stable.html # 安装 paddleocr pip install paddleocr==2.7.0

GPU 版本的安装最容易出问题的地方是 CUDA 和 cuDNN 版本对不上。我建议装之前先跑一句nvidia-smi看驱动支持的 CUDA 版本,然后去 PaddlePaddle 官网选对应的 wheel 包。如果没有 GPU 环境,CPU 版本也能跑,只是速度会慢很多,一张 1500x1000 的图大概需要 5-8 秒,GPU 的话 1 秒以内。

模型文件不需要手动下载。PaddleOCR 首次运行时自动下载,但国内网络经常下载到一半就断。解决方法是手动从官网模型库下载,放到指定目录:

  • 检测模型:ch_PP-OCRv4_det_infer
  • 识别模型:ch_PP-OCRv4_rec_infer
  • 表格结构模型:SLANet_plus 或 ch_ppstructure_mobile_v2.0_SLANet

下载后把压缩包解压到~/.paddleocr/对应目录下,再次运行就不会重复下载了。

2.2 用 FastAPI 封装一个 OCR 推理接口

安装好 PaddleOCR 后,直接用 Python 写小脚本调用就行。但没有必要让 Java 侧拿 subprocess 去调 Python,那样进程管理和异常处理都很难受。更优雅的方式是起一个 HTTP 推理服务。

我用 FastAPI 封装了三个接口:

import base64 import json import numpy as np import cv2 from fastapi import FastAPI, UploadFile, File from paddleocr import PPStructure app = FastAPI() engine = PPStructure( lang='ch', show_log=False, use_gpu=True, det_model_dir='models/ch_PP-OCRv4_det_infer', rec_model_dir='models/ch_PP-OCRv4_rec_infer', table_model_dir='models/SLANet_plus', ) @app.get("/health") def health(): return {"status": "ok"} @app.post("/table") async def table_recognition(file: UploadFile = File(...)): data = await file.read() img = cv2.imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_COLOR) result = engine(img) # result 是 list,每个元素是一个表格区域的结构化结果 # 里面包含 'res'(单元格坐标和文字)和 'img'(绘制了框的图片) ... return {"status": "ok", "result": parsed}

这里有几个重点:

  • PPStructure是 PaddleOCR 里的版面分析+表格识别入口,它会先做版面分析,再对识别为表格的区域做表格结构还原。如果你的输入图片本身就是完整的一张表,这个流程非常合适。
  • 返回值里result是一个列表,每个元素代表一个版面区域。需要遍历找type == 'table'的区域,这些才是表格。res字段里是表格的 HTML 结构和单元格坐标。
  • cv2.imdecode处理上传的字节流,比用Image.open再转 numpy 要快,也更省内存。

2.3 Java 工程引入必要依赖

Java 侧我用的是 Spring Boot 3.x + JDK 17,核心依赖就四个:

<dependency> <groupId>org.apache.httpcomponents.client5</groupId> <artifactId>httpclient5</artifactId> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </dependency> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>5.2.5</version> </dependency> <dependency> <groupId>org.jsoup</groupId> <artifactId>jsoup</artifactId> <version>1.17.2</version> </dependency>
  • httpclient5用来调用 Python 推理服务,支持连接池和超时控制。
  • jackson解析推理服务返回的 JSON。
  • poi-ooxml用来生成 Excel。
  • jsoup用来解析 PaddleOCR 返回的 HTML 片段,提取表格结构并做修正。

3. 表格识别核心流程实现

3.1 图片预处理:不要急着调模型

很多做 OCR 的人上来直接调模型,结果识别率上不去就怪模型不行。实际上大部分识别精度问题都出在图片质量上。我在实际处理中会把预处理当成一个独立环节来做:

  • 转正:扫描件经常有轻微倾斜,如果倾斜超过 5 度,表格线检测就会出问题。我一般先用 OpenCV 检测表格线的角度,再做仿射变换矫正。
def rotate_image(img): gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) edges = cv2.Canny(gray, 50, 150, apertureSize=3) lines = cv2.HoughLinesP(edges, 1, np.pi / 180, threshold=200) angles = [] for line in lines: x1, y1, x2, y2 = line[0] angle = np.arctan2(y2 - y1, x2 - x1) * 180 / np.pi if 70 < abs(angle) < 90: angles.append(angle) if not angles: return img median_angle = np.median(angles) angle = median_angle - 90 if median_angle > 0 else median_angle + 90 h, w = img.shape[:2] M = cv2.getRotationMatrix2D((w / 2, h / 2), angle, 1.0) return cv2.warpAffine(img, M, (w, h), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE)
  • 提高分辨率:如果图片宽度小于 1000 像素,表格里的文字会很难识别。我用cv2.resize统一放大到宽 1600 左右,长宽比保持不变。
  • 增强对比度:对于拍照件,阴影和反光会影响识别。可以用 CLAHE 做局部直方图均衡化,让文字和背景的区分更加明显。

预处理的原则是:把模型从“硬扛复杂环境”中解放出来,模型只在相对干净的图上做推理,精度会有肉眼可见的提升。

3.2 Java 调用推理服务的完整代码

Java 端把图片上传到 Python 服务,我用的是 multipart/form-data 方式,而不是 base64。原因很简单,multipart 不用额外处理大图的内存占用,而且 HttpClient 自带支持。

public String callTableService(byte[] imageBytes, String fileName) throws IOException { try (CloseableHttpClient client = HttpClients.custom() .setConnectionTimeToLive(30, TimeUnit.SECONDS) .build()) { HttpPost post = new HttpPost(paddleServiceUrl + "/table"); MultipartEntityBuilder builder = MultipartEntityBuilder.create(); builder.setMode(HttpMultipartMode.STRICT); builder.addBinaryBody("file", imageBytes, ContentType.IMAGE_JPEG, fileName); post.setEntity(builder.build()); post.setConfig(RequestConfig.custom() .setConnectTimeout(5000) .setSocketTimeout(60000) .build()); try (CloseableHttpResponse response = client.execute(post)) { String body = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); if (response.getStatusLine().getStatusCode() != 200) { throw new IOException("OCR service error: " + response.getStatusLine()); } return body; } } }

注意setSocketTimeout一定要设置且不能太小。如果图片比较大,CPU 推理可能要 10 秒以上,默认的超时时间很容易触发 SocketTimeoutException。

3.3 解析 PaddleOCR 返回的表格结构

PaddleOCR 表格识别接口返回的结果结构比较有特点。核心内容是两部分:一个是整个表格的 HTML 片段,另一个是每个单元格的坐标信息和识别文字。

正常情况下返回的 HTML 片段长这样:

{ "status": "ok", "table": { "html": "<html><body><table><tbody><tr><td>项目</td><td>数量</td><td>金额</td></tr><tr><td>A</td><td>10</td><td>100.00</td></tr></tbody></table></body></html>", "cells": [ { "bbox": [112, 89, 260, 120], "text": "项目", "confidence": 0.986, "row_start": 0, "row_end": 0, "col_start": 0, "col_end": 0 }, { "bbox": [260, 89, 420, 120], "text": "数量", "confidence": 0.975, "row_start": 0, "row_end": 0, "col_start": 1, "col_end": 1 } ] } }

这里的row_startrow_endcol_startcol_end是我在 Python 服务里根据单元格坐标计算好的索引,目的是让 Java 侧生成 Excel 时能直接判断要不要合并单元格。

# Python 服务端计算行列索引的核心逻辑 def calc_cell_index(cells, tol=5): # 提取所有单元格的 x 和 y 中心点 xs = [((bbox[0] + bbox[2]) / 2) for bbox in cells['bbox']] ys = [((bbox[1] + bbox[3]) / 2) for bbox in cells['bbox']] # 按 y 中心点聚类得到行索引,按 x 中心点聚类得到列索引 rows = cluster_values(sorted(set(ys)), tol) cols = cluster_values(sorted(set(xs)), tol) for cell in cells: y_center = (cell['bbox'][1] + cell['bbox'][3]) / 2 x_center = (cell['bbox'][0] + cell['bbox'][2]) / 2 cell['row_start'] = nearest_index(rows, y_center) cell['row_end'] = nearest_index(rows, y_center) cell['col_start'] = nearest_index(cols, x_center) cell['col_end'] = nearest_index(cols, x_center) # 补充合并逻辑:高相同的行、宽相同的列互相覆盖,判断是否有跨行/跨列 ...

中间聚类部分要仔细处理,否则同一行的单元格中心点因为识别偏移产生 2-3 像素的差异,就会认为属于两个不同行,最终表格结构错乱。

4. 如何可靠地导出 HTML

4.1 利用 PaddleOCR 的 HTML 片段做二次加工

PaddleOCR 返回的 HTML 是完整字符串,但直接拿来用有几个问题:一是标签可能不闭合;二是tdrowspancolspan属性缺失,合并单元格信息不在 HTML 里;三是字体和样式没有,页面显示很丑。

我的处理思路是:把返回的 HTML 片段作为“骨架”,把单元格坐标和行列索引作为“修正依据”,最终用 JSoup 重建一个规范表格。

public String buildHtml(String ocrHtml, List<CellInfo> cells) { Document doc = Jsoup.parse(ocrHtml); // 去掉 html/body 包裹,只保留 table Element table = doc.selectFirst("table"); if (table == null) { throw new RuntimeException("OCR result does not contain table element"); } // 遍历解析后的单元格,补充 rowspan/colspan for (Element td : table.select("td")) { int rowSpan = findRowSpan(td, cells); int colSpan = findColSpan(td, cells); if (rowSpan > 1) { td.attr("rowspan", String.valueOf(rowSpan)); } if (colSpan > 1) { td.attr("colspan", String.valueOf(colSpan)); } } // 补样式,输出完整 HTML ... }

4.2 单元格合并的坐标计算逻辑

要正确生成rowspancolspan,需要搞清楚一个核心问题:怎么通过坐标判断一个单元格是否跨行跨列。

我的做法是这样的:

  • 先取得所有单元格的row_startrow_endcol_startcol_end
  • 遍历时检查每个单元格的row_end > row_start,是则说明它跨越了多个行,rowspan = row_end - row_start + 1
  • 列同理,colspan = col_end - col_start + 1

理论上这很直接。但实际中最大的坑是:PaddleOCR 的表格结构识别对“合并单元格”的判定依赖模型训练数据,如果表格结构特别复杂,返回的row_endcol_start会不准确。这时候就需要通过坐标辅助判断。

比如,一个单元格中心点的 y 坐标位于第三行和第四行之间,但它的高度明显超过第三行的行高,那它大概率是一个跨两行的单元格。我在这个项目里实现了一个启发式规则:

// 判断单元格是否跨行 int estimateRowSpan(CellInfo cell, List<Double> rowCenters) { double top = cell.bbox[1]; double bottom = cell.bbox[3]; int startRow = findClosestRow(top, rowCenters); int endRow = findClosestRow(bottom, rowCenters); return Math.max(1, endRow - startRow + 1); }

核心逻辑很简单:拿单元格的上边界和下边界,分别找到它们属于哪一行,差值就是跨行数量。用这个方式校验模型返回的rowspan,不一致时优先信任坐标计算结果,能修复不少复杂表格的合并错误。

4.3 输出完整可预览的 HTML 页面

生成表格骨架之后,我会套一个简洁的样式模板,让页面在浏览器里直接看、直接打印也不难看:

String htmlTemplate = """ <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <style> body { font-family: "Microsoft YaHei", sans-serif; margin: 20px; } table { border-collapse: collapse; width: 100%; } td, th { border: 1px solid #333; padding: 6px 10px; font-size: 14px; } th { background-color: #f0f0f0; font-weight: bold; } </style> </head> <body> %s </body> </html> """;

这里注意charset一定要是 UTF-8,否则打开页面中文会乱码。其次表格边框用border-collapse: collapse,这样相邻单元格之间不会出现双线,打印和导出都更干净。

5. 用 Apache POI 导出 Excel

5.1 XSSFWorkbook 还是 SXSSFWorkbook

项目初期我直接用了XSSFWorkbook,因为实现简单。但实际跑了一批几十张表格转 Excel 的任务后,发现内存涨得很快。原因是一个大表格如果有几百行、几十列,POI 会把每个单元格对象都保留在内存里,对几十张图片批量处理时 JVM 很容易 OOM。

改成SXSSFWorkbook后情况明显好转。它是 POI 提供的一种流式写 Excel 的实现,设置一个窗口大小,超出部分自动写入临时文件,内存中只保留最近 N 行。

SXSSFWorkbook workbook = new SXSSFWorkbook(100); // 窗口大小 100 行 SXSSFSheet sheet = workbook.createSheet("表格识别结果");

但也有代价:SXSSFWorkbook不支持读取已有 Excel,也不支持部分高级样式操作。我们这个场景是纯生成,完全够用。用完记得调用workbook.dispose()释放临时文件。

5.2 表格数据写入 Excel 单元格

核心流程是把之前解析好的二维数组结构写入 Excel。数组的索引就是单元格的行列位置,合并信息单独处理。

void writeTableToSheet(SXSSFWorkbook workbook, TableData tableData) { SXSSFSheet sheet = workbook.createSheet("Sheet1"); // 设置默认列宽 for (int i = 0; i < tableData.getColumnCount(); i++) { sheet.setColumnWidth(i, 18 * 256); // 18 个字符宽度 } // 写入单元格 for (int r = 0; r < tableData.getRowCount(); r++) { SXSSFRow row = sheet.createRow(r); for (int c = 0; c < tableData.getColumnCount(); c++) { CellData cellData = tableData.getCell(r, c); if (cellData == null) { continue; } XSSFCell cell = row.createCell(c); cell.setCellValue(cellData.getText()); // 处理数字格式:如果内容是纯数字,保留原样避免被转成科学计数法 if (cellData.getText() != null && isNumeric(cellData.getText())) { cell.setCellValue(Double.parseDouble(cellData.getText())); cell.setCellType(CellType.NUMERIC); NestedDataFormat dataFormat = workbook.getDataFormat(); cell.getCellStyle().setDataFormat(dataFormat.getFormat("0.00")); } } } // 合并单元格 for (CellRange range : tableData.getMergeRanges()) { CellRangeAddress region = new CellRangeAddress( range.getFirstRow(), range.getLastRow(), range.getFirstCol(), range.getLastCol()); sheet.addMergedRegion(region); } }

这里有一个容易踩的坑:纯数字内容的类型判断。表格里像“120.50”“84”这种数字,如果当成字符串写入 Excel,单元格左上角会出现绿色的小三角,而且财务同事后续求和、透视表都用不了。所以识别出来的文字如果能转成数字,就应该按NUMERIC类型写入。但也要注意,像手机号、订单号这种前面有 0 的长数字,不能一律转成数字,否则 0 丢失。我这里的策略是:如果字符串长度超过 10 位,优先按文本处理。

5.3 单元格样式和合并处理

样式方面,需求比较简单:中文字体、边框、表头加粗、背景色。POI 的样式创建比较啰嗦,而且每个单元格都设置样式会很慢。正确做法是创建少量样式对象,然后复用:

CellStyle headerStyle = workbook.createCellStyle(); Font headerFont = workbook.createFont(); headerFont.setFontName("微软雅黑"); headerFont.setBold(true); headerFont.setFontHeightInPoints((short) 11); headerStyle.setFont(headerFont); headerStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); headerStyle.setBorderTop(BorderStyle.THIN); headerStyle.setBorderBottom(BorderStyle.THIN); headerStyle.setBorderLeft(BorderStyle.THIN); headerStyle.setBorderRight(BorderStyle.THIN);

这里有个关键点:设置边框和字体时,同一个CellStyle实例可以被多个单元格复用,但如果对同一个实例反复修改属性,会影响之前所有引用它的单元格。所以表头一种样式、正文一种样式、合计行一种样式,分三个实例就够了。

合并单元格使用CellRangeAddress,参数是起始行、结束行、起始列、结束列,注意行列索引都是从 0 开始的。合并后只有左上角的单元格有值,其他位置的单元格必须在写入数据时跳过,否则 POI 会报“Invalid cell range”异常。

6. 常见问题与排查技巧实录

6.1 中文乱码:源头不在 Java,在 PaddleOCR 和字体

项目里遇到两次乱码。第一次是 HTML 页面打开后中文显示为问号,排查后发现是 Python 服务返回的 JSON 里中文被错误转成了\ufffd,这个是 PaddleOCR 识别模型的问题,模型加载时没有指定中文字典路径。解决方法是重新指定rec_model_dir并且在创建PPStructure时加上lang='ch'

第二次是导出 Excel 时出现方框。这个是字体问题,Linux 服务器上没有安装中文字体,POI 指定“微软雅黑”时找不到字体,Excel 打开只能用默认字体渲染。解决办法很简单:

# 服务器上安装中文字体 apt-get install -y fonts-wqy-microhei fc-cache -f

POI 里的字体名改成"WenQuanYi Micro Hei"或者直接不指定字体名,用 Excel 默认字体。

6.2 CPU 推理慢到怀疑人生

我一开始在开发机上用 CPU 跑,一张 4K 分辨率的货单表格识别耗时接近 20 秒,作为一个 HTTP 接口来说不可接受。后来做了三件事:

  • 把输入图片统一缩放,最长边不超过 2000 像素,识别速度提升 4 倍,精度几乎不受影响。
  • 给推理服务加了并发线程池,同一时刻最多处理 4 张图片,避免多请求同时打进来的时候模型被反复初始化。
  • 有条件就上 GPU。实测从 CPU 切到 GPU 后单张识别耗时从 10-20 秒降到 0.8-1.5 秒,效果极其明显。

如果你没有独立的 GPU 机器,至少用 Docker 把推理服务单独部署,别和 Java 应用挤在一起,应用 GC 和模型推理互相影响很严重。

6.3 复杂表格合并单元格识别错误

实际业务中很多表格的表头是三层、四层的嵌套合并,比如“1-2月”列下又分“销售额”“利润”,再下面还分“预算”“实际”。PaddleOCR 的表格结构模型对这类复杂表头的识别存在一定失败率,会出现rowspan偏大或偏小、单元格位置错位的情况。

我的应对策略是加了一个“人工确认”流程:识别结果中置信度低于 0.8 的单元格,在 HTML 页面里用黄色高亮标出来,由操作员快速核对一遍再导出 Excel。虽然多了一步人工,但整体效率相比纯手工录入还是提升了几十倍,而且准确率有保障。

另外针对多层表头,我发现预处理时把图片调大一点,比换模型还有效。复杂表头的小字非常多,分辨率不够时识别模型连字符都认不出来,更别说判断结构。建议复杂表格的图片宽度保持在 2000 像素以上。

6.4 线程等待与连接池耗尽问题

Java 应用如果在循环里批量调用推理服务,极易把 HttpClient 的连接池打满。默认连接池单路由最多 5 个连接,超过的请求全部排队。解决方法是调整连接池参数:

PoolingHttpClientConnectionManager connManager = new PoolingHttpClientConnectionManager(); connManager.setMaxTotal(50); connManager.setDefaultMaxPerRoute(20); CloseableHttpClient client = HttpClients.custom() .setConnectionManager(connManager) .build();

同时每个请求的 SocketTimeout 设置为 60 秒,确保不会因为某一个推理请求卡死,把整个连接池的连接都占住不释放。

6.5 大图和并发时的 OOM

一次处理几十张图片时,Java 堆内存很容易爆掉。我做了一层保护:一张图片内存中的字节数组超过 10MB 就不走 multipart 直接传给推理服务,而是先压缩到 JPEG 质量 80% 再发送;得到的推理结果 JSON 也是字符串,解析时用 Jackson 的JsonNode流式读取,不要直接绑成整个大对象。

Python 侧也要防内存泄漏。PPStructure实例应当是全局单例,不能每次请求都创建一个,否则加载模型的时间不仅长,还容易把内存吃光。

7. 后续可以怎么扩展

这个项目目前已经稳定跑了几个月,基本功能是:上传图片 → 自动识别 → 在线预览 HTML → 一键导出 Excel。实际使用中我总结了两点可以继续扩展的方向。

第一是增加 PDF 支持。很多业务表格其实是 PDF 格式,需要先用 Java 侧的 PDFBox 或 Python 侧的 PyMuPDF 把 PDF 转成图片,再走现有的 OCR 流程。转图时要注意 DPI,zoom参数一般设为 2 就够,太大反而会增加噪音。

第二是加入置信度过滤和人工修正闭环。OCR 识别永远做不到 100% 正确,尤其是手写数字和盖章遮挡区域。一个更完善的系统应该把低置信度单元格在界面上标出来,让用户在线修改后再导出,修改记录还可以沉淀成训练数据反哺模型。

前端的展示层我目前是直接生成 HTML 字符串返回浏览器预览,后续可以改成用html2canvas截图保存为 PDF 归档,或者用x-spreadsheet这种控件让用户在网页上直接编辑识别结果。表格识别这个方向,难的不是技术选型,而是怎么把识别能力无缝嵌入到现有业务流里,让使用者觉得“方便、准确、不用二次加工”。这才是这个项目的真正价值所在。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询