sensorcal 0.1.1__py3-none-any.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.
- sensorcal/__init__.py +7 -0
- sensorcal/app.py +854 -0
- sensorcal/cli.py +26 -0
- sensorcal-0.1.1.dist-info/METADATA +155 -0
- sensorcal-0.1.1.dist-info/RECORD +8 -0
- sensorcal-0.1.1.dist-info/WHEEL +5 -0
- sensorcal-0.1.1.dist-info/entry_points.txt +2 -0
- sensorcal-0.1.1.dist-info/top_level.txt +1 -0
sensorcal/__init__.py
ADDED
sensorcal/app.py
ADDED
|
@@ -0,0 +1,854 @@
|
|
|
1
|
+
"""SensorCal app: interactive LiDAR-to-camera calibration tool (Tkinter GUI)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Iterable, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
import cv2
|
|
11
|
+
import numpy as np
|
|
12
|
+
import open3d as o3d
|
|
13
|
+
import yaml
|
|
14
|
+
from PIL import Image, ImageTk
|
|
15
|
+
import tkinter as tk
|
|
16
|
+
from tkinter import ttk
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
SAMPLE_IMAGE_FOLDER = os.path.join(os.path.dirname(__file__), "..", "..", "samples", "images")
|
|
20
|
+
SAMPLE_PCD_FOLDER = os.path.join(os.path.dirname(__file__), "..", "..", "samples", "pcds")
|
|
21
|
+
SAMPLE_INTRINSIC_K = [554.26, 0.0, 960.0, 0.0, 554.26, 540.0, 0.0, 0.0, 1.0]
|
|
22
|
+
SAMPLE_LIDAR_CAMERA = [
|
|
23
|
+
0.5,
|
|
24
|
+
-0.866,
|
|
25
|
+
-0.0,
|
|
26
|
+
-0.825,
|
|
27
|
+
-0.0,
|
|
28
|
+
-0.0,
|
|
29
|
+
-1.0,
|
|
30
|
+
-0.6,
|
|
31
|
+
0.866,
|
|
32
|
+
0.5,
|
|
33
|
+
-0.0,
|
|
34
|
+
-1.429,
|
|
35
|
+
0.0,
|
|
36
|
+
0.0,
|
|
37
|
+
0.0,
|
|
38
|
+
1.0,
|
|
39
|
+
]
|
|
40
|
+
MAX_TRANSLATION = 2.0
|
|
41
|
+
MAX_ROTATION_DEG = 30.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SensorCalApp:
|
|
45
|
+
"""Interactive calibration app with Tkinter GUI.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
config_path: Optional YAML config path containing intrinsics/extrinsics.
|
|
49
|
+
intrinsic_k: Optional 3x3 intrinsics (flat list of 9 values, row-major).
|
|
50
|
+
lidar_camera: Optional 4x4 extrinsics (flat list of 16 values, row-major).
|
|
51
|
+
image_path: Optional single image path.
|
|
52
|
+
pcd_path: Optional single PCD path.
|
|
53
|
+
image_folder: Optional folder containing images.
|
|
54
|
+
pcd_folder: Optional folder containing PCDs.
|
|
55
|
+
save_file: Output YAML path for saved transforms (TXT is created alongside).
|
|
56
|
+
point_size: Rendered point size in pixels.
|
|
57
|
+
point_color: Fallback RGB color when depth coloring is disabled (currently unused).
|
|
58
|
+
depth_colormap: If True, color points by depth (always on in UI).
|
|
59
|
+
|
|
60
|
+
Notes:
|
|
61
|
+
- Provide either (image_path + pcd_path) or (image_folder + pcd_folder).
|
|
62
|
+
- When using folders, files are paired by sorted filename order.
|
|
63
|
+
- Calling `process()` starts the GUI event loop (blocks until window closes).
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
*,
|
|
69
|
+
config_path: Optional[str],
|
|
70
|
+
intrinsic_k: Optional[Iterable[float]],
|
|
71
|
+
lidar_camera: Optional[Iterable[float]],
|
|
72
|
+
image_path: Optional[str],
|
|
73
|
+
pcd_path: Optional[str],
|
|
74
|
+
image_folder: Optional[str],
|
|
75
|
+
pcd_folder: Optional[str],
|
|
76
|
+
save_file: str,
|
|
77
|
+
point_size: int = 2,
|
|
78
|
+
point_color: Tuple[int, int, int] = (0, 255, 0),
|
|
79
|
+
depth_colormap: bool = True,
|
|
80
|
+
) -> None:
|
|
81
|
+
self.config = self._load_config(config_path) if config_path else {}
|
|
82
|
+
|
|
83
|
+
self.img_folder = image_folder or self._get_config_path("img_folder")
|
|
84
|
+
self.pcd_folder = pcd_folder or self._get_config_path("pcd_folder")
|
|
85
|
+
|
|
86
|
+
self.Tr_lidar_to_cam = self._get_transform(lidar_camera)
|
|
87
|
+
self.K = self._get_intrinsics(intrinsic_k)
|
|
88
|
+
|
|
89
|
+
self.image_path = image_path
|
|
90
|
+
self.pcd_path = pcd_path
|
|
91
|
+
self.save_file = save_file
|
|
92
|
+
|
|
93
|
+
# Visualization parameters
|
|
94
|
+
self.point_size = point_size
|
|
95
|
+
self.point_color = point_color
|
|
96
|
+
self.depth_colormap = True if depth_colormap else True
|
|
97
|
+
self.error_heatmap = False
|
|
98
|
+
self.density_map = True
|
|
99
|
+
self.depth_legend = True
|
|
100
|
+
self.overlay_alpha = 0.7
|
|
101
|
+
|
|
102
|
+
# State
|
|
103
|
+
self.current_image = None
|
|
104
|
+
self.current_points = None
|
|
105
|
+
self.edge_distance = None
|
|
106
|
+
self.original_transform = None
|
|
107
|
+
self.base_transform = None
|
|
108
|
+
|
|
109
|
+
# Pairs
|
|
110
|
+
self.pairs = []
|
|
111
|
+
self.current_index = 0
|
|
112
|
+
|
|
113
|
+
# Tkinter UI
|
|
114
|
+
self.root = None
|
|
115
|
+
self.image_label = None
|
|
116
|
+
self._photo = None
|
|
117
|
+
self.pair_label_var = None
|
|
118
|
+
self.tx_var = None
|
|
119
|
+
self.ty_var = None
|
|
120
|
+
self.tz_var = None
|
|
121
|
+
self.roll_var = None
|
|
122
|
+
self.pitch_var = None
|
|
123
|
+
self.yaw_var = None
|
|
124
|
+
self.alpha_var = None
|
|
125
|
+
self.point_size_var = None
|
|
126
|
+
self.density_var = None
|
|
127
|
+
self.legend_var = None
|
|
128
|
+
|
|
129
|
+
self.original_text = None
|
|
130
|
+
self.current_text = None
|
|
131
|
+
self._last_size = (0, 0)
|
|
132
|
+
self._resize_job = None
|
|
133
|
+
self.is_dark = True
|
|
134
|
+
self.style = None
|
|
135
|
+
self.dark_bg = "#1e1f24"
|
|
136
|
+
self.dark_panel = "#2a2c33"
|
|
137
|
+
self.dark_text = "#e6e6e6"
|
|
138
|
+
self.dark_accent = "#3a7bd5"
|
|
139
|
+
self.light_bg = "#f1f2f4"
|
|
140
|
+
self.light_panel = "#ffffff"
|
|
141
|
+
self.light_text = "#1f1f1f"
|
|
142
|
+
self.font_base = ("Segoe UI", 11)
|
|
143
|
+
self.font_small = ("Segoe UI", 10)
|
|
144
|
+
self.font_mono = ("Consolas", 10)
|
|
145
|
+
|
|
146
|
+
def _load_config(self, config_path: str) -> dict:
|
|
147
|
+
with open(config_path, "r") as config_file:
|
|
148
|
+
return yaml.safe_load(config_file) or {}
|
|
149
|
+
|
|
150
|
+
def _get_config_path(self, key: str) -> Optional[str]:
|
|
151
|
+
return (self.config.get("path") or {}).get(key)
|
|
152
|
+
|
|
153
|
+
def _get_transform(self, lidar_camera: Optional[Iterable[float]]) -> np.ndarray:
|
|
154
|
+
raw = lidar_camera if lidar_camera is not None else (
|
|
155
|
+
self.config.get("transform") or {}
|
|
156
|
+
).get("lidar_camera")
|
|
157
|
+
if raw is None:
|
|
158
|
+
return np.eye(4)
|
|
159
|
+
return self._reshape_matrix(raw, (4, 4), "lidar_camera")
|
|
160
|
+
|
|
161
|
+
def _get_intrinsics(self, intrinsic_k: Optional[Iterable[float]]) -> np.ndarray:
|
|
162
|
+
raw = intrinsic_k if intrinsic_k is not None else (
|
|
163
|
+
self.config.get("transform") or {}
|
|
164
|
+
).get("intrinsic_k")
|
|
165
|
+
if raw is None:
|
|
166
|
+
raise ValueError(
|
|
167
|
+
"Missing camera intrinsics. Provide --intrinsic-k or transform.intrinsic_k in config."
|
|
168
|
+
)
|
|
169
|
+
return self._reshape_matrix(raw, (3, 3), "intrinsic_k")
|
|
170
|
+
|
|
171
|
+
def _reshape_matrix(self, raw: Iterable[float], shape: Tuple[int, int], name: str) -> np.ndarray:
|
|
172
|
+
arr = np.array(list(raw), dtype=float)
|
|
173
|
+
expected = shape[0] * shape[1]
|
|
174
|
+
if arr.size != expected:
|
|
175
|
+
raise ValueError(f"{name} must have {expected} values, got {arr.size}.")
|
|
176
|
+
return arr.reshape(shape)
|
|
177
|
+
|
|
178
|
+
def load_pcd(self, file_path: str) -> np.ndarray:
|
|
179
|
+
pcd = o3d.io.read_point_cloud(file_path)
|
|
180
|
+
points = np.asarray(pcd.points)
|
|
181
|
+
if points.size == 0:
|
|
182
|
+
raise ValueError(f"Point cloud is empty: {file_path}")
|
|
183
|
+
return points
|
|
184
|
+
|
|
185
|
+
def project_points_to_image(
|
|
186
|
+
self,
|
|
187
|
+
points: np.ndarray,
|
|
188
|
+
camera_matrix: Optional[np.ndarray] = None,
|
|
189
|
+
lidar_to_cam: Optional[np.ndarray] = None,
|
|
190
|
+
) -> Tuple[np.ndarray, np.ndarray]:
|
|
191
|
+
"""Project 3D LiDAR points into image space.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
points: (N, 3) array in LiDAR frame.
|
|
195
|
+
camera_matrix: Optional 3x3 intrinsics override.
|
|
196
|
+
lidar_to_cam: Optional 4x4 extrinsics override.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
points_2d: (N, 2) image coordinates.
|
|
200
|
+
points_3d: (N, 3) array of [u, v, depth] where depth is Z in camera frame.
|
|
201
|
+
"""
|
|
202
|
+
K = np.array(camera_matrix) if camera_matrix is not None else self.K
|
|
203
|
+
Tr = np.array(lidar_to_cam) if lidar_to_cam is not None else self.Tr_lidar_to_cam
|
|
204
|
+
|
|
205
|
+
assert points.ndim == 2 and points.shape[1] == 3, f"Expected [N x 3] points, got {points.shape}"
|
|
206
|
+
assert K.shape == (3, 3), f"Expected 3x3 intrinsic matrix, got {K.shape}"
|
|
207
|
+
assert Tr.shape == (4, 4), f"Transformation matrix should be [4 x 4], got {Tr.shape}"
|
|
208
|
+
|
|
209
|
+
points_homog = np.hstack((points, np.ones((points.shape[0], 1), dtype=np.float32)))
|
|
210
|
+
points_cam = (Tr @ points_homog.T).T[:, :3]
|
|
211
|
+
|
|
212
|
+
points_proj = K @ points_cam.T
|
|
213
|
+
points_2d = points_proj[:2, :] / points_proj[2, :]
|
|
214
|
+
|
|
215
|
+
points_3d = np.column_stack([points_2d.T, points_cam[:, 2]])
|
|
216
|
+
return points_2d.T, points_3d
|
|
217
|
+
|
|
218
|
+
def get_depth_color(self, depth: float, min_depth: float, max_depth: float) -> Tuple[int, int, int]:
|
|
219
|
+
norm_depth = np.clip((depth - min_depth) / (max_depth - min_depth + 1e-6), 0, 1)
|
|
220
|
+
if norm_depth < 0.25:
|
|
221
|
+
r = 0
|
|
222
|
+
g = int(255 * (norm_depth / 0.25))
|
|
223
|
+
b = 255
|
|
224
|
+
elif norm_depth < 0.5:
|
|
225
|
+
r = 0
|
|
226
|
+
g = 255
|
|
227
|
+
b = int(255 * (1 - (norm_depth - 0.25) / 0.25))
|
|
228
|
+
elif norm_depth < 0.75:
|
|
229
|
+
r = int(255 * ((norm_depth - 0.5) / 0.25))
|
|
230
|
+
g = 255
|
|
231
|
+
b = 0
|
|
232
|
+
else:
|
|
233
|
+
r = 255
|
|
234
|
+
g = int(255 * (1 - (norm_depth - 0.75) / 0.25))
|
|
235
|
+
b = 0
|
|
236
|
+
return (b, g, r)
|
|
237
|
+
|
|
238
|
+
def _compute_edge_distance(self, image: np.ndarray) -> np.ndarray:
|
|
239
|
+
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
|
240
|
+
edges = cv2.Canny(gray, 80, 160)
|
|
241
|
+
edges_inv = cv2.bitwise_not(edges)
|
|
242
|
+
return cv2.distanceTransform(edges_inv, cv2.DIST_L2, 3)
|
|
243
|
+
|
|
244
|
+
def _draw_colorbar(
|
|
245
|
+
self,
|
|
246
|
+
image: np.ndarray,
|
|
247
|
+
min_val: float,
|
|
248
|
+
max_val: float,
|
|
249
|
+
label: str,
|
|
250
|
+
x: int = 10,
|
|
251
|
+
y: int = 60,
|
|
252
|
+
height: int = 120,
|
|
253
|
+
width: int = 10,
|
|
254
|
+
) -> None:
|
|
255
|
+
bar = np.linspace(1.0, 0.0, height, dtype=np.float32).reshape(height, 1)
|
|
256
|
+
bar = (bar * 255).astype(np.uint8)
|
|
257
|
+
bar = cv2.applyColorMap(bar, cv2.COLORMAP_TURBO)
|
|
258
|
+
bar = cv2.resize(bar, (width, height), interpolation=cv2.INTER_NEAREST)
|
|
259
|
+
image[y : y + height, x : x + width] = bar
|
|
260
|
+
cv2.putText(image, f"{max_val:.2f}", (x + width + 6, y + 10),
|
|
261
|
+
cv2.FONT_HERSHEY_SIMPLEX, 0.4, (255, 255, 255), 1, cv2.LINE_AA)
|
|
262
|
+
cv2.putText(image, f"{min_val:.2f}", (x + width + 6, y + height),
|
|
263
|
+
cv2.FONT_HERSHEY_SIMPLEX, 0.4, (255, 255, 255), 1, cv2.LINE_AA)
|
|
264
|
+
cv2.putText(image, label, (x, y - 8),
|
|
265
|
+
cv2.FONT_HERSHEY_SIMPLEX, 0.45, (255, 255, 255), 1, cv2.LINE_AA)
|
|
266
|
+
|
|
267
|
+
def _render_density_map(self, image: np.ndarray, points_2d: np.ndarray) -> np.ndarray:
|
|
268
|
+
h, w = image.shape[:2]
|
|
269
|
+
density = np.zeros((h, w), dtype=np.float32)
|
|
270
|
+
xs = np.clip(points_2d[:, 0].astype(int), 0, w - 1)
|
|
271
|
+
ys = np.clip(points_2d[:, 1].astype(int), 0, h - 1)
|
|
272
|
+
np.add.at(density, (ys, xs), 1.0)
|
|
273
|
+
density = cv2.GaussianBlur(density, (0, 0), 3.0)
|
|
274
|
+
if density.max() > 0:
|
|
275
|
+
density = density / density.max()
|
|
276
|
+
heat = cv2.applyColorMap((density * 255).astype(np.uint8), cv2.COLORMAP_TURBO)
|
|
277
|
+
return cv2.addWeighted(image, 0.7, heat, 0.3, 0)
|
|
278
|
+
|
|
279
|
+
def overlay_points_on_image(
|
|
280
|
+
self, image: np.ndarray, points_2d: np.ndarray, points_3d: np.ndarray
|
|
281
|
+
) -> np.ndarray:
|
|
282
|
+
"""Render projected points on the image.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
image: BGR image.
|
|
286
|
+
points_2d: (N, 2) image coordinates.
|
|
287
|
+
points_3d: (N, 3) [u, v, depth] array (depth used for coloring).
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
Image with points overlaid.
|
|
291
|
+
"""
|
|
292
|
+
overlay = image.copy()
|
|
293
|
+
h, w = image.shape[:2]
|
|
294
|
+
valid_mask = (
|
|
295
|
+
(points_2d[:, 0] >= 0) & (points_2d[:, 0] < w) &
|
|
296
|
+
(points_2d[:, 1] >= 0) & (points_2d[:, 1] < h) &
|
|
297
|
+
(points_3d[:, 2] > 0)
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
valid_points = points_2d[valid_mask]
|
|
301
|
+
valid_depths = points_3d[valid_mask, 2]
|
|
302
|
+
|
|
303
|
+
if len(valid_depths) == 0:
|
|
304
|
+
return overlay
|
|
305
|
+
|
|
306
|
+
min_depth = np.percentile(valid_depths, 5)
|
|
307
|
+
max_depth = np.percentile(valid_depths, 95)
|
|
308
|
+
|
|
309
|
+
if self.density_map:
|
|
310
|
+
overlay = self._render_density_map(overlay, valid_points)
|
|
311
|
+
|
|
312
|
+
colors = np.array(
|
|
313
|
+
[self.get_depth_color(d, min_depth, max_depth) for d in valid_depths],
|
|
314
|
+
dtype=np.uint8,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
for (u, v), color in zip(valid_points, colors):
|
|
318
|
+
if 0 <= u < w and 0 <= v < h:
|
|
319
|
+
b, g, r = int(color[0]), int(color[1]), int(color[2])
|
|
320
|
+
cv2.circle(overlay, (int(u), int(v)), self.point_size + 1, (0, 0, 0), -1)
|
|
321
|
+
cv2.circle(overlay, (int(u), int(v)), self.point_size, (b, g, r), -1)
|
|
322
|
+
|
|
323
|
+
result = cv2.addWeighted(overlay, self.overlay_alpha, image, 1 - self.overlay_alpha, 0)
|
|
324
|
+
|
|
325
|
+
info_text = [
|
|
326
|
+
f"Points visible: {len(valid_points)}/{len(points_2d)}",
|
|
327
|
+
f"Depth range: {min_depth:.2f}m - {max_depth:.2f}m",
|
|
328
|
+
]
|
|
329
|
+
|
|
330
|
+
y_offset = 30
|
|
331
|
+
for text in info_text:
|
|
332
|
+
cv2.putText(result, text, (10, y_offset), cv2.FONT_HERSHEY_SIMPLEX,
|
|
333
|
+
0.6, (255, 255, 255), 2, cv2.LINE_AA)
|
|
334
|
+
cv2.putText(result, text, (10, y_offset), cv2.FONT_HERSHEY_SIMPLEX,
|
|
335
|
+
0.6, (0, 0, 0), 1, cv2.LINE_AA)
|
|
336
|
+
y_offset += 25
|
|
337
|
+
|
|
338
|
+
if self.depth_legend:
|
|
339
|
+
x = w - 60
|
|
340
|
+
self._draw_colorbar(result, min_depth, max_depth, "Depth (m)", x=x)
|
|
341
|
+
|
|
342
|
+
return result
|
|
343
|
+
|
|
344
|
+
def create_adjustment_matrix(self, adjustment: str, value: float) -> np.ndarray:
|
|
345
|
+
mat = np.eye(4)
|
|
346
|
+
if adjustment == "tx":
|
|
347
|
+
mat[0, 3] = value
|
|
348
|
+
elif adjustment == "ty":
|
|
349
|
+
mat[1, 3] = value
|
|
350
|
+
elif adjustment == "tz":
|
|
351
|
+
mat[2, 3] = value
|
|
352
|
+
elif adjustment == "rx":
|
|
353
|
+
c, s = np.cos(value), np.sin(value)
|
|
354
|
+
mat[:3, :3] = [[1, 0, 0], [0, c, -s], [0, s, c]]
|
|
355
|
+
elif adjustment == "ry":
|
|
356
|
+
c, s = np.cos(value), np.sin(value)
|
|
357
|
+
mat[:3, :3] = [[c, 0, s], [0, 1, 0], [-s, 0, c]]
|
|
358
|
+
elif adjustment == "rz":
|
|
359
|
+
c, s = np.cos(value), np.sin(value)
|
|
360
|
+
mat[:3, :3] = [[c, -s, 0], [s, c, 0], [0, 0, 1]]
|
|
361
|
+
return mat
|
|
362
|
+
|
|
363
|
+
def _apply_slider_transform(self) -> None:
|
|
364
|
+
if self.base_transform is None:
|
|
365
|
+
return
|
|
366
|
+
tx = self.tx_var.get()
|
|
367
|
+
ty = self.ty_var.get()
|
|
368
|
+
tz = self.tz_var.get()
|
|
369
|
+
roll = np.deg2rad(self.roll_var.get())
|
|
370
|
+
pitch = np.deg2rad(self.pitch_var.get())
|
|
371
|
+
yaw = np.deg2rad(self.yaw_var.get())
|
|
372
|
+
|
|
373
|
+
delta = self.create_adjustment_matrix("tx", tx)
|
|
374
|
+
delta = self.create_adjustment_matrix("ty", ty) @ delta
|
|
375
|
+
delta = self.create_adjustment_matrix("tz", tz) @ delta
|
|
376
|
+
delta = self.create_adjustment_matrix("rx", roll) @ delta
|
|
377
|
+
delta = self.create_adjustment_matrix("ry", pitch) @ delta
|
|
378
|
+
delta = self.create_adjustment_matrix("rz", yaw) @ delta
|
|
379
|
+
|
|
380
|
+
self.Tr_lidar_to_cam = delta @ self.base_transform
|
|
381
|
+
|
|
382
|
+
def _update_transform_text(self) -> None:
|
|
383
|
+
if self.original_text is None or self.current_text is None:
|
|
384
|
+
return
|
|
385
|
+
self.original_text.config(state=tk.NORMAL)
|
|
386
|
+
self.current_text.config(state=tk.NORMAL)
|
|
387
|
+
self.original_text.delete("1.0", tk.END)
|
|
388
|
+
self.current_text.delete("1.0", tk.END)
|
|
389
|
+
if self.original_transform is not None:
|
|
390
|
+
for row in self.original_transform:
|
|
391
|
+
self.original_text.insert(tk.END, " ".join(f"{v:8.3f}" for v in row) + "\n")
|
|
392
|
+
for row in self.Tr_lidar_to_cam:
|
|
393
|
+
self.current_text.insert(tk.END, " ".join(f"{v:8.3f}" for v in row) + "\n")
|
|
394
|
+
self.original_text.config(state=tk.DISABLED)
|
|
395
|
+
self.current_text.config(state=tk.DISABLED)
|
|
396
|
+
|
|
397
|
+
def update_display(self) -> None:
|
|
398
|
+
if self.current_image is None or self.current_points is None:
|
|
399
|
+
return
|
|
400
|
+
self._apply_slider_transform()
|
|
401
|
+
points_2d, points_3d = self.project_points_to_image(self.current_points)
|
|
402
|
+
result = self.overlay_points_on_image(self.current_image, points_2d, points_3d)
|
|
403
|
+
rgb = cv2.cvtColor(result, cv2.COLOR_BGR2RGB)
|
|
404
|
+
img = Image.fromarray(rgb)
|
|
405
|
+
|
|
406
|
+
target_w = self.image_label.winfo_width()
|
|
407
|
+
target_h = self.image_label.winfo_height()
|
|
408
|
+
if target_w > 10 and target_h > 10:
|
|
409
|
+
scale = min(target_w / img.width, target_h / img.height)
|
|
410
|
+
new_w = max(1, int(img.width * scale))
|
|
411
|
+
new_h = max(1, int(img.height * scale))
|
|
412
|
+
if (new_w, new_h) != self._last_size:
|
|
413
|
+
self._last_size = (new_w, new_h)
|
|
414
|
+
img = img.resize((new_w, new_h), Image.BILINEAR)
|
|
415
|
+
|
|
416
|
+
self._photo = ImageTk.PhotoImage(image=img)
|
|
417
|
+
self.image_label.configure(image=self._photo)
|
|
418
|
+
self._update_transform_text()
|
|
419
|
+
|
|
420
|
+
def save_transformation(self, pair_number: int) -> None:
|
|
421
|
+
if os.path.exists(self.save_file):
|
|
422
|
+
with open(self.save_file, "r") as file:
|
|
423
|
+
data = yaml.safe_load(file) or {}
|
|
424
|
+
else:
|
|
425
|
+
data = {}
|
|
426
|
+
|
|
427
|
+
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
|
428
|
+
entry_name = f"pair_{pair_number:03d}_{timestamp}"
|
|
429
|
+
|
|
430
|
+
new_entry = {
|
|
431
|
+
entry_name: {
|
|
432
|
+
"pair_number": pair_number,
|
|
433
|
+
"timestamp": timestamp,
|
|
434
|
+
"Tr_lidar_to_cam": self.Tr_lidar_to_cam.tolist(),
|
|
435
|
+
"translation": {
|
|
436
|
+
"x": float(self.Tr_lidar_to_cam[0, 3]),
|
|
437
|
+
"y": float(self.Tr_lidar_to_cam[1, 3]),
|
|
438
|
+
"z": float(self.Tr_lidar_to_cam[2, 3]),
|
|
439
|
+
},
|
|
440
|
+
"rotation_matrix": self.Tr_lidar_to_cam[:3, :3].tolist(),
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
data.update(new_entry)
|
|
445
|
+
|
|
446
|
+
with open(self.save_file, "w") as file:
|
|
447
|
+
yaml.dump(data, file, default_flow_style=False)
|
|
448
|
+
|
|
449
|
+
print(f"Transformation saved: {entry_name} -> {self.save_file}")
|
|
450
|
+
|
|
451
|
+
def save_transform_txt(self, pair_number: int) -> None:
|
|
452
|
+
base, _ext = os.path.splitext(self.save_file)
|
|
453
|
+
txt_path = f"{base}.txt"
|
|
454
|
+
with open(txt_path, "a", encoding="ascii") as f:
|
|
455
|
+
f.write(f"pair {pair_number}\n")
|
|
456
|
+
f.write("K:\n")
|
|
457
|
+
for row in self.K:
|
|
458
|
+
f.write(" " + " ".join(f"{v:.6f}" for v in row) + "\n")
|
|
459
|
+
f.write("Tr_lidar_to_cam:\n")
|
|
460
|
+
for row in self.Tr_lidar_to_cam:
|
|
461
|
+
f.write(" " + " ".join(f"{v:.6f}" for v in row) + "\n")
|
|
462
|
+
f.write("\n")
|
|
463
|
+
print(f"Text saved: {txt_path}")
|
|
464
|
+
|
|
465
|
+
def _iter_pairs(self) -> Iterable[Tuple[str, str]]:
|
|
466
|
+
if self.image_path and self.pcd_path:
|
|
467
|
+
yield self.image_path, self.pcd_path
|
|
468
|
+
return
|
|
469
|
+
|
|
470
|
+
if not self.img_folder or not self.pcd_folder:
|
|
471
|
+
raise ValueError("Provide --image and --pcd, or --image-folder and --pcd-folder.")
|
|
472
|
+
|
|
473
|
+
if not os.path.exists(self.img_folder) or not os.path.exists(self.pcd_folder):
|
|
474
|
+
raise ValueError(
|
|
475
|
+
f"Folders do not exist: image={self.img_folder}, pcd={self.pcd_folder}"
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
image_files = sorted(
|
|
479
|
+
f for f in os.listdir(self.img_folder) if f.lower().endswith((".png", ".jpg", ".jpeg"))
|
|
480
|
+
)
|
|
481
|
+
pcd_files = sorted(
|
|
482
|
+
f for f in os.listdir(self.pcd_folder) if f.lower().endswith(".pcd")
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
if not image_files or not pcd_files:
|
|
486
|
+
raise ValueError(
|
|
487
|
+
f"No files found. Images={len(image_files)}, PCDs={len(pcd_files)}"
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
num_pairs = min(len(image_files), len(pcd_files))
|
|
491
|
+
print(f"Found {num_pairs} pair(s): {len(image_files)} images, {len(pcd_files)} point clouds")
|
|
492
|
+
|
|
493
|
+
for i in range(num_pairs):
|
|
494
|
+
image_path = os.path.join(self.img_folder, image_files[i])
|
|
495
|
+
pcd_path = os.path.join(self.pcd_folder, pcd_files[i])
|
|
496
|
+
yield image_path, pcd_path
|
|
497
|
+
|
|
498
|
+
def _load_pair(self, index: int) -> None:
|
|
499
|
+
image_path, pcd_path = self.pairs[index]
|
|
500
|
+
self.current_image = cv2.imread(image_path)
|
|
501
|
+
if self.current_image is None:
|
|
502
|
+
raise ValueError(f"Could not read image: {image_path}")
|
|
503
|
+
self.current_points = self.load_pcd(pcd_path)
|
|
504
|
+
self.edge_distance = self._compute_edge_distance(self.current_image)
|
|
505
|
+
|
|
506
|
+
self.original_transform = self.Tr_lidar_to_cam.copy()
|
|
507
|
+
self.base_transform = self.Tr_lidar_to_cam.copy()
|
|
508
|
+
|
|
509
|
+
self.tx_var.set(0.0)
|
|
510
|
+
self.ty_var.set(0.0)
|
|
511
|
+
self.tz_var.set(0.0)
|
|
512
|
+
self.roll_var.set(0.0)
|
|
513
|
+
self.pitch_var.set(0.0)
|
|
514
|
+
self.yaw_var.set(0.0)
|
|
515
|
+
|
|
516
|
+
name = f"{os.path.basename(image_path)} | {os.path.basename(pcd_path)}"
|
|
517
|
+
self.pair_label_var.set(f"Pair {index + 1}/{len(self.pairs)}: {name}")
|
|
518
|
+
self.update_display()
|
|
519
|
+
|
|
520
|
+
def _on_save(self) -> None:
|
|
521
|
+
self.save_transformation(self.current_index + 1)
|
|
522
|
+
self.save_transform_txt(self.current_index + 1)
|
|
523
|
+
|
|
524
|
+
def _on_reset(self) -> None:
|
|
525
|
+
if self.original_transform is None:
|
|
526
|
+
return
|
|
527
|
+
self.Tr_lidar_to_cam = self.original_transform.copy()
|
|
528
|
+
self.base_transform = self.original_transform.copy()
|
|
529
|
+
self.tx_var.set(0.0)
|
|
530
|
+
self.ty_var.set(0.0)
|
|
531
|
+
self.tz_var.set(0.0)
|
|
532
|
+
self.roll_var.set(0.0)
|
|
533
|
+
self.pitch_var.set(0.0)
|
|
534
|
+
self.yaw_var.set(0.0)
|
|
535
|
+
self.update_display()
|
|
536
|
+
|
|
537
|
+
def _on_prev(self) -> None:
|
|
538
|
+
if self.current_index > 0:
|
|
539
|
+
self.current_index -= 1
|
|
540
|
+
self._load_pair(self.current_index)
|
|
541
|
+
|
|
542
|
+
def _on_next(self) -> None:
|
|
543
|
+
if self.current_index < len(self.pairs) - 1:
|
|
544
|
+
self.current_index += 1
|
|
545
|
+
self._load_pair(self.current_index)
|
|
546
|
+
|
|
547
|
+
def _on_alpha(self, _val: str) -> None:
|
|
548
|
+
self.overlay_alpha = self.alpha_var.get()
|
|
549
|
+
self.update_display()
|
|
550
|
+
|
|
551
|
+
def _on_point_size(self, _val: str) -> None:
|
|
552
|
+
self.point_size = self.point_size_var.get()
|
|
553
|
+
self.update_display()
|
|
554
|
+
|
|
555
|
+
def _on_toggle(self) -> None:
|
|
556
|
+
self.density_map = bool(self.density_var.get())
|
|
557
|
+
self.depth_legend = bool(self.legend_var.get())
|
|
558
|
+
self.update_display()
|
|
559
|
+
|
|
560
|
+
def _on_slider(self, _val: str) -> None:
|
|
561
|
+
self.update_display()
|
|
562
|
+
|
|
563
|
+
def _on_key(self, event: tk.Event) -> None:
|
|
564
|
+
key = event.keysym.lower()
|
|
565
|
+
if key == "a":
|
|
566
|
+
self.tx_var.set(max(-MAX_TRANSLATION, self.tx_var.get() - 0.001))
|
|
567
|
+
elif key == "d":
|
|
568
|
+
self.tx_var.set(min(MAX_TRANSLATION, self.tx_var.get() + 0.001))
|
|
569
|
+
elif key == "w":
|
|
570
|
+
self.ty_var.set(max(-MAX_TRANSLATION, self.ty_var.get() - 0.001))
|
|
571
|
+
elif key == "s":
|
|
572
|
+
self.ty_var.set(min(MAX_TRANSLATION, self.ty_var.get() + 0.001))
|
|
573
|
+
elif key == "q":
|
|
574
|
+
self.tz_var.set(max(-MAX_TRANSLATION, self.tz_var.get() - 0.001))
|
|
575
|
+
elif key == "e":
|
|
576
|
+
self.tz_var.set(min(MAX_TRANSLATION, self.tz_var.get() + 0.001))
|
|
577
|
+
elif key == "j":
|
|
578
|
+
self.roll_var.set(max(-MAX_ROTATION_DEG, self.roll_var.get() - 0.1))
|
|
579
|
+
elif key == "l":
|
|
580
|
+
self.roll_var.set(min(MAX_ROTATION_DEG, self.roll_var.get() + 0.1))
|
|
581
|
+
elif key == "i":
|
|
582
|
+
self.pitch_var.set(max(-MAX_ROTATION_DEG, self.pitch_var.get() - 0.1))
|
|
583
|
+
elif key == "k":
|
|
584
|
+
self.pitch_var.set(min(MAX_ROTATION_DEG, self.pitch_var.get() + 0.1))
|
|
585
|
+
elif key == "u":
|
|
586
|
+
self.yaw_var.set(max(-MAX_ROTATION_DEG, self.yaw_var.get() - 0.1))
|
|
587
|
+
elif key == "o":
|
|
588
|
+
self.yaw_var.set(min(MAX_ROTATION_DEG, self.yaw_var.get() + 0.1))
|
|
589
|
+
elif key == "r":
|
|
590
|
+
self._on_reset()
|
|
591
|
+
elif key == "p":
|
|
592
|
+
self._on_save()
|
|
593
|
+
elif key == "n":
|
|
594
|
+
self._on_next()
|
|
595
|
+
elif key == "bracketleft":
|
|
596
|
+
self.point_size_var.set(max(1, self.point_size_var.get() - 1))
|
|
597
|
+
elif key == "bracketright":
|
|
598
|
+
self.point_size_var.set(min(10, self.point_size_var.get() + 1))
|
|
599
|
+
elif key == "minus":
|
|
600
|
+
self.alpha_var.set(max(0.0, self.alpha_var.get() - 0.1))
|
|
601
|
+
self._on_alpha("")
|
|
602
|
+
elif key == "equal":
|
|
603
|
+
self.alpha_var.set(min(1.0, self.alpha_var.get() + 0.1))
|
|
604
|
+
self._on_alpha("")
|
|
605
|
+
|
|
606
|
+
def _on_resize(self, _event: tk.Event) -> None:
|
|
607
|
+
if self._resize_job is not None:
|
|
608
|
+
try:
|
|
609
|
+
self.root.after_cancel(self._resize_job)
|
|
610
|
+
except Exception:
|
|
611
|
+
pass
|
|
612
|
+
self._resize_job = self.root.after(60, self.update_display)
|
|
613
|
+
|
|
614
|
+
def _apply_theme(self) -> None:
|
|
615
|
+
if self.root is None:
|
|
616
|
+
return
|
|
617
|
+
self.style = ttk.Style(self.root)
|
|
618
|
+
try:
|
|
619
|
+
self.style.theme_use("clam")
|
|
620
|
+
except tk.TclError:
|
|
621
|
+
pass
|
|
622
|
+
|
|
623
|
+
if self.is_dark:
|
|
624
|
+
bg = self.dark_bg
|
|
625
|
+
panel = self.dark_panel
|
|
626
|
+
text = self.dark_text
|
|
627
|
+
button_bg = self.dark_accent
|
|
628
|
+
else:
|
|
629
|
+
bg = self.light_bg
|
|
630
|
+
panel = self.light_panel
|
|
631
|
+
text = self.light_text
|
|
632
|
+
button_bg = "#4a7bd8"
|
|
633
|
+
|
|
634
|
+
self.root.configure(bg=bg)
|
|
635
|
+
self.style.configure("TFrame", background=bg)
|
|
636
|
+
self.style.configure("Panel.TFrame", background=panel)
|
|
637
|
+
self.style.configure("TLabel", background=bg, foreground=text, font=self.font_base)
|
|
638
|
+
self.style.configure("Panel.TLabel", background=panel, foreground=text, font=self.font_base)
|
|
639
|
+
self.style.configure("TButton", background=button_bg, foreground="white", font=self.font_base)
|
|
640
|
+
self.style.map("TButton", background=[("active", button_bg)])
|
|
641
|
+
self.style.configure("TCheckbutton", background=bg, foreground=text, font=self.font_base)
|
|
642
|
+
|
|
643
|
+
for text_widget in (self.original_text, self.current_text):
|
|
644
|
+
if text_widget is not None:
|
|
645
|
+
text_widget.configure(
|
|
646
|
+
bg=panel,
|
|
647
|
+
fg=text,
|
|
648
|
+
insertbackground=text,
|
|
649
|
+
highlightbackground=panel,
|
|
650
|
+
font=self.font_mono,
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
def _toggle_theme(self) -> None:
|
|
654
|
+
self.is_dark = not self.is_dark
|
|
655
|
+
self._apply_theme()
|
|
656
|
+
|
|
657
|
+
def _build_ui(self) -> None:
|
|
658
|
+
self.root = tk.Tk()
|
|
659
|
+
self.root.title("SensorCal — Recalibrate (LiDAR/Camera Calibration)")
|
|
660
|
+
self.root.geometry("1400x900")
|
|
661
|
+
self.root.bind("<Key>", self._on_key)
|
|
662
|
+
|
|
663
|
+
self.pair_label_var = tk.StringVar(master=self.root)
|
|
664
|
+
self.tx_var = tk.DoubleVar(master=self.root, value=0.0)
|
|
665
|
+
self.ty_var = tk.DoubleVar(master=self.root, value=0.0)
|
|
666
|
+
self.tz_var = tk.DoubleVar(master=self.root, value=0.0)
|
|
667
|
+
self.roll_var = tk.DoubleVar(master=self.root, value=0.0)
|
|
668
|
+
self.pitch_var = tk.DoubleVar(master=self.root, value=0.0)
|
|
669
|
+
self.yaw_var = tk.DoubleVar(master=self.root, value=0.0)
|
|
670
|
+
self.alpha_var = tk.DoubleVar(master=self.root, value=self.overlay_alpha)
|
|
671
|
+
self.point_size_var = tk.IntVar(master=self.root, value=self.point_size)
|
|
672
|
+
self.density_var = tk.BooleanVar(master=self.root, value=self.density_map)
|
|
673
|
+
self.legend_var = tk.BooleanVar(master=self.root, value=self.depth_legend)
|
|
674
|
+
|
|
675
|
+
self._apply_theme()
|
|
676
|
+
|
|
677
|
+
main = ttk.Frame(self.root, padding=8, style="TFrame")
|
|
678
|
+
main.grid(row=0, column=0, sticky="nsew")
|
|
679
|
+
self.root.grid_rowconfigure(0, weight=1)
|
|
680
|
+
self.root.grid_columnconfigure(0, weight=1)
|
|
681
|
+
|
|
682
|
+
main.grid_rowconfigure(0, weight=1)
|
|
683
|
+
main.grid_columnconfigure(0, weight=1)
|
|
684
|
+
|
|
685
|
+
left = ttk.Frame(main, style="TFrame")
|
|
686
|
+
left.grid(row=0, column=0, sticky="nsew")
|
|
687
|
+
right = ttk.Frame(main, style="Panel.TFrame")
|
|
688
|
+
right.grid(row=0, column=1, sticky="ns")
|
|
689
|
+
|
|
690
|
+
self.image_label = ttk.Label(left, style="TLabel")
|
|
691
|
+
self.image_label.pack(fill=tk.BOTH, expand=True)
|
|
692
|
+
left.bind("<Configure>", self._on_resize)
|
|
693
|
+
|
|
694
|
+
ttk.Label(right, textvariable=self.pair_label_var, wraplength=360, style="Panel.TLabel").pack(
|
|
695
|
+
anchor="w", pady=(0, 8)
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
def slider(parent, label, var, from_, to_, resolution, command):
|
|
699
|
+
frame = ttk.Frame(parent, style="Panel.TFrame")
|
|
700
|
+
frame.pack(fill=tk.X, pady=2)
|
|
701
|
+
ttk.Label(frame, text=label, width=12, style="Panel.TLabel").pack(side=tk.LEFT)
|
|
702
|
+
scale = tk.Scale(
|
|
703
|
+
frame,
|
|
704
|
+
variable=var,
|
|
705
|
+
from_=from_,
|
|
706
|
+
to=to_,
|
|
707
|
+
resolution=resolution,
|
|
708
|
+
orient=tk.HORIZONTAL,
|
|
709
|
+
length=240,
|
|
710
|
+
command=command,
|
|
711
|
+
)
|
|
712
|
+
scale.configure(font=self.font_small)
|
|
713
|
+
scale.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
|
714
|
+
val = ttk.Label(frame, textvariable=var, width=8, style="Panel.TLabel")
|
|
715
|
+
val.pack(side=tk.RIGHT)
|
|
716
|
+
|
|
717
|
+
slider(right, "tx (m)", self.tx_var, -MAX_TRANSLATION, MAX_TRANSLATION, 0.001, self._on_slider)
|
|
718
|
+
slider(right, "ty (m)", self.ty_var, -MAX_TRANSLATION, MAX_TRANSLATION, 0.001, self._on_slider)
|
|
719
|
+
slider(right, "tz (m)", self.tz_var, -MAX_TRANSLATION, MAX_TRANSLATION, 0.001, self._on_slider)
|
|
720
|
+
slider(right, "roll (deg)", self.roll_var, -MAX_ROTATION_DEG, MAX_ROTATION_DEG, 0.1, self._on_slider)
|
|
721
|
+
slider(right, "pitch (deg)", self.pitch_var, -MAX_ROTATION_DEG, MAX_ROTATION_DEG, 0.1, self._on_slider)
|
|
722
|
+
slider(right, "yaw (deg)", self.yaw_var, -MAX_ROTATION_DEG, MAX_ROTATION_DEG, 0.1, self._on_slider)
|
|
723
|
+
slider(right, "alpha", self.alpha_var, 0.0, 1.0, 0.05, self._on_alpha)
|
|
724
|
+
slider(right, "pt size", self.point_size_var, 1, 10, 1, self._on_point_size)
|
|
725
|
+
|
|
726
|
+
toggles = ttk.Frame(right, style="Panel.TFrame")
|
|
727
|
+
toggles.pack(fill=tk.X, pady=(6, 6))
|
|
728
|
+
ttk.Checkbutton(toggles, text="Density", variable=self.density_var, command=self._on_toggle).pack(side=tk.LEFT)
|
|
729
|
+
ttk.Checkbutton(toggles, text="Legend", variable=self.legend_var, command=self._on_toggle).pack(side=tk.LEFT)
|
|
730
|
+
ttk.Button(toggles, text="Dark/Light", command=self._toggle_theme).pack(side=tk.RIGHT)
|
|
731
|
+
|
|
732
|
+
btns = ttk.Frame(right, style="Panel.TFrame")
|
|
733
|
+
btns.pack(fill=tk.X, pady=(4, 10))
|
|
734
|
+
ttk.Button(btns, text="Prev", command=self._on_prev).pack(side=tk.LEFT, padx=2)
|
|
735
|
+
ttk.Button(btns, text="Next", command=self._on_next).pack(side=tk.LEFT, padx=2)
|
|
736
|
+
ttk.Button(btns, text="Original", command=self._on_reset).pack(side=tk.LEFT, padx=2)
|
|
737
|
+
ttk.Button(btns, text="Save", command=self._on_save).pack(side=tk.LEFT, padx=2)
|
|
738
|
+
|
|
739
|
+
ttk.Label(right, text="Original Transform", style="Panel.TLabel").pack(anchor="w")
|
|
740
|
+
self.original_text = tk.Text(right, width=44, height=6, wrap=tk.NONE, font=self.font_mono)
|
|
741
|
+
self.original_text.pack(fill=tk.X, pady=(0, 6))
|
|
742
|
+
self.original_text.config(state=tk.DISABLED)
|
|
743
|
+
|
|
744
|
+
ttk.Label(right, text="Current Transform", style="Panel.TLabel").pack(anchor="w")
|
|
745
|
+
self.current_text = tk.Text(right, width=44, height=6, wrap=tk.NONE, font=self.font_mono)
|
|
746
|
+
self.current_text.pack(fill=tk.X, pady=(0, 6))
|
|
747
|
+
self.current_text.config(state=tk.DISABLED)
|
|
748
|
+
|
|
749
|
+
self._apply_theme()
|
|
750
|
+
|
|
751
|
+
def process(self) -> None:
|
|
752
|
+
"""Launch the GUI and start interactive calibration."""
|
|
753
|
+
print("\nINTERACTIVE LIDAR-CAMERA CALIBRATION")
|
|
754
|
+
self.pairs = list(self._iter_pairs())
|
|
755
|
+
if not self.pairs:
|
|
756
|
+
raise ValueError("No pairs found to process.")
|
|
757
|
+
|
|
758
|
+
self._build_ui()
|
|
759
|
+
self._load_pair(0)
|
|
760
|
+
self.root.mainloop()
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _parse_args() -> argparse.Namespace:
|
|
764
|
+
parser = argparse.ArgumentParser(
|
|
765
|
+
description="Interactive LiDAR-to-camera calibration with GUI controls",
|
|
766
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
parser.add_argument("--config", help="Path to YAML config with intrinsics and transform")
|
|
770
|
+
parser.add_argument(
|
|
771
|
+
"--intrinsic-k",
|
|
772
|
+
nargs=9,
|
|
773
|
+
type=float,
|
|
774
|
+
metavar=("K00", "K01", "K02", "K10", "K11", "K12", "K20", "K21", "K22"),
|
|
775
|
+
help="Camera intrinsics as 9 values (row-major 3x3)",
|
|
776
|
+
)
|
|
777
|
+
parser.add_argument(
|
|
778
|
+
"--lidar-camera",
|
|
779
|
+
nargs=16,
|
|
780
|
+
type=float,
|
|
781
|
+
metavar=("T00", "T01", "T02", "T03", "T10", "T11", "T12", "T13",
|
|
782
|
+
"T20", "T21", "T22", "T23", "T30", "T31", "T32", "T33"),
|
|
783
|
+
help="LiDAR-to-camera transform as 16 values (row-major 4x4)",
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
parser.add_argument("--image", help="Path to a single image")
|
|
787
|
+
parser.add_argument("--pcd", help="Path to a single PCD")
|
|
788
|
+
parser.add_argument("--image-folder", help="Folder containing images")
|
|
789
|
+
parser.add_argument("--pcd-folder", help="Folder containing point clouds")
|
|
790
|
+
parser.add_argument("--use-sample", action="store_true", help="Use bundled sample data")
|
|
791
|
+
|
|
792
|
+
parser.add_argument(
|
|
793
|
+
"--save-file",
|
|
794
|
+
default="calibration_results.yaml",
|
|
795
|
+
help="Path to output YAML file (default: calibration_results.yaml)",
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
parser.add_argument("--point-size", type=int, default=2, help="Size of projected points")
|
|
799
|
+
parser.add_argument(
|
|
800
|
+
"--point-color",
|
|
801
|
+
nargs=3,
|
|
802
|
+
type=int,
|
|
803
|
+
metavar=("R", "G", "B"),
|
|
804
|
+
default=[0, 255, 0],
|
|
805
|
+
help="RGB color for points when depth coloring is off (unused)",
|
|
806
|
+
)
|
|
807
|
+
|
|
808
|
+
args = parser.parse_args()
|
|
809
|
+
|
|
810
|
+
if (args.image and not args.pcd) or (args.pcd and not args.image):
|
|
811
|
+
parser.error("--image and --pcd must be provided together")
|
|
812
|
+
if (args.image_folder and not args.pcd_folder) or (args.pcd_folder and not args.image_folder):
|
|
813
|
+
parser.error("--image-folder and --pcd-folder must be provided together")
|
|
814
|
+
if not ((args.image and args.pcd) or (args.image_folder and args.pcd_folder) or args.use_sample):
|
|
815
|
+
print("No input specified, using sample data")
|
|
816
|
+
args.use_sample = True
|
|
817
|
+
|
|
818
|
+
return args
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def main() -> None:
|
|
822
|
+
args = _parse_args()
|
|
823
|
+
|
|
824
|
+
if args.use_sample:
|
|
825
|
+
if not args.image_folder and not args.image:
|
|
826
|
+
args.image_folder = os.path.abspath(SAMPLE_IMAGE_FOLDER)
|
|
827
|
+
args.pcd_folder = os.path.abspath(SAMPLE_PCD_FOLDER)
|
|
828
|
+
if args.intrinsic_k is None and args.config is None:
|
|
829
|
+
args.intrinsic_k = SAMPLE_INTRINSIC_K
|
|
830
|
+
if args.lidar_camera is None and args.config is None:
|
|
831
|
+
args.lidar_camera = SAMPLE_LIDAR_CAMERA
|
|
832
|
+
print(
|
|
833
|
+
f"Using sample data from:\n Images: {args.image_folder}\n PCDs: {args.pcd_folder}"
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
app = SensorCalApp(
|
|
837
|
+
config_path=args.config,
|
|
838
|
+
intrinsic_k=args.intrinsic_k,
|
|
839
|
+
lidar_camera=args.lidar_camera,
|
|
840
|
+
image_path=args.image,
|
|
841
|
+
pcd_path=args.pcd,
|
|
842
|
+
image_folder=args.image_folder,
|
|
843
|
+
pcd_folder=args.pcd_folder,
|
|
844
|
+
save_file=args.save_file,
|
|
845
|
+
point_size=args.point_size,
|
|
846
|
+
point_color=tuple(args.point_color[::-1]),
|
|
847
|
+
depth_colormap=True,
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
app.process()
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
if __name__ == "__main__":
|
|
854
|
+
main()
|
sensorcal/cli.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""SensorCal CLI entrypoint with subcommands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from . import app as recalibrate_app
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main() -> None:
|
|
12
|
+
parser = argparse.ArgumentParser(prog="sensorcal")
|
|
13
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
14
|
+
|
|
15
|
+
# Recalibrate subcommand passes through to app.main
|
|
16
|
+
subparsers.add_parser("recalibrate", help="Run the LiDAR/camera recalibration UI")
|
|
17
|
+
|
|
18
|
+
args, unknown = parser.parse_known_args()
|
|
19
|
+
|
|
20
|
+
if args.command == "recalibrate":
|
|
21
|
+
sys.argv = ["sensorcal recalibrate", *unknown]
|
|
22
|
+
recalibrate_app.main()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
if __name__ == "__main__":
|
|
26
|
+
main()
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sensorcal
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: SensorCal package
|
|
5
|
+
Author-email: Murdism <murdiszm@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://example.com
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: numpy
|
|
14
|
+
Requires-Dist: pyyaml
|
|
15
|
+
Requires-Dist: opencv-python
|
|
16
|
+
Requires-Dist: open3d
|
|
17
|
+
Requires-Dist: pillow
|
|
18
|
+
|
|
19
|
+
# interactive_camera_lidar_calibration
|
|
20
|
+
|
|
21
|
+
Python package scaffold for `sensorcal`.
|
|
22
|
+
|
|
23
|
+
## Install (editable)
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
python -m pip install -e .
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Install (pip)
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
python -m pip install sensorcal
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
Single image + pcd:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
sensorcal recalibrate \
|
|
41
|
+
--config path/to/calibrate.yaml \
|
|
42
|
+
--image path/to/img_001.png \
|
|
43
|
+
--pcd path/to/pc_001.pcd
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Single image + pcd with parameters (no config):
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
sensorcal recalibrate \
|
|
50
|
+
--image path/to/img_001.png \
|
|
51
|
+
--pcd path/to/pc_001.pcd \
|
|
52
|
+
--intrinsic-k 600 0 640 0 600 360 0 0 1 \
|
|
53
|
+
--lidar-camera 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Folder mode:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
sensorcal recalibrate \
|
|
60
|
+
--config path/to/calibrate.yaml \
|
|
61
|
+
--image-folder path/to/images \
|
|
62
|
+
--pcd-folder path/to/pcds
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Sample data (3 images + 3 PCDs bundled in `samples/`):
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
sensorcal recalibrate --use-sample
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
If you run `sensorcal recalibrate` with no arguments, it defaults to the sample data.
|
|
72
|
+
|
|
73
|
+
## Input Structure
|
|
74
|
+
|
|
75
|
+
You can provide inputs in two ways:
|
|
76
|
+
|
|
77
|
+
1) Direct CLI arguments
|
|
78
|
+
- Single pair:
|
|
79
|
+
- `--image path/to/img.png`
|
|
80
|
+
- `--pcd path/to/cloud.pcd`
|
|
81
|
+
- Folder mode (paired by sorted filename order):
|
|
82
|
+
- `--image-folder path/to/images`
|
|
83
|
+
- `--pcd-folder path/to/pcds`
|
|
84
|
+
|
|
85
|
+
2) YAML config file (`--config path/to/calibrate.yaml`)
|
|
86
|
+
|
|
87
|
+
### Expected YAML structure
|
|
88
|
+
|
|
89
|
+
```yaml
|
|
90
|
+
transform:
|
|
91
|
+
# 3x3 camera intrinsics in row-major order
|
|
92
|
+
intrinsic_k: [fx, 0, cx, 0, fy, cy, 0, 0, 1]
|
|
93
|
+
|
|
94
|
+
# 4x4 LiDAR-to-camera transform in row-major order
|
|
95
|
+
lidar_camera: [r00, r01, r02, tx,
|
|
96
|
+
r10, r11, r12, ty,
|
|
97
|
+
r20, r21, r22, tz,
|
|
98
|
+
0, 0, 0, 1]
|
|
99
|
+
|
|
100
|
+
path:
|
|
101
|
+
# Optional defaults for folder mode
|
|
102
|
+
img_folder: /abs/or/relative/path/to/images
|
|
103
|
+
pcd_folder: /abs/or/relative/path/to/pcds
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Notes
|
|
107
|
+
|
|
108
|
+
- If you pass `--intrinsic-k` or `--lidar-camera` on the CLI, those override the config file.
|
|
109
|
+
- If you pass both `--image/--pcd` and folder paths, the single pair wins.
|
|
110
|
+
- For folder mode, files are paired by **sorted filename order**, so keep names aligned.
|
|
111
|
+
|
|
112
|
+
## Python (pip installed)
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from sensorcal.app import SensorCalApp
|
|
116
|
+
|
|
117
|
+
app = SensorCalApp(
|
|
118
|
+
config_path="path/to/calibrate.yaml",
|
|
119
|
+
intrinsic_k=None,
|
|
120
|
+
lidar_camera=None,
|
|
121
|
+
image_path="path/to/img_001.png",
|
|
122
|
+
pcd_path="path/to/pc_001.pcd",
|
|
123
|
+
image_folder=None,
|
|
124
|
+
pcd_folder=None,
|
|
125
|
+
save_file="calibration_results.yaml",
|
|
126
|
+
)
|
|
127
|
+
app.process()
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Controls
|
|
131
|
+
|
|
132
|
+
- Single-window Tkinter app with live sliders, buttons, and dark mode toggle
|
|
133
|
+
- Sliders: tx/ty/tz (meters), roll/pitch/yaw (degrees), alpha, point size
|
|
134
|
+
- Density toggle: overlays a heatmap of point concentration (helps reveal clusters)
|
|
135
|
+
- Keyboard: A/D (X-/X+), W/S (Y-/Y+), Q/E (Z-/Z+)
|
|
136
|
+
- Rotate: J/L (roll-/+), I/K (pitch-/+), U/O (yaw-/+)
|
|
137
|
+
- Buttons: Prev, Next, Original, Save
|
|
138
|
+
- Save writes YAML plus a sibling `.txt` containing K and the transform.
|
|
139
|
+
|
|
140
|
+
## Python API
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
|
|
144
|
+
app = InteractiveCalibration(
|
|
145
|
+
config_path="path/to/calibrate.yaml",
|
|
146
|
+
intrinsic_k=None,
|
|
147
|
+
lidar_camera=None,
|
|
148
|
+
image_path="path/to/img_001.png",
|
|
149
|
+
pcd_path="path/to/pc_001.pcd",
|
|
150
|
+
image_folder=None,
|
|
151
|
+
pcd_folder=None,
|
|
152
|
+
save_file="transformations.yaml",
|
|
153
|
+
)
|
|
154
|
+
app.process()
|
|
155
|
+
```
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
sensorcal/__init__.py,sha256=9syUJeSE02ExwxLoT6durhhuSYDcglfqVIxX8sBIwgg,122
|
|
2
|
+
sensorcal/app.py,sha256=3nWlCea3eoSu0OVpmodAtiqrViPIEkin1V09wncZEuI,33755
|
|
3
|
+
sensorcal/cli.py,sha256=SNdXLsjFKaeSAWbQOWFN5bqQNxSOmoFFZwWr0Kpmjj0,654
|
|
4
|
+
sensorcal-0.1.1.dist-info/METADATA,sha256=XGkCNLlntTqxVKPgpM6BomofgTIDgPo-_JR4JLOUjGc,3681
|
|
5
|
+
sensorcal-0.1.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
6
|
+
sensorcal-0.1.1.dist-info/entry_points.txt,sha256=FqCiB8EI0I9xa52o7bP-NT1l7xWYrPlpT16BH1Ku3Rg,49
|
|
7
|
+
sensorcal-0.1.1.dist-info/top_level.txt,sha256=1ZXmLMIiuJFAKYbOGCBTDUXMQsY6pMSyBSedv4pDNSU,10
|
|
8
|
+
sensorcal-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sensorcal
|