Geometry, Mapping, and 3D/4D State › Depth, Pose, and Scene Flow

Michael BrenndoerferJune 19, 202611 min read
World Models HandbookAll chapters
Part 4 of 12Space, Physics, and Causality
Chapter 19Geometry, Mapping, and 3D/4D State › Depth, Pose, and Scene Flow
Reading section 3 of 9 · Depth, Pose, and Scene Flow · 11 minSection 3 of 9 · 11 min

Book contents

Section 3 of 9 · 11 min read

Geometry, Mapping, and 3D/4D State › Depth, Pose, and Scene FlowOne section · Sections in this chapter3 / 9 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Recovering geometry from observations involves three coupled estimation problems: depth (where points are), pose (where the sensor or object is), and scene flow (how points move). They are coupled because you usually need one to estimate the others: motion of the camera explains part of the image change, motion of the world explains the rest. To convert a set of depth measurements into a world-frame map you need the sensor pose; to estimate the sensor pose from point clouds you need the geometry of the surfaces around you; to tell whether a surface moved you need to know how much of its apparent motion came from the sensor. There is no fixed order of operations that breaks this loop cleanly. Practical systems break it with approximations, iteration, or priors, and the quality of the resulting geometry depends on how honest the system is about which variable was held fixed while estimating the others.

Estimating Pose

This section covers two common pose-estimation cases, distinguished by the correspondences available: 3D-to-2D and 3D-to-3D.

  • 3D-to-2D (perspective-n-point): given known 3D points and their pixel projections, solve for the camera pose. Each known 3D point together with its observed pixel constrains the pose, because the point must lie on that pixel's ray, and enough rays must intersect at a common camera position and orientation. This is what happens when a robot localizes against a known map, or when it estimates an object's pose from detected keypoints. For a calibrated perspective camera, P3P uses three nondegenerate correspondences but can yield multiple pose solutions, so a fourth correspondence is commonly used to disambiguate them. Other camera models and degenerate point configurations change the requirement. Closed-form and iterative solvers exist, and robust variants handle outliers. In deployed pose estimation, robustness to correspondence outliers is often at least as important as nominal inlier accuracy: a single mis-detected keypoint, if trusted, can drag the whole pose estimate far away, so deployed systems pair the solver with RANSAC-style hypothesis testing that fits the pose to random minimal subsets and keeps the one supported by the most correspondences.
  • 3D-to-3D (Procrustes / Kabsch): given two point clouds that are related by a rigid transform, the best-fit rotation and translation have a closed-form solution via SVD. This is the core step inside iterative closest point (ICP) registration and inside many odometry pipelines. ICP illustrates how a chicken-and-egg problem is handled by alternation: correspondences between the clouds are guessed from the current transform estimate, the optimal transform for those correspondences is computed in closed form, and the two steps repeat until convergence. Each half of the loop is easy precisely because the other half is temporarily fixed, an alternation also used by expectation-maximization. An EKF addresses coupling differently: it performs sequential prediction and a joint linearized Bayesian update rather than alternating between fixed optimization blocks.

The Kabsch solution is worth knowing because it recurs everywhere. Given matched source points PP and target points QQ, first compute their centroids and the centered versions P~=P−Pˉ\tilde{P} = P - \bar{P} and Q~=Q−Qˉ\tilde{Q} = Q - \bar{Q}:

  1. Compute the cross-covariance H=P~⊤Q~H = \tilde{P}^\top \tilde{Q}.
  2. Take the SVD H=UΣV⊤H = U \Sigma V^\top.
  3. The optimal rotation is R=VDU⊤R = V D U^\top, where DD corrects the sign if det⁡(VU⊤)<0\det(VU^\top) < 0 (reflections are not rigid transforms).
  4. The translation is t=Qˉ−RPˉ\mathbf{t} = \bar{Q} - R \bar{P}.

A few remarks on why this works and when it does not. Centering both clouds first removes the translation from the problem, leaving a pure rotation to be found; the cross-covariance HH summarizes how source directions map to target directions, and the SVD extracts the orthogonal matrix best aligning them. The determinant correction in step 3 is essential: without it, the least-squares optimum occasionally wants to be a reflection, which is geometrically illegal, and the correction projects back to the nearest legal rotation. Finally, the closed form is only as good as its correspondences. Kabsch assumes each source point is already matched to the correct target point; if the matching is wrong, the transform will be confidently wrong, which is why ICP wraps this solver in an outer loop that revises the matching.

