pivtools 0.1.3__cp311-cp311-win_amd64.whl

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 (127) hide show
  1. pivtools-0.1.3.dist-info/METADATA +222 -0
  2. pivtools-0.1.3.dist-info/RECORD +127 -0
  3. pivtools-0.1.3.dist-info/WHEEL +5 -0
  4. pivtools-0.1.3.dist-info/entry_points.txt +3 -0
  5. pivtools-0.1.3.dist-info/top_level.txt +3 -0
  6. pivtools_cli/__init__.py +5 -0
  7. pivtools_cli/_build_marker.c +25 -0
  8. pivtools_cli/_build_marker.cp311-win_amd64.pyd +0 -0
  9. pivtools_cli/cli.py +225 -0
  10. pivtools_cli/example.py +139 -0
  11. pivtools_cli/lib/PIV_2d_cross_correlate.c +334 -0
  12. pivtools_cli/lib/PIV_2d_cross_correlate.h +22 -0
  13. pivtools_cli/lib/common.h +36 -0
  14. pivtools_cli/lib/interp2custom.c +146 -0
  15. pivtools_cli/lib/interp2custom.h +48 -0
  16. pivtools_cli/lib/peak_locate_gsl.c +711 -0
  17. pivtools_cli/lib/peak_locate_gsl.h +40 -0
  18. pivtools_cli/lib/peak_locate_gsl_print.c +736 -0
  19. pivtools_cli/lib/peak_locate_lm.c +751 -0
  20. pivtools_cli/lib/peak_locate_lm.h +27 -0
  21. pivtools_cli/lib/xcorr.c +342 -0
  22. pivtools_cli/lib/xcorr.h +31 -0
  23. pivtools_cli/lib/xcorr_cache.c +78 -0
  24. pivtools_cli/lib/xcorr_cache.h +26 -0
  25. pivtools_cli/piv/interp2custom/interp2custom.py +69 -0
  26. pivtools_cli/piv/piv.py +240 -0
  27. pivtools_cli/piv/piv_backend/base.py +825 -0
  28. pivtools_cli/piv/piv_backend/cpu_instantaneous.py +1005 -0
  29. pivtools_cli/piv/piv_backend/factory.py +28 -0
  30. pivtools_cli/piv/piv_backend/gpu_instantaneous.py +15 -0
  31. pivtools_cli/piv/piv_backend/infilling.py +445 -0
  32. pivtools_cli/piv/piv_backend/outlier_detection.py +306 -0
  33. pivtools_cli/piv/piv_backend/profile_cpu_instantaneous.py +230 -0
  34. pivtools_cli/piv/piv_result.py +40 -0
  35. pivtools_cli/piv/save_results.py +342 -0
  36. pivtools_cli/piv_cluster/cluster.py +108 -0
  37. pivtools_cli/preprocessing/filters.py +399 -0
  38. pivtools_cli/preprocessing/preprocess.py +79 -0
  39. pivtools_cli/tests/helpers.py +107 -0
  40. pivtools_cli/tests/instantaneous_piv/test_piv_integration.py +167 -0
  41. pivtools_cli/tests/instantaneous_piv/test_piv_integration_multi.py +553 -0
  42. pivtools_cli/tests/preprocessing/test_filters.py +41 -0
  43. pivtools_core/__init__.py +5 -0
  44. pivtools_core/config.py +703 -0
  45. pivtools_core/config.yaml +135 -0
  46. pivtools_core/image_handling/__init__.py +0 -0
  47. pivtools_core/image_handling/load_images.py +464 -0
  48. pivtools_core/image_handling/readers/__init__.py +53 -0
  49. pivtools_core/image_handling/readers/generic_readers.py +50 -0
  50. pivtools_core/image_handling/readers/lavision_reader.py +190 -0
  51. pivtools_core/image_handling/readers/registry.py +24 -0
  52. pivtools_core/paths.py +49 -0
  53. pivtools_core/vector_loading.py +248 -0
  54. pivtools_gui/__init__.py +3 -0
  55. pivtools_gui/app.py +687 -0
  56. pivtools_gui/calibration/__init__.py +0 -0
  57. pivtools_gui/calibration/app/__init__.py +0 -0
  58. pivtools_gui/calibration/app/views.py +1186 -0
  59. pivtools_gui/calibration/calibration_planar/planar_calibration_production.py +570 -0
  60. pivtools_gui/calibration/vector_calibration_production.py +544 -0
  61. pivtools_gui/config.py +703 -0
  62. pivtools_gui/image_handling/__init__.py +0 -0
  63. pivtools_gui/image_handling/load_images.py +464 -0
  64. pivtools_gui/image_handling/readers/__init__.py +53 -0
  65. pivtools_gui/image_handling/readers/generic_readers.py +50 -0
  66. pivtools_gui/image_handling/readers/lavision_reader.py +190 -0
  67. pivtools_gui/image_handling/readers/registry.py +24 -0
  68. pivtools_gui/masking/__init__.py +0 -0
  69. pivtools_gui/masking/app/__init__.py +0 -0
  70. pivtools_gui/masking/app/views.py +123 -0
  71. pivtools_gui/paths.py +49 -0
  72. pivtools_gui/piv_runner.py +261 -0
  73. pivtools_gui/pivtools.py +58 -0
  74. pivtools_gui/plotting/__init__.py +0 -0
  75. pivtools_gui/plotting/app/__init__.py +0 -0
  76. pivtools_gui/plotting/app/views.py +1671 -0
  77. pivtools_gui/plotting/plot_maker.py +220 -0
  78. pivtools_gui/post_processing/POD/__init__.py +0 -0
  79. pivtools_gui/post_processing/POD/app/__init__.py +0 -0
  80. pivtools_gui/post_processing/POD/app/views.py +647 -0
  81. pivtools_gui/post_processing/POD/pod_decompose.py +979 -0
  82. pivtools_gui/post_processing/POD/views.py +1096 -0
  83. pivtools_gui/post_processing/__init__.py +0 -0
  84. pivtools_gui/static/404.html +1 -0
  85. pivtools_gui/static/_next/static/chunks/117-d5793c8e79de5511.js +2 -0
  86. pivtools_gui/static/_next/static/chunks/484-cfa8b9348ce4f00e.js +1 -0
  87. pivtools_gui/static/_next/static/chunks/869-320a6b9bdafbb6d3.js +1 -0
  88. pivtools_gui/static/_next/static/chunks/app/_not-found/page-12f067ceb7415e55.js +1 -0
  89. pivtools_gui/static/_next/static/chunks/app/layout-b907d5f31ac82e9d.js +1 -0
  90. pivtools_gui/static/_next/static/chunks/app/page-334cc4e8444cde2f.js +1 -0
  91. pivtools_gui/static/_next/static/chunks/fd9d1056-ad15f396ddf9b7e5.js +1 -0
  92. pivtools_gui/static/_next/static/chunks/framework-f66176bb897dc684.js +1 -0
  93. pivtools_gui/static/_next/static/chunks/main-a1b3ced4d5f6d998.js +1 -0
  94. pivtools_gui/static/_next/static/chunks/main-app-8a63c6f5e7baee11.js +1 -0
  95. pivtools_gui/static/_next/static/chunks/pages/_app-72b849fbd24ac258.js +1 -0
  96. pivtools_gui/static/_next/static/chunks/pages/_error-7ba65e1336b92748.js +1 -0
  97. pivtools_gui/static/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  98. pivtools_gui/static/_next/static/chunks/webpack-4a8ca7c99e9bb3d8.js +1 -0
  99. pivtools_gui/static/_next/static/css/7d3f2337d7ea12a5.css +3 -0
  100. pivtools_gui/static/_next/static/vQeR20OUdSSKlK4vukC4q/_buildManifest.js +1 -0
  101. pivtools_gui/static/_next/static/vQeR20OUdSSKlK4vukC4q/_ssgManifest.js +1 -0
  102. pivtools_gui/static/file.svg +1 -0
  103. pivtools_gui/static/globe.svg +1 -0
  104. pivtools_gui/static/grid.svg +8 -0
  105. pivtools_gui/static/index.html +1 -0
  106. pivtools_gui/static/index.txt +8 -0
  107. pivtools_gui/static/next.svg +1 -0
  108. pivtools_gui/static/vercel.svg +1 -0
  109. pivtools_gui/static/window.svg +1 -0
  110. pivtools_gui/stereo_reconstruction/__init__.py +0 -0
  111. pivtools_gui/stereo_reconstruction/app/__init__.py +0 -0
  112. pivtools_gui/stereo_reconstruction/app/views.py +1985 -0
  113. pivtools_gui/stereo_reconstruction/stereo_calibration_production.py +606 -0
  114. pivtools_gui/stereo_reconstruction/stereo_reconstruction_production.py +544 -0
  115. pivtools_gui/utils.py +63 -0
  116. pivtools_gui/vector_loading.py +248 -0
  117. pivtools_gui/vector_merging/__init__.py +1 -0
  118. pivtools_gui/vector_merging/app/__init__.py +1 -0
  119. pivtools_gui/vector_merging/app/views.py +759 -0
  120. pivtools_gui/vector_statistics/app/__init__.py +1 -0
  121. pivtools_gui/vector_statistics/app/views.py +710 -0
  122. pivtools_gui/vector_statistics/ensemble_statistics.py +49 -0
  123. pivtools_gui/vector_statistics/instantaneous_statistics.py +311 -0
  124. pivtools_gui/video_maker/__init__.py +0 -0
  125. pivtools_gui/video_maker/app/__init__.py +0 -0
  126. pivtools_gui/video_maker/app/views.py +436 -0
  127. pivtools_gui/video_maker/video_maker.py +662 -0
