Paper Walkthroughs
3D Object DetectionPart 3

VoxelNet: a mini-PointNet inside every voxel

VoxelNet replaced hand-crafted bird's-eye-view features with a small PointNet run inside each voxel, followed by 3D convolutions and an RPN — the first end-to-end LiDAR detector and the template for every voxel-based method after it.

October 30, 2026 · 12 min read

Motivation & Main Contribution

  • The two roads so far:
    • PointNet learns features from raw points, but was shown on ~1k points; a LiDAR scan has ~100k.
    • MV3D scales to whole scenes by flattening LiDAR into images, but its BEV features are hand-crafted.
  • VoxelNet joins the roads: group points into voxels, run a mini-PointNet inside each voxel, then use convolutions and an RPN as in image detection.
  • Main contributions:
    • Voxel Feature Encoding (VFE) layer: point-wise features + a max-pooled voxel feature, concatenated and stacked.
    • A single-stage network trained end-to-end from raw points — no manual feature engineering, no camera.
    • An efficient implementation that processes only non-empty voxels.
  • Reading tip: VoxelNet’s three blocks — voxel encoder → middle layers → BEV RPN — are the skeleton of SECOND, PointPillars, PV-RCNN and Voxel R-CNN.

Challenges at the Time

  • Hand-crafted encodings (MV3D’s height/density/intensity maps, voxel statistics) are an information bottleneck: they fix in advance what the network may see.
A hand-crafted encoding on the real car of the scan, seen from the side and cut into 0.5 m bird’s-eye-view columns: only the maximum height (red bars) and the number of points per column survive. The second layout is constructed to keep the top point and the count of every column, so it gets exactly the same numbers. A feature computed from all points (here a per-column height profile, standing in for the learned VFE feature) tells the two apart.
  • PointNet does not scale as-is: applying it (and its feature-transform networks) to ~100k points costs too much compute and memory.
PointNet’s shared MLP keeps an n×1024n \times 1024 activation. For one object (n=1,024n = 1{,}024) that is 4 MB; for a whole scan (n≈100,000n \approx 100{,}000) it is 410 MB, and the multiply-adds grow from 0.44 G to 43 G (float32, classification network with both T-Nets, rough counts). One max over the whole scene then leaves a single vector for all five cars. The scan shown is the 66k-point PandaSet frame of the home page.
  • Highly variable density: near points are dense, far points sparse; a voxel may hold hundreds of points or none.
Points per voxel on the real scan, with VoxelNet’s voxels of 0.2 × 0.2 × 0.4 m (each 1 m cell shows its busiest voxel, log colour scale). The stored scan is subsampled, so absolute counts are lower than in a raw sweep; the fall-off with distance is what matters. VoxelNet answers it by sampling at most T = 35 points per voxel.
  • RPNs need dense tensors, but a LiDAR scan is a sparse, unordered set — more than 90% of voxels are empty.
The scan as an unordered list of points, then voxelized with VoxelNet’s voxel size over ±40 m and 4 m of height: about one voxel in a hundred holds a point (the window shows one horizontal slice of the grid, 0.6–1.0 m above the road). Convolutions need the full grid with zeros in the empty cells: over the whole volume about nine in ten positions of a 3 × 3 × 3 kernel see only zeros.

Naive Questions

  • Why not run PointNet on the whole scene? Too many points; and one global max pool would mix all objects together.
  • Why voxels, if PointNet’s whole point was avoiding grids? The grid is only used to group points; the feature inside each voxel is still learned from raw points.
  • What does one VFE layer add over a plain shared MLP? Each point sees the voxel’s max-pooled summary, so points within a voxel interact.
  • Why cap a voxel at TT points? To fix the tensor size, save compute and reduce the imbalance between dense and sparse voxels.
  • Why 3D convolutions before the RPN? To aggregate context across neighbouring voxels and collapse the height axis into channels.
  • Is it fast? No: 225 ms per frame, of which the 3D convolutions take 170 ms — the problem SECOND attacks next.

Key Idea

Key idea. Partition space into voxels, learn each voxel’s feature with a small PointNet (VFE), then treat the voxel grid like an image volume: 3D convolutions, then a 2D RPN in bird’s-eye view.

One VFE layer on a voxel with points p1,…,ptp_1, \dots, p_t:

