跨平台 OCR 的几个后端切换

这个工具 macOS 和 Windows 都得用,但 OCR 引擎在两个平台上没有哪个是完美的。

macOS 上最舒服的,其实就是系统自带的 Vision 框架——不用装额外运行时、不占内存、中文识别还挺不错。但 Vision 在 Windows 上?根本不存在。

Windows 这边比较通用的方案是 RapidOCR(PaddleOCR 的 ONNX 版本)——跨平台、安装简单、识别质量也够用,就是占用比 Vision 大一些。Linux 上 Vision 不可用,那就只剩 RapidOCR 这条路了。

要是硬编码任一种,结果都很尬。所以做了个能切换的后端。

基类和数据结构

最小接口就两个方法:ocris_available。后者用来在初始化时筛掉当前平台跑不动的后端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import List

@dataclass
class OCRResult:
text: str
confidence: float
bbox: tuple # (x1, y1, x2, y2)

@dataclass
class OCRResponse:
results: List[OCRResult]
backend: str

class BaseOCRBackend(ABC):
@abstractmethod
def ocr(self, image) -> OCRResponse: ...

@abstractmethod
def is_available(self) -> bool: ...

OCRResponse 里带个 backend 字段,是给调试时方便用的——日志里能直接看出来”这次识别是哪个后端跑出来的”,省得猜。

RapidOCR 后端

RapidOCR 是 pip 包,import 之后直接用。is_available 实际上就是看包能不能 import 进来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class RapidOCRBackend(BaseOCRBackend):
def __init__(self):
from rapidocr_onnxruntime import RapidOCR
self._engine = RapidOCR()

def ocr(self, image) -> OCRResponse:
result, _ = self._engine(image)
if result is None:
return OCRResponse(results=[], backend="rapidocr")

ocr_results = []
for line in result:
bbox, text, confidence = line
ocr_results.append(OCRResult(text=text, confidence=confidence, bbox=bbox))
return OCRResponse(results=ocr_results, backend="rapidocr")

def is_available(self) -> bool:
try:
import rapidocr_onnxruntime
return True
except ImportError:
return False

Vision 后端

Vision 这边走 PyObjC 桥接,坑稍微多点。图像得先转成 CIImage、识别请求是异步的但可以同步 wait、识别语言要显式声明否则识别率会掉——一步走错就给你来个屏幕全空:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class VisionOCRBackend(BaseOCRBackend):
def ocr(self, image) -> OCRResponse:
import Vision
import Quartz

ci_image = Quartz.CIImage.imageWithCGImage_(image)

request = Vision.VNRecognizeTextRequest.alloc().init()
request.setRecognitionLanguages_(["zh-Hans", "en"])
request.setRecognitionLevel_(Vision.VNRequestTextRecognitionLevelAccurate)

handler = Vision.VNImageRequestHandler.alloc().initWithCIImage_options_(
ci_image, None
)
success = handler.performRequests_error_([request], None)

if not success:
return OCRResponse(results=[], backend="vision")

ocr_results = []
for observation in request.results():
text = observation.topCandidates_(1)[0].string()
confidence = observation.topCandidates_(1)[0].confidence()
bbox = observation.boundingBox()
ocr_results.append(OCRResult(text=text, confidence=confidence, bbox=bbox))
return OCRResponse(results=ocr_results, backend="vision")

def is_available(self) -> bool:
import platform
return platform.system() == "Darwin"

VNRequestTextRecognitionLevelAccurateFast 慢三五倍,但识别率明显高一截。这种取舍很容易选——我宁愿慢 100ms,也不愿意识别错。

工厂 + 回退

工厂函数干两件事:根据 requested 解析出候选列表,按顺序尝试,第一个能用的就用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
from enum import Enum

class OCRBackendType(str, Enum):
AUTO = "auto"
RAPIDOCR = "rapidocr"
VISION = "vision"

def create_ocr_backend(requested_backend: str = None) -> BaseOCRBackend:
requested = requested_backend or get_configured_backend()
candidates = resolve_candidates(requested)

errors = []
for candidate in candidates:
try:
backend = _build_backend(candidate)
if requested == "auto":
logger.info(f"Using OCR backend {candidate} (auto)")
elif candidate != requested:
logger.warning(f"OCR backend {requested} unavailable, fallback to {candidate}")
return backend
except Exception as exc:
errors.append(f"{candidate}: {exc}")
logger.warning(f"Failed to initialize OCR backend {candidate}: {exc}")

raise RuntimeError(f"Failed to initialize any OCR backend: {' | '.join(errors)}")

def _build_backend(backend_name: str) -> BaseOCRBackend:
if backend_name == "vision":
return VisionOCRBackend()
if backend_name == "rapidocr":
return RapidOCRBackend()
raise ValueError(f"Unknown OCR backend: {backend_name}")

按平台选默认顺序

auto 模式的候选顺序看平台脸色:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import platform

def resolve_candidates(requested: str) -> list:
current_platform = platform.system()

if requested == "auto":
if current_platform == "Darwin":
return ["vision", "rapidocr"] # macOS:Vision 优先
return ["rapidocr"]

if requested == "vision":
# 用户指名要 Vision,但失败了就降级到 RapidOCR
return ["vision", "rapidocr"]

return ["rapidocr"]

"vision" 也支持降级,是为了开发者方便——比如他本来在 Mac 上 debug,配置文件写死了 vision,临时塞给 Windows 同事跑也不会当场炸锅。

环境变量覆盖

环境变量优先级高于配置文件,方便临时切换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import os

def get_configured_backend() -> str:
if env_backend := os.getenv("GAKUMAS_OCR_BACKEND"):
return normalize_backend_name(env_backend)

from config import ConfigService
return ConfigService().base.ocr_backend

def normalize_backend_name(value: str) -> str:
normalized = value.strip().lower()
valid_values = ["auto", "rapidocr", "vision"]
if normalized in valid_values:
return normalized
return "auto"

未知值就退回到 "auto",不报错——这个文件有可能是从老版本配置迁移过来的,宽松一点比直接挂掉好。

对外的统一入口

业务代码不直接用 backend,走 OCRService

1
2
3
4
5
6
7
8
9
10
class OCRService:
def __init__(self):
self._backend = create_ocr_backend()

def ocr(self, image) -> OCRResponse:
return self._backend.ocr(image)

def ocr_text(self, image) -> str:
response = self.ocr(image)
return " ".join(r.text for r in response.results)

后端切换对业务零感知。以后想加个 PaddleOCR-GPU 之类的新后端,也就是在工厂里多注册一个类的事——业务代码动都不用动。

欢迎关注我的其它发布渠道