ai_helper_vision2.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. # -*- coding: utf-8 -*-
  2. """
  3. ai_helper_vision2 — 起送行锚定列表解析(纯规则, 零AI调用)
  4. =============================================================================
  5. 替换 ai_helper_vision1 的 PP-OCR+GLM 交叉验证分卡。
  6. 原理(2026-09-20 与用户真机联调确定):
  7. 每张商品卡片的底部必然是一行「起送¥…」(与"1-3日达"同行)
  8. → 起送行 = 卡片底部锚点, 相邻两条起送行之间 = 一张卡的完整内容
  9. → 卡片数 = 起送行数, 结构性防漏卡/防错位
  10. 残卡: 首区间高度 < 中位60%, 或 首区间文字不含采集关键词 → 裁掉不采
  11. 底部: 最后一条起送行以下留给下一屏, 不产生残卡
  12. 耗尽横幅: 「搜索结果较少,为你推荐相关店铺」以下 = 店铺推荐流, 不采集
  13. 店铺名: 只取括号前部分(去「(快递发…)」等后缀)
  14. 字段: 标题/价格/已售/店铺名 全部规则提取, 失败只影响字段不影响分卡
  15. 接口与 ai_helper_vision1 完全兼容:
  16. parse_shops(image_path, screen_size=(w,h), keyword='', crop_x=0) -> (shops, status)
  17. shops 元素: [店名, 标题, 价格, [click_x, click_y], 店铺行y, 已售]
  18. status: "ok" | "page_wrong"(无起送行,非列表页) | "failed"(OCR失败)
  19. 独立测试: python ai_helper_vision2.py <截图路径> [更多路径] [--kw 采集关键词]
  20. 调试图自动保存: swipe_test_preview/vision2_debug/
  21. """
  22. import os
  23. import re
  24. import cv2
  25. import numpy as np
  26. try: # main1 环境(steps已在sys.path)
  27. from ocr import OCR as _OCRClass
  28. except ImportError: # 独立运行
  29. from steps.ocr import OCR as _OCRClass
  30. OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)),
  31. "swipe_test_preview", "vision2_debug")
  32. os.makedirs(OUT_DIR, exist_ok=True)
  33. HAS_Y = lambda t: ('¥' in t or '¥' in t) # 半角/全角人民币符号
  34. SHOP_RE = re.compile(r'药房|药店|医药|健康|自营|旗舰|连锁|快递发')
  35. BADGE = ('快递发货', '快递') # 纯徽标块不算店铺名
  36. TAIL_RE = re.compile(r'^起送|日达$') # 卡片底部行(起送/日达)
  37. SPEC_RE = re.compile(r'\d|盒|丸|支|瓶|片|粒|袋|贴|枚') # 标题续行特征(含规格)
  38. EFF_RE = re.compile(r'湿疹|皮炎|瘙痒|疼痛|止咳|祛痰|消炎|杀菌|通络|活血'
  39. r'|止痛|消肿|鼻炎|感冒|咳嗽|祛风|燥湿|止痒|解毒|清热|上火'
  40. r'|手足癣|体股癣|花斑癣|神经性|异位性|脓疱|头皮脂溢')
  41. _OCR_SINGLE = None
  42. def _get_ocr():
  43. """RapidOCR 单例"""
  44. global _OCR_SINGLE
  45. if _OCR_SINGLE is None:
  46. _OCR_SINGLE = _OCRClass()
  47. return _OCR_SINGLE
  48. class VisionParser2:
  49. """起送行锚定列表解析器(接口兼容 VisionParser1)"""
  50. def __init__(self, api_key: str = None):
  51. self.api_key = api_key # 兼容保留, 本类不使用
  52. # ── 主入口(接口兼容 VisionParser1.parse_shops) ──
  53. def parse_shops(self, image_path: str, screen_size: tuple = (720, 1640),
  54. keyword: str = "", crop_x: int = 0) -> tuple:
  55. try:
  56. o = _get_ocr()
  57. raw = o.recognize(image_path, detail="all")
  58. if not raw:
  59. print("[v2] OCR无结果")
  60. return [], "failed"
  61. H = max((r["box"][3] for r in raw), default=0) or screen_size[1]
  62. # 关键词: 未传时从顶部搜索框取最长块
  63. # (排除状态栏时钟 及 顶部chips/横幅词, 否则会把"8:38"当关键词)
  64. keyword = (keyword or "").strip()
  65. if not keyword:
  66. top_blocks = [r["text"] for r in raw
  67. if (r["box"][1] + r["box"][3]) // 2 < H * 0.085
  68. and r["box"][1] >= 60 # 跳过状态栏
  69. and not re.match(r'^\d{1,2}:\d{2}$', r["text"]) # 跳过时钟
  70. and r["text"].strip() not in
  71. ('外卖红包', '医保支付', '快递', '阿里自营', '30分钟达',
  72. '筛选', '签到', '搜索')]
  73. if top_blocks:
  74. keyword = max(top_blocks, key=len).strip()
  75. # 右列过滤(裁剪坐标: 优先调用方给的 crop_x, 否则用¥块左缘中位数自检)
  76. if not crop_x or crop_x <= 0:
  77. ysb = [r["box"][0] for r in raw if HAS_Y(r.get("text", ""))]
  78. crop_x = int(np.median(ysb)) - 15 if ysb else int(screen_size[0] * 0.4)
  79. blocks = sorted([((r["box"][1] + r["box"][3]) // 2, r["text"],
  80. r["box"][3], (r["box"][0] + r["box"][2]) // 2)
  81. for r in raw
  82. if (r["box"][0] + r["box"][2]) // 2 >= crop_x])
  83. # ── 底部边界: 同一水平内出现「两个及以上」卡片尾部标记:
  84. # ①起送¥ ②免配送费/配送¥ ③1-3日达 (②③可与①合并在同一块内,
  85. # 如"起送¥20配送¥5" = ①+② 两个标记) ──
  86. bounds, i = [], 0
  87. while i < len(blocks):
  88. j = i
  89. while j < len(blocks) and blocks[j][0] - blocks[i][0] <= 16:
  90. j += 1
  91. grp = blocks[i:j]
  92. texts = [t for _, t, _, _ in grp]
  93. n = sum([any('起送' in t and HAS_Y(t) for t in texts),
  94. any('免配送费' in t or '配送¥' in t or '配送¥' in t for t in texts),
  95. any('日达' in t for t in texts)])
  96. if n >= 2 and grp[0][0] > H * 0.12:
  97. bounds.append({'cy': grp[0][0], 'line': max(g[2] for g in grp) + 3})
  98. i = j
  99. if not bounds:
  100. print("[v2] 未检出起送行 → 非列表页")
  101. self._debug_save(image_path, [], [])
  102. return [], "page_wrong"
  103. # ── 顶线: 快递/阿里自营/筛选 chips 行(文字下方) ──
  104. top, top_line = 0, 0
  105. for a in range(len(blocks)):
  106. grp = [g for g in blocks if abs(g[0] - blocks[a][0]) <= 16 and g[0] < H * 0.18]
  107. texts = [t for _, t, _, _ in grp]
  108. n = sum([any(t.strip() == '快递' for t in texts),
  109. any('阿里自营' in t for t in texts),
  110. any('筛选' in t for t in texts)])
  111. if n >= 2:
  112. top, top_line = grp[0][0], max(g[2] for g in grp) + 3
  113. break
  114. # ── 区间划分与分类 ──
  115. # 耗尽横幅「搜索结果较少,为你推荐相关店铺」以下 = 店铺推荐流, 不采集
  116. banner_top = 0
  117. for r in raw:
  118. t = r.get('text', '')
  119. if ('搜索结果较少' in t) or ('为你推荐相关店铺' in t):
  120. banner_top = r['box'][1] if not banner_top else min(banner_top, r['box'][1])
  121. cys = [b['cy'] for b in bounds]
  122. intervals = [(top_line, cys[0])] + [(cys[k], cys[k + 1]) for k in range(len(cys) - 1)]
  123. intervals.append((cys[-1], H))
  124. heights = [b - a for a, b in intervals[:-1]] or [1]
  125. med = sorted(heights)[len(heights) // 2]
  126. shops, marks = [], []
  127. for k, (a, b) in enumerate(intervals):
  128. ib = [(cy, t, cx) for cy, t, y2, cx in blocks if a < cy <= b]
  129. price_t = next((t for cy, t, cx in reversed(ib)
  130. if HAS_Y(t) and not t.startswith('起送')), '')
  131. last = (k == len(intervals) - 1)
  132. if last:
  133. marks.append((k + 1, 'bottom_partial', a, b, (160, 160, 160)))
  134. continue
  135. if banner_top and (a >= banner_top - 5 or a < banner_top < b):
  136. marks.append((k + 1, 'below_banner', a, b, (200, 150, 80)))
  137. continue
  138. if (b - a) < 0.6 * med:
  139. marks.append((k + 1, 'residual', a, b, (0, 0, 255)))
  140. continue
  141. kw_hit = bool(keyword) and any(keyword[:2] in t or keyword[:4] in t
  142. for _, t, _ in ib)
  143. if a == top_line and keyword and not kw_hit:
  144. marks.append((k + 1, 'residual', a, b, (0, 0, 255)))
  145. continue
  146. # ── 字段提取 ──
  147. price_m = re.search(r'[¥¥]\s*([0-9]+(?:\.[0-9]+)?)', price_t)
  148. price = f"¥{price_m.group(1)}" if price_m else price_t[:8]
  149. sold_m = re.search(r'已售(\d+\+?)', price_t)
  150. sold = sold_m.group(1) if sold_m else ''
  151. title = self._build_title(ib)
  152. shop = self._extract_shop(ib, title)
  153. click = self._price_center(ib)
  154. shops.append([shop, title, price, click, bounds[k]['line'], sold])
  155. marks.append((k + 1, 'ok', a, b, (0, 200, 0)))
  156. n_ok = sum(1 for m in marks if m[1] == 'ok')
  157. n_res = sum(1 for m in marks if m[1] == 'residual')
  158. n_bot = sum(1 for m in marks if m[1] == 'bottom_partial')
  159. n_ban = sum(1 for m in marks if m[1] == 'below_banner')
  160. print(f"[v2] 起送行{len(bounds)} → 完整{n_ok} 残卡{n_res} 底部{n_bot} 横幅下{n_ban} "
  161. f"(标注图: vision2_debug/)")
  162. self._debug_save(image_path, marks, bounds)
  163. return shops, "ok"
  164. except Exception as e:
  165. print(f"[v2] 解析异常: {e}")
  166. import traceback
  167. traceback.print_exc()
  168. return [], "failed"
  169. # ── 标题组装: 首块 + 规格续行(跳过功效行) ──
  170. def _build_title(self, ib):
  171. content = [t for cy, t, cx in ib if not TAIL_RE.search(t) and not HAS_Y(t)]
  172. if not content:
  173. return ''
  174. title = content[0].strip()
  175. if len(content) >= 2:
  176. nxt = content[1].strip()
  177. m = SPEC_RE.search(nxt)
  178. if len(nxt) <= 14 and m and not EFF_RE.search(re.sub(r'\d|盒|丸|支|瓶|片|粒|袋|贴|枚', '', nxt)):
  179. title += nxt
  180. return title[:60]
  181. # ── 店铺名: 数据字段, 宽松提取, 失败不影响分卡 ──
  182. def _extract_shop(self, ib, title):
  183. shop = next((t for cy, t, cx in reversed(ib)
  184. if SHOP_RE.search(t) and t.strip() not in BADGE
  185. and not (len(t.strip()) <= 5 and '快递' in t)), '')
  186. if shop:
  187. shop = re.split(r'快递发货', shop)[0] # 剥掉混入的「快递发货」徽标
  188. shop = re.split(r'[((]', shop)[0].strip() # 只要括号前的店名(去「(快递发…)」后缀)
  189. shop = re.sub(r'[..…]+$', '', shop).strip() # 去尾部省略号
  190. if shop:
  191. return shop[:24]
  192. cand = [t for cy, t, cx in ib
  193. if t != title and not HAS_Y(t) and not TAIL_RE.search(t)
  194. and len(t.strip()) >= 4]
  195. if cand:
  196. return re.split(r'[((]', max(cand, key=len))[0].strip()[:20]
  197. return ''
  198. # ── 价格块中心(click) ──
  199. def _price_center(self, ib):
  200. cand = [(cy, cx) for cy, t, cx in ib if HAS_Y(t) and not t.startswith('起送')]
  201. if not cand:
  202. return [0, 0]
  203. cy, cx = cand[-1]
  204. return [int(cx), int(cy)]
  205. # ── 调试图: 起送行绿线+各区间标注; 首区间为残卡时额外输出裁掉残卡的最终图 ──
  206. def _debug_save(self, image_path, marks, bounds):
  207. try:
  208. img = cv2.imread(image_path)
  209. if img is None:
  210. return
  211. for (n, kind, a, b, color) in marks:
  212. cv2.putText(img, f'#{n} {kind}', (8, min(a + 30, img.shape[0] - 10)),
  213. cv2.FONT_HERSHEY_SIMPLEX, 0.9, color, 2)
  214. for b in bounds:
  215. cv2.line(img, (0, b['line']), (img.shape[1], b['line']), (0, 200, 0), 2)
  216. base = os.path.basename(image_path)
  217. cv2.imwrite(os.path.join(OUT_DIR, f'{base}_v2.png'), img)
  218. # 最终图: 只留完整卡区域 — 首区间残卡→从第一条边界线裁顶,
  219. # 末区间底部不完整→裁到最后一条边界线为止
  220. crop_top = bounds[0]['line'] if marks and marks[0][1] == 'residual' else 0
  221. crop_bottom = bounds[-1]['line'] if marks and marks[-1][1] == 'bottom_partial' \
  222. else img.shape[0]
  223. final = img[crop_top:crop_bottom, :]
  224. if final.size:
  225. cv2.imwrite(os.path.join(OUT_DIR, f'{base}_v2_final.png'), final)
  226. except Exception:
  227. pass
  228. VisionParser = VisionParser2
  229. if __name__ == '__main__':
  230. import sys
  231. args = [a for a in sys.argv[1:] if a != '--kw']
  232. kw = sys.argv[sys.argv.index('--kw') + 1] if '--kw' in sys.argv else ''
  233. if not args:
  234. print('用法: python ai_helper_vision2.py <截图路径> [更多路径] [--kw 采集关键词]')
  235. sys.exit(0)
  236. p = VisionParser2()
  237. for f in args:
  238. print(f'\n===== {os.path.basename(f)} (关键词: {kw or "自动从搜索框提取"}) =====')
  239. shops, status = p.parse_shops(f, keyword=kw)
  240. print(f'status={status}, 卡片{len(shops)}张:')
  241. for s in shops:
  242. print(f' {s[0]} | {str(s[1])[:36]} | {s[2]} | 已售={s[5]} | click={s[3]}')