A camera gives a detector a crisp, coloured picture but no idea how far anything is; a LiDAR measures distance to the millimetre but paints only a sparse cloud of dots. Fuse the two and you get both — texture and geometry — which is why almost every self-driving stack carries both sensors. This article builds a working camera + LiDAR early-fusion 2D detector on KITTI[1], the classic autonomous-driving benchmark: it projects a single Velodyne scan onto a single colour camera, stacks the result into a 4-channel RGB-D image, and fine-tunes a pretrained detector on it. Then it measures exactly what the LiDAR channel buys you against a camera-only baseline.

Why KITTI, and why one camera + one LiDAR

There are richer fusion datasets, but for learning fusion KITTI is still the sweet spot. It is small, perfectly calibrated, ubiquitous in the literature, and it pairs a single 64-beam LiDAR with a forward colour camera — no radar, no six-camera rig to wrangle. Every requirement below maps straight onto it:

Requirement How KITTI meets it
Annotations 2D and 3D boxes, plus per-frame calibration — everything fusion needs.
Easy / fast to train ~7.5k labelled frames — a fine-tune, not a week-long job.
Strong community The most-published fusion benchmark, so baselines and tutorials abound.
Transfer learning Pretrained detectors drop straight in; we start from COCO weights.
Camera + LiDAR (not radar) A 64-beam Velodyne and colour cameras — and no radar to complicate things.
Single camera + single LiDAR One Velodyne and the left colour camera (cam 2) — the minimal fusion rig.

One honest caveat up front: KITTI is a registered ~12 GB download, so to keep this tutorial reproducible on any machine the runnable figures below are rendered from a compact, self-contained synthetic scene written in the exact KITTI on-disk layout by src/make_demo_scene.py. The same src/ code — calibration, projection, dataset, training, evaluation — runs byte-for-byte on the real dataset the moment you point KITTI_ROOT at it. The whole pipeline is drawn in Fig 1.

Block diagram: a left colour camera image and a single Velodyne cloud; the cloud is projected to the image with P2 times R0 times Tr to make a sparse depth map; camera and depth are concatenated into a 4-channel RGB-D tensor; a COCO-pretrained Faster R-CNN with MobileNetV3-FPN and a 3-to-4 channel stem is fine-tuned and emits 2D boxes; the fusion and RGB-only models are compared by mAP
Fig 1. The early-fusion pipeline: project the single LiDAR onto the single camera, concatenate a depth channel onto RGB, and fine-tune a COCO-pretrained detector on the 4-channel input — then compare it against the same detector trained on RGB alone.

Setup

python -m venv .venv
.venv\Scripts\activate                 # Windows
pip install -r requirements.txt        # torch, torchvision, numpy, matplotlib, pillow

# For an NVIDIA GPU, install the CUDA build of PyTorch instead:
pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121

python src/make_demo_scene.py          # writes a KITTI-format demo to data/synthetic

Line 1 creates the virtual environment; line 2 activates it on Windows; line 3 installs the CPU dependencies; lines 5–6 are the one-line CUDA-build alternative for an NVIDIA GPU; line 8 writes the self-contained demo dataset. Skip line 8 and set KITTI_ROOT instead to train on the real thing.

The stack is PyTorch[2] and torchvision[3] for the detector and its COCO[4]-pretrained weights, with NumPy[5] doing the projection maths and Matplotlib[6] plus Pillow[7] rendering the figures. Every script runs from the project root and uses CUDA when it is available, falling back to the CPU otherwise.

Projecting one LiDAR onto one camera

Fusion starts with geometry. KITTI ships three pieces of calibration per frame: the camera projection matrix P2, the rectifying rotation R0_rect, and the rigid transform Tr_velo_to_cam that moves a point from the LiDAR frame into the camera frame. Chain them and a Velodyne point x lands at pixel P2 · R0_rect · Tr_velo_to_cam · x.

@dataclass
class Kitti_Calibration:
    p2: np.ndarray               # (3, 4) left colour camera
    r0_rect: np.ndarray          # (4, 4) rectifying rotation
    tr_velo_to_cam: np.ndarray   # (4, 4) velodyne -> camera

    @property
    def velo_to_image(self):
        return self.p2 @ self.r0_rect @ self.tr_velo_to_cam


