支持 HI13/H32 主机 UTC 桥接对齐、多会话联合标定与 CAD 平移先验。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -152,21 +152,21 @@ def _build_nav_rotations(
|
||||
r_x: np.ndarray,
|
||||
t_x: np.ndarray,
|
||||
) -> list[np.ndarray]:
|
||||
"""Chain IMU orientations in the first-keyframe nav frame using LiDAR+extrinsic."""
|
||||
"""Chain IMU orientations; restart at session/gap boundaries (no cross-link)."""
|
||||
|
||||
del id_to_idx
|
||||
rotations = [np.eye(3) for _ in keyframe_ids]
|
||||
for k in range(len(keyframe_ids) - 1):
|
||||
a = keyframe_ids[k]
|
||||
b = keyframe_ids[k + 1]
|
||||
pair = consecutive_pairs.get((a, b))
|
||||
if pair is None:
|
||||
rotations[k + 1] = rotations[k]
|
||||
# Missing link or new session: start a fresh nav chain.
|
||||
rotations[k + 1] = np.eye(3)
|
||||
continue
|
||||
t_b = np.zeros(3) if pair.t_B_m is None else np.asarray(pair.t_B_m, dtype=float)
|
||||
r_meas, _ = _lidar_to_imu_relative(r_x, t_x, pair.R_B, t_b)
|
||||
rotations[k + 1] = orthonormalize_rotation(rotations[k] @ r_meas)
|
||||
# Ensure list indexed by id_to_idx
|
||||
del id_to_idx
|
||||
return rotations
|
||||
|
||||
|
||||
@@ -178,6 +178,9 @@ def _solve_phase_c_se3(
|
||||
gravity_init: np.ndarray,
|
||||
sigma_bg_rw: float = 1.0e-5,
|
||||
sigma_ba_rw: float = 1.0e-3,
|
||||
t_init: np.ndarray | None = None,
|
||||
t_prior: np.ndarray | None = None,
|
||||
t_prior_sigma_m: np.ndarray | float | None = None,
|
||||
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, float, float, list[str]]:
|
||||
"""Keyframe IMU factor optimization for full SE(3)."""
|
||||
|
||||
@@ -185,21 +188,36 @@ def _solve_phase_c_se3(
|
||||
usable = [pair for pair in pairs if pair.t_B_m is not None and "delta_v" in pair.metadata]
|
||||
if len(usable) < 3:
|
||||
notes.append("phase-C skipped: need pairs with full preintegration metadata")
|
||||
return r_x, np.zeros(3), gravity_init, gyro_bias0, np.zeros(3), 1e9, 1e9, notes
|
||||
t0 = np.zeros(3) if t_init is None else np.asarray(t_init, dtype=float).reshape(3)
|
||||
return r_x, t0, gravity_init, gyro_bias0, np.zeros(3), 1e9, 1e9, notes
|
||||
|
||||
# Unique keyframes sorted by IMU time.
|
||||
# Keyframes: group by session, sort each session by IMU time (no cross-session chain).
|
||||
stamp: dict[int, float] = {}
|
||||
kf_session: dict[int, str] = {}
|
||||
for pair in usable:
|
||||
stamp[pair.i] = float(pair.metadata.get("t_i_imu_s", pair.t_i_s))
|
||||
stamp[pair.j] = float(pair.metadata.get("t_j_imu_s", pair.t_j_s))
|
||||
keyframe_ids = sorted(stamp.keys(), key=lambda kid: stamp[kid])
|
||||
kf_session[pair.i] = pair.session_id
|
||||
kf_session[pair.j] = pair.session_id
|
||||
session_ids = sorted(set(kf_session.values()))
|
||||
keyframe_ids: list[int] = []
|
||||
for sid in session_ids:
|
||||
local = [kid for kid, sess in kf_session.items() if sess == sid]
|
||||
local.sort(key=lambda kid: stamp[kid])
|
||||
keyframe_ids.extend(local)
|
||||
k_count = len(keyframe_ids)
|
||||
id_to_idx = {kid: idx for idx, kid in enumerate(keyframe_ids)}
|
||||
|
||||
consecutive_pairs: dict[tuple[int, int], MotionPair] = {}
|
||||
for pair in usable:
|
||||
if kf_session.get(pair.i) != kf_session.get(pair.j):
|
||||
continue
|
||||
if id_to_idx[pair.j] == id_to_idx[pair.i] + 1:
|
||||
consecutive_pairs[(pair.i, pair.j)] = pair
|
||||
notes.append(
|
||||
f"phase-C multi-session graph: sessions={len(session_ids)}, "
|
||||
f"keyframes={k_count}, consecutive_links={len(consecutive_pairs)}"
|
||||
)
|
||||
|
||||
g0 = np.asarray(gravity_init, dtype=float).reshape(3)
|
||||
if np.linalg.norm(g0) < 1e-6:
|
||||
@@ -214,6 +232,15 @@ def _solve_phase_c_se3(
|
||||
n_b = 3 * k_count
|
||||
dim = 3 + 3 + 2 + n_v + n_b + n_b
|
||||
x0 = np.zeros(dim)
|
||||
t0 = np.zeros(3) if t_init is None else np.asarray(t_init, dtype=float).reshape(3)
|
||||
x0[3:6] = t0
|
||||
t_prior_vec = None if t_prior is None else np.asarray(t_prior, dtype=float).reshape(3)
|
||||
if t_prior_sigma_m is None:
|
||||
t_sigma = np.array([0.05, 0.05, 0.05], dtype=float)
|
||||
else:
|
||||
t_sigma = np.asarray(t_prior_sigma_m, dtype=float).reshape(-1)
|
||||
if t_sigma.size == 1:
|
||||
t_sigma = np.full(3, float(t_sigma[0]), dtype=float)
|
||||
# velocities start at 0; biases at prior
|
||||
for idx in range(k_count):
|
||||
x0[8 + n_v + 3 * idx : 8 + n_v + 3 * idx + 3] = bg0
|
||||
@@ -269,18 +296,28 @@ def _solve_phase_c_se3(
|
||||
w = np.sqrt(_pair_weight(pair))
|
||||
out.append(w * (whiten @ err))
|
||||
|
||||
# Bias random-walk between consecutive keyframes.
|
||||
# Bias random-walk between consecutive keyframes (same session only).
|
||||
for k in range(k_count - 1):
|
||||
dt = max(stamp[keyframe_ids[k + 1]] - stamp[keyframe_ids[k]], 1e-3)
|
||||
a = keyframe_ids[k]
|
||||
b = keyframe_ids[k + 1]
|
||||
if kf_session.get(a) != kf_session.get(b):
|
||||
continue
|
||||
dt = max(stamp[b] - stamp[a], 1e-3)
|
||||
scale_g = 1.0 / (max(sigma_bg_rw, 1e-8) * np.sqrt(dt))
|
||||
scale_a = 1.0 / (max(sigma_ba_rw, 1e-8) * np.sqrt(dt))
|
||||
out.append(scale_g * (bgs[k + 1] - bgs[k]))
|
||||
out.append(scale_a * (bas[k + 1] - bas[k]))
|
||||
|
||||
# Weak priors: first-keyframe biases and translation magnitude.
|
||||
out.append(50.0 * (bgs[0] - bg0))
|
||||
out.append(20.0 * bas[0])
|
||||
out.append(0.2 * t_opt) # soft |t| prior ~ meters
|
||||
# Weak priors: first keyframe of each session + CAD/installation translation.
|
||||
for sid in session_ids:
|
||||
first = next(kid for kid in keyframe_ids if kf_session[kid] == sid)
|
||||
idx0 = id_to_idx[first]
|
||||
out.append(50.0 * (bgs[idx0] - bg0))
|
||||
out.append(20.0 * bas[idx0])
|
||||
if t_prior_vec is not None:
|
||||
out.append((t_opt - t_prior_vec) / np.maximum(t_sigma, 1e-3))
|
||||
else:
|
||||
out.append(0.2 * t_opt) # soft |t|~0 prior when no CAD prior
|
||||
return np.concatenate(out)
|
||||
|
||||
# Cap evaluations: Phase-C is high-dimensional; synthetic ICP already dominates runtime.
|
||||
@@ -331,6 +368,9 @@ def solve_joint_extrinsic(
|
||||
gravity_init_m_s2: np.ndarray | None = None,
|
||||
bias_prior_sigma_rad_s: float = 0.02,
|
||||
enable_phase_c: bool | None = None,
|
||||
t_init_m: np.ndarray | None = None,
|
||||
t_prior_m: np.ndarray | None = None,
|
||||
t_prior_sigma_m: np.ndarray | float | None = None,
|
||||
) -> JointExtrinsicResult:
|
||||
"""Refine extrinsic using Phase-A whitened rotation factors, optional Phase-C SE(3)."""
|
||||
|
||||
@@ -344,6 +384,7 @@ def solve_joint_extrinsic(
|
||||
|
||||
r = orthonormalize_rotation(np.asarray(r_x, dtype=float))
|
||||
bias0 = np.zeros(3) if gyro_bias_rad_s is None else np.asarray(gyro_bias_rad_s, dtype=float).reshape(3)
|
||||
t_seed = None if t_init_m is None else np.asarray(t_init_m, dtype=float).reshape(3)
|
||||
weights = np.asarray([_pair_weight(pair) for pair in usable], dtype=float)
|
||||
whitens = [residual_whiten_matrix(_pair_cov(pair)) for pair in usable]
|
||||
prior_w = 1.0 / max(bias_prior_sigma_rad_s, 1e-4)
|
||||
@@ -389,7 +430,7 @@ def solve_joint_extrinsic(
|
||||
rot_errs.append(np.degrees(np.linalg.norm(err)))
|
||||
rot_rms = float(np.sqrt(np.mean(np.square(rot_errs)))) if rot_errs else 1e9
|
||||
|
||||
t = np.zeros(3)
|
||||
t = np.zeros(3) if t_seed is None else t_seed.copy()
|
||||
translation_accepted = False
|
||||
trans_rms = 1e9
|
||||
gravity_out: np.ndarray | None = None
|
||||
@@ -400,6 +441,12 @@ def solve_joint_extrinsic(
|
||||
else:
|
||||
gravity_init = np.asarray(gravity_init_m_s2, dtype=float).reshape(3)
|
||||
|
||||
if t_prior_m is not None:
|
||||
notes.append(
|
||||
"using CAD/installation translation prior "
|
||||
f"t={np.asarray(t_prior_m, dtype=float).reshape(3).tolist()}"
|
||||
)
|
||||
|
||||
if (
|
||||
enable_phase_c
|
||||
and not force_rotation_only
|
||||
@@ -412,12 +459,23 @@ def solve_joint_extrinsic(
|
||||
r,
|
||||
gyro_bias0=bias_out,
|
||||
gravity_init=gravity_init,
|
||||
t_init=t_seed if t_seed is not None else t_prior_m,
|
||||
t_prior=t_prior_m,
|
||||
t_prior_sigma_m=t_prior_sigma_m,
|
||||
)
|
||||
notes.extend(c_notes)
|
||||
translation_accepted = bool(trans_rms < 0.75 and np.linalg.norm(t) > 1e-4)
|
||||
if not translation_accepted:
|
||||
notes.append("phase-C translation residual/gate failed; keeping translation at zero")
|
||||
t = np.zeros(3)
|
||||
# Prefer CAD prior over silent zero when motion SE3 is rejected.
|
||||
if t_prior_m is not None:
|
||||
t = np.asarray(t_prior_m, dtype=float).reshape(3)
|
||||
translation_accepted = True
|
||||
notes.append(
|
||||
"phase-C translation residual/gate failed; keeping CAD translation prior"
|
||||
)
|
||||
else:
|
||||
notes.append("phase-C translation residual/gate failed; keeping translation at zero")
|
||||
t = np.zeros(3)
|
||||
elif (
|
||||
not force_rotation_only
|
||||
and observability.translation_observable
|
||||
@@ -437,9 +495,19 @@ def solve_joint_extrinsic(
|
||||
pred = (pair.R_A - np.eye(3)) @ t_opt
|
||||
meas = r_opt @ np.asarray(pair.t_B_m, dtype=float)
|
||||
residuals.append(np.sqrt(weight) * (pred - meas))
|
||||
if t_prior_m is not None:
|
||||
sigma = np.asarray(t_prior_sigma_m if t_prior_sigma_m is not None else 0.05, dtype=float)
|
||||
if sigma.size == 1:
|
||||
sigma = np.full(3, float(sigma), dtype=float)
|
||||
residuals.append((t_opt - np.asarray(t_prior_m, dtype=float).reshape(3)) / np.maximum(sigma, 1e-3))
|
||||
return np.concatenate(residuals)
|
||||
|
||||
opt_t = least_squares(residual_se3, np.zeros(6), loss="huber", f_scale=0.05, max_nfev=200)
|
||||
x_se3 = np.zeros(6)
|
||||
if t_seed is not None:
|
||||
x_se3[3:] = t_seed
|
||||
elif t_prior_m is not None:
|
||||
x_se3[3:] = np.asarray(t_prior_m, dtype=float).reshape(3)
|
||||
opt_t = least_squares(residual_se3, x_se3, loss="huber", f_scale=0.05, max_nfev=200)
|
||||
r = orthonormalize_rotation(so3_exp(opt_t.x[:3]) @ r)
|
||||
t = opt_t.x[3:]
|
||||
rot_errs = []
|
||||
@@ -452,11 +520,15 @@ def solve_joint_extrinsic(
|
||||
trans_errs.append(np.linalg.norm(pred - meas))
|
||||
rot_rms = float(np.sqrt(np.mean(np.square(rot_errs))))
|
||||
trans_rms = float(np.sqrt(np.mean(np.square(trans_errs))))
|
||||
translation_accepted = trans_rms < 0.5
|
||||
translation_accepted = trans_rms < 0.5 or t_prior_m is not None
|
||||
notes.append(f"legacy translation refine rms={trans_rms:.3f} m")
|
||||
if not translation_accepted:
|
||||
notes.append("translation residual too large; keeping translation at zero")
|
||||
t = np.zeros(3)
|
||||
elif not force_rotation_only and t_prior_m is not None:
|
||||
t = np.asarray(t_prior_m, dtype=float).reshape(3)
|
||||
translation_accepted = True
|
||||
notes.append("SE3 motion solve gated off; using CAD translation prior with refined rotation")
|
||||
else:
|
||||
notes.append("rotation-only extrinsic returned (phase-A; phase-C SE3 gated off)")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user