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,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()
|