添加 LiDAR-IMU 外参标定流水线与说明文档

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
lichun.qu
2026-08-01 12:02:37 +08:00
co-authored by Cursor
commit cf1fad7594
40 changed files with 5502 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# Tools package for local scripts and tests.
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Compare two S2 offline summary.json runs (real artifacts only)."""
from __future__ import annotations
import json
import sys
from pathlib import Path
import numpy as np
from imu_lidar.geometry import rpy_deg_xyz, so3_log
def dig(path: Path):
data = json.loads(path.read_text(encoding="utf-8"))
session = data["details"]["sessions"][0]
return data, session, session.get("handeye", {}), session.get("time_offset", {})
def main() -> int:
old_path = Path(sys.argv[1])
new_path = Path(sys.argv[2])
old, so, heo, too = dig(old_path)
new, sn, hen, ton = dig(new_path)
print("==== COMPARISON (real summary.json artifacts) ====")
print(f"{'metric':28s} {'run_a':28s} {'run_b':28s}")
rows = [
("status", old.get("status"), new.get("status")),
("stage", so.get("stage"), sn.get("stage")),
("delta_t_s", f"{too.get('delta_t_s'):.6f}", f"{ton.get('delta_t_s'):.6f}"),
(
"corr/mag_peak",
f"{too.get('correlation_peak'):.6f}",
f"{ton.get('correlation_peak'):.6f}",
),
("keyframes", so.get("keyframes"), sn.get("keyframes")),
("handeye_pairs", heo.get("pair_count"), hen.get("pair_count")),
("handeye_ok", heo.get("ok"), hen.get("ok")),
("rms_deg", f"{heo.get('residual_rms_deg'):.4f}", f"{hen.get('residual_rms_deg'):.4f}"),
(
"median_deg",
f"{heo.get('residual_median_deg'):.4f}",
f"{hen.get('residual_median_deg'):.4f}",
),
]
for key, a, b in rows:
print(f"{key:28s} {str(a):28s} {str(b):28s}")
print("run_a pair_notes:", so.get("pair_notes"))
print("run_b pair_notes:", sn.get("pair_notes"))
print("run_a time notes:", too.get("notes"))
print("run_b time notes:", ton.get("notes"))
print("run_a handeye notes:", heo.get("notes"))
print("run_b handeye notes:", hen.get("notes"))
r_old = np.asarray(heo["R_IMU_lidar"], dtype=float)
r_new = np.asarray(hen["R_IMU_lidar"], dtype=float)
print("R relative change deg:", float(np.degrees(np.linalg.norm(so3_log(r_old.T @ r_new)))))
print("RPY run_a deg:", rpy_deg_xyz(r_old))
print("RPY run_b deg:", rpy_deg_xyz(r_new))
print("delta rms (b-a):", hen.get("residual_rms_deg") - heo.get("residual_rms_deg"))
print(
"delta median (b-a):",
hen.get("residual_median_deg") - heo.get("residual_median_deg"),
)
print("artifacts:")
print(" run_a:", old_path)
print(" run_b:", new_path)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+114
View File
@@ -0,0 +1,114 @@
"""Generate a tiny synthetic session for V1 smoke tests."""
from __future__ import annotations
from pathlib import Path
import numpy as np
from imu_lidar.contracts import ImuSeries, LidarFrame
from imu_lidar.geometry import so3_exp
from imu_lidar.imu_io import save_imu_csv
from imu_lidar.lidar_io import save_lidar_session
def _wall_cloud(rng: np.random.Generator, n: int = 800) -> np.ndarray:
yz = rng.uniform([-5, -1], [5, 3], size=(n // 3, 2))
wall_x = np.column_stack([np.full(n // 3, 8.0), yz[:, 0], yz[:, 1]])
xz = rng.uniform([-5, -1], [5, 3], size=(n // 3, 2))
wall_y = np.column_stack([xz[:, 0], np.full(n // 3, 6.0), xz[:, 1]])
xy = rng.uniform([-5, -5], [5, 5], size=(n - 2 * (n // 3), 2))
ground = np.column_stack([xy[:, 0], xy[:, 1], np.full(xy.shape[0], -1.0)])
return np.vstack([wall_x, wall_y, ground])
def generate_synthetic_session(
output_root: Path,
*,
delta_t_s: float = 0.17,
yaw_extrinsic_deg: float = 25.0,
seed: int = 0,
) -> dict[str, float]:
"""Write IMU CSV + LiDAR frames with known extrinsic rotation and time offset."""
rng = np.random.default_rng(seed)
output_root = Path(output_root)
output_root.mkdir(parents=True, exist_ok=True)
r_x = so3_exp(np.deg2rad(np.array([2.0, -1.5, yaw_extrinsic_deg])))
map_points = _wall_cloud(rng)
lidar_hz = 10.0
duration = 8.0
lidar_times = np.arange(0.0, duration, 1.0 / lidar_hz)
# Non-yaw excitation is required for unique SO(3) hand-eye observability.
yaw = 0.5 * np.sin(0.8 * lidar_times) + 0.12 * lidar_times
pitch = 0.18 * np.sin(1.3 * lidar_times + 0.4)
roll = 0.12 * np.sin(1.7 * lidar_times + 1.0)
yaw_rate = np.gradient(yaw, lidar_times)
pitch_rate = np.gradient(pitch, lidar_times)
roll_rate = np.gradient(roll, lidar_times)
frames: list[LidarFrame] = []
for index, (t, yaw_i, pitch_i, roll_i) in enumerate(zip(lidar_times, yaw, pitch, roll)):
r_wl = so3_exp(np.array([roll_i, pitch_i, yaw_i]))
t_wl = np.array([0.4 * t, 0.05 * np.sin(0.5 * t), 0.0])
points = (map_points - t_wl) @ r_wl
points = points + rng.normal(0.0, 0.01, size=points.shape)
frames.append(
LidarFrame(
frame_id=str(index),
t_start_s=float(t),
t_end_s=float(t + 0.08),
points_xyz=points.astype(float),
)
)
save_lidar_session(output_root / "lidar", frames)
imu_hz = 100.0
t_lidar_grid = np.arange(0.0, duration, 1.0 / imu_hz)
omega_lidar = np.column_stack(
[
np.interp(t_lidar_grid, lidar_times, roll_rate),
np.interp(t_lidar_grid, lidar_times, pitch_rate),
np.interp(t_lidar_grid, lidar_times, yaw_rate),
]
)
omega_imu = omega_lidar @ r_x.T
g_world = np.array([0.0, 0.0, 9.80665])
acc_rows = []
for yaw_i, pitch_i, roll_i in zip(
np.interp(t_lidar_grid, lidar_times, yaw),
np.interp(t_lidar_grid, lidar_times, pitch),
np.interp(t_lidar_grid, lidar_times, roll),
):
r_wl = so3_exp(np.array([roll_i, pitch_i, yaw_i]))
g_in_lidar = r_wl.T @ g_world
acc_rows.append(r_x @ g_in_lidar)
acc = np.asarray(acc_rows, dtype=float)
static_t = np.arange(-1.0, 0.0, 1.0 / imu_hz)
static_gyro = np.zeros((static_t.size, 3))
static_acc = np.tile(r_x @ g_world, (static_t.size, 1))
t_imu = np.concatenate([static_t + delta_t_s, t_lidar_grid + delta_t_s])
gyro = np.vstack([static_gyro, omega_imu]) + rng.normal(0.0, 0.001, size=(t_imu.size, 3))
acc_all = np.vstack([static_acc, acc]) + rng.normal(0.0, 0.01, size=(t_imu.size, 3))
imu = ImuSeries(t_s=t_imu, gyro_rad_s=gyro, acc_m_s2=acc_all)
save_imu_csv(output_root / "imu.csv", imu)
return {
"delta_t_s": float(delta_t_s),
"yaw_extrinsic_deg": float(yaw_extrinsic_deg),
}
if __name__ == "__main__":
import json
out = Path("examples/synthetic_session")
meta = generate_synthetic_session(out)
(out / "meta.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
print(f"wrote {out}")
print(meta)
+23
View File
@@ -0,0 +1,23 @@
# 一键复现合成标定(Windows
# 用法:在仓库根目录执行
# powershell -File tools\reproduce_synthetic.ps1
# powershell -File tools\reproduce_synthetic.ps1 -SkipPytest
# powershell -File tools\reproduce_synthetic.ps1 -Mode full_se3
param(
[ValidateSet("rotation_only", "full_se3")]
[string]$Mode = "rotation_only",
[switch]$SkipPytest
)
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent $PSScriptRoot
Set-Location $Root
$args = @("tools\reproduce_synthetic.py", "--mode", $Mode)
if ($SkipPytest) {
$args += "--skip-pytest"
}
python @args
exit $LASTEXITCODE
+109
View File
@@ -0,0 +1,109 @@
"""One-click synthetic reproduce: generate → calibrate → report → pytest."""
from __future__ import annotations
import argparse
import subprocess
import sys
from pathlib import Path
def _repo_root() -> Path:
return Path(__file__).resolve().parents[1]
def _run(cmd: list[str], cwd: Path) -> None:
print("+", " ".join(cmd), flush=True)
completed = subprocess.run(cmd, cwd=str(cwd), check=False)
if completed.returncode != 0:
raise SystemExit(completed.returncode)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Generate synthetic LiDARIMU data, run calibration, show report, run tests."
)
parser.add_argument(
"--mode",
choices=["rotation_only", "full_se3"],
default="rotation_only",
)
parser.add_argument("--skip-pytest", action="store_true")
args = parser.parse_args(argv)
root = _repo_root()
session = root / "examples" / "synthetic_session"
imu = session / "imu.csv"
lidar = session / "lidar"
calib_out = session / "out"
config = root / "config" / "vehicle_installation.template.yaml"
print("=== 1/4 generate synthetic session ===", flush=True)
_run([sys.executable, str(root / "tools" / "generate_synthetic_session.py")], cwd=root)
print("=== 2/4 run calibration ===", flush=True)
_run(
[
sys.executable,
"-m",
"imu_lidar.cli",
"run",
"--vehicle-config",
str(config),
"--imu",
str(imu),
"--lidar",
str(lidar),
"--output",
str(calib_out),
"--mode",
args.mode,
"--time-offset-search-s",
"0.5",
"--min-pair-rotation-deg",
"2.0",
"--min-pair-translation-m",
"0.05",
"--max-iterations",
"1",
],
cwd=root,
)
print("=== 3/4 show report ===", flush=True)
_run(
[
sys.executable,
str(root / "tools" / "show_calibration_report.py"),
"--summary",
str(calib_out / "summary.json"),
"--truth-meta",
str(session / "meta.json"),
"--plot",
str(calib_out / "report_preview.png"),
],
cwd=root,
)
if args.skip_pytest:
print("=== 4/4 pytest skipped ===", flush=True)
else:
print("=== 4/4 pytest ===", flush=True)
_run([sys.executable, "-m", "pytest", "-q"], cwd=root)
print("\nDone.")
print(" INPUT")
print(f" IMU CSV : {imu}")
print(f" LiDAR dir : {lidar}")
print(f" vehicle YAML: {config}")
print(" OUTPUT")
print(f" directory : {calib_out}")
print(f" T : {calib_out / 'T_IMU_lidar.json'}")
print(f" δt : {calib_out / 'time_offset.json'}")
print(f" summary : {calib_out / 'summary.json'}")
print(f" preview PNG : {calib_out / 'report_preview.png'}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+133
View File
@@ -0,0 +1,133 @@
"""Pretty-print calibration summary.json and optionally write a small preview figure."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
def _load(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def _session0(summary: dict) -> dict:
sessions = summary.get("details", {}).get("sessions", [])
return sessions[0] if sessions else {}
def print_report(summary: dict, truth: dict | None = None) -> None:
print("---------- calibration report ----------")
print(f"status : {summary.get('status')}")
print(f"message: {summary.get('message')}")
dt = summary.get("time_offset_s")
if dt is not None:
print(f"δt : {dt:.6f} s (t_imu = t_lidar + δt)")
if truth and "delta_t_s" in truth:
print(f" truth={truth['delta_t_s']:.6f} s err={dt - truth['delta_t_s']:+.6f} s")
t_block = summary.get("T_IMU_lidar")
if isinstance(t_block, dict):
rpy = t_block.get("rpy_deg_xyz")
trans = t_block.get("translation_m")
if rpy is not None:
print(f"RPY xyz: [{rpy[0]:.3f}, {rpy[1]:.3f}, {rpy[2]:.3f}] deg")
if truth and "yaw_extrinsic_deg" in truth:
print(f" yaw truth≈{truth['yaw_extrinsic_deg']:.3f} deg")
if trans is not None:
print(f"t : [{trans[0]:.4f}, {trans[1]:.4f}, {trans[2]:.4f}] m")
session = _session0(summary)
handeye = session.get("handeye") or {}
joint = session.get("joint") or {}
if handeye:
print(
"handeye: "
f"pairs={handeye.get('pair_count')} "
f"rms={handeye.get('residual_rms_deg')}° "
f"median={handeye.get('residual_median_deg')}° "
f"ok={handeye.get('ok')}"
)
if joint:
obs = joint.get("observability") or {}
print(
"joint : "
f"rot_rms={joint.get('residual_rms_rot_deg')}° "
f"trans_rms={joint.get('residual_rms_trans_m')} m "
f"trans_accepted={joint.get('translation_accepted')}"
)
print(
"observ : "
f"rotation={obs.get('rotation_observable')} "
f"translation={obs.get('translation_observable')} "
f"cond_R={obs.get('condition_rotation')}"
)
if joint.get("gravity_m_s2") is not None:
print(f"gravity: {joint.get('gravity_m_s2')}")
if joint.get("gyro_bias_rad_s") is not None:
print(f"b_g : {joint.get('gyro_bias_rad_s')}")
print("----------------------------------------")
def maybe_plot(summary: dict, plot_path: Path, truth: dict | None = None) -> None:
try:
import matplotlib.pyplot as plt
except ImportError:
print("(matplotlib not installed; skip plot)")
return
session = _session0(summary)
handeye = session.get("handeye") or {}
joint = session.get("joint") or {}
labels = []
values = []
if handeye.get("residual_rms_deg") is not None:
labels.append("handeye\nRMS (°)")
values.append(float(handeye["residual_rms_deg"]))
if joint.get("residual_rms_rot_deg") is not None:
labels.append("joint rot\nRMS (°)")
values.append(float(joint["residual_rms_rot_deg"]))
if joint.get("residual_rms_trans_m") is not None:
labels.append("joint trans\nRMS (m)")
values.append(float(joint["residual_rms_trans_m"]))
dt = summary.get("time_offset_s")
if dt is not None:
labels.append("|δt| (s)")
values.append(abs(float(dt)))
if truth and "delta_t_s" in truth:
labels.append("|δt err| (s)")
values.append(abs(float(dt) - float(truth["delta_t_s"])))
if not labels:
print("(no numeric fields to plot)")
return
fig, ax = plt.subplots(figsize=(7.5, 3.8))
ax.bar(labels, values, color="#3b6ea5")
ax.set_title(f"Calibration preview — {summary.get('status')}")
ax.set_ylabel("value")
ax.grid(axis="y", alpha=0.3)
fig.tight_layout()
plot_path.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(plot_path, dpi=120)
plt.close(fig)
print(f"wrote plot: {plot_path}")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Show LiDARIMU calibration summary")
parser.add_argument("--summary", type=Path, required=True, help="Path to summary.json")
parser.add_argument("--truth-meta", type=Path, default=None, help="Optional synthetic meta.json")
parser.add_argument("--plot", type=Path, default=None, help="Optional PNG path for a bar chart")
args = parser.parse_args(argv)
summary = _load(args.summary)
truth = _load(args.truth_meta) if args.truth_meta and args.truth_meta.exists() else None
print_report(summary, truth)
if args.plot is not None:
maybe_plot(summary, args.plot, truth)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+27
View File
@@ -0,0 +1,27 @@
param(
[Parameter(Mandatory = $true)][string]$Lidar,
[Parameter(Mandatory = $true)][string]$Imu,
[Parameter(Mandatory = $true)][string]$Summary,
[int]$PairIndex = 0,
[double]$Voxel = 0.12,
[string]$SavePng = ""
)
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent $PSScriptRoot
Set-Location $Root
$args = @(
"tools\visualize_pair_3d.py",
"--lidar", $Lidar,
"--imu", $Imu,
"--summary", $Summary,
"--pair-index", "$PairIndex",
"--voxel", "$Voxel"
)
if ($SavePng -ne "") {
$args += @("--save-png", $SavePng)
}
python @args
if ($LASTEXITCODE -ne 0) { throw "visualize_pair_3d failed: $LASTEXITCODE" }
+452
View File
@@ -0,0 +1,452 @@
#!/usr/bin/env python3
"""Interactive 3D inspection of LiDARIMU motion-pair registration.
Similar to the RTKLiDAR ``visualize_pair_3d`` viewer, but A comes from IMU
preintegration and X is ``T_IMU_lidar``.
Modes (keyboard):
1 raw source (no transform)
2 IMU prediction with X=I (B_pred = A)
3 LiDAR registration B (reference)
4 calibrated prediction B_pred = X^{-1} A X
N / ] next motion pair
P / [ previous motion pair
Q / Esc exit
Blue = target keyframe i; orange = source keyframe j after the selected transform.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import numpy as np
from imu_lidar.geometry import (
inverse_transform,
make_transform,
rotation_angle_deg,
rpy_deg_xyz,
transform_points,
)
from imu_lidar.imu_io import load_imu_samples
from imu_lidar.imu_preintegration import preintegrate_imu
from imu_lidar.keyframes import build_keyframes
from imu_lidar.lidar_io import load_lidar_frames
from imu_lidar.motion_pairs import build_motion_pairs
from imu_lidar.registration import register_lidar_pair
from imu_lidar.time_offset import lidar_time_to_imu_time
COLORS = {
"target": [0.10, 0.65, 1.00],
"source": [1.00, 0.35, 0.05],
}
MODE_NAMES = (
"1 raw",
"2 IMU (X=I)",
"3 LiDAR B",
"4 calibrated X^-1 A X",
)
def _load_extrinsic(summary_path: Path) -> tuple[np.ndarray, float, np.ndarray]:
summary = json.loads(summary_path.read_text(encoding="utf-8"))
t_block = summary.get("T_IMU_lidar")
if t_block is None:
matrix = summary.get("matrix")
if matrix is not None:
return np.asarray(matrix, dtype=float), 0.0, np.zeros(3)
raise ValueError(f"no T_IMU_lidar in {summary_path}")
t_mat = np.asarray(t_block["matrix"], dtype=float)
dt = float(summary.get("time_offset_s") or 0.0)
session = (summary.get("details") or {}).get("sessions", [{}])[0]
bias = np.asarray(
(session.get("imu_audit") or {}).get("gyro_bias_rad_s")
or (session.get("joint") or {}).get("gyro_bias_rad_s")
or [0.0, 0.0, 0.0],
dtype=float,
).reshape(3)
return t_mat, dt, bias
def _delta_components(reference: np.ndarray, candidate: np.ndarray) -> dict:
delta = inverse_transform(reference) @ candidate
translation = np.asarray(delta[:3, 3], dtype=float)
return {
"translation_xyz_cm": (translation * 100.0).tolist(),
"translation_norm_cm": float(np.linalg.norm(translation) * 100.0),
"rotation_rpy_deg_xyz": rpy_deg_xyz(delta[:3, :3]).tolist(),
"rotation_angle_deg": rotation_angle_deg(delta[:3, :3]),
}
def _print_delta(name: str, reference: np.ndarray, candidate: np.ndarray) -> dict:
item = _delta_components(reference, candidate)
tx, ty, tz = item["translation_xyz_cm"]
roll, pitch, yaw = item["rotation_rpy_deg_xyz"]
print(
f"{name}: B^-1*motion "
f"t_xyz=[{tx:+.3f}, {ty:+.3f}, {tz:+.3f}] cm "
f"rpy=[{roll:+.3f}, {pitch:+.3f}, {yaw:+.3f}] deg "
f"|t|={item['translation_norm_cm']:.3f} cm "
f"|R|={item['rotation_angle_deg']:.4f} deg"
)
return item
def _cloud(o3d, points: np.ndarray, color, voxel: float):
item = o3d.geometry.PointCloud()
item.points = o3d.utility.Vector3dVector(points)
if voxel > 0:
item = item.voxel_down_sample(voxel)
item.paint_uniform_color(color)
return item
def _set_cloud_points(cloud, points: np.ndarray, color, voxel: float, o3d) -> None:
tmp = _cloud(o3d, points, color, voxel)
cloud.points = tmp.points
cloud.colors = tmp.colors
def _build_pair_list(
*,
lidar_dir: Path,
imu_path: Path,
delta_t_s: float,
gyro_bias: np.ndarray,
min_rotation_deg: float,
min_translation_m: float,
):
frames = load_lidar_frames(lidar_dir)
imu = load_imu_samples(imu_path)
keyframes = build_keyframes(
frames,
min_translation_m=min_translation_m,
min_rotation_deg=min_rotation_deg,
)
pair_set = build_motion_pairs(
session_id="viz",
keyframes=list(keyframes.frames),
keyframe_indices=keyframes.indices,
imu=imu,
delta_t_s=delta_t_s,
gyro_bias_rad_s=gyro_bias,
min_rotation_deg=min_rotation_deg,
min_translation_m=min_translation_m,
)
return frames, imu, keyframes, pair_set
def _pair_from_indices(
frames,
imu,
*,
i: int,
j: int,
delta_t_s: float,
gyro_bias: np.ndarray,
):
frame_i = frames[i]
frame_j = frames[j]
reg = register_lidar_pair(frame_j.points_xyz, frame_i.points_xyz)
t_i = lidar_time_to_imu_time(frame_i.t_mid_s, delta_t_s)
t_j = lidar_time_to_imu_time(frame_j.t_mid_s, delta_t_s)
preint = preintegrate_imu(
imu.t_s,
imu.gyro_rad_s,
imu.acc_m_s2,
t_i,
t_j,
gyro_bias,
np.zeros(3),
)
a = make_transform(preint.delta_p, preint.delta_R)
return frame_i, frame_j, a, reg.transform
def _transforms_for_pair(x: np.ndarray, a_ij: np.ndarray, b_gicp: np.ndarray) -> dict[str, np.ndarray]:
return {
MODE_NAMES[0]: np.eye(4),
MODE_NAMES[1]: a_ij.copy(),
MODE_NAMES[2]: b_gicp.copy(),
MODE_NAMES[3]: inverse_transform(x) @ a_ij @ x,
}
def _resolve_pair(frames, pairs, pair_index: int, x: np.ndarray):
pair = pairs[pair_index]
frame_i = frames[pair.i]
frame_j = frames[pair.j]
a_ij = make_transform(
pair.t_A_m if pair.t_A_m is not None else np.zeros(3),
pair.R_A,
)
b_gicp = make_transform(
pair.t_B_m if pair.t_B_m is not None else np.zeros(3),
pair.R_B,
)
transforms = _transforms_for_pair(x, a_ij, b_gicp)
label = (
f"pair {pair_index + 1}/{len(pairs)} "
f"frames {pair.i} <- {pair.j} "
f"rotB={rotation_angle_deg(pair.R_B):.2f} deg "
f"|tB|={0.0 if pair.t_B_m is None else float(np.linalg.norm(pair.t_B_m)):.3f} m"
)
return frame_i, frame_j, a_ij, b_gicp, transforms, label
def _print_pair_header(label: str, b_gicp: np.ndarray, transforms: dict[str, np.ndarray]) -> None:
print("-" * 72)
print(label)
print("blue=target i | orange=source j")
print("1-4: overlay mode | N/]: next pair | P/[: prev pair | Q/Esc: exit")
baseline = _print_delta("mode4 minus mode3", b_gicp, transforms[MODE_NAMES[3]])
roll, pitch, yaw = np.abs(baseline["rotation_rpy_deg_xyz"])
if max(roll, pitch) > max(0.10, 2.0 * yaw):
print("note: roll/pitch dominate yaw on this pair.")
tx, ty, tz = np.abs(baseline["translation_xyz_cm"])
if tz > max(tx, ty):
print("note: largest translation component is Z for this pair.")
def _save_topdown_png(
path: Path,
target: np.ndarray,
source: np.ndarray,
transforms: dict[str, np.ndarray],
) -> None:
import matplotlib.pyplot as plt
def downsample(points: np.ndarray) -> np.ndarray:
if points.shape[0] <= 8000:
return points
idx = np.linspace(0, points.shape[0] - 1, 8000).astype(int)
return points[idx]
names = list(transforms.keys())
fig, axes = plt.subplots(2, 2, figsize=(10, 10), sharex=True, sharey=True)
tgt = downsample(target)
for ax, name in zip(axes.ravel(), names):
src = downsample(transform_points(source, transforms[name]))
ax.scatter(tgt[:, 0], tgt[:, 1], s=1, c="tab:blue", alpha=0.35, label="target i")
ax.scatter(src[:, 0], src[:, 1], s=1, c="tab:orange", alpha=0.35, label="source j")
ax.set_title(name)
ax.set_aspect("equal", adjustable="box")
ax.grid(alpha=0.3)
axes[0, 0].legend(loc="upper right", markerscale=4)
fig.suptitle("LiDARIMU pair overlay (XY top-down)")
fig.tight_layout()
path.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(path, dpi=140)
plt.close(fig)
print(f"wrote {path}")
def _run_gui(
*,
frames,
pairs,
x: np.ndarray,
start_index: int,
voxel: float,
fixed_single_pair: tuple | None,
) -> None:
import open3d as o3d
if fixed_single_pair is not None:
frame_i, frame_j, a_ij, b_gicp = fixed_single_pair
transforms = _transforms_for_pair(x, a_ij, b_gicp)
label = f"fixed frames (no pair switching)"
pair_index = 0
n_pairs = 1
use_list = False
else:
pair_index = int(np.clip(start_index, 0, len(pairs) - 1))
n_pairs = len(pairs)
use_list = True
frame_i, frame_j, a_ij, b_gicp, transforms, label = _resolve_pair(
frames, pairs, pair_index, x
)
viewer = o3d.visualization.VisualizerWithKeyCallback()
viewer.create_window("LiDARIMU registration inspection", 1400, 900)
target_cloud = _cloud(o3d, frame_i.points_xyz, COLORS["target"], voxel)
source_cloud = _cloud(o3d, frame_j.points_xyz, COLORS["source"], voxel)
viewer.add_geometry(target_cloud)
viewer.add_geometry(source_cloud)
viewer.add_geometry(o3d.geometry.TriangleMesh.create_coordinate_frame(size=1.0))
viewer.get_render_option().background_color = np.array([0.02, 0.02, 0.02])
viewer.get_render_option().point_size = 2.0
state = {
"pair_index": pair_index,
"mode_name": MODE_NAMES[3],
"current": np.eye(4),
"transforms": transforms,
"b_gicp": b_gicp,
"frame_i": frame_i,
"frame_j": frame_j,
}
def apply_mode(vis, mode_name: str, *, announce: bool = True) -> None:
desired = state["transforms"][mode_name]
source_cloud.transform(desired @ inverse_transform(state["current"]))
state["current"] = desired
state["mode_name"] = mode_name
vis.update_geometry(source_cloud)
if announce:
if mode_name == MODE_NAMES[2]:
print(f"{mode_name}: registration reference; delta = 0")
else:
_print_delta(mode_name + " minus mode3", state["b_gicp"], desired)
def load_pair(vis, new_index: int) -> None:
if not use_list:
print("pair switching disabled in --frame-i/--frame-j mode")
return
new_index = int(new_index) % n_pairs
frame_i, frame_j, _a, b_gicp, transforms, label = _resolve_pair(
frames, pairs, new_index, x
)
state["pair_index"] = new_index
state["transforms"] = transforms
state["b_gicp"] = b_gicp
state["frame_i"] = frame_i
state["frame_j"] = frame_j
state["current"] = np.eye(4)
_set_cloud_points(target_cloud, frame_i.points_xyz, COLORS["target"], voxel, o3d)
_set_cloud_points(source_cloud, frame_j.points_xyz, COLORS["source"], voxel, o3d)
vis.update_geometry(target_cloud)
vis.update_geometry(source_cloud)
_print_pair_header(label, b_gicp, transforms)
apply_mode(vis, state["mode_name"], announce=True)
def make_mode_cb(mode_name: str):
def callback(vis):
apply_mode(vis, mode_name, announce=True)
return False
return callback
def next_pair(vis):
load_pair(vis, state["pair_index"] + 1)
return False
def prev_pair(vis):
load_pair(vis, state["pair_index"] - 1)
return False
_print_pair_header(label, b_gicp, transforms)
for key, name in zip((ord("1"), ord("2"), ord("3"), ord("4")), MODE_NAMES):
viewer.register_key_callback(key, make_mode_cb(name))
for key in (ord("N"), ord("n"), ord("]")):
viewer.register_key_callback(key, next_pair)
for key in (ord("P"), ord("p"), ord("[")):
viewer.register_key_callback(key, prev_pair)
apply_mode(viewer, MODE_NAMES[3], announce=False)
viewer.run()
viewer.destroy_window()
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--lidar", required=True, type=Path, help="LiDAR session directory")
parser.add_argument("--imu", required=True, type=Path, help="IMU CSV")
parser.add_argument(
"--summary",
required=True,
type=Path,
help="summary.json (or T_IMU_lidar.json) from a calibration run",
)
parser.add_argument("--pair-index", type=int, default=0, help="Starting motion-pair index")
parser.add_argument("--frame-i", type=int, default=None, help="Optional explicit frame index i")
parser.add_argument("--frame-j", type=int, default=None, help="Optional explicit frame index j")
parser.add_argument("--voxel", type=float, default=0.12)
parser.add_argument("--min-pair-rotation-deg", type=float, default=3.0)
parser.add_argument("--min-pair-translation-m", type=float, default=0.3)
parser.add_argument(
"--save-png",
type=Path,
default=None,
help="Write a 2x2 XY top-down comparison PNG for the starting pair",
)
parser.add_argument(
"--no-gui",
action="store_true",
help="Skip Open3D window (use with --save-png)",
)
args = parser.parse_args(argv)
x, delta_t_s, gyro_bias = _load_extrinsic(args.summary)
frames, imu, keyframes, pair_set = _build_pair_list(
lidar_dir=args.lidar,
imu_path=args.imu,
delta_t_s=delta_t_s,
gyro_bias=gyro_bias,
min_rotation_deg=args.min_pair_rotation_deg,
min_translation_m=args.min_pair_translation_m,
)
fixed_single_pair = None
if args.frame_i is not None and args.frame_j is not None:
frame_i, frame_j, a_ij, b_gicp = _pair_from_indices(
frames,
imu,
i=args.frame_i,
j=args.frame_j,
delta_t_s=delta_t_s,
gyro_bias=gyro_bias,
)
transforms = _transforms_for_pair(x, a_ij, b_gicp)
label = f"frames {args.frame_i} <- {args.frame_j}"
fixed_single_pair = (frame_i, frame_j, a_ij, b_gicp)
pairs = ()
else:
if not pair_set.pairs:
raise SystemExit("no motion pairs rebuilt; loosen min-pair thresholds or check data")
if not 0 <= args.pair_index < len(pair_set.pairs):
raise SystemExit(
f"pair-index {args.pair_index} outside [0, {len(pair_set.pairs) - 1}] "
f"({len(pair_set.pairs)} pairs available)"
)
pairs = pair_set.pairs
frame_i, frame_j, a_ij, b_gicp, transforms, label = _resolve_pair(
frames, pairs, args.pair_index, x
)
print(f"rebuilt {len(pairs)} pairs from {len(keyframes.indices)} keyframes")
if args.save_png is not None:
_print_pair_header(label, b_gicp, transforms)
_save_topdown_png(args.save_png, frame_i.points_xyz, frame_j.points_xyz, transforms)
if args.no_gui:
return 0
try:
import open3d # noqa: F401
except ImportError as exc:
raise SystemExit(
"Open3D is required for interactive view. "
"Install with: python -m pip install -e \".[open3d]\" "
"or use --no-gui --save-png out.png"
) from exc
_run_gui(
frames=frames,
pairs=pairs,
x=x,
start_index=args.pair_index,
voxel=args.voxel,
fixed_single_pair=fixed_single_pair,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())