feat: OCR 服务 + 会议材料模块
ry-ocr/ (新)
本地发票识别微服务 (PaddleOCR 3.x + FastAPI, 8801)
- QR 优先: 扫到二维码即取开票时间/发票号/金额; 没扫到/格式不合法直接判非发票, 不跑 OCR
- 配置 QR_FULL_OCR 控制快路径(false, 0.2s)还是全字段(true, 4.5s)
- /recognize/invoice (multipart) + /recognize/invoice/by-path (本地路径, 白名单) + /recognize/text
- is_invoice / from_qr / qr_raw / qr_error / error_code 字段
- 12 字段发票抽取 (regex + 启发式, 左右主体识别)
- 超时保护 (15s 单页 / 60s 总流程) + PaddleOCR 单例 + ThreadPoolExecutor
ry-api/ruoyi-business/
- pom.xml: 加 hutool-http/json/core 5.8.27, lombok 1.18.30 (OcrClient @Slf4j 所需)
- ocr/: OcrClient + InvoiceResult/Fields/Line + ZipExtractor + InvoiceOcrScheduler
- oss/: OssUploader + OssConfMeta (OCR 识别后重传 OSS)
- config/: OcrConfig + OcrExecutorConfig (后台线程池)
- service/impl/InvoiceOcrService: 后台提交 OCR, ZIP 路径解压识别, 替换场景先清旧
- 会议材料 CRUD 全套 (BizMeetingAuditLog/Executor/Invoice/Material/Supervisor):
controller + service + mapper + domain + xml
ry-vue3/
- MeetingDetail.vue (新建): 会议详情页 (含评分维度章节, 改只读)
- Meetings.vue / OssFileUploader.vue / router / Login.vue: 适配新字段
ry-api/ruoyi-admin/
- RuoYiApplication.java + application.yml: 启用 @Async 异步支持
_self/
- manager_meetings.md / manager_meeting_detail.md: 文档
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
"""核心能力:OCR / PDF / 图像"""
|
||||
from .ocr_engine import get_engine, recognize, warmup, OCRTimeout
|
||||
from .pdf_processor import pdf_to_images
|
||||
from .image_processor import auto_rotate, enhance
|
||||
|
||||
__all__ = ["get_engine", "recognize", "warmup", "OCRTimeout", "pdf_to_images", "auto_rotate", "enhance"]
|
||||
@@ -0,0 +1,47 @@
|
||||
"""图像预处理:自动旋转、放大、去噪"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
||||
def auto_rotate(img_path: str | Path) -> Path:
|
||||
"""基于方向检测的简易旋转(> 阈值倾斜就转 90°)
|
||||
|
||||
说明:发票多为横向,此处只处理 0/90/180/270 四方向,
|
||||
复杂倾斜交给 PaddleOCR 自带的 textline orientation。
|
||||
"""
|
||||
p = Path(img_path)
|
||||
img = cv2.imread(str(p))
|
||||
if img is None:
|
||||
return p
|
||||
|
||||
h, w = img.shape[:2]
|
||||
# 横图宽 > 高 * 1.2,认为方向正确;否则旋转
|
||||
if h > w * 1.2:
|
||||
rotated = cv2.rotate(img, cv2.ROTATE_90_CLOCKWISE)
|
||||
out = p.with_name(f"{p.stem}_rot.png")
|
||||
cv2.imwrite(str(out), rotated)
|
||||
return out
|
||||
return p
|
||||
|
||||
|
||||
def enhance(img_path: str | Path) -> Path:
|
||||
"""轻度增强:灰度 + 自适应二值化(对手机拍的发票有帮助)"""
|
||||
p = Path(img_path)
|
||||
img = cv2.imread(str(p))
|
||||
if img is None:
|
||||
return p
|
||||
|
||||
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
||||
# 弱增强:只对低对比度图做二值化
|
||||
if gray.std() < 50:
|
||||
binary = cv2.adaptiveThreshold(
|
||||
gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 31, 10
|
||||
)
|
||||
out = p.with_name(f"{p.stem}_enh.png")
|
||||
cv2.imwrite(str(out), binary)
|
||||
return out
|
||||
return p
|
||||
@@ -0,0 +1,146 @@
|
||||
"""PaddleOCR 引擎封装(单例 + 超时)
|
||||
|
||||
适配 paddleocr 3.0+ 的 predict() 接口。
|
||||
降级到 2.x 时也能跑(参数兼容)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutTimeout
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from app.config import settings
|
||||
from app.models import OCRLine
|
||||
|
||||
_lock = threading.Lock()
|
||||
_engine = None
|
||||
_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="ocr")
|
||||
|
||||
|
||||
# ---------- 引擎构造 ----------
|
||||
|
||||
def _build_engine():
|
||||
try:
|
||||
from paddleocr import PaddleOCR
|
||||
# paddleocr >= 3.0: device="cpu"/"gpu", 没有 use_gpu / show_log
|
||||
# 模型选择:
|
||||
# v5_server - 精度高但 CPU 慢 (5+ 秒/页)
|
||||
# v5_mobile - CPU 友好 (1~2 秒/页), 精度略低, 推荐生产环境
|
||||
mobile = settings.ocr_lang.endswith("mobile") or settings.ocr_engine == "mobile"
|
||||
if settings.use_gpu or settings.ocr_engine == "server":
|
||||
# GPU 或用户显式指定 server 模型
|
||||
det_name = "PP-OCRv5_server_det"
|
||||
rec_name = "PP-OCRv5_server_rec"
|
||||
else:
|
||||
# 默认 mobile (CPU 友好)
|
||||
det_name = "PP-OCRv5_mobile_det"
|
||||
rec_name = "PP-OCRv5_mobile_rec"
|
||||
|
||||
return PaddleOCR(
|
||||
use_doc_orientation_classify=False,
|
||||
use_doc_unwarping=False,
|
||||
use_textline_orientation=False,
|
||||
lang=settings.ocr_lang,
|
||||
device="gpu" if settings.use_gpu else "cpu",
|
||||
text_detection_model_name=det_name,
|
||||
text_recognition_model_name=rec_name,
|
||||
)
|
||||
except TypeError:
|
||||
# paddleocr 2.x 老参数(向后兼容)
|
||||
from paddleocr import PaddleOCR
|
||||
return PaddleOCR(
|
||||
use_angle_cls=True,
|
||||
lang=settings.ocr_lang,
|
||||
use_gpu=settings.use_gpu,
|
||||
show_log=False,
|
||||
)
|
||||
|
||||
|
||||
def get_engine():
|
||||
global _engine
|
||||
if _engine is None:
|
||||
with _lock:
|
||||
if _engine is None:
|
||||
logger.info("正在初始化 PaddleOCR (lang={}, gpu={})...", settings.ocr_lang, settings.use_gpu)
|
||||
_engine = _build_engine()
|
||||
logger.info("PaddleOCR 初始化完成")
|
||||
return _engine
|
||||
|
||||
|
||||
def _normalize(result) -> List[OCRLine]:
|
||||
lines: List[OCRLine] = []
|
||||
if not result:
|
||||
return lines
|
||||
|
||||
if isinstance(result, list) and result and isinstance(result[0], dict):
|
||||
for page in result:
|
||||
texts = page.get("rec_texts") or []
|
||||
scores = page.get("rec_scores") or []
|
||||
polys = page.get("rec_polys") or []
|
||||
for i, txt in enumerate(texts):
|
||||
conf = float(scores[i]) if i < len(scores) else 0.0
|
||||
box = polys[i].tolist() if i < len(polys) and hasattr(polys[i], "tolist") else []
|
||||
lines.append(OCRLine(text=str(txt).strip(), confidence=conf, box=box))
|
||||
return lines
|
||||
|
||||
try:
|
||||
for page in result:
|
||||
for det in page:
|
||||
box = det[0]
|
||||
txt, conf = det[1]
|
||||
lines.append(OCRLine(text=str(txt).strip(), confidence=float(conf), box=box))
|
||||
except Exception:
|
||||
logger.exception("无法解析 PaddleOCR 输出: {}", result)
|
||||
return lines
|
||||
|
||||
|
||||
class OCRTimeout(Exception):
|
||||
"""OCR 识别超时"""
|
||||
pass
|
||||
|
||||
|
||||
def recognize(image_path: str | Path, timeout_s: Optional[float] = None) -> List[OCRLine]:
|
||||
"""对单张图片做 OCR,带超时控制
|
||||
|
||||
timeout_s 默认为 settings.ocr_page_timeout_s(默认 5 秒)。
|
||||
超时立即抛 OCRTimeout,不等模型跑完。
|
||||
"""
|
||||
timeout = timeout_s or settings.ocr_page_timeout_s
|
||||
engine = get_engine()
|
||||
|
||||
future = _executor.submit(engine.predict, str(image_path))
|
||||
try:
|
||||
raw = future.result(timeout=timeout)
|
||||
except FutTimeout:
|
||||
future.cancel()
|
||||
logger.error("OCR 超时 ({:.1f}s): {}", timeout, image_path)
|
||||
raise OCRTimeout(f"OCR 识别超时 ({timeout}秒): {image_path}")
|
||||
except Exception as e:
|
||||
logger.exception("OCR 执行异常: {}", e)
|
||||
raise
|
||||
|
||||
return _normalize(raw)
|
||||
|
||||
|
||||
def warmup():
|
||||
"""预热:构造引擎 + 真跑一次空白图预测(避免首次请求超时)
|
||||
|
||||
跳过预测失败(比如没装 paddle)但不报错。
|
||||
"""
|
||||
try:
|
||||
engine = get_engine()
|
||||
except Exception as e:
|
||||
logger.warning("OCR 引擎初始化失败: {}", e)
|
||||
return
|
||||
|
||||
try:
|
||||
import numpy as np
|
||||
# 1x1 灰度图,足以触发模型完整链路
|
||||
dummy = np.zeros((64, 64, 3), dtype=np.uint8)
|
||||
engine.predict(dummy)
|
||||
logger.info("OCR 预热完成(首次预测已跑)")
|
||||
except Exception as e:
|
||||
logger.warning("OCR 预测预热失败(不影响主流程): {}", e)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""PDF → 图片 (使用 PyMuPDF,无需 poppler 系统依赖)"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
import fitz # PyMuPDF
|
||||
from loguru import logger
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def pdf_to_images(pdf_path: str | Path, dpi: int | None = None) -> List[Path]:
|
||||
"""把 PDF 每页渲染成 PNG,返回文件路径列表"""
|
||||
dpi = dpi or settings.pdf_dpi
|
||||
pdf_path = Path(pdf_path)
|
||||
out_dir = pdf_path.parent / f".{pdf_path.stem}_pages"
|
||||
out_dir.mkdir(exist_ok=True)
|
||||
|
||||
doc = fitz.open(str(pdf_path))
|
||||
saved: List[Path] = []
|
||||
try:
|
||||
# 1.0 = 72 DPI 的 zoom 系数
|
||||
zoom = dpi / 72.0
|
||||
mat = fitz.Matrix(zoom, zoom)
|
||||
for idx, page in enumerate(doc, start=1):
|
||||
pix = page.get_pixmap(matrix=mat, alpha=False)
|
||||
out_path = out_dir / f"page_{idx:03d}.png"
|
||||
pix.save(str(out_path))
|
||||
saved.append(out_path)
|
||||
finally:
|
||||
doc.close()
|
||||
|
||||
logger.info("PDF 转图片: {} → {} 页 (dpi={})", pdf_path.name, len(saved), dpi)
|
||||
return saved
|
||||
Reference in New Issue
Block a user