openlpt 2.2.2__tar.gz → 2.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. {openlpt-2.2.2 → openlpt-2.2.3}/PKG-INFO +1 -1
  2. {openlpt-2.2.2 → openlpt-2.2.3}/_version.py +1 -1
  3. {openlpt-2.2.2 → openlpt-2.2.3}/gui/views/tracking_settings_view.py +49 -16
  4. {openlpt-2.2.2 → openlpt-2.2.3}/modules/camera_calibration/view.py +231 -30
  5. {openlpt-2.2.2 → openlpt-2.2.3}/openlpt.egg-info/PKG-INFO +1 -1
  6. {openlpt-2.2.2 → openlpt-2.2.3}/openlpt.egg-info/SOURCES.txt +1 -0
  7. openlpt-2.2.3/test/test_plate_error_stats.py +60 -0
  8. {openlpt-2.2.2 → openlpt-2.2.3}/LICENSE +0 -0
  9. {openlpt-2.2.2 → openlpt-2.2.3}/README.md +0 -0
  10. {openlpt-2.2.2 → openlpt-2.2.3}/gui/__init__.py +0 -0
  11. {openlpt-2.2.2 → openlpt-2.2.3}/gui/app.py +0 -0
  12. {openlpt-2.2.2 → openlpt-2.2.3}/gui/assets/icon.png +0 -0
  13. {openlpt-2.2.2 → openlpt-2.2.3}/gui/create_shortcut.py +0 -0
  14. {openlpt-2.2.2 → openlpt-2.2.3}/gui/docs/calibration/axis_alignment_guide.html +0 -0
  15. {openlpt-2.2.2 → openlpt-2.2.3}/gui/docs/calibration/plate_calibration.png +0 -0
  16. {openlpt-2.2.2 → openlpt-2.2.3}/gui/docs/calibration/plate_guide.html +0 -0
  17. {openlpt-2.2.2 → openlpt-2.2.3}/gui/docs/calibration/plate_point_detection.png +0 -0
  18. {openlpt-2.2.2 → openlpt-2.2.3}/gui/docs/calibration/wand_calibration_ui_v2.png +0 -0
  19. {openlpt-2.2.2 → openlpt-2.2.3}/gui/docs/calibration/wand_detection_ui.png +0 -0
  20. {openlpt-2.2.2 → openlpt-2.2.3}/gui/docs/calibration/wand_guide.html +0 -0
  21. {openlpt-2.2.2 → openlpt-2.2.3}/gui/docs/index.html +0 -0
  22. {openlpt-2.2.2 → openlpt-2.2.3}/gui/docs/preprocessing.html +0 -0
  23. {openlpt-2.2.2 → openlpt-2.2.3}/gui/docs/results.html +0 -0
  24. {openlpt-2.2.2 → openlpt-2.2.3}/gui/docs/settings.html +0 -0
  25. {openlpt-2.2.2 → openlpt-2.2.3}/gui/docs/tracking.html +0 -0
  26. {openlpt-2.2.2 → openlpt-2.2.3}/gui/main.py +0 -0
  27. {openlpt-2.2.2 → openlpt-2.2.3}/gui/style.qss +0 -0
  28. {openlpt-2.2.2 → openlpt-2.2.3}/gui/test_pyqt.py +0 -0
  29. {openlpt-2.2.2 → openlpt-2.2.3}/gui/utils/__init__.py +0 -0
  30. {openlpt-2.2.2 → openlpt-2.2.3}/gui/utils/auto_updater.py +0 -0
  31. {openlpt-2.2.2 → openlpt-2.2.3}/gui/utils/update_checker.py +0 -0
  32. {openlpt-2.2.2 → openlpt-2.2.3}/gui/views/__init__.py +0 -0
  33. {openlpt-2.2.2 → openlpt-2.2.3}/gui/views/camera_calibration_view.py +0 -0
  34. {openlpt-2.2.2 → openlpt-2.2.3}/gui/views/image_preprocessing_view.py +0 -0
  35. {openlpt-2.2.2 → openlpt-2.2.3}/gui/views/results_view.py +0 -0
  36. {openlpt-2.2.2 → openlpt-2.2.3}/gui/views/tracking_view.py +0 -0
  37. {openlpt-2.2.2 → openlpt-2.2.3}/gui/widgets/__init__.py +0 -0
  38. {openlpt-2.2.2 → openlpt-2.2.3}/modules/__init__.py +0 -0
  39. {openlpt-2.2.2 → openlpt-2.2.3}/modules/camera_calibration/__init__.py +0 -0
  40. {openlpt-2.2.2 → openlpt-2.2.3}/modules/camera_calibration/image_utils.py +0 -0
  41. {openlpt-2.2.2 → openlpt-2.2.3}/modules/camera_calibration/plate_calibration/PLATE_CALIBRATION_USER_GUIDE.html +0 -0
  42. {openlpt-2.2.2 → openlpt-2.2.3}/modules/camera_calibration/plate_calibration/grid_detector.py +0 -0
  43. {openlpt-2.2.2 → openlpt-2.2.3}/modules/camera_calibration/plate_calibration/plate_calibration.png +0 -0
  44. {openlpt-2.2.2 → openlpt-2.2.3}/modules/camera_calibration/plate_calibration/plate_point_detection.png +0 -0
  45. {openlpt-2.2.2 → openlpt-2.2.3}/modules/camera_calibration/plate_calibration/refraction_plate_calibration.py +0 -0
  46. {openlpt-2.2.2 → openlpt-2.2.3}/modules/camera_calibration/plate_calibration/refraction_settings_panel.png +0 -0
  47. {openlpt-2.2.2 → openlpt-2.2.3}/modules/camera_calibration/wand_calibration/WAND_CALIBRATION_USER_GUIDE.html +0 -0
  48. {openlpt-2.2.2 → openlpt-2.2.3}/modules/camera_calibration/wand_calibration/axis_calibrator_endpoint_sizes.png +0 -0
  49. {openlpt-2.2.2 → openlpt-2.2.3}/modules/camera_calibration/wand_calibration/full_global_search.py +0 -0
  50. {openlpt-2.2.2 → openlpt-2.2.3}/modules/camera_calibration/wand_calibration/plane_d_solver.py +0 -0
  51. {openlpt-2.2.2 → openlpt-2.2.3}/modules/camera_calibration/wand_calibration/pretest_global_search.py +0 -0
  52. {openlpt-2.2.2 → openlpt-2.2.3}/modules/camera_calibration/wand_calibration/refraction_calibration_BA.py +0 -0
  53. {openlpt-2.2.2 → openlpt-2.2.3}/modules/camera_calibration/wand_calibration/refraction_settings_panel.png +0 -0
  54. {openlpt-2.2.2 → openlpt-2.2.3}/modules/camera_calibration/wand_calibration/refraction_wand_calibrator.py +0 -0
  55. {openlpt-2.2.2 → openlpt-2.2.3}/modules/camera_calibration/wand_calibration/refractive_bootstrap.py +0 -0
  56. {openlpt-2.2.2 → openlpt-2.2.3}/modules/camera_calibration/wand_calibration/refractive_geometry.py +0 -0
  57. {openlpt-2.2.2 → openlpt-2.2.3}/modules/camera_calibration/wand_calibration/run_full_global_search.py +0 -0
  58. {openlpt-2.2.2 → openlpt-2.2.3}/modules/camera_calibration/wand_calibration/wand_calibration_ui.png +0 -0
  59. {openlpt-2.2.2 → openlpt-2.2.3}/modules/camera_calibration/wand_calibration/wand_calibration_ui_v2.png +0 -0
  60. {openlpt-2.2.2 → openlpt-2.2.3}/modules/camera_calibration/wand_calibration/wand_calibrator.py +0 -0
  61. {openlpt-2.2.2 → openlpt-2.2.3}/modules/camera_calibration/wand_calibration/wand_calibrator_target.png +0 -0
  62. {openlpt-2.2.2 → openlpt-2.2.3}/modules/camera_calibration/wand_calibration/wand_detection_ui.png +0 -0
  63. {openlpt-2.2.2 → openlpt-2.2.3}/modules/camera_calibration/widgets.py +0 -0
  64. {openlpt-2.2.2 → openlpt-2.2.3}/modules/image_preprocessing/__init__.py +0 -0
  65. {openlpt-2.2.2 → openlpt-2.2.3}/modules/image_preprocessing/cli.py +0 -0
  66. {openlpt-2.2.2 → openlpt-2.2.3}/modules/image_preprocessing/core.py +0 -0
  67. {openlpt-2.2.2 → openlpt-2.2.3}/modules/image_preprocessing/io.py +0 -0
  68. {openlpt-2.2.2 → openlpt-2.2.3}/modules/image_preprocessing/runner.py +0 -0
  69. {openlpt-2.2.2 → openlpt-2.2.3}/modules/image_preprocessing/view.py +0 -0
  70. {openlpt-2.2.2 → openlpt-2.2.3}/modules/image_preprocessing/widgets.py +0 -0
  71. {openlpt-2.2.2 → openlpt-2.2.3}/modules/post_processing/__init__.py +0 -0
  72. {openlpt-2.2.2 → openlpt-2.2.3}/modules/post_processing/processor.py +0 -0
  73. {openlpt-2.2.2 → openlpt-2.2.3}/modules/vsc/__init__.py +0 -0
  74. {openlpt-2.2.2 → openlpt-2.2.3}/modules/vsc/camera_io.py +0 -0
  75. {openlpt-2.2.2 → openlpt-2.2.3}/modules/vsc/optimizer.py +0 -0
  76. {openlpt-2.2.2 → openlpt-2.2.3}/modules/vsc/refraction_optimizer.py +0 -0
  77. {openlpt-2.2.2 → openlpt-2.2.3}/modules/vsc/vsc_service.py +0 -0
  78. {openlpt-2.2.2 → openlpt-2.2.3}/openlpt.egg-info/dependency_links.txt +0 -0
  79. {openlpt-2.2.2 → openlpt-2.2.3}/openlpt.egg-info/entry_points.txt +0 -0
  80. {openlpt-2.2.2 → openlpt-2.2.3}/openlpt.egg-info/requires.txt +0 -0
  81. {openlpt-2.2.2 → openlpt-2.2.3}/openlpt.egg-info/top_level.txt +0 -0
  82. {openlpt-2.2.2 → openlpt-2.2.3}/openlpt.py +0 -0
  83. {openlpt-2.2.2 → openlpt-2.2.3}/pyproject.toml +0 -0
  84. {openlpt-2.2.2 → openlpt-2.2.3}/setup.cfg +0 -0
  85. {openlpt-2.2.2 → openlpt-2.2.3}/setup.py +0 -0
  86. {openlpt-2.2.2 → openlpt-2.2.3}/tests/test_axis_alignment.py +0 -0
  87. {openlpt-2.2.2 → openlpt-2.2.3}/tests/test_axis_alignment_baseline.py +0 -0
  88. {openlpt-2.2.2 → openlpt-2.2.3}/tests/test_axis_alignment_transpose.py +0 -0
  89. {openlpt-2.2.2 → openlpt-2.2.3}/tests/test_image_preprocessing_cli.py +0 -0
  90. {openlpt-2.2.2 → openlpt-2.2.3}/tests/test_image_preprocessing_core.py +0 -0
  91. {openlpt-2.2.2 → openlpt-2.2.3}/tests/test_image_preprocessing_io.py +0 -0
  92. {openlpt-2.2.2 → openlpt-2.2.3}/tests/test_p0_bootstrap_failure.py +0 -0
  93. {openlpt-2.2.2 → openlpt-2.2.3}/tests/test_p1_round4_radius_frame_mismatch.py +0 -0
  94. {openlpt-2.2.2 → openlpt-2.2.3}/tests/test_plane_d_solver.py +0 -0
  95. {openlpt-2.2.2 → openlpt-2.2.3}/tests/test_plane_pipeline.py +0 -0
  96. {openlpt-2.2.2 → openlpt-2.2.3}/tests/test_precalibrate_3d_view.py +0 -0
  97. {openlpt-2.2.2 → openlpt-2.2.3}/tests/test_utils_axis_alignment.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openlpt
