Skip to content

Fix body frame jacobians - #5

Open
AkshayHinduja wants to merge 6 commits into
scomup:mainfrom
AkshayHinduja:fix-body-frame-jacobians
Open

Fix body frame jacobians#5
AkshayHinduja wants to merge 6 commits into
scomup:mainfrom
AkshayHinduja:fix-body-frame-jacobians

Conversation

@AkshayHinduja

Copy link
Copy Markdown

Correctness fixes for Jacobians, ICP, expSO3, and reference implementations

Summary

Numerical validation of the registration math uncovered several correctness issues. This PR fixes them and adds regression coverage; it introduces no new features. A follow-up PR will build degeneracy handling on this corrected foundation.

Fixes

  1. ICP fast-path rotational gradient (icp.py): the vectorized path used rs @ R.T (R @ r per row), while its Jacobian, J = [I | -R@skew(p)], requires R.T @ r (rs @ R). The Hessian was unaffected, but g[3:] was wrong away from identity and moved the Gauss-Newton stationary point. On noise-free data at |ω| = 0.5, align() converged about 0.3 rad from the true rotation. Parity tests now include rotated poses.

  2. Retraction-consistent Jacobians in all four solvers: plus() applies a right-multiplicative update (T @ [expSO3(w)|dt]), so translation increments are expressed in the body frame. The solvers instead used world-frame translation Jacobians (J_t = I for point-to-point/NDT and J_t = n for point-to-plane). The correct Jacobian is J = [R | -R@skew(p)].

    This did not change stationary points, but it mixed world-frame translation and body-frame rotation in H. That invalidates interpretations of its eigenstructure—such as covariance, observability/degeneracy, and direction-dependent step control—once the pose is rotated. The fast paths remain closed-form because the R factors partially cancel (for example, ICP H_lr = -skew(Σp)).

  3. expSO3 small-angle branch: for θ² ≤ 1e-5, it returned the first-order approximation I + W, which is not in SO(3) (det ≈ 1.000009 at |ω| = 3 mrad). Near convergence, repeated small updates therefore introduced scale/shear. The branch now uses second-order Taylor expansions of the Rodrigues coefficients; it is orthonormal to machine precision and agrees with the exact formula to ~3e-15 at the branch boundary.

  4. Reference (*_no_parallel_ver) implementations: rejected correspondences were handled incorrectly. icp.py left idx unmasked, silently misaligning pairs; the other three implementations looped over unmasked bounds and could raise IndexError; and the plane variants gated twice on residual instead of correspondence distance. Since these implementations underpin parity tests, those tests were only trustworthy for all-inlier inputs. They now also cover outliers.

  5. align() aliased init_T: if convergence occurred before the first step, align() returned the caller's array. With the mutable np.eye(4) default, mutating that result also corrupted the default for later calls. align() now copies the input on entry.

New test infrastructure

tests/test_retraction_consistency.py validates each solver against a numerical oracle. With correspondences frozen, a float64 central-difference Jacobian of the residual through plus() must independently reproduce g = ΣJᵀWr and H = ΣJᵀWJ. This catches convention errors that fast-vs-reference parity cannot, because both implementations could share the same mistake.

The suite also adds end-to-end recovery tests: ICP at |ω| = 0.5, PlaneICP from a rotated initialization, and voxel solvers on a textured corner.

CI

CI now runs pip install -e . pykdtree rather than installing the released PyPI package, ensuring tests exercise this checkout. pykdtree is installed explicitly because the poetry-core backend's dependency list omits it. Without this change, the new tests would run against the PyPI wheel.

Test suite: 6 → 31 tests, all green.

Retraction convention

The solvers use the body-frame, right-tangent parametrization consumed by math_tools.plus(), with DOF order [tx, ty, tz, ωx, ωy, ωz]. Solver docstrings now state this explicitly.