fi=FCN⁡(p^i),f~=MAX⁡i=1..t fi,fiout=[ fi ; f~ ]∈R2mf_i = \operatorname{FCN}(\hat p_i), \qquad \tilde f = \operatorname{MAX}_{i=1..t}\, f_i, \qquad f_i^{out} = [\, f_i \,;\, \tilde f \,] \in \mathbb{R}^{2m}
  • p^i=[xi,yi,zi,ri,xi−vx,yi−vy,zi−vz]\hat p_i = [x_i, y_i, z_i, r_i, x_i - v_x, y_i - v_y, z_i - v_z]: the point, its reflectance rir_i, and its offset from the voxel centroid (vx,vy,vz)(v_x, v_y, v_z).
  • FCN⁡\operatorname{FCN}: Linear → BatchNorm → ReLU, shared by all points of all voxels (fi∈Rmf_i \in \mathbb{R}^m).
  • MAX⁡\operatorname{MAX}: element-wise max over the points of the voxel — PointNet’s symmetric function.

Dive into Model Architecture & Key Components

VoxelNet end to end (after Fig. 2 of the paper), stage by stage, with the tensor shapes of the car network: a learned voxel encoder, 3D convolutions that fold the height into channels, and a 2D region proposal network.

VoxelNet, layer by layer (car network)

Range [−3,1]×[−40,40]×[0,70.4][-3, 1] \times [-40, 40] \times [0, 70.4] m along Z,Y,XZ, Y, X; voxel size 0.4×0.2×0.20.4 \times 0.2 \times 0.2 m; T=35T = 35 points per voxel (Section 3.1):

StageOperation → purposeShape
Inputpoints inside the camera view → the raw measurement; KITTI labels objects only there~100k ×\times 4 (x,y,z,r)(x, y, z, r)
Grouping (voxelization)each point goes to the voxel that contains it, grid D′×H′×W′D' \times H' \times W' → give the unordered points a fixed spatial structure10×400×35210 \times 400 \times 352 cells
Sample (non-empty)keep only the KK non-empty voxels, ≤ TT random points each (zero-padded), add each point’s offset to the voxel centroid → a fixed-size tensor, empty space skipped, cost cappedK×35×7K \times 35 \times 7
VFE-1(7, 32)Linear 7→167 \to 16, max, concat → point features that also know the other points of their voxelK×35×32K \times 35 \times 32
VFE-2(32, 128)Linear 32→6432 \to 64, max, concat → the same, stacked: richer point featuresK×35×128K \times 35 \times 128
FCN + maxLinear to 128, max over points → one vector per voxel, independent of point orderK×128K \times 128
Scattereach voxel vector back at its grid position, sparse 4D tensor → neighbours in space become neighbours in the tensor128×10×400×352128 \times 10 \times 400 \times 352
Conv3D(128, 64)3×3×33 \times 3 \times 3, stride (2, 1, 1), padding (1, 1, 1) → mix neighbouring voxels in 3D; height 10 → 564×5×400×35264 \times 5 \times 400 \times 352
Conv3D(64, 64)3×3×33 \times 3 \times 3, stride (1, 1, 1), padding (0, 1, 1) → wider 3D context; height 5 → 364×3×400×35264 \times 3 \times 400 \times 352
Conv3D(64, 64)3×3×33 \times 3 \times 3, stride (2, 1, 1), padding (1, 1, 1) → wider 3D context; height 3 → 264×2×400×35264 \times 2 \times 400 \times 352
Reshapemerge height into channels → a 2D bird’s-eye-view map, so cheap 2D convolutions can take over128×400×352128 \times 400 \times 352
RPN (3 blocks + deconv, concat)see below → downsampled blocks see far, upsampling + concat keeps detail: objects of every size768×200×176768 \times 200 \times 176
Heads1×11 \times 1 convs → per location and anchor (2 orientations): car or not, and the 7 residuals that fit the anchor to the boxscores 2×200×1762 \times 200 \times 176
regression 14×200×17614 \times 200 \times 176

KK: number of non-empty voxels. Intermediate depths 5 and 3 follow from the stated strides and paddings.

The step from Grouping to Sample is where the irregular point cloud becomes a regular tensor: every non-empty voxel gets exactly TT slots, whatever its number of points.