3
- Version: 2.2.2
3
+ Version: 2.2.3
4
4
  Summary: Open-source Lagrangian Particle Tracking (LPT) with GUI and CLI
5
5
  Author-email: "Shiyong Tan @ JHU Ni Research Lab" <tanshiyong84@gmail.com>
6
6
  License: MIT
@@ -5,4 +5,4 @@ This is the single source of truth for the version number.
5
5
  Update this when preparing a new release.
6
6
  """
7
7
 
8
- __version__ = "2.2.2"
8
+ __version__ = "2.2.3"
@@ -1519,7 +1519,7 @@ class TrackingSettingsView(QWidget):
1519
1519
  return pts_common.min(axis=0), pts_common.max(axis=0)
1520
1520
 
1521
1521
  def _validate_settings(self):
1522
- """Validate current settings by running 2D detection and 3D matching on the first frame."""
1522
+ """Validate current settings by running 2D detection and 3D matching on the configured start frame."""
1523
1523
  from PySide6.QtWidgets import QProgressDialog, QApplication
1524
1524
  from PySide6.QtCore import Qt
1525
1525
  self._busy_begin('validate_settings', 'Validating tracking settings')
@@ -1582,7 +1582,7 @@ class TrackingSettingsView(QWidget):
1582
1582
  progress.setLabelText("Loading Images...")
1583
1583
  QApplication.processEvents()
1584
1584
 
1585
- # Load images for the first frame (frame_id = 0)
1585
+ # Load images for the configured start frame.
1586
1586
  imgio_list = []
1587
1587
  folder_base = os.path.abspath(project_dir).replace('\\', '/') + '/'
1588
1588
  for path in basic_settings._image_file_paths:
@@ -1591,35 +1591,60 @@ class TrackingSettingsView(QWidget):
1591
1591
  imgio_list.append(io)
1592
1592
 
1593
1593
  num_cams = len(imgio_list)
1594
- frame_id = 0
1594
+ frame_id = int(getattr(basic_settings, '_frame_start', 0))
1595
1595
  image_list = []
1596
+ progress.setLabelText(f"Loading Images (frame {frame_id})...")
1597
+ QApplication.processEvents()
1596
1598
  for i in range(num_cams):
1597
1599
  image_list.append(imgio_list[i].loadImg(frame_id))
1598
1600
 
1599
- progress.setLabelText("Detecting 2D Objects...")
1601
+ progress.setLabelText(f"Detecting 2D Objects (frame {frame_id})...")
1600
1602
  QApplication.processEvents()
1601
1603
 
1602
1604
  # Detect 2D objects
1603
1605
  obj_finder = lpt.ObjectFinder2D()
1604
1606
  obj2d_list = []
1605
1607
  total_2d_count = 0
1608
+ per_camera_2d_counts = []
1606
1609
  for cam_id in range(num_cams):
1607
1610
  obj2ds = obj_finder.findObject2D(image_list[cam_id], obj_cfg)
1608
1611
  obj2d_list.append(obj2ds)
1609
1612
  count = len(obj2ds)
1613
+ per_camera_2d_counts.append(count)
1610
1614
  total_2d_count += count
1611
- print(f"[Validation] Camera {cam_id}: found {count} 2D objects.")
1612
-
1615
+ print(f"[Validation] Frame {frame_id}, Camera {cam_id}: found {count} 2D objects.")
1616
+
1613
1617
  avg_2d_count = total_2d_count / num_cams if num_cams > 0 else 0
1614
-
1615
- progress.setLabelText(f"Matching 3D Objects (2D Avg: {avg_2d_count:.1f})...")
1618
+ camera_count_summary = ", ".join(
1619
+ f"Cam {cam_id}: {count}" for cam_id, count in enumerate(per_camera_2d_counts)
1620
+ ) or "No cameras"
1621
+ zero_camera_ids = [cam_id for cam_id, count in enumerate(per_camera_2d_counts) if count == 0]
1622
+ zero_camera_warning = ""
1623
+ if zero_camera_ids:
1624
+ zero_camera_warning = (
1625
+ "\n\nWarning: no 2D objects were detected in camera(s): "
1626
+ + ", ".join(str(cam_id) for cam_id in zero_camera_ids)
1627
+ + "."
1628
+ )
1629
+
1630
+ if total_2d_count == 0:
1631
+ progress.close()
1632
+ error_msg = f"Validation failed for frame {frame_id}.\n\n" \
1633
+ "No 2D objects were detected in any camera, so 3D matching cannot proceed.\n\n" \
1634
+ f"Per-camera 2D counts: {camera_count_summary}\n\n" \
1635
+ "Check image paths, frame range, object detection thresholds, and image preprocessing settings."
1636
+ print(f"[Validation] Frame {frame_id}: no 2D objects detected in any camera.")
1637
+ QMessageBox.warning(self, "Validation Failed", error_msg)
1638
+ return
1639
+
1640
+ progress.setLabelText(f"Matching 3D Objects (frame {frame_id}, 2D Avg: {avg_2d_count:.1f})...")
1616
1641
  QApplication.processEvents()
1617
1642
 
1618
1643
  # Initial 3D match
1619
1644
  stereomath = lpt.StereoMatch(camera_models, obj2d_list, obj_cfg)
1620
1645
  obj3d_list = stereomath.match()
1621
1646
  count_3d = len(obj3d_list)
1622
- print(f"[Validation] Initial Match: found {count_3d} 3D objects.")
1647
+ print(f"[Validation] Frame {frame_id} Initial Match: found {count_3d} 3D objects.")
1623
1648
 
1624
1649
  # Step 1: Iterative 2D tolerance increase if 3D count is too low (< 25% of avg 2D)
1625
1650
  orig_tol_2d = obj_cfg._sm_param.tol_2d_px
@@ -1632,7 +1657,7 @@ class TrackingSettingsView(QWidget):
1632
1657
  current_tol_2d += tol_2d_step
1633
1658
  obj_cfg._sm_param.tol_2d_px = current_tol_2d
1634
1659
 
1635
- progress.setLabelText(f"Stage 1 (2D Tol): Matching 3D (tol={current_tol_2d:.2f})...")
1660
+ progress.setLabelText(f"Stage 1 (2D Tol): Matching 3D frame {frame_id} (tol={current_tol_2d:.2f})...")
1636
1661
  if progress.wasCanceled(): break
1637
1662
  QApplication.processEvents()
1638
1663
 
@@ -1641,7 +1666,7 @@ class TrackingSettingsView(QWidget):
1641
1666
  obj3d_list = stereomath.match()
1642
1667
  count_3d = len(obj3d_list)
1643
1668
  modified_2d = True
1644
- print(f"[Validation] Retry Match (2D tol={current_tol_2d:.2f}): found {count_3d} 3D objects.")
1669
+ print(f"[Validation] Frame {frame_id} Retry Match (2D tol={current_tol_2d:.2f}): found {count_3d} 3D objects.")
1645
1670
 
1646
1671
  # Step 2: Iterative 3D tolerance increase if still insufficient
1647
1672
  orig_tol_3d_mm = obj_cfg._sm_param.tol_3d_mm
@@ -1654,7 +1679,7 @@ class TrackingSettingsView(QWidget):
1654
1679
  current_tol_3d_mm += tol_3d_step_mm
1655
1680
  obj_cfg._sm_param.tol_3d_mm = current_tol_3d_mm
1656
1681
 
1657
- progress.setLabelText(f"Stage 2 (3D Tol): Matching 3D (tol={current_tol_3d_mm:.2f}mm)...")
1682
+ progress.setLabelText(f"Stage 2 (3D Tol): Matching 3D frame {frame_id} (tol={current_tol_3d_mm:.2f}mm)...")
1658
1683
  if progress.wasCanceled(): break
1659
1684
  QApplication.processEvents()
1660
1685
 
@@ -1663,16 +1688,18 @@ class TrackingSettingsView(QWidget):
1663
1688
  obj3d_list = stereomath.match()
1664
1689
  count_3d = len(obj3d_list)
1665
1690
  modified_3d = True
1666
- print(f"[Validation] Retry Match (3D tol={current_tol_3d_mm:.2f}mm): found {count_3d} 3D objects.")
1691
+ print(f"[Validation] Frame {frame_id} Retry Match (3D tol={current_tol_3d_mm:.2f}mm): found {count_3d} 3D objects.")
1667
1692
 
1668
1693
  progress.close()
1669
1694
 
1670
1695
  # Check final result
1671
1696
  if count_3d < (avg_2d_count / 4.0):
1672
- error_msg = f"Validation failed.\n\n" \
1697
+ error_msg = f"Validation failed for frame {frame_id}.\n\n" \
1673
1698
  f"Even with 2D tolerance increased by {current_tol_2d - orig_tol_2d:.1f}px " \
1674
1699
  f"and 3D tolerance increased by {current_tol_3d_mm - orig_tol_3d_mm:.1f}mm, " \
1675
1700
  f"only {count_3d} 3D objects were reconstructed from ~{avg_2d_count:.1f} 2D objects.\n\n" \
1701
+ f"Per-camera 2D counts: {camera_count_summary}" \
1702
+ f"{zero_camera_warning}\n\n" \
1676
1703
  "The current camera parameters may be inaccurate or invalid for tracking."
1677
1704
  QMessageBox.warning(self, "Validation Failed", error_msg)
1678
1705
  else:
@@ -1694,13 +1721,19 @@ class TrackingSettingsView(QWidget):
1694
1721
  if modified_3d: adjust_info.append(f"3D tolerance -> {current_tol_3d_mm:.2f}mm")
1695
1722
 
1696
1723
  msg = f"Validation successful with adjustment!\n\n" \
1724
+ f"Validated Frame: {frame_id}\n" \
1697
1725
  f"Adjustments: {', '.join(adjust_info)}\n" \
1698
1726
  f"3D Objects: {count_3d}\n" \
1699
- f"Average 2D Objects: {avg_2d_count:.1f}"
1727
+ f"Average 2D Objects: {avg_2d_count:.1f}\n" \
1728
+ f"Per-camera 2D counts: {camera_count_summary}" \
1729
+ f"{zero_camera_warning}"
1700
1730
  else:
1701
1731
  msg = f"Validation Successful!\n\n" \
1732
+ f"Validated Frame: {frame_id}\n" \
1702
1733
  f"3D Objects: {count_3d}\n" \
1703
- f"Average 2D Objects: {avg_2d_count:.1f}"
1734
+ f"Average 2D Objects: {avg_2d_count:.1f}\n" \
1735
+ f"Per-camera 2D counts: {camera_count_summary}" \
1736
+ f"{zero_camera_warning}"
1704
1737
 
1705
1738
  QMessageBox.information(self, "Validation Result", msg)
1706
1739
 
@@ -26,6 +26,144 @@ from PySide6.QtCore import QRect, Signal, QPoint, QThread, QTimer, Slot, QObject
26
26
 
27
27
  from .image_utils import load_image_any_depth, to_gray_uint8, to_rgb_uint8
28
28
 
29
+
30
+ def format_calibration_error_stats(stats):
31
+ """Format camFile error stats as 'mean,std' or 'None'."""
32
+ if stats is None:
33
+ return "None"
34
+ try:
35
+ mean, std = stats
36
+ if mean is None or std is None:
37
+ return "None"
38
+ mean = float(mean)
39
+ std = float(std)
40
+ if not np.isfinite(mean) or not np.isfinite(std):
41
+ return "None"
42
+ return f"{mean:.8g},{std:.8g}"
43
+ except Exception:
44
+ return "None"
45
+
46
+
47
+ def compute_projection_error_stats(world_points, image_points, rvec, tvec, K, dist):
48
+ """Return mean/std Euclidean reprojection error in pixels for pinhole data."""
49
+ world = np.asarray(world_points, dtype=np.float64).reshape(-1, 3)
50
+ image = np.asarray(image_points, dtype=np.float64).reshape(-1, 2)
51
+ if world.size == 0 or len(world) != len(image):
52
+ return None
53
+ proj_pts, _ = cv2.projectPoints(world, rvec, tvec, np.asarray(K, dtype=np.float64), np.asarray(dist, dtype=np.float64))
54
+ errors = np.linalg.norm(proj_pts.reshape(-1, 2) - image, axis=1)
55
+ if errors.size == 0:
56
+ return None
57
+ return float(np.mean(errors)), float(np.std(errors))
58
+
59
+
60
+ def _camera_projection_matrix_normalized(params):
61
+ """Return [R|T] for normalized undistorted pinhole observations."""
62
+ R = np.asarray(params.get('R'), dtype=np.float64).reshape(3, 3)
63
+ T = params.get('T', params.get('tvec'))
64
+ T = np.asarray(T, dtype=np.float64).reshape(3, 1)
65
+ return np.hstack([R, T])
66
+
67
+
68
+ def _undistort_to_normalized_point(uv, params):
69
+ K = np.asarray(params.get('K'), dtype=np.float64).reshape(3, 3)
70
+ dist = np.asarray(params.get('dist', np.zeros(5)), dtype=np.float64).reshape(-1, 1)
71
+ pts = np.asarray(uv, dtype=np.float64).reshape(1, 1, 2)
72
+ norm = cv2.undistortPoints(pts, K, dist)
73
+ return norm.reshape(2)
74
+
75
+
76
+ def _linear_triangulate_normalized(obs_by_cam, all_camera_params):
77
+ """DLT triangulation from normalized camera observations."""
78
+ rows = []
79
+ for cam_idx, uv in sorted(obs_by_cam.items()):
80
+ params = all_camera_params.get(cam_idx)
81
+ if params is None:
82
+ continue
83
+ try:
84
+ x, y = _undistort_to_normalized_point(uv, params)
85
+ P = _camera_projection_matrix_normalized(params)
86
+ except Exception:
87
+ continue
88
+ rows.append(x * P[2, :] - P[0, :])
89
+ rows.append(y * P[2, :] - P[1, :])
90
+ if len(rows) < 4:
91
+ return None
92
+ A = np.asarray(rows, dtype=np.float64)
93
+ try:
94
+ _, _, vt = np.linalg.svd(A)
95
+ X_h = vt[-1, :]
96
+ except np.linalg.LinAlgError:
97
+ return None
98
+ if abs(X_h[3]) < 1e-12:
99
+ return None
100
+ X = X_h[:3] / X_h[3]
101
+ if not np.all(np.isfinite(X)):
102
+ return None
103
+ return X
104
+
105
+
106
+ def compute_plate_triangulation_error_stats(all_camera_params, saved_calibration_data):
107
+ """
108
+ Compute mean/std 3D reconstruction error in mm for shared plate points.
109
+
110
+ Points are grouped by rounded global/world 3D coordinate. Each grouped point
111
+ must have observations from at least two calibrated pinhole cameras; otherwise
112
+ triangulation is unavailable and None is returned.
113
+ """
114
+ if not all_camera_params or len(all_camera_params) < 2 or not saved_calibration_data:
115
+ return None
116
+
117
+ valid_cam_params = {}
118
+ for cam_idx, params in all_camera_params.items():
119
+ try:
120
+ _camera_projection_matrix_normalized(params)
121
+ np.asarray(params.get('K'), dtype=np.float64).reshape(3, 3)
122
+ valid_cam_params[int(cam_idx)] = params
123
+ except Exception:
124
+ continue
125
+ if len(valid_cam_params) < 2:
126
+ return None
127
+
128
+ grouped = {}
129
+ for (cid, _img_path), data in saved_calibration_data.items():
130
+ cid = int(cid)
131
+ if cid not in valid_cam_params:
132
+ continue
133
+ keypoints = data.get('keypoints', [])
134
+ worlds = data.get('world_coords', [])
135
+ for kp, world in zip(keypoints, worlds):
136
+ if world is None or kp is None:
137
+ continue
138
+ world_arr = np.asarray(world, dtype=np.float64).reshape(3)
139
+ if not np.all(np.isfinite(world_arr)):
140
+ continue
141
+ uv = np.asarray(kp.pt, dtype=np.float64).reshape(2)
142
+ if not np.all(np.isfinite(uv)):
143
+ continue
144
+ key = tuple(round(float(v), 6) for v in world_arr)
145
+ entry = grouped.setdefault(key, {'world': world_arr, 'uvs_by_cam': {}})
146
+ entry['uvs_by_cam'].setdefault(cid, []).append(uv)
147
+
148
+ errors = []
149
+ for entry in grouped.values():
150
+ obs_by_cam = {
151
+ cid: np.mean(np.asarray(uvs, dtype=np.float64), axis=0)
152
+ for cid, uvs in entry['uvs_by_cam'].items()
153
+ if len(uvs) > 0
154
+ }
155
+ if len(obs_by_cam) < 2:
156
+ continue
157
+ X = _linear_triangulate_normalized(obs_by_cam, valid_cam_params)
158
+ if X is None:
159
+ continue
160
+ errors.append(float(np.linalg.norm(X - entry['world'])))
161
+
162
+ if not errors:
163
+ return None
164
+ errors = np.asarray(errors, dtype=np.float64)
165
+ return float(np.mean(errors)), float(np.std(errors))
166
+
29
167
  class ZoomableImageLabel(QLabel):
30
168
  """
