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
+17
View File
@@ -0,0 +1,17 @@
# 不打包进镜像的内容
__pycache__
*.py[cod]
.venv
venv
env
.env
*.log
uploads/
test_files/
tests/
.git
.gitignore
*.md
.idea
.vscode
client/
+38
View File
@@ -0,0 +1,38 @@
# ===== OCR 服务配置 =====
# 服务端口
APP_HOST=0.0.0.0
APP_PORT=8801
# 是否使用 GPUtrue/false
USE_GPU=false
# OCR 语言(ch / en / chinese_cht
OCR_LANG=ch
# OCR 模型: "mobile" (CPU 友好, 默认) / "server" (高精度, 需 GPU)
OCR_ENGINE=mobile
# 单文件最大体积(MB
MAX_UPLOAD_MB=20
# PDF DPI(清晰数字 PDF 150 够用,扫描件建议 250~300
PDF_DPI=150
# 路径接口允许的根目录(Windows 用分号;Linux 用冒号:分隔多个),空 = 禁用
# 安全考虑:默认空,需显式开启
# ALLOWED_DIRS=E:\gitee\guoju-hegui;D:\uploads
# 单页 OCR 识别超时(秒)- CPU mobile 约 4 秒, 建议 15
OCR_PAGE_TIMEOUT_S=15
# 整流程超时(秒) - 包括 PDF 转图 + OCR + 字段抽取
OCR_TOTAL_TIMEOUT_S=60
# QR 识别:
# true = 扫到 QR 后仍跑全量 OCR + 字段抽取 (向后兼容)
# false = 扫到 QR 后直接返回 QR 里的 3 个核心字段 (开票时间/发票号/金额), 跳过 OCR
# 没扫到 QR 或 QR 格式不合法时一律回退到 OCR 流水线
QR_FULL_OCR=true
# 日志级别(DEBUG/INFO/WARNING
LOG_LEVEL=INFO
+36
View File
@@ -0,0 +1,36 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
.venv/
venv/
env/
.pytest_cache/
# 配置
.env
.env.local
# 日志
*.log
logs/
# 上传 / 测试图片
uploads/
test_files/
*.pdf
*.jpg
*.jpeg
*.png
!docs/**/*.png
# IDE
.idea/
.vscode/
*.swp
# 系统
.DS_Store
Thumbs.db
+30
View File
@@ -0,0 +1,30 @@
FROM python:3.11-slim
# 系统依赖:libgl/opencvPyMuPDF 自带 PDF 渲染,无需 poppler
RUN apt-get update && apt-get install -y --no-install-recommends \
libgl1 \
libglib2.0-0 \
libsm6 \
libxext6 \
libxrender1 \
curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# 先装依赖(缓存层)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt \
&& pip install --no-cache-dir paddlepaddle==3.0.0 paddleocr==3.0.1
# 再拷代码
COPY . .
# 模型预热(首次构建会下载模型到 /root/.paddleocr
RUN python -c "from app.core import warmup; warmup()" || true
EXPOSE 8801
ENV PYTHONUNBUFFERED=1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8801", "--workers", "1"]
+422
View File
@@ -0,0 +1,422 @@
# ry-ocr — 本地发票识别服务 API 文档
基于 **PaddleOCR 3.x + FastAPI** 的本地部署发票识别微服务。
完全离线运行,无任何云依赖,适合内网 / 等保环境。
服务默认监听 `0.0.0.0:8801`,在线文档:`http://localhost:8801/docs`
---
## 0. TL;DR
| 接口 | 用途 | 鉴权 |
|---|---|---|
| `GET /health` | 健康检查 | 无 |
| `POST /recognize/invoice` | 上传文件识别 (multipart) | 无 |
| `POST /recognize/invoice/by-path` | 服务器本地路径识别 (JSON) | 白名单 |
| `POST /recognize/text` | 纯文本字段抽取 (跳过 OCR) | 无 |
**识别流程**(默认 `QR_FULL_OCR=true`):
```
文件 → PDF/图片 → 扫 QR (opencv) → 解出 3 字段?
├─ 是 + fast mode → 直接返回 (engine="qr", 跳过 OCR)
├─ 是 + full mode → 继续 OCR + 抽取, QR 字段覆盖 OCR 结果
└─ 否 / 格式不合法 → 直接 not_invoice, 不跑 OCR
```
---
## 1. 快速启动
### A. 本地 Python
```bash
pip install -r requirements.txt
cp .env.example .env
python run.py # → http://127.0.0.1:8801
```
### B. Docker
```bash
docker-compose up -d
curl http://localhost:8801/health
```
首次启动会下载模型到 `/root/.paddleocr`(约 100MB),`docker-compose.yml` 已挂载 volume 持久化。
---
## 2. 配置项 (`.env`)
| 变量 | 默认 | 说明 |
|---|---|---|
| `APP_HOST` | `0.0.0.0` | 监听地址 |
| `APP_PORT` | `8801` | 监听端口 |
| `USE_GPU` | `false` | 是否使用 GPU |
| `OCR_LANG` | `ch` | OCR 语言 (ch/en/chinese_cht) |
| `OCR_ENGINE` | `mobile` | `mobile`=CPU 友好 / `server`=高精度需 GPU |
| `MAX_UPLOAD_MB` | `20` | 上传接口单文件最大体积 |
| `PDF_DPI` | `150` | PDF 转图片 DPI (扫描件建议 250~300) |
| `ALLOWED_DIRS` | (空) | `by-path` 接口允许的根目录, 空=禁用 |
| `OCR_PAGE_TIMEOUT_S` | `15` | 单页 OCR 超时 |
| `OCR_TOTAL_TIMEOUT_S` | `60` | 整流程超时 |
| **`QR_FULL_OCR`** | **`true`** | QR 命中后是否继续跑全量 OCR |
| `LOG_LEVEL` | `INFO` | 日志级别 |
**`QR_FULL_OCR` 双模式:**
| 取值 | 行为 |
|---|---|
| `true` | QR 命中 → 12 字段全抽取 (QR 3 字段覆盖 OCR) ← 默认 |
| `false` | QR 命中 → 仅返回 3 字段, 跳过 OCR (从 4.5s 降到 0.2s) |
`ALLOWED_DIRS` 格式:
- Windows(分号分隔):`ALLOWED_DIRS=E:\gitee\guoju-hegui;D:\uploads`
- Linux(冒号分隔):`ALLOWED_DIRS=/data/invoices:/tmp/uploads`
---
## 3. 接口详解
### 3.1 `GET /health`
健康检查。检查 PaddleOCR 引擎是否就绪。
**响应 200**
```json
{
"status": "ok",
"version": "0.1.0",
"engine_ready": true
}
```
`engine_ready=false` → 服务降级但仍能响应,建议先排查 OCR 模型加载问题。
---
### 3.2 `POST /recognize/invoice`
multipart/form-data 上传发票图片或 PDF。
**请求:**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `file` | file | ✅ | 图片 (PNG/JPG/JPEG/BMP/WEBP/TIFF) 或 PDF |
**curl**
```bash
curl -X POST http://localhost:8801/recognize/invoice \
-F "file=@/path/to/invoice.pdf"
```
**错误码:**
| HTTP | 场景 |
|---|---|
| 400 | 文件为空 |
| 413 | 文件超过 `MAX_UPLOAD_MB` |
| 422 | 缺少 file 字段 |
**响应 (`InvoiceResult`) — 见 §4。**
---
### 3.3 `POST /recognize/invoice/by-path`
传入**服务器本地路径**识别,避免重复上传大文件。
> ⚠️ **安全**:路径必须在 `.env` 的 `ALLOWED_DIRS` 白名单内才会被执行。
> resolve 后必须等于或为某个允许根目录的后代;否则 403。
> `ALLOWED_DIRS` 为空时整个接口 403(默认禁用)。
**请求体 (`PathRecognizeRequest`)**
```json
{
"file_path": "E:/invoice/abc.pdf"
}
```
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `file_path` | string | ✅ | 服务器本地绝对路径(正反斜杠均可) |
**curl**
```bash
curl -X POST http://localhost:8801/recognize/invoice/by-path \
-H "Content-Type: application/json" \
-d '{"file_path": "E:/gitee/guoju-hegui/guoju0808/ry-ocr/fapiao.pdf"}'
```
**错误码:**
| HTTP | 场景 |
|---|---|
| 403 | 路径不在 `ALLOWED_DIRS` 白名单, 或 `ALLOWED_DIRS` 未配置 |
| 404 | 文件不存在 |
| 400 | 不是文件 (路径是目录) |
---
### 3.4 `POST /recognize/text`
纯文本字段抽取,**不调用 OCR**。便于接入其他识别引擎(百度/腾讯/扫描件 OCR SDK 等)。
**Query 参数:**
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `raw_text` | string | ✅ | OCR 原始文本 (多行用 `\n` 分隔) |
**curl**
```bash
curl -X POST 'http://localhost:8801/recognize/text?raw_text=电子发票%0A发票号码:24922000000006110014%0A价税合计(大写)叁万玖仟伍佰圆整%0A(小写)%EF%BF%A539500.00'
```
**响应:** `{"fields": {...InvoiceFields}}`
---
## 4. 响应模型
### 4.1 `InvoiceResult` (主响应)
| 字段 | 类型 | 说明 |
|---|---|---|
| `success` | bool | 整体是否成功 |
| `is_invoice` | bool | 是否被判定为发票 (false=非发票) |
| `raw_text` | string | 全部 OCR 文本拼接 (快路径为 `[QR only] ...`) |
| `lines` | OCRLine[] | 分行识别结果 |
| `fields` | InvoiceFields | 结构化字段 |
| `page_count` | int | PDF 页数 / 图片=1 |
| `engine` | string | `paddleocr` / `qr` |
| `elapsed_ms` | int | 服务端识别耗时 (毫秒) |
| `error` | string? | 失败原因描述 |
| `error_code` | string? | 见 §4.4 错误码表 |
| `from_qr` | bool | 是否从 QR 取到了 3 个核心字段 |
| `qr_raw` | string? | 二维码原始文本 (排查用) |
| `qr_error` | string? | `no_qr` / `bad_format` |
### 4.2 `InvoiceFields` (fields 子对象)
| 字段 | 类型 | 来源 |
|---|---|---|
| `invoice_type` | string? | OCR: "电子发票"/"增值税专用发票"等 |
| `invoice_no` | string? | **QR (权威)** / OCR |
| `invoice_code` | string? | OCR (数电票此字段为空) |
| `invoice_date` | string (YYYY-MM-DD) | **QR (权威)** / OCR |
| `amount` | float? | **QR (权威)** / OCR — 价税合计小写 |
| `amount_cn` | string? | OCR — 价税合计大写 |
| `amount_pretax` | float? | OCR — 不含税金额 |
| `tax_amount` | float? | OCR — 税额 |
| `seller_name` | string? | OCR |
| `seller_tax_no` | string? | OCR |
| `buyer_name` | string? | OCR |
| `buyer_tax_no` | string? | OCR |
| `amount_match` | bool? | 大写金额 vs 小写金额一致性 |
**QR (权威)** 的字段:当 QR 命中时,无论 OCR 结果如何,最终值取 QR。
### 4.3 `OCRLine`
```json
{
"text": "发票号码:24922000000006110014",
"confidence": 0.998,
"box": [[915, 67], [1191, 67], [1191, 83], [915, 83]]
}
```
### 4.4 `error_code` 表
| 取值 | 含义 | 触发场景 |
|---|---|---|
| `not_invoice` | 非发票 | QR 没扫到 / 格式不合法 |
| `unsupported` | 不支持的文件类型 | 后缀不是 PDF/图片 |
| `process_failed` | 处理失败 | PDF 渲染异常等 |
| `timeout` | 超时 | 达到单页/总流程超时 |
| `ocr_failed` | OCR 异常 | PaddleOCR 内部错误 |
---
## 5. 完整示例
### 5.1 真发票 PDF(默认模式 → 12 字段)
**请求:** `POST /recognize/invoice/by-path` body=`{"file_path":"E:/.../fapiao.pdf"}`
**响应:**
```json
{
"success": true,
"is_invoice": true,
"raw_text": "电子发票\n(电子发票)\n发票号码:24922000000006110014\n...",
"lines": [...37 ],
"fields": {
"invoice_type": "电子发票",
"invoice_no": "24922000000006110014",
"invoice_code": null,
"invoice_date": "2024-02-02",
"amount": 39500.0,
"amount_cn": "叁万玖仟伍佰圆整",
"amount_pretax": 37264.15,
"tax_amount": 2235.85,
"seller_name": "青岛鸿图华构信息技术有限公司",
"seller_tax_no": "91370222MA3N7N3Y1H",
"buyer_name": "北京国钜科技实业股份有限公司",
"buyer_tax_no": "91110108MA01EMTK2E",
"amount_match": true
},
"page_count": 1,
"engine": "paddleocr",
"elapsed_ms": 4516,
"from_qr": true,
"qr_raw": "01,31,,24922000000006110014,39500.00,20240202,,A371",
"qr_error": null
}
```
### 5.2 真发票 PDF(快路径 `QR_FULL_OCR=false` → 仅 3 字段)
```json
{
"success": true,
"is_invoice": true,
"raw_text": "[QR only] 01,31,,24922000000006110014,39500.00,20240202,,A371",
"lines": [],
"fields": {
"invoice_no": "24922000000006110014",
"amount": 39500.0,
"invoice_date": "2024-02-02"
},
"page_count": 1,
"engine": "qr",
"elapsed_ms": 209,
"from_qr": true,
"qr_raw": "01,31,,24922000000006110014,39500.00,20240202,,A371",
"qr_error": null
}
```
### 5.3 非发票图片(无 QR
```json
{
"success": false,
"is_invoice": false,
"error": "未识别到发票二维码(可能不是发票图片)",
"error_code": "not_invoice",
"raw_text": "",
"lines": [],
"fields": {},
"page_count": 1,
"engine": "paddleocr",
"elapsed_ms": 220,
"from_qr": false,
"qr_raw": null,
"qr_error": "no_qr"
}
```
---
## 6. 性能基线 (PP-OCRv5_mobile, CPU)
| 场景 | HTTP 耗时 | 服务端 OCR | 备注 |
|---|---|---|---|
| 真发票 PDF (默认) | 4.5s | 4516ms | 含 1500ms PDF 渲染 |
| 真发票 PDF (快路径) | **0.22s** | 209ms | QR 解出即返回 |
| 非发票图片 | **0.22s** | 220ms | QR 没扫到, 不跑 OCR |
| 非发票文字截图 | **0.38s** | 377ms | 同上 |
PDF 转图 DPI=150OCR 移动端模型单页约 1.5~4s,**首请求**因模型预热会更慢。
---
## 7. Java 客户端 (RuoYi)
`client/` 目录下:
- `OcrClient.java`
- `InvoiceResult.java` / `InvoiceFields.java` / `OcrLine.java`
**Service 调用:**
```java
@Service
@RequiredArgsConstructor
public class InvoiceOcrService {
private final OcrClient ocrClient = new OcrClient("http://127.0.0.1:8801");
public InvoiceResult recognize(MultipartFile file) {
File tmp;
try {
tmp = File.createTempFile("inv_", "_" + file.getOriginalFilename());
file.transferTo(tmp);
} catch (IOException e) {
throw new RuntimeException("保存临时文件失败", e);
}
try {
InvoiceResult r = ocrClient.recognize(tmp);
if (!Boolean.TRUE.equals(r.getSuccess())) {
throw new RuntimeException("OCR 识别失败: " + r.getError());
}
return r;
} finally {
tmp.delete();
}
}
}
```
**Controller**
```java
@RestController
@RequestMapping("/business/invoice")
public class InvoiceOcrController {
private final InvoiceOcrService ocrService;
@PostMapping("/recognize")
public AjaxResult recognize(@RequestParam("file") MultipartFile file) {
return AjaxResult.success(ocrService.recognize(file));
}
}
```
依赖(已用 hutool 可省):
```xml
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-http</artifactId>
<version>5.8.27</version>
</dependency>
```
---
## 8. 错误码速查
调用方拿到响应后建议这样分流:
```python
if not resp.success:
if resp.error_code == "not_invoice":
# 不是发票 — 直接告诉用户"请上传发票图片"
elif resp.error_code == "timeout":
# 超时 — 建议重试 / 提高 DPI
elif resp.error_code in ("unsupported", "process_failed"):
# 文件问题 — 提示格式
else:
# 其他 OCR 异常 — 兜底
if not resp.is_invoice:
# 跟 not_invoice 等价 — 多数情况下 success=False 也伴随 is_invoice=False
pass
```
---
## 9. 局限 & 后续
- **无 QR 的老式纸质发票** 当前会判 not_invoice — 需新增「无 QR 回退 OCR」配置项可破
- **表格明细** (货物/数量/单价) 未抽取 — 需要时接 PP-Structure
- **字段抽取基于正则**,对版式变化敏感;如有大量样本可考虑 LayoutLMv3 微调
- **并发**PaddleOCR 非进程安全,**`workers=1`**;高并发前置 nginx 负载均衡
+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
+37
View File
@@ -0,0 +1,37 @@
package com.ruoyi.business.ocr;
import lombok.Data;
/** 结构化发票字段 */
@Data
public class InvoiceFields {
/** 发票类型:增值税电子普通发票 / 增值税专用发票 / ... */
private String invoiceType;
/** 发票号码 */
private String invoiceNo;
/** 发票代码 */
private String invoiceCode;
/** 开票日期 YYYY-MM-DD */
private String invoiceDate;
/** 价税合计(小写) */
private Double amount;
/** 价税合计(大写中文) */
private String amountCn;
/** 不含税金额 */
private Double amountPretax;
/** 税额 */
private Double taxAmount;
/** 销售方名称 */
private String sellerName;
/** 销售方纳税人识别号 */
private String sellerTaxNo;
/** 购买方名称 */
private String buyerName;
/** 购买方纳税人识别号 */
private String buyerTaxNo;
/** 大写金额 vs 小写金额是否一致(null=未能比对) */
private Boolean amountMatch;
}
+18
View File
@@ -0,0 +1,18 @@
package com.ruoyi.business.ocr;
import lombok.Data;
import java.util.List;
/** OCR 识别结果(与 ry-ocr 的 InvoiceResult JSON 对应) */
@Data
public class InvoiceResult {
private Boolean success;
private String rawText;
private String engine;
private Integer pageCount;
private Integer elapsedMs;
private String error;
private InvoiceFields fields;
private List<OcrLine> lines;
}
+120
View File
@@ -0,0 +1,120 @@
package com.ruoyi.business.ocr;
import cn.hutool.core.io.FileUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import java.io.File;
/**
* ry-ocr Java 调用客户端
*
* 依赖:hutool-http, hutool-json, hutool-core, lombok
*
* 用法:
* OcrClient client = new OcrClient("http://127.0.0.1:8801");
* InvoiceResult r = client.recognize(new File("d:/发票.pdf"));
* InvoiceResult r2 = client.recognizeByUrl("https://oss.example.com/xxx.png");
* System.out.println(r.getFields().getAmount());
*/
@Slf4j
public class OcrClient {
private final String baseUrl;
public OcrClient(String baseUrl) {
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
}
/** 健康检查 */
public boolean ping() {
try (HttpResponse resp = HttpRequest.get(baseUrl + "/health").timeout(3000).execute()) {
return resp.getStatus() == 200 && "ok".equals(JSONUtil.parseObj(resp.body()).getStr("status"));
} catch (Exception e) {
log.warn("ocr ping failed: {}", e.getMessage());
return false;
}
}
/** 识别发票(图片或 PDF) */
public InvoiceResult recognize(File file) {
try (HttpResponse resp = HttpRequest.post(baseUrl + "/recognize/invoice")
.form("file", file)
.timeout(60_000)
.execute()) {
String body = resp.body();
JSONObject json = JSONUtil.parseObj(body);
if (resp.getStatus() != 200) {
throw new RuntimeException("OCR 调用失败: " + resp.getStatus() + " " + body);
}
return parse(json);
}
}
/**
* 从 URL 识别发票: 后端下载 OSS URL 到临时文件 → recognize → 清理临时文件.
* 临时文件目录: System.getProperty("java.io.tmpdir")/ry-ocr/
*
* @param url OSS 可访问 URL
* @return 识别结果
*/
public InvoiceResult recognizeByUrl(String url) {
if (url == null || url.isEmpty()) {
throw new IllegalArgumentException("ossUrl 不能为空");
}
File tmpDir = new File(System.getProperty("java.io.tmpdir"), "ry-ocr");
if (!tmpDir.exists() && !tmpDir.mkdirs()) {
throw new RuntimeException("无法创建临时目录: " + tmpDir.getAbsolutePath());
}
// 从 URL 截取文件名, 保留后缀 (用于 ry-ocr 推断图片/PDF)
String name = url.substring(url.lastIndexOf('/') + 1);
if (name.indexOf('?') >= 0) name = name.substring(0, name.indexOf('?'));
if (name.indexOf('.') < 0) name = name + ".png";
File tmp = new File(tmpDir, System.currentTimeMillis() + "_" + name);
try {
long size = HttpUtil.downloadFile(url, tmp);
if (size <= 0) {
throw new RuntimeException("OSS 文件下载失败或为空: " + url);
}
log.info("OCR 下载: url={} size={}B tmp={}", url, size, tmp.getAbsolutePath());
return recognize(tmp);
} finally {
FileUtil.del(tmp);
}
}
private InvoiceResult parse(JSONObject json) {
InvoiceResult r = new InvoiceResult();
r.setSuccess(json.getBool("success", false));
r.setRawText(json.getStr("rawText", ""));
r.setEngine(json.getStr("engine", ""));
r.setPageCount(json.getInt("pageCount", 1));
r.setElapsedMs(json.getInt("elapsedMs", 0));
r.setError(json.getStr("error"));
JSONObject f = json.getJSONObject("fields");
if (f != null) {
InvoiceFields fields = new InvoiceFields();
fields.setInvoiceType(f.getStr("invoiceType"));
fields.setInvoiceNo(f.getStr("invoiceNo"));
fields.setInvoiceCode(f.getStr("invoiceCode"));
fields.setInvoiceDate(f.getStr("invoiceDate"));
fields.setAmount(f.getDouble("amount"));
fields.setAmountCn(f.getStr("amountCn"));
fields.setAmountPretax(f.getDouble("amount_pretax"));
fields.setTaxAmount(f.getDouble("taxAmount"));
fields.setSellerName(f.getStr("sellerName"));
fields.setSellerTaxNo(f.getStr("sellerTaxNo"));
fields.setBuyerName(f.getStr("buyerName"));
fields.setBuyerTaxNo(f.getStr("buyerTaxNo"));
fields.setAmountMatch(f.getBool("amountMatch"));
r.setFields(fields);
}
return r;
}
}
+13
View File
@@ -0,0 +1,13 @@
package com.ruoyi.business.ocr;
import lombok.Data;
import java.util.List;
/** 单行 OCR 识别结果 */
@Data
public class OcrLine {
private String text;
private Double confidence;
private List<List<Double>> box;
}
+29
View File
@@ -0,0 +1,29 @@
version: "3.9"
services:
ry-ocr:
build: .
container_name: ry-ocr
restart: unless-stopped
ports:
- "8801:8801"
environment:
- APP_HOST=0.0.0.0
- APP_PORT=8801
- USE_GPU=false
- OCR_LANG=ch
- MAX_UPLOAD_MB=20
- PDF_DPI=200
- LOG_LEVEL=INFO
volumes:
# 模型缓存持久化(避免重建容器重新下载)
- paddle_models:/root/.paddleocr
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8801/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
volumes:
paddle_models:
+27
View File
@@ -0,0 +1,27 @@
# Web 框架
fastapi==0.115.6
uvicorn[standard]==0.32.1
python-multipart==0.0.20
# PaddlePaddle (CPU 版;如需 GPU 改为 paddlepaddle-gpu)
paddlepaddle==3.0.0
paddleocr==3.0.1
# PDF 处理 (PyMuPDF,无需 poppler)
PyMuPDF==1.27.2
# 图像处理
opencv-python-headless==4.10.0.84
numpy==1.26.4
Pillow==10.4.0
# 数据校验
pydantic==2.10.3
pydantic-settings==2.7.0
# 中文大写金额转换
cn2an==0.5.22
# 日志 + 工具
loguru==0.7.3
python-dotenv==1.0.1
+14
View File
@@ -0,0 +1,14 @@
"""开发模式启动: python run.py"""
import uvicorn
from app.config import settings
if __name__ == "__main__":
uvicorn.run(
"app.main:app",
host=settings.app_host,
port=settings.app_port,
reload=False,
workers=1, # PaddleOCR 不是进程安全,单 worker
log_level=settings.log_level.lower(),
)
+1
View File
@@ -0,0 +1 @@
"""tests"""
+44
View File
@@ -0,0 +1,44 @@
"""基准测试:模型预热后,统计 PNG / PDF 单次识别耗时"""
import sys
import time
from pathlib import Path
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from app.core import warmup
from app.services.recognize_service import recognize_file
def bench(label: str, file_path: Path, runs: int = 5):
content = file_path.read_bytes()
print(f"\n=== {label}: {file_path.name} ({len(content)} bytes) ===")
# 预热(不计耗时) - 现在 warmup 会真跑一次 dummy 图预测
print("预热中(不计入)...", flush=True)
warmup()
print("预热完成", flush=True)
times = []
for i in range(runs):
t0 = time.time()
result = recognize_file(file_path.name, content)
elapsed = (time.time() - t0) * 1000
times.append(elapsed)
print(f"{i+1} 次: {elapsed:7.1f} ms success={result.success}")
times.sort()
print(f"\n min: {times[0]:7.1f} ms")
print(f" median: {times[len(times)//2]:7.1f} ms")
print(f" max: {times[-1]:7.1f} ms")
print(f" avg: {sum(times)/len(times):7.1f} ms")
if __name__ == "__main__":
bench("PNG", ROOT / "fapiao_1.png", runs=5)
bench("PDF", ROOT / "fapiao.pdf", runs=5)
+93
View File
@@ -0,0 +1,93 @@
"""基准测试:不同 DPI 下的 PDF 渲染耗时 vs OCR 耗时 vs 识别准确度"""
import sys
import time
from pathlib import Path
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
import fitz
from app.core import warmup
from app.services.recognize_service import recognize_file
def render(pdf_path: Path, dpi: int) -> Path:
out = ROOT / f"_bench_{dpi}dpi.png"
doc = fitz.open(str(pdf_path))
page = doc[0]
pix = page.get_pixmap(matrix=fitz.Matrix(dpi / 72, dpi / 72), alpha=False)
pix.save(str(out))
doc.close()
return out
def bench_dpi(pdf_path: Path, dpi: int, runs: int = 3):
img_path = render(pdf_path, dpi)
img_bytes = img_path.read_bytes()
print(f"\n--- DPI={dpi} | 渲染图 {img_path.stat().st_size//1024}KB ---")
times = []
fields_ok = None
for i in range(runs):
t0 = time.time()
result = recognize_file(img_path.name, img_bytes)
elapsed = (time.time() - t0) * 1000
times.append(elapsed)
if i == 0 and result.success:
fields_ok = result.fields
print(f" run {i+1}: {elapsed:7.1f}ms success={result.success}")
# 清理中间文件
img_path.unlink(missing_ok=True)
(ROOT / f".{img_path.stem}_pages").rmdir() if (ROOT / f".{img_path.stem}_pages").exists() else None
# 上面可能删不了子目录,try 再清理 _pages 下文件
pages_dir = ROOT / f".{img_path.stem}_pages"
if pages_dir.exists():
for f in pages_dir.glob("*"):
f.unlink(missing_ok=True)
pages_dir.rmdir()
times.sort()
print(f" min={times[0]:.1f}ms median={times[len(times)//2]:.1f}ms max={times[-1]:.1f}ms")
return fields_ok
if __name__ == "__main__":
pdf = ROOT / "fapiao.pdf"
print(f"PDF: {pdf.name}, {pdf.stat().st_size//1024}KB, 1 页")
warmup()
print("预热完成\n")
print("=" * 60)
fields_100 = bench_dpi(pdf, 100)
print("=" * 60)
fields_150 = bench_dpi(pdf, 150)
print("=" * 60)
fields_200 = bench_dpi(pdf, 200)
print("=" * 60)
fields_300 = bench_dpi(pdf, 300)
print("=" * 60)
# 汇总
print("\n\n=== 关键字段对比 ===")
print(f"{'字段':<15} {'100dpi':<25} {'150dpi':<25} {'200dpi':<25} {'300dpi':<25}")
key_fields = ["invoice_no", "invoice_date", "amount", "amount_cn",
"amount_pretax", "tax_amount", "seller_name", "buyer_name",
"seller_tax_no", "buyer_tax_no", "amount_match"]
for k in key_fields:
row = [k]
for f in [fields_100, fields_150, fields_200, fields_300]:
v = getattr(f, k) if f else None
if v is None:
row.append("(空)")
else:
s = str(v)[:23]
row.append(s)
print(f"{row[0]:<15} {row[1]:<25} {row[2]:<25} {row[3]:<25} {row[4]:<25}")
+91
View File
@@ -0,0 +1,91 @@
"""单元测试:发票字段抽取(不依赖 PaddleOCR)
直接喂文本,验证字段抽取与金额校验逻辑。
"""
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
# 强制 UTF-8 输出(Windows cp936 默认中文乱码)
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
from app.services import extract_invoice # noqa: E402
SAMPLE_VAT_ELEC = """
增值税电子普通发票
发票代码: 011002000000
发票号码: 12345678
开票日期: 2024年05月20日
名称: 上海某科技有限公司
纳税人识别号: 91310115MA1K3X9Y8A
名称: 北京某某贸易有限公司
纳税人识别号: 91110108MA01K3X9Y8A
金额 ¥1234.56
税额 ¥74.07
价税合计 ¥1308.63
(贰仟零捌元陆角叁分)
备注:
"""
SAMPLE_VAT_SPECIAL = """
增值税专用发票
发票代码 011002100111
发票号码 87654321
开 票 日 期: 2023年12月01日
名 称: 深圳某有限公司
纳税人识别号: 91440300MA5DCBA123
名 称: 广州某科技股份公司
纳税人识别号: 91440101MA59ABC987
不含税价 ¥10000.00 税率 13%
税 额 ¥1300.00
价税合计(大写)壹万壹仟叁佰元整 (小写)¥11300.00
"""
def test_vat_electronic():
fields = extract_invoice(SAMPLE_VAT_ELEC)
print(fields.model_dump_json(indent=2))
assert fields.invoice_type == "增值税电子普通发票"
assert fields.invoice_code == "011002000000"
assert fields.invoice_no == "12345678"
assert fields.invoice_date == "2024-05-20"
assert fields.amount is not None and abs(fields.amount - 1308.63) < 0.01
def test_vat_special():
fields = extract_invoice(SAMPLE_VAT_SPECIAL)
print(fields.model_dump_json(indent=2))
assert fields.invoice_type == "增值税专用发票"
assert fields.invoice_no == "87654321"
assert fields.invoice_date == "2023-12-01"
assert fields.amount is not None and abs(fields.amount - 11300.0) < 0.01
# 大写金额提取(壹万壹仟叁佰元整)
assert fields.amount_cn is not None
assert "壹万" in fields.amount_cn or "" in fields.amount_cn
# 大写 vs 小写一致
assert fields.amount_match is True
def test_amount_consistency():
"""大写小写一致"""
from app.utils import amount_consistent
assert amount_consistent("贰佰元整", 200.0) is True
assert amount_consistent("壹仟元整", 1000.0) is True
# 不一致
assert amount_consistent("贰佰元整", 300.0) is False
if __name__ == "__main__":
test_vat_electronic()
print("-" * 60)
test_vat_special()
print("-" * 60)
test_amount_consistency()
print("✅ all tests passed")