Skip to content

Extrinsics Math

SymbolMeaning
plidarp_{\text{lidar}}3D point in the LiDAR / vehicle frame (metres): xx forward, yy left, zz up
pcamp_{\text{cam}}Same point in the OpenCV camera frame (metres): xx right, yy down, zz forward
R,tR, tRigid LiDAR→camera map: pcam=Rplidar+tp_{\text{cam}} = R\,p_{\text{lidar}} + t. RSO(3)R\in\mathrm{SO}(3), tR3t\in\mathbb{R}^3
KruntimeK_{\text{runtime}}3×33\times3 camera matrix for the pixels in the pose images (undistorted; right cam already 180180^\circ)
PP3×43\times4 projection matrix written to params.yaml: P=Kruntime[Rt]P = K_{\text{runtime}}[R \mid t]
XiX_iii-th checkerboard corner in the board frame (z=0z=0), spaced by square_m
xicamx_i^{\text{cam}}That corner expressed in the camera frame (metres)
xilidarx_i^{\text{lidar}}The same corner recovered from the LiDAR board rectangle (metres)
uimgu^{\text{img}}Detected corner in image pixels (runtime frame)
n,dn, dPlane nx+d=0n\cdot x + d = 0 with unit normal nn
nc,dcn_c, d_cBoard plane in the camera frame
u,vu, v (axes)Orthonormal in-plane axes of the fitted board rectangle (metres); not image pixels
Pose kkOne synced capture: runtime image + cloud.npy

Homogeneous projection into pixels is written π(Px~)=(px/pz,py/pz)\pi(P\tilde{x}) = (p_x/p_z,\, p_y/p_z) with x~=(x,y,z,1)\tilde{x}=(x,y,z,1)^\top. More on PP and KruntimeK_{\text{runtime}}: projection math.

Estimate (R,t)(R,t) from multi-pose board observations, then write P=Kruntime[Rt]P = K_{\text{runtime}}[R \mid t].

Correspondences are not clicked. OpenCV recovers ordered chessboard corners on the camera; LiDAR recovers the same corners from a board-sized plane fit. Multi-pose consensus resolves planar labeling ambiguity that a single pose cannot.

Images are already in SeeCam runtime space. Detection:

  1. findChessboardCorners + cornerSubPix on a (9,6)(9,6) internal-corner grid (cols ×\times rows).
  2. Object points XiX_i lie in the board frame (z=0z=0), spaced by square_m.
  3. solvePnP with KruntimeK_{\text{runtime}} (distortion coeffs unused; image already undistorted) yields the board pose in the camera frame: rotation RbR_b and translation tbt_b, so a board point maps as x=RbX+tbx = R_b X + t_b.

Camera-frame pattern corners and the board plane:

xicam=RbXi+tb,nc=Rb[001],dc=nctb.x_i^{\text{cam}} = R_b X_i + t_b, \qquad n_c = R_b\begin{bmatrix}0\\0\\1\end{bmatrix}, \quad d_c = -n_c^\top t_b.

So ncn_c is the board normal in the camera frame (third column of RbR_b), and the plane through the board is ncx+dc=0n_c\cdot x + d_c = 0. pose_to_plane is that conversion. If the board is not found, the pose is skipped.

Input is a dense cloud (cloud.npy from /lidar_points), not /cones.

  1. Soft radial gate (script defaults about 0.50.5 to 12m12\,\text{m} from the LiDAR origin) and voxel downsample for discovery.
  2. Iterative RANSAC peels dominant planes nx+d=0n\cdot x + d = 0 (inlier distance about 3cm3\,\text{cm}), then refines each plane with SVD on its inliers.
  3. For each plane, project inliers into an orthonormal basis on that plane and fit a rectangle of known outer size (board_width_m ×\times board_height_m).

Rectangle fit sweeps the in-plane angle (about 22^\circ steps). For each angle it measures percentile extents of the projected points, checks size within tolerance (about 25%25\%), and scores fill fraction: fraction of inliers inside the known-size box centered on the observed extent. Candidates need enough inliers (about ≥40) and fill ≥ about 0.350.35. Rank by fill; later consensus uses only the top-ranked footprint.

From the fitted rectangle center and in-plane axes u,vu,v (unit vectors in metres, lying in the LiDAR plane), rebuild ordered pattern corners xilidarx_i^{\text{lidar}} (pattern_corners_from_board_rectangle). The printed checker is assumed centered on the board (plus optional offsets in BoardSpec), with the same row-major OpenCV ordering as board_object_points / the XiX_i above.

A rectangle has eight discrete corner labelings: 4 cyclic rotations × reflection (rectangle_corner_permutations). Each labeling produces a different ordered set {xilidar}\{x_i^{\text{lidar}}\}.

