| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394 |
- # -*- 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)}
|