BotManifold
Tutorial

Camera geometry and object queries

Coordinate frames, intrinsics and extrinsics, ego-motion compensation, and how DETR-style queries and Hungarian matching turn a set of objects into a loss.

Why this matters

The Sensor encoders module says cameras become tokens and then a BEV grid. Two pieces of machinery sit underneath and are assumed everywhere in the driving literature. First, geometry: which pixel does a 3D point land on, and where is a point seen a moment ago now? Second, set prediction: a scene has an unknown number of objects with no natural order, so how do you write a loss for "predict all the cars"? This module covers both with code you can run.

1. Frames

A driving stack uses several coordinate frames, and most bugs are a point in the wrong one.

  • World (or map) frame: fixed to the ground.
  • Vehicle (ego) frame: attached to the car. In this module, x forward, y left, z up.
  • Camera frame: attached to a camera. x right, y down, z forward, so z is the depth.
  • Image (pixel) frame: 2D coordinates on the sensor, origin at the top left.

Moving a point between frames is a rigid transform: a rotation RR and a translation tt, written as one 4×4 matrix. The extrinsics are where each camera sits and points relative to the vehicle (calibrated once per car). The intrinsics describe the camera itself.

2. The pinhole camera

For a point (X,Y,Z)(X, Y, Z) in the camera frame, with focal lengths fx,fyf_x, f_y (in pixels) and principal point (cx,cy)(c_x, c_y):

u=fx XZ+cx,v=fy YZ+cyu = f_x\,\frac{X}{Z} + c_x,\qquad v = f_y\,\frac{Y}{Z} + c_y

The division by depth ZZ is why far objects are small and parallel lines converge. The reference code takes points in the vehicle frame, applies the inverse of the camera's pose to move them into the camera frame, and projects:

libs/tutorial/geometry.py (projection)
def project_points(
    points_ego: torch.Tensor,
    intrinsics: torch.Tensor,
    camera_to_ego: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor]:
    """Projects 3D points from the vehicle frame into a pinhole camera.

    The vehicle frame has x forward, y left, z up. The camera frame has x right, y
    down, z forward (depth). ``camera_to_ego`` is the camera's pose in the vehicle
    frame (the extrinsics); its inverse maps vehicle points into the camera.

    Args:
        points_ego: Shape (n, 3).
        intrinsics: The 3x3 matrix [[fx, 0, cx], [0, fy, cy], [0, 0, 1]].
        camera_to_ego: A 4x4 rigid transform.

    Returns:
        Pixel coordinates (n, 2) and depths (n,). A point with depth <= 0 is behind
        the camera and gets NaN pixels.
    """
    ego_to_camera = torch.linalg.inv(camera_to_ego)
    homogeneous = torch.cat([points_ego, torch.ones(len(points_ego), 1)], dim=1)
    camera = (homogeneous @ ego_to_camera.T)[:, :3]
    depth = camera[:, 2]
    pixels = camera @ intrinsics.T
    ahead = depth > 0
    safe_depth = torch.where(ahead, depth, torch.ones_like(depth))
    uv = torch.where(ahead[:, None], pixels[:, :2] / safe_depth[:, None], torch.nan)
    return uv, depth
Left: a top-down view with a camera at the origin looking right and four orange points inside its view cone. Right: the same four points in a 640 by 480 image, with the far points close to the horizon line.
Four ground points as seen from above and in the image of a camera 1.5 m up (500-pixel focal length). Points on flat ground never rise above the horizon line, and the farther they are the closer they get to it.

The tests check the properties that matter: a point straight ahead at camera height lands on the principal point; left in the vehicle frame is left in the image; doubling the distance halves the offset from the image centre; a point behind the camera gets a negative depth and no pixel.