From grouped voxels to the VFE input, on a real 6×56 \times 5 voxel patch of the scan (0.2 m voxels, one 0.4 m height layer, seen from the top). Only the K=12K = 12 non-empty voxels get a row. The busiest voxel (39 points) keeps a random 35; shorter rows are padded with zeros. Each kept point (x,y,z,r)(x, y, z, r) then gets its offset to the centroid of its voxel’s points (the × in the busiest voxel), giving 7 numbers. The stored scan has no reflectance, so rr is left symbolic.

Stacked VFE: PointNet, made local

  • VFE-i(cin,cout)i(c_{in}, c_{out}): the linear layer is cin×(cout/2)c_{in} \times (c_{out}/2); concatenation with the max-pooled vector doubles it back to coutc_{out}.
  • Stacking two VFE layers lets points in a voxel exchange information twice before the final max → the voxel feature.
  • Compared with PointNet (walkthrough 01): same shared MLP + max pool, but on ≤ 35 points of one voxel instead of a whole object, and with no T-Net.
One VFE layer inside a real voxel (0.2×0.2×0.40.2 \times 0.2 \times 0.4 m) on a parked car. The FCN is a tiny random one with m=4m = 4 instead of 16, but every number is computed from it; rr is shown as ”–” because this scan stores no reflectance. After the concatenation, every point carries both its own feature and the voxel’s summary.

Builds on. PointNet’s shared MLP + max pool (walkthrough 01), now applied to the points of one voxel.

Convolutional middle layers and the RPN

  • Each middle layer: Conv3D → BN → ReLU. Strides of 2 along ZZ shrink depth 10→210 \to 2; the result is reshaped into a BEV feature map — from here on it is image detection.
The three middle layers on one real side slice through a parked car (32 voxels along xx, 10 along zz; the kernel’s 3×33 \times 3 footprint in this slice is outlined). Each layer computes every cell, though most of them see only zeros — this is where 170 of the 225 ms go. The last two depth cells are folded into channels, giving a BEV map.
  • RPN (Fig. 4): three blocks, each starting with a stride-2 conv:
    • Block 1: Conv2D(128, 128, 3, 2, 1) + 3 × Conv2D(128, 128, 3, 1, 1)
    • Block 2: Conv2D(128, 128, 3, 2, 1) + 5 × Conv2D(128, 128, 3, 1, 1)
    • Block 3: Conv2D(128, 256, 3, 2, 1) + 5 × Conv2D(256, 256, 3, 1, 1)
    • Each block output is deconvolved to 256×H′/2×W′/2256 \times H'/2 \times W'/2 and concatenated → 768 channels.
  • Anchors (car): one size l=3.9l = 3.9, w=1.6w = 1.6, h=1.56h = 1.56 m at z=−1.0z = -1.0 m, rotations 0°0° and 90°90°. Positive if BEV IoU > 0.6 (or highest for a GT), negative below 0.45.
  • Pedestrian / cyclist: range [−3,1]×[−20,20]×[0,48][-3, 1] \times [-20, 20] \times [0, 48] m, T=45T = 45, first RPN stride 1 instead of 2, thresholds 0.5 / 0.35.

Builds on. MV3D’s bird’s-eye-view RPN with 0°/90°0°/90° anchors and the camera crop (walkthrough 02), and Faster R-CNN’s anchor-based RPN.

Loss: oriented boxes, regressed directly

  • Box (xc,yc,zc,l,w,h,θ)(x_c, y_c, z_c, l, w, h, \theta); residuals with da=(la)2+(wa)2d^a = \sqrt{(l^a)^2 + (w^a)^2}, the anchor’s base diagonal:
Δx=xcg−xcada,  Δy=ycg−ycada,  Δz=zcg−zcaha,  Δs=log⁡sgsa (s∈{l,w,h}),  Δθ=θg−θa\Delta x = \frac{x_c^g - x_c^a}{d^a},\; \Delta y = \frac{y_c^g - y_c^a}{d^a},\; \Delta z = \frac{z_c^g - z_c^a}{h^a},\; \Delta s = \log\frac{s^g}{s^a}\ (s \in \{l, w, h\}),\; \Delta\theta = \theta^g - \theta^a L=α1Npos∑iLcls(pipos,1)+β1Nneg∑jLcls(pjneg,0)+1Npos∑iLreg(ui,ui∗)L = \alpha \frac{1}{N_{pos}} \sum_i L_{cls}(p_i^{pos}, 1) + \beta \frac{1}{N_{neg}} \sum_j L_{cls}(p_j^{neg}, 0) + \frac{1}{N_{pos}} \sum_i L_{reg}(u_i, u_i^*)
  • LclsL_{cls}: binary cross-entropy; LregL_{reg}: Smooth ℓ1\ell_1; α=1.5\alpha = 1.5, β=1\beta = 1.
  • Δθ=θg−θa\Delta\theta = \theta^g - \theta^a penalises a box flipped by π\pi heavily even though it covers the same space — SECOND fixes this with a sine loss.