31
169
  Label with zoom, pan, and multiple interaction modes.
@@ -4119,11 +4257,14 @@ class CameraCalibrationView(QWidget):
4119
4257
  target_cam_idx = self.cal_target_cam_combo.currentIndex()
4120
4258
  print(f"Running Plate Calibration for Camera {target_cam_idx+1}...")
4121
4259
 
4122
- # 1. Gather all points for this camera
4123
- img_points = []
4124
- obj_points = []
4125
-
4126
- # Unique paths that have data for this camera
4260
+ # 1. Gather all global 3D-2D correspondences for this camera.
4261
+ # OpenLPT_calPoints.csv plate points are already expressed in one
4262
+ # global coordinate system, so they must be calibrated as one OpenCV
4263
+ # view instead of one view per image/path.
4264
+ all_kpts_list = []
4265
+ all_world_list = []
4266
+
4267
+ # Data entries that have observations for this camera.
4127
4268
  relevant_keys = [k for k in self.saved_calibration_data.keys() if k[0] == target_cam_idx]
4128
4269
 
4129
4270
  if not relevant_keys:
@@ -4143,14 +4284,31 @@ class CameraCalibrationView(QWidget):
4143
4284
  world = np.array([wc for _, wc in valid_pairs], dtype=np.float32)
4144
4285
 