@@ -0,0 +1,570 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ planar_calibration_production.py
4
+
5
+ Production-ready planar calibration script for individual cameras.
6
+ Processes calibration images, saves grid indexing, calibration models, and dewarped images.
7
+ """
8
+
9
+ import glob
10
+ import logging
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+
14
+ import cv2
15
+ import matplotlib.pyplot as plt
16
+ import numpy as np
17
+ from scipy.io import savemat
18
+
19
+ # ===================== CONFIGURATION VARIABLES =====================
20
+ # Set these variables for your calibration setup
21
+ SOURCE_DIR = "/Users/morgan/Library/CloudStorage/OneDrive-UniversityofSouthampton/Documents/#current_processing/query_JHTDB/Planar_Images_with_wall"
22
+ BASE_DIR = "/Users/morgan/Library/CloudStorage/OneDrive-UniversityofSouthampton/Documents/#current_processing/query_JHTDB/Planar_Images_with_wall/test"
23
+ CAMERA_COUNT = 1
24
+ FILE_PATTERN = "calib%05d.tif" # or 'B%05d.tif' for numbered files
25
+
26
+ # Grid pattern parameters
27
+ PATTERN_COLS = 10
28
+ PATTERN_ROWS = 10
29
+ DOT_SPACING_MM = 28.89
30
+ ASYMMETRIC = False
31
+ ENHANCE_DOTS = True
32
+ SELECTED_IMAGE_IDX = 1 # Set to specific image index (1-based) to process only that image, or None to process all
33
+
34
+ # ===================================================================
35
+
36
+ # Configure logging
37
+ logging.basicConfig(
38
+ level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
39
+ )
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ class PlanarCalibrator:
44
+ def __init__(
45
+ self,
46
+ source_dir,
47
+ base_dir,
48
+ camera_count,
49
+ file_pattern,
50
+ pattern_cols=10,
51
+ pattern_rows=10,
52
+ dot_spacing_mm=28.89,
53
+ asymmetric=False,
54
+ enhance_dots=False,
55
+ dt=1.0,
56
+ selected_image_idx=1, # 1-based index of specific calibration image to process
57
+ ):
58
+ """
59
+ Initialize planar calibrator
60
+
61
+ Args:
62
+ source_dir: Source directory containing calibration subdirectory
63
+ base_dir: Base output directory
64
+ camera_count: Number of cameras to process
65
+ file_pattern: File pattern (e.g., 'B%05d.tif', 'planar_calibration_plate_*.tif')
66
+ pattern_cols: Number of columns in calibration grid
67
+ pattern_rows: Number of rows in calibration grid
68
+ dot_spacing_mm: Physical spacing between dots in mm
69
+ asymmetric: Whether grid is asymmetric
70
+ enhance_dots: Whether to apply dot enhancement
71
+ dt: Time step between frames in seconds
72
+ """
73
+ self.source_dir = Path(source_dir)
74
+ self.base_dir = Path(base_dir)
75
+ self.camera_count = camera_count
76
+ self.file_pattern = file_pattern
77
+ self.pattern_size = (pattern_cols, pattern_rows)
78
+ self.dot_spacing_mm = dot_spacing_mm
79
+ self.asymmetric = asymmetric
80
+ self.enable_dot_enhancement = enhance_dots
81
+ self.dt = dt # Add dt parameter
82
+ self.selected_image_idx = selected_image_idx
83
+
84
+ # Create blob detector
85
+ self.detector = self._create_blob_detector()
86
+
87
+ # Create base directories
88
+ self._setup_directories()
89
+
90
+ def _create_blob_detector(self):
91
+ """Create optimized blob detector for circle grid detection"""
92
+ params = cv2.SimpleBlobDetector_Params()
93
+ params.filterByArea = True
94
+ params.minArea = 200
95
+ params.maxArea = 1000
96
+ params.filterByCircularity = False
97
+ params.filterByConvexity = False
98
+ params.filterByInertia = False
99
+ params.minThreshold = 0
100
+ params.maxThreshold = 255
101
+ params.thresholdStep = 5
102
+ return cv2.SimpleBlobDetector_create(params)
103
+
104
+ def _setup_directories(self):
105
+ """Create necessary output directories"""
106
+ for cam_num in range(1, self.camera_count + 1):
107
+ cam_base = self.base_dir / "calibration" / f"Cam{cam_num}"
108
+ (cam_base / "indices").mkdir(parents=True, exist_ok=True)
109
+ (cam_base / "model").mkdir(parents=True, exist_ok=True)
110
+ (cam_base / "dewarp").mkdir(parents=True, exist_ok=True)
111
+
112
+ def enhance_dots_image(self, img, fixed_radius=9):
113
+ """
114
+ Enhance white dots in calibration image for better detection
115
+
116
+ Args:
117
+ img: Input grayscale image
118
+ fixed_radius: Radius for enhanced dots
119
+
120
+ Returns:
121
+ Enhanced image
122
+ """
123
+ # Threshold to binary to isolate white dots
124
+ _, binary = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
125
+
126
+ # Find contours (each white dot)
127
+ contours, _ = cv2.findContours(
128
+ binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
129
+ )
130
+
131
+ # Create output as copy of original
132
+ output = img.copy()
133
+
134
+ for cnt in contours:
135
+ # Find center of each dot
136
+ (x, y), _ = cv2.minEnclosingCircle(cnt)
137
+ center = (int(round(x)), int(round(y)))
138
+
139
+ # Draw filled white circle with fixed radius
140
+ cv2.circle(output, center, fixed_radius, (255,), -1)
141
+
142
+ return output
143
+
144
+ def make_object_points(self):
145
+ """Create 3D object points for calibration grid"""
146
+ cols, rows = self.pattern_size
147
+ objp = []
148
+ for i in range(rows):
149
+ for j in range(cols):
150
+ if self.asymmetric:
151
+ x = j * self.dot_spacing_mm + (
152
+ 0.5 * self.dot_spacing_mm if (i % 2 == 1) else 0.0
153
+ )
154
+ y = i * self.dot_spacing_mm
155
+ else:
156
+ x = j * self.dot_spacing_mm
157
+ y = i * self.dot_spacing_mm
158
+ objp.append([x, y, 0.0])
159
+ return np.array(objp, dtype=np.float32)
160
+
161
+ def detect_grid_in_image(self, img):
162
+ """
163
+ Detect circle grid in image
164
+
165
+ Args:
166
+ img: Input image
167
+
168
+ Returns:
169
+ (found, centers) - boolean and Nx2 array of points
170
+ """
171
+ # Convert to grayscale if needed
172
+ if img.ndim == 3:
173
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
174
+ else:
175
+ gray = img.copy()
176
+
177
+ # Apply dot enhancement if requested
178
+ if self.enable_dot_enhancement:
179
+ gray = self.enhance_dots_image(gray)
180
+
181
+ # Grid detection flags
182
+ grid_flags = (
183
+ cv2.CALIB_CB_ASYMMETRIC_GRID
184
+ if self.asymmetric
185
+ else cv2.CALIB_CB_SYMMETRIC_GRID
186
+ )
187
+
188
+ # Try both original and inverted images
189
+ for test_img, label in [(gray, "Original"), (255 - gray, "Inverted")]:
190
+ found, centers = cv2.findCirclesGrid(
191
+ test_img,
192
+ self.pattern_size,
193
+ flags=grid_flags,
194
+ blobDetector=self.detector,
195
+ )
196
+
197
+ if found:
198
+ logger.info(f"Grid detected ({label} image)")
199
+ return True, centers.reshape(-1, 2).astype(np.float32)
200
+
201
+ return False, None
202
+
203
+ def calculate_reprojection_error(self, grid_points, objp_2d, H):
204
+ """Calculate reprojection error using homography and return per-axis errors"""
205
+ H_inv = np.linalg.inv(H)
206
+ objp_h = np.hstack([objp_2d, np.ones((objp_2d.shape[0], 1))])
207
+ projected_h = (H_inv @ objp_h.T).T
208
+ projected = projected_h[:, :2] / projected_h[:, 2:]
209
+
210
+ # Error vector per point (pixel units)
211
+ error_vec = grid_points - projected
212
+ errors = np.linalg.norm(error_vec, axis=1)
213
+ errors_x = error_vec[:, 0]
214
+ errors_y = error_vec[:, 1]
215
+
216
+ # Return overall mean, full vector of norms, and per-axis errors
217
+ return errors.mean(), errors, errors_x, errors_y
218
+
219
+ def calculate_dewarped_size(self, H, img_shape):
220
+ """Calculate optimal output size for dewarped image"""
221
+ h, w = img_shape[:2]
222
+ corners = np.array(
223
+ [[0, 0], [w - 1, 0], [w - 1, h - 1], [0, h - 1]], dtype=np.float32
224
+ ).reshape(-1, 1, 2)
225
+ physical_corners = cv2.perspectiveTransform(corners, H).reshape(-1, 2)
226
+
227
+ min_x, max_x = np.min(physical_corners[:, 0]), np.max(physical_corners[:, 0])
228
+ min_y, max_y = np.min(physical_corners[:, 1]), np.max(physical_corners[:, 1])
229
+
230
+ width_px = int(np.ceil(max_x - min_x))
231
+ height_px = int(np.ceil(max_y - min_y))
232
+
233
+ physical_to_pixel = np.array(
234
+ [[1, 0, -min_x], [0, 1, -min_y], [0, 0, 1]], dtype=np.float32
235
+ )
236
+ combined_H = physical_to_pixel @ H
237
+
238
+ return (width_px, height_px), combined_H
239
+
240
+ def process_camera(self, cam_num):
241
+ """
242
+ Process all calibration images for one camera
243
+
244
+ Args:
245
+ cam_num: Camera number (1-based)
246
+ """
247
+ logger.info(f"Processing Camera {cam_num}")
248
+
249
+ # Setup paths
250
+ cam_input_dir = self.source_dir / "calibration" / f"Cam{cam_num}"
251
+ cam_output_base = self.base_dir / "calibration" / f"Cam{cam_num}"
252
+
253
+ if not cam_input_dir.exists():
254
+ logger.error(f"Camera directory not found: {cam_input_dir}")
255
+ return
256
+
257
+ # Build list of available image files (1-based indexing for numbered patterns)
258
+ image_files = []
259
+ if "%" in self.file_pattern:
260
+ i = 1
261
+ while True:
262
+ filename = self.file_pattern % i
263
+ filepath = cam_input_dir / filename
264
+ if filepath.exists():
265
+ image_files.append(str(filepath))
266
+ i += 1
267
+ else:
268
+ break
269
+ else:
270
+ image_files = sorted(glob.glob(str(cam_input_dir / self.file_pattern)))
271
+
272
+ if not image_files:
273
+ logger.error(
274
+ f"No calibration images found in {cam_input_dir} with pattern {self.file_pattern}"
275
+ )
276
+ return
277
+
278
+ idx = int(self.selected_image_idx)
279
+ if idx < 1 or idx > len(image_files):
280
+ logger.error(
281
+ f"Selected image index {idx} out of range (available: 1-{len(image_files)})"
282
+ )
283
+ return
284
+ img_path = image_files[idx - 1]
285
+ logger.info(f"Processing calibration image {idx}: {img_path}")
286
+
287
+ # Create object points template
288
+ objp = self.make_object_points()
289
+ objp_2d = objp[:, :2]
290
+
291
+ # Detect grid and process single image
292
+ img = cv2.imread(img_path, cv2.IMREAD_UNCHANGED)
293
+ found, grid_points = self.detect_grid_in_image(img)
294
+ if not found or grid_points is None:
295
+ logger.error(f"Grid not found in image index {idx}: {img_path}")
296
+ return
297
+
298
+ # Compute homography and dewarp
299
+ H, _ = cv2.findHomography(
300
+ grid_points, objp_2d[: grid_points.shape[0]], cv2.RANSAC, 3.0
301
+ )
302
+ output_size, combined_H = self.calculate_dewarped_size(H, img.shape)
303
+ dewarped = cv2.warpPerspective(
304
+ img, combined_H, output_size, flags=cv2.INTER_LANCZOS4
305
+ )
306
+
307
+ # Calculate reprojection error
308
+ mean_error, reproj_errs, reproj_errs_x, reproj_errs_y = (
309
+ self.calculate_reprojection_error(
310
+ grid_points, objp_2d[: grid_points.shape[0]], H
311
+ )
312
+ )
313
+
314
+ # Save indexing and dewarp
315
+ self._save_results(
316
+ idx,
317
+ cam_output_base,
318
+ grid_points,
319
+ H,
320
+ None,
321
+ dewarped,
322
+ mean_error,
323
+ reproj_errs,
324
+ reproj_errs_x,
325
+ reproj_errs_y,
326
+ Path(img_path).name,
327
+ )
328
+
329
+ # Prepare points for calibration
330
+ obj_pts_3d = np.hstack(
331
+ [objp_2d[: grid_points.shape[0]], np.zeros((grid_points.shape[0], 1))]
332
+ ).astype(np.float32)
333
+ objpoints = [obj_pts_3d.reshape(-1, 1, 3)]
334
+ imgpoints = [grid_points.reshape(-1, 1, 2)]
335
+
336
+ # Run camera calibration
337
+ logger.info(f"Calibrating camera from image {idx}...")
338
+ ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera(
339
+ objpoints,
340
+ imgpoints,
341
+ (
342
+ int(np.max(imgpoints[0][:, 0, 0])) + 100,
343
+ int(np.max(imgpoints[0][:, 0, 1])) + 100,
344
+ ),
345
+ None,
346
+ None,
347
+ )
348
+
349
+ # Compute reprojection errors
350
+ proj, _ = cv2.projectPoints(objpoints[0], rvecs[0], tvecs[0], mtx, dist)
351
+ proj = proj.reshape(-1, 2)
352
+ imgpt = imgpoints[0].reshape(-1, 2)
353
+ err_vec = imgpt - proj
354
+ all_errors = np.linalg.norm(err_vec, axis=1)
355
+ all_errs_x = err_vec[:, 0]
356
+ all_errs_y = err_vec[:, 1]
357
+
358
+ logger.info(f"Calibration reprojection RMS: {ret:.5f}")
359
+
360
+ # Save camera model with homography
361
+ model_data = {
362
+ "camera_matrix": mtx,
363
+ "dist_coeffs": dist,
364
+ "rvecs": rvecs,
365
+ "tvecs": tvecs,
366
+ "reprojection_error": ret,
367
+ "homography": np.array(H, dtype=np.float32),
368
+ "reprojection_error_x_mean": float(np.mean(np.abs(all_errs_x))),
369
+ "reprojection_error_y_mean": float(np.mean(np.abs(all_errs_y))),
370
+ "reprojection_errors": all_errors,
371
+ "reprojection_errors_x": all_errs_x,
372
+ "reprojection_errors_y": all_errs_y,
373
+ "num_images": 1,
374
+ "timestamp": datetime.now().isoformat(),
375
+ "pattern_size": self.pattern_size,
376
+ "dot_spacing_mm": self.dot_spacing_mm,
377
+ "dt": self.dt,
378
+ }
379
+ savemat(cam_output_base / "model" / "camera_model.mat", model_data)
380
+ logger.info(
381
+ f"Saved camera model: {cam_output_base / 'model' / 'camera_model.mat'}"
382
+ )
383
+
384
+ def _save_results(
385
+ self,
386
+ img_index,
387
+ cam_output_base,
388
+ grid_points,
389
+ H,
390
+ camera_model,
391
+ dewarped,
392
+ reprojection_error,
393
+ reproj_errs,
394
+ reproj_errs_x,
395
+ reproj_errs_y,
396
+ original_filename,
397
+ ):
398
+ """Save all calibration results"""
399
+ # Save grid indexing (store in indices folder)
400
+ grid_data = {
401
+ "grid_points": grid_points,
402
+ "homography": H,
403
+ "reprojection_error": reprojection_error,
404
+ "reprojection_error_x_mean": float(np.mean(np.abs(reproj_errs_x))),
405
+ "reprojection_error_y_mean": float(np.mean(np.abs(reproj_errs_y))),
406
+ "reprojection_errors": reproj_errs,
407
+ "reprojection_errors_x": reproj_errs_x,
408
+ "reprojection_errors_y": reproj_errs_y,
409
+ "original_filename": original_filename,
410
+ "pattern_size": self.pattern_size,
411
+ "dot_spacing_mm": self.dot_spacing_mm,
412
+ "dt": self.dt, # Add dt to grid data
413
+ "timestamp": datetime.now().isoformat(),
414
+ }
415
+ savemat(cam_output_base / "indices" / f"indexing_{img_index}.mat", grid_data)
416
+
417
+ # Save calibration model - include dt and use camera_calibration_{idx}.mat in model folder
418
+ # Optionally save a per-image calibration model if provided
419
+ if camera_model is not None:
420
+ model_data = {
421
+ "camera_matrix": camera_model["camera_matrix"],
422
+ "dist_coeffs": camera_model["dist_coeffs"],
423
+ "rvecs": camera_model["rvecs"],
424
+ "tvecs": camera_model["tvecs"],
425
+ "reprojection_error": camera_model["reprojection_error"],
426
+ "reprojection_error_x_mean": float(np.mean(np.abs(reproj_errs_x))),
427
+ "reprojection_error_y_mean": float(np.mean(np.abs(reproj_errs_y))),
428
+ "reprojection_errors": reproj_errs,
429
+ "reprojection_errors_x": reproj_errs_x,
430
+ "reprojection_errors_y": reproj_errs_y,
431
+ "grid_points": grid_points,
432
+ "homography": H,
433
+ "original_filename": original_filename,
434
+ "pattern_size": self.pattern_size,
435
+ "dot_spacing_mm": self.dot_spacing_mm,
436
+ "dt": self.dt, # IMPORTANT: Save dt with the model for vector calibration
437
+ "timestamp": datetime.now().isoformat(),
438
+ }
439
+ savemat(
440
+ cam_output_base / "model" / f"camera_calibration_{img_index}.mat",
441
+ model_data,
442
+ )
443
+ else:
444
+ logger.debug(f"No per-image camera model to save for image {img_index}")
445
+
446
+ # Save dewarped image in dewarp folder with clear name
447
+ dewarped_path = cam_output_base / "dewarp" / f"dewarped_{img_index}.tif"
448
+ cv2.imwrite(str(dewarped_path), dewarped)
449
+
450
+ # Save grid visualization with indices into indices folder as indexes_{idx}.png
451
+ self._save_grid_visualization(
452
+ img_index,
453
+ cam_output_base,
454
+ grid_points,
455
+ original_filename,
456
+ reprojection_error,
457
+ )
458
+
459
+ logger.info(f"Saved results for image {img_index}")
460
+
461
+ def _save_grid_visualization(
462
+ self,
463
+ img_index,
464
+ cam_output_base,
465
+ grid_points,
466
+ original_filename,
467
+ reprojection_error,
468
+ ):
469
+ """Save a figure showing the detected grid with dot indices"""
470
+ try:
471
+ # Load original image for background
472
+ img_path = (
473
+ self.source_dir
474
+ / "calibration"
475
+ / cam_output_base.name
476
+ / original_filename
477
+ )
478
+ img = cv2.imread(str(img_path), cv2.IMREAD_GRAYSCALE)
479
+
480
+ cols, rows = self.pattern_size
481
+
482
+ # Create figure
483
+ fig, ax = plt.subplots(figsize=(12, 10))
484
+
485
+ # Display image
486
+ ax.imshow(img, cmap="gray", alpha=0.7)
487
+
488
+ # Plot detected grid points with indices
489
+ for idx, (x, y) in enumerate(grid_points):
490
+ # Calculate grid coordinates (row, col)
491
+ row = idx // cols
492
+ col = idx % cols
493
+
494
+ # Plot point
495
+ ax.scatter(x, y, c="red", s=60, marker="o", alpha=0.8)
496
+
497
+ # Add index label
498
+ ax.text(
499
+ x + 10,
500
+ y - 10,
501
+ f"({row},{col})",
502
+ color="cyan",
503
+ fontsize=8,
504
+ fontweight="bold",
505
+ bbox=dict(boxstyle="round,pad=0.3", facecolor="black", alpha=0.7),
506
+ )
507
+
508
+ ax.set_title(
509
+ f"Grid Detection: {original_filename}\n"
510
+ f"Detected: {len(grid_points)} points | "
511
+ f"Reprojection Error: {reprojection_error:.2f}px",
512
+ fontsize=12,
513
+ fontweight="bold",
514
+ )
515
+ ax.set_xlabel("X (pixels)")
516
+ ax.set_ylabel("Y (pixels)")
517
+ ax.grid(True, alpha=0.3)
518
+
519
+ # Invert y-axis to match image coordinates
520
+ ax.invert_yaxis()
521
+
522
+ # Save figure into indices folder with filename indexes_{idx}.png
523
+ fig_path = cam_output_base / "indices" / f"indexes_{img_index}.png"
524
+ plt.savefig(fig_path, dpi=150, bbox_inches="tight", facecolor="white")
525
+ plt.close(fig)
526
+
527
+ logger.info(f"Saved grid visualization: {fig_path}")
528
+
529
+ except Exception as e:
530
+ logger.warning(f"Failed to save grid visualization: {str(e)}")
531
+
532
+ def run(self):
533
+ """Run calibration for all cameras"""
534
+ logger.info(f"Starting planar calibration for {self.camera_count} cameras")
535
+ logger.info(f"Source: {self.source_dir}")
536
+ logger.info(f"Output: {self.base_dir}")
537
+ logger.info(f"Pattern: {self.file_pattern}")
538
+ logger.info(f"Grid size: {self.pattern_size}")
539
+ logger.info(f"Dot spacing: {self.dot_spacing_mm} mm")
540
+ logger.info(f"Dot enhancement: {self.enable_dot_enhancement}")
541
+
542
+ for cam_num in range(1, self.camera_count + 1):
543
+ try:
544
+ self.process_camera(cam_num)
545
+ except Exception as e:
546
+ logger.error(f"Failed to process Camera {cam_num}: {str(e)}")
547
+ continue
548
+
549
+ logger.info("Planar calibration completed")
550
+
551
+
552
+ def main():
553
+ calibrator = PlanarCalibrator(
554
+ source_dir=SOURCE_DIR,
555
+ base_dir=BASE_DIR,
556
+ camera_count=CAMERA_COUNT,
557
+ file_pattern=FILE_PATTERN,
558
+ pattern_cols=PATTERN_COLS,
559
+ pattern_rows=PATTERN_ROWS,
560
+ dot_spacing_mm=DOT_SPACING_MM,
561
+ asymmetric=ASYMMETRIC,
562
+ enhance_dots=ENHANCE_DOTS,
563
+ selected_image_idx=SELECTED_IMAGE_IDX, # Set to specific image index (or range) if needed
564
+ )
565
+
566
+ calibrator.run()
567
+
568
+
569
+ if __name__ == "__main__":
570
+ main()