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