def project_velo_to_image(points_velo, calib):
    xyz = points_velo[:, :3]
    ones = np.ones((len(xyz), 1), dtype=np.float32)
    projected = np.concatenate([xyz, ones], axis=1) @ calib.velo_to_image.T
    depth = projected[:, 2]
    front = depth > 1e-3
    uv = projected[front, :2] / depth[front, None]
    return uv, depth[front]

Lines 1–9 hold the calibration: three matrices, and a velo_to_image property (lines 8–9) that pre-multiplies them into one 3×4 matrix. Lines 12–19 do the projection: line 13 takes the point coordinates, lines 14–15 make them homogeneous and apply the matrix, line 16 reads off the camera-frame depth, line 17 keeps only points in front of the camera, and line 18 does the perspective divide to get pixel coordinates. The function returns both the pixels uv and their depth — and that depth is the signal the camera cannot supply.

Painting that depth into an image-sized map gives the channel we will fuse. Empty pixels stay zero; where several points hit one pixel, the nearest wins so a car's surface is not erased by the ground behind it.

def build_sparse_depth_map(points_velo, calib, image_hw, max_depth_m=80.0):
    height, width = image_hw
    uv, depth = project_velo_to_image(points_velo, calib)
    u = np.round(uv[:, 0]).astype(int)
    v = np.round(uv[:, 1]).astype(int)
    inside = (u >= 0) & (u < width) & (v >= 0) & (v < height) & (depth < max_depth_m)
    u, v, depth = u[inside], v[inside], depth[inside]

    depth_map = np.zeros((height, width), dtype=np.float32)
    order = np.argsort(-depth)                 # far first, so nearer overwrites
    depth_map[v[order], u[order]] = depth[order]
    return np.clip(depth_map / max_depth_m, 0.0, 1.0)

Lines 4–5 round the projected pixels to integers; line 6 keeps only the ones inside the frame and closer than max_depth_m; line 9 starts an empty map; lines 10–11 write depths far-to-near so the closest return wins each pixel; and line 12 normalises to [0, 1]. The result is Fig 2: a sparse cloud of coloured depth landing exactly where the physical scene is.

A forward driving image with the projected Velodyne point cloud overlaid, each point coloured by depth from red (near) to blue (far); the points tile the road surface in scan-line rings and pile up densely on the cars, which stand nearer than the ground behind them
Fig 2. The single Velodyne scan projected onto the single camera image through P2 · R0_rect · Tr_velo_to_cam, coloured by depth. Near returns are red, far ones blue; the cars read as dense patches nearer than the road behind them.

Building the 4-channel fused input

Early fusion is the simplest way to combine the two sensors: just stack the depth map onto the RGB image as a fourth channel and hand the detector a 4×H×W tensor. The dataset does exactly that per frame — and setting in_channels=3 gives the RGB-only baseline from the identical code.

rgb = np.asarray(Image.open(image_path).convert("RGB"), np.float32) / 255.0
channels = [torch.from_numpy(rgb.transpose(2, 0, 1))]        # (3, H, W)

if self.in_channels == 4:
    calib = parse_calib_file(calib_path)
    cloud = load_velodyne_bin(velodyne_path)
    depth = build_sparse_depth_map(cloud, calib, (height, width),
                                   max_depth_m=self.config.max_depth_m,
                                   dilation=self.config.depth_dilation)
    channels.append(torch.from_numpy(depth)[None])           # (1, H, W)

image = torch.cat(channels, dim=0)                           # (4, H, W)

Line 1 loads the image as a normalised float array; line 2 turns it into a 3-channel tensor. Lines 4–10 run only for the fusion model: they read the calibration and the cloud (lines 5–6), build the depth map (lines 7–9), and append it as a single extra channel (line 10). Line 12 concatenates everything into the 4-channel image. The two channel groups of that tensor are shown in Fig 3 — and note the low-contrast car in the RGB panel that nearly vanishes into the road yet stands out cleanly in depth.

Two stacked panels of the same scene: the top is the RGB image (channels 1 to 3) with several cars, one of them grey and hard to separate from the road; the bottom is the projected LiDAR depth (channel 4) as a colour map where the cars appear as clear patches distinct from the ground gradient
Fig 3. The 4-channel fused input: RGB (channels 1–3) on top, the projected-LiDAR depth (channel 4) below. The grey car that blends into the road in RGB is unmistakable in depth — exactly the case fusion is meant to rescue.

Transfer learning: inflating the stem

