ai_helper_vision_cross.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  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. sales = str(item.get("sales", "") or "")
  88. click = item.get("click") or item.get("coords") or []
  89. if not (shop and title and price):
  90. print(f"[v1] 卡{i}: [丢弃] 字段不完整: {shop} | {title[:20]} | {price}")
  91. continue
  92. if '¥' in title or '¥' in title:
  93. print(f"[v1] 卡{i}: [丢弃] 标题含价格符号(残卡)")
  94. continue
  95. if isinstance(click, list) and len(click) == 2:
  96. try:
  97. cx, cy = int(click[0]), int(click[1])
  98. except (TypeError, ValueError):
  99. continue
  100. if not (0 <= cx <= screen_w and 0 <= cy <= screen_h):
  101. print(f"[v1] 卡{i}: [丢弃] 坐标越界: ({cx},{cy})")
  102. continue
  103. else:
  104. print(f"[v1] 卡{i}: [丢弃] 无有效点击坐标")
  105. continue
  106. shop_y = int(item.get("shop_y", cy)) if str(item.get("shop_y", "")).lstrip('-').isdigit() else cy
  107. print(f"[v1] 卡{i}: [收录] {shop} | {title[:36]} | {price} | click=({cx},{cy}) | 已售={sales}")
  108. shops.append([shop, title, price, [cx, cy], shop_y, sales])
  109. return shops
  110. # ── 分歧仲裁 ────────────────────────────────────────
  111. def _arbitrate(self, ocr_text: str, a_only: list, b_only: list) -> list:
  112. user = (f"{ARBI_SYSTEM}\n\nOCR原文:\n{ocr_text}\n\n"
  113. f"第一次识别独有的卡片:\n{json.dumps(a_only, ensure_ascii=False)}\n\n"
  114. f"第二次识别独有的卡片:\n{json.dumps(b_only, ensure_ascii=False)}\n\n"
  115. f'请输出纯JSON:{{"keep":[{{"title":"...","price":"...","shop":"...","click":[x,y],"shop_y":y,"sales":"..."}}]}}')
  116. resp = self._call_llm(ARBI_SYSTEM, user)
  117. try:
  118. m = re.search(r'\{[\s\S]*\}', resp or "")
  119. keep = json.loads(m.group(0)).get("keep", []) if m else []
  120. return [k for k in keep if isinstance(k, dict)]
  121. except Exception as e:
  122. print(f"[v1] 仲裁解析失败({e}), 分歧卡全部丢弃(保守)")
  123. return []
  124. # ── 决策日志(供 check_cross.py 事后核对仲裁正确率) ────
  125. @staticmethod
  126. def _log_cross(record: dict):
  127. try:
  128. from pathlib import Path
  129. log_dir = Path(__file__).parent / "logs"
  130. log_dir.mkdir(exist_ok=True)
  131. record["ts"] = __import__("time").strftime("%Y-%m-%d %H:%M:%S")
  132. with open(log_dir / "cross_validate.jsonl", "a", encoding="utf-8") as f:
  133. f.write(json.dumps(record, ensure_ascii=False) + "\n")
  134. except Exception:
  135. pass
  136. def parse_shops(self, image_path: str, screen_size: tuple = (720, 1640),
  137. keyword: str = "", crop_x: int = 0) -> tuple:
  138. """记录当前截图路径(供决策日志追溯), 其余与稳定版一致"""
  139. self._cur_image = str(image_path)
  140. return super().parse_shops(image_path, screen_size=screen_size, keyword=keyword, crop_x=crop_x)
  141. # ── 主流程(重写稳定版的GLM分卡部分) ────────────────────
  142. def _extract_all_cards(self, blocks: list, screen_w: int, screen_h: int, keyword: str = "") -> tuple:
  143. """双次独立识别 + 交叉仲裁 → 基础校验 → 几何校验。返回 (shops, status)"""
  144. bs = sorted(blocks, key=lambda b: b["box"][1])
  145. lines = []
  146. for b in bs:
  147. box = b["box"]
  148. cx = (box[0] + box[2]) // 2
  149. cy = (box[1] + box[3]) // 2
  150. lines.append(f"[x={cx:4d}, y={cy:4d}] {b.get('text','')}")
  151. ocr_text = "\n".join(lines)
  152. system_prompt = self._cards_prompt()
  153. user_text = (
  154. f"屏幕OCR识别结果(共{len(bs)}个文字块):\n\n{ocr_text}\n\n"
  155. f"当前搜索的商品是「{keyword}」。请划分商品卡片并逐卡提取字段。"
  156. )
  157. def _compact(cards):
  158. return [{"shop": c.get("shop"), "price": c.get("price"),
  159. "title": str(c.get("title"))[:40], "click": c.get("click")} for c in cards]
  160. rec = {"image": getattr(self, "_cur_image", ""), "keyword": keyword,
  161. "blocks": len(bs), "n1": 0, "n2": 0, "agreement": 0,
  162. "run1": [], "run2": [], "a_only": [], "b_only": [], "kept": [],
  163. "final_cards": [], "final": 0, "status": ""}
  164. # 第1次独立识别
  165. status_a, cards_a = self._call_cards(system_prompt, user_text)
  166. rec["n1"] = len(cards_a)
  167. rec["run1"] = _compact(cards_a)
  168. if status_a != "ok":
  169. print(f"[v1] 第1次识别: {status_a}")
  170. rec["status"] = status_a
  171. self._log_cross(rec)
  172. return [], status_a
  173. # 第2次独立识别
  174. status_b, cards_b = self._call_cards(system_prompt, user_text)
  175. rec["n2"] = len(cards_b)
  176. rec["run2"] = _compact(cards_b)
  177. if status_b == "page_wrong":
  178. print("[v1] 第2次识别判定: page_wrong")
  179. rec["status"] = "page_wrong"
  180. self._log_cross(rec)
  181. return [], "page_wrong"
  182. if status_b != "ok":
  183. # 第2次失败 → 无交叉, 退回单次结果
  184. print("[v1] 第2次识别失败, 退回单次结果")
  185. rec["status"] = "ok(单次降级)"
  186. shops = self._basic_filter(cards_a, screen_w, screen_h)
  187. shops = self._validate_cards(shops, bs, screen_w, screen_h)
  188. rec["final"] = len(shops)
  189. self._log_cross(rec)
  190. return shops, "ok"
  191. # 交叉: 两次都有的 = 高置信
  192. # 某一侧空返回(GLM偶发) → 直接采用另一侧, 跳过仲裁(省一次调用)
  193. if not cards_a and cards_b:
  194. print(f"[v1] 第1次识别空返回, 直接采用第2次的 {len(cards_b)} 张(免仲裁)")
  195. shops = self._basic_filter(cards_b, screen_w, screen_h)
  196. shops = self._validate_cards(shops, bs, screen_w, screen_h)
  197. rec["final"] = len(shops)
  198. rec["final_cards"] = [{"shop": s[0], "title": str(s[1])[:40], "price": s[2],
  199. "click": s[3]} for s in shops]
  200. rec["status"] = "ok(单侧空,采用另一侧)"
  201. self._log_cross(rec)
  202. return shops, "ok"
  203. if cards_a and not cards_b:
  204. print(f"[v1] 第2次识别空返回, 直接采用第1次的 {len(cards_a)} 张(免仲裁)")
  205. shops = self._basic_filter(cards_a, screen_w, screen_h)
  206. shops = self._validate_cards(shops, bs, screen_w, screen_h)
  207. rec["final"] = len(shops)
  208. rec["final_cards"] = [{"shop": s[0], "title": str(s[1])[:40], "price": s[2],
  209. "click": s[3]} for s in shops]
  210. rec["status"] = "ok(单侧空,采用另一侧)"
  211. self._log_cross(rec)
  212. return shops, "ok"
  213. b_unused = list(cards_b)
  214. agreement, a_only = [], []
  215. for ca in cards_a:
  216. hit = None
  217. for cb in b_unused:
  218. if self._same_card(ca, cb):
  219. hit = cb
  220. break
  221. if hit is not None:
  222. b_unused.remove(hit)
  223. agreement.append(ca)
  224. else:
  225. a_only.append(ca)
  226. b_only = b_unused
  227. rec["a_only"] = _compact(a_only)
  228. rec["b_only"] = _compact(b_only)
  229. print(f"[v1] 交叉: 两次一致 {len(agreement)} 张, 分歧 {len(a_only)}+{len(b_only)} 张")
  230. # 分歧仲裁(仅分歧时第3次调用; 仲裁失败则保守丢弃分歧卡)
  231. merged = list(agreement)
  232. if a_only or b_only:
  233. kept = self._arbitrate(ocr_text, a_only, b_only)
  234. rec["kept"] = _compact(kept)
  235. print(f"[v1] 仲裁保留分歧卡 {len(kept)} 张")
  236. merged += kept
  237. shops = self._basic_filter(merged, screen_w, screen_h)
  238. shops = self._validate_cards(shops, bs, screen_w, screen_h)
  239. rec["agreement"] = len(agreement)
  240. rec["final"] = len(shops)
  241. rec["final_cards"] = [{"shop": s[0], "title": str(s[1])[:40], "price": s[2],
  242. "click": s[3]} for s in shops]
  243. rec["status"] = "ok"
  244. self._log_cross(rec)
  245. return shops, "ok"
  246. VisionParser = VisionParserCross
  247. # ── 独立测试 ───────────────────────────────────────────
  248. if __name__ == "__main__":
  249. TEST_IMAGE = r"" # 留空 = 自动找最新 step3_b0.png
  250. TEST_KEYWORD = "甲巯咪唑片"
  251. TEST_CROP_X = 287
  252. import glob
  253. _proj = os.path.dirname(os.path.abspath(__file__))
  254. _shots = []
  255. if os.path.isdir(os.path.join(_proj, "screenshots")):
  256. for p in glob.glob(os.path.join(_proj, "screenshots", "**", "step3", "step3_b0.png"), recursive=True):
  257. _shots.append(p)
  258. _shots.sort(key=os.path.getmtime, reverse=True)
  259. if TEST_IMAGE.strip():
  260. _shots = [TEST_IMAGE.strip()]
  261. if not _shots:
  262. print("没找到截图"); sys.exit(1)
  263. from PIL import Image
  264. for img_path in _shots[:3]:
  265. print("=" * 60)
  266. print(f"图片: {img_path}")
  267. try:
  268. w, h = Image.open(img_path).size
  269. except Exception:
  270. w, h = 720, 1640
  271. try:
  272. shops, status = VisionParserCross().parse_shops(img_path, screen_size=(w, h),
  273. keyword=TEST_KEYWORD, crop_x=TEST_CROP_X)
  274. print(f" → status={status}, {len(shops)} 卡:")
  275. for s in shops:
  276. print(f" {s[0]} | {s[1][:36]} | {s[2]} | click={s[3]}")
  277. except Exception as e:
  278. print(f" 异常: {e}")
  279. print()