4145
4286
  if len(kpts) > 0:
4146
- img_points.append(kpts)
4147
- obj_points.append(world)
4148
-
4149
- if not img_points:
4287
+ all_kpts_list.append(kpts)
4288
+ all_world_list.append(world)
4289
+
4290
+ if not all_kpts_list:
4150
4291
  QMessageBox.warning(self, "No Data", "No valid point sets found.")
4151
4292
  self._busy_end('plate_calibration')
4152
4293
  return
4153
4294
 
4295
+ all_kpts = np.vstack(all_kpts_list).astype(np.float32)
4296
+ all_world = np.vstack(all_world_list).astype(np.float32)
4297
+ print(
4298
+ f"[PlateCalib] Camera {target_cam_idx+1}: using "
4299
+ f"{len(all_world)} global 3D-2D correspondences from "
4300
+ f"{len(relevant_keys)} data entries as a single OpenCV view."
4301
+ )
4302
+
4303
+ if len(all_world) < 6:
4304
+ QMessageBox.warning(
4305
+ self,
4306
+ "Insufficient Data",
4307
+ "At least 6 valid 3D-2D point correspondences are required for plate calibration."
4308
+ )
4309
+ self._busy_end('plate_calibration')
4310
+ return
4311
+
4154
4312
  # Image size