We do not train from scratch. We start from a Faster R-CNN[8] with a MobileNetV3[9] backbone and an FPN[10] neck, already trained on COCO. There is one snag: a COCO model expects 3 input channels and we have 4. The fix is a standard trick — inflate the first convolution to accept the extra channel, copying the pretrained RGB filters and seeding the new depth filter with their mean so the network starts life behaving exactly like the RGB model.

def _inflate_stem(model, in_channels):
    old_conv = model.backbone.body["0"][0]            # first 3->16 conv
    new_conv = nn.Conv2d(in_channels, old_conv.out_channels,
                         kernel_size=old_conv.kernel_size, stride=old_conv.stride,
                         padding=old_conv.padding, bias=False)
    with torch.no_grad():
        new_conv.weight[:, :3] = old_conv.weight                        # keep RGB filters
        new_conv.weight[:, 3:] = old_conv.weight.mean(1, keepdim=True)  # seed the depth filter
    model.backbone.body["0"][0] = new_conv

in_features = model.roi_heads.box_predictor.cls_score.in_features
model.roi_heads.box_predictor = FastRCNNPredictor(in_features, num_classes)

Lines 2–5 build a fresh first convolution with in_channels inputs but the same shape otherwise; lines 7–8 are the heart of it — the first three input filters are copied verbatim from the pretrained weights, and the fourth is initialised to their channel mean, a neutral starting point for depth; line 9 swaps it into the backbone. Lines 11–12 replace the box predictor head so it outputs KITTI's classes (background + Car/Pedestrian/Cyclist) instead of COCO's 91. The detector's input normaliser is also widened to four channels so the depth channel is standardised alongside RGB. The RGB-only baseline runs this same builder with in_channels=3 and skips the inflation — so the two models differ by exactly one input channel.

Training

Because torchvision's detector returns its own loss dictionary in training mode, the loop is short: hand it images and targets, sum the losses it hands back, and step.

model = build_fusion_detector(num_classes=4, in_channels=in_channels).to(device)
params = [p for p in model.parameters() if p.requires_grad]
optimizer = torch.optim.SGD(params, lr=5e-3, momentum=0.9, weight_decay=5e-4)

model.train()
for epoch in range(1, num_epochs + 1):
    for images, targets in loader:
        images = [img.to(device) for img in images]
        targets = [{k: v.to(device) for k, v in t.items()} for t in targets]
        loss_dict = model(images, targets)         # rpn + box losses
        loss = sum(loss_dict.values())
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

Line 1 builds the fusion (or baseline) detector; lines 2–3 collect the trainable parameters and an SGD optimiser. Lines 6–7 loop over epochs and batches; lines 8–9 move the images and targets to the device; line 10 runs the model with targets, which puts it in training mode and returns the region-proposal and box losses; line 11 sums them; and lines 12–14 do the usual zero → backward → step. Training both models is two commands, then a third to score them:

python src/train.py                 # RGB-D fusion  -> checkpoints/fusion_detector.pt
python src/train.py --rgb-only      # RGB baseline  -> checkpoints/rgb_baseline.pt
python src/evaluate.py              # AP@0.5 and This email address is being protected from spambots. You need JavaScript enabled to view it. for both

Line 1 fine-tunes the 4-channel fusion model, line 2 the 3-channel baseline, and line 3 reports their Average Precision. On the demo scene (200 frames, a 160 / 40 train/val split) six epochs take a couple of minutes on a laptop GPU and the loss falls from about 0.78 to 0.53 for both.

Results: what the LiDAR channel buys

The two models are identical but for that one depth channel, so the gap between them is a clean measure of what fusion adds. Scored on the held-out validation frames:

Model AP@0.5 (Car) This email address is being protected from spambots. You need JavaScript enabled to view it. (Car)
RGB-only baseline 0.862 0.633
RGB-D fusion 0.904 0.773

Fusion wins at both thresholds, and the gap widens at the stricter This email address is being protected from spambots. You need JavaScript enabled to view it. (+0.14 vs +0.04 at AP@0.5), plotted in Fig 5. That is the tell-tale signature of depth: it does not just find more cars, it pins down where they are, which is precisely what a tighter IoU threshold rewards. The qualitative case is Fig 4, where the fusion model recovers a car the RGB-only baseline misses outright.

