上传图片开始识别
+diff --git a/.gitignore b/.gitignore index 36b13f1..edbec85 100644 --- a/.gitignore +++ b/.gitignore @@ -174,3 +174,4 @@ cython_debug/ # PyPI configuration file .pypirc +/CLAUDE.md diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..b6b1ecf --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 已忽略包含查询文件的默认文件夹 +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ diff --git a/RAPIDOCR_PARAMETERS.md b/RAPIDOCR_PARAMETERS.md new file mode 100644 index 0000000..3805707 --- /dev/null +++ b/RAPIDOCR_PARAMETERS.md @@ -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` + +对于本文未给出明确结论的第三方模型兼容性、具体后端性能和模型元数据,应以实际模型来源、对应版本源码和业务样本验证为准。 diff --git a/README_GPU.md b/README_GPU.md new file mode 100644 index 0000000..a7185f6 --- /dev/null +++ b/README_GPU.md @@ -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 +``` diff --git a/config/config.py b/config/config.py new file mode 100644 index 0000000..51d5dc5 --- /dev/null +++ b/config/config.py @@ -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() diff --git a/engine/ocr_engine.py b/engine/ocr_engine.py new file mode 100644 index 0000000..e46d8f0 --- /dev/null +++ b/engine/ocr_engine.py @@ -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 + diff --git a/main.py b/main.py new file mode 100644 index 0000000..5a8b058 --- /dev/null +++ b/main.py @@ -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""" + + +
+ + +上传图片开始识别
+