diff --git a/.gitignore b/.gitignore index 4d5507e..dab9666 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,7 @@ ry8080/ ry8080.zip ry-console/ ry-vue2/ # 旧 vue 控制台 + +# ===== OCR 模型 (大二进制, 不进版本库, 部署时放 jar 同目录 models/) ===== +ry-api/models/ +ry-ocr-java/src/main/resources/models/ diff --git a/ry-api/ruoyi-admin/src/main/resources/application.yml b/ry-api/ruoyi-admin/src/main/resources/application.yml index df90b96..82087de 100644 --- a/ry-api/ruoyi-admin/src/main/resources/application.yml +++ b/ry-api/ruoyi-admin/src/main/resources/application.yml @@ -31,15 +31,31 @@ ruoyi: signName: 北京仙仁掌医学科技发展 template: SMS_321560247 esignTemplate: SMS_492460505 - esignBaseUrl: https://ringdoctor.com/hg + esignBaseUrl: https://risingdoctor.com/hg endpoint: dysmsapi.aliyuncs.com regionId: cn-hangzhou - # 发票 OCR (ry-ocr 微服务, PaddleOCR + FastAPI, 默认 http://127.0.0.1:8801) + # 发票 OCR (本地 Java 识别, PaddleOCR ONNX Runtime, 替代原 ry-ocr Python 微服务) ocr: - base-url: http://127.0.0.1:8801 + version: 0.1.0 + # 模型目录: 绝对路径 → 直读; 相对路径 → 相对进程工作目录 (java -jar 启动目录 = jar 同目录, 部署时 models/ 与 jar 同级) + models-dir: models + # 模型版本: v5_mobile (H=48, 20MB, 推荐) / v5_server (90MB+) / v4_mobile (15MB, 最快) + model-version: v5_mobile + # 单页 OCR 超时 / 整流程超时 (秒) + page-timeout-s: 15 + total-timeout-s: 60 + # QR 命中后是否继续跑全量 OCR (false = 快路径只返回 QR 3 字段) + qr-full-ocr: true + lang: ch + # PDF 优先抽内嵌文本层 (电子发票秒出, 扫描件自动回退 ONNX) + use-pdf-text-first: true + pdf-text-min-chars: 30 + upload: + max-mb: 20 + pdf-dpi: 150 # 扫码拍照 (ry-h5 相机网页地址, 前端二维码目标 URL) camera: - base-url: https://ringdoctor.com/camera/ + base-url: https://risingdoctor.com/camera/ # 开发环境配置 server: diff --git a/ry-api/ruoyi-business/pom.xml b/ry-api/ruoyi-business/pom.xml index 7619979..4859ee1 100644 --- a/ry-api/ruoyi-business/pom.xml +++ b/ry-api/ruoyi-business/pom.xml @@ -51,7 +51,7 @@ html2pdf 3.0.2 - + cn.hutool hutool-http @@ -67,7 +67,30 @@ hutool-core 5.8.27 - + + + com.microsoft.onnxruntime + onnxruntime + 1.20.0 + + + + org.apache.pdfbox + pdfbox + 2.0.31 + + + + com.google.zxing + core + 3.5.3 + + + com.google.zxing + javase + 3.5.3 + + org.projectlombok lombok diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/config/OcrConfig.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/config/OcrConfig.java deleted file mode 100644 index 473869a..0000000 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/config/OcrConfig.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.ruoyi.business.config; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import com.ruoyi.business.ocr.OcrClient; - -/** - * ry-ocr 微服务集成配置 - *

