ocr.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. """
  2. OCR 识别类 — 纯识别,不操作手机
  3. 默认 RapidOCR (rapidocr_onnxruntime),本地免费,用于导航大字检测
  4. engine="cloud" 切换百度云 OCR(标准版,用于说明书等小字页面),失败自动回退本地
  5. """
  6. import json
  7. import re
  8. from pathlib import Path
  9. from typing import Optional, Union
  10. import cv2
  11. import numpy as np
  12. from paddleocr import PaddleOCR
  13. from rapidocr_onnxruntime import RapidOCR
  14. class OCR:
  15. """RapidOCR 封装,只负责图片文字识别,不参与设备操作"""
  16. def __init__(self):
  17. import os as _os
  18. _os.environ.setdefault("OMP_NUM_THREADS", "1")
  19. self._engine = RapidOCR(text_det_thresh=0.3, text_det_box_thresh=0.3)
  20. self._baidu_client = None # 百度OCR客户端,惰性初始化
  21. self.oom = False # 内存崩溃熔断标志:本地引擎OOM后置True,上层据此跳过依赖OCR的步骤
  22. # ── 百度云 OCR ──────────────────────────────────────────
  23. def _load_baidu_keys(self) -> dict:
  24. """从 config.json 或环境变量读取百度 OCR 密钥"""
  25. import os as _os
  26. cfg_path = Path(__file__).parent.parent / "config.json"
  27. cfg = {}
  28. if cfg_path.exists():
  29. try:
  30. with open(cfg_path, encoding="utf-8") as f:
  31. cfg = json.load(f)
  32. except Exception:
  33. pass
  34. ocr_cfg = cfg.get("baidu_ocr", {})
  35. return {
  36. "app_id": _os.environ.get("BAIDU_OCR_APP_ID") or ocr_cfg.get("app_id", ""),
  37. "api_key": _os.environ.get("BAIDU_OCR_API_KEY") or ocr_cfg.get("api_key", ""),
  38. "secret_key": _os.environ.get("BAIDU_OCR_SECRET_KEY") or ocr_cfg.get("secret_key", ""),
  39. }
  40. def _get_baidu_client(self):
  41. if self._baidu_client is None:
  42. from aip import AipOcr
  43. k = self._load_baidu_keys()
  44. if not (k["app_id"] and k["api_key"] and k["secret_key"]):
  45. raise RuntimeError("config.json 缺少 baidu_ocr 密钥")
  46. self._baidu_client = AipOcr(k["app_id"], k["api_key"], k["secret_key"])
  47. return self._baidu_client
  48. @staticmethod
  49. def _baidu_to_standard(words_result: list) -> list:
  50. """百度 words_result → 项目统一格式 [{text, confidence, bbox, box}]"""
  51. results = []
  52. for item in words_result or []:
  53. loc = item.get("location", {})
  54. l, t = loc.get("left", 0), loc.get("top", 0)
  55. w, h = loc.get("width", 0), loc.get("height", 0)
  56. prob = item.get("probability") or {}
  57. conf = prob.get("average") if isinstance(prob, dict) and prob.get("average") is not None else 1.0
  58. results.append({
  59. "text": item.get("words", ""),
  60. "confidence": round(float(conf), 4),
  61. # 四角点:左上、右上、右下、左下
  62. "bbox": [[l, t], [l + w, t], [l + w, t + h], [l, t + h]],
  63. # 简化坐标:[左上x, 左上y, 右下x, 右下y],方便直接查看
  64. "box": [l, t, l + w, t + h],
  65. })
  66. return results
  67. def recognize_cloud(
  68. self,
  69. image: Union[str, Path, np.ndarray],
  70. rect: Optional[list] = None,
  71. confidence: float = 0.1,
  72. pattern: Optional[str] = None,
  73. detail: str = "all",
  74. ) -> list:
  75. """百度云 OCR(通用文字识别标准版),返回格式与 recognize 一致"""
  76. if isinstance(image, (str, Path)):
  77. img = cv2.imread(str(image))
  78. if img is None:
  79. raise FileNotFoundError(f"无法读取图片: {image}")
  80. else:
  81. img = image
  82. if rect:
  83. l, t, r, b = rect
  84. img = img[t:b, l:r]
  85. client = self._get_baidu_client()
  86. # JPEG 压缩发送,减小体积(百度推荐);标准版上限 4M/4096px
  87. ok, enc = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 90])
  88. if not ok:
  89. raise RuntimeError("图片编码失败")
  90. # 标准版(高精度版 accurate 配额小、天天触发日限 error_code=17,已弃用;
  91. # 生僻字场景已由 PP-OCRv6 + 交叉验证覆盖)
  92. res = client.basicGeneral(enc.tobytes())
  93. words_result = res.get("words_result", []) if isinstance(res, dict) else []
  94. if isinstance(res, dict) and res.get("error_code"):
  95. print(f"[ocr] 标准版受限(error_code={res.get('error_code')}: {res.get('error_msg')})")
  96. words_result = []
  97. results = self._baidu_to_standard(words_result)
  98. if confidence:
  99. results = [r for r in results if r["confidence"] >= confidence]
  100. if pattern:
  101. results = [r for r in results if re.search(pattern, r["text"])]
  102. if detail == "text":
  103. return [r["text"] for r in results]
  104. elif detail == "first":
  105. return results[0]["text"] if results else None
  106. return results
  107. def recognize_license(self, image: Union[str, Path, np.ndarray], rect: Optional[list] = None) -> dict:
  108. """
  109. 百度营业执照专用接口(美团同款 /rest/2.0/ocr/v1/business_license)。
  110. 返回结构化字段 dict,如 {"注册号": "...", "公司名称": "...", "地址": "...", ...}
  111. 失败返回 {}
  112. Args:
  113. image: 图片路径或 numpy 数组
  114. rect: 裁剪区域 [left, top, right, bottom],不传则整图
  115. """
  116. if isinstance(image, (str, Path)):
  117. img = cv2.imread(str(image))
  118. if img is None:
  119. raise FileNotFoundError(f"无法读取图片: {image}")
  120. else:
  121. img = image
  122. if rect:
  123. l, t, r, b = rect
  124. img = img[t:b, l:r]
  125. ok, enc = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 90])
  126. if not ok:
  127. return {}
  128. client = self._get_baidu_client()
  129. try:
  130. res = client.businessLicense(enc.tobytes())
  131. except Exception as e:
  132. print(f"[ocr] 百度营业执照OCR失败: {e}")
  133. return {}
  134. words_result = res.get("words_result", {}) if isinstance(res, dict) else {}
  135. return {k: v.get("words", "") for k, v in (words_result or {}).items()}
  136. # ── 核心识别 ────────────────────────────────────────────
  137. def recognize(
  138. self,
  139. image: Union[str, Path, np.ndarray],
  140. rect: Optional[list] = None,
  141. confidence: float = 0.1,
  142. pattern: Optional[str] = None,
  143. detail: str = "all",
  144. engine: str = "local",
  145. ) -> list:
  146. """
  147. 对图片执行 OCR 识别
  148. Args:
  149. image: 图片路径(str/Path) 或 numpy 数组
  150. rect: 裁剪区域 [left, top, right, bottom],不传则全图
  151. confidence: 置信度阈值,默认 0.1
  152. pattern: 正则过滤,只返回匹配的文字
  153. detail: 'all'=返回完整结果, 'text'=只返回文字列表, 'first'=只返回第一个匹配文字
  154. engine: 'local'=RapidOCR(默认), 'cloud'=百度云OCR(失败自动回退本地)
  155. Returns:
  156. detail='all'时:
  157. [{'text': str, 'confidence': float,
  158. 'bbox': [[x1,y1],[x2,y1],[x2,y2],[x1,y2]]}, ...]
  159. detail='text'时:
  160. ['文字1', '文字2', ...]
  161. detail='first'时:
  162. str 或 None
  163. """
  164. if engine == "cloud":
  165. try:
  166. return self.recognize_cloud(image, rect, confidence, pattern, detail)
  167. except Exception as e:
  168. print(f"[ocr] 百度OCR失败,回退本地: {e}")
  169. # 1. 读取图片
  170. if isinstance(image, (str, Path)):
  171. img = cv2.imread(str(image))
  172. if img is None:
  173. raise FileNotFoundError(f"无法读取图片: {image}")
  174. img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
  175. elif isinstance(image, np.ndarray):
  176. img = image
  177. else:
  178. raise TypeError(f"不支持的图片类型: {type(image)}")
  179. # 2. 裁剪区域(在压缩前,用原始坐标)
  180. crop_offset_l, crop_offset_t = 0, 0
  181. if rect:
  182. l, t, r, b = rect
  183. img = img[t:b, l:r]
  184. crop_offset_l, crop_offset_t = l, t
  185. # 3. 压缩大图,避免ONNX OOM
  186. orig_h, orig_w = img.shape[:2]
  187. if orig_h < 1 or orig_w < 1:
  188. return [] if detail != "first" else ""
  189. scale = 1.0
  190. max_size = 960
  191. if max(orig_h, orig_w) > max_size:
  192. scale = max_size / max(orig_h, orig_w)
  193. img = cv2.resize(img, (max(1, int(orig_w * scale)), max(1, int(orig_h * scale))))
  194. # 4. 执行 OCR(内存崩溃时置熔断标志后抛出,由上层跳过依赖OCR的步骤)
  195. try:
  196. raw, _ = self._engine(img)
  197. except Exception as e:
  198. if any(k in str(e).lower() for k in ("bad allocation", "out of memory", "insufficient memory", "unable to allocate")):
  199. self.oom = True
  200. print("[ocr] 本地OCR内存崩溃,已熔断后续OCR步骤")
  201. raise
  202. # 5. 结构化 & 过滤,把坐标映射回原始图
  203. results = []
  204. for bbox, text, conf in (raw or []):
  205. if conf < confidence:
  206. continue
  207. if pattern and not re.search(pattern, text):
  208. continue
  209. pts = [[int(p[0] / scale) + crop_offset_l, int(p[1] / scale) + crop_offset_t] for p in bbox]
  210. results.append({
  211. "text": text,
  212. "confidence": round(float(conf), 4),
  213. # 四角点:左上、右上、右下、左下
  214. "bbox": pts,
  215. # 简化坐标:[左上x, 左上y, 右下x, 右下y],方便直接查看
  216. "box": [pts[0][0], pts[0][1], pts[2][0], pts[2][1]],
  217. })
  218. # 5. 按需返回格式
  219. if detail == "text":
  220. return [r["text"] for r in results]
  221. elif detail == "first":
  222. return results[0]["text"] if results else None
  223. return results # detail='all'
  224. # ── 快捷方法 ────────────────────────────────────────────
  225. def recognize_file(self, path: str, **kwargs) -> list:
  226. """从文件路径识别"""
  227. return self.recognize(path, **kwargs)
  228. def recognize_array(self, arr: np.ndarray, **kwargs) -> list:
  229. """从 numpy 数组识别"""
  230. return self.recognize(arr, **kwargs)
  231. if __name__ == "__main__":
  232. """
  233. 验证 OCR 对指定图片的识别效果(本地RapidOCR / 百度云高精度 / PaddleOCR 三引擎对比)。
  234. 用法: python steps/ocr.py <图片路径> [关键词]
  235. 例: python steps/ocr.py "C:/Users/18357/Downloads/image (5).png" 胃舒
  236. """
  237. import sys as _sys
  238. img_path = _sys.argv[1] if len(_sys.argv) > 1 else r"C:\Users\18357\Downloads\image.png"
  239. keyword = _sys.argv[2] if len(_sys.argv) > 2 else ""
  240. print(f"验证图片: {img_path}")
  241. print(f"过滤关键词: {keyword}\n")
  242. _ocr = OCR()
  243. # 1. 本地 RapidOCR
  244. print("=== 本地 RapidOCR ===")
  245. try:
  246. raw = _ocr.recognize(img_path, detail="all")
  247. hits = [r for r in raw if keyword in r["text"]]
  248. if not hits:
  249. print(" (未识别到含关键词的文字)")
  250. for r in hits:
  251. print(f" conf={r['confidence']:.2f} box={r['box']} [{r['text']}]")
  252. except Exception as e:
  253. print(f" 识别失败: {e}")
  254. # 2. 百度云标准版
  255. print("=== 百度云标准版 ===")
  256. try:
  257. raw_c = _ocr.recognize(img_path, detail="all", engine="cloud")
  258. hits = [r for r in raw_c if keyword in r["text"]]
  259. if not hits:
  260. print(" (未识别到含关键词的文字)")
  261. for r in hits:
  262. print(f" conf={r['confidence']:.2f} box={r['box']} [{r['text']}]")
  263. except Exception as e:
  264. print(f" 云端失败: {e}")
  265. # 3. PaddleOCR
  266. print("=== PaddleOCR ===")
  267. try:
  268. import cv2 as _cv2
  269. img = _cv2.imread(img_path)
  270. img = _cv2.cvtColor(img, _cv2.COLOR_BGR2RGB)
  271. _paddle = PaddleOCR(lang="ch", show_log=False)
  272. res = _paddle.ocr(img)
  273. hits = [line[1] for line in (res[0] or []) if keyword in line[1][0]]
  274. if not hits:
  275. print(" (未识别到含关键词的文字)")
  276. for text, conf in hits:
  277. print(f" conf={conf:.2f} [{text}]")
  278. except Exception as e:
  279. print(f" PaddleOCR失败: {e}")