card_split.py 4.0 KB

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