| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272 |
- # -*- coding: utf-8 -*-
- """
- ai_helper_vision2 — 起送行锚定列表解析(纯规则, 零AI调用)
- =============================================================================
- 替换 ai_helper_vision1 的 PP-OCR+GLM 交叉验证分卡。
- 原理(2026-09-20 与用户真机联调确定):
- 每张商品卡片的底部必然是一行「起送¥…」(与"1-3日达"同行)
- → 起送行 = 卡片底部锚点, 相邻两条起送行之间 = 一张卡的完整内容
- → 卡片数 = 起送行数, 结构性防漏卡/防错位
- 残卡: 首区间高度 < 中位60%, 或 首区间文字不含采集关键词 → 裁掉不采
- 底部: 最后一条起送行以下留给下一屏, 不产生残卡
- 耗尽横幅: 「搜索结果较少,为你推荐相关店铺」以下 = 店铺推荐流, 不采集
- 店铺名: 只取括号前部分(去「(快递发…)」等后缀)
- 字段: 标题/价格/已售/店铺名 全部规则提取, 失败只影响字段不影响分卡
- 接口与 ai_helper_vision1 完全兼容:
- parse_shops(image_path, screen_size=(w,h), keyword='', crop_x=0) -> (shops, status)
- shops 元素: [店名, 标题, 价格, [click_x, click_y], 店铺行y, 已售]
- status: "ok" | "page_wrong"(无起送行,非列表页) | "failed"(OCR失败)
- 独立测试: python ai_helper_vision2.py <截图路径> [更多路径] [--kw 采集关键词]
- 调试图自动保存: swipe_test_preview/vision2_debug/
- """
- import os
- import re
- import cv2
- import numpy as np
- try: # main1 环境(steps已在sys.path)
- from ocr import OCR as _OCRClass
- except ImportError: # 独立运行
- from steps.ocr import OCR as _OCRClass
- OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)),
- "swipe_test_preview", "vision2_debug")
- os.makedirs(OUT_DIR, exist_ok=True)
- HAS_Y = lambda t: ('¥' in t or '¥' in t) # 半角/全角人民币符号
- SHOP_RE = re.compile(r'药房|药店|医药|健康|自营|旗舰|连锁|快递发')
- BADGE = ('快递发货', '快递') # 纯徽标块不算店铺名
- TAIL_RE = re.compile(r'^起送|日达$') # 卡片底部行(起送/日达)
- SPEC_RE = re.compile(r'\d|盒|丸|支|瓶|片|粒|袋|贴|枚') # 标题续行特征(含规格)
- EFF_RE = re.compile(r'湿疹|皮炎|瘙痒|疼痛|止咳|祛痰|消炎|杀菌|通络|活血'
- r'|止痛|消肿|鼻炎|感冒|咳嗽|祛风|燥湿|止痒|解毒|清热|上火'
- r'|手足癣|体股癣|花斑癣|神经性|异位性|脓疱|头皮脂溢')
- _OCR_SINGLE = None
- def _get_ocr():
- """RapidOCR 单例"""
- global _OCR_SINGLE
- if _OCR_SINGLE is None:
- _OCR_SINGLE = _OCRClass()
- return _OCR_SINGLE
- class VisionParser2:
- """起送行锚定列表解析器(接口兼容 VisionParser1)"""
- def __init__(self, api_key: str = None):
- self.api_key = api_key # 兼容保留, 本类不使用
- # ── 主入口(接口兼容 VisionParser1.parse_shops) ──
- def parse_shops(self, image_path: str, screen_size: tuple = (720, 1640),
- keyword: str = "", crop_x: int = 0) -> tuple:
- try:
- o = _get_ocr()
- raw = o.recognize(image_path, detail="all")
- if not raw:
- print("[v2] OCR无结果")
- return [], "failed"
- H = max((r["box"][3] for r in raw), default=0) or screen_size[1]
- # 关键词: 未传时从顶部搜索框取最长块
- # (排除状态栏时钟 及 顶部chips/横幅词, 否则会把"8:38"当关键词)
- keyword = (keyword or "").strip()
- if not keyword:
- top_blocks = [r["text"] for r in raw
- if (r["box"][1] + r["box"][3]) // 2 < H * 0.085
- and r["box"][1] >= 60 # 跳过状态栏
- and not re.match(r'^\d{1,2}:\d{2}$', r["text"]) # 跳过时钟
- and r["text"].strip() not in
- ('外卖红包', '医保支付', '快递', '阿里自营', '30分钟达',
- '筛选', '签到', '搜索')]
- if top_blocks:
- keyword = max(top_blocks, key=len).strip()
- # 右列过滤(裁剪坐标: 优先调用方给的 crop_x, 否则用¥块左缘中位数自检)
- if not crop_x or crop_x <= 0:
- ysb = [r["box"][0] for r in raw if HAS_Y(r.get("text", ""))]
- crop_x = int(np.median(ysb)) - 15 if ysb else int(screen_size[0] * 0.4)
- blocks = sorted([((r["box"][1] + r["box"][3]) // 2, r["text"],
- r["box"][3], (r["box"][0] + r["box"][2]) // 2)
- for r in raw
- if (r["box"][0] + r["box"][2]) // 2 >= crop_x])
- # ── 底部边界: 同一水平内出现「两个及以上」卡片尾部标记:
- # ①起送¥ ②免配送费/配送¥ ③1-3日达 (②③可与①合并在同一块内,
- # 如"起送¥20配送¥5" = ①+② 两个标记) ──
- bounds, i = [], 0
- while i < len(blocks):
- j = i
- while j < len(blocks) and blocks[j][0] - blocks[i][0] <= 16:
- j += 1
- grp = blocks[i:j]
- texts = [t for _, t, _, _ in grp]
- n = sum([any('起送' in t and HAS_Y(t) for t in texts),
- any('免配送费' in t or '配送¥' in t or '配送¥' in t for t in texts),
- any('日达' in t for t in texts)])
- if n >= 2 and grp[0][0] > H * 0.12:
- bounds.append({'cy': grp[0][0], 'line': max(g[2] for g in grp) + 3})
- i = j
- if not bounds:
- print("[v2] 未检出起送行 → 非列表页")
- self._debug_save(image_path, [], [])
- return [], "page_wrong"
- # ── 顶线: 快递/阿里自营/筛选 chips 行(文字下方) ──
- top, top_line = 0, 0
- for a in range(len(blocks)):
- grp = [g for g in blocks if abs(g[0] - blocks[a][0]) <= 16 and g[0] < H * 0.18]
- texts = [t for _, t, _, _ in grp]
- n = sum([any(t.strip() == '快递' for t in texts),
- any('阿里自营' in t for t in texts),
- any('筛选' in t for t in texts)])
- if n >= 2:
- top, top_line = grp[0][0], max(g[2] for g in grp) + 3
- break
- # ── 区间划分与分类 ──
- # 耗尽横幅「搜索结果较少,为你推荐相关店铺」以下 = 店铺推荐流, 不采集
- banner_top = 0
- for r in raw:
- t = r.get('text', '')
- if ('搜索结果较少' in t) or ('为你推荐相关店铺' in t):
- banner_top = r['box'][1] if not banner_top else min(banner_top, r['box'][1])
- cys = [b['cy'] for b in bounds]
- intervals = [(top_line, cys[0])] + [(cys[k], cys[k + 1]) for k in range(len(cys) - 1)]
- intervals.append((cys[-1], H))
- heights = [b - a for a, b in intervals[:-1]] or [1]
- med = sorted(heights)[len(heights) // 2]
- shops, marks = [], []
- for k, (a, b) in enumerate(intervals):
- ib = [(cy, t, cx) for cy, t, y2, cx in blocks if a < cy <= b]
- price_t = next((t for cy, t, cx in reversed(ib)
- if HAS_Y(t) and not t.startswith('起送')), '')
- last = (k == len(intervals) - 1)
- if last:
- marks.append((k + 1, 'bottom_partial', a, b, (160, 160, 160)))
- continue
- if banner_top and (a >= banner_top - 5 or a < banner_top < b):
- marks.append((k + 1, 'below_banner', a, b, (200, 150, 80)))
- continue
- if (b - a) < 0.6 * med:
- marks.append((k + 1, 'residual', a, b, (0, 0, 255)))
- continue
- kw_hit = bool(keyword) and any(keyword[:2] in t or keyword[:4] in t
- for _, t, _ in ib)
- if a == top_line and keyword and not kw_hit:
- marks.append((k + 1, 'residual', a, b, (0, 0, 255)))
- continue
- # ── 字段提取 ──
- price_m = re.search(r'[¥¥]\s*([0-9]+(?:\.[0-9]+)?)', price_t)
- price = f"¥{price_m.group(1)}" if price_m else price_t[:8]
- sold_m = re.search(r'已售(\d+\+?)', price_t)
- sold = sold_m.group(1) if sold_m else ''
- title = self._build_title(ib)
- shop = self._extract_shop(ib, title)
- click = self._price_center(ib)
- shops.append([shop, title, price, click, bounds[k]['line'], sold])
- marks.append((k + 1, 'ok', a, b, (0, 200, 0)))
- n_ok = sum(1 for m in marks if m[1] == 'ok')
- n_res = sum(1 for m in marks if m[1] == 'residual')
- n_bot = sum(1 for m in marks if m[1] == 'bottom_partial')
- n_ban = sum(1 for m in marks if m[1] == 'below_banner')
- print(f"[v2] 起送行{len(bounds)} → 完整{n_ok} 残卡{n_res} 底部{n_bot} 横幅下{n_ban} "
- f"(标注图: vision2_debug/)")
- self._debug_save(image_path, marks, bounds)
- return shops, "ok"
- except Exception as e:
- print(f"[v2] 解析异常: {e}")
- import traceback
- traceback.print_exc()
- return [], "failed"
- # ── 标题组装: 首块 + 规格续行(跳过功效行) ──
- def _build_title(self, ib):
- content = [t for cy, t, cx in ib if not TAIL_RE.search(t) and not HAS_Y(t)]
- if not content:
- return ''
- title = content[0].strip()
- if len(content) >= 2:
- nxt = content[1].strip()
- m = SPEC_RE.search(nxt)
- if len(nxt) <= 14 and m and not EFF_RE.search(re.sub(r'\d|盒|丸|支|瓶|片|粒|袋|贴|枚', '', nxt)):
- title += nxt
- return title[:60]
- # ── 店铺名: 数据字段, 宽松提取, 失败不影响分卡 ──
- def _extract_shop(self, ib, title):
- shop = next((t for cy, t, cx in reversed(ib)
- if SHOP_RE.search(t) and t.strip() not in BADGE
- and not (len(t.strip()) <= 5 and '快递' in t)), '')
- if shop:
- shop = re.split(r'快递发货', shop)[0] # 剥掉混入的「快递发货」徽标
- shop = re.split(r'[((]', shop)[0].strip() # 只要括号前的店名(去「(快递发…)」后缀)
- shop = re.sub(r'[..…]+$', '', shop).strip() # 去尾部省略号
- if shop:
- return shop[:24]
- cand = [t for cy, t, cx in ib
- if t != title and not HAS_Y(t) and not TAIL_RE.search(t)
- and len(t.strip()) >= 4]
- if cand:
- return re.split(r'[((]', max(cand, key=len))[0].strip()[:20]
- return ''
- # ── 价格块中心(click) ──
- def _price_center(self, ib):
- cand = [(cy, cx) for cy, t, cx in ib if HAS_Y(t) and not t.startswith('起送')]
- if not cand:
- return [0, 0]
- cy, cx = cand[-1]
- return [int(cx), int(cy)]
- # ── 调试图: 起送行绿线+各区间标注; 首区间为残卡时额外输出裁掉残卡的最终图 ──
- def _debug_save(self, image_path, marks, bounds):
- try:
- img = cv2.imread(image_path)
- if img is None:
- return
- for (n, kind, a, b, color) in marks:
- cv2.putText(img, f'#{n} {kind}', (8, min(a + 30, img.shape[0] - 10)),
- cv2.FONT_HERSHEY_SIMPLEX, 0.9, color, 2)
- for b in bounds:
- cv2.line(img, (0, b['line']), (img.shape[1], b['line']), (0, 200, 0), 2)
- base = os.path.basename(image_path)
- cv2.imwrite(os.path.join(OUT_DIR, f'{base}_v2.png'), img)
- # 最终图: 只留完整卡区域 — 首区间残卡→从第一条边界线裁顶,
- # 末区间底部不完整→裁到最后一条边界线为止
- crop_top = bounds[0]['line'] if marks and marks[0][1] == 'residual' else 0
- crop_bottom = bounds[-1]['line'] if marks and marks[-1][1] == 'bottom_partial' \
- else img.shape[0]
- final = img[crop_top:crop_bottom, :]
- if final.size:
- cv2.imwrite(os.path.join(OUT_DIR, f'{base}_v2_final.png'), final)
- except Exception:
- pass
- VisionParser = VisionParser2
- if __name__ == '__main__':
- import sys
- args = [a for a in sys.argv[1:] if a != '--kw']
- kw = sys.argv[sys.argv.index('--kw') + 1] if '--kw' in sys.argv else ''
- if not args:
- print('用法: python ai_helper_vision2.py <截图路径> [更多路径] [--kw 采集关键词]')
- sys.exit(0)
- p = VisionParser2()
- for f in args:
- print(f'\n===== {os.path.basename(f)} (关键词: {kw or "自动从搜索框提取"}) =====')
- shops, status = p.parse_shops(f, keyword=kw)
- print(f'status={status}, 卡片{len(shops)}张:')
- for s in shops:
- print(f' {s[0]} | {str(s[1])[:36]} | {s[2]} | 已售={s[5]} | click={s[3]}')
|