ai_helper_vision_cross.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. """
  2. AI 视觉助手-交叉验证版(实验) — 与 ai_helper_vision1 平行存在, 不改动稳定版
  3. =============================================================================
  4. 流程: 同一屏 OCR 独立识别 2 次
  5. → 两次都有的卡 = 高置信, 直接收
  6. → 只在一次出现的卡 = 分歧, 发第 3 次仲裁判真/假
  7. (真卡标准: 标题在价格上方 + 店铺名在价格下方 + 三者同一张卡)
  8. → 基础校验 + 几何校验(继承稳定版, 宁丢卡不配错)
  9. 实验数据(3张陷阱图×3轮): 完美 5/9→6/9, 平均漏卡 0.78→0.33, 代价=每屏2.2次GLM调用
  10. 接入方式(零改动切换): main1.py 里把
  11. from ai_helper_vision1 import VisionParser
  12. 改为
  13. from ai_helper_vision_cross import VisionParser
  14. 其余代码不动; 想换回就改回来。
  15. 独立测试: python ai_helper_vision_cross.py (配置在 __main__ 的 TEST_IMAGE/TEST_KEYWORD)
  16. """
  17. import json
  18. import os
  19. import re
  20. import sys
  21. from ai_helper_vision1 import VisionParser1
  22. VisionParser = None # 末尾赋值: VisionParserCross
  23. ARBI_SYSTEM = """你是电商列表页 OCR 复核助手。对同一屏的 OCR 做了两次独立识别,两次结果有分歧。
  24. 分歧候选可能是:另一次漏识别的真卡片,也可能是把顶部残卡/相邻卡片误配出来的假卡片。
  25. 真卡片必须同时满足:标题在价格上方、店铺名在价格下方、三者属于同一张卡(y坐标相邻)。
  26. 判断后只保留真卡,字段值严格取自 OCR 原文。"""
  27. class VisionParserCross(VisionParser1):
  28. """双次独立识别 + 交叉仲裁 版解析器(几何校验继承稳定版)"""
  29. # ── 单次识别调用 ────────────────────────────────────
  30. def _call_cards(self, system_prompt: str, user_text: str) -> tuple:
  31. """返回 (status, cards): status = ok / page_wrong / failed"""
  32. resp = self._call_llm(system_prompt, user_text)
  33. cleaned = (resp or "").strip()
  34. if cleaned.startswith("```"):
  35. ls = cleaned.split("\n")
  36. if ls and ls[0].startswith("```"):
  37. ls = ls[1:]
  38. if ls and ls[-1].strip() == "```":
  39. ls = ls[:-1]
  40. cleaned = "\n".join(ls).strip()
  41. try:
  42. data = json.loads(cleaned)
  43. except json.JSONDecodeError:
  44. m = re.search(r'\[[\s\S]*\]', cleaned)
  45. if m:
  46. try:
  47. data = json.loads(m.group(0))
  48. except json.JSONDecodeError:
  49. data = None
  50. if not isinstance(data, list):
  51. return "failed", []
  52. if len(data) == 1 and isinstance(data[0], dict) and data[0].get("page_wrong"):
  53. return "page_wrong", []
  54. return "ok", [d for d in data if isinstance(d, dict)]
  55. # ── 配对工具 ────────────────────────────────────────
  56. @staticmethod
  57. def _pnum(p) -> str:
  58. m = re.search(r"[\d.]+", str(p or ""))
  59. return m.group(0).rstrip(".") if m else ""
  60. @classmethod
  61. def _shop_eq(cls, a, b) -> bool:
  62. na, nb = cls._gnorm(a), cls._gnorm(b)
  63. if len(na) < 3 or len(nb) < 3:
  64. return False
  65. if na in nb or nb in na:
  66. return True
  67. if len(na) == len(nb):
  68. return sum(x != y for x, y in zip(na, nb)) <= 1
  69. return False
  70. @classmethod
  71. def _same_card(cls, c1: dict, c2: dict) -> bool:
  72. """两次识别的两张卡是否同一张: 价格相同 且 (店名容错相同 或 标题重叠≥6字)"""
  73. if cls._pnum(c1.get("price")) != cls._pnum(c2.get("price")):
  74. return False
  75. shop_ok = cls._shop_eq(c1.get("shop"), c2.get("shop"))
  76. title_ok = cls._glcs(cls._gnorm(c1.get("title")), cls._gnorm(c2.get("title"))) >= 6
  77. return shop_ok or title_ok
  78. # ── 基础校验(与稳定版逐字一致) ────────────────────────
  79. def _basic_filter(self, data: list, screen_w: int, screen_h: int) -> list:
  80. shops = []
  81. for i, item in enumerate(data, 1):
  82. if not isinstance(item, dict):
  83. continue
  84. title = str(item.get("title", "") or "")
  85. price = str(item.get("price", "") or "")
  86. shop = str(item.get("shop", "") or "")
  87. shop = re.split(r'[((]', shop)[0].strip() # 只要括号前的店名(去「(快递发…)」后缀)
  88. sales = str(item.get("sales", "") or "")
  89. click = item.get("click") or item.get("coords") or []
  90. if not (shop and title and price):
  91. print(f"[v1] 卡{i}: [丢弃] 字段不完整: {shop} | {title[:20]} | {price}")
  92. continue
  93. if '¥' in title or '¥' in title:
  94. print(f"[v1] 卡{i}: [丢弃] 标题含价格符号(残卡)")
  95. continue
  96. if isinstance(click, list) and len(click) == 2:
  97. try:
  98. cx, cy = int(click[0]), int(click[1])
  99. except (TypeError, ValueError):
  100. continue
  101. if not (0 <= cx <= screen_w and 0 <= cy <= screen_h):
  102. print(f"[v1] 卡{i}: [丢弃] 坐标越界: ({cx},{cy})")
  103. continue
  104. else:
  105. print(f"[v1] 卡{i}: [丢弃] 无有效点击坐标")
  106. continue
  107. shop_y = int(item.get("shop_y", cy)) if str(item.get("shop_y", "")).lstrip('-').isdigit() else cy
  108. print(f"[v1] 卡{i}: [收录] {shop} | {title[:36]} | {price} | click=({cx},{cy}) | 已售={sales}")
  109. shops.append([shop, title, price, [cx, cy], shop_y, sales])
  110. return shops
  111. # ── 分歧仲裁 ────────────────────────────────────────
  112. def _arbitrate(self, ocr_text: str, a_only: list, b_only: list) -> list:
  113. user = (f"{ARBI_SYSTEM}\n\nOCR原文:\n{ocr_text}\n\n"
  114. f"第一次识别独有的卡片:\n{json.dumps(a_only, ensure_ascii=False)}\n\n"
  115. f"第二次识别独有的卡片:\n{json.dumps(b_only, ensure_ascii=False)}\n\n"
  116. f'请输出纯JSON:{{"keep":[{{"title":"...","price":"...","shop":"...","click":[x,y],"shop_y":y,"sales":"..."}}]}}')
  117. resp = self._call_llm(ARBI_SYSTEM, user)
  118. try:
  119. m = re.search(r'\{[\s\S]*\}', resp or "")
  120. keep = json.loads(m.group(0)).get("keep", []) if m else []
  121. return [k for k in keep if isinstance(k, dict)]
  122. except Exception as e:
  123. print(f"[v1] 仲裁解析失败({e}), 分歧卡全部丢弃(保守)")
  124. return []
  125. # ── 决策日志(供 check_cross.py 事后核对仲裁正确率) ────
  126. @staticmethod
  127. def _log_cross(record: dict):
  128. try:
  129. from pathlib import Path
  130. log_dir = Path(__file__).parent / "logs"
  131. log_dir.mkdir(exist_ok=True)
  132. record["ts"] = __import__("time").strftime("%Y-%m-%d %H:%M:%S")
  133. with open(log_dir / "cross_validate.jsonl", "a", encoding="utf-8") as f:
  134. f.write(json.dumps(record, ensure_ascii=False) + "\n")
  135. except Exception:
  136. pass
  137. def parse_shops(self, image_path: str, screen_size: tuple = (720, 1640),
  138. keyword: str = "", crop_x: int = 0) -> tuple:
  139. """记录当前截图路径(供决策日志追溯), 其余与稳定版一致"""
  140. self._cur_image = str(image_path)
  141. return super().parse_shops(image_path, screen_size=screen_size, keyword=keyword, crop_x=crop_x)
  142. # ── 主流程(重写稳定版的GLM分卡部分) ────────────────────
  143. def _extract_all_cards(self, blocks: list, screen_w: int, screen_h: int, keyword: str = "") -> tuple:
  144. """双次独立识别 + 交叉仲裁 → 基础校验 → 几何校验。返回 (shops, status)"""
  145. bs = sorted(blocks, key=lambda b: b["box"][1])
  146. lines = []
  147. for b in bs:
  148. box = b["box"]
  149. cx = (box[0] + box[2]) // 2
  150. cy = (box[1] + box[3]) // 2
  151. lines.append(f"[x={cx:4d}, y={cy:4d}] {b.get('text','')}")
  152. ocr_text = "\n".join(lines)
  153. system_prompt = self._cards_prompt()
  154. user_text = (
  155. f"屏幕OCR识别结果(共{len(bs)}个文字块):\n\n{ocr_text}\n\n"
  156. f"当前搜索的商品是「{keyword}」。请划分商品卡片并逐卡提取字段。"
  157. )
  158. def _compact(cards):
  159. return [{"shop": c.get("shop"), "price": c.get("price"),
  160. "title": str(c.get("title"))[:40], "click": c.get("click")} for c in cards]
  161. rec = {"image": getattr(self, "_cur_image", ""), "keyword": keyword,
  162. "blocks": len(bs), "n1": 0, "n2": 0, "agreement": 0,
  163. "run1": [], "run2": [], "a_only": [], "b_only": [], "kept": [],
  164. "final_cards": [], "final": 0, "status": ""}
  165. # 第1次独立识别
  166. status_a, cards_a = self._call_cards(system_prompt, user_text)
  167. rec["n1"] = len(cards_a)
  168. rec["run1"] = _compact(cards_a)
  169. if status_a != "ok":
  170. print(f"[v1] 第1次识别: {status_a}")
  171. rec["status"] = status_a
  172. self._log_cross(rec)
  173. return [], status_a
  174. # 第2次独立识别
  175. status_b, cards_b = self._call_cards(system_prompt, user_text)
  176. rec["n2"] = len(cards_b)
  177. rec["run2"] = _compact(cards_b)
  178. if status_b == "page_wrong":
  179. print("[v1] 第2次识别判定: page_wrong")
  180. rec["status"] = "page_wrong"
  181. self._log_cross(rec)
  182. return [], "page_wrong"
  183. if status_b != "ok":
  184. # 第2次失败 → 无交叉, 退回单次结果
  185. print("[v1] 第2次识别失败, 退回单次结果")
  186. rec["status"] = "ok(单次降级)"
  187. shops = self._basic_filter(cards_a, screen_w, screen_h)
  188. shops = self._validate_cards(shops, bs, screen_w, screen_h)
  189. rec["final"] = len(shops)
  190. self._log_cross(rec)
  191. return shops, "ok"
  192. # 交叉: 两次都有的 = 高置信
  193. # 某一侧空返回(GLM偶发) → 直接采用另一侧, 跳过仲裁(省一次调用)
  194. if not cards_a and cards_b:
  195. print(f"[v1] 第1次识别空返回, 直接采用第2次的 {len(cards_b)} 张(免仲裁)")
  196. shops = self._basic_filter(cards_b, screen_w, screen_h)
  197. shops = self._validate_cards(shops, bs, screen_w, screen_h)
  198. rec["final"] = len(shops)
  199. rec["final_cards"] = [{"shop": s[0], "title": str(s[1])[:40], "price": s[2],
  200. "click": s[3]} for s in shops]
  201. rec["status"] = "ok(单侧空,采用另一侧)"
  202. self._log_cross(rec)
  203. return shops, "ok"
  204. if cards_a and not cards_b:
  205. print(f"[v1] 第2次识别空返回, 直接采用第1次的 {len(cards_a)} 张(免仲裁)")
  206. shops = self._basic_filter(cards_a, screen_w, screen_h)
  207. shops = self._validate_cards(shops, bs, screen_w, screen_h)
  208. rec["final"] = len(shops)
  209. rec["final_cards"] = [{"shop": s[0], "title": str(s[1])[:40], "price": s[2],
  210. "click": s[3]} for s in shops]
  211. rec["status"] = "ok(单侧空,采用另一侧)"
  212. self._log_cross(rec)
  213. return shops, "ok"
  214. b_unused = list(cards_b)
  215. agreement, a_only = [], []
  216. for ca in cards_a:
  217. hit = None
  218. for cb in b_unused:
  219. if self._same_card(ca, cb):
  220. hit = cb
  221. break
  222. if hit is not None:
  223. b_unused.remove(hit)
  224. agreement.append(ca)
  225. else:
  226. a_only.append(ca)
  227. b_only = b_unused
  228. rec["a_only"] = _compact(a_only)
  229. rec["b_only"] = _compact(b_only)
  230. print(f"[v1] 交叉: 两次一致 {len(agreement)} 张, 分歧 {len(a_only)}+{len(b_only)} 张")
  231. # 分歧仲裁(仅分歧时第3次调用; 仲裁失败则保守丢弃分歧卡)
  232. merged = list(agreement)
  233. if a_only or b_only:
  234. kept = self._arbitrate(ocr_text, a_only, b_only)
  235. rec["kept"] = _compact(kept)
  236. print(f"[v1] 仲裁保留分歧卡 {len(kept)} 张")
  237. merged += kept
  238. shops = self._basic_filter(merged, screen_w, screen_h)
  239. shops = self._validate_cards(shops, bs, screen_w, screen_h)
  240. rec["agreement"] = len(agreement)
  241. rec["final"] = len(shops)
  242. rec["final_cards"] = [{"shop": s[0], "title": str(s[1])[:40], "price": s[2],
  243. "click": s[3]} for s in shops]
  244. rec["status"] = "ok"
  245. self._log_cross(rec)
  246. return shops, "ok"
  247. VisionParser = VisionParserCross
  248. # ── 独立测试 ───────────────────────────────────────────
  249. if __name__ == "__main__":
  250. TEST_IMAGE = r"" # 留空 = 自动找最新 step3_b0.png
  251. TEST_KEYWORD = "甲巯咪唑片"
  252. TEST_CROP_X = 287
  253. import glob
  254. _proj = os.path.dirname(os.path.abspath(__file__))
  255. _shots = []
  256. if os.path.isdir(os.path.join(_proj, "screenshots")):
  257. for p in glob.glob(os.path.join(_proj, "screenshots", "**", "step3", "step3_b0.png"), recursive=True):
  258. _shots.append(p)
  259. _shots.sort(key=os.path.getmtime, reverse=True)
  260. if TEST_IMAGE.strip():
  261. _shots = [TEST_IMAGE.strip()]
  262. if not _shots:
  263. print("没找到截图"); sys.exit(1)
  264. from PIL import Image
  265. for img_path in _shots[:3]:
  266. print("=" * 60)
  267. print(f"图片: {img_path}")
  268. try:
  269. w, h = Image.open(img_path).size
  270. except Exception:
  271. w, h = 720, 1640
  272. try:
  273. shops, status = VisionParserCross().parse_shops(img_path, screen_size=(w, h),
  274. keyword=TEST_KEYWORD, crop_x=TEST_CROP_X)
  275. print(f" → status={status}, {len(shops)} 卡:")
  276. for s in shops:
  277. print(f" {s[0]} | {s[1][:36]} | {s[2]} | click={s[3]}")
  278. except Exception as e:
  279. print(f" 异常: {e}")
  280. print()