diff --git a/README.md b/README.md
index 662c42e..9512b03 100644
--- a/README.md
+++ b/README.md
@@ -510,7 +510,7 @@ SmartJavaAI是专为JAVA 开发者打造的一个功能丰富、开箱即用的
cn.smartjavaai
all
- 1.1.1
+ 1.1.2
```
diff --git a/all/pom.xml b/all/pom.xml
index bb154f0..07b1f1a 100644
--- a/all/pom.xml
+++ b/all/pom.xml
@@ -6,11 +6,11 @@
cn.smartjavaai
smartjavaai-parent
- 1.1.1
+ 1.1.2
all
- 1.1.1
+ 1.1.2
${project.artifactId}
SmartJavaAI
https://github.com/geekwenjie/SmartJavaAI
diff --git a/bom/pom.xml b/bom/pom.xml
index d4af111..70ed6fc 100644
--- a/bom/pom.xml
+++ b/bom/pom.xml
@@ -6,10 +6,10 @@
cn.smartjavaai
smartjavaai-parent
- 1.1.1
+ 1.1.2
- 1.1.1
+ 1.1.2
bom
bom
统一版本管理的 BOM 包,同时支持 import 和全量依赖
diff --git a/common/pom.xml b/common/pom.xml
index 80a80fd..aac1be4 100644
--- a/common/pom.xml
+++ b/common/pom.xml
@@ -6,7 +6,7 @@
cn.smartjavaai
smartjavaai-parent
- 1.1.1
+ 1.1.2
common
diff --git a/examples/face-example/pom.xml b/examples/face-example/pom.xml
index bb240c3..36ef05b 100644
--- a/examples/face-example/pom.xml
+++ b/examples/face-example/pom.xml
@@ -12,7 +12,7 @@
11
11
UTF-8
- 1.1.1
+ 1.1.2
smartai.examples.face.FaceDemo
diff --git a/examples/ocr-examples/pom.xml b/examples/ocr-examples/pom.xml
index f2ee663..0fe67f1 100644
--- a/examples/ocr-examples/pom.xml
+++ b/examples/ocr-examples/pom.xml
@@ -12,9 +12,9 @@
11
11
UTF-8
- 1.1.1
+ 1.1.2
- smartai.examples.ocr.common.OcrRecognizeDemo
+ smartai.examples.ocr.common.OcrDetectionDemo
1.5.10
@@ -90,19 +90,33 @@
cn.smartjavaai
ocr
-
- com.microsoft.onnxruntime
- onnxruntime
-
+
+
+
+
+
+
+
+
+
+
+
+
-
- com.microsoft.onnxruntime
- onnxruntime
- 1.20.0
- runtime
-
+
+
+
+
+
+
+
+
+
+
+
+
@@ -113,79 +127,10 @@
-
-
- org.bytedeco
- javacpp
- ${javacv.version}
- ${javacv.platform.windows-x86_64}
-
-
- org.bytedeco
- ffmpeg
- 6.1.1-1.5.10
- ${javacv.platform.windows-x86_64}
-
-
-
- org.bytedeco
- openblas
- 0.3.26-1.5.10
- ${javacv.platform.windows-x86_64}
-
-
-
- org.bytedeco
- opencv
- 4.9.0-1.5.10
- ${javacv.platform.windows-x86_64}
-
-
-
- ai.djl.pytorch
- pytorch-native-cpu
- ${djl.platform.windows-x86_64}
- 2.7.1
- runtime
-
-
-
- org.bytedeco
- javacpp
- ${javacv.version}
- ${javacv.platform.linux-x86_64}
-
-
- org.bytedeco
- ffmpeg
- 6.1.1-1.5.10
- ${javacv.platform.linux-x86_64}
-
-
- org.bytedeco
- openblas
- 0.3.26-1.5.10
- ${javacv.platform.linux-x86_64}
-
-
-
- org.bytedeco
- opencv
- 4.9.0-1.5.10
- ${javacv.platform.linux-x86_64}
-
-
-
- ai.djl.pytorch
- pytorch-native-cpu
- ${djl.platform.linux-x86_64}
- 2.7.1
- runtime
-
@@ -223,42 +168,16 @@
runtime
+
+
+
+
+
+
+
-
-
- org.bytedeco
- javacpp
- ${javacv.version}
- ${javacv.platform.macosx-arm64}
-
-
- org.bytedeco
- ffmpeg
- 6.1.1-1.5.10
- ${javacv.platform.macosx-arm64}
-
-
- org.bytedeco
- openblas
- 0.3.26-1.5.10
- ${javacv.platform.macosx-arm64}
-
-
- org.bytedeco
- opencv
- 4.9.0-1.5.10
- ${javacv.platform.macosx-arm64}
-
-
-
- ai.djl.pytorch
- pytorch-native-cpu
- ${djl.platform.osx-aarch64}
- 2.7.1
- runtime
-
diff --git a/examples/ocr-examples/src/main/java/smartai/examples/ocr/idcard/IdCardRecDemo.java b/examples/ocr-examples/src/main/java/smartai/examples/ocr/idcard/IdCardRecDemo.java
new file mode 100644
index 0000000..dbf6aae
--- /dev/null
+++ b/examples/ocr-examples/src/main/java/smartai/examples/ocr/idcard/IdCardRecDemo.java
@@ -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);
+ }
+ }
+}
diff --git a/examples/ocr-examples/src/main/resources/idcard/idcard_back1.png b/examples/ocr-examples/src/main/resources/idcard/idcard_back1.png
new file mode 100644
index 0000000..7ea0dc4
Binary files /dev/null and b/examples/ocr-examples/src/main/resources/idcard/idcard_back1.png differ
diff --git a/examples/ocr-examples/src/main/resources/idcard/idcard_front1.png b/examples/ocr-examples/src/main/resources/idcard/idcard_front1.png
new file mode 100644
index 0000000..62842d8
Binary files /dev/null and b/examples/ocr-examples/src/main/resources/idcard/idcard_front1.png differ
diff --git a/examples/speech-examples/pom.xml b/examples/speech-examples/pom.xml
index 8407683..99fd2e9 100644
--- a/examples/speech-examples/pom.xml
+++ b/examples/speech-examples/pom.xml
@@ -12,7 +12,7 @@
11
11
UTF-8
- 1.1.1
+ 1.1.2
smartai.examples.speech.asr.common.OcrRecognizeDemo
diff --git a/examples/translation-example/pom.xml b/examples/translation-example/pom.xml
index 52610c8..b079035 100644
--- a/examples/translation-example/pom.xml
+++ b/examples/translation-example/pom.xml
@@ -12,7 +12,7 @@
11
11
UTF-8
- 1.1.1
+ 1.1.2
smartai.examples.nlp.translation.TranslationDemo
diff --git a/examples/vision-example/pom.xml b/examples/vision-example/pom.xml
index 81a5277..3406c51 100644
--- a/examples/vision-example/pom.xml
+++ b/examples/vision-example/pom.xml
@@ -12,7 +12,7 @@
11
11
UTF-8
- 1.1.1
+ 1.1.2
smartai.examples.vision.ObjectDetectionDemo
diff --git a/face/pom.xml b/face/pom.xml
index 56f3654..0a62a46 100644
--- a/face/pom.xml
+++ b/face/pom.xml
@@ -6,11 +6,11 @@
cn.smartjavaai
smartjavaai-parent
- 1.1.1
+ 1.1.2
face
- 1.1.1
+ 1.1.2
face
SmartJavaAI
https://github.com/geekwenjie/SmartJavaAI
diff --git a/ocr/pom.xml b/ocr/pom.xml
index 32df199..e72bb14 100644
--- a/ocr/pom.xml
+++ b/ocr/pom.xml
@@ -6,7 +6,7 @@
cn.smartjavaai
smartjavaai-parent
- 1.1.1
+ 1.1.2
ocr
@@ -42,7 +42,7 @@
- 1.1.1
+ 1.1.2
ocr
SmartJavaAI
https://github.com/geekwenjie/SmartJavaAI
diff --git a/ocr/src/main/java/cn/smartjavaai/ocr/entity/IdCardInfo.java b/ocr/src/main/java/cn/smartjavaai/ocr/entity/IdCardInfo.java
index 608d006..77bcf83 100644
--- a/ocr/src/main/java/cn/smartjavaai/ocr/entity/IdCardInfo.java
+++ b/ocr/src/main/java/cn/smartjavaai/ocr/entity/IdCardInfo.java
@@ -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;
}
+
diff --git a/ocr/src/main/java/cn/smartjavaai/ocr/idcard/DefaultIdCardRecognizer.java b/ocr/src/main/java/cn/smartjavaai/ocr/idcard/DefaultIdCardRecognizer.java
new file mode 100644
index 0000000..9bdb3f7
--- /dev/null
+++ b/ocr/src/main/java/cn/smartjavaai/ocr/idcard/DefaultIdCardRecognizer.java
@@ -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 frontParser;
+
+ /**
+ * 反面解析器(默认按需 lazy-init 为 IdCardBackParser)
+ */
+ private IdCardParser 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 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 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 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;
+ }
+
+}
diff --git a/ocr/src/main/java/cn/smartjavaai/ocr/idcard/IdCardBackParser.java b/ocr/src/main/java/cn/smartjavaai/ocr/idcard/IdCardBackParser.java
new file mode 100644
index 0000000..801b8cd
--- /dev/null
+++ b/ocr/src/main/java/cn/smartjavaai/ocr/idcard/IdCardBackParser.java
@@ -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 {
+
+ 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 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 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 allItems, Map itemByBox) {
+ String selfAuthority = extractIssuingAuthority(authorityLabel.getText());
+ if (selfAuthority != null) {
+ return selfAuthority;
+ }
+
+ List 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 itemByBox,
+ List boxList,
+ BoxUtils.Direction direction,
+ int limit) {
+ List neighbors = BoxUtils.findNearestBoxes(anchorBox, boxList, direction, limit);
+ if (neighbors == null || neighbors.isEmpty()) {
+ return null;
+ }
+
+ List 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 allItems, Map itemByBox) {
+ ValidPeriod selfPeriod = extractValidPeriod(validLabel.getText());
+ if (selfPeriod != null) {
+ return selfPeriod;
+ }
+
+ List 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 itemByBox,
+ List boxList,
+ BoxUtils.Direction direction,
+ int limit) {
+ List neighbors = BoxUtils.findNearestBoxes(anchorBox, boxList, direction, limit);
+ if (neighbors == null || neighbors.isEmpty()) {
+ return null;
+ }
+
+ List 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 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 allItems) {
+ List 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 allItems, List 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 filterSmallBoxes(List items) {
+ List result = IdCardOcrUtils.filterSmallBoxes(items, 0.15);
+ return result;
+ }
+
+ private Map buildBoxItemMap(List items) {
+ Map 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;
+ }
+ }
+}
diff --git a/ocr/src/main/java/cn/smartjavaai/ocr/idcard/IdCardFrontParser.java b/ocr/src/main/java/cn/smartjavaai/ocr/idcard/IdCardFrontParser.java
new file mode 100644
index 0000000..44f5f8c
--- /dev/null
+++ b/ocr/src/main/java/cn/smartjavaai/ocr/idcard/IdCardFrontParser.java
@@ -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 {
+
+ 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 allItems = ocrInfo.getLineList().stream()
+ .filter(Objects::nonNull)
+ .flatMap(List::stream)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toList());
+
+ // 过滤明显过小的检测框(噪声)
+ allItems = filterSmallBoxes(allItems);
+
+ // 可用的检测框列表:解析出字段后逐步移除,减少后续遍历量
+ List remainingItems = new ArrayList<>(allItems);
+ Map 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 allItems, Map itemByBox) {
+ if (genderLabel == null || genderLabel.getOcrBox() == null || allItems == null || allItems.isEmpty()) {
+ return null;
+ }
+ // 情况一:标签所在框本身包含“性别+男/女”
+ String selfGender = cleanGender(genderLabel.getText());
+ if (selfGender != null) {
+ return selfGender;
+ }
+
+ List 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 itemByBox,
+ List boxList,
+ BoxUtils.Direction direction,
+ int limit) {
+ List 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 allItems, Map itemByBox) {
+ if (ethnicLabel == null || ethnicLabel.getOcrBox() == null || allItems == null || allItems.isEmpty()) {
+ return null;
+ }
+
+ String selfEthnic = extractEthnicity(ethnicLabel.getText());
+ if (selfEthnic != null) {
+ return selfEthnic;
+ }
+
+ List 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 itemByBox,
+ List boxList,
+ BoxUtils.Direction direction,
+ int limit) {
+ List 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 allItems,
+ Function 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 allItems, Map itemByBox) {
+ if (birthLabel == null || birthLabel.getOcrBox() == null || allItems == null || allItems.isEmpty()) {
+ return null;
+ }
+
+ String fromSelf = extractBirthday(birthLabel.getText());
+ if (fromSelf != null) {
+ return fromSelf;
+ }
+
+ List 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 itemByBox,
+ List boxList,
+ BoxUtils.Direction direction,
+ int limit) {
+ List neighbors = BoxUtils.findNearestBoxes(anchorBox, boxList, direction, limit);
+ if (neighbors == null || neighbors.isEmpty()) {
+ return null;
+ }
+
+ List 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 filterSmallBoxes(List items) {
+ List 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 allItems, Map itemByBox) {
+ if (anchor == null || anchor.getOcrBox() == null) {
+ return null;
+ }
+ List 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 allItems, List 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 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 allItems, Map itemByBox) {
+ StringBuilder sb = new StringBuilder();
+ if (addressLabel == null || addressLabel.getOcrBox() == null || allItems == null || allItems.isEmpty()) {
+ return "";
+ }
+
+ List boxList = allItems.stream()
+ .map(OcrItem::getOcrBox)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toList());
+
+ Set 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 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 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 itemByBox,
+ List boxList,
+ Set 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 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 buildBoxItemMap(List items) {
+ Map 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;
+ }
+}
diff --git a/ocr/src/main/java/cn/smartjavaai/ocr/idcard/IdCardOcrUtils.java b/ocr/src/main/java/cn/smartjavaai/ocr/idcard/IdCardOcrUtils.java
new file mode 100644
index 0000000..42512f6
--- /dev/null
+++ b/ocr/src/main/java/cn/smartjavaai/ocr/idcard/IdCardOcrUtils.java
@@ -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 ETHNIC_SET = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
+ "汉", "蒙古", "回", "藏", "维吾尔", "苗", "彝", "壮", "布依", "朝鲜", "满", "侗",
+ "瑶", "白", "土家", "哈尼", "哈萨克", "傣", "黎", "傈僳", "佤", "畲", "高山",
+ "拉祜", "水", "东乡", "纳西", "景颇", "柯尔克孜", "土", "达斡尔", "仫佬",
+ "羌", "布朗", "撒拉", "毛南", "仡佬", "锡伯", "阿昌", "普米", "塔吉克", "怒",
+ "乌孜别克", "俄罗斯", "鄂温克", "德昂", "保安", "裕固", "京", "塔塔尔", "独龙",
+ "鄂伦春", "赫哲", "门巴", "珞巴", "基诺"
+ )));
+
+ private IdCardOcrUtils() {
+ }
+
+ static List filterSmallBoxes(List items, double factor) {
+ if (items == null || items.isEmpty()) {
+ return items;
+ }
+
+ List 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 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;
+ }
+}
diff --git a/ocr/src/main/java/cn/smartjavaai/ocr/idcard/IdCardParser.java b/ocr/src/main/java/cn/smartjavaai/ocr/idcard/IdCardParser.java
new file mode 100644
index 0000000..d526cc3
--- /dev/null
+++ b/ocr/src/main/java/cn/smartjavaai/ocr/idcard/IdCardParser.java
@@ -0,0 +1,15 @@
+package cn.smartjavaai.ocr.idcard;
+
+import cn.smartjavaai.ocr.entity.OcrInfo;
+
+/**
+ * 身份证解析接口,用于从 OCR 结果中解析结构化字段。
+ */
+public interface IdCardParser {
+
+ /**
+ * 从 OCR 结果中解析出指定类型的身份证信息
+ */
+ T parse(OcrInfo ocrInfo);
+}
+
diff --git a/ocr/src/main/java/cn/smartjavaai/ocr/idcard/IdCardPreprocessListener.java b/ocr/src/main/java/cn/smartjavaai/ocr/idcard/IdCardPreprocessListener.java
new file mode 100644
index 0000000..06c5fc3
--- /dev/null
+++ b/ocr/src/main/java/cn/smartjavaai/ocr/idcard/IdCardPreprocessListener.java
@@ -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) {}
+}
diff --git a/ocr/src/main/java/cn/smartjavaai/ocr/idcard/IdCardPreprocessResult.java b/ocr/src/main/java/cn/smartjavaai/ocr/idcard/IdCardPreprocessResult.java
new file mode 100644
index 0000000..040fe14
--- /dev/null
+++ b/ocr/src/main/java/cn/smartjavaai/ocr/idcard/IdCardPreprocessResult.java
@@ -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 reusableBoxes;
+
+ private boolean rotated;
+}
diff --git a/ocr/src/main/java/cn/smartjavaai/ocr/idcard/IdCardPreprocessor.java b/ocr/src/main/java/cn/smartjavaai/ocr/idcard/IdCardPreprocessor.java
new file mode 100644
index 0000000..63dfadd
--- /dev/null
+++ b/ocr/src/main/java/cn/smartjavaai/ocr/idcard/IdCardPreprocessor.java
@@ -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 detectedBoxes = textDetModel.detect(src);
+ List filteredBoxes = filterSmallBoxes(detectedBoxes);
+ if (filteredBoxes.isEmpty()) {
+ result.setReusableBoxes(detectedBoxes);
+ return result;
+ }
+ List 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 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 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 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 filterSmallBoxes(List boxes) {
+ if (boxes == null || boxes.isEmpty()) {
+ return boxes;
+ }
+ List items = new ArrayList<>(boxes.size());
+ for (OcrBox box : boxes) {
+ items.add(new OcrItem(box, AngleEnum.ANGLE_0, 1.0f));
+ }
+ List filteredItems = IdCardOcrUtils.filterSmallBoxes(items, 0.15);
+ List 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;
+ }
+
+}
diff --git a/ocr/src/main/java/cn/smartjavaai/ocr/idcard/IdCardRecognizer.java b/ocr/src/main/java/cn/smartjavaai/ocr/idcard/IdCardRecognizer.java
new file mode 100644
index 0000000..6624e3b
--- /dev/null
+++ b/ocr/src/main/java/cn/smartjavaai/ocr/idcard/IdCardRecognizer.java
@@ -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);
+
+
+}
diff --git a/ocr/src/main/java/cn/smartjavaai/ocr/idcard/IdCardValidator.java b/ocr/src/main/java/cn/smartjavaai/ocr/idcard/IdCardValidator.java
new file mode 100644
index 0000000..8b20e04
--- /dev/null
+++ b/ocr/src/main/java/cn/smartjavaai/ocr/idcard/IdCardValidator.java
@@ -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];
+ }
+}
diff --git a/pom.xml b/pom.xml
index 1104534..5391799 100644
--- a/pom.xml
+++ b/pom.xml
@@ -7,7 +7,7 @@
SmartJavaAI
cn.smartjavaai
smartjavaai-parent
- 1.1.1
+ 1.1.2
pom
SmartJavaAI
@@ -19,7 +19,6 @@
ocr
bom
speech
-
diff --git a/speech/pom.xml b/speech/pom.xml
index fdc832f..ef8dcdc 100644
--- a/speech/pom.xml
+++ b/speech/pom.xml
@@ -6,7 +6,7 @@
cn.smartjavaai
smartjavaai-parent
- 1.1.1
+ 1.1.2
speech
@@ -57,7 +57,7 @@
- 1.1.1
+ 1.1.2
speech
SmartJavaAI
https://github.com/geekwenjie/SmartJavaAI
diff --git a/translate/pom.xml b/translate/pom.xml
index b476a82..68cd53f 100644
--- a/translate/pom.xml
+++ b/translate/pom.xml
@@ -25,7 +25,7 @@
- 1.1.1
+ 1.1.2
translate
SmartJavaAI
https://github.com/geekwenjie/SmartJavaAI
diff --git a/vision/pom.xml b/vision/pom.xml
index 225de2f..4fbedb6 100644
--- a/vision/pom.xml
+++ b/vision/pom.xml
@@ -6,11 +6,11 @@
cn.smartjavaai
smartjavaai-parent
- 1.1.1
+ 1.1.2
vision
- 1.1.1
+ 1.1.2
vision
SmartJavaAI
https://github.com/geekwenjie/SmartJavaAI