Why this matters for learning. Going from a 3D point to a pixel is easy. Going from a pixel to a 3D point is not: a pixel is a ray, and every depth along it projects to the same place. This is why camera-to-BEV methods must estimate depth, or let attention figure out where to look. BEVFormer (2203.17270) takes the second route: grid-shaped BEV queries each pull features from the camera regions that their 3D location projects to, where the regions are the ones a query's 3D location projects to, the operation above (we have not re-checked that detail against the paper's method section), and earlier BEV frames are fused in to give temporal context.

3. Ego-motion: where is that object now?

A model that uses frames from the past has to put them in the same frame. A static sign seen 0.5 s ago is not at the same vehicle-relative position now, because the car has moved. Going through the world frame, with TthenT_{\text{then}} and TnowT_{\text{now}} the vehicle's poses in the world:

pnow=Tnow−1 Tthen pthenp_{\text{now}} = T_{\text{now}}^{-1}\,T_{\text{then}}\,p_{\text{then}}
libs/tutorial/geometry.py (ego_motion)
def pose_matrix(x: float, y: float, heading: float) -> torch.Tensor:
    """A 3x3 planar pose: where the vehicle is in a world frame."""
    c, s = math.cos(heading), math.sin(heading)
    return torch.tensor([[c, -s, x], [s, c, y], [0.0, 0.0, 1.0]])


def to_current_frame(
    points_then: torch.Tensor, pose_then: torch.Tensor, pose_now: torch.Tensor
) -> torch.Tensor:
    """Re-expresses (n, 2) points seen in the vehicle frame at time then, at time now.

    Static things do not move in the world, but the vehicle does, so their position
    relative to the vehicle changes. Going via the world frame:
    now_frame = pose_now^-1 @ pose_then @ points_then.

    Args:
        points_then: Shape (n, 2) in the vehicle frame at the earlier time.
        pose_then: 3x3 vehicle pose in the world at the earlier time.
        pose_now: 3x3 vehicle pose in the world now.
    """
    homogeneous = torch.cat([points_then, torch.ones(len(points_then), 1)], dim=1)
    moved = homogeneous @ (torch.linalg.inv(pose_now) @ pose_then).T
    return moved[:, :2]
Two panels showing the same three orange points: in the vehicle frame at time then, and in the vehicle frame now after the car moved forward 5 metres and turned left.
Three static objects before and after the car drives 5 m and turns left by 0.3 rad. Their positions in the vehicle frame change; their positions in the world do not.

The tests: driving 4 m forward brings a point 10 m ahead to 6 m, a 90° left turn moves a point ahead to the right, and no motion changes nothing. Moving objects need more (their own motion), which is why temporal models use both ego-motion and a motion model. The same re-framing applies to a plan that arrives late (the Deploying a driving VLA module).

4. Predicting a set of objects

A detector must output "all the cars", but the number varies and there is no natural order. A network with a fixed number of output slots can emit them in any order, so which output should be compared with which ground-truth object? Comparing slot ii with object ii punishes a perfect answer that lists the objects in a different order.

DETR (2005.12872) treats detection as direct set prediction. It uses a small, fixed number of learned object queries, vectors that attend to the image and each produce one prediction, and a loss computed over the whole set, where bipartite matching forces unique predictions. The matching finds the one-to-one assignment of predictions to ground-truth objects with the lowest total cost, and the loss is then computed on the matched pairs.

libs/tutorial/geometry.py (matching)
def hungarian_match(cost: torch.Tensor) -> list[tuple[int, int]]:
    """Optimal one-to-one assignment of rows to columns minimising total cost.

    Dynamic programming over subsets of columns, exact and fine for the handful of
    objects in a teaching example (it is exponential in the number of columns; real
    detectors use the O(n^3) Hungarian algorithm).

    Args:
        cost: Shape (rows, cols) with rows <= cols.

    Returns:
        (row, column) pairs, one per row.

    Raises:
        ValueError: If there are more rows than columns or more than 16 columns.
    """
    rows, cols = cost.shape
    if rows > cols:
        raise ValueError(f"need rows <= cols, got {rows} x {cols}")
    if cols > 16:
        raise ValueError(f"at most 16 columns supported, got {cols}")
    best: dict[int, tuple[float, list[int]]] = {0: (0.0, [])}
    for row in range(rows):
        step: dict[int, tuple[float, list[int]]] = {}
        for used, (total, chosen) in best.items():
            for col in range(cols):
                if used >> col & 1:
                    continue
                key = used | (1 << col)
                candidate = total + cost[row, col].item()
                if key not in step or candidate < step[key][0]:
                    step[key] = (candidate, [*chosen, col])
        best = step
    _, columns = min(best.values(), key=lambda item: item[0])
    return list(enumerate(columns))


