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,606 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ stereo_calibration_production.py
4
+
5
+ Production-ready stereo calibration script for camera pairs.
6
+ Processes calibration images, saves individual camera results and stereo reconstruction data.
7
+ """
8
+
9
+ import glob
10
+ import math
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+
14
+ import cv2
15
+ import numpy as np
16
+ from loguru import logger
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/Stereo_Images"
22
+ BASE_DIR = "/Users/morgan/Library/CloudStorage/OneDrive-UniversityofSouthampton/Documents/#current_processing/query_JHTDB/Stereo_Images/ProcessedPIV"
23
+ CAMERA_PAIRS = [[1, 2]] # Array of camera pairs
24
+ FILE_PATTERN = "planar_calibration_plate_*.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 # Physical spacing between dots in mm
30
+ ASYMMETRIC = False
31
+ ENHANCE_DOTS = True
32
+
33
+ # ===================================================================
34
+
35
+
36
+ class StereoCalibrator:
37
+ def __init__(
38
+ self,
39
+ source_dir,
40
+ base_dir,
41
+ camera_pairs,
42
+ file_pattern,
43
+ pattern_cols=10,
44
+ pattern_rows=10,
45
+ dot_spacing_mm=28.89,
46
+ asymmetric=False,
47
+ enhance_dots=False,
48
+ ):
49
+ """
50
+ Initialize stereo calibrator
51
+
52
+ Args:
53
+ source_dir: Source directory containing calibration subdirectory
54
+ base_dir: Base output directory
55
+ camera_pairs: Array of camera pairs [[1,2], [3,4], ...]
56
+ file_pattern: File pattern (e.g., 'B%05d.tif', 'planar_calibration_plate_*.tif')
57
+ pattern_cols: Number of columns in calibration grid
58
+ pattern_rows: Number of rows in calibration grid
59
+ dot_spacing_mm: Physical spacing between dots in mm
60
+ asymmetric: Whether grid is asymmetric
61
+ enhance_dots: Whether to apply dot enhancement
62
+ """
63
+ self.source_dir = Path(source_dir)
64
+ self.base_dir = Path(base_dir)
65
+ self.camera_pairs = camera_pairs
66
+ self.file_pattern = file_pattern
67
+ self.pattern_size = (pattern_cols, pattern_rows)
68
+ self.dot_spacing_mm = dot_spacing_mm
69
+ self.asymmetric = asymmetric
70
+ self.enhance_dots = enhance_dots
71
+
72
+ # Get all unique cameras
73
+ self.all_cameras = sorted(set([cam for pair in camera_pairs for cam in pair]))
74
+
75
+ # Create blob detector
76
+ self.detector = self._create_blob_detector()
77
+
78
+ def _create_blob_detector(self):
79
+ """Create optimized blob detector for circle grid detection"""
80
+ params = cv2.SimpleBlobDetector_Params()
81
+ params.filterByArea = True
82
+ params.minArea = 200
83
+ params.maxArea = 1000
84
+ params.filterByCircularity = False
85
+ params.filterByConvexity = False
86
+ params.filterByInertia = False
87
+ params.minThreshold = 0
88
+ params.maxThreshold = 255
89
+ params.thresholdStep = 5
90
+ return cv2.SimpleBlobDetector_create(params)
91
+
92
+ def enhance_dots_image(self, img, fixed_radius=9):
93
+ """Enhance white dots in calibration image for better detection"""
94
+ _, binary = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
95
+ contours, _ = cv2.findContours(
96
+ binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
97
+ )
98
+ output = img.copy()
99
+ for cnt in contours:
100
+ (x, y), _ = cv2.minEnclosingCircle(cnt)
101
+ center = (int(round(x)), int(round(y)))
102
+ cv2.circle(output, center, fixed_radius, (255,), -1)
103
+ return output
104
+
105
+ def make_object_points(self):
106
+ """Create 3D object points for calibration grid"""
107
+ cols, rows = self.pattern_size
108
+ objp = []
109
+ for i in range(rows):
110
+ for j in range(cols):
111
+ if self.asymmetric:
112
+ x = j * self.dot_spacing_mm + (
113
+ 0.5 * self.dot_spacing_mm if (i % 2 == 1) else 0.0
114
+ )
115
+ y = i * self.dot_spacing_mm
116
+ else:
117
+ x = j * self.dot_spacing_mm
118
+ y = i * self.dot_spacing_mm
119
+ objp.append([x, y, 0.0])
120
+ return np.array(objp, dtype=np.float32)
121
+
122
+ def detect_grid_in_image(self, img):
123
+ """
124
+ Detect circle grid in image
125
+
126
+ Args:
127
+ img: Input image
128
+
129
+ Returns:
130
+ (found, centers) - boolean and Nx2 array of points
131
+ """
132
+ # Convert to grayscale if needed
133
+ if img.ndim == 3:
134
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
135
+ else:
136
+ gray = img.copy()
137
+
138
+ # Apply dot enhancement if requested
139
+ if self.enhance_dots:
140
+ gray = self.enhance_dots_image(gray)
141
+
142
+ # Grid detection flags
143
+ grid_flags = (
144
+ cv2.CALIB_CB_ASYMMETRIC_GRID
145
+ if self.asymmetric
146
+ else cv2.CALIB_CB_SYMMETRIC_GRID
147
+ )
148
+
149
+ # Try both original and inverted images
150
+ for test_img, label in [(gray, "Original"), (255 - gray, "Inverted")]:
151
+ found, centers = cv2.findCirclesGrid(
152
+ test_img,
153
+ self.pattern_size,
154
+ flags=grid_flags,
155
+ blobDetector=self.detector,
156
+ )
157
+
158
+ if found:
159
+ return True, centers.reshape(-1, 2).astype(np.float32)
160
+
161
+ return False, None
162
+
163
+ def get_image_files(self, cam_dir):
164
+ """Get list of calibration image files for a camera"""
165
+ if "%" in self.file_pattern:
166
+ # Handle numbered patterns like B%05d.tif
167
+ image_files = []
168
+ i = 1
169
+ while True:
170
+ filename = self.file_pattern % i
171
+ filepath = cam_dir / filename
172
+ if filepath.exists():
173
+ image_files.append(filepath)
174
+ i += 1
175
+ else:
176
+ break
177
+ else:
178
+ # Handle glob patterns like planar_calibration_plate_*.tif
179
+ image_files = sorted(
180
+ [Path(p) for p in glob.glob(str(cam_dir / self.file_pattern))]
181
+ )
182
+
183
+ return image_files
184
+
185
+ def process_camera_pair(self, cam1_num, cam2_num):
186
+ """
187
+ Process a camera pair for stereo calibration
188
+
189
+ Args:
190
+ cam1_num: First camera number
191
+ cam2_num: Second camera number
192
+ """
193
+ logger.info(f"Processing stereo pair: Camera {cam1_num} and Camera {cam2_num}")
194
+
195
+ # Setup paths
196
+ cam1_input_dir = self.source_dir / "calibration" / f"Cam{cam1_num}"
197
+ cam2_input_dir = self.source_dir / "calibration" / f"Cam{cam2_num}"
198
+
199
+ if not cam1_input_dir.exists() or not cam2_input_dir.exists():
200
+ logger.error(
201
+ f"Camera directories not found: {cam1_input_dir} or {cam2_input_dir}"
202
+ )
203
+ return
204
+
205
+ # Get image files for both cameras
206
+ cam1_files = self.get_image_files(cam1_input_dir)
207
+ cam2_files = self.get_image_files(cam2_input_dir)
208
+
209
+ if not cam1_files or not cam2_files:
210
+ logger.error(f"No images found for camera pair {cam1_num}-{cam2_num}")
211
+ return
212
+
213
+ # Match files by name
214
+ cam1_dict = {f.name: f for f in cam1_files}
215
+ cam2_dict = {f.name: f for f in cam2_files}
216
+ common_names = sorted(set(cam1_dict.keys()) & set(cam2_dict.keys()))
217
+
218
+ if not common_names:
219
+ logger.error("No matching image pairs found between cameras")
220
+ return
221
+
222
+ logger.info(f"Found {len(common_names)} matching image pairs")
223
+
224
+ # Process all pairs and collect successful ones
225
+ objpoints = []
226
+ imgpoints1 = []
227
+ imgpoints2 = []
228
+ successful_pairs = []
229
+ objp = self.make_object_points()
230
+ image_size = None
231
+
232
+ for filename in common_names:
233
+ cam1_file = cam1_dict[filename]
234
+ cam2_file = cam2_dict[filename]
235
+
236
+ # Process first image
237
+ img1 = cv2.imread(str(cam1_file), cv2.IMREAD_UNCHANGED)
238
+ if img1 is None:
239
+ logger.warning(f"Could not load {cam1_file}")
240
+ continue
241
+
242
+ # Process second image
243
+ img2 = cv2.imread(str(cam2_file), cv2.IMREAD_UNCHANGED)
244
+ if img2 is None:
245
+ logger.warning(f"Could not load {cam2_file}")
246
+ continue
247
+
248
+ # Set image size from first successful pair
249
+ if image_size is None:
250
+ image_size = img1.shape[:2][::-1] # width, height
251
+
252
+ # Detect grid in both images
253
+ found1, grid1 = self.detect_grid_in_image(img1)
254
+ found2, grid2 = self.detect_grid_in_image(img2)
255
+
256
+ # Skip if grid not found in either image
257
+ if not found1 or not found2:
258
+ logger.warning(f"Grid not found in pair {filename}")
259
+ continue
260
+
261
+ # Verify that we have the correct number of points
262
+ expected_points = self.pattern_size[0] * self.pattern_size[1]
263
+ if len(grid1) != expected_points or len(grid2) != expected_points:
264
+ logger.warning(f"Incorrect number of points detected in {filename}")
265
+ continue
266
+
267
+ # Add to calibration data
268
+ objpoints.append(objp.reshape(-1, 1, 3))
269
+ imgpoints1.append(grid1.reshape(-1, 1, 2))
270
+ imgpoints2.append(grid2.reshape(-1, 1, 2))
271
+ successful_pairs.append(filename)
272
+
273
+ # Save individual results for this image pair (using 1-based indexing)
274
+ img_index = len(successful_pairs)
275
+
276
+ # Compute homographies for visualization
277
+ objp_2d = objp[:, :2].astype(np.float32)
278
+ if grid1 is not None and grid2 is not None:
279
+ grid1_f32 = grid1.astype(np.float32)
280
+ grid2_f32 = grid2.astype(np.float32)
281
+ H1, _ = cv2.findHomography(grid1_f32, objp_2d, method=cv2.RANSAC)
282
+ H2, _ = cv2.findHomography(grid2_f32, objp_2d, method=cv2.RANSAC)
283
+ else:
284
+ logger.warning(f"Grid points are None for {filename}")
285
+ continue
286
+
287
+ # Calculate individual reprojection errors
288
+ def calc_reproj_error(grid_pts, H_matrix):
289
+ objp_2d = objp[:, :2]
290
+ H_inv = np.linalg.inv(H_matrix)
291
+ objp_h = np.hstack([objp_2d, np.ones((objp_2d.shape[0], 1))])
292
+ projected_h = (H_inv @ objp_h.T).T
293
+ projected = projected_h[:, :2] / projected_h[:, 2:]
294
+ error_vec = grid_pts - projected
295
+ errors = np.linalg.norm(error_vec, axis=1)
296
+ return errors.mean(), error_vec[:, 0], error_vec[:, 1]
297
+
298
+ reproj_err1, reproj_x1, reproj_y1 = calc_reproj_error(grid1, H1)
299
+ reproj_err2, reproj_x2, reproj_y2 = calc_reproj_error(grid2, H2)
300
+
301
+ # Placeholder camera matrices (will be computed later)
302
+ placeholder_matrix = np.eye(3, dtype=np.float32) * 1000
303
+ placeholder_dist = np.zeros(5, dtype=np.float32)
304
+
305
+ # Save individual results for both cameras
306
+ self._save_individual_results(
307
+ cam1_num,
308
+ img_index,
309
+ grid1,
310
+ H1,
311
+ placeholder_matrix,
312
+ placeholder_dist,
313
+ reproj_err1,
314
+ reproj_x1,
315
+ reproj_y1,
316
+ filename,
317
+ )
318
+ self._save_individual_results(
319
+ cam2_num,
320
+ img_index,
321
+ grid2,
322
+ H2,
323
+ placeholder_matrix,
324
+ placeholder_dist,
325
+ reproj_err2,
326
+ reproj_x2,
327
+ reproj_y2,
328
+ filename,
329
+ )
330
+
331
+ logger.info(f"Successfully processed pair {filename}")
332
+
333
+ # Check if we have enough pairs for calibration
334
+ if len(successful_pairs) < 3:
335
+ logger.error(
336
+ f"Not enough successful image pairs for calibration: {len(successful_pairs)}"
337
+ )
338
+ return
339
+
340
+ logger.info(f"Using {len(successful_pairs)} image pairs for calibration")
341
+
342
+ # Calibrate each camera individually
343
+ logger.info("Calibrating individual cameras...")
344
+ ret1, mtx1, dist1, rvecs1, tvecs1 = cv2.calibrateCamera( # type: ignore
345
+ objpoints, imgpoints1, image_size, None, None
346
+ )
347
+ ret2, mtx2, dist2, rvecs2, tvecs2 = cv2.calibrateCamera( # type: ignore
348
+ objpoints, imgpoints2, image_size, None, None
349
+ )
350
+ logger.info(f"Camera 1 reprojection error: {ret1:.5f}")
351
+ logger.info(f"Camera 2 reprojection error: {ret2:.5f}")
352
+
353
+ # Stereo calibration
354
+ logger.info("Performing stereo calibration...")
355
+ criteria = (cv2.TERM_CRITERIA_MAX_ITER + cv2.TERM_CRITERIA_EPS, 100, 1e-5)
356
+ flags = cv2.CALIB_FIX_INTRINSIC # Use pre-calculated intrinsics
357
+
358
+ ret, mtx1, dist1, mtx2, dist2, R, T, E, F = cv2.stereoCalibrate(
359
+ objpoints,
360
+ imgpoints1,
361
+ imgpoints2,
362
+ mtx1,
363
+ dist1,
364
+ mtx2,
365
+ dist2,
366
+ image_size,
367
+ criteria=criteria,
368
+ flags=flags,
369
+ )
370
+
371
+ # Stereo rectification with alpha=-1 and USE_INTRINSIC_GUESS
372
+ R1, R2, P1, P2, Q, validPixROI1, validPixROI2 = cv2.stereoRectify(
373
+ mtx1,
374
+ dist1,
375
+ mtx2,
376
+ dist2,
377
+ image_size,
378
+ R,
379
+ T,
380
+ flags=cv2.CALIB_USE_INTRINSIC_GUESS,
381
+ alpha=-1, # keep all pixels, don't crop
382
+ )
383
+
384
+ # Calculate relative angle
385
+ angle_rad = math.acos((np.trace(R) - 1) / 2)
386
+ angle_deg = np.degrees(angle_rad)
387
+
388
+ # Save results
389
+ self._save_stereo_results(
390
+ cam1_num,
391
+ cam2_num,
392
+ {
393
+ "camera_matrix_1": mtx1,
394
+ "dist_coeffs_1": dist1,
395
+ "camera_matrix_2": mtx2,
396
+ "dist_coeffs_2": dist2,
397
+ "rotation_matrix": R,
398
+ "translation_vector": T,
399
+ "essential_matrix": E,
400
+ "fundamental_matrix": F,
401
+ "rectification_R1": R1,
402
+ "rectification_R2": R2,
403
+ "projection_P1": P1,
404
+ "projection_P2": P2,
405
+ "disparity_to_depth_Q": Q,
406
+ "valid_pixel_ROI1": validPixROI1,
407
+ "valid_pixel_ROI2": validPixROI2,
408
+ "stereo_reprojection_error": ret,
409
+ "relative_angle_deg": angle_deg,
410
+ "num_image_pairs": len(successful_pairs),
411
+ "timestamp": datetime.now().isoformat(),
412
+ "image_size": image_size,
413
+ "successful_filenames": successful_pairs,
414
+ },
415
+ )
416
+
417
+ logger.info(f"Stereo calibration completed successfully with error: {ret:.5f}")
418
+ logger.info(f"Relative angle between cameras: {angle_deg:.2f} degrees")
419
+ logger.info(f"Translation vector: {T.ravel()}")
420
+
421
+ def _save_grid_visualization(
422
+ self, cam_num, img_index, grid_points, original_filename, reprojection_error
423
+ ):
424
+ """Save a figure showing the detected grid with dot indices for a camera"""
425
+ try:
426
+ import matplotlib.pyplot as plt
427
+
428
+ # Load original image for background
429
+ cam_input_dir = self.source_dir / "calibration" / f"Cam{cam_num}"
430
+ img_path = cam_input_dir / original_filename
431
+ img = cv2.imread(str(img_path), cv2.IMREAD_GRAYSCALE)
432
+
433
+ if img is None:
434
+ logger.warning(f"Could not load image for visualization: {img_path}")
435
+ return None
436
+
437
+ cols, rows = self.pattern_size
438
+
439
+ # Create figure
440
+ fig, ax = plt.subplots(figsize=(12, 10))
441
+
442
+ # Display image
443
+ ax.imshow(img, cmap="gray", alpha=0.7)
444
+
445
+ # Plot detected grid points with indices
446
+ for idx, (x, y) in enumerate(grid_points):
447
+ # Calculate grid coordinates (row, col)
448
+ row = idx // cols
449
+ col = idx % cols
450
+
451
+ # Plot point
452
+ ax.scatter(x, y, c="red", s=60, marker="o", alpha=0.8)
453
+
454
+ # Add index label
455
+ ax.text(
456
+ x + 10,
457
+ y - 10,
458
+ f"({row},{col})",
459
+ color="cyan",
460
+ fontsize=8,
461
+ fontweight="bold",
462
+ bbox=dict(boxstyle="round,pad=0.3", facecolor="black", alpha=0.7),
463
+ )
464
+
465
+ ax.set_title(
466
+ f"Stereo Grid Detection - Cam{cam_num}: {original_filename}\n"
467
+ f"Detected: {len(grid_points)} points | "
468
+ f"Reprojection Error: {reprojection_error:.2f}px",
469
+ fontsize=12,
470
+ fontweight="bold",
471
+ )
472
+ ax.set_xlabel("X (pixels)")
473
+ ax.set_ylabel("Y (pixels)")
474
+ ax.grid(True, alpha=0.3)
475
+
476
+ # Invert y-axis to match image coordinates
477
+ ax.invert_yaxis()
478
+
479
+ # Save figure
480
+ stereo_dir = self.base_dir / "calibration" / f"Cam{cam_num}" / "stereo"
481
+ stereo_dir.mkdir(parents=True, exist_ok=True)
482
+ fig_path = stereo_dir / f"grid_detection_{img_index}.png"
483
+ plt.savefig(fig_path, dpi=150, bbox_inches="tight", facecolor="white")
484
+ plt.close(fig)
485
+
486
+ logger.info(f"Saved stereo grid visualization: {fig_path}")
487
+ return fig_path
488
+
489
+ except Exception as e:
490
+ logger.warning(f"Failed to save stereo grid visualization: {str(e)}")
491
+ return None
492
+
493
+ def _save_individual_results(
494
+ self,
495
+ cam_num,
496
+ img_index,
497
+ grid_points,
498
+ H,
499
+ camera_matrix,
500
+ dist_coeffs,
501
+ reprojection_error,
502
+ reproj_errs_x,
503
+ reproj_errs_y,
504
+ original_filename,
505
+ ):
506
+ """Save individual camera calibration results for stereo"""
507
+ stereo_dir = self.base_dir / "calibration" / f"Cam{cam_num}" / "stereo"
508
+ stereo_dir.mkdir(parents=True, exist_ok=True)
509
+
510
+ # Save grid data
511
+ grid_data = {
512
+ "grid_points": grid_points,
513
+ "homography": H,
514
+ "reprojection_error": reprojection_error,
515
+ "reprojection_error_x_mean": float(np.mean(np.abs(reproj_errs_x))),
516
+ "reprojection_error_y_mean": float(np.mean(np.abs(reproj_errs_y))),
517
+ "reprojection_errors_x": reproj_errs_x,
518
+ "reprojection_errors_y": reproj_errs_y,
519
+ "original_filename": original_filename,
520
+ "pattern_size": self.pattern_size,
521
+ "dot_spacing_mm": self.dot_spacing_mm,
522
+ "timestamp": datetime.now().isoformat(),
523
+ }
524
+ savemat(stereo_dir / f"grid_detection_{img_index}.mat", grid_data)
525
+
526
+ # Save camera model
527
+ model_data = {
528
+ "camera_matrix": camera_matrix,
529
+ "dist_coeffs": dist_coeffs,
530
+ "reprojection_error": reprojection_error,
531
+ "reprojection_error_x_mean": float(np.mean(np.abs(reproj_errs_x))),
532
+ "reprojection_error_y_mean": float(np.mean(np.abs(reproj_errs_y))),
533
+ "grid_points": grid_points,
534
+ "homography": H,
535
+ "original_filename": original_filename,
536
+ "pattern_size": self.pattern_size,
537
+ "dot_spacing_mm": self.dot_spacing_mm,
538
+ "timestamp": datetime.now().isoformat(),
539
+ }
540
+ savemat(stereo_dir / f"camera_model_{img_index}.mat", model_data)
541
+
542
+ # Save visualization
543
+ self._save_grid_visualization(
544
+ cam_num, img_index, grid_points, original_filename, reprojection_error
545
+ )
546
+
547
+ def _save_stereo_results(self, cam1_num, cam2_num, stereo_data):
548
+ """Save stereo calibration results"""
549
+ stereo_filename = f"stereo_model_cam{cam1_num}-cam{cam2_num}.mat"
550
+
551
+ # Save to both locations for compatibility
552
+ savemat(self.base_dir / "calibration" / stereo_filename, stereo_data)
553
+
554
+ # Also save to individual camera stereo directories
555
+ stereo_dir1 = self.base_dir / "calibration" / f"Cam{cam1_num}" / "stereo"
556
+ stereo_dir2 = self.base_dir / "calibration" / f"Cam{cam2_num}" / "stereo"
557
+ stereo_dir1.mkdir(parents=True, exist_ok=True)
558
+ stereo_dir2.mkdir(parents=True, exist_ok=True)
559
+
560
+ savemat(stereo_dir1 / stereo_filename, stereo_data)
561
+ savemat(stereo_dir2 / stereo_filename, stereo_data)
562
+
563
+ logger.info(
564
+ f"Saved stereo model: {self.base_dir / 'calibration' / stereo_filename}"
565
+ )
566
+ logger.info(
567
+ f"Stereo calibration results saved at: {self.base_dir / 'calibration' / stereo_filename}"
568
+ )
569
+
570
+ def run(self):
571
+ """Run stereo calibration for all camera pairs"""
572
+ logger.info("Starting stereo calibration")
573
+ logger.info(f"Source: {self.source_dir}")
574
+ logger.info(f"Output: {self.base_dir}")
575
+ logger.info(f"Camera pairs: {self.camera_pairs}")
576
+
577
+ for cam1_num, cam2_num in self.camera_pairs:
578
+ try:
579
+ self.process_camera_pair(cam1_num, cam2_num)
580
+ except Exception as e:
581
+ logger.error(
582
+ f"Failed to process camera pair {cam1_num}-{cam2_num}: {str(e)}"
583
+ )
584
+ continue
585
+
586
+ logger.info("Stereo calibration completed")
587
+
588
+
589
+ def main():
590
+ calibrator = StereoCalibrator(
591
+ source_dir=SOURCE_DIR,
592
+ base_dir=BASE_DIR,
593
+ camera_pairs=CAMERA_PAIRS,
594
+ file_pattern=FILE_PATTERN,
595
+ pattern_cols=PATTERN_COLS,
596
+ pattern_rows=PATTERN_ROWS,
597
+ dot_spacing_mm=DOT_SPACING_MM,
598
+ asymmetric=ASYMMETRIC,
599
+ enhance_dots=ENHANCE_DOTS,
600
+ )
601
+
602
+ calibrator.run()
603
+
604
+
605
+ if __name__ == "__main__":
606
+ main()