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