- * 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
+ * 直接委托 {@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
+ * 绑定 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
+ * 复刻 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
+ * 模型输出约定: PP-OCRv5 multilingual 的输出维度 = len(dict_file_lines) + 2, 其中
+ *
+ * ⚠️ 此约定根据 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
+ * 全部使用 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
+ * 使用 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
+ * 适用: 电子发票 / 数电票 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
+ * 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
+ * 核心优化: 把所有 crop pad 到 batch 内统一宽度 (maxW), 一次性喂给 ONNX.
+ * ONNX 内部用 8 线程并行算所有样本, 单图推理省掉 33 次 kernel launch.
+ *
+ * 输入: 每张 crop 任意宽度 → resize 高 48 + 宽按比例 ≤ maxW
+ * 输出: 与输入 crops 一一对应的 RecognizedText
+ */
+ public List
+ * 单页 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
+ * 策略:
+ * - byX 分支 (左右栏): 用 buyer/seller 标签的 x 坐标分栏. **用 COMPANY_PATTERN** (不依赖"名称:"前缀).
+ * - 上下栏 / 单栏分支: 按 y 排序, **第一个 = 销售方** (中国数电票/电子专票版式 — 销方先印).
+ */
+ private String[] extractPartiesFromLines(List
+ * 中国电子发票版式 (数电票 / 电子专票): 国家税务总局标准 — **购买方信息在前(左/上), 销售方信息在后(右/下)**.
+ * 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
+ * 注意: "名称" 不在这里过滤 — 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:
+ *
+ * 中文大写金额解析是 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
+ * 优先级:
+ * 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> 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)
+ *
+ *
+ * 切换时改 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.
+ * > 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
> 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
+ *
+ * 因此本类在加载时**主动在 idx 0 插入空串占位, idx 1 插入半角空格**, 使
+ * dict.characters[2] 对应文件第 1 行.
+ * > 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
> boxes = detector.detect(img);
+ // 2. 过滤 + 收集 crops (排除 height<8 或 width<5 的噪点 — 不会影响字段抽取)
+ List
> validBoxes = new ArrayList<>();
+ for (List
> boxList = new ArrayList<>();
+ for (int k = 0; k < poly.size(); k += 2) {
+ 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
> 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[][]
+ *
> 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
> 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
+ * 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