Two stacked panels of the same driving frame with white dashed ground-truth boxes. Top, the RGB-only baseline draws green boxes on two obvious cars but misses a third. Bottom, the RGB-D fusion model draws green boxes on all three, including the extra car the baseline missed, each labelled with a confidence score
Fig 4. Same frame, both models (green = prediction, dashed white = ground truth). The RGB-only baseline (top) misses a car; the RGB-D fusion model (bottom) catches it, because the LiDAR channel makes it obvious even when its colour barely differs from the road.
Grouped bar chart of Average Precision for the Car class. At AP at 0.5 the RGB-only bar is 0.86 and the RGB-D fusion bar is 0.90; at AP at 0.75 the RGB-only bar is 0.63 and the fusion bar is 0.77, a clearly larger gap
Fig 5. AP for the Car class at two IoU thresholds. Fusion leads at both and pulls further ahead at This email address is being protected from spambots. You need JavaScript enabled to view it., where accurate localisation matters most.

These are honest numbers from an easy synthetic benchmark, not a KITTI leaderboard entry — their job is to show the fusion mechanism paying off, end to end. On real KITTI the same code trains the same way; the absolute numbers change, the story does not. And this is only the gentlest form of fusion: stacking a depth channel is input-level fusion. Heavier schemes fuse LiDAR and camera features deeper in the network or lift everything into a shared bird's-eye-view space, and they win more — at the cost of the simplicity that makes this version trainable in minutes.

Key takeaways

  • Fusion begins with calibration: chain KITTI's P2 · R0_rect · Tr_velo_to_cam to drop a single LiDAR scan onto a single camera image.
  • Early fusion is just a channel stack: paint the projected depth into an image-sized map and concatenate it onto RGB to get a 4-channel input.
  • Transfer learning survives the extra channel — inflate the first conv, copy the pretrained RGB filters, and seed the depth filter with their mean.
  • The baseline is the same builder with in_channels=3, so the fusion-vs-RGB gap is a clean measurement of what LiDAR adds.
  • Depth helps most with localisation and hard cases: the win grows at tighter IoU and it rescues low-contrast objects a camera-only model walks past.

The complete, runnable code — the calibration and projection in src/lidar_projection.py, the fused dataset in src/kitti_dataset.py, the stem-inflated detector in src/fusion_model.py, and src/train.py / src/evaluate.py / src/infer.py — lives in the companion repository. Run src/make_demo_scene.py to reproduce every figure without a download, or set KITTI_ROOT to train on the real dataset. Full attribution for every dataset and library is in the repository's RESOURCES.md.

Resources

Every dataset and library the implementation builds on — numbered in order of first appearance. The figures in this article are original renders of a synthetic scene generated in code, not KITTI imagery, so no dataset licence restricts their reuse here. Click any bracketed marker such as [1] in the text to jump to its entry.

  • [1] KITTI — Geiger, Lenz & Urtasun, Are We Ready for Autonomous Driving? The KITTI Vision Benchmark Suite, CVPR 2012. Dataset released under CC BY-NC-SA 3.0 — non-commercial, research/education only; do not use KITTI or images derived from it on a commercial page. cvlibs.net/datasets/kitti
  • [2] PyTorch — Paszke et al., An Imperative Style, High-Performance Deep Learning Library, NeurIPS 2019 (BSD-3-Clause style). pytorch.org
  • [3] torchvision — models, weights & transforms (BSD-3-Clause). github.com/pytorch/vision
  • [4] COCO — Lin et al., Microsoft COCO: Common Objects in Context, ECCV 2014. Source of the pretrained detector weights; annotations under CC BY 4.0, images per Flickr terms. cocodataset.org
  • [5] NumPy — Harris et al., Array Programming with NumPy, Nature 2020 (BSD-3-Clause). numpy.org
  • [6] Matplotlib — J. D. Hunter, Matplotlib: A 2D Graphics Environment, CiSE 2007 (Matplotlib license, BSD-style). matplotlib.org
  • [7] Pillow (PIL fork) — image I/O (HPND / MIT-CMU license). python-pillow.org
  • [8] Faster R-CNN — Ren et al., Towards Real-Time Object Detection with Region Proposal Networks, NeurIPS 2015. arXiv:1506.01497
  • [9] MobileNetV3 — Howard et al., Searching for MobileNetV3, ICCV 2019. arXiv:1905.02244
  • [10] FPN — Lin et al., Feature Pyramid Networks for Object Detection, CVPR 2017. arXiv:1612.03144