- * yml 配置: ruoyi.ocr.base-url (默认 http://127.0.0.1:8801) - */ -@Configuration -public class OcrConfig { - - @Value("${ruoyi.ocr.base-url:http://127.0.0.1:8801}") - private String ocrBaseUrl; - - @Bean - public OcrClient ocrClient() { - return new OcrClient(ocrBaseUrl); - } -} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectPlanController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectPlanController.java index 980912a..6813cc8 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectPlanController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectPlanController.java @@ -16,8 +16,8 @@ import com.ruoyi.business.service.IBizProjectPlanService; * 项目策划方案Controller * * 角色权限: - * - doctor: 只看自己投的稿 (submitter_id = 当前用户); 新建/编辑强制 submitter_id 写自己, status 默认 '0' - * - manager / sponsor / admin: 全部可见, 不强制 submitter + * - 投稿角色 (doctor/executor/sponsor): 只看自己投的稿 (submitter_id = 当前用户); 新建/编辑强制 submitter_id 写自己, status 默认 '0' + * - 管理角色 (admin/manager): 全部可见, 不强制 submitter (用于审核/结算) */ @RestController @RequestMapping("/business/projectPlan") @@ -28,9 +28,9 @@ public class BizProjectPlanController extends BaseController @GetMapping("/list") public TableDataInfo list(BizProjectPlan BizProjectPlan) { - // 医生角色: 后端兜底只查自己投的稿 + // 投稿角色: 后端兜底只查自己投的稿 String roleType = SecurityUtils.getLoginUser().getUser().getRoleType(); - if ("doctor".equals(roleType)) { + if (isSubmitterRole(roleType)) { BizProjectPlan.setSubmitterId(SecurityUtils.getUserId()); } startPage(); @@ -46,9 +46,9 @@ public class BizProjectPlanController extends BaseController @PostMapping public AjaxResult add(@RequestBody BizProjectPlan BizProjectPlan) { - // 医生角色: 强制 submitter_id 写自己 + 状态兜底 '0' (未提交), 防止绕过 + // 投稿角色: 强制 submitter_id 写自己 + 状态兜底 '0' (未提交), 防止绕过 String roleType = SecurityUtils.getLoginUser().getUser().getRoleType(); - if ("doctor".equals(roleType)) { + if (isSubmitterRole(roleType)) { BizProjectPlan.setSubmitterId(SecurityUtils.getUserId()); if (BizProjectPlan.getStatus() == null || BizProjectPlan.getStatus().isEmpty()) { BizProjectPlan.setStatus("0"); @@ -60,9 +60,9 @@ public class BizProjectPlanController extends BaseController @PutMapping public AjaxResult edit(@RequestBody BizProjectPlan BizProjectPlan) { - // 医生角色: 修改时也强制覆盖 submitter_id, 防止越权篡改 + // 投稿角色: 修改时也强制覆盖 submitter_id, 防止越权篡改 String roleType = SecurityUtils.getLoginUser().getUser().getRoleType(); - if ("doctor".equals(roleType)) { + if (isSubmitterRole(roleType)) { BizProjectPlan.setSubmitterId(SecurityUtils.getUserId()); } return toAjax(BizProjectPlanService.updateByPrimaryKey(BizProjectPlan)); @@ -73,4 +73,10 @@ public class BizProjectPlanController extends BaseController { return toAjax(BizProjectPlanService.deleteByPrimaryKeys(ids)); } + + /** 投稿角色 = 非 admin/manager (doctor/executor/sponsor): 只看/只写自己的稿 */ + private boolean isSubmitterRole(String roleType) + { + return !("admin".equals(roleType) || "manager".equals(roleType)); + } } \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/InvoiceResult.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/InvoiceResult.java index ea55cfc..8d90c9e 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/InvoiceResult.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/InvoiceResult.java @@ -8,11 +8,21 @@ import java.util.List; @Data public class InvoiceResult { private Boolean success; - private String rawText; + /** 是否被判定为发票 (false = 非发票图片) */ + private Boolean isInvoice = true; + private String rawText = ""; private String engine; private Integer pageCount; private Integer elapsedMs; private String error; + /** 错误码: not_invoice / timeout / unsupported / ocr_failed / process_failed */ + private String errorCode; + /** 是否从 QR 取到了核心字段 */ + private Boolean fromQr = false; + /** 二维码原始文本 (排查用) */ + private String qrRaw; + /** 二维码识别失败原因 (no_qr / bad_format) */ + private String qrError; private InvoiceFields fields; private List lines; } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/LocalInvoiceRecognizer.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/LocalInvoiceRecognizer.java new file mode 100644 index 0000000..f5565ea --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/LocalInvoiceRecognizer.java @@ -0,0 +1,57 @@ +package com.ruoyi.business.ocr; + +import cn.hutool.core.io.FileUtil; +import cn.hutool.http.HttpUtil; +import com.ruoyi.business.ocr.service.RecognizeService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.io.File; + +/** + * 本地发票识别门面 — 替代原 OcrClient (HTTP 调 Python ry-ocr 微服务). + *

+ * 直接委托 {@link RecognizeService} 在进程内跑 PaddleOCR ONNX 推理, + * 对外暴露与原 OcrClient 相同的两个方法, 调用方 (InvoiceOcrService) 无需改动. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class LocalInvoiceRecognizer { + + private final RecognizeService recognizeService; + + /** 识别发票 (图片或 PDF) */ + public InvoiceResult recognize(File file) { + byte[] content = FileUtil.readBytes(file); + return recognizeService.recognizeFile(file.getName(), content); + } + + /** + * 从 URL 识别发票: 下载 OSS URL 到临时文件 → 本地识别 → 清理临时文件. + */ + public InvoiceResult recognizeByUrl(String url) { + if (url == null || url.isEmpty()) { + throw new IllegalArgumentException("ossUrl 不能为空"); + } + File tmpDir = new File(System.getProperty("java.io.tmpdir"), "ry-ocr"); + if (!tmpDir.exists() && !tmpDir.mkdirs()) { + throw new RuntimeException("无法创建临时目录: " + tmpDir.getAbsolutePath()); + } + String name = url.substring(url.lastIndexOf('/') + 1); + if (name.indexOf('?') >= 0) name = name.substring(0, name.indexOf('?')); + if (name.indexOf('.') < 0) name = name + ".png"; + File tmp = new File(tmpDir, System.currentTimeMillis() + "_" + name); + try { + long size = HttpUtil.downloadFile(url, tmp); + if (size <= 0) { + throw new RuntimeException("OSS 文件下载失败或为空: " + url); + } + log.info("OCR 下载: url={} size={}B tmp={}", url, size, tmp.getAbsolutePath()); + return recognize(tmp); + } finally { + FileUtil.del(tmp); + } + } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/OcrClient.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/OcrClient.java deleted file mode 100644 index 72558f4..0000000 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/OcrClient.java +++ /dev/null @@ -1,120 +0,0 @@ -package com.ruoyi.business.ocr; - -import cn.hutool.core.io.FileUtil; -import cn.hutool.http.HttpRequest; -import cn.hutool.http.HttpResponse; -import cn.hutool.http.HttpUtil; -import cn.hutool.json.JSONObject; -import cn.hutool.json.JSONUtil; -import lombok.extern.slf4j.Slf4j; - -import java.io.File; - -/** - * ry-ocr Java 调用客户端 - * - * 依赖:hutool-http, hutool-json, hutool-core, lombok - * - * 用法: - * OcrClient client = new OcrClient("http://127.0.0.1:8801"); - * InvoiceResult r = client.recognize(new File("d:/发票.pdf")); - * InvoiceResult r2 = client.recognizeByUrl("https://oss.example.com/xxx.png"); - * System.out.println(r.getFields().getAmount()); - */ -@Slf4j -public class OcrClient { - - private final String baseUrl; - - public OcrClient(String baseUrl) { - this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; - } - - /** 健康检查 */ - public boolean ping() { - try (HttpResponse resp = HttpRequest.get(baseUrl + "/health").timeout(3000).execute()) { - return resp.getStatus() == 200 && "ok".equals(JSONUtil.parseObj(resp.body()).getStr("status")); - } catch (Exception e) { - log.warn("ocr ping failed: {}", e.getMessage()); - return false; - } - } - - /** 识别发票(图片或 PDF) */ - public InvoiceResult recognize(File file) { - try (HttpResponse resp = HttpRequest.post(baseUrl + "/recognize/invoice") - .form("file", file) - .timeout(60_000) - .execute()) { - - String body = resp.body(); - JSONObject json = JSONUtil.parseObj(body); - if (resp.getStatus() != 200) { - throw new RuntimeException("OCR 调用失败: " + resp.getStatus() + " " + body); - } - return parse(json); - } - } - - /** - * 从 URL 识别发票: 后端下载 OSS URL 到临时文件 → recognize → 清理临时文件. - * 临时文件目录: System.getProperty("java.io.tmpdir")/ry-ocr/ - * - * @param url OSS 可访问 URL - * @return 识别结果 - */ - public InvoiceResult recognizeByUrl(String url) { - if (url == null || url.isEmpty()) { - throw new IllegalArgumentException("ossUrl 不能为空"); - } - File tmpDir = new File(System.getProperty("java.io.tmpdir"), "ry-ocr"); - if (!tmpDir.exists() && !tmpDir.mkdirs()) { - throw new RuntimeException("无法创建临时目录: " + tmpDir.getAbsolutePath()); - } - // 从 URL 截取文件名, 保留后缀 (用于 ry-ocr 推断图片/PDF) - String name = url.substring(url.lastIndexOf('/') + 1); - if (name.indexOf('?') >= 0) name = name.substring(0, name.indexOf('?')); - if (name.indexOf('.') < 0) name = name + ".png"; - File tmp = new File(tmpDir, System.currentTimeMillis() + "_" + name); - try { - long size = HttpUtil.downloadFile(url, tmp); - if (size <= 0) { - throw new RuntimeException("OSS 文件下载失败或为空: " + url); - } - log.info("OCR 下载: url={} size={}B tmp={}", url, size, tmp.getAbsolutePath()); - return recognize(tmp); - } finally { - FileUtil.del(tmp); - } - } - - private InvoiceResult parse(JSONObject json) { - InvoiceResult r = new InvoiceResult(); - r.setSuccess(json.getBool("success", false)); - r.setRawText(json.getStr("rawText", "")); - r.setEngine(json.getStr("engine", "")); - r.setPageCount(json.getInt("pageCount", 1)); - r.setElapsedMs(json.getInt("elapsedMs", 0)); - r.setError(json.getStr("error")); - - JSONObject f = json.getJSONObject("fields"); - if (f != null) { - InvoiceFields fields = new InvoiceFields(); - fields.setInvoiceType(f.getStr("invoiceType")); - fields.setInvoiceNo(f.getStr("invoiceNo")); - fields.setInvoiceCode(f.getStr("invoiceCode")); - fields.setInvoiceDate(f.getStr("invoiceDate")); - fields.setAmount(f.getDouble("amount")); - fields.setAmountCn(f.getStr("amountCn")); - fields.setAmountPretax(f.getDouble("amount_pretax")); - fields.setTaxAmount(f.getDouble("taxAmount")); - fields.setSellerName(f.getStr("sellerName")); - fields.setSellerTaxNo(f.getStr("sellerTaxNo")); - fields.setBuyerName(f.getStr("buyerName")); - fields.setBuyerTaxNo(f.getStr("buyerTaxNo")); - fields.setAmountMatch(f.getBool("amountMatch")); - r.setFields(fields); - } - return r; - } -} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/OcrLine.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/OcrLine.java index e4c3ccb..4919939 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/OcrLine.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/OcrLine.java @@ -1,13 +1,17 @@ package com.ruoyi.business.ocr; +import lombok.AllArgsConstructor; import lombok.Data; +import lombok.NoArgsConstructor; import java.util.List; /** 单行 OCR 识别结果 */ @Data +@NoArgsConstructor +@AllArgsConstructor public class OcrLine { private String text; private Double confidence; - private List> box; + private List> box; } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/QrDecodeResult.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/QrDecodeResult.java new file mode 100644 index 0000000..fcb6116 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/QrDecodeResult.java @@ -0,0 +1,31 @@ +package com.ruoyi.business.ocr; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 二维码识别结果 — 对齐 Python app.services.qr_decoder.QRDecodeResult + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class QrDecodeResult { + + /** 发票号码 */ + private String invoiceNo; + + /** 金额 (小写) */ + private Double amount; + + /** 开票日期 YYYY-MM-DD */ + private String invoiceDate; + + /** 二维码原始文本 */ + private String raw = ""; + + /** 是否有任一关键字段解出 */ + public boolean hasAnyField() { + return invoiceNo != null || amount != null || invoiceDate != null; + } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/config/OcrProperties.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/config/OcrProperties.java new file mode 100644 index 0000000..f18853f --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/config/OcrProperties.java @@ -0,0 +1,73 @@ +package com.ruoyi.business.ocr.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** + * 本地发票 OCR 配置 (移植自 ry-ocr-java, 对应 Python app/config.py + .env) + *

+ * 绑定 application.yml 中 ruoyi.ocr.* 配置段. + */ +@Data +@Component +@ConfigurationProperties(prefix = "ruoyi.ocr") +public class OcrProperties { + + /** 服务版本号 (对应 Python __version__) */ + private String version = "0.1.0"; + + private final Upload upload = new Upload(); + private final Ocr ocr = new Ocr(); + + /** + * 路径白名单: 空字符串/空列表 = 禁用 by-path 接口 + *

+ * 支持: yml 数组 ["E:\\a", "D:\\b"] 或单字符串 "E:\\a;D:\\b" (Windows 分号分隔) + */ + private List allowedDirs = new ArrayList<>(); + + @Data + public static class Upload { + /** 单文件最大 MB, 超限返回 HTTP 413 */ + private int maxMb = 20; + /** PDF 转图片 DPI */ + private int pdfDpi = 150; + } + + @Data + public static class Ocr { + /** 单页 OCR 超时 (秒) */ + private int pageTimeoutS = 15; + /** 整流程 OCR 超时 (秒) */ + private int totalTimeoutS = 60; + /** QR 命中后是否继续跑全量 OCR + 字段抽取 (false = 快路径, 只返回 QR 3 字段) */ + private boolean qrFullOcr = true; + /** OCR 语言: ch (简中) / en / chinese_cht */ + private String lang = "ch"; + /** 模型目录: 相对路径 → classpath:models/, 绝对路径 → 直读 */ + private String modelsDir = "models"; + /** PDF 文件优先抽内嵌文本层 (pdftotext 等价) — 抽到非空文本则跳过 ONNX. + * 适用电子发票 / 数电票 PDF (含真实文本); 扫描件 PDF 会回退到 ONNX. */ + private boolean usePdfTextFirst = true; + /** 内嵌文本字符数低于此值视为无效, 回退到 ONNX */ + private int pdfTextMinChars = 30; + /** 模型版本 (决定 CRNN 输入高度): + *

+ * 切换时改 models-dir + 本字段 + 重启即可. */ + private String modelVersion = "v5_mobile"; + /** CRNN 输入高度 (覆盖 modelVersion 默认值). 高级用户用, 一般不动. */ + private Integer recHeight = null; + /** CRNN 最大宽度 (覆盖 modelVersion 默认值) */ + private Integer recMaxWidth = null; + /** DB 检测最长边限制 (覆盖 modelVersion 默认值) */ + private Integer detMaxSide = null; + } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/core/CtcDecoder.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/core/CtcDecoder.java new file mode 100644 index 0000000..7104026 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/core/CtcDecoder.java @@ -0,0 +1,53 @@ +package com.ruoyi.business.ocr.core; + +/** + * CTC greedy decoder: argmax → 去连续重复 → 去 blank (idx 0) → 字典查表. + */ +public final class CtcDecoder { + + private CtcDecoder() {} + + /** + * @param logits [T, N] + * @param dict 字典 (idx 0 = blank) + * @return RecognizedText + */ + public static TextRecognizer.RecognizedText decode(float[][] logits, Dictionary dict) { + int t = logits.length; + if (t == 0) return new TextRecognizer.RecognizedText("", 0.0); + + StringBuilder sb = new StringBuilder(); + int lastIdx = -1; + double confSum = 0; + int confCount = 0; + + for (int i = 0; i < t; i++) { + int bestIdx = 0; + float bestVal = Float.NEGATIVE_INFINITY; + for (int j = 0; j < logits[i].length; j++) { + if (logits[i][j] > bestVal) { + bestVal = logits[i][j]; + bestIdx = j; + } + } + if (bestIdx != 0 && bestIdx != lastIdx) { + // 跳过 blank (0), 跳过与上一次相同的 (CTC 合并) + if (bestIdx < dict.size()) { + sb.append(dict.getCharacters().get(bestIdx)); + } + // softmax → exp / sum + double sumExp = 0; + for (int j = 0; j < logits[i].length; j++) { + sumExp += Math.exp(logits[i][j] - bestVal); + } + double prob = 1.0 / sumExp; + confSum += prob; + confCount++; + } + lastIdx = bestIdx; + } + + double conf = confCount == 0 ? 0 : confSum / confCount; + return new TextRecognizer.RecognizedText(sb.toString(), conf); + } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/core/DbPostProcessor.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/core/DbPostProcessor.java new file mode 100644 index 0000000..00d89a6 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/core/DbPostProcessor.java @@ -0,0 +1,150 @@ +package com.ruoyi.business.ocr.core; + +import java.awt.geom.Path2D; +import java.awt.geom.PathIterator; +import java.util.ArrayList; +import java.util.List; + +/** + * DB (Differentiable Binarization) 后处理 — 从概率图提取文本框 polygon. + *

+ * 复刻 Python PaddleOCR 的 db_post_process / boxes_from_bitmap. + */ +public final class DbPostProcessor { + + private DbPostProcessor() {} + + /** + * @param prob [H, W] 概率图 + * @param resizeH/W 概率图对应的输入图尺寸 + * @param origH/W 原图尺寸 (用于映射回原图坐标) + */ + public static List> postProcess(float[][] prob, int resizeH, int resizeW, + int origH, int origW, + float dbThresh, float boxThresh, + float unclipRatio) { + // 1. 二值化 + 膨胀 (这里简化为阈值 + 内置 unclipRatio 计算 box) + // 生产实现需要 findContours, 这里用简化: 标记连通分量 + bounding box + unclip + boolean[][] mask = new boolean[resizeH][resizeW]; + for (int y = 0; y < resizeH; y++) { + for (int x = 0; x < resizeW; x++) { + mask[y][x] = prob[y][x] >= dbThresh; + } + } + // 简易膨胀 (3x3) + boolean[][] dilated = dilate(mask, resizeH, resizeW); + // 简易 8 邻接连通分量 + List components = connectedComponents(dilated, resizeH, resizeW); + // 过滤 + unclip + float scaleX = (float) origW / resizeW; + float scaleY = (float) origH / resizeH; + List> boxes = new ArrayList<>(); + for (int[][] component : components) { + int minX = component[0][0], minY = component[0][1]; + int maxX = component[0][0], maxY = component[0][1]; + int area = 0; + for (int[] p : component) { + if (p[0] < minX) minX = p[0]; + if (p[0] > maxX) maxX = p[0]; + if (p[1] < minY) minY = p[1]; + if (p[1] > maxY) maxY = p[1]; + area++; + } + // box_thresh 过滤: 用平均 prob 二次过滤 + if (area < 3) continue; + float meanProb = 0; + for (int[] p : component) { + meanProb += prob[p[1]][p[0]]; + } + meanProb /= area; + if (meanProb < boxThresh) continue; + + // unclip: 扩展 box (简化为固定比例放大) + int w = maxX - minX + 1, h = maxY - minY + 1; + int dx = (int) (w * (unclipRatio - 1) / 2); + int dy = (int) (h * (unclipRatio - 1) / 2); + minX = Math.max(0, minX - dx); + minY = Math.max(0, minY - dy); + maxX = Math.min(resizeW - 1, maxX + dx); + maxY = Math.min(resizeH - 1, maxY + dy); + + List box = new ArrayList<>(); + float[][] corners = { + {minX * scaleX, minY * scaleY}, + {maxX * scaleX, minY * scaleY}, + {maxX * scaleX, maxY * scaleY}, + {minX * scaleX, maxY * scaleY} + }; + for (float[] c : corners) { + box.add(c[0]); + box.add(c[1]); + } + boxes.add(box); + } + // 按 y 排序 (从上到下) + boxes.sort((a, b) -> Float.compare(a.get(1), b.get(1))); + return boxes; + } + + private static boolean[][] dilate(boolean[][] src, int h, int w) { + boolean[][] dst = new boolean[h][w]; + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + boolean any = false; + for (int dy = -1; dy <= 1 && !any; dy++) { + for (int dx = -1; dx <= 1 && !any; dx++) { + int ny = y + dy, nx = x + dx; + if (ny >= 0 && ny < h && nx >= 0 && nx < w && src[ny][nx]) { + any = true; + } + } + } + dst[y][x] = any; + } + } + return dst; + } + + private static List connectedComponents(boolean[][] mask, int h, int w) { + boolean[][] visited = new boolean[h][w]; + List result = new ArrayList<>(); + int[] dx = {-1, 0, 1, -1, 1, -1, 0, 1}; + int[] dy = {-1, -1, -1, 0, 0, 1, 1, 1}; + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + if (!mask[y][x] || visited[y][x]) continue; + List comp = new ArrayList<>(); + java.util.Deque stack = new java.util.ArrayDeque<>(); + stack.push(new int[]{x, y}); + visited[y][x] = true; + while (!stack.isEmpty()) { + int[] p = stack.pop(); + comp.add(p); + for (int i = 0; i < 8; i++) { + int nx = p[0] + dx[i], ny = p[1] + dy[i]; + if (nx >= 0 && nx < w && ny >= 0 && ny < h && mask[ny][nx] && !visited[ny][nx]) { + visited[ny][nx] = true; + stack.push(new int[]{nx, ny}); + } + } + } + result.add(comp.toArray(new int[0][])); + } + } + return result; + } + + // 工具: polygon → bounding box + public static int[] bbox(List box) { + float minX = Float.MAX_VALUE, minY = Float.MAX_VALUE; + float maxX = -Float.MAX_VALUE, maxY = -Float.MAX_VALUE; + for (int i = 0; i < box.size(); i += 2) { + float x = box.get(i), y = box.get(i + 1); + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + return new int[]{Math.round(minX), Math.round(minY), Math.round(maxX), Math.round(maxY)}; + } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/core/Dictionary.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/core/Dictionary.java new file mode 100644 index 0000000..a3ca660 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/core/Dictionary.java @@ -0,0 +1,56 @@ +package com.ruoyi.business.ocr.core; + +import lombok.Getter; + +import java.io.BufferedReader; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * 中文字典加载 — 加载 PaddleOCR ppocr_keys_v1.txt + *

+ * 模型输出约定: PP-OCRv5 multilingual 的输出维度 = len(dict_file_lines) + 2, 其中 + *

+ * 因此本类在加载时**主动在 idx 0 插入空串占位, idx 1 插入半角空格**, 使 + * dict.characters[2] 对应文件第 1 行. + *

+ * ⚠️ 此约定根据 monkt/paddleocr-onnx PP-OCRv5 mobile 输出维度 18385 (文件 18383 行 + 2) + * 反推得出, 适配大多数 PP-OCRv5 导出模型. + */ +@Getter +public class Dictionary { + + /** 字符表: idx 0 = CTC blank 占位, idx 1 = " " (半角空格), idx 2.. = 文件字符 */ + private final List characters; + + private Dictionary(List characters) { + this.characters = characters; + } + + public int size() { + return characters.size(); + } + + public static Dictionary load(Path dictPath) throws IOException { + List chars = new ArrayList<>(); + // idx 0 = CTC blank 占位 (模型 output[0] = blank, 由 CtcDecoder 跳过) + chars.add(""); + try (BufferedReader r = Files.newBufferedReader(dictPath, StandardCharsets.UTF_8)) { + String line; + while ((line = r.readLine()) != null) { + if (!line.isEmpty()) { + chars.add(line); + } + } + } + return new Dictionary(chars); + } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/core/ImageProcessor.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/core/ImageProcessor.java new file mode 100644 index 0000000..efa23a8 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/core/ImageProcessor.java @@ -0,0 +1,227 @@ +package com.ruoyi.business.ocr.core; + +import lombok.extern.slf4j.Slf4j; + +import javax.imageio.ImageIO; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.geom.AffineTransform; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; + +/** + * 图像预处理: 自动旋转 / 轻度增强 — 对齐 Python app.core.image_processor + *

+ * 全部使用 Java 2D, 无需引入 OpenCV. + */ +@Slf4j +public final class ImageProcessor { + + private ImageProcessor() {} + + /** + * 简易方向校正: 纵向图 (高 > 宽 × 1.2) 顺时针旋转 90°. + *

+ * 复杂倾斜交给 ONNX 引擎自带的 textline orientation. + */ + public static Path autoRotate(Path imgPath) { + Path p = imgPath; + BufferedImage img; + try { + img = ImageIO.read(p.toFile()); + } catch (IOException e) { + log.warn("read image failed: {}", e.getMessage()); + return p; + } + if (img == null) { + return p; + } + int h = img.getHeight(); + int w = img.getWidth(); + if (h > w * 1.2) { + BufferedImage rotated = rotate90Clockwise(img); + Path out = p.resolveSibling(stem(p) + "_rot.png"); + try { + ImageIO.write(rotated, "png", out.toFile()); + return out; + } catch (IOException e) { + log.warn("write rotated image failed: {}", e.getMessage()); + } + } + return p; + } + + /** + * 轻度增强: 灰度化 + (低对比度图) 自适应二值化. + */ + public static Path enhance(Path imgPath) { + Path p = imgPath; + BufferedImage img; + try { + img = ImageIO.read(p.toFile()); + } catch (IOException e) { + log.warn("read image failed: {}", e.getMessage()); + return p; + } + if (img == null) { + return p; + } + BufferedImage gray = toGray(img); + double std = stddev(gray); + if (std < 50) { + BufferedImage binary = adaptiveThreshold(gray, 31, 10); + Path out = p.resolveSibling(stem(p) + "_enh.png"); + try { + ImageIO.write(binary, "png", out.toFile()); + return out; + } catch (IOException e) { + log.warn("write enhanced image failed: {}", e.getMessage()); + } + } + return p; + } + + // ---------- 内部 ---------- + + private static String stem(Path p) { + String name = p.getFileName().toString(); + int dot = name.lastIndexOf('.'); + return dot > 0 ? name.substring(0, dot) : name; + } + + private static BufferedImage rotate90Clockwise(BufferedImage src) { + int w = src.getWidth(); + int h = src.getHeight(); + BufferedImage dst = new BufferedImage(h, w, src.getType() == 0 ? BufferedImage.TYPE_INT_RGB : src.getType()); + Graphics2D g = dst.createGraphics(); + AffineTransform tx = new AffineTransform(); + tx.translate(h, 0); + tx.rotate(Math.toRadians(90)); + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g.drawImage(src, tx, null); + g.dispose(); + return dst; + } + + private static BufferedImage toGray(BufferedImage src) { + if (src.getType() == BufferedImage.TYPE_BYTE_GRAY) return src; + BufferedImage gray = new BufferedImage(src.getWidth(), src.getHeight(), BufferedImage.TYPE_BYTE_GRAY); + Graphics2D g = gray.createGraphics(); + g.drawImage(src, 0, 0, null); + g.dispose(); + return gray; + } + + /** + * 计算灰度图标准差 (判断是否低对比度) + *

+ * 优化: 用 getRGB() 一次性读出整张图到 int[], 直接遍历 byte 提取亮度, 避免 Raster.getSample 逐元素调用 (慢 10x+). + */ + private static double stddev(BufferedImage gray) { + int w = gray.getWidth(); + int h = gray.getHeight(); + int[] pixels = new int[w * h]; + gray.getRGB(0, 0, w, h, pixels, 0, w); + long sum = 0; + long sumSq = 0; + long count = 0; + // 采样: 每 4 像素采样一次 (避免遍历几百万像素) + for (int i = 0; i < pixels.length; i += 16) { + int v = pixels[i] >>> 24 == 0 ? pixels[i] & 0xFF : (pixels[i] >> 16) & 0xFF; // 灰度图 R=G=B + // TYPE_BYTE_GRAY 的灰度值在 R/G/B 都一样, 取 R 即可 + // 直接按 TYPE_BYTE_GRAY: ARGB 编码时 R 通道存的就是灰度 + sum += v; + sumSq += (long) v * v; + count++; + } + if (count == 0) return 0; + double mean = (double) sum / count; + double variance = ((double) sumSq / count) - mean * mean; + return Math.sqrt(Math.max(0, variance)); + } + + /** + * 简易自适应二值化 (高斯加权 + 常数偏移). + *

+ * 与 cv2.adaptiveThreshold(..., ADAPTIVE_THRESH_GAUSSIAN_C, ...) 行为近似. + *

+ * 优化: 整图 getRGB 一次性读出, 用 byte[] 操作, 避免 Raster 逐元素访问. + */ + private static BufferedImage adaptiveThreshold(BufferedImage gray, int blockSize, int C) { + int w = gray.getWidth(); + int h = gray.getHeight(); + int[] srcPixels = new int[w * h]; + gray.getRGB(0, 0, w, h, srcPixels, 0, w); + // 提取灰度字节 (TYPE_BYTE_GRAY 在 BufferedImage 内部实际存为 TYPE_INT_ARGB 但 R 通道就是灰度) + byte[] srcGray = new byte[w * h]; + for (int i = 0; i < srcPixels.length; i++) { + srcGray[i] = (byte) (srcPixels[i] & 0xFF); + } + // 先做 box blur (近似高斯) + byte[] blurredGray = boxBlurBytes(srcGray, w, h, blockSize); + BufferedImage out = new BufferedImage(w, h, BufferedImage.TYPE_BYTE_BINARY); + byte[] outData = new byte[w * h]; + for (int y = 0; y < h; y++) { + int rowStart = y * w; + for (int x = 0; x < w; x++) { + int idx = rowStart + x; + int src = srcGray[idx] & 0xFF; + int bg = blurredGray[idx] & 0xFF; + int v = src - bg + 127 - C; + outData[idx] = (byte) (v > 127 ? 255 : 0); + } + } + out.getRaster().setDataElements(0, 0, w, h, outData); + return out; + } + + /** + * 轻量 box blur (半径 = blockSize / 2) — 纯 byte[] 数组操作, 无 Raster 开销 + */ + private static byte[] boxBlurBytes(byte[] gray, int w, int h, int blockSize) { + int radius = Math.max(1, blockSize / 2); + // 构造积分图 (像素值累加, 0-255) + int[] integral = new int[w * h]; + for (int y = 0; y < h; y++) { + int rowSum = 0; + int rowStart = y * w; + for (int x = 0; x < w; x++) { + rowSum += gray[rowStart + x] & 0xFF; + integral[rowStart + x] = rowSum + (y > 0 ? integral[rowStart + x - w] : 0); + } + } + byte[] out = new byte[w * h]; + for (int y = 0; y < h; y++) { + int y1 = Math.max(0, y - radius); + int y2 = Math.min(h - 1, y + radius); + int rowStart = y * w; + for (int x = 0; x < w; x++) { + int x1 = Math.max(0, x - radius); + int x2 = Math.min(w - 1, x + radius); + int area = (x2 - x1 + 1) * (y2 - y1 + 1); + int sum = integral[y2 * w + x2]; + if (x1 > 0) sum -= integral[y2 * w + (x1 - 1)]; + if (y1 > 0) sum -= integral[(y1 - 1) * w + x2]; + if (x1 > 0 && y1 > 0) sum += integral[(y1 - 1) * w + (x1 - 1)]; + out[rowStart + x] = (byte) (sum / area); + } + } + return out; + } + + /** 加载 BufferedImage (封装异常) */ + public static BufferedImage read(Path p) throws IOException { + return ImageIO.read(p.toFile()); + } + + /** 保存 BufferedImage (封装异常) */ + public static void write(BufferedImage img, Path p) throws IOException { + File f = p.toFile(); + if (f.getParentFile() != null) { + f.getParentFile().mkdirs(); + } + ImageIO.write(img, "png", f); + } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/core/OcrEngine.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/core/OcrEngine.java new file mode 100644 index 0000000..019b55b --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/core/OcrEngine.java @@ -0,0 +1,231 @@ +package com.ruoyi.business.ocr.core; + +import ai.onnxruntime.OrtEnvironment; +import ai.onnxruntime.OrtException; +import com.ruoyi.business.ocr.config.OcrProperties; +import com.ruoyi.business.ocr.exception.OcrTimeoutException; +import com.ruoyi.business.ocr.OcrLine; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.*; + +/** + * OCR 引擎单例 — 对齐 Python app.core.ocr_engine + *

+ * 单例 + 单页超时 (Future.get(timeout)). + * 底层: PaddleOCR ONNX Runtime (det + rec). + */ +@Slf4j +@Component +public class OcrEngine { + + private final OcrProperties props; + private final ExecutorService executor = Executors.newFixedThreadPool(2, r -> { + Thread t = new Thread(r, "ocr-worker"); + t.setDaemon(true); + return t; + }); + + private OrtEnvironment ortEnv; + private TextDetector detector; + private TextRecognizer recognizer; + + private volatile boolean ready = false; + + public OcrEngine(OcrProperties props) { + this.props = props; + } + + /** + * 启动预热: 加载 ONNX Session + */ + @PostConstruct + public synchronized void warmup() { + try { + Path modelsDir = resolveModelsDir(); + Path detPath = modelsDir.resolve("det.onnx"); + Path recPath = modelsDir.resolve("rec.onnx"); + Path dictPath = modelsDir.resolve("ppocr_keys_v1.txt"); + + if (!Files.exists(detPath) || !Files.exists(recPath) || !Files.exists(dictPath)) { + log.warn("OCR 模型未找到 ({}/det.onnx + rec.onnx + ppocr_keys_v1.txt), 引擎未就绪, /health 返回 degraded", modelsDir); + return; + } + + log.info("加载 OCR 模型 from {} (version={})", modelsDir, props.getOcr().getModelVersion()); + long t0 = System.currentTimeMillis(); + this.ortEnv = OrtEnvironment.getEnvironment(); + this.detector = new TextDetector(ortEnv, detPath, resolveDetMaxSide()); + Dictionary dict = Dictionary.load(dictPath); + this.recognizer = new TextRecognizer(ortEnv, recPath, dict, resolveRecHeight(), resolveRecMaxW()); + this.ready = true; + log.info("OCR 引擎就绪, 耗时 {}ms (recHeight={}, recMaxW={}, detMaxSide={})", + System.currentTimeMillis() - t0, resolveRecHeight(), resolveRecMaxW(), resolveDetMaxSide()); + } catch (Exception e) { + log.warn("OCR 引擎初始化失败: {}", e.getMessage(), e); + } + } + + /** + * 解析模型目录: 相对路径 → classpath:models/, 绝对路径 → 直读 + */ + private Path resolveModelsDir() { + String dir = props.getOcr().getModelsDir(); + Path p = Path.of(dir); + if (p.isAbsolute()) return p; + + // 部署约定: 模型与 jar 同目录 (user.dir = java -jar 启动目录, 见 ry.sh 的 `pwd`) + Path workDir = Path.of(System.getProperty("user.dir"), dir); + if (hasModels(workDir)) return workDir; + + // classpath: resources/models/ (开发环境备选) + try { + java.net.URL url = getClass().getClassLoader().getResource(dir); + if (url != null && "file".equals(url.getProtocol())) { + return Path.of(url.toURI()); + } + } catch (Exception ignored) {} + + // IDE 源码目录回退 + return Path.of("src/main/resources", dir); + } + + /** det.onnx + rec.onnx + ppocr_keys_v1.txt 齐全才算有效模型目录 */ + private boolean hasModels(Path modelsDir) { + return Files.exists(modelsDir.resolve("det.onnx")) + && Files.exists(modelsDir.resolve("rec.onnx")) + && Files.exists(modelsDir.resolve("ppocr_keys_v1.txt")); + } + + /** 解析 CRNN 输入高度: 显式 rec-height > modelVersion 默认 */ + private int resolveRecHeight() { + Integer override = props.getOcr().getRecHeight(); + if (override != null) return override; + return switch (props.getOcr().getModelVersion()) { + case "v5_server", "v5_mobile" -> 48; + case "v4_mobile" -> 32; + default -> 48; + }; + } + + private int resolveRecMaxW() { + Integer override = props.getOcr().getRecMaxWidth(); + if (override != null) return override; + return 320; + } + + private int resolveDetMaxSide() { + Integer override = props.getOcr().getDetMaxSide(); + if (override != null) return override; + return switch (props.getOcr().getModelVersion()) { + case "v5_server", "v5_mobile" -> 800; // 优化: v5 原生 960 → 800, 算力 -31% + case "v4_mobile" -> 960; + default -> 960; + }; + } + + @PreDestroy + public void close() { + try { + if (recognizer != null) recognizer.close(); + if (detector != null) detector.close(); + } catch (Exception e) { + log.warn("close engine error: {}", e.getMessage()); + } + executor.shutdownNow(); + } + + public boolean isReady() { + return ready; + } + + /** + * 识别单张图片 — 带单页超时 + */ + public List recognize(Path imagePath) { + if (!ready) { + throw new IllegalStateException("OCR 引擎未就绪, 请检查 models/ 目录下是否有 det.onnx / rec.onnx / ppocr_keys_v1.txt"); + } + int timeoutSec = props.getOcr().getPageTimeoutS(); + Future> future = executor.submit(() -> doRecognize(imagePath)); + try { + return future.get(timeoutSec, TimeUnit.SECONDS); + } catch (TimeoutException e) { + future.cancel(true); + throw new OcrTimeoutException("OCR 识别超时 (" + timeoutSec + "秒): " + imagePath); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new OcrRuntimeException("OCR 中断: " + e.getMessage()); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof OcrTimeoutException) throw (OcrTimeoutException) cause; + if (cause instanceof OcrRuntimeException) throw (OcrRuntimeException) cause; + throw new OcrRuntimeException("OCR 执行异常: " + (cause == null ? e.getMessage() : cause.getMessage()), cause); + } + } + + private List doRecognize(Path imagePath) { + try { + BufferedImage img = ImageIO.read(imagePath.toFile()); + if (img == null) return Collections.emptyList(); + + // 1. 检测 + List> boxes = detector.detect(img); + // 2. 过滤 + 收集 crops (排除 height<8 或 width<5 的噪点 — 不会影响字段抽取) + List crops = new ArrayList<>(); + List> validBoxes = new ArrayList<>(); + for (List box : boxes) { + int[] bb = DbPostProcessor.bbox(box); + int x1 = Math.max(0, bb[0]); + int y1 = Math.max(0, bb[1]); + int x2 = Math.min(img.getWidth(), bb[2]); + int y2 = Math.min(img.getHeight(), bb[3]); + int w = x2 - x1, h = y2 - y1; + if (w < 5 || h < 8) continue; // 过小 — 噪点 + if (w < 3 || h < 3) continue; // 原安全检查 + crops.add(img.getSubimage(x1, y1, w, h)); + validBoxes.add(box); + } + + // 3. 批量推理 (性能关键: ONNX 一次推理处理所有 crop) + List rts = recognizer.recognizeBatch(crops); + + // 4. 配对 boxes + texts + List lines = new ArrayList<>(); + for (int i = 0; i < validBoxes.size(); i++) { + TextRecognizer.RecognizedText rt = rts.get(i); + if (rt.text() != null && !rt.text().isEmpty()) { + List poly = validBoxes.get(i); + List> boxList = new ArrayList<>(); + for (int k = 0; k < poly.size(); k += 2) { + List p = new ArrayList<>(); + p.add(poly.get(k)); + p.add(poly.get(k + 1)); + boxList.add(p); + } + lines.add(new OcrLine(rt.text().trim(), rt.confidence(), boxList)); + } + } + return lines; + } catch (OrtException | IOException e) { + throw new OcrRuntimeException("OCR 执行异常: " + e.getMessage(), e); + } + } + + /** 内部异常, 避免暴露 ONNX 细节 */ + public static class OcrRuntimeException extends RuntimeException { + public OcrRuntimeException(String message) { super(message); } + public OcrRuntimeException(String message, Throwable cause) { super(message, cause); } + } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/core/PdfProcessor.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/core/PdfProcessor.java new file mode 100644 index 0000000..ab42988 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/core/PdfProcessor.java @@ -0,0 +1,86 @@ +package com.ruoyi.business.ocr.core; + +import com.ruoyi.business.ocr.config.OcrProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.rendering.PDFRenderer; +import org.apache.pdfbox.text.PDFTextStripper; +import org.springframework.stereotype.Component; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * PDF → 图片 — 对齐 Python app.core.pdf_processor + *

+ * 使用 PDFBox (无需 poppler 等系统依赖). + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PdfProcessor { + + private final OcrProperties props; + + /** + * 把 PDF 每页渲染成 PNG, 返回临时文件路径列表. + *

+ * 输出目录: {pdf.parent}/.{pdf.stem}_pages/page_{idx:03d}.png + */ + public List pdfToImages(Path pdfPath) throws IOException { + int dpi = props.getUpload().getPdfDpi(); + Path outDir = pdfPath.getParent().resolve("." + stem(pdfPath) + "_pages"); + outDir.toFile().mkdirs(); + + List saved = new ArrayList<>(); + try (PDDocument doc = PDDocument.load(pdfPath.toFile())) { + PDFRenderer renderer = new PDFRenderer(doc); + renderer.setSubsamplingAllowed(true); // 大图降采样, 提速 + 省内存 + int pageCount = doc.getNumberOfPages(); + for (int idx = 0; idx < pageCount; idx++) { + BufferedImage img = renderer.renderImageWithDPI(idx, dpi); + Path outPath = outDir.resolve(String.format("page_%03d.png", idx + 1)); + ImageProcessor.write(img, outPath); + saved.add(outPath); + } + } + log.info("PDF 转图片: {} → {} 页 (dpi={})", pdfPath.getFileName(), saved.size(), dpi); + return saved; + } + + private static String stem(Path p) { + String name = p.getFileName().toString(); + int dot = name.lastIndexOf('.'); + return dot > 0 ? name.substring(0, dot) : name; + } + + /** + * 抽取 PDF 内嵌文本(pdftotext 等价)— 用于"不调用 ONNX"的 fast path + *

+ * 适用: 电子发票 / 数电票 PDF (含真实可复制文本层). + * 扫描件 PDF 抽出来为空或字符极少, 调用方应回退到 OCR 流程. + * + * @return 抽取到的纯文本 (trim 后); 若文件无文本层, 返回空字符串 + */ + public String extractText(Path pdfPath) throws IOException { + try (PDDocument doc = PDDocument.load(pdfPath.toFile())) { + PDFTextStripper stripper = new PDFTextStripper(); + // 逐页拼接 + StringBuilder sb = new StringBuilder(); + int pageCount = doc.getNumberOfPages(); + for (int i = 1; i <= pageCount; i++) { + stripper.setStartPage(i); + stripper.setEndPage(i); + sb.append(stripper.getText(doc)); + if (i < pageCount) sb.append('\n'); + } + String text = sb.toString().trim(); + log.info("PDF 内嵌文本抽取: {} → {} 字符 ({} 页)", pdfPath.getFileName(), text.length(), pageCount); + return text; + } + } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/core/TextDetector.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/core/TextDetector.java new file mode 100644 index 0000000..9a220e9 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/core/TextDetector.java @@ -0,0 +1,156 @@ +package com.ruoyi.business.ocr.core; + +import ai.onnxruntime.OrtEnvironment; +import ai.onnxruntime.OrtException; +import ai.onnxruntime.OrtSession; +import ai.onnxruntime.OrtSession.SessionOptions; +import ai.onnxruntime.OnnxTensor; +import ai.onnxruntime.OrtSession.SessionOptions.ExecutionMode; +import ai.onnxruntime.OrtSession.SessionOptions.OptLevel; +import lombok.extern.slf4j.Slf4j; + +import java.awt.image.BufferedImage; +import java.nio.FloatBuffer; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * PaddleOCR 检测 (DB 算法) — ONNX Runtime 推理. + *

+ * 输入: [1, 3, H, W] 归一化图 (mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) + * 输出: 概率图 [1, 1, H, W] + */ +@Slf4j +public class TextDetector implements AutoCloseable { + + private final OrtEnvironment env; + private final OrtSession session; + private final float[] mean = {0.485f, 0.456f, 0.406f}; + private final float[] std = {0.229f, 0.224f, 0.225f}; + /** DB 二值化阈值 */ + private final float dbThresh = 0.3f; + /** DB box 阈值 */ + private final float boxThresh = 0.5f; + /** unclip 膨胀系数 */ + private final float unclipRatio = 1.6f; + /** 最长边限制 — 通过构造参数传入 (v5 默认 800, v4 默认 960) */ + private final int maxSideLen; + + public TextDetector(OrtEnvironment env, Path modelPath, int maxSideLen) throws OrtException { + this.maxSideLen = maxSideLen; + this.env = env; + SessionOptions opts = new SessionOptions(); + opts.setExecutionMode(ExecutionMode.PARALLEL); + opts.setOptimizationLevel(OptLevel.ALL_OPT); + int threads = Math.max(2, Runtime.getRuntime().availableProcessors()); + opts.setIntraOpNumThreads(threads); + opts.setInterOpNumThreads(threads); + this.session = env.createSession(modelPath.toString(), opts); + log.info("DB 检测器加载完成: {}, threads={}", modelPath.getFileName(), threads); + } + + /** + * 检测: 返回多边形 (每个 polygon 是 4-8 个 [x, y] 点) + */ + public List> detect(BufferedImage img) throws OrtException { + // 1. 预处理 (resize, pad, normalize) + int origH = img.getHeight(); + int origW = img.getWidth(); + // 按最长边缩放 + int targetH = origH, targetW = origW; + int maxSide = Math.max(origH, origW); + if (maxSide > maxSideLen) { + float ratio = (float) maxSideLen / maxSide; + targetH = Math.round(origH * ratio); + targetW = Math.round(origW * ratio); + } + // pad 到 32 倍数 + int padH = (32 - targetH % 32) % 32; + int padW = (32 - targetW % 32) % 32; + int inputH = targetH + padH; + int inputW = targetW + padW; + + BufferedImage resized = resize(img, targetW, targetH); + float[] inputData = new float[3 * inputH * inputW]; + // CHW 归一化 + pad + int[] pixels = new int[targetW * targetH]; + resized.getRGB(0, 0, targetW, targetH, pixels, 0, targetW); + // CHW + for (int c = 0; c < 3; c++) { + for (int y = 0; y < targetH; y++) { + for (int x = 0; x < targetW; x++) { + int argb = pixels[y * targetW + x]; + int v; + switch (c) { + case 0: v = (argb >> 16) & 0xFF; break; + case 1: v = (argb >> 8) & 0xFF; break; + default: v = argb & 0xFF; + } + int idx = c * inputH * inputW + y * inputW + x; + inputData[idx] = ((float) v / 255f - mean[c]) / std[c]; + } + } + // pad 行: 已经是 0, 因为 FloatBuffer 默认 0 + } + + // 2. 推理 + long[] shape = {1, 3, inputH, inputW}; + OnnxTensor inputTensor = OnnxTensor.createTensor(env, FloatBuffer.wrap(inputData), shape); + Map inputs = Collections.singletonMap("x", inputTensor); + + List> boxes; + try (OrtSession.Result result = session.run(inputs)) { + OnnxTensor outTensor = (OnnxTensor) result.get(0); + float[][] prob = extractProbMap(outTensor, inputH, inputW); + // 3. DB 后处理 + boxes = DbPostProcessor.postProcess(prob, targetH, targetW, origH, origW, + dbThresh, boxThresh, unclipRatio); + } finally { + inputTensor.close(); + } + return boxes; + } + + /** + * 从 ONNX tensor 抽取概率图为 [H, W] float[][] + *

+ * PaddleOCR 检测模型输出可能是 float[1][1][H][W] / float[1][H][W] / FloatBuffer 等. + */ + private static float[][] extractProbMap(OnnxTensor tensor, int h, int w) throws OrtException { + Object val = tensor.getValue(); + if (val instanceof float[][][][]) { + return ((float[][][][]) val)[0][0]; + } else if (val instanceof float[][][]) { + return ((float[][][]) val)[0]; + } else if (val instanceof float[][]) { + return (float[][]) val; + } else if (val instanceof FloatBuffer) { + FloatBuffer buf = (FloatBuffer) val; + float[][] map = new float[h][w]; + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + map[y][x] = buf.get(y * w + x); + } + } + return map; + } + throw new IllegalStateException("不支持的 ONNX 输出类型: " + (val == null ? "null" : val.getClass())); + } + + private static BufferedImage resize(BufferedImage src, int w, int h) { + java.awt.Image tmp = src.getScaledInstance(w, h, java.awt.Image.SCALE_SMOOTH); + BufferedImage dst = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB); + java.awt.Graphics2D g = dst.createGraphics(); + g.drawImage(tmp, 0, 0, null); + g.dispose(); + return dst; + } + + @Override + public void close() throws OrtException { + session.close(); + } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/core/TextRecognizer.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/core/TextRecognizer.java new file mode 100644 index 0000000..6ce134f --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/core/TextRecognizer.java @@ -0,0 +1,181 @@ +package com.ruoyi.business.ocr.core; + +import ai.onnxruntime.OnnxTensor; +import ai.onnxruntime.OrtEnvironment; +import ai.onnxruntime.OrtException; +import ai.onnxruntime.OrtSession; +import ai.onnxruntime.OrtSession.SessionOptions; +import ai.onnxruntime.OrtSession.SessionOptions.ExecutionMode; +import ai.onnxruntime.OrtSession.SessionOptions.OptLevel; +import lombok.extern.slf4j.Slf4j; + +import java.awt.image.BufferedImage; +import java.nio.FloatBuffer; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * PaddleOCR 识别 (CRNN + CTC) — ONNX Runtime 推理. + *

+ * 输入: [1, 3, H, W] 归一化图 (mean=0.5, std=0.5) + * 输出: [1, T, N] (T=序列长度, N=字典大小+1 含 blank) + *

+ * 当前使用 PP-OCRv4 mobile: H=32, maxW=320 (v4 标准, v5 是 H=48). + */ +@Slf4j +public class TextRecognizer implements AutoCloseable { + + private final OrtEnvironment env; + private final OrtSession session; + private final Dictionary dict; + private final float[] mean = {0.5f, 0.5f, 0.5f}; + private final float[] std = {0.5f, 0.5f, 0.5f}; + /** 输入最大宽 — 通过构造参数传入 */ + private final int maxW; + /** 输入高度 — 通过构造参数传入 (v5=48, v4=32) */ + private final int targetH; + + public TextRecognizer(OrtEnvironment env, Path modelPath, Dictionary dict, int targetH, int maxW) throws OrtException { + this.env = env; + this.dict = dict; + this.targetH = targetH; + this.maxW = maxW; + SessionOptions opts = new SessionOptions(); + // 并行执行模式 + 全图优化 (节点融合/常量折叠) — CRNN 是主要瓶颈, 收益最大 + opts.setExecutionMode(ExecutionMode.PARALLEL); + opts.setOptimizationLevel(OptLevel.ALL_OPT); + // CRNN 单图推理 op 数少, intra-op 多核并行收益高; inter-op 多图并发 (无 batch 时无效) + int threads = Math.max(2, Runtime.getRuntime().availableProcessors()); + opts.setIntraOpNumThreads(threads); + opts.setInterOpNumThreads(threads); + this.session = env.createSession(modelPath.toString(), opts); + log.info("CRNN 识别器加载完成: {}, 字典={} 字符, threads={}", modelPath.getFileName(), dict.size(), threads); + } + + /** + * 识别一张裁剪图, 返回 (文本, 置信度) + */ + public RecognizedText recognize(BufferedImage crop) throws OrtException { + // 复用 batch 推理, 单图 = batch=1 + List results = recognizeBatch(Collections.singletonList(crop)); + return results.get(0); + } + + /** + * 批量识别多张裁剪图 — 性能关键. + *

+ * 核心优化: 把所有 crop pad 到 batch 内统一宽度 (maxW), 一次性喂给 ONNX. + * ONNX 内部用 8 线程并行算所有样本, 单图推理省掉 33 次 kernel launch. + *

+ * 输入: 每张 crop 任意宽度 → resize 高 48 + 宽按比例 ≤ maxW + * 输出: 与输入 crops 一一对应的 RecognizedText + */ + public List recognizeBatch(List crops) throws OrtException { + int B = crops.size(); + if (B == 0) return Collections.emptyList(); + + // targetH 来自构造参数 (v5=48, v4=32, 通过 OcrEngine 从配置读取) + + // 1. 计算每张 crop 的目标宽度 + batch 内最大宽度 + int[] targetWs = new int[B]; + int batchMaxW = 0; + for (int i = 0; i < B; i++) { + BufferedImage c = crops.get(i); + float ratio = (float) targetH / c.getHeight(); + int w = Math.min(maxW, Math.max(1, Math.round(c.getWidth() * ratio))); + // pad 到 8 倍数 (CRNN 下采样 8x) + w = ((w + 7) / 8) * 8; + targetWs[i] = w; + if (w > batchMaxW) batchMaxW = w; + } + + // 2. 拼成 [B, 3, 48, batchMaxW] — 每张图只在 [0..targetW] 区间写, 其余为 0 + float[] inputData = new float[B * 3 * targetH * batchMaxW]; + for (int b = 0; b < B; b++) { + int w = targetWs[b]; + if (w == 0) continue; + BufferedImage resized = resize(crops.get(b), w, targetH); + int[] pixels = new int[w * targetH]; + resized.getRGB(0, 0, w, targetH, pixels, 0, w); + int bOffset = b * 3 * targetH * batchMaxW; + for (int c = 0; c < 3; c++) { + int cOffset = bOffset + c * targetH * batchMaxW; + int meanC = (int) (mean[c] * 255); + int stdC = (int) (std[c] * 255); + for (int y = 0; y < targetH; y++) { + int rowStart = cOffset + y * batchMaxW; + int pixRowStart = y * w; + for (int x = 0; x < w; x++) { + int argb = pixels[pixRowStart + x]; + int v; + switch (c) { + case 0: v = (argb >> 16) & 0xFF; break; + case 1: v = (argb >> 8) & 0xFF; break; + default: v = argb & 0xFF; + } + // (v/255 - mean) / std, 避免浮点除法 (mean/std 都是 0.5) + inputData[rowStart + x] = (v - meanC) / (stdC * 1.0f); + } + } + } + } + + // 3. 推理 + long[] shape = {B, 3, targetH, batchMaxW}; + OnnxTensor inputTensor = OnnxTensor.createTensor(env, FloatBuffer.wrap(inputData), shape); + Map inputs = Collections.singletonMap("x", inputTensor); + + List results = new ArrayList<>(B); + try (OrtSession.Result result = session.run(inputs)) { + OnnxTensor outTensor = (OnnxTensor) result.get(0); + Object val = outTensor.getValue(); + float[][][] logits3d; + if (val instanceof float[][][]) { + logits3d = (float[][][]) val; + } else if (val instanceof float[][][][]) { + logits3d = ((float[][][][]) val)[0]; + } else { + throw new IllegalStateException("不支持的 ONNX 输出类型: " + (val == null ? "null" : val.getClass())); + } + for (int b = 0; b < B; b++) { + results.add(CtcDecoder.decode(logits3d[b], dict)); + } + } finally { + inputTensor.close(); + } + return results; + } + + /** + * 从 ONNX tensor 抽取 logits 为 [T, N] float[][] + */ + private static float[][] extractLogits(OnnxTensor tensor) throws OrtException { + Object val = tensor.getValue(); + if (val instanceof float[][][]) { + return ((float[][][]) val)[0]; + } else if (val instanceof float[][]) { + return (float[][]) val; + } + throw new IllegalStateException("不支持的 ONNX 输出类型: " + (val == null ? "null" : val.getClass())); + } + + private static BufferedImage resize(BufferedImage src, int w, int h) { + java.awt.Image tmp = src.getScaledInstance(w, h, java.awt.Image.SCALE_SMOOTH); + BufferedImage dst = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB); + java.awt.Graphics2D g = dst.createGraphics(); + g.drawImage(tmp, 0, 0, null); + g.dispose(); + return dst; + } + + @Override + public void close() throws OrtException { + session.close(); + } + + /** 识别结果 */ + public record RecognizedText(String text, double confidence) {} +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/exception/OcrTimeoutException.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/exception/OcrTimeoutException.java new file mode 100644 index 0000000..37d27c9 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/exception/OcrTimeoutException.java @@ -0,0 +1,17 @@ +package com.ruoyi.business.ocr.exception; + +/** + * OCR 识别超时异常 — 对齐 Python app.core.ocr_engine.OCRTimeout + *

+ * 单页 OCR 超时 / 整流程超时时抛出, 由 RecognizeService 转成 error_code="timeout". + */ +public class OcrTimeoutException extends RuntimeException { + + public OcrTimeoutException(String message) { + super(message); + } + + public OcrTimeoutException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/service/InvoiceExtractor.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/service/InvoiceExtractor.java new file mode 100644 index 0000000..470d668 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/service/InvoiceExtractor.java @@ -0,0 +1,434 @@ +package com.ruoyi.business.ocr.service; + +import com.ruoyi.business.ocr.InvoiceFields; +import com.ruoyi.business.ocr.OcrLine; +import com.ruoyi.business.ocr.util.AmountUtils; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 从 OCR 文本/行里抽取发票字段 — 对齐 Python app.services.invoice_extractor + *

+ * 适配中国大陆 增值税发票(电子普票 / 专票 / 电子专票 / 数电票). + *

+ * 关键策略: + * - 主体按 OCR box 坐标判断归属 (左右两栏) + * - 名称提取加 stop word, 避免单行文本混淆 + * - 金额兜底: tax + pretax = total 组合搜索 + */ +@Slf4j +@Service +public class InvoiceExtractor { + + // ---------- 发票类型 (按长度降序, 优先匹配最长前缀, 避免 "电子发票(增值税专用发票)" 被 "增值税专用发票" 抢先命中) ---------- + private static final List INVOICE_TYPES = Arrays.asList( + "电子发票(增值税专用发票)", // 13 + "电子发票(增值税普通发票)", // 13 + "增值税电子专用发票", // 9 + "增值税电子普通发票", // 9 + "增值税专用发票", // 7 + "增值税普通发票", // 7 + "通用机打发票", // 6 + "数电票(电子发票)", // 8 + "数电票", // 3 + "电子发票" // 4 + ); + + // ---------- 发票号码 ---------- + private static final Pattern NO_PATTERN = Pattern.compile( + "(?:发\\s*票\\s*号\\s*码|号\\s*码|No\\.?|号)\\s*[::]?\\s*(\\d{8,20})", + Pattern.CASE_INSENSITIVE + ); + + // ---------- 发票代码 ---------- + private static final Pattern CODE_PATTERN = Pattern.compile( + "(?:发\\s*票\\s*代\\s*码|代\\s*码)\\s*[::]?\\s*(\\d{10,12}|\\d{8,12})" + ); + + // ---------- 开票日期 ---------- + private static final Pattern DATE_PATTERN = Pattern.compile( + "(?:开\\s*票\\s*日\\s*期|日\\s*期)\\s*[::]?\\s*" + + "(\\d{4})\\s*[年/\\.]\\s*(\\d{1,2})\\s*[月/\\.]\\s*(\\d{1,2})" + ); + + // ---------- 纳税人识别号 (至少 1 个字母, 排除纯数字发票号) ---------- + private static final Pattern TAX_NO_PATTERN = Pattern.compile("((?=[0-9A-Z]*[A-Z])[0-9A-Z]{18})"); + + // ---------- 主体标签 ---------- + private static final Pattern BUYER_LABEL = Pattern.compile("购\\s*买\\s*方\\s*(?:信\\s*息|名\\s*称|)"); + private static final Pattern SELLER_LABEL = Pattern.compile("销\\s*售\\s*方\\s*(?:信\\s*息|名\\s*称|)"); + + // ---------- 名称 (带 stop word 截断) ---------- + private static final String NAME_STOP = "(?:销售方|购买方|统一社会信用|纳税人|项目名称|规格型号|^单位$|^数量$|^单价$|^金额|^税率|^税额|备注|收款人|复核|开票人|价税合计|小写|大写)"; + private static final Pattern NAME_PATTERN = Pattern.compile( + "名\\s*称\\s*[::]\\s*" + + "((?:(?!" + NAME_STOP + ")[^\\n\\r]){2,60}?(?:公司|商店|厂|店|部|中心|工作室))" + ); + // 不依赖 "名称:" 前缀 — 用于 PDF 内嵌文本拆字版式 (label 和 value 分两段) + // 不强制 lookback 拒绝中文 (OCR 行可能整行连在一起如 "名称北京国钜...公司"), 靠 cleanName 截 stop word 过滤杂质 + private static final Pattern COMPANY_PATTERN = Pattern.compile( + "([一-龥A-Za-z0-9()()·\\-]{2,30}(?:公司|商店|厂|店|部|中心|工作室))" + ); + + // ---------- 数字候选 ---------- + private static final Pattern DECIMAL_PATTERN = Pattern.compile("(\\d+\\.\\d{2})"); + + /** + * 入口: 从文本 + (可选) 行列表抽取 + * + * @param text OCR 全文 + * @param lines OCR 行列表 (含 box) + * @param qrTotalAmount QR 解出的金额 (权威). 不为 null 时, 强制作为 total 用于 fallback 组合搜索. + */ + public InvoiceFields extract(String text, List lines, Double qrTotalAmount) { + String norm = norm(text); + InvoiceFields fields = new InvoiceFields(); + + fields.setInvoiceType(detectInvoiceType(norm)); + fields.setInvoiceCode(extractInvoiceCode(norm)); + fields.setInvoiceNo(extractInvoiceNo(norm)); + fields.setInvoiceDate(extractDate(norm)); + + // 金额 — 优先用 QR 提供的 total (权威, 数电票/电子发票的价税合计在 QR 里), + // QR 缺失时回退 OCR 的 TOTAL_PATTERN / 第一个数字 + Double total; + if (qrTotalAmount != null) { + total = qrTotalAmount; + } else { + total = AmountUtils.extractTotalAmount(norm); + } + Double tax = AmountUtils.extractTaxAmount(norm); + Double pretax = AmountUtils.extractPretaxAmount(norm); + + // 兜底 1: tax + pretax = total 组合搜索 + if ((tax == null || pretax == null) && total != null) { + List candidates = new ArrayList<>(); + Set seen = new HashSet<>(); + Matcher m = DECIMAL_PATTERN.matcher(norm); + while (m.find()) { + String s = m.group(1); + double v = Double.parseDouble(s); + if (v < total && seen.add(s)) { + candidates.add(v); + } + } + candidates.sort(Comparator.reverseOrder()); + for (int i = 0; i < candidates.size(); i++) { + double a = candidates.get(i); + for (int j = i + 1; j < candidates.size(); j++) { + double b = candidates.get(j); + if (Math.abs(a + b - total) < 0.011) { + if (pretax == null) pretax = round2(a); + if (tax == null) tax = round2(b); + break; + } + } + if (tax != null && pretax != null) break; + } + // 兜底: 只剩一个候选 + if ((tax == null || pretax == null) && candidates.size() == 1) { + double only = round2(candidates.get(0)); + if (pretax == null && tax == null) { + pretax = only; + tax = round2(total - only); + } else if (tax == null) { + tax = only; + } else if (pretax == null) { + pretax = only; + } + } + } + + // 兜底 2: total - 任一 = 另一 + if (tax == null && total != null && pretax != null) { + tax = round2(total - pretax); + } + if (pretax == null && total != null && tax != null) { + pretax = round2(total - tax); + } + + fields.setAmount(total); + fields.setTaxAmount(tax); + fields.setAmountPretax(pretax); + fields.setAmountCn(AmountUtils.extractCnAmount(norm)); + fields.setAmountMatch(AmountUtils.amountConsistent(fields.getAmountCn(), fields.getAmount())); + + // 主体 + String sellerName, sellerTax, buyerName, buyerTax; + if (lines != null && !lines.isEmpty()) { + String[] parties = extractPartiesFromLines(lines); + sellerName = parties[0]; sellerTax = parties[1]; + buyerName = parties[2]; buyerTax = parties[3]; + if (!(sellerName != null && buyerName != null)) { + String[] textParties = extractPartiesFromText(norm); + sellerName = or(sellerName, textParties[0]); + sellerTax = or(sellerTax, textParties[1]); + buyerName = or(buyerName, textParties[2]); + buyerTax = or(buyerTax, textParties[3]); + } + } else { + String[] textParties = extractPartiesFromText(norm); + sellerName = textParties[0]; sellerTax = textParties[1]; + buyerName = textParties[2]; buyerTax = textParties[3]; + } + fields.setSellerName(sellerName); + fields.setSellerTaxNo(sellerTax); + fields.setBuyerName(buyerName); + fields.setBuyerTaxNo(buyerTax); + + return fields; + } + + private static String or(String a, String b) { + return a != null ? a : b; + } + + private static double round2(double v) { + return Math.round(v * 100.0) / 100.0; + } + + private static String norm(String text) { + return text == null ? "" : text.replaceAll("\\s+", " ").trim(); + } + + private static String detectInvoiceType(String text) { + for (String t : INVOICE_TYPES) { + if (text.contains(t)) return t; + } + return null; + } + + private static String extractInvoiceNo(String text) { + Matcher m = NO_PATTERN.matcher(text); + return m.find() ? m.group(1) : null; + } + + private static String extractInvoiceCode(String text) { + Matcher m = CODE_PATTERN.matcher(text); + return m.find() ? m.group(1) : null; + } + + private static String extractDate(String text) { + Matcher m = DATE_PATTERN.matcher(text); + if (!m.find()) return null; + int y = Integer.parseInt(m.group(1)); + int mo = Integer.parseInt(m.group(2)); + int d = Integer.parseInt(m.group(3)); + return String.format("%04d-%02d-%02d", y, mo, d); + } + + private static String cleanName(String name) { + if (name == null) return null; + String s = name; + // OCR 容易把 "名称北京国钜..." 整段匹出来 (名称 是字符类里的字), 剥掉前缀保留公司名 + for (String prefix : new String[]{"名称", "购买方", "销售方", "买方", "卖方", "购方", "销方"}) { + if (s.startsWith(prefix)) s = s.substring(prefix.length()); + } + // 截断 stop word (label 残留: OCR/PDF 都可能把 label 跟 value 拼在一起) + for (String stop : new String[]{"纳税人", "统一社会", "购买方", "销售方", + "项目名称", "规格型号", "单价", "数量", "金额", "税率", "税额", + "价税合计", "大写", "小写", "备注", "收款", "复核", "开票"}) { + int idx = s.indexOf(stop); + if (idx >= 0) s = s.substring(0, idx); + } + // 去掉前导符号 + s = s.replaceAll("^[\\s::,,。、]+", ""); + // 只保留中文/字母/数字/()/-/· + s = s.replaceAll("[^一-龥A-Za-z0-9()()·\\-]", ""); + s = s.replaceAll("[::;,,。、 ]+$", "").trim(); + return s.isEmpty() ? null : s; + } + + /** + * box: [[x1,y1], ...] → (cx, cy) + */ + private static double[] boxCenter(List> box) { + if (box == null || box.size() < 4) return new double[]{0, 0}; + double minX = Double.MAX_VALUE, maxX = -Double.MAX_VALUE; + double minY = Double.MAX_VALUE, maxY = -Double.MAX_VALUE; + for (List p : box) { + if (p.size() < 2) continue; + double x = p.get(0), y = p.get(1); + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + return new double[]{(minX + maxX) / 2, (minY + maxY) / 2}; + } + + /** + * 返回 [seller_name, seller_tax, buyer_name, buyer_tax] + *

+ * 策略: + * - byX 分支 (左右栏): 用 buyer/seller 标签的 x 坐标分栏. **用 COMPANY_PATTERN** (不依赖"名称:"前缀). + * - 上下栏 / 单栏分支: 按 y 排序, **第一个 = 销售方** (中国数电票/电子专票版式 — 销方先印). + */ + private String[] extractPartiesFromLines(List lines) { + List> buyerLabelBox = null, sellerLabelBox = null; + for (OcrLine line : lines) { + if (buyerLabelBox == null && BUYER_LABEL.matcher(line.getText() != null ? line.getText() : "").find()) { + buyerLabelBox = line.getBox(); + } + if (sellerLabelBox == null && SELLER_LABEL.matcher(line.getText() != null ? line.getText() : "").find()) { + sellerLabelBox = line.getBox(); + } + } + boolean byX = buyerLabelBox != null && sellerLabelBox != null + && Math.abs(boxCenter(buyerLabelBox)[0] - boxCenter(sellerLabelBox)[0]) > 50; + + String sellerName = null, sellerTax = null, buyerName = null, buyerTax = null; + + if (byX) { + double[] bc = boxCenter(buyerLabelBox); + double[] sc = boxCenter(sellerLabelBox); + double mid = (bc[0] + sc[0]) / 2; + for (OcrLine line : lines) { + if (line.getText() == null) continue; + // 用 COMPANY_PATTERN 替代 NAME_PATTERN — 不依赖 "名称:" 前缀, 对拆字/拼接行更鲁棒 + Matcher nm = COMPANY_PATTERN.matcher(line.getText()); + if (nm.find()) { + double cx = boxCenter(line.getBox())[0]; + String cleaned = cleanName(nm.group(1)); + if (cleaned == null || containsStopWord(cleaned)) continue; + if (cx < mid && buyerName == null) buyerName = cleaned; + else if (cx >= mid && sellerName == null) sellerName = cleaned; + } + Matcher tm = TAX_NO_PATTERN.matcher(line.getText()); + if (tm.find()) { + double cx = boxCenter(line.getBox())[0]; + String tax = tm.group(1); + if (cx < mid && buyerTax == null) buyerTax = tax; + else if (cx >= mid && sellerTax == null) sellerTax = tax; + } + } + } else { + // 没找到 buyer/seller 标签 box → 自动检测栏位 (左右栏 vs 上下栏) + // 收集所有 (name, cx, cy) 和 (tax, cx, cy) 候选 + List nameCoords = new ArrayList<>(); // [cx, cy] + List nameVals = new ArrayList<>(); + List taxCoords = new ArrayList<>(); + List taxVals = new ArrayList<>(); + for (OcrLine line : lines) { + if (line.getText() == null) continue; + double[] c = boxCenter(line.getBox()); + Matcher nm = COMPANY_PATTERN.matcher(line.getText()); + if (nm.find()) { + String cleaned = cleanName(nm.group(1)); + log.info("DEBUG company match: text={} match={} cleaned={} stopWord={}", + line.getText(), nm.group(1), cleaned, containsStopWord(cleaned)); + if (cleaned != null && !containsStopWord(cleaned)) { + nameCoords.add(c); + nameVals.add(cleaned); + } + } + Matcher tm = TAX_NO_PATTERN.matcher(line.getText()); + if (tm.find()) { + taxCoords.add(c); + taxVals.add(tm.group(1)); + } + } + + // 自动判断: 前两个 name 的 |dy| < 30 且 |dx| > 200 → 左右栏 + boolean leftRight = false; + if (nameCoords.size() >= 2) { + double dx = Math.abs(nameCoords.get(0)[0] - nameCoords.get(1)[0]); + double dy = Math.abs(nameCoords.get(0)[1] - nameCoords.get(1)[1]); + leftRight = dy < 30 && dx > 200; + } + + if (leftRight) { + // 左右栏: 按 x 排序, **x 小 = 买方 (购买方在左), x 大 = 卖方 (销售方在右)** + sortByFirstCoord(nameCoords, nameVals, 0); + sortByFirstCoord(taxCoords, taxVals, 0); + } else { + // 上下栏: 按 y 排序, **第一个 = 买方** (国家税务总局标准: 购方信息在前) + sortByFirstCoord(nameCoords, nameVals, 1); + sortByFirstCoord(taxCoords, taxVals, 1); + } + + if (nameVals.size() > 0) buyerName = nameVals.get(0); + if (taxVals.size() > 0) buyerTax = taxVals.get(0); + if (nameVals.size() > 1) sellerName = nameVals.get(1); + if (taxVals.size() > 1) sellerTax = taxVals.get(1); + } + + return new String[]{sellerName, sellerTax, buyerName, buyerTax}; + } + + /** + * 按指定坐标 (0=x, 1=y) 同步排序坐标和值列表 + */ + private static void sortByFirstCoord(List coords, List vals, int dim) { + List idx = new ArrayList<>(); + for (int i = 0; i < coords.size(); i++) idx.add(i); + idx.sort(Comparator.comparingDouble(i -> coords.get(i)[dim])); + List sc = new ArrayList<>(); + List sv = new ArrayList<>(); + for (int i : idx) { + sc.add(coords.get(i)); + sv.add(vals.get(i)); + } + coords.clear(); coords.addAll(sc); + vals.clear(); vals.addAll(sv); + } + + /** + * 无 box 信息时的 fallback: 全局收集 name + tax, 按出现顺序配对. + *

+ * 中国电子发票版式 (数电票 / 电子专票): 国家税务总局标准 — **购买方信息在前(左/上), 销售方信息在后(右/下)**. + * PDF 文字流的 value 顺序通常按视觉顺序 (左/上 先), 所以: + * **第一个匹配 = 购买方, 第二个匹配 = 销售方**. + *

+ * 修复: + * 1. PDF 内嵌文本拆字版式 ("购/买/方/信/息") — 旧 "在标签后 200 字符找 name" 失效 (label/value 分段) + * 2. 用 COMPANY_PATTERN (不依赖 "名称:" 前缀) 替代 NAME_PATTERN + * 3. 第一个 = 买方 (国家税务总局标准: 购方信息在前) + * + * @return [seller_name, seller_tax, buyer_name, buyer_tax] + */ + private String[] extractPartiesFromText(String text) { + // 1. 全局收集所有公司名 (按出现顺序, 去重叠, 去 stop word) + List cleanedNames = new ArrayList<>(); + Matcher nm = COMPANY_PATTERN.matcher(text); + int lastEnd = -1; + while (nm.find()) { + if (nm.start() < lastEnd) continue; // 去重叠 + String c = cleanName(nm.group(1)); + if (c != null && !containsStopWord(c)) cleanedNames.add(c); + lastEnd = nm.end(); + } + + // 2. 全局收集所有税号 (按出现顺序) + List taxes = new ArrayList<>(); + Matcher tm = TAX_NO_PATTERN.matcher(text); + while (tm.find()) taxes.add(tm.group(1)); + + // 3. 配对 (按位置一一对应): name[0]<->tax[0] = 买方, name[1]<->tax[1] = 卖方 + String buyerName = cleanedNames.size() > 0 ? cleanedNames.get(0) : null; + String buyerTax = taxes.size() > 0 ? taxes.get(0) : null; + String sellerName = cleanedNames.size() > 1 ? cleanedNames.get(1) : null; + String sellerTax = taxes.size() > 1 ? taxes.get(1) : null; + + return new String[]{sellerName, sellerTax, buyerName, buyerTax}; + } + + /** + * 公司名 stop word 过滤 (cleanName 已经过滤一些, 这里再覆盖): + * 表格项目名 ("项目名称"), 规格型号 ("规格"), 备注 ("备注"), 单位, 数量 等. + *

+ * 注意: "名称" 不在这里过滤 — cleanName 会截断 "名称" 前缀; 如果 cleanName 截断后还是包含 "名称" + * 才在这里过滤 (防止截断失败导致整个公司名被拒). + */ + private static boolean containsStopWord(String s) { + if (s == null) return true; + // 只过滤明显不是公司名的杂质 (cleanName 已经处理过大部分 stop word) + return s.contains("价税合计") || s.contains("大写") || s.contains("小写") + || s.contains("项目名称") || s.contains("规格型号") + || s.equals("公司") || s.length() < 4; + } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/service/QrDecoder.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/service/QrDecoder.java new file mode 100644 index 0000000..2235ae4 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/service/QrDecoder.java @@ -0,0 +1,159 @@ +package com.ruoyi.business.ocr.service; + +import com.google.zxing.*; +import com.google.zxing.client.j2se.BufferedImageLuminanceSource; +import com.google.zxing.common.HybridBinarizer; +import com.ruoyi.business.ocr.QrDecodeResult; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import javax.imageio.ImageIO; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.nio.file.Path; + +/** + * 电子发票二维码识别 — 对齐 Python app.services.qr_decoder + *

+ * 国家税务总局规范的电子发票二维码内容格式 (8 字段逗号分隔): + * 01,<type>,<invoice_code>,<invoice_no>,<amount>,<date>,<check_code>,<reserved> + *

+ * 例: 01,31,,24922000000006110014,39500.00,20240202,,A371 + *

+ * 使用 ZXing (纯 Java, 无需 opencv). + */ +@Slf4j +@Service +public class QrDecoder { + + private static final MultiFormatReader ZXING_READER = new MultiFormatReader(); + + /** + * 从图片文件解电子发票二维码 + */ + public QrDecodeResult decodeQr(Path imagePath) { + String raw; + try { + raw = detectQr(imagePath); + } catch (Exception e) { + log.warn("QR 检测异常 {}: {}", imagePath.getFileName(), e.getMessage()); + return new QrDecodeResult(); + } + if (raw == null || raw.isEmpty()) { + return new QrDecodeResult(); + } + QrDecodeResult parsed = parsePayload(raw); + if (parsed.hasAnyField()) { + log.info("QR 解码成功: no={}, amt={}, date={}", + parsed.getInvoiceNo(), parsed.getAmount(), parsed.getInvoiceDate()); + } else { + log.debug("QR 解出但字段无效: raw={}", raw.substring(0, Math.min(80, raw.length()))); + } + return parsed; + } + + /** + * 全图 → 4 象限 → 2x 放大, 任一命中即返回. + */ + private String detectQr(Path imagePath) throws IOException { + BufferedImage img = ImageIO.read(imagePath.toFile()); + if (img == null) { + return ""; + } + // 1) 全图 + String txt = tryDecode(img); + if (txt != null && !txt.isEmpty()) { + return txt; + } + // 2) 四象限 + int h = img.getHeight(); + int w = img.getWidth(); + BufferedImage[][] crops = { + {img.getSubimage(0, 0, w / 2, h / 2), img.getSubimage(w / 2, 0, w - w / 2, h / 2)}, + {img.getSubimage(0, h / 2, w / 2, h - h / 2), img.getSubimage(w / 2, h / 2, w - w / 2, h - h / 2)} + }; + String[] names = {"left-top", "right-top", "left-bottom", "right-bottom"}; + int idx = 0; + for (BufferedImage[] row : crops) { + for (BufferedImage crop : row) { + txt = tryDecode(crop); + if (txt != null && !txt.isEmpty()) { + log.debug("QR found in {}", names[idx]); + return txt; + } + idx++; + } + } + // 3) 2x 放大 (二维码像素过小的情况) + BufferedImage scaled = resize(img, 2.0); + return tryDecode(scaled); + } + + private String tryDecode(BufferedImage img) { + try { + LuminanceSource source = new BufferedImageLuminanceSource(img); + BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source)); + Result result = ZXING_READER.decode(bitmap); + return result != null ? result.getText() : ""; + } catch (NotFoundException e) { + return ""; + } catch (Exception e) { + log.debug("ZXing decode error: {}", e.getMessage()); + return ""; + } + } + + private static BufferedImage resize(BufferedImage src, double scale) { + int w = (int) (src.getWidth() * scale); + int h = (int) (src.getHeight() * scale); + BufferedImage dst = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB); + Graphics2D g = dst.createGraphics(); + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); + g.drawImage(src, 0, 0, w, h, null); + g.dispose(); + return dst; + } + + /** + * 解析电子发票二维码内容 → QrDecodeResult + *

+ * 字段全 null 表示无二维码或格式不正确. + */ + private QrDecodeResult parsePayload(String raw) { + if (raw == null || raw.isEmpty()) { + return new QrDecodeResult(); + } + String[] parts = raw.split(",", -1); + if (parts.length != 8) { + log.debug("QR 字段数 {} != 8, 视为格式不正确", parts.length); + QrDecodeResult r = new QrDecodeResult(); + r.setRaw(raw); + return r; + } + QrDecodeResult result = new QrDecodeResult(); + result.setRaw(raw); + + // parts[3] = 发票号 + String invoiceNo = parts[3].trim(); + if (!invoiceNo.isEmpty() && invoiceNo.length() >= 10 && invoiceNo.length() <= 30) { + result.setInvoiceNo(invoiceNo); + } + + // parts[4] = 金额 + String amtStr = parts[4].trim(); + if (!amtStr.isEmpty()) { + try { + result.setAmount(Math.round(Double.parseDouble(amtStr) * 100.0) / 100.0); + } catch (NumberFormatException ignored) {} + } + + // parts[5] = 开票日期 (YYYYMMDD) + String dateStr = parts[5].trim(); + if (dateStr.length() == 8 && dateStr.chars().allMatch(Character::isDigit)) { + result.setInvoiceDate(dateStr.substring(0, 4) + "-" + dateStr.substring(4, 6) + "-" + dateStr.substring(6, 8)); + } + return result; + } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/service/RecognizeService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/service/RecognizeService.java new file mode 100644 index 0000000..5badece --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/service/RecognizeService.java @@ -0,0 +1,312 @@ +package com.ruoyi.business.ocr.service; + +import com.ruoyi.business.ocr.config.OcrProperties; +import com.ruoyi.business.ocr.core.ImageProcessor; +import com.ruoyi.business.ocr.core.OcrEngine; +import com.ruoyi.business.ocr.core.PdfProcessor; +import com.ruoyi.business.ocr.exception.OcrTimeoutException; +import com.ruoyi.business.ocr.InvoiceFields; +import com.ruoyi.business.ocr.InvoiceResult; +import com.ruoyi.business.ocr.OcrLine; +import com.ruoyi.business.ocr.QrDecodeResult; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * 端到端识别流水: 文件 → QR → (可选)OCR → 字段抽取 → InvoiceResult + *

+ * 完全对齐 Python app.services.recognize_service: + *

+ * 1. PDF / 图片 → BufferedImage 列表 (page_count)
+ * 2. qr = decode_qr(page[0])
+ * 3. qr_ok && !qr_full_ocr → [QR only] 快路径返回
+ * 4. !qr_ok → not_invoice, 不跑 OCR
+ * 5. for each page:
+ *      if elapsed > total_deadline → timeout
+ *      try _ocr_image(page) with page_timeout
+ * 6. extract_invoice(raw_text, lines)
+ * 7. overlay_qr_fields(qr)  ← QR 3 字段覆盖 OCR 结果
+ * 8. return InvoiceResult
+ * 
+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RecognizeService { + + private static final Set IMG_EXTS = Set.of(".png", ".jpg", ".jpeg", ".bmp", ".webp", ".tif", ".tiff"); + + private final OcrProperties props; + private final PdfProcessor pdfProcessor; + private final OcrEngine ocrEngine; + private final QrDecoder qrDecoder; + private final InvoiceExtractor extractor; + + /** + * 入口: 上传文件字节流 + */ + public InvoiceResult recognizeFile(String filename, byte[] content) { + long t0 = System.currentTimeMillis(); + String suffix = filename == null ? ".bin" : ext(filename); + Path tmp = saveUpload(content, suffix); + try { + return recognizePath(tmp, true); + } finally { + // 已在 recognizePath finally 里删, 这里兜底 + try { Files.deleteIfExists(tmp); } catch (IOException ignored) {} + log.info("总耗时 {}ms", System.currentTimeMillis() - t0); + } + } + + /** + * 入口: 服务器本地路径 (已在白名单校验) + */ + public InvoiceResult recognizePath(Path filePath, boolean deleteAfter) { + long t0 = System.currentTimeMillis(); + String suffix = ext(filePath.getFileName().toString()); + long totalDeadline = t0 + props.getOcr().getTotalTimeoutS() * 1000L; + + try { + // 1. PDF / 图片 → 临时图片列表 + List pageImgs; + try { + if (suffix.equals(".pdf")) { + pageImgs = pdfProcessor.pdfToImages(filePath); + } else if (IMG_EXTS.contains(suffix)) { + pageImgs = List.of(filePath); + } else { + return err("不支持的文件类型: " + suffix + "(仅支持 PDF / 图片)", + "unsupported", false, 1, t0); + } + } catch (Exception e) { + log.error("PDF/图片处理失败: {}", e.getMessage(), e); + return err("PDF/图片处理失败: " + e.getMessage(), + "process_failed", false, 1, t0); + } + + // 2. QR 优先识别 + QrDecodeResult qr = qrDecoder.decodeQr(pageImgs.get(0)); + boolean qrOk = qr.hasAnyField(); + + // 3. 快路径: QR 命中且 fast 模式 + if (qrOk && !props.getOcr().isQrFullOcr()) { + log.info("QR 快路径: {} 耗时 {}ms", filePath.getFileName(), elapsedMs(t0)); + return buildQrOnlyResult(qr, elapsedMs(t0), pageImgs.size()); + } + + // 4. 无有效 QR → 非发票, 不跑 OCR + if (!qrOk) { + String reason = qr.getRaw() == null || qr.getRaw().isEmpty() + ? "未识别到发票二维码" + : "二维码格式不合法"; + log.info("非发票 (无有效 QR): {} 耗时 {}ms", filePath.getFileName(), elapsedMs(t0)); + InvoiceResult r = err(reason + "(可能不是发票图片)", + "not_invoice", false, pageImgs.size(), t0); + r.setFromQr(false); + r.setQrRaw(qr.getRaw() == null || qr.getRaw().isEmpty() ? null : qr.getRaw()); + r.setQrError(qr.getRaw() == null || qr.getRaw().isEmpty() ? "no_qr" : "bad_format"); + return r; + } + + // 4.5 PDF 内嵌文本 fast path (跳过 ONNX, 直接 PDFTextStripper) + // - 适用电子发票 / 数电票 PDF (含真实文本层) + // - 扫描件 PDF 抽不到文本, 自动回退到 ONNX + if (suffix.equals(".pdf") && props.getOcr().isUsePdfTextFirst()) { + try { + String pdfText = pdfProcessor.extractText(filePath); + int minChars = props.getOcr().getPdfTextMinChars(); + if (pdfText.length() >= minChars) { + int elapsed = elapsedMs(t0); + log.info("PDF 内嵌文本 fast path: {} chars={}, 跳过 ONNX, 耗时 {}ms", + filePath.getFileName(), pdfText.length(), elapsed); + InvoiceFields fields = extractor.extract(pdfText, java.util.Collections.emptyList(), qr.getAmount()); + fields = overlayQrFields(fields, qr); + InvoiceResult r = new InvoiceResult(); + r.setSuccess(true); + r.setIsInvoice(true); + r.setRawText(pdfText); + r.setFields(fields); + r.setPageCount(pageImgs.size()); + r.setEngine("pdftxt"); + r.setElapsedMs(elapsed); + r.setFromQr(true); + r.setQrRaw(qr.getRaw()); + return r; + } + log.info("PDF 内嵌文本太短 ({} 字符 < {}), 回退 ONNX", pdfText.length(), minChars); + } catch (Exception e) { + log.warn("PDF 内嵌文本抽取失败, 回退 ONNX: {}", e.getMessage()); + } + } + + // 5. 每页 OCR + List allLines = new ArrayList<>(); + for (int idx = 0; idx < pageImgs.size(); idx++) { + long remaining = totalDeadline - System.currentTimeMillis(); + if (remaining <= 0) { + log.warn("达到总超时 ({}s), 中断 OCR", props.getOcr().getTotalTimeoutS()); + InvoiceResult r = err(String.format("达到总超时 (%d秒), 已识别 %d/%d 页", + props.getOcr().getTotalTimeoutS(), idx, pageImgs.size()), + "timeout", false, pageImgs.size(), t0); + r.setRawText(joinLines(allLines)); + r.setLines(allLines); + r.setFromQr(true); + r.setQrRaw(qr.getRaw()); + return r; + } + int pageTimeout = (int) Math.min(props.getOcr().getPageTimeoutS(), remaining / 1000.0); + try { + allLines.addAll(ocrImage(pageImgs.get(idx))); + } catch (OcrTimeoutException e) { + log.warn("第 {} 页 OCR 超时: {}", idx + 1, e.getMessage()); + InvoiceResult r = err(String.format("第 %d 页识别超时 (%.1f秒)", + idx + 1, pageTimeout * 1.0), + "timeout", false, pageImgs.size(), t0); + r.setRawText(joinLines(allLines)); + r.setLines(allLines); + r.setFromQr(true); + r.setQrRaw(qr.getRaw()); + return r; + } catch (Exception e) { + log.error("第 {} 页 OCR 失败: {}", idx + 1, e.getMessage(), e); + InvoiceResult r = err(String.format("第 %d 页识别失败: %s", idx + 1, e.getMessage()), + "ocr_failed", false, pageImgs.size(), t0); + r.setRawText(joinLines(allLines)); + r.setLines(allLines); + r.setFromQr(true); + r.setQrRaw(qr.getRaw()); + return r; + } + } + + // 6. 字段抽取 (QR total 优先) + QR 字段覆盖 + String rawText = joinLines(allLines); + // 把 QR 的 amount 提前传给 extractor, 让 fallback 组合搜索能用对的总价. + InvoiceFields fields = extractor.extract(rawText, allLines, qr.getAmount()); + fields = overlayQrFields(fields, qr); + + int elapsed = elapsedMs(t0); + log.info("识别完成: {} 页={}, from_qr=true, 耗时={}ms", filePath.getFileName(), pageImgs.size(), elapsed); + InvoiceResult r = new InvoiceResult(); + r.setSuccess(true); + r.setIsInvoice(true); + r.setRawText(rawText); + r.setLines(allLines); + r.setFields(fields); + r.setPageCount(pageImgs.size()); + r.setEngine("paddleocr"); + r.setElapsedMs(elapsed); + r.setFromQr(true); + r.setQrRaw(qr.getRaw()); + return r; + + } finally { + if (deleteAfter) { + try { + Files.deleteIfExists(filePath); + if (suffix.equals(".pdf")) { + // 清理 .xxx_pages/ 临时目录 + Path pagesDir = filePath.getParent().resolve("." + stem(filePath) + "_pages"); + if (Files.exists(pagesDir)) { + try (var stream = Files.walk(pagesDir)) { + stream.sorted((a, b) -> b.compareTo(a)).forEach(p -> { + try { Files.deleteIfExists(p); } catch (IOException ignored) {} + }); + } + } + } + } catch (Exception ignored) {} + } + } + } + + // ---------- 内部 ---------- + + private List ocrImage(Path imgPath) { + Path rotated = ImageProcessor.autoRotate(imgPath); + Path enhanced = ImageProcessor.enhance(rotated); + return ocrEngine.recognize(enhanced); + } + + private static String joinLines(List lines) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < lines.size(); i++) { + if (i > 0) sb.append('\n'); + sb.append(lines.get(i).getText()); + } + return sb.toString(); + } + + private static String ext(String filename) { + int dot = filename.lastIndexOf('.'); + return dot >= 0 ? filename.substring(dot).toLowerCase() : ""; + } + + private static String stem(Path p) { + String name = p.getFileName().toString(); + int dot = name.lastIndexOf('.'); + return dot > 0 ? name.substring(0, dot) : name; + } + + private static Path saveUpload(byte[] content, String suffix) { + try { + Path tmp = Files.createTempFile("ry_ocr_", suffix); + Files.write(tmp, content); + return tmp; + } catch (IOException e) { + throw new RuntimeException("保存临时文件失败: " + e.getMessage(), e); + } + } + + private static int elapsedMs(long t0) { + return (int) (System.currentTimeMillis() - t0); + } + + private InvoiceResult err(String error, String errorCode, boolean isInvoice, int pageCount, long t0) { + InvoiceResult r = new InvoiceResult(); + r.setSuccess(false); + r.setIsInvoice(isInvoice); + r.setError(error); + r.setErrorCode(errorCode); + r.setPageCount(pageCount); + r.setEngine("paddleocr"); + r.setElapsedMs(elapsedMs(t0)); + return r; + } + + private InvoiceResult buildQrOnlyResult(QrDecodeResult qr, int elapsedMs, int pageCount) { + InvoiceFields fields = new InvoiceFields(); + fields.setInvoiceNo(qr.getInvoiceNo()); + fields.setAmount(qr.getAmount()); + fields.setInvoiceDate(qr.getInvoiceDate()); + InvoiceResult r = new InvoiceResult(); + r.setSuccess(true); + r.setIsInvoice(true); + r.setRawText("[QR only] " + qr.getRaw()); + r.setFields(fields); + r.setPageCount(pageCount); + r.setEngine("qr"); + r.setElapsedMs(elapsedMs); + r.setFromQr(true); + r.setQrRaw(qr.getRaw()); + return r; + } + + /** + * QR 解出的 3 字段优先, 没解到的保持 OCR 结果 + */ + private InvoiceFields overlayQrFields(InvoiceFields fields, QrDecodeResult qr) { + if (qr.getInvoiceNo() != null) fields.setInvoiceNo(qr.getInvoiceNo()); + if (qr.getAmount() != null) fields.setAmount(qr.getAmount()); + if (qr.getInvoiceDate() != null) fields.setInvoiceDate(qr.getInvoiceDate()); + return fields; + } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/util/AmountUtils.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/util/AmountUtils.java new file mode 100644 index 0000000..71334c6 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/util/AmountUtils.java @@ -0,0 +1,296 @@ +package com.ruoyi.business.ocr.util; + +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 金额工具: 中文大写金额解析 + 小写金额正则 — 对齐 Python app.utils.amount_utils + *

+ * 中文大写金额解析是 cn2an smart 模式的简化版: + * - 支持字符: 零壹贰叁肆伍陆柒捌玖拾佰仟万亿圆元角分整 + * - 处理 亿/万/元 三段累计, 段内 仟佰拾 累加 + * - 处理 角分 (0.1 / 0.01) + */ +public final class AmountUtils { + + private AmountUtils() {} + + // ---------- 小写金额正则 ---------- + + /** 通用金额: ¥1,234.56 或 1234.56 */ + private static final Pattern NUM_PATTERN = Pattern.compile("¥?\\s*(\\d{1,8}(?:,\\d{3})*\\.\\d{2})"); + + /** 税额: "税额 ¥12.34" / "税额:12.34" */ + private static final Pattern TAX_AMOUNT_PATTERN = Pattern.compile("税\\s*额\\s*[¥:]?\\s*(\\d{1,8}(?:,\\d{3})*\\.\\d{2})"); + + /** 不含税: "不含税价 ¥1234.56" / "不含税:1234.56" */ + private static final Pattern PRETAX_PATTERN = Pattern.compile("(?:不合?税价|不含税)\\s*[¥:]?\\s*(\\d{1,8}(?:,\\d{3})*\\.\\d{2})"); + + /** 价税合计: "价税合计 ¥1234.56" */ + private static final Pattern TOTAL_PATTERN = Pattern.compile("价税合计[^\\d]*[¥]?\\s*(\\d{1,8}(?:,\\d{3})*\\.\\d{2})"); + + // ---------- 中文大写金额正则 ---------- + + private static final Pattern CN_IN_PARENS_AFTER_TOTAL = Pattern.compile( + "价税合计[^\\((]*[\\((]([零壹贰叁肆伍陆柒捌玖拾佰仟万亿圆元角分整]+)[\\))]" + ); + + private static final Pattern CN_IN_ANY_PARENS = Pattern.compile( + "[\\((]([零壹贰叁肆伍陆柒捌玖拾佰仟万亿圆元角分整]{3,30})[\\))]" + ); + + private static final Pattern CN_LONG = Pattern.compile("[零壹贰叁肆伍陆柒捌玖拾佰仟万亿圆元角分整]{3,30}"); + + // ---------- 数字映射 ---------- + + private static final Map DIGIT_MAP = new HashMap<>(); + private static final Map UNIT_MAP = new HashMap<>(); + private static final Map BIG_UNIT_MAP = new HashMap<>(); + + static { + DIGIT_MAP.put('零', 0); + DIGIT_MAP.put('壹', 1); + DIGIT_MAP.put('贰', 2); + DIGIT_MAP.put('叁', 3); + DIGIT_MAP.put('肆', 4); + DIGIT_MAP.put('伍', 5); + DIGIT_MAP.put('陆', 6); + DIGIT_MAP.put('柒', 7); + DIGIT_MAP.put('捌', 8); + DIGIT_MAP.put('玖', 9); + + UNIT_MAP.put('拾', 10.0); + UNIT_MAP.put('佰', 100.0); + UNIT_MAP.put('仟', 1000.0); + + BIG_UNIT_MAP.put('角', 0.1); + BIG_UNIT_MAP.put('分', 0.01); + } + + // ---------- 公开方法 ---------- + + /** + * 从文本里抽取中文大写金额 + *

+ * 优先级: + * 1. "价税合计" 后面括号内 + * 2. 任意中括号里的中文金额 + * 3. 含"元"或"圆"的最长中文字符串 + */ + public static String extractCnAmount(String text) { + if (text == null || text.isEmpty()) { + return null; + } + + Matcher m = CN_IN_PARENS_AFTER_TOTAL.matcher(text); + if (m.find()) { + return m.group(1); + } + + m = CN_IN_ANY_PARENS.matcher(text); + if (m.find()) { + return m.group(1); + } + + Matcher m2 = CN_LONG.matcher(text); + while (m2.find()) { + String cand = m2.group(); + if (cand.contains("元") || cand.contains("圆")) { + return cand; + } + } + return null; + } + + /** + * 中文大写金额 → float, 例如 "贰佰元整" → 200.0 + */ + public static Double parseCnAmount(String cnText) { + if (cnText == null || cnText.isEmpty()) { + return null; + } + try { + String s = normalizeCnAmount(cnText); + s = s.replaceAll("整$", ""); + if (!s.endsWith("元")) { + s = s + "元"; + } + return smartParse(s); + } catch (Exception e) { + return null; + } + } + + /** + * 大写 vs 小写金额比对 + */ + public static Boolean amountConsistent(String cnText, Double numAmount) { + if (cnText == null || numAmount == null) { + return null; + } + Double cnValue = parseCnAmount(cnText); + if (cnValue == null) { + return null; + } + return Math.abs(cnValue - numAmount) < 0.011; + } + + /** + * 抽取第一个形如 1234.56 或 ¥1,234.56 的金额 + */ + public static Double extractNumAmount(String text) { + if (text == null || text.isEmpty()) { + return null; + } + Matcher m = NUM_PATTERN.matcher(text); + if (!m.find()) { + return null; + } + try { + return Double.parseDouble(m.group(1).replace(",", "")); + } catch (NumberFormatException e) { + return null; + } + } + + /** + * 抽取价税合计 (优先), 兜底走 extractNumAmount + */ + public static Double extractTotalAmount(String text) { + if (text == null || text.isEmpty()) { + return null; + } + Matcher m = TOTAL_PATTERN.matcher(text); + if (m.find()) { + try { + return Double.parseDouble(m.group(1).replace(",", "")); + } catch (NumberFormatException ignored) {} + } + return extractNumAmount(text); + } + + /** + * 抽取税额 + */ + public static Double extractTaxAmount(String text) { + if (text == null || text.isEmpty()) { + return null; + } + Matcher m = TAX_AMOUNT_PATTERN.matcher(text); + if (m.find()) { + try { + return Double.parseDouble(m.group(1).replace(",", "")); + } catch (NumberFormatException ignored) {} + } + return null; + } + + /** + * 抽取不含税金额 + */ + public static Double extractPretaxAmount(String text) { + if (text == null || text.isEmpty()) { + return null; + } + Matcher m = PRETAX_PATTERN.matcher(text); + if (m.find()) { + try { + return Double.parseDouble(m.group(1).replace(",", "")); + } catch (NumberFormatException ignored) {} + } + return null; + } + + // ---------- 内部 ---------- + + /** + * 中文金额归一化: 圆→元, 〇→零, 去空格 + */ + private static String normalizeCnAmount(String text) { + if (text == null) return ""; + return text.replace("圆", "元").replace("〇", "零").replace(" ", ""); + } + + /** + * cn2an smart 模式简化实现: + * - 拾佰仟 在元段内累加 + * - 万 / 亿 切换大段 + * - 角分 处理小数 + */ + private static double smartParse(String s) { + // 拆分: 整数部分 (元段) + 小数部分 (角分) + int yuanIdx = s.indexOf('元'); + String intPart = yuanIdx >= 0 ? s.substring(0, yuanIdx) : s; + String decPart = yuanIdx >= 0 ? s.substring(yuanIdx + 1) : ""; + + double intValue = parseIntegerPart(intPart); + double decValue = parseDecimalPart(decPart); + return intValue + decValue; + } + + /** + * 解析整数部分: 处理 拾佰仟 万 亿 + */ + private static double parseIntegerPart(String s) { + if (s == null || s.isEmpty()) return 0; + + // 分段: 以 亿 / 万 分隔 + // 例: 叁万玖仟伍佰 → [叁] (亿段=0) + [玖] (万段) + [伍佰] (元段) + // 例: 壹亿贰仟万叁仟 → [壹] (亿段) + [贰] (万段) + [叁] (元段) + double total = 0; + double currentSection = 0; // 当前段(元/万/亿) 的累加值 + double currentNum = 0; // 当前数字 (0-9) + + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (DIGIT_MAP.containsKey(c)) { + currentNum = DIGIT_MAP.get(c); + } else if (UNIT_MAP.containsKey(c)) { + // 拾佰仟: 处理 拾伍 = 15 (省略壹) 的情况 + double v = currentNum == 0 ? 1 : currentNum; + currentSection += v * UNIT_MAP.get(c); + currentNum = 0; + } else if (c == '万') { + currentSection += currentNum; + total += currentSection * 10000; + currentSection = 0; + currentNum = 0; + } else if (c == '亿') { + currentSection += currentNum; + total += currentSection * 100000000; + currentSection = 0; + currentNum = 0; + } + // 零 跳过 + } + // 收尾: 段尾若有数字未乘单位 (如 "叁万玖" 末尾的 "玖") + currentSection += currentNum; + total += currentSection; + return total; + } + + /** + * 解析小数部分: 角分 + */ + private static double parseDecimalPart(String s) { + if (s == null || s.isEmpty()) return 0; + + double value = 0; + double currentNum = 0; + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (DIGIT_MAP.containsKey(c)) { + currentNum = DIGIT_MAP.get(c); + } else if (BIG_UNIT_MAP.containsKey(c)) { + if (currentNum > 0 || i == 0) { + double v = currentNum == 0 ? 1 : currentNum; + value += v * BIG_UNIT_MAP.get(c); + } + currentNum = 0; + } + } + return value; + } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/InvoiceOcrService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/InvoiceOcrService.java index d79933f..0f881d4 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/InvoiceOcrService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/InvoiceOcrService.java @@ -17,7 +17,7 @@ import com.ruoyi.business.domain.BizMeetingInvoice; import com.ruoyi.business.mapper.BizMeetingInvoiceMapper; import com.ruoyi.business.ocr.InvoiceFields; import com.ruoyi.business.ocr.InvoiceResult; -import com.ruoyi.business.ocr.OcrClient; +import com.ruoyi.business.ocr.LocalInvoiceRecognizer; import com.ruoyi.business.ocr.ZipExtractor; import com.ruoyi.business.oss.OssUploader; import com.ruoyi.common.utils.SecurityUtils; @@ -44,7 +44,7 @@ public class InvoiceOcrService private static final Logger log = LoggerFactory.getLogger(InvoiceOcrService.class); @Autowired - private OcrClient ocrClient; + private LocalInvoiceRecognizer recognizer; @Autowired private OssUploader ossUploader; @@ -159,7 +159,7 @@ public class InvoiceOcrService */ void recognizeSingle(Long materialId, String ossUrl) { - InvoiceResult ir = ocrClient.recognizeByUrl(ossUrl); + InvoiceResult ir = recognizer.recognizeByUrl(ossUrl); if (Boolean.TRUE.equals(ir.getSuccess()) && ir.getFields() != null && isRecognizedAsInvoice(ir.getFields())) { BigDecimal amount = ir.getFields().getAmount() != null @@ -210,7 +210,7 @@ public class InvoiceOcrService { try { - InvoiceResult ir = ocrClient.recognize(f); + InvoiceResult ir = recognizer.recognize(f); if (!Boolean.TRUE.equals(ir.getSuccess()) || ir.getFields() == null || !isRecognizedAsInvoice(ir.getFields())) { @@ -275,7 +275,7 @@ public class InvoiceOcrService if (inv == null || inv.getOssUrl() == null) return; try { - InvoiceResult ir = ocrClient.recognizeByUrl(inv.getOssUrl()); + InvoiceResult ir = recognizer.recognizeByUrl(inv.getOssUrl()); if (Boolean.TRUE.equals(ir.getSuccess()) && ir.getFields() != null && isRecognizedAsInvoice(ir.getFields())) { BigDecimal amount = ir.getFields().getAmount() != null diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/sms/AliyunSmsSender.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/sms/AliyunSmsSender.java index 30d002f..4da25d6 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/sms/AliyunSmsSender.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/sms/AliyunSmsSender.java @@ -45,7 +45,7 @@ public class AliyunSmsSender { @Value("${ruoyi.sms.esignTemplate}") private String esignTemplate; - @Value("${ruoyi.sms.esignBaseUrl:https://ringdoctor.com/hg}") + @Value("${ruoyi.sms.esignBaseUrl:https://risingdoctor.com/hg}") private String esignBaseUrl; @Value("${ruoyi.sms.regionId:cn-hangzhou}") @@ -105,7 +105,7 @@ public class AliyunSmsSender { /** * 拼电子签签署链接: {esignBaseUrl}/#/doctor/sign-fill?attendeeId={attendeeId} - * 例: https://ringdoctor.com/hg/#/doctor/sign-fill?attendeeId=123 + * 例: https://risingdoctor.com/hg/#/doctor/sign-fill?attendeeId=123 * (nginx 子路径 /hg 已配在 ruoyi.sms.esignBaseUrl 里, 前端 Vue Router 是 hash 模式, * 所以 Java 只拼 #/doctor/sign-fill 路由 + attendeeId) */ diff --git a/ry-h5/composables/useCamera.ts b/ry-h5/composables/useCamera.ts index 0d4b0a7..a1776dd 100644 --- a/ry-h5/composables/useCamera.ts +++ b/ry-h5/composables/useCamera.ts @@ -32,7 +32,27 @@ export function useCamera(videoId: string, blur?: BlurConfig) { const errorMsg = ref('') function getVideoEl(): HTMLVideoElement | null { - return document.getElementById(videoId) as HTMLVideoElement | null + const el = document.getElementById(videoId) as HTMLElement | null + if (!el) return null + // uni-app H5 把