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