4155
4313
  w = self.cal_img_width.value()
4156
4314
  h = self.cal_img_height.value()
@@ -4172,18 +4330,21 @@ class CameraCalibrationView(QWidget):
4172
4330
  dist_num = self.cal_dist_model_combo.currentIndex() if hasattr(self, 'cal_dist_model_combo') else 0
4173
4331
  dist_coeffs = np.zeros(5, dtype=np.float32) # Always use 5 for standard pinhole logic
4174
4332
 
4175
- # 3. Stage 1: Solve for Pose (Extrinsics) using solvePnP for initial guess
4176
- # We pick the first set of points for a rough pose initialization.
4177
- success_pnp, rvec, tvec = cv2.solvePnP(obj_points[0], img_points[0], K, dist_coeffs)
4333
+ # 3. Stage 1: Solve for Pose (Extrinsics) using all global points.
4334
+ try:
4335
+ success_pnp, rvec, tvec = cv2.solvePnP(all_world, all_kpts, K, dist_coeffs)
4336
+ except cv2.error as e:
4337
+ QMessageBox.warning(self, "PnP Failed", f"Initial pose estimation (PnP) error: {str(e)}")
4338
+ self._busy_end('plate_calibration')
4339
+ return
4178
4340
  if not success_pnp:
4179
4341
  QMessageBox.critical(self, "Error", "Initial pose estimation (PnP) failed.")
