audit_ocr.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. # -*- coding: utf-8 -*-
  2. """
  3. OCR 准确率核对工具 — 用截图当标准答案,核对 Excel 采集数据
  4. =============================================================================
  5. 用法:
  6. python audit_ocr.py # 默认核对 采集数据/ 最新一天的 xlsx
  7. python audit_ocr.py 采集数据/20260909.xlsx # 指定文件
  8. python audit_ocr.py xxx.xlsx --max 10 # 最多核对10张截图(PP-OCR较慢,约30~60秒/张)
  9. 核对口径:
  10. A. 店名字准率: Excel里 列表页OCR店名 vs 详情页AI店名(独立来源互为答案)
  11. B. 现场覆盖率: 来源截图列 追溯到原图 → 本地PP-OCR重新识别 → 图上枚举出的完整卡片
  12. 是否都进了 Excel(漏采检测);Excel行记录的价格是否真的出现在原图上(追溯检测)
  13. 说明:
  14. B 口径的图上枚举用本地PP-OCR+几何规则(价格块+下方店铺块+上方标题块),与GLM无关,
  15. 所以能发现 GLM 漏卡/错配;字级别的标题准确率仍以人工抽查为准。
  16. """
  17. import json
  18. import random
  19. import re
  20. import sys
  21. from pathlib import Path
  22. ROOT = Path(__file__).parent
  23. sys.path.insert(0, str(ROOT))
  24. MAX_GAP = 216 # 价格→店铺窗口(与采集端校验一致,按1220x2712标定)
  25. TITLE_WIN = 330 # 标题窗口(价格上方)
  26. PROMO_RE = re.compile(r"起送|满\d|配送|发货|日达|红包|已售|月售|预估|减\d|处方|OTC|同款|低价|原研|新客|超值")
  27. def norm(s):
  28. return re.sub(r"\s+", "", str(s or "")).lower()
  29. def cy(b):
  30. return (b["box"][1] + b["box"][3]) // 2
  31. def cx(b):
  32. return (b["box"][0] + b["box"][2]) // 2
  33. def price_num(p):
  34. m = re.search(r"[\d.]+", str(p or ""))
  35. return m.group(0).rstrip(".") if m else ""
  36. def edit_dist(a, b):
  37. if abs(len(a) - len(b)) > 2:
  38. return 99
  39. prev = list(range(len(b) + 1))
  40. for i, ca in enumerate(a, 1):
  41. cur = [i]
  42. for j, cb in enumerate(b, 1):
  43. cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
  44. prev = cur
  45. return prev[-1]
  46. def shop_match(a, b):
  47. na, nb = norm(a), norm(b)
  48. if len(na) < 3 or len(nb) < 3:
  49. return False
  50. if na in nb or nb in na:
  51. return True
  52. return edit_dist(na, nb) <= 1
  53. def load_excel(path):
  54. from openpyxl import load_workbook
  55. wb = load_workbook(path, read_only=True)
  56. rows = []
  57. for ws in wb.worksheets:
  58. for r in list(ws.iter_rows(values_only=True))[1:]:
  59. t, p, s1, s2, snap, link, src = (list(r) + [None] * 7)[:7]
  60. rows.append({"sheet": ws.title, "title": str(t or ""), "price": str(p or ""),
  61. "shop": str(s1 or ""), "shop_ai": str(s2 or ""), "src": str(src or "")})
  62. return rows
  63. def shop_accuracy(rows):
  64. """口径A: 列表OCR店名 vs 详情页AI店名"""
  65. comparable = [r for r in rows if norm(r["shop"]) and norm(r["shop_ai"])]
  66. exact = sum(1 for r in comparable if norm(r["shop"]) == norm(r["shop_ai"]))
  67. fuzzy = [r for r in comparable
  68. if norm(r["shop"]) != norm(r["shop_ai"]) and shop_match(r["shop"], r["shop_ai"])]
  69. diff = [r for r in comparable
  70. if norm(r["shop"]) != norm(r["shop_ai"]) and not shop_match(r["shop"], r["shop_ai"])]
  71. print("=" * 78)
  72. print(f"口径A 店名字准率(可比 {len(comparable)}/{len(rows)} 行)")
  73. print(f" 完全一致 {exact:>4} {exact/len(comparable)*100 if comparable else 0:.1f}%")
  74. print(f" 同店近似 {len(fuzzy):>4} (错1~2字/前后缀差)")
  75. print(f" 不一致 {len(diff):>4} ← 逐条人工判断是'读错字'还是'配错卡'")
  76. for r in diff:
  77. print(f" [{r['sheet']}] {r['title'][:30]} {r['price']}")
  78. print(f" 列表OCR: {r['shop'][:24]} ↔ 详情AI: {r['shop_ai'][:24]} 来源:{r['src']}")
  79. n = len(comparable)
  80. if n:
  81. print(f" → 店名识别成功率 ≈ {(exact + len(fuzzy)) / n * 100:.1f}%")
  82. return diff
  83. def split_right_col(blocks):
  84. """定位右列分界x(同采集端:¥块左边缘中位数-15,图片区在左、文字区在右)"""
  85. price_x = sorted(b["box"][0] for b in blocks if "¥" in b["text"] or "¥" in b["text"])
  86. if len(price_x) < 3:
  87. return 0
  88. return max(price_x[len(price_x) // 2] - 15, 0)
  89. def enumerate_cards(blocks):
  90. """几何规则枚举图上的完整卡片: 价格块 + 下方店铺块 + 上方标题块(与GLM无关的独立读图)
  91. 先裁掉左列商品图(包装文字会污染店铺/标题),价格块必须以¥开头(排除'起送¥0'类促销行)"""
  92. split = split_right_col(blocks)
  93. blocks = [b for b in blocks if cx(b) >= split] if split else blocks
  94. cards = []
  95. used_shop = []
  96. prices = [b for b in blocks if norm(b["text"]).startswith(("¥", "¥"))
  97. and re.search(r"¥[\d.]+|¥[\d.]+", b["text"])]
  98. prices.sort(key=cy)
  99. for pb in prices:
  100. py = cy(pb)
  101. pn = price_num(pb["text"])
  102. if not pn:
  103. continue
  104. # 店铺: 价格下方最近的有效块
  105. shop = None
  106. for b in sorted(blocks, key=cy):
  107. y = cy(b)
  108. if not (py + 5 < y < py + MAX_GAP):
  109. continue
  110. t = norm(b["text"])
  111. if len(t) < 4 or "¥" in b["text"] or "¥" in b["text"] or PROMO_RE.search(t):
  112. continue
  113. if any(abs(y - u) < 25 for u in used_shop):
  114. continue
  115. shop = b
  116. break
  117. if shop is None:
  118. continue
  119. used_shop.append(cy(shop))
  120. # 标题: 价格上方最长有效块
  121. title, tlen = "", 0
  122. for b in blocks:
  123. y = cy(b)
  124. if not (py - TITLE_WIN < y < py - 5):
  125. continue
  126. t = norm(b["text"])
  127. if len(t) < 6 or "¥" in b["text"] or "¥" in b["text"] or PROMO_RE.search(t):
  128. continue
  129. if len(t) > tlen:
  130. title, tlen = b["text"], len(t)
  131. cards.append({"price": pn, "shop": re.split(r"[((]", re.sub(r"\s+", "", shop["text"]))[0],
  132. "title": re.sub(r"\s+", "", title), "py": py})
  133. return cards
  134. def field_audit(rows, max_images):
  135. """口径B: 来源截图追溯 + 图上卡片漏采检测"""
  136. src_rows = [r for r in rows if r["src"]]
  137. if not src_rows:
  138. print("\n口径B 跳过:该Excel没有「来源截图」列数据(列是今天新加的,明天采集后生效)")
  139. return
  140. try:
  141. from ai_helper_vision1 import VisionParser1
  142. except Exception as e:
  143. print(f"\n口径B 跳过:PP-OCR加载失败({e})")
  144. return
  145. # 来源 → 截图文件(取 base 文件;若被覆盖可用同名时间戳副本里最新的一张)
  146. shots = {}
  147. for r in src_rows:
  148. dev, batch = r["src"].split("/step3_b", 1)
  149. base = ROOT / "screenshots" / dev / "step3" / f"step3_b{batch}.png"
  150. if base.exists():
  151. shots.setdefault(str(base), (dev, batch))
  152. else: # 找时间戳副本
  153. cands = sorted((ROOT / "screenshots" / dev / "step3").glob(f"step3_b{batch}_*.png"),
  154. key=lambda p: p.stat().st_mtime)
  155. if cands:
  156. shots.setdefault(str(cands[-1]), (dev, batch))
  157. parser = VisionParser1()
  158. keys = list(shots.keys())
  159. if max_images and len(keys) > max_images:
  160. keys = random.sample(keys, max_images) # 跨设备随机抽样,避免样本偏斜
  161. total_cards = matched = 0
  162. missing_report, trace_fail = [], []
  163. audited_srcs = {shots[k] for k in keys}
  164. print("\n" + "=" * 78)
  165. print(f"口径B 现场核对({len(keys)}/{len(shots)} 张截图,PP-OCR本地识别,每张约30~60秒)")
  166. for k in keys:
  167. dev, batch = shots[k]
  168. try:
  169. blocks = parser._ppocr_blocks(k, 0) # 整图识别,由几何规则自行分列
  170. except Exception as e:
  171. print(f" [跳过] {dev}/b{batch}: OCR失败 {e}")
  172. continue
  173. cards = enumerate_cards(blocks)
  174. # 图上卡片 → Excel 全量匹配(价格+店名)
  175. hit = 0
  176. for c in cards:
  177. rows_p = [r for r in rows if price_num(r["price"]) == c["price"]]
  178. if any(shop_match(c["shop"], r["shop"]) for r in rows_p):
  179. hit += 1
  180. else:
  181. missing_report.append((dev, batch, c))
  182. total_cards += len(cards)
  183. matched += hit
  184. print(f" {dev}/b{batch}: 图上{len(cards)}卡, 入Excel {hit}")
  185. # 追溯: 来源=这张图的Excel行,其价格必须出现在图上
  186. for r in src_rows:
  187. if (r["src"].split("/step3_b")[0], r["src"].split("/step3_b")[1]) == (dev, batch):
  188. pn = price_num(r["price"])
  189. if pn and not any(pn in norm(b["text"]) for b in blocks if "¥" in b["text"] or "¥" in b["text"]):
  190. trace_fail.append(r)
  191. print("-" * 78)
  192. if total_cards:
  193. print(f"图上卡片共 {total_cards} 张, 已入Excel {matched} 张 → 现场覆盖率 {matched/total_cards*100:.1f}%")
  194. for dev, batch, c in missing_report:
  195. print(f" [图上有但Excel没有] {dev}/b{batch} ¥{c['price']} {c['shop'][:14]} {c['title'][:26]}")
  196. if trace_fail:
  197. print(f"追溯异常 {len(trace_fail)} 行(Excel记的价格在来源截图上找不到):")
  198. for r in trace_fail[:10]:
  199. print(f" [{r['sheet']}] {r['title'][:26]} {r['price']} 来源:{r['src']}")
  200. if not trace_fail:
  201. print("追溯检测: 全部Excel行的价格都能在其来源截图上找到 ✓")
  202. def main():
  203. args = [a for a in sys.argv[1:] if not a.startswith("--")]
  204. max_images = None
  205. if "--max" in sys.argv:
  206. try:
  207. max_images = int(sys.argv[sys.argv.index("--max") + 1])
  208. except (IndexError, ValueError):
  209. pass
  210. if args:
  211. xlsx = ROOT / args[0]
  212. else:
  213. cands = sorted((ROOT / "采集数据").glob("*.xlsx"), key=lambda p: p.stat().st_mtime)
  214. if not cands:
  215. print("采集数据/ 下没有 xlsx"); sys.exit(1)
  216. xlsx = cands[-1]
  217. if not xlsx.exists():
  218. print(f"文件不存在: {xlsx}"); sys.exit(1)
  219. print(f"核对文件: {xlsx}\n")
  220. rows = load_excel(xlsx)
  221. print(f"共 {len(rows)} 行, {len(set(r['sheet'] for r in rows))} 个药品\n")
  222. shop_accuracy(rows)
  223. field_audit(rows, max_images)
  224. print("\n提示: 口径B依赖「来源截图」列(今天新加),旧数据该列为空会自动跳过。")
  225. if __name__ == "__main__":
  226. main()