The workflow installed point-cloud-registration from PyPI, so every CI
run exercised the released wheel rather than the code under review.
Install the checkout in editable mode instead. pykdtree is listed
explicitly because the editable install resolves through the
pyproject.toml poetry-core backend, whose dependency list does not
include the KDTree backend the library uses at runtime.
The reference (educational) versions of calc_H_g_e2 mishandled rejected
correspondences:

- icp.py never masked idx, silently pairing masked source points with
  the wrong targets whenever a rejection occurred before them.
- plane_icp.py, voxelized_plane_icp.py and ndt.py iterated over the
  unmasked source length while norms/means/src_trans had already been
  reduced, raising IndexError on any rejection.
- The plane variants re-gated on the residual magnitude (a signed
  point-to-plane distance, not the correspondence distance the mask
  already applies) and ndt.py re-checked unmasked dist[i] against
  masked loop indices.

Mask idx and source consistently, iterate the masked length, and drop
the bogus secondary gates. The parity tests now cover rejection: each
is parametrized with a with_outliers case that prepends far-away
points, which previously crashed three references and silently
corrupted the fourth.
The vectorized point-to-point path computed Rt_r = rs @ R.T, which is
R @ r per row; the reference Jacobian J = [I | -R@skew(p)] needs
R.T @ r, i.e. rs @ R. H was unaffected, but g[3:] was wrong at any
non-identity pose, shifting the Gauss-Newton stationary point: on
noise-free data with |w_true| = 0.5, align() settled ~0.3 rad from the
true rotation. The parity tests are now parametrized over a rotated
evaluation pose as well as identity, which catches this: before the
fix only the ICP rotated-pose cases failed, confirming the other three
solvers were already consistent with their references.
align() applies its Gauss-Newton step through the right-multiplicative
retraction plus(T, dx) = T @ [expSO3(dx[3:]) | dx[:3]], so translation
increments act in the body frame (t += R @ dt). Every solver built its
translation Jacobian in the world frame instead (J_t = I for
point-to-point/NDT, J_t = n rows for point-to-plane); the exact
Jacobian for this retraction is J = [R | -R@skew(p)].

The stationary points were unchanged (the two gradients differ by an
invertible map), but H mixed world-frame translation coordinates with
body-frame rotation coordinates. Anything that interprets H's
eigenstructure — degeneracy analysis, covariance extraction,
per-direction step control — reads the wrong translation directions as
soon as R differs from identity.

Per solver: ICP g0 = R.T @ sum(r) and H_lr collapses to -skew(sum p)
(the R factors cancel in H); PlaneICP/VPlaneICP translation rows become
(R.T @ n); NDT H_ll = R.T @ (sum icov) @ R, H_lr and g0 gain the same
R.T factor; every H_rr/g1/e2 is untouched. Reference implementations
updated in lockstep.

New tests/test_retraction_consistency.py holds every solver to a
numerical oracle: with the solver's own correspondences frozen, a
float64 central-difference Jacobian of the residual through plus()
must reproduce both g = sum J'Wr and H = sum J'WJ — a check that
fast-vs-reference parity can never provide, since both paths could
share the same wrong convention. End-to-end recovery tests cover ICP
at |w| = 0.5, PlaneICP from a rotated initial pose, and the voxel
solvers on a mid-voxel textured corner.
For theta^2 <= 1e-5 the exponential map returned the first-order I + W,
which is not a rotation: det(I + W) = 1 + O(theta^2), about 1.000009 at
|omega| = 3e-3. Every small Gauss-Newton step applied through plus()
bakes that scale/shear into the pose, and near convergence all steps
are small. Use the second-order Taylor expansion of the Rodrigues
coefficients instead (A = 1 - theta^2/6, B = 1/2 - theta^2/24), which
agrees with the exact formula to ~3e-15 at the branch boundary and is
orthonormal to machine precision.
align() bound cur_T = init_T, and when it converges before the first
plus() call (source already aligned: the first solved step is below
tol) it returned the caller's own array. With the mutable np.eye(4)
default argument, a caller mutating the returned pose silently
corrupts the default for every subsequent align() call in the process.
Copy on entry instead.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant