Files
guoju0808/ry-ocr/app/core/ocr_engine.py
T
郭庆泰 c3eb8ed9c3 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: 文档
2026-08-22 00:23:22 +08:00

146 lines
4.7 KiB
Python

"""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)