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,544 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ stereo_reconstruction_production.py
4
+
5
+ Production script for 3D velocity reconstruction from stereo camera pairs.
6
+ Takes calibrated 2D velocity fields from two cameras and reconstructs 3D velocities (ux, uy, uz).
7
+ """
8
+
9
+ import logging
10
+ import sys
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+
14
+ import cv2
15
+ import numpy as np
16
+ from scipy.io import loadmat, savemat
17
+
18
+ # Add src to path to import modules
19
+ sys.path.append(str(Path(__file__).parent.parent))
20
+ from ..paths import get_data_paths
21
+ from ..vector_loading import load_coords_from_directory, read_mat_contents
22
+
23
+ # ===================== CONFIGURATION VARIABLES =====================
24
+ # Set these variables for your stereo reconstruction setup
25
+ BASE_DIR = "/Users/morgan/Library/CloudStorage/OneDrive-UniversityofSouthampton/Documents/#current_processing/query_JHTDB/Stereo_Images/ProcessedPIV"
26
+ CAMERA_PAIRS = [[1, 2]] # Array of camera pairs to process
27
+ IMAGE_COUNT = 1000 # Number of images to process for stereo reconstruction
28
+ VECTOR_PATTERN = "%05d.mat" # Pattern for vector files
29
+ TYPE_NAME = "instantaneous" # Type name for calibrated data directory
30
+ MAX_CORRESPONDENCE_DISTANCE = 5.0 # Maximum distance in mm for point correspondence
31
+ MIN_TRIANGULATION_ANGLE = 5.0 # Minimum angle in degrees for triangulation
32
+ DT = 0.01 # Time between frames in seconds
33
+ # ===================================================================
34
+
35
+ # Configure logging
36
+ logging.basicConfig(
37
+ level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
38
+ )
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ class StereoReconstructor:
43
+ def __init__(
44
+ self,
45
+ base_dir,
46
+ camera_pairs,
47
+ image_count,
48
+ vector_pattern="%05d.mat",
49
+ type_name="instantaneous",
50
+ max_distance=5.0,
51
+ min_angle=5.0,
52
+ progress_cb=None,
53
+ dt=1.0, # NEW: time between frames in seconds
54
+ ):
55
+ self.base_dir = Path(base_dir)
56
+ self.camera_pairs = camera_pairs
57
+ self.image_count = image_count
58
+ self.vector_pattern = vector_pattern
59
+ self.type_name = type_name
60
+ self.max_distance = max_distance
61
+ self.min_angle = min_angle
62
+ self._stereo_diag_done = False
63
+ self.progress_cb = progress_cb
64
+ self.dt = dt # Store dt
65
+
66
+ def load_stereo_calibration(self, cam1_num, cam2_num):
67
+ stereo_file = (
68
+ self.base_dir
69
+ / "calibration"
70
+ / f"stereo_model_cam{cam1_num}-cam{cam2_num}.mat"
71
+ )
72
+ if not stereo_file.exists():
73
+ raise FileNotFoundError(f"Stereo calibration not found: {stereo_file}")
74
+ stereo_data = loadmat(str(stereo_file), squeeze_me=True, struct_as_record=False)
75
+ required_fields = [
76
+ "camera_matrix_1",
77
+ "camera_matrix_2",
78
+ "dist_coeffs_1",
79
+ "dist_coeffs_2",
80
+ "rotation_matrix",
81
+ "translation_vector",
82
+ "projection_P1",
83
+ "projection_P2",
84
+ "disparity_to_depth_Q",
85
+ ]
86
+ missing_fields = [
87
+ field for field in required_fields if field not in stereo_data
88
+ ]
89
+ if missing_fields:
90
+ raise ValueError(
91
+ f"Missing required fields in stereo calibration: {missing_fields}"
92
+ )
93
+ return stereo_data
94
+
95
+ def load_uncalibrated_coordinates(self, cam_num):
96
+ paths = get_data_paths(
97
+ self.base_dir,
98
+ num_images=self.image_count,
99
+ cam=cam_num,
100
+ type_name=self.type_name,
101
+ use_uncalibrated=True,
102
+ )
103
+ coords_file = paths["data_dir"]
104
+ logger.info(f"Loading coordinates from: {coords_file}")
105
+
106
+ # First try to detect available runs by looking at coordinate files
107
+ coord_files = list(coords_file.glob("coords_run*.mat"))
108
+ if coord_files:
109
+ # Extract run numbers from filenames
110
+ available_runs = []
111
+ for f in sorted(coord_files):
112
+ try:
113
+ run_num = int(f.stem.split("_run")[-1])
114
+ available_runs.append(run_num)
115
+ except ValueError:
116
+ continue
117
+ logger.info(f"Found coordinate files for runs: {available_runs}")
118
+ x_list, y_list = load_coords_from_directory(
119
+ coords_file, runs=available_runs
120
+ )
121
+ else:
122
+ # Fallback: try to load all runs without specifying
123
+ logger.info("No specific run files found, trying to load all coordinates")
124
+ x_list, y_list = load_coords_from_directory(coords_file, runs=None)
125
+ available_runs = list(range(1, len(x_list) + 1)) if x_list else []
126
+
127
+ logger.info(f"Loaded {len(x_list)} coordinate sets")
128
+ filtered_coords = []
129
+ for i, (x, y) in enumerate(zip(x_list, y_list)):
130
+ run_num = available_runs[i] if i < len(available_runs) else i + 1
131
+ filtered_coords.append({"x_px": x, "y_px": y, "run": run_num})
132
+ return filtered_coords
133
+
134
+ def load_uncalibrated_vectors(self, cam_num, frame_idx, run_idx):
135
+ paths = get_data_paths(
136
+ self.base_dir,
137
+ num_images=self.image_count,
138
+ cam=cam_num,
139
+ type_name=self.type_name,
140
+ use_uncalibrated=True,
141
+ )
142
+ vector_file = paths["data_dir"] / (self.vector_pattern % frame_idx)
143
+ logger.debug(f"Looking for vector file: {vector_file}")
144
+ if not vector_file.exists():
145
+ raise FileNotFoundError(f"Vector file not found: {vector_file}")
146
+ logger.debug(f"Loading vector file: {vector_file}")
147
+ vector_data = read_mat_contents(str(vector_file))
148
+ logger.debug(f"Vector data shape: {vector_data.shape}")
149
+
150
+ # Extract data for the specific run
151
+ if vector_data.ndim == 4 and vector_data.shape[0] >= run_idx:
152
+ # Multiple runs in file: (runs, 3, height, width)
153
+ ux_px = vector_data[run_idx - 1, 0, :, :]
154
+ uy_px = vector_data[run_idx - 1, 1, :, :]
155
+ b_mask = vector_data[run_idx - 1, 2, :, :]
156
+ elif (
157
+ vector_data.ndim == 4
158
+ and vector_data.shape[0] == 1
159
+ and vector_data.shape[1] == 3
160
+ ):
161
+ # Single run with extra dimension: (1, 3, height, width)
162
+ # Assume this single run corresponds to the requested run
163
+ logger.debug(
164
+ f"Vector file contains 1 run, assuming it corresponds to requested run {run_idx}"
165
+ )
166
+ ux_px = vector_data[0, 0, :, :]
167
+ uy_px = vector_data[0, 1, :, :]
168
+ b_mask = vector_data[0, 2, :, :]
169
+ elif vector_data.ndim == 3 and vector_data.shape[0] == 3:
170
+ # Single run: (3, height, width)
171
+ # Assume this single run corresponds to the requested run
172
+ logger.debug(
173
+ f"Vector file contains 1 run, assuming it corresponds to requested run {run_idx}"
174
+ )
175
+ ux_px = vector_data[0, :, :]
176
+ uy_px = vector_data[1, :, :]
177
+ b_mask = vector_data[2, :, :]
178
+ else:
179
+ raise ValueError(f"Unexpected vector_data shape: {vector_data.shape}")
180
+ return {
181
+ "ux_px": ux_px,
182
+ "uy_px": uy_px,
183
+ "b_mask": b_mask,
184
+ "frame": frame_idx,
185
+ "run": run_idx,
186
+ }
187
+
188
+ def find_corresponding_points(self, coords1_px, coords2_px):
189
+ shape1 = coords1_px[0].shape
190
+ shape2 = coords2_px[0].shape
191
+ if shape1 != shape2:
192
+ min_h = min(shape1[0], shape2[0])
193
+ min_w = min(shape1[1], shape2[1])
194
+ indices1 = []
195
+ indices2 = []
196
+ for i in range(min_h):
197
+ for j in range(min_w):
198
+ idx1 = np.ravel_multi_index((i, j), shape1)
199
+ idx2 = np.ravel_multi_index((i, j), shape2)
200
+ indices1.append(idx1)
201
+ indices2.append(idx2)
202
+ indices1 = np.array(indices1)
203
+ indices2 = np.array(indices2)
204
+ else:
205
+ total_points = np.prod(shape1)
206
+ indices1 = np.arange(total_points)
207
+ indices2 = np.arange(total_points)
208
+ return indices1, indices2
209
+
210
+ def triangulate_3d_points(self, pts1_px, pts2_px, stereo_data):
211
+ mtx1 = stereo_data["camera_matrix_1"]
212
+ dist1 = stereo_data["dist_coeffs_1"]
213
+ mtx2 = stereo_data["camera_matrix_2"]
214
+ dist2 = stereo_data["dist_coeffs_2"]
215
+ R1 = stereo_data["rectification_R1"]
216
+ R2 = stereo_data["rectification_R2"]
217
+ P1 = stereo_data["projection_P1"]
218
+ P2 = stereo_data["projection_P2"]
219
+ pts1_rect = cv2.undistortPoints(
220
+ pts1_px.reshape(-1, 1, 2).astype(np.float32), mtx1, dist1, R=R1, P=P1
221
+ ).reshape(-1, 2)
222
+ pts2_rect = cv2.undistortPoints(
223
+ pts2_px.reshape(-1, 1, 2).astype(np.float32), mtx2, dist2, R=R2, P=P2
224
+ ).reshape(-1, 2)
225
+ points_4d = cv2.triangulatePoints(P1, P2, pts1_rect.T, pts2_rect.T)
226
+ points_3d = points_4d[:3] / points_4d[3]
227
+ points_3d = points_3d.T
228
+ # Removed mean-centering to avoid artificial offset
229
+ return points_3d, pts1_rect, pts2_rect
230
+
231
+ def compute_triangulation_angles(self, pts_3d, stereo_data):
232
+ R = stereo_data["rotation_matrix"]
233
+ T = stereo_data["translation_vector"].reshape(3)
234
+ cam1_center = np.array([0.0, 0.0, 0.0])
235
+ cam2_center = -R.T @ T
236
+ vec1 = pts_3d - cam1_center
237
+ vec2 = pts_3d - cam2_center
238
+ vec1_norm = vec1 / np.linalg.norm(vec1, axis=1, keepdims=True)
239
+ vec2_norm = vec2 / np.linalg.norm(vec2, axis=1, keepdims=True)
240
+ dot_products = np.sum(vec1_norm * vec2_norm, axis=1)
241
+ angles_rad = np.arccos(np.clip(dot_products, -1, 1))
242
+ angles_deg = np.degrees(angles_rad)
243
+ return angles_deg
244
+
245
+ def reconstruct_3d_velocities(
246
+ self, ux1, uy1, ux2, uy2, coords1_px, coords2_px, stereo_data
247
+ ):
248
+ indices1, indices2 = self.find_corresponding_points(coords1_px, coords2_px)
249
+ if len(indices1) == 0:
250
+ raise ValueError("No corresponding points found between cameras")
251
+ shape1 = coords1_px[0].shape
252
+ shape2 = coords2_px[0].shape
253
+ row1, col1 = np.unravel_index(indices1, shape1)
254
+ row2, col2 = np.unravel_index(indices2, shape2)
255
+ pts1_px = np.column_stack(
256
+ [coords1_px[0][row1, col1], coords1_px[1][row1, col1]]
257
+ )
258
+ pts2_px = np.column_stack(
259
+ [coords2_px[0][row2, col2], coords2_px[1][row2, col2]]
260
+ )
261
+ vel1 = np.column_stack([ux1[row1, col1], uy1[row1, col1]])
262
+ vel2 = np.column_stack([ux2[row2, col2], uy2[row2, col2]])
263
+ pts_3d, pts1_rect, pts2_rect = self.triangulate_3d_points(
264
+ pts1_px, pts2_px, stereo_data
265
+ )
266
+ angles = self.compute_triangulation_angles(pts_3d, stereo_data)
267
+ angle_mask = angles > self.min_angle
268
+ pts1_displaced_px = pts1_px + vel1
269
+ pts2_displaced_px = pts2_px + vel2
270
+ pts_3d_displaced, _, _ = self.triangulate_3d_points(
271
+ pts1_displaced_px, pts2_displaced_px, stereo_data
272
+ )
273
+ vel_3d_mm_per_frame = pts_3d_displaced - pts_3d
274
+ # Output in mm, coordinates centered around zero
275
+ vel_3d_mm = vel_3d_mm_per_frame
276
+ valid_mask = angle_mask
277
+ return {
278
+ "velocities_3d": vel_3d_mm[valid_mask],
279
+ "positions_3d": pts_3d[valid_mask],
280
+ "indices1": indices1[valid_mask],
281
+ "indices2": indices2[valid_mask],
282
+ "triangulation_angles": angles[valid_mask],
283
+ "num_valid": np.sum(valid_mask),
284
+ "num_total": len(valid_mask),
285
+ }
286
+
287
+ def save_calibrated_vectors_matlab_format(
288
+ self,
289
+ result_3d,
290
+ coords1_px,
291
+ coords2_px,
292
+ frame_idx,
293
+ output_dir,
294
+ num_runs,
295
+ current_run_num,
296
+ ):
297
+ ref_shape = coords1_px[0].shape
298
+ ux_grid = np.full(ref_shape, np.nan, dtype=np.float64)
299
+ uy_grid = np.full(ref_shape, np.nan, dtype=np.float64)
300
+ uz_grid = np.full(ref_shape, np.nan, dtype=np.float64)
301
+ # Convert mm to m for displacement and divide by dt for velocity
302
+ velocities_3d_mps = (result_3d["velocities_3d"] / 1000.0) / max(self.dt, 1e-12)
303
+ if result_3d["num_valid"] > 0:
304
+ valid_indices = result_3d["indices1"]
305
+ row_indices, col_indices = np.unravel_index(valid_indices, ref_shape)
306
+ ux_grid[row_indices, col_indices] = velocities_3d_mps[:, 0]
307
+ uy_grid[row_indices, col_indices] = velocities_3d_mps[:, 1]
308
+ uz_grid[row_indices, col_indices] = velocities_3d_mps[:, 2]
309
+
310
+ piv_dtype = np.dtype([("ux", "O"), ("uy", "O"), ("uz", "O")])
311
+ piv_result = np.empty(num_runs, dtype=piv_dtype)
312
+ for run_idx in range(num_runs):
313
+ run_num = run_idx + 1
314
+ if run_num == current_run_num:
315
+ # This is the current run with data
316
+ piv_result[run_idx] = (ux_grid, uy_grid, uz_grid)
317
+ else:
318
+ # Empty run - save empty arrays
319
+ piv_result[run_idx] = (np.array([]), np.array([]), np.array([]))
320
+ vector_file = output_dir / (self.vector_pattern % frame_idx)
321
+ savemat(str(vector_file), {"piv_result": piv_result})
322
+ return vector_file
323
+
324
+ def save_stereo_coordinates(
325
+ self, result_3d, coords1_px, output_dir, num_runs, current_run_num
326
+ ):
327
+ ref_shape = coords1_px[0].shape
328
+ x_grid = np.full(ref_shape, np.nan, dtype=np.float64)
329
+ y_grid = np.full(ref_shape, np.nan, dtype=np.float64)
330
+ z_grid = np.full(ref_shape, np.nan, dtype=np.float64)
331
+ if result_3d["num_valid"] > 0:
332
+ valid_indices = result_3d["indices1"]
333
+ row_indices, col_indices = np.unravel_index(valid_indices, ref_shape)
334
+ positions_3d = result_3d["positions_3d"]
335
+ # Center coordinates around zero
336
+ mean_xyz = np.mean(positions_3d, axis=0)
337
+ centered_xyz = positions_3d - mean_xyz
338
+ x_grid[row_indices, col_indices] = centered_xyz[:, 0]
339
+ y_grid[row_indices, col_indices] = centered_xyz[:, 1]
340
+ z_grid[row_indices, col_indices] = centered_xyz[:, 2]
341
+
342
+ coord_dtype = np.dtype([("x", "O"), ("y", "O"), ("z", "O")])
343
+ coordinates = np.empty(num_runs, dtype=coord_dtype)
344
+ for run_idx in range(num_runs):
345
+ run_num = run_idx + 1
346
+ if run_num == current_run_num:
347
+ # This is the current run with data
348
+ coordinates[run_idx] = (x_grid, y_grid, z_grid)
349
+ else:
350
+ # Empty run - save empty arrays
351
+ coordinates[run_idx] = (np.array([]), np.array([]), np.array([]))
352
+ coord_file = output_dir / "coordinates.mat"
353
+ coords_output = {"coordinates": coordinates}
354
+ savemat(str(coord_file), coords_output)
355
+ return coord_file
356
+
357
+ def determine_output_camera(self, cam1_num, cam2_num):
358
+ return cam1_num
359
+
360
+ def process_camera_pair(self, cam1_num, cam2_num):
361
+ logger.info(
362
+ f"Starting stereo 3D reconstruction for pair ({cam1_num},{cam2_num})"
363
+ )
364
+ stereo_data = self.load_stereo_calibration(cam1_num, cam2_num)
365
+ # Strip private MATLAB keys to avoid MatWriteWarning
366
+ stereo_data_sanitized = {
367
+ k: v for k, v in stereo_data.items() if not k.startswith("_")
368
+ }
369
+ coords1_list = self.load_uncalibrated_coordinates(cam1_num)
370
+ coords2_list = self.load_uncalibrated_coordinates(cam2_num)
371
+ logger.info(f"Camera {cam1_num}: Found {len(coords1_list)} coordinate sets")
372
+ logger.info(f"Camera {cam2_num}: Found {len(coords2_list)} coordinate sets")
373
+
374
+ if len(coords1_list) == 0:
375
+ raise ValueError(f"No coordinate data found for Camera {cam1_num}")
376
+ if len(coords2_list) == 0:
377
+ raise ValueError(f"No coordinate data found for Camera {cam2_num}")
378
+ if len(coords1_list) != len(coords2_list):
379
+ min_sets = min(len(coords1_list), len(coords2_list))
380
+ coords1_list = coords1_list[:min_sets]
381
+ coords2_list = coords2_list[:min_sets]
382
+ logger.info(f"Adjusted to {min_sets} coordinate sets to match both cameras")
383
+
384
+ # Check which runs have valid coordinate data
385
+ valid_runs = []
386
+ for i, (coords1, coords2) in enumerate(zip(coords1_list, coords2_list)):
387
+ valid_coords1 = np.sum(~np.isnan(coords1["x_px"]))
388
+ valid_coords2 = np.sum(~np.isnan(coords2["x_px"]))
389
+ run_num = coords1["run"]
390
+ logger.info(
391
+ f"Run {run_num}: Cam{cam1_num}={valid_coords1}, Cam{cam2_num}={valid_coords2} valid coordinates"
392
+ )
393
+ if valid_coords1 > 0 and valid_coords2 > 0:
394
+ valid_runs.append((i, run_num, valid_coords1 + valid_coords2))
395
+
396
+ if not valid_runs:
397
+ raise ValueError("No runs with valid coordinate data found")
398
+
399
+ logger.info(
400
+ f"Found {len(valid_runs)} runs with valid data: {[r[1] for r in valid_runs]}"
401
+ )
402
+
403
+ output_cam = self.determine_output_camera(cam1_num, cam2_num)
404
+ output_dir = (
405
+ self.base_dir
406
+ / "calibrated_piv"
407
+ / str(self.image_count)
408
+ / f"cam{output_cam}"
409
+ / self.type_name
410
+ )
411
+ output_dir.mkdir(parents=True, exist_ok=True)
412
+ logger.info(f"Output directory: {output_dir}")
413
+
414
+ # Process each valid run
415
+ total_successful_frames = 0
416
+ for run_idx, run_num, total_coords in valid_runs:
417
+ logger.info(
418
+ f"Processing run {run_num} (index {run_idx}) with {total_coords} coordinates"
419
+ )
420
+
421
+ coords1 = coords1_list[run_idx]
422
+ coords2 = coords2_list[run_idx]
423
+ coords1_px = (coords1["x_px"], coords1["y_px"])
424
+ coords2_px = (coords2["x_px"], coords2["y_px"])
425
+
426
+ logger.info(
427
+ f"Run {run_num} coord shapes: Cam{cam1_num}={coords1_px[0].shape}, Cam{cam2_num}={coords2_px[0].shape}"
428
+ )
429
+
430
+ successful_frames = 0
431
+ coordinates_saved = False
432
+ for frame_idx in range(1, self.image_count + 1):
433
+ try:
434
+ if frame_idx <= 5 or frame_idx % 100 == 0:
435
+ logger.info(f"Run {run_num}, Frame {frame_idx}")
436
+ vectors1 = self.load_uncalibrated_vectors(
437
+ cam1_num, frame_idx, run_num
438
+ )
439
+ vectors2 = self.load_uncalibrated_vectors(
440
+ cam2_num, frame_idx, run_num
441
+ )
442
+ result_3d = self.reconstruct_3d_velocities(
443
+ vectors1["ux_px"],
444
+ vectors1["uy_px"],
445
+ vectors2["ux_px"],
446
+ vectors2["uy_px"],
447
+ coords1_px,
448
+ coords2_px,
449
+ stereo_data,
450
+ )
451
+ self.save_calibrated_vectors_matlab_format(
452
+ result_3d,
453
+ coords1_px,
454
+ coords2_px,
455
+ frame_idx,
456
+ output_dir,
457
+ len(coords1_list),
458
+ run_num,
459
+ )
460
+ if not coordinates_saved and result_3d["num_valid"] > 0:
461
+ self.save_stereo_coordinates(
462
+ result_3d,
463
+ coords1_px,
464
+ output_dir,
465
+ len(coords1_list),
466
+ run_num,
467
+ )
468
+ coordinates_saved = True
469
+ successful_frames += 1
470
+ except FileNotFoundError as e:
471
+ if frame_idx <= 3:
472
+ logger.warning(f"Run {run_num}, Frame {frame_idx}: {e}")
473
+ break # Stop processing this run if vector files don't exist
474
+ except Exception as e:
475
+ if frame_idx <= 5:
476
+ logger.error(f"Run {run_num}, Frame {frame_idx} failed: {e}")
477
+ finally:
478
+ if self.progress_cb:
479
+ try:
480
+ self.progress_cb(
481
+ {
482
+ "camera_pair": [cam1_num, cam2_num],
483
+ "processed": frame_idx,
484
+ "successful": successful_frames,
485
+ "total": self.image_count,
486
+ "current_run": run_num,
487
+ }
488
+ )
489
+ except Exception:
490
+ pass
491
+
492
+ logger.info(f"Run {run_num}: {successful_frames} successful frames")
493
+ total_successful_frames += successful_frames
494
+
495
+ logger.info(
496
+ f"All runs completed: {total_successful_frames} total successful frames"
497
+ )
498
+ summary_data = {
499
+ "stereo_calibration": stereo_data_sanitized,
500
+ "reconstruction_summary": {
501
+ "total_frames_processed": total_successful_frames,
502
+ "total_frames_attempted": self.image_count,
503
+ "camera_pair": [cam1_num, cam2_num],
504
+ "output_camera": output_cam,
505
+ "output_directory": str(output_dir),
506
+ "configuration": {
507
+ "max_correspondence_distance": self.max_distance,
508
+ "min_triangulation_angle": self.min_angle,
509
+ "vector_pattern": self.vector_pattern,
510
+ "type_name": self.type_name,
511
+ "image_count": self.image_count,
512
+ },
513
+ "timestamp": datetime.now().isoformat(),
514
+ },
515
+ }
516
+ summary_file = output_dir / "stereo_reconstruction_summary.mat"
517
+ savemat(str(summary_file), summary_data)
518
+
519
+ def run(self):
520
+ for cam1_num, cam2_num in self.camera_pairs:
521
+ try:
522
+ self.process_camera_pair(cam1_num, cam2_num)
523
+ except Exception as e:
524
+ logger.error(
525
+ f"Reconstruction failed for pair ({cam1_num},{cam2_num}): {e}"
526
+ )
527
+
528
+
529
+ def main():
530
+ reconstructor = StereoReconstructor(
531
+ base_dir=BASE_DIR,
532
+ camera_pairs=CAMERA_PAIRS,
533
+ image_count=IMAGE_COUNT,
534
+ vector_pattern=VECTOR_PATTERN,
535
+ type_name=TYPE_NAME,
536
+ max_distance=MAX_CORRESPONDENCE_DISTANCE,
537
+ min_angle=MIN_TRIANGULATION_ANGLE,
538
+ dt=DT,
539
+ )
540
+ reconstructor.run()
541
+
542
+
543
+ if __name__ == "__main__":
544
+ main()
pivtools_gui/utils.py ADDED
@@ -0,0 +1,63 @@
1
+ """Common utility helpers shared across blueprints.
2
+
3
+ Centralizes small duplicated snippets so updates (e.g. image encoding or
4
+ camera folder normalization) propagate consistently.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ from io import BytesIO
11
+ from typing import Union
12
+
13
+ import numpy as np
14
+ from loguru import logger
15
+ from PIL import Image
16
+
17
+
18
+ def camera_number(camera: Union[str, int]) -> int:
19
+ """Return the numeric camera id from a value like 1, "1", "Cam1".
20
+
21
+ Raises ValueError if it cannot parse a positive int.
22
+ """
23
+ if isinstance(camera, int):
24
+ return camera
25
+ s = str(camera).strip()
26
+ if s.lower().startswith("cam"):
27
+ s = s[3:]
28
+ try:
29
+ cam_int = int(s)
30
+ except (TypeError, ValueError):
31
+ logger.error(f"Invalid camera identifier (non-parsable): {camera!r}")
32
+ raise
33
+ if cam_int < 0:
34
+ raise ValueError("camera must be positive integer")
35
+ return cam_int
36
+
37
+
38
+ def camera_folder(camera: Union[str, int]) -> str:
39
+ """Return canonical folder name (e.g. Cam1) for a camera reference."""
40
+ return f"Cam{camera_number(camera)}"
41
+
42
+
43
+ def numpy_to_png_base64(arr: np.ndarray) -> str:
44
+ """Convert a numpy array (uint8 or convertible) to a base64 PNG string."""
45
+ if arr.dtype != np.uint8:
46
+ a = arr.astype(np.float32, copy=False)
47
+ if a.size:
48
+ mn = float(a.min())
49
+ mx = float(a.max())
50
+ if mx > mn:
51
+ a = (255 * (a - mn) / (mx - mn)).astype(np.uint8)
52
+ else:
53
+ # Completely flat -> black; log once so caller can trace
54
+ logger.debug("Flat image (min==max); producing black output")
55
+ a = np.zeros_like(a, dtype=np.uint8)
56
+ else:
57
+ logger.debug("Empty array; substituting 1x1 black pixel")
58
+ a = np.zeros((1, 1), dtype=np.uint8)
59
+ arr = a
60
+ img = Image.fromarray(arr)
61
+ buf = BytesIO()
62
+ img.save(buf, format="PNG")
63
+ return base64.b64encode(buf.getvalue()).decode("utf-8")