支持 HI13/H32 主机 UTC 桥接对齐、多会话联合标定与 CAD 平移先验。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,6 +5,20 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-09 14:30 (UTC+8)
|
||||
|
||||
### 导出:HI13 IMU + recovered dlog zip + 墙钟切窗
|
||||
|
||||
- **原本**:IMU 只解 N300 FDILink;dlog 只认标准 `*.dorec`;无法按图上时段切窗。
|
||||
- **改成**:
|
||||
- 新增 `tools/rscap_v2/hi13_imu.py`(HI91:g→m/s²、°/s→rad/s、设备 ms)。
|
||||
- `h32_dlog` 支持 recovered zip(`indices.log` + `data.bin`),ZIP_STORED 成员按文件绝对 offset 直读。
|
||||
- `export_rscap_to_v1.py`:`--imu-kind hi13|n300|auto`、多段 `--imu-rscap`、`--host-start/end` 切窗。
|
||||
- 辅助脚本 `tools/export_usable_20260808_windows.py` 导出优先运动段。
|
||||
- **未推送**(按用户要求本地改完即可)。
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-05 09:00 (UTC+8)
|
||||
|
||||
### 导出:支持 H32 DLogCapture(MSOP+DIFOP)→ V1
|
||||
|
||||
+48
-12
@@ -21,10 +21,28 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
default=CalibrationMode.ROTATION_ONLY.value,
|
||||
)
|
||||
|
||||
run = subcommands.add_parser("run", help="执行 V1 标定流水线")
|
||||
run.add_argument("--session-id", default="session0")
|
||||
run.add_argument("--imu", required=True, help="IMU CSV/NPZ 路径")
|
||||
run.add_argument("--lidar", required=True, help="LiDAR 会话目录(含 frames_index.csv)")
|
||||
run = subcommands.add_parser(
|
||||
"run",
|
||||
help="执行 V1 标定流水线(可重复 --imu/--lidar/--session-id 做多会话联合)",
|
||||
)
|
||||
run.add_argument(
|
||||
"--session-id",
|
||||
action="append",
|
||||
default=None,
|
||||
help="会话 ID(可重复;与 --imu/--lidar 一一对应)",
|
||||
)
|
||||
run.add_argument(
|
||||
"--imu",
|
||||
action="append",
|
||||
required=True,
|
||||
help="IMU CSV/NPZ 路径(可重复)",
|
||||
)
|
||||
run.add_argument(
|
||||
"--lidar",
|
||||
action="append",
|
||||
required=True,
|
||||
help="LiDAR 会话目录(可重复)",
|
||||
)
|
||||
run.add_argument("--vehicle-config", required=True, help="车辆配置 YAML")
|
||||
run.add_argument("--output", required=True, help="输出目录")
|
||||
run.add_argument(
|
||||
@@ -39,6 +57,25 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
return parser
|
||||
|
||||
|
||||
def _build_sessions(args: argparse.Namespace) -> tuple[SessionInput, ...]:
|
||||
imus = [Path(p) for p in args.imu]
|
||||
lidars = [Path(p) for p in args.lidar]
|
||||
if len(imus) != len(lidars):
|
||||
raise SystemExit(f"--imu count ({len(imus)}) must match --lidar count ({len(lidars)})")
|
||||
if args.session_id is None:
|
||||
session_ids = [f"session{i}" for i in range(len(imus))]
|
||||
else:
|
||||
session_ids = list(args.session_id)
|
||||
if len(session_ids) != len(imus):
|
||||
raise SystemExit(
|
||||
f"--session-id count ({len(session_ids)}) must match --imu/--lidar ({len(imus)})"
|
||||
)
|
||||
return tuple(
|
||||
SessionInput(session_id=sid, imu_source=imu, lidar_source=lidar)
|
||||
for sid, imu, lidar in zip(session_ids, imus, lidars)
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
@@ -55,15 +92,10 @@ def main(argv: list[str] | None = None) -> int:
|
||||
return 0
|
||||
|
||||
if args.command == "run":
|
||||
sessions = _build_sessions(args)
|
||||
request = CalibrationRequest(
|
||||
vehicle_config=Path(args.vehicle_config),
|
||||
sessions=(
|
||||
SessionInput(
|
||||
session_id=args.session_id,
|
||||
imu_source=Path(args.imu),
|
||||
lidar_source=Path(args.lidar),
|
||||
),
|
||||
),
|
||||
sessions=sessions,
|
||||
requested_mode=CalibrationMode(args.mode),
|
||||
output_directory=Path(args.output),
|
||||
max_iterations=args.max_iterations,
|
||||
@@ -75,7 +107,11 @@ def main(argv: list[str] | None = None) -> int:
|
||||
print(f"status: {result.status.value}")
|
||||
print(f"message: {result.message}")
|
||||
if result.time_offset_s is not None:
|
||||
print(f"time_offset_s (t_imu = t_lidar + dt): {result.time_offset_s:.6f}")
|
||||
print(f"time_offset_s (first session; t_imu = t_lidar + dt): {result.time_offset_s:.6f}")
|
||||
joint = (result.details or {}).get("joint") or {}
|
||||
if joint:
|
||||
print(f"merged_pair_count: {joint.get('merged_pair_count')}")
|
||||
print(f"pair_counts_per_session: {joint.get('pair_counts_per_session')}")
|
||||
if result.T_IMU_lidar is not None:
|
||||
print("T_IMU_lidar:")
|
||||
print(result.T_IMU_lidar)
|
||||
|
||||
@@ -152,21 +152,21 @@ def _build_nav_rotations(
|
||||
r_x: np.ndarray,
|
||||
t_x: np.ndarray,
|
||||
) -> list[np.ndarray]:
|
||||
"""Chain IMU orientations in the first-keyframe nav frame using LiDAR+extrinsic."""
|
||||
"""Chain IMU orientations; restart at session/gap boundaries (no cross-link)."""
|
||||
|
||||
del id_to_idx
|
||||
rotations = [np.eye(3) for _ in keyframe_ids]
|
||||
for k in range(len(keyframe_ids) - 1):
|
||||
a = keyframe_ids[k]
|
||||
b = keyframe_ids[k + 1]
|
||||
pair = consecutive_pairs.get((a, b))
|
||||
if pair is None:
|
||||
rotations[k + 1] = rotations[k]
|
||||
# Missing link or new session: start a fresh nav chain.
|
||||
rotations[k + 1] = np.eye(3)
|
||||
continue
|
||||
t_b = np.zeros(3) if pair.t_B_m is None else np.asarray(pair.t_B_m, dtype=float)
|
||||
r_meas, _ = _lidar_to_imu_relative(r_x, t_x, pair.R_B, t_b)
|
||||
rotations[k + 1] = orthonormalize_rotation(rotations[k] @ r_meas)
|
||||
# Ensure list indexed by id_to_idx
|
||||
del id_to_idx
|
||||
return rotations
|
||||
|
||||
|
||||
@@ -178,6 +178,9 @@ def _solve_phase_c_se3(
|
||||
gravity_init: np.ndarray,
|
||||
sigma_bg_rw: float = 1.0e-5,
|
||||
sigma_ba_rw: float = 1.0e-3,
|
||||
t_init: np.ndarray | None = None,
|
||||
t_prior: np.ndarray | None = None,
|
||||
t_prior_sigma_m: np.ndarray | float | None = None,
|
||||
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, float, float, list[str]]:
|
||||
"""Keyframe IMU factor optimization for full SE(3)."""
|
||||
|
||||
@@ -185,21 +188,36 @@ def _solve_phase_c_se3(
|
||||
usable = [pair for pair in pairs if pair.t_B_m is not None and "delta_v" in pair.metadata]
|
||||
if len(usable) < 3:
|
||||
notes.append("phase-C skipped: need pairs with full preintegration metadata")
|
||||
return r_x, np.zeros(3), gravity_init, gyro_bias0, np.zeros(3), 1e9, 1e9, notes
|
||||
t0 = np.zeros(3) if t_init is None else np.asarray(t_init, dtype=float).reshape(3)
|
||||
return r_x, t0, gravity_init, gyro_bias0, np.zeros(3), 1e9, 1e9, notes
|
||||
|
||||
# Unique keyframes sorted by IMU time.
|
||||
# Keyframes: group by session, sort each session by IMU time (no cross-session chain).
|
||||
stamp: dict[int, float] = {}
|
||||
kf_session: dict[int, str] = {}
|
||||
for pair in usable:
|
||||
stamp[pair.i] = float(pair.metadata.get("t_i_imu_s", pair.t_i_s))
|
||||
stamp[pair.j] = float(pair.metadata.get("t_j_imu_s", pair.t_j_s))
|
||||
keyframe_ids = sorted(stamp.keys(), key=lambda kid: stamp[kid])
|
||||
kf_session[pair.i] = pair.session_id
|
||||
kf_session[pair.j] = pair.session_id
|
||||
session_ids = sorted(set(kf_session.values()))
|
||||
keyframe_ids: list[int] = []
|
||||
for sid in session_ids:
|
||||
local = [kid for kid, sess in kf_session.items() if sess == sid]
|
||||
local.sort(key=lambda kid: stamp[kid])
|
||||
keyframe_ids.extend(local)
|
||||
k_count = len(keyframe_ids)
|
||||
id_to_idx = {kid: idx for idx, kid in enumerate(keyframe_ids)}
|
||||
|
||||
consecutive_pairs: dict[tuple[int, int], MotionPair] = {}
|
||||
for pair in usable:
|
||||
if kf_session.get(pair.i) != kf_session.get(pair.j):
|
||||
continue
|
||||
if id_to_idx[pair.j] == id_to_idx[pair.i] + 1:
|
||||
consecutive_pairs[(pair.i, pair.j)] = pair
|
||||
notes.append(
|
||||
f"phase-C multi-session graph: sessions={len(session_ids)}, "
|
||||
f"keyframes={k_count}, consecutive_links={len(consecutive_pairs)}"
|
||||
)
|
||||
|
||||
g0 = np.asarray(gravity_init, dtype=float).reshape(3)
|
||||
if np.linalg.norm(g0) < 1e-6:
|
||||
@@ -214,6 +232,15 @@ def _solve_phase_c_se3(
|
||||
n_b = 3 * k_count
|
||||
dim = 3 + 3 + 2 + n_v + n_b + n_b
|
||||
x0 = np.zeros(dim)
|
||||
t0 = np.zeros(3) if t_init is None else np.asarray(t_init, dtype=float).reshape(3)
|
||||
x0[3:6] = t0
|
||||
t_prior_vec = None if t_prior is None else np.asarray(t_prior, dtype=float).reshape(3)
|
||||
if t_prior_sigma_m is None:
|
||||
t_sigma = np.array([0.05, 0.05, 0.05], dtype=float)
|
||||
else:
|
||||
t_sigma = np.asarray(t_prior_sigma_m, dtype=float).reshape(-1)
|
||||
if t_sigma.size == 1:
|
||||
t_sigma = np.full(3, float(t_sigma[0]), dtype=float)
|
||||
# velocities start at 0; biases at prior
|
||||
for idx in range(k_count):
|
||||
x0[8 + n_v + 3 * idx : 8 + n_v + 3 * idx + 3] = bg0
|
||||
@@ -269,18 +296,28 @@ def _solve_phase_c_se3(
|
||||
w = np.sqrt(_pair_weight(pair))
|
||||
out.append(w * (whiten @ err))
|
||||
|
||||
# Bias random-walk between consecutive keyframes.
|
||||
# Bias random-walk between consecutive keyframes (same session only).
|
||||
for k in range(k_count - 1):
|
||||
dt = max(stamp[keyframe_ids[k + 1]] - stamp[keyframe_ids[k]], 1e-3)
|
||||
a = keyframe_ids[k]
|
||||
b = keyframe_ids[k + 1]
|
||||
if kf_session.get(a) != kf_session.get(b):
|
||||
continue
|
||||
dt = max(stamp[b] - stamp[a], 1e-3)
|
||||
scale_g = 1.0 / (max(sigma_bg_rw, 1e-8) * np.sqrt(dt))
|
||||
scale_a = 1.0 / (max(sigma_ba_rw, 1e-8) * np.sqrt(dt))
|
||||
out.append(scale_g * (bgs[k + 1] - bgs[k]))
|
||||
out.append(scale_a * (bas[k + 1] - bas[k]))
|
||||
|
||||
# Weak priors: first-keyframe biases and translation magnitude.
|
||||
out.append(50.0 * (bgs[0] - bg0))
|
||||
out.append(20.0 * bas[0])
|
||||
out.append(0.2 * t_opt) # soft |t| prior ~ meters
|
||||
# Weak priors: first keyframe of each session + CAD/installation translation.
|
||||
for sid in session_ids:
|
||||
first = next(kid for kid in keyframe_ids if kf_session[kid] == sid)
|
||||
idx0 = id_to_idx[first]
|
||||
out.append(50.0 * (bgs[idx0] - bg0))
|
||||
out.append(20.0 * bas[idx0])
|
||||
if t_prior_vec is not None:
|
||||
out.append((t_opt - t_prior_vec) / np.maximum(t_sigma, 1e-3))
|
||||
else:
|
||||
out.append(0.2 * t_opt) # soft |t|~0 prior when no CAD prior
|
||||
return np.concatenate(out)
|
||||
|
||||
# Cap evaluations: Phase-C is high-dimensional; synthetic ICP already dominates runtime.
|
||||
@@ -331,6 +368,9 @@ def solve_joint_extrinsic(
|
||||
gravity_init_m_s2: np.ndarray | None = None,
|
||||
bias_prior_sigma_rad_s: float = 0.02,
|
||||
enable_phase_c: bool | None = None,
|
||||
t_init_m: np.ndarray | None = None,
|
||||
t_prior_m: np.ndarray | None = None,
|
||||
t_prior_sigma_m: np.ndarray | float | None = None,
|
||||
) -> JointExtrinsicResult:
|
||||
"""Refine extrinsic using Phase-A whitened rotation factors, optional Phase-C SE(3)."""
|
||||
|
||||
@@ -344,6 +384,7 @@ def solve_joint_extrinsic(
|
||||
|
||||
r = orthonormalize_rotation(np.asarray(r_x, dtype=float))
|
||||
bias0 = np.zeros(3) if gyro_bias_rad_s is None else np.asarray(gyro_bias_rad_s, dtype=float).reshape(3)
|
||||
t_seed = None if t_init_m is None else np.asarray(t_init_m, dtype=float).reshape(3)
|
||||
weights = np.asarray([_pair_weight(pair) for pair in usable], dtype=float)
|
||||
whitens = [residual_whiten_matrix(_pair_cov(pair)) for pair in usable]
|
||||
prior_w = 1.0 / max(bias_prior_sigma_rad_s, 1e-4)
|
||||
@@ -389,7 +430,7 @@ def solve_joint_extrinsic(
|
||||
rot_errs.append(np.degrees(np.linalg.norm(err)))
|
||||
rot_rms = float(np.sqrt(np.mean(np.square(rot_errs)))) if rot_errs else 1e9
|
||||
|
||||
t = np.zeros(3)
|
||||
t = np.zeros(3) if t_seed is None else t_seed.copy()
|
||||
translation_accepted = False
|
||||
trans_rms = 1e9
|
||||
gravity_out: np.ndarray | None = None
|
||||
@@ -400,6 +441,12 @@ def solve_joint_extrinsic(
|
||||
else:
|
||||
gravity_init = np.asarray(gravity_init_m_s2, dtype=float).reshape(3)
|
||||
|
||||
if t_prior_m is not None:
|
||||
notes.append(
|
||||
"using CAD/installation translation prior "
|
||||
f"t={np.asarray(t_prior_m, dtype=float).reshape(3).tolist()}"
|
||||
)
|
||||
|
||||
if (
|
||||
enable_phase_c
|
||||
and not force_rotation_only
|
||||
@@ -412,12 +459,23 @@ def solve_joint_extrinsic(
|
||||
r,
|
||||
gyro_bias0=bias_out,
|
||||
gravity_init=gravity_init,
|
||||
t_init=t_seed if t_seed is not None else t_prior_m,
|
||||
t_prior=t_prior_m,
|
||||
t_prior_sigma_m=t_prior_sigma_m,
|
||||
)
|
||||
notes.extend(c_notes)
|
||||
translation_accepted = bool(trans_rms < 0.75 and np.linalg.norm(t) > 1e-4)
|
||||
if not translation_accepted:
|
||||
notes.append("phase-C translation residual/gate failed; keeping translation at zero")
|
||||
t = np.zeros(3)
|
||||
# Prefer CAD prior over silent zero when motion SE3 is rejected.
|
||||
if t_prior_m is not None:
|
||||
t = np.asarray(t_prior_m, dtype=float).reshape(3)
|
||||
translation_accepted = True
|
||||
notes.append(
|
||||
"phase-C translation residual/gate failed; keeping CAD translation prior"
|
||||
)
|
||||
else:
|
||||
notes.append("phase-C translation residual/gate failed; keeping translation at zero")
|
||||
t = np.zeros(3)
|
||||
elif (
|
||||
not force_rotation_only
|
||||
and observability.translation_observable
|
||||
@@ -437,9 +495,19 @@ def solve_joint_extrinsic(
|
||||
pred = (pair.R_A - np.eye(3)) @ t_opt
|
||||
meas = r_opt @ np.asarray(pair.t_B_m, dtype=float)
|
||||
residuals.append(np.sqrt(weight) * (pred - meas))
|
||||
if t_prior_m is not None:
|
||||
sigma = np.asarray(t_prior_sigma_m if t_prior_sigma_m is not None else 0.05, dtype=float)
|
||||
if sigma.size == 1:
|
||||
sigma = np.full(3, float(sigma), dtype=float)
|
||||
residuals.append((t_opt - np.asarray(t_prior_m, dtype=float).reshape(3)) / np.maximum(sigma, 1e-3))
|
||||
return np.concatenate(residuals)
|
||||
|
||||
opt_t = least_squares(residual_se3, np.zeros(6), loss="huber", f_scale=0.05, max_nfev=200)
|
||||
x_se3 = np.zeros(6)
|
||||
if t_seed is not None:
|
||||
x_se3[3:] = t_seed
|
||||
elif t_prior_m is not None:
|
||||
x_se3[3:] = np.asarray(t_prior_m, dtype=float).reshape(3)
|
||||
opt_t = least_squares(residual_se3, x_se3, loss="huber", f_scale=0.05, max_nfev=200)
|
||||
r = orthonormalize_rotation(so3_exp(opt_t.x[:3]) @ r)
|
||||
t = opt_t.x[3:]
|
||||
rot_errs = []
|
||||
@@ -452,11 +520,15 @@ def solve_joint_extrinsic(
|
||||
trans_errs.append(np.linalg.norm(pred - meas))
|
||||
rot_rms = float(np.sqrt(np.mean(np.square(rot_errs))))
|
||||
trans_rms = float(np.sqrt(np.mean(np.square(trans_errs))))
|
||||
translation_accepted = trans_rms < 0.5
|
||||
translation_accepted = trans_rms < 0.5 or t_prior_m is not None
|
||||
notes.append(f"legacy translation refine rms={trans_rms:.3f} m")
|
||||
if not translation_accepted:
|
||||
notes.append("translation residual too large; keeping translation at zero")
|
||||
t = np.zeros(3)
|
||||
elif not force_rotation_only and t_prior_m is not None:
|
||||
t = np.asarray(t_prior_m, dtype=float).reshape(3)
|
||||
translation_accepted = True
|
||||
notes.append("SE3 motion solve gated off; using CAD translation prior with refined rotation")
|
||||
else:
|
||||
notes.append("rotation-only extrinsic returned (phase-A; phase-C SE3 gated off)")
|
||||
|
||||
|
||||
+164
-87
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from dataclasses import asdict, dataclass, replace
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -13,6 +13,7 @@ from .contracts import (
|
||||
CalibrationRequest,
|
||||
CalibrationResult,
|
||||
CalibrationStatus,
|
||||
MotionPair,
|
||||
SessionInput,
|
||||
)
|
||||
from .finalize import finalize_result
|
||||
@@ -26,7 +27,10 @@ from .motion_pairs import build_motion_pairs
|
||||
from .rotation_handeye import solve_rotation_handeye
|
||||
from .time_offset import TimeOffsetResult, estimate_time_offset, refine_time_offset_signed
|
||||
from .timestamp_audit import audit_timestamps
|
||||
from .vehicle_config import load_vehicle_config
|
||||
from .vehicle_config import load_vehicle_config, prior_enabled
|
||||
|
||||
# Remap keyframe indices so multi-session Phase-C graphs do not collide.
|
||||
_SESSION_INDEX_OFFSET = 1_000_000
|
||||
|
||||
|
||||
def _merge_time_offset(previous: TimeOffsetResult, refined: TimeOffsetResult) -> TimeOffsetResult:
|
||||
@@ -49,11 +53,11 @@ STAGES = (
|
||||
PipelineStage("vehicle_config", "加载并校验当前车辆安装配置"),
|
||||
PipelineStage("timestamp_audit", "审查 IMU 与 LiDAR 时间域"),
|
||||
PipelineStage("imu_audit", "审查单位、轴向启发与静止零偏"),
|
||||
PipelineStage("time_offset", "粗估 δt,并用 R 做有符号三轴精修"),
|
||||
PipelineStage("lidar_motion", "关键帧、可选去畸变与 LiDAR 相对运动"),
|
||||
PipelineStage("motion_pairs", "IMU 预积分与雷达配准,构造相对运动对"),
|
||||
PipelineStage("rotation_handeye", "加权求解旋转外参"),
|
||||
PipelineStage("joint_optimizer", "联合精修;完整模式下可估计平移"),
|
||||
PipelineStage("time_offset", "各会话独立粗估/精修 δt"),
|
||||
PipelineStage("lidar_motion", "各会话关键帧、可选去畸变与 LiDAR 相对运动"),
|
||||
PipelineStage("motion_pairs", "各会话构造运动对,再合并"),
|
||||
PipelineStage("rotation_handeye", "用全部会话运动对联合求解旋转外参"),
|
||||
PipelineStage("joint_optimizer", "用全部会话运动对联合精修;完整模式估平移"),
|
||||
PipelineStage("finalize", "写出结果与质量报告"),
|
||||
)
|
||||
|
||||
@@ -92,21 +96,34 @@ def _build_pairs_and_handeye(
|
||||
return keyframes, pair_set, handeye
|
||||
|
||||
|
||||
def _session_details(
|
||||
def _translation_prior_from_config(
|
||||
vehicle_config: dict[str, Any] | None,
|
||||
) -> tuple[np.ndarray | None, np.ndarray | float | None]:
|
||||
if vehicle_config is None or not prior_enabled(vehicle_config, "translation_prior"):
|
||||
return None, None
|
||||
init_cfg = vehicle_config.get("initialization") or {}
|
||||
tp = init_cfg.get("translation_prior") or {}
|
||||
if tp.get("t_IMU_lidar_m") is None:
|
||||
return None, None
|
||||
return np.asarray(tp["t_IMU_lidar_m"], dtype=float).reshape(3), tp.get("sigma_m", [0.05, 0.05, 0.05])
|
||||
|
||||
|
||||
def _prepare_session_pairs(
|
||||
session: SessionInput,
|
||||
request: CalibrationRequest,
|
||||
vehicle_config: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Per-session: audit, δt, keyframes/pairs. No joint extrinsic yet."""
|
||||
|
||||
imu = load_imu_samples(session.imu_source)
|
||||
frames = load_lidar_frames(session.lidar_source)
|
||||
|
||||
ts = audit_timestamps(imu, frames)
|
||||
if not ts.ok:
|
||||
return {"ok": False, "stage": "timestamp_audit", "report": asdict(ts)}
|
||||
return {"ok": False, "stage": "timestamp_audit", "session_id": session.session_id, "report": asdict(ts)}
|
||||
|
||||
imu_report = audit_imu(imu)
|
||||
if not imu_report.ok:
|
||||
return {"ok": False, "stage": "imu_audit", "report": asdict(imu_report)}
|
||||
return {"ok": False, "stage": "imu_audit", "session_id": session.session_id, "report": asdict(imu_report)}
|
||||
|
||||
offset = estimate_time_offset(
|
||||
imu,
|
||||
@@ -115,7 +132,7 @@ def _session_details(
|
||||
search_s=request.time_offset_search_s,
|
||||
)
|
||||
if not offset.ok:
|
||||
return {"ok": False, "stage": "time_offset", "report": asdict(offset)}
|
||||
return {"ok": False, "stage": "time_offset", "session_id": session.session_id, "report": asdict(offset)}
|
||||
|
||||
working_frames = frames
|
||||
r_x = np.eye(3)
|
||||
@@ -124,7 +141,6 @@ def _session_details(
|
||||
keyframes = None
|
||||
pairs_notes: list[str] = []
|
||||
pair_count = 0
|
||||
time_offset_notes = list(offset.notes)
|
||||
|
||||
for iteration in range(max(1, request.max_iterations)):
|
||||
if iteration > 0:
|
||||
@@ -145,22 +161,21 @@ def _session_details(
|
||||
)
|
||||
pairs_notes = list(pair_set.notes)
|
||||
pair_count = len(pair_set.pairs)
|
||||
if handeye.pair_count < 3:
|
||||
if pair_count < 3:
|
||||
return {
|
||||
"ok": False,
|
||||
"stage": "rotation_handeye",
|
||||
"stage": "motion_pairs",
|
||||
"session_id": session.session_id,
|
||||
"iteration": iteration,
|
||||
"time_offset": asdict(offset),
|
||||
"imu_audit": asdict(imu_report),
|
||||
"timestamp_audit": asdict(ts),
|
||||
"keyframes": len(keyframes.indices),
|
||||
"keyframes": 0 if keyframes is None else len(keyframes.indices),
|
||||
"pair_notes": pairs_notes,
|
||||
"handeye": asdict(handeye),
|
||||
}
|
||||
# Use candidate R even if RMS gate failed, so signed δt refine can still run.
|
||||
r_x = handeye.R_IMU_lidar
|
||||
|
||||
# Phase-A: alternate signed δt refine with current R (up to 2 rounds).
|
||||
for _ in range(2):
|
||||
refined = refine_time_offset_signed(
|
||||
imu,
|
||||
@@ -172,7 +187,6 @@ def _session_details(
|
||||
)
|
||||
delta_shift = abs(refined.delta_t_s - offset.delta_t_s)
|
||||
offset = _merge_time_offset(offset, refined)
|
||||
time_offset_notes = list(offset.notes)
|
||||
if delta_shift < 1e-3:
|
||||
break
|
||||
keyframes, pair_set, handeye = _build_pairs_and_handeye(
|
||||
@@ -185,70 +199,47 @@ def _session_details(
|
||||
)
|
||||
pairs_notes = list(pair_set.notes)
|
||||
pair_count = len(pair_set.pairs)
|
||||
if handeye.pair_count < 3:
|
||||
if pair_count < 3:
|
||||
return {
|
||||
"ok": False,
|
||||
"stage": "rotation_handeye",
|
||||
"stage": "motion_pairs",
|
||||
"session_id": session.session_id,
|
||||
"iteration": iteration,
|
||||
"time_offset": asdict(offset),
|
||||
"imu_audit": asdict(imu_report),
|
||||
"timestamp_audit": asdict(ts),
|
||||
"keyframes": len(keyframes.indices),
|
||||
"keyframes": 0 if keyframes is None else len(keyframes.indices),
|
||||
"pair_notes": pairs_notes,
|
||||
"handeye": asdict(handeye),
|
||||
}
|
||||
r_x = handeye.R_IMU_lidar
|
||||
|
||||
if not handeye.ok:
|
||||
return {
|
||||
"ok": False,
|
||||
"stage": "rotation_handeye",
|
||||
"iteration": iteration,
|
||||
"time_offset": asdict(offset),
|
||||
"imu_audit": asdict(imu_report),
|
||||
"timestamp_audit": asdict(ts),
|
||||
"keyframes": len(keyframes.indices),
|
||||
"pair_notes": pairs_notes,
|
||||
"handeye": asdict(handeye),
|
||||
}
|
||||
|
||||
assert handeye is not None and pair_set is not None and keyframes is not None
|
||||
force_rotation_only = request.requested_mode == CalibrationMode.ROTATION_ONLY
|
||||
# Specific force opposing measured specific force ≈ −g in the static IMU frame.
|
||||
acc_mean = np.asarray(imu_report.static_acc_mean_m_s2, dtype=float).reshape(3)
|
||||
acc_n = float(np.linalg.norm(acc_mean))
|
||||
if acc_n > 1e-6:
|
||||
gravity_init = -acc_mean * (9.80665 / acc_n)
|
||||
else:
|
||||
gravity_init = np.array([0.0, 0.0, -9.80665])
|
||||
joint = solve_joint_extrinsic(
|
||||
pair_set.pairs,
|
||||
r_x,
|
||||
force_rotation_only=force_rotation_only,
|
||||
imu=imu,
|
||||
delta_t_s=offset.delta_t_s,
|
||||
gyro_bias_rad_s=imu_report.gyro_bias_rad_s,
|
||||
gravity_init_m_s2=gravity_init,
|
||||
enable_phase_c=not force_rotation_only,
|
||||
)
|
||||
|
||||
offset_payload = asdict(offset)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"session_id": session.session_id,
|
||||
"vehicle_config_loaded": vehicle_config is not None,
|
||||
"pairs": tuple(pair_set.pairs),
|
||||
"gyro_bias_rad_s": np.asarray(imu_report.gyro_bias_rad_s, dtype=float).reshape(3),
|
||||
"gravity_init_m_s2": gravity_init,
|
||||
"timestamp_audit": asdict(ts),
|
||||
"imu_audit": {
|
||||
**asdict(imu_report),
|
||||
"gyro_bias_rad_s": imu_report.gyro_bias_rad_s.tolist(),
|
||||
"static_acc_mean_m_s2": imu_report.static_acc_mean_m_s2.tolist(),
|
||||
},
|
||||
"time_offset": offset_payload,
|
||||
"time_offset": asdict(offset),
|
||||
"time_offset_s": float(offset.delta_t_s),
|
||||
"keyframes": len(keyframes.indices),
|
||||
"pair_count": pair_count,
|
||||
"pair_notes": pairs_notes,
|
||||
"handeye": {
|
||||
"handeye_local": {
|
||||
"residual_rms_deg": handeye.residual_rms_deg,
|
||||
"residual_median_deg": handeye.residual_median_deg,
|
||||
"pair_count": handeye.pair_count,
|
||||
@@ -256,32 +247,30 @@ def _session_details(
|
||||
"notes": handeye.notes,
|
||||
"R_IMU_lidar": handeye.R_IMU_lidar.tolist(),
|
||||
},
|
||||
"joint": {
|
||||
"translation_accepted": joint.translation_accepted,
|
||||
"residual_rms_rot_deg": joint.residual_rms_rot_deg,
|
||||
"residual_rms_trans_m": joint.residual_rms_trans_m,
|
||||
"observability": asdict(joint.observability),
|
||||
"notes": joint.notes,
|
||||
"T_IMU_lidar": joint.T_IMU_lidar.tolist(),
|
||||
"gyro_bias_rad_s": None
|
||||
if joint.gyro_bias_rad_s is None
|
||||
else np.asarray(joint.gyro_bias_rad_s, dtype=float).tolist(),
|
||||
"accel_bias_m_s2": None
|
||||
if joint.accel_bias_m_s2 is None
|
||||
else np.asarray(joint.accel_bias_m_s2, dtype=float).tolist(),
|
||||
"gravity_m_s2": None
|
||||
if joint.gravity_m_s2 is None
|
||||
else np.asarray(joint.gravity_m_s2, dtype=float).tolist(),
|
||||
},
|
||||
"T_IMU_lidar": joint.T_IMU_lidar,
|
||||
"time_offset_s": offset.delta_t_s,
|
||||
"translation_accepted": joint.translation_accepted,
|
||||
"rotation_ok": handeye.ok and joint.observability.rotation_observable,
|
||||
}
|
||||
|
||||
|
||||
def _remap_pairs_for_joint(prepared: list[dict[str, Any]]) -> list[MotionPair]:
|
||||
merged: list[MotionPair] = []
|
||||
for index, prep in enumerate(prepared):
|
||||
id_offset = (index + 1) * _SESSION_INDEX_OFFSET
|
||||
for pair in prep["pairs"]:
|
||||
merged.append(
|
||||
replace(
|
||||
pair,
|
||||
i=int(pair.i) + id_offset,
|
||||
j=int(pair.j) + id_offset,
|
||||
)
|
||||
)
|
||||
return merged
|
||||
|
||||
|
||||
def run_calibration(request: CalibrationRequest) -> CalibrationResult:
|
||||
"""Run the V1 calibration pipeline for one or more sessions."""
|
||||
"""Run the V1 calibration pipeline for one or more sessions.
|
||||
|
||||
Multi-session: each session estimates its own δt and builds motion pairs;
|
||||
rotation hand-eye and joint SE3 are solved once on the merged pair set.
|
||||
"""
|
||||
|
||||
if not request.sessions:
|
||||
return finalize_result(
|
||||
@@ -303,38 +292,123 @@ def run_calibration(request: CalibrationRequest) -> CalibrationResult:
|
||||
output_directory=request.output_directory,
|
||||
)
|
||||
|
||||
session_results = []
|
||||
prepared: list[dict[str, Any]] = []
|
||||
for session in request.sessions:
|
||||
session_results.append(_session_details(session, request, vehicle_config))
|
||||
prep = _prepare_session_pairs(session, request)
|
||||
if not prep.get("ok"):
|
||||
return finalize_result(
|
||||
status=CalibrationStatus.BLOCKED,
|
||||
message=f"blocked at stage {prep.get('stage')} ({prep.get('session_id')})",
|
||||
details={"sessions": [prep]},
|
||||
output_directory=request.output_directory,
|
||||
)
|
||||
prepared.append(prep)
|
||||
|
||||
primary = session_results[0]
|
||||
if not primary.get("ok"):
|
||||
all_pairs = _remap_pairs_for_joint(prepared)
|
||||
handeye = solve_rotation_handeye(all_pairs)
|
||||
if not handeye.ok:
|
||||
return finalize_result(
|
||||
status=CalibrationStatus.BLOCKED,
|
||||
message=f"blocked at stage {primary.get('stage')}",
|
||||
details={"sessions": session_results},
|
||||
message="blocked at stage rotation_handeye (joint)",
|
||||
details={
|
||||
"sessions": [_public_session(p) for p in prepared],
|
||||
"joint_handeye": asdict(handeye),
|
||||
"merged_pair_count": len(all_pairs),
|
||||
},
|
||||
output_directory=request.output_directory,
|
||||
)
|
||||
|
||||
T = np.asarray(primary["T_IMU_lidar"], dtype=float)
|
||||
delta_t = float(primary["time_offset_s"])
|
||||
force_rotation_only = request.requested_mode == CalibrationMode.ROTATION_ONLY
|
||||
t_prior, t_prior_sigma = _translation_prior_from_config(vehicle_config)
|
||||
gyro_bias = np.mean(np.stack([p["gyro_bias_rad_s"] for p in prepared], axis=0), axis=0)
|
||||
gravity_init = np.mean(np.stack([p["gravity_init_m_s2"] for p in prepared], axis=0), axis=0)
|
||||
g_n = float(np.linalg.norm(gravity_init))
|
||||
if g_n > 1e-6:
|
||||
gravity_init = gravity_init * (9.80665 / g_n)
|
||||
|
||||
joint = solve_joint_extrinsic(
|
||||
all_pairs,
|
||||
handeye.R_IMU_lidar,
|
||||
force_rotation_only=force_rotation_only,
|
||||
imu=None,
|
||||
delta_t_s=0.0,
|
||||
gyro_bias_rad_s=gyro_bias,
|
||||
gravity_init_m_s2=gravity_init,
|
||||
enable_phase_c=not force_rotation_only,
|
||||
t_init_m=t_prior,
|
||||
t_prior_m=t_prior,
|
||||
t_prior_sigma_m=t_prior_sigma,
|
||||
)
|
||||
|
||||
session_results = []
|
||||
for prep in prepared:
|
||||
session_results.append(
|
||||
{
|
||||
**_public_session(prep),
|
||||
"vehicle_config_loaded": vehicle_config is not None,
|
||||
"handeye": {
|
||||
"residual_rms_deg": handeye.residual_rms_deg,
|
||||
"residual_median_deg": handeye.residual_median_deg,
|
||||
"pair_count": handeye.pair_count,
|
||||
"ok": handeye.ok,
|
||||
"notes": tuple(list(handeye.notes) + [f"joint over {len(request.sessions)} sessions"]),
|
||||
"R_IMU_lidar": handeye.R_IMU_lidar.tolist(),
|
||||
},
|
||||
"joint": {
|
||||
"translation_accepted": joint.translation_accepted,
|
||||
"residual_rms_rot_deg": joint.residual_rms_rot_deg,
|
||||
"residual_rms_trans_m": joint.residual_rms_trans_m,
|
||||
"observability": asdict(joint.observability),
|
||||
"notes": joint.notes,
|
||||
"T_IMU_lidar": joint.T_IMU_lidar.tolist(),
|
||||
"gyro_bias_rad_s": None
|
||||
if joint.gyro_bias_rad_s is None
|
||||
else np.asarray(joint.gyro_bias_rad_s, dtype=float).tolist(),
|
||||
"accel_bias_m_s2": None
|
||||
if joint.accel_bias_m_s2 is None
|
||||
else np.asarray(joint.accel_bias_m_s2, dtype=float).tolist(),
|
||||
"gravity_m_s2": None
|
||||
if joint.gravity_m_s2 is None
|
||||
else np.asarray(joint.gravity_m_s2, dtype=float).tolist(),
|
||||
},
|
||||
"translation_accepted": joint.translation_accepted,
|
||||
"rotation_ok": handeye.ok and joint.observability.rotation_observable,
|
||||
}
|
||||
)
|
||||
|
||||
T = np.asarray(joint.T_IMU_lidar, dtype=float)
|
||||
# Report per-session δt list; keep first as scalar for backward-compatible field.
|
||||
delta_t = float(prepared[0]["time_offset_s"])
|
||||
if request.requested_mode == CalibrationMode.FULL_SE3:
|
||||
if primary.get("translation_accepted"):
|
||||
if joint.translation_accepted:
|
||||
status = CalibrationStatus.FULL_SE3_ACCEPTED
|
||||
message = "full SE3 accepted"
|
||||
message = f"full SE3 accepted (joint {len(prepared)} sessions, {len(all_pairs)} pairs)"
|
||||
else:
|
||||
status = CalibrationStatus.FULL_SE3_REJECTED
|
||||
message = "rotation accepted; translation rejected by observability/residual gates"
|
||||
message = (
|
||||
f"rotation accepted jointly ({len(prepared)} sessions); "
|
||||
"translation rejected by observability/residual gates"
|
||||
)
|
||||
else:
|
||||
status = CalibrationStatus.ROTATION_ONLY_ACCEPTED
|
||||
message = "rotation-only calibration accepted"
|
||||
message = f"rotation-only calibration accepted (joint {len(prepared)} sessions, {len(all_pairs)} pairs)"
|
||||
T = T.copy()
|
||||
T[:3, 3] = 0.0
|
||||
|
||||
return finalize_result(
|
||||
status=status,
|
||||
message=message,
|
||||
details={"sessions": [_public_session(s) for s in session_results]},
|
||||
details={
|
||||
"sessions": session_results,
|
||||
"joint": {
|
||||
"session_count": len(prepared),
|
||||
"merged_pair_count": len(all_pairs),
|
||||
"pair_counts_per_session": {p["session_id"]: p["pair_count"] for p in prepared},
|
||||
"time_offset_s_per_session": {p["session_id"]: p["time_offset_s"] for p in prepared},
|
||||
"handeye_rms_deg": handeye.residual_rms_deg,
|
||||
"translation_accepted": joint.translation_accepted,
|
||||
},
|
||||
},
|
||||
T_IMU_lidar=T,
|
||||
time_offset_s=delta_t,
|
||||
output_directory=request.output_directory,
|
||||
@@ -344,4 +418,7 @@ def run_calibration(request: CalibrationRequest) -> CalibrationResult:
|
||||
def _public_session(session_result: dict[str, Any]) -> dict[str, Any]:
|
||||
payload = dict(session_result)
|
||||
payload.pop("T_IMU_lidar", None)
|
||||
payload.pop("pairs", None)
|
||||
payload.pop("gyro_bias_rad_s", None)
|
||||
payload.pop("gravity_init_m_s2", None)
|
||||
return payload
|
||||
|
||||
@@ -73,7 +73,10 @@ def _correlate_offset(
|
||||
y0, y1, y2 = peaks
|
||||
denom = y0 - 2 * y1 + y2
|
||||
if abs(denom) > 1e-12:
|
||||
best_delta = float(best_delta + 0.5 * (y0 - y2) / denom * dt)
|
||||
refined = float(best_delta + 0.5 * (y0 - y2) / denom * dt)
|
||||
# Parabola can jump outside the searched window; keep it clamped.
|
||||
if abs(refined) <= search_s + dt:
|
||||
best_delta = refined
|
||||
best_peak = float(y1)
|
||||
return best_delta, best_peak
|
||||
|
||||
@@ -99,9 +102,12 @@ def estimate_time_offset(
|
||||
bias = np.zeros(3) if gyro_bias_rad_s is None else np.asarray(gyro_bias_rad_s, dtype=float)
|
||||
gyro = imu.gyro_rad_s - bias
|
||||
|
||||
stride = max(1, len(frames) // 20)
|
||||
# Use short consecutive (or near-consecutive) pairs. A large stride (e.g.
|
||||
# len//20) averages over many seconds and destroys |ω| correlation even when
|
||||
# host/device clocks are already aligned.
|
||||
stride = 1 if len(frames) < 80 else 2
|
||||
rotations, pair_times = estimate_frame_rotations(frames, stride=stride)
|
||||
if len(rotations) < 4:
|
||||
if len(rotations) < 8:
|
||||
rotations, pair_times = estimate_frame_rotations(frames, stride=1)
|
||||
if len(rotations) < 4:
|
||||
return TimeOffsetResult(0.0, 0.0, search_s, ("not enough LiDAR relative rotations",), False)
|
||||
|
||||
Reference in New Issue
Block a user