소스 검색

feat(新增MinerU OCR运行时补丁): 新增对MinerU OCR的运行时补丁,解决Apple MPS环境下印章检测概率图输出NaN的问题,确保在不修改MinerU源码的情况下,mps设备默认使用fp32,提升OCR识别的稳定性与准确性。

zhch158_admin 1 개월 전
부모
커밋
7950de50cd

+ 234 - 0
docs/ocr_tools/universal_doc_parser/MinerU印章OCR-MPS-fp32补丁.md

@@ -0,0 +1,234 @@
+# MinerU 印章 OCR 空结果与 MPS fp32 运行时补丁
+
+> 关联文档:[`PaddleOCR-VL表格文字丢失-OTSL补丁.md`](./PaddleOCR-VL表格文字丢失-OTSL补丁.md)(同类 monkey-patch 模式)
+
+## 1. 背景与现象
+
+升级 MinerU(OCR 升到 PP-OCRv6,并引入动态推理精度)后,银行流水场景
+(如 `bank_statement_yusys_local`)中:
+
+- Layout 能正确检出印章(`type: "seal"`,bbox 正常);
+- 最终 JSON 里印章字段却是空的:`"text": ""`, `"confidence": 0.0`。
+
+样例:`彭_广东兴宁农村商业银行_page_001.json` 中印章框
+`[325, 124, 538, 325]` 可见红章文字,但识别结果为空。
+
+## 2. 根因分析
+
+### 2.1 印章 OCR 调用链
+
+`ocr_platform` 印章识别走 `SealOCRRecognizer` → MinerU
+`PytorchPaddleOCR(lang="seal")`:
+
+| 阶段 | 模型 |
+|---|---|
+| 检测 det | `seal_PP-OCRv4_det_server_infer.pth`(仍是 v4) |
+| 识别 rec | `ch_PP-OCRv6_medium_rec_infer.safetensors`(升级后换成 v6) |
+
+检测失败时不会进入识别,直接得到空文本。
+
+### 2.2 真正炸掉的点:MPS + fp16
+
+MinerU 在 `mineru/model/utils/pytorchocr/base_ocr_v20.py` 引入:
+
+```python
+# OCR 推理精度开关:auto 表示 CPU 使用 fp32,非 CPU 自动使用 fp16。
+OCR_INFERENCE_PRECISION = "auto"
+```
+
+Mac 上 `get_device()` 默认选 `mps` → `auto` 切到 **fp16** →
+`seal_PP-OCRv4_det`(`PFHeadLocal`)概率图输出 **全 NaN** → 检测框数 = 0 →
+`text: ""`。
+
+证据:
+
+| 条件 | 结果 |
+|---|---|
+| 默认 `mps` + fp16 | det 概率图 `nan`,boxes=0,FULL=`[None]` |
+| `MINERU_DEVICE_MODE=cpu`(fp32) | 正常识别出「广东兴宁…」「电子回单专用章」 |
+| `mps` + 强制 fp32 | 同样正常 |
+
+> 重要结论:问题**不是**「v6 rec 认不出字」,也不是 layout 漏检。
+> 是 **seal det 在 MPS/fp16 数值不稳定**。普通 `ch` OCR 在同一裁剪图上
+> 仍能认出横排「电子回单专用章」,进一步说明图本身可读。
+
+### 2.3 为何不能直接改 MinerU
+
+本仓库需要持续同步上游 MinerU。直接改 MinerU 源码会导致:
+
+- 每次 `git pull` / 升级都要手工重放改动;
+- 团队环境不一致;
+- 与「ocr_platform 适配第三方、MinerU 保持可同步」的约定冲突。
+
+## 3. 方案选型
+
+| 方案 | 说明 | 结论 |
+|---|---|---|
+| 改 MinerU `base_ocr_v20.py` | 同步升级成本高 | ❌ 不采用 |
+| 跑批前 `export MINERU_DEVICE_MODE=cpu` | 有效但全局强制 CPU,拖慢其它 OCR | ⚠️ 仅临时 |
+| 全局把 `OCR_INFERENCE_PRECISION="fp32"` | cuda 也会失去 fp16 加速 | ⚠️ 过粗 |
+| **运行时 monkey-patch:仅 mps→fp32** | 不改 MinerU、随本仓库版本化、cuda 仍走上游 auto | ✅ 采用 |
+
+### 为什么 monkey-patch 打在 `_resolve_inference_dtype`
+
+精度决策集中在 `BaseOCRV20._resolve_inference_dtype(device)`:
+`_apply_inference_precision` 在模型加载时调用它,再决定是否
+`self.net.to(dtype=torch.float16)`。
+
+因此只要在 **OCR 模型初始化之前** 替换该方法:
+
+- `mps` → 固定返回 `torch.float32`;
+- 其它设备 → 仍调用原始实现(cuda 继续 auto/fp16)。
+
+即可在不改 MinerU 文件的前提下修正错误行为。
+
+## 4. 最终实现
+
+### 4.1 补丁模块
+
+新增:
+
+`ocr_tools/universal_doc_parser/models/adapters/_mineru_ocr_patches.py`
+
+(对齐已有的 `_mineru_vl_patches.py` 模式)
+
+核心逻辑:
+
+```python
+def _patch_mps_ocr_fp32() -> None:
+    import torch
+    import mineru.model.utils.pytorchocr.base_ocr_v20 as base
+
+    if not hasattr(base.BaseOCRV20, "_resolve_inference_dtype"):
+        raise RuntimeError(
+            "mineru 接口已变更:找不到 BaseOCRV20._resolve_inference_dtype,"
+            "请检查 MinerU 版本并更新 ocr_platform OCR 精度补丁。"
+        )
+
+    # 可选:环境变量整库覆盖(auto/fp32/fp16)
+    override = os.getenv("OCR_PLATFORM_OCR_INFERENCE_PRECISION") or \
+               os.getenv("MINERU_OCR_INFERENCE_PRECISION")
+    if override:
+        base.OCR_INFERENCE_PRECISION = override.strip().lower()
+        return
+
+    orig = base.BaseOCRV20._resolve_inference_dtype
+
+    def _resolve_inference_dtype(self, device):
+        if str(device).lower().startswith("mps"):
+            return torch.float32
+        return orig(self, device)
+
+    _resolve_inference_dtype.__wrapped__ = orig
+    base.BaseOCRV20._resolve_inference_dtype = _resolve_inference_dtype
+```
+
+通过 `apply_once()` 应用,特性:
+
+- **幂等**:模块级 `_applied`,仅首次真正打补丁。
+- **失败大声**:找不到 `_resolve_inference_dtype` 时抛 `RuntimeError`,
+  避免上游改名后补丁静默失效、印章再次变空。
+- **可覆盖**:环境变量可强制 `fp32` / `fp16` / `auto`(调试用)。
+- **保留原方法**:`__wrapped__` 指向原始实现,便于排查与还原。
+
+### 4.2 调用点(必须在 OCR 模型创建之前)
+
+补丁必须在 `AtomModelSingleton.get_atom_model(AtomicModel.OCR, ...)` /
+`PytorchPaddleOCR(...)` **之前**执行,因为精度在构造时写入网络 dtype。
+
+当前挂载点:
+
+1. `seal_ocr_adapter.py` → `SealOCRRecognizer.initialize()`
+2. `mineru_adapter.py` → `MinerUOCRRecognizer.initialize()`
+
+```python
+# seal_ocr_adapter.py / mineru_adapter.py 的 initialize() 内
+from ._mineru_ocr_patches import apply_once as _apply_mineru_ocr_patches
+_apply_mineru_ocr_patches()
+
+self.xxx_model = self.atom_model_manager.get_atom_model(
+    atom_model_name=AtomicModel.OCR,
+    ...
+)
+```
+
+> 印章路径与整页 OCR 路径都会创建 OCR 原子模型;两处都挂是为了
+> 「无论先初始化哪条路径,补丁都已生效」。`apply_once()` 幂等,重复调用安全。
+
+### 4.3 环境变量
+
+| 变量 | 作用 |
+|---|---|
+| (不设) | 默认:`mps`→fp32,其它设备走 MinerU 原逻辑 |
+| `OCR_PLATFORM_OCR_INFERENCE_PRECISION=fp32\|fp16\|auto` | 本仓库优先覆盖 |
+| `MINERU_OCR_INFERENCE_PRECISION=...` | 兼容通用名 |
+| `MINERU_DEVICE_MODE=cpu` | 整进程强制 CPU(临时绕过,非推荐长期方案) |
+
+## 5. 验证
+
+在 `mineru` 环境下:
+
+```bash
+conda run -n mineru python - <<'PY'
+import os, sys, cv2
+from pathlib import Path
+os.environ.pop("MINERU_DEVICE_MODE", None)
+os.environ.pop("OCR_PLATFORM_OCR_INFERENCE_PRECISION", None)
+
+root = Path("/Users/zhch158/workspace/repository.git/ocr_platform")
+sys.path[:0] = [str(root), str(root / "ocr_tools/universal_doc_parser")]
+
+from models.adapters._mineru_ocr_patches import apply_once
+from mineru.backend.pipeline.model_init import AtomModelSingleton
+from mineru.backend.pipeline.model_list import AtomicModel
+from mineru.utils.config_reader import get_device
+
+print("device", get_device())
+print("apply_once", apply_once())  # True
+print("apply_once again", apply_once())  # False
+
+page = ("/Users/zhch158/workspace/data/流水分析/"
+        "彭_广东兴宁农村商业银行/bank_statement_yusys_local/"
+        "彭_广东兴宁农村商业银行/彭_广东兴宁农村商业银行_page_001.png")
+crop = cv2.imread(page)[124:325, 325:538]
+model = AtomModelSingleton().get_atom_model(
+    atom_model_name=AtomicModel.OCR, lang="seal")
+print("dtype", next(model.text_detector.net.parameters()).dtype)  # float32 on mps
+full = model.ocr(crop, det=True, rec=True)
+texts = [it[1][0] for it in (full[0] or [])]
+print(texts)
+assert any("兴宁" in t or "电子回单" in t for t in texts)
+PY
+```
+
+期望输出(节选):
+
+```text
+device mps
+已应用 MinerU OCR 补丁:mps 默认使用 fp32(避免 seal det NaN)
+apply_once True
+dtype torch.float32
+['广东兴宁农村商业银行股份有限公司', '电子回单专用章']
+```
+
+## 6. 维护注意事项
+
+1. **不要改 MinerU 源码**解决此问题。修复集中在 `_mineru_ocr_patches.py`,
+   随 `ocr_platform` 版本化,方便同步升级 MinerU。
+2. **升级 MinerU 后**重跑第 5 节验证。若上游已对 mps 默认 fp32 / 修复 seal det NaN,
+   可考虑移除本补丁;若 `_resolve_inference_dtype` 被改名或删除,
+   `apply_once()` 会抛 `RuntimeError` 提示更新补丁。
+3. **新增 MinerU OCR 相关运行时修补**统一加到 `_mineru_ocr_patches.py`,
+   由 `apply_once()` 串联,保持「补丁集中、可开关、可追溯」。
+   (VL / OTSL 类补丁仍放在 `_mineru_vl_patches.py`。)
+4. 补丁是**进程级**的:必须在首次创建 OCR 模型前调用;模型已用 fp16 建好后再打补丁无效,
+   需重启进程。
+
+## 7. 涉及文件
+
+| 文件 | 变更 |
+|---|---|
+| `models/adapters/_mineru_ocr_patches.py` | 新增:MinerU OCR 运行时补丁(mps→fp32) |
+| `models/adapters/seal_ocr_adapter.py` | `initialize()` 在创建 seal OCR 前调用 `apply_once()` |
+| `models/adapters/mineru_adapter.py` | `MinerUOCRRecognizer.initialize()` 同样接入 |
+| MinerU 仓库 | **无改动** |

