| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311 |
- """
- AI 视觉助手-交叉验证版(实验) — 与 ai_helper_vision1 平行存在, 不改动稳定版
- =============================================================================
- 流程: 同一屏 OCR 独立识别 2 次
- → 两次都有的卡 = 高置信, 直接收
- → 只在一次出现的卡 = 分歧, 发第 3 次仲裁判真/假
- (真卡标准: 标题在价格上方 + 店铺名在价格下方 + 三者同一张卡)
- → 基础校验 + 几何校验(继承稳定版, 宁丢卡不配错)
- 实验数据(3张陷阱图×3轮): 完美 5/9→6/9, 平均漏卡 0.78→0.33, 代价=每屏2.2次GLM调用
- 接入方式(零改动切换): main1.py 里把
- from ai_helper_vision1 import VisionParser
- 改为
- from ai_helper_vision_cross import VisionParser
- 其余代码不动; 想换回就改回来。
- 独立测试: python ai_helper_vision_cross.py (配置在 __main__ 的 TEST_IMAGE/TEST_KEYWORD)
- """
- import json
- import os
- import re
- import sys
- from ai_helper_vision1 import VisionParser1
- VisionParser = None # 末尾赋值: VisionParserCross
- ARBI_SYSTEM = """你是电商列表页 OCR 复核助手。对同一屏的 OCR 做了两次独立识别,两次结果有分歧。
- 分歧候选可能是:另一次漏识别的真卡片,也可能是把顶部残卡/相邻卡片误配出来的假卡片。
- 真卡片必须同时满足:标题在价格上方、店铺名在价格下方、三者属于同一张卡(y坐标相邻)。
- 判断后只保留真卡,字段值严格取自 OCR 原文。"""
- class VisionParserCross(VisionParser1):
- """双次独立识别 + 交叉仲裁 版解析器(几何校验继承稳定版)"""
- # ── 单次识别调用 ────────────────────────────────────
- def _call_cards(self, system_prompt: str, user_text: str) -> tuple:
- """返回 (status, cards): status = ok / page_wrong / failed"""
- resp = self._call_llm(system_prompt, user_text)
- cleaned = (resp or "").strip()
- if cleaned.startswith("```"):
- ls = cleaned.split("\n")
- if ls and ls[0].startswith("```"):
- ls = ls[1:]
- if ls and ls[-1].strip() == "```":
- ls = ls[:-1]
- cleaned = "\n".join(ls).strip()
- try:
- data = json.loads(cleaned)
- except json.JSONDecodeError:
- m = re.search(r'\[[\s\S]*\]', cleaned)
- if m:
- try:
- data = json.loads(m.group(0))
- except json.JSONDecodeError:
- data = None
- if not isinstance(data, list):
- return "failed", []
- if len(data) == 1 and isinstance(data[0], dict) and data[0].get("page_wrong"):
- return "page_wrong", []
- return "ok", [d for d in data if isinstance(d, dict)]
- # ── 配对工具 ────────────────────────────────────────
- @staticmethod
- def _pnum(p) -> str:
- m = re.search(r"[\d.]+", str(p or ""))
- return m.group(0).rstrip(".") if m else ""
- @classmethod
- def _shop_eq(cls, a, b) -> bool:
- na, nb = cls._gnorm(a), cls._gnorm(b)
- if len(na) < 3 or len(nb) < 3:
- return False
- if na in nb or nb in na:
- return True
- if len(na) == len(nb):
- return sum(x != y for x, y in zip(na, nb)) <= 1
- return False
- @classmethod
- def _same_card(cls, c1: dict, c2: dict) -> bool:
- """两次识别的两张卡是否同一张: 价格相同 且 (店名容错相同 或 标题重叠≥6字)"""
- if cls._pnum(c1.get("price")) != cls._pnum(c2.get("price")):
- return False
- shop_ok = cls._shop_eq(c1.get("shop"), c2.get("shop"))
- title_ok = cls._glcs(cls._gnorm(c1.get("title")), cls._gnorm(c2.get("title"))) >= 6
- return shop_ok or title_ok
- # ── 基础校验(与稳定版逐字一致) ────────────────────────
- def _basic_filter(self, data: list, screen_w: int, screen_h: int) -> list:
- shops = []
- for i, item in enumerate(data, 1):
- if not isinstance(item, dict):
- continue
- title = str(item.get("title", "") or "")
- price = str(item.get("price", "") or "")
- shop = str(item.get("shop", "") or "")
- shop = re.split(r'[((]', shop)[0].strip() # 只要括号前的店名(去「(快递发…)」后缀)
- sales = str(item.get("sales", "") or "")
- click = item.get("click") or item.get("coords") or []
- if not (shop and title and price):
- print(f"[v1] 卡{i}: [丢弃] 字段不完整: {shop} | {title[:20]} | {price}")
- continue
- if '¥' in title or '¥' in title:
- print(f"[v1] 卡{i}: [丢弃] 标题含价格符号(残卡)")
- continue
- if isinstance(click, list) and len(click) == 2:
- try:
- cx, cy = int(click[0]), int(click[1])
- except (TypeError, ValueError):
- continue
- if not (0 <= cx <= screen_w and 0 <= cy <= screen_h):
- print(f"[v1] 卡{i}: [丢弃] 坐标越界: ({cx},{cy})")
- continue
- else:
- print(f"[v1] 卡{i}: [丢弃] 无有效点击坐标")
- continue
- shop_y = int(item.get("shop_y", cy)) if str(item.get("shop_y", "")).lstrip('-').isdigit() else cy
- print(f"[v1] 卡{i}: [收录] {shop} | {title[:36]} | {price} | click=({cx},{cy}) | 已售={sales}")
- shops.append([shop, title, price, [cx, cy], shop_y, sales])
- return shops
- # ── 分歧仲裁 ────────────────────────────────────────
- def _arbitrate(self, ocr_text: str, a_only: list, b_only: list) -> list:
- user = (f"{ARBI_SYSTEM}\n\nOCR原文:\n{ocr_text}\n\n"
- f"第一次识别独有的卡片:\n{json.dumps(a_only, ensure_ascii=False)}\n\n"
- f"第二次识别独有的卡片:\n{json.dumps(b_only, ensure_ascii=False)}\n\n"
- f'请输出纯JSON:{{"keep":[{{"title":"...","price":"...","shop":"...","click":[x,y],"shop_y":y,"sales":"..."}}]}}')
- resp = self._call_llm(ARBI_SYSTEM, user)
- try:
- m = re.search(r'\{[\s\S]*\}', resp or "")
- keep = json.loads(m.group(0)).get("keep", []) if m else []
- return [k for k in keep if isinstance(k, dict)]
- except Exception as e:
- print(f"[v1] 仲裁解析失败({e}), 分歧卡全部丢弃(保守)")
- return []
- # ── 决策日志(供 check_cross.py 事后核对仲裁正确率) ────
- @staticmethod
- def _log_cross(record: dict):
- try:
- from pathlib import Path
- log_dir = Path(__file__).parent / "logs"
- log_dir.mkdir(exist_ok=True)
- record["ts"] = __import__("time").strftime("%Y-%m-%d %H:%M:%S")
- with open(log_dir / "cross_validate.jsonl", "a", encoding="utf-8") as f:
- f.write(json.dumps(record, ensure_ascii=False) + "\n")
- except Exception:
- pass
- def parse_shops(self, image_path: str, screen_size: tuple = (720, 1640),
- keyword: str = "", crop_x: int = 0) -> tuple:
- """记录当前截图路径(供决策日志追溯), 其余与稳定版一致"""
- self._cur_image = str(image_path)
- return super().parse_shops(image_path, screen_size=screen_size, keyword=keyword, crop_x=crop_x)
- # ── 主流程(重写稳定版的GLM分卡部分) ────────────────────
- def _extract_all_cards(self, blocks: list, screen_w: int, screen_h: int, keyword: str = "") -> tuple:
- """双次独立识别 + 交叉仲裁 → 基础校验 → 几何校验。返回 (shops, status)"""
- bs = sorted(blocks, key=lambda b: b["box"][1])
- lines = []
- for b in bs:
- box = b["box"]
- cx = (box[0] + box[2]) // 2
- cy = (box[1] + box[3]) // 2
- lines.append(f"[x={cx:4d}, y={cy:4d}] {b.get('text','')}")
- ocr_text = "\n".join(lines)
- system_prompt = self._cards_prompt()
- user_text = (
- f"屏幕OCR识别结果(共{len(bs)}个文字块):\n\n{ocr_text}\n\n"
- f"当前搜索的商品是「{keyword}」。请划分商品卡片并逐卡提取字段。"
- )
- def _compact(cards):
- return [{"shop": c.get("shop"), "price": c.get("price"),
- "title": str(c.get("title"))[:40], "click": c.get("click")} for c in cards]
- rec = {"image": getattr(self, "_cur_image", ""), "keyword": keyword,
- "blocks": len(bs), "n1": 0, "n2": 0, "agreement": 0,
- "run1": [], "run2": [], "a_only": [], "b_only": [], "kept": [],
- "final_cards": [], "final": 0, "status": ""}
- # 第1次独立识别
- status_a, cards_a = self._call_cards(system_prompt, user_text)
- rec["n1"] = len(cards_a)
- rec["run1"] = _compact(cards_a)
- if status_a != "ok":
- print(f"[v1] 第1次识别: {status_a}")
- rec["status"] = status_a
- self._log_cross(rec)
- return [], status_a
- # 第2次独立识别
- status_b, cards_b = self._call_cards(system_prompt, user_text)
- rec["n2"] = len(cards_b)
- rec["run2"] = _compact(cards_b)
- if status_b == "page_wrong":
- print("[v1] 第2次识别判定: page_wrong")
- rec["status"] = "page_wrong"
- self._log_cross(rec)
- return [], "page_wrong"
- if status_b != "ok":
- # 第2次失败 → 无交叉, 退回单次结果
- print("[v1] 第2次识别失败, 退回单次结果")
- rec["status"] = "ok(单次降级)"
- shops = self._basic_filter(cards_a, screen_w, screen_h)
- shops = self._validate_cards(shops, bs, screen_w, screen_h)
- rec["final"] = len(shops)
- self._log_cross(rec)
- return shops, "ok"
- # 交叉: 两次都有的 = 高置信
- # 某一侧空返回(GLM偶发) → 直接采用另一侧, 跳过仲裁(省一次调用)
- if not cards_a and cards_b:
- print(f"[v1] 第1次识别空返回, 直接采用第2次的 {len(cards_b)} 张(免仲裁)")
- shops = self._basic_filter(cards_b, screen_w, screen_h)
- shops = self._validate_cards(shops, bs, screen_w, screen_h)
- rec["final"] = len(shops)
- rec["final_cards"] = [{"shop": s[0], "title": str(s[1])[:40], "price": s[2],
- "click": s[3]} for s in shops]
- rec["status"] = "ok(单侧空,采用另一侧)"
- self._log_cross(rec)
- return shops, "ok"
- if cards_a and not cards_b:
- print(f"[v1] 第2次识别空返回, 直接采用第1次的 {len(cards_a)} 张(免仲裁)")
- shops = self._basic_filter(cards_a, screen_w, screen_h)
- shops = self._validate_cards(shops, bs, screen_w, screen_h)
- rec["final"] = len(shops)
- rec["final_cards"] = [{"shop": s[0], "title": str(s[1])[:40], "price": s[2],
- "click": s[3]} for s in shops]
- rec["status"] = "ok(单侧空,采用另一侧)"
- self._log_cross(rec)
- return shops, "ok"
- b_unused = list(cards_b)
- agreement, a_only = [], []
- for ca in cards_a:
- hit = None
- for cb in b_unused:
- if self._same_card(ca, cb):
- hit = cb
- break
- if hit is not None:
- b_unused.remove(hit)
- agreement.append(ca)
- else:
- a_only.append(ca)
- b_only = b_unused
- rec["a_only"] = _compact(a_only)
- rec["b_only"] = _compact(b_only)
- print(f"[v1] 交叉: 两次一致 {len(agreement)} 张, 分歧 {len(a_only)}+{len(b_only)} 张")
- # 分歧仲裁(仅分歧时第3次调用; 仲裁失败则保守丢弃分歧卡)
- merged = list(agreement)
- if a_only or b_only:
- kept = self._arbitrate(ocr_text, a_only, b_only)
- rec["kept"] = _compact(kept)
- print(f"[v1] 仲裁保留分歧卡 {len(kept)} 张")
- merged += kept
- shops = self._basic_filter(merged, screen_w, screen_h)
- shops = self._validate_cards(shops, bs, screen_w, screen_h)
- rec["agreement"] = len(agreement)
- rec["final"] = len(shops)
- rec["final_cards"] = [{"shop": s[0], "title": str(s[1])[:40], "price": s[2],
- "click": s[3]} for s in shops]
- rec["status"] = "ok"
- self._log_cross(rec)
- return shops, "ok"
- VisionParser = VisionParserCross
- # ── 独立测试 ───────────────────────────────────────────
- if __name__ == "__main__":
- TEST_IMAGE = r"" # 留空 = 自动找最新 step3_b0.png
- TEST_KEYWORD = "甲巯咪唑片"
- TEST_CROP_X = 287
- import glob
- _proj = os.path.dirname(os.path.abspath(__file__))
- _shots = []
- if os.path.isdir(os.path.join(_proj, "screenshots")):
- for p in glob.glob(os.path.join(_proj, "screenshots", "**", "step3", "step3_b0.png"), recursive=True):
- _shots.append(p)
- _shots.sort(key=os.path.getmtime, reverse=True)
- if TEST_IMAGE.strip():
- _shots = [TEST_IMAGE.strip()]
- if not _shots:
- print("没找到截图"); sys.exit(1)
- from PIL import Image
- for img_path in _shots[:3]:
- print("=" * 60)
- print(f"图片: {img_path}")
- try:
- w, h = Image.open(img_path).size
- except Exception:
- w, h = 720, 1640
- try:
- shops, status = VisionParserCross().parse_shops(img_path, screen_size=(w, h),
- keyword=TEST_KEYWORD, crop_x=TEST_CROP_X)
- print(f" → status={status}, {len(shops)} 卡:")
- for s in shops:
- print(f" {s[0]} | {s[1][:36]} | {s[2]} | click={s[3]}")
- except Exception as e:
- print(f" 异常: {e}")
- print()
|