第2201篇:合同扫描件的AI处理——从图片到结构化合同数据的完整流程
2026/4/30大约 7 分钟
第2201篇:合同扫描件的AI处理——从图片到结构化合同数据的完整流程
适读人群:处理合同数字化的Java工程师或法务技术团队 | 阅读时长:约16分钟 | 核心价值:合同扫描件的端到端AI处理流水线,从图片到结构化JSON的完整实现
一家律所找我们做合同管理系统。他们有几十年的纸质合同,全部扫描成了PDF,但都是图片格式,没有文字层。新来的律师查历史合同要靠人工翻,一份合同少则几十页,多则几百页。
"能不能让AI把合同里的关键信息提取出来,做成数据库?"
这个需求听起来简单,实际做起来有几个难点:
- 合同格式差异极大——不同时期、不同甲方的合同版本格式完全不同
- 手写签名、盖章区域干扰OCR识别
- 法律文本的精确性要求极高,一字之差可能改变法律含义
- 保密性要求——合同内容不能外传给第三方云API
这几个约束加在一起,推导出来的方案:本地化部署OCR + 本地/私有化LLM + 人工审核工作流。
一、合同处理的数据模型设计
先把要提取什么定义清楚:
/**
* 合同结构化数据模型
*/
@Data
@Builder
public class ContractStructure {
// 基本信息
private String contractNumber; // 合同编号
private String contractTitle; // 合同名称
private ContractType contractType; // 合同类型(买卖/服务/劳动等)
private LocalDate signingDate; // 签署日期
private LocalDate effectiveDate; // 生效日期
private LocalDate expirationDate; // 到期日期
// 当事人信息
private Party partyA; // 甲方
private Party partyB; // 乙方
private List<Party> otherParties; // 其他当事人
// 金额信息
private BigDecimal contractAmount; // 合同金额
private String currency; // 货币种类
private PaymentTerms paymentTerms; // 付款条款
// 标的信息
private String subject; // 合同标的描述
private List<String> deliverables; // 交付物清单
// 关键条款
private String liabilityClause; // 违约责任条款(原文)
private String disputeResolution; // 争议解决方式
private String confidentialityClause; // 保密条款
// 提取元数据
private ExtractionMetadata metadata;
@Data
@Builder
public static class Party {
private String name;
private String type; // 企业/个人
private String idNumber; // 统一社会信用代码/身份证号
private String address;
private String legalRepresentative;
private String contactPerson;
}
@Data
@Builder
public static class PaymentTerms {
private String schedule; // 付款时间表
private String method; // 付款方式
private String accountInfo; // 收款账户
}
@Data
@Builder
public static class ExtractionMetadata {
private double ocrConfidence; // OCR平均置信度
private int totalPages;
private int processedPages;
private List<String> uncertainFields; // 提取结果不确定的字段
private String extractionModel;
private LocalDateTime extractedAt;
}
}二、合同图像的预处理
合同扫描件有几个特殊挑战:印章、手写、折痕:
@Component
public class ContractImagePreprocessor {
/**
* 合同专用预处理流水线
*/
public PreprocessResult preprocess(byte[] pageImageBytes) {
Mat image = bytesToMat(pageImageBytes);
// 1. 检测并标记印章区域(圆形红色区域)
List<Rect> stampRegions = detectStamps(image);
// 2. 文档纠偏
Mat deskewed = deskewDocument(image);
// 3. 去除折痕和污渍(形态学操作)
Mat cleaned = cleanArtifacts(deskewed);
// 4. 增强文字对比度
Mat enhanced = enhanceTextContrast(cleaned);
// 5. 检测手写签名区域
List<Rect> handwrittenRegions = detectHandwrittenRegions(enhanced);
return new PreprocessResult(
matToBytes(enhanced),
stampRegions,
handwrittenRegions
);
}
/**
* 检测圆形印章(红色圆形区域)
*/
private List<Rect> detectStamps(Mat image) {
// 提取红色通道
Mat hsv = new Mat();
Imgproc.cvtColor(image, hsv, Imgproc.COLOR_BGR2HSV);
// 红色的HSV范围
Mat redMask1 = new Mat();
Mat redMask2 = new Mat();
Core.inRange(hsv, new Scalar(0, 100, 100), new Scalar(10, 255, 255), redMask1);
Core.inRange(hsv, new Scalar(160, 100, 100), new Scalar(180, 255, 255), redMask2);
Mat redMask = new Mat();
Core.addWeighted(redMask1, 1, redMask2, 1, 0, redMask);
// 检测圆形(霍夫圆变换)
Mat circles = new Mat();
Imgproc.HoughCircles(redMask, circles, Imgproc.HOUGH_GRADIENT,
1, 50, 100, 30, 30, 200);
List<Rect> stampRegions = new ArrayList<>();
for (int i = 0; i < circles.cols(); i++) {
double[] circle = circles.get(0, i);
int cx = (int) circle[0];
int cy = (int) circle[1];
int r = (int) circle[2];
// 转为矩形区域
stampRegions.add(new Rect(cx - r, cy - r, r * 2, r * 2));
}
return stampRegions;
}
/**
* 增强文字对比度(CLAHE自适应直方图均衡化)
*/
private Mat enhanceTextContrast(Mat image) {
Mat gray = new Mat();
Imgproc.cvtColor(image, gray, Imgproc.COLOR_BGR2GRAY);
CLAHE clahe = Imgproc.createCLAHE(2.0, new Size(8, 8));
Mat enhanced = new Mat();
clahe.apply(gray, enhanced);
// 转回BGR
Mat result = new Mat();
Imgproc.cvtColor(enhanced, result, Imgproc.COLOR_GRAY2BGR);
return result;
}
// bytesToMat, matToBytes, deskewDocument, cleanArtifacts 等方法省略
public record PreprocessResult(
byte[] processedImage,
List<Rect> stampRegions,
List<Rect> handwrittenRegions
) {}
}三、合同关键信息提取
提取合同信息需要分阶段进行——先做整体识别,再做关键字段的精确提取:
@Service
public class ContractExtractor {
private final PaddleOCRClient ocrClient;
private final ChatClient chatClient;
private final ContractImagePreprocessor preprocessor;
/**
* 完整合同处理流水线
*/
public ContractStructure extractContract(List<byte[]> pageImages, String contractId) {
// 1. 所有页面预处理 + OCR
StringBuilder fullText = new StringBuilder();
double totalConfidence = 0;
for (int i = 0; i < pageImages.size(); i++) {
PreprocessResult preprocessed = preprocessor.preprocess(pageImages.get(i));
PaddleOCRClient.OCRResult ocrResult = ocrClient.recognize(
preprocessed.processedImage());
fullText.append("=== 第").append(i + 1).append("页 ===\n");
fullText.append(ocrResult.toPlainText()).append("\n\n");
totalConfidence += ocrResult.avgConfidence();
}
double avgConfidence = totalConfidence / pageImages.size();
String contractText = fullText.toString();
// 2. 第一步:提取结构化基本信息
ContractBasicInfo basicInfo = extractBasicInfo(contractText);
// 3. 第二步:提取关键条款(法律文本需要精确提取原文)
ContractClauses clauses = extractKeyClauses(contractText);
// 4. 组装结果
return ContractStructure.builder()
.contractNumber(basicInfo.contractNumber())
.contractTitle(basicInfo.contractTitle())
.contractType(basicInfo.contractType())
.signingDate(basicInfo.signingDate())
.effectiveDate(basicInfo.effectiveDate())
.expirationDate(basicInfo.expirationDate())
.partyA(basicInfo.partyA())
.partyB(basicInfo.partyB())
.contractAmount(basicInfo.contractAmount())
.currency(basicInfo.currency())
.liabilityClause(clauses.liabilityClause())
.disputeResolution(clauses.disputeResolution())
.confidentialityClause(clauses.confidentialityClause())
.metadata(ContractStructure.ExtractionMetadata.builder()
.ocrConfidence(avgConfidence)
.totalPages(pageImages.size())
.processedPages(pageImages.size())
.extractionModel("paddleocr+gpt-4o")
.extractedAt(LocalDateTime.now())
.build())
.build();
}
private ContractBasicInfo extractBasicInfo(String contractText) {
// 只处理前几页(基本信息通常在开头)
String headerText = contractText.length() > 3000
? contractText.substring(0, 3000)
: contractText;
String prompt = """
从以下合同文本中提取基本信息,以JSON格式返回。
注意:
1. 日期格式统一为 YYYY-MM-DD,如无法确定则为null
2. 金额为纯数字(元为单位),无法确定则为null
3. 合同类型从以下选项中选择:SALE(买卖)、SERVICE(服务)、LABOR(劳动)、LEASE(租赁)、LOAN(借款)、OTHER(其他)
提取字段:
{
"contractNumber": "合同编号",
"contractTitle": "合同名称",
"contractType": "合同类型枚举值",
"signingDate": "签署日期",
"effectiveDate": "生效日期",
"expirationDate": "到期日期",
"partyA": {"name": "", "type": "企业/个人", "idNumber": "", "address": "", "legalRepresentative": ""},
"partyB": {"name": "", "type": "企业/个人", "idNumber": "", "address": "", "legalRepresentative": ""},
"contractAmount": null,
"currency": "CNY"
}
合同文本:
""" + headerText + "\n只返回JSON。";
String response = chatClient.prompt()
.user(prompt)
.options(OpenAiChatOptions.builder()
.withResponseFormat(new ResponseFormat(ResponseFormat.Type.JSON_OBJECT))
.withTemperature(0.0f)
.build())
.call()
.content();
return parseBasicInfo(response);
}
private ContractClauses extractKeyClauses(String contractText) {
// 关键条款通常在合同中后段,分段处理
String clausesPrompt = """
从以下合同文本中,原文提取以下条款内容(不要修改原文):
1. 违约责任条款(通常包含"违约"、"赔偿"等关键词)
2. 争议解决条款(通常包含"仲裁"、"诉讼"、"管辖"等关键词)
3. 保密条款(通常包含"保密"、"保密信息"等关键词)
返回JSON格式:
{
"liabilityClause": "违约责任原文(找不到则null)",
"disputeResolution": "争议解决原文",
"confidentialityClause": "保密条款原文"
}
合同文本:
""" + contractText + "\n只返回JSON。";
String response = chatClient.prompt()
.user(clausesPrompt)
.options(OpenAiChatOptions.builder()
.withTemperature(0.0f)
.build())
.call()
.content();
return parseClauses(response);
}
// parseBasicInfo, parseClauses 的JSON解析逻辑省略
private ContractBasicInfo parseBasicInfo(String json) {
throw new UnsupportedOperationException("实现JSON解析");
}
private ContractClauses extractKeyClauses2(String text) {
throw new UnsupportedOperationException();
}
private ContractClauses parseClauses(String json) {
throw new UnsupportedOperationException("实现JSON解析");
}
}四、人工审核工作流
法律文件的提取结果必须有人工审核环节:
@Service
public class ContractReviewWorkflow {
/**
* 生成审核要点:识别AI提取中不确定的字段,给审核人提示
*/
public ReviewChecklist generateReviewChecklist(ContractStructure extracted) {
List<String> checkItems = new ArrayList<>();
// 检查空值字段
if (extracted.getContractNumber() == null) {
checkItems.add("⚠️ 合同编号未识别,请人工填写");
}
if (extracted.getSigningDate() == null) {
checkItems.add("⚠️ 签署日期未识别,请确认");
}
if (extracted.getContractAmount() == null) {
checkItems.add("⚠️ 合同金额未识别,请确认");
}
// OCR置信度低时提示
if (extracted.getMetadata().getOcrConfidence() < 0.8) {
checkItems.add("⚠️ OCR识别置信度较低("
+ String.format("%.1f%%", extracted.getMetadata().getOcrConfidence() * 100)
+ "),建议全文核对");
}
// 法律关键条款提醒
if (extracted.getLiabilityClause() == null) {
checkItems.add("❗ 未找到违约责任条款,请确认合同是否包含");
}
if (extracted.getDisputeResolution() == null) {
checkItems.add("❗ 未找到争议解决条款,请确认管辖法院/仲裁机构");
}
return new ReviewChecklist(checkItems, extracted.getMetadata().getOcrConfidence());
}
public record ReviewChecklist(List<String> checkItems, double ocrConfidence) {
public boolean requiresFullReview() {
return ocrConfidence < 0.75 || checkItems.size() > 3;
}
}
}五、工程部署注意事项
保密性方案
对于合同这种高敏感数据,推荐:
- OCR层:部署私有化PaddleOCR(Docker容器,不出内网)
- LLM层:部署私有化LLM(如Qwen-14B/72B),或使用有数据合规保障的商业API
- 向量存储:内网Milvus,不使用云服务
性能基准
在一台配备A10 GPU的服务器上:
- PaddleOCR处理1页合同:约0.8秒
- LLM提取基本信息:约3-5秒
- 全流程(10页合同):约20-30秒
对于批量处理需求,推荐异步队列模式:合同上传后异步处理,处理完成后推送通知。