Efficient implementation: only non-empty voxels

  • One pass over the points with a hash table keyed by voxel coordinate fills a K×T×7K \times T \times 7 buffer and a voxel-coordinate buffer — O(n)O(n).
  • VFE runs densely on this buffer on the GPU; padded (empty) point slots are reset to zero after each concatenation.
  • The voxel features are then scattered back into a dense grid — so the 3D convolutions still visit every empty cell.
The voxel input pipeline on a real 2×1.22 \times 1.2 m patch next to the sensor (one 0.4 m height layer, 0.20.2 m voxels, T=35T = 35). Because the points are shuffled first, “keep the first 35” is a random sample. The encoder works only on the KK non-empty voxels, but the scatter puts them back into a dense grid.

Data augmentation

  • Per-box: rotate each GT box and its points by Δθ∼U[−π/10,π/10]\Delta\theta \sim U[-\pi/10, \pi/10], translate by N(0,1)\mathcal{N}(0, 1) per axis; undo on collision.
  • Global scaling U[0.95,1.05]U[0.95, 1.05] and global rotation U[−π/4,π/4]U[-\pi/4, \pi/4] around ZZ.
  • Needed because KITTI has fewer than 4,000 training clouds and VoxelNet trains from scratch.

Conclusion

Results

KITTI validation, 3D AP (Table 2 of the paper; train/val split of 3,712 / 3,769 frames; IoU 0.7 for car, 0.5 for pedestrian):

MethodDataCar Easy ↑Car Mod. ↑Car Hard ↑Ped. Easy ↑Ped. Mod. ↑Ped. Hard ↑
Mono3DMono2.532.312.31N/AN/AN/A
3DOPStereo6.555.074.10N/AN/AN/A
VeloFCNLiDAR15.2013.6615.98N/AN/AN/A
MV3D (BEV+FV)LiDAR71.1956.6055.30N/AN/AN/A
MV3D (BEV+FV+RGB)LiDAR + Mono71.2962.6856.56N/AN/AN/A
HC-baseline (MV3D-style features, same RPN)LiDAR71.7359.7555.6943.9540.1837.48
VoxelNetLiDAR81.9765.4662.8557.8653.4248.87
  • Validation, BEV AP (Table 1): car 89.60 / 84.81 / 78.57.
  • Test, car (Table 3): 3D AP 77.47 / 65.11 / 57.73, BEV AP 89.35 / 79.26 / 77.39.
  • Runtime: 225 ms per frame (voxel input 5 ms, feature net 20 ms, middle layers 170 ms, RPN 30 ms) on a Titan X.

Pros and Cons

  • Pros
    • First end-to-end detector from raw points: no hand-crafted features, no camera.
    • The voxel encoder (PointNet applied per voxel) beats hand-crafted BEV features, most of all for pedestrians and cyclists.
    • Its encoder → middle layers → BEV RPN skeleton became the template for SECOND, PointPillars, PV-RCNN and Voxel R-CNN.
  • Cons
    • Dense 3D convolutions over a grid that is more than 90% empty take 170 of 225 ms.
    • Direct angle regression (Δθ=θg−θa\Delta\theta = \theta^g - \theta^a) punishes a box flipped by π\pi heavily.
    • Few positive anchors per frame, and less than 4,000 training clouds.

Future Work

  • Skip the empty voxels in the middle layers (sparse convolution).
  • An angle loss that respects the π\pi symmetry of a box, and more positives per training frame.

Next

  • SECOND keeps VoxelNet’s pipeline but computes the 3D convolutions only where there are points, fixes the angle loss with a sine, and pastes extra objects into training scenes.