test_calibration.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. """标定功能测试:偏移计算、1 秒平均采集、持久化与标定后 READY。"""
  2. import time
  3. import pytest
  4. from hookvision.models import HookState, Config, BucketModel
  5. from hookvision.config_loader import load_config
  6. from hookvision import main_window_v2 as mwmod
  7. from hookvision.main_window_v2 import MainWindow
  8. def _obj(label, x, y, w=20, h=20):
  9. return {"x": x, "y": y, "w": w, "h": h, "label": label}
  10. def _fresh(mw):
  11. mw.hook_state = HookState.IDLE
  12. mw.prev_state = HookState.IDLE
  13. mw.state_counter = 0
  14. mw._pair_traj = {"left": [], "right": []}
  15. mw._voice_suppress_until = 0.0
  16. mw.prev_left_hook = None
  17. mw.prev_left_lug = None
  18. mw.prev_right_hook = None
  19. mw.prev_right_lug = None
  20. def _write_minimal_config(path):
  21. path.write_text(
  22. """[App]
  23. name = 测试系统
  24. copyright = 测试
  25. screen = 0
  26. [Video]
  27. rtsp_timeout = 5
  28. [Hardware]
  29. module_type = RFID
  30. [RFID]
  31. ip = 192.168.1.1
  32. port = 60000
  33. stability_time = 5
  34. stability_count = 3
  35. [AI]
  36. enabled = true
  37. [Bucket:0]
  38. id = 1
  39. description = 测试工位
  40. code_x = TEST001
  41. left_rtsp =
  42. right_rtsp =
  43. left_topic =
  44. right_topic =
  45. """, encoding="utf-8"
  46. )
  47. class TestComputeOffsetRatios:
  48. def test_normalized_ratios(self, qapp):
  49. mw = MainWindow(Config())
  50. # hook 中心 (40,40),lug 中心 (50,50),lug 20x20 → xr=yr=-0.5
  51. mw.left_objects = [_obj("hook", 30, 30), _obj("lug", 40, 40)]
  52. assert mw._compute_offset_ratios("left") == (-0.5, -0.5)
  53. def test_no_intersecting_pair_none(self, qapp):
  54. mw = MainWindow(Config())
  55. mw.left_objects = [_obj("hook", 0, 0), _obj("lug", 200, 200)]
  56. assert mw._compute_offset_ratios("left") is None
  57. def test_missing_objects_none(self, qapp):
  58. mw = MainWindow(Config())
  59. assert mw._compute_offset_ratios("left") is None
  60. mw.right_objects = [_obj("hook", 0, 0)]
  61. assert mw._compute_offset_ratios("right") is None
  62. def test_best_intersecting_pair_ignores_far_pair(self, qapp):
  63. mw = MainWindow(Config())
  64. # 远处的配对不相交,应被忽略;只有近处相交配对参与
  65. mw.left_objects = [_obj("hook", 0, 0), _obj("lug", 200, 200),
  66. _obj("hook", 30, 30), _obj("lug", 40, 40)]
  67. assert mw._compute_offset_ratios("left") == (-0.5, -0.5)
  68. def test_is_bucket_calibrated(self, qapp):
  69. mw = MainWindow(Config())
  70. b = BucketModel(id=1, description="t", code_x="X")
  71. assert mw._is_bucket_calibrated(b) is False
  72. b.hook_lug_x_offset_ratio_left = 0.0
  73. b.hook_lug_y_offset_ratio_left = 0.0
  74. assert mw._is_bucket_calibrated(b) is True
  75. def test_y_axis_uses_bottom_edge_not_center(self, qapp):
  76. mw = MainWindow(Config())
  77. # hook(30,30,20,40):底边 y+h=70,中心 y=50;lug(40,50,20,20):底边 70,中心 60
  78. # 底边对齐 → yr=0.0;若按中心算会是 (50-60)/20=-0.5
  79. mw.left_objects = [_obj("hook", 30, 30, w=20, h=40), _obj("lug", 40, 50)]
  80. assert mw._compute_offset_ratios("left") == (-0.5, 0.0)
  81. def test_compute_ratios_skips_lug_at_top(self, qapp):
  82. # 吊耳贴着画面上沿:采样无效,不参与标定
  83. mw = MainWindow(Config())
  84. mw.left_objects = [_obj("hook", 40, 0), _obj("lug", 40, 0)]
  85. assert mw._compute_offset_ratios("left") is None
  86. class TestCalibrationFlow:
  87. def _make_mw(self, tmp_path):
  88. cfg_path = tmp_path / "config.ini"
  89. _write_minimal_config(cfg_path)
  90. config = load_config(str(cfg_path))
  91. mw = MainWindow(config)
  92. _fresh(mw)
  93. mw._config_path = str(cfg_path)
  94. mw.active_bucket = config.buckets[0]
  95. return mw, cfg_path
  96. def test_sample_and_finish_writes_offsets(self, qapp, tmp_path, monkeypatch):
  97. monkeypatch.setattr(mwmod.QMessageBox, "information", lambda *a, **k: None)
  98. mw, _ = self._make_mw(tmp_path)
  99. bucket = mw.active_bucket
  100. mw.left_objects = [_obj("hook", 30, 30), _obj("lug", 40, 40)]
  101. mw._start_calibration()
  102. for _ in range(5):
  103. mw._sample_calibration()
  104. assert bucket.hook_lug_x_offset_ratio_left is None
  105. mw._calib_until = time.time() - 1
  106. mw._sample_calibration()
  107. assert bucket.hook_lug_x_offset_ratio_left is not None
  108. assert bucket.hook_lug_x_offset_ratio_left == pytest.approx(-0.5, abs=1e-6)
  109. assert bucket.hook_lug_y_offset_ratio_left == pytest.approx(-0.5, abs=1e-6)
  110. def test_sample_requires_intersecting_pair(self, qapp, tmp_path, monkeypatch):
  111. monkeypatch.setattr(mwmod.QMessageBox, "information", lambda *a, **k: None)
  112. mw, _ = self._make_mw(tmp_path)
  113. bucket = mw.active_bucket
  114. mw.left_objects = [_obj("hook", 0, 0), _obj("lug", 200, 200)]
  115. mw._start_calibration()
  116. for _ in range(5):
  117. mw._sample_calibration()
  118. mw._calib_until = time.time() - 1
  119. mw._sample_calibration()
  120. assert bucket.hook_lug_x_offset_ratio_left is None
  121. assert mw._is_bucket_calibrated(bucket) is False
  122. def test_calibrated_bucket_reaches_ready(self, qapp, tmp_path, monkeypatch):
  123. monkeypatch.setattr(mwmod.QMessageBox, "information", lambda *a, **k: None)
  124. mw, _ = self._make_mw(tmp_path)
  125. # 先标定(中心对齐,偏移 0,0)
  126. mw.left_objects = [_obj("hook", 40, 40), _obj("lug", 40, 40)]
  127. mw._start_calibration()
  128. for _ in range(5):
  129. mw._sample_calibration()
  130. mw._calib_until = time.time() - 1
  131. mw._sample_calibration()
  132. assert mw.active_bucket.hook_lug_x_offset_ratio_left == pytest.approx(0.0, abs=1e-6)
  133. # 重新喂帧 → 标定值匹配 → READY
  134. _fresh(mw)
  135. for _ in range(3):
  136. mw.left_objects = [_obj("hook", 40, 40), _obj("lug", 40, 40)]
  137. mw.evaluate_hook_state()
  138. assert mw.hook_state == HookState.READY
  139. def test_reset_calibration(self, qapp, tmp_path):
  140. mw, _ = self._make_mw(tmp_path)
  141. bucket = mw.active_bucket
  142. bucket.hook_lug_x_offset_ratio_left = -0.5
  143. bucket.hook_lug_y_offset_ratio_left = -0.5
  144. assert mw._is_bucket_calibrated(bucket) is True
  145. mw._reset_calibration()
  146. assert mw._is_bucket_calibrated(bucket) is False
  147. class TestCalibrationPersistence:
  148. def test_save_config_round_trip(self, qapp, tmp_path):
  149. cfg_path = tmp_path / "config.ini"
  150. _write_minimal_config(cfg_path)
  151. config = load_config(str(cfg_path))
  152. mw = MainWindow(config)
  153. mw._config_path = str(cfg_path)
  154. bucket = config.buckets[0]
  155. bucket.hook_lug_x_offset_ratio_left = -0.25
  156. bucket.hook_lug_y_offset_ratio_left = 0.125
  157. bucket.hook_lug_x_offset_ratio_right = 0.0
  158. bucket.hook_lug_y_offset_ratio_right = 0.0
  159. mw._save_config()
  160. reloaded = load_config(str(cfg_path))
  161. b = reloaded.buckets[0]
  162. assert b.hook_lug_x_offset_ratio_left == pytest.approx(-0.25, abs=1e-6)
  163. assert b.hook_lug_y_offset_ratio_left == pytest.approx(0.125, abs=1e-6)
  164. assert b.hook_lug_x_offset_ratio_right == pytest.approx(0.0, abs=1e-6)
  165. assert b.hook_lug_y_offset_ratio_right == pytest.approx(0.0, abs=1e-6)
  166. assert reloaded.ai.offset_ratio_tolerance == 0.25
  167. assert reloaded.ai.ready_debounce == 3
  168. def test_save_config_removes_uncalibrated_keys(self, qapp, tmp_path):
  169. cfg_path = tmp_path / "config.ini"
  170. _write_minimal_config(cfg_path)
  171. config = load_config(str(cfg_path))
  172. mw = MainWindow(config)
  173. mw._config_path = str(cfg_path)
  174. mw._save_config()
  175. reloaded = load_config(str(cfg_path))
  176. b = reloaded.buckets[0]
  177. assert b.hook_lug_x_offset_ratio_left is None
  178. assert b.hook_lug_y_offset_ratio_left is None
  179. class TestCalibrationUI:
  180. def test_calib_button_exists(self, qapp):
  181. mw = MainWindow(Config())
  182. assert mw.calib_btn is not None
  183. assert mw.calib_btn.text() == "标定"
  184. def test_badge_refresh(self, qapp):
  185. bucket = BucketModel(id=1, description="t", code_x="X")
  186. config = Config()
  187. config.buckets = [bucket]
  188. mw = MainWindow(config)
  189. assert bucket.id in mw._calib_badge_labels
  190. mw.refresh_bucket_icons()
  191. assert mw._calib_badge_labels[bucket.id].styleSheet() == "background-color: #dcdfe6; border-radius: 2px;"
  192. assert "未标定" in mw._calib_badge_labels[bucket.id].toolTip()
  193. bucket.hook_lug_x_offset_ratio_left = 0.0
  194. bucket.hook_lug_y_offset_ratio_left = 0.0
  195. mw.refresh_bucket_icons()
  196. assert mw._calib_badge_labels[bucket.id].styleSheet() == "background-color: #67c23a; border-radius: 2px;"
  197. assert "已标定" in mw._calib_badge_labels[bucket.id].toolTip()
  198. def _bucket_with_cameras(cfg_bucket):
  199. from hookvision.models import CameraModel, CameraPosition
  200. cfg_bucket.cameras = [
  201. CameraModel(position=CameraPosition.LEFT, rtsp_url="", topic="test/left"),
  202. CameraModel(position=CameraPosition.RIGHT, rtsp_url="", topic="test/right"),
  203. ]
  204. return cfg_bucket
  205. class TestAutoCalibration:
  206. def _make_mw(self, qapp, tmp_path):
  207. cfg_path = tmp_path / "config.ini"
  208. _write_minimal_config(cfg_path)
  209. config = load_config(str(cfg_path))
  210. bucket = _bucket_with_cameras(config.buckets[0])
  211. mw = MainWindow(config)
  212. _fresh(mw)
  213. mw._config_path = str(cfg_path)
  214. mw.active_bucket = bucket
  215. return mw, bucket
  216. def _ready_trigger(self, mw, side="left"):
  217. for _ in range(3):
  218. mw._update_auto_calibration(side, True)
  219. def test_no_camera_side_not_learned(self, qapp, tmp_path):
  220. # 该侧无摄像头 → 不自动标定
  221. mw, bucket = self._make_mw(qapp, tmp_path)
  222. bucket.cameras = []
  223. mw.left_objects = [_obj("hook", 40, 40), _obj("lug", 40, 40)]
  224. self._ready_trigger(mw)
  225. assert bucket.hook_lug_x_offset_ratio_left is None
  226. def test_no_active_bucket_not_learned(self, qapp, tmp_path):
  227. # 无活动工位 → 不自动标定
  228. mw, bucket = self._make_mw(qapp, tmp_path)
  229. mw.active_bucket = None
  230. mw.left_objects = [_obj("hook", 40, 40), _obj("lug", 40, 40)]
  231. self._ready_trigger(mw)
  232. assert bucket.hook_lug_x_offset_ratio_left is None
  233. def test_three_ready_within_window_writes(self, qapp, tmp_path):
  234. mw, bucket = self._make_mw(qapp, tmp_path)
  235. mw.left_objects = [_obj("hook", 40, 40), _obj("lug", 40, 40)]
  236. self._ready_trigger(mw)
  237. assert bucket.hook_lug_x_offset_ratio_left == pytest.approx(0.0, abs=1e-6)
  238. assert bucket.hook_lug_y_offset_ratio_left == pytest.approx(0.0, abs=1e-6)
  239. def test_two_ready_not_enough(self, qapp, tmp_path):
  240. mw, bucket = self._make_mw(qapp, tmp_path)
  241. mw.left_objects = [_obj("hook", 40, 40), _obj("lug", 40, 40)]
  242. for _ in range(2):
  243. mw._update_auto_calibration("left", True)
  244. assert bucket.hook_lug_x_offset_ratio_left is None
  245. def test_interleaved_not_ready_tolerated(self, qapp, tmp_path):
  246. # 非连续:1 秒窗口内 3 帧 ready 即可触发,中间的非 ready 帧不清零
  247. mw, bucket = self._make_mw(qapp, tmp_path)
  248. mw.left_objects = [_obj("hook", 40, 40), _obj("lug", 40, 40)]
  249. mw._update_auto_calibration("left", True)
  250. mw._update_auto_calibration("left", False)
  251. mw._update_auto_calibration("left", True)
  252. mw._update_auto_calibration("left", True)
  253. assert bucket.hook_lug_x_offset_ratio_left == pytest.approx(0.0, abs=1e-6)
  254. def test_frames_outside_window_not_counted(self, qapp, tmp_path):
  255. # 超过窗口时长的 ready 帧互不累计
  256. mw, bucket = self._make_mw(qapp, tmp_path)
  257. mw.config.ai.auto_calib_duration_secs = 0.002
  258. mw.left_objects = [_obj("hook", 40, 40), _obj("lug", 40, 40)]
  259. for _ in range(3):
  260. mw._update_auto_calibration("left", True)
  261. time.sleep(0.005)
  262. assert bucket.hook_lug_x_offset_ratio_left is None
  263. def test_calibrated_side_not_overwritten(self, qapp, tmp_path):
  264. # 已标定侧 → 自动标定不应覆盖
  265. mw, bucket = self._make_mw(qapp, tmp_path)
  266. bucket.hook_lug_x_offset_ratio_left = -0.25
  267. bucket.hook_lug_y_offset_ratio_left = 0.125
  268. mw.left_objects = [_obj("hook", 40, 40), _obj("lug", 40, 40)]
  269. self._ready_trigger(mw)
  270. assert bucket.hook_lug_x_offset_ratio_left == pytest.approx(-0.25, abs=1e-6)
  271. assert bucket.hook_lug_y_offset_ratio_left == pytest.approx(0.125, abs=1e-6)
  272. def test_both_sides_calibrated_automatically(self, qapp, tmp_path):
  273. # 无需入队:左右未标定侧连续识别 ready 自动全部标定
  274. mw, bucket = self._make_mw(qapp, tmp_path)
  275. for side in ("left", "right"):
  276. mw.left_objects = [_obj("hook", 40, 40), _obj("lug", 40, 40)]
  277. mw.right_objects = [_obj("hook", 40, 40), _obj("lug", 40, 40)]
  278. self._ready_trigger(mw, side)
  279. assert bucket.hook_lug_x_offset_ratio_left is not None
  280. assert bucket.hook_lug_x_offset_ratio_right is not None
  281. assert bucket.id not in getattr(mw, "_auto_calib_pending", set())
  282. def test_reset_then_auto_recalibrates(self, qapp, tmp_path):
  283. # 重置后再次 ready 自动重新标定
  284. mw, bucket = self._make_mw(qapp, tmp_path)
  285. bucket.hook_lug_x_offset_ratio_left = -0.5
  286. bucket.hook_lug_y_offset_ratio_left = -0.5
  287. assert mw._is_bucket_calibrated(bucket) is True
  288. mw._reset_calibration()
  289. assert bucket.hook_lug_x_offset_ratio_left is None
  290. mw.left_objects = [_obj("hook", 40, 40), _obj("lug", 40, 40)]
  291. self._ready_trigger(mw)
  292. assert bucket.hook_lug_x_offset_ratio_left == pytest.approx(0.0, abs=1e-6)
  293. def test_reset_calib_button_exists(self, qapp):
  294. mw = MainWindow(Config())
  295. assert mw.reset_calib_btn is not None
  296. assert mw.reset_calib_btn.text() == "重置标定"
  297. def test_custom_ready_frames_honored(self, qapp, tmp_path):
  298. mw, bucket = self._make_mw(qapp, tmp_path)
  299. mw.config.ai.auto_calib_ready_frames = 2
  300. mw.left_objects = [_obj("hook", 40, 40), _obj("lug", 40, 40)]
  301. mw._update_auto_calibration("left", True)
  302. assert bucket.hook_lug_x_offset_ratio_left is None
  303. mw._update_auto_calibration("left", True)
  304. assert bucket.hook_lug_x_offset_ratio_left == pytest.approx(0.0, abs=1e-6)
  305. def test_custom_min_samples_not_written_too_early(self, qapp, tmp_path):
  306. mw, bucket = self._make_mw(qapp, tmp_path)
  307. mw.config.ai.auto_calib_min_samples = 5
  308. mw.left_objects = [_obj("hook", 40, 40), _obj("lug", 40, 40)]
  309. self._ready_trigger(mw)
  310. assert bucket.hook_lug_x_offset_ratio_left is None