Scene Flow

Scene Flow

Scene flow is a per-point 3D motion field expressed in one specified coordinate frame. In a world frame, for example, fiW=piW(t+1)−piW(t)\mathbf{f}_i^W = \mathbf{p}_i^W(t{+}1) - \mathbf{p}_i^W(t). Sensor observations instead arrive in the distinct time-indexed frames CtC_t and Ct+1C_{t+1}. Directly subtracting their numerical coordinate triples gives an apparent coordinate displacement, not a geometric vector in one frame. A geometric displacement requires transporting one endpoint into the other's frame first. Scene flow is the 3D generalization of optical flow and the bridge between per-frame geometry and temporal prediction.

Scene flow is where geometry meets dynamics, and it deserves careful decomposition. When a sensor moves through a static world, all apparent sensor-coordinate change is explained by one rigid transform: the egomotion. This is a remarkably strong structural prior, because a single SE(3)SE(3) transform accounts for the coordinate change of potentially millions of observed points at once. Camera and object rigid transforms compose rather than adding as independently defined displacements. A precise destination-frame residual transports the time-tt point from CtC_t into Ct+1C_{t+1} before subtraction:

fiobj,Ct+1:=piCt+1(t+1)−(TCt+1Ct p~iCt(t))1:3,\mathbf{f}^{\mathrm{obj},C_{t+1}}_i := \mathbf{p}^{C_{t+1}}_i(t{+}1) - \left(T_{C_{t+1}C_t}\,\tilde{\mathbf{p}}^{C_t}_i(t)\right)_{1:3},

where the homogeneous transform is applied before taking the three-vector difference. A static world point has zero residual under the correct egomotion transform; nonzero residual captures object motion or modeling error in Ct+1C_{t+1}.

A world model without an explicit separation must learn observer-induced change jointly with scene dynamics. It may misattribute that change or generalize poorly unless action or pose conditioning, model capacity, data, and observability make egomotion predictable. Explicitly decomposing a global rigid component from the residual is therefore a useful sanity check in dynamic-scene perception, not a prerequisite for every successful predictor. The residual contains the dynamics of walking people, articulating arms, and falling objects, along with any uncompensated estimation error. It is also, as you might expect from Part III's object-centric chapter, a strong cue for segmentation: points that share a residual motion tend to belong to the same object. Coherent residual motion can support segmentation before object classification, although it does not by itself establish object identity.

Let's estimate a rigid transform from a moving point cloud and look at what remains.

In[12]:
Code
## A point cloud observed at two times: the object rotates and translates,
## and the measurements carry sensor noise.
rng = np.random.default_rng(11)
P0 = rng.uniform(-0.3, 0.3, size=(100, 3))

theta = np.deg2rad(15.0)
R_true = np.array(
    [
        [np.cos(theta), -np.sin(theta), 0.0],
        [np.sin(theta), np.cos(theta), 0.0],
        [0.0, 0.0, 1.0],
    ]
)
t_true = np.array([0.12, 0.05, 0.0])
noise = 0.008

P1 = (R_true @ P0.T).T + t_true + rng.normal(0, noise, P0.shape)


def kabsch(P_src, P_dst):
    """Best-fit rigid transform mapping P_src onto P_dst."""
    c_src, c_dst = P_src.mean(axis=0), P_dst.mean(axis=0)
    H = (P_src - c_src).T @ (P_dst - c_dst)
    U, _, Vt = np.linalg.svd(H)
    D = np.diag([1.0, 1.0, np.sign(np.linalg.det(Vt.T @ U.T))])
    R = Vt.T @ D @ U.T
    t = c_dst - R @ c_src
    return R, t


R_est, t_est = kabsch(P0, P1)

flow_raw = P1 - P0
flow_res = P1 - ((R_est @ P0.T).T + t_est)
Out[13]:
Console
Rotation error: 0.0100
Translation error: 0.0006
Mean raw flow magnitude:     0.1389 m
Mean residual flow magnitude: 0.0127 m

Despite the sensor noise, the recovered transform matches the ground truth closely, and the residual flow magnitude is an order of magnitude below the raw flow. The averaging effect is what makes this work: with one hundred points, independent per-point noise partially cancels in the cross-covariance, so the estimated transform is much more accurate than any individual point correspondence. Rigid decomposition can provide this noise-averaging benefit when the matches are correct and well distributed and their errors are sufficiently independent and unbiased. More points do not cure correlated bias, outliers, or degenerate geometry.