+ 91 - 0
ocr_tools/universal_doc_parser/models/adapters/_mineru_ocr_patches.py

@@ -0,0 +1,91 @@
+"""MinerU OCR 运行时补丁集合。
+
+集中存放对第三方库 ``mineru`` OCR 推理路径的运行时修补(monkey-patch),
+目的是在**不修改 MinerU 源码**的前提下修复兼容性问题,
+并保证补丁随本仓库一起版本化、可随时开关、升级 MinerU 后不会丢失。
+
+当前包含的补丁:
+
+1. ``patch_mps_ocr_fp32``
+   Apple MPS 上 ``OCR_INFERENCE_PRECISION=auto`` 会把 OCR 网络切到 fp16,
+   导致 ``seal_PP-OCRv4_det``(PFHeadLocal)概率图全 NaN、印章检测框为 0。
+   本补丁让 **mps 默认使用 fp32**;cuda 等设备仍走上游 auto/fp16 逻辑。
+
+统一通过 :func:`apply_once` 应用,幂等且仅在首次调用时生效。
+应在任何 ``AtomModelSingleton.get_atom_model(AtomicModel.OCR, ...)`` /
+``PytorchPaddleOCR(...)`` 初始化之前调用。
+"""
+
+from __future__ import annotations
+
+import os
+
+from loguru import logger
+
+_applied = False
+
+
+def _resolve_precision_override() -> str | None:
+    """读取可选环境变量覆盖;未设置则返回 None(走 mps→fp32 默认策略)。"""
+    # 优先本仓库专用变量,其次兼容通用名
+    for key in ("OCR_PLATFORM_OCR_INFERENCE_PRECISION", "MINERU_OCR_INFERENCE_PRECISION"):
+        value = os.getenv(key)
+        if value:
+            return value.strip().lower()
+    return None
+
+
+def _patch_mps_ocr_fp32() -> None:
+    """让 BaseOCRV20 在 mps 上默认使用 fp32。"""
+    import torch
+    import mineru.model.utils.pytorchocr.base_ocr_v20 as base
+
+    if not hasattr(base, "BaseOCRV20") or not hasattr(
+        base.BaseOCRV20, "_resolve_inference_dtype"
+    ):
+        raise RuntimeError(
+            "mineru 接口已变更:找不到 BaseOCRV20._resolve_inference_dtype,"
+            "请检查 MinerU 版本并更新 ocr_platform OCR 精度补丁。"
+        )
+
+    override = _resolve_precision_override()
+    if override is not None:
+        if override not in {"auto", "fp32", "fp16"}:
+            raise ValueError(
+                "OCR_PLATFORM_OCR_INFERENCE_PRECISION / MINERU_OCR_INFERENCE_PRECISION "
+                "must be one of: auto, fp32, fp16"
+            )
+        base.OCR_INFERENCE_PRECISION = override
+        logger.info(f"已应用 MinerU OCR 精度覆盖:OCR_INFERENCE_PRECISION={override}")
+        return
+
+    orig = base.BaseOCRV20._resolve_inference_dtype
+
+    def _resolve_inference_dtype(self, device):
+        device_name = str(device).lower()
+        # MPS + fp16 会使 seal_PP-OCRv4_det 输出 NaN;mps 默认强制 fp32
+        if device_name.startswith("mps"):
+            return torch.float32
+        return orig(self, device)
+
+    _resolve_inference_dtype.__wrapped__ = orig
+    base.BaseOCRV20._resolve_inference_dtype = _resolve_inference_dtype
+    logger.info("已应用 MinerU OCR 补丁:mps 默认使用 fp32(避免 seal det NaN)")
+
+
+def apply_once() -> bool:
+    """应用全部 MinerU OCR 运行时补丁,幂等。
+
+    Returns:
+        bool: 本次调用是否真正应用了补丁(首次为 True,后续为 False)。
+    """
+    global _applied
+    if _applied:
+        return False
+    try:
+        _patch_mps_ocr_fp32()
+        _applied = True
+        return True
+    except Exception as e:
+        logger.error(f"应用 MinerU OCR 补丁失败:{e}")
+        raise

