# -*- coding: utf-8 -*- """ card_split — 纯视觉卡片边界检测(备用方案) ============================================================================= 原理(2026-09-20 真机联调验证, 间距标准差 <10): 商品卡片之间的空白【横贯整屏】—— 一行内连续浅色游程 ≥90% 屏宽 → 卡间空白带高度(20~40px) 显著大于卡内文字行间距(1~11px) → 候选带按间距约束(相邻 ≥200px, 冲突留高者)清理 → 稳定的卡片边界线 定位: 【备用方案】生产主路径为起送行锚定(ai_helper_vision2, 文本特征更稳)。 本模块适用于: 起送行锚定失败时的对账/复核, 或纯图像调试。 注意: 白底白卡页面(b0型)卡内空白与卡间缝隙亮度相同, 必须依赖 "游程长度+带高"两个几何特征, 单靠亮度不可行(实测)。 用法: from commons.card_split import find_card_boundaries lines = find_card_boundaries("shot.png") # → [422, 719, 1011, 1299] lines = find_card_boundaries("shot.png", debug="out.png") # 附带标注图 对账: 卡片数 = len(lines) + 1 (屏内完整卡 + 底部未完整卡) """ import cv2 import numpy as np def longest_light_run(row): """一行内最长连续浅色像素游程(暗像素/彩色像素 = 断点)""" m = row > 235 d = np.diff(np.concatenate(([0], m.astype(np.int8), [0]))) starts = np.where(d == 1)[0] ends = np.where(d == -1)[0] return int((ends - starts).max()) if len(starts) else 0 def find_card_boundaries(image_path, debug_path=None, run_ratio=0.90, min_band=15, min_gap=200): """检测列表页卡片之间的空白分隔带, 返回边界线 y 坐标列表(从上到下)。 run_ratio: 空白游程占屏宽比例阈值(默认0.90 — 缝隙横贯整屏) min_band: 空白带最小高度 px(卡间margin 20~40px, 卡内行间距 1~11px) min_gap: 相邻边界的最小间距 px(≈卡片最小高度, 冲突时保留更高的带)""" img = cv2.imread(image_path) if img is None: return [] H, W = img.shape[:2] gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) x0, x1 = int(W * 0.02), int(W * 0.98) width = x1 - x0 min_run = int(width * run_ratio) blank = [y for y in range(int(H * 0.09), int(H * 0.95)) if longest_light_run(gray[y, x0:x1]) >= min_run] bands, s = [], None for i, y in enumerate(blank): if s is None: s = y if i == len(blank) - 1 or y + 1 != blank[i + 1]: bands.append((s, y)) s = None cands = [((a + b) // 2, b - a + 1) for (a, b) in bands if b - a + 1 >= min_band] bounds = [] for c, h in cands: if bounds and c - bounds[-1][0] < min_gap: if h > bounds[-1][1]: # 冲突时保留更高的带(真卡缝更宽) bounds[-1] = (c, h) continue bounds.append((c, h)) lines = [c for c, _ in bounds] if debug_path and lines: vis = img.copy() for c in lines: cv2.line(vis, (0, c), (W, c), (0, 0, 255), 2) cv2.putText(vis, str(c), (8, c - 6), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2) cv2.imwrite(debug_path, vis) return lines def cross_check(boundaries: list, shops: list) -> dict: """几何对账: 解析出的卡片(含click坐标)必须落在相邻两条边界线之间。 返回 {"ok": 在界内卡数, "out": 越界卡列表, "gaps": 空区间数} shops 元素: [店名, 标题, 价格, [cx, cy], ...] (ai_helper_vision2 兼容格式)""" lines = sorted(boundaries) ok, out = 0, [] for s in shops or []: click = s[3] if len(s) > 3 and s[3] else [0, 0] cy = click[1] if not lines or (lines[0] <= cy <= lines[-1]): ok += 1 else: out.append({"shop": s[0], "click_y": cy}) gaps = len(lines) - 1 - ok # 有边界区间但没对应卡片 = 疑似漏卡 return {"ok": ok, "out": out, "gaps": max(0, gaps)}