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.
- pivtools-0.1.3.dist-info/METADATA +222 -0
- pivtools-0.1.3.dist-info/RECORD +127 -0
- pivtools-0.1.3.dist-info/WHEEL +5 -0
- pivtools-0.1.3.dist-info/entry_points.txt +3 -0
- pivtools-0.1.3.dist-info/top_level.txt +3 -0
- pivtools_cli/__init__.py +5 -0
- pivtools_cli/_build_marker.c +25 -0
- pivtools_cli/_build_marker.cp311-win_amd64.pyd +0 -0
- pivtools_cli/cli.py +225 -0
- pivtools_cli/example.py +139 -0
- pivtools_cli/lib/PIV_2d_cross_correlate.c +334 -0
- pivtools_cli/lib/PIV_2d_cross_correlate.h +22 -0
- pivtools_cli/lib/common.h +36 -0
- pivtools_cli/lib/interp2custom.c +146 -0
- pivtools_cli/lib/interp2custom.h +48 -0
- pivtools_cli/lib/peak_locate_gsl.c +711 -0
- pivtools_cli/lib/peak_locate_gsl.h +40 -0
- pivtools_cli/lib/peak_locate_gsl_print.c +736 -0
- pivtools_cli/lib/peak_locate_lm.c +751 -0
- pivtools_cli/lib/peak_locate_lm.h +27 -0
- pivtools_cli/lib/xcorr.c +342 -0
- pivtools_cli/lib/xcorr.h +31 -0
- pivtools_cli/lib/xcorr_cache.c +78 -0
- pivtools_cli/lib/xcorr_cache.h +26 -0
- pivtools_cli/piv/interp2custom/interp2custom.py +69 -0
- pivtools_cli/piv/piv.py +240 -0
- pivtools_cli/piv/piv_backend/base.py +825 -0
- pivtools_cli/piv/piv_backend/cpu_instantaneous.py +1005 -0
- pivtools_cli/piv/piv_backend/factory.py +28 -0
- pivtools_cli/piv/piv_backend/gpu_instantaneous.py +15 -0
- pivtools_cli/piv/piv_backend/infilling.py +445 -0
- pivtools_cli/piv/piv_backend/outlier_detection.py +306 -0
- pivtools_cli/piv/piv_backend/profile_cpu_instantaneous.py +230 -0
- pivtools_cli/piv/piv_result.py +40 -0
- pivtools_cli/piv/save_results.py +342 -0
- pivtools_cli/piv_cluster/cluster.py +108 -0
- pivtools_cli/preprocessing/filters.py +399 -0
- pivtools_cli/preprocessing/preprocess.py +79 -0
- pivtools_cli/tests/helpers.py +107 -0
- pivtools_cli/tests/instantaneous_piv/test_piv_integration.py +167 -0
- pivtools_cli/tests/instantaneous_piv/test_piv_integration_multi.py +553 -0
- pivtools_cli/tests/preprocessing/test_filters.py +41 -0
- pivtools_core/__init__.py +5 -0
- pivtools_core/config.py +703 -0
- pivtools_core/config.yaml +135 -0
- pivtools_core/image_handling/__init__.py +0 -0
- pivtools_core/image_handling/load_images.py +464 -0
- pivtools_core/image_handling/readers/__init__.py +53 -0
- pivtools_core/image_handling/readers/generic_readers.py +50 -0
- pivtools_core/image_handling/readers/lavision_reader.py +190 -0
- pivtools_core/image_handling/readers/registry.py +24 -0
- pivtools_core/paths.py +49 -0
- pivtools_core/vector_loading.py +248 -0
- pivtools_gui/__init__.py +3 -0
- pivtools_gui/app.py +687 -0
- pivtools_gui/calibration/__init__.py +0 -0
- pivtools_gui/calibration/app/__init__.py +0 -0
- pivtools_gui/calibration/app/views.py +1186 -0
- pivtools_gui/calibration/calibration_planar/planar_calibration_production.py +570 -0
- pivtools_gui/calibration/vector_calibration_production.py +544 -0
- pivtools_gui/config.py +703 -0
- pivtools_gui/image_handling/__init__.py +0 -0
- pivtools_gui/image_handling/load_images.py +464 -0
- pivtools_gui/image_handling/readers/__init__.py +53 -0
- pivtools_gui/image_handling/readers/generic_readers.py +50 -0
- pivtools_gui/image_handling/readers/lavision_reader.py +190 -0
- pivtools_gui/image_handling/readers/registry.py +24 -0
- pivtools_gui/masking/__init__.py +0 -0
- pivtools_gui/masking/app/__init__.py +0 -0
- pivtools_gui/masking/app/views.py +123 -0
- pivtools_gui/paths.py +49 -0
- pivtools_gui/piv_runner.py +261 -0
- pivtools_gui/pivtools.py +58 -0
- pivtools_gui/plotting/__init__.py +0 -0
- pivtools_gui/plotting/app/__init__.py +0 -0
- pivtools_gui/plotting/app/views.py +1671 -0
- pivtools_gui/plotting/plot_maker.py +220 -0
- pivtools_gui/post_processing/POD/__init__.py +0 -0
- pivtools_gui/post_processing/POD/app/__init__.py +0 -0
- pivtools_gui/post_processing/POD/app/views.py +647 -0
- pivtools_gui/post_processing/POD/pod_decompose.py +979 -0
- pivtools_gui/post_processing/POD/views.py +1096 -0
- pivtools_gui/post_processing/__init__.py +0 -0
- pivtools_gui/static/404.html +1 -0
- pivtools_gui/static/_next/static/chunks/117-d5793c8e79de5511.js +2 -0
- pivtools_gui/static/_next/static/chunks/484-cfa8b9348ce4f00e.js +1 -0
- pivtools_gui/static/_next/static/chunks/869-320a6b9bdafbb6d3.js +1 -0
- pivtools_gui/static/_next/static/chunks/app/_not-found/page-12f067ceb7415e55.js +1 -0
- pivtools_gui/static/_next/static/chunks/app/layout-b907d5f31ac82e9d.js +1 -0
- pivtools_gui/static/_next/static/chunks/app/page-334cc4e8444cde2f.js +1 -0
- pivtools_gui/static/_next/static/chunks/fd9d1056-ad15f396ddf9b7e5.js +1 -0
- pivtools_gui/static/_next/static/chunks/framework-f66176bb897dc684.js +1 -0
- pivtools_gui/static/_next/static/chunks/main-a1b3ced4d5f6d998.js +1 -0
- pivtools_gui/static/_next/static/chunks/main-app-8a63c6f5e7baee11.js +1 -0
- pivtools_gui/static/_next/static/chunks/pages/_app-72b849fbd24ac258.js +1 -0
- pivtools_gui/static/_next/static/chunks/pages/_error-7ba65e1336b92748.js +1 -0
- pivtools_gui/static/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- pivtools_gui/static/_next/static/chunks/webpack-4a8ca7c99e9bb3d8.js +1 -0
- pivtools_gui/static/_next/static/css/7d3f2337d7ea12a5.css +3 -0
- pivtools_gui/static/_next/static/vQeR20OUdSSKlK4vukC4q/_buildManifest.js +1 -0
- pivtools_gui/static/_next/static/vQeR20OUdSSKlK4vukC4q/_ssgManifest.js +1 -0
- pivtools_gui/static/file.svg +1 -0
- pivtools_gui/static/globe.svg +1 -0
- pivtools_gui/static/grid.svg +8 -0
- pivtools_gui/static/index.html +1 -0
- pivtools_gui/static/index.txt +8 -0
- pivtools_gui/static/next.svg +1 -0
- pivtools_gui/static/vercel.svg +1 -0
- pivtools_gui/static/window.svg +1 -0
- pivtools_gui/stereo_reconstruction/__init__.py +0 -0
- pivtools_gui/stereo_reconstruction/app/__init__.py +0 -0
- pivtools_gui/stereo_reconstruction/app/views.py +1985 -0
- pivtools_gui/stereo_reconstruction/stereo_calibration_production.py +606 -0
- pivtools_gui/stereo_reconstruction/stereo_reconstruction_production.py +544 -0
- pivtools_gui/utils.py +63 -0
- pivtools_gui/vector_loading.py +248 -0
- pivtools_gui/vector_merging/__init__.py +1 -0
- pivtools_gui/vector_merging/app/__init__.py +1 -0
- pivtools_gui/vector_merging/app/views.py +759 -0
- pivtools_gui/vector_statistics/app/__init__.py +1 -0
- pivtools_gui/vector_statistics/app/views.py +710 -0
- pivtools_gui/vector_statistics/ensemble_statistics.py +49 -0
- pivtools_gui/vector_statistics/instantaneous_statistics.py +311 -0
- pivtools_gui/video_maker/__init__.py +0 -0
- pivtools_gui/video_maker/app/__init__.py +0 -0
- pivtools_gui/video_maker/app/views.py +436 -0
- 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")
|