Out[14]:
Visualization
Quiver plot of point motions mixing rotation and translation.
Raw per-point scene flow for a rigidly moving object seen by a static sensor. The flow is large and spatially structured, combining the object's rotation and translation with measurement noise.
Quiver plot of tiny residual vectors after rigid alignment.
The same flow after removing the best-fit rigid transform: only sensor noise remains, confirming the motion was rigid.

Read the two panels as a diagnostic. The raw flow is large and spatially structured: it encodes the object's rigid rotation and translation in the static sensor frame, plus measurement noise. The residual collapses to the noise floor, confirming that a single rigid transform explains everything. The diagnostic has a clear decision rule. If the residual collapses everywhere, the observed correspondences support a single shared rigid-motion hypothesis within the noise level. That does not prove there is one physical object, exclude unobserved structure, or guarantee that a single object-level state suffices. If some cluster of points keeps a large coherent residual, it is evidence for independently moving structure and a candidate motion segmentation. Calibration error, rolling shutter, nonrigid deformation, mismatches, or multiple objects sharing motion can produce similar patterns, so the candidate must be validated before it becomes an object or dynamics model. If the residual is large but incoherent, the matching or the rigidity assumption itself is failing, and no amount of averaging will fix it. Note also that this procedure silently used correspondences: we knew which point at time t+1t{+}1 was which point at time tt. In real data, establishing those correspondences is its own estimation problem, and matching errors show up as outlier residuals that robust estimation must reject.

Before leaving scene flow, it helps to see the egomotion coupling as a picture. The next cell uses a new static-world point cloud and asks what happens when the estimated camera motion is wrong by a small rotation: every point acquires a coherent residual.

In[15]:
Code
import numpy as np

## Sensor egomotion: a small true camera rotation between frames.
eps_rot = np.deg2rad(4.0)
R_ego = np.array(
    [
        [np.cos(eps_rot), -np.sin(eps_rot), 0.0],
        [np.sin(eps_rot), np.cos(eps_rot), 0.0],
        [0.0, 0.0, 1.0],
    ]
)
t_ego = np.array([0.05, -0.03, 0.0])

## Static-world points observed by the moving sensor. The noise floor is kept
## small (0.002 m) so the coherent residual produced by a 1-degree egomotion
## error stands out clearly above it.
rng_ego = np.random.default_rng(21)
static_pts = rng_ego.uniform(-0.8, 0.8, size=(100, 3))
static_next = (
    (R_ego @ static_pts.T).T
    + t_ego
    + rng_ego.normal(0, 0.002, static_pts.shape)
)

## Correct egomotion removal leaves only noise; a 1-degree error leaves a coherent residual.
R_fix, t_fix = kabsch(static_pts, static_next)
res_correct = static_next - ((R_fix @ static_pts.T).T + t_fix)
err_rot = np.deg2rad(1.0)
R_wrong = np.array(
    [
        [np.cos(err_rot), -np.sin(err_rot), 0.0],
        [np.sin(err_rot), np.cos(err_rot), 0.0],
        [0.0, 0.0, 1.0],
    ]
)
fitted_next = (R_fix @ static_pts.T).T + t_fix
wrong_next = (R_wrong @ fitted_next.T).T
res_wrong = static_next - wrong_next
mag_correct = np.linalg.norm(res_correct[:, :2], axis=1).mean()
mag_wrong = np.linalg.norm(res_wrong[:, :2], axis=1).mean()
Out[16]:
Visualization
One plot with two overlaid quiver-vector series comparing noise-floor residuals against the coherent false motion caused by a one-degree egomotion error.
Residual flow of static points after correct egomotion removal versus removal with a one-degree rotation error: the error creates coherent false motion.

A word of caution: this decomposition assumed the sensor was static. In practice the sensor moves, and egomotion must be estimated and removed first, which is exactly the localization problem of the next section. Errors in egomotion leak directly into false "object motion." If the estimated camera motion is wrong by a small rotation, every static point acquires a small coherent residual that can masquerade as a slow-moving object. This coupling is why dynamic-scene perception and localization are usually solved together or in an alternating scheme, and why any claim of "object detection by motion" should be checked against the quality of the egomotion estimate underneath it.