For a single pose, Kabsch aligning {xilidar}\{x_i^{\text{lidar}}\} to {xicam}\{x_i^{\text{cam}}\} often has near-zero RMSE even for a wrong 180180^\circ flip of the board in its plane. Local 3D error cannot break that symmetry. The code therefore keeps all size-consistent orientations with low local RMSE (about <0.15m<0.15\,\text{m} mean error on pattern corners), not only the single best.

initial_rt is a nominal seed: fixed LiDAR→OpenCV axis map, about 1414^\circ pitch, and a hand-set camera position in the LiDAR frame. The multi-pose solve does not depend on its accuracy.

Need ≥3 valid poses. Build a pool of candidate global transforms (Rh,th)(R_h, t_h) from the per-pose orientation hypotheses. For each candidate, and for each pose kk, pick the orientation of that pose’s LiDAR pattern that best matches under the same global map:

ek(Rh,th)=minorientation1Ni=1NRhxk,ilidar+thxk,icam,e_k(R_h,t_h) = \min_{\text{orientation}} \frac{1}{N}\sum_{i=1}^{N} \big\| R_h\,x_{k,i}^{\text{lidar}} + t_h - x_{k,i}^{\text{cam}} \big\|,

where NN is the number of internal corners (54 for a 9×69\times6 grid), xk,ilidarx_{k,i}^{\text{lidar}} are that orientation’s LiDAR corners for pose kk, and xk,icamx_{k,i}^{\text{cam}} are the camera-frame corners from solvePnP on pose kk.

Accept a hypothesis only if every pose’s best eke_k stays below a few centimetres (script headroom about 0.12m0.12\,\text{m}). Among survivors, pick the lowest kek\sum_k e_k. That locks one oriented LiDAR pattern per pose.

Scoring applies the candidate global (Rh,th)(R_h,t_h) to each pose’s corners. It does not ask whether that pose’s own one-shot Kabsch rotation equals RhR_h. A wrong flip can look great locally and still fail this global check.

With consensus labels fixed, stack every pose’s LiDAR corners into one source cloud and every matching camera corner into one destination cloud. Re-estimate a single rigid map (orthogonal Procrustes / Kabsch). Below, the sums run over all stacked correspondences (all poses, all corners):

μs=mean of all xlidar,μd=mean of all xcam,H=(xlidarμs)(xcamμd),H=UΣV(SVD),R=VU(if detR<0, flip the last row of V),t=μdRμs.\begin{aligned} \mu_s &= \text{mean of all } x^{\text{lidar}}, & \mu_d &= \text{mean of all } x^{\text{cam}}, \\ H &= \sum (x^{\text{lidar}}-\mu_s)(x^{\text{cam}}-\mu_d)^\top, \\ H &= U\Sigma V^\top \quad\text{(SVD)}, & R &= V U^\top \quad(\text{if }\det R < 0,\text{ flip the last row of }V^\top), \\ t &= \mu_d - R\mu_s. \end{aligned}

Here HH is the 3×33\times3 cross-covariance used by Kabsch, not a homogeneous transform. The resulting (R,t)(R,t) is what enters P=Kruntime[Rt]P = K_{\text{runtime}}[R \mid t].

Even a clean Kabsch fit is under-constrained if every board pose is nearly the same orientation and position. assess_observability looks at the LiDAR planes (n,d)(n_\ell, d_\ell) across poses:

  • Max pairwise angle between normals nn_\ell (using cosθ|\cos\theta| so sign flips do not matter) ≥ about 1515^\circ.
  • Spread of plane points dn-d_\ell n_\ell (a point on each plane along its normal): Euclidean std of those 3D points ≥ about 0.15m0.15\,\text{m}.

Poses with parallel normals at similar range fail observability.

After (R,t)(R,t) is fixed, project every consensus LiDAR pattern corner and compare 1:1 to the detected image corner (structured_board_reprojection_error):

ei=π ⁣(Kruntime[Rt]x~ilidar)uiimg2,e_i = \big\| \pi\!\big(K_{\text{runtime}}[R \mid t]\,\tilde{x}_i^{\text{lidar}}\big) - u_i^{\text{img}} \big\|_2,

with x~ilidar=(x,y,z,1)\tilde{x}_i^{\text{lidar}} = (x,y,z,1)^\top and uiimgu_i^{\text{img}} the corresponding OpenCV corner in pixels.

Report mean and p95 of eie_i over all corners and poses. Script write gate: mean ≤ 2 px, p95 ≤ 4 px.

This is stricter than nearest-neighbor matching between projected cloud points and chessboard edges. Wrong orientation consensus can pass a soft nearest-neighbor check and still fail the structured gate.

Plane peel and rectangle fill need many returns on the board face. The live cone stack publishes sparse centroids (interfaces/msg/Points on /cones); that representation cannot support this solve.

Procedure and sync gates: calibration procedure. Runtime PP and right-cam 180180^\circ: projection math. Prod click path: legacy DLT.