这个工具 macOS 和 Windows 都得用,但 OCR 引擎在两个平台上没有哪个是完美的。
macOS 上最舒服的,其实就是系统自带的 Vision 框架——不用装额外运行时、不占内存、中文识别还挺不错。但 Vision 在 Windows 上?根本不存在。
Windows 这边比较通用的方案是 RapidOCR(PaddleOCR 的 ONNX 版本)——跨平台、安装简单、识别质量也够用,就是占用比 Vision 大一些。Linux 上 Vision 不可用,那就只剩 RapidOCR 这条路了。
要是硬编码任一种,结果都很尬。所以做了个能切换的后端。
基类和数据结构 最小接口就两个方法:ocr 和 is_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, abstractmethodfrom dataclasses import dataclassfrom typing import List @dataclass class OCRResult : text: str confidence: float bbox: tuple @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"
VNRequestTextRecognitionLevelAccurate 比 Fast 慢三五倍,但识别率明显高一截。这种取舍很容易选——我宁愿慢 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 Enumclass 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 platformdef resolve_candidates (requested: str ) -> list : current_platform = platform.system() if requested == "auto" : if current_platform == "Darwin" : return ["vision" , "rapidocr" ] return ["rapidocr" ] if requested == "vision" : 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 osdef 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_backenddef 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 之类的新后端,也就是在工厂里多注册一个类的事——业务代码动都不用动。