40 changed files with 6605 additions and 498 deletions
+17 -5
View File
@@ -44,12 +44,15 @@ p_IMU = T_IMU_lidar · p_lidar
| `summary.json` | 状态、残差、可观性 | | `summary.json` | 状态、残差、可观性 |
新车原始数据导出(H32 dlog + N300 rscap): 新车原始数据导出(H32 dlog/zip + HI13 rscap):
```powershell ```powershell
python tools\export_rscap_to_v1.py ` python tools\export_rscap_to_v1.py `
--imu-rscap path\to\n300.rscap ` --imu-rscap path\to\hi13r4-imu.rscap `
--lidar-dlog path\to\session_or_dlog ` --imu-kind hi13 `
--lidar-dlog path\to\session_or_recovered.zip `
--host-start 2026-08-08T17:40:05 `
--host-end 2026-08-08T17:45:15 `
--out path\to\session_v1 ` --out path\to\session_v1 `
--require-difop --require-difop
``` ```
@@ -70,16 +73,25 @@ powershell -File tools\reproduce_synthetic.ps1
证明:链路可跑通,能收回已知 yaw / δt。 证明:链路可跑通,能收回已知 yaw / δt。
不证明:实车安装精度、平移可交付。 不证明:实车安装精度、平移可交付。
产物在 `examples/synthetic_session/out/`。叠点查看: 产物在 `examples/synthetic_session/out/`(含 `summary.json``motion_pairs.json`。叠点查看:
```powershell ```powershell
# 优先读取 summary 同目录的 motion_pairs.json,按需加载点云(无需重算配准)
python tools\visualize_pair_3d.py ` python tools\visualize_pair_3d.py `
--lidar examples\synthetic_session\lidar ` --lidar examples\synthetic_session\lidar `
--imu examples\synthetic_session\imu.csv `
--summary examples\synthetic_session\out\summary.json ` --summary examples\synthetic_session\out\summary.json `
--pair-index 0 --pair-index 0
``` ```
旧标定目录若缺少缓存,可只补导出运动对(不重求解外参):
```powershell
python tools\export_motion_pairs_for_viz.py `
--lidar path\to\lidar `
--imu path\to\imu.csv `
--summary path\to\out\summary.json
```
`1``4` 切换叠点模式;`N`/`P` 切换运动对。 `1``4` 切换叠点模式;`N`/`P` 切换运动对。
--- ---
+86
View File
@@ -0,0 +1,86 @@
schema_version: 1
vehicle:
vehicle_id: "outdoor_usable_20260808"
body_frame:
name: "base_link"
# 车体约定:后轮轴中心在地面投影为原点附近参考;X 前 / Y 左 / Z 上
# translation_m 的 Z 使用「离地高度」;后轮轴中心离地 294 mm
axes: "X forward, Y left, Z up"
unit: m
reference_point: "rear_axle_center_xy__z_above_ground"
rear_axle_height_above_ground_m: 0.294
installation:
installation_id: "20260808_priority_windows"
installed_at: "2026-08-08"
notes: >
HI13R4 + H32 DLogCapture. Body +X forward: LiDAR and IMU at positive X.
CAD sheet may draw +X rearward; numbers below are body-frame.
Z is height above ground = CAD height at axle + 0.294 m (axle AGL).
LiDAR CAD dZ is 1637.499879 mm relative to the axle reference. Phase-center
AGL adds rear-axle height 294 mm and the 63.5 mm phase-center offset.
IMU axes: HI13R4 manual §2.4 RFU (X right, Y forward, Z up).
LiDAR Cartesian in NPZ assumed body-aligned (X forward).
sensors:
imu:
model: "HI13R4"
raw_frame:
# HI13R4 用户手册 2.4:右-前-上 (RFU)
axes: "X right, Y forward, Z up (RFU)"
driver_axis_remapped: false
mount_in_body:
# X/Y:后轮轴中心 → IMUZ:离地 = CAD 0.8925 + 0.294
translation_m: [2.574126255, 0.0365, 1.1865]
# body <- imu : p_body = R_body_imu * p_imu
# R_body_imu = [[0,1,0],[-1,0,0],[0,0,1]] (fwd=imu_y, left=-imu_x, up=imu_z)
rotation_matrix_body_imu: [[0.0, 1.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 0.0, 1.0]]
rotation_quaternion_xyzw: null
source: "CAD X/Y in body (+X forward); Z = CAD axle-height + 294mm AGL + HI13R4 RFU"
lidar:
model: "RSLidarH32"
points_field: points
raw_frame:
axes: "X forward, Y left, Z up (Cartesian metres in NPZ points)"
driver_axis_remapped: false
mount_in_body:
# X/Y:后轮轴中心 → 雷达
# Z离地 = CAD dZ 1.637499879 + 后轮轴离地 0.294 + 相位中心偏移 0.0635
translation_m: [2.522276859, 0.000020526, 1.994999879]
rotation_matrix_body_lidar: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]
rotation_quaternion_xyzw: null
source: "CAD X/Y in body (+X forward); Z AGL = CAD dZ 1.637499879 + axle AGL 0.294 + phase-center offset 0.0635; attitude = body"
rtk:
frame_definition: ""
reference_point: ""
existing_T_RTK_LIDAR_file: ""
time:
imu_timestamp_source: "hi13_device_timestamp_ms_seconds"
lidar_timestamp_source: "h32_msop_device_timestamp_seconds"
lidar_frame_time_definition: "t_start/t_end in frames_index.csv; pipeline uses midpoint"
host_bridge: "MSOP HostReceiveUtcTicks + IMU receive_utc_ticks"
# Derived prior for p_IMU = R_IMU_lidar * p_lidar + t_IMU_lidar
# t_body = t_lidar_body - t_imu_body
# t_IMU_lidar = R_IMU_body * t_body, R_IMU_lidar = R_IMU_body * R_body_lidar
derived_T_IMU_lidar_prior:
R_IMU_lidar: [[0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]]
t_IMU_lidar_m: [0.036479474, -0.051849396, 0.808499879]
t_lidar_from_imu_in_body_m: [-0.051849396, -0.036479474, 0.808499879]
notes: >
Rotation prior ~90 deg yaw (body/lidar X-fwd vs IMU Y-fwd).
Relative Z = 1.994999879 - 1.1865 = 0.808499879 m.
initialization:
translation_prior:
enabled: true
sigma_m: [0.05, 0.05, 0.05]
t_IMU_lidar_m: [0.036479474, -0.051849396, 0.808499879]
rotation_prior:
enabled: true
sigma_deg: 15.0
R_IMU_lidar: [[0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]]
@@ -0,0 +1,209 @@
# 20260808 HI13 + H32LiDARIMU 标定现状与问题
> 数据:`D:\data\calibration_usable_20260808`
> 可用会话:`sessions_v1_host_aligned`(三优先窗)
> 当前结果目录:各窗 `out_fixed_dt0/`
> 清单:`sessions_v1_host_aligned/calibration_manifest_fixed_dt0.json`
> 车辆配置:`config/vehicle_hi13_h32_20260808.yaml`
> 约定外参:`p_IMU = T_IMU_lidar · p_lidar`
---
## 1. 一句话结论
**旋转 + 主机桥接时间对齐可以冻结;平移(full_se3)尚不可正式交付。**
三窗 `rotation_only`(δt=0)结果跨窗一致,**不必因平移先验 Z 修正而重跑旋转**。
---
## 2. 当前可用结果(`out_fixed_dt0`
约定:`p_IMU = T_IMU_lidar · p_lidar`;本轮交付 **仅旋转**`t = [0,0,0]``time_offset_s = 0`
| 窗 | 状态 | δt | roll/pitch/yaw (°) | 手眼 RMS (°) | 手眼对数 | vs CAD prior |
|----|------|----|---------------------|--------------|----------|--------------|
| `priority_174005_174515` | `rotation_only_accepted` | 0 | 0.398 / +0.108 / **89.998** | 0.625 | 1374 | 0.413° |
| `priority_174905_175450` | 同上 | 0 | 0.316 / 0.352 / **90.002** | 0.293 | 1182 | 0.473° |
| `priority_175910_180530` | 同上 | 0 | 0.373 / 0.036 / **90.005** | 0.786 | 789 | 0.375° |
- 跨窗旋转互差约 **0.15°–0.47°**(相对三窗均值 ≤0.26°)。
- CAD/安装平移先验只用于后续 SE3 / 校验,不写入本轮交付 `T`
- 原始摘要:各窗 `out_fixed_dt0/summary.json`;总表 `calibration_manifest_fixed_dt0.json`
### 2.1 窗1 `priority_174005_174515` — `R_IMU_lidar`
- 路径:`...\priority_174005_174515\out_fixed_dt0\summary.json`
- rpy_deg_xyz`[-0.39806616272552936, 0.10842366761721789, 89.99788311264182]`
- quaternion_xyzw`[-0.0031254048203223084, -0.0017872288323561246, 0.7070914597978358, 0.707112936622415]`
```text
R =
[[ 3.6946588133e-05, -0.9999758655693408, -0.0069474393698490 ],
[ 0.9999982088237712, 2.3798651350e-05, 0.0018925598731371 ],
[-0.0018923488575947, -0.0069474968493909, 0.9999740753156198 ]]
t = [0, 0, 0]
```
### 2.2 窗2 `priority_174905_175450` — `R_IMU_lidar`
- 路径:`...\priority_174905_175450\out_fixed_dt0\summary.json`
- rpy_deg_xyz`[-0.31554362742542746, -0.35231680831805484, 90.00193338170823]`
- quaternion_xyzw`[0.00022698471903919405, -0.004121119694188399, 0.7071067019847445, 0.7070948146172911]`
```text
R =
[[-3.3743238552e-05, -0.9999848355714863, -0.0055070399001942 ],
[ 0.9999810938467022, 1.2097239027e-07, -0.0061491421465438 ],
[ 0.0061490495645171, -0.0055071432752239, 0.9999659297008071 ]]
t = [0, 0, 0]
```
### 2.3 窗3 `priority_175910_180530` — `R_IMU_lidar`
- 路径:`...\priority_175910_180530\out_fixed_dt0\summary.json`
- rpy_deg_xyz`[-0.37335575043737196, -0.03585856981709423, 90.00538974486676]`
- quaternion_xyzw`[-0.002082462199099642, -0.002525219418619018, 0.7071355299053291, 0.7070704554452735]`
```text
R =
[[-9.4068775206e-05, -0.9999787650354249, -0.0065161821101807 ],
[ 0.9999997997313592, -8.9988606603e-05, -0.0006264497522950 ],
[ 0.0006258500675082, -0.0065162397345547, 0.9999785732361544 ]]
t = [0, 0, 0]
```
### 相对历史失败轮次
| 轮次 | 问题 | 结果 |
|------|------|------|
| `sessions_v1_aligned` | 首帧强行对齐设备钟 | 三窗手眼失败,RMS ~9°–12° |
| 自由估 δt + signed refine | 窗3 δt 漂到 0.48 s;窗2 yaw≈19° | 跨窗 yaw 矛盾(81°/19°/93°) |
| **本轮 fixed δt=0** | 主机桥接后冻结时间 | 三窗 yaw≈90°,可互证 |
---
## 3. 已澄清并写入配置的坐标系 / 先验
### 3.1 车体与传感器
- 车体:X 前 / Y 左 / Z 上;雷达与 IMU 安装在 **X 正方向**(后轮轴前方)。
- CAD 图纸可能画成 +X 朝后,那只是读图坐标系,**不是**车体真实轴。
- IMUHI13 RFUX 右 / Y 前 / Z 上),原始数据不做轴向重映射。
- 雷达 NPZ:假定与车体一致(X 前 / Y 左 / Z 上)。
### 3.2 安装量(`translation_m`
| 传感器 | X / Y(后轮轴中心) | Z(离地) |
|--------|---------------------|-----------|
| IMU | 2.574 / 0.0365 m | 0.8925 + 0.294 = **1.1865 m** |
| 雷达 | 2.522 / 0.00002 m | 相位中心离地 **1.994999879 m** |
- 后轮轴中心离地:**294 mm**(Z 用离地高时加在 CAD 轴心高上)。
- 雷达 CAD `dZ=1.637499879 m`;相位中心离地还需加后轮轴离地 `0.294 m` 和相位中心偏移 `0.0635 m`,最终为 `1.994999879 m`
### 3.3 导出外参先验
- `R_IMU_lidar` ≈ yaw 90°:`[[0,-1,0],[1,0,0],[0,0,1]]`(软约束 σ=15°)。
- `t_IMU_lidar`**`[0.0365, -0.0518, 0.8085]` m**(相对 Z = 1.994999879 1.1865 = 0.808499879 m)。
- **旋转先验不因 Z 修正改变**;平移先验 Z 更新为 0.808499879 m。
---
## 4. 现存问题清单
### P1. IMU 预积分平移 `Δp` 不可用(阻塞正式平移)
- 现象:可视化模式 4 若用完整 `X⁻¹ A X`,橙/蓝点云常呈**上下错层**(Z 差米级~几十米)。
- 根因:加速度预积分缺少可靠重力/零偏处理,`t_A` 尤其 Z 发散;**不是旋转外参错了**。
- 旁证:相对 GICP 的旋转残差中位约 0.16°;`|t_A|` 中位却常 >1 m。
- 影响:`full_se3` / 依赖 IMU 位移的平移估计不可信。
- 缓解(已做):`visualize_pair_3d.py``rotation_only` 默认模式 4 = **R 共轭 + GICP 的 t_B**`--mode4-translation gicp|imu|auto`)。
### P2. 平面运动导致竖直平移弱可观
- 三优先窗以水平转弯为主,缺少缓坡/俯仰激励。
- 流水线门控已给出 `translation_accepted=false`
- 即使打开平移先验(σ≈5 cm),弱激励下结果易变成**先验回显**,不宜当标定成功。
### P3. 时间偏移若再自由估计会被带偏(已规避,需保持)
- 主机 UTC 桥接(MSOP/IMU `HostReceiveUtc`)后,两路已在同一时间轴,残差通常几十毫秒量级。
- 若再做有符号 δt 精修,会与错误/未收敛的 R 耦合,窗3 曾从约 −0.12 s 走到 **0.48 s**。
- **现行做法**:桥接会话使用 `--fixed-time-offset-s 0 --no-signed-time-refine`
### P4. 单窗低残差 ≠ 外参正确(历史教训)
- 自由 δt 轮次中,窗2 手眼 RMS 最低(~0.3°)但 yaw≈19°,与 CAD/其他窗差 60°+。
- 平面运动下 yaw 外参可出现多个能拟合 `R_A R_X ≈ R_X R_B` 的解。
- **必须**做跨窗一致性 + 可视化叠点,不能只看单窗 RMS。
### P5. 旋转软先验尚未做无先验对照
- 当前 σ=15°;笔记显示 Tsai 初值本身已接近(约 0.3°–1.1° RMS),不像纯先验硬拽。
- 仍缺一次:关闭先验或放大 `sigma_deg` 的对照,以排除「只是被拉到 90°」的疑虑。
### P6. 文档与操作约定未完全同步(工程)
- README 需明确写清:host-bridge 后固定 δt=0、禁用 signed refine、rotation_only 可视化用法。
- 交付物目前缺一版「冻结的联合/中位 R + 使用说明」JSON/报告(旋转可交,平移明确不交)。
---
## 5. 不该做 / 可以做
| 动作 | 建议 |
|------|------|
| 因 Z 先验修正重跑三窗 rotation_only | **不必**R 未依赖新 t |
| 正式交付 6-DOF / 信赖当前 `Δp` 估 t | **不要** |
| 试验性 `full_se3`(固定 R、δt=0、新 t 先验) | 可做,结果标「实验」 |
| 可视化验收模式 3 vs 4(gicp 平移) | **建议做** |
| 无先验 / 大 σ 旋转对照 | **建议做** |
| 冻结交付 `R` + `δt=0` 说明 | **建议做** |
| 补采缓坡或加强垂直尺寸约束后再估 t | 正式平移前需要 |
---
## 6. 建议下一步顺序
1. **验收旋转**:三窗抽转弯运动对,模式 3/4 叠点;可选无先验对照。
2. **定稿旋转**:三窗中位或联合手眼 → 交付 `R_IMU_lidar` +「δt=0(主机桥接)」说明;**明确不交 t**。
3. **工程收尾**:README 主机桥接配方;需要时再整理联合标定脚本入口。
4. **平移(靠后)**:改善 IMU 位移模型或改用更可靠的位移观测 + 竖直激励后,再用新 `t` 先验跑 SE3。
---
## 7. 常用路径与命令
```text
数据根:
D:\data\calibration_usable_20260808\sessions_v1_host_aligned\
结果:
...\priority_XXXX\out_fixed_dt0\summary.json
...\priority_XXXX\out_fixed_dt0\motion_pairs.json
...\calibration_manifest_fixed_dt0.json
```
```powershell
# 可视化(rotation_only 默认模式4用 GICP 平移)
python tools\visualize_pair_3d.py `
--lidar D:\data\calibration_usable_20260808\sessions_v1_host_aligned\priority_174005_174515\lidar `
--summary D:\data\calibration_usable_20260808\sessions_v1_host_aligned\priority_174005_174515\out_fixed_dt0\summary.json `
--pair-index 0
# 若要看「坏 Δp」导致的错层效果:
# --mode4-translation imu
```
---
## 8. 问题优先级(跟踪用)
| ID | 严重度 | 状态 | 标题 |
|----|--------|------|------|
| P1 | 高 | 未解决 | IMU `Δp` 不可用,阻塞正式平移 |
| P2 | 高 | 未解决 | 平面运动,竖直 t 弱可观 |
| P3 | 高 | 已规避 | 自由 δt / signed refine 带偏(需保持冻结) |
| P4 | 中 | 已吸收教训 | 单窗低残差不可单独验收 |
| P5 | 中 | 待做 | 无旋转先验对照 |
| P6 | 低 | 待做 | README/交付物同步 |
+25 -15
View File
@@ -4,20 +4,24 @@
## 从原始数据导出 ## 从原始数据导出
**推荐(新 H32 插件 `RSLidarH32_3D_DLogCaptureNet48`):** N300 `.rscap` + 雷达 Medulla dlograw MSOP / DIFOP)。 **推荐(新 H32 + HI13):** HI13 `.rscap` + 雷达 Medulla dlog / recovered zipraw MSOP + DIFOP)。
```powershell ```powershell
python tools\export_rscap_to_v1.py ` python tools\export_rscap_to_v1.py `
--imu-rscap path\to\n300.rscap ` --imu-rscap path\to\hi13r4-imu.rscap `
--lidar-dlog path\to\session_or_dlog ` --imu-kind hi13 `
--lidar-dlog path\to\session_or_dlog_or_recovered.zip `
--host-start 2026-08-08T17:40:05 `
--host-end 2026-08-08T17:45:15 `
--out path\to\session_v1 ` --out path\to\session_v1 `
--frame-stride 1 ` --frame-stride 5 `
--require-difop --require-difop
``` ```
`--lidar-dlog` 指向含 `dobject/` + `dobject_recording/` 目录(或其上级含 `dlog/` 子目录亦可)。 `--lidar-dlog` 可为:标准 `dobject/`+`dobject_recording/` 目录,或 recovered zip`indices.log` + `data.bin`)。
默认 DObject`frontlidar-msop-raw``frontlidar-difop-raw`(可用 `--msop-object` / `--difop-object` 覆盖)。 `--imu-kind``hi13` / `n300` / `auto`(默认按文件名推断)。
有 DIFOP 时用设备通道角做 XYZ;`--require-difop` 在缺少有效 DIFOP 时直接失败。 `--host-start/end`:按本地墙钟切窗(仅裁剪;标定主轴仍是设备时间)。
默认 DObject`frontlidar-msop-raw``frontlidar-difop-raw`
**兼容旧 MSOP-only `.rscap`** **兼容旧 MSOP-only `.rscap`**
@@ -30,7 +34,7 @@ python tools\export_rscap_to_v1.py `
``` ```
产出:`imu.csv``lidar/`(含 `frames_index.csv`)、`export_summary.json` 产出:`imu.csv``lidar/`(含 `frames_index.csv`)、`export_summary.json`
时间轴为**设备时间**N300 `device_timestamp_us`→秒;H32 MSOP 设备时间戳→秒。主机接收时间不写入标定主轴 标定主轴仍是**设备时间**;同时写出**主机 UTC 接收时间**,用于把雷达帧桥接到 IMU 设备钟(禁止把两边设备时间第一帧强行重合)
## IMU ## IMU
@@ -39,16 +43,18 @@ python tools\export_rscap_to_v1.py `
### CSV ### CSV
```text ```text
t,gx,gy,gz,ax,ay,az t,gx,gy,gz,ax,ay,az,t_host_utc_s,receive_utc_ticks
0.000000000,0.01,-0.02,0.00,0.05,-0.03,9.81 0.000000000,0.01,-0.02,0.00,0.05,-0.03,9.81,1754646005.123,6389...
... ...
``` ```
| 列 | 含义 | 单位 | | 列 | 含义 | 单位 |
|---|---|---| |---|---|---|
| t | IMU 时钟时间 | s | | t | IMU 设备时钟时间 | s |
| gx,gy,gz | 角速度 | rad/s | | gx,gy,gz | 角速度 | rad/s |
| ax,ay,az | 比力/加速度 | m/s² | | ax,ay,az | 比力/加速度 | m/s² |
| t_host_utc_s | 主机 UTC 接收时间(Unix | s |
| receive_utc_ticks | 同上,.NET UTC ticks | — |
### NPZ ### NPZ
@@ -72,12 +78,16 @@ lidar_session/
### frames_index.csv ### frames_index.csv
```text ```text
frame_id,filename,t_start,t_end frame_id,filename,t_start,t_end,host_receive_utc_ticks,t_host_utc_s,host_receive_utc_end_ticks,t_host_utc_end_s
0,frames/frame_00000.npz,10.000,10.100 0,frames/frame_00000.npz,10.000,10.100,6389...,1754646005.12,6389...,1754646005.22
1,frames/frame_00001.npz,10.100,10.200
``` ```
也兼容旧列名 `file`NumPy 读取时可能变成 `file_`)。 | 列 | 含义 |
|---|---|
| t_start / t_end | H32 MSOP **设备时间**(秒) |
| t_host_utc_s / t_host_utc_end_s | 帧首/末包 **HostReceiveUtcTicks** → Unix 秒 |
也兼容旧列名 `file`。对齐脚本用主机 UTC 把 `t_*` 重写到 IMU 设备钟后再跑标定。
### 每帧 NPZ ### 每帧 NPZ
+209
View File
@@ -0,0 +1,209 @@
# 20260808 HI13 + H32LiDARIMU 标定现状与问题
> 数据:`D:\data\calibration_usable_20260808`
> 可用会话:`sessions_v1_host_aligned`(三优先窗)
> 当前结果目录:各窗 `out_fixed_dt0/`
> 清单:`sessions_v1_host_aligned/calibration_manifest_fixed_dt0.json`
> 车辆配置:`config/vehicle_hi13_h32_20260808.yaml`
> 约定外参:`p_IMU = T_IMU_lidar · p_lidar`
---
## 1. 一句话结论
**旋转 + 主机桥接时间对齐可以冻结;平移(full_se3)尚不可正式交付。**
三窗 `rotation_only`(δt=0)结果跨窗一致,**不必因平移先验 Z 修正而重跑旋转**。
---
## 2. 当前可用结果(`out_fixed_dt0`
约定:`p_IMU = T_IMU_lidar · p_lidar`;本轮交付 **仅旋转**`t = [0,0,0]``time_offset_s = 0`
| 窗 | 状态 | δt | roll/pitch/yaw (°) | 手眼 RMS (°) | 手眼对数 | vs CAD prior |
|----|------|----|---------------------|--------------|----------|--------------|
| `priority_174005_174515` | `rotation_only_accepted` | 0 | 0.398 / +0.108 / **89.998** | 0.625 | 1374 | 0.413° |
| `priority_174905_175450` | 同上 | 0 | 0.316 / 0.352 / **90.002** | 0.293 | 1182 | 0.473° |
| `priority_175910_180530` | 同上 | 0 | 0.373 / 0.036 / **90.005** | 0.786 | 789 | 0.375° |
- 跨窗旋转互差约 **0.15°–0.47°**(相对三窗均值 ≤0.26°)。
- CAD/安装平移先验只用于后续 SE3 / 校验,不写入本轮交付 `T`
- 原始摘要:各窗 `out_fixed_dt0/summary.json`;总表 `calibration_manifest_fixed_dt0.json`
### 2.1 窗1 `priority_174005_174515` — `R_IMU_lidar`
- 路径:`...\priority_174005_174515\out_fixed_dt0\summary.json`
- rpy_deg_xyz`[-0.39806616272552936, 0.10842366761721789, 89.99788311264182]`
- quaternion_xyzw`[-0.0031254048203223084, -0.0017872288323561246, 0.7070914597978358, 0.707112936622415]`
```text
R =
[[ 3.6946588133e-05, -0.9999758655693408, -0.0069474393698490 ],
[ 0.9999982088237712, 2.3798651350e-05, 0.0018925598731371 ],
[-0.0018923488575947, -0.0069474968493909, 0.9999740753156198 ]]
t = [0, 0, 0]
```
### 2.2 窗2 `priority_174905_175450` — `R_IMU_lidar`
- 路径:`...\priority_174905_175450\out_fixed_dt0\summary.json`
- rpy_deg_xyz`[-0.31554362742542746, -0.35231680831805484, 90.00193338170823]`
- quaternion_xyzw`[0.00022698471903919405, -0.004121119694188399, 0.7071067019847445, 0.7070948146172911]`
```text
R =
[[-3.3743238552e-05, -0.9999848355714863, -0.0055070399001942 ],
[ 0.9999810938467022, 1.2097239027e-07, -0.0061491421465438 ],
[ 0.0061490495645171, -0.0055071432752239, 0.9999659297008071 ]]
t = [0, 0, 0]
```
### 2.3 窗3 `priority_175910_180530` — `R_IMU_lidar`
- 路径:`...\priority_175910_180530\out_fixed_dt0\summary.json`
- rpy_deg_xyz`[-0.37335575043737196, -0.03585856981709423, 90.00538974486676]`
- quaternion_xyzw`[-0.002082462199099642, -0.002525219418619018, 0.7071355299053291, 0.7070704554452735]`
```text
R =
[[-9.4068775206e-05, -0.9999787650354249, -0.0065161821101807 ],
[ 0.9999997997313592, -8.9988606603e-05, -0.0006264497522950 ],
[ 0.0006258500675082, -0.0065162397345547, 0.9999785732361544 ]]
t = [0, 0, 0]
```
### 相对历史失败轮次
| 轮次 | 问题 | 结果 |
|------|------|------|
| `sessions_v1_aligned` | 首帧强行对齐设备钟 | 三窗手眼失败,RMS ~9°–12° |
| 自由估 δt + signed refine | 窗3 δt 漂到 0.48 s;窗2 yaw≈19° | 跨窗 yaw 矛盾(81°/19°/93°) |
| **本轮 fixed δt=0** | 主机桥接后冻结时间 | 三窗 yaw≈90°,可互证 |
---
## 3. 已澄清并写入配置的坐标系 / 先验
### 3.1 车体与传感器
- 车体:X 前 / Y 左 / Z 上;雷达与 IMU 安装在 **X 正方向**(后轮轴前方)。
- CAD 图纸可能画成 +X 朝后,那只是读图坐标系,**不是**车体真实轴。
- IMUHI13 RFUX 右 / Y 前 / Z 上),原始数据不做轴向重映射。
- 雷达 NPZ:假定与车体一致(X 前 / Y 左 / Z 上)。
### 3.2 安装量(`translation_m`
| 传感器 | X / Y(后轮轴中心) | Z(离地) |
|--------|---------------------|-----------|
| IMU | 2.574 / 0.0365 m | 0.8925 + 0.294 = **1.1865 m** |
| 雷达 | 2.522 / 0.00002 m | 相位中心离地 **1.994999879 m** |
- 后轮轴中心离地:**294 mm**(Z 用离地高时加在 CAD 轴心高上)。
- 雷达 CAD `dZ=1.637499879 m`;相位中心离地还需加后轮轴离地 `0.294 m` 和相位中心偏移 `0.0635 m`,最终为 `1.994999879 m`
### 3.3 导出外参先验
- `R_IMU_lidar` ≈ yaw 90°:`[[0,-1,0],[1,0,0],[0,0,1]]`(软约束 σ=15°)。
- `t_IMU_lidar`**`[0.0365, -0.0518, 0.8085]` m**(相对 Z = 1.994999879 1.1865 = 0.808499879 m)。
- **旋转先验不因 Z 修正改变**;平移先验 Z 更新为 0.808499879 m。
---
## 4. 现存问题清单
### P1. IMU 预积分平移 `Δp` 不可用(阻塞正式平移)
- 现象:可视化模式 4 若用完整 `X⁻¹ A X`,橙/蓝点云常呈**上下错层**(Z 差米级~几十米)。
- 根因:加速度预积分缺少可靠重力/零偏处理,`t_A` 尤其 Z 发散;**不是旋转外参错了**。
- 旁证:相对 GICP 的旋转残差中位约 0.16°;`|t_A|` 中位却常 >1 m。
- 影响:`full_se3` / 依赖 IMU 位移的平移估计不可信。
- 缓解(已做):`visualize_pair_3d.py``rotation_only` 默认模式 4 = **R 共轭 + GICP 的 t_B**`--mode4-translation gicp|imu|auto`)。
### P2. 平面运动导致竖直平移弱可观
- 三优先窗以水平转弯为主,缺少缓坡/俯仰激励。
- 流水线门控已给出 `translation_accepted=false`
- 即使打开平移先验(σ≈5 cm),弱激励下结果易变成**先验回显**,不宜当标定成功。
### P3. 时间偏移若再自由估计会被带偏(已规避,需保持)
- 主机 UTC 桥接(MSOP/IMU `HostReceiveUtc`)后,两路已在同一时间轴,残差通常几十毫秒量级。
- 若再做有符号 δt 精修,会与错误/未收敛的 R 耦合,窗3 曾从约 −0.12 s 走到 **0.48 s**。
- **现行做法**:桥接会话使用 `--fixed-time-offset-s 0 --no-signed-time-refine`
### P4. 单窗低残差 ≠ 外参正确(历史教训)
- 自由 δt 轮次中,窗2 手眼 RMS 最低(~0.3°)但 yaw≈19°,与 CAD/其他窗差 60°+。
- 平面运动下 yaw 外参可出现多个能拟合 `R_A R_X ≈ R_X R_B` 的解。
- **必须**做跨窗一致性 + 可视化叠点,不能只看单窗 RMS。
### P5. 旋转软先验尚未做无先验对照
- 当前 σ=15°;笔记显示 Tsai 初值本身已接近(约 0.3°–1.1° RMS),不像纯先验硬拽。
- 仍缺一次:关闭先验或放大 `sigma_deg` 的对照,以排除「只是被拉到 90°」的疑虑。
### P6. 文档与操作约定未完全同步(工程)
- README 需明确写清:host-bridge 后固定 δt=0、禁用 signed refine、rotation_only 可视化用法。
- 交付物目前缺一版「冻结的联合/中位 R + 使用说明」JSON/报告(旋转可交,平移明确不交)。
---
## 5. 不该做 / 可以做
| 动作 | 建议 |
|------|------|
| 因 Z 先验修正重跑三窗 rotation_only | **不必**R 未依赖新 t |
| 正式交付 6-DOF / 信赖当前 `Δp` 估 t | **不要** |
| 试验性 `full_se3`(固定 R、δt=0、新 t 先验) | 可做,结果标「实验」 |
| 可视化验收模式 3 vs 4(gicp 平移) | **建议做** |
| 无先验 / 大 σ 旋转对照 | **建议做** |
| 冻结交付 `R` + `δt=0` 说明 | **建议做** |
| 补采缓坡或加强垂直尺寸约束后再估 t | 正式平移前需要 |
---
## 6. 建议下一步顺序
1. **验收旋转**:三窗抽转弯运动对,模式 3/4 叠点;可选无先验对照。
2. **定稿旋转**:三窗中位或联合手眼 → 交付 `R_IMU_lidar` +「δt=0(主机桥接)」说明;**明确不交 t**。
3. **工程收尾**:README 主机桥接配方;需要时再整理联合标定脚本入口。
4. **平移(靠后)**:改善 IMU 位移模型或改用更可靠的位移观测 + 竖直激励后,再用新 `t` 先验跑 SE3。
---
## 7. 常用路径与命令
```text
数据根:
D:\data\calibration_usable_20260808\sessions_v1_host_aligned\
结果:
...\priority_XXXX\out_fixed_dt0\summary.json
...\priority_XXXX\out_fixed_dt0\motion_pairs.json
...\calibration_manifest_fixed_dt0.json
```
```powershell
# 可视化(rotation_only 默认模式4用 GICP 平移)
python tools\visualize_pair_3d.py `
--lidar D:\data\calibration_usable_20260808\sessions_v1_host_aligned\priority_174005_174515\lidar `
--summary D:\data\calibration_usable_20260808\sessions_v1_host_aligned\priority_174005_174515\out_fixed_dt0\summary.json `
--pair-index 0
# 若要看「坏 Δp」导致的错层效果:
# --mode4-translation imu
```
---
## 8. 问题优先级(跟踪用)
| ID | 严重度 | 状态 | 标题 |
|----|--------|------|------|
| P1 | 高 | 未解决 | IMU `Δp` 不可用,阻塞正式平移 |
| P2 | 高 | 未解决 | 平面运动,竖直 t 弱可观 |
| P3 | 高 | 已规避 | 自由 δt / signed refine 带偏(需保持冻结) |
| P4 | 中 | 已吸收教训 | 单窗低残差不可单独验收 |
| P5 | 中 | 待做 | 无旋转先验对照 |
| P6 | 低 | 待做 | README/交付物同步 |
+39
View File
@@ -5,6 +5,45 @@
--- ---
## 2026-08-11 10:55 (UTC+8)
### 运动对缓存:标定落盘,可视化直读
- **原本**`visualize_pair_3d` 每次启动都重新关键帧+配准+预积分,等同半次标定。
- **改成**
- 标定成功后写出 `motion_pairs.json``motion_pairs_io.py` / `finalize`)。
- 可视化优先读缓存并对点云懒加载;`--rebuild-pairs` 可回退旧路径。
- 旧结果可用 `tools/export_motion_pairs_for_viz.py` 只补导出运动对,无需重求解外参。
---
## 2026-08-11 08:55 (UTC+8)
### 主机桥接后冻结 δt + 旋转先验软约束
- **原本**:手眼后 signed δt 精修可在弱 MSE 下降下连走数步(最远约 0.5 s);旋转手眼无 CAD 先验,平面运动下 yaw 易掉进低残差错解。
- **改成**
- CLI`--fixed-time-offset-s``--no-signed-time-refine``--max-signed-refine-shift-s`
- signed refine:默认 `|Δδt|≤0.05 s`,且要求 MSE 至少降约 2%。
- `rotation_handeye` 读取配置 `rotation_prior` 作初值/软约束。
- 主机 UTC 桥接会话建议:`--fixed-time-offset-s 0 --no-signed-time-refine`
---
## 2026-08-09 14:30 (UTC+8)
### 导出:HI13 IMU + recovered dlog zip + 墙钟切窗
- **原本**IMU 只解 N300 FDILinkdlog 只认标准 `*.dorec`;无法按图上时段切窗。
- **改成**
- 新增 `tools/rscap_v2/hi13_imu.py`HI91g→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) ## 2026-08-05 09:00 (UTC+8)
### 导出:支持 H32 DLogCaptureMSOP+DIFOP)→ V1 ### 导出:支持 H32 DLogCaptureMSOP+DIFOP)→ V1
+268 -13
View File
@@ -3,12 +3,82 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any
from .contracts import CalibrationMode, CalibrationRequest, CalibrationStatus, SessionInput from .contracts import CalibrationMode, CalibrationRequest, CalibrationStatus, SessionInput
from .phase_a_replay import run_phase_a_replay
from .pipeline import describe_pipeline, run_calibration from .pipeline import describe_pipeline, run_calibration
def _format_progress_value(value: Any) -> str:
if isinstance(value, float):
return f"{value:.3f}"
if isinstance(value, (list, tuple, set)):
return "[" + ",".join(str(item) for item in value) + "]"
return str(value)
def _print_progress(event: dict[str, Any]) -> None:
"""Print one compact, immediately flushed progress line."""
timestamp = datetime.now().strftime("%H:%M:%S")
stage_index = event.get("stage_index", "?")
stage_total = event.get("stage_total", "?")
stage_name = event.get("stage", "unknown")
message = event.get("event", "progress")
fields = " ".join(
f"{key}={_format_progress_value(value)}"
for key, value in event.items()
if key not in {"stage_index", "stage_total", "stage", "event"}
and value is not None
)
suffix = f" | {fields}" if fields else ""
print(
f"[{timestamp}] [stage {stage_index}/{stage_total} {stage_name}] {message}{suffix}",
flush=True,
)
def _parse_session_imu_specs(
specs: list[str] | None,
) -> dict[str, Path]:
result: dict[str, Path] = {}
for spec in specs or []:
if "=" not in spec:
raise SystemExit(
"--session-imu must use SESSION_ID=PATH syntax"
)
session_id, raw_path = spec.split("=", 1)
session_id = session_id.strip()
if not session_id or not raw_path.strip():
raise SystemExit(
"--session-imu must use non-empty SESSION_ID=PATH"
)
if session_id in result:
raise SystemExit(
f"duplicate --session-imu for {session_id}"
)
result[session_id] = Path(raw_path.strip())
return result
def _print_phase_a_progress(
event: str,
fields: dict[str, Any],
) -> None:
_print_progress(
{
"stage_index": "A",
"stage_total": "A",
"stage": "phase_a_replay",
"event": event,
**fields,
}
)
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="LiDARIMU extrinsic calibration (V1)") parser = argparse.ArgumentParser(description="LiDARIMU extrinsic calibration (V1)")
subcommands = parser.add_subparsers(dest="command", required=True) subcommands = parser.add_subparsers(dest="command", required=True)
@@ -21,10 +91,28 @@ def build_parser() -> argparse.ArgumentParser:
default=CalibrationMode.ROTATION_ONLY.value, default=CalibrationMode.ROTATION_ONLY.value,
) )
run = subcommands.add_parser("run", help="执行 V1 标定流水线") run = subcommands.add_parser(
run.add_argument("--session-id", default="session0") "run",
run.add_argument("--imu", required=True, help="IMU CSV/NPZ 路径") help="执行 V1 标定流水线(可重复 --imu/--lidar/--session-id 做多会话联合)",
run.add_argument("--lidar", required=True, help="LiDAR 会话目录(含 frames_index.csv") )
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("--vehicle-config", required=True, help="车辆配置 YAML")
run.add_argument("--output", required=True, help="输出目录") run.add_argument("--output", required=True, help="输出目录")
run.add_argument( run.add_argument(
@@ -34,11 +122,131 @@ def build_parser() -> argparse.ArgumentParser:
) )
run.add_argument("--max-iterations", type=int, default=2) run.add_argument("--max-iterations", type=int, default=2)
run.add_argument("--time-offset-search-s", type=float, default=1.0) run.add_argument("--time-offset-search-s", type=float, default=1.0)
run.add_argument(
"--fixed-time-offset-s",
type=float,
default=None,
help="Skip |ω| δt search and use this constant (use 0 after host-UTC bridge)",
)
run.add_argument(
"--session-time-offset-s",
action="append",
type=float,
default=None,
help="Per-session fixed time offset; repeat once per --imu/--lidar input",
)
run.add_argument(
"--no-signed-time-refine",
action="store_true",
help="Disable signed 3-axis δt refine after hand-eye (recommended for host-bridged data)",
)
run.add_argument(
"--max-signed-refine-shift-s",
type=float,
default=0.05,
help="Max |Δδt| accepted by signed refine from the coarse estimate",
)
run.add_argument("--min-pair-rotation-deg", type=float, default=3.0) run.add_argument("--min-pair-rotation-deg", type=float, default=3.0)
run.add_argument("--min-pair-translation-m", type=float, default=0.3) run.add_argument("--min-pair-translation-m", type=float, default=0.3)
run.add_argument("--min-registration-fitness", type=float, default=0.5)
run.add_argument("--max-imu-gap-s", type=float, default=0.05)
run.add_argument("--max-lidar-gap-s", type=float, default=1.0)
replay = subcommands.add_parser(
"phase-a-replay",
help="Replay Phase-A from cached motion pairs without rerunning GICP",
)
replay.add_argument("--motion-pairs", type=Path, required=True)
replay.add_argument("--vehicle-config", type=Path, required=True)
replay.add_argument("--output", type=Path, required=True)
replay.add_argument(
"--session-imu",
action="append",
default=None,
metavar="SESSION_ID=PATH",
help="Raw IMU mapping used only when cache lacks J_bg/cov",
)
replay.add_argument(
"--exclude-session",
action="append",
default=None,
help="Session ID to exclude; may be repeated",
)
replay.add_argument(
"--strong-rotation-min-deg",
type=float,
default=1.0,
)
replay.add_argument(
"--decorrelation-block-s",
type=float,
default=3.0,
help="Per-session time-block length used to decorrelate factors",
)
replay.add_argument(
"--max-pairs-per-block",
type=int,
default=1,
help="Maximum factors kept in each decorrelation block",
)
replay.add_argument(
"--bias-prior-sigma-rad-s",
type=float,
default=0.002,
)
replay.add_argument(
"--yaw-std-max-deg",
type=float,
default=0.5,
)
replay.add_argument(
"--loo-yaw-range-max-deg",
type=float,
default=1.0,
)
replay.add_argument(
"--data-prior-difference-max-deg",
type=float,
default=1.0,
)
replay.add_argument("--max-nfev", type=int, default=200)
return parser 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)})"
)
if args.session_time_offset_s is None:
session_offsets: list[float | None] = [None] * len(imus)
else:
session_offsets = list(args.session_time_offset_s)
if len(session_offsets) != len(imus):
raise SystemExit(
f"--session-time-offset-s count ({len(session_offsets)}) must match "
f"--imu/--lidar ({len(imus)})"
)
return tuple(
SessionInput(
session_id=sid,
imu_source=imu,
lidar_source=lidar,
fixed_time_offset_s=offset,
)
for sid, imu, lidar, offset in zip(session_ids, imus, lidars, session_offsets)
)
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
parser = build_parser() parser = build_parser()
args = parser.parse_args(argv) args = parser.parse_args(argv)
@@ -54,28 +262,75 @@ def main(argv: list[str] | None = None) -> int:
print(f"{index}. {stage.name}: {stage.responsibility}") print(f"{index}. {stage.name}: {stage.responsibility}")
return 0 return 0
if args.command == "phase-a-replay":
summary = run_phase_a_replay(
motion_pairs_path=args.motion_pairs,
vehicle_config_path=args.vehicle_config,
output_directory=args.output,
imu_paths_by_session=_parse_session_imu_specs(
args.session_imu
),
excluded_sessions=set(args.exclude_session or []),
strong_rotation_min_deg=args.strong_rotation_min_deg,
decorrelation_block_s=args.decorrelation_block_s,
max_pairs_per_block=args.max_pairs_per_block,
bias_prior_sigma_rad_s=args.bias_prior_sigma_rad_s,
yaw_std_max_deg=args.yaw_std_max_deg,
leave_one_out_yaw_range_max_deg=(
args.loo_yaw_range_max_deg
),
data_prior_difference_max_deg=(
args.data_prior_difference_max_deg
),
max_nfev=args.max_nfev,
progress_callback=_print_phase_a_progress,
)
print(f"status: {summary['status']}")
print(f"acceptance_checks: {summary['acceptance_checks']}")
for name, variant in summary["variants"].items():
print(
f"{name}: rpy_deg_xyz={variant['rpy_deg_xyz']} "
f"RMS={variant['residual_rms_deg']:.6f} "
f"P95={variant['residual_p95_deg']:.6f}"
)
print(
"A1 marginalized yaw_std_deg: "
f"{summary['marginal_observability_A1']['yaw_std_deg']}"
)
print(
"leave_one_out_yaw_range_deg: "
f"{summary['leave_one_out_yaw_range_deg']}"
)
print(f"report directory: {args.output}")
return 0 if (summary["accepted"] or summary.get("partial_accepted")) else 2
if args.command == "run": if args.command == "run":
sessions = _build_sessions(args)
request = CalibrationRequest( request = CalibrationRequest(
vehicle_config=Path(args.vehicle_config), vehicle_config=Path(args.vehicle_config),
sessions=( sessions=sessions,
SessionInput(
session_id=args.session_id,
imu_source=Path(args.imu),
lidar_source=Path(args.lidar),
),
),
requested_mode=CalibrationMode(args.mode), requested_mode=CalibrationMode(args.mode),
output_directory=Path(args.output), output_directory=Path(args.output),
max_iterations=args.max_iterations, max_iterations=args.max_iterations,
min_pair_rotation_deg=args.min_pair_rotation_deg, min_pair_rotation_deg=args.min_pair_rotation_deg,
min_pair_translation_m=args.min_pair_translation_m, min_pair_translation_m=args.min_pair_translation_m,
min_registration_fitness=args.min_registration_fitness,
max_imu_gap_s=args.max_imu_gap_s,
max_lidar_gap_s=args.max_lidar_gap_s,
time_offset_search_s=args.time_offset_search_s, time_offset_search_s=args.time_offset_search_s,
fixed_time_offset_s=args.fixed_time_offset_s,
enable_signed_time_refine=not args.no_signed_time_refine,
max_signed_refine_shift_s=args.max_signed_refine_shift_s,
) )
result = run_calibration(request) result = run_calibration(request, progress_callback=_print_progress)
print(f"status: {result.status.value}") print(f"status: {result.status.value}")
print(f"message: {result.message}") print(f"message: {result.message}")
if result.time_offset_s is not None: 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: if result.T_IMU_lidar is not None:
print("T_IMU_lidar:") print("T_IMU_lidar:")
print(result.T_IMU_lidar) print(result.T_IMU_lidar)
+13
View File
@@ -25,6 +25,7 @@ class CalibrationStatus(str, Enum):
NOT_RUN = "not_run" NOT_RUN = "not_run"
BLOCKED = "blocked" BLOCKED = "blocked"
ROTATION_ONLY_ACCEPTED = "rotation_only_accepted" ROTATION_ONLY_ACCEPTED = "rotation_only_accepted"
ROTATION_ONLY_PRIOR_CONSTRAINED = "rotation_only_prior_constrained"
FULL_SE3_ACCEPTED = "full_se3_accepted" FULL_SE3_ACCEPTED = "full_se3_accepted"
FULL_SE3_REJECTED = "full_se3_rejected_due_to_observability" FULL_SE3_REJECTED = "full_se3_rejected_due_to_observability"
@@ -37,6 +38,9 @@ class SessionInput:
imu_source: Path imu_source: Path
lidar_source: Path lidar_source: Path
board_configuration_id: str | None = None board_configuration_id: str | None = None
# Optional session-local override. The request-level value remains a
# backward-compatible fallback for batches whose timelines are all aligned.
fixed_time_offset_s: float | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -50,7 +54,16 @@ class CalibrationRequest:
max_iterations: int = 2 max_iterations: int = 2
min_pair_rotation_deg: float = 3.0 min_pair_rotation_deg: float = 3.0
min_pair_translation_m: float = 0.3 min_pair_translation_m: float = 0.3
min_registration_fitness: float = 0.5
max_imu_gap_s: float = 0.05
max_lidar_gap_s: float = 1.0
time_offset_search_s: float = 1.0 time_offset_search_s: float = 1.0
# If set, skip |ω| search and use this constant (host-UTC-bridged sessions: 0).
fixed_time_offset_s: float | None = None
# Signed 3-axis refine after hand-eye; disable for already-bridged timelines.
enable_signed_time_refine: bool = True
# Reject signed refine steps that walk farther than this from the coarse δt.
max_signed_refine_shift_s: float = 0.05
@dataclass @dataclass
+6
View File
@@ -34,6 +34,7 @@ def finalize_result(
T_IMU_lidar: np.ndarray | None = None, T_IMU_lidar: np.ndarray | None = None,
time_offset_s: float | None = None, time_offset_s: float | None = None,
output_directory: Path | None = None, output_directory: Path | None = None,
motion_pairs_payload: dict[str, Any] | None = None,
) -> CalibrationResult: ) -> CalibrationResult:
"""Build the result envelope and optionally write report files.""" """Build the result envelope and optionally write report files."""
@@ -72,5 +73,10 @@ def finalize_result(
json.dumps({"delta_t_s": time_offset_s, "definition": "t_imu = t_lidar + delta_t"}, indent=2), json.dumps({"delta_t_s": time_offset_s, "definition": "t_imu = t_lidar + delta_t"}, indent=2),
encoding="utf-8", encoding="utf-8",
) )
if motion_pairs_payload is not None:
from .motion_pairs_io import save_motion_pairs
save_motion_pairs(output_directory / "motion_pairs.json", motion_pairs_payload)
summary["motion_pairs_file"] = "motion_pairs.json"
(output_directory / "summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8") (output_directory / "summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
return result return result
+533 -29
View File
@@ -2,7 +2,9 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from typing import Any
import numpy as np import numpy as np
from scipy.optimize import least_squares from scipy.optimize import least_squares
@@ -17,10 +19,26 @@ from .imu_preintegration import (
residual_whiten_matrix, residual_whiten_matrix,
) )
from .observability import ObservabilityReport, analyze_observability from .observability import ObservabilityReport, analyze_observability
from .phase_a import phase_a_comparison_to_dict, solve_phase_a_comparison
from .rotation_handeye import select_strong_rotation_pairs
G_NORM = 9.80665 G_NORM = 9.80665
@dataclass(frozen=True)
class PhaseASessionResult:
session_id: str
pair_count: int
gyro_bias0_rad_s: np.ndarray
gyro_bias_rad_s: np.ndarray
residual_rms_deg: float
residual_median_deg: float
residual_p95_deg: float
outlier_fraction_gt_5deg: float
accepted: bool
included_in_final: bool
@dataclass(frozen=True) @dataclass(frozen=True)
class JointExtrinsicResult: class JointExtrinsicResult:
T_IMU_lidar: np.ndarray T_IMU_lidar: np.ndarray
@@ -31,6 +49,10 @@ class JointExtrinsicResult:
gyro_bias_rad_s: np.ndarray | None = None gyro_bias_rad_s: np.ndarray | None = None
accel_bias_m_s2: np.ndarray | None = None accel_bias_m_s2: np.ndarray | None = None
gravity_m_s2: np.ndarray | None = None gravity_m_s2: np.ndarray | None = None
gyro_bias_rad_s_per_session: dict[str, np.ndarray] = field(default_factory=dict)
phase_a_sessions: tuple[PhaseASessionResult, ...] = ()
phase_a_accepted: bool = False
phase_a_comparison: dict[str, Any] = field(default_factory=dict)
notes: tuple[str, ...] = () notes: tuple[str, ...] = ()
@@ -152,21 +174,21 @@ def _build_nav_rotations(
r_x: np.ndarray, r_x: np.ndarray,
t_x: np.ndarray, t_x: np.ndarray,
) -> list[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] rotations = [np.eye(3) for _ in keyframe_ids]
for k in range(len(keyframe_ids) - 1): for k in range(len(keyframe_ids) - 1):
a = keyframe_ids[k] a = keyframe_ids[k]
b = keyframe_ids[k + 1] b = keyframe_ids[k + 1]
pair = consecutive_pairs.get((a, b)) pair = consecutive_pairs.get((a, b))
if pair is None: 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 continue
t_b = np.zeros(3) if pair.t_B_m is None else np.asarray(pair.t_B_m, dtype=float) 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) r_meas, _ = _lidar_to_imu_relative(r_x, t_x, pair.R_B, t_b)
rotations[k + 1] = orthonormalize_rotation(rotations[k] @ r_meas) rotations[k + 1] = orthonormalize_rotation(rotations[k] @ r_meas)
# Ensure list indexed by id_to_idx
del id_to_idx
return rotations return rotations
@@ -174,10 +196,14 @@ def _solve_phase_c_se3(
pairs: list[MotionPair], pairs: list[MotionPair],
r_x: np.ndarray, r_x: np.ndarray,
*, *,
gyro_bias0: np.ndarray, gyro_bias_linearization: np.ndarray,
gyro_bias_init: np.ndarray,
gravity_init: np.ndarray, gravity_init: np.ndarray,
sigma_bg_rw: float = 1.0e-5, sigma_bg_rw: float = 1.0e-5,
sigma_ba_rw: float = 1.0e-3, 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]]: ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, float, float, list[str]]:
"""Keyframe IMU factor optimization for full SE(3).""" """Keyframe IMU factor optimization for full SE(3)."""
@@ -185,21 +211,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] usable = [pair for pair in pairs if pair.t_B_m is not None and "delta_v" in pair.metadata]
if len(usable) < 3: if len(usable) < 3:
notes.append("phase-C skipped: need pairs with full preintegration metadata") 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_bias_init, 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] = {} stamp: dict[int, float] = {}
kf_session: dict[int, str] = {}
for pair in usable: for pair in usable:
stamp[pair.i] = float(pair.metadata.get("t_i_imu_s", pair.t_i_s)) 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)) 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) k_count = len(keyframe_ids)
id_to_idx = {kid: idx for idx, kid in enumerate(keyframe_ids)} id_to_idx = {kid: idx for idx, kid in enumerate(keyframe_ids)}
consecutive_pairs: dict[tuple[int, int], MotionPair] = {} consecutive_pairs: dict[tuple[int, int], MotionPair] = {}
for pair in usable: 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: if id_to_idx[pair.j] == id_to_idx[pair.i] + 1:
consecutive_pairs[(pair.i, pair.j)] = pair 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) g0 = np.asarray(gravity_init, dtype=float).reshape(3)
if np.linalg.norm(g0) < 1e-6: if np.linalg.norm(g0) < 1e-6:
@@ -207,16 +248,26 @@ def _solve_phase_c_se3(
g0 = g0 * (G_NORM / max(np.linalg.norm(g0), 1e-9)) g0 = g0 * (G_NORM / max(np.linalg.norm(g0), 1e-9))
basis = _gravity_basis(g0) basis = _gravity_basis(g0)
ba0 = np.zeros(3) ba0 = np.zeros(3)
bg0 = np.asarray(gyro_bias0, dtype=float).reshape(3) bg0 = np.asarray(gyro_bias_linearization, dtype=float).reshape(3)
bg_init = np.asarray(gyro_bias_init, dtype=float).reshape(3)
# State: dθ(3), t(3), g_xy(2), v(3K), bg(3K), ba(3K) # State: dθ(3), t(3), g_xy(2), v(3K), bg(3K), ba(3K)
n_v = 3 * k_count n_v = 3 * k_count
n_b = 3 * k_count n_b = 3 * k_count
dim = 3 + 3 + 2 + n_v + n_b + n_b dim = 3 + 3 + 2 + n_v + n_b + n_b
x0 = np.zeros(dim) 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 # velocities start at 0; biases at prior
for idx in range(k_count): for idx in range(k_count):
x0[8 + n_v + 3 * idx : 8 + n_v + 3 * idx + 3] = bg0 x0[8 + n_v + 3 * idx : 8 + n_v + 3 * idx + 3] = bg_init
whitened = [] whitened = []
for pair in usable: for pair in usable:
@@ -269,18 +320,28 @@ def _solve_phase_c_se3(
w = np.sqrt(_pair_weight(pair)) w = np.sqrt(_pair_weight(pair))
out.append(w * (whiten @ err)) 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): 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_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)) 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_g * (bgs[k + 1] - bgs[k]))
out.append(scale_a * (bas[k + 1] - bas[k])) out.append(scale_a * (bas[k + 1] - bas[k]))
# Weak priors: first-keyframe biases and translation magnitude. # Weak priors: first keyframe of each session + CAD/installation translation.
out.append(50.0 * (bgs[0] - bg0)) for sid in session_ids:
out.append(20.0 * bas[0]) first = next(kid for kid in keyframe_ids if kf_session[kid] == sid)
out.append(0.2 * t_opt) # soft |t| prior ~ meters idx0 = id_to_idx[first]
out.append(50.0 * (bgs[idx0] - bg_init))
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) return np.concatenate(out)
# Cap evaluations: Phase-C is high-dimensional; synthetic ICP already dominates runtime. # Cap evaluations: Phase-C is high-dimensional; synthetic ICP already dominates runtime.
@@ -320,7 +381,197 @@ def _solve_phase_c_se3(
return r_opt, t_opt, g_opt, bg_mean, ba_mean, rot_rms, trans_rms, notes return r_opt, t_opt, g_opt, bg_mean, ba_mean, rot_rms, trans_rms, notes
def solve_joint_extrinsic( def _pair_gyro_bias0(pair: MotionPair, fallback: np.ndarray) -> np.ndarray:
raw = pair.metadata.get("gyro_bias0_rad_s")
if raw is None:
return np.asarray(fallback, dtype=float).reshape(3)
return np.asarray(raw, dtype=float).reshape(3)
def _phase_a_bias_bases(
pairs: list[MotionPair],
*,
gyro_bias_rad_s: np.ndarray | None,
gyro_bias_rad_s_by_session: Mapping[str, np.ndarray] | None,
) -> dict[str, np.ndarray]:
session_ids = sorted({pair.session_id for pair in pairs})
scalar = None
if gyro_bias_rad_s is not None:
scalar = np.asarray(gyro_bias_rad_s, dtype=float).reshape(3)
supplied = {} if gyro_bias_rad_s_by_session is None else gyro_bias_rad_s_by_session
bases: dict[str, np.ndarray] = {}
for sid in session_ids:
if sid in supplied:
bases[sid] = np.asarray(supplied[sid], dtype=float).reshape(3)
continue
pair = next(
(
item
for item in pairs
if item.session_id == sid and "gyro_bias0_rad_s" in item.metadata
),
None,
)
if pair is not None:
bases[sid] = np.asarray(pair.metadata["gyro_bias0_rad_s"], dtype=float).reshape(3)
elif scalar is not None:
bases[sid] = scalar.copy()
else:
bases[sid] = np.zeros(3)
return bases
def _rotation_distribution(errs_deg: list[float]) -> tuple[float, float, float, float, bool]:
if not errs_deg:
return 1e9, 1e9, 1e9, 1.0, False
errs = np.asarray(errs_deg, dtype=float)
rms = float(np.sqrt(np.mean(errs**2)))
median = float(np.median(errs))
p95 = float(np.percentile(errs, 95.0))
outlier_fraction = float(np.mean(errs > 5.0))
accepted = (
len(errs) >= 3
and rms < 1.5
and median < 0.5
and p95 < 1.5
and outlier_fraction <= 0.005
)
return rms, median, p95, outlier_fraction, accepted
def _solve_phase_a_rotation(
pairs: list[MotionPair],
r_seed: np.ndarray,
*,
bias_bases: Mapping[str, np.ndarray],
imu: ImuSeries | None,
bias_prior_sigma_rad_s: float,
preexcluded_session_ids: set[str] | None = None,
) -> tuple[
np.ndarray,
dict[str, np.ndarray],
tuple[PhaseASessionResult, ...],
list[MotionPair],
float,
bool,
list[str],
]:
notes: list[str] = []
all_session_ids = sorted({pair.session_id for pair in pairs})
prior_w = 1.0 / max(bias_prior_sigma_rad_s, 1e-4)
def optimize(
active_pairs: list[MotionPair],
r0: np.ndarray,
bias_seed: Mapping[str, np.ndarray],
) -> tuple[np.ndarray, dict[str, np.ndarray]]:
session_ids = sorted({pair.session_id for pair in active_pairs})
session_index = {sid: index for index, sid in enumerate(session_ids)}
whiten = [residual_whiten_matrix(_pair_cov(pair)) for pair in active_pairs]
x0 = np.zeros(3 + 3 * len(session_ids))
for sid, index in session_index.items():
x0[3 + 3 * index : 6 + 3 * index] = np.asarray(bias_seed[sid], dtype=float)
def residual(vec: np.ndarray) -> np.ndarray:
r_opt = orthonormalize_rotation(so3_exp(vec[:3]) @ r0)
out: list[np.ndarray] = []
for pair, sqrt_info in zip(active_pairs, whiten):
index = session_index[pair.session_id]
bias = vec[3 + 3 * index : 6 + 3 * index]
base = _pair_gyro_bias0(pair, bias_bases[pair.session_id])
delta_r = _corrected_delta_r(
pair, bias - base, imu=imu, bias0=base
)
out.append(
sqrt_info
@ preintegration_rotation_residual(delta_r, r_opt, pair.R_B)
)
for sid, index in session_index.items():
bias = vec[3 + 3 * index : 6 + 3 * index]
out.append(prior_w * (bias - bias_bases[sid]))
return np.concatenate(out)
opt = least_squares(residual, x0, loss="huber", f_scale=1.0, max_nfev=200)
r_opt = orthonormalize_rotation(so3_exp(opt.x[:3]) @ r0)
biases = {
sid: opt.x[3 + 3 * index : 6 + 3 * index].copy()
for sid, index in session_index.items()
}
return r_opt, biases
def summarize(
r_opt: np.ndarray,
biases: Mapping[str, np.ndarray],
included: set[str],
) -> tuple[PhaseASessionResult, ...]:
results: list[PhaseASessionResult] = []
for sid in all_session_ids:
local_pairs = [pair for pair in pairs if pair.session_id == sid]
bias = np.asarray(biases.get(sid, bias_bases[sid]), dtype=float).reshape(3)
errs: list[float] = []
for pair in local_pairs:
base = _pair_gyro_bias0(pair, bias_bases[sid])
delta_r = _corrected_delta_r(pair, bias - base, imu=imu, bias0=base)
err = preintegration_rotation_residual(delta_r, r_opt, pair.R_B)
errs.append(float(np.degrees(np.linalg.norm(err))))
rms, median, p95, outlier, accepted = _rotation_distribution(errs)
results.append(
PhaseASessionResult(
session_id=sid,
pair_count=len(local_pairs),
gyro_bias0_rad_s=np.asarray(bias_bases[sid], dtype=float),
gyro_bias_rad_s=bias,
residual_rms_deg=rms,
residual_median_deg=median,
residual_p95_deg=p95,
outlier_fraction_gt_5deg=outlier,
accepted=accepted,
included_in_final=sid in included,
)
)
return tuple(results)
if not pairs:
return r_seed, dict(bias_bases), (), [], 1e9, False, ["no pairs for phase-A"]
r_first, biases_first = optimize(pairs, r_seed, bias_bases)
first = summarize(r_first, biases_first, set(all_session_ids))
accepted_ids = {item.session_id for item in first if item.accepted}
preexcluded = set() if preexcluded_session_ids is None else set(preexcluded_session_ids)
accepted_ids -= preexcluded
active_ids = set(all_session_ids)
r_final = r_first
biases_final = dict(biases_first)
if preexcluded and not accepted_ids:
active_ids = set()
notes.append(f"phase-A pre-gate excluded all sessions: {sorted(preexcluded)}")
elif accepted_ids and accepted_ids != active_ids:
active_ids = accepted_ids
active_pairs = [pair for pair in pairs if pair.session_id in active_ids]
r_final, active_biases = optimize(active_pairs, r_first, biases_first)
biases_final.update(active_biases)
excluded = sorted(set(all_session_ids) - active_ids)
notes.append(f"phase-A excluded sessions after local/pre residual gate: {excluded}")
active_pairs = [pair for pair in pairs if pair.session_id in active_ids]
final = summarize(r_final, biases_final, active_ids)
active_results = [item for item in final if item.included_in_final]
global_errs: list[float] = []
for pair in active_pairs:
bias = biases_final[pair.session_id]
base = _pair_gyro_bias0(pair, bias_bases[pair.session_id])
delta_r = _corrected_delta_r(pair, bias - base, imu=imu, bias0=base)
err = preintegration_rotation_residual(delta_r, r_final, pair.R_B)
global_errs.append(float(np.degrees(np.linalg.norm(err))))
rot_rms, _, _, _, global_ok = _rotation_distribution(global_errs)
accepted = bool(active_results and global_ok and all(item.accepted for item in active_results))
notes.append(
f"phase-A session-local bias refine: sessions={len(active_ids)}/{len(all_session_ids)}, "
f"pairs={len(active_pairs)}, rms={rot_rms:.3f} deg"
)
return r_final, biases_final, final, active_pairs, rot_rms, accepted, notes
def _solve_joint_extrinsic_legacy(
pairs: list[MotionPair] | tuple[MotionPair, ...], pairs: list[MotionPair] | tuple[MotionPair, ...],
r_x: np.ndarray, r_x: np.ndarray,
*, *,
@@ -329,8 +580,13 @@ def solve_joint_extrinsic(
delta_t_s: float = 0.0, delta_t_s: float = 0.0,
gyro_bias_rad_s: np.ndarray | None = None, gyro_bias_rad_s: np.ndarray | None = None,
gravity_init_m_s2: np.ndarray | None = None, gravity_init_m_s2: np.ndarray | None = None,
gyro_bias_rad_s_by_session: Mapping[str, np.ndarray] | None = None,
time_offset_s_by_session: Mapping[str, float] | None = None,
bias_prior_sigma_rad_s: float = 0.02, bias_prior_sigma_rad_s: float = 0.02,
enable_phase_c: bool | None = None, 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: ) -> JointExtrinsicResult:
"""Refine extrinsic using Phase-A whitened rotation factors, optional Phase-C SE(3).""" """Refine extrinsic using Phase-A whitened rotation factors, optional Phase-C SE(3)."""
@@ -344,16 +600,17 @@ def solve_joint_extrinsic(
r = orthonormalize_rotation(np.asarray(r_x, dtype=float)) 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) 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) weights = np.asarray([_pair_weight(pair) for pair in usable], dtype=float)
whitens = [residual_whiten_matrix(_pair_cov(pair)) for pair in usable] whitens = [residual_whiten_matrix(_pair_cov(pair)) for pair in usable]
prior_w = 1.0 / max(bias_prior_sigma_rad_s, 1e-4) prior_w = 1.0 / max(bias_prior_sigma_rad_s, 1e-4)
def rotation_residuals(r_opt: np.ndarray, delta_bias: np.ndarray) -> np.ndarray: def rotation_residuals(r_opt: np.ndarray, delta_bias: np.ndarray) -> np.ndarray:
residuals = [] residuals = []
for pair, weight, whiten in zip(usable, weights, whitens): for pair, whiten in zip(usable, whitens):
delta_r = _corrected_delta_r(pair, delta_bias, imu=imu, bias0=bias0) delta_r = _corrected_delta_r(pair, delta_bias, imu=imu, bias0=bias0)
err = preintegration_rotation_residual(delta_r, r_opt, pair.R_B) err = preintegration_rotation_residual(delta_r, r_opt, pair.R_B)
residuals.append(np.sqrt(weight) * (whiten @ err)) residuals.append(whiten @ err)
residuals.append(prior_w * delta_bias) residuals.append(prior_w * delta_bias)
return np.concatenate(residuals) if residuals else np.zeros(0) return np.concatenate(residuals) if residuals else np.zeros(0)
@@ -366,16 +623,16 @@ def solve_joint_extrinsic(
residual_rot_bias, residual_rot_bias,
np.zeros(6), np.zeros(6),
loss="huber", loss="huber",
f_scale=np.deg2rad(1.0), f_scale=1.0,
max_nfev=200, max_nfev=200,
) )
r = orthonormalize_rotation(so3_exp(opt.x[:3]) @ r) r = orthonormalize_rotation(so3_exp(opt.x[:3]) @ r)
delta_bias = opt.x[3:] delta_bias = opt.x[3:]
bias_out = bias0 + delta_bias bias_out = bias0 + delta_bias
notes.append( notes.append(
"phase-A joint refine (Σ-whitened + J_bg): " "phase-A joint refine (single Σ whitening + J_bg): "
f"|δb|={float(np.linalg.norm(delta_bias)):.3e} rad/s, " f"|δb|={float(np.linalg.norm(delta_bias)):.3e} rad/s, "
f"weighted pairs={len(usable)}" f"pairs={len(usable)}"
) )
else: else:
bias_out = bias0 bias_out = bias0
@@ -389,7 +646,7 @@ def solve_joint_extrinsic(
rot_errs.append(np.degrees(np.linalg.norm(err))) 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 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 translation_accepted = False
trans_rms = 1e9 trans_rms = 1e9
gravity_out: np.ndarray | None = None gravity_out: np.ndarray | None = None
@@ -400,6 +657,12 @@ def solve_joint_extrinsic(
else: else:
gravity_init = np.asarray(gravity_init_m_s2, dtype=float).reshape(3) 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 ( if (
enable_phase_c enable_phase_c
and not force_rotation_only and not force_rotation_only
@@ -410,14 +673,26 @@ def solve_joint_extrinsic(
r, t, gravity_out, bias_out, accel_bias_out, rot_rms, trans_rms, c_notes = _solve_phase_c_se3( r, t, gravity_out, bias_out, accel_bias_out, rot_rms, trans_rms, c_notes = _solve_phase_c_se3(
usable, usable,
r, r,
gyro_bias0=bias_out, gyro_bias_linearization=bias0,
gyro_bias_init=bias_out,
gravity_init=gravity_init, 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) notes.extend(c_notes)
translation_accepted = bool(trans_rms < 0.75 and np.linalg.norm(t) > 1e-4) translation_accepted = bool(trans_rms < 0.75 and np.linalg.norm(t) > 1e-4)
if not translation_accepted: if not translation_accepted:
notes.append("phase-C translation residual/gate failed; keeping translation at zero") # Prefer CAD prior over silent zero when motion SE3 is rejected.
t = np.zeros(3) if t_prior_m is not None:
t = np.asarray(t_prior_m, dtype=float).reshape(3)
notes.append(
"phase-C translation residual/gate failed; CAD translation is reported "
"as a prior only and is not accepted as calibration"
)
else:
notes.append("phase-C translation residual/gate failed; keeping translation at zero")
t = np.zeros(3)
elif ( elif (
not force_rotation_only not force_rotation_only
and observability.translation_observable and observability.translation_observable
@@ -437,9 +712,19 @@ def solve_joint_extrinsic(
pred = (pair.R_A - np.eye(3)) @ t_opt pred = (pair.R_A - np.eye(3)) @ t_opt
meas = r_opt @ np.asarray(pair.t_B_m, dtype=float) meas = r_opt @ np.asarray(pair.t_B_m, dtype=float)
residuals.append(np.sqrt(weight) * (pred - meas)) 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) 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) r = orthonormalize_rotation(so3_exp(opt_t.x[:3]) @ r)
t = opt_t.x[3:] t = opt_t.x[3:]
rot_errs = [] rot_errs = []
@@ -457,6 +742,13 @@ def solve_joint_extrinsic(
if not translation_accepted: if not translation_accepted:
notes.append("translation residual too large; keeping translation at zero") notes.append("translation residual too large; keeping translation at zero")
t = np.zeros(3) 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 = False
notes.append(
"SE3 motion solve gated off; CAD translation is reported as a prior only "
"and is not accepted as calibration"
)
else: else:
notes.append("rotation-only extrinsic returned (phase-A; phase-C SE3 gated off)") notes.append("rotation-only extrinsic returned (phase-A; phase-C SE3 gated off)")
@@ -464,10 +756,222 @@ def solve_joint_extrinsic(
T_IMU_lidar=make_transform(t, r), T_IMU_lidar=make_transform(t, r),
translation_accepted=bool(translation_accepted and np.linalg.norm(t) > 0), translation_accepted=bool(translation_accepted and np.linalg.norm(t) > 0),
residual_rms_rot_deg=rot_rms, residual_rms_rot_deg=rot_rms,
residual_rms_trans_m=0.0 if not translation_accepted else trans_rms, residual_rms_trans_m=trans_rms,
observability=observability, observability=observability,
gyro_bias_rad_s=np.asarray(bias_out, dtype=float), gyro_bias_rad_s=np.asarray(bias_out, dtype=float),
accel_bias_m_s2=None if accel_bias_out is None else np.asarray(accel_bias_out, dtype=float), accel_bias_m_s2=None if accel_bias_out is None else np.asarray(accel_bias_out, dtype=float),
gravity_m_s2=None if gravity_out is None else np.asarray(gravity_out, dtype=float), gravity_m_s2=None if gravity_out is None else np.asarray(gravity_out, dtype=float),
notes=tuple(notes), notes=tuple(notes),
) )
def solve_joint_extrinsic(
pairs: list[MotionPair] | tuple[MotionPair, ...],
r_x: np.ndarray,
*,
force_rotation_only: bool = False,
imu: ImuSeries | None = None,
delta_t_s: float = 0.0,
gyro_bias_rad_s: np.ndarray | None = None,
gyro_bias_rad_s_by_session: Mapping[str, np.ndarray] | None = None,
time_offset_s_by_session: Mapping[str, float] | None = None,
preexcluded_session_ids: set[str] | None = None,
gravity_init_m_s2: np.ndarray | None = None,
bias_prior_sigma_rad_s: float = 0.002,
rotation_prior: np.ndarray | None = None,
rotation_prior_sigma_deg: float = 15.0,
phase_a_yaw_std_max_deg: float = 0.5,
phase_a_loo_yaw_range_max_deg: float = 1.0,
phase_a_data_prior_difference_max_deg: float = 1.0,
run_phase_a_leave_one_out: bool = True,
phase_a_progress_callback: (
Callable[[str, dict[str, Any]], None] | None
) = None,
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:
"""Run the corrected session-aware Phase-A and gate unfinished SE(3) stages."""
del gravity_init_m_s2, t_init_m, t_prior_sigma_m, imu, r_x
usable_input = [pair for pair in pairs if pair.t_B_m is not None]
bias_bases = _phase_a_bias_bases(
usable_input,
gyro_bias_rad_s=gyro_bias_rad_s,
gyro_bias_rad_s_by_session=gyro_bias_rad_s_by_session,
)
comparison = solve_phase_a_comparison(
usable_input,
gyro_bias_rad_s_by_session=bias_bases,
rotation_prior=rotation_prior,
rotation_prior_sigma_deg=rotation_prior_sigma_deg,
preexcluded_session_ids=preexcluded_session_ids,
bias_prior_sigma_rad_s=bias_prior_sigma_rad_s,
yaw_std_max_deg=phase_a_yaw_std_max_deg,
leave_one_out_yaw_range_max_deg=(
phase_a_loo_yaw_range_max_deg
),
data_prior_difference_max_deg=(
phase_a_data_prior_difference_max_deg
),
run_leave_one_out=run_phase_a_leave_one_out,
progress_callback=phase_a_progress_callback,
)
primary = comparison.session_bg_data_only
r = primary.R_IMU_lidar
biases = primary.gyro_bias_rad_s_per_session
rot_rms = primary.residual_rms_deg
phase_a_accepted = comparison.accepted
notes = list(comparison.notes)
notes.append(
"phase-A primary=A1_session_bg_data_only; "
f"A0 RPY={comparison.fixed_bg_data_only.rpy_deg_xyz.tolist()}, "
f"A1 RPY={primary.rpy_deg_xyz.tolist()}, "
"A2 RPY="
f"{comparison.session_bg_with_rotation_prior.rpy_deg_xyz.tolist()}"
)
notes.append(
f"phase-A marginal yaw_std={comparison.marginal_observability.yaw_std_deg:.3f} deg, "
f"LOO yaw range={comparison.leave_one_out_yaw_range_deg:.3f} deg"
)
session_results_list: list[PhaseASessionResult] = [
PhaseASessionResult(
session_id=item.session_id,
pair_count=item.pair_count,
gyro_bias0_rad_s=item.gyro_bias0_rad_s,
gyro_bias_rad_s=item.gyro_bias_rad_s,
residual_rms_deg=item.residual_rms_deg,
residual_median_deg=item.residual_median_deg,
residual_p95_deg=item.residual_p95_deg,
outlier_fraction_gt_5deg=item.outlier_fraction_gt_5deg,
accepted=item.accepted,
included_in_final=True,
)
for item in primary.sessions
]
preexcluded = (
set()
if preexcluded_session_ids is None
else set(preexcluded_session_ids)
)
strong_all = select_strong_rotation_pairs(usable_input)
for session_id in sorted(preexcluded):
local_pairs = [
pair for pair in strong_all if pair.session_id == session_id
]
errors = [
float(
np.degrees(
np.linalg.norm(
preintegration_rotation_residual(
pair.R_A, r, pair.R_B
)
)
)
)
for pair in local_pairs
]
rms, median, p95, outlier, accepted = _rotation_distribution(
errors
)
base = np.asarray(
bias_bases.get(session_id, np.zeros(3)), dtype=float
).reshape(3)
session_results_list.append(
PhaseASessionResult(
session_id=session_id,
pair_count=len(local_pairs),
gyro_bias0_rad_s=base,
gyro_bias_rad_s=base,
residual_rms_deg=rms,
residual_median_deg=median,
residual_p95_deg=p95,
outlier_fraction_gt_5deg=outlier,
accepted=accepted,
included_in_final=False,
)
)
session_results = tuple(
sorted(session_results_list, key=lambda item: item.session_id)
)
usable = [
pair
for pair in strong_all
if pair.session_id not in preexcluded
]
base_observability = analyze_observability(usable, r)
marginal = comparison.marginal_observability
observability = ObservabilityReport(
rotation_observable=bool(
marginal.rank == 3
and marginal.yaw_std_deg <= phase_a_yaw_std_max_deg
),
translation_observable=base_observability.translation_observable,
condition_rotation=marginal.condition,
condition_translation=base_observability.condition_translation,
notes=tuple(
list(marginal.notes)
+ list(base_observability.notes)
),
)
notes.extend(observability.notes)
if time_offset_s_by_session is None:
notes.append(
f"legacy scalar time offset fixed during pair construction: {float(delta_t_s):.6f}s"
)
else:
fixed_offsets = {
str(sid): float(value) for sid, value in time_offset_s_by_session.items()
}
notes.append(
f"time offsets fixed during pair construction (not optimized): {fixed_offsets}"
)
for item in session_results:
notes.append(
f"phase-A session {item.session_id}: included={item.included_in_final}, "
f"pairs={item.pair_count}, rms={item.residual_rms_deg:.3f} deg, "
f"p95={item.residual_p95_deg:.3f} deg, "
f"|bias-bias0|={float(np.linalg.norm(item.gyro_bias_rad_s - item.gyro_bias0_rad_s)):.3e}"
)
phase_c_requested = (not force_rotation_only) if enable_phase_c is None else bool(enable_phase_c)
t = np.zeros(3)
if not force_rotation_only:
if phase_c_requested:
notes.append(
"phase-B/C gated off: session-aware translation/gravity/navigation "
"states are not implemented yet"
)
else:
notes.append("phase-C disabled; translation is not accepted")
if t_prior_m is not None:
t = np.asarray(t_prior_m, dtype=float).reshape(3)
notes.append(
"CAD translation is reported as a prior only and is not accepted as calibration"
)
else:
notes.append("rotation-only extrinsic returned after corrected phase-A")
single_bias = None
if len(biases) == 1:
single_bias = np.asarray(next(iter(biases.values())), dtype=float)
return JointExtrinsicResult(
T_IMU_lidar=make_transform(t, r),
translation_accepted=False,
residual_rms_rot_deg=rot_rms,
residual_rms_trans_m=1e9,
observability=observability,
gyro_bias_rad_s=single_bias,
accel_bias_m_s2=None,
gravity_m_s2=None,
gyro_bias_rad_s_per_session={
sid: np.asarray(value, dtype=float) for sid, value in biases.items()
},
phase_a_sessions=session_results,
phase_a_accepted=phase_a_accepted,
phase_a_comparison=phase_a_comparison_to_dict(comparison),
notes=tuple(notes),
)
+2 -1
View File
@@ -21,6 +21,7 @@ def build_keyframes(
*, *,
min_translation_m: float = 0.3, min_translation_m: float = 0.3,
min_rotation_deg: float = 3.0, min_rotation_deg: float = 3.0,
min_registration_fitness: float = 0.5,
max_frame_gap: int = 8, max_frame_gap: int = 8,
) -> KeyframeSet: ) -> KeyframeSet:
"""Select keyframes with enough relative motion for hand-eye pairs.""" """Select keyframes with enough relative motion for hand-eye pairs."""
@@ -36,7 +37,7 @@ def build_keyframes(
last = index last = index
continue continue
result = register_lidar_pair(frames[index].points_xyz, frames[last].points_xyz) result = register_lidar_pair(frames[index].points_xyz, frames[last].points_xyz)
if not result.ok: if not result.ok or result.fitness < min_registration_fitness:
continue continue
if result.translation_m >= min_translation_m or result.rotation_deg >= min_rotation_deg: if result.translation_m >= min_translation_m or result.rotation_deg >= min_rotation_deg:
selected.append(index) selected.append(index)
+49 -4
View File
@@ -19,10 +19,7 @@ import numpy as np
from .contracts import LidarFrame from .contracts import LidarFrame
def load_lidar_frames(path: Path | str) -> list[LidarFrame]: def _read_frames_index(root: Path) -> tuple[np.ndarray, str]:
"""Load all LiDAR frames listed by ``frames_index.csv`` under ``path``."""
root = Path(path)
index_path = root / "frames_index.csv" index_path = root / "frames_index.csv"
if not index_path.exists(): if not index_path.exists():
raise FileNotFoundError(f"missing frames_index.csv under {root}") raise FileNotFoundError(f"missing frames_index.csv under {root}")
@@ -38,6 +35,54 @@ def load_lidar_frames(path: Path | str) -> list[LidarFrame]:
raise ValueError( raise ValueError(
f"frames_index.csv must contain frame_id,{file_key}/filename,t_start,t_end; got {sorted(names)}" f"frames_index.csv must contain frame_id,{file_key}/filename,t_start,t_end; got {sorted(names)}"
) )
return rows, file_key
def list_lidar_frame_entries(path: Path | str) -> list[tuple[str, float, float, Path]]:
"""Return ``(frame_id, t_start, t_end, npz_path)`` sorted by mid time (same as ``load_lidar_frames``)."""
root = Path(path)
rows, file_key = _read_frames_index(root)
entries: list[tuple[str, float, float, Path]] = []
for row in rows:
t0 = float(row["t_start"])
t1 = float(row["t_end"])
entries.append((str(row["frame_id"]), t0, t1, root / str(row[file_key])))
entries.sort(key=lambda item: 0.5 * (item[1] + item[2]))
return entries
def load_lidar_frame_at(root: Path | str, index: int) -> LidarFrame:
"""Load one frame by index in mid-time-sorted order (matches motion-pair ``i``/``j``)."""
entries = list_lidar_frame_entries(root)
if index < 0 or index >= len(entries):
raise IndexError(f"frame index {index} outside [0, {len(entries) - 1}] for {root}")
frame_id, t0, t1, npz_path = entries[index]
with np.load(npz_path) as payload:
if "points" not in payload.files:
raise ValueError(f"{npz_path} must contain array 'points'")
points = np.asarray(payload["points"], dtype=float)
if points.ndim != 2 or points.shape[1] < 3:
raise ValueError(f"{npz_path}: points must have shape (N, 3[+])")
return LidarFrame(
frame_id=frame_id,
t_start_s=t0,
t_end_s=t1,
points_xyz=points[:, :3],
path=npz_path,
)
def lidar_frame_count(path: Path | str) -> int:
return len(list_lidar_frame_entries(path))
def load_lidar_frames(path: Path | str) -> list[LidarFrame]:
"""Load all LiDAR frames listed by ``frames_index.csv`` under ``path``."""
root = Path(path)
rows, file_key = _read_frames_index(root)
frames: list[LidarFrame] = [] frames: list[LidarFrame] = []
for row in rows: for row in rows:
+84
View File
@@ -2,7 +2,10 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from time import perf_counter
from typing import Any
import numpy as np import numpy as np
@@ -30,7 +33,12 @@ def build_motion_pairs(
acc_bias_m_s2: np.ndarray | None = None, acc_bias_m_s2: np.ndarray | None = None,
min_rotation_deg: float = 3.0, min_rotation_deg: float = 3.0,
min_translation_m: float = 0.3, min_translation_m: float = 0.3,
min_registration_fitness: float = 0.5,
max_imu_gap_s: float = 0.05,
max_lidar_gap_s: float = 1.0,
all_frame_times_s: np.ndarray | None = None,
max_index_span: int = 4, max_index_span: int = 4,
progress_callback: Callable[[dict[str, Any]], None] | None = None,
) -> MotionPairSet: ) -> MotionPairSet:
"""Create A/B motion pairs between nearby keyframes. """Create A/B motion pairs between nearby keyframes.
@@ -40,21 +48,74 @@ def build_motion_pairs(
notes: list[str] = [] notes: list[str] = []
pairs: list[MotionPair] = [] pairs: list[MotionPair] = []
rejected_fitness = 0
rejected_imu_gap = 0
rejected_lidar_gap = 0
frame_times = (
None
if all_frame_times_s is None
else np.asarray(all_frame_times_s, dtype=float).reshape(-1)
)
bias_g = np.zeros(3) if gyro_bias_rad_s is None else np.asarray(gyro_bias_rad_s, dtype=float) bias_g = np.zeros(3) if gyro_bias_rad_s is None else np.asarray(gyro_bias_rad_s, dtype=float)
bias_a = np.zeros(3) if acc_bias_m_s2 is None else np.asarray(acc_bias_m_s2, dtype=float) bias_a = np.zeros(3) if acc_bias_m_s2 is None else np.asarray(acc_bias_m_s2, dtype=float)
n = len(keyframes) n = len(keyframes)
if n < 2: if n < 2:
return MotionPairSet((), ("need at least two keyframes",)) return MotionPairSet((), ("need at least two keyframes",))
total_candidates = sum(max(n - span, 0) for span in range(1, max_index_span + 1))
processed_candidates = 0
started_at = perf_counter()
last_progress_at = started_at
def report_progress(*, event: str, span: int, force: bool = False) -> None:
nonlocal last_progress_at
if progress_callback is None:
return
now = perf_counter()
if not force and processed_candidates > 1 and now - last_progress_at < 10.0:
return
last_progress_at = now
progress_callback(
{
"event": event,
"processed_candidates": processed_candidates,
"total_candidates": total_candidates,
"progress_pct": 100.0 * processed_candidates / max(total_candidates, 1),
"current_span": span,
"max_span": max_index_span,
"accepted_pairs": len(pairs),
"rejected_fitness": rejected_fitness,
"rejected_imu_gap": rejected_imu_gap,
"rejected_lidar_gap": rejected_lidar_gap,
"elapsed_s": now - started_at,
}
)
report_progress(event="start", span=1, force=True)
for span in range(1, max_index_span + 1): for span in range(1, max_index_span + 1):
for start in range(0, n - span): for start in range(0, n - span):
processed_candidates += 1
report_progress(event="running", span=span)
i = start i = start
j = start + span j = start + span
frame_i = keyframes[i] frame_i = keyframes[i]
frame_j = keyframes[j] frame_j = keyframes[j]
source_i = int(keyframe_indices[i])
source_j = int(keyframe_indices[j])
if frame_times is not None:
lo = min(source_i, source_j)
hi = max(source_i, source_j)
local_times = frame_times[lo : hi + 1]
if local_times.size >= 2 and np.any(np.diff(local_times) > max_lidar_gap_s):
rejected_lidar_gap += 1
continue
reg = register_lidar_pair(frame_j.points_xyz, frame_i.points_xyz) reg = register_lidar_pair(frame_j.points_xyz, frame_i.points_xyz)
if not reg.ok: if not reg.ok:
continue continue
if reg.fitness < min_registration_fitness:
rejected_fitness += 1
continue
if reg.rotation_deg < min_rotation_deg and reg.translation_m < min_translation_m: if reg.rotation_deg < min_rotation_deg and reg.translation_m < min_translation_m:
continue continue
@@ -64,6 +125,16 @@ def build_motion_pairs(
continue continue
if t_i_imu < imu.t_s[0] or t_j_imu > imu.t_s[-1]: if t_i_imu < imu.t_s[0] or t_j_imu > imu.t_s[-1]:
continue continue
imu_lo = max(int(np.searchsorted(imu.t_s, t_i_imu, side="right")) - 1, 0)
imu_hi = min(
int(np.searchsorted(imu.t_s, t_j_imu, side="left")) + 1,
imu.t_s.size,
)
if imu_hi - imu_lo >= 2 and np.any(
np.diff(imu.t_s[imu_lo:imu_hi]) > max_imu_gap_s
):
rejected_imu_gap += 1
continue
preint = preintegrate_imu( preint = preintegrate_imu(
imu.t_s, imu.t_s,
@@ -111,14 +182,27 @@ def build_motion_pairs(
"delta_p": preint.delta_p.tolist(), "delta_p": preint.delta_p.tolist(),
"t_i_imu_s": t_i_imu, "t_i_imu_s": t_i_imu,
"t_j_imu_s": t_j_imu, "t_j_imu_s": t_j_imu,
"gyro_bias0_rad_s": bias_g.tolist(),
"accel_bias0_m_s2": bias_a.tolist(),
"time_offset_s": float(delta_t_s),
"keyframe_span": int(span),
"is_consecutive": bool(span == 1),
"modeling": "imu_preintegration_factor_phase_c", "modeling": "imu_preintegration_factor_phase_c",
}, },
) )
) )
report_progress(event="complete", span=max_index_span, force=True)
notes.append( notes.append(
f"built {len(pairs)} motion pairs (Phase-C preintegration: ΔR/Δv/Δp, Σ9, J_bg/J_ba)" f"built {len(pairs)} motion pairs (Phase-C preintegration: ΔR/Δv/Δp, Σ9, J_bg/J_ba)"
) )
notes.append(
"quality rejects: "
f"fitness<{min_registration_fitness:.2f}: {rejected_fitness}, "
f"IMU gap>{max_imu_gap_s:.3f}s: {rejected_imu_gap}, "
f"LiDAR gap>{max_lidar_gap_s:.3f}s: {rejected_lidar_gap}"
)
return MotionPairSet(pairs=tuple(pairs), notes=tuple(notes)) return MotionPairSet(pairs=tuple(pairs), notes=tuple(notes))
+154
View File
@@ -0,0 +1,154 @@
"""Serialize / deserialize motion pairs for fast visualization."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import numpy as np
from .contracts import MotionPair
SCHEMA_VERSION = 2
# Keep visualization fields plus the compact 3x3 rotation metadata needed to
# rerun Phase-A without repeating LiDAR registration. Full 9x9 Phase-C matrices
# remain excluded from this cache.
_METADATA_KEEP = frozenset(
{
"backend",
"rotation_deg_A",
"rotation_deg_B",
"translation_m_B",
"weight",
"duration_s",
"mean_gyro_norm",
"preint_sigma_rad",
"cov",
"J_bg",
"phase_a_metadata_rehydrated",
"rehydrated_R_A_error_deg",
"t_i_imu_s",
"t_j_imu_s",
"gyro_bias0_rad_s",
"accel_bias0_m_s2",
"time_offset_s",
"keyframe_span",
"is_consecutive",
"modeling",
}
)
def _to_list(value: Any) -> Any:
if isinstance(value, np.ndarray):
return value.tolist()
if isinstance(value, (np.floating, np.integer, np.bool_)):
return value.item()
return value
def pair_to_dict(pair: MotionPair) -> dict[str, Any]:
meta = {
str(k): _to_list(v)
for k, v in (pair.metadata or {}).items()
if str(k) in _METADATA_KEEP
}
return {
"session_id": pair.session_id,
"i": int(pair.i),
"j": int(pair.j),
"t_i_s": float(pair.t_i_s),
"t_j_s": float(pair.t_j_s),
"R_A": np.asarray(pair.R_A, dtype=float).reshape(3, 3).tolist(),
"R_B": np.asarray(pair.R_B, dtype=float).reshape(3, 3).tolist(),
"t_A_m": None if pair.t_A_m is None else np.asarray(pair.t_A_m, dtype=float).reshape(3).tolist(),
"t_B_m": None if pair.t_B_m is None else np.asarray(pair.t_B_m, dtype=float).reshape(3).tolist(),
"fitness": float(pair.fitness),
"metadata": meta,
}
def pair_from_dict(payload: dict[str, Any]) -> MotionPair:
t_a = payload.get("t_A_m")
t_b = payload.get("t_B_m")
return MotionPair(
session_id=str(payload.get("session_id", "")),
i=int(payload["i"]),
j=int(payload["j"]),
t_i_s=float(payload["t_i_s"]),
t_j_s=float(payload["t_j_s"]),
R_A=np.asarray(payload["R_A"], dtype=float).reshape(3, 3),
R_B=np.asarray(payload["R_B"], dtype=float).reshape(3, 3),
t_A_m=None if t_a is None else np.asarray(t_a, dtype=float).reshape(3),
t_B_m=None if t_b is None else np.asarray(t_b, dtype=float).reshape(3),
fitness=float(payload.get("fitness", 0.0)),
metadata=dict(payload.get("metadata") or {}),
)
def build_motion_pairs_payload(
*,
prepared_sessions: list[dict[str, Any]],
) -> dict[str, Any]:
"""Build a JSON-serializable cache from pipeline ``prepared`` session dicts."""
sessions_out: list[dict[str, Any]] = []
for prep in prepared_sessions:
pairs = prep.get("pairs") or ()
sessions_out.append(
{
"session_id": prep.get("session_id"),
"delta_t_s": float(prep.get("time_offset_s", 0.0)),
"gyro_bias_rad_s": np.asarray(prep.get("gyro_bias_rad_s", np.zeros(3)), dtype=float)
.reshape(3)
.tolist(),
"pair_count": len(pairs),
"pairs": [pair_to_dict(pair) for pair in pairs],
}
)
return {
"schema_version": SCHEMA_VERSION,
"sessions": sessions_out,
"note": "Cached motion pairs for visualization; A=IMU preintegration, B=LiDAR registration",
}
def save_motion_pairs(path: Path | str, payload: dict[str, Any]) -> Path:
destination = Path(path)
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(json.dumps(payload, indent=2), encoding="utf-8")
return destination
def load_motion_pairs(path: Path | str) -> dict[str, Any]:
payload = json.loads(Path(path).read_text(encoding="utf-8"))
version = int(payload.get("schema_version", 0))
if version not in {1, SCHEMA_VERSION}:
raise ValueError(
f"unsupported motion_pairs schema_version={version}; "
f"expected 1 or {SCHEMA_VERSION}"
)
return payload
def pairs_for_session(payload: dict[str, Any], session_id: str | None = None) -> list[MotionPair]:
sessions = payload.get("sessions") or []
if not sessions:
return []
if session_id is None:
chosen = sessions[0]
else:
chosen = next((s for s in sessions if s.get("session_id") == session_id), None)
if chosen is None:
raise KeyError(f"session_id {session_id!r} not found in motion_pairs cache")
return [pair_from_dict(item) for item in chosen.get("pairs") or []]
def resolve_motion_pairs_path(summary_path: Path | str) -> Path | None:
"""Return ``motion_pairs.json`` next to a summary if it exists."""
summary = Path(summary_path)
candidate = summary.parent / "motion_pairs.json"
return candidate if candidate.is_file() else None
+40 -30
View File
@@ -57,43 +57,53 @@ def analyze_observability(
if j_r.size == 0: if j_r.size == 0:
return ObservabilityReport(False, False, 1e9, 1e9, ("empty rotation jacobian",)) return ObservabilityReport(False, False, 1e9, 1e9, ("empty rotation jacobian",))
# Normalize columns. singular = np.linalg.svd(j_r, compute_uv=False)
col_norm = np.linalg.norm(j_r, axis=0) + 1e-12
j_r_n = j_r / col_norm
singular = np.linalg.svd(j_r_n, compute_uv=False)
cond_r = float(singular[0] / max(singular[-1], 1e-12)) cond_r = float(singular[0] / max(singular[-1], 1e-12))
rotation_ok = cond_r < condition_threshold and singular[-1] > 1e-3 rotation_information = float(singular[-1] / np.sqrt(max(len(usable), 1)))
rotation_ok = (
cond_r < condition_threshold
and rotation_information > 1e-3
and singular[-1] > 1e-6
)
# Translation observability proxy: diversity of rotation axes and presence of translation in B. # Translation lever arm is observable through stacked (R_A - I). Pure
axes = [] # planar yaw leaves its vertical column in the nullspace and must fail.
translations = [] translation_rows = [
for pair in usable: np.asarray(pair.R_A, dtype=float).reshape(3, 3) - np.eye(3)
axis = so3_log(pair.R_B) for pair in usable
n = np.linalg.norm(axis) if pair.t_B_m is not None
if n > 1e-8: ]
axes.append(axis / n) if translation_rows:
if pair.t_B_m is not None: j_t = np.vstack(translation_rows)
translations.append(pair.t_B_m) singular_t = np.linalg.svd(j_t, compute_uv=False)
axis_rank = 0 cond_t = float(singular_t[0] / max(singular_t[-1], 1e-12))
if axes: translation_information = float(
axis_mat = np.asarray(axes, dtype=float) singular_t[-1] / np.sqrt(max(len(translation_rows), 1))
axis_rank = int(np.linalg.matrix_rank(axis_mat, tol=0.1)) )
trans_span = 0.0 else:
if translations: cond_t = 1e9
tmat = np.asarray(translations, dtype=float) translation_information = 0.0
trans_span = float(np.linalg.norm(np.std(tmat, axis=0))) translation_ok = (
# For planar yaw-mostly motion, translation z is typically weak. len(translation_rows) >= 5
translation_ok = axis_rank >= 2 and trans_span > 0.2 and len(translations) >= 5 and cond_t < condition_threshold
cond_t = 1e9 if not translation_ok else float(max(3, 10 - axis_rank * 2) * (0.5 / max(trans_span, 1e-3))) and translation_information > 0.02
)
if not rotation_ok: if not rotation_ok:
notes.append(f"rotation condition {cond_r:.1f} exceeds threshold {condition_threshold}") notes.append(
f"rotation not observable: condition={cond_r:.1f}, "
f"min_information={rotation_information:.3e}"
)
else: else:
notes.append(f"rotation condition {cond_r:.1f}") notes.append(
f"rotation observable: condition={cond_r:.1f}, "
f"min_information={rotation_information:.3e}"
)
if not translation_ok: if not translation_ok:
notes.append( notes.append(
f"translation not observable (axis_rank={axis_rank}, trans_span={trans_span:.3f} m); " f"translation not observable: condition={cond_t:.1f}, "
"V1 will reject full SE3 without strong priors" f"min_information={translation_information:.3e}; "
"full SE3 will be rejected"
) )
return ObservabilityReport( return ObservabilityReport(
rotation_observable=rotation_ok, rotation_observable=rotation_ok,
+1435
View File
File diff suppressed because it is too large Load Diff
+290
View File
@@ -0,0 +1,290 @@
"""Cached Phase-A replay: rehydrate gyro factors, compare variants, write reports."""
from __future__ import annotations
import json
from collections import defaultdict
from pathlib import Path
from typing import Any
import numpy as np
from .imu_io import load_imu_samples
from .motion_pairs_io import (
build_motion_pairs_payload,
load_motion_pairs,
pair_from_dict,
save_motion_pairs,
)
from .phase_a import (
ProgressCallback,
phase_a_comparison_to_dict,
phase_a_metadata_complete,
rehydrate_phase_a_pairs,
solve_phase_a_comparison,
)
from .vehicle_config import load_vehicle_config, prior_enabled
def _rotation_prior(
vehicle_config_path: Path,
) -> tuple[np.ndarray | None, float]:
config = load_vehicle_config(vehicle_config_path)
if not prior_enabled(config, "rotation_prior"):
return None, 15.0
prior = (config.get("initialization") or {}).get("rotation_prior") or {}
matrix = prior.get("R_IMU_lidar")
if matrix is None:
return None, float(prior.get("sigma_deg", 15.0))
return (
np.asarray(matrix, dtype=float).reshape(3, 3),
float(prior.get("sigma_deg", 15.0)),
)
def _sanitize_json(value: Any) -> Any:
if isinstance(value, dict):
return {str(key): _sanitize_json(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_sanitize_json(item) for item in value]
if isinstance(value, np.ndarray):
return _sanitize_json(value.tolist())
if isinstance(value, (np.floating, float)):
number = float(value)
return number if np.isfinite(number) else None
if isinstance(value, (np.integer, np.bool_)):
return value.item()
return value
def _write_json(path: Path, payload: Any) -> None:
path.write_text(
json.dumps(_sanitize_json(payload), indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
def _load_cached_sessions(
motion_pairs_path: Path,
) -> tuple[
dict[str, Any],
list,
dict[str, np.ndarray],
dict[str, float],
]:
payload = load_motion_pairs(motion_pairs_path)
pairs = []
biases: dict[str, np.ndarray] = {}
offsets: dict[str, float] = {}
for session in payload.get("sessions") or []:
session_id = str(session["session_id"])
biases[session_id] = np.asarray(
session.get("gyro_bias_rad_s", np.zeros(3)),
dtype=float,
).reshape(3)
offsets[session_id] = float(session.get("delta_t_s", 0.0))
pairs.extend(
pair_from_dict(item)
for item in session.get("pairs") or []
)
if not pairs:
raise ValueError(f"motion-pair cache is empty: {motion_pairs_path}")
return payload, pairs, biases, offsets
def run_phase_a_replay(
*,
motion_pairs_path: Path,
vehicle_config_path: Path,
output_directory: Path,
imu_paths_by_session: dict[str, Path] | None = None,
excluded_sessions: set[str] | None = None,
strong_rotation_min_deg: float = 1.0,
decorrelation_block_s: float = 3.0,
max_pairs_per_block: int = 1,
bias_prior_sigma_rad_s: float = 0.002,
yaw_std_max_deg: float = 0.5,
leave_one_out_yaw_range_max_deg: float = 1.0,
data_prior_difference_max_deg: float = 1.0,
max_nfev: int = 200,
progress_callback: ProgressCallback | None = None,
) -> dict[str, Any]:
"""Run Phase-A only. Existing LiDAR relative motions are never recomputed."""
output_directory.mkdir(parents=True, exist_ok=True)
source_payload, pairs, bias0, offsets = _load_cached_sessions(
motion_pairs_path
)
session_ids = sorted(bias0)
if progress_callback is not None:
progress_callback(
"cache_loaded",
{
"schema_version": source_payload.get("schema_version"),
"sessions": len(session_ids),
"pairs": len(pairs),
},
)
rehydration_report: dict[str, Any] = {
"required": not phase_a_metadata_complete(pairs),
"pair_count": len(pairs),
}
if not phase_a_metadata_complete(pairs):
supplied_paths = {} if imu_paths_by_session is None else imu_paths_by_session
missing = [sid for sid in session_ids if sid not in supplied_paths]
if missing:
raise ValueError(
"v1 cache lacks J_bg/cov; provide --session-imu for: "
+ ", ".join(missing)
)
imu_by_session = {
sid: load_imu_samples(supplied_paths[sid])
for sid in session_ids
}
pairs, details = rehydrate_phase_a_pairs(
pairs,
imu_by_session=imu_by_session,
bias0_by_session=bias0,
progress_callback=progress_callback,
)
rehydration_report.update(details)
if float(details["max_R_A_error_deg"]) > 0.05:
raise ValueError(
"rehydrated IMU rotations do not match cached R_A: "
f"max error={details['max_R_A_error_deg']:.6f} deg; "
"check session-to-IMU path mapping"
)
grouped: dict[str, list] = defaultdict(list)
for pair in pairs:
grouped[pair.session_id].append(pair)
enriched_payload = build_motion_pairs_payload(
prepared_sessions=[
{
"session_id": sid,
"time_offset_s": offsets[sid],
"gyro_bias_rad_s": bias0[sid],
"pairs": tuple(grouped[sid]),
}
for sid in session_ids
]
)
enriched_cache_path = save_motion_pairs(
output_directory / "motion_pairs_phase_a_v2.json",
enriched_payload,
)
rotation_prior, rotation_prior_sigma_deg = _rotation_prior(
vehicle_config_path
)
comparison = solve_phase_a_comparison(
pairs,
gyro_bias_rad_s_by_session=bias0,
rotation_prior=rotation_prior,
rotation_prior_sigma_deg=rotation_prior_sigma_deg,
preexcluded_session_ids=excluded_sessions,
strong_rotation_min_deg=strong_rotation_min_deg,
decorrelation_block_s=decorrelation_block_s,
max_pairs_per_block=max_pairs_per_block,
bias_prior_sigma_rad_s=bias_prior_sigma_rad_s,
yaw_std_max_deg=yaw_std_max_deg,
leave_one_out_yaw_range_max_deg=(
leave_one_out_yaw_range_max_deg
),
data_prior_difference_max_deg=data_prior_difference_max_deg,
run_leave_one_out=True,
max_nfev=max_nfev,
progress_callback=progress_callback,
)
full = phase_a_comparison_to_dict(comparison)
full["input"] = {
"motion_pairs": str(motion_pairs_path),
"source_schema_version": source_payload.get("schema_version"),
"vehicle_config": str(vehicle_config_path),
"session_imu_paths": {
sid: str(path)
for sid, path in (imu_paths_by_session or {}).items()
},
"excluded_sessions": sorted(excluded_sessions or set()),
}
full["rehydration"] = rehydration_report
full["enriched_cache"] = str(enriched_cache_path)
full["parameters"] = {
"strong_rotation_min_deg": strong_rotation_min_deg,
"decorrelation_block_s": decorrelation_block_s,
"max_pairs_per_block": max_pairs_per_block,
"bias_prior_sigma_rad_s": bias_prior_sigma_rad_s,
"rotation_prior_sigma_deg": rotation_prior_sigma_deg,
"yaw_std_max_deg": yaw_std_max_deg,
"leave_one_out_yaw_range_max_deg": (
leave_one_out_yaw_range_max_deg
),
"data_prior_difference_max_deg": (
data_prior_difference_max_deg
),
"max_nfev": max_nfev,
}
variants = full["variants"]
summary = {
"status": comparison.solution_status,
"accepted": comparison.accepted,
"partial_accepted": comparison.partial_accepted,
"acceptance_checks": comparison.acceptance_checks,
"primary_result": comparison.recommended_result,
"variants": {
name: {
"rpy_deg_xyz": item["rpy_deg_xyz"],
"R_IMU_lidar": item["R_IMU_lidar"],
"residual_rms_deg": item["residual_rms_deg"],
"residual_p95_deg": item["residual_p95_deg"],
"accepted": item["accepted"],
"gyro_bias_rad_s_per_session": item[
"gyro_bias_rad_s_per_session"
],
}
for name, item in variants.items()
if item is not None
},
"marginal_observability_A1": full[
"marginal_observability_A1"
],
"data_vs_prior_yaw_diff_deg": (
comparison.data_vs_prior_yaw_diff_deg
),
"data_vs_prior_geodesic_deg": (
comparison.data_vs_prior_geodesic_deg
),
"leave_one_out_yaw_range_deg": (
comparison.leave_one_out_yaw_range_deg
),
"leave_one_out_observable_max_deg": (
comparison.leave_one_out_observable_max_deg
),
"strong_pair_candidate_count": (
comparison.strong_pair_candidate_count
),
"decorrelated_pair_count": comparison.decorrelated_pair_count,
"strong_pair_counts_per_session": (
comparison.strong_pair_counts_per_session
),
"excluded_sessions": list(comparison.excluded_sessions),
"rehydration": rehydration_report,
"comparison_file": "phase_a_comparison.json",
"observability_file": "phase_a_observability.json",
"leave_one_out_file": "phase_a_leave_one_out.json",
"enriched_cache_file": enriched_cache_path.name,
}
_write_json(output_directory / "phase_a_comparison.json", full)
_write_json(
output_directory / "phase_a_observability.json",
full["marginal_observability_A1"],
)
_write_json(
output_directory / "phase_a_leave_one_out.json",
full["leave_one_out"],
)
_write_json(output_directory / "phase_a_summary.json", summary)
return summary
+707 -113
View File
File diff suppressed because it is too large Load Diff
+116 -12
View File
@@ -16,6 +16,8 @@ class RotationHandeyeResult:
R_IMU_lidar: np.ndarray R_IMU_lidar: np.ndarray
residual_rms_deg: float residual_rms_deg: float
residual_median_deg: float residual_median_deg: float
residual_p95_deg: float
outlier_fraction_gt_5deg: float
pair_count: int pair_count: int
ok: bool ok: bool
notes: tuple[str, ...] = () notes: tuple[str, ...] = ()
@@ -28,17 +30,21 @@ def _pair_weight(pair: MotionPair) -> float:
return weight return weight
def _tsai_rotation_initial(pairs: list[MotionPair]) -> np.ndarray: def _tsai_rotation_initial(
pairs: list[MotionPair],
pair_weights: np.ndarray | None = None,
) -> np.ndarray:
"""Closed-form rotation hand-eye initial guess (Tsai-style linear solve).""" """Closed-form rotation hand-eye initial guess (Tsai-style linear solve)."""
rows: list[np.ndarray] = [] rows: list[np.ndarray] = []
rhs: list[np.ndarray] = [] rhs: list[np.ndarray] = []
for pair in pairs: weights = np.ones(len(pairs)) if pair_weights is None else np.asarray(pair_weights, dtype=float)
for pair, pair_weight in zip(pairs, weights):
alpha = so3_log(pair.R_A) alpha = so3_log(pair.R_A)
beta = so3_log(pair.R_B) beta = so3_log(pair.R_B)
if np.linalg.norm(alpha) < 1e-6 or np.linalg.norm(beta) < 1e-6: if np.linalg.norm(alpha) < 1e-6 or np.linalg.norm(beta) < 1e-6:
continue continue
w = np.sqrt(_pair_weight(pair)) w = np.sqrt(float(pair_weight))
rows.append(w * skew(alpha + beta)) rows.append(w * skew(alpha + beta))
rhs.append(w * (beta - alpha)) rhs.append(w * (beta - alpha))
if len(rows) < 2: if len(rows) < 2:
@@ -57,26 +63,101 @@ def _pair_residual_deg(r_x: np.ndarray, pair: MotionPair) -> float:
return float(np.degrees(np.linalg.norm(err))) return float(np.degrees(np.linalg.norm(err)))
def solve_rotation_handeye(pairs: list[MotionPair] | tuple[MotionPair, ...]) -> RotationHandeyeResult: def _rms_deg(r_x: np.ndarray, pairs: list[MotionPair]) -> float:
"""Solve ``R_A R_X = R_X R_B`` with weighted robust nonlinear refinement.""" if not pairs:
return 1e9
errs = np.asarray([_pair_residual_deg(r_x, pair) for pair in pairs], dtype=float)
return float(np.sqrt(np.mean(errs**2)))
usable = [pair for pair in pairs if rotation_angle_deg(pair.R_A) > 1.0 and rotation_angle_deg(pair.R_B) > 1.0]
def select_strong_rotation_pairs(
pairs: list[MotionPair] | tuple[MotionPair, ...],
*,
min_rotation_deg: float = 1.0,
) -> list[MotionPair]:
"""Return pairs that independently excite rotation on both sensor sides."""
threshold = float(min_rotation_deg)
return [
pair
for pair in pairs
if rotation_angle_deg(pair.R_A) > threshold
and rotation_angle_deg(pair.R_B) > threshold
]
def estimate_rotation_handeye_initial(
pairs: list[MotionPair] | tuple[MotionPair, ...],
*,
min_rotation_deg: float = 1.0,
) -> np.ndarray:
"""Return the fast data-only Tsai initialization without nonlinear refine."""
usable = select_strong_rotation_pairs(
pairs,
min_rotation_deg=min_rotation_deg,
)
if not usable:
return np.eye(3)
raw_weights = np.asarray(
[_pair_weight(pair) for pair in usable],
dtype=float,
)
median = max(float(np.median(raw_weights)), 1e-12)
weights = np.clip(raw_weights / median, 0.1, 10.0)
return _tsai_rotation_initial(usable, weights)
def solve_rotation_handeye(
pairs: list[MotionPair] | tuple[MotionPair, ...],
*,
R_prior: np.ndarray | None = None,
prior_sigma_deg: float | None = None,
) -> RotationHandeyeResult:
"""Solve ``R_A R_X = R_X R_B`` with weighted robust nonlinear refinement.
Optional CAD / installation ``R_prior`` soft-constrains the extrinsic yaw that
is weakly observable under near-planar motion.
"""
usable = select_strong_rotation_pairs(pairs)
notes: list[str] = [] notes: list[str] = []
if len(usable) < 3: if len(usable) < 3:
return RotationHandeyeResult( return RotationHandeyeResult(
R_IMU_lidar=np.eye(3), R_IMU_lidar=np.eye(3),
residual_rms_deg=1e9, residual_rms_deg=1e9,
residual_median_deg=1e9, residual_median_deg=1e9,
residual_p95_deg=1e9,
outlier_fraction_gt_5deg=1.0,
pair_count=len(usable), pair_count=len(usable),
ok=False, ok=False,
notes=("need at least 3 motion pairs with meaningful rotation",), notes=("need at least 3 motion pairs with meaningful rotation",),
) )
r0 = _tsai_rotation_initial(usable) raw_weights = np.asarray([_pair_weight(pair) for pair in usable], dtype=float)
weights = np.asarray([_pair_weight(pair) for pair in usable], dtype=float) median_raw_weight = max(float(np.median(raw_weights)), 1e-12)
weights = np.clip(raw_weights / median_raw_weight, 0.1, 10.0)
r0 = _tsai_rotation_initial(usable, weights)
r_prior = None
if R_prior is not None:
r_prior = orthonormalize_rotation(np.asarray(R_prior, dtype=float).reshape(3, 3))
rms_tsai = _rms_deg(r0, usable)
rms_prior = _rms_deg(r_prior, usable)
if rms_prior <= rms_tsai * 1.25:
r0 = r_prior
notes.append(
f"init from rotation prior (rms={rms_prior:.3f} deg vs Tsai {rms_tsai:.3f} deg)"
)
else:
notes.append(
f"init from Tsai (rms={rms_tsai:.3f} deg; prior {rms_prior:.3f} deg kept as soft constraint)"
)
notes.append( notes.append(
f"weighted hand-eye: weight median={float(np.median(weights)):.3g}, " "weighted hand-eye: normalized/clipped IMU confidence "
f"min={float(np.min(weights)):.3g}, max={float(np.max(weights)):.3g}" f"raw_median={median_raw_weight:.3g}, "
f"normalized_min={float(np.min(weights)):.3g}, "
f"normalized_max={float(np.max(weights)):.3g}"
) )
def pack(r: np.ndarray) -> np.ndarray: def pack(r: np.ndarray) -> np.ndarray:
@@ -85,12 +166,21 @@ def solve_rotation_handeye(pairs: list[MotionPair] | tuple[MotionPair, ...]) ->
def unpack(vec: np.ndarray) -> np.ndarray: def unpack(vec: np.ndarray) -> np.ndarray:
return orthonormalize_rotation(so3_exp(vec)) return orthonormalize_rotation(so3_exp(vec))
sigma = 15.0 if prior_sigma_deg is None else float(prior_sigma_deg)
prior_w = 0.0
if r_prior is not None and sigma > 1e-6:
# Scale prior to a few strong pairs so it regularizes yaw without dominating.
prior_w = float(np.sqrt(np.median(weights)) / np.deg2rad(sigma))
notes.append(f"rotation prior soft constraint sigma={sigma:.1f} deg, weight={prior_w:.3g}")
def residual(vec: np.ndarray) -> np.ndarray: def residual(vec: np.ndarray) -> np.ndarray:
r_x = unpack(vec) r_x = unpack(vec)
residuals = [] residuals = []
for pair, weight in zip(usable, weights): for pair, weight in zip(usable, weights):
err = so3_log(r_x.T @ pair.R_A @ r_x @ pair.R_B.T) err = so3_log(r_x.T @ pair.R_A @ r_x @ pair.R_B.T)
residuals.append(np.sqrt(weight) * err) residuals.append(np.sqrt(weight) * err)
if r_prior is not None and prior_w > 0:
residuals.append(prior_w * so3_log(r_prior.T @ r_x))
return np.concatenate(residuals) return np.concatenate(residuals)
opt = least_squares(residual, pack(r0), loss="huber", f_scale=np.deg2rad(1.0), max_nfev=200) opt = least_squares(residual, pack(r0), loss="huber", f_scale=np.deg2rad(1.0), max_nfev=200)
@@ -99,14 +189,28 @@ def solve_rotation_handeye(pairs: list[MotionPair] | tuple[MotionPair, ...]) ->
# Report unweighted RMS/median for interpretability. # Report unweighted RMS/median for interpretability.
rms = float(np.sqrt(np.mean(errs**2))) rms = float(np.sqrt(np.mean(errs**2)))
med = float(np.median(errs)) med = float(np.median(errs))
p95 = float(np.percentile(errs, 95.0))
outlier_fraction = float(np.mean(errs > 5.0))
notes.append(f"optimized over {len(usable)} pairs") notes.append(f"optimized over {len(usable)} pairs")
ok = rms < 5.0 and len(usable) >= 3 notes.append(
f"rotation residual quality: rms={rms:.3f} deg, median={med:.3f} deg, "
f"p95={p95:.3f} deg, >5deg={100.0 * outlier_fraction:.2f}%"
)
ok = (
len(usable) >= 3
and rms < 1.5
and med < 0.5
and p95 < 1.5
and outlier_fraction <= 0.005
)
if not ok: if not ok:
notes.append("rotation residual RMS too high or too few pairs") notes.append("rotation residual distribution failed acceptance gates")
return RotationHandeyeResult( return RotationHandeyeResult(
R_IMU_lidar=r_x, R_IMU_lidar=r_x,
residual_rms_deg=rms, residual_rms_deg=rms,
residual_median_deg=med, residual_median_deg=med,
residual_p95_deg=p95,
outlier_fraction_gt_5deg=outlier_fraction,
pair_count=len(usable), pair_count=len(usable),
ok=ok, ok=ok,
notes=tuple(notes), notes=tuple(notes),
+35 -6
View File
@@ -73,7 +73,10 @@ def _correlate_offset(
y0, y1, y2 = peaks y0, y1, y2 = peaks
denom = y0 - 2 * y1 + y2 denom = y0 - 2 * y1 + y2
if abs(denom) > 1e-12: 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) best_peak = float(y1)
return best_delta, best_peak 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) 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 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) 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) rotations, pair_times = estimate_frame_rotations(frames, stride=1)
if len(rotations) < 4: if len(rotations) < 4:
return TimeOffsetResult(0.0, 0.0, search_s, ("not enough LiDAR relative rotations",), False) return TimeOffsetResult(0.0, 0.0, search_s, ("not enough LiDAR relative rotations",), False)
@@ -131,8 +137,16 @@ def estimate_time_offset(
f"LiDAR mean pair rotation {np.mean([rotation_angle_deg(r) for r in rotations]):.2f} deg" f"LiDAR mean pair rotation {np.mean([rotation_angle_deg(r) for r in rotations]):.2f} deg"
) )
notes.append(f"searched delta_t in ±{search_s:.3f}s by direct correlation") notes.append(f"searched delta_t in ±{search_s:.3f}s by direct correlation")
ok = peak > 0.15 # Host-UTC-bridged sessions are already on one timeline; |ω| peak can stay
if not ok: # weak even at the correct lag (ICP rate vs gyro scale). Accept near-zero δt.
near_zero = abs(float(delta)) <= min(0.05, 0.25 * float(search_s))
ok = peak > 0.15 or near_zero
if peak <= 0.15 and near_zero:
notes.append(
f"correlation peak weak ({peak:.3f}) but |delta_t|={abs(delta):.4f}s ~0; "
"accepting as already-aligned (e.g. host UTC bridge)"
)
elif not ok:
notes.append("correlation peak is weak; check overlapping motion and axis units") notes.append("correlation peak is weak; check overlapping motion and axis units")
return TimeOffsetResult( return TimeOffsetResult(
delta_t_s=delta, delta_t_s=delta,
@@ -176,6 +190,7 @@ def refine_time_offset_signed(
gyro_bias_rad_s: np.ndarray | None = None, gyro_bias_rad_s: np.ndarray | None = None,
search_s: float = 0.08, search_s: float = 0.08,
sample_hz: float = 50.0, sample_hz: float = 50.0,
max_shift_s: float | None = 0.05,
) -> TimeOffsetResult: ) -> TimeOffsetResult:
"""Refine ``δt`` with signed 3-axis rates using a known ``R_IMU_lidar``. """Refine ``δt`` with signed 3-axis rates using a known ``R_IMU_lidar``.
@@ -279,9 +294,23 @@ def refine_time_offset_signed(
f"corr={best_corr:.3f}, mag_corr={mag_at_best:.3f} (coarse_mag={mag_at_coarse:.3f}), " f"corr={best_corr:.3f}, mag_corr={mag_at_best:.3f} (coarse_mag={mag_at_coarse:.3f}), "
f"search=±{half:.3f}s" f"search=±{half:.3f}s"
) )
shift = abs(best_delta - float(delta_t_s))
if max_shift_s is not None and shift > float(max_shift_s):
notes.append(
f"signed refine rejected: |Δδt|={shift:.4f}s exceeds max_shift={float(max_shift_s):.4f}s; "
"keeping previous delta_t"
)
return TimeOffsetResult(
delta_t_s=float(delta_t_s),
correlation_peak=mag_at_coarse if mag_at_coarse > 0 else best_corr,
search_s=search_s,
notes=tuple(notes),
ok=True,
)
# Require a meaningful MSE drop so tiny downhill noise cannot walk δt across iterations.
improved = ( improved = (
np.isfinite(best_cost) np.isfinite(best_cost)
and best_cost < coarse_cost * 0.999 and best_cost < coarse_cost * 0.98
# Do not sacrifice the more reliable magnitude alignment for a noisy signed MSE gain. # Do not sacrifice the more reliable magnitude alignment for a noisy signed MSE gain.
and mag_at_best + 1e-4 >= mag_at_coarse and mag_at_best + 1e-4 >= mag_at_coarse
) )
+3 -2
View File
@@ -41,10 +41,11 @@ python -m imu_lidar.cli run --vehicle-config ... --imu ... --lidar ... --output
| 5 | `lidar_deskew.py` | 可选点云去畸变(低速可关) | | 5 | `lidar_deskew.py` | 可选点云去畸变(低速可关) |
| 6 | `imu_preintegration.py` | IMU 预积分(旋转及速度/位移增量、协方差、零偏雅可比) | | 6 | `imu_preintegration.py` | IMU 预积分(旋转及速度/位移增量、协方差、零偏雅可比) |
| 6 | `motion_pairs.py` | 构造运动对;手眼使用其中的旋转 | | 6 | `motion_pairs.py` | 构造运动对;手眼使用其中的旋转 |
| 6 | `motion_pairs_io.py` | 运动对 JSON 缓存读写(供可视化直读) |
| 7 | `rotation_handeye.py` | 加权旋转手眼 | | 7 | `rotation_handeye.py` | 加权旋转手眼 |
| 8 | `observability.py` | 旋转 / 平移可观性检查 | | 8 | `observability.py` | 旋转 / 平移可观性检查 |
| 8 | `joint_optimizer.py` | 联合精修;完整模式下可估计平移、重力、速度与时变零偏 | | 8 | `joint_optimizer.py` | 联合精修;完整模式下可估计平移、重力、速度与时变零偏 |
| 9 | `finalize.py` | 写出结果 JSON | | 9 | `finalize.py` | 写出结果 JSON(含 `motion_pairs.json` |
| — | `pipeline.py` | 编排全流程 | | — | `pipeline.py` | 编排全流程 |
| — | `cli.py` | 命令行入口 | | — | `cli.py` | 命令行入口 |
| — | `CHANGELOG.md` | 改动记录 | | — | `CHANGELOG.md` | 改动记录 |
@@ -56,7 +57,7 @@ python -m imu_lidar.cli run --vehicle-config ... --imu ... --lidar ... --output
- **运动对**始终计算完整预积分量(旋转、速度增量、位移增量及不确定度)。 - **运动对**始终计算完整预积分量(旋转、速度增量、位移增量及不确定度)。
- `--mode rotation_only`:只精修旋转与常值陀螺零偏,交付旋转与时间偏置。 - `--mode rotation_only`:只精修旋转与常值陀螺零偏,交付旋转与时间偏置。
- `--mode full_se3`在可观时再估计重力、关键帧速度、时变零偏与平移;结果写入 `summary.json` 的 joint 字段 - `--mode full_se3`当前完成 Phase-A 后明确拒绝平移;待 Phase-B/C 会话状态重构完成后再恢复完整 SE(3) 交付
--- ---
+8 -2
View File
@@ -28,8 +28,14 @@ python -m pytest -q
| `test_preintegration_bias_jacobian_matches_finite_difference` | 随机陀螺序列 | 旋转预积分 `J_bg` | 与有限差分一致(松阈值) | | `test_preintegration_bias_jacobian_matches_finite_difference` | 随机陀螺序列 | 旋转预积分 `J_bg` | 与有限差分一致(松阈值) |
| `test_imu_preintegration_recovers_constant_accel_translation` | 常值加速度 | 完整预积分 Δv/Δp | 接近解析值 | | `test_imu_preintegration_recovers_constant_accel_translation` | 常值加速度 | 完整预积分 Δv/Δp | 接近解析值 |
| `test_imu_preintegration_bias_jacobian_finite_difference` | 随机 IMU | `J_bg`/`J_ba` 一阶修正 | 与重积分接近 | | `test_imu_preintegration_bias_jacobian_finite_difference` | 随机 IMU | `J_bg`/`J_ba` 一阶修正 | 与重积分接近 |
| `test_synthetic_pipeline_rotation_and_time_offset` | 端到端合成会话 | `rotation_only` 全流程 | `rotation_only_accepted`;δt 准;手眼 RMS < 5° | | `test_synthetic_pipeline_rejects_noisy_icp_but_keeps_time_audit` | synthetic end-to-end | strict rotation quality gate + time audit | noisy ICP is blocked; delta-t remains accurate |
| `test_synthetic_pipeline_full_se3_smoke` | 同上 | `full_se3` 不崩溃 | 状态为 accepted / rejected / rotation_only 之一 | | `test_synthetic_pipeline_full_se3_smoke` | synthetic end-to-end | full-SE(3) smoke test | returns an explicit accepted/rejected/blocked status |
| `test_planar_yaw_is_not_full_rotation_or_translation_observable` | pure-yaw motion pairs | degeneracy detection | full rotation/translation observability is rejected |
| `test_multi_axis_motion_is_rotation_and_translation_observable` | multi-axis motion pairs | positive observability case | rotation and translation pass |
| `test_translation_prior_is_reported_but_not_accepted_when_unobservable` | planar motion + CAD prior | prior semantics | prior is reported but not accepted as calibration |
| `test_handeye_rejects_a_small_fraction_of_gross_rotation_outliers` | motion pairs with a gross outlier | residual-distribution gate | solve is rejected |
| `test_motion_pairs_reject_low_fitness` | low-fitness registration | fitness gate | no motion pair is emitted |
| `test_motion_pairs_reject_imu_and_lidar_discontinuities` | timestamp gaps | continuity gates | cross-gap pairs are rejected |
合成数据由 `tools/generate_synthetic_session.py` 生成(墙面点云 + 已知外参 yaw 与 δt)。 合成数据由 `tools/generate_synthetic_session.py` 生成(墙面点云 + 已知外参 yaw 与 δt)。
+15 -1
View File
@@ -8,7 +8,8 @@ from pathlib import Path
import numpy as np import numpy as np
from tools.h32_dlog.difop import CHANNELS, HORIZONTAL_START, VERTICAL_START, parse_difop_angles from tools.h32_dlog.difop import CHANNELS, HORIZONTAL_START, VERTICAL_START, parse_difop_angles
from tools.h32_dlog.dobject import discover_records, iter_payloads, resolve_dlog_root from tools.h32_dlog.dobject import RECORD_RE, discover_records, iter_payloads, resolve_dlog_root
from tools.h32_dlog.timeutil import local_wall_to_dotnet_ticks
from tools.h32_dlog.load_session import load_h32_dlog_lidar from tools.h32_dlog.load_session import load_h32_dlog_lidar
from tools.h32_dlog.payload_v1 import ( from tools.h32_dlog.payload_v1 import (
MsopPacketItem, MsopPacketItem,
@@ -90,6 +91,19 @@ def _write_dorec_record(
return start return start
def test_recovered_index_line_and_local_ticks():
line = (
">DObject `frontlidar-msop-raw` post len=15532B, id:9CF1, "
"tic:639218060782100466, @data.bin:0"
)
match = RECORD_RE.search(line)
assert match is not None
assert match.group("name") == "frontlidar-msop-raw"
assert match.group("file") == "data.bin"
assert int(match.group("offset")) == 0
assert local_wall_to_dotnet_ticks("2026-08-08T17:14:38") == 639218060780000000
def test_parse_msop_and_difop_payload_roundtrip(): def test_parse_msop_and_difop_payload_roundtrip():
packet = _make_msop_packet(seconds=1700000000, microseconds=123456) packet = _make_msop_packet(seconds=1700000000, microseconds=123456)
item = MsopPacketItem( item = MsopPacketItem(
+79
View File
@@ -0,0 +1,79 @@
"""Unit tests for HI13 / HI91 IMU decoding."""
from __future__ import annotations
import struct
from tools.rscap_v2.capture_format_v2 import CaptureFile, CaptureHeader, RawChunk
from tools.rscap_v2.hi13_imu import crc16_hi13, iter_hi13_imu_samples, parse_hi91_frame
def _hi91_frame(
*,
device_ms: int = 123456,
accel_g=(0.0, 0.0, 1.0),
gyro_dps=(1.0, -2.0, 3.0),
) -> bytes:
payload = bytearray(76)
payload[0] = 0x91
struct.pack_into("<H", payload, 1, 0) # pps
payload[3] = 25 # temp
struct.pack_into("<f", payload, 4, 101325.0)
struct.pack_into("<I", payload, 8, device_ms)
struct.pack_into("<fff", payload, 12, *accel_g)
struct.pack_into("<fff", payload, 24, *gyro_dps)
# remaining mag/rpy/quat left zero
payload_length = len(payload)
header = bytearray(6)
header[0] = 0x5A
header[1] = 0xA5
header[2] = payload_length & 0xFF
header[3] = (payload_length >> 8) & 0xFF
frame_wo_crc = bytes(header[:4]) + bytes(payload)
# crc over header[0:4] + payload
tmp = bytearray(6 + payload_length)
tmp[0:4] = header[0:4]
tmp[6:] = payload
crc = crc16_hi13(tmp, payload_length)
header[4] = crc & 0xFF
header[5] = (crc >> 8) & 0xFF
return bytes(header) + bytes(payload)
def test_parse_hi91_units():
frame = _hi91_frame(device_ms=5000, accel_g=(0.0, 0.0, 1.0), gyro_dps=(57.2957795, 0.0, 0.0))
parsed = parse_hi91_frame(frame)
assert parsed is not None
gyro, accel, device_ms = parsed
assert device_ms == 5000
assert abs(accel[2] - 9.80665) < 1e-4
assert abs(gyro[0] - 1.0) < 1e-5
def test_iter_hi13_from_capture():
frame = _hi91_frame(device_ms=42)
header = CaptureHeader(
sensor_kind="hi13r4-imu",
session_id="t",
session_start_utc_ticks=0,
session_start_monotonic_ticks=0,
monotonic_frequency=10_000_000,
port="COM1",
baud=115200,
file_start_utc_ticks=0,
)
chunk = RawChunk(
sequence=1,
receive_utc_ticks=100,
receive_monotonic_ticks=1,
raw=frame,
record_file_offset=0,
raw_file_offset=0,
record_crc32=0,
crc_valid=True,
)
capture = CaptureFile(path="mem", header=header, chunks=[chunk], footer=None)
samples = iter_hi13_imu_samples(capture)
assert len(samples) == 1
assert samples[0].device_timestamp_us == 42_000
assert abs(samples[0].t_s - 0.042) < 1e-12
+78
View File
@@ -0,0 +1,78 @@
"""Tests for motion-pair cache IO."""
from __future__ import annotations
from pathlib import Path
import numpy as np
from imu_lidar.contracts import MotionPair
from imu_lidar.motion_pairs_io import (
build_motion_pairs_payload,
load_motion_pairs,
pair_from_dict,
pair_to_dict,
pairs_for_session,
save_motion_pairs,
)
def test_pair_roundtrip(tmp_path: Path) -> None:
pair = MotionPair(
session_id="s0",
i=1,
j=4,
t_i_s=1.0,
t_j_s=2.5,
R_A=np.eye(3),
R_B=np.eye(3),
t_A_m=np.array([0.1, 0.0, 0.0]),
t_B_m=np.array([0.1, 0.0, 0.0]),
fitness=0.8,
metadata={
"weight": 12.0,
"cov": (np.eye(3) * 1e-4).tolist(),
"J_bg": (-np.eye(3)).tolist(),
"cov9": [[0.0] * 9] * 9,
"backend": "test",
"gyro_bias0_rad_s": [0.01, -0.02, 0.03],
"accel_bias0_m_s2": [0.1, 0.2, -0.1],
"time_offset_s": 0.004,
"keyframe_span": 3,
"is_consecutive": False,
},
)
encoded = pair_to_dict(pair)
assert "cov9" not in encoded["metadata"]
assert "cov" in encoded["metadata"]
assert "J_bg" in encoded["metadata"]
assert encoded["metadata"]["weight"] == 12.0
restored = pair_from_dict(encoded)
assert restored.i == 1 and restored.j == 4
np.testing.assert_allclose(restored.t_A_m, [0.1, 0.0, 0.0])
np.testing.assert_allclose(restored.metadata["gyro_bias0_rad_s"], [0.01, -0.02, 0.03])
assert restored.metadata["keyframe_span"] == 3
payload = build_motion_pairs_payload(
prepared_sessions=[
{
"session_id": "s0",
"time_offset_s": 0.0,
"gyro_bias_rad_s": np.zeros(3),
"pairs": (pair,),
}
]
)
path = save_motion_pairs(tmp_path / "motion_pairs.json", payload)
loaded = load_motion_pairs(path)
assert loaded["schema_version"] == 2
pairs = pairs_for_session(loaded, "s0")
assert len(pairs) == 1
assert pairs[0].session_id == "s0"
payload["schema_version"] = 1
legacy_path = save_motion_pairs(
tmp_path / "motion_pairs_v1.json", payload
)
legacy = load_motion_pairs(legacy_path)
assert legacy["schema_version"] == 1
+194
View File
@@ -0,0 +1,194 @@
"""Tests for cached, session-balanced Phase-A comparison."""
from __future__ import annotations
import numpy as np
from imu_lidar.contracts import ImuSeries, MotionPair
from imu_lidar.geometry import so3_exp, so3_log
from imu_lidar.imu_preintegration import preintegrate_gyro
from imu_lidar.phase_a import (
rehydrate_phase_a_pairs,
select_decorrelated_phase_a_pairs,
solve_phase_a_comparison,
)
def _phase_a_pair(
session_id: str,
index: int,
r_true: np.ndarray,
vector_deg: tuple[float, float, float],
bias0: np.ndarray,
) -> MotionPair:
r_b = so3_exp(np.deg2rad(np.asarray(vector_deg, dtype=float)))
return MotionPair(
session_id=session_id,
i=index,
j=index + 1,
t_i_s=float(index),
t_j_s=float(index + 1),
R_A=r_true @ r_b @ r_true.T,
R_B=r_b,
t_A_m=np.zeros(3),
t_B_m=np.zeros(3),
metadata={
"J_bg": (-np.eye(3)).tolist(),
"cov": (np.eye(3) * 1e-5).tolist(),
"gyro_bias0_rad_s": bias0.tolist(),
},
)
def test_phase_a_reports_three_variants_and_leave_one_session_out() -> None:
r_true = so3_exp(np.deg2rad(np.array([3.0, -2.0, 25.0])))
prior = so3_exp(np.deg2rad(np.array([0.0, 0.0, 0.2]))) @ r_true
vectors = (
(12.0, 0.0, 0.0),
(0.0, 15.0, 0.0),
(0.0, 0.0, 18.0),
(10.0, 8.0, 0.0),
(0.0, 11.0, 9.0),
(7.0, 0.0, 13.0),
(9.0, -5.0, 6.0),
(-6.0, 8.0, 11.0),
(5.0, 7.0, -9.0),
)
biases = {
"s0": np.array([0.001, -0.0005, 0.0002]),
"s1": np.array([-0.0004, 0.0008, -0.0001]),
"s2": np.array([0.0002, 0.0001, -0.0006]),
}
pairs: list[MotionPair] = []
index = 0
for sid, count in (("s0", 18), ("s1", 9), ("s2", 6)):
for local_index in range(count):
pairs.append(
_phase_a_pair(
sid,
index,
r_true,
vectors[local_index % len(vectors)],
biases[sid],
)
)
index += 1
result = solve_phase_a_comparison(
pairs,
gyro_bias_rad_s_by_session=biases,
rotation_prior=prior,
rotation_prior_sigma_deg=15.0,
yaw_std_max_deg=1.0,
leave_one_out_yaw_range_max_deg=1.0,
data_prior_difference_max_deg=1.0,
decorrelation_block_s=0.0,
max_nfev=80,
)
assert result.accepted
assert result.strong_pair_counts_per_session == {
"s0": 18,
"s1": 9,
"s2": 6,
}
assert len(result.leave_one_out) == 3
assert result.marginal_observability.rank == 3
assert result.leave_one_out_yaw_range_deg < 0.1
for variant in (
result.fixed_bg_data_only,
result.session_bg_data_only,
result.session_bg_with_rotation_prior,
):
error_deg = np.degrees(
np.linalg.norm(
so3_log(r_true.T @ variant.R_IMU_lidar)
)
)
assert error_deg < 0.1
def test_rehydrate_phase_a_pairs_recovers_jacobian_without_lidar() -> None:
t_s = np.linspace(0.0, 1.0, 201)
gyro = np.tile(np.array([0.12, -0.04, 0.2]), (t_s.size, 1))
bias0 = np.array([0.01, -0.005, 0.002])
imu = ImuSeries(
t_s=t_s,
gyro_rad_s=gyro,
acc_m_s2=np.zeros((t_s.size, 3)),
)
preint = preintegrate_gyro(t_s, gyro, 0.1, 0.8, bias0)
pair = MotionPair(
session_id="s0",
i=0,
j=1,
t_i_s=0.1,
t_j_s=0.8,
R_A=preint.delta_R,
R_B=preint.delta_R,
metadata={
"t_i_imu_s": 0.1,
"t_j_imu_s": 0.8,
"gyro_bias0_rad_s": bias0.tolist(),
"preint_sigma_rad": preint.sigma_rad,
},
)
enriched, report = rehydrate_phase_a_pairs(
[pair],
imu_by_session={"s0": imu},
bias0_by_session={"s0": bias0},
)
assert "J_bg" in enriched[0].metadata
assert "cov" in enriched[0].metadata
assert report["max_R_A_error_deg"] < 1e-8
def test_phase_a_time_blocks_do_not_count_overlapping_pairs_as_independent() -> None:
r_true = so3_exp(np.deg2rad(np.array([1.0, -2.0, 20.0])))
bias = np.zeros(3)
pairs = [
_phase_a_pair("s0", index, r_true, (5.0 + index, 2.0, 1.0), bias)
for index in range(9)
]
selected = select_decorrelated_phase_a_pairs(
pairs,
block_s=3.0,
max_pairs_per_block=1,
)
assert len(selected) == 3
assert all(pair in pairs for pair in selected)
def test_phase_a_planar_motion_is_partial_and_keeps_weak_direction_from_prior() -> None:
r_true = so3_exp(np.deg2rad(np.array([4.0, -3.0, 31.0])))
prior = so3_exp(np.deg2rad(np.array([0.2, -0.1, 0.4]))) @ r_true
biases = {"s0": np.zeros(3), "s1": np.zeros(3)}
pairs: list[MotionPair] = []
for session_index, sid in enumerate(biases):
for index in range(12):
pairs.append(
_phase_a_pair(
sid,
session_index * 100 + index,
r_true,
(0.0, 0.0, 8.0 + index),
biases[sid],
)
)
result = solve_phase_a_comparison(
pairs,
gyro_bias_rad_s_by_session=biases,
rotation_prior=prior,
decorrelation_block_s=0.0,
yaw_std_max_deg=0.5,
run_leave_one_out=False,
max_nfev=80,
)
assert not result.accepted
assert result.partial_accepted
assert result.solution_status == "phase_a_partial_accepted"
assert result.marginal_observability.precision_rank == 2
assert result.observable_subspace_with_prior is not None
assert np.isinf(result.marginal_observability.direction_std_deg[0])
+260
View File
@@ -0,0 +1,260 @@
"""Regression tests for calibration quality, continuity, and observability gates."""
from __future__ import annotations
import numpy as np
from imu_lidar.contracts import ImuSeries, LidarFrame, MotionPair
from imu_lidar.geometry import make_transform, so3_exp, so3_log
from imu_lidar.joint_optimizer import solve_joint_extrinsic
from imu_lidar.motion_pairs import build_motion_pairs
from imu_lidar.observability import analyze_observability
from imu_lidar.registration import RegistrationResult
from imu_lidar.rotation_handeye import solve_rotation_handeye
def _motion_pair(index: int, rotation_vector: np.ndarray) -> MotionPair:
rotation = so3_exp(np.asarray(rotation_vector, dtype=float))
return MotionPair(
session_id="synthetic",
i=index,
j=index + 1,
t_i_s=float(index),
t_j_s=float(index + 1),
R_A=rotation,
R_B=rotation,
t_A_m=np.zeros(3),
t_B_m=np.array([0.1, -0.03, 0.0]),
fitness=0.9,
metadata={
"J_bg": (-np.eye(3)).tolist(),
"cov": (np.eye(3) * 1e-4).tolist(),
"gyro_bias0_rad_s": [0.0, 0.0, 0.0],
},
)
def _frame(frame_id: str, mid_s: float) -> LidarFrame:
return LidarFrame(
frame_id=frame_id,
t_start_s=mid_s - 0.01,
t_end_s=mid_s + 0.01,
points_xyz=np.zeros((64, 3)),
)
def _registration(*, fitness: float = 0.9) -> RegistrationResult:
rotation = so3_exp(np.deg2rad(np.array([0.0, 0.0, 10.0])))
return RegistrationResult(
transform=make_transform(np.array([0.4, 0.0, 0.0]), rotation),
fitness=fitness,
rotation_deg=10.0,
translation_m=0.4,
backend="test",
ok=True,
)
def test_planar_yaw_is_not_full_rotation_or_translation_observable():
pairs = [
_motion_pair(i, np.deg2rad(np.array([0.0, 0.0, angle_deg])))
for i, angle_deg in enumerate((5.0, 8.0, 12.0, 17.0, 23.0, 31.0))
]
report = analyze_observability(pairs, np.eye(3))
assert not report.rotation_observable
assert not report.translation_observable
def test_multi_axis_motion_is_rotation_and_translation_observable():
vectors_deg = (
(12.0, 0.0, 0.0),
(0.0, 15.0, 0.0),
(0.0, 0.0, 18.0),
(10.0, 8.0, 0.0),
(0.0, 11.0, 9.0),
(7.0, 0.0, 13.0),
)
pairs = [
_motion_pair(i, np.deg2rad(np.asarray(vector_deg)))
for i, vector_deg in enumerate(vectors_deg)
]
report = analyze_observability(pairs, np.eye(3))
assert report.rotation_observable
assert report.translation_observable
def test_translation_prior_is_reported_but_not_accepted_when_unobservable():
pairs = [
_motion_pair(i, np.deg2rad(np.array([0.0, 0.0, angle_deg])))
for i, angle_deg in enumerate((5.0, 8.0, 12.0, 17.0, 23.0, 31.0))
]
prior = np.array([0.3, -0.2, 0.5])
result = solve_joint_extrinsic(
pairs,
np.eye(3),
force_rotation_only=False,
enable_phase_c=False,
t_prior_m=prior,
)
assert not result.translation_accepted
np.testing.assert_allclose(result.T_IMU_lidar[:3, 3], prior)
assert any("prior only" in note for note in result.notes)
def test_handeye_rejects_a_small_fraction_of_gross_rotation_outliers():
rng = np.random.default_rng(7)
r_true = so3_exp(np.deg2rad(np.array([2.0, -3.0, 20.0])))
pairs: list[MotionPair] = []
for index in range(100):
axis = rng.normal(size=3)
axis /= np.linalg.norm(axis)
r_b = so3_exp(axis * np.deg2rad(rng.uniform(8.0, 30.0)))
r_a = r_true @ r_b @ r_true.T
if index == 0:
r_a = so3_exp(np.deg2rad(np.array([18.0, 0.0, 0.0]))) @ r_a
pairs.append(
MotionPair(
session_id="outlier",
i=index,
j=index + 1,
t_i_s=float(index),
t_j_s=float(index + 1),
R_A=r_a,
R_B=r_b,
)
)
result = solve_rotation_handeye(pairs)
assert not result.ok
assert result.outlier_fraction_gt_5deg > 0.005
def test_motion_pairs_reject_low_fitness(monkeypatch):
monkeypatch.setattr(
"imu_lidar.motion_pairs.register_lidar_pair",
lambda *_args, **_kwargs: _registration(fitness=0.3),
)
imu = ImuSeries(
t_s=np.linspace(0.0, 1.2, 121),
gyro_rad_s=np.zeros((121, 3)),
acc_m_s2=np.zeros((121, 3)),
)
result = build_motion_pairs(
session_id="fitness",
keyframes=[_frame("0", 0.1), _frame("1", 1.1)],
keyframe_indices=[0, 1],
imu=imu,
delta_t_s=0.0,
min_registration_fitness=0.5,
)
assert not result.pairs
assert any("fitness<0.50: 1" in note for note in result.notes)
def test_motion_pairs_reject_imu_and_lidar_discontinuities(monkeypatch):
monkeypatch.setattr(
"imu_lidar.motion_pairs.register_lidar_pair",
lambda *_args, **_kwargs: _registration(),
)
imu_with_gap = ImuSeries(
t_s=np.array([0.0, 0.1, 0.2, 0.3, 0.4, 0.8, 0.9, 1.0, 1.1, 1.2]),
gyro_rad_s=np.zeros((10, 3)),
acc_m_s2=np.zeros((10, 3)),
)
imu_result = build_motion_pairs(
session_id="imu-gap",
keyframes=[_frame("0", 0.1), _frame("1", 1.1)],
keyframe_indices=[0, 1],
imu=imu_with_gap,
delta_t_s=0.0,
max_imu_gap_s=0.2,
)
assert not imu_result.pairs
assert any("IMU gap>0.200s: 1" in note for note in imu_result.notes)
continuous_imu = ImuSeries(
t_s=np.linspace(0.0, 2.2, 221),
gyro_rad_s=np.zeros((221, 3)),
acc_m_s2=np.zeros((221, 3)),
)
lidar_result = build_motion_pairs(
session_id="lidar-gap",
keyframes=[_frame("0", 0.1), _frame("2", 2.1)],
keyframe_indices=[0, 2],
imu=continuous_imu,
delta_t_s=0.0,
all_frame_times_s=np.array([0.1, 0.2, 2.1]),
max_lidar_gap_s=0.5,
)
assert not lidar_result.pairs
assert any("LiDAR gap>0.500s: 1" in note for note in lidar_result.notes)
def test_phase_a_keeps_session_bias_linearization_points_independent():
r_true = so3_exp(np.deg2rad(np.array([2.0, -3.0, 20.0])))
bias0_by_session = {
"s0": np.array([0.010, -0.004, 0.002]),
"s1": np.array([-0.006, 0.008, -0.003]),
}
vectors_deg = (
(12.0, 0.0, 0.0),
(0.0, 15.0, 0.0),
(0.0, 0.0, 18.0),
(10.0, 8.0, 0.0),
(0.0, 11.0, 9.0),
(7.0, 0.0, 13.0),
)
pairs: list[MotionPair] = []
for session_index, (session_id, bias0) in enumerate(bias0_by_session.items()):
for pair_index, vector_deg in enumerate(vectors_deg):
r_b = so3_exp(np.deg2rad(np.asarray(vector_deg)))
r_a = r_true @ r_b @ r_true.T
index = session_index * 100 + pair_index
pairs.append(
MotionPair(
session_id=session_id,
i=index,
j=index + 1,
t_i_s=float(pair_index),
t_j_s=float(pair_index + 1),
R_A=r_a,
R_B=r_b,
t_A_m=np.zeros(3),
t_B_m=np.zeros(3),
metadata={
"J_bg": np.eye(3).tolist(),
"cov": (np.eye(3) * 1e-4).tolist(),
"gyro_bias0_rad_s": bias0.tolist(),
},
)
)
result = solve_joint_extrinsic(
pairs,
r_true,
force_rotation_only=True,
gyro_bias_rad_s_by_session=bias0_by_session,
)
assert result.phase_a_accepted
assert set(result.phase_a_comparison["variants"]) == {
"A0_fixed_bg_data_only",
"A1_session_bg_data_only",
"A2_session_bg_with_rotation_prior",
}
assert set(result.gyro_bias_rad_s_per_session) == {"s0", "s1"}
for session_id, bias0 in bias0_by_session.items():
np.testing.assert_allclose(
result.gyro_bias_rad_s_per_session[session_id], bias0, atol=1e-8
)
assert np.linalg.norm(so3_log(r_true.T @ result.T_IMU_lidar[:3, :3])) < 1e-8
+29 -14
View File
@@ -54,7 +54,7 @@ def _log(rotation: np.ndarray) -> np.ndarray:
return so3_log(rotation) return so3_log(rotation)
def test_synthetic_pipeline_rotation_and_time_offset(tmp_path: Path): def test_synthetic_pipeline_rejects_noisy_icp_but_keeps_time_audit(tmp_path: Path):
meta = generate_synthetic_session(tmp_path, delta_t_s=0.17, yaw_extrinsic_deg=25.0) meta = generate_synthetic_session(tmp_path, delta_t_s=0.17, yaw_extrinsic_deg=25.0)
config = Path(__file__).resolve().parents[1] / "config" / "vehicle_installation.template.yaml" config = Path(__file__).resolve().parents[1] / "config" / "vehicle_installation.template.yaml"
out = tmp_path / "out" out = tmp_path / "out"
@@ -74,17 +74,28 @@ def test_synthetic_pipeline_rotation_and_time_offset(tmp_path: Path):
min_pair_rotation_deg=2.0, min_pair_rotation_deg=2.0,
min_pair_translation_m=0.05, min_pair_translation_m=0.05,
) )
result = run_calibration(request) progress_events: list[dict] = []
assert result.status.value == "rotation_only_accepted" result = run_calibration(request, progress_callback=progress_events.append)
assert result.time_offset_s is not None # The lightweight synthetic point cloud uses approximate ICP and has a
assert abs(result.time_offset_s - meta["delta_t_s"]) < 0.05 # roughly 3-degree P95 residual. The production gate must reject it rather
assert result.T_IMU_lidar is not None # than expose a plausible-looking extrinsic.
# End-to-end uses approximate ICP; allow moderate absolute error but require consistency. assert result.status.value == "blocked"
r_true = so3_exp(np.deg2rad(np.array([2.0, -1.5, meta["yaw_extrinsic_deg"]]))) assert result.T_IMU_lidar is None
err_deg = np.degrees(np.linalg.norm(_log(r_true.T @ result.T_IMU_lidar[:3, :3])))
assert err_deg < 15.0
session0 = result.details["sessions"][0] session0 = result.details["sessions"][0]
assert session0["handeye"]["residual_rms_deg"] < 5.0 assert abs(session0["time_offset_s"] - meta["delta_t_s"]) < 0.05
assert result.details["joint_handeye"]["residual_p95_deg"] > 1.5
assert not result.details["joint_handeye"]["ok"]
assert progress_events[0]["event"] == "pipeline_start"
assert any(
event["stage"] == "motion_pairs" and event["event"] == "complete"
for event in progress_events
)
assert any(
event["stage"] == "joint_optimizer" and event["event"] == "phase_a_complete"
for event in progress_events
)
assert progress_events[-1]["stage"] == "finalize"
assert progress_events[-1]["event"] == "complete"
def test_time_offset_on_synthetic(tmp_path: Path): def test_time_offset_on_synthetic(tmp_path: Path):
@@ -176,13 +187,17 @@ def test_synthetic_pipeline_full_se3_smoke(tmp_path: Path):
"full_se3_accepted", "full_se3_accepted",
"full_se3_rejected_due_to_observability", "full_se3_rejected_due_to_observability",
"rotation_only_accepted", "rotation_only_accepted",
"blocked",
} }
assert result.T_IMU_lidar is not None
session0 = result.details["sessions"][0] session0 = result.details["sessions"][0]
assert "delta_v" in session0.get("pair_notes", []) or session0.get("pair_count", 0) >= 0 assert "delta_v" in session0.get("pair_notes", []) or session0.get("pair_count", 0) >= 0
# Phase-C fields appear when joint ran successfully on pairs. # Phase-C fields appear only when the strict rotation gate passed.
if session0.get("ok"): if result.status.value != "blocked":
assert result.T_IMU_lidar is not None
assert "gyro_bias_rad_s" in session0["joint"] assert "gyro_bias_rad_s" in session0["joint"]
else:
assert result.T_IMU_lidar is None
assert not result.details["joint_handeye"]["ok"]
def test_signed_time_offset_refine_improves_or_keeps(tmp_path: Path): def test_signed_time_offset_refine_improves_or_keeps(tmp_path: Path):
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Build motion_pairs.json next to an existing summary without re-solving extrinsic.
Use this once for older calibration outputs that predate automatic pair caching.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
import numpy as np
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from imu_lidar.imu_audit import audit_imu
from imu_lidar.imu_io import load_imu_samples
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.motion_pairs_io import build_motion_pairs_payload, save_motion_pairs
def _load_summary_meta(summary_path: Path) -> tuple[float, np.ndarray, str]:
summary = json.loads(summary_path.read_text(encoding="utf-8"))
delta_t = float(summary.get("time_offset_s") or 0.0)
session = (summary.get("details") or {}).get("sessions", [{}])[0]
session_id = str(session.get("session_id") or summary_path.parent.name)
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 delta_t, bias, session_id
def export_one(
*,
lidar: Path,
imu: Path,
summary: Path,
output: Path | None,
min_rotation_deg: float,
min_translation_m: float,
) -> Path:
delta_t, bias_from_summary, session_id = _load_summary_meta(summary)
imu_series = load_imu_samples(imu)
# Prefer freshly audited bias if summary bias is missing/zeros.
if float(np.linalg.norm(bias_from_summary)) < 1e-12:
bias = audit_imu(imu_series).gyro_bias_rad_s
else:
bias = bias_from_summary
frames = load_lidar_frames(lidar)
keyframes = build_keyframes(
frames,
min_translation_m=min_translation_m,
min_rotation_deg=min_rotation_deg,
)
pair_set = build_motion_pairs(
session_id=session_id,
keyframes=list(keyframes.frames),
keyframe_indices=keyframes.indices,
imu=imu_series,
delta_t_s=delta_t,
gyro_bias_rad_s=bias,
min_rotation_deg=min_rotation_deg,
min_translation_m=min_translation_m,
)
prepared = [
{
"session_id": session_id,
"time_offset_s": delta_t,
"gyro_bias_rad_s": np.asarray(bias, dtype=float).reshape(3),
"pairs": pair_set.pairs,
}
]
payload = build_motion_pairs_payload(prepared_sessions=prepared)
out = output or (summary.parent / "motion_pairs.json")
save_motion_pairs(out, payload)
print(f"wrote {out} ({len(pair_set.pairs)} pairs, session={session_id}, dt={delta_t:.6f})")
return out
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--lidar", type=Path, required=True)
parser.add_argument("--imu", type=Path, required=True)
parser.add_argument("--summary", type=Path, required=True)
parser.add_argument("--output", type=Path, default=None, help="Default: <summary_dir>/motion_pairs.json")
parser.add_argument("--min-pair-rotation-deg", type=float, default=2.0)
parser.add_argument("--min-pair-translation-m", type=float, default=0.3)
args = parser.parse_args()
export_one(
lidar=args.lidar,
imu=args.imu,
summary=args.summary,
output=args.output,
min_rotation_deg=args.min_pair_rotation_deg,
min_translation_m=args.min_pair_translation_m,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+212 -63
View File
@@ -1,13 +1,18 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Export N300 IMU + H32 LiDAR captures to Lidar-IMU V1 intermediate format. """Export IMU + H32 LiDAR captures to Lidar-IMU V1 intermediate format.
Supported LiDAR sources (exactly one required): IMU sources:
- ``--imu-kind hi13`` (HI13R4 / HI91) or ``n300`` or ``auto``
- one or more ``--imu-rscap`` files (concatenated)
- ``--lidar-dlog``: Medulla dlog from ``RSLidarH32_3D_DLogCaptureNet48`` LiDAR sources (exactly one):
(raw MSOP + DIFOP DObjects; preferred for new recordings) - ``--lidar-dlog``: Medulla dlog dir **or recovered zip** (MSOP + DIFOP)
- ``--lidar-rscap``: legacy H32 MSOP V2 ``.rscap`` (MSOP-only defaults for angles) - ``--lidar-rscap``: legacy H32 MSOP V2 ``.rscap``
Output layout under --out: Optional host-time window (local wall clock, DateTime.Now.Ticks convention):
- ``--host-start`` / ``--host-end`` e.g. ``2026-08-08T17:40:05``
Output under ``--out``:
imu.csv imu.csv
lidar/ lidar/
@@ -15,8 +20,9 @@ Output layout under --out:
frames/frame_XXXXX.npz frames/frame_XXXXX.npz
export_summary.json export_summary.json
Timestamps written into the intermediate format are **device times** Device times stay in ``t`` / ``t_start``/``t_end``. Host UTC receive times are
(N300 device_timestamp_us, H32 MSOP device timestamp), not host receive time. also written so LiDARIMU alignment can bridge clocks without forcing first-frame
device coincidence.
""" """
from __future__ import annotations from __future__ import annotations
@@ -34,26 +40,48 @@ if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
from tools.h32_dlog.load_session import load_h32_dlog_lidar from tools.h32_dlog.load_session import load_h32_dlog_lidar
from tools.h32_dlog.timeutil import (
local_wall_to_dotnet_ticks,
local_wall_to_utc_dotnet_ticks,
utc_dotnet_ticks_to_unix_s,
)
from tools.rscap_v2.capture_format_v2 import file_summary, read_capture from tools.rscap_v2.capture_format_v2 import file_summary, read_capture
from tools.rscap_v2.h32_msop import iter_h32_frames, iter_h32_frames_from_packets from tools.rscap_v2.h32_msop import iter_h32_frames, iter_h32_frames_from_packets
from tools.rscap_v2.n300_imu import iter_n300_imu_samples, samples_to_arrays from tools.rscap_v2.hi13_imu import iter_hi13_imu_samples
from tools.rscap_v2.n300_imu import ImuSample, iter_n300_imu_samples, samples_to_arrays
def write_imu_csv(path: Path, t: np.ndarray, gyro: np.ndarray, accel: np.ndarray) -> None: def write_imu_csv(path: Path, samples: list[ImuSample]) -> None:
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", newline="", encoding="utf-8") as handle: with path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.writer(handle) writer = csv.writer(handle)
writer.writerow(["t", "gx", "gy", "gz", "ax", "ay", "az"]) writer.writerow(
for index in range(t.shape[0]): [
"t",
"gx",
"gy",
"gz",
"ax",
"ay",
"az",
"t_host_utc_s",
"receive_utc_ticks",
]
)
for sample in samples:
ticks = int(sample.host_receive_utc_ticks)
t_host = utc_dotnet_ticks_to_unix_s(ticks) if ticks > 0 else float("nan")
writer.writerow( writer.writerow(
[ [
f"{t[index]:.9f}", f"{sample.t_s:.9f}",
f"{gyro[index, 0]:.12g}", f"{sample.gyro_rad_s[0]:.12g}",
f"{gyro[index, 1]:.12g}", f"{sample.gyro_rad_s[1]:.12g}",
f"{gyro[index, 2]:.12g}", f"{sample.gyro_rad_s[2]:.12g}",
f"{accel[index, 0]:.12g}", f"{sample.accel_m_s2[0]:.12g}",
f"{accel[index, 1]:.12g}", f"{sample.accel_m_s2[1]:.12g}",
f"{accel[index, 2]:.12g}", f"{sample.accel_m_s2[2]:.12g}",
f"{t_host:.9f}" if ticks > 0 else "",
ticks,
] ]
) )
@@ -64,22 +92,45 @@ def write_lidar_session(root: Path, frames) -> dict:
index_path = root / "frames_index.csv" index_path = root / "frames_index.csv"
with index_path.open("w", newline="", encoding="utf-8") as handle: with index_path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.writer(handle) writer = csv.writer(handle)
writer.writerow(["frame_id", "filename", "t_start", "t_end"]) writer.writerow(
[
"frame_id",
"filename",
"t_start",
"t_end",
"host_receive_utc_ticks",
"t_host_utc_s",
"host_receive_utc_end_ticks",
"t_host_utc_end_s",
]
)
point_counts = [] point_counts = []
host_ok = 0
for index, frame in enumerate(frames): for index, frame in enumerate(frames):
rel = f"frames/frame_{index:05d}.npz" rel = f"frames/frame_{index:05d}.npz"
np.savez_compressed(root / rel, points=np.asarray(frame.points_xyz, dtype=np.float32)) np.savez_compressed(root / rel, points=np.asarray(frame.points_xyz, dtype=np.float32))
h0 = int(getattr(frame, "host_receive_utc_ticks_start", 0) or 0)
h1 = int(getattr(frame, "host_receive_utc_ticks_end", 0) or 0)
t_host0 = utc_dotnet_ticks_to_unix_s(h0) if h0 > 0 else float("nan")
t_host1 = utc_dotnet_ticks_to_unix_s(h1) if h1 > 0 else float("nan")
if h0 > 0:
host_ok += 1
writer.writerow( writer.writerow(
[ [
index, index,
rel, rel,
f"{frame.t_start_s:.9f}", f"{frame.t_start_s:.9f}",
f"{frame.t_end_s:.9f}", f"{frame.t_end_s:.9f}",
h0,
f"{t_host0:.9f}" if h0 > 0 else "",
h1,
f"{t_host1:.9f}" if h1 > 0 else "",
] ]
) )
point_counts.append(int(frame.points_xyz.shape[0])) point_counts.append(int(frame.points_xyz.shape[0]))
return { return {
"frames": len(frames), "frames": len(frames),
"frames_with_host_utc": host_ok,
"points_min": int(min(point_counts)) if point_counts else 0, "points_min": int(min(point_counts)) if point_counts else 0,
"points_max": int(max(point_counts)) if point_counts else 0, "points_max": int(max(point_counts)) if point_counts else 0,
"points_mean": float(np.mean(point_counts)) if point_counts else 0.0, "points_mean": float(np.mean(point_counts)) if point_counts else 0.0,
@@ -88,15 +139,63 @@ def write_lidar_session(root: Path, frames) -> dict:
} }
def detect_imu_kind(paths: list[Path], explicit: str) -> str:
if explicit != "auto":
return explicit
joined = " ".join(path.name.lower() for path in paths)
if "hi13" in joined or "hipnuc" in joined:
return "hi13"
if "n300" in joined or "wheeltec" in joined:
return "n300"
return "hi13"
def load_imu_samples(
paths: list[Path],
*,
kind: str,
host_ticks_min: int | None,
host_ticks_max: int | None,
) -> tuple[list[ImuSample], list[dict], str]:
samples: list[ImuSample] = []
captures_meta: list[dict] = []
for path in paths:
capture = read_capture(path)
captures_meta.append(file_summary(capture))
if kind == "hi13":
part = iter_hi13_imu_samples(
capture,
host_utc_ticks_min=host_ticks_min,
host_utc_ticks_max=host_ticks_max,
)
elif kind == "n300":
part = iter_n300_imu_samples(capture)
if host_ticks_min is not None or host_ticks_max is not None:
part = [
sample
for sample in part
if (host_ticks_min is None or sample.host_receive_utc_ticks >= host_ticks_min)
and (host_ticks_max is None or sample.host_receive_utc_ticks <= host_ticks_max)
]
else:
raise ValueError(f"unsupported imu kind: {kind}")
samples.extend(part)
samples.sort(key=lambda sample: (sample.t_s, sample.device_timestamp_us))
return samples, captures_meta, kind
def export_session( def export_session(
*, *,
imu_rscap: Path, imu_rscap: list[Path] | Path,
out: Path, out: Path,
lidar_rscap: Path | None = None, lidar_rscap: Path | None = None,
lidar_dlog: Path | None = None, lidar_dlog: Path | None = None,
imu_kind: str = "auto",
msop_object: str = "frontlidar-msop-raw", msop_object: str = "frontlidar-msop-raw",
difop_object: str = "frontlidar-difop-raw", difop_object: str = "frontlidar-difop-raw",
require_difop: bool = False, require_difop: bool = False,
host_start: str | None = None,
host_end: str | None = None,
frame_stride: int = 1, frame_stride: int = 1,
max_points_per_frame: int | None = 80000, max_points_per_frame: int | None = 80000,
min_range_m: float = 0.3, min_range_m: float = 0.3,
@@ -106,24 +205,41 @@ def export_session(
if (lidar_rscap is None) == (lidar_dlog is None): if (lidar_rscap is None) == (lidar_dlog is None):
raise ValueError("provide exactly one of lidar_rscap or lidar_dlog") raise ValueError("provide exactly one of lidar_rscap or lidar_dlog")
imu_paths = [imu_rscap] if isinstance(imu_rscap, Path) else list(imu_rscap)
if not imu_paths:
raise ValueError("at least one --imu-rscap is required")
# LiDAR DObject tic uses DateTime.Now; IMU/MSOP host fields use UTC.
lidar_ticks_min = local_wall_to_dotnet_ticks(host_start) if host_start else None
lidar_ticks_max = local_wall_to_dotnet_ticks(host_end) if host_end else None
imu_ticks_min = local_wall_to_utc_dotnet_ticks(host_start) if host_start else None
imu_ticks_max = local_wall_to_utc_dotnet_ticks(host_end) if host_end else None
kind = detect_imu_kind(imu_paths, imu_kind)
out.mkdir(parents=True, exist_ok=True) out.mkdir(parents=True, exist_ok=True)
imu_capture = read_capture(imu_rscap) samples, imu_captures, kind = load_imu_samples(
imu_paths,
samples = iter_n300_imu_samples(imu_capture) kind=kind,
t, gyro, accel = samples_to_arrays(samples) host_ticks_min=imu_ticks_min,
host_ticks_max=imu_ticks_max,
)
t, _gyro, _accel = samples_to_arrays(samples)
imu_csv = out / "imu.csv" imu_csv = out / "imu.csv"
write_imu_csv(imu_csv, t, gyro, accel) write_imu_csv(imu_csv, samples)
imu_host_ok = sum(1 for sample in samples if sample.host_receive_utc_ticks > 0)
lidar_meta: dict
if lidar_dlog is not None: if lidar_dlog is not None:
session = load_h32_dlog_lidar( session = load_h32_dlog_lidar(
lidar_dlog, lidar_dlog,
msop_object=msop_object, msop_object=msop_object,
difop_object=difop_object, difop_object=difop_object,
require_difop=require_difop, require_difop=require_difop,
host_ticks_min=lidar_ticks_min,
host_ticks_max=lidar_ticks_max,
) )
frames = iter_h32_frames_from_packets( frames = iter_h32_frames_from_packets(
session.msop_packets, session.msop_packets,
host_utc_ticks=session.msop_host_utc_ticks,
min_frame_points=min_frame_points, min_frame_points=min_frame_points,
frame_stride=frame_stride, frame_stride=frame_stride,
min_range_m=min_range_m, min_range_m=min_range_m,
@@ -134,16 +250,20 @@ def export_session(
) )
lidar_meta = { lidar_meta = {
"source": "dlog", "source": "dlog",
"lidar_dlog": str(session.dlog_root), "lidar_dlog": session.dlog_root,
"msop_object": session.msop_object, "msop_object": session.msop_object,
"difop_object": session.difop_object, "difop_object": session.difop_object,
"msop_packets": len(session.msop_packets), "msop_packets": len(session.msop_packets),
"msop_packets_with_host_utc": sum(1 for ticks in session.msop_host_utc_ticks if ticks > 0),
"msop_batches": session.msop_batch_count, "msop_batches": session.msop_batch_count,
"difop_records": session.difop_record_count, "difop_records": session.difop_record_count,
"session_id": session.session_id, "session_id": session.session_id,
"lidar_ip": session.lidar_ip, "lidar_ip": session.lidar_ip,
"angle_source": session.angle_source, "angle_source": session.angle_source,
"timestamp_note": "h32_msop_device_timestamp -> seconds (from MSOP bytes)", "timestamp_note": (
"device: h32_msop_device_timestamp -> seconds; "
"host: MSOP HostReceiveUtcTicks -> unix seconds"
),
} }
else: else:
assert lidar_rscap is not None assert lidar_rscap is not None
@@ -161,25 +281,46 @@ def export_session(
"lidar_rscap": str(lidar_rscap), "lidar_rscap": str(lidar_rscap),
"capture": file_summary(lidar_capture), "capture": file_summary(lidar_capture),
"angle_source": "default_msop_only_vertical_-16_to_16_deg", "angle_source": "default_msop_only_vertical_-16_to_16_deg",
"timestamp_note": "h32_msop_device_timestamp_ms -> seconds", "timestamp_note": (
"device: h32_msop_device_timestamp_ms -> seconds; "
"host: rscap receive_utc_ticks -> unix seconds"
),
} }
lidar_dir = out / "lidar" lidar_dir = out / "lidar"
lidar_stats = write_lidar_session(lidar_dir, frames) lidar_stats = write_lidar_session(lidar_dir, frames)
imu_time_note = (
"hi13_device_timestamp_ms -> seconds"
if kind == "hi13"
else "n300_device_timestamp_us -> seconds"
)
summary = { summary = {
"imu_rscap": str(imu_rscap), "imu_rscap": [str(path) for path in imu_paths],
"imu_kind": kind,
"out": str(out), "out": str(out),
"host_window": {
"host_start": host_start,
"host_end": host_end,
"lidar_ticks_min": lidar_ticks_min,
"lidar_ticks_max": lidar_ticks_max,
"imu_ticks_min": imu_ticks_min,
"imu_ticks_max": imu_ticks_max,
"note": "local wall cut; lidar DObject tic=DateTime.Now, IMU/MSOP host=UTC",
},
"timestamp_policy": { "timestamp_policy": {
"imu": "n300_device_timestamp_us -> seconds", "imu_device": imu_time_note,
"lidar": lidar_meta["timestamp_note"], "imu_host": "rscap receive_utc_ticks -> t_host_utc_s",
"host_utc": "not used as calibration timeline", "lidar_device": "MSOP device timestamp -> t_start/t_end",
"lidar_host": "MSOP HostReceiveUtcTicks -> t_host_utc_s",
"calibration_align": "bridge via host UTC; do not force first device samples to coincide",
}, },
"imu": { "imu": {
"samples": int(t.shape[0]), "samples": int(t.shape[0]),
"samples_with_host_utc": imu_host_ok,
"t_start": float(t[0]) if t.size else None, "t_start": float(t[0]) if t.size else None,
"t_end": float(t[-1]) if t.size else None, "t_end": float(t[-1]) if t.size else None,
"capture": file_summary(imu_capture), "captures": imu_captures,
}, },
"lidar": { "lidar": {
**lidar_stats, **lidar_stats,
@@ -201,41 +342,38 @@ def export_session(
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--imu-rscap", type=Path, required=True, help="N300 V2 .rscap") parser.add_argument(
"--imu-rscap",
type=Path,
action="append",
required=True,
help="IMU V2 .rscap (repeatable)",
)
parser.add_argument(
"--imu-kind",
choices=("auto", "hi13", "n300"),
default="auto",
help="IMU decoder (default: auto from filename)",
)
lidar = parser.add_mutually_exclusive_group(required=True) lidar = parser.add_mutually_exclusive_group(required=True)
lidar.add_argument( lidar.add_argument(
"--lidar-dlog", "--lidar-dlog",
type=Path, type=Path,
help="H32 Medulla dlog root (dobject/ + dobject_recording/), preferred", help="H32 dlog directory or recovered zip (indices.log + data.bin)",
) )
lidar.add_argument( lidar.add_argument(
"--lidar-rscap", "--lidar-rscap",
type=Path, type=Path,
help="Legacy H32 MSOP V2 .rscap (no DIFOP; default vertical angles)", help="Legacy H32 MSOP V2 .rscap",
)
parser.add_argument(
"--msop-object",
default="frontlidar-msop-raw",
help="DObject name for raw MSOP batches (dlog path)",
)
parser.add_argument(
"--difop-object",
default="frontlidar-difop-raw",
help="DObject name for raw DIFOP packets (dlog path)",
)
parser.add_argument(
"--require-difop",
action="store_true",
help="Fail if dlog has no valid DIFOP channel angles",
)
parser.add_argument("--out", type=Path, required=True, help="Output session directory")
parser.add_argument("--frame-stride", type=int, default=1, help="Keep every N-th LiDAR frame")
parser.add_argument(
"--max-points-per-frame",
type=int,
default=80000,
help="Uniform downsample cap per frame; 0 disables",
) )
parser.add_argument("--msop-object", default="frontlidar-msop-raw")
parser.add_argument("--difop-object", default="frontlidar-difop-raw")
parser.add_argument("--require-difop", action="store_true")
parser.add_argument("--host-start", type=str, default=None, help="Local wall start, e.g. 2026-08-08T17:40:05")
parser.add_argument("--host-end", type=str, default=None, help="Local wall end, e.g. 2026-08-08T17:45:15")
parser.add_argument("--out", type=Path, required=True)
parser.add_argument("--frame-stride", type=int, default=1)
parser.add_argument("--max-points-per-frame", type=int, default=80000)
parser.add_argument("--min-range-m", type=float, default=0.3) parser.add_argument("--min-range-m", type=float, default=0.3)
parser.add_argument("--max-range-m", type=float, default=120.0) parser.add_argument("--max-range-m", type=float, default=120.0)
parser.add_argument("--min-frame-points", type=int, default=100) parser.add_argument("--min-frame-points", type=int, default=100)
@@ -245,9 +383,12 @@ def main() -> int:
imu_rscap=args.imu_rscap, imu_rscap=args.imu_rscap,
lidar_rscap=args.lidar_rscap, lidar_rscap=args.lidar_rscap,
lidar_dlog=args.lidar_dlog, lidar_dlog=args.lidar_dlog,
imu_kind=args.imu_kind,
msop_object=args.msop_object, msop_object=args.msop_object,
difop_object=args.difop_object, difop_object=args.difop_object,
require_difop=args.require_difop, require_difop=args.require_difop,
host_start=args.host_start,
host_end=args.host_end,
out=args.out, out=args.out,
frame_stride=args.frame_stride, frame_stride=args.frame_stride,
max_points_per_frame=max_points, max_points_per_frame=max_points,
@@ -258,10 +399,14 @@ def main() -> int:
print( print(
json.dumps( json.dumps(
{ {
"imu_kind": summary["imu_kind"],
"imu_samples": summary["imu"]["samples"], "imu_samples": summary["imu"]["samples"],
"imu_host_utc": summary["imu"]["samples_with_host_utc"],
"lidar_frames": summary["lidar"]["frames"], "lidar_frames": summary["lidar"]["frames"],
"lidar_host_utc": summary["lidar"]["frames_with_host_utc"],
"lidar_source": summary["lidar"]["source"], "lidar_source": summary["lidar"]["source"],
"angle_source": summary["lidar"]["angle_source"], "angle_source": summary["lidar"]["angle_source"],
"host_window": summary["host_window"],
"imu_csv": summary["outputs"]["imu_csv"], "imu_csv": summary["outputs"]["imu_csv"],
"lidar_session": summary["outputs"]["lidar_session"], "lidar_session": summary["outputs"]["lidar_session"],
"export_summary": str(Path(args.out) / "export_summary.json"), "export_summary": str(Path(args.out) / "export_summary.json"),
@@ -271,9 +416,13 @@ def main() -> int:
) )
) )
if summary["imu"]["samples"] == 0: if summary["imu"]["samples"] == 0:
raise SystemExit("no valid N300 IMU samples decoded") raise SystemExit("no valid IMU samples decoded in window")
if summary["lidar"]["frames"] == 0: if summary["lidar"]["frames"] == 0:
raise SystemExit("no valid H32 frames decoded") raise SystemExit("no valid H32 frames decoded in window")
if summary["lidar"]["frames_with_host_utc"] == 0:
raise SystemExit("no LiDAR frames with MSOP HostReceiveUtcTicks; cannot host-bridge align")
if summary["imu"]["samples_with_host_utc"] == 0:
raise SystemExit("no IMU samples with host receive UTC; cannot host-bridge align")
return 0 return 0
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Export priority LiDARIMU windows from calibration_usable_20260808.
Does not push anything; writes local V1 sessions under --out-root.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from tools.export_rscap_to_v1 import export_session
DEFAULT_DATA = Path(r"D:\data\calibration_usable_20260808")
LIDAR_ZIP = "lidar_dlog/dorec_recovered_20260808_171438_181422.zip"
IMU_MAIN = "imu_rscap/hi13r4-imu_20260808-092827.638_39783edb-e46e-4b28-a5e8-427b981c2fce.rscap"
IMU_TAIL = "imu_rscap/hi13r4-imu_20260808-101022.036_87ea5edc-cd3d-4192-809a-469fbc8cac01.rscap"
# From usable-segment chart (local wall clock).
WINDOWS = [
{
"name": "priority_174005_174515",
"host_start": "2026-08-08T17:40:05",
"host_end": "2026-08-08T17:45:15",
"imu": [IMU_MAIN],
"priority": True,
},
{
"name": "priority_174905_175450",
"host_start": "2026-08-08T17:49:05",
"host_end": "2026-08-08T17:54:50",
"imu": [IMU_MAIN],
"priority": True,
},
{
"name": "priority_175910_180530",
"host_start": "2026-08-08T17:59:10",
"host_end": "2026-08-08T18:05:30",
"imu": [IMU_MAIN],
"priority": True,
},
{
"name": "usable_181035_181050",
"host_start": "2026-08-08T18:10:35",
"host_end": "2026-08-08T18:10:50",
"imu": [IMU_TAIL],
"priority": False,
},
{
"name": "usable_181225_181300",
"host_start": "2026-08-08T18:12:25",
"host_end": "2026-08-08T18:13:00",
"imu": [IMU_TAIL],
"priority": False,
},
{
"name": "usable_181350_181410",
"host_start": "2026-08-08T18:13:50",
"host_end": "2026-08-08T18:14:10",
"imu": [IMU_TAIL],
"priority": False,
},
]
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--data-root", type=Path, default=DEFAULT_DATA)
parser.add_argument(
"--out-root",
type=Path,
default=DEFAULT_DATA / "sessions_v1",
)
parser.add_argument("--priority-only", action="store_true", default=True)
parser.add_argument("--all-windows", action="store_true")
parser.add_argument("--frame-stride", type=int, default=5)
parser.add_argument("--max-points-per-frame", type=int, default=40000)
args = parser.parse_args()
priority_only = not args.all_windows
lidar = args.data_root / LIDAR_ZIP
if not lidar.is_file():
raise SystemExit(f"missing lidar zip: {lidar}")
selected = [w for w in WINDOWS if (not priority_only) or w["priority"]]
results = []
for window in selected:
out = args.out_root / window["name"]
imu_paths = [args.data_root / rel for rel in window["imu"]]
print(f"=== exporting {window['name']} ===", flush=True)
summary = export_session(
imu_rscap=imu_paths,
lidar_dlog=lidar,
imu_kind="hi13",
require_difop=True,
host_start=window["host_start"],
host_end=window["host_end"],
out=out,
frame_stride=args.frame_stride,
max_points_per_frame=args.max_points_per_frame,
)
brief = {
"name": window["name"],
"imu_samples": summary["imu"]["samples"],
"lidar_frames": summary["lidar"]["frames"],
"angle_source": summary["lidar"]["angle_source"],
"out": str(out),
}
results.append(brief)
print(json.dumps(brief, ensure_ascii=False, indent=2), flush=True)
manifest = args.out_root / "export_windows_manifest.json"
args.out_root.mkdir(parents=True, exist_ok=True)
manifest.write_text(json.dumps(results, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"manifest: {manifest}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+7 -1
View File
@@ -1,12 +1,18 @@
"""Medulla dlog readers for RSLidarH32_3D_DLogCaptureNet48 raw MSOP/DIFOP.""" """Medulla dlog readers for RSLidarH32_3D_DLogCaptureNet48 raw MSOP/DIFOP."""
from .difop import parse_difop_angles from .difop import parse_difop_angles
from .dobject import discover_records, iter_payloads, resolve_dlog_root from .dobject import discover_records, iter_payloads, open_dlog_source, resolve_dlog_root
from .load_session import H32DlogLidarSession, load_h32_dlog_lidar
from .payload_v1 import parse_difop_payload, parse_msop_batch_payload from .payload_v1 import parse_difop_payload, parse_msop_batch_payload
from .timeutil import local_wall_to_dotnet_ticks
__all__ = [ __all__ = [
"H32DlogLidarSession",
"discover_records", "discover_records",
"iter_payloads", "iter_payloads",
"load_h32_dlog_lidar",
"local_wall_to_dotnet_ticks",
"open_dlog_source",
"parse_difop_angles", "parse_difop_angles",
"parse_difop_payload", "parse_difop_payload",
"parse_msop_batch_payload", "parse_msop_batch_payload",
+283 -81
View File
@@ -1,16 +1,24 @@
"""Index and read Medulla DObject recordings (dobject/ + dobject_recording/).""" """Index and read Medulla DObject recordings.
Supports:
- standard layout: ``dobject/**/*.log`` + ``dobject_recording/**/*.dorec``
- recovered layout: ``dobject/all/indices.log`` + ``dobject_recording/data.bin``
- either as an extracted directory or a zip containing those paths
"""
from __future__ import annotations from __future__ import annotations
import re import re
import struct import struct
import zipfile
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import BinaryIO, Iterator from typing import BinaryIO, Iterator
RECORD_RE = re.compile( RECORD_RE = re.compile(
r"^\[(?P<log_time>[^]]+)\].*?DObject `(?P<name>[^`]+)` post " r"^(?:\[(?P<log_time>[^]]+)\])?>?\s*DObject `(?P<name>[^`]+)` post "
r"len=(?P<len>\d+)B, id:(?P<id>[0-9A-Fa-f]+), tic:(?P<tic>\d+), " r"len=(?P<len>\d+)B, id:(?P<id>[0-9A-Fa-f]+), tic:(?P<tic>\d+), "
r"@(?P<file>[^:]+):(?P<offset>\d+)" r"@(?P<file>[^:]+):(?P<offset>\d+)"
) )
@@ -29,37 +37,225 @@ class RecordRef:
dotnet_ticks: int dotnet_ticks: int
def resolve_dlog_root(value: Path | str) -> Path: class _ZipStoredMemberIO:
root = Path(value).expanduser().resolve() """Random-access reader for a ZIP_STORED member via the underlying zip file.
if (root / "dobject").is_dir() and (root / "dobject_recording").is_dir():
return root ``ZipExtFile.seek`` on multi-GB members is far too slow for per-record reads.
child = root / "dlog" """
if (child / "dobject").is_dir() and (child / "dobject_recording").is_dir():
return child def __init__(self, zip_path: Path, member_name: str, data_offset: int, data_size: int):
raise FileNotFoundError(f"{root} does not contain dobject and dobject_recording") self._path = zip_path
self._member_name = member_name
self._data_offset = data_offset
self._data_size = data_size
self._fh = zip_path.open("rb")
self._pos = 0
def seek(self, offset: int, whence: int = 0) -> int:
if whence == 0:
self._pos = offset
elif whence == 1:
self._pos += offset
elif whence == 2:
self._pos = self._data_size + offset
else:
raise ValueError(f"invalid whence: {whence}")
if self._pos < 0:
raise ValueError("negative seek")
return self._pos
def read(self, size: int = -1) -> bytes:
if size is None or size < 0:
size = self._data_size - self._pos
if size <= 0 or self._pos >= self._data_size:
return b""
size = min(size, self._data_size - self._pos)
self._fh.seek(self._data_offset + self._pos)
data = self._fh.read(size)
self._pos += len(data)
return data
def close(self) -> None:
self._fh.close()
def discover_records(dlog_root: Path, object_name: str) -> list[RecordRef]: def _zip_stored_member_offset(zip_path: Path, info: zipfile.ZipInfo) -> int:
pending: list[tuple[str, str, str, int, int, str, int, str]] = [] if info.compress_type != zipfile.ZIP_STORED:
for log_path in sorted((dlog_root / "dobject").rglob("*.log")): raise RuntimeError(
relative_log = log_path.relative_to(dlog_root).as_posix() f"member {info.filename!r} is compressed (type={info.compress_type}); "
with log_path.open("r", encoding="utf-8", errors="replace") as stream: "extract it first or store uncompressed"
for line in stream: )
match = RECORD_RE.search(line) with zip_path.open("rb") as handle:
if not match or match.group("name").casefold() != object_name.casefold(): handle.seek(info.header_offset)
continue header = handle.read(30)
pending.append( if len(header) != 30 or header[:4] != b"PK\x03\x04":
( raise RuntimeError(f"bad local zip header for {info.filename!r}")
match.group("name"), name_len, extra_len = struct.unpack("<HH", header[26:30])
match.group("log_time"), return info.header_offset + 30 + name_len + extra_len
relative_log,
int(match.group("offset")),
int(match.group("len")), @dataclass
match.group("id").upper(), class DlogSource:
int(match.group("tic")), """Opened dlog directory or recovered zip."""
match.group("file"),
) label: str
directory: Path | None = None
zip_path: Path | None = None
_zip: zipfile.ZipFile | None = None
_log_cache: dict[str, str] | None = None
_member_offsets: dict[str, tuple[int, int]] | None = None
def close(self) -> None:
if self._zip is not None:
self._zip.close()
self._zip = None
def __enter__(self) -> "DlogSource":
return self
def __exit__(self, exc_type, exc, tb) -> None:
self.close()
def iter_log_texts(self) -> Iterator[tuple[str, str]]:
if self.zip_path is not None:
assert self._zip is not None
if self._log_cache is None:
self._log_cache = {}
names = sorted(
name
for name in self._zip.namelist()
if name.replace("\\", "/").startswith("dobject/")
and name.replace("\\", "/").endswith(".log")
) )
for name in names:
key = name.replace("\\", "/")
self._log_cache[key] = self._zip.read(name).decode("utf-8", errors="replace")
for name, text in self._log_cache.items():
yield name, text
return
assert self.directory is not None
for log_path in sorted((self.directory / "dobject").rglob("*.log")):
relative = log_path.relative_to(self.directory).as_posix()
yield relative, log_path.read_text(encoding="utf-8", errors="replace")
def open_recording(self, name: str) -> tuple[object, BinaryIO]:
"""Return (owner, binary stream) supporting seek/read of one recording member."""
base = Path(name).name
if self.zip_path is not None:
assert self._zip is not None
candidates = [
n
for n in self._zip.namelist()
if Path(n.replace("\\", "/")).name.casefold() == base.casefold()
and "dobject_recording/" in n.replace("\\", "/")
]
if not candidates:
alt = name.replace("\\", "/")
if alt in self._zip.namelist():
candidates = [alt]
elif f"dobject_recording/{base}" in self._zip.namelist():
candidates = [f"dobject_recording/{base}"]
if not candidates:
raise FileNotFoundError(f"missing recording in zip: {name}")
if len(candidates) > 1:
raise RuntimeError(f"ambiguous recording in zip {name}: {candidates}")
member = candidates[0].replace("\\", "/")
if self._member_offsets is None:
self._member_offsets = {}
if member not in self._member_offsets:
info = self._zip.getinfo(member)
self._member_offsets[member] = (
_zip_stored_member_offset(self.zip_path, info),
info.file_size,
)
data_offset, data_size = self._member_offsets[member]
stream = _ZipStoredMemberIO(self.zip_path, member, data_offset, data_size)
return stream, stream
assert self.directory is not None
index = index_dorec_files(self.directory)
if base.casefold() == "data.bin":
path = self.directory / "dobject_recording" / "data.bin"
if not path.is_file():
matches = list((self.directory / "dobject_recording").rglob("data.bin"))
if not matches:
raise FileNotFoundError(f"missing recording file: {name}")
path = matches[0]
stream = path.open("rb")
return stream, stream
path = choose_dorec(index, name)
stream = path.open("rb")
return stream, stream
def open_dlog_source(value: Path | str) -> DlogSource:
path = Path(value).expanduser().resolve()
if path.is_file() and path.suffix.lower() == ".zip":
zf = zipfile.ZipFile(path, "r")
names = {n.replace("\\", "/") for n in zf.namelist()}
has_log = any(n.startswith("dobject/") and n.endswith(".log") for n in names)
has_rec = any(n.startswith("dobject_recording/") for n in names)
if not (has_log and has_rec):
zf.close()
raise FileNotFoundError(f"{path} is not a recovered/standard dlog zip")
return DlogSource(label=str(path), zip_path=path, _zip=zf)
root = path
if not ((root / "dobject").is_dir() and (root / "dobject_recording").is_dir()):
child = root / "dlog"
if (child / "dobject").is_dir() and (child / "dobject_recording").is_dir():
root = child
else:
raise FileNotFoundError(f"{path} does not contain dobject and dobject_recording")
return DlogSource(label=str(root), directory=root)
def resolve_dlog_root(value: Path | str) -> Path:
"""Backward-compatible helper: directory roots only (not zip)."""
source = open_dlog_source(value)
try:
if source.directory is None:
raise FileNotFoundError(
f"{value} is a zip; use open_dlog_source()/iter_payloads_from_source()"
)
return source.directory
finally:
source.close()
def discover_records_from_source(
source: DlogSource,
object_name: str,
*,
host_ticks_min: int | None = None,
host_ticks_max: int | None = None,
) -> list[RecordRef]:
pending: list[tuple[str, str, str, int, int, str, int, str]] = []
name_key = object_name.casefold()
for relative_log, text in source.iter_log_texts():
for line in text.splitlines():
match = RECORD_RE.search(line.strip())
if not match or match.group("name").casefold() != name_key:
continue
ticks = int(match.group("tic"))
if host_ticks_min is not None and ticks < host_ticks_min:
continue
if host_ticks_max is not None and ticks > host_ticks_max:
continue
pending.append(
(
match.group("name"),
match.group("log_time") or "",
relative_log,
int(match.group("offset")),
int(match.group("len")),
match.group("id").upper(),
ticks,
match.group("file"),
)
)
pending.sort(key=lambda item: (item[6], item[7].casefold(), item[3])) pending.sort(key=lambda item: (item[6], item[7].casefold(), item[3]))
seen: set[tuple[str, int, int]] = set() seen: set[tuple[str, int, int]] = set()
records: list[RecordRef] = [] records: list[RecordRef] = []
@@ -84,10 +280,19 @@ def discover_records(dlog_root: Path, object_name: str) -> list[RecordRef]:
return records return records
def discover_records(dlog_root: Path, object_name: str) -> list[RecordRef]:
with open_dlog_source(dlog_root) as source:
return discover_records_from_source(source, object_name)
def index_dorec_files(dlog_root: Path) -> dict[str, list[Path]]: def index_dorec_files(dlog_root: Path) -> dict[str, list[Path]]:
result: dict[str, list[Path]] = {} result: dict[str, list[Path]] = {}
for path in (dlog_root / "dobject_recording").rglob("*.dorec"): recording = dlog_root / "dobject_recording"
result.setdefault(path.name.casefold(), []).append(path) if not recording.is_dir():
return result
for path in recording.rglob("*"):
if path.is_file() and path.suffix.lower() in {".dorec", ".bin"}:
result.setdefault(path.name.casefold(), []).append(path)
return result return result
@@ -107,17 +312,15 @@ def read_exact(stream: BinaryIO, size: int) -> bytes:
return data return data
def read_record_payload(path: Path, record: RecordRef) -> bytes: def _read_payload_at(stream: BinaryIO, record: RecordRef) -> bytes:
with path.open("rb") as stream: stream.seek(record.source_offset)
stream.seek(record.source_offset) name_length = read_exact(stream, 1)[0]
name_length = read_exact(stream, 1)[0] name = read_exact(stream, name_length).decode("ascii")
name = read_exact(stream, name_length).decode("ascii") ticks = struct.unpack("<q", read_exact(stream, 8))[0]
ticks = struct.unpack("<q", read_exact(stream, 8))[0] id_length = read_exact(stream, 1)[0]
id_length = read_exact(stream, 1)[0] id_bytes = read_exact(stream, id_length)
id_bytes = read_exact(stream, id_length) payload_length = struct.unpack("<i", read_exact(stream, 4))[0]
payload_length = struct.unpack("<i", read_exact(stream, 4))[0] payload = read_exact(stream, payload_length)
payload = read_exact(stream, payload_length)
try: try:
record_id = id_bytes.decode("ascii") record_id = id_bytes.decode("ascii")
except UnicodeDecodeError: except UnicodeDecodeError:
@@ -133,47 +336,46 @@ def read_record_payload(path: Path, record: RecordRef) -> bytes:
return payload return payload
def iter_payloads(dlog_root: Path, object_name: str) -> Iterator[tuple[RecordRef, bytes]]: def iter_payloads_from_source(
root = resolve_dlog_root(dlog_root) source: DlogSource,
records = discover_records(root, object_name) object_name: str,
*,
host_ticks_min: int | None = None,
host_ticks_max: int | None = None,
) -> Iterator[tuple[RecordRef, bytes]]:
records = discover_records_from_source(
source,
object_name,
host_ticks_min=host_ticks_min,
host_ticks_max=host_ticks_max,
)
if not records: if not records:
return return
dorec_index = index_dorec_files(root) open_files: dict[str, BinaryIO] = {}
open_files: dict[str, tuple[Path, BinaryIO]] = {}
try: try:
for record in records: for record in records:
key = record.source_dorec.casefold() key = Path(record.source_dorec).name.casefold()
handle = open_files.get(key) stream = open_files.get(key)
if handle is None: if stream is None:
path = choose_dorec(dorec_index, record.source_dorec) _owner, stream = source.open_recording(record.source_dorec)
handle = (path, path.open("rb")) open_files[key] = stream
open_files[key] = handle yield record, _read_payload_at(stream, record)
path, stream = handle
stream.seek(record.source_offset)
name_length = read_exact(stream, 1)[0]
name = read_exact(stream, name_length).decode("ascii")
ticks = struct.unpack("<q", read_exact(stream, 8))[0]
id_length = read_exact(stream, 1)[0]
id_bytes = read_exact(stream, id_length)
payload_length = struct.unpack("<i", read_exact(stream, 4))[0]
payload = read_exact(stream, payload_length)
try:
record_id = id_bytes.decode("ascii")
except UnicodeDecodeError:
record_id = id_bytes.hex().upper()
if name != record.object_name:
raise ValueError(f"name mismatch: log={record.object_name}, dorec={name}")
if ticks != record.dotnet_ticks:
raise ValueError(f"tick mismatch: log={record.dotnet_ticks}, dorec={ticks}")
if payload_length != record.payload_length:
raise ValueError(
f"payload mismatch: log={record.payload_length}, dorec={payload_length}"
)
if record_id.upper() != record.log_record_id.upper():
raise ValueError(
f"record id mismatch: log={record.log_record_id}, dorec={record_id}"
)
yield record, payload
finally: finally:
for _path, stream in open_files.values(): for stream in open_files.values():
stream.close() stream.close()
def iter_payloads(
dlog_root: Path | str,
object_name: str,
*,
host_ticks_min: int | None = None,
host_ticks_max: int | None = None,
) -> Iterator[tuple[RecordRef, bytes]]:
with open_dlog_source(dlog_root) as source:
yield from iter_payloads_from_source(
source,
object_name,
host_ticks_min=host_ticks_min,
host_ticks_max=host_ticks_max,
)
+98 -57
View File
@@ -10,16 +10,21 @@ import numpy as np
from tools.rscap_v2.h32_msop import default_horizontal_deg, default_vertical_deg from tools.rscap_v2.h32_msop import default_horizontal_deg, default_vertical_deg
from .difop import DifopAngles, parse_difop_angles from .difop import DifopAngles, parse_difop_angles
from .dobject import discover_records, iter_payloads, resolve_dlog_root from .dobject import (
discover_records_from_source,
iter_payloads_from_source,
open_dlog_source,
)
from .payload_v1 import parse_difop_payload, parse_msop_batch_payload from .payload_v1 import parse_difop_payload, parse_msop_batch_payload
@dataclass @dataclass
class H32DlogLidarSession: class H32DlogLidarSession:
dlog_root: Path dlog_root: str
msop_object: str msop_object: str
difop_object: str difop_object: str
msop_packets: list[bytes] msop_packets: list[bytes]
msop_host_utc_ticks: list[int]
msop_batch_count: int msop_batch_count: int
difop_record_count: int difop_record_count: int
angle_source: str angle_source: str
@@ -27,6 +32,8 @@ class H32DlogLidarSession:
horizontal_deg: np.ndarray horizontal_deg: np.ndarray
session_id: str | None = None session_id: str | None = None
lidar_ip: str | None = None lidar_ip: str | None = None
host_ticks_min: int | None = None
host_ticks_max: int | None = None
def load_h32_dlog_lidar( def load_h32_dlog_lidar(
@@ -35,65 +42,99 @@ def load_h32_dlog_lidar(
msop_object: str = "frontlidar-msop-raw", msop_object: str = "frontlidar-msop-raw",
difop_object: str = "frontlidar-difop-raw", difop_object: str = "frontlidar-difop-raw",
require_difop: bool = False, require_difop: bool = False,
host_ticks_min: int | None = None,
host_ticks_max: int | None = None,
) -> H32DlogLidarSession: ) -> H32DlogLidarSession:
root = resolve_dlog_root(dlog_root) with open_dlog_source(dlog_root) as source:
msop_packets: list[bytes] = [] # DIFOP angles: prefer packets inside the window, else any in the capture.
batch_count = 0 angles: DifopAngles | None = None
session_id: str | None = None difop_count = 0
lidar_ip: str | None = None session_id: str | None = None
lidar_ip: str | None = None
for _record, payload in iter_payloads_from_source(
source,
difop_object,
host_ticks_min=host_ticks_min,
host_ticks_max=host_ticks_max,
):
difop = parse_difop_payload(payload)
difop_count += 1
try:
angles = parse_difop_angles(difop.raw)
except ValueError:
continue
if session_id is None:
session_id = difop.session_id
lidar_ip = difop.lidar_ip
for _record, payload in iter_payloads(root, msop_object): if angles is None:
batch = parse_msop_batch_payload(payload) for _record, payload in iter_payloads_from_source(source, difop_object):
batch_count += 1 difop = parse_difop_payload(payload)
if session_id is None: difop_count += 1
session_id = batch.session_id try:
lidar_ip = batch.lidar_ip angles = parse_difop_angles(difop.raw)
for item in batch.packets: except ValueError:
msop_packets.append(item.raw) continue
if session_id is None:
session_id = difop.session_id
lidar_ip = difop.lidar_ip
if angles is not None:
break
angles: DifopAngles | None = None msop_packets: list[bytes] = []
difop_count = 0 msop_host_utc_ticks: list[int] = []
for _record, payload in iter_payloads(root, difop_object): batch_count = 0
difop = parse_difop_payload(payload) for record, payload in iter_payloads_from_source(
difop_count += 1 source,
try: msop_object,
angles = parse_difop_angles(difop.raw) host_ticks_min=host_ticks_min,
except ValueError: host_ticks_max=host_ticks_max,
continue ):
if session_id is None: batch = parse_msop_batch_payload(payload)
session_id = difop.session_id batch_count += 1
lidar_ip = difop.lidar_ip if session_id is None:
session_id = batch.session_id
lidar_ip = batch.lidar_ip
for item in batch.packets:
msop_packets.append(item.raw)
# Per-packet UTC host receive from MSOP DLog payload only.
# Do NOT fall back to DObject tic (DateTime.Now / local).
msop_host_utc_ticks.append(int(item.host_receive_utc_ticks))
if not msop_packets: if not msop_packets:
msop_records = discover_records(root, msop_object) msop_records = discover_records_from_source(source, msop_object)
raise RuntimeError(
f"no MSOP packets from DObject {msop_object!r} under {root} "
f"(log records={len(msop_records)})"
)
if angles is None:
if require_difop:
raise RuntimeError( raise RuntimeError(
f"no valid DIFOP calibration from DObject {difop_object!r} under {root}" f"no MSOP packets from DObject {msop_object!r} under {source.label} "
f"(log records={len(msop_records)}, "
f"host_ticks=[{host_ticks_min}, {host_ticks_max}])"
) )
vertical = default_vertical_deg()
horizontal = default_horizontal_deg()
angle_source = "default_msop_only_vertical_-16_to_16_deg"
else:
vertical = angles.vertical_deg
horizontal = angles.horizontal_deg
angle_source = "difop_channel_angles"
return H32DlogLidarSession( if angles is None:
dlog_root=root, if require_difop:
msop_object=msop_object, raise RuntimeError(
difop_object=difop_object, f"no valid DIFOP calibration from DObject {difop_object!r} under {source.label}"
msop_packets=msop_packets, )
msop_batch_count=batch_count, vertical = default_vertical_deg()
difop_record_count=difop_count, horizontal = default_horizontal_deg()
angle_source=angle_source, angle_source = "default_msop_only_vertical_-16_to_16_deg"
vertical_deg=vertical, else:
horizontal_deg=horizontal, vertical = angles.vertical_deg
session_id=session_id, horizontal = angles.horizontal_deg
lidar_ip=lidar_ip, angle_source = "difop_channel_angles"
)
return H32DlogLidarSession(
dlog_root=source.label,
msop_object=msop_object,
difop_object=difop_object,
msop_packets=msop_packets,
msop_host_utc_ticks=msop_host_utc_ticks,
msop_batch_count=batch_count,
difop_record_count=difop_count,
angle_source=angle_source,
vertical_deg=vertical,
horizontal_deg=horizontal,
session_id=session_id,
lidar_ip=lidar_ip,
host_ticks_min=host_ticks_min,
host_ticks_max=host_ticks_max,
)
+56
View File
@@ -0,0 +1,56 @@
"""Wall-clock helpers for Medulla tick filtering.
Two tick conventions appear in this dataset:
- LiDAR DObject ``tic`` / recovered ``indices.log``: ``DateTime.Now.Ticks`` (local)
- IMU / MSOP payload host receive fields: UTC ``DateTime.UtcNow.Ticks``
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
TICKS_PER_SECOND = 10_000_000
DOTNET_UNIX_EPOCH_TICKS = 621355968000000000
def _parse_local_wall(text: str) -> datetime:
normalized = text.strip().replace(" ", "T")
if normalized.endswith("Z"):
raise ValueError("expected local wall time without Z; got UTC marker")
if "+" in normalized[10:]:
idx = normalized.find("+", 10)
normalized = normalized[:idx]
elif normalized.count("-") > 2:
# timezone like -08:00 after the date
idx = normalized.find("-", 10)
if idx > 0 and ":" in normalized[idx + 1 :]:
normalized = normalized[:idx]
return datetime.fromisoformat(normalized).replace(tzinfo=None)
def local_wall_to_dotnet_ticks(text: str) -> int:
"""Local wall time → ``DateTime.Now.Ticks`` (LiDAR DObject tic)."""
dt = _parse_local_wall(text)
delta = dt - datetime(1, 1, 1)
return int(delta.total_seconds() * TICKS_PER_SECOND)
def local_wall_to_utc_dotnet_ticks(text: str, *, tz_hours: float = 8.0) -> int:
"""Local wall time in ``tz_hours`` → UTC ``DateTime.UtcNow.Ticks`` (IMU host)."""
dt = _parse_local_wall(text).replace(tzinfo=timezone(timedelta(hours=tz_hours)))
unix = dt.timestamp()
return int(round(unix * TICKS_PER_SECOND)) + DOTNET_UNIX_EPOCH_TICKS
def dotnet_ticks_to_local_iso(ticks: int) -> str:
dt = datetime(1, 1, 1) + timedelta(microseconds=ticks / 10.0)
return dt.isoformat(timespec="milliseconds")
def utc_dotnet_ticks_to_unix_s(ticks: int) -> float:
"""UTC ``DateTime.UtcNow.Ticks`` → Unix seconds."""
return (float(ticks) - float(DOTNET_UNIX_EPOCH_TICKS)) / float(TICKS_PER_SECOND)
+41 -6
View File
@@ -15,7 +15,7 @@ and horizontal channel offsets default to 0.
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Iterable from typing import Iterable, Sequence
import numpy as np import numpy as np
@@ -66,6 +66,8 @@ class LidarFrameExport:
t_start_s: float t_start_s: float
t_end_s: float t_end_s: float
points_xyz: np.ndarray # (N, 3) metres points_xyz: np.ndarray # (N, 3) metres
host_receive_utc_ticks_start: int = 0
host_receive_utc_ticks_end: int = 0
def decode_packet_points( def decode_packet_points(
@@ -144,6 +146,7 @@ def _block_points(
def iter_h32_frames_from_packets( def iter_h32_frames_from_packets(
packets: Iterable[bytes], packets: Iterable[bytes],
*, *,
host_utc_ticks: Sequence[int] | None = None,
min_frame_points: int = MIN_FRAME_POINTS_DEFAULT, min_frame_points: int = MIN_FRAME_POINTS_DEFAULT,
frame_stride: int = 1, frame_stride: int = 1,
min_range_m: float = 0.3, min_range_m: float = 0.3,
@@ -152,31 +155,49 @@ def iter_h32_frames_from_packets(
vertical_deg: np.ndarray | None = None, vertical_deg: np.ndarray | None = None,
horizontal_deg: np.ndarray | None = None, horizontal_deg: np.ndarray | None = None,
) -> list[LidarFrameExport]: ) -> list[LidarFrameExport]:
"""Assemble raw MSOP packets into frames using the 270°→90° azimuth wrap.""" """Assemble raw MSOP packets into frames using the 270°→90° azimuth wrap.
``host_utc_ticks`` is optional per-packet ``HostReceiveUtcTicks`` from the
MSOP DLog payload (UTC DateTime ticks). When provided, each emitted frame
carries host receive start/end ticks from the first/last contributing packet.
"""
vertical = default_vertical_deg() if vertical_deg is None else np.asarray(vertical_deg, dtype=np.float64) vertical = default_vertical_deg() if vertical_deg is None else np.asarray(vertical_deg, dtype=np.float64)
horizontal = default_horizontal_deg() if horizontal_deg is None else np.asarray(horizontal_deg, dtype=np.float64) horizontal = default_horizontal_deg() if horizontal_deg is None else np.asarray(horizontal_deg, dtype=np.float64)
if vertical.shape != (CHANNELS,) or horizontal.shape != (CHANNELS,): if vertical.shape != (CHANNELS,) or horizontal.shape != (CHANNELS,):
raise ValueError(f"vertical/horizontal must have shape ({CHANNELS},)") raise ValueError(f"vertical/horizontal must have shape ({CHANNELS},)")
packet_list = list(packets)
host_list = list(host_utc_ticks) if host_utc_ticks is not None else None
if host_list is not None and len(host_list) != len(packet_list):
raise ValueError(
f"host_utc_ticks length {len(host_list)} != packets length {len(packet_list)}"
)
frames: list[LidarFrameExport] = [] frames: list[LidarFrameExport] = []
point_chunks: list[np.ndarray] = [] point_chunks: list[np.ndarray] = []
t_start: float | None = None t_start: float | None = None
t_end: float | None = None t_end: float | None = None
host_start: int | None = None
host_end: int | None = None
prev_az: float | None = None prev_az: float | None = None
kept = 0 kept = 0
stride = max(1, int(frame_stride)) stride = max(1, int(frame_stride))
def emit() -> None: def emit() -> None:
nonlocal point_chunks, t_start, t_end, kept nonlocal point_chunks, t_start, t_end, host_start, host_end, kept
if not point_chunks or t_start is None or t_end is None: if not point_chunks or t_start is None or t_end is None:
point_chunks = [] point_chunks = []
t_start = t_end = None t_start = t_end = None
host_start = host_end = None
return return
points = np.vstack(point_chunks) points = np.vstack(point_chunks)
point_chunks = [] point_chunks = []
start_s, end_s = t_start, t_end start_s, end_s = t_start, t_end
h0 = int(host_start or 0)
h1 = int(host_end or 0)
t_start = t_end = None t_start = t_end = None
host_start = host_end = None
if points.shape[0] < min_frame_points: if points.shape[0] < min_frame_points:
return return
if kept % stride != 0: if kept % stride != 0:
@@ -188,12 +209,21 @@ def iter_h32_frames_from_packets(
points = points[select] points = points[select]
if end_s <= start_s: if end_s <= start_s:
end_s = start_s + 0.1 end_s = start_s + 0.1
frames.append(LidarFrameExport(t_start_s=start_s, t_end_s=end_s, points_xyz=points)) frames.append(
LidarFrameExport(
t_start_s=start_s,
t_end_s=end_s,
points_xyz=points,
host_receive_utc_ticks_start=h0,
host_receive_utc_ticks_end=h1,
)
)
for packet in packets: for index, packet in enumerate(packet_list):
if len(packet) != PACKET_LENGTH: if len(packet) != PACKET_LENGTH:
continue continue
packet_t = device_timestamp_ms(packet) * 1e-3 packet_t = device_timestamp_ms(packet) * 1e-3
packet_host = int(host_list[index]) if host_list is not None else 0
unit = distance_unit_mm(packet) unit = distance_unit_mm(packet)
idx = DATA_START idx = DATA_START
for _block in range(BLOCKS): for _block in range(BLOCKS):
@@ -216,7 +246,9 @@ def iter_h32_frames_from_packets(
if pts.shape[0]: if pts.shape[0]:
if t_start is None: if t_start is None:
t_start = packet_t t_start = packet_t
host_start = packet_host
t_end = packet_t t_end = packet_t
host_end = packet_host
point_chunks.append(pts) point_chunks.append(pts)
idx += BLOCK_LENGTH idx += BLOCK_LENGTH
@@ -237,8 +269,11 @@ def iter_h32_frames(
) -> list[LidarFrameExport]: ) -> list[LidarFrameExport]:
"""Assemble MSOP packets from a V2 .rscap capture into frames.""" """Assemble MSOP packets from a V2 .rscap capture into frames."""
packets = [chunk.raw for chunk in capture.chunks]
host_ticks = [chunk.receive_utc_ticks for chunk in capture.chunks]
return iter_h32_frames_from_packets( return iter_h32_frames_from_packets(
(chunk.raw for chunk in capture.chunks), packets,
host_utc_ticks=host_ticks,
min_frame_points=min_frame_points, min_frame_points=min_frame_points,
frame_stride=frame_stride, frame_stride=frame_stride,
min_range_m=min_range_m, min_range_m=min_range_m,
+136
View File
@@ -0,0 +1,136 @@
"""Decode Hipnuc / HI13 (HI91/HI92) IMU frames from a V2 .rscap capture.
Matches ``EcarSensorMinimal/RawSerialImu/Hi13Protocol.cs``:
sync ``5A A5``, CRC16 over header[0:4]+payload, tag ``0x91`` / ``0x92``.
HI91 (preferred for calibration):
- accel: float32 in g → m/s² (* 9.80665)
- gyro: float32 in deg/s → rad/s
- device time: uint32 ms at frame offset 14 → ``t_s = ms * 1e-3``
"""
from __future__ import annotations
import struct
import numpy as np
from .capture_format_v2 import CaptureFile
from .n300_imu import ImuSample, samples_to_arrays
G0 = 9.80665
DEG2RAD = np.pi / 180.0
def crc16_hi13(frame: bytes, payload_length: int) -> int:
crc = 0
for value in frame[:4]:
crc = _update_crc16(crc, value)
for value in frame[6 : 6 + payload_length]:
crc = _update_crc16(crc, value)
return crc & 0xFFFF
def _update_crc16(crc: int, value: int) -> int:
crc ^= (value & 0xFF) << 8
for _ in range(8):
if crc & 0x8000:
crc = ((crc << 1) ^ 0x1021) & 0xFFFF
else:
crc = (crc << 1) & 0xFFFF
return crc
def parse_hi91_frame(raw: bytes) -> tuple[tuple[float, float, float], tuple[float, float, float], int] | None:
"""Return (gyro_rad_s, accel_m_s2, device_timestamp_ms) for a CRC-valid HI91 frame."""
if len(raw) < 6 + 76:
return None
payload_length = raw[2] | (raw[3] << 8)
if payload_length < 76 or len(raw) < 6 + payload_length:
return None
if raw[6] != 0x91:
return None
expected = raw[4] | (raw[5] << 8)
if crc16_hi13(raw, payload_length) != expected:
return None
device_ms = struct.unpack_from("<I", raw, 14)[0]
ax, ay, az = struct.unpack_from("<fff", raw, 18)
gx, gy, gz = struct.unpack_from("<fff", raw, 30)
gyro = (gx * DEG2RAD, gy * DEG2RAD, gz * DEG2RAD)
accel = (ax * G0, ay * G0, az * G0)
return gyro, accel, int(device_ms)
def iter_hi13_imu_samples(
capture: CaptureFile,
*,
host_utc_ticks_min: int | None = None,
host_utc_ticks_max: int | None = None,
) -> list[ImuSample]:
"""Return CRC-valid HI91 samples sorted by device timestamp.
Streams chunk-by-chunk (no giant join) and can skip whole chunks outside the
host UTC receive window before parsing.
"""
samples: list[ImuSample] = []
carry = b""
for chunk in capture.chunks:
if host_utc_ticks_min is not None and chunk.receive_utc_ticks < host_utc_ticks_min:
carry = b""
continue
if host_utc_ticks_max is not None and chunk.receive_utc_ticks > host_utc_ticks_max:
# chunks are time-ordered; remaining ones are later
if chunk.receive_utc_ticks > host_utc_ticks_max:
break
stream = carry + chunk.raw
cursor = 0
while cursor + 6 < len(stream):
sync = stream.find(b"\x5A\xA5", cursor)
if sync < 0:
carry = b""
break
if sync + 6 > len(stream):
carry = stream[sync:]
break
payload_length = stream[sync + 2] | (stream[sync + 3] << 8)
if payload_length < 1 or payload_length > 512:
cursor = sync + 1
continue
end = sync + 6 + payload_length
if end > len(stream):
carry = stream[sync:]
break
parsed = parse_hi91_frame(stream[sync:end])
cursor = end
if parsed is None:
continue
gyro, accel, device_ms = parsed
host_ticks = chunk.receive_utc_ticks
if host_utc_ticks_min is not None and host_ticks < host_utc_ticks_min:
continue
if host_utc_ticks_max is not None and host_ticks > host_utc_ticks_max:
continue
samples.append(
ImuSample(
t_s=float(device_ms) * 1e-3,
gyro_rad_s=gyro,
accel_m_s2=accel,
host_receive_utc_ticks=host_ticks,
device_timestamp_us=int(device_ms) * 1000,
)
)
else:
carry = b""
samples.sort(key=lambda sample: (sample.t_s, sample.device_timestamp_us))
return samples
__all__ = [
"ImuSample",
"crc16_hi13",
"iter_hi13_imu_samples",
"parse_hi91_frame",
"samples_to_arrays",
]
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""Joint full_se3 on the three host-aligned priority windows."""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from imu_lidar.cli import main as cli_main
ALIGNED = Path(r"D:\data\calibration_usable_20260808\sessions_v1_host_aligned")
SESSIONS = [
"priority_174005_174515",
"priority_174905_175450",
"priority_175910_180530",
]
def main() -> int:
out = ALIGNED / "joint_full_se3"
argv = [
"run",
"--vehicle-config",
str(ROOT / "config" / "vehicle_hi13_h32_20260808.yaml"),
"--output",
str(out),
"--mode",
"full_se3",
"--time-offset-search-s",
"0.5",
"--min-pair-rotation-deg",
"2.0",
]
for name in SESSIONS:
session = ALIGNED / name
if not (session / "imu.csv").is_file() or not (session / "lidar" / "frames_index.csv").is_file():
raise SystemExit(f"missing host-aligned session: {session}")
argv.extend(["--session-id", name])
argv.extend(["--imu", str(session / "imu.csv")])
argv.extend(["--lidar", str(session / "lidar")])
print("argv:", " ".join(argv), flush=True)
return cli_main(argv)
if __name__ == "__main__":
raise SystemExit(main())
+292
View File
@@ -0,0 +1,292 @@
#!/usr/bin/env python3
"""Align HI13/H32 via host-UTC bridge, then run rotation_only.
Device clocks (HI13 boot ms vs H32 absolute) must NOT be forced to share a
first-sample epoch. Instead map each LiDAR frame onto the IMU device timeline
by interpolating IMU device time at the frame's MSOP HostReceiveUtcTicks.
Optional |ω| correlation then refines residual host/path delay.
"""
from __future__ import annotations
import argparse
import csv
import json
import shutil
import sys
from pathlib import Path
import numpy as np
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from imu_lidar.cli import main as cli_main
from imu_lidar.geometry import so3_log
from imu_lidar.imu_io import load_imu_samples
from imu_lidar.lidar_io import load_lidar_frames
from imu_lidar.registration import estimate_frame_rotations
from imu_lidar.time_offset import _correlate_offset, _magnitude_series
SESSIONS = [
"priority_174005_174515",
"priority_174905_175450",
"priority_175910_180530",
]
def _read_imu_host_table(imu_csv: Path) -> tuple[np.ndarray, np.ndarray]:
rows = list(csv.DictReader(imu_csv.open(encoding="utf-8")))
if not rows:
raise RuntimeError(f"empty IMU csv: {imu_csv}")
if "t_host_utc_s" not in rows[0] or not rows[0].get("t_host_utc_s"):
raise RuntimeError(
f"{imu_csv} missing t_host_utc_s; re-export with HostReceiveUtcTicks support"
)
t_dev = np.asarray([float(row["t"]) for row in rows], dtype=np.float64)
t_host = np.asarray([float(row["t_host_utc_s"]) for row in rows], dtype=np.float64)
order = np.argsort(t_host)
return t_host[order], t_dev[order]
def _imu_device_at_host(t_host_query: np.ndarray, imu_host: np.ndarray, imu_dev: np.ndarray) -> np.ndarray:
"""Map host UTC seconds → IMU device seconds (linear interp, edge clamp)."""
return np.interp(t_host_query, imu_host, imu_dev)
def rewrite_lidar_index_host_bridge(
src_index: Path,
dst_index: Path,
*,
imu_host: np.ndarray,
imu_dev: np.ndarray,
residual_delta_s: float = 0.0,
) -> dict:
"""Rewrite LiDAR times onto IMU device clock via host UTC bridge.
For each frame:
t_host_mid = mid of MSOP host receive window
t_imu_mid = interp(IMU device @ t_host_mid) + residual_delta
keep device duration: t_start/t_end centered on t_imu_mid
"""
rows = list(csv.DictReader(src_index.open(encoding="utf-8")))
if not rows:
raise RuntimeError(f"empty frames_index: {src_index}")
if "t_host_utc_s" not in rows[0]:
raise RuntimeError(
f"{src_index} missing t_host_utc_s; re-export DLog with MSOP HostReceiveUtcTicks"
)
dst_index.parent.mkdir(parents=True, exist_ok=True)
offsets: list[float] = []
with dst_index.open("w", newline="", encoding="utf-8") as handle:
writer = csv.writer(handle)
writer.writerow(["frame_id", "filename", "t_start", "t_end"])
for row in rows:
t0 = float(row["t_start"])
t1 = float(row["t_end"])
host0 = row.get("t_host_utc_s") or ""
host1 = row.get("t_host_utc_end_s") or ""
if not host0:
raise RuntimeError(f"frame {row.get('frame_id')} missing t_host_utc_s")
h0 = float(host0)
h1 = float(host1) if host1 else h0
host_mid = 0.5 * (h0 + h1)
imu_mid = float(_imu_device_at_host(np.asarray([host_mid]), imu_host, imu_dev)[0])
imu_mid += residual_delta_s
duration = max(t1 - t0, 1e-3)
new0 = imu_mid - 0.5 * duration
new1 = imu_mid + 0.5 * duration
offsets.append(imu_mid - 0.5 * (t0 + t1))
writer.writerow(
[
row["frame_id"],
row["filename"],
f"{new0:.9f}",
f"{new1:.9f}",
]
)
arr = np.asarray(offsets, dtype=np.float64)
return {
"frames": len(offsets),
"bridge_offset_median_s": float(np.median(arr)),
"bridge_offset_mean_s": float(np.mean(arr)),
"bridge_offset_std_s": float(np.std(arr)),
"bridge_offset_min_s": float(np.min(arr)),
"bridge_offset_max_s": float(np.max(arr)),
"residual_delta_s": float(residual_delta_s),
}
def estimate_residual_delta(session_dir: Path, *, search_s: float = 5.0) -> tuple[float, float]:
imu = load_imu_samples(session_dir / "imu.csv")
frames = load_lidar_frames(session_dir / "lidar")
# Short pairs only — large stride anti-correlates with IMU |gyro|.
stride = 1 if len(frames) < 80 else 2
rotations, pair_times = estimate_frame_rotations(frames, stride=stride)
if len(rotations) < 8:
rotations, pair_times = estimate_frame_rotations(frames, stride=1)
lidar_t = []
lidar_w = []
for (t_a, t_b), rotation in zip(pair_times, rotations):
dt_pair = max(t_b - t_a, 1e-3)
omega = so3_log(rotation) / dt_pair
lidar_t.append(0.5 * (t_a + t_b))
lidar_w.append(omega)
imu_t, imu_mag = _magnitude_series(imu.t_s, imu.gyro_rad_s)
lidar_t_arr, lidar_mag = _magnitude_series(np.asarray(lidar_t), np.asarray(lidar_w))
delta, peak = _correlate_offset(
imu_t,
imu_mag,
lidar_t_arr,
lidar_mag,
search_s=search_s,
sample_hz=20.0,
)
return float(delta), float(peak)
def align_session(src: Path, dst: Path, *, residual_search_s: float = 5.0) -> dict:
if dst.exists():
shutil.rmtree(dst)
dst.mkdir(parents=True)
shutil.copy2(src / "imu.csv", dst / "imu.csv")
shutil.copytree(src / "lidar" / "frames", dst / "lidar" / "frames")
imu_host, imu_dev = _read_imu_host_table(src / "imu.csv")
bridge = rewrite_lidar_index_host_bridge(
src / "lidar" / "frames_index.csv",
dst / "lidar" / "frames_index.csv",
imu_host=imu_host,
imu_dev=imu_dev,
residual_delta_s=0.0,
)
residual_delta, residual_peak = estimate_residual_delta(dst, search_s=residual_search_s)
# Only apply residual when correlation is clearly positive; otherwise the
# host-UTC bridge alone is the trusted alignment (weak peaks are noise).
apply_residual = residual_peak >= 0.5 and abs(residual_delta) <= residual_search_s
applied = float(residual_delta) if apply_residual else 0.0
if apply_residual:
bridge = rewrite_lidar_index_host_bridge(
src / "lidar" / "frames_index.csv",
dst / "lidar" / "frames_index.csv",
imu_host=imu_host,
imu_dev=imu_dev,
residual_delta_s=applied,
)
meta = {
"source": str(src),
"aligned": str(dst),
"method": "host_utc_bridge",
"imu_host_span_s": [float(imu_host[0]), float(imu_host[-1])],
"imu_device_span_s": [float(imu_dev[0]), float(imu_dev[-1])],
"bridge": bridge,
"residual_delta_s": residual_delta,
"residual_peak": residual_peak,
"residual_applied_s": applied,
"residual_applied": apply_residual,
"note": (
"LiDAR t_* rewritten onto IMU device clock via MSOP/IMU HostReceiveUtc; "
"not first-device-sample coincidence. residual |omega| shift applied only if peak>=0.5."
),
}
(dst / "align_meta.json").write_text(
json.dumps(meta, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
)
return meta
def run_one(session_dir: Path, vehicle: Path, search_s: float) -> dict:
output = session_dir / "out"
if output.exists():
shutil.rmtree(output)
argv = [
"run",
"--session-id",
session_dir.name,
"--imu",
str(session_dir / "imu.csv"),
"--lidar",
str(session_dir / "lidar"),
"--vehicle-config",
str(vehicle),
"--output",
str(output),
"--mode",
"rotation_only",
"--time-offset-search-s",
str(search_s),
"--min-pair-rotation-deg",
"2.0",
]
code = cli_main(argv)
summary_path = output / "summary.json"
summary = {}
if summary_path.is_file():
summary = json.loads(summary_path.read_text(encoding="utf-8"))
t_block = summary.get("T_IMU_lidar") or {}
return {
"session": session_dir.name,
"exit_code": code,
"status": summary.get("status"),
"message": summary.get("message"),
"time_offset_s": summary.get("time_offset_s"),
"rotation_deg": t_block.get("rotation_deg") if isinstance(t_block, dict) else None,
"summary": str(summary_path) if summary_path.is_file() else None,
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--sessions-root",
type=Path,
default=Path(r"D:\data\calibration_usable_20260808\sessions_v1"),
)
parser.add_argument(
"--aligned-root",
type=Path,
default=Path(r"D:\data\calibration_usable_20260808\sessions_v1_host_aligned"),
)
parser.add_argument(
"--vehicle-config",
type=Path,
default=ROOT / "config" / "vehicle_hi13_h32_20260808.yaml",
)
parser.add_argument(
"--residual-search-s",
type=float,
default=5.0,
help="|ω| residual search after host bridge (seconds)",
)
parser.add_argument("--time-offset-search-s", type=float, default=1.0)
args = parser.parse_args()
results = []
for name in SESSIONS:
src = args.sessions_root / name
if not src.is_dir():
raise SystemExit(f"missing session: {src}")
aligned = args.aligned_root / name
print(f"=== align {name} ===", flush=True)
meta = align_session(src, aligned, residual_search_s=args.residual_search_s)
print(json.dumps(meta, ensure_ascii=False, indent=2), flush=True)
print(f"=== calibrate {name} ===", flush=True)
result = run_one(aligned, args.vehicle_config, args.time_offset_search_s)
results.append({"align": meta, **result})
print(json.dumps(result, ensure_ascii=False, indent=2), flush=True)
manifest = args.aligned_root / "calibration_manifest.json"
args.aligned_root.mkdir(parents=True, exist_ok=True)
manifest.write_text(json.dumps(results, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"manifest: {manifest}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+211 -43
View File
@@ -9,6 +9,8 @@ Modes (keyboard):
2 IMU prediction with X=I (B_pred = A) 2 IMU prediction with X=I (B_pred = A)
3 LiDAR registration B (reference) 3 LiDAR registration B (reference)
4 calibrated prediction B_pred = X^{-1} A X 4 calibrated prediction B_pred = X^{-1} A X
(rotation_only runs default to R conjug + t_B so bad IMU Δp
does not dominate the overlay)
N / ] next motion pair N / ] next motion pair
P / [ previous motion pair P / [ previous motion pair
Q / Esc exit Q / Esc exit
@@ -20,10 +22,16 @@ from __future__ import annotations
import argparse import argparse
import json import json
import sys
from pathlib import Path from pathlib import Path
from typing import Any
import numpy as np import numpy as np
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from imu_lidar.geometry import ( from imu_lidar.geometry import (
inverse_transform, inverse_transform,
make_transform, make_transform,
@@ -34,12 +42,39 @@ from imu_lidar.geometry import (
from imu_lidar.imu_io import load_imu_samples from imu_lidar.imu_io import load_imu_samples
from imu_lidar.imu_preintegration import preintegrate_imu from imu_lidar.imu_preintegration import preintegrate_imu
from imu_lidar.keyframes import build_keyframes from imu_lidar.keyframes import build_keyframes
from imu_lidar.lidar_io import load_lidar_frames from imu_lidar.lidar_io import load_lidar_frame_at, load_lidar_frames
from imu_lidar.motion_pairs import build_motion_pairs from imu_lidar.motion_pairs import build_motion_pairs
from imu_lidar.motion_pairs_io import (
load_motion_pairs,
pairs_for_session,
resolve_motion_pairs_path,
)
from imu_lidar.registration import register_lidar_pair from imu_lidar.registration import register_lidar_pair
from imu_lidar.time_offset import lidar_time_to_imu_time from imu_lidar.time_offset import lidar_time_to_imu_time
class _LazyFrameStore:
"""Load NPZ frames on demand; indices match mid-time-sorted ``load_lidar_frames``."""
def __init__(self, lidar_dir: Path, *, max_cached: int = 16):
self.lidar_dir = Path(lidar_dir)
self.max_cached = max_cached
self._cache: dict[int, object] = {}
self._order: list[int] = []
def __getitem__(self, index: int):
index = int(index)
if index in self._cache:
return self._cache[index]
frame = load_lidar_frame_at(self.lidar_dir, index)
self._cache[index] = frame
self._order.append(index)
while len(self._order) > self.max_cached:
old = self._order.pop(0)
self._cache.pop(old, None)
return frame
COLORS = { COLORS = {
"target": [0.10, 0.65, 1.00], "target": [0.10, 0.65, 1.00],
"source": [1.00, 0.35, 0.05], "source": [1.00, 0.35, 0.05],
@@ -53,24 +88,45 @@ MODE_NAMES = (
) )
def _load_extrinsic(summary_path: Path) -> tuple[np.ndarray, float, np.ndarray]: def _load_extrinsic(summary_path: Path) -> tuple[np.ndarray, float, np.ndarray, dict[str, Any]]:
summary = json.loads(summary_path.read_text(encoding="utf-8")) summary = json.loads(summary_path.read_text(encoding="utf-8"))
t_block = summary.get("T_IMU_lidar") t_block = summary.get("T_IMU_lidar")
meta: dict[str, Any] = {
"rotation_only": False,
"translation_accepted": False,
"status": str(summary.get("status") or ""),
}
if t_block is None: if t_block is None:
matrix = summary.get("matrix") matrix = summary.get("matrix")
if matrix is not None: if matrix is not None:
return np.asarray(matrix, dtype=float), 0.0, np.zeros(3) return np.asarray(matrix, dtype=float), 0.0, np.zeros(3), meta
raise ValueError(f"no T_IMU_lidar in {summary_path}") raise ValueError(f"no T_IMU_lidar in {summary_path}")
t_mat = np.asarray(t_block["matrix"], dtype=float) t_mat = np.asarray(t_block["matrix"], dtype=float)
dt = float(summary.get("time_offset_s") or 0.0) dt = float(summary.get("time_offset_s") or 0.0)
session = (summary.get("details") or {}).get("sessions", [{}])[0] session = (summary.get("details") or {}).get("sessions", [{}])[0]
joint = session.get("joint") or {}
bias = np.asarray( bias = np.asarray(
(session.get("imu_audit") or {}).get("gyro_bias_rad_s") (session.get("imu_audit") or {}).get("gyro_bias_rad_s")
or (session.get("joint") or {}).get("gyro_bias_rad_s") or joint.get("gyro_bias_rad_s")
or [0.0, 0.0, 0.0], or [0.0, 0.0, 0.0],
dtype=float, dtype=float,
).reshape(3) ).reshape(3)
return t_mat, dt, bias status = str(summary.get("status") or "")
translation_accepted = bool(
joint.get("translation_accepted")
or (summary.get("details") or {}).get("joint", {}).get("translation_accepted")
)
rotation_only = ("rotation_only" in status) or (
not translation_accepted and float(np.linalg.norm(t_mat[:3, 3])) < 1e-9
)
meta.update(
{
"rotation_only": rotation_only,
"translation_accepted": translation_accepted,
"status": status,
}
)
return t_mat, dt, bias, meta
def _delta_components(reference: np.ndarray, candidate: np.ndarray) -> dict: def _delta_components(reference: np.ndarray, candidate: np.ndarray) -> dict:
@@ -169,16 +225,42 @@ def _pair_from_indices(
return frame_i, frame_j, a, reg.transform 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]: def _transforms_for_pair(
x: np.ndarray,
a_ij: np.ndarray,
b_gicp: np.ndarray,
*,
mode4_translation: str = "imu",
) -> dict[str, np.ndarray]:
"""Build overlay transforms.
``mode4_translation``:
- ``imu``: full SE3 conjug ``X^{-1} A X`` (needs trustworthy IMU Δp)
- ``gicp``: rotation conjug only; translation taken from LiDAR B
(correct check for rotation_only calibrations)
"""
calibrated = inverse_transform(x) @ a_ij @ x
if mode4_translation == "gicp":
calibrated = make_transform(b_gicp[:3, 3], calibrated[:3, :3])
elif mode4_translation != "imu":
raise ValueError(f"unknown mode4_translation={mode4_translation!r}")
return { return {
MODE_NAMES[0]: np.eye(4), MODE_NAMES[0]: np.eye(4),
MODE_NAMES[1]: a_ij.copy(), MODE_NAMES[1]: a_ij.copy(),
MODE_NAMES[2]: b_gicp.copy(), MODE_NAMES[2]: b_gicp.copy(),
MODE_NAMES[3]: inverse_transform(x) @ a_ij @ x, MODE_NAMES[3]: calibrated,
} }
def _resolve_pair(frames, pairs, pair_index: int, x: np.ndarray): def _resolve_pair(
frames,
pairs,
pair_index: int,
x: np.ndarray,
*,
mode4_translation: str = "imu",
):
pair = pairs[pair_index] pair = pairs[pair_index]
frame_i = frames[pair.i] frame_i = frames[pair.i]
frame_j = frames[pair.j] frame_j = frames[pair.j]
@@ -190,7 +272,9 @@ def _resolve_pair(frames, pairs, pair_index: int, x: np.ndarray):
pair.t_B_m if pair.t_B_m is not None else np.zeros(3), pair.t_B_m if pair.t_B_m is not None else np.zeros(3),
pair.R_B, pair.R_B,
) )
transforms = _transforms_for_pair(x, a_ij, b_gicp) transforms = _transforms_for_pair(
x, a_ij, b_gicp, mode4_translation=mode4_translation
)
label = ( label = (
f"pair {pair_index + 1}/{len(pairs)} " f"pair {pair_index + 1}/{len(pairs)} "
f"frames {pair.i} <- {pair.j} " f"frames {pair.i} <- {pair.j} "
@@ -255,12 +339,15 @@ def _run_gui(
start_index: int, start_index: int,
voxel: float, voxel: float,
fixed_single_pair: tuple | None, fixed_single_pair: tuple | None,
mode4_translation: str = "imu",
) -> None: ) -> None:
import open3d as o3d import open3d as o3d
if fixed_single_pair is not None: if fixed_single_pair is not None:
frame_i, frame_j, a_ij, b_gicp = fixed_single_pair frame_i, frame_j, a_ij, b_gicp = fixed_single_pair
transforms = _transforms_for_pair(x, a_ij, b_gicp) transforms = _transforms_for_pair(
x, a_ij, b_gicp, mode4_translation=mode4_translation
)
label = f"fixed frames (no pair switching)" label = f"fixed frames (no pair switching)"
pair_index = 0 pair_index = 0
n_pairs = 1 n_pairs = 1
@@ -270,7 +357,7 @@ def _run_gui(
n_pairs = len(pairs) n_pairs = len(pairs)
use_list = True use_list = True
frame_i, frame_j, a_ij, b_gicp, transforms, label = _resolve_pair( frame_i, frame_j, a_ij, b_gicp, transforms, label = _resolve_pair(
frames, pairs, pair_index, x frames, pairs, pair_index, x, mode4_translation=mode4_translation
) )
viewer = o3d.visualization.VisualizerWithKeyCallback() viewer = o3d.visualization.VisualizerWithKeyCallback()
@@ -311,7 +398,7 @@ def _run_gui(
return return
new_index = int(new_index) % n_pairs new_index = int(new_index) % n_pairs
frame_i, frame_j, _a, b_gicp, transforms, label = _resolve_pair( frame_i, frame_j, _a, b_gicp, transforms, label = _resolve_pair(
frames, pairs, new_index, x frames, pairs, new_index, x, mode4_translation=mode4_translation
) )
state["pair_index"] = new_index state["pair_index"] = new_index
state["transforms"] = transforms state["transforms"] = transforms
@@ -357,13 +444,34 @@ def _run_gui(
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--lidar", required=True, type=Path, help="LiDAR session directory") 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(
"--imu",
type=Path,
default=None,
help="IMU CSV (only needed when rebuilding pairs without motion_pairs.json)",
)
parser.add_argument( parser.add_argument(
"--summary", "--summary",
required=True, required=True,
type=Path, type=Path,
help="summary.json (or T_IMU_lidar.json) from a calibration run", help="summary.json (or T_IMU_lidar.json) from a calibration run",
) )
parser.add_argument(
"--motion-pairs",
type=Path,
default=None,
help="Cached motion_pairs.json (default: next to --summary)",
)
parser.add_argument(
"--session-id",
default=None,
help="Session id inside multi-session motion_pairs.json",
)
parser.add_argument(
"--rebuild-pairs",
action="store_true",
help="Ignore cache and rebuild pairs from IMU/LiDAR (slow)",
)
parser.add_argument("--pair-index", type=int, default=0, help="Starting motion-pair index") 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-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("--frame-j", type=int, default=None, help="Optional explicit frame index j")
@@ -381,45 +489,104 @@ def main(argv: list[str] | None = None) -> int:
action="store_true", action="store_true",
help="Skip Open3D window (use with --save-png)", help="Skip Open3D window (use with --save-png)",
) )
parser.add_argument(
"--mode4-translation",
choices=("auto", "gicp", "imu"),
default="auto",
help=(
"Mode-4 translation source: gicp=R conjug + t_B (rotation check); "
"imu=full X^-1 A X; auto=gicp for rotation_only summaries"
),
)
args = parser.parse_args(argv) args = parser.parse_args(argv)
x, delta_t_s, gyro_bias = _load_extrinsic(args.summary) x, delta_t_s, gyro_bias, extr_meta = _load_extrinsic(args.summary)
frames, imu, keyframes, pair_set = _build_pair_list( if args.mode4_translation == "auto":
lidar_dir=args.lidar, mode4_translation = "gicp" if extr_meta.get("rotation_only") else "imu"
imu_path=args.imu, else:
delta_t_s=delta_t_s, mode4_translation = args.mode4_translation
gyro_bias=gyro_bias, cache_path = args.motion_pairs or resolve_motion_pairs_path(args.summary)
min_rotation_deg=args.min_pair_rotation_deg, use_cache = (not args.rebuild_pairs) and cache_path is not None and args.frame_i is None
min_translation_m=args.min_pair_translation_m,
)
frames = None
pairs: tuple = ()
fixed_single_pair = None fixed_single_pair = None
if args.frame_i is not None and args.frame_j is not None: label = ""
frame_i, frame_j, a_ij, b_gicp = _pair_from_indices( b_gicp = np.eye(4)
frames, transforms: dict[str, np.ndarray] = {}
imu, frame_i = frame_j = None
i=args.frame_i,
j=args.frame_j, if use_cache:
payload = load_motion_pairs(cache_path)
pair_list = pairs_for_session(payload, args.session_id)
if not pair_list:
raise SystemExit(f"no pairs in cache: {cache_path}")
if not 0 <= args.pair_index < len(pair_list):
raise SystemExit(
f"pair-index {args.pair_index} outside [0, {len(pair_list) - 1}] "
f"({len(pair_list)} pairs in cache)"
)
frames = _LazyFrameStore(args.lidar)
pairs = tuple(pair_list)
frame_i, frame_j, a_ij, b_gicp, transforms, label = _resolve_pair(
frames, pairs, args.pair_index, x, mode4_translation=mode4_translation
)
print(f"loaded {len(pairs)} cached pairs from {cache_path}")
else:
if args.imu is None:
raise SystemExit("--imu is required when motion_pairs.json is missing (or use --rebuild-pairs with --imu)")
frames, imu, keyframes, pair_set = _build_pair_list(
lidar_dir=args.lidar,
imu_path=args.imu,
delta_t_s=delta_t_s, delta_t_s=delta_t_s,
gyro_bias=gyro_bias, gyro_bias=gyro_bias,
min_rotation_deg=args.min_pair_rotation_deg,
min_translation_m=args.min_pair_translation_m,
) )
transforms = _transforms_for_pair(x, a_ij, b_gicp) if args.frame_i is not None and args.frame_j is not None:
label = f"frames {args.frame_i} <- {args.frame_j}" frame_i, frame_j, a_ij, b_gicp = _pair_from_indices(
fixed_single_pair = (frame_i, frame_j, a_ij, b_gicp) frames,
pairs = () imu,
else: i=args.frame_i,
if not pair_set.pairs: j=args.frame_j,
raise SystemExit("no motion pairs rebuilt; loosen min-pair thresholds or check data") delta_t_s=delta_t_s,
if not 0 <= args.pair_index < len(pair_set.pairs): gyro_bias=gyro_bias,
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 transforms = _transforms_for_pair(
frame_i, frame_j, a_ij, b_gicp, transforms, label = _resolve_pair( x, a_ij, b_gicp, mode4_translation=mode4_translation
frames, pairs, args.pair_index, x )
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, mode4_translation=mode4_translation
)
print(f"rebuilt {len(pairs)} pairs from {len(keyframes.indices)} keyframes")
print(
f"mode4 translation={mode4_translation} "
f"(status={extr_meta.get('status') or 'n/a'}, "
f"rotation_only={bool(extr_meta.get('rotation_only'))})"
)
if mode4_translation == "gicp":
print(
"note: mode4 uses R conjug + t_B; IMU Δp is ignored "
"(typical for rotation_only — raw Δp often has large Z drift)."
)
if mode4_translation == "imu":
print(
"note: mode4 uses full X^-1 A X. If clouds stack vertically, "
"IMU Δp is likely bad; retry with --mode4-translation gicp."
) )
print(f"rebuilt {len(pairs)} pairs from {len(keyframes.indices)} keyframes")
if args.save_png is not None: if args.save_png is not None:
_print_pair_header(label, b_gicp, transforms) _print_pair_header(label, b_gicp, transforms)
@@ -444,6 +611,7 @@ def main(argv: list[str] | None = None) -> int:
start_index=args.pair_index, start_index=args.pair_index,
voxel=args.voxel, voxel=args.voxel,
fixed_single_pair=fixed_single_pair, fixed_single_pair=fixed_single_pair,
mode4_translation=mode4_translation,
) )
return 0 return 0