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:
郭庆泰
2026-08-22 00:23:22 +08:00
parent edfaf4e7f5
commit c3eb8ed9c3
88 changed files with 6710 additions and 240 deletions
+2
View File
@@ -0,0 +1,2 @@
"""ry-ocr: 本地发票识别服务 (PaddleOCR + FastAPI)"""
__version__ = "0.1.0"
+1
View File
@@ -0,0 +1 @@
"""API 层"""
+125
View File
@@ -0,0 +1,125 @@
"""FastAPI 路由"""
from __future__ import annotations
import platform
from pathlib import Path
from typing import List
from fastapi import APIRouter, File, HTTPException, UploadFile
from app import __version__
from app.config import settings
from app.core import get_engine
from app.models import HealthResponse, InvoiceResult, PathRecognizeRequest
from app.services.recognize_service import recognize_file, recognize_path
router = APIRouter()
# ---------- 路径白名单 ----------
def _parse_allowed_dirs() -> List[Path]:
"""解析 ALLOWED_DIRS 配置为绝对路径列表"""
if not settings.allowed_dirs.strip():
return []
sep = ";" if platform.system() == "Windows" else ":"
roots: List[Path] = []
for raw in settings.allowed_dirs.split(sep):
raw = raw.strip().strip('"').strip("'")
if not raw:
continue
try:
p = Path(raw).resolve()
if p.is_dir():
roots.append(p)
except Exception:
pass
return roots
def _check_path_allowed(file_path: Path) -> None:
"""校验路径在白名单内(路径遍历攻击防护)
resolve 后必须是某个 allowed_dir 的子路径。
"""
roots = _parse_allowed_dirs()
if not roots:
raise HTTPException(
status_code=403,
detail="路径接口未启用:在 .env 配置 ALLOWED_DIRS 后重启服务",
)
try:
abs_path = file_path.resolve()
except Exception as e:
raise HTTPException(status_code=400, detail=f"路径无效: {e}")
for root in roots:
try:
abs_path.relative_to(root)
return
except ValueError:
continue
raise HTTPException(
status_code=403,
detail=f"路径不在白名单内(允许: {', '.join(str(r) for r in roots)}",
)
# ---------- 路由 ----------
@router.get("/health", response_model=HealthResponse, summary="健康检查")
def health():
engine_ok = True
try:
get_engine()
except Exception:
engine_ok = False
return HealthResponse(
status="ok" if engine_ok else "degraded",
version=__version__,
engine_ready=engine_ok,
)
@router.post("/recognize/invoice", response_model=InvoiceResult, summary="识别发票(上传文件)")
async def recognize_invoice(file: UploadFile = File(..., description="发票图片或 PDF")):
"""识别发票并返回结构化字段
支持:PNG/JPG/JPEG/BMP/WEBP/TIFF/PDF
"""
content = await file.read()
max_bytes = settings.max_upload_mb * 1024 * 1024
if len(content) > max_bytes:
raise HTTPException(status_code=413, detail=f"文件超过 {settings.max_upload_mb}MB 限制")
if not content:
raise HTTPException(status_code=400, detail="文件为空")
return recognize_file(file.filename or "unknown", content)
@router.post("/recognize/invoice/by-path", response_model=InvoiceResult, summary="识别发票(服务器本地路径)")
def recognize_invoice_by_path(req: PathRecognizeRequest):
"""传入服务器本地路径识别发票(避免重复上传大文件)
**安全**:路径必须在 .env 的 ALLOWED_DIRS 白名单内才会被执行。
防止任意文件读取 / 路径遍历攻击。
"""
p = Path(req.file_path)
_check_path_allowed(p)
if not p.exists():
raise HTTPException(status_code=404, detail=f"文件不存在: {p}")
if not p.is_file():
raise HTTPException(status_code=400, detail=f"不是文件: {p}")
return recognize_path(p, delete_after=False)
@router.post("/recognize/text", summary="仅做字段抽取(不上传文件)")
def recognize_text(raw_text: str):
"""对已有的 OCR 文本做字段抽取(便于接入其他 OCR 引擎)"""
from app.services import extract_invoice
fields = extract_invoice(raw_text)
return {"fields": fields}
+48
View File
@@ -0,0 +1,48 @@
"""应用配置(从环境变量 / .env 读取)"""
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
# 服务
app_host: str = "0.0.0.0"
app_port: int = 8801
# OCR
use_gpu: bool = False
ocr_lang: str = "ch"
# 模型: "mobile" (CPU 友好, 默认) / "server" (高精度, GPU 适用)
ocr_engine: str = "mobile"
# 上传 / PDF
max_upload_mb: int = 20
# 默认 150 DPI:清晰数字 PDF 150 已够,扫描件需调到 250~300
pdf_dpi: int = 150
# 路径接口允许的根目录(逗号分隔)。空 = 禁用路径接口
# 示例: "E:\\gitee\\guoju-hegui;D:\\uploads"
allowed_dirs: str = ""
# 识别超时(秒)- 单页 OCR / 整流程任一超时即返回失败
# CPU mobile 模型单页约 4 秒, 设 15s 留余量
ocr_page_timeout_s: int = 15
ocr_total_timeout_s: int = 60
# QR 识别:
# true = 扫到 QR 后仍跑全量 OCR + 字段抽取 (默认, 向后兼容)
# false = 扫到 QR 后直接返回 QR 里的 3 个核心字段 (开票时间/发票号/金额), 跳过 OCR
# 没扫到 QR 或 QR 格式不合法时一律回退到 OCR 流水线, 不受此开关影响
qr_full_ocr: bool = True
# 日志
log_level: str = "INFO"
model_config = SettingsConfigDict(
env_file=str(Path(__file__).resolve().parent.parent / ".env"),
env_file_encoding="utf-8",
extra="ignore",
)
settings = Settings()
+6
View File
@@ -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"]
+47
View File
@@ -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
+146
View File
@@ -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)
+35
View File
@@ -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
+56
View File
@@ -0,0 +1,56 @@
"""FastAPI 入口"""
from __future__ import annotations
import sys
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from loguru import logger
from app import __version__
from app.api.routes import router
from app.config import settings
from app.core import warmup
def _setup_logging():
logger.remove()
logger.add(
sys.stdout,
level=settings.log_level,
format="<g>{time:HH:mm:ss}</g> | {level:<7} | {message}",
colorize=True,
)
def create_app() -> FastAPI:
_setup_logging()
app = FastAPI(
title="ry-ocr",
description="本地发票识别服务(PaddleOCR + FastAPI",
version=__version__,
docs_url="/docs",
redoc_url="/redoc",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(router)
@app.on_event("startup")
def _startup():
logger.info("ry-ocr v{} 启动中...", __version__)
# 启动预热 OCR(首次 predict 较慢,提前触发)
warmup()
logger.info("服务已就绪: http://{}:{}", settings.app_host, settings.app_port)
return app
app = create_app()
+4
View File
@@ -0,0 +1,4 @@
"""数据模型"""
from .schemas import OCRLine, InvoiceFields, InvoiceResult, HealthResponse, PathRecognizeRequest
__all__ = ["OCRLine", "InvoiceFields", "InvoiceResult", "HealthResponse", "PathRecognizeRequest"]
+71
View File
@@ -0,0 +1,71 @@
"""接口协议 - Pydantic v2"""
from typing import List, Optional
from pydantic import BaseModel, Field
# ===== 单行 OCR 结果 =====
class OCRLine(BaseModel):
"""OCR 识别到的一行文字"""
text: str = Field(..., description="识别文本")
confidence: float = Field(..., ge=0, le=1, description="置信度 0~1")
box: List[List[float]] = Field(default_factory=list, description="四点坐标 [[x1,y1],...]")
# ===== 发票字段 =====
class InvoiceFields(BaseModel):
"""结构化发票字段"""
# 基本信息
invoice_type: Optional[str] = Field(None, description="发票类型,如 增值税电子普通发票")
invoice_no: Optional[str] = Field(None, description="发票号码")
invoice_code: Optional[str] = Field(None, description="发票代码")
invoice_date: Optional[str] = Field(None, description="开票日期 YYYY-MM-DD")
# 金额
amount: Optional[float] = Field(None, description="价税合计(小写)")
amount_cn: Optional[str] = Field(None, description="价税合计(大写中文)")
amount_pretax: Optional[float] = Field(None, description="不含税金额")
tax_amount: Optional[float] = Field(None, description="税额")
# 主体
seller_name: Optional[str] = Field(None, description="销售方名称")
seller_tax_no: Optional[str] = Field(None, description="销售方纳税人识别号")
buyer_name: Optional[str] = Field(None, description="购买方名称")
buyer_tax_no: Optional[str] = Field(None, description="购买方纳税人识别号")
# 校验
amount_match: Optional[bool] = Field(None, description="大写金额与小数金额是否一致")
# ===== 响应 =====
class InvoiceResult(BaseModel):
"""发票识别总响应"""
success: bool = Field(..., description="是否识别成功")
is_invoice: bool = Field(True, description="是否被判定为发票(false = 非发票图片)")
raw_text: str = Field("", description="全部 OCR 文本拼接")
lines: List[OCRLine] = Field(default_factory=list, description="分行识别结果")
fields: InvoiceFields = Field(default_factory=InvoiceFields, description="抽取的结构化字段")
page_count: int = Field(1, description="PDF 页数 / 图片=1")
engine: str = Field("paddleocr", description="使用的 OCR 引擎")
elapsed_ms: int = Field(0, description="识别耗时(毫秒)")
error: Optional[str] = Field(None, description="失败原因")
error_code: Optional[str] = Field(None, description="错误码: not_invoice / timeout / unsupported / ocr_failed")
# QR 识别相关
from_qr: bool = Field(False, description="是否从二维码取到了 3 个核心字段 (开票时间/发票号/金额)")
qr_raw: Optional[str] = Field(None, description="二维码原始文本 (用于排查)")
qr_error: Optional[str] = Field(None, description="二维码识别失败原因 (no_qr / bad_format)")
class HealthResponse(BaseModel):
status: str = "ok"
version: str
engine_ready: bool
class PathRecognizeRequest(BaseModel):
"""按文件路径识别的请求体"""
file_path: str = Field(..., description="服务器本地绝对路径", examples=["E:/invoice/abc.pdf"])
+10
View File
@@ -0,0 +1,10 @@
"""业务服务"""
from .invoice_extractor import extract as extract_invoice
from .qr_decoder import decode_qr, QRDecodeResult, has_any_field as qr_has_any_field
__all__ = [
"extract_invoice",
"decode_qr",
"QRDecodeResult",
"qr_has_any_field",
]
+299
View File
@@ -0,0 +1,299 @@
"""从 OCR 文本/行里抽取发票字段
适配中国大陆 增值税发票(电子普票 / 专票 / 电子专票 / 数电票)
关键策略:
- 主体(销售方/购买方)按 OCR box **坐标**判断归属(左右两栏)
- 名称提取加 stop word,避免 OCR 单行文本混淆
"""
from __future__ import annotations
import re
from typing import List, Optional, Tuple
from app.models import InvoiceFields, OCRLine
from app.utils import (
amount_consistent,
extract_cn_amount,
extract_num_amount,
extract_pretax_amount,
extract_tax_amount,
extract_total_amount,
)
# ---------- 发票类型 ----------
_INVOICE_TYPES = [
"增值税电子专用发票",
"增值税电子普通发票",
"增值税专用发票",
"增值税普通发票",
"通用机打发票",
"数电票",
"电子发票",
]
# ---------- 发票号码 ----------
_NO_PATTERN = re.compile(
r"(?:发\s*票\s*号\s*码|号\s*码|No\.?|号)\s*[:]?\s*(\d{8,20})",
re.IGNORECASE,
)
# ---------- 发票代码 ----------
_CODE_PATTERN = re.compile(
r"(?:发\s*票\s*代\s*码|代\s*码)\s*[:]?\s*(\d{10,12}|\d{8,12})",
)
# ---------- 开票日期 ----------
_DATE_PATTERN = re.compile(
r"(?:开\s*票\s*日\s*期|日\s*期)\s*[:]?\s*"
r"(\d{4})\s*[年/\.]\s*(\d{1,2})\s*[月/\.]\s*(\d{1,2})",
)
# ---------- 纳税人识别号(必须至少含 1 个字母,排除纯数字发票号)----------
_TAX_NO_PATTERN = re.compile(r"((?=[0-9A-Z]*[A-Z])[0-9A-Z]{18})")
# ---------- 主体标签 ----------
_BUYER_LABEL = re.compile(r"\s*买\s*方\s*(?:信\s*息|名\s*称|)")
_SELLER_LABEL = re.compile(r"\s*售\s*方\s*(?:信\s*息|名\s*称|)")
# ---------- 名称(带 stop word 截断)----------
_NAME_STOP = r"(?:销售方|购买方|统一社会信用|纳税人|项目名称|规格型号|^单位$|^数量$|^单价$|^金额|^税率|^税额|备注|收款人|复核|开票人|价税合计|小写|大写)"
_NAME_PATTERN = re.compile(
rf"\s*称\s*[:]\s*"
rf"((?:(?!{_NAME_STOP})[^\n\r]){{2,60}}?(?:公司|商店|厂|店|部|中心|工作室))"
)
# ---------- 工具函数 ----------
def _norm(text: str) -> str:
return re.sub(r"\s+", " ", text or "").strip()
def _box_center(box) -> Tuple[float, float]:
"""box: [[x1,y1], [x2,y2], [x3,y3], [x4,y4]] → (cx, cy)"""
if not box or len(box) < 4:
return (0.0, 0.0)
xs = [p[0] for p in box]
ys = [p[1] for p in box]
return ((min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2)
def _detect_invoice_type(text: str) -> Optional[str]:
for t in _INVOICE_TYPES:
if t in text:
return t
return None
def _extract_invoice_no(text: str) -> Optional[str]:
m = _NO_PATTERN.search(text)
return m.group(1) if m else None
def _extract_invoice_code(text: str) -> Optional[str]:
m = _CODE_PATTERN.search(text)
return m.group(1) if m else None
def _extract_date(text: str) -> Optional[str]:
m = _DATE_PATTERN.search(text)
if not m:
return None
y, mo, d = m.groups()
return f"{int(y):04d}-{int(mo):02d}-{int(d):02d}"
def _clean_name(name: str) -> str:
name = re.sub(r"^[\s:,,。、]+", "", name)
name = name.split("纳税人")[0].split("统一社会")[0]
name = re.sub(r"[^一-龥A-Za-z0-9()()·\-]", "", name)
return name.rstrip(":;,,。、 ").strip()
def _extract_parties_from_lines(lines: List[OCRLine]) -> tuple:
"""基于 box 坐标的主体识别
1) 找到 "购买方信息"/"销售方信息" 标签所在位置(box 中心)
2) 把每个 "名称: xxx" 行按 x 坐标归属到对应栏
3) 兜底:左右栏无法区分时按 y 坐标(top/bottom
"""
# 找标签坐标
buyer_label_box = None
seller_label_box = None
for line in lines:
if _BUYER_LABEL.search(line.text) and not buyer_label_box:
buyer_label_box = line.box
if _SELLER_LABEL.search(line.text) and not seller_label_box:
seller_label_box = line.box
by_x = buyer_label_box is not None and seller_label_box is not None and \
abs(_box_center(buyer_label_box)[0] - _box_center(seller_label_box)[0]) > 50
# 找所有 "名称: ..." 行的 box
name_lines = []
for line in lines:
m = _NAME_PATTERN.search(line.text)
if m:
name_lines.append((line, _clean_name(m.group(1))))
# 找所有税号
tax_lines = []
for line in lines:
m = _TAX_NO_PATTERN.search(line.text)
if m:
tax_lines.append((line, m.group(1)))
seller_name = buyer_name = None
seller_tax = buyer_tax = None
if by_x and buyer_label_box and seller_label_box:
# 左右栏布局:用 x 坐标归属
bx, _ = _box_center(buyer_label_box)
sx, _ = _box_center(seller_label_box)
mid = (bx + sx) / 2
for line, name in name_lines:
cx, _ = _box_center(line.box)
if cx < mid and not buyer_name:
buyer_name = name
elif cx >= mid and not seller_name:
seller_name = name
for line, tax in tax_lines:
cx, _ = _box_center(line.box)
if cx < mid and not buyer_tax:
buyer_tax = tax
elif cx >= mid and not seller_tax:
seller_tax = tax
else:
# 上下栏 / 单栏:按 y 坐标(第一个 = 购买方)
name_lines.sort(key=lambda x: (_box_center(x[0].box)[1], _box_center(x[0].box)[0]))
tax_lines.sort(key=lambda x: (_box_center(x[0].box)[1], _box_center(x[0].box)[0]))
if len(name_lines) >= 1:
buyer_name = name_lines[0][1]
if len(name_lines) >= 2:
seller_name = name_lines[1][1]
if len(tax_lines) >= 1:
buyer_tax = tax_lines[0][1]
if len(tax_lines) >= 2:
seller_tax = tax_lines[1][1]
return seller_name, seller_tax, buyer_name, buyer_tax
def _extract_parties_from_text(text: str) -> tuple:
"""无 box 信息时的 fallback:按显式标签分段"""
seller_name = buyer_name = None
seller_tax = buyer_tax = None
for m in _BUYER_LABEL.finditer(text):
chunk = text[m.end():m.end() + 200]
nm = _NAME_PATTERN.search(chunk)
tm = _TAX_NO_PATTERN.search(chunk[:100])
if nm and not buyer_name:
buyer_name = _clean_name(nm.group(1))
if tm and not buyer_tax:
buyer_tax = tm.group(1)
if buyer_name and buyer_tax:
break
for m in _SELLER_LABEL.finditer(text):
chunk = text[m.end():m.end() + 200]
nm = _NAME_PATTERN.search(chunk)
tm = _TAX_NO_PATTERN.search(chunk[:100])
if nm and not seller_name:
seller_name = _clean_name(nm.group(1))
if tm and not seller_tax:
seller_tax = tm.group(1)
if seller_name and seller_tax:
break
if not buyer_name:
name_positions = [(m.start(), m.group(1)) for m in _NAME_PATTERN.finditer(text)]
if len(name_positions) >= 1:
buyer_name = _clean_name(name_positions[0][1])
if len(name_positions) >= 2:
seller_name = _clean_name(name_positions[1][1])
tax_positions = _TAX_NO_PATTERN.findall(text)
if not buyer_tax and len(tax_positions) >= 1:
buyer_tax = tax_positions[0]
if not seller_tax and len(tax_positions) >= 2:
seller_tax = tax_positions[1]
return seller_name, seller_tax, buyer_name, buyer_tax
def extract(text: str, lines: Optional[List[OCRLine]] = None) -> InvoiceFields:
raw = _norm(text)
fields = InvoiceFields()
fields.invoice_type = _detect_invoice_type(raw)
fields.invoice_code = _extract_invoice_code(raw)
fields.invoice_no = _extract_invoice_no(raw)
fields.invoice_date = _extract_date(raw)
# 金额
total = extract_total_amount(raw)
tax = extract_tax_amount(raw)
pretax = extract_pretax_amount(raw)
# 兜底:从所有 .xx 数字中找满足 tax + pretax = total 的组合
if (tax is None or pretax is None) and total is not None:
candidates = sorted(
{float(x) for x in re.findall(r"(\d+\.\d{2})", raw) if float(x) < total},
reverse=True,
)
for i, a in enumerate(candidates):
for b in candidates[i + 1:]:
if abs(a + b - total) < 0.011:
if pretax is None:
pretax = round(a, 2)
if tax is None:
tax = round(b, 2)
break
if tax is not None and pretax is not None:
break
# 单数字兜底:如果 total 不在候选里,只剩一个候选时
if (tax is None or pretax is None) and len(candidates) == 1:
only = round(candidates[0], 2)
if pretax is None and tax is None:
pretax = only
tax = round(total - only, 2)
elif tax is None:
tax = only
elif pretax is None:
pretax = only
# 二次兜底:合计 - 任一 = 另一
if tax is None and total is not None and pretax is not None:
tax = round(total - pretax, 2)
if pretax is None and total is not None and tax is not None:
pretax = round(total - tax, 2)
fields.amount = total
fields.tax_amount = tax
fields.amount_pretax = pretax
fields.amount_cn = extract_cn_amount(raw)
fields.amount_match = amount_consistent(fields.amount_cn, fields.amount)
# 主体
if lines:
seller_name, seller_tax, buyer_name, buyer_tax = _extract_parties_from_lines(lines)
if not (seller_name and buyer_name):
sn, st, bn, bt = _extract_parties_from_text(raw)
seller_name = seller_name or sn
buyer_name = buyer_name or bn
seller_tax = seller_tax or st
buyer_tax = buyer_tax or bt
else:
seller_name, seller_tax, buyer_name, buyer_tax = _extract_parties_from_text(raw)
fields.seller_name = seller_name
fields.seller_tax_no = seller_tax
fields.buyer_name = buyer_name
fields.buyer_tax_no = buyer_tax
return fields
+138
View File
@@ -0,0 +1,138 @@
"""电子发票二维码识别
国家税务总局规范的电子发票二维码内容格式 (8 字段逗号分隔):
01,<type>,<invoice_code>,<invoice_no>,<amount>,<date>,<check_code>,<reserved>
例: 01,31,,24922000000006110014,39500.00,20240202,,A371
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, Tuple
import cv2
import numpy as np
from loguru import logger
@dataclass
class QRDecodeResult:
"""二维码识别结果"""
invoice_no: Optional[str] = None # 发票号码
amount: Optional[float] = None # 金额(小写)
invoice_date: Optional[str] = None # 开票日期 YYYY-MM-DD
raw: str = "" # 二维码原始文本
def _try_detect(img: np.ndarray, qd: cv2.QRCodeDetector) -> str:
"""opencv 单图识别 + 多二维码识别, 任一成功即返回文本"""
data, _, _ = qd.detectAndDecode(img)
if data:
return data
try:
retval, decoded_info, _, _ = qd.detectAndDecodeMulti(img)
if decoded_info:
# 取第一个非空的
for d in decoded_info:
if d:
return d
except Exception:
pass
return ""
def _detect_qr(image_path: Path) -> str:
"""从图片里解二维码; 全图 + 四象限各扫一遍"""
img = cv2.imread(str(image_path))
if img is None:
return ""
qd = cv2.QRCodeDetector()
# 1) 全图
txt = _try_detect(img, qd)
if txt:
return txt
# 2) 四象限 (二维码常在票面边角)
h, w = img.shape[:2]
for name, crop in [
("left-top", img[: h // 2, : w // 2]),
("right-top", img[: h // 2, w // 2 :]),
("left-bottom", img[h // 2 :, : w // 2]),
("right-bottom", img[h // 2 :, w // 2 :]),
]:
txt = _try_detect(crop, qd)
if txt:
logger.debug("QR found in {}", name)
return txt
# 3) 放大再试 (二维码像素过小的情况)
scaled = cv2.resize(img, None, fx=2.0, fy=2.0, interpolation=cv2.INTER_CUBIC)
return _try_detect(scaled, qd)
def _parse_qr_payload(raw: str) -> QRDecodeResult:
"""解析电子发票二维码内容 → QRDecodeResult
格式不合规返回空对象 (字段全 None), 由调用方判定为「格式不正确」并 fallback。
"""
if not raw:
return QRDecodeResult(raw=raw or "")
parts = raw.split(",")
if len(parts) != 8:
logger.debug("QR 字段数 {} != 8, 视为格式不正确", len(parts))
return QRDecodeResult(raw=raw)
result = QRDecodeResult(raw=raw)
# parts[3] = 发票号
invoice_no = parts[3].strip()
if invoice_no and (10 <= len(invoice_no) <= 30):
result.invoice_no = invoice_no
# parts[4] = 金额
amt_str = parts[4].strip()
if amt_str:
try:
result.amount = round(float(amt_str), 2)
except ValueError:
pass
# parts[5] = 开票日期 (YYYYMMDD)
date_str = parts[5].strip()
if len(date_str) == 8 and date_str.isdigit():
result.invoice_date = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:8]}"
return result
def decode_qr(image_path: Path) -> QRDecodeResult:
"""从图片文件解电子发票二维码
返回 QRDecodeResult; 字段全 None 表示无二维码或格式不正确。
调用方应据此判定 fallback。
"""
try:
raw = _detect_qr(image_path)
except Exception as e:
logger.warning("QR 检测异常 {}: {}", image_path.name, e)
return QRDecodeResult()
if not raw:
return QRDecodeResult()
parsed = _parse_qr_payload(raw)
# 任一关键字段解出即视为「格式正确」
if parsed.invoice_no or parsed.amount or parsed.invoice_date:
logger.info("QR 解码成功: no={}, amt={}, date={}",
parsed.invoice_no, parsed.amount, parsed.invoice_date)
else:
logger.debug("QR 解出但字段无效: raw={}", raw[:80])
return parsed
def has_any_field(qr: QRDecodeResult) -> bool:
"""QR 是否解出了至少一个核心字段"""
return bool(qr.invoice_no or qr.amount or qr.invoice_date)
+237
View File
@@ -0,0 +1,237 @@
"""端到端识别流水:文件 → QR → (可选)OCR → 字段抽取 → InvoiceResult
整体有 OCR_TOTAL_TIMEOUT_S 兜底;单页有 OCR_PAGE_TIMEOUT_S 兜底。
任一超时立即返回失败,不再死等。
QR 流程 (config: QR_FULL_OCR):
- 先扫二维码取 开票时间/发票号/金额 (国家税务总局 8 字段逗号规范)
- 扫到 + qr_full_ocr=false → 直接返回这 3 个字段 (快路径, 跳过 OCR)
- 扫到 + qr_full_ocr=true → 继续跑 OCR, QR 的 3 字段覆盖 OCR 抽取结果
- 没扫到 / 格式不合法 → 直接判定为非发票 (不再跑 OCR)
"""
from __future__ import annotations
import shutil
import tempfile
import time
from pathlib import Path
from typing import List
from loguru import logger
from app.config import settings
from app.core import OCRTimeout, auto_rotate, enhance, pdf_to_images, recognize
from app.models import InvoiceResult, InvoiceFields, OCRLine
from app.services import decode_qr, extract_invoice, qr_has_any_field
_IMG_EXTS = {".png", ".jpg", ".jpeg", ".bmp", ".webp", ".tif", ".tiff"}
def _save_upload(upload_bytes: bytes, suffix: str) -> Path:
tmp = Path(tempfile.mkstemp(suffix=suffix)[1])
tmp.write_bytes(upload_bytes)
return tmp
def _ocr_image(image_path: Path) -> List[OCRLine]:
img = auto_rotate(image_path)
img = enhance(img)
return recognize(img)
def _overlay_qr_fields(fields: InvoiceFields, qr) -> InvoiceFields:
"""QR 解出的 3 字段优先, 没解到的保持 OCR 结果"""
data = fields.model_dump()
if qr.invoice_no:
data["invoice_no"] = qr.invoice_no
if qr.amount is not None:
data["amount"] = qr.amount
if qr.invoice_date:
data["invoice_date"] = qr.invoice_date
return InvoiceFields(**data)
def _build_qr_only_result(qr, elapsed_ms: int, page_count: int) -> InvoiceResult:
"""快路径: 只用 QR 字段, 跳过 OCR"""
fields = InvoiceFields(
invoice_no=qr.invoice_no,
amount=qr.amount,
invoice_date=qr.invoice_date,
)
return InvoiceResult(
success=True,
is_invoice=True,
raw_text=f"[QR only] {qr.raw}",
lines=[],
fields=fields,
page_count=page_count,
engine="qr",
elapsed_ms=elapsed_ms,
from_qr=True,
qr_raw=qr.raw,
)
def recognize_file(filename: str, content: bytes) -> InvoiceResult:
"""识别入口(从字节流):自动判断 PDF / 图片;任何环节超时立即返回失败"""
t0 = time.time()
suffix = Path(filename).suffix.lower()
tmp_path = _save_upload(content, suffix or ".bin")
try:
return recognize_path(tmp_path, delete_after=True)
except Exception:
raise
def recognize_path(file_path: Path, *, delete_after: bool = False) -> InvoiceResult:
"""识别入口(从磁盘路径):不做上传字节校验,直接读本地文件
Args:
file_path: 已在白名单校验过的本地文件路径
delete_after: True 表示临时文件用完删除(上传流场景),False 保留(原文件场景)
"""
t0 = time.time()
suffix = file_path.suffix.lower()
total_deadline = t0 + settings.ocr_total_timeout_s
def _elapsed_ms() -> int:
return int((time.time() - t0) * 1000)
try:
# ---------- 1. PDF / 图片 → 图片列表 ----------
try:
if suffix == ".pdf":
page_imgs = pdf_to_images(file_path)
elif suffix in _IMG_EXTS:
page_imgs = [file_path]
else:
return InvoiceResult(
success=False,
is_invoice=False,
error=f"不支持的文件类型: {suffix}(仅支持 PDF / 图片)",
error_code="unsupported",
engine="paddleocr",
elapsed_ms=_elapsed_ms(),
)
except Exception as e:
logger.exception("PDF/图片处理失败: {}", e)
return InvoiceResult(
success=False,
is_invoice=False,
error=f"PDF/图片处理失败: {e}",
error_code="process_failed",
engine="paddleocr",
elapsed_ms=_elapsed_ms(),
)
# ---------- 2. QR 优先识别 ----------
qr = decode_qr(page_imgs[0])
qr_ok = qr_has_any_field(qr)
if qr_ok and not settings.qr_full_ocr:
# 快路径: 扫到 QR 且配置为 fast, 直接返回
logger.info("QR 快路径: {} 耗时 {}ms", file_path.name, _elapsed_ms())
return _build_qr_only_result(qr, _elapsed_ms(), len(page_imgs))
if not qr_ok:
# 没扫到 / 格式不合法 → 直接认为非发票, 不再跑 OCR
reason = "未识别到发票二维码" if not qr.raw else "二维码格式不合法"
logger.info("非发票 (无有效 QR): {} 耗时 {}ms", file_path.name, _elapsed_ms())
return InvoiceResult(
success=False,
is_invoice=False,
error=f"{reason}(可能不是发票图片)",
error_code="not_invoice",
page_count=len(page_imgs),
engine="paddleocr",
elapsed_ms=_elapsed_ms(),
from_qr=False,
qr_raw=qr.raw or None,
qr_error="no_qr" if not qr.raw else "bad_format",
)
# ---------- 3. 每页 OCR(单页超时 + 总超时双重保护)----------
# 到这里 qr_ok=True, OCR 仅为补充信息; QR 已保证是发票
all_lines: List[OCRLine] = []
for idx, img_path in enumerate(page_imgs, start=1):
remaining = total_deadline - time.time()
if remaining <= 0:
logger.warning("达到总超时 ({:.1f}s), 中断 OCR", settings.ocr_total_timeout_s)
return InvoiceResult(
success=False,
is_invoice=False,
error=f"达到总超时 ({settings.ocr_total_timeout_s}秒), 已识别 {idx-1}/{len(page_imgs)}",
error_code="timeout",
raw_text="\n".join(l.text for l in all_lines),
lines=all_lines,
page_count=len(page_imgs),
engine="paddleocr",
elapsed_ms=_elapsed_ms(),
from_qr=True,
qr_raw=qr.raw,
)
page_timeout = min(settings.ocr_page_timeout_s, remaining)
try:
all_lines.extend(_ocr_image(img_path))
except OCRTimeout as e:
logger.warning("{} 页 OCR 超时: {}", idx, e)
return InvoiceResult(
success=False,
is_invoice=False,
error=f"{idx} 页识别超时 ({page_timeout:.1f}秒)",
error_code="timeout",
raw_text="\n".join(l.text for l in all_lines),
lines=all_lines,
page_count=len(page_imgs),
engine="paddleocr",
elapsed_ms=_elapsed_ms(),
from_qr=True,
qr_raw=qr.raw,
)
except Exception as e:
logger.exception("{} 页 OCR 失败: {}", idx, e)
return InvoiceResult(
success=False,
is_invoice=False,
error=f"{idx} 页识别失败: {e}",
error_code="ocr_failed",
raw_text="\n".join(l.text for l in all_lines),
lines=all_lines,
page_count=len(page_imgs),
engine="paddleocr",
elapsed_ms=_elapsed_ms(),
from_qr=True,
qr_raw=qr.raw,
)
# ---------- 4. 字段抽取 + QR 字段覆盖 ----------
raw_text = "\n".join(l.text for l in all_lines)
fields = extract_invoice(raw_text, all_lines)
fields = _overlay_qr_fields(fields, qr)
elapsed = _elapsed_ms()
logger.info("识别完成: {} 页={}, from_qr=true, 字段数={}, 耗时={}ms",
file_path.name, len(page_imgs),
sum(1 for f in fields.model_dump().values() if f), elapsed)
return InvoiceResult(
success=True,
is_invoice=True,
raw_text=raw_text,
lines=all_lines,
fields=fields,
page_count=len(page_imgs),
engine="paddleocr",
elapsed_ms=elapsed,
from_qr=True,
qr_raw=qr.raw,
)
finally:
if delete_after:
try:
file_path.unlink(missing_ok=True)
if suffix == ".pdf":
shutil.rmtree(file_path.parent / f".{file_path.stem}_pages", ignore_errors=True)
except Exception:
pass
+20
View File
@@ -0,0 +1,20 @@
"""工具"""
from .amount_utils import (
amount_consistent,
extract_cn_amount,
extract_num_amount,
extract_pretax_amount,
extract_tax_amount,
extract_total_amount,
parse_cn_amount,
)
__all__ = [
"amount_consistent",
"extract_cn_amount",
"extract_num_amount",
"extract_pretax_amount",
"extract_tax_amount",
"extract_total_amount",
"parse_cn_amount",
]
+117
View File
@@ -0,0 +1,117 @@
"""金额工具:中文大写金额解析 + 与小写金额比对"""
from __future__ import annotations
import re
from typing import Optional
import cn2an
# 小写金额正则
_NUM_PATTERN = re.compile(r"¥?\s*(\d{1,8}(?:,\d{3})*\.\d{2})")
_TAX_AMOUNT_PATTERN = re.compile(r"\s*额\s*[¥:]?\s*(\d{1,8}(?:,\d{3})*\.\d{2})")
_PRETAX_PATTERN = re.compile(r"(?:不合?税价|不含税)\s*[¥:]?\s*(\d{1,8}(?:,\d{3})*\.\d{2})")
_TOTAL_PATTERN = re.compile(r"价税合计[^\d]*[¥]?\s*(\d{1,8}(?:,\d{3})*\.\d{2})")
def normalize_cn_amount(text: str) -> str:
"""中文金额归一化:圆→元、〇→零"""
if not text:
return ""
return text.replace("", "").replace("", "").replace(" ", "")
def extract_cn_amount(text: str) -> Optional[str]:
"""从原文里匹配出第一段大写金额字符串
策略(按优先级):
1. "价税合计" 后面括号内
2. 任意中括号里的中文金额
3. 含""""的最长中文字符串
"""
if not text:
return None
# 1. 价税合计之后括号里
m = re.search(r"价税合计[^\(]*[\((]([零壹贰叁肆伍陆柒捌玖拾佰仟万亿圆元角分整]+)[\)]", text)
if m:
return m.group(1)
# 2. 任意中括号里的中文金额
m = re.search(r"[\((]([零壹贰叁肆伍陆柒捌玖拾佰仟万亿圆元角分整]{3,30})[\)]", text)
if m:
return m.group(1)
# 3. 含"元"/"圆"的中文片段
for cand in re.findall(r"[零壹贰叁肆伍陆柒捌玖拾佰仟万亿圆元角分整]{3,30}", text):
if "" in cand or "" in cand:
return cand
return None
def parse_cn_amount(cn_text: str) -> Optional[float]:
"""把中文大写金额转 float,例如 '贰佰元整' → 200.0"""
if not cn_text:
return None
try:
s = normalize_cn_amount(cn_text)
# cn2an 要求带 '元' 或 '圆' 结尾
s = s.rstrip("")
if not s.endswith(""):
s += ""
value = cn2an.cn2an(s, "smart")
return float(value)
except Exception:
return None
def amount_consistent(cn_text: Optional[str], num_amount: Optional[float]) -> Optional[bool]:
"""大写 vs 小写金额比对"""
if cn_text is None or num_amount is None:
return None
cn_value = parse_cn_amount(cn_text)
if cn_value is None:
return None
return abs(cn_value - num_amount) < 0.011
def extract_num_amount(text: str) -> Optional[float]:
"""从文本里提取第一个形如 1234.56 或 ¥1,234.56 的金额"""
if not text:
return None
m = _NUM_PATTERN.search(text)
if not m:
return None
try:
return float(m.group(1).replace(",", ""))
except ValueError:
return None
def extract_total_amount(text: str) -> Optional[float]:
m = _TOTAL_PATTERN.search(text)
if m:
try:
return float(m.group(1).replace(",", ""))
except ValueError:
pass
return extract_num_amount(text)
def extract_tax_amount(text: str) -> Optional[float]:
m = _TAX_AMOUNT_PATTERN.search(text)
if m:
try:
return float(m.group(1).replace(",", ""))
except ValueError:
pass
return None
def extract_pretax_amount(text: str) -> Optional[float]:
m = _PRETAX_PATTERN.search(text)
if m:
try:
return float(m.group(1).replace(",", ""))
except ValueError:
pass
return None