4180
4342
  self._busy_end('plate_calibration')
4181
4343
  return
4182
-
4344
+
4183
4345
  # 4. Stage 2: Refine All (Intrinsics + Extrinsics)
4184
- # calibrateCamera expects lists of rvecs/tvecs for initial guess.
4185
- rvecs = [rvec.copy() for _ in range(len(img_points))]
4186
- tvecs = [tvec.copy() for _ in range(len(img_points))]
4346
+ # Pass all global plate correspondences as one OpenCV view so the
4347
+ # returned rvecs_opt[0]/tvecs_opt[0] is the single global pose.
4187
4348
 
4188
4349
  # Calibration Flags: Always use intrinsic guess
4189
4350
  flags = cv2.CALIB_USE_INTRINSIC_GUESS
@@ -4200,16 +4361,28 @@ class CameraCalibrationView(QWidget):
4200
4361
 
4201
4362
  try:
4202
4363
  rms, K_opt, dist_opt, rvecs_opt, tvecs_opt = cv2.calibrateCamera(
4203
- obj_points, img_points, img_size, K, dist_coeffs,
4364
+ [all_world], [all_kpts], img_size, K, dist_coeffs,
4204
4365
  flags=flags
4205
4366
  )
4206
4367
  except Exception as e:
4207
4368
  QMessageBox.critical(self, "Calibration Failed", f"Optimization error: {str(e)}")