+ 4 - 0
ocr_tools/universal_doc_parser/models/adapters/mineru_adapter.py

@@ -657,6 +657,10 @@ class MinerUOCRRecognizer(BaseOCRRecognizer):
             det_threshold = self.config.get('det_threshold', 0.6)  # 从0.3提高到0.6
             unclip_ratio = self.config.get('unclip_ratio', 1.5)    # 从1.8降低到1.5
             enable_merge = self.config.get('enable_merge_det_boxes', False)  # 从True改为False
+
+            # 不修改 MinerU:mps 上强制 OCR fp32,避免 seal det 概率图 NaN
+            from ._mineru_ocr_patches import apply_once as _apply_mineru_ocr_patches
+            _apply_mineru_ocr_patches()
             
             # 初始化OCR模型
             self.ocr_model = self.atom_model_manager.get_atom_model(

+ 4 - 0
ocr_tools/universal_doc_parser/models/adapters/seal_ocr_adapter.py

@@ -38,6 +38,10 @@ class SealOCRRecognizer(BaseOCRRecognizer):
     def initialize(self):
         """初始化印章 OCR 模型"""
         try:
+            # 不修改 MinerU:mps 上强制 OCR fp32,避免 seal det 概率图 NaN
+            from ._mineru_ocr_patches import apply_once as _apply_mineru_ocr_patches
+            _apply_mineru_ocr_patches()
+
             self.seal_model = self.atom_model_manager.get_atom_model(
                 atom_model_name=AtomicModel.OCR,
                 lang="seal",