test_card_merge.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. 近距 mask 合并逻辑测试(在 249 上跑;本地无模型权重)。
  5. 用 data/gallery_images/ 的干净单卡图 PIL 合成多卡场景,只测 CardDetector 层
  6. (检出数 / 合并行为 / 阅读顺序),不加载 DINOv2 不查检索,秒级出结果。
  7. 用例(期望数按 CARD_MERGE_DIST_PX=50、IoU 门槛关闭):
  8. same@20/40 同一张卡水平偏移粘贴 → 中心距=偏移 → 合并 → 检出 1
  9. same@50 恰好阈值(<= 判定) → 合并 → 检出 1
  10. same@60/100 超阈值 → 并存 → 检出 2
  11. diff@wide 两张不同卡左右并排(间隔≈卡宽)→ 不合并 → 检出 2
  12. 2x2 四卡田字格 → 检出 4 且阅读顺序=先上后下、同行左→右
  13. vert 两卡竖排 → 检出 2(上在前)
  14. 用法(pytorch env):
  15. python tools/test_card_merge.py
  16. CARD_MERGE_DIST_PX=80 python tools/test_card_merge.py # 换阈值复跑
  17. """
  18. import os
  19. import sys
  20. import glob
  21. import random
  22. sys.stdout.reconfigure(encoding="utf-8")
  23. _ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  24. sys.path.insert(0, _ROOT)
  25. import numpy as np
  26. from PIL import Image
  27. import config
  28. from modules.yolo_detector import CardDetector
  29. GAP = 60 # 拼接图之间的空白间隔(px),避免贴边影响检测
  30. MARGIN = 40 # 画布边缘留白
  31. def _fetch_minio_fronts(n, out_dir):
  32. """gallery/query_images 都空时(如 249),从 MinIO grading/raspi_img_data 拉 N 张正面拍摄图。"""
  33. import config as _cfg
  34. from minio import Minio
  35. cfg = _cfg.CAPP_MINIO
  36. client = Minio(cfg["endpoint"], access_key=cfg["access_key"],
  37. secret_key=cfg["secret_key"], secure=bool(cfg.get("secure")))
  38. objs = [o for o in client.list_objects(cfg["bucket"], prefix="raspi_img_data/", recursive=True)
  39. if os.path.basename(o.object_name).startswith("raspi_front_")]
  40. objs.sort(key=lambda o: o.last_modified or 0, reverse=True)
  41. os.makedirs(out_dir, exist_ok=True)
  42. paths = []
  43. for o in objs:
  44. if len(paths) >= n:
  45. break
  46. dst = os.path.join(out_dir, os.path.basename(o.object_name))
  47. if not os.path.exists(dst) or os.path.getsize(dst) == 0:
  48. try:
  49. client.fget_object(cfg["bucket"], o.object_name, dst)
  50. except Exception:
  51. continue
  52. paths.append(dst)
  53. return paths
  54. def _load_card_images(n):
  55. """单卡图源:gallery_images → query_images → MinIO raspi_front(树莓派真实拍摄图)。"""
  56. files = sorted(glob.glob(os.path.join(config.GALLERY_IMG_DIR, "*.jpg")))
  57. if not files and os.path.isdir(config.QUERY_IMG_DIR):
  58. files = [os.path.join(config.QUERY_IMG_DIR, f) for f in os.listdir(config.QUERY_IMG_DIR)]
  59. random.seed(42)
  60. random.shuffle(files)
  61. out = []
  62. for f in files:
  63. try:
  64. img = Image.open(f).convert("RGB")
  65. if img.width >= 200 and img.height >= 200:
  66. out.append((os.path.basename(f), img))
  67. except Exception:
  68. continue
  69. if len(out) >= n:
  70. break
  71. if len(out) < n:
  72. cache_dir = os.path.join(config.DATA_DIR, "audit_imgs")
  73. need = n - len(out)
  74. fetched = _fetch_minio_fronts(max(need, 6), cache_dir)
  75. for f in fetched[:need]:
  76. try:
  77. out.append((os.path.basename(f), Image.open(f).convert("RGB")))
  78. except Exception:
  79. continue
  80. if len(out) < n:
  81. raise RuntimeError(f"可用单卡图不足: {len(out)}/{n}(gallery/query_images/MinIO raspi_front 均不够)")
  82. return out
  83. def _paste_offset(base_img, offset_px):
  84. """同卡水平偏移粘贴:中心距 = offset_px。"""
  85. w, h = base_img.size
  86. canvas = Image.new("RGB", (w + offset_px + 2 * MARGIN, h + 2 * MARGIN), (245, 245, 245))
  87. canvas.paste(base_img, (MARGIN, MARGIN))
  88. canvas.paste(base_img, (MARGIN + offset_px, MARGIN))
  89. return canvas
  90. def _paste_two(img_a, img_b, gap=GAP, vertical=False):
  91. """两张不同卡并排/竖排粘贴。"""
  92. if vertical:
  93. w = max(img_a.width, img_b.width)
  94. canvas = Image.new("RGB", (w + 2 * MARGIN, img_a.height + gap + img_b.height + 2 * MARGIN),
  95. (245, 245, 245))
  96. canvas.paste(img_a, (MARGIN + (w - img_a.width) // 2, MARGIN))
  97. canvas.paste(img_b, (MARGIN + (w - img_b.width) // 2, MARGIN + img_a.height + gap))
  98. else:
  99. h = max(img_a.height, img_b.height)
  100. canvas = Image.new("RGB", (img_a.width + gap + img_b.width + 2 * MARGIN, h + 2 * MARGIN),
  101. (245, 245, 245))
  102. canvas.paste(img_a, (MARGIN, MARGIN + (h - img_a.height) // 2))
  103. canvas.paste(img_b, (MARGIN + img_a.width + gap, MARGIN + (h - img_b.height) // 2))
  104. return canvas
  105. def _paste_grid(imgs, gap=GAP):
  106. """2×2 田字格(顺序:左上、右上、左下、右下)。"""
  107. a, b, c, d = imgs
  108. cw = max(a.width, b.width, c.width, d.width)
  109. ch = max(a.height, b.height, c.height, d.height)
  110. W = 2 * cw + gap + 2 * MARGIN
  111. H = 2 * ch + gap + 2 * MARGIN
  112. canvas = Image.new("RGB", (W, H), (245, 245, 245))
  113. for i, im in enumerate(imgs):
  114. x = MARGIN + (i % 2) * (cw + gap) + (cw - im.width) // 2
  115. y = MARGIN + (i // 2) * (ch + gap) + (ch - im.height) // 2
  116. canvas.paste(im, (x, y))
  117. return canvas
  118. def _detect(detector, canvas, tag):
  119. """跑检测,返回 (items, center_dists)。"""
  120. arr = np.array(canvas)
  121. items = detector.detect_and_crop_all(arr)
  122. dists = []
  123. for i in range(len(items)):
  124. for j in range(i + 1, len(items)):
  125. d = float(np.hypot(items[i]["center"][0] - items[j]["center"][0],
  126. items[i]["center"][1] - items[j]["center"][1]))
  127. dists.append(round(d, 1))
  128. print(f" [{tag}] 检出 {len(items)} 张, 中心距={dists}, "
  129. f"conf={[round(it['conf'], 3) for it in items]}", flush=True)
  130. return items, dists
  131. def _make_meta(i, cx, cy, conf, bw=1000.0, bh=1400.0):
  132. """构造 _merge_indices 输入用的假实例(box 以中心+固定宽高展开)。"""
  133. return {"box": (cx - bw / 2, cy - bh / 2, cx + bw / 2, cy + bh / 2),
  134. "label": "pokemon", "mask": None, "conf": conf, "center": (float(cx), float(cy))}
  135. def test_merge_indices(detector, thr):
  136. """_merge_indices 纯算法单测(不依赖 YOLO):贪心按 conf 保高者、阈值边界、IoU 门槛。"""
  137. p = f = 0
  138. def check(tag, got, expect, keep_conf=None):
  139. nonlocal p, f
  140. ok = len(got) == expect and (keep_conf is None or
  141. all(abs(m["conf"] - keep_conf) < 1e-9 for m in got))
  142. print(f" [{'PASS' if ok else 'FAIL'}] {tag}: 保留 {len(got)} 个 conf="
  143. f"{[m['conf'] for m in got]}(期望 {expect} 个"
  144. + (f", conf={keep_conf}" if keep_conf is not None else "") + ")", flush=True)
  145. p += int(ok)
  146. f += int(not ok)
  147. # 近距高低 conf:低者被吸收
  148. detector.merge_dist_px = thr
  149. got, dropped = detector._merge_indices([
  150. _make_meta(0, 1000, 1000, 0.60), _make_meta(1, 1030, 1000, 0.95)])
  151. check("近距保高conf", got, 1, keep_conf=0.95)
  152. # 阈值边界:中心距恰 = thr → 合并(<= 判定)
  153. got, _ = detector._merge_indices([
  154. _make_meta(0, 1000, 1000, 0.95), _make_meta(1, 1000 + thr, 1000, 0.60)])
  155. check(f"边界{thr:g}px合并(<=)", got, 1, keep_conf=0.95)
  156. # 刚超阈值:并存
  157. got, _ = detector._merge_indices([
  158. _make_meta(0, 1000, 1000, 0.95), _make_meta(1, 1000 + thr + 1, 1000, 0.60)])
  159. check(f"{thr + 1:g}px并存", got, 2)
  160. # 链式:A(0.9)-B(0.8) 近、B-C 近、A-C 远 → 贪心保 A,C 与 A 远所以 C 存活
  161. got, _ = detector._merge_indices([
  162. _make_meta(0, 1000, 1000, 0.90), _make_meta(1, 1040, 1000, 0.80),
  163. _make_meta(2, 1080 + thr, 1000, 0.70)])
  164. check("链式贪心", got, 2, keep_conf=None)
  165. # 三个近距聚堆:只留 conf 最高
  166. got, _ = detector._merge_indices([
  167. _make_meta(0, 1000, 1000, 0.70), _make_meta(1, 1020, 1020, 0.95),
  168. _make_meta(2, 1040, 980, 0.80)])
  169. check("三实例聚堆", got, 1, keep_conf=0.95)
  170. # IoU 门槛启用:距离近但框几乎不重叠(交叉摆卡)→ 不合并
  171. detector.merge_min_iou = 0.35
  172. m_a = _make_meta(0, 1000, 1000, 0.95)
  173. m_b = _make_meta(1, 1000 + thr * 0.5, 1000 + thr * 0.5, 0.60)
  174. m_b["box"] = (m_b["box"][0] + 900, m_b["box"][1] + 1300, m_b["box"][2] + 900, m_b["box"][3] + 1300)
  175. m_b["center"] = (m_a["center"][0] + thr * 0.5, m_a["center"][1] + thr * 0.5)
  176. got, _ = detector._merge_indices([m_a, m_b])
  177. check("IoU门槛防误杀", got, 2)
  178. detector.merge_min_iou = 0.0
  179. # 阈值 0:关闭合并
  180. detector.merge_dist_px = 0.0
  181. got, _ = detector._merge_indices([
  182. _make_meta(0, 1000, 1000, 0.95), _make_meta(1, 1001, 1001, 0.60)])
  183. check("阈值0关闭", got, 2)
  184. detector.merge_dist_px = thr
  185. return p, f
  186. def _reading_order_ok(items):
  187. """验证输出顺序 == 阅读顺序期望序列:按行容差聚簇(0.5*中位高),
  188. 行间按行内平均 cy,行内按 cx——与 _sort_reading_order 同一套语义重建期望后逐项比对。"""
  189. if len(items) <= 1:
  190. return True
  191. heights = [max(1.0, it["box"][3] - it["box"][1]) for it in items]
  192. row_tol = 0.5 * float(np.median(heights))
  193. ordered = sorted(items, key=lambda it: ((it["box"][1] + it["box"][3]) / 2.0,
  194. (it["box"][0] + it["box"][2]) / 2.0))
  195. rows, cur_cy = [], None
  196. for it in ordered:
  197. cy = (it["box"][1] + it["box"][3]) / 2.0
  198. if rows and abs(cy - cur_cy) <= row_tol:
  199. rows[-1].append(it)
  200. cur_cy = sum((x["box"][1] + x["box"][3]) / 2.0 for x in rows[-1]) / len(rows[-1])
  201. else:
  202. rows.append([it])
  203. cur_cy = cy
  204. expected = [it for r in rows for it in
  205. sorted(r, key=lambda x: (x["box"][0] + x["box"][2]) / 2.0)]
  206. return all(a is b for a, b in zip(items, expected))
  207. def main():
  208. detector = CardDetector(config.YOLO_MODEL_PATH)
  209. thr = float(getattr(detector, "merge_dist_px", 50.0))
  210. print(f"[Test] CARD_MERGE_DIST_PX={thr} MIN_IOU={getattr(detector, 'merge_min_iou', 0.0)}", flush=True)
  211. passed = failed = 0
  212. print("\n[Part1] _merge_indices 纯算法单测(不依赖 YOLO 输出)", flush=True)
  213. p, f = test_merge_indices(detector, thr)
  214. passed += p
  215. failed += f
  216. print("\n[Part2] 合成多卡图全链路 smoke(YOLO 检出 + 合并不回归)", flush=True)
  217. print(" 注:同卡小偏移粘贴时 NMS 在检出层就只出 1 框(近全重叠),与距离合并无关;", flush=True)
  218. print(" 本组验证的是多卡主流程检出数与阅读顺序不被合并逻辑破坏。", flush=True)
  219. imgs = _load_card_images(5)
  220. name_a, img_a = imgs[0]
  221. name_b, img_b = imgs[1]
  222. print(f"[Test] 用图 A={name_a} B={name_b}", flush=True)
  223. cases = []
  224. for off in (20, 60):
  225. cases.append((f"same@{off}", _paste_offset(img_a, off), None)) # None=不卡期望,对照raw
  226. cases.append(("diff@wide", _paste_two(img_a, img_b), 2))
  227. cases.append(("vert", _paste_two(img_a, img_b, vertical=True), 2))
  228. cases.append(("grid2x2", _paste_grid([img_a, img_b, imgs[2][1], imgs[3][1]]), 4))
  229. for tag, canvas, expect in cases:
  230. detector.merge_dist_px = 0.0
  231. raw_items, _ = _detect(detector, canvas, tag + "/raw")
  232. detector.merge_dist_px = thr
  233. items, dists = _detect(detector, canvas, tag + "/merge")
  234. if expect is None:
  235. ok = len(items) == len(raw_items) # 近重叠场景:合并前后一致(不误减)
  236. else:
  237. ok = len(items) == expect
  238. print(f" ==> [{'PASS' if ok else 'FAIL'}] {tag}: 原始检出 {len(raw_items)} → 合并后 {len(items)}"
  239. f"(期望 {'=raw' if expect is None else expect})\n", flush=True)
  240. passed += int(ok)
  241. failed += int(not ok)
  242. if tag == "grid2x2":
  243. order_ok = _reading_order_ok(items)
  244. print(f" ==> grid2x2 阅读顺序(行容差语义)centers="
  245. f"{[(round(it['center'][0]), round(it['center'][1])) for it in items]} "
  246. f"[{'PASS' if order_ok else 'FAIL'}]\n", flush=True)
  247. passed += int(order_ok)
  248. failed += int(not order_ok)
  249. print(f"[Result] PASS {passed} / FAIL {failed}", flush=True)
  250. return 0 if failed == 0 else 1
  251. if __name__ == "__main__":
  252. sys.exit(main())