首次提交
This commit is contained in:
@@ -174,3 +174,4 @@ cython_debug/
|
|||||||
# PyPI configuration file
|
# PyPI configuration file
|
||||||
.pypirc
|
.pypirc
|
||||||
|
|
||||||
|
/CLAUDE.md
|
||||||
|
|||||||
Generated
+10
@@ -0,0 +1,10 @@
|
|||||||
|
# 默认忽略的文件
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# 已忽略包含查询文件的默认文件夹
|
||||||
|
/queries/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
|
# 基于编辑器的 HTTP 客户端请求
|
||||||
|
/httpRequests/
|
||||||
File diff suppressed because it is too large
Load Diff
+241
@@ -0,0 +1,241 @@
|
|||||||
|
# GPU 推理部署指南
|
||||||
|
|
||||||
|
## 配置参数
|
||||||
|
|
||||||
|
在 `.env` 文件中设置:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# 推理设备: cpu / cuda / dml / tensorrt
|
||||||
|
OCR_DEVICE=cpu
|
||||||
|
|
||||||
|
# GPU设备编号(多GPU时指定,默认0)
|
||||||
|
OCR_DEVICE_ID=0
|
||||||
|
|
||||||
|
# CPU线程数(仅 cpu 模式生效)
|
||||||
|
OCR_INTRA_THREADS=4
|
||||||
|
OCR_INTER_THREADS=2
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 方案一:CUDA(NVIDIA 显卡)
|
||||||
|
|
||||||
|
适用:有 NVIDIA 独立显卡(GTX/RTX/Quadro/Tesla 等)。
|
||||||
|
|
||||||
|
### 1. 确认显卡支持 CUDA
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
nvidia-smi
|
||||||
|
```
|
||||||
|
|
||||||
|
确保 CUDA Version >= 11.8。
|
||||||
|
|
||||||
|
### 2. 安装 CUDA Toolkit 和 cuDNN
|
||||||
|
|
||||||
|
**方法A(推荐):直接安装 onnxruntime-gpu**
|
||||||
|
|
||||||
|
onnxruntime-gpu 已内置必要的 CUDA/cuDNN 依赖(Windows 上通过 DirectML 或 CUDA EP):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip uninstall onnxruntime
|
||||||
|
pip install onnxruntime-gpu
|
||||||
|
```
|
||||||
|
|
||||||
|
**方法B:手动安装 CUDA + cuDNN**
|
||||||
|
|
||||||
|
1. 下载 [CUDA Toolkit 11.8](https://developer.nvidia.com/cuda-11-8-0-download-archive)
|
||||||
|
2. 下载 [cuDNN 8.x for CUDA 11.x](https://developer.nvidia.com/cudnn)
|
||||||
|
3. 安装后将 cuDNN 的 bin/lib/include 复制到 CUDA 安装目录
|
||||||
|
|
||||||
|
### 3. 配置 .env
|
||||||
|
|
||||||
|
```env
|
||||||
|
OCR_DEVICE=cuda
|
||||||
|
OCR_DEVICE_ID=0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 验证
|
||||||
|
|
||||||
|
启动服务后查看日志:
|
||||||
|
|
||||||
|
```
|
||||||
|
推理设备: CUDA (device_id=0)
|
||||||
|
```
|
||||||
|
|
||||||
|
或用 Python 测试:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import onnxruntime as ort
|
||||||
|
print(ort.get_available_providers())
|
||||||
|
# 应包含: ['CUDAExecutionProvider', 'CPUExecutionProvider']
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 方案二:DirectML(Windows 任意 GPU)
|
||||||
|
|
||||||
|
适用:Windows 系统,任意显卡(NVIDIA/AMD/Intel 核显),无需安装 CUDA。
|
||||||
|
|
||||||
|
### 1. 安装 onnxruntime-directml
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip uninstall onnxruntime
|
||||||
|
pip install onnxruntime-directml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 配置 .env
|
||||||
|
|
||||||
|
```env
|
||||||
|
OCR_DEVICE=dml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 验证
|
||||||
|
|
||||||
|
```python
|
||||||
|
import onnxruntime as ort
|
||||||
|
print(ort.get_available_providers())
|
||||||
|
# 应包含: ['DmlExecutionProvider', 'CPUExecutionProvider']
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意**:首次推理时 ONNX Runtime 会编译 DML 算子,可能耗时 10-30 秒,后续推理恢复正常速度。
|
||||||
|
|
||||||
|
### 4. 核显部署机(无独显)完整流程
|
||||||
|
|
||||||
|
开发机与部署机分离时,部署机常为只有核显的轻薄本/迷你主机。核显同样支持 DirectML 加速(Intel UHD / Iris Xe、AMD Radeon 核显均满足),无需任何 NVIDIA/AMD 驱动层面的额外配置。
|
||||||
|
|
||||||
|
**前置条件**
|
||||||
|
|
||||||
|
| 项目 | 要求 |
|
||||||
|
|------|------|
|
||||||
|
| 系统 | Windows 10 1903(Build 18362)及以上(RapidOCR 会检查,不满足则自动回退 CPU) |
|
||||||
|
| 显卡 | 支持 DirectX 12 的核显(现代 Intel/AMD 核显均满足) |
|
||||||
|
| Python | 3.10+ |
|
||||||
|
|
||||||
|
**全新环境安装**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 1. 安装项目依赖
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 2. 安装 DirectML 后端(替换掉 rapidocr 自带的 CPU 版 onnxruntime)
|
||||||
|
pip uninstall onnxruntime
|
||||||
|
pip install onnxruntime-directml
|
||||||
|
|
||||||
|
# 3. 验证后端可用
|
||||||
|
python -c "import onnxruntime as ort; print(ort.get_available_providers())"
|
||||||
|
# 应输出: ['DmlExecutionProvider', 'CPUExecutionProvider']
|
||||||
|
```
|
||||||
|
|
||||||
|
**配置 .env**
|
||||||
|
|
||||||
|
```env
|
||||||
|
OCR_DEVICE=dml
|
||||||
|
# OCR_INTRA_THREADS / OCR_INTER_THREADS 仅 cpu 模式生效,dml 模式无需设置
|
||||||
|
```
|
||||||
|
|
||||||
|
**启动并验证**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uvicorn main:app --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
- 启动日志应出现 `推理设备: DirectML`
|
||||||
|
- 首次识别会编译着色器,等待 30-60 秒属正常现象,之后恢复正常速度
|
||||||
|
|
||||||
|
**核显加速预期**
|
||||||
|
|
||||||
|
| 场景 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 加速比 | 约为 CPU 的 1.5-3 倍(Intel Iris Xe 单张 A4 约 1-2s) |
|
||||||
|
| CPU 较强的机器 | 核显与 CPU 共享内存带宽,加速比更接近下限 1.5x |
|
||||||
|
| 双显卡机器 | DML 使用系统默认适配器(通常为独显);当前代码未将 `OCR_DEVICE_ID` 透传给 DML,该参数对 dml 模式不生效 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 方案三:TensorRT(NVIDIA 显卡,极致性能)
|
||||||
|
|
||||||
|
适用:追求最快推理速度的 NVIDIA GPU 用户。
|
||||||
|
|
||||||
|
### 1. 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip uninstall onnxruntime
|
||||||
|
pip install onnxruntime-gpu
|
||||||
|
|
||||||
|
# 安装 TensorRT
|
||||||
|
pip install tensorrt
|
||||||
|
# 或从 NVIDIA 官网下载: https://developer.nvidia.com/tensorrt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 配置 .env
|
||||||
|
|
||||||
|
```env
|
||||||
|
OCR_DEVICE=tensorrt
|
||||||
|
OCR_DEVICE_ID=0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 首次运行
|
||||||
|
|
||||||
|
TensorRT 首次加载模型时需要构建 engine(耗时 30 秒到几分钟),之后会缓存到默认模型目录。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 方案四:OpenVINO(Intel CPU/GPU)
|
||||||
|
|
||||||
|
如果你使用 Intel CPU(特别是第 10 代及更新),OpenVINO 比默认 ONNX Runtime CPU 更快。
|
||||||
|
|
||||||
|
### 1. 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install openvino
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 修改 `engine/ocr_engine.py`
|
||||||
|
|
||||||
|
在 `_build_device_params()` 中添加 OpenVINO 分支(目前代码模板已预留结构)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 性能预期
|
||||||
|
|
||||||
|
| 设备 | 单张 A4 耗时 | vs CPU | 适用场景 |
|
||||||
|
|------|-------------|--------|---------|
|
||||||
|
| CPU (i7-12700) | ~2-5s | 基准 | 低并发、开发调试 |
|
||||||
|
| CUDA (RTX 3060) | ~0.3-0.8s | 3-8x | 生产环境 NVIDIA GPU |
|
||||||
|
| DirectML (RTX 3060) | ~0.5-1.0s | 2-5x | Windows 任意 GPU |
|
||||||
|
| DirectML (Intel Iris Xe) | ~1-2s | 1.5-3x | 轻薄本核显 |
|
||||||
|
| TensorRT (RTX 3060) | ~0.15-0.4s | 5-15x | 高吞吐生产环境 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
|
||||||
|
### CUDA 模式下报错 "CUDAExecutionProvider not found"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 确认安装了 onnxruntime-gpu
|
||||||
|
pip show onnxruntime-gpu
|
||||||
|
|
||||||
|
# 确认 CUDA 可用
|
||||||
|
python -c "import onnxruntime; print(onnxruntime.get_available_providers())"
|
||||||
|
```
|
||||||
|
|
||||||
|
### DirectML 下首次推理卡住
|
||||||
|
|
||||||
|
首次推理需要编译着色器,等待 30-60 秒即可。后续推理恢复正常。
|
||||||
|
|
||||||
|
### GPU 显存不足
|
||||||
|
|
||||||
|
服务使用整图 OCR。如果处理大图时出现 OOM:
|
||||||
|
- 在调用服务前降低输入图片分辨率
|
||||||
|
- 降低并发请求数量
|
||||||
|
- 或降低 `.env` 中 `OCR_REC_BATCH_NUM` 的值
|
||||||
|
|
||||||
|
### 多 GPU 环境
|
||||||
|
|
||||||
|
设置 `OCR_DEVICE_ID` 指定使用哪张卡:
|
||||||
|
|
||||||
|
```env
|
||||||
|
OCR_DEVICE=cuda
|
||||||
|
OCR_DEVICE_ID=1 # 使用第2张GPU
|
||||||
|
```
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
|
from pydantic import AfterValidator, Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_model_dir(value: Path) -> Path:
|
||||||
|
"""将模型目录解析为项目根目录下的绝对路径。"""
|
||||||
|
path = value.expanduser()
|
||||||
|
if not path.is_absolute():
|
||||||
|
path = PROJECT_ROOT / path
|
||||||
|
path = path.resolve()
|
||||||
|
if not path.is_dir():
|
||||||
|
raise ValueError(f"OCR模型目录不存在: {path}")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
ModelDir = Annotated[Path, AfterValidator(resolve_model_dir)]
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""应用配置"""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=PROJECT_ROOT / ".env",
|
||||||
|
case_sensitive=False,
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
host: str = "0.0.0.0"
|
||||||
|
port: int = 15651
|
||||||
|
debug: bool = False
|
||||||
|
|
||||||
|
# OCR阶段
|
||||||
|
ocr_use_det: bool = True
|
||||||
|
ocr_use_cls: bool = False
|
||||||
|
ocr_use_rec: bool = True
|
||||||
|
|
||||||
|
# 模型与常用调优参数
|
||||||
|
ocr_model_dir: ModelDir = Path("model")
|
||||||
|
ocr_text_score: float = Field(default=0.5, ge=0.0, le=1.0)
|
||||||
|
ocr_max_side_len: int = Field(default=2000, ge=32, le=16384)
|
||||||
|
ocr_det_limit_side_len: int = Field(default=736, ge=32, le=8192)
|
||||||
|
ocr_det_box_thresh: float = Field(default=0.5, ge=0.0, le=1.0)
|
||||||
|
ocr_det_thresh: float = Field(default=0.3, ge=0.0, le=1.0)
|
||||||
|
ocr_rec_batch_num: int = Field(default=6, ge=1, le=256)
|
||||||
|
|
||||||
|
# 推理设备
|
||||||
|
ocr_device: Literal["cpu", "cuda", "dml", "tensorrt"] = "cpu"
|
||||||
|
ocr_device_id: int = Field(default=0, ge=0)
|
||||||
|
ocr_intra_threads: int = Field(default=4, ge=1)
|
||||||
|
ocr_inter_threads: int = Field(default=2, ge=1)
|
||||||
|
|
||||||
|
# 竖排文字补充通道(边缘带旋转90°检测,需 model/PP-OCRv4 目录下的 server 模型)
|
||||||
|
ocr_enable_vertical: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
@@ -0,0 +1,526 @@
|
|||||||
|
"""
|
||||||
|
OCR引擎模块
|
||||||
|
|
||||||
|
使用 RapidOCR 对整张图片进行文字识别。
|
||||||
|
推理设备支持:CPU / CUDA / DirectML / TensorRT
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
import cv2
|
||||||
|
from rapidocr import EngineType, LangCls, LangDet, LangRec, ModelType, OCRVersion, RapidOCR
|
||||||
|
from rapidocr.utils.output import RapidOCROutput
|
||||||
|
from pathlib import Path
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OCRProcessor:
|
||||||
|
"""整图 OCR 处理器"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
use_det: bool = True,
|
||||||
|
use_cls: bool = False,
|
||||||
|
use_rec: bool = True,
|
||||||
|
model_dir: str | Path = "model",
|
||||||
|
text_score: float = 0.5,
|
||||||
|
max_side_len: int = 2000,
|
||||||
|
det_limit_side_len: int = 736,
|
||||||
|
det_box_thresh: float = 0.5,
|
||||||
|
det_thresh: float = 0.3,
|
||||||
|
rec_batch_num: int = 6,
|
||||||
|
# 推理设备
|
||||||
|
device: str = "cpu",
|
||||||
|
device_id: int = 0,
|
||||||
|
intra_threads: int = 4,
|
||||||
|
inter_threads: int = 2,
|
||||||
|
# 竖排文字补充通道
|
||||||
|
enable_vertical: bool = False,
|
||||||
|
):
|
||||||
|
self.use_det = use_det
|
||||||
|
self.use_cls = use_cls
|
||||||
|
self.use_rec = use_rec
|
||||||
|
self.device = device
|
||||||
|
self.device_id = device_id
|
||||||
|
self.intra_threads = intra_threads
|
||||||
|
self.inter_threads = inter_threads
|
||||||
|
self.text_score = text_score
|
||||||
|
self.max_side_len = max_side_len
|
||||||
|
self.det_limit_side_len = det_limit_side_len
|
||||||
|
self.det_box_thresh = det_box_thresh
|
||||||
|
self.det_thresh = det_thresh
|
||||||
|
self.rec_batch_num = rec_batch_num
|
||||||
|
self.enable_vertical = enable_vertical
|
||||||
|
self._vert_engine = None
|
||||||
|
|
||||||
|
# 模型路径
|
||||||
|
model_path = Path(model_dir).resolve()
|
||||||
|
self.rec_model_path = model_path / "ch_PP-OCRv5_rec_mobile.onnx"
|
||||||
|
self.det_model_path = model_path / "ch_PP-OCRv5_det_mobile.onnx"
|
||||||
|
self.cls_model_path = model_path / "ch_PP-LCNet_x0_25_textline_ori_cls_mobile.onnx"
|
||||||
|
missing_models = [
|
||||||
|
str(path)
|
||||||
|
for path in (self.det_model_path, self.cls_model_path, self.rec_model_path)
|
||||||
|
if not path.is_file()
|
||||||
|
]
|
||||||
|
if missing_models:
|
||||||
|
raise FileNotFoundError(f"OCR模型文件不存在: {', '.join(missing_models)}")
|
||||||
|
|
||||||
|
# 初始化引擎
|
||||||
|
self.engine = self._init_engine()
|
||||||
|
|
||||||
|
def _build_device_params(self) -> dict:
|
||||||
|
"""根据 device 配置构建推理引擎参数"""
|
||||||
|
params = {}
|
||||||
|
|
||||||
|
if self.device == "cpu":
|
||||||
|
# CPU: 控制线程数以优化并发
|
||||||
|
params.update({
|
||||||
|
"EngineConfig.onnxruntime.intra_op_num_threads": self.intra_threads,
|
||||||
|
"EngineConfig.onnxruntime.inter_op_num_threads": self.inter_threads,
|
||||||
|
})
|
||||||
|
logger.info(f"推理设备: CPU (intra_threads={self.intra_threads}, inter_threads={self.inter_threads})")
|
||||||
|
|
||||||
|
elif self.device == "cuda":
|
||||||
|
# NVIDIA GPU with CUDA
|
||||||
|
params.update({
|
||||||
|
"EngineConfig.onnxruntime.use_cuda": True,
|
||||||
|
"EngineConfig.onnxruntime.cuda_ep_cfg.device_id": self.device_id,
|
||||||
|
})
|
||||||
|
logger.info(f"推理设备: CUDA (device_id={self.device_id})")
|
||||||
|
|
||||||
|
elif self.device == "dml":
|
||||||
|
# Windows DirectML (任意GPU)
|
||||||
|
params.update({
|
||||||
|
"EngineConfig.onnxruntime.use_dml": True,
|
||||||
|
})
|
||||||
|
logger.info("推理设备: DirectML")
|
||||||
|
|
||||||
|
elif self.device == "tensorrt":
|
||||||
|
# NVIDIA TensorRT (需要预先构建engine)
|
||||||
|
params.update({
|
||||||
|
"Det.engine_type": EngineType.TENSORRT,
|
||||||
|
"Rec.engine_type": EngineType.TENSORRT,
|
||||||
|
"Cls.engine_type": EngineType.TENSORRT,
|
||||||
|
"EngineConfig.tensorrt.device_id": self.device_id,
|
||||||
|
"EngineConfig.tensorrt.use_fp16": True,
|
||||||
|
})
|
||||||
|
logger.info(f"推理设备: TensorRT (device_id={self.device_id}, fp16=True)")
|
||||||
|
|
||||||
|
return params
|
||||||
|
|
||||||
|
def _init_engine(self) -> RapidOCR:
|
||||||
|
"""初始化OCR引擎"""
|
||||||
|
params = {
|
||||||
|
# 全局与常用调优参数
|
||||||
|
"Global.text_score": self.text_score,
|
||||||
|
"Global.max_side_len": self.max_side_len,
|
||||||
|
"Det.limit_side_len": self.det_limit_side_len,
|
||||||
|
"Det.box_thresh": self.det_box_thresh,
|
||||||
|
"Det.thresh": self.det_thresh,
|
||||||
|
"Rec.rec_batch_num": self.rec_batch_num,
|
||||||
|
# 模型契约
|
||||||
|
"Det.model_path": str(self.det_model_path),
|
||||||
|
"Det.engine_type": EngineType.ONNXRUNTIME,
|
||||||
|
"Det.lang_type": LangDet.CH,
|
||||||
|
"Det.model_type": ModelType.MOBILE,
|
||||||
|
"Det.ocr_version": OCRVersion.PPOCRV5,
|
||||||
|
"Rec.model_path": str(self.rec_model_path),
|
||||||
|
"Rec.engine_type": EngineType.ONNXRUNTIME,
|
||||||
|
"Rec.lang_type": LangRec.CH,
|
||||||
|
"Rec.model_type": ModelType.MOBILE,
|
||||||
|
"Rec.ocr_version": OCRVersion.PPOCRV5,
|
||||||
|
"Cls.engine_type": EngineType.ONNXRUNTIME,
|
||||||
|
"Cls.lang_type": LangCls.CH,
|
||||||
|
"Cls.model_type": ModelType.MOBILE,
|
||||||
|
"Cls.ocr_version": OCRVersion.PPOCRV5,
|
||||||
|
"Cls.model_path": str(self.cls_model_path),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 合并设备参数
|
||||||
|
params.update(self._build_device_params())
|
||||||
|
|
||||||
|
return RapidOCR(params=params)
|
||||||
|
|
||||||
|
def process(self, image: np.ndarray) -> RapidOCROutput:
|
||||||
|
"""标准OCR识别(整图直接OCR)"""
|
||||||
|
return self.engine(
|
||||||
|
image,
|
||||||
|
use_det=self.use_det,
|
||||||
|
use_rec=self.use_rec,
|
||||||
|
use_cls=self.use_cls,
|
||||||
|
return_word_box=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── 边缘/竖排文字补充通道(纯 CV,CPU 友好)──────────
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _row_bands(gray_img: np.ndarray, dark_thresh: int = 160, char_h: int | None = None):
|
||||||
|
"""行投影找文字行带(字高自适应)。
|
||||||
|
|
||||||
|
手写/毛笔大字内部笔画断裂会产生大间隙,固定空白阈值会把一个字拆成多段。
|
||||||
|
流程:小阈值粗切 → 按字高比例合并相邻带、丢弃碎片带。
|
||||||
|
char_h 为字高(px);None 时自动估计(取主带——≥0.35×最大带高——的平均高,
|
||||||
|
避免被小噪声碎片带拉偏)。
|
||||||
|
返回 [(y0, y1), ...](区域内坐标)
|
||||||
|
"""
|
||||||
|
row_dark = (gray_img < dark_thresh).sum(axis=1)
|
||||||
|
bands = []
|
||||||
|
in_b = False
|
||||||
|
for y, d in enumerate(row_dark):
|
||||||
|
if d > 2 and not in_b:
|
||||||
|
in_b = True
|
||||||
|
b0 = y
|
||||||
|
elif d <= 2 and in_b:
|
||||||
|
in_b = False
|
||||||
|
if y - b0 >= 2:
|
||||||
|
bands.append((b0, y))
|
||||||
|
if in_b:
|
||||||
|
bands.append((b0, len(row_dark)))
|
||||||
|
if not bands:
|
||||||
|
return []
|
||||||
|
if char_h is None:
|
||||||
|
hmax = max(y1 - y0 for y0, y1 in bands)
|
||||||
|
mains = [y1 - y0 for y0, y1 in bands if y1 - y0 >= hmax * 0.35]
|
||||||
|
char_h = round(sum(mains) / len(mains)) if mains else hmax
|
||||||
|
gap_min = max(2, int(char_h * 0.35)) # 相邻带空白 <= 该值 -> 笔画断裂,合并
|
||||||
|
band_min = max(4, int(char_h * 0.5)) # 低于该高度的带为噪声碎片,丢弃
|
||||||
|
merged = []
|
||||||
|
last = None
|
||||||
|
for y0, y1 in bands:
|
||||||
|
if y1 - y0 < band_min:
|
||||||
|
continue
|
||||||
|
if last is not None and y0 - last[1] <= gap_min:
|
||||||
|
last[1] = y1
|
||||||
|
else:
|
||||||
|
last = [y0, y1]
|
||||||
|
merged.append(last)
|
||||||
|
return [(y0, y1) for y0, y1 in merged]
|
||||||
|
|
||||||
|
def _cv_supplement(self, image: np.ndarray, main_boxes: list, band_h: int = 160) -> list:
|
||||||
|
"""边缘带补充检测(纯 OpenCV,无模型依赖,CPU 毫秒级):
|
||||||
|
四边带 → 多级低阈值二值化 → 膨胀 → 连通域 → 文字状块过滤。
|
||||||
|
解决主流程漏检的边缘小字/竖排手写文字。
|
||||||
|
返回 [(box_np, text), ...](原图坐标)"""
|
||||||
|
if not self.enable_vertical:
|
||||||
|
return []
|
||||||
|
H, W = image.shape[:2]
|
||||||
|
gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
|
||||||
|
|
||||||
|
bands = [
|
||||||
|
(0, 0, W, min(band_h, H)), # 上边带
|
||||||
|
(0, max(0, H - band_h), W, H), # 下边带
|
||||||
|
(0, 0, min(band_h, W), H), # 左边带
|
||||||
|
(max(0, W - band_h), 0, W, H), # 右边带
|
||||||
|
]
|
||||||
|
extra = []
|
||||||
|
for bx1, by1, bx2, by2 in bands:
|
||||||
|
if bx2 - bx1 < 40 or by2 - by1 < 40:
|
||||||
|
continue
|
||||||
|
band = gray[by1:by2, bx1:bx2]
|
||||||
|
for th in (155, 145, 135):
|
||||||
|
_, bw = cv2.threshold(band, th, 255, cv2.THRESH_BINARY_INV)
|
||||||
|
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 5))
|
||||||
|
closed = cv2.morphologyEx(bw, cv2.MORPH_CLOSE, kernel, iterations=2)
|
||||||
|
contours, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
for c in contours:
|
||||||
|
x, y, w, h = cv2.boundingRect(c)
|
||||||
|
if not (8 <= w <= 110 and 10 <= h <= 130):
|
||||||
|
continue
|
||||||
|
if closed[y:y + h, x:x + w].mean() / 255.0 < 0.12:
|
||||||
|
continue
|
||||||
|
cx, cy = bx1 + x + w / 2, by1 + y + h / 2
|
||||||
|
# 块中心落在任一主流程检测框内(含4px容差)则跳过
|
||||||
|
if any(b[:, 0].min() - 4 <= cx <= b[:, 0].max() + 4
|
||||||
|
and b[:, 1].min() - 4 <= cy <= b[:, 1].max() + 4 for b in main_boxes):
|
||||||
|
continue
|
||||||
|
if any(abs(cx - eb[:, 0].mean()) < 10 and abs(cy - eb[:, 1].mean()) < 10 for eb, _ in extra):
|
||||||
|
continue
|
||||||
|
box = np.array([[x, y], [x + w, y], [x + w, y + h], [x, y + h]], dtype=float)
|
||||||
|
box[:, 0] += bx1
|
||||||
|
box[:, 1] += by1
|
||||||
|
text = self._cv_box_text(gray, box) if h / max(w, 1) > 1.2 else ""
|
||||||
|
extra.append((box, text))
|
||||||
|
return extra
|
||||||
|
|
||||||
|
def _cv_box_text(self, gray: np.ndarray, box: np.ndarray) -> str:
|
||||||
|
"""补充框内逐字识别(mobile rec,CPU 可跑):行投影切单字 → 逐字识别。
|
||||||
|
识别失败返回空串(标注优先,文本对错次要)。"""
|
||||||
|
H, W = gray.shape[:2]
|
||||||
|
x1, y1 = int(max(0, box[:, 0].min() - 3)), int(max(0, box[:, 1].min() - 3))
|
||||||
|
x2, y2 = int(min(W, box[:, 0].max() + 3)), int(min(H, box[:, 1].max() + 3))
|
||||||
|
if x2 - x1 < 4 or y2 - y1 < 4:
|
||||||
|
return ""
|
||||||
|
region = gray[y1:y2, x1:x2]
|
||||||
|
text = ""
|
||||||
|
for c0, c1 in self._row_bands(region, 160):
|
||||||
|
seg = region[c0:c1, :]
|
||||||
|
if seg.shape[0] < 8:
|
||||||
|
continue
|
||||||
|
big = cv2.resize(seg, None, fx=2, fy=2, interpolation=cv2.INTER_LANCZOS4)
|
||||||
|
r = self.engine(cv2.cvtColor(big, cv2.COLOR_GRAY2RGB), use_det=False, use_cls=False, use_rec=True)
|
||||||
|
if r.txts:
|
||||||
|
text += "".join(r.txts)
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _vertical_box_text(self, gray: np.ndarray, box: np.ndarray) -> str:
|
||||||
|
"""竖排框内逐字识别:行投影(阈值160)切单字 → 逐字 mobile rec"""
|
||||||
|
H, W = gray.shape[:2]
|
||||||
|
x1, y1 = int(max(0, box[:, 0].min() - 3)), int(max(0, box[:, 1].min() - 3))
|
||||||
|
x2, y2 = int(min(W, box[:, 0].max() + 3)), int(min(H, box[:, 1].max() + 3))
|
||||||
|
if x2 - x1 < 4 or y2 - y1 < 4:
|
||||||
|
return ""
|
||||||
|
region = gray[y1:y2, x1:x2]
|
||||||
|
text = ""
|
||||||
|
for c0, c1 in self._row_bands(region, 160):
|
||||||
|
seg = region[c0:c1, :]
|
||||||
|
if seg.shape[0] < 8:
|
||||||
|
continue
|
||||||
|
big = cv2.resize(seg, None, fx=2, fy=2, interpolation=cv2.INTER_LANCZOS4)
|
||||||
|
r = self.engine(cv2.cvtColor(big, cv2.COLOR_GRAY2RGB), use_det=False, use_cls=False, use_rec=True)
|
||||||
|
if r.txts:
|
||||||
|
text += "".join(r.txts)
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _vertical_longbox_supplement(self, image: np.ndarray, result: RapidOCROutput) -> RapidOCROutput:
|
||||||
|
"""竖排长框旋转识别补充:
|
||||||
|
检测把竖排一列文字框成超长行(>300px),Rec 压缩后丢字(如"殁")。
|
||||||
|
按列合并长框为竖条 → 旋转90°变横排 → 完整OCR → 行框换算回原图 →
|
||||||
|
行内均分字坐标 → 补充原 word_results 缺失的位置。"""
|
||||||
|
if result.boxes is None or not result.word_results:
|
||||||
|
return result
|
||||||
|
H, W = image.shape[:2]
|
||||||
|
|
||||||
|
# 超长竖排框(>300px 高且高宽比 >2)
|
||||||
|
def is_longbox(b):
|
||||||
|
return (b[:, 1].max() - b[:, 1].min() > 300
|
||||||
|
and (b[:, 1].max() - b[:, 1].min()) / max(b[:, 0].max() - b[:, 0].min(), 1) > 2)
|
||||||
|
|
||||||
|
def flat_items(word_results):
|
||||||
|
"""展平 word_results;rapidocr 对超长竖框的字级切分会把整列切成
|
||||||
|
碎片(半字高),这些框的字级条目整框丢弃,交给下方长框通道重切"""
|
||||||
|
out = []
|
||||||
|
for i, line in enumerate(word_results):
|
||||||
|
if isinstance(line, (tuple, list)) and len(line) >= 3 and isinstance(line[0], str):
|
||||||
|
out.append((line[0], line[1], line[2]))
|
||||||
|
elif isinstance(line, (tuple, list)):
|
||||||
|
if result.boxes is not None and i < len(result.boxes) and is_longbox(result.boxes[i]):
|
||||||
|
continue
|
||||||
|
for it in line:
|
||||||
|
if isinstance(it, (tuple, list)) and len(it) >= 3 and isinstance(it[0], str):
|
||||||
|
out.append((it[0], it[1], it[2]))
|
||||||
|
return out
|
||||||
|
|
||||||
|
def center(coords):
|
||||||
|
return sum(p[0] for p in coords) / len(coords), sum(p[1] for p in coords) / len(coords)
|
||||||
|
|
||||||
|
orig_items = flat_items(result.word_results)
|
||||||
|
|
||||||
|
# 超长竖排框(>300px 高且高宽比 >2)按 x 中心聚类合并为竖条
|
||||||
|
longs = [b for b in result.boxes if is_longbox(b)]
|
||||||
|
if not longs:
|
||||||
|
return result
|
||||||
|
# 聚类容差按列宽自适应: 硬编码 60px 会把列间距较密的竖排相邻列误并为一组
|
||||||
|
col_w = float(np.median([b[:, 0].max() - b[:, 0].min() for b in longs]))
|
||||||
|
cluster_d = max(2, int(col_w * 0.8))
|
||||||
|
groups = []
|
||||||
|
for b in longs:
|
||||||
|
cx = b[:, 0].mean()
|
||||||
|
for g in groups:
|
||||||
|
if abs(cx - g[0]) < cluster_d:
|
||||||
|
g[1] = min(g[1], b[:, 1].min()); g[2] = max(g[2], b[:, 1].max())
|
||||||
|
g[3] = min(g[3], b[:, 0].min()); g[4] = max(g[4], b[:, 0].max())
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
groups.append([cx, b[:, 1].min(), b[:, 1].max(), b[:, 0].min(), b[:, 0].max()])
|
||||||
|
|
||||||
|
for cx, gy1, gy2, gx1, gx2 in groups:
|
||||||
|
x1, y1 = max(0, int(gx1)), max(0, int(gy1))
|
||||||
|
x2, y2 = min(W, int(gx2)), min(H, int(gy2))
|
||||||
|
if x2 - x1 < 10 or y2 - y1 < 50:
|
||||||
|
continue
|
||||||
|
crop = image[y1:y2, x1:x2]
|
||||||
|
_, chw = crop.shape[:2]
|
||||||
|
# 竖排(上→下)需逆时针转90°: 顺时针会字头朝左、字序反转, rec 无法识别
|
||||||
|
r90 = cv2.rotate(crop, cv2.ROTATE_90_COUNTERCLOCKWISE)
|
||||||
|
big = cv2.resize(r90, None, fx=2, fy=2, interpolation=cv2.INTER_CUBIC)
|
||||||
|
# 旋转后为横排: 列投影切字(按字高比例合并断笔) -> 逐字识别。
|
||||||
|
# 整列 rec + 均分会把毛笔大字切成两半输出, 这里用真实字块边界。
|
||||||
|
gb = cv2.cvtColor(big, cv2.COLOR_RGB2GRAY)
|
||||||
|
col_dark = (gb < 160).sum(axis=0)
|
||||||
|
blocks, in_b, b0 = [], False, 0
|
||||||
|
for x, d in enumerate(col_dark):
|
||||||
|
if d > 2 and not in_b:
|
||||||
|
in_b, b0 = True, x
|
||||||
|
elif d <= 2 and in_b:
|
||||||
|
in_b = False
|
||||||
|
if x - b0 >= 2:
|
||||||
|
blocks.append([b0, x])
|
||||||
|
if in_b:
|
||||||
|
blocks.append([b0, len(col_dark)])
|
||||||
|
if not blocks:
|
||||||
|
continue
|
||||||
|
char_h = big.shape[0] # 旋转后字高 ≈ 原列宽, 作为切字尺子
|
||||||
|
gap_min = max(2, int(char_h * 0.2)) # 断笔间隙 <= 该值 -> 同字, 合并
|
||||||
|
merged = [blocks[0]]
|
||||||
|
for blk in blocks[1:]:
|
||||||
|
if blk[0] - merged[-1][1] <= gap_min:
|
||||||
|
merged[-1][1] = blk[1]
|
||||||
|
else:
|
||||||
|
merged.append(blk)
|
||||||
|
|
||||||
|
# 字宽尺子: 合并后块宽的中位数(断笔已合并, 单字块为主)。
|
||||||
|
# 竖排字宽不等于列宽(Det 框可能框住1~2列), 不能用 char_h 直接当字宽;
|
||||||
|
# 连笔列会合并成单个巨型块污染中位数, 字宽也不可能超过列宽, 故取上界
|
||||||
|
widths = sorted(b[1] - b[0] for b in merged)
|
||||||
|
w_med = widths[len(widths) // 2] if widths else char_h
|
||||||
|
w_med = max(2, min(w_med, int(0.9 * char_h)))
|
||||||
|
|
||||||
|
# 断笔残块并入相邻块: 飞白/横笔间隙切出的残块明显窄于字宽,
|
||||||
|
# 合并后按字宽整块识别, 避免半个字独立成框
|
||||||
|
i = 0
|
||||||
|
while i < len(merged):
|
||||||
|
if merged[i][1] - merged[i][0] < 0.65 * w_med:
|
||||||
|
left_gap = merged[i][0] - merged[i - 1][1] if i > 0 else float("inf")
|
||||||
|
right_gap = merged[i + 1][0] - merged[i][1] if i < len(merged) - 1 else float("inf")
|
||||||
|
if left_gap <= right_gap and i > 0:
|
||||||
|
merged[i - 1][1] = merged[i][1]
|
||||||
|
del merged[i]
|
||||||
|
i -= 1
|
||||||
|
elif i < len(merged) - 1:
|
||||||
|
merged[i][1] = merged[i + 1][1]
|
||||||
|
del merged[i + 1]
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# 超宽块拆开: 合并后宽 > 1.4×字宽说明粘了多个字, 在最大间隙处递归拆
|
||||||
|
def split_wide(blk, subs):
|
||||||
|
if blk[1] - blk[0] <= 1.4 * w_med or len(subs) < 2:
|
||||||
|
return [blk]
|
||||||
|
gaps = [(subs[i + 1][0] - subs[i][1], i) for i in range(len(subs) - 1)]
|
||||||
|
gi = max(range(len(gaps)), key=lambda i: gaps[i][0])
|
||||||
|
if gaps[gi][0] < 0.2 * w_med:
|
||||||
|
return [blk]
|
||||||
|
cut = (subs[gi][1] + subs[gi + 1][0]) // 2
|
||||||
|
left = [blk[0], cut]; right = [cut, blk[1]]
|
||||||
|
return (split_wide(left, [s for s in subs if s[1] <= cut])
|
||||||
|
+ split_wide(right, [s for s in subs if s[0] >= cut]))
|
||||||
|
|
||||||
|
final_blocks = []
|
||||||
|
for blk in merged:
|
||||||
|
subs = [s for s in blocks if s[0] >= blk[0] - 1 and s[1] <= blk[1] + 1]
|
||||||
|
final_blocks.extend(split_wide(blk, subs))
|
||||||
|
new_items = []
|
||||||
|
for bx0, bx1 in final_blocks:
|
||||||
|
if bx1 - bx0 < 8:
|
||||||
|
continue
|
||||||
|
# 连笔/倾斜导致投影切不开的超宽块: 按字宽等份切开, 逐份识别
|
||||||
|
if bx1 - bx0 > 1.4 * w_med:
|
||||||
|
n = max(2, round((bx1 - bx0) / w_med))
|
||||||
|
cuts = [bx0 + (bx1 - bx0) * i // n for i in range(n + 1)]
|
||||||
|
blocks_2 = [[cuts[i], cuts[i + 1]] for i in range(n)]
|
||||||
|
else:
|
||||||
|
blocks_2 = [[bx0, bx1]]
|
||||||
|
for cx0, cx1 in blocks_2:
|
||||||
|
r = self.engine(big[:, cx0:cx1], use_det=False, use_cls=False, use_rec=True)
|
||||||
|
txt = "".join(r.txts) if r.txts else ""
|
||||||
|
if not txt:
|
||||||
|
continue
|
||||||
|
# 逆时针旋转90°后: big 的 x' 方向(宽2×chh)对应 crop 的 y 方向,
|
||||||
|
# y' 方向(高2×chw)对应 crop 的 x 方向。
|
||||||
|
# crop(x_c, y_c) = (chw-1-y'/2, x'/2), 字块占满 y' 全高 -> x_c 全宽
|
||||||
|
oy1 = y1 + cx0 / 2; oy2 = y1 + cx1 / 2
|
||||||
|
ox1 = x1; ox2 = x1 + chw - 1
|
||||||
|
coords = [[int(ox1), int(oy1)], [int(ox2), int(oy1)],
|
||||||
|
[int(ox2), int(oy2)], [int(ox1), int(oy2)]]
|
||||||
|
cxx, cyy = center(coords)
|
||||||
|
if any(abs(cxx - center(c)[0]) <= 10 and abs(cyy - center(c)[1]) <= 10
|
||||||
|
for _, _, c in orig_items + new_items):
|
||||||
|
continue
|
||||||
|
new_items.append((txt, 0.5, coords))
|
||||||
|
if new_items:
|
||||||
|
orig_items = orig_items + new_items
|
||||||
|
logger.info(f"竖排长框旋转补充 {len(new_items)} 字 (列x~{cx:.0f})")
|
||||||
|
|
||||||
|
# 主流程整列条目若被逐字补充充分覆盖(>=2个字且覆盖>=50%面积),
|
||||||
|
# 则丢弃, 只保留字级框, 避免前端画出一堆叠加框
|
||||||
|
n_new = len(new_items)
|
||||||
|
if n_new:
|
||||||
|
main_part = orig_items[:len(orig_items) - n_new]
|
||||||
|
new_coords = [c for _, _, c in new_items]
|
||||||
|
|
||||||
|
def covered(item):
|
||||||
|
t, s, c = item
|
||||||
|
if not isinstance(t, str) or not t:
|
||||||
|
return False
|
||||||
|
x0, y0 = min(p[0] for p in c), min(p[1] for p in c)
|
||||||
|
x1, y1 = max(p[0] for p in c), max(p[1] for p in c)
|
||||||
|
area = (x1 - x0) * (y1 - y0)
|
||||||
|
if area <= 0:
|
||||||
|
return False
|
||||||
|
hits, hit_area = 0, 0
|
||||||
|
for nc in new_coords:
|
||||||
|
nx0, ny0 = min(p[0] for p in nc), min(p[1] for p in nc)
|
||||||
|
nx1, ny1 = max(p[0] for p in nc), max(p[1] for p in nc)
|
||||||
|
ix0, iy0 = max(x0, nx0), max(y0, ny0)
|
||||||
|
ix1, iy1 = min(x1, nx1), min(y1, ny1)
|
||||||
|
if ix1 > ix0 and iy1 > iy0:
|
||||||
|
hits += 1
|
||||||
|
hit_area += (ix1 - ix0) * (iy1 - iy0)
|
||||||
|
return hits >= 2 and hit_area >= 0.5 * area
|
||||||
|
|
||||||
|
main_part = [item for item in main_part if not covered(item)]
|
||||||
|
|
||||||
|
# 列内相对过滤: 明显矮于同列中位的 0.5 分字块是残笔/等份碎片, 删除
|
||||||
|
if len(new_items) >= 3:
|
||||||
|
by_col = {}
|
||||||
|
for it in new_items:
|
||||||
|
cxx = sum(p[0] for p in it[2]) / len(it[2])
|
||||||
|
by_col.setdefault(round(cxx / 30), []).append(it)
|
||||||
|
for key, its in by_col.items():
|
||||||
|
if len(its) < 3:
|
||||||
|
continue
|
||||||
|
hs = sorted(max(p[1] for p in c) - min(p[1] for p in c) for _, _, c in its)
|
||||||
|
med = hs[len(hs) // 2]
|
||||||
|
by_col[key] = [it for it in its
|
||||||
|
if max(p[1] for p in it[2]) - min(p[1] for p in it[2]) >= med * 0.5]
|
||||||
|
new_items = [it for its in by_col.values() for it in its]
|
||||||
|
|
||||||
|
orig_items = main_part + new_items
|
||||||
|
|
||||||
|
result.word_results = tuple(
|
||||||
|
(t, s, c) if isinstance(t, str) else (t[0], t[1], t[2])
|
||||||
|
for t, s, c in orig_items
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def process_with_vertical(self, image: np.ndarray) -> RapidOCROutput:
|
||||||
|
"""标准 OCR + 边缘/竖排文字补充通道(纯 CV + 长框旋转识别,合并检测框与文本)"""
|
||||||
|
result = self.process(image)
|
||||||
|
if not self.enable_vertical:
|
||||||
|
return result
|
||||||
|
main_boxes = list(result.boxes) if result.boxes is not None else []
|
||||||
|
extra = self._cv_supplement(image, main_boxes)
|
||||||
|
if extra:
|
||||||
|
merged = []
|
||||||
|
for box, text in extra:
|
||||||
|
cx, cy = box[:, 0].mean(), box[:, 1].mean()
|
||||||
|
# 中心落在主流程框内则跳过
|
||||||
|
if any(b[:, 0].min() - 4 <= cx <= b[:, 0].max() + 4
|
||||||
|
and b[:, 1].min() - 4 <= cy <= b[:, 1].max() + 4 for b in main_boxes):
|
||||||
|
continue
|
||||||
|
# 补充块之间去重(多阈值可能检出同一区域)
|
||||||
|
if any(abs(cx - mx) < 15 and abs(cy - my) < 15 for _, _, _, mx, my in merged):
|
||||||
|
continue
|
||||||
|
coords = [[int(p[0]), int(p[1])] for p in box]
|
||||||
|
merged.append((text, 1.0, coords, cx, cy))
|
||||||
|
if merged:
|
||||||
|
existing = list(result.word_results) if result.word_results else []
|
||||||
|
result.word_results = tuple(existing + [(t, s, c) for t, s, c, _, _ in merged])
|
||||||
|
logger.info(f"竖排通道补充 {len(merged)} 条: {[t for t, _, _, _, _ in merged]}")
|
||||||
|
# 竖排长框旋转补充(恢复超长行识别丢字)
|
||||||
|
result = self._vertical_longbox_supplement(image, result)
|
||||||
|
return result
|
||||||
|
|
||||||
@@ -0,0 +1,558 @@
|
|||||||
|
"""
|
||||||
|
OCR Internal Service
|
||||||
|
|
||||||
|
提供标准整图 OCR 识别服务。
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
import asyncio
|
||||||
|
from typing import List, Optional
|
||||||
|
from fastapi import FastAPI, File, UploadFile
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from PIL import Image
|
||||||
|
import numpy as np
|
||||||
|
from datetime import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from config.config import settings
|
||||||
|
from engine.ocr_engine import OCRProcessor
|
||||||
|
|
||||||
|
# 配置日志
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG if settings.debug else logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
app = FastAPI(title="OCR Internal Service", version="2.0.0")
|
||||||
|
|
||||||
|
# 初始化OCR处理器
|
||||||
|
ocr = OCRProcessor(
|
||||||
|
use_det=settings.ocr_use_det,
|
||||||
|
use_cls=settings.ocr_use_cls,
|
||||||
|
use_rec=settings.ocr_use_rec,
|
||||||
|
model_dir=settings.ocr_model_dir,
|
||||||
|
text_score=settings.ocr_text_score,
|
||||||
|
max_side_len=settings.ocr_max_side_len,
|
||||||
|
det_limit_side_len=settings.ocr_det_limit_side_len,
|
||||||
|
det_box_thresh=settings.ocr_det_box_thresh,
|
||||||
|
det_thresh=settings.ocr_det_thresh,
|
||||||
|
rec_batch_num=settings.ocr_rec_batch_num,
|
||||||
|
device=settings.ocr_device,
|
||||||
|
device_id=settings.ocr_device_id,
|
||||||
|
intra_threads=settings.ocr_intra_threads,
|
||||||
|
inter_threads=settings.ocr_inter_threads,
|
||||||
|
enable_vertical=settings.ocr_enable_vertical,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 响应模型 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TextRegion(BaseModel):
|
||||||
|
confidence: float
|
||||||
|
text: str
|
||||||
|
text_region: List[List[int]] # [[x1,y1], [x2,y2], [x3,y3], [x4,y4]]
|
||||||
|
|
||||||
|
|
||||||
|
class OCRResponse(BaseModel):
|
||||||
|
code: int
|
||||||
|
message: str
|
||||||
|
data: Optional[List[TextRegion]] = None
|
||||||
|
timestamp: str
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 辅助函数 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _build_response(word_results) -> OCRResponse:
|
||||||
|
"""将 word_results 构建为响应,兼容嵌套和扁平两种格式"""
|
||||||
|
ocr_result = []
|
||||||
|
|
||||||
|
if not word_results:
|
||||||
|
return OCRResponse(
|
||||||
|
code=0, message="success", data=ocr_result,
|
||||||
|
timestamp=datetime.now().isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
for line in word_results:
|
||||||
|
if isinstance(line, (tuple, list)) and len(line) >= 3 and isinstance(line[0], str):
|
||||||
|
# 扁平 item: 单条 (text, conf, coords),line 本身就是 item
|
||||||
|
text, confidence, coords = line[0], line[1], line[2]
|
||||||
|
if coords is not None:
|
||||||
|
ocr_result.append(TextRegion(
|
||||||
|
confidence=confidence, text=text, text_region=coords,
|
||||||
|
))
|
||||||
|
elif isinstance(line, (tuple, list)):
|
||||||
|
# 嵌套结构: line = ((item1), (item2), ...)
|
||||||
|
for item in line:
|
||||||
|
if isinstance(item, (tuple, list)) and len(item) >= 3:
|
||||||
|
text, confidence, coords = item[0], item[1], item[2]
|
||||||
|
if coords is not None:
|
||||||
|
ocr_result.append(TextRegion(
|
||||||
|
confidence=confidence, text=text, text_region=coords,
|
||||||
|
))
|
||||||
|
|
||||||
|
return OCRResponse(
|
||||||
|
code=0,
|
||||||
|
message="success",
|
||||||
|
data=ocr_result,
|
||||||
|
timestamp=datetime.now().isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _error_response(msg: str) -> OCRResponse:
|
||||||
|
return OCRResponse(
|
||||||
|
code=500,
|
||||||
|
message=msg,
|
||||||
|
timestamp=datetime.now().isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── API 端点 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.get("/ocr_system/health")
|
||||||
|
async def health_check():
|
||||||
|
"""健康检查"""
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"service": "ocr",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"device": ocr.device,
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/ocr_system/ocr", response_model=OCRResponse)
|
||||||
|
async def ocr_recognition(file: UploadFile = File(...)):
|
||||||
|
"""上传图片文件并执行整图 OCR 识别。"""
|
||||||
|
try:
|
||||||
|
# 读取文件
|
||||||
|
image_bytes = await file.read()
|
||||||
|
image_file = io.BytesIO(image_bytes)
|
||||||
|
image = Image.open(image_file)
|
||||||
|
image_np = np.array(image)
|
||||||
|
|
||||||
|
logger.info(f"收到OCR请求: size={image_np.shape}")
|
||||||
|
|
||||||
|
# 预处理(原图直通:灰度化+CLAHE+锐化会损失边缘小字细节,已实测降低召回)
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
im_np = image_np
|
||||||
|
|
||||||
|
logger.info("使用标准OCR模式")
|
||||||
|
result = await loop.run_in_executor(None, ocr.process_with_vertical, im_np)
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
return _error_response("OCR处理失败")
|
||||||
|
|
||||||
|
return _build_response(result.word_results)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"OCR处理异常: {str(e)}", exc_info=True)
|
||||||
|
return _error_response(f"处理异常: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 可视化页面 ───────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.get("/ocr_system/viewer", response_class=HTMLResponse)
|
||||||
|
async def viewer():
|
||||||
|
"""OCR结果可视化页面"""
|
||||||
|
return HTMLResponse(content=VIEWER_HTML)
|
||||||
|
|
||||||
|
|
||||||
|
VIEWER_HTML = r"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>OCR 可视化工具</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #e0e0e0;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
background: #16213e;
|
||||||
|
padding: 12px 24px;
|
||||||
|
display: flex; align-items: center; gap: 16px;
|
||||||
|
border-bottom: 1px solid #0f3460;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.header h1 { font-size: 18px; font-weight: 600; }
|
||||||
|
/* ── 三栏布局 ── */
|
||||||
|
.main-layout {
|
||||||
|
display: flex; flex: 1; min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 左栏:控件 */
|
||||||
|
.panel-left {
|
||||||
|
width: 280px; min-width: 280px;
|
||||||
|
background: #16213e;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex; flex-direction: column; gap: 12px;
|
||||||
|
border-right: 1px solid #0f3460;
|
||||||
|
overflow-y: auto; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
font-size: 12px; font-weight: 600; color: #a0a0b0;
|
||||||
|
text-transform: uppercase; letter-spacing: 1px;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
.upload-zone {
|
||||||
|
border: 2px dashed #0f3460; border-radius: 10px;
|
||||||
|
padding: 24px 16px; text-align: center; cursor: pointer;
|
||||||
|
transition: all .2s;
|
||||||
|
}
|
||||||
|
.upload-zone:hover { border-color: #53d8fb; background: rgba(83,216,251,0.05); }
|
||||||
|
.upload-zone.dragover { border-color: #53d8fb; background: rgba(83,216,251,0.1); }
|
||||||
|
.upload-zone .icon { font-size: 32px; margin-bottom: 4px; }
|
||||||
|
.upload-zone .text { font-size: 13px; color: #a0a0b0; }
|
||||||
|
.upload-zone .hint { font-size: 11px; color: #606080; margin-top: 2px; }
|
||||||
|
.upload-zone input { display: none; }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 9px 14px; border: none; border-radius: 6px;
|
||||||
|
cursor: pointer; font-size: 13px; font-weight: 500;
|
||||||
|
transition: all .2s; width: 100%;
|
||||||
|
}
|
||||||
|
.btn-primary { background: #53d8fb; color: #1a1a2e; }
|
||||||
|
.btn-primary:hover { background: #7ce4ff; }
|
||||||
|
.btn-primary:disabled { background: #303050; color: #606080; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.status {
|
||||||
|
font-size: 12px; padding: 8px 10px; border-radius: 6px;
|
||||||
|
background: #0f3460; word-break: break-all;
|
||||||
|
}
|
||||||
|
.status.success { background: #0a3d2e; color: #4ade80; }
|
||||||
|
.status.error { background: #3d0a0a; color: #f87171; }
|
||||||
|
.status.loading { background: #3d2e0a; color: #fbbf24; }
|
||||||
|
|
||||||
|
.info-grid {
|
||||||
|
display: grid; grid-template-columns: 1fr 1fr; gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.info-item { background: #0f3460; padding: 6px 10px; border-radius: 4px; }
|
||||||
|
.info-item .label { color: #606080; font-size: 10px; }
|
||||||
|
.info-item .value { color: #e0e0e0; font-weight: 500; }
|
||||||
|
|
||||||
|
/* 中栏:OCR 画布区 */
|
||||||
|
.panel-center {
|
||||||
|
flex: 1; display: flex; flex-direction: column;
|
||||||
|
background: #0d1117; min-width: 0;
|
||||||
|
}
|
||||||
|
.canvas-pane {
|
||||||
|
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
}
|
||||||
|
.canvas-pane .pane-label {
|
||||||
|
position: absolute; top: 6px; left: 8px;
|
||||||
|
font-size: 11px; padding: 2px 6px; border-radius: 3px;
|
||||||
|
background: rgba(0,0,0,0.6); color: #a0a0b0;
|
||||||
|
z-index: 2; pointer-events: none;
|
||||||
|
}
|
||||||
|
.canvas-pane canvas {
|
||||||
|
max-width: 100%; max-height: 100%;
|
||||||
|
object-fit: contain; border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右栏:文本列表 */
|
||||||
|
.panel-right {
|
||||||
|
width: 280px; min-width: 280px;
|
||||||
|
background: #16213e; border-left: 1px solid #0f3460;
|
||||||
|
display: flex; flex-direction: column; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.panel-right .section-title { padding: 16px 16px 8px; }
|
||||||
|
#text-list {
|
||||||
|
flex: 1; overflow-y: auto; padding: 0 12px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.text-item {
|
||||||
|
padding: 6px 8px; margin-bottom: 4px;
|
||||||
|
background: #0f3460; border-radius: 4px;
|
||||||
|
cursor: pointer; transition: all .12s;
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
}
|
||||||
|
.text-item:hover { background: #1a3a70; }
|
||||||
|
.text-item.highlight { background: #1a4a80; border: 1px solid #53d8fb; }
|
||||||
|
.text-item .text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.text-item .conf {
|
||||||
|
font-size: 10px; color: #53d8fb; margin-left: 8px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center; color: #606080;
|
||||||
|
position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%);
|
||||||
|
}
|
||||||
|
.empty-state .icon { font-size: 48px; margin-bottom: 8px; }
|
||||||
|
.empty-state p { font-size: 14px; }
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.panel-right { width: 220px; min-width: 220px; }
|
||||||
|
.panel-left { width: 240px; min-width: 240px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>OCR 可视化工具</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="main-layout">
|
||||||
|
<!-- 左栏 -->
|
||||||
|
<div class="panel-left">
|
||||||
|
<div class="section-title">上传图片</div>
|
||||||
|
<div class="upload-zone" id="upload-zone">
|
||||||
|
<div class="icon">+</div>
|
||||||
|
<div class="text">点击或拖拽上传</div>
|
||||||
|
<div class="hint">JPG / PNG / BMP / TIFF</div>
|
||||||
|
<input type="file" id="file-input" accept="image/*">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-primary" id="btn-recognize" disabled>开始识别</button>
|
||||||
|
|
||||||
|
<div id="status-area"></div>
|
||||||
|
|
||||||
|
<div class="info-grid" id="info-grid" style="display:none;">
|
||||||
|
<div class="info-item"><div class="label">识别数</div><div class="value" id="info-count">-</div></div>
|
||||||
|
<div class="info-item"><div class="label">耗时</div><div class="value" id="info-elapsed">-</div></div>
|
||||||
|
<div class="info-item"><div class="label">尺寸</div><div class="value" id="info-size">-</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 中栏 -->
|
||||||
|
<div class="panel-center" id="panel-center">
|
||||||
|
<div class="canvas-pane" id="pane-text">
|
||||||
|
<div class="pane-label">文字文本框</div>
|
||||||
|
<div class="empty-state" id="empty-text">
|
||||||
|
<div class="icon">T</div>
|
||||||
|
<p>上传图片开始识别</p>
|
||||||
|
</div>
|
||||||
|
<canvas id="text-canvas" style="display:none;"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右栏 -->
|
||||||
|
<div class="panel-right">
|
||||||
|
<div class="section-title">识别文本</div>
|
||||||
|
<div id="text-list">
|
||||||
|
<div style="color:#606080;text-align:center;padding-top:40px;">暂无识别结果</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let ocrData = [];
|
||||||
|
let imageElement = null;
|
||||||
|
let isProcessing = false;
|
||||||
|
|
||||||
|
const uploadZone = document.getElementById('upload-zone');
|
||||||
|
const fileInput = document.getElementById('file-input');
|
||||||
|
const btnRecognize = document.getElementById('btn-recognize');
|
||||||
|
const textCanvas = document.getElementById('text-canvas');
|
||||||
|
const textList = document.getElementById('text-list');
|
||||||
|
const statusArea = document.getElementById('status-area');
|
||||||
|
const infoGrid = document.getElementById('info-grid');
|
||||||
|
const emptyText = document.getElementById('empty-text');
|
||||||
|
|
||||||
|
// ── 文件选择 ──
|
||||||
|
uploadZone.addEventListener('click', () => fileInput.click());
|
||||||
|
fileInput.addEventListener('change', (e) => handleFile(e.target.files[0]));
|
||||||
|
uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.classList.add('dragover'); });
|
||||||
|
uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('dragover'));
|
||||||
|
uploadZone.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault(); uploadZone.classList.remove('dragover');
|
||||||
|
if (e.dataTransfer.files[0]) handleFile(e.dataTransfer.files[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleFile(file) {
|
||||||
|
if (!file || !file.type.startsWith('image/')) {
|
||||||
|
showStatus('请选择图片文件', 'error'); return;
|
||||||
|
}
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
imageElement = new Image();
|
||||||
|
imageElement.onload = () => {
|
||||||
|
ocrData = [];
|
||||||
|
textList.innerHTML = '<div style="color:#606080;text-align:center;padding-top:40px;">暂无识别结果</div>';
|
||||||
|
infoGrid.style.display = 'none';
|
||||||
|
drawBaseImages();
|
||||||
|
btnRecognize.disabled = false;
|
||||||
|
showStatus(`已加载: ${file.name} (${imageElement.width}x${imageElement.height})`, 'success');
|
||||||
|
};
|
||||||
|
imageElement.src = e.target.result;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawBaseImages() {
|
||||||
|
if (!imageElement) return;
|
||||||
|
drawCanvas(textCanvas, emptyText);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawCanvas(canvas, emptyEl) {
|
||||||
|
const pane = canvas.parentElement;
|
||||||
|
const maxW = pane.clientWidth * 0.92;
|
||||||
|
const maxH = pane.clientHeight * 0.88;
|
||||||
|
let w = imageElement.width, h = imageElement.height;
|
||||||
|
const scale = Math.min(maxW / w, maxH / h, 1);
|
||||||
|
w = Math.floor(w * scale); h = Math.floor(h * scale);
|
||||||
|
|
||||||
|
canvas.width = w; canvas.height = h;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.drawImage(imageElement, 0, 0, w, h);
|
||||||
|
|
||||||
|
canvas.style.display = 'block';
|
||||||
|
if (emptyEl) emptyEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OCR ──
|
||||||
|
btnRecognize.addEventListener('click', async () => {
|
||||||
|
if (!imageElement || isProcessing) return;
|
||||||
|
isProcessing = true;
|
||||||
|
btnRecognize.disabled = true;
|
||||||
|
btnRecognize.textContent = '识别中...';
|
||||||
|
showStatus('正在识别...', 'loading');
|
||||||
|
ocrData = [];
|
||||||
|
textList.innerHTML = '<div style="color:#606080;text-align:center;padding-top:40px;">识别中...</div>';
|
||||||
|
infoGrid.style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const blob = await fetch(imageElement.src).then(r => r.blob());
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', blob, 'image.png');
|
||||||
|
|
||||||
|
const t0 = performance.now();
|
||||||
|
const resp = await fetch('/ocr_system/ocr', { method: 'POST', body: formData });
|
||||||
|
const t1 = performance.now();
|
||||||
|
const json = await resp.json();
|
||||||
|
|
||||||
|
if (json.code !== 0) {
|
||||||
|
showStatus(`识别失败: ${json.message}`, 'error'); return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ocrData = json.data || [];
|
||||||
|
const elapsed = (t1 - t0).toFixed(0);
|
||||||
|
|
||||||
|
document.getElementById('info-count').textContent = ocrData.length;
|
||||||
|
document.getElementById('info-elapsed').textContent = `${elapsed}ms`;
|
||||||
|
document.getElementById('info-size').textContent = `${imageElement.width}x${imageElement.height}`;
|
||||||
|
infoGrid.style.display = 'grid';
|
||||||
|
|
||||||
|
redrawTextCanvas();
|
||||||
|
renderTextList();
|
||||||
|
|
||||||
|
if (ocrData.length > 0) {
|
||||||
|
showStatus(`完成: ${ocrData.length}条 / ${elapsed}ms`, 'success');
|
||||||
|
} else {
|
||||||
|
showStatus('未识别到文字', 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showStatus(`请求失败: ${err.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
isProcessing = false;
|
||||||
|
btnRecognize.disabled = false;
|
||||||
|
btnRecognize.textContent = '开始识别';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 文字框画布 ──
|
||||||
|
function redrawTextCanvas() {
|
||||||
|
if (!imageElement) return;
|
||||||
|
drawCanvas(textCanvas, emptyText);
|
||||||
|
|
||||||
|
if (ocrData.length === 0) return;
|
||||||
|
|
||||||
|
const ctx = textCanvas.getContext('2d');
|
||||||
|
const scaleX = textCanvas.width / imageElement.width;
|
||||||
|
const scaleY = textCanvas.height / imageElement.height;
|
||||||
|
const lw = Math.max(1, 1.5 * (textCanvas.width / 800));
|
||||||
|
|
||||||
|
ocrData.forEach((item, i) => {
|
||||||
|
const coords = item.text_region;
|
||||||
|
if (!coords || coords.length < 4) return;
|
||||||
|
ctx.strokeStyle = `hsl(${(i * 37) % 360}, 70%, 65%)`;
|
||||||
|
ctx.lineWidth = lw;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(coords[0][0] * scaleX, coords[0][1] * scaleY);
|
||||||
|
for (let j = 1; j < coords.length; j++) {
|
||||||
|
ctx.lineTo(coords[j][0] * scaleX, coords[j][1] * scaleY);
|
||||||
|
}
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.stroke();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 文本列表 ──
|
||||||
|
function renderTextList() {
|
||||||
|
if (ocrData.length === 0) {
|
||||||
|
textList.innerHTML = '<div style="color:#606080;text-align:center;padding-top:40px;">未识别到文字</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
textList.innerHTML = ocrData.map((item, i) => `
|
||||||
|
<div class="text-item" data-index="${i}" onclick="highlightBox(${i})">
|
||||||
|
<span class="text">${escapeHtml(item.text)}</span>
|
||||||
|
<span class="conf">${(item.confidence * 100).toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
let highlightedIndex = -1;
|
||||||
|
function highlightBox(index) {
|
||||||
|
redrawTextCanvas();
|
||||||
|
const ctx = textCanvas.getContext('2d');
|
||||||
|
const scaleX = textCanvas.width / imageElement.width;
|
||||||
|
const scaleY = textCanvas.height / imageElement.height;
|
||||||
|
const coords = ocrData[index].text_region;
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#ff6b6b';
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(coords[0][0] * scaleX, coords[0][1] * scaleY);
|
||||||
|
for (let j = 1; j < coords.length; j++) {
|
||||||
|
ctx.lineTo(coords[j][0] * scaleX, coords[j][1] * scaleY);
|
||||||
|
}
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
document.querySelectorAll('.text-item').forEach(el => el.classList.remove('highlight'));
|
||||||
|
const target = document.querySelector(`.text-item[data-index="${index}"]`);
|
||||||
|
if (target) target.classList.add('highlight');
|
||||||
|
target?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = text;
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStatus(msg, type) {
|
||||||
|
statusArea.innerHTML = `<div class="status ${type}">${msg}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (imageElement && !isProcessing) {
|
||||||
|
redrawTextCanvas();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 启动入口 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
logger.info(f"启动OCR服务: {settings.host}:{settings.port}")
|
||||||
|
uvicorn.run(
|
||||||
|
"main:app",
|
||||||
|
host=settings.host,
|
||||||
|
port=settings.port,
|
||||||
|
reload=settings.debug
|
||||||
|
)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
rapidocr==3.8.1
|
||||||
|
opencv-python>=4.10.0.84
|
||||||
|
numpy>=2.1.2
|
||||||
|
pillow>=12.2.0
|
||||||
|
uvicorn>=0.32.0
|
||||||
|
fastapi>=0.115.3
|
||||||
|
pydantic>=2.9.2
|
||||||
|
pydantic-settings>=2.14.1
|
||||||
|
python-multipart
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
from PIL import Image
|
||||||
|
from PIL.Image import Image
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
def split_image(image_array: np.ndarray, dirt: int) -> (np.ndarray, np.ndarray):
|
||||||
|
# 确保图像是二维的灰度图
|
||||||
|
if len(image_array.shape) > 2:
|
||||||
|
# 如果是彩色图,转换为灰度图 (取平均值或者只取一个通道)
|
||||||
|
image_array = image_array.mean(axis=2) # 或者直接取第一个通道:image_array = image_array[:, :, 0]
|
||||||
|
|
||||||
|
# 将图片转为二值化(假设白色背景值为255,黑色文字值为0)
|
||||||
|
# 你可以根据图片实际情况调整阈值
|
||||||
|
_, binary_image = cv2.threshold(image_array, 127, 255, cv2.THRESH_BINARY)
|
||||||
|
|
||||||
|
# 寻找切割线
|
||||||
|
split_line = find_split_line(binary_image, dirt)
|
||||||
|
|
||||||
|
if dirt == 1:
|
||||||
|
# 竖着切
|
||||||
|
split1 = image_array[:, :split_line]
|
||||||
|
split2 = image_array[:, split_line:]
|
||||||
|
else:
|
||||||
|
# 横着切
|
||||||
|
split1 = image_array[:split_line, :]
|
||||||
|
split2 = image_array[split_line:, :]
|
||||||
|
|
||||||
|
return split1, split2
|
||||||
|
|
||||||
|
|
||||||
|
def find_split_line(image_array, dirt):
|
||||||
|
height, width = image_array.shape # 现在应该是 (height, width)
|
||||||
|
|
||||||
|
# 根据dirt值选择切割方向
|
||||||
|
if dirt == 1:
|
||||||
|
# 竖着切
|
||||||
|
middle = width // 2
|
||||||
|
for x in range(middle, width):
|
||||||
|
# 检查竖直方向的列是否全为空白
|
||||||
|
if np.all(image_array[:, x] > 200): # 假设200是空白阈值
|
||||||
|
return x
|
||||||
|
return middle # 如果找不到合适的切割线,返回中间
|
||||||
|
else:
|
||||||
|
# 横着切
|
||||||
|
middle = height // 2
|
||||||
|
for y in range(middle, height):
|
||||||
|
# 检查水平方向的行是否全为空白
|
||||||
|
if np.all(image_array[y, :] > 200): # 假设200是空白阈值
|
||||||
|
return y
|
||||||
|
return middle # 如果找不到合适的切割线,返回中间
|
||||||
|
|
||||||
|
|
||||||
|
async def image_split(image: np.ndarray, dirt: int):
|
||||||
|
part1, part2 = split_image(image, dirt)
|
||||||
|
# 将 NumPy 数组转换为 Pillow 图像对象
|
||||||
|
pil_image1 = Image.fromarray(part1)
|
||||||
|
pil_image2 = Image.fromarray(part2)
|
||||||
|
# 显示图像
|
||||||
|
pil_image1.show()
|
||||||
|
pil_image2.show()
|
||||||
|
return pil_image1, pil_image2
|
||||||
|
|
||||||
|
|
||||||
|
async def get_able_area(image, coords, text):
|
||||||
|
"""
|
||||||
|
根据给定的文本区域坐标,从图像中截取文本区域。
|
||||||
|
|
||||||
|
:param text:
|
||||||
|
:param image: 原始图像 (使用cv2.imread读取的图像)
|
||||||
|
:param coords: 文本区域的四个角坐标,格式为:
|
||||||
|
[[x1_top_left, y1_top_left],
|
||||||
|
[x2_top_right, y2_top_right],
|
||||||
|
[x3_bottom_right, y3_bottom_right],
|
||||||
|
[x4_bottom_left, y4_bottom_left]]
|
||||||
|
:return: 截取的文本区域图像
|
||||||
|
"""
|
||||||
|
# 将坐标转换为numpy数组并重新整理为 [[x1, y1], [x2, y2], [x3, y3], [x4, y4]] 的形式
|
||||||
|
pts = np.float32([coords[0], coords[1], coords[2], coords[3]])
|
||||||
|
|
||||||
|
# 计算变换后的目标矩形的宽和高
|
||||||
|
top_width = np.linalg.norm(np.array(coords[1]) - np.array(coords[0])) # 顶边长度
|
||||||
|
bottom_width = np.linalg.norm(np.array(coords[2]) - np.array(coords[3])) # 底边长度
|
||||||
|
height = np.linalg.norm(np.array(coords[2]) - np.array(coords[1])) # 高
|
||||||
|
|
||||||
|
# 取最大宽度作为目标宽度
|
||||||
|
max_width = max(top_width, bottom_width)
|
||||||
|
|
||||||
|
# 目标矩形的四个角点
|
||||||
|
dst = np.float32([[0, 0], [max_width - 1, 0], [max_width - 1, height - 1], [0, height - 1]])
|
||||||
|
|
||||||
|
# 计算透视变换矩阵
|
||||||
|
M = cv2.getPerspectiveTransform(pts, dst)
|
||||||
|
|
||||||
|
# 进行透视变换,获取裁剪后的图像
|
||||||
|
cropped_image = cv2.warpPerspective(image, M, (int(max_width), int(height)))
|
||||||
|
await get_box_size(cropped_image, text)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_box_size(image, text):
|
||||||
|
# 预处理图像
|
||||||
|
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
||||||
|
_, binary = cv2.threshold(gray, 128, 255, cv2.THRESH_BINARY_INV)
|
||||||
|
|
||||||
|
# 查找轮廓
|
||||||
|
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
|
||||||
|
# 存储所有文字大小
|
||||||
|
text_sizes = []
|
||||||
|
|
||||||
|
# 遍历每个轮廓
|
||||||
|
for cnt in contours:
|
||||||
|
# 获取包围盒的坐标和尺寸
|
||||||
|
x, y, w, h = cv2.boundingRect(cnt)
|
||||||
|
|
||||||
|
# 计算文字区域的面积
|
||||||
|
area = cv2.contourArea(cnt)
|
||||||
|
|
||||||
|
# 假设每个文字区域有一个平均字符数 (可以根据实际情况调整)
|
||||||
|
avg_char_width = w // len(text) # 假设每行有10个字符
|
||||||
|
avg_char_height = h // 1 # 假设每行有1个字符
|
||||||
|
|
||||||
|
# 计算字符的大小
|
||||||
|
character_size = (avg_char_width, avg_char_height)
|
||||||
|
text_sizes.append(character_size)
|
||||||
|
|
||||||
|
# 可视化结果
|
||||||
|
cv2.rectangle(image, (x, y), (x + h, y + h), (0, 255, 0), 2)
|
||||||
|
|
||||||
|
# 计算平均字符大小
|
||||||
|
if len(text_sizes) > 0:
|
||||||
|
avg_char_width = sum(size[0] for size in text_sizes) / len(text_sizes)
|
||||||
|
avg_char_height = sum(size[1] for size in text_sizes) / len(text_sizes)
|
||||||
|
print(f"平均字符宽度: {avg_char_width}, 平均字符高度: {avg_char_height}")
|
||||||
|
else:
|
||||||
|
print("未检测到文字区域")
|
||||||
|
|
||||||
|
# 显示结果图像
|
||||||
|
im = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
||||||
|
il_image1 = Image.fromarray(im)
|
||||||
|
il_image1.show()
|
||||||
|
|
||||||
|
#增强图片
|
||||||
|
async def positive_image(image):
|
||||||
|
# 1. 灰度化(注意:输入为 RGB,须用 COLOR_RGB2GRAY;误用 BGR 会翻转红蓝通道,彩色文字/背景灰度失真)
|
||||||
|
gray_img = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
|
||||||
|
|
||||||
|
# 2. 对比度增强(CLAHE)
|
||||||
|
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
|
||||||
|
enhanced = clahe.apply(gray_img)
|
||||||
|
|
||||||
|
# 3. 锐化(可选)
|
||||||
|
blurred = cv2.GaussianBlur(enhanced, (0, 0), 2)
|
||||||
|
sharpened = cv2.addWeighted(enhanced, 1.5, blurred, -0.5, 0)
|
||||||
|
|
||||||
|
return sharpened
|
||||||
|
|
||||||
|
|
||||||
|
def draw_text_box(image, coords):
|
||||||
|
pos1 = coords[0]
|
||||||
|
pos2 = coords[2]
|
||||||
|
x1 = int(pos1[0])
|
||||||
|
y1 = int(pos1[1])
|
||||||
|
x2 = int(pos2[0])
|
||||||
|
y2 = int(pos2[1])
|
||||||
|
# 可视化结果
|
||||||
|
cv2.rectangle(image, (x1, y1), (x2, y2), (0, 255, 0), 1)
|
||||||
|
|
||||||
|
|
||||||
|
# 倾斜矫正
|
||||||
|
async def tilt_correction(image):
|
||||||
|
# 转为灰度图
|
||||||
|
gray_img = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
||||||
|
|
||||||
|
# 二值化
|
||||||
|
_, binary_img = cv2.threshold(gray_img, 128, 255, cv2.THRESH_BINARY)
|
||||||
|
|
||||||
|
# 边缘检测
|
||||||
|
edges = cv2.Canny(binary_img, 50, 150)
|
||||||
|
|
||||||
|
# 提取轮廓
|
||||||
|
contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
|
||||||
|
if len(contours) == 0:
|
||||||
|
print("No contours found.")
|
||||||
|
return image # 没有找到轮廓,返回原图
|
||||||
|
|
||||||
|
# 找到最大轮廓
|
||||||
|
largest_contour = max(contours, key=cv2.contourArea)
|
||||||
|
|
||||||
|
# 计算最小外接矩形的角度
|
||||||
|
rect = cv2.minAreaRect(largest_contour)
|
||||||
|
angle = rect[-1]
|
||||||
|
|
||||||
|
# 修正角度
|
||||||
|
if angle < -45:
|
||||||
|
angle = -(90 + angle) # 修正角度,使其在 [-90, 90] 范围内
|
||||||
|
else:
|
||||||
|
angle = -angle # 直接取反号
|
||||||
|
|
||||||
|
# 如果角度接近0度,则不进行旋转
|
||||||
|
if abs(angle) < 0.5:
|
||||||
|
return image # 角度非常小,几乎不需要旋转
|
||||||
|
|
||||||
|
# 旋转图像
|
||||||
|
(h, w) = image.shape[:2] # 使用原始图像的形状
|
||||||
|
center = (w // 2, h // 2)
|
||||||
|
M = cv2.getRotationMatrix2D(center, angle, 1.0)
|
||||||
|
rotated_img = cv2.warpAffine(image, M, (w, h), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE)
|
||||||
|
|
||||||
|
return rotated_img
|
||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
import math
|
||||||
|
from PIL import Image as pli
|
||||||
|
from PIL.Image import Image
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
def distance(p1, p2):
|
||||||
|
return math.sqrt((p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2)
|
||||||
|
|
||||||
|
|
||||||
|
def find_edge_lengths(points):
|
||||||
|
# 计算所有可能的边的长度
|
||||||
|
edge_lengths = []
|
||||||
|
for i in range(len(points)):
|
||||||
|
for j in range(i + 1, len(points)):
|
||||||
|
edge_lengths.append(distance(points[i], points[j]))
|
||||||
|
return edge_lengths
|
||||||
|
|
||||||
|
|
||||||
|
def find_opposite_edges(edge_lengths):
|
||||||
|
# 对边的长度应该是成对的
|
||||||
|
edge_lengths.sort()
|
||||||
|
return edge_lengths
|
||||||
|
|
||||||
|
|
||||||
|
async def determine_orientation(points):
|
||||||
|
# 提取所有点的x和y坐标
|
||||||
|
x_coords = [p[0] for p in points]
|
||||||
|
y_coords = [p[1] for p in points]
|
||||||
|
|
||||||
|
# 计算x和y坐标的最大值和最小值
|
||||||
|
x_max, x_min = max(x_coords), min(x_coords)
|
||||||
|
y_max, y_min = max(y_coords), min(y_coords)
|
||||||
|
|
||||||
|
# 计算宽度和高度
|
||||||
|
width = x_max - x_min
|
||||||
|
height = y_max - y_min
|
||||||
|
|
||||||
|
edge_lengths = find_edge_lengths(points)
|
||||||
|
# 计算对边长度
|
||||||
|
opposite_edges = find_opposite_edges(edge_lengths)
|
||||||
|
|
||||||
|
# 判断竖着还是横着
|
||||||
|
if height > width:
|
||||||
|
return 1, opposite_edges
|
||||||
|
else:
|
||||||
|
return 0, opposite_edges
|
||||||
|
|
||||||
|
|
||||||
|
async def split_text_box(direction, opposite_edges, text_content, points):
|
||||||
|
# 左上,右上,右下,左下
|
||||||
|
point_lu_x, point_lu_y = points[0]
|
||||||
|
point_ru_x, point_ru_y = points[1]
|
||||||
|
point_rd_x, point_rd_y = points[2]
|
||||||
|
point_ld_x, point_ld_y = points[3]
|
||||||
|
if direction == 0:
|
||||||
|
# 文本框 长度
|
||||||
|
box_length_l = point_ru_x - point_lu_x
|
||||||
|
box_length_r = point_ld_x - point_rd_x
|
||||||
|
text_length = len(text_content)
|
||||||
|
each_text_size = (box_length_l + box_length_r) / 2 / text_length
|
||||||
|
min_size = min(opposite_edges)
|
||||||
|
cha = min_size / 6
|
||||||
|
print(f'差值:{cha}')
|
||||||
|
word_box = []
|
||||||
|
point_lu_x_sp = point_lu_x
|
||||||
|
point_ru_x_sp = point_ru_x
|
||||||
|
point_rd_x_sp = point_rd_x
|
||||||
|
point_ld_x_sp = point_ld_x
|
||||||
|
for char in text_content:
|
||||||
|
word_const = {
|
||||||
|
"text": char,
|
||||||
|
"box": [[point_lu_x_sp, point_lu_y],
|
||||||
|
[point_ru_x_sp, point_ru_y],
|
||||||
|
[point_rd_x_sp, point_rd_y],
|
||||||
|
[point_ld_x_sp, point_ld_y]]
|
||||||
|
}
|
||||||
|
point_lu_x_sp += min_size + cha
|
||||||
|
point_ru_x_sp += min_size + cha
|
||||||
|
point_rd_x_sp += min_size + cha
|
||||||
|
point_ld_x_sp += min_size + cha
|
||||||
|
word_box.append(word_const)
|
||||||
|
return word_box
|
||||||
|
elif direction == 1:
|
||||||
|
# 文本框 长度
|
||||||
|
box_length_l = point_ru_y - point_lu_y
|
||||||
|
box_length_r = point_ld_y - point_rd_y
|
||||||
|
text_length = len(text_content)
|
||||||
|
each_text_size = (box_length_l + box_length_r) / 2 / text_length
|
||||||
|
min_size = min(opposite_edges)
|
||||||
|
cha = min_size / 5
|
||||||
|
print(f'差值:{cha}')
|
||||||
|
word_box = []
|
||||||
|
point_lu_y_sp = point_lu_y
|
||||||
|
point_ru_y_sp = point_ru_y
|
||||||
|
point_rd_y_sp = point_rd_y
|
||||||
|
point_ld_y_sp = point_ld_y
|
||||||
|
for char in text_content:
|
||||||
|
word_const = {
|
||||||
|
"text": char,
|
||||||
|
"box": [[point_lu_x, point_lu_y_sp],
|
||||||
|
[point_ru_x, point_ru_y_sp],
|
||||||
|
[point_rd_x, point_rd_y_sp],
|
||||||
|
[point_ld_x, point_ld_y_sp]]
|
||||||
|
}
|
||||||
|
point_lu_y_sp += min_size + cha
|
||||||
|
point_ru_y_sp += min_size + cha
|
||||||
|
point_rd_y_sp += min_size + cha
|
||||||
|
point_ld_y_sp += min_size + cha
|
||||||
|
word_box.append(word_const)
|
||||||
|
return word_box
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user