新增身份证识别能力

This commit is contained in:
dengwenjie
2026-03-29 15:28:18 +08:00
parent c8cda3f240
commit 8a6f671703
29 changed files with 2241 additions and 137 deletions

View File

@@ -510,7 +510,7 @@ SmartJavaAI是专为JAVA 开发者打造的一个功能丰富、开箱即用的
<dependency>
<groupId>cn.smartjavaai</groupId>
<artifactId>all</artifactId>
<version>1.1.1</version>
<version>1.1.2</version>
</dependency>
```

View File

@@ -6,11 +6,11 @@
<parent>
<groupId>cn.smartjavaai</groupId>
<artifactId>smartjavaai-parent</artifactId>
<version>1.1.1</version>
<version>1.1.2</version>
</parent>
<artifactId>all</artifactId>
<version>1.1.1</version>
<version>1.1.2</version>
<name>${project.artifactId}</name>
<description>SmartJavaAI</description>
<url>https://github.com/geekwenjie/SmartJavaAI</url>

View File

@@ -6,10 +6,10 @@
<parent>
<groupId>cn.smartjavaai</groupId>
<artifactId>smartjavaai-parent</artifactId>
<version>1.1.1</version>
<version>1.1.2</version>
</parent>
<version>1.1.1</version>
<version>1.1.2</version>
<artifactId>bom</artifactId>
<name>bom</name>
<description>统一版本管理的 BOM 包,同时支持 import 和全量依赖</description>

View File

@@ -6,7 +6,7 @@
<parent>
<groupId>cn.smartjavaai</groupId>
<artifactId>smartjavaai-parent</artifactId>
<version>1.1.1</version>
<version>1.1.2</version>
</parent>
<name>common</name>

View File

@@ -12,7 +12,7 @@
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<smartjavaai.version>1.1.1</smartjavaai.version>
<smartjavaai.version>1.1.2</smartjavaai.version>
<!--如果打包运行需要替换成你的main-->
<exec.mainClass>smartai.examples.face.FaceDemo</exec.mainClass>

View File

@@ -12,9 +12,9 @@
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<smartjavaai.version>1.1.1</smartjavaai.version>
<smartjavaai.version>1.1.2</smartjavaai.version>
<!--如果打包运行需要替换成你的main-->
<exec.mainClass>smartai.examples.ocr.common.OcrRecognizeDemo</exec.mainClass>
<exec.mainClass>smartai.examples.ocr.common.OcrDetectionDemo</exec.mainClass>
<javacv.version>1.5.10</javacv.version>
@@ -90,19 +90,33 @@
<groupId>cn.smartjavaai</groupId>
<artifactId>ocr</artifactId>
<exclusions>
<exclusion>
<groupId>com.microsoft.onnxruntime</groupId>
<artifactId>onnxruntime</artifactId>
</exclusion>
<!-- <exclusion>-->
<!-- <groupId>org.openpnp</groupId>-->
<!-- <artifactId>opencv</artifactId>-->
<!-- </exclusion>-->
<!-- <exclusion>-->
<!-- <groupId>com.microsoft.onnxruntime</groupId>-->
<!-- <artifactId>onnxruntime</artifactId>-->
<!-- </exclusion>-->
<!-- <exclusion>-->
<!-- <groupId>org.bytedeco</groupId>-->
<!-- <artifactId>javacv</artifactId>-->
<!-- </exclusion>-->
</exclusions>
</dependency>
<dependency>
<groupId>com.microsoft.onnxruntime</groupId>
<artifactId>onnxruntime</artifactId>
<version>1.20.0</version>
<scope>runtime</scope>
</dependency>
<!-- <dependency>-->
<!-- <groupId>com.microsoft.onnxruntime</groupId>-->
<!-- <artifactId>onnxruntime</artifactId>-->
<!-- <version>1.16.3</version>-->
<!-- <scope>compile</scope>-->
<!-- </dependency>-->
<!-- <dependency>-->
<!-- <groupId>org.openpnp</groupId>-->
<!-- <artifactId>opencv</artifactId>-->
<!-- <version>3.4.2-2</version>-->
<!-- </dependency>-->
<dependency>
@@ -113,79 +127,10 @@
</dependency>
<!-- windows平台 (保留对应平台的配置,可以减小包大小)-->
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>javacpp</artifactId>
<version>${javacv.version}</version>
<classifier>${javacv.platform.windows-x86_64}</classifier>
</dependency>
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>ffmpeg</artifactId>
<version>6.1.1-1.5.10</version>
<classifier>${javacv.platform.windows-x86_64}</classifier>
</dependency>
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>openblas</artifactId>
<version>0.3.26-1.5.10</version>
<classifier>${javacv.platform.windows-x86_64}</classifier>
</dependency>
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>opencv</artifactId>
<version>4.9.0-1.5.10</version>
<classifier>${javacv.platform.windows-x86_64}</classifier>
</dependency>
<dependency>
<groupId>ai.djl.pytorch</groupId>
<artifactId>pytorch-native-cpu</artifactId>
<classifier>${djl.platform.windows-x86_64}</classifier>
<version>2.7.1</version>
<scope>runtime</scope>
</dependency>
<!-- linux x86 平台 (保留对应平台的配置,可以减小包大小)-->
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>javacpp</artifactId>
<version>${javacv.version}</version>
<classifier>${javacv.platform.linux-x86_64}</classifier>
</dependency>
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>ffmpeg</artifactId>
<version>6.1.1-1.5.10</version>
<classifier>${javacv.platform.linux-x86_64}</classifier>
</dependency>
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>openblas</artifactId>
<version>0.3.26-1.5.10</version>
<classifier>${javacv.platform.linux-x86_64}</classifier>
</dependency>
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>opencv</artifactId>
<version>4.9.0-1.5.10</version>
<classifier>${javacv.platform.linux-x86_64}</classifier>
</dependency>
<dependency>
<groupId>ai.djl.pytorch</groupId>
<artifactId>pytorch-native-cpu</artifactId>
<classifier>${djl.platform.linux-x86_64}</classifier>
<version>2.7.1</version>
<scope>runtime</scope>
</dependency>
<!-- linux aarch64 平台 (保留对应平台的配置,可以减小包大小)-->
<dependency>
@@ -223,42 +168,16 @@
<scope>runtime</scope>
</dependency>
<!-- <dependency>-->
<!-- <groupId>ai.djl.pytorch</groupId>-->
<!-- <artifactId>pytorch-native-cpu-precxx11</artifactId>-->
<!-- <classifier>linux-aarch64</classifier>-->
<!-- <version>2.5.1</version>-->
<!-- <scope>runtime</scope>-->
<!-- </dependency>-->
<!-- macOS M系列 平台 (保留对应平台的配置,可以减小包大小)-->
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>javacpp</artifactId>
<version>${javacv.version}</version>
<classifier>${javacv.platform.macosx-arm64}</classifier>
</dependency>
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>ffmpeg</artifactId>
<version>6.1.1-1.5.10</version>
<classifier>${javacv.platform.macosx-arm64}</classifier>
</dependency>
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>openblas</artifactId>
<version>0.3.26-1.5.10</version>
<classifier>${javacv.platform.macosx-arm64}</classifier>
</dependency>
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>opencv</artifactId>
<version>4.9.0-1.5.10</version>
<classifier>${javacv.platform.macosx-arm64}</classifier>
</dependency>
<dependency>
<groupId>ai.djl.pytorch</groupId>
<artifactId>pytorch-native-cpu</artifactId>
<classifier>${djl.platform.osx-aarch64}</classifier>
<version>2.7.1</version>
<scope>runtime</scope>
</dependency>
</dependencies>

View File

@@ -0,0 +1,216 @@
package smartai.examples.ocr.idcard;
import ai.djl.modality.cv.Image;
import ai.djl.util.JsonUtils;
import cn.smartjavaai.common.config.Config;
import cn.smartjavaai.common.cv.SmartImageFactory;
import cn.smartjavaai.common.enums.DeviceEnum;
import cn.smartjavaai.common.utils.ImageUtils;
import cn.smartjavaai.ocr.config.DirectionModelConfig;
import cn.smartjavaai.ocr.config.OcrDetModelConfig;
import cn.smartjavaai.ocr.config.OcrRecModelConfig;
import cn.smartjavaai.ocr.config.OcrRecOptions;
import cn.smartjavaai.ocr.entity.IdCardBackInfo;
import cn.smartjavaai.ocr.entity.IdCardFrontInfo;
import cn.smartjavaai.ocr.entity.IdCardInfo;
import cn.smartjavaai.ocr.entity.OcrInfo;
import cn.smartjavaai.ocr.enums.CommonDetModelEnum;
import cn.smartjavaai.ocr.enums.CommonRecModelEnum;
import cn.smartjavaai.ocr.enums.DirectionModelEnum;
import cn.smartjavaai.ocr.factory.OcrModelFactory;
import cn.smartjavaai.ocr.idcard.DefaultIdCardRecognizer;
import cn.smartjavaai.ocr.idcard.IdCardPreprocessListener;
import cn.smartjavaai.ocr.model.common.detect.OcrCommonDetModel;
import cn.smartjavaai.ocr.model.common.direction.OcrDirectionModel;
import cn.smartjavaai.ocr.model.common.recognize.OcrCommonRecModel;
import lombok.extern.slf4j.Slf4j;
import org.junit.BeforeClass;
import org.junit.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* 身份证识别 demo
* 使用说明:
* 1、先下载 OCR 模型
* 2、把下面的模型路径改成你自己的本地路径
* 3、把身份证图片路径改成你自己的图片路径
* 4、优先运行 recognizeFront() / recognizeBack() 查看结构化结果
*
* 模型下载地址https://pan.baidu.com/s/1MLfd73Vjdpnuls9-oqc9uw?pwd=1234 提取码: 1234
* 开发文档http://doc.smartjavaai.cn/
* @author dwj
*/
@Slf4j
public class IdCardRecDemo {
// 设备类型
public static DeviceEnum device = DeviceEnum.CPU;
// 下载模型后,请替换成你自己的模型路径
private static final String DET_MODEL_PATH =
"/Users/wenjie/Documents/develop/model/ocr/PP-OCRv5_server_det_infer/PP-OCRv5_server_det.onnx";
private static final String REC_MODEL_PATH =
"/Users/wenjie/Documents/develop/model/ocr/PP-OCRv5_server_rec_infer/PP-OCRv5_server_rec.onnx";
private static final String DIRECTION_MODEL_PATH =
"/Users/wenjie/Documents/develop/model/ocr/PP-LCNet_x0_25_textline_ori_infer/PP-LCNet_x0_25_textline_ori_infer.onnx";
// 这里改成你自己的身份证图片路径
private static final String FRONT_IMAGE_PATH = "src/main/resources/idcard/idcard_front1.png";
private static final String BACK_IMAGE_PATH = "src/main/resources/idcard/idcard_back1.png";
@BeforeClass
public static void beforeAll() throws IOException {
// 修改缓存路径
// Config.setCachePath("/Users/xxx/smartjavaai_cache");
}
/**
* 获取文本检测模型
* @return
*/
public OcrCommonDetModel getDetectionModel() {
OcrDetModelConfig config = new OcrDetModelConfig();
// 文本检测模型,切换模型需要同时修改 modelEnum 及 modelPath
config.setModelEnum(CommonDetModelEnum.PP_OCR_V4_SERVER_DET_MODEL);
// 下载模型并替换本地路径
config.setDetModelPath(DET_MODEL_PATH);
config.setDevice(device);
return OcrModelFactory.getInstance().getDetModel(config);
}
/**
* 获取方向检测模型
* @return
*/
public OcrDirectionModel getDirectionModel() {
DirectionModelConfig directionModelConfig = new DirectionModelConfig();
// 行文本方向检测模型,切换模型需要同时修改 modelEnum 及 modelPath
directionModelConfig.setModelEnum(DirectionModelEnum.PP_LCNET_X0_25);
// 下载模型并替换本地路径
directionModelConfig.setModelPath(DIRECTION_MODEL_PATH);
directionModelConfig.setTextDetModel(getDetectionModel());
directionModelConfig.setDevice(device);
return OcrModelFactory.getInstance().getDirectionModel(directionModelConfig);
}
/**
* 获取 OCR 识别模型
* @return
*/
public OcrCommonRecModel getRecModel() {
OcrRecModelConfig recModelConfig = new OcrRecModelConfig();
// 文本识别模型,切换模型需要同时修改 modelEnum 及 modelPath
recModelConfig.setRecModelEnum(CommonRecModelEnum.PP_OCR_V5_MOBILE_REC_MODEL);
// 下载模型并替换本地路径
recModelConfig.setRecModelPath(REC_MODEL_PATH);
recModelConfig.setTextDetModel(getDetectionModel());
recModelConfig.setDevice(device);
return OcrModelFactory.getInstance().getRecModel(recModelConfig);
}
/**
* 创建身份证识别器
* @return
*/
public DefaultIdCardRecognizer getIdCardRecognizer() {
return new DefaultIdCardRecognizer()
.setRecModel(getRecModel())
// 身份证识别建议按行返回,方向矫正交给预处理逻辑处理
.setRecOptions(new OcrRecOptions(false, true))
.setDirectionModel(getDirectionModel())
.setEnablePreprocess(true);
}
/**
* 身份证正面识别
* 可识别:姓名、性别、民族、出生日期、住址、身份证号
*/
@Test
public void recognizeFront() {
try {
DefaultIdCardRecognizer recognizer = getIdCardRecognizer();
Image image = SmartImageFactory.getInstance().fromFile(FRONT_IMAGE_PATH);
IdCardFrontInfo result = recognizer.recognizeFront(image);
log.info("身份证正面识别结果:{}", JsonUtils.toJson(result));
} catch (Exception e) {
log.error("身份证正面识别失败", e);
}
}
/**
* 身份证反面识别
* 可识别:签发机关、有效期开始时间、有效期结束时间
*/
@Test
public void recognizeBack() {
try {
DefaultIdCardRecognizer recognizer = getIdCardRecognizer();
Image image = SmartImageFactory.getInstance().fromFile(BACK_IMAGE_PATH);
IdCardBackInfo result = recognizer.recognizeBack(image);
log.info("身份证反面识别结果:{}", JsonUtils.toJson(result));
} catch (Exception e) {
log.error("身份证反面识别失败", e);
}
}
/**
* 同时识别身份证正反面
*/
@Test
public void recognizeBoth() {
try {
DefaultIdCardRecognizer recognizer = getIdCardRecognizer();
Image frontImage = SmartImageFactory.getInstance().fromFile(FRONT_IMAGE_PATH);
Image backImage = SmartImageFactory.getInstance().fromFile(BACK_IMAGE_PATH);
IdCardInfo result = recognizer.recognizeBoth(frontImage, backImage);
log.info("身份证正反面识别结果:{}", JsonUtils.toJson(result));
} catch (Exception e) {
log.error("身份证正反面识别失败", e);
}
}
/**
* 身份证识别并输出调试图片
* 适合排查“为什么没识别出来”,不适合生产环境使用
*/
@Test
public void recognizeFrontWithDebugImages() {
try {
Path debugDir = Paths.get("output/idcard_debug");
Files.createDirectories(debugDir);
String filePrefix = "idcard_front";
DefaultIdCardRecognizer recognizer = getIdCardRecognizer()
// .setPreprocessListener(createDebugListener(debugDir, "idcard_front"));
.setPreprocessListener(new IdCardPreprocessListener() {
@Override
public void onAfterDirection(Image image) {
saveDebugImage(image, debugDir.resolve(filePrefix + "_step1_direction.png"));
}
@Override
public void onAfterRecognize(Image image, OcrInfo ocrInfo) {
saveDebugImage(image, debugDir.resolve(filePrefix + "_step2_ocr_boxes.png"));
}
});
Image image = SmartImageFactory.getInstance().fromFile(FRONT_IMAGE_PATH);
IdCardFrontInfo result = recognizer.recognizeFront(image);
log.info("身份证正面识别结果:{}", JsonUtils.toJson(result));
log.info("调试图片已输出到:{}", debugDir.toAbsolutePath());
} catch (Exception e) {
log.error("身份证调试识别失败", e);
}
}
private void saveDebugImage(Image image, Path outputPath) {
try {
ImageUtils.save(image, outputPath, "png");
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 997 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

View File

@@ -12,7 +12,7 @@
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<smartjavaai.version>1.1.1</smartjavaai.version>
<smartjavaai.version>1.1.2</smartjavaai.version>
<!--如果打包运行需要替换成你的main-->
<exec.mainClass>smartai.examples.speech.asr.common.OcrRecognizeDemo</exec.mainClass>

View File

@@ -12,7 +12,7 @@
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<smartjavaai.version>1.1.1</smartjavaai.version>
<smartjavaai.version>1.1.2</smartjavaai.version>
<!--如果打包运行需要替换成你的main-->
<exec.mainClass>smartai.examples.nlp.translation.TranslationDemo</exec.mainClass>

View File

@@ -12,7 +12,7 @@
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<smartjavaai.version>1.1.1</smartjavaai.version>
<smartjavaai.version>1.1.2</smartjavaai.version>
<!--如果打包运行需要替换成你的main-->
<exec.mainClass>smartai.examples.vision.ObjectDetectionDemo</exec.mainClass>

View File

@@ -6,11 +6,11 @@
<parent>
<groupId>cn.smartjavaai</groupId>
<artifactId>smartjavaai-parent</artifactId>
<version>1.1.1</version>
<version>1.1.2</version>
</parent>
<artifactId>face</artifactId>
<version>1.1.1</version>
<version>1.1.2</version>
<name>face</name>
<description>SmartJavaAI</description>
<url>https://github.com/geekwenjie/SmartJavaAI</url>

View File

@@ -6,7 +6,7 @@
<parent>
<groupId>cn.smartjavaai</groupId>
<artifactId>smartjavaai-parent</artifactId>
<version>1.1.1</version>
<version>1.1.2</version>
</parent>
<artifactId>ocr</artifactId>
@@ -42,7 +42,7 @@
</dependency>
</dependencies>
<version>1.1.1</version>
<version>1.1.2</version>
<name>ocr</name>
<description>SmartJavaAI</description>
<url>https://github.com/geekwenjie/SmartJavaAI</url>

View File

@@ -1,12 +1,26 @@
package cn.smartjavaai.ocr.entity;
import lombok.Data;
/**
* 身份证信息
* 身份证信息(聚合正反面)
*
* 当前主要使用正面信息,预留反面字段,方便后续扩展。
*
* @author dwj
* @date 2025/5/22
*/
@Data
public class IdCardInfo {
/**
* 身份证正面信息
*/
private IdCardFrontInfo front;
/**
* 身份证反面信息
*/
private IdCardBackInfo back;
}

View File

@@ -0,0 +1,203 @@
package cn.smartjavaai.ocr.idcard;
import ai.djl.modality.cv.Image;
import ai.djl.util.JsonUtils;
import cn.smartjavaai.common.utils.ImageUtils;
import cn.smartjavaai.ocr.config.OcrRecOptions;
import cn.smartjavaai.ocr.entity.IdCardBackInfo;
import cn.smartjavaai.ocr.entity.IdCardFrontInfo;
import cn.smartjavaai.ocr.entity.IdCardInfo;
import cn.smartjavaai.ocr.entity.OcrItem;
import cn.smartjavaai.ocr.entity.OcrInfo;
import cn.smartjavaai.ocr.model.common.direction.OcrDirectionModel;
import cn.smartjavaai.ocr.model.common.recognize.OcrCommonRecModel;
import cn.smartjavaai.ocr.utils.OcrUtils;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import lombok.experimental.Accessors;
import java.util.List;
/**
* 身份证 OCR 识别服务实现
*
* - 支持从 Image 开始:内部完成预处理(方向矫正 OcrDirectionModel+ OCR + 解析
* - 支持从 OcrInfo 开始:只做结构化解析
*
* 通过 Lombok @Accessors(chain = true) 支持链式设置依赖。
*/
@Slf4j
@Data
@Accessors(chain = true)
public class DefaultIdCardRecognizer implements IdCardRecognizer {
private static final int DEBUG_DRAW_FONT_SIZE = 20;
/**
* 文本识别模型(可选;仅当从 Image 开始时需要)
*/
private OcrCommonRecModel recModel;
/**
* 识别选项(如是否方向矫正、是否按行返回等)
*/
private OcrRecOptions recOptions;
/**
* 方向检测模型(可选;预处理时用于 0/90/180/270° 整图方向矫正)。
* 未设置则跳过方向矫正。
*/
private OcrDirectionModel directionModel;
/**
* 图像预处理器(方向矫正,可选,默认按需 lazy-init
*/
private IdCardPreprocessor preprocessor;
/**
* 正面解析器(默认按需 lazy-init 为 IdCardFrontParser
*/
private IdCardParser<IdCardFrontInfo> frontParser;
/**
* 反面解析器(默认按需 lazy-init 为 IdCardBackParser
*/
private IdCardParser<IdCardBackInfo> backParser;
/**
* 校验器(可选,默认按需 lazy-init 为 IdCardValidator
*/
private IdCardValidator validator;
/**
* 是否进行图像预处理(方向矫正)。默认 true。
* 若图片已校正,可设为 false 以跳过预处理、节省时间。
*/
private boolean enablePreprocess = true;
/**
* 预处理调试监听器(可选)。
* 若设置,则在方向矫正阶段结束后回调当前图像,
* 方便最终用户在需要时保存中间结果进行排查和可视化调试。
* 默认 null不影响正常业务逻辑。
*/
private IdCardPreprocessListener preprocessListener;
@Override
public IdCardFrontInfo recognizeFront(Image image) {
if (recModel == null) {
throw new IllegalStateException("recModel 未配置,无法从 Image 执行身份证识别,请先设置 recModel 或改用 recognizeFront(OcrInfo)。");
}
long totalStart = System.nanoTime();
Image toRecognize = image;
IdCardPreprocessResult preprocessResult = null;
if (enablePreprocess) {
long preprocessStart = System.nanoTime();
IdCardPreprocessor pp = (preprocessor != null) ? preprocessor : (preprocessor = new IdCardPreprocessor());
preprocessResult = pp.preprocess(image, recModel.getTextDetModel(), directionModel, preprocessListener);
toRecognize = preprocessResult.getProcessedImage();
log.debug("身份证正面预处理耗时={}ms", elapsedMillis(preprocessStart));
}
long recognizeStart = System.nanoTime();
OcrInfo ocrInfo = preprocessResult != null
&& !preprocessResult.isRotated()
&& preprocessResult.getReusableBoxes() != null
&& !preprocessResult.getReusableBoxes().isEmpty()
? recModel.recognize(toRecognize, preprocessResult.getReusableBoxes(), recOptions)
: recModel.recognize(toRecognize, recOptions);
log.debug("身份证正面OCR模型调用耗时={}ms", elapsedMillis(recognizeStart));
notifyAfterRecognize(toRecognize, ocrInfo);
IdCardFrontInfo info = recognizeFront(ocrInfo);
log.debug("身份证正面总耗时={}ms", elapsedMillis(totalStart));
return info;
}
@Override
public IdCardBackInfo recognizeBack(Image image) {
if (recModel == null) {
throw new IllegalStateException("recModel 未配置,无法从 Image 执行身份证识别,请先设置 recModel 或改用 recognizeBack(OcrInfo)。");
}
long totalStart = System.nanoTime();
Image toRecognize = image;
IdCardPreprocessResult preprocessResult = null;
if (enablePreprocess) {
long preprocessStart = System.nanoTime();
IdCardPreprocessor pp = (preprocessor != null) ? preprocessor : (preprocessor = new IdCardPreprocessor());
preprocessResult = pp.preprocess(image, recModel.getTextDetModel(), directionModel, preprocessListener);
toRecognize = preprocessResult.getProcessedImage();
log.debug("身份证反面预处理耗时={}ms", elapsedMillis(preprocessStart));
}
long recognizeStart = System.nanoTime();
OcrInfo ocrInfo = preprocessResult != null
&& !preprocessResult.isRotated()
&& preprocessResult.getReusableBoxes() != null
&& !preprocessResult.getReusableBoxes().isEmpty()
? recModel.recognize(toRecognize, preprocessResult.getReusableBoxes(), recOptions)
: recModel.recognize(toRecognize, recOptions);
log.debug("身份证反面OCR模型调用耗时={}ms", elapsedMillis(recognizeStart));
notifyAfterRecognize(toRecognize, ocrInfo);
IdCardBackInfo info = recognizeBack(ocrInfo);
log.debug("身份证反面总耗时={}ms", elapsedMillis(totalStart));
return info;
}
private void notifyAfterRecognize(Image toRecognize, OcrInfo ocrInfo) {
if (preprocessListener == null || toRecognize == null || ocrInfo == null) {
return;
}
try {
//打印
log.debug("身份证 OCR 调试结果:{}", JsonUtils.toJson(ocrInfo));
Image drawImage = ImageUtils.copy(toRecognize);
List<OcrItem> ocrItems = ocrInfo.getOcrItemList();
if ((ocrItems == null || ocrItems.isEmpty()) && ocrInfo.getLineList() != null) {
ocrItems = ocrInfo.flattenLines();
}
if (ocrItems != null && !ocrItems.isEmpty()) {
OcrUtils.drawOcrResult(drawImage, ocrItems, DEBUG_DRAW_FONT_SIZE);
}
preprocessListener.onAfterRecognize(drawImage, ocrInfo);
} catch (Exception e) {
log.warn("身份证 OCR 调试结果绘制失败,跳过 onAfterRecognize 回调。", e);
}
}
@Override
public IdCardFrontInfo recognizeFront(OcrInfo ocrInfo) {
long start = System.nanoTime();
IdCardParser<IdCardFrontInfo> parser =
(frontParser != null) ? frontParser : (frontParser = new IdCardFrontParser());
IdCardFrontInfo info = parser.parse(ocrInfo);
IdCardValidator v = (validator != null) ? validator : (validator = new IdCardValidator());
v.validateFront(info);
return info;
}
@Override
public IdCardBackInfo recognizeBack(OcrInfo ocrInfo) {
IdCardParser<IdCardBackInfo> parser =
(backParser != null) ? backParser : (backParser = new IdCardBackParser());
IdCardBackInfo info = parser.parse(ocrInfo);
IdCardValidator v = (validator != null) ? validator : (validator = new IdCardValidator());
v.validateBack(info);
return info;
}
@Override
public IdCardInfo recognizeBoth(Image frontImage, Image backImage) {
long totalStart = System.nanoTime();
IdCardInfo info = new IdCardInfo();
info.setFront(recognizeFront(frontImage));
info.setBack(recognizeBack(backImage));
IdCardValidator v = (validator != null) ? validator : (validator = new IdCardValidator());
v.validate(info);
log.debug("身份证正反面识别总耗时={}ms", elapsedMillis(totalStart));
return info;
}
private long elapsedMillis(long startNanos) {
return (System.nanoTime() - startNanos) / 1_000_000;
}
}

View File

@@ -0,0 +1,335 @@
package cn.smartjavaai.ocr.idcard;
import cn.smartjavaai.ocr.entity.IdCardBackInfo;
import cn.smartjavaai.ocr.entity.OcrBox;
import cn.smartjavaai.ocr.entity.OcrInfo;
import cn.smartjavaai.ocr.entity.OcrItem;
import cn.smartjavaai.ocr.utils.BoxUtils;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* 身份证反面解析逻辑。
*
* 解析:
* - 签发机关
* - 有效期限(起止日期 / 长期)
*/
public class IdCardBackParser implements IdCardParser<IdCardBackInfo> {
private static final Pattern VALID_PERIOD_PATTERN = Pattern.compile(
"((?:19|20)\\d{2})[./-]?((?:1[0-2])|(?:0[1-9]))[./-]?((?:3[01])|(?:[12]\\d)|(?:0[1-9]))" +
"\\s*[-一至到~]\\s*" +
"(((?:19|20)\\d{2})[./-]?((?:1[0-2])|(?:0[1-9]))[./-]?((?:3[01])|(?:[12]\\d)|(?:0[1-9]))|长期)"
);
@Override
public IdCardBackInfo parse(OcrInfo ocrInfo) {
IdCardBackInfo info = new IdCardBackInfo();
if (ocrInfo == null || ocrInfo.getLineList() == null || ocrInfo.getLineList().isEmpty()) {
return info;
}
List<OcrItem> allItems = ocrInfo.getLineList().stream()
.filter(Objects::nonNull)
.flatMap(List::stream)
.filter(Objects::nonNull)
.collect(Collectors.toList());
allItems = filterSmallBoxes(allItems);
if (allItems.isEmpty()) {
return info;
}
Map<OcrBox, OcrItem> itemByBox = buildBoxItemMap(allItems);
OcrItem authorityLabel = findLabel(allItems, Arrays.asList("签发机关", "签发機关", "签发机関", "签发"));
if (authorityLabel != null) {
String authority = findIssuingAuthority(authorityLabel, allItems, itemByBox);
info.setIssuingAuthority(authority);
}
OcrItem validLabel = findLabel(allItems, Arrays.asList("有效期限", "有效期", "有效期限:", "有效期限:"));
if (validLabel != null) {
ValidPeriod validPeriod = findValidPeriod(validLabel, allItems, itemByBox);
if (validPeriod != null) {
info.setValidFrom(validPeriod.validFrom);
info.setValidTo(validPeriod.validTo);
}
}
if (info.getIssuingAuthority() == null || info.getIssuingAuthority().isEmpty()) {
info.setIssuingAuthority(findIssuingAuthorityByGlobalFallback(allItems));
}
if (info.getValidFrom() == null || info.getValidTo() == null) {
ValidPeriod fallback = findValidPeriodByGlobalFallback(allItems);
if (fallback != null) {
if (info.getValidFrom() == null) {
info.setValidFrom(fallback.validFrom);
}
if (info.getValidTo() == null) {
info.setValidTo(fallback.validTo);
}
}
}
return info;
}
private String findIssuingAuthority(OcrItem authorityLabel, List<OcrItem> allItems, Map<OcrBox, OcrItem> itemByBox) {
String selfAuthority = extractIssuingAuthority(authorityLabel.getText());
if (selfAuthority != null) {
return selfAuthority;
}
List<OcrBox> boxList = allItems.stream()
.map(OcrItem::getOcrBox)
.filter(Objects::nonNull)
.collect(Collectors.toList());
OcrBox anchorBox = authorityLabel.getOcrBox();
String authority = findAuthorityByDirection(anchorBox, itemByBox, boxList, BoxUtils.Direction.RIGHT, 3);
if (authority != null) {
return authority;
}
authority = findAuthorityByDirection(anchorBox, itemByBox, boxList, BoxUtils.Direction.DOWN, 3);
if (authority != null) {
return authority;
}
authority = findAuthorityByDirection(anchorBox, itemByBox, boxList, BoxUtils.Direction.UP, 2);
if (authority != null) {
return authority;
}
return null;
}
private String findAuthorityByDirection(OcrBox anchorBox,
Map<OcrBox, OcrItem> itemByBox,
List<OcrBox> boxList,
BoxUtils.Direction direction,
int limit) {
List<OcrBox> neighbors = BoxUtils.findNearestBoxes(anchorBox, boxList, direction, limit);
if (neighbors == null || neighbors.isEmpty()) {
return null;
}
List<OcrItem> neighborItems = neighbors.stream()
.map(itemByBox::get)
.filter(Objects::nonNull)
.collect(Collectors.toList());
for (OcrItem item : neighborItems) {
String authority = extractIssuingAuthority(item.getText());
if (authority != null) {
return authority;
}
}
String merged = neighborItems.stream()
.map(OcrItem::getText)
.filter(Objects::nonNull)
.map(this::normalizeText)
.collect(Collectors.joining());
return extractIssuingAuthority(merged);
}
private String extractIssuingAuthority(String text) {
if (text == null || text.isEmpty()) {
return null;
}
String clean = normalizeText(text)
.replace("签发机关", "")
.replace("签发機关", "")
.replace("签发机関", "")
.replace("签发", "");
if (clean.isEmpty()) {
return null;
}
if (clean.contains("有效期限") || clean.contains("有效期")) {
return null;
}
return clean;
}
private ValidPeriod findValidPeriod(OcrItem validLabel, List<OcrItem> allItems, Map<OcrBox, OcrItem> itemByBox) {
ValidPeriod selfPeriod = extractValidPeriod(validLabel.getText());
if (selfPeriod != null) {
return selfPeriod;
}
List<OcrBox> boxList = allItems.stream()
.map(OcrItem::getOcrBox)
.filter(Objects::nonNull)
.collect(Collectors.toList());
OcrBox anchorBox = validLabel.getOcrBox();
ValidPeriod period = findValidPeriodByDirection(anchorBox, itemByBox, boxList, BoxUtils.Direction.RIGHT, 4);
if (period != null) {
return period;
}
period = findValidPeriodByDirection(anchorBox, itemByBox, boxList, BoxUtils.Direction.DOWN, 4);
if (period != null) {
return period;
}
period = findValidPeriodByDirection(anchorBox, itemByBox, boxList, BoxUtils.Direction.UP, 2);
if (period != null) {
return period;
}
return null;
}
private ValidPeriod findValidPeriodByDirection(OcrBox anchorBox,
Map<OcrBox, OcrItem> itemByBox,
List<OcrBox> boxList,
BoxUtils.Direction direction,
int limit) {
List<OcrBox> neighbors = BoxUtils.findNearestBoxes(anchorBox, boxList, direction, limit);
if (neighbors == null || neighbors.isEmpty()) {
return null;
}
List<OcrItem> neighborItems = neighbors.stream()
.map(itemByBox::get)
.filter(Objects::nonNull)
.collect(Collectors.toList());
for (OcrItem item : neighborItems) {
ValidPeriod period = extractValidPeriod(item.getText());
if (period != null) {
return period;
}
}
String merged = neighborItems.stream()
.map(OcrItem::getText)
.filter(Objects::nonNull)
.map(this::normalizeText)
.collect(Collectors.joining());
return extractValidPeriod(merged);
}
private ValidPeriod extractValidPeriod(String text) {
if (text == null || text.isEmpty()) {
return null;
}
String clean = normalizeText(text)
.replace("有效期限", "")
.replace("有效期", "");
Matcher matcher = VALID_PERIOD_PATTERN.matcher(clean);
if (!matcher.find()) {
return null;
}
String from = normalizeDate(matcher.group(1), matcher.group(2), matcher.group(3));
if (from == null) {
return null;
}
String toRaw = matcher.group(4);
String to = "长期".equals(toRaw)
? "长期"
: normalizeDate(matcher.group(5), matcher.group(6), matcher.group(7));
if (to == null) {
return null;
}
return new ValidPeriod(from, to);
}
private String normalizeDate(String year, String month, String day) {
if (year == null || month == null || day == null) {
return null;
}
return year + "-" + month + "-" + day;
}
private String findIssuingAuthorityByGlobalFallback(List<OcrItem> allItems) {
for (OcrItem item : allItems) {
String authority = extractIssuingAuthority(item.getText());
if (authority != null && (authority.contains("公安局") || authority.contains("分局") || authority.contains("机关"))) {
return authority;
}
}
return null;
}
private ValidPeriod findValidPeriodByGlobalFallback(List<OcrItem> allItems) {
List<String> texts = new ArrayList<>();
for (OcrItem item : allItems) {
if (item != null && item.getText() != null) {
ValidPeriod period = extractValidPeriod(item.getText());
if (period != null) {
return period;
}
texts.add(normalizeText(item.getText()));
}
}
return extractValidPeriod(String.join("", texts));
}
private OcrItem findLabel(List<OcrItem> allItems, List<String> keywords) {
for (OcrItem item : allItems) {
String text = item.getText();
if (text == null) {
continue;
}
String clean = normalizeText(text);
for (String keyword : keywords) {
if (clean.contains(keyword)) {
return item;
}
}
}
return null;
}
private String normalizeText(String text) {
if (text == null) {
return "";
}
return text.replace(" ", "")
.replace("", "-")
.replace("", "-")
.replace("", "-")
.replace("~", "-")
.replace("", "-")
.replace("", "-");
}
private List<OcrItem> filterSmallBoxes(List<OcrItem> items) {
List<OcrItem> result = IdCardOcrUtils.filterSmallBoxes(items, 0.15);
return result;
}
private Map<OcrBox, OcrItem> buildBoxItemMap(List<OcrItem> items) {
Map<OcrBox, OcrItem> itemByBox = new IdentityHashMap<>();
if (items == null) {
return itemByBox;
}
for (OcrItem item : items) {
if (item != null && item.getOcrBox() != null) {
itemByBox.put(item.getOcrBox(), item);
}
}
return itemByBox;
}
private static class ValidPeriod {
private final String validFrom;
private final String validTo;
private ValidPeriod(String validFrom, String validTo) {
this.validFrom = validFrom;
this.validTo = validTo;
}
}
}

View File

@@ -0,0 +1,930 @@
package cn.smartjavaai.ocr.idcard;
import cn.smartjavaai.common.entity.Point;
import cn.smartjavaai.ocr.entity.IdCardFrontInfo;
import cn.smartjavaai.ocr.entity.OcrBox;
import cn.smartjavaai.ocr.entity.OcrInfo;
import cn.smartjavaai.ocr.entity.OcrItem;
import cn.smartjavaai.ocr.utils.BoxUtils;
import lombok.extern.slf4j.Slf4j;
import java.time.DateTimeException;
import java.time.LocalDate;
import java.util.*;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* 身份证正面解析逻辑。
*
* 从 OCR 结果中解析:
* - 姓名(兼容“姓名+值在同一检测框”与“键值分框”的情况)
* - 性别
* - 民族
* - 出生日期
* - 公民身份号码
* - 住址
*/
@Slf4j
public class IdCardFrontParser implements IdCardParser<IdCardFrontInfo> {
private static final Pattern BIRTHDAY_WITH_SEPARATORS = Pattern.compile(
"((?:19|20)\\d{2})[年/._-]?((?:1[0-2])|(?:0?[1-9]))[月/._-]?((?:3[01])|(?:[12]\\d)|(?:0?[1-9]))日?"
);
private static final Pattern BIRTHDAY_COMPACT = Pattern.compile(
"((?:19|20)\\d{2})((?:1[0-2])|(?:0[1-9]))((?:3[01])|(?:[12]\\d)|(?:0[1-9]))"
);
@Override
public IdCardFrontInfo parse(OcrInfo ocrInfo) {
IdCardFrontInfo info = new IdCardFrontInfo();
if (ocrInfo == null || ocrInfo.getLineList() == null || ocrInfo.getLineList().isEmpty()) {
return info;
}
// 扁平化所有 item
List<OcrItem> allItems = ocrInfo.getLineList().stream()
.filter(Objects::nonNull)
.flatMap(List::stream)
.filter(Objects::nonNull)
.collect(Collectors.toList());
// 过滤明显过小的检测框(噪声)
allItems = filterSmallBoxes(allItems);
// 可用的检测框列表:解析出字段后逐步移除,减少后续遍历量
List<OcrItem> remainingItems = new ArrayList<>(allItems);
Map<OcrBox, OcrItem> itemByBox = buildBoxItemMap(remainingItems);
// 1. 姓名(兼容“标签+值同框”以及右侧单独值框)
OcrItem nameLabel = findLabel(remainingItems, Arrays.asList("姓名", "姓名:", "姓名:"));
if (nameLabel != null) {
StringBuilder nameSb = new StringBuilder();
if (nameLabel.getText() != null) {
nameSb.append(nameLabel.getText());
}
// 右侧最近一个框(可能是姓名值)
OcrItem rightItem = findNearestRightItem(nameLabel, remainingItems, itemByBox);
if (rightItem != null && rightItem.getText() != null) {
nameSb.append(rightItem.getText());
}
// 过滤:只保留中文字符
String nameText = nameSb.toString()
.replace(" ", "")
.replaceAll("[^\\u4e00-\\u9fa5]", "");
// 提取"姓名"后面的内容作为名字
String name = extractNameAfterLabel(nameText);
if (name != null && !name.isEmpty()) {
info.setName(name);
remainingItems.remove(nameLabel);
if (rightItem != null) {
remainingItems.remove(rightItem);
}
itemByBox = buildBoxItemMap(remainingItems);
}
}
// 2. 性别(优先右 → 上 → 下 → 全局)
OcrItem genderLabel = findLabel(remainingItems, Arrays.asList("性别", "性别:", "性别:"));
if (genderLabel != null) {
String gender = findGenderAroundLabel(genderLabel, remainingItems, itemByBox);
if (gender != null) {
info.setGender(gender);
final String g = gender;
// 移除性别相关检测框(若同时包含民族信息则保留用于民族解析)
remainingItems.removeIf(it -> {
String t = it.getText();
if (t == null) {
return false;
}
String clean = t.replace(" ", "");
if (clean.contains("性别") && !clean.contains("民族")) {
return true;
}
String cg = cleanGender(t);
return g.equals(cg) && !clean.contains("民族");
});
itemByBox = buildBoxItemMap(remainingItems);
}
}
// 3. 民族(策略同性别)
OcrItem ethnicLabel = findLabel(remainingItems, Arrays.asList("民族", "民族:", "民族:"));
if (ethnicLabel != null) {
String ethnicity = findEthnicityAroundLabel(ethnicLabel, remainingItems, itemByBox);
if (ethnicity != null) {
info.setEthnicity(ethnicity);
final String eth = ethnicity;
remainingItems.removeIf(it -> {
String t = it.getText();
if (t == null) {
return false;
}
String clean = t.replace(" ", "");
if (clean.contains("民族")) {
return true;
}
String e = extractEthnicity(t);
return eth.equals(e);
});
itemByBox = buildBoxItemMap(remainingItems);
}
}
// 4. 公民身份号码
OcrItem idSourceItem = null;
OcrItem idRightItem = null;
for (OcrItem item : remainingItems) {
String text = normalizeText(item.getText());
if (text.contains("公民身份号码") || text.contains("公民身份號碼") || text.toUpperCase().contains("ID")) {
String idInBox = extractIdNumber(text);
if (idInBox == null) {
OcrItem idRight = findNearestRightItem(item, remainingItems, itemByBox);
if (idRight != null) {
idInBox = extractIdNumber(normalizeText(idRight.getText()));
idRightItem = idRight;
}
}
if (idInBox != null) {
info.setIdNumber(idInBox);
idSourceItem = item;
break;
}
}
}
if (idSourceItem != null) {
remainingItems.remove(idSourceItem);
}
if (idRightItem != null) {
remainingItems.remove(idRightItem);
}
if (idSourceItem != null || idRightItem != null) {
itemByBox = buildBoxItemMap(remainingItems);
}
// 5. 出生日期:考虑多框拆分 & 与标签同框等多种情况
OcrItem birthLabel = findLabel(remainingItems, Arrays.asList("出生", "出生日期", "出生:", "出生:"));
if (birthLabel != null) {
String birth = findBirthdayAroundLabel(birthLabel, remainingItems, itemByBox);
if (birth != null) {
info.setBirthday(birth);
final String bFinal = birth;
remainingItems.removeIf(it -> {
String t = it.getText();
if (t == null) {
return false;
}
String clean = t.replace(" ", "");
if (clean.contains("出生")) {
return true;
}
String ex = extractBirthday(t);
return bFinal.equals(ex);
});
itemByBox = buildBoxItemMap(remainingItems);
}
}
// 如果生日没识别到,则尝试从身份证号码中推断
if (info.getBirthday() == null && info.getIdNumber() != null && info.getIdNumber().length() >= 14) {
String id = info.getIdNumber();
String yyyyMMdd = id.substring(6, 14);
info.setBirthday(formatBirthdayFromId(yyyyMMdd));
}
// 6. 住址(从“住址”右侧开始,向下若干行拼接)
OcrItem addressLabel = findAddressLabel(remainingItems);
if (addressLabel != null) {
String addr = collectAddress(addressLabel, remainingItems, itemByBox);
info.setAddress(addr);
}
return info;
}
// ----------------- 文本与 Box 相关的工具方法 -----------------
private String normalizeText(String text) {
if (text == null) {
return "";
}
return text.replace(" ", "")
.replaceAll("[^0-9Xx\\u4e00-\\u9fa5]", "");
}
/**
* 从包含"姓名"的文本中提取姓名值(提取"姓名"后面的所有中文字符)
*/
private String extractNameAfterLabel(String text) {
if (text == null || text.isEmpty()) {
return null;
}
int nameIndex = text.indexOf("姓名");
if (nameIndex >= 0) {
String name = text.substring(nameIndex + 2);
return name.isEmpty() ? null : name;
}
int surnameIndex = text.indexOf("");
if (surnameIndex >= 0 && surnameIndex < text.length() - 1) {
String name = text.substring(surnameIndex + 1);
return name.isEmpty() ? null : name;
}
int givenNameIndex = text.indexOf("");
if (givenNameIndex >= 0 && givenNameIndex < text.length() - 1) {
String name = text.substring(givenNameIndex + 1);
return name.isEmpty() ? null : name;
}
// 如果都没找到,返回原文本(可能已经是纯姓名)
return text;
}
private String cleanGender(String text) {
if (text == null) {
return null;
}
String clean = text.replace(" ", "");
if (clean.contains("")) {
return "";
}
if (clean.contains("")) {
return "";
}
return null;
}
/**
* 按“右 → 上 → 下 → 全局”顺序查找性别信息
*/
private String findGenderAroundLabel(OcrItem genderLabel, List<OcrItem> allItems, Map<OcrBox, OcrItem> itemByBox) {
if (genderLabel == null || genderLabel.getOcrBox() == null || allItems == null || allItems.isEmpty()) {
return null;
}
// 情况一:标签所在框本身包含“性别+男/女”
String selfGender = cleanGender(genderLabel.getText());
if (selfGender != null) {
return selfGender;
}
List<OcrBox> boxList = allItems.stream()
.map(OcrItem::getOcrBox)
.filter(Objects::nonNull)
.collect(Collectors.toList());
OcrBox anchorBox = genderLabel.getOcrBox();
String gender = findGenderByDirection(anchorBox, itemByBox, boxList, BoxUtils.Direction.RIGHT, 3);
if (gender != null) {
return gender;
}
gender = findGenderByDirection(anchorBox, itemByBox, boxList, BoxUtils.Direction.UP, 3);
if (gender != null) {
return gender;
}
gender = findGenderByDirection(anchorBox, itemByBox, boxList, BoxUtils.Direction.DOWN, 3);
if (gender != null) {
return gender;
}
return findNearbyFallback(anchorBox, allItems, this::cleanGender);
}
/**
* 按指定方向在若干个最近框中查找性别
*/
private String findGenderByDirection(OcrBox anchorBox,
Map<OcrBox, OcrItem> itemByBox,
List<OcrBox> boxList,
BoxUtils.Direction direction,
int limit) {
List<OcrBox> neighbors = BoxUtils.findNearestBoxes(anchorBox, boxList, direction, limit);
if (neighbors == null || neighbors.isEmpty()) {
return null;
}
for (OcrBox neighbor : neighbors) {
OcrItem item = itemByBox.get(neighbor);
if (item == null) {
continue;
}
String g = cleanGender(item.getText());
if (g != null) {
return g;
}
}
return null;
}
/**
* 按“右 → 上 → 下 → 全局”顺序查找民族信息
*/
private String findEthnicityAroundLabel(OcrItem ethnicLabel, List<OcrItem> allItems, Map<OcrBox, OcrItem> itemByBox) {
if (ethnicLabel == null || ethnicLabel.getOcrBox() == null || allItems == null || allItems.isEmpty()) {
return null;
}
String selfEthnic = extractEthnicity(ethnicLabel.getText());
if (selfEthnic != null) {
return selfEthnic;
}
List<OcrBox> boxList = allItems.stream()
.map(OcrItem::getOcrBox)
.filter(Objects::nonNull)
.collect(Collectors.toList());
OcrBox anchorBox = ethnicLabel.getOcrBox();
String ethnicity = findEthnicityByDirection(anchorBox, itemByBox, boxList, BoxUtils.Direction.RIGHT, 3);
if (ethnicity != null) {
return ethnicity;
}
ethnicity = findEthnicityByDirection(anchorBox, itemByBox, boxList, BoxUtils.Direction.UP, 3);
if (ethnicity != null) {
return ethnicity;
}
ethnicity = findEthnicityByDirection(anchorBox, itemByBox, boxList, BoxUtils.Direction.DOWN, 3);
if (ethnicity != null) {
return ethnicity;
}
return findNearbyFallback(anchorBox, allItems, this::extractEthnicity);
}
/**
* 按指定方向在若干个最近框中查找民族
*/
private String findEthnicityByDirection(OcrBox anchorBox,
Map<OcrBox, OcrItem> itemByBox,
List<OcrBox> boxList,
BoxUtils.Direction direction,
int limit) {
List<OcrBox> neighbors = BoxUtils.findNearestBoxes(anchorBox, boxList, direction, limit);
if (neighbors == null || neighbors.isEmpty()) {
return null;
}
for (OcrBox neighbor : neighbors) {
OcrItem item = itemByBox.get(neighbor);
if (item == null) {
continue;
}
String e = extractEthnicity(item.getText());
if (e != null) {
return e;
}
}
return null;
}
/**
* 从文本中提取并纠正民族信息
*/
private String extractEthnicity(String ocrText) {
if (ocrText == null || ocrText.isEmpty()) {
return null;
}
String cleanText = ocrText.replaceAll("[^\\u4e00-\\u9fa5]", "");
cleanText = cleanText.replaceAll("^民族", "");
for (String ethnic : IdCardOcrUtils.ETHNIC_SET) {
if (cleanText.contains(ethnic)) {
return ethnic;
}
}
return null;
}
/**
* 兜底时仅在标签附近寻找合法候选,避免被远处噪声或少数民族文字误带偏。
*/
private String findNearbyFallback(OcrBox anchorBox,
List<OcrItem> allItems,
Function<String, String> extractor) {
if (anchorBox == null || allItems == null || allItems.isEmpty() || extractor == null) {
return null;
}
double anchorWidth = boxWidth(anchorBox);
double anchorHeight = boxHeight(anchorBox);
Point anchorCenter = boxCenter(anchorBox);
OcrItem bestItem = null;
double bestScore = Double.MAX_VALUE;
for (OcrItem item : allItems) {
if (item == null || item.getOcrBox() == null) {
continue;
}
String extracted = extractor.apply(item.getText());
if (extracted == null) {
continue;
}
Point candidateCenter = boxCenter(item.getOcrBox());
double dx = Math.abs(candidateCenter.getX() - anchorCenter.getX());
double dy = Math.abs(candidateCenter.getY() - anchorCenter.getY());
// 性别/民族通常紧邻标签,限制在局部窗口内做兜底。
if (dx > anchorWidth * 5.0 || dy > anchorHeight * 2.5) {
continue;
}
double score = dy * 2.0 + dx;
if (score < bestScore) {
bestScore = score;
bestItem = item;
}
}
return bestItem == null ? null : extractor.apply(bestItem.getText());
}
/**
* 按“自身 → 右 → 下 → 上 → 全局”顺序查找出生日期
*/
private String findBirthdayAroundLabel(OcrItem birthLabel, List<OcrItem> allItems, Map<OcrBox, OcrItem> itemByBox) {
if (birthLabel == null || birthLabel.getOcrBox() == null || allItems == null || allItems.isEmpty()) {
return null;
}
String fromSelf = extractBirthday(birthLabel.getText());
if (fromSelf != null) {
return fromSelf;
}
List<OcrBox> boxList = allItems.stream()
.map(OcrItem::getOcrBox)
.filter(Objects::nonNull)
.collect(Collectors.toList());
OcrBox anchorBox = birthLabel.getOcrBox();
String fromRight = findBirthdayByDirection(anchorBox, itemByBox, boxList, BoxUtils.Direction.RIGHT, 4);
if (fromRight != null) {
return fromRight;
}
String fromDown = findBirthdayByDirection(anchorBox, itemByBox, boxList, BoxUtils.Direction.DOWN, 4);
if (fromDown != null) {
return fromDown;
}
String fromUp = findBirthdayByDirection(anchorBox, itemByBox, boxList, BoxUtils.Direction.UP, 3);
if (fromUp != null) {
return fromUp;
}
for (OcrItem item : allItems) {
String b = extractBirthday(item.getText());
if (b != null) {
return b;
}
}
return null;
}
/**
* 按指定方向在若干最近框中尝试组合/单独解析出生日期
*/
private String findBirthdayByDirection(OcrBox anchorBox,
Map<OcrBox, OcrItem> itemByBox,
List<OcrBox> boxList,
BoxUtils.Direction direction,
int limit) {
List<OcrBox> neighbors = BoxUtils.findNearestBoxes(anchorBox, boxList, direction, limit);
if (neighbors == null || neighbors.isEmpty()) {
return null;
}
List<OcrItem> neighborItems = neighbors.stream()
.map(itemByBox::get)
.filter(Objects::nonNull)
.collect(Collectors.toList());
if (neighborItems.isEmpty()) {
return null;
}
for (OcrItem item : neighborItems) {
String b = extractBirthday(item.getText());
if (b != null) {
return b;
}
}
StringBuilder sb = new StringBuilder();
for (OcrItem item : neighborItems) {
if (item.getText() != null) {
sb.append(item.getText().replace(" ", ""));
}
}
String merged = sb.toString();
if (!merged.isEmpty()) {
String b = extractBirthday(merged);
if (b != null) {
return b;
}
}
return null;
}
/**
* 从文本中提取身份证号(优先 18 位二代证)
*/
private String extractIdNumber(String text) {
if (text == null) {
return null;
}
String candidate = text.toUpperCase();
Matcher m = Pattern.compile("[0-9X]{15,18}").matcher(candidate);
while (m.find()) {
String id = m.group();
if (id.length() == 18) {
return id;
}
}
return null;
}
/**
* 从文本中提取生日(标准化为 yyyy-MM-dd
*/
private String extractBirthday(String text) {
if (text == null) {
return null;
}
String clean = text.replace(" ", "");
Matcher m1 = BIRTHDAY_WITH_SEPARATORS.matcher(clean);
if (m1.find()) {
String birthday = normalizeBirthday(m1.group(1), m1.group(2), m1.group(3));
if (birthday != null) {
return birthday;
}
}
Matcher m2 = BIRTHDAY_COMPACT.matcher(clean);
if (m2.find()) {
String birthday = normalizeBirthday(m2.group(1), m2.group(2), m2.group(3));
if (birthday != null) {
return birthday;
}
}
return null;
}
private String normalizeBirthday(String raw) {
String clean = raw.replace("", "-")
.replace("", "-")
.replace("", "")
.replace("/", "-")
.replace(".", "-");
String[] parts = clean.split("-");
if (parts.length != 3) {
return null;
}
String y = parts[0];
String m = parts[1].length() == 1 ? "0" + parts[1] : parts[1];
String d = parts[2].length() == 1 ? "0" + parts[2] : parts[2];
return y + "-" + m + "-" + d;
}
private String normalizeBirthday(String year, String month, String day) {
if (year == null || month == null || day == null) {
return null;
}
String normalizedMonth = month.length() == 1 ? "0" + month : month;
String normalizedDay = day.length() == 1 ? "0" + day : day;
try {
LocalDate date = LocalDate.of(
Integer.parseInt(year),
Integer.parseInt(normalizedMonth),
Integer.parseInt(normalizedDay)
);
return date.toString();
} catch (DateTimeException | NumberFormatException e) {
return null;
}
}
private String formatBirthdayFromId(String yyyymmdd) {
if (yyyymmdd == null || yyyymmdd.length() != 8) {
return null;
}
String y = yyyymmdd.substring(0, 4);
String m = yyyymmdd.substring(4, 6);
String d = yyyymmdd.substring(6, 8);
return y + "-" + m + "-" + d;
}
private Point boxCenter(OcrBox box) {
float[] pts = box.toFloatArray();
float cx = (pts[0] + pts[2] + pts[4] + pts[6]) / 4;
float cy = (pts[1] + pts[3] + pts[5] + pts[7]) / 4;
return new Point(cx, cy);
}
private double boxWidth(OcrBox box) {
float[] pts = box.toFloatArray();
float minX = Math.min(Math.min(pts[0], pts[2]), Math.min(pts[4], pts[6]));
float maxX = Math.max(Math.max(pts[0], pts[2]), Math.max(pts[4], pts[6]));
return Math.max(1.0, maxX - minX);
}
private double boxHeight(OcrBox box) {
float[] pts = box.toFloatArray();
float minY = Math.min(Math.min(pts[1], pts[3]), Math.min(pts[5], pts[7]));
float maxY = Math.max(Math.max(pts[1], pts[3]), Math.max(pts[5], pts[7]));
return Math.max(1.0, maxY - minY);
}
/**
* 过滤掉面积明显小于整体的检测框,粗略去噪
*/
private List<OcrItem> filterSmallBoxes(List<OcrItem> items) {
List<OcrItem> result = IdCardOcrUtils.filterSmallBoxes(items, 0.15);
log.debug("身份证正面解析:过滤小框完成,原始数量={},过滤后数量={}", items == null ? 0 : items.size(), result == null ? 0 : result.size());
return result;
}
/**
* 基于 BoxUtils从锚点右侧找到最近的一个文本框
*/
private OcrItem findNearestRightItem(OcrItem anchor, List<OcrItem> allItems, Map<OcrBox, OcrItem> itemByBox) {
if (anchor == null || anchor.getOcrBox() == null) {
return null;
}
List<OcrBox> boxList = allItems.stream()
.map(OcrItem::getOcrBox)
.filter(Objects::nonNull)
.collect(Collectors.toList());
OcrBox nearest = BoxUtils.findNearestBox(anchor.getOcrBox(), boxList, BoxUtils.Direction.RIGHT);
if (nearest == null) {
return null;
}
return itemByBox.get(nearest);
}
/**
* 查找包含标签关键字的 item
*/
private OcrItem findLabel(List<OcrItem> allItems, List<String> keywords) {
for (OcrItem item : allItems) {
String text = item.getText();
if (text == null) {
continue;
}
String clean = text.replace(" ", "");
for (String kw : keywords) {
if (clean.contains(kw)) {
return item;
}
}
}
return null;
}
private OcrItem findAddressLabel(List<OcrItem> allItems) {
OcrItem direct = findLabel(allItems, Arrays.asList("住址", "地址", "住址:", "住址:"));
if (direct != null) {
return direct;
}
if (allItems == null) {
return null;
}
for (OcrItem item : allItems) {
String text = item.getText();
if (text == null) {
continue;
}
String clean = text.replace(" ", "");
if (looksLikeAddressLabel(clean)) {
return item;
}
}
return null;
}
private boolean looksLikeAddressLabel(String text) {
if (text == null || text.length() < 2) {
return false;
}
if (text.contains("") && (text.startsWith("") || text.startsWith("") || text.startsWith(""))) {
return true;
}
return false;
}
/**
* 拼接地址:
* 1. 从“住址”右侧开始,整行向右拼接多个检测框
* 2. 继续向下拼接若干行同一列附近的文本
* 3. 若某一行疑似“公民身份号码”行,则终止
*/
private String collectAddress(OcrItem addressLabel, List<OcrItem> allItems, Map<OcrBox, OcrItem> itemByBox) {
StringBuilder sb = new StringBuilder();
if (addressLabel == null || addressLabel.getOcrBox() == null || allItems == null || allItems.isEmpty()) {
return "";
}
List<OcrBox> boxList = allItems.stream()
.map(OcrItem::getOcrBox)
.filter(Objects::nonNull)
.collect(Collectors.toList());
Set<OcrBox> usedBoxes = new HashSet<>();
String labelAddressText = extractAddressAfterLabel(addressLabel.getText());
if (!labelAddressText.isEmpty()) {
appendMergedText(sb, labelAddressText);
usedBoxes.add(addressLabel.getOcrBox());
}
OcrItem firstLineItem = findNearestRightItem(addressLabel, allItems, itemByBox);
if (firstLineItem != null && firstLineItem.getOcrBox() != null) {
String firstLineText = buildAddressLine(firstLineItem.getOcrBox(), itemByBox, boxList, usedBoxes);
if (!firstLineText.isEmpty() && !isIdNumberLine(firstLineText)) {
appendMergedText(sb, firstLineText);
} else if (isIdNumberLine(firstLineText)) {
return sb.toString();
}
}
OcrBox current = firstLineItem != null && firstLineItem.getOcrBox() != null
? firstLineItem.getOcrBox()
: addressLabel.getOcrBox();
for (int i = 0; i < 3; i++) {
List<OcrBox> downs = BoxUtils.findNearestBoxes(
current,
boxList,
BoxUtils.Direction.DOWN,
1
);
if (downs == null || downs.isEmpty()) {
break;
}
OcrBox down = downs.get(0);
if (!isLikelyAddressContinuation(current, down, itemByBox)) {
break;
}
String lineText = buildAddressLine(down, itemByBox, boxList, usedBoxes);
if (lineText.isEmpty()) {
break;
}
if (isIdNumberLine(lineText)) {
break;
}
appendMergedText(sb, lineText);
current = down;
}
return sb.toString();
}
private String extractAddressAfterLabel(String text) {
if (text == null || text.isEmpty()) {
return "";
}
String clean = text.replace(" ", "");
int idx = clean.indexOf("住址");
if (idx >= 0) {
return clean.substring(idx + 2);
}
idx = clean.indexOf("佳址");
if (idx >= 0) {
return clean.substring(idx + 2);
}
idx = clean.indexOf("往址");
if (idx >= 0) {
return clean.substring(idx + 2);
}
idx = clean.indexOf("地址");
if (idx >= 0) {
return clean.substring(idx + 2);
}
return "";
}
private void appendMergedText(StringBuilder sb, String nextText) {
if (nextText == null || nextText.isEmpty()) {
return;
}
if (sb.length() == 0) {
sb.append(nextText);
return;
}
String existing = sb.toString();
int maxOverlap = Math.min(existing.length(), nextText.length());
for (int overlap = maxOverlap; overlap > 0; overlap--) {
if (existing.regionMatches(existing.length() - overlap, nextText, 0, overlap)) {
sb.append(nextText.substring(overlap));
return;
}
}
sb.append(nextText);
}
private boolean isLikelyAddressContinuation(OcrBox current, OcrBox candidate, Map<OcrBox, OcrItem> itemByBox) {
if (current == null || candidate == null) {
return false;
}
OcrItem candidateItem = itemByBox.get(candidate);
if (candidateItem == null) {
return false;
}
String text = candidateItem.getText();
if (text == null || text.trim().isEmpty()) {
return false;
}
String compact = text.replace(" ", "");
if (!compact.matches(".*[\\u4e00-\\u9fa5].*")) {
return false;
}
if (compact.matches("[A-Za-z]+")) {
return false;
}
double currentBottom = Math.max(Math.max(current.getBottomLeft().getY(), current.getBottomRight().getY()),
Math.max(current.getTopLeft().getY(), current.getTopRight().getY()));
double candidateTop = Math.min(Math.min(candidate.getTopLeft().getY(), candidate.getTopRight().getY()),
Math.min(candidate.getBottomLeft().getY(), candidate.getBottomRight().getY()));
double verticalGap = candidateTop - currentBottom;
double allowedGap = Math.max(boxHeight(current), boxHeight(candidate)) * 1.2;
if (verticalGap > allowedGap) {
return false;
}
double currentCenterX = boxCenter(current).getX();
double candidateCenterX = boxCenter(candidate).getX();
double centerDeltaX = Math.abs(candidateCenterX - currentCenterX);
double allowedDeltaX = Math.max(boxWidth(current), boxWidth(candidate)) * 0.9;
return centerDeltaX <= allowedDeltaX;
}
/**
* 根据某一行的起始框,向右拼接同一行上的多个检测框文本
*/
private String buildAddressLine(OcrBox rowAnchor,
Map<OcrBox, OcrItem> itemByBox,
List<OcrBox> boxList,
Set<OcrBox> usedBoxes) {
if (rowAnchor == null) {
return "";
}
StringBuilder sb = new StringBuilder();
OcrItem anchorItem = itemByBox.get(rowAnchor);
if (anchorItem != null && anchorItem.getText() != null && !usedBoxes.contains(rowAnchor)) {
sb.append(anchorItem.getText().replace(" ", ""));
usedBoxes.add(rowAnchor);
}
List<OcrBox> rightBoxes = BoxUtils.findNearestBoxes(rowAnchor, boxList, BoxUtils.Direction.RIGHT, 8);
if (rightBoxes != null && !rightBoxes.isEmpty()) {
for (OcrBox rb : rightBoxes) {
if (usedBoxes.contains(rb)) {
continue;
}
OcrItem item = itemByBox.get(rb);
if (item != null && item.getText() != null) {
sb.append(item.getText().replace(" ", ""));
usedBoxes.add(rb);
}
}
}
return sb.toString();
}
/**
* 判断一行文本是否疑似“公民身份号码”行
*/
private boolean isIdNumberLine(String text) {
if (text == null || text.isEmpty()) {
return false;
}
String norm = normalizeText(text);
if (norm.contains("公民身份号码") || norm.contains("公民身份號碼") || norm.toUpperCase().contains("ID")) {
return true;
}
return extractIdNumber(norm) != null;
}
private Map<OcrBox, OcrItem> buildBoxItemMap(List<OcrItem> items) {
Map<OcrBox, OcrItem> itemByBox = new IdentityHashMap<>();
if (items == null) {
return itemByBox;
}
for (OcrItem item : items) {
if (item != null && item.getOcrBox() != null) {
itemByBox.put(item.getOcrBox(), item);
}
}
return itemByBox;
}
}

View File

@@ -0,0 +1,68 @@
package cn.smartjavaai.ocr.idcard;
import cn.smartjavaai.ocr.entity.OcrBox;
import cn.smartjavaai.ocr.entity.OcrItem;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 身份证 OCR 解析相关的通用工具。
*/
final class IdCardOcrUtils {
static final Set<String> ETHNIC_SET = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
"", "蒙古", "", "", "维吾尔", "", "", "", "布依", "朝鲜", "", "",
"", "", "土家", "哈尼", "哈萨克", "", "", "傈僳", "", "", "高山",
"拉祜", "", "东乡", "纳西", "景颇", "柯尔克孜", "", "达斡尔", "仫佬",
"", "布朗", "撒拉", "毛南", "仡佬", "锡伯", "阿昌", "普米", "塔吉克", "",
"乌孜别克", "俄罗斯", "鄂温克", "德昂", "保安", "裕固", "", "塔塔尔", "独龙",
"鄂伦春", "赫哲", "门巴", "珞巴", "基诺"
)));
private IdCardOcrUtils() {
}
static List<OcrItem> filterSmallBoxes(List<OcrItem> items, double factor) {
if (items == null || items.isEmpty()) {
return items;
}
List<Double> areas = items.stream()
.map(OcrItem::getOcrBox)
.filter(Objects::nonNull)
.map(IdCardOcrUtils::estimateBoxArea)
.sorted()
.collect(Collectors.toList());
if (areas.size() < 5) {
return items;
}
double median = areas.get(areas.size() / 2);
double threshold = median * factor;
List<OcrItem> result = items.stream()
.filter(it -> it.getOcrBox() == null || estimateBoxArea(it.getOcrBox()) >= threshold)
.collect(Collectors.toList());
return result.isEmpty() ? items : result;
}
static double estimateBoxArea(OcrBox box) {
if (box == null) {
return 0.0;
}
float[] pts = box.toFloatArray();
float minX = Math.min(Math.min(pts[0], pts[2]), Math.min(pts[4], pts[6]));
float minY = Math.min(Math.min(pts[1], pts[3]), Math.min(pts[5], pts[7]));
float maxX = Math.max(Math.max(pts[0], pts[2]), Math.max(pts[4], pts[6]));
float maxY = Math.max(Math.max(pts[1], pts[3]), Math.max(pts[5], pts[7]));
double width = Math.max(1.0, maxX - minX);
double height = Math.max(1.0, maxY - minY);
return width * height;
}
}

View File

@@ -0,0 +1,15 @@
package cn.smartjavaai.ocr.idcard;
import cn.smartjavaai.ocr.entity.OcrInfo;
/**
* 身份证解析接口,用于从 OCR 结果中解析结构化字段。
*/
public interface IdCardParser<T> {
/**
* 从 OCR 结果中解析出指定类型的身份证信息
*/
T parse(OcrInfo ocrInfo);
}

View File

@@ -0,0 +1,29 @@
package cn.smartjavaai.ocr.idcard;
import ai.djl.modality.cv.Image;
import cn.smartjavaai.ocr.entity.OcrInfo;
/**
* 身份证预处理调试监听器。
*
* 在预处理或识别流程中,只有当某个阶段真的产出了中间结果时,才会回调当前图像,
* 便于测试环境中将中间结果保存到磁盘进行对比和调试。
*
* 生产环境可不设置监听器,完全不影响正常流程。
*/
public interface IdCardPreprocessListener {
/**
* 方向矫正完成后回调。
* 如果方向检测结果为 0 且未发生旋转,则不会调用。
*/
default void onAfterDirection(Image image) {}
/**
* OCR 识别完成后回调。
*
* 传入的 image 为预处理后的图片副本,已绘制 OCR 检测框,便于直接保存调试结果。
* 如果识别流程失败或未进入识别阶段,则不会调用。
*/
default void onAfterRecognize(Image image, OcrInfo ocrInfo) {}
}

View File

@@ -0,0 +1,26 @@
package cn.smartjavaai.ocr.idcard;
import ai.djl.modality.cv.Image;
import cn.smartjavaai.ocr.entity.OcrBox;
import lombok.Data;
import lombok.experimental.Accessors;
import java.util.List;
/**
* 身份证预处理结果。
*
* - processedImage: 预处理后的图像;若无需旋转,则通常与原图一致
* - reusableBoxes: 可复用的文本检测框;仅在无需旋转时可直接复用
* - rotated: 是否进行了整图旋转
*/
@Data
@Accessors(chain = true)
public class IdCardPreprocessResult {
private Image processedImage;
private List<OcrBox> reusableBoxes;
private boolean rotated;
}

View File

@@ -0,0 +1,227 @@
package cn.smartjavaai.ocr.idcard;
import ai.djl.modality.cv.Image;
import cn.smartjavaai.ocr.entity.OcrBox;
import cn.smartjavaai.ocr.entity.OcrItem;
import cn.smartjavaai.ocr.enums.AngleEnum;
import cn.smartjavaai.ocr.model.common.detect.OcrCommonDetModel;
import cn.smartjavaai.ocr.model.common.direction.OcrDirectionModel;
import cn.smartjavaai.ocr.utils.OcrUtils;
import lombok.extern.slf4j.Slf4j;
import org.opencv.core.Mat;
import java.util.ArrayList;
import java.util.List;
/**
* 身份证图像预处理方向矫正0/90/180/270°
*
* - 方向矫正:使用 OcrDirectionModel 检测整图方向并旋转至正向;
*/
@Slf4j
public class IdCardPreprocessor {
/**
* 预处理:
* 1. 方向矫正(若提供 directionModel纠正 0/90/180/270° 整体方向;
* 2. 若提供 listener则在阶段结束后回调当前图像便于测试保存中间结果。
*/
public Image preprocess(Image src,
OcrDirectionModel directionModel,
IdCardPreprocessListener listener) {
if (src == null) {
return src;
}
if (directionModel != null) {
return applyDirectionCorrection(src, directionModel, listener);
}
return src;
}
/**
* 预处理(不关心调试监听器的常规调用入口)。
*/
public Image preprocess(Image src,
OcrDirectionModel directionModel) {
return preprocess(src, directionModel, null);
}
/**
* 预处理增强版:
* 1. 先做文本检测
* 2. 过滤明显过小的文本框,降低方向判断噪声
* 3. 使用已有文本框做方向检测
* 4. 若无需旋转,则直接返回可复用的检测框
* 5. 若需要旋转,则返回旋转后的图像,检测框不复用
*/
public IdCardPreprocessResult preprocess(Image src,
OcrCommonDetModel textDetModel,
OcrDirectionModel directionModel,
IdCardPreprocessListener listener) {
IdCardPreprocessResult result = new IdCardPreprocessResult()
.setProcessedImage(src)
.setRotated(false);
if (src == null) {
return result;
}
if (textDetModel == null || directionModel == null) {
if (directionModel != null) {
result.setProcessedImage(preprocess(src, directionModel, listener));
result.setRotated(result.getProcessedImage() != src);
}
return result;
}
Mat srcMat = null;
try {
srcMat = cn.smartjavaai.common.utils.ImageUtils.toMat(src).clone();
List<OcrBox> detectedBoxes = textDetModel.detect(src);
List<OcrBox> filteredBoxes = filterSmallBoxes(detectedBoxes);
if (filteredBoxes.isEmpty()) {
result.setReusableBoxes(detectedBoxes);
return result;
}
List<OcrItem> directionItems = directionModel.detect(filteredBoxes, srcMat);
AngleEnum dominant = dominantAngle(directionItems);
if (dominant != null && dominant != AngleEnum.ANGLE_0) {
log.debug("方向检测结果为 {},执行整图旋转矫正。", dominant);
Image rotated = OcrUtils.rotateImg(src, dominant);
if (listener != null) {
listener.onAfterDirection(rotated);
}
return result.setProcessedImage(rotated)
.setReusableBoxes(null)
.setRotated(true);
}
return result.setReusableBoxes(detectedBoxes);
} catch (Exception e) {
log.warn("身份证方向矫正失败,使用原图继续。", e);
return result;
} finally {
if (srcMat != null) {
srcMat.release();
}
}
}
/**
* 使用方向模型检测整图方向0/90/180/270°并将图片旋转至正向。
*/
private Image applyDirectionCorrection(Image src,
OcrDirectionModel directionModel,
IdCardPreprocessListener listener) {
if (src == null || directionModel == null) {
return src;
}
try {
long detectStart = System.nanoTime();
List<OcrItem> items = directionModel.detect(src);
log.debug("身份证方向模型调用耗时={}ms, 检测框数量={}", elapsedMillis(detectStart), items == null ? 0 : items.size());
log.debug("方向检测结果:{}", items);
AngleEnum dominant = dominantAngle(items);
if (dominant != null && dominant != AngleEnum.ANGLE_0) {
log.info("方向检测结果为 {},执行整图旋转矫正。", dominant);
long rotateStart = System.nanoTime();
Image rotated = OcrUtils.rotateImg(src, dominant);
log.debug("身份证整图旋转耗时={}ms", elapsedMillis(rotateStart));
if (listener != null) {
listener.onAfterDirection(rotated);
}
return rotated;
}
} catch (Exception e) {
log.warn("身份证方向矫正失败,使用原图继续。", e);
}
return src;
}
/**
* 从方向检测结果中取主角度:
* 1. 先过滤掉明显的小框,减少碎框噪声;
* 2. 再按 面积 * score 做加权投票;
* 3. 若过滤后为空,则退化为对全部框做加权投票。
*/
private AngleEnum dominantAngle(List<OcrItem> items) {
if (items == null || items.isEmpty()) {
return null;
}
double maxArea = 0.0;
for (OcrItem item : items) {
if (item == null || item.getAngle() == null || item.getOcrBox() == null) {
continue;
}
maxArea = Math.max(maxArea, estimateBoxArea(item.getOcrBox()));
}
if (maxArea <= 0.0) {
return weightedVote(items, 0.0);
}
double minArea = maxArea * 0.15;
AngleEnum dominant = weightedVote(items, minArea);
if (dominant != null) {
return dominant;
}
return weightedVote(items, 0.0);
}
private AngleEnum weightedVote(List<OcrItem> items, double minArea) {
AngleEnum first = null;
double weight0 = 0.0, weight90 = 0.0, weight180 = 0.0, weight270 = 0.0;
for (OcrItem item : items) {
if (item == null || item.getAngle() == null || item.getOcrBox() == null) {
continue;
}
double area = estimateBoxArea(item.getOcrBox());
if (area < minArea) {
continue;
}
double score = item.getScore() > 0 ? item.getScore() : 1.0;
double weight = area * score;
AngleEnum angle = item.getAngle();
if (first == null) {
first = angle;
}
switch (angle) {
case ANGLE_0: weight0 += weight; break;
case ANGLE_90: weight90 += weight; break;
case ANGLE_180: weight180 += weight; break;
case ANGLE_270: weight270 += weight; break;
}
}
double maxWeight = Math.max(Math.max(weight0, weight90), Math.max(weight180, weight270));
if (maxWeight <= 0.0) {
return first;
}
if (Double.compare(weight90, maxWeight) == 0) return AngleEnum.ANGLE_90;
if (Double.compare(weight270, maxWeight) == 0) return AngleEnum.ANGLE_270;
if (Double.compare(weight180, maxWeight) == 0) return AngleEnum.ANGLE_180;
if (Double.compare(weight0, maxWeight) == 0) return AngleEnum.ANGLE_0;
return first;
}
private List<OcrBox> filterSmallBoxes(List<OcrBox> boxes) {
if (boxes == null || boxes.isEmpty()) {
return boxes;
}
List<OcrItem> items = new ArrayList<>(boxes.size());
for (OcrBox box : boxes) {
items.add(new OcrItem(box, AngleEnum.ANGLE_0, 1.0f));
}
List<OcrItem> filteredItems = IdCardOcrUtils.filterSmallBoxes(items, 0.15);
List<OcrBox> filteredBoxes = new ArrayList<>(filteredItems.size());
for (OcrItem item : filteredItems) {
if (item != null && item.getOcrBox() != null) {
filteredBoxes.add(item.getOcrBox());
}
}
return filteredBoxes.isEmpty() ? boxes : filteredBoxes;
}
private double estimateBoxArea(OcrBox box) {
return IdCardOcrUtils.estimateBoxArea(box);
}
private long elapsedMillis(long startNanos) {
return (System.nanoTime() - startNanos) / 1_000_000;
}
}

View File

@@ -0,0 +1,44 @@
package cn.smartjavaai.ocr.idcard;
import ai.djl.modality.cv.Image;
import cn.smartjavaai.ocr.entity.IdCardBackInfo;
import cn.smartjavaai.ocr.entity.IdCardFrontInfo;
import cn.smartjavaai.ocr.entity.IdCardInfo;
import cn.smartjavaai.ocr.entity.OcrInfo;
/**
* 身份证 OCR 识别服务接口
*
* 支持:
* 1. 直接传入图片,由服务内部完成预处理 + OCR + 解析;
* 2. 已有 OCR 结果的情况下,仅做单面结构化解析。
*/
public interface IdCardRecognizer {
/**
* 识别身份证正面(从图片开始)
*/
IdCardFrontInfo recognizeFront(Image image);
/**
* 识别身份证反面(从图片开始)
*/
IdCardBackInfo recognizeBack(Image image);
/**
* 识别身份证正面(仅结构化已有 OCR 结果)
*/
IdCardFrontInfo recognizeFront(OcrInfo ocrInfo);
/**
* 识别身份证反面(仅结构化已有 OCR 结果)
*/
IdCardBackInfo recognizeBack(OcrInfo ocrInfo);
/**
* 同时识别身份证正反面(分别传入两面图片)
*/
IdCardInfo recognizeBoth(Image frontImage, Image backImage);
}

View File

@@ -0,0 +1,79 @@
package cn.smartjavaai.ocr.idcard;
import cn.smartjavaai.ocr.entity.IdCardBackInfo;
import cn.smartjavaai.ocr.entity.IdCardFrontInfo;
import cn.smartjavaai.ocr.entity.IdCardInfo;
import lombok.extern.slf4j.Slf4j;
/**
* 身份证字段校验工具。
*
* 提供:
* - 正面字段完整性与格式校验
* - 反面字段简单格式校验(预留)
* - 身份证号码校验位校验
*/
@Slf4j
public class IdCardValidator {
public boolean validateFront(IdCardFrontInfo info) {
if (info == null) {
return false;
}
boolean nameOk = info.getName() != null
&& info.getName().length() >= 2
&& info.getName().length() <= 4;
boolean genderOk = "".equals(info.getGender()) || "".equals(info.getGender());
boolean ethnicOk = info.getEthnicity() != null && IdCardOcrUtils.ETHNIC_SET.contains(info.getEthnicity());
boolean idOk = validateIdNumber(info.getIdNumber());
log.debug("身份证正面字段校验详情nameOk={}, genderOk={}, ethnicOk={}, idOk={}",
nameOk, genderOk, ethnicOk, idOk);
return nameOk && genderOk && ethnicOk && idOk;
}
public boolean validateBack(IdCardBackInfo info) {
// 目前只做存在性校验,后续可以根据业务需要增加日期格式、范围等更严格校验
if (info == null) {
return false;
}
boolean authorityOk = info.getIssuingAuthority() != null && !info.getIssuingAuthority().isEmpty();
boolean validFromOk = info.getValidFrom() != null && !info.getValidFrom().isEmpty();
boolean validToOk = info.getValidTo() != null && !info.getValidTo().isEmpty();
log.info("身份证反面字段校验详情authorityOk={}, validFromOk={}, validToOk={}",
authorityOk, validFromOk, validToOk);
return authorityOk && validFromOk && validToOk;
}
public boolean validate(IdCardInfo info) {
if (info == null) {
return false;
}
boolean frontOk = info.getFront() == null || validateFront(info.getFront());
boolean backOk = info.getBack() == null || validateBack(info.getBack());
return frontOk && backOk;
}
/**
* 简单的身份证号码格式 + 校验位校验
*/
public boolean validateIdNumber(String id) {
if (id == null) {
return false;
}
String upper = id.toUpperCase();
if (!upper.matches("^[1-9]\\d{5}(19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])\\d{3}[0-9X]$")) {
return false;
}
// 校验位
char[] chars = upper.toCharArray();
int[] weight = {7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2};
char[] validateCode = {'1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'};
int sum = 0;
for (int i = 0; i < 17; i++) {
sum += (chars[i] - '0') * weight[i];
}
int mod = sum % 11;
return validateCode[mod] == chars[17];
}
}

View File

@@ -7,7 +7,7 @@
<name>SmartJavaAI</name>
<groupId>cn.smartjavaai</groupId>
<artifactId>smartjavaai-parent</artifactId>
<version>1.1.1</version>
<version>1.1.2</version>
<packaging>pom</packaging>
<description>SmartJavaAI</description>
<modules>
@@ -19,7 +19,6 @@
<module>ocr</module>
<module>bom</module>
<module>speech</module>
<!-- <module>face-pro</module>-->
</modules>
<properties>

View File

@@ -6,7 +6,7 @@
<parent>
<groupId>cn.smartjavaai</groupId>
<artifactId>smartjavaai-parent</artifactId>
<version>1.1.1</version>
<version>1.1.2</version>
</parent>
<artifactId>speech</artifactId>
@@ -57,7 +57,7 @@
</dependencies>
<version>1.1.1</version>
<version>1.1.2</version>
<name>speech</name>
<description>SmartJavaAI</description>
<url>https://github.com/geekwenjie/SmartJavaAI</url>

View File

@@ -25,7 +25,7 @@
</dependency>
</dependencies>
<version>1.1.1</version>
<version>1.1.2</version>
<name>translate</name>
<description>SmartJavaAI</description>
<url>https://github.com/geekwenjie/SmartJavaAI</url>

View File

@@ -6,11 +6,11 @@
<parent>
<groupId>cn.smartjavaai</groupId>
<artifactId>smartjavaai-parent</artifactId>
<version>1.1.1</version>
<version>1.1.2</version>
</parent>
<artifactId>vision</artifactId>
<version>1.1.1</version>
<version>1.1.2</version>
<name>vision</name>
<description>SmartJavaAI</description>
<url>https://github.com/geekwenjie/SmartJavaAI</url>