第2196篇:表格数据的AI提取——非结构化表格到结构化数据的工程流程
第2196篇:表格数据的AI提取——非结构化表格到结构化数据的工程流程
适读人群:需要从图片或PDF中提取表格数据的Java工程师 | 阅读时长:约15分钟 | 核心价值:处理各种复杂表格的完整工程方案,从合并单元格到跨页表格
表格提取这件事,我原来觉得不难,结果第一次做就被打脸了。
客户发来一批招标文件,要求把里面的报价表提取出来入库。我用Apache POI读Excel——结果文件是扫描PDF。换PaddleOCR识别——识别出来的文字根本没有表格结构,全是流水线式的文字。写规则解析——表格样式几十种,有合并单元格、有斜线表头、有跨页表格,规则写不完。
这个问题让我意识到表格提取是多模态处理里最难的子问题之一。它难在哪?不是识别文字本身,而是理解表格的"形状"——哪些单元格是合并的,行头和列头是什么关系,一个值属于哪一行哪一列。
一、表格类型分析:先分类再分治
不同类型的表格需要不同的处理策略:
表格类型分类:
├── 规则表格(有完整网格线)
│ ├── 简单表格(无合并单元格)→ 传统OCR+坐标分析即可
│ └── 复杂表格(有合并单元格)→ 需要结构理解
├── 半规则表格(部分有线,部分靠对齐)→ 需要版面分析
└── 不规则表格(纯靠空格/缩进对齐)→ VLM是最佳选择对于规则表格,可以用OpenCV检测网格线,推算单元格边界:
@Component
public class TableStructureDetector {
/**
* 检测表格的网格结构
* 返回单元格的边界框列表
*/
public List<CellBoundary> detectTableCells(Mat tableImage) {
// 转灰度并二值化
Mat gray = new Mat();
Mat binary = new Mat();
Imgproc.cvtColor(tableImage, gray, Imgproc.COLOR_BGR2GRAY);
Imgproc.threshold(gray, binary, 180, 255, Imgproc.THRESH_BINARY_INV);
// 检测水平线
Mat horizontalKernel = Imgproc.getStructuringElement(
Imgproc.MORPH_RECT, new Size(tableImage.cols() / 30, 1));
Mat horizontalLines = new Mat();
Imgproc.morphologyEx(binary, horizontalLines, Imgproc.MORPH_OPEN, horizontalKernel);
// 检测垂直线
Mat verticalKernel = Imgproc.getStructuringElement(
Imgproc.MORPH_RECT, new Size(1, tableImage.rows() / 30));
Mat verticalLines = new Mat();
Imgproc.morphologyEx(binary, verticalLines, Imgproc.MORPH_OPEN, verticalKernel);
// 合并线条
Mat tableMask = new Mat();
Core.add(horizontalLines, verticalLines, tableMask);
// 查找轮廓(每个轮廓对应一个单元格)
List<MatOfPoint> contours = new ArrayList<>();
Mat hierarchy = new Mat();
Imgproc.findContours(tableMask, contours, hierarchy,
Imgproc.RETR_TREE, Imgproc.CHAIN_APPROX_SIMPLE);
// 过滤并转换为CellBoundary
return contours.stream()
.map(Imgproc::boundingRect)
.filter(rect -> rect.area() > 100) // 过滤噪点
.map(rect -> new CellBoundary(rect.x, rect.y, rect.width, rect.height))
.sorted(Comparator.comparingInt(CellBoundary::y)
.thenComparingInt(CellBoundary::x))
.collect(Collectors.toList());
}
/**
* 从单元格边界推断表格的行列结构
* 返回二维数组:[rowIndex][colIndex] = CellBoundary
*/
public List<List<CellBoundary>> inferTableGrid(List<CellBoundary> cells,
int imageHeight, int imageWidth) {
if (cells.isEmpty()) return List.of();
// 聚类Y坐标,找到行分隔线
List<Integer> rowBoundaries = clusterCoordinates(
cells.stream().map(CellBoundary::y).collect(Collectors.toList()), 5);
// 聚类X坐标,找到列分隔线
List<Integer> colBoundaries = clusterCoordinates(
cells.stream().map(CellBoundary::x).collect(Collectors.toList()), 5);
// 构建二维网格
int rows = rowBoundaries.size();
int cols = colBoundaries.size();
List<List<CellBoundary>> grid = new ArrayList<>();
for (int r = 0; r < rows; r++) {
List<CellBoundary> row = new ArrayList<>();
for (int c = 0; c < cols; c++) {
// 找到对应位置的单元格
final int rIdx = r, cIdx = c;
CellBoundary cell = cells.stream()
.filter(cb -> Math.abs(cb.y() - rowBoundaries.get(rIdx)) < 5
&& Math.abs(cb.x() - colBoundaries.get(cIdx)) < 5)
.findFirst()
.orElse(null);
row.add(cell);
}
grid.add(row);
}
return grid;
}
private List<Integer> clusterCoordinates(List<Integer> coords, int tolerance) {
List<Integer> sorted = coords.stream().distinct().sorted().collect(Collectors.toList());
List<Integer> clusters = new ArrayList<>();
if (sorted.isEmpty()) return clusters;
int current = sorted.get(0);
clusters.add(current);
for (int coord : sorted) {
if (coord - current > tolerance) {
current = coord;
clusters.add(current);
}
}
return clusters;
}
public record CellBoundary(int x, int y, int width, int height) {}
}二、VLM驱动的表格理解
对于复杂表格(合并单元格、斜线表头等),VLM是更好的选择:
@Service
public class VLMTableExtractor {
private final VisionService visionService;
private final ObjectMapper objectMapper;
private static final String TABLE_EXTRACTION_PROMPT = """
请仔细分析这张图片中的表格,将其转换为结构化的JSON格式。
要求:
1. 识别表格的行头(第一行)和列头(第一列,如果存在)
2. 处理合并单元格:合并的单元格在JSON中重复填写相同的值
3. 如果单元格为空,值设为null
4. 数字字段保持数字类型,不要转为字符串
5. 金额/百分比等带单位的数值,提取纯数字并在字段名中说明单位
输出格式(JSON数组,每个元素代表一行):
{
"headers": ["列名1", "列名2", ...],
"rows": [
["值1", "值2", ...],
...
],
"notes": "表格备注(如果有)"
}
只返回JSON,不要有任何解释。
""";
public TableData extractTable(byte[] tableImageBytes) {
VisionRequest request = VisionRequest.builder()
.images(List.of(ImageInput.fromBytes(tableImageBytes, "image/jpeg")))
.prompt(TABLE_EXTRACTION_PROMPT)
.build();
String response = visionService.analyzeImage(request).getContent();
try {
// 清理响应(去掉可能的markdown代码块)
String cleanJson = response.replaceAll("```json\\s*", "")
.replaceAll("```\\s*", "").trim();
JsonNode root = objectMapper.readTree(cleanJson);
// 解析表头
List<String> headers = new ArrayList<>();
root.get("headers").forEach(h -> headers.add(h.asText()));
// 解析数据行
List<List<Object>> rows = new ArrayList<>();
root.get("rows").forEach(row -> {
List<Object> rowData = new ArrayList<>();
row.forEach(cell -> {
if (cell.isNull()) {
rowData.add(null);
} else if (cell.isNumber()) {
rowData.add(cell.numberValue());
} else {
rowData.add(cell.asText());
}
});
rows.add(rowData);
});
String notes = root.has("notes") ? root.get("notes").asText() : null;
return new TableData(headers, rows, notes);
} catch (JsonProcessingException e) {
throw new TableExtractionException("表格JSON解析失败", e);
}
}
/**
* 表格数据转Map列表(便于业务层使用)
*/
public List<Map<String, Object>> toMapList(TableData tableData) {
return tableData.rows().stream()
.map(row -> {
Map<String, Object> rowMap = new LinkedHashMap<>();
for (int i = 0; i < tableData.headers().size() && i < row.size(); i++) {
rowMap.put(tableData.headers().get(i), row.get(i));
}
return rowMap;
})
.collect(Collectors.toList());
}
public record TableData(List<String> headers, List<List<Object>> rows, String notes) {}
}三、复杂表格的专项处理
合并单元格的处理
合并单元格是表格提取里最棘手的问题。VLM在理解合并单元格时有时会犯错,需要后处理验证:
@Service
public class MergedCellHandler {
/**
* 检测并展开合并单元格
* 将合并单元格的值复制到所有被合并的位置
*/
public List<List<Object>> expandMergedCells(List<List<Object>> rawRows) {
if (rawRows.isEmpty()) return rawRows;
int maxCols = rawRows.stream()
.mapToInt(List::size)
.max().orElse(0);
// 统一所有行到相同列数(短行用null填充)
List<List<Object>> normalized = rawRows.stream()
.map(row -> {
List<Object> normalized1 = new ArrayList<>(row);
while (normalized1.size() < maxCols) {
normalized1.add(null);
}
return normalized1;
})
.collect(Collectors.toList());
// 向下填充(垂直合并单元格:当前为null时,使用上一行同列的值)
for (int col = 0; col < maxCols; col++) {
Object lastNonNull = null;
for (List<Object> row : normalized) {
if (row.get(col) != null && !row.get(col).toString().isEmpty()) {
lastNonNull = row.get(col);
} else if (lastNonNull != null) {
// 这里需要业务判断:是真的合并单元格,还是真的空值
// 保守策略:不自动填充,保留null
}
}
}
return normalized;
}
}跨页表格的合并
扫描件里经常遇到跨页的表格,需要识别续表并合并:
@Service
public class MultiPageTableMerger {
private final VisionService visionService;
/**
* 判断某页是否是上页表格的续表
*/
public boolean isContinuationTable(byte[] pageImage) {
VisionRequest request = VisionRequest.builder()
.images(List.of(ImageInput.fromBytes(pageImage, "image/jpeg")))
.prompt("""
这张图片的顶部是否有"续表"、"接上页"、"(续)"等标识?
或者这张图是否是一个表格,且表格没有标题行(说明是接上页的续表)?
只回答 true 或 false。
""")
.build();
String response = visionService.analyzeImage(request).getContent().trim().toLowerCase();
return response.contains("true");
}
/**
* 合并多页表格(去掉续页的表头,直接合并数据行)
*/
public TableData mergeTablePages(List<TableData> pages) {
if (pages.isEmpty()) return new TableData(List.of(), List.of(), null);
TableData firstPage = pages.get(0);
List<List<Object>> allRows = new ArrayList<>(firstPage.rows());
// 从第二页开始,如果有表头且和第一页相同,则跳过表头
for (int i = 1; i < pages.size(); i++) {
TableData page = pages.get(i);
// 简单策略:直接追加所有行(假设续表已去掉重复表头)
allRows.addAll(page.rows());
}
String notes = pages.stream()
.map(TableData::notes)
.filter(Objects::nonNull)
.collect(Collectors.joining("; "));
return new TableData(firstPage.headers(), allRows, notes.isEmpty() ? null : notes);
}
public record TableData(List<String> headers, List<List<Object>> rows, String notes) {}
}四、表格数据验证与修正
提取出来的数据不能直接入库,需要做验证:
@Service
public class TableDataValidator {
/**
* 数值一致性检查:合计行的值应等于各行之和
*/
public ValidationResult validateNumericConsistency(List<Map<String, Object>> tableData,
String totalRowIndicator) {
List<String> errors = new ArrayList<>();
// 找到合计行
Optional<Map<String, Object>> totalRow = tableData.stream()
.filter(row -> row.values().stream()
.anyMatch(v -> v != null && v.toString().contains(totalRowIndicator)))
.findFirst();
if (totalRow.isEmpty()) {
return ValidationResult.passed();
}
// 对每个数值列验证合计
List<Map<String, Object>> dataRows = tableData.stream()
.filter(row -> !row.equals(totalRow.get()))
.collect(Collectors.toList());
for (String header : totalRow.get().keySet()) {
Object totalValue = totalRow.get().get(header);
if (!(totalValue instanceof Number)) continue;
double declaredTotal = ((Number) totalValue).doubleValue();
double calculatedTotal = dataRows.stream()
.mapToDouble(row -> {
Object val = row.get(header);
return val instanceof Number ? ((Number) val).doubleValue() : 0;
})
.sum();
if (Math.abs(declaredTotal - calculatedTotal) > 0.01) {
errors.add(String.format("列 '%s' 合计不符: 声明值=%.2f, 计算值=%.2f",
header, declaredTotal, calculatedTotal));
}
}
return errors.isEmpty() ? ValidationResult.passed()
: ValidationResult.failed(errors);
}
public record ValidationResult(boolean passed, List<String> errors) {
public static ValidationResult passed() { return new ValidationResult(true, List.of()); }
public static ValidationResult failed(List<String> errors) {
return new ValidationResult(false, errors);
}
}
}五、工程经验总结
几个实际踩过的坑:
坑1:表格截图很重要
如果一页里有多张表格,不要把整页喂给VLM让它自己找表格。先用版面分析(Layout Analysis)定位每张表格的位置,截图后单独处理。混在一起的结果往往是把多张表格的内容搅在一起。
坑2:VLM对数字的处理
VLM有时会把数字里的逗号(千分位分隔符)和小数点搞混,比如把1,234.56识别为1234.56,或者反过来。建议在Prompt里明确说明:"数值格式:千分位用逗号,小数点用点,如 1,234.56"。
坑3:大表格要分段处理
超过20行的大表格,建议把图片水平切割,每次只处理5-8行,最后合并。VLM处理太长的表格时,后面几行的识别精度会下降。
坑4:提取结果的Schema验证
提取完成后,用JSON Schema对结果做一次结构验证。如果字段类型不符(比如金额字段是字符串),可以尝试自动转换,转换失败则标记为人工复核。
