feat(detail): 参会人表新增 增值税及附加/摘要/现场照片 3 列 + 劳务协议展示, 角色=劳务形式, 费项改名 应发金额/个税税金/实发金额
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
package com.ruoyi.ocr;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||
|
||||
/**
|
||||
* ry-ocr-java 入口
|
||||
* <p>
|
||||
* 默认端口 8802 (与 ry-ocr Python 8801 并列部署, 不冲突).
|
||||
*/
|
||||
@SpringBootApplication
|
||||
@ConfigurationPropertiesScan("com.ruoyi.ocr.config")
|
||||
public class OcrApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(OcrApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.ruoyi.ocr.api;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||
import org.springframework.web.multipart.support.MissingServletRequestPartException;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 全局异常处理 — 把 Spring 内部异常翻译为 ry-ocr 一致的 HTTP 码
|
||||
*/
|
||||
@Slf4j
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
/**
|
||||
* 上传文件超过 Spring max-file-size → 413
|
||||
*/
|
||||
@ExceptionHandler(MaxUploadSizeExceededException.class)
|
||||
public ResponseEntity<?> handleUploadTooLarge(MaxUploadSizeExceededException e) {
|
||||
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
|
||||
.body(Map.of("detail", "文件超过大小限制"));
|
||||
}
|
||||
|
||||
/**
|
||||
* @Valid 校验失败 → 400
|
||||
*/
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<?> handleValidation(MethodArgumentNotValidException e) {
|
||||
String msg = e.getBindingResult().getFieldErrors().stream()
|
||||
.map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
|
||||
.collect(Collectors.joining("; "));
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("detail", msg.isEmpty() ? "参数校验失败" : msg));
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 解析失败 → 400
|
||||
*/
|
||||
@ExceptionHandler(HttpMessageNotReadableException.class)
|
||||
public ResponseEntity<?> handleBadJson(HttpMessageNotReadableException e) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("detail", "请求体格式错误: " + (e.getMostSpecificCause() == null ? e.getMessage() : e.getMostSpecificCause().getMessage())));
|
||||
}
|
||||
|
||||
/**
|
||||
* multipart 缺 file 字段 → 400
|
||||
*/
|
||||
@ExceptionHandler(MissingServletRequestPartException.class)
|
||||
public ResponseEntity<?> handleMissingPart(MissingServletRequestPartException e) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("detail", "缺少请求字段: " + e.getRequestPartName()));
|
||||
}
|
||||
|
||||
/**
|
||||
* OCR 引擎未就绪 → 503
|
||||
*/
|
||||
@ExceptionHandler(IllegalStateException.class)
|
||||
public ResponseEntity<?> handleEngineNotReady(IllegalStateException e) {
|
||||
if (e.getMessage() != null && e.getMessage().contains("OCR 引擎未就绪")) {
|
||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.body(Map.of("detail", e.getMessage()));
|
||||
}
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("detail", "内部错误: " + e.getMessage()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 兜底
|
||||
*/
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<?> handleAny(Exception e) {
|
||||
log.error("未处理异常", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("detail", "内部错误: " + e.getClass().getSimpleName() + " - " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package com.ruoyi.ocr.api;
|
||||
|
||||
import com.ruoyi.ocr.config.OcrProperties;
|
||||
import com.ruoyi.ocr.core.OcrEngine;
|
||||
import com.ruoyi.ocr.model.HealthResponse;
|
||||
import com.ruoyi.ocr.model.InvoiceFields;
|
||||
import com.ruoyi.ocr.model.InvoiceResult;
|
||||
import com.ruoyi.ocr.model.PathRecognizeRequest;
|
||||
import com.ruoyi.ocr.service.InvoiceExtractor;
|
||||
import com.ruoyi.ocr.service.RecognizeService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* OCR REST 控制器 — 对齐 Python app.api.routes
|
||||
* <p>
|
||||
* 4 个接口:
|
||||
* - GET /health
|
||||
* - POST /recognize/invoice (multipart file)
|
||||
* - POST /recognize/invoice/by-path (JSON {file_path}, 路径白名单)
|
||||
* - POST /recognize/text (Query: raw_text, 仅字段抽取)
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
public class OcrController {
|
||||
|
||||
private final OcrProperties props;
|
||||
private final OcrEngine ocrEngine;
|
||||
private final RecognizeService recognizeService;
|
||||
private final InvoiceExtractor extractor;
|
||||
|
||||
// ---------- /health ----------
|
||||
|
||||
@GetMapping("/health")
|
||||
public HealthResponse health() {
|
||||
return new HealthResponse(
|
||||
ocrEngine.isReady() ? "ok" : "degraded",
|
||||
props.getVersion(),
|
||||
ocrEngine.isReady()
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- /recognize/invoice (multipart) ----------
|
||||
|
||||
@PostMapping(value = "/recognize/invoice", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ResponseEntity<?> recognizeInvoice(@RequestParam("file") MultipartFile file) {
|
||||
if (file.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("detail", "文件为空"));
|
||||
}
|
||||
long maxBytes = (long) props.getUpload().getMaxMb() * 1024 * 1024;
|
||||
if (file.getSize() > maxBytes) {
|
||||
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
|
||||
.body(Map.of("detail", "文件超过 " + props.getUpload().getMaxMb() + "MB 限制"));
|
||||
}
|
||||
try {
|
||||
byte[] content = file.getBytes();
|
||||
InvoiceResult r = recognizeService.recognizeFile(file.getOriginalFilename(), content);
|
||||
return ResponseEntity.ok(r);
|
||||
} catch (Exception e) {
|
||||
log.error("recognize 失败", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("detail", "识别失败: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- /recognize/invoice/by-path (JSON, 白名单) ----------
|
||||
|
||||
@PostMapping(value = "/recognize/invoice/by-path",
|
||||
consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<?> recognizeByPath(@Valid @RequestBody PathRecognizeRequest req) {
|
||||
Path p;
|
||||
try {
|
||||
p = Paths.get(req.getFilePath());
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("detail", "路径无效: " + e.getMessage()));
|
||||
}
|
||||
// 白名单校验
|
||||
ResponseEntity<?> guard = checkPathAllowed(p);
|
||||
if (guard != null) return guard;
|
||||
|
||||
if (!Files.exists(p)) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("detail", "文件不存在: " + p));
|
||||
}
|
||||
if (!Files.isRegularFile(p)) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("detail", "不是文件: " + p));
|
||||
}
|
||||
InvoiceResult r = recognizeService.recognizePath(p, false);
|
||||
return ResponseEntity.ok(r);
|
||||
}
|
||||
|
||||
// ---------- /recognize/text (Query raw_text, 不调 OCR) ----------
|
||||
|
||||
@PostMapping("/recognize/text")
|
||||
public Map<String, Object> recognizeText(@RequestParam("raw_text") String rawText) {
|
||||
// /recognize/text 没有 QR, 传 null 让 extractor 走 OCR TOTAL_PATTERN 兜底
|
||||
InvoiceFields fields = extractor.extract(rawText, null, null);
|
||||
return Map.of("fields", fields);
|
||||
}
|
||||
|
||||
// ---------- 白名单工具 ----------
|
||||
|
||||
private ResponseEntity<?> checkPathAllowed(Path filePath) {
|
||||
List<Path> roots = parseAllowedDirs();
|
||||
if (roots.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(Map.of(
|
||||
"detail", "路径接口未启用: 在 application.yml 配置 app.allowed-dirs 后重启服务"
|
||||
));
|
||||
}
|
||||
Path absPath;
|
||||
try {
|
||||
absPath = filePath.toAbsolutePath().normalize();
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("detail", "路径无效: " + e.getMessage()));
|
||||
}
|
||||
for (Path root : roots) {
|
||||
if (absPath.startsWith(root)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(Map.of(
|
||||
"detail", "路径不在白名单内(允许: " + roots + ")"
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 app.allowed-dirs 配置: List 或 String(";" / ":" 分隔, 跨平台)
|
||||
*/
|
||||
private List<Path> parseAllowedDirs() {
|
||||
List<String> raw = props.getAllowedDirs();
|
||||
if (raw == null || raw.isEmpty()) return List.of();
|
||||
|
||||
// 兼容: 单元素可能是 ";D:\\a;E:\\b" 形式
|
||||
List<String> expanded = new ArrayList<>();
|
||||
for (String s : raw) {
|
||||
if (s == null) continue;
|
||||
String sep = s.contains(";") ? ";" : (s.contains(":") && !s.matches("^[A-Z]:.*") ? ":" : null);
|
||||
if (sep == null) {
|
||||
expanded.add(s.trim());
|
||||
} else {
|
||||
for (String part : s.split(java.util.regex.Pattern.quote(sep))) {
|
||||
String t = part.trim();
|
||||
if (!t.isEmpty()) expanded.add(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<Path> roots = new ArrayList<>();
|
||||
for (String r : expanded) {
|
||||
if (r.isEmpty()) continue;
|
||||
try {
|
||||
Path p = Paths.get(r).toAbsolutePath().normalize();
|
||||
if (Files.isDirectory(p)) {
|
||||
roots.add(p);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("allowed-dirs 解析失败: {} ({})", r, e.getMessage());
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.ruoyi.ocr.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* ry-ocr-java 配置 (对应 Python app/config.py + .env)
|
||||
* <p>
|
||||
* 绑定 application.yml 中 app.* 配置段.
|
||||
*/
|
||||
@Data
|
||||
@ConfigurationProperties(prefix = "app")
|
||||
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 接口
|
||||
* <p>
|
||||
* 支持: yml 数组 ["E:\\a", "D:\\b"] 或单字符串 "E:\\a;D:\\b" (Windows 分号分隔)
|
||||
*/
|
||||
private List<String> 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 输入高度):
|
||||
* <ul>
|
||||
* <li>v5_server — H=48, maxW=320 (PaddleOCR v5 server 多语言模型, 90MB+ 大, 精度最高)</li>
|
||||
* <li>v5_mobile — H=48, maxW=320 (PaddleOCR v5 mobile 多语言模型, 20MB, 推荐折中)</li>
|
||||
* <li>v4_mobile — H=32, maxW=320 (PaddleOCR v4 mobile 中文模型, 15MB, 最快, 中文精度略低)</li>
|
||||
* </ul>
|
||||
* 切换时改 models-dir + 本字段 + 重启即可. */
|
||||
private String modelVersion = "v5_server";
|
||||
/** CRNN 输入高度 (覆盖 modelVersion 默认值). 高级用户用, 一般不动. */
|
||||
private Integer recHeight = null;
|
||||
/** CRNN 最大宽度 (覆盖 modelVersion 默认值) */
|
||||
private Integer recMaxWidth = null;
|
||||
/** DB 检测最长边限制 (覆盖 modelVersion 默认值) */
|
||||
private Integer detMaxSide = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.ruoyi.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.ruoyi.ocr.core;
|
||||
|
||||
import java.awt.geom.Path2D;
|
||||
import java.awt.geom.PathIterator;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* DB (Differentiable Binarization) 后处理 — 从概率图提取文本框 polygon.
|
||||
* <p>
|
||||
* 复刻 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<List<Float>> 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<int[][]> components = connectedComponents(dilated, resizeH, resizeW);
|
||||
// 过滤 + unclip
|
||||
float scaleX = (float) origW / resizeW;
|
||||
float scaleY = (float) origH / resizeH;
|
||||
List<List<Float>> 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<Float> 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<int[][]> connectedComponents(boolean[][] mask, int h, int w) {
|
||||
boolean[][] visited = new boolean[h][w];
|
||||
List<int[][]> 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<int[]> comp = new ArrayList<>();
|
||||
java.util.Deque<int[]> 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<Float> 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)};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.ruoyi.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
|
||||
* <p>
|
||||
* 模型输出约定: PP-OCRv5 multilingual 的输出维度 = len(dict_file_lines) + 2, 其中
|
||||
* <ul>
|
||||
* <li>idx 0 = CTC blank (不查表, 由解码器跳过)</li>
|
||||
* <li>idx 1 = " " (半角空格, 多语言模型中英文混排用, 文件中无此行)</li>
|
||||
* <li>idx 2..N+1 = 字典字符 (来自 ppocr_keys_v1.txt 的每一行)</li>
|
||||
* </ul>
|
||||
* 因此本类在加载时**主动在 idx 0 插入空串占位, idx 1 插入半角空格**, 使
|
||||
* dict.characters[2] 对应文件第 1 行.
|
||||
* <p>
|
||||
* ⚠️ 此约定根据 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<String> characters;
|
||||
|
||||
private Dictionary(List<String> characters) {
|
||||
this.characters = characters;
|
||||
}
|
||||
|
||||
public int size() {
|
||||
return characters.size();
|
||||
}
|
||||
|
||||
public static Dictionary load(Path dictPath) throws IOException {
|
||||
List<String> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package com.ruoyi.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
|
||||
* <p>
|
||||
* 全部使用 Java 2D, 无需引入 OpenCV.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class ImageProcessor {
|
||||
|
||||
private ImageProcessor() {}
|
||||
|
||||
/**
|
||||
* 简易方向校正: 纵向图 (高 > 宽 × 1.2) 顺时针旋转 90°.
|
||||
* <p>
|
||||
* 复杂倾斜交给 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算灰度图标准差 (判断是否低对比度)
|
||||
* <p>
|
||||
* 优化: 用 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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 简易自适应二值化 (高斯加权 + 常数偏移).
|
||||
* <p>
|
||||
* 与 cv2.adaptiveThreshold(..., ADAPTIVE_THRESH_GAUSSIAN_C, ...) 行为近似.
|
||||
* <p>
|
||||
* 优化: 整图 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package com.ruoyi.ocr.core;
|
||||
|
||||
import ai.onnxruntime.OrtEnvironment;
|
||||
import ai.onnxruntime.OrtException;
|
||||
import com.ruoyi.ocr.config.OcrProperties;
|
||||
import com.ruoyi.ocr.exception.OcrTimeoutException;
|
||||
import com.ruoyi.ocr.model.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
|
||||
* <p>
|
||||
* 单例 + 单页超时 (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;
|
||||
// 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) {}
|
||||
// 回退: ./target/classes/models/
|
||||
return Path.of("src/main/resources", dir);
|
||||
}
|
||||
|
||||
/** 解析 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<OcrLine> recognize(Path imagePath) {
|
||||
if (!ready) {
|
||||
throw new IllegalStateException("OCR 引擎未就绪, 请检查 models/ 目录下是否有 det.onnx / rec.onnx / ppocr_keys_v1.txt");
|
||||
}
|
||||
int timeoutSec = props.getOcr().getPageTimeoutS();
|
||||
Future<List<OcrLine>> 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<OcrLine> doRecognize(Path imagePath) {
|
||||
try {
|
||||
BufferedImage img = ImageIO.read(imagePath.toFile());
|
||||
if (img == null) return Collections.emptyList();
|
||||
|
||||
// 1. 检测
|
||||
List<List<Float>> boxes = detector.detect(img);
|
||||
// 2. 过滤 + 收集 crops (排除 height<8 或 width<5 的噪点 — 不会影响字段抽取)
|
||||
List<BufferedImage> crops = new ArrayList<>();
|
||||
List<List<Float>> validBoxes = new ArrayList<>();
|
||||
for (List<Float> 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<TextRecognizer.RecognizedText> rts = recognizer.recognizeBatch(crops);
|
||||
|
||||
// 4. 配对 boxes + texts
|
||||
List<OcrLine> 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<Float> poly = validBoxes.get(i);
|
||||
List<List<Float>> boxList = new ArrayList<>();
|
||||
for (int k = 0; k < poly.size(); k += 2) {
|
||||
List<Float> 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); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.ruoyi.ocr.core;
|
||||
|
||||
import com.ruoyi.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
|
||||
* <p>
|
||||
* 使用 PDFBox (无需 poppler 等系统依赖).
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PdfProcessor {
|
||||
|
||||
private final OcrProperties props;
|
||||
|
||||
/**
|
||||
* 把 PDF 每页渲染成 PNG, 返回临时文件路径列表.
|
||||
* <p>
|
||||
* 输出目录: {pdf.parent}/.{pdf.stem}_pages/page_{idx:03d}.png
|
||||
*/
|
||||
public List<Path> pdfToImages(Path pdfPath) throws IOException {
|
||||
int dpi = props.getUpload().getPdfDpi();
|
||||
Path outDir = pdfPath.getParent().resolve("." + stem(pdfPath) + "_pages");
|
||||
outDir.toFile().mkdirs();
|
||||
|
||||
List<Path> 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
|
||||
* <p>
|
||||
* 适用: 电子发票 / 数电票 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.ruoyi.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 推理.
|
||||
* <p>
|
||||
* 输入: [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<List<Float>> 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<String, OnnxTensor> inputs = Collections.singletonMap("x", inputTensor);
|
||||
|
||||
List<List<Float>> 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[][]
|
||||
* <p>
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package com.ruoyi.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 推理.
|
||||
* <p>
|
||||
* 输入: [1, 3, H, W] 归一化图 (mean=0.5, std=0.5)
|
||||
* 输出: [1, T, N] (T=序列长度, N=字典大小+1 含 blank)
|
||||
* <p>
|
||||
* 当前使用 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<RecognizedText> results = recognizeBatch(Collections.singletonList(crop));
|
||||
return results.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量识别多张裁剪图 — 性能关键.
|
||||
* <p>
|
||||
* 核心优化: 把所有 crop pad 到 batch 内统一宽度 (maxW), 一次性喂给 ONNX.
|
||||
* ONNX 内部用 8 线程并行算所有样本, 单图推理省掉 33 次 kernel launch.
|
||||
* <p>
|
||||
* 输入: 每张 crop 任意宽度 → resize 高 48 + 宽按比例 ≤ maxW
|
||||
* 输出: 与输入 crops 一一对应的 RecognizedText
|
||||
*/
|
||||
public List<RecognizedText> recognizeBatch(List<BufferedImage> 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<String, OnnxTensor> inputs = Collections.singletonMap("x", inputTensor);
|
||||
|
||||
List<RecognizedText> 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) {}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.ruoyi.ocr.exception;
|
||||
|
||||
/**
|
||||
* OCR 识别超时异常 — 对齐 Python app.core.ocr_engine.OCRTimeout
|
||||
* <p>
|
||||
* 单页 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.ruoyi.ocr.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 健康检查响应 — 对齐 Python app.models.schemas.HealthResponse
|
||||
* <p>
|
||||
* JSON 字段: status / version / engine_ready
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class HealthResponse {
|
||||
|
||||
/** "ok" / "degraded" */
|
||||
private String status = "ok";
|
||||
|
||||
/** 服务版本号 */
|
||||
private String version;
|
||||
|
||||
/** OCR 引擎是否就绪 */
|
||||
@JsonProperty("engine_ready")
|
||||
private Boolean engineReady;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.ruoyi.ocr.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 发票结构化字段 — 对齐 Python app.models.schemas.InvoiceFields
|
||||
* <p>
|
||||
* QR 权威字段 (QR 命中时覆盖 OCR 结果): invoice_no / invoice_date / amount
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class InvoiceFields {
|
||||
|
||||
/** 发票类型, 如 增值税电子普通发票 / 增值税专用发票 / 数电票 */
|
||||
@JsonProperty("invoice_type")
|
||||
private String invoiceType;
|
||||
|
||||
/** 发票号码 — QR 权威 / OCR */
|
||||
@JsonProperty("invoice_no")
|
||||
private String invoiceNo;
|
||||
|
||||
/** 发票代码 — OCR (数电票此字段为空) */
|
||||
@JsonProperty("invoice_code")
|
||||
private String invoiceCode;
|
||||
|
||||
/** 开票日期 YYYY-MM-DD — QR 权威 / OCR */
|
||||
@JsonProperty("invoice_date")
|
||||
private String invoiceDate;
|
||||
|
||||
/** 价税合计 (小写) — QR 权威 / OCR */
|
||||
private Double amount;
|
||||
|
||||
/** 价税合计 (大写中文) — OCR */
|
||||
@JsonProperty("amount_cn")
|
||||
private String amountCn;
|
||||
|
||||
/** 不含税金额 — OCR */
|
||||
@JsonProperty("amount_pretax")
|
||||
private Double amountPretax;
|
||||
|
||||
/** 税额 — OCR */
|
||||
@JsonProperty("tax_amount")
|
||||
private Double taxAmount;
|
||||
|
||||
/** 销售方名称 — OCR */
|
||||
@JsonProperty("seller_name")
|
||||
private String sellerName;
|
||||
|
||||
/** 销售方纳税人识别号 — OCR */
|
||||
@JsonProperty("seller_tax_no")
|
||||
private String sellerTaxNo;
|
||||
|
||||
/** 购买方名称 — OCR */
|
||||
@JsonProperty("buyer_name")
|
||||
private String buyerName;
|
||||
|
||||
/** 购买方纳税人识别号 — OCR */
|
||||
@JsonProperty("buyer_tax_no")
|
||||
private String buyerTaxNo;
|
||||
|
||||
/** 大写金额与小数金额一致性 (null=未能比对) */
|
||||
@JsonProperty("amount_match")
|
||||
private Boolean amountMatch;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.ruoyi.ocr.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 发票识别主响应 — 对齐 Python app.models.schemas.InvoiceResult
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class InvoiceResult {
|
||||
|
||||
/** 整体是否成功 */
|
||||
private Boolean success;
|
||||
|
||||
/** 是否被判定为发票 (false = 非发票图片) */
|
||||
@JsonProperty("is_invoice")
|
||||
private Boolean isInvoice = true;
|
||||
|
||||
/** 全部 OCR 文本拼接 (快路径为 "[QR only] ...") */
|
||||
@JsonProperty("raw_text")
|
||||
private String rawText = "";
|
||||
|
||||
/** 分行识别结果 */
|
||||
private List<OcrLine> lines = new ArrayList<>();
|
||||
|
||||
/** 抽取的结构化字段 */
|
||||
private InvoiceFields fields = new InvoiceFields();
|
||||
|
||||
/** PDF 页数 / 图片 = 1 */
|
||||
@JsonProperty("page_count")
|
||||
private Integer pageCount = 1;
|
||||
|
||||
/** 使用的 OCR 引擎: "paddleocr" / "qr" */
|
||||
private String engine = "paddleocr";
|
||||
|
||||
/** 识别耗时 (毫秒) */
|
||||
@JsonProperty("elapsed_ms")
|
||||
private Integer elapsedMs = 0;
|
||||
|
||||
/** 失败原因描述 */
|
||||
private String error;
|
||||
|
||||
/** 错误码: not_invoice / timeout / unsupported / ocr_failed / process_failed */
|
||||
@JsonProperty("error_code")
|
||||
private String errorCode;
|
||||
|
||||
/** 是否从 QR 取到了 3 个核心字段 */
|
||||
@JsonProperty("from_qr")
|
||||
private Boolean fromQr = false;
|
||||
|
||||
/** 二维码原始文本 (排查用) */
|
||||
@JsonProperty("qr_raw")
|
||||
private String qrRaw;
|
||||
|
||||
/** 二维码识别失败原因 (no_qr / bad_format) */
|
||||
@JsonProperty("qr_error")
|
||||
private String qrError;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.ruoyi.ocr.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 单行 OCR 识别结果 — 对齐 Python app.models.schemas.OCRLine
|
||||
* <p>
|
||||
* JSON 字段: text / confidence / box
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class OcrLine {
|
||||
|
||||
/** 识别文本 */
|
||||
private String text;
|
||||
|
||||
/** 置信度 0~1 */
|
||||
private Double confidence;
|
||||
|
||||
/** 四点坐标 [[x1,y1], [x2,y2], [x3,y3], [x4,y4]] */
|
||||
@JsonProperty("box")
|
||||
private List<List<Float>> box = new ArrayList<>();
|
||||
|
||||
public OcrLine(String text, Double confidence) {
|
||||
this.text = text;
|
||||
this.confidence = confidence;
|
||||
this.box = new ArrayList<>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.ruoyi.ocr.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 按文件路径识别的请求体 — 对齐 Python app.models.schemas.PathRecognizeRequest
|
||||
* <p>
|
||||
* 安全: 路径必须在 app.allowed-dirs 白名单内才会被执行.
|
||||
* <p>
|
||||
* JSON 字段名: file_path (snake_case, 与 Python 一致)
|
||||
*/
|
||||
@Data
|
||||
public class PathRecognizeRequest {
|
||||
|
||||
/** 服务器本地绝对路径 (正反斜杠均可) */
|
||||
@JsonProperty("file_path")
|
||||
@NotBlank(message = "file_path 不能为空")
|
||||
private String filePath;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.ruoyi.ocr.model;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
package com.ruoyi.ocr.service;
|
||||
|
||||
import com.ruoyi.ocr.model.InvoiceFields;
|
||||
import com.ruoyi.ocr.model.OcrLine;
|
||||
import com.ruoyi.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
|
||||
* <p>
|
||||
* 适配中国大陆 增值税发票(电子普票 / 专票 / 电子专票 / 数电票).
|
||||
* <p>
|
||||
* 关键策略:
|
||||
* - 主体按 OCR box 坐标判断归属 (左右两栏)
|
||||
* - 名称提取加 stop word, 避免单行文本混淆
|
||||
* - 金额兜底: tax + pretax = total 组合搜索
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class InvoiceExtractor {
|
||||
|
||||
// ---------- 发票类型 (按长度降序, 优先匹配最长前缀, 避免 "电子发票(增值税专用发票)" 被 "增值税专用发票" 抢先命中) ----------
|
||||
private static final List<String> 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<OcrLine> 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<Double> candidates = new ArrayList<>();
|
||||
Set<String> 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<List<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<Float> 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]
|
||||
* <p>
|
||||
* 策略:
|
||||
* - byX 分支 (左右栏): 用 buyer/seller 标签的 x 坐标分栏. **用 COMPANY_PATTERN** (不依赖"名称:"前缀).
|
||||
* - 上下栏 / 单栏分支: 按 y 排序, **第一个 = 销售方** (中国数电票/电子专票版式 — 销方先印).
|
||||
*/
|
||||
private String[] extractPartiesFromLines(List<OcrLine> lines) {
|
||||
List<List<Float>> 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<double[]> nameCoords = new ArrayList<>(); // [cx, cy]
|
||||
List<String> nameVals = new ArrayList<>();
|
||||
List<double[]> taxCoords = new ArrayList<>();
|
||||
List<String> 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<double[]> coords, List<String> vals, int dim) {
|
||||
List<Integer> idx = new ArrayList<>();
|
||||
for (int i = 0; i < coords.size(); i++) idx.add(i);
|
||||
idx.sort(Comparator.comparingDouble(i -> coords.get(i)[dim]));
|
||||
List<double[]> sc = new ArrayList<>();
|
||||
List<String> 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, 按出现顺序配对.
|
||||
* <p>
|
||||
* 中国电子发票版式 (数电票 / 电子专票): 国家税务总局标准 — **购买方信息在前(左/上), 销售方信息在后(右/下)**.
|
||||
* PDF 文字流的 value 顺序通常按视觉顺序 (左/上 先), 所以:
|
||||
* **第一个匹配 = 购买方, 第二个匹配 = 销售方**.
|
||||
* <p>
|
||||
* 修复:
|
||||
* 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<String> 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<String> 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 已经过滤一些, 这里再覆盖):
|
||||
* 表格项目名 ("项目名称"), 规格型号 ("规格"), 备注 ("备注"), 单位, 数量 等.
|
||||
* <p>
|
||||
* 注意: "名称" 不在这里过滤 — 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package com.ruoyi.ocr.service;
|
||||
|
||||
import com.google.zxing.*;
|
||||
import com.google.zxing.client.j2se.BufferedImageLuminanceSource;
|
||||
import com.google.zxing.common.HybridBinarizer;
|
||||
import com.ruoyi.ocr.model.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
|
||||
* <p>
|
||||
* 国家税务总局规范的电子发票二维码内容格式 (8 字段逗号分隔):
|
||||
* 01,<type>,<invoice_code>,<invoice_no>,<amount>,<date>,<check_code>,<reserved>
|
||||
* <p>
|
||||
* 例: 01,31,,24922000000006110014,39500.00,20240202,,A371
|
||||
* <p>
|
||||
* 使用 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
|
||||
* <p>
|
||||
* 字段全 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package com.ruoyi.ocr.service;
|
||||
|
||||
import com.ruoyi.ocr.config.OcrProperties;
|
||||
import com.ruoyi.ocr.core.ImageProcessor;
|
||||
import com.ruoyi.ocr.core.OcrEngine;
|
||||
import com.ruoyi.ocr.core.PdfProcessor;
|
||||
import com.ruoyi.ocr.exception.OcrTimeoutException;
|
||||
import com.ruoyi.ocr.model.InvoiceFields;
|
||||
import com.ruoyi.ocr.model.InvoiceResult;
|
||||
import com.ruoyi.ocr.model.OcrLine;
|
||||
import com.ruoyi.ocr.model.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
|
||||
* <p>
|
||||
* 完全对齐 Python app.services.recognize_service:
|
||||
* <pre>
|
||||
* 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
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RecognizeService {
|
||||
|
||||
private static final Set<String> 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<Path> 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<OcrLine> 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<OcrLine> ocrImage(Path imgPath) {
|
||||
Path rotated = ImageProcessor.autoRotate(imgPath);
|
||||
Path enhanced = ImageProcessor.enhance(rotated);
|
||||
return ocrEngine.recognize(enhanced);
|
||||
}
|
||||
|
||||
private static String joinLines(List<OcrLine> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package com.ruoyi.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
|
||||
* <p>
|
||||
* 中文大写金额解析是 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<Character, Integer> DIGIT_MAP = new HashMap<>();
|
||||
private static final Map<Character, Double> UNIT_MAP = new HashMap<>();
|
||||
private static final Map<Character, Double> 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);
|
||||
}
|
||||
|
||||
// ---------- 公开方法 ----------
|
||||
|
||||
/**
|
||||
* 从文本里抽取中文大写金额
|
||||
* <p>
|
||||
* 优先级:
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user