首次提交

This commit is contained in:
sansen committed 2026-08-31 16:41:12 +08:00
1 parent 680e028a0b
commit 9a479b6832
10 files changed
+2735

No files matched your search

+1
View File
@@ -174,3 +174,4 @@ cython_debug/
# PyPI configuration file # PyPI configuration file
.pypirc .pypirc
/CLAUDE.md
+10
View File
@@ -0,0 +1,10 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 已忽略包含查询文件的默认文件夹
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
+1000
View File
@@ -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
View File
@@ -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
```
+62
View File
@@ -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()
+526
View File
@@ -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
+558
View File
@@ -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
)
+9
View File
@@ -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
+212
View File
@@ -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
View File
@@ -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