4208
4369
  self._busy_end('plate_calibration')
4209
4370
  return
4210
-
4371
+
4372
+ proj_pts, _ = cv2.projectPoints(all_world, rvecs_opt[0], tvecs_opt[0], K_opt, dist_opt)
4373
+ proj_pts = proj_pts.reshape(-1, 2)
4374
+ residuals = proj_pts - all_kpts.reshape(-1, 2)
4375
+ point_proj_errors = np.linalg.norm(residuals, axis=1)
4376
+ global_rms = float(np.sqrt(np.mean(point_proj_errors * point_proj_errors)))
4377
+ proj_error_stats = (float(np.mean(point_proj_errors)), float(np.std(point_proj_errors)))
4378
+ print(
4379
+ f"[PlateCalib] Camera {target_cam_idx+1}: OpenCV RMS={float(rms):.6f} px, "
4380
+ f"final global reprojection RMS={global_rms:.6f} px, "
4381
+ f"proj mean/std={proj_error_stats[0]:.6f}/{proj_error_stats[1]:.6f} px"
4382
+ )
4383
+
4211
4384
  # 5. Display Results
4212
- self.lbl_cal_rms.setText(f"{rms:.4f} px")
4385
+ self.lbl_cal_rms.setText(f"{global_rms:.4f} px")
4213
4386
 
4214
4387
  # 6. Store Calibration Results
4215
4388
  R_first, _ = cv2.Rodrigues(rvecs_opt[0])
@@ -4222,22 +4395,25 @@ class CameraCalibrationView(QWidget):
4222
4395
  'K': K_opt,
4223
4396
  'dist': dist_opt,
4224
4397
  'img_size': (h, w),
4225
- 'rms': rms
4398
+ 'rms': global_rms,
4399
+ # camFile Camera Calibration Error: per-point Euclidean reprojection
4400
+ # error mean/std in pixels for this camera's merged global plate data.
4401
+ 'proj_error': proj_error_stats,
4402
+ 'proj_mean': proj_error_stats[0],
4403
+ 'proj_std': proj_error_stats[1],
4226
4404
  }
4227
4405
 
4228
4406
  # 7. Update 3D View with ALL calibrated cameras
4229
4407
  # Reformat for plot_calibration (expects 1-based keys)
4230
4408
  cam_viz_data = {idx + 1: params for idx, params in self.all_camera_params.items()}
4231
4409
 
4232
- # Combine all 3D points from all images into one cloud
4233
- all_3d = np.vstack(obj_points)
4234
-
4235
- self.plate_3d_viewer.plot_calibration(cam_viz_data, all_3d)
4410
+ # Show the same merged global 3D points used for calibration.
4411
+ self.plate_3d_viewer.plot_calibration(cam_viz_data, all_world)
4236
4412
  self.plate_vis_tabs.setCurrentWidget(self.plate_3d_viewer)
4237
4413
 
4238
4414
  # 8. Notify user (no per-camera save prompt - use Save All button instead)
4239
4415
  QMessageBox.information(self, "Calibration Complete",
4240
- f"Camera {target_cam_idx+1} calibrated successfully.\nRMS: {rms:.4f} px\n\nUse 'Save All Camera Parameters' to export.")
4416
+ f"Camera {target_cam_idx+1} calibrated successfully.\nRMS: {global_rms:.4f} px\n\nUse 'Save All Camera Parameters' to export.")
4241
4417
  self._busy_end('plate_calibration')
4242
4418
 
4243
4419
  def _collect_plate_refraction_inputs(self):
@@ -4543,15 +4719,21 @@ class CameraCalibrationView(QWidget):
4543
4719
  "Text Files (*.txt)")
4544
4720
  if not file_path:
4545
4721
  return
4546
-
4722
+
4547
4723
  try:
4724
+ params = getattr(self, 'all_camera_params', {}).get(cam_idx, {})
4725
+ proj_error = params.get('proj_error')
4726
+ if proj_error is None and 'proj_mean' in params and 'proj_std' in params:
4727
+ proj_error = (params.get('proj_mean'), params.get('proj_std'))
4728
+ tri_error = params.get('tri_error', params.get('triang_error'))
4729
+
4548
4730
  with open(file_path, 'w') as f:
4549
4731
  f.write("# Camera Model: (PINHOLE/POLYNOMIAL)\n")
4550
4732
  f.write("PINHOLE\n")
4551
4733
  f.write("# Camera Calibration Error: \n")
4552
- f.write("None\n")
4734
+ f.write(f"{format_calibration_error_stats(proj_error)}\n")
4553
4735
  f.write("# Pose Calibration Error: \n")
4554
- f.write("None\n")
4736
+ f.write(f"{format_calibration_error_stats(tri_error)}\n")
4555
4737
  f.write("# Image Size: (n_row,n_col)\n")
4556
4738
  f.write(f"{img_size[1]},{img_size[0]}\n") # H, W
4557
4739
 
@@ -4689,6 +4871,21 @@ class CameraCalibrationView(QWidget):
4689
4871
  cam_folder = Path(folder) / "camFile"
