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 ADDED
@@ -0,0 +1,7 @@
1
+ """SensorCal package."""
2
+
3
+ from .app import SensorCalApp
4
+
5
+ __all__ = ["__version__", "SensorCalApp"]
6
+
7
+ __version__ = "0.1.0"
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sensorcal = sensorcal.cli:main
@@ -0,0 +1 @@
1
+ sensorcal