def set_loss(predicted: torch.Tensor, target: torch.Tensor) -> tuple[float, float]:
    """Loss between predicted and target object positions, with and without matching.

    Args:
        predicted: Shape (q, 2): q query outputs, one position each.
        target: Shape (m, 2) with m <= q ground-truth objects.

    Returns:
        The mean distance after optimal matching (unmatched predictions are ignored
        here; a real detector also trains them to say "no object"), and the mean
        distance when targets are paired with predictions in index order.
    """
    cost = torch.cdist(target, predicted)
    matched = hungarian_match(cost)
    matched_loss = sum(cost[r, c].item() for r, c in matched) / len(matched)
    ordered_loss = cost.diagonal().mean().item()
    return matched_loss, ordered_loss

The reference code finds that assignment exactly with a small dynamic program (real detectors use the Hungarian algorithm, which is polynomial; ours is exponential in the number of columns and capped at 16). The tests compare it with brute force on random costs, with SciPy when available, and with a case where a greedy choice blocks a better pairing:

Predictions in the targets' orderMatched lossIndex-order loss
Same objects, shuffled0.0above 5 m

A perfect answer given in a different order has zero loss after matching and a large loss without it. Unmatched queries are trained to predict "no object", which is how the model learns how many objects there are.

Where queries show up in driving. The same idea appears wherever the output is an unordered set: detected agents, map elements, and, in Alpamayo-R1's vision encoder, a fixed set of query vectors that compress many cameras and timesteps into a few tokens (the Deploying a driving VLA module).

Reference implementation (PyTorch)

libs/tutorial/geometry.py; tests are in libs/tutorial/tests/test_geometry.py. Run uv run --group tutorial python -m libs.tutorial.geometry.

Check yourself

  1. Which two matrices does projecting a vehicle-frame point to a pixel use, and what does each describe?
  2. Why can a pixel not be turned back into a 3D point without more information?
  3. A static object was 10 m ahead 0.5 s ago and the car has driven 4 m straight. Where is it now?
  4. Why does a set loss need matching, and what trains the unmatched queries?
Answers
  1. The extrinsics, a rigid transform (rotation and translation) from the vehicle frame to the camera frame, describe where the camera is mounted and where it points. The intrinsics (focal lengths and principal point) describe how the camera maps a point in its own frame to a pixel.
  2. Projection divides by depth, so every point along the same ray lands on the same pixel. Recovering the 3D point needs extra information: lidar, a second camera, a predicted depth, or an assumption such as a flat ground plane.
  3. 6 m ahead. In the car's current frame, a static object moves backwards by the car's own displacement: 10 m − 4 m.
  4. The predictions and the ground-truth objects are unordered sets, so the loss must first decide which prediction answers for which object. Hungarian matching finds the lowest-cost one-to-one assignment, and the loss is computed on matched pairs. Unmatched queries are trained to predict "no object", which is how the model learns how many objects there are.

Quiz

Answer each question; feedback appears as soon as you choose.

Question 1 of 5

What do the extrinsics of a camera describe?

Question 2 of 5

Why do points farther away land closer to the image centre line for the same sideways offset?

Question 3 of 5

A static object is 10 m ahead. The car drives 4 m straight. Where is it in the vehicle frame now?

Question 4 of 5

Why is a ground-truth object compared with the output of the matched query, not with output slot i?

Question 5 of 5

What can a single pixel not give you without extra information?

0 of 5 answered

Sources

On this page