4690
4872
  cam_folder.mkdir(parents=True, exist_ok=True)
4691
4873
 
4874
+ tri_error_stats = compute_plate_triangulation_error_stats(
4875
+ self.all_camera_params,
4876
+ getattr(self, 'saved_calibration_data', None),
4877
+ )
4878
+ if tri_error_stats is None:
4879
+ print(
4880
+ "[PlateCalib] Multi-camera triangulation error unavailable; "
4881
+ "need at least two calibrated pinhole cameras observing shared global plate points."
4882
+ )
4883
+ else:
4884
+ print(
4885
+ f"[PlateCalib] Pose triangulation error mean/std="
4886
+ f"{tri_error_stats[0]:.6f}/{tri_error_stats[1]:.6f} mm"
4887
+ )
4888
+
4692
4889
  saved_count = 0
4693
4890
  try:
4694
4891
  for cam_idx, params in sorted(self.all_camera_params.items()):
@@ -4699,14 +4896,18 @@ class CameraCalibrationView(QWidget):
4699
4896
  R = params['R']
4700
4897
  T = params['T']
4701
4898
  img_size = params['img_size']
4899
+ proj_error = params.get('proj_error')
4900
+ if proj_error is None and 'proj_mean' in params and 'proj_std' in params:
4901
+ proj_error = (params.get('proj_mean'), params.get('proj_std'))
4902
+ tri_error = params.get('tri_error', params.get('triang_error', tri_error_stats))
4702
4903
 
4703
4904
  with open(file_path, 'w') as f:
4704
4905
  f.write("# Camera Model: (PINHOLE/POLYNOMIAL)\n")
4705
4906
  f.write("PINHOLE\n")
4706
4907
  f.write("# Camera Calibration Error: \n")
4707
- f.write("None\n")
4908
+ f.write(f"{format_calibration_error_stats(proj_error)}\n")
4708
4909
  f.write("# Pose Calibration Error: \n")
4709
- f.write("None\n")
4910
+ f.write(f"{format_calibration_error_stats(tri_error)}\n")
4710
4911
  f.write("# Image Size: (n_row,n_col)\n")
4711
4912
  f.write(f"{img_size[0]},{img_size[1]}\n") # H, W
4712
4913
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openlpt
3
- Version: 2.2.2
3
+ Version: 2.2.3
4
4
  Summary: Open-source Lagrangian Particle Tracking (LPT) with GUI and CLI
5
5
  Author-email: "Shiyong Tan @ JHU Ni Research Lab" <tanshiyong84@gmail.com>
6
6
  License: MIT
@@ -80,6 +80,7 @@ openlpt.egg-info/dependency_links.txt
80
80
  openlpt.egg-info/entry_points.txt
81
81
  openlpt.egg-info/requires.txt
82
82
  openlpt.egg-info/top_level.txt
83
+ test/test_plate_error_stats.py
83
84
  tests/test_axis_alignment.py
84
85
  tests/test_axis_alignment_baseline.py
85
86
  tests/test_axis_alignment_transpose.py
@@ -0,0 +1,60 @@
1
+ import numpy as np
2
+ import cv2
3
+
4
+ from modules.camera_calibration.view import (
5
+ format_calibration_error_stats,
6
+ compute_plate_triangulation_error_stats,
7
+ )
8
+
9
+
10
+ class _KP:
11
+ def __init__(self, x, y):
12
+ self.pt = (float(x), float(y))
13
+
14
+
15
+ def _project(K, R, T, point):
16
+ rvec, _ = cv2.Rodrigues(np.asarray(R, dtype=np.float64))
17
+ uv, _ = cv2.projectPoints(
18
+ np.asarray([point], dtype=np.float64),
19
+ rvec,
20
+ np.asarray(T, dtype=np.float64).reshape(3, 1),
21
+ K,
22
+ np.zeros(5),
23
+ )
24
+ return uv.reshape(2)
25
+
26
+
27
+ def test_format_calibration_error_stats_writes_mean_std_or_none():
28
+ assert format_calibration_error_stats((1.25, 0.5)) == "1.25,0.5"
29
+ assert format_calibration_error_stats(None) == "None"
30
+
31
+
32
+ def test_compute_plate_triangulation_error_stats_uses_shared_world_points():
33
+ K = np.array([[800.0, 0.0, 320.0], [0.0, 800.0, 240.0], [0.0, 0.0, 1.0]])
34
+ R0 = np.eye(3)
35
+ T0 = np.zeros((3, 1))
36
+ R1 = np.eye(3)
37
+ T1 = np.array([[-100.0], [0.0], [0.0]])
38
+ points = [np.array([0.0, 0.0, 1000.0]), np.array([50.0, 20.0, 1100.0])]
39
+ saved = {}
40
+ for cid, R, T in [(0, R0, T0), (1, R1, T1)]:
41
+ saved[(cid, "frame.csv")] = {
42
+ "world_coords": points,
43
+ "keypoints": [_KP(*_project(K, R, T, p)) for p in points],
44
+ }
45
+ cams = {
46
+ 0: {"K": K, "dist": np.zeros(5), "R": R0, "T": T0},
47
+ 1: {"K": K, "dist": np.zeros(5), "R": R1, "T": T1},
48
+ }
49
+
50
+ stats = compute_plate_triangulation_error_stats(cams, saved)
51
+
52
+ assert stats is not None
53
+ mean, std = stats
54
+ assert mean < 1e-6
55
+ assert std < 1e-6
56
+
57
+
58
+ def test_compute_plate_triangulation_error_stats_returns_none_for_single_camera():
59
+ stats = compute_plate_triangulation_error_stats({0: {}}, {})
60
+ assert stats is None
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes