首次提交
This commit is contained in:
1 parent
680e028a0b
commit
9a479b6832
10 files changed
+2735
No files matched your search
@@ -174,3 +174,4 @@ cython_debug/
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
/CLAUDE.md
|
||||
Generated
+10
@@ -0,0 +1,10 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 已忽略包含查询文件的默认文件夹
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
@@ -0,0 +1,1000 @@
|
||||
# RapidOCR 参数详解(结合本项目 RapidOCR 3.8.1)
|
||||
|
||||
> 本文面向 `D:\PycharmProjects\ocr-server` 的实际使用与调优。
|
||||
>
|
||||
> 核验环境(2026-07-17):
|
||||
>
|
||||
> - `rapidocr==3.8.1`
|
||||
> - `onnxruntime-directml==1.23.0`
|
||||
> - 项目入口:`engine/ocr_engine.py`
|
||||
> - 本项目目前使用整图 OCR,不再使用分块模式
|
||||
>
|
||||
> **重要:** RapidOCR 官方 `main/latest` 文档目前主要描述 3.9.x。3.9.0 起默认模型和部分枚举发生变化,不能直接把新文档中的所有默认值套到本项目 3.8.1。
|
||||
|
||||
---
|
||||
|
||||
## 1. 先理解 RapidOCR 的参数层级
|
||||
|
||||
RapidOCR 参数分为三层:
|
||||
|
||||
1. **构造配置**:创建 `RapidOCR` 实例时确定模型、推理后端、预处理及默认行为。
|
||||
2. **Det / Cls / Rec 模块配置**:分别控制检测、方向分类、文字识别。
|
||||
3. **运行时调用参数**:调用 `engine(image, ...)` 时覆盖少量可动态调整的选项。
|
||||
|
||||
配置树的顶层结构是:
|
||||
|
||||
```yaml
|
||||
Global: # OCR 流程和输出的全局设置
|
||||
EngineConfig: # 各推理后端设置
|
||||
Det: # 文本检测
|
||||
Cls: # 文本行方向分类
|
||||
Rec: # 文本识别
|
||||
```
|
||||
|
||||
参数路径使用点号表达,例如:
|
||||
|
||||
```python
|
||||
params = {
|
||||
"Global.text_score": 0.6,
|
||||
"Det.box_thresh": 0.55,
|
||||
"EngineConfig.onnxruntime.use_dml": True,
|
||||
}
|
||||
```
|
||||
|
||||
### 1.1 三种创建方式
|
||||
|
||||
#### 使用包内默认配置
|
||||
|
||||
```python
|
||||
from rapidocr import RapidOCR
|
||||
|
||||
engine = RapidOCR()
|
||||
```
|
||||
|
||||
#### 使用完整 YAML
|
||||
|
||||
```python
|
||||
engine = RapidOCR(config_path="rapidocr.yaml")
|
||||
```
|
||||
|
||||
YAML 中枚举参数写字符串:
|
||||
|
||||
```yaml
|
||||
Det:
|
||||
engine_type: onnxruntime
|
||||
lang_type: ch
|
||||
model_type: mobile
|
||||
ocr_version: PP-OCRv4
|
||||
```
|
||||
|
||||
#### 使用 `params` 字典覆盖
|
||||
|
||||
```python
|
||||
from rapidocr import EngineType, LangDet, ModelType, OCRVersion, RapidOCR
|
||||
|
||||
engine = RapidOCR(params={
|
||||
"Det.engine_type": EngineType.ONNXRUNTIME,
|
||||
"Det.lang_type": LangDet.CH,
|
||||
"Det.model_type": ModelType.MOBILE,
|
||||
"Det.ocr_version": OCRVersion.PPOCRV4,
|
||||
"Det.box_thresh": 0.55,
|
||||
})
|
||||
```
|
||||
|
||||
**3.8.1 的重要规则:** `params` 字典中的枚举字段必须传 RapidOCR 的 Enum,不能传普通字符串。包括:
|
||||
|
||||
- `engine_type`
|
||||
- `model_type`
|
||||
- `ocr_version`
|
||||
- `lang_type`
|
||||
- `task_type`
|
||||
|
||||
例如以下写法在 `params` 中可能触发 `TypeError`:
|
||||
|
||||
```python
|
||||
# 不适合 rapidocr 3.8.1 的 params 字典
|
||||
{"Det.engine_type": "onnxruntime"}
|
||||
```
|
||||
|
||||
但同一个值在 YAML 中应当写字符串,因为加载 YAML 后 RapidOCR 会自动将其转换成 Enum。
|
||||
|
||||
### 1.2 配置覆盖顺序
|
||||
|
||||
构造时的实际顺序为:
|
||||
|
||||
1. 如果 `config_path` 存在,加载该 YAML;
|
||||
2. 否则加载 RapidOCR 包内 `config.yaml`;
|
||||
3. 最后用 `params` 逐项覆盖。
|
||||
|
||||
因此:
|
||||
|
||||
```python
|
||||
RapidOCR(config_path="a.yaml", params={"Global.text_score": 0.7})
|
||||
```
|
||||
|
||||
表示先加载 `a.yaml`,再把其中的 `Global.text_score` 改为 `0.7`。
|
||||
|
||||
如果 `config_path` 指向不存在的文件,3.8.1 源码会退回包内默认配置,而不是必然报错。生产环境应主动检查路径,避免拼错路径后静默使用默认配置。
|
||||
|
||||
---
|
||||
|
||||
## 2. OCR 三阶段是什么
|
||||
|
||||
完整 OCR 流程通常是:
|
||||
|
||||
```text
|
||||
输入图片
|
||||
↓
|
||||
Det:检测文字区域
|
||||
↓
|
||||
裁剪每个文本行
|
||||
↓
|
||||
Cls:判断文本行是否需要旋转 180°
|
||||
↓
|
||||
Rec:识别文本内容
|
||||
↓
|
||||
按置信度过滤并输出
|
||||
```
|
||||
|
||||
三个开关的典型组合:
|
||||
|
||||
| 场景 | use_det | use_cls | use_rec | 返回类型/意义 |
|
||||
|---|---:|---:|---:|---|
|
||||
| 完整 OCR | true | 按需 | true | `RapidOCROutput` |
|
||||
| 只检测文字框 | true | false | false | `TextDetOutput` |
|
||||
| 已裁切文本行,只识别 | false | false | true | `TextRecOutput` |
|
||||
| 已裁切文本行,方向分类后识别 | false | true | true | 无检测框,处理输入整体 |
|
||||
| 只做方向分类 | false | true | false | `TextClsOutput` |
|
||||
|
||||
注意:如果关闭 Det,却把一张包含很多文本行的整页图交给 Rec,Rec 会把整张图当成一条待识别文本行,通常不是想要的效果。
|
||||
|
||||
---
|
||||
|
||||
## 3. Global 全局参数
|
||||
|
||||
以下默认值以本项目安装的 **RapidOCR 3.8.1 包内 `config.yaml`** 为准,而不是当前 3.9.x 在线文档。
|
||||
|
||||
| 参数 | 3.8.1 默认值 | 作用 | 调优建议 |
|
||||
|---|---:|---|---|
|
||||
| `text_score` | `0.5` | 最终识别置信度过滤阈值 | 误识别太多时提高;漏掉低置信度文字时降低 |
|
||||
| `use_det` | `true` | 是否执行文本检测 | 整页图片一般开启 |
|
||||
| `use_cls` | `true` | 是否执行 0°/180°方向分类 | 方向固定时可关闭以降低耗时 |
|
||||
| `use_rec` | `true` | 是否执行文字识别 | 只要框时可关闭 |
|
||||
| `min_height` | `30` | 全局预处理的小尺寸/竖图补边相关阈值 | 通常保留模型默认 |
|
||||
| `width_height_ratio` | `8` | 竖长图触发补边逻辑的宽高比阈值 | 通常不要作为第一调优项 |
|
||||
| `max_side_len` | `2000` | 整体输入预处理允许的最大边 | 大图精度与内存/耗时的重要平衡项 |
|
||||
| `min_side_len` | `30` | 整体输入预处理允许的最小边 | 极小图片可能被放大 |
|
||||
| `return_word_box` | `false` | 是否计算字/词级坐标 | 需要细粒度坐标时开启,会增加后处理工作 |
|
||||
| `return_single_char_box` | `false` | 是否将英文数字进一步拆到单字符坐标 | 仅在 `return_word_box=True` 时有意义 |
|
||||
| `font_path` | `null` | 可视化结果使用的字体 | 只影响可视化,不改善识别准确率 |
|
||||
| `log_level` | `info` | RapidOCR 日志级别 | 排查后端和模型加载时可设 `debug` |
|
||||
| `model_root_dir` | `null` | 内置/自动下载模型的根目录 | `null` 时使用包目录下 `models` |
|
||||
|
||||
### 3.1 `text_score` 与 `box_thresh` 不一样
|
||||
|
||||
这是最常混淆的一组参数:
|
||||
|
||||
- `Det.box_thresh`:检测阶段判断一个候选区域是否像文本。
|
||||
- `Global.text_score`:识别完成后,按识别置信度过滤结果。
|
||||
|
||||
例子:
|
||||
|
||||
- 图片中某行完全没有检测框:先检查 `limit_side_len`、`thresh`、`box_thresh`。
|
||||
- 框已经检测到了,但最终结果中没有这行:可能是 `text_score` 太高,或者 Rec 识别失败。
|
||||
|
||||
### 3.2 `max_side_len` 与 `Det.limit_side_len` 不一样
|
||||
|
||||
- `Global.max_side_len`:进入整个 OCR 流程前,对原图做一次全局尺寸约束;3.8.1 默认 `2000`。
|
||||
- `Det.limit_side_len`:检测模型自身预处理的缩放目标;3.8.1 默认 `736`。
|
||||
|
||||
两者可能连续生效。把 `Det.limit_side_len` 调得很大,如果图片之前已被 `Global.max_side_len` 缩小,也无法恢复被缩掉的细节。
|
||||
|
||||
调大图小字时,应先确认这两个层级,而不是只调整其中一个。
|
||||
|
||||
### 3.3 字/词/字符坐标
|
||||
|
||||
`return_word_box=True` 时,RapidOCR 会结合识别结果和文本行框计算更细粒度坐标。其“word”的粒度与语言、版本有关,不应简单理解成永远返回英文意义上的单词:
|
||||
|
||||
- 中文及中英混合通常更接近按字拆分;
|
||||
- 纯英文通常更接近按词拆分;
|
||||
- `return_single_char_box=True` 用于进一步获取英文/数字字符级坐标,而且依赖 `return_word_box=True`。
|
||||
|
||||
本项目每次调用都显式传入:
|
||||
|
||||
```python
|
||||
return_word_box=True
|
||||
```
|
||||
|
||||
因此构造配置中的 `Global.return_word_box` 会在首次项目调用时被覆盖为 `True`。
|
||||
|
||||
---
|
||||
|
||||
## 4. Det 文本检测参数
|
||||
|
||||
Det 的目标是找到文字区域,输出四点框。
|
||||
|
||||
### 4.1 模型选择参数
|
||||
|
||||
| 参数 | 3.8.1 默认值 | 作用 |
|
||||
|---|---|---|
|
||||
| `engine_type` | `onnxruntime` | Det 使用的推理后端 |
|
||||
| `lang_type` | `ch` | 检测模型语言类别 |
|
||||
| `model_type` | `mobile` | 模型规模,本地 3.8.1 只有 `mobile/server` 枚举 |
|
||||
| `ocr_version` | `PP-OCRv4` | OCR 模型系列 |
|
||||
| `task_type` | `det` | 模型任务类型,通常不要修改 |
|
||||
| `model_path` | `null` | 明确指定模型文件 |
|
||||
| `model_dir` | `null` | 部分后端使用的模型目录 |
|
||||
|
||||
如果 `model_path` 不为空,ONNXRuntime 会直接使用该文件;模型选择元数据主要用于 `model_path=null` 时定位 RapidOCR 托管的默认模型。
|
||||
|
||||
### 4.2 检测预处理与后处理参数
|
||||
|
||||
| 参数 | 3.8.1 默认值 | 含义 | 调大/调小的典型影响 |
|
||||
|---|---:|---|---|
|
||||
| `limit_side_len` | `736` | 检测前图像缩放边长限制 | 调大通常保留更多小字细节,但更慢、更耗显存/内存 |
|
||||
| `limit_type` | `min` | `limit_side_len` 作用方式 | 与模型训练预处理有关,不建议随意改 |
|
||||
| `std` | `[0.5,0.5,0.5]` | 输入归一化标准差 | 必须匹配模型训练配置 |
|
||||
| `mean` | `[0.5,0.5,0.5]` | 输入归一化均值 | 必须匹配模型训练配置 |
|
||||
| `thresh` | `0.3` | DB 像素二值化阈值 | 降低会产生更多候选区域,也可能增加噪声 |
|
||||
| `box_thresh` | `0.5` | 候选文本框平均得分阈值 | 降低增加召回;提高减少假框 |
|
||||
| `max_candidates` | `1000` | 最多处理候选区域数 | 超密集页面可能受限;调大增加后处理成本 |
|
||||
| `unclip_ratio` | `1.6` | 检测框向外扩张比例 | 调大可避免切掉字符边缘,但可能框入邻行 |
|
||||
| `use_dilation` | `true` | 二值图是否做膨胀 | 有助于连接断裂区域,也可能粘连相邻文字 |
|
||||
| `score_mode` | `fast` | 文本框评分方式 | `fast` 优先速度;其他值须以版本源码支持为准 |
|
||||
|
||||
### 4.3 检测调优顺序
|
||||
|
||||
#### 小字检测不到
|
||||
|
||||
建议按以下顺序检查:
|
||||
|
||||
1. 原始图片是否已被业务预处理缩小;
|
||||
2. `Global.max_side_len` 是否过小;
|
||||
3. 适当提高 `Det.limit_side_len`;
|
||||
4. 小幅降低 `Det.box_thresh`;
|
||||
5. 再考虑小幅降低 `Det.thresh`。
|
||||
|
||||
不要一开始就同时大幅降低两个阈值,否则很难判断是哪项有效,而且容易产生大量背景假框。
|
||||
|
||||
#### 框太紧、字符边缘被切掉
|
||||
|
||||
优先小幅提高:
|
||||
|
||||
```yaml
|
||||
Det:
|
||||
unclip_ratio: 1.8
|
||||
```
|
||||
|
||||
框入邻行时反向降低。
|
||||
|
||||
#### 假框很多
|
||||
|
||||
优先提高:
|
||||
|
||||
```yaml
|
||||
Det:
|
||||
box_thresh: 0.6
|
||||
```
|
||||
|
||||
若大量纹理像素形成候选,再提高 `thresh`。
|
||||
|
||||
---
|
||||
|
||||
## 5. Cls 文本行方向分类参数
|
||||
|
||||
Cls 通常只判断裁切文本行是 `0°` 还是 `180°`。它不是通用的任意角度纠偏模块,不能替代 90°旋转、透视矫正或倾斜校正。
|
||||
|
||||
| 参数 | 3.8.1 默认值 | 作用 |
|
||||
|---|---:|---|
|
||||
| `engine_type` | `onnxruntime` | 分类推理后端 |
|
||||
| `lang_type` | `ch` | 3.8.1 的 `LangCls` 只支持 `ch` |
|
||||
| `model_type` | `mobile` | 模型规格 |
|
||||
| `ocr_version` | `PP-OCRv4` | 模型系列 |
|
||||
| `task_type` | `cls` | 固定为分类任务 |
|
||||
| `model_path` | `null` | 自定义分类模型路径 |
|
||||
| `cls_image_shape` | `[3,48,192]` | 分类输入形状 |
|
||||
| `cls_batch_num` | `6` | 一次分类的文本行数量 |
|
||||
| `cls_thresh` | `0.9` | 达到该置信度才按分类结果旋转 |
|
||||
| `label_list` | `["0","180"]` | 分类标签 |
|
||||
|
||||
调优建议:
|
||||
|
||||
- 扫描件方向稳定:关闭 `use_cls`,通常能降低延迟。
|
||||
- 确实混有上下颠倒文本:开启 `use_cls`。
|
||||
- 不要期待 Cls 修正任意角度倾斜。
|
||||
- `cls_batch_num` 增大可能提高大量文本行时的吞吐,但会增加瞬时内存/显存;应实测。
|
||||
- `cls_image_shape`、`label_list` 必须与模型匹配,不是通用性能旋钮。
|
||||
|
||||
本项目 `.env` 当前为:
|
||||
|
||||
```env
|
||||
OCR_USE_CLS=true
|
||||
```
|
||||
|
||||
所以会运行分类阶段。
|
||||
|
||||
---
|
||||
|
||||
## 6. Rec 文字识别参数
|
||||
|
||||
Rec 接收裁切后的文本行,输出文字和识别置信度。
|
||||
|
||||
| 参数 | 3.8.1 默认值 | 作用 |
|
||||
|---|---:|---|
|
||||
| `engine_type` | `onnxruntime` | 识别推理后端 |
|
||||
| `lang_type` | `ch` | 字符语言/字典族 |
|
||||
| `model_type` | `mobile` | 模型规模 |
|
||||
| `ocr_version` | `PP-OCRv4` | OCR 模型系列 |
|
||||
| `task_type` | `rec` | 固定为识别任务 |
|
||||
| `model_path` | `null` | 自定义识别模型路径 |
|
||||
| `model_dir` | `null` | 部分后端模型目录 |
|
||||
| `rec_keys_path` | `null` | 自定义字符字典路径 |
|
||||
| `rec_img_shape` | `[3,48,320]` | 识别模型输入形状 |
|
||||
| `rec_batch_num` | `6` | 一次识别的文本行数量 |
|
||||
|
||||
### 6.1 `lang_type` 支持范围(本地 3.8.1)
|
||||
|
||||
本地 `LangRec` 枚举包括:
|
||||
|
||||
```text
|
||||
ch, ch_doc, en, arabic, chinese_cht, cyrillic,
|
||||
devanagari, japan, korean, ka, latin, ta, te,
|
||||
eslav, th, el
|
||||
```
|
||||
|
||||
模型文件、`lang_type` 和字符字典必须彼此匹配。
|
||||
|
||||
### 6.2 `rec_keys_path` 不是“额外允许字符”
|
||||
|
||||
它是识别模型的字符索引映射。随意给一个新字典,并不会让原模型学会新字符,反而可能导致输出索引映射错误。
|
||||
|
||||
只有自定义模型确实按该字典训练/导出时才应设置。
|
||||
|
||||
### 6.3 `rec_img_shape` 不应随意改
|
||||
|
||||
它必须符合模型输入和预处理约定。仅为了“识别更长文本”直接改宽度,不保证有效;动态宽度能力、模型导出方式和后端均会影响是否支持。
|
||||
|
||||
### 6.4 `rec_batch_num`
|
||||
|
||||
- 增大:可能提高多文本行吞吐,增加峰值内存/显存。
|
||||
- 减小:降低峰值资源,可能降低吞吐。
|
||||
- 对一张只有少量文本行的图片,增大批量不一定更快。
|
||||
|
||||
---
|
||||
|
||||
## 7. EngineConfig 与推理后端
|
||||
|
||||
`Det.engine_type`、`Cls.engine_type`、`Rec.engine_type` 决定各阶段使用哪个后端;`EngineConfig.<后端>` 只是该后端的参数。
|
||||
|
||||
例如:
|
||||
|
||||
```python
|
||||
params = {
|
||||
"Det.engine_type": EngineType.ONNXRUNTIME,
|
||||
"Rec.engine_type": EngineType.ONNXRUNTIME,
|
||||
"EngineConfig.onnxruntime.use_dml": True,
|
||||
}
|
||||
```
|
||||
|
||||
如果把 `EngineConfig.tensorrt.use_fp16=True` 写好了,但三个模块仍是 `EngineType.ONNXRUNTIME`,TensorRT 参数不会被使用。
|
||||
|
||||
RapidOCR 支持 Det、Cls、Rec 分别使用不同后端,但混用会增加部署依赖、初始化复杂度和排障成本。一般先统一后端。
|
||||
|
||||
---
|
||||
|
||||
## 8. ONNXRuntime 参数
|
||||
|
||||
### 8.1 通用会话参数
|
||||
|
||||
| 参数 | 3.8.1 默认值 | 作用 |
|
||||
|---|---:|---|
|
||||
| `intra_op_num_threads` | `-1` | 单个算子内部并行线程数 |
|
||||
| `inter_op_num_threads` | `-1` | 算子之间并行线程数 |
|
||||
| `enable_cpu_mem_arena` | `false` | 是否启用 ORT CPU 内存池 |
|
||||
| `cpu_ep_cfg.arena_extend_strategy` | `kSameAsRequested` | CPU 内存池扩展策略 |
|
||||
|
||||
3.8.1 源码中:
|
||||
|
||||
- `-1` 表示不显式设置,由 ONNXRuntime 处理;
|
||||
- 只有线程数位于 `1..os.cpu_count()` 时才写入 SessionOptions;
|
||||
- 超出 CPU 数或无效值会被跳过,不会按填写值生效。
|
||||
|
||||
Web 服务调优时不要只追求单请求最快:
|
||||
|
||||
- 单进程、单请求:可适当提高 `intra_op_num_threads`;
|
||||
- 多个并发请求:每个 Session 使用过多线程会相互争抢,反而使尾延迟恶化;
|
||||
- `inter_op_num_threads` 并非越大越好,应以真实并发压测决定。
|
||||
|
||||
### 8.2 CPU 配置示例
|
||||
|
||||
```python
|
||||
params = {
|
||||
"Det.engine_type": EngineType.ONNXRUNTIME,
|
||||
"Cls.engine_type": EngineType.ONNXRUNTIME,
|
||||
"Rec.engine_type": EngineType.ONNXRUNTIME,
|
||||
"EngineConfig.onnxruntime.intra_op_num_threads": 4,
|
||||
"EngineConfig.onnxruntime.inter_op_num_threads": 2,
|
||||
}
|
||||
```
|
||||
|
||||
本项目 `OCR_DEVICE=cpu` 时正是这样构建参数。
|
||||
|
||||
---
|
||||
|
||||
## 9. DirectML 参数
|
||||
|
||||
DirectML 不是独立的 `engine_type`;它是 ONNXRuntime 的 Execution Provider。
|
||||
|
||||
正确关系是:
|
||||
|
||||
```text
|
||||
Det/Cls/Rec.engine_type = onnxruntime
|
||||
EngineConfig.onnxruntime.use_dml = true
|
||||
安装 onnxruntime-directml
|
||||
```
|
||||
|
||||
示例:
|
||||
|
||||
```python
|
||||
params = {
|
||||
"Det.engine_type": EngineType.ONNXRUNTIME,
|
||||
"Cls.engine_type": EngineType.ONNXRUNTIME,
|
||||
"Rec.engine_type": EngineType.ONNXRUNTIME,
|
||||
"EngineConfig.onnxruntime.use_dml": True,
|
||||
}
|
||||
```
|
||||
|
||||
### 9.1 生效条件
|
||||
|
||||
本地 3.8.1 源码会检查:
|
||||
|
||||
1. 操作系统必须是 Windows;
|
||||
2. Windows Build 必须不低于 `18362`;
|
||||
3. `onnxruntime.get_available_providers()` 中必须有 `DmlExecutionProvider`。
|
||||
|
||||
不满足时会记录警告,并回退到默认可用 Provider,通常是 CPU。
|
||||
|
||||
检查命令:
|
||||
|
||||
```bash
|
||||
.venv/Scripts/python.exe -c "import onnxruntime as ort; print(ort.get_available_providers())"
|
||||
```
|
||||
|
||||
预期包含:
|
||||
|
||||
```text
|
||||
DmlExecutionProvider
|
||||
CPUExecutionProvider
|
||||
```
|
||||
|
||||
### 9.2 `dm_ep_cfg` 拼写
|
||||
|
||||
RapidOCR 3.8.1 配置字段是:
|
||||
|
||||
```yaml
|
||||
EngineConfig:
|
||||
onnxruntime:
|
||||
use_dml: true
|
||||
dm_ep_cfg: null
|
||||
```
|
||||
|
||||
是 `dm_ep_cfg`,不是 `dml_ep_cfg`。
|
||||
|
||||
当它为 `null` 时,3.8.1 会复用 CPU Provider 配置;若 CUDA 也被认为可用,则可能复用 CUDA 配置。一般不需要手动填写 DirectML provider options。
|
||||
|
||||
### 9.3 DirectML 常见误区
|
||||
|
||||
- 日志打印“推理设备: DirectML”只代表应用选择了 DML 配置,不单独证明实际 Session 使用 DML。
|
||||
- 要结合可用 Provider 和 RapidOCR/ORT 日志确认。
|
||||
- 同一环境不要混装多个互斥的 `onnxruntime`、`onnxruntime-gpu`、`onnxruntime-directml` 包,否则 Provider 和二进制版本可能相互覆盖。
|
||||
- DirectML 首次初始化可能较慢;是否比 CPU 快必须用真实图片、完整三阶段和并发模型实测。
|
||||
|
||||
---
|
||||
|
||||
## 10. ONNXRuntime CUDA 参数
|
||||
|
||||
CUDA 也是 ONNXRuntime Execution Provider,不是 `EngineType.CUDA`。
|
||||
|
||||
```python
|
||||
params = {
|
||||
"Det.engine_type": EngineType.ONNXRUNTIME,
|
||||
"Cls.engine_type": EngineType.ONNXRUNTIME,
|
||||
"Rec.engine_type": EngineType.ONNXRUNTIME,
|
||||
"EngineConfig.onnxruntime.use_cuda": True,
|
||||
"EngineConfig.onnxruntime.cuda_ep_cfg.device_id": 0,
|
||||
}
|
||||
```
|
||||
|
||||
| 参数 | 默认值 | 作用 |
|
||||
|---|---:|---|
|
||||
| `use_cuda` | `false` | 请求启用 CUDA EP |
|
||||
| `cuda_ep_cfg.device_id` | `0` | GPU 编号 |
|
||||
| `arena_extend_strategy` | `kNextPowerOfTwo` | GPU 内存池扩展策略 |
|
||||
| `cudnn_conv_algo_search` | `EXHAUSTIVE` | cuDNN 卷积算法搜索策略 |
|
||||
| `do_copy_in_default_stream` | `true` | 是否在默认流中执行复制 |
|
||||
|
||||
生效还需要:
|
||||
|
||||
- 安装兼容的 `onnxruntime-gpu`;
|
||||
- CUDA/cuDNN 与 ORT 版本匹配;
|
||||
- `get_available_providers()` 包含 `CUDAExecutionProvider`。
|
||||
|
||||
仅设置 `use_cuda=True` 不会自动安装 CUDA,也不保证实际使用 GPU。条件不满足时,RapidOCR 3.8.1 会警告并回退。
|
||||
|
||||
---
|
||||
|
||||
## 11. TensorRT 参数
|
||||
|
||||
TensorRT 在 RapidOCR 中是独立的推理引擎:
|
||||
|
||||
```python
|
||||
params = {
|
||||
"Det.engine_type": EngineType.TENSORRT,
|
||||
"Cls.engine_type": EngineType.TENSORRT,
|
||||
"Rec.engine_type": EngineType.TENSORRT,
|
||||
"EngineConfig.tensorrt.device_id": 0,
|
||||
"EngineConfig.tensorrt.use_fp16": True,
|
||||
}
|
||||
```
|
||||
|
||||
| 参数 | 3.8.1 默认值 | 作用 |
|
||||
|---|---:|---|
|
||||
| `device_id` | `0` | GPU 编号 |
|
||||
| `use_fp16` | `true` | 使用 FP16 |
|
||||
| `use_int8` | `false` | 使用 INT8;通常需要正确量化支持 |
|
||||
| `workspace_size` | `1073741824` | TensorRT workspace,默认 1 GiB |
|
||||
| `cache_dir` | `null` | engine 缓存目录 |
|
||||
| `force_rebuild` | `false` | 是否强制重建 engine |
|
||||
| `det_profile` | 见默认配置 | 检测动态 shape 范围 |
|
||||
| `rec_profile` | 见默认配置 | 识别动态 shape 范围 |
|
||||
| `cls_profile` | 见默认配置 | 分类动态 shape 范围 |
|
||||
|
||||
默认动态 shape:
|
||||
|
||||
```yaml
|
||||
EngineConfig:
|
||||
tensorrt:
|
||||
det_profile:
|
||||
min_shape: [1, 3, 32, 32]
|
||||
opt_shape: [1, 3, 736, 736]
|
||||
max_shape: [1, 3, 2048, 2048]
|
||||
rec_profile:
|
||||
min_shape: [1, 3, 48, 32]
|
||||
opt_shape: [6, 3, 48, 320]
|
||||
max_shape: [6, 3, 48, 2048]
|
||||
cls_profile:
|
||||
min_shape: [1, 3, 48, 32]
|
||||
opt_shape: [6, 3, 48, 192]
|
||||
max_shape: [6, 3, 48, 192]
|
||||
```
|
||||
|
||||
注意:
|
||||
|
||||
- 输入 shape 超出 profile 可能失败或触发重新构建;
|
||||
- 首次 ONNX 转 TensorRT engine 会明显较慢,后续使用缓存;
|
||||
- `.engine` 通常与 GPU 架构、TensorRT/CUDA 版本、模型和 profile 相关,不宜跨机器盲目复用;
|
||||
- `use_fp16=True` 并不保证所有算子都以 FP16 执行,也不保证所有 GPU 都同等受益;
|
||||
- TensorRT 部署依赖比 ONNXRuntime CPU/DirectML 高,先确认稳定性再追求吞吐。
|
||||
|
||||
---
|
||||
|
||||
## 12. 其他后端参数概览
|
||||
|
||||
本地 3.8.1 的 `EngineType` 还包括:
|
||||
|
||||
```text
|
||||
openvino, paddle, torch, mnn
|
||||
```
|
||||
|
||||
配置树还有:
|
||||
|
||||
- `EngineConfig.openvino`
|
||||
- `EngineConfig.paddle`
|
||||
- `EngineConfig.torch`
|
||||
- `EngineConfig.mnn`
|
||||
|
||||
但“配置中出现”不等于当前环境已安装对应依赖,也不等于任意模型组合都可运行。切换前应同时确认:
|
||||
|
||||
1. 本地 RapidOCR 版本支持该后端;
|
||||
2. 后端 Python 包和原生运行库已安装;
|
||||
3. 模型格式与该后端匹配;
|
||||
4. 枚举、模型选择和配置项属于同一个版本。
|
||||
|
||||
---
|
||||
|
||||
## 13. `RapidOCR.__call__()` 运行时参数
|
||||
|
||||
本地 RapidOCR 3.8.1 的实际签名:
|
||||
|
||||
```python
|
||||
engine(
|
||||
img_content,
|
||||
use_det=None,
|
||||
use_cls=None,
|
||||
use_rec=None,
|
||||
return_word_box=None,
|
||||
return_single_char_box=None,
|
||||
text_score=None,
|
||||
box_thresh=None,
|
||||
unclip_ratio=None,
|
||||
)
|
||||
```
|
||||
|
||||
### 13.1 参数表
|
||||
|
||||
| 参数 | 作用 |
|
||||
|---|---|
|
||||
| `img_content` | 图片路径、URL(取决于加载器支持)、`numpy.ndarray`、bytes 或 `Path` |
|
||||
| `use_det` | 覆盖检测开关 |
|
||||
| `use_cls` | 覆盖方向分类开关 |
|
||||
| `use_rec` | 覆盖识别开关 |
|
||||
| `return_word_box` | 覆盖字/词级坐标开关 |
|
||||
| `return_single_char_box` | 覆盖英文数字单字符坐标开关 |
|
||||
| `text_score` | 覆盖最终识别置信度阈值 |
|
||||
| `box_thresh` | 直接更新 Det 后处理器的框阈值 |
|
||||
| `unclip_ratio` | 直接更新 Det 后处理器的扩框比例 |
|
||||
|
||||
其它构造参数(例如 `limit_side_len`、`rec_batch_num`、`engine_type`)不能直接作为本地 3.8.1 `__call__()` 的关键字参数传入。
|
||||
|
||||
### 13.2 运行时“覆盖”会保留到后续调用
|
||||
|
||||
这是 3.8.1 源码里非常容易忽略的行为:`update_params()` 直接修改实例属性,而不是建立仅本次调用的临时配置。
|
||||
|
||||
```python
|
||||
engine(img1, text_score=0.8)
|
||||
engine(img2) # text_score 仍然是 0.8,不会自动恢复构造值
|
||||
```
|
||||
|
||||
传 `None` 表示“不修改当前状态”,不是“恢复默认值”。
|
||||
|
||||
因此共享同一个 `RapidOCR` 实例的 Web 服务,不应允许不同并发请求随意传不同的运行时阈值。否则一次请求可能修改后续请求看到的状态,并存在并发竞态。
|
||||
|
||||
推荐做法:
|
||||
|
||||
- 服务启动时固定构造参数;
|
||||
- 业务请求只传固定的 `use_det/use_cls/use_rec/return_word_box`;
|
||||
- 如果必须支持不同配置,创建独立的引擎实例池,或者在外部做串行化与显式恢复。
|
||||
|
||||
### 13.3 未知参数
|
||||
|
||||
`__call__()` 的 Python 签名不接受任意 `**kwargs`。传入未声明参数会由 Python 直接报 `TypeError`。
|
||||
|
||||
---
|
||||
|
||||
## 14. 模型选择、模型路径与自动下载
|
||||
|
||||
### 14.1 `model_path` 指定时
|
||||
|
||||
ONNXRuntime 直接加载该路径:
|
||||
|
||||
```python
|
||||
params = {
|
||||
"Det.model_path": "model/custom_det.onnx",
|
||||
}
|
||||
```
|
||||
|
||||
此时 RapidOCR 不会根据 `ocr_version/model_type/lang_type` 替你验证文件内容是否真的匹配这些声明。最终是否可用取决于模型输入输出、后处理和字典是否兼容。
|
||||
|
||||
### 14.2 `model_path=null` 时
|
||||
|
||||
RapidOCR 会根据以下组合查找托管模型:
|
||||
|
||||
```text
|
||||
engine_type + ocr_version + task_type + lang_type + model_type
|
||||
```
|
||||
|
||||
若本地模型不存在,则按照内置模型清单下载并校验 SHA256。该能力只适用于 RapidOCR 已登记的托管模型组合,不表示任意自定义路径缺失时都会自动下载。
|
||||
|
||||
### 14.3 `model_root_dir`
|
||||
|
||||
`Global.model_root_dir=null` 时,本地 3.8.1 会设为 RapidOCR 安装目录下的 `models`。如果运行环境对 site-packages 没有写权限,而又需要下载非内置模型,应显式指定一个可写目录。
|
||||
|
||||
---
|
||||
|
||||
## 15. 版本差异
|
||||
|
||||
### 15.1 本项目:RapidOCR 3.8.1
|
||||
|
||||
本地枚举仅包含:
|
||||
|
||||
```python
|
||||
OCRVersion: PP-OCRv4, PP-OCRv5
|
||||
ModelType: mobile, server
|
||||
```
|
||||
|
||||
包内无参默认配置是:
|
||||
|
||||
- Det:PP-OCRv4 mobile + ONNXRuntime
|
||||
- Cls:PP-OCRv4 mobile + ONNXRuntime
|
||||
- Rec:PP-OCRv4 mobile + ONNXRuntime
|
||||
|
||||
### 15.2 RapidOCR 3.9.0 及以后
|
||||
|
||||
官方文档说明 3.9.0 起默认组合改为:
|
||||
|
||||
- Det:PP-OCRv6 small
|
||||
- Cls:PP-OCRv4 mobile
|
||||
- Rec:PP-OCRv6 small
|
||||
- 默认后端仍是 ONNXRuntime
|
||||
|
||||
这意味着升级后,如果没有显式固定模型,默认模型可能改变,准确率、速度、模型文件和输出行为都可能变化。
|
||||
|
||||
**升级建议:**
|
||||
|
||||
1. 先生成目标版本默认配置;
|
||||
2. 对照新版本 Enum 和参数树;
|
||||
3. 明确固定 Det/Cls/Rec 模型;
|
||||
4. 用真实业务样本比较准确率、耗时和资源占用;
|
||||
5. 不要直接复制 3.9.x 示例到 3.8.1。
|
||||
|
||||
---
|
||||
|
||||
## 16. 本项目当前实际配置
|
||||
|
||||
项目不维护 RapidOCR YAML 副本,唯一配置链是:
|
||||
|
||||
```text
|
||||
项目根 .env
|
||||
→ config/config.py::Settings
|
||||
→ main.py
|
||||
→ OCRProcessor
|
||||
→ RapidOCR(params=...)
|
||||
```
|
||||
|
||||
修改 `.env` 后需要重启服务。项目开放以下常用参数:
|
||||
|
||||
| `.env` | 默认值 | RapidOCR 映射/作用 |
|
||||
|---|---:|---|
|
||||
| `OCR_USE_DET` | `true` | 每次调用的 `use_det` |
|
||||
| `OCR_USE_CLS` | `false` | 每次调用的 `use_cls` |
|
||||
| `OCR_USE_REC` | `true` | 每次调用的 `use_rec` |
|
||||
| `OCR_MODEL_DIR` | `model` | 三个模型文件所在目录;相对项目根解析 |
|
||||
| `OCR_TEXT_SCORE` | `0.5` | `Global.text_score` |
|
||||
| `OCR_MAX_SIDE_LEN` | `2000` | `Global.max_side_len` |
|
||||
| `OCR_DET_LIMIT_SIDE_LEN` | `736` | `Det.limit_side_len` |
|
||||
| `OCR_DET_BOX_THRESH` | `0.5` | `Det.box_thresh` |
|
||||
| `OCR_REC_BATCH_NUM` | `6` | `Rec.rec_batch_num` |
|
||||
| `OCR_DEVICE` | `cpu` | `cpu/cuda/dml/tensorrt` |
|
||||
| `OCR_DEVICE_ID` | `0` | GPU 编号 |
|
||||
| `OCR_INTRA_THREADS` | `4` | ORT CPU 算子内线程数 |
|
||||
| `OCR_INTER_THREADS` | `2` | ORT CPU 算子间线程数 |
|
||||
|
||||
模型目录会在配置加载时转成绝对路径,`OCRProcessor` 启动时检查以下文件:
|
||||
|
||||
```text
|
||||
ch_PP-OCRv5_det_mobile.onnx
|
||||
ch_PP-LCNet_x0_25_textline_ori_cls_mobile.onnx
|
||||
ch_PP-OCRv5_rec_mobile.onnx
|
||||
```
|
||||
|
||||
三阶段模型契约固定为 PP-OCRv5 mobile:
|
||||
|
||||
```python
|
||||
Det: EngineType.ONNXRUNTIME, LangDet.CH, ModelType.MOBILE, OCRVersion.PPOCRV5
|
||||
Cls: EngineType.ONNXRUNTIME, LangCls.CH, ModelType.MOBILE, OCRVersion.PPOCRV5
|
||||
Rec: EngineType.ONNXRUNTIME, LangRec.CH, ModelType.MOBILE, OCRVersion.PPOCRV5
|
||||
```
|
||||
|
||||
这些模型契约不是普通调优参数,因此不通过 `.env` 开放。其余本文介绍的底层参数用于理解 RapidOCR;除上表外,项目目前并未提供外部配置入口。
|
||||
|
||||
每次识别仍显式固定三阶段开关,并请求 `return_word_box=True`。阈值在引擎构造时设置,不按单个请求动态修改,避免共享实例状态串扰。
|
||||
|
||||
---
|
||||
|
||||
## 17. 推荐配置示例
|
||||
|
||||
### 17.1 本项目 DirectML 稳定起点
|
||||
|
||||
```python
|
||||
params = {
|
||||
"Global.text_score": 0.5,
|
||||
"Global.max_side_len": 2000,
|
||||
|
||||
"Det.model_path": "model/ch_PP-OCRv5_det_mobile.onnx",
|
||||
"Det.engine_type": EngineType.ONNXRUNTIME,
|
||||
"Det.lang_type": LangDet.CH,
|
||||
"Det.model_type": ModelType.MOBILE,
|
||||
"Det.ocr_version": OCRVersion.PPOCRV5,
|
||||
"Det.limit_side_len": 736,
|
||||
"Det.thresh": 0.3,
|
||||
"Det.box_thresh": 0.5,
|
||||
"Det.unclip_ratio": 1.6,
|
||||
|
||||
"Cls.model_path": "model/ch_PP-LCNet_x0_25_textline_ori_cls_mobile.onnx",
|
||||
"Cls.engine_type": EngineType.ONNXRUNTIME,
|
||||
"Cls.model_type": ModelType.MOBILE,
|
||||
|
||||
"Rec.model_path": "model/ch_PP-OCRv5_rec_mobile.onnx",
|
||||
"Rec.engine_type": EngineType.ONNXRUNTIME,
|
||||
"Rec.lang_type": LangRec.CH,
|
||||
"Rec.model_type": ModelType.MOBILE,
|
||||
"Rec.ocr_version": OCRVersion.PPOCRV5,
|
||||
|
||||
"EngineConfig.onnxruntime.use_dml": True,
|
||||
}
|
||||
```
|
||||
|
||||
> 上面是依据文件命名给出的统一方向,不代表已经验证这些模型的全部元数据。应用前应使用真实样本回归。
|
||||
|
||||
### 17.2 CPU 并发服务起点
|
||||
|
||||
```python
|
||||
params = {
|
||||
"EngineConfig.onnxruntime.intra_op_num_threads": 4,
|
||||
"EngineConfig.onnxruntime.inter_op_num_threads": 1,
|
||||
"Global.text_score": 0.5,
|
||||
"Det.limit_side_len": 736,
|
||||
"Rec.rec_batch_num": 6,
|
||||
}
|
||||
```
|
||||
|
||||
线程数必须按机器核心数和服务并发压测,不存在对所有机器都最优的固定值。
|
||||
|
||||
### 17.3 小字优先配置
|
||||
|
||||
```python
|
||||
params = {
|
||||
"Global.max_side_len": 3000,
|
||||
"Det.limit_side_len": 1280,
|
||||
"Det.box_thresh": 0.45,
|
||||
"Det.unclip_ratio": 1.7,
|
||||
}
|
||||
```
|
||||
|
||||
影响:通常更慢、更耗内存/显存,也可能增加假框。应逐项修改,并记录召回、误检和时延。
|
||||
|
||||
### 17.4 减少误识别
|
||||
|
||||
```python
|
||||
params = {
|
||||
"Det.box_thresh": 0.6,
|
||||
"Global.text_score": 0.65,
|
||||
}
|
||||
```
|
||||
|
||||
- 提高 `box_thresh`:减少低质量检测框;
|
||||
- 提高 `text_score`:过滤低置信度识别结果。
|
||||
|
||||
两者同时提高会降低召回率,应以业务验收集评估。
|
||||
|
||||
### 17.5 已裁切单行文本
|
||||
|
||||
```python
|
||||
result = engine(
|
||||
line_image,
|
||||
use_det=False,
|
||||
use_cls=False,
|
||||
use_rec=True,
|
||||
return_word_box=False,
|
||||
)
|
||||
```
|
||||
|
||||
这适合输入已经是单行文本的情况,不适合整页图片。
|
||||
|
||||
---
|
||||
|
||||
## 18. 常见无效、冲突或误导性配置
|
||||
|
||||
### 18.1 只设置 `use_dml/use_cuda`,但后端不是 ONNXRuntime
|
||||
|
||||
```python
|
||||
"Det.engine_type": EngineType.TENSORRT,
|
||||
"EngineConfig.onnxruntime.use_dml": True,
|
||||
```
|
||||
|
||||
DML 参数对 TensorRT Det 无效。
|
||||
|
||||
### 18.2 只写 GPU 参数,没有安装对应 Provider
|
||||
|
||||
`use_cuda=True` 或 `use_dml=True` 只是请求使用该 Provider。Provider 不存在时会回退,必须检查:
|
||||
|
||||
```python
|
||||
onnxruntime.get_available_providers()
|
||||
```
|
||||
|
||||
### 18.3 同时打开多个 ONNXRuntime 加速 Provider
|
||||
|
||||
同时 `use_cuda=True` 和 `use_dml=True` 会形成 Provider 优先级,并不代表两张后端共同加速一个推理。3.8.1 的插入顺序还会影响谁排在前面。一般一次只启用一个主要 GPU Provider。
|
||||
|
||||
### 18.4 显式 `model_path` 与模型声明不一致
|
||||
|
||||
文件可能仍能加载,但声明会误导下载逻辑、维护者和未来后端。应统一 `ocr_version/model_type/lang_type/task_type`。
|
||||
|
||||
### 18.5 修改 `mean/std/image_shape/label_list` 来“调准确率”
|
||||
|
||||
这些通常是模型契约,不是普通阈值。与模型不匹配会直接破坏结果。
|
||||
|
||||
### 18.6 只调 `Det.limit_side_len`,忽略 `Global.max_side_len`
|
||||
|
||||
原图可能已经先被全局缩放,检测阶段无法恢复细节。
|
||||
|
||||
### 18.7 把 `text_score` 当检测阈值
|
||||
|
||||
它只过滤识别置信度。根本没有框时,降低 `text_score` 通常无效。
|
||||
|
||||
### 18.8 把 Cls 当任意旋转矫正
|
||||
|
||||
默认标签只有 `0/180`,不处理所有角度。
|
||||
|
||||
### 18.9 运行时参数被误认为只影响一次调用
|
||||
|
||||
本地 3.8.1 会把非 `None` 运行时值写回共享实例。Web 并发中动态阈值可能相互影响。
|
||||
|
||||
### 18.10 依赖 `main/latest` 默认值但不锁定版本
|
||||
|
||||
RapidOCR 3.9.0 已更换默认 Det/Rec 模型。生产环境应锁定包版本和模型选择。
|
||||
|
||||
---
|
||||
|
||||
## 19. 推荐调优方法
|
||||
|
||||
不要凭单张图片调参数。建议准备固定验收集,至少覆盖:
|
||||
|
||||
- 正常扫描件;
|
||||
- 小字高分辨率图;
|
||||
- 模糊、压缩、低对比度图;
|
||||
- 横排、上下颠倒文本;
|
||||
- 中英数字混合;
|
||||
- 空白和复杂纹理背景。
|
||||
|
||||
记录指标:
|
||||
|
||||
- 文本行召回率;
|
||||
- 误检框数量;
|
||||
- 字符/字段准确率;
|
||||
- 单请求 P50/P95 延迟;
|
||||
- 并发吞吐;
|
||||
- 峰值内存/显存;
|
||||
- 空白图和异常图片行为。
|
||||
|
||||
推荐顺序:
|
||||
|
||||
1. 固定 RapidOCR、ONNXRuntime 和模型文件;
|
||||
2. 确认实际 Execution Provider;
|
||||
3. 调输入尺寸(`Global.max_side_len`、`Det.limit_side_len`);
|
||||
4. 调检测(`box_thresh`、`thresh`、`unclip_ratio`);
|
||||
5. 调最终过滤(`text_score`);
|
||||
6. 调批量与线程以优化性能;
|
||||
7. 最后比较不同模型和后端。
|
||||
|
||||
每次只改一到两个相关参数,并保存对照结果。
|
||||
|
||||
---
|
||||
|
||||
## 20. 本项目的优先建议
|
||||
|
||||
1. 调参只修改项目根 `.env` 中已开放的 `OCR_*` 变量,并重启服务。
|
||||
2. 输入方向稳定时可关闭 `OCR_USE_CLS`,对比准确率和延迟后决定。
|
||||
3. API 当前消费细粒度坐标,因此保留 `return_word_box=True`;若未来只需要文本行框,可再单独评估关闭。
|
||||
4. 不要向并发请求开放运行时阈值修改:3.8.1 会持久修改共享引擎状态。
|
||||
5. DirectML 以实际 Provider 为准,不要只依据应用日志中的设备名称。
|
||||
6. 升级 RapidOCR 前做模型回归;3.9.x 的默认模型、枚举和参数契约已有变化。
|
||||
|
||||
---
|
||||
|
||||
## 21. 参考来源
|
||||
|
||||
官方资料:
|
||||
|
||||
- [RapidOCR 参数说明](https://rapidai.github.io/RapidOCRDocs/main/install_usage/rapidocr/parameters/)
|
||||
- [RapidOCR 使用说明](https://rapidai.github.io/RapidOCRDocs/main/install_usage/rapidocr/usage/)
|
||||
- [RapidOCR 推理引擎说明](https://rapidai.github.io/RapidOCRDocs/main/install_usage/rapidocr/how_to_use_infer_engine/)
|
||||
- [RapidOCR 模型列表](https://rapidai.github.io/RapidOCRDocs/latest/model_list/)
|
||||
- [RapidOCR GitHub 仓库](https://github.com/RapidAI/RapidOCR)
|
||||
- [RapidOCR Releases](https://github.com/RapidAI/RapidOCR/releases)
|
||||
- [ONNXRuntime DirectML Execution Provider](https://onnxruntime.ai/docs/execution-providers/DirectML-ExecutionProvider.html)
|
||||
- [ONNXRuntime CUDA Execution Provider](https://onnxruntime.ai/docs/execution-providers/CUDA-ExecutionProvider.html)
|
||||
|
||||
本地核验文件:
|
||||
|
||||
- `.venv/Lib/site-packages/rapidocr/config.yaml`
|
||||
- `.venv/Lib/site-packages/rapidocr/main.py`
|
||||
- `.venv/Lib/site-packages/rapidocr/utils/parse_parameters.py`
|
||||
- `.venv/Lib/site-packages/rapidocr/utils/typings.py`
|
||||
- `.venv/Lib/site-packages/rapidocr/inference_engine/onnxruntime/main.py`
|
||||
- `.venv/Lib/site-packages/rapidocr/inference_engine/onnxruntime/provider_config.py`
|
||||
- `config/config.py`
|
||||
- `engine/ocr_engine.py`
|
||||
- `.env`
|
||||
|
||||
对于本文未给出明确结论的第三方模型兼容性、具体后端性能和模型元数据,应以实际模型来源、对应版本源码和业务样本验证为准。
|
||||
+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