Zero2FSD portal
Module 04Reading notes
2. Vision-to-BEV•Module 04

3D Occupancy & Temporal Memory

A 3D grid of what space is occupied, updated over time

Reading notes on this site; run the Colab notebook and use the full reference code in the repo.

This page shows the notebook with saved outputs. Open it in Colab to run every cell yourself.

Tutorial

Read the explanation, work the examples, then run the module notebook or code.

A bird's-eye grid is a floor plan. It can say that something sits on this square of ground. It cannot say whether that something is a low bumper or a branch hanging over empty air.

An occupancy grid cuts the space around the car into small cubes, called voxels, and marks each cube occupied or free. Later frames can keep the mark when the camera blinks — memory — so a pillar does not erase a car for one timestep.

Each section explains one idea, then runs code. Predict first, then execute the cell.

In [1]
import os
import subprocess
import sys
from pathlib import Path

import matplotlib
import matplotlib.pyplot as plt
import numpy as np
import torch

def keep_inline():
    matplotlib.use("module://matplotlib_inline.backend_inline", force=True)
    ip = None
    try:
        from IPython import get_ipython
        ip = get_ipython()
    except Exception:
        ip = None
    if ip is not None:
        ip.run_line_magic("matplotlib", "inline")

%matplotlib inline

SEED = 0
np.random.seed(SEED)
torch.manual_seed(SEED)

def find_repo(start: Path) -> Path:
    for p in [start, start.parent]:
        if (p / "modules" / "04_occupancy_network").is_dir():
            return p.resolve()
    return start.resolve()

REPO = find_repo(Path.cwd())
if not (REPO / "modules" / "04_occupancy_network").is_dir():
    dest = Path.cwd() / "self-driving-ai-course"
    if not (dest / "modules" / "04_occupancy_network").is_dir():
        subprocess.run(
            ["git", "clone", "--depth", "1", "-q",
             "https://github.com/vvknyn/self-driving-ai-course.git", str(dest)],
            check=True,
        )
    REPO = dest.resolve()

os.chdir(REPO)

try:
    import torch as _t
except ImportError:
    subprocess.run(
        [sys.executable, "-m", "pip", "install", "-q", "torch",
         "--index-url", "https://download.pytorch.org/whl/cpu"],
        check=True,
    )
try:
    import matplotlib as _m
    import numpy as _n
except ImportError:
    subprocess.run([sys.executable, "-m", "pip", "install", "-q", "numpy", "matplotlib"], check=True)
try:
    import pytest  # noqa: F401
except ImportError:
    subprocess.run([sys.executable, "-m", "pip", "install", "-q", "pytest"], check=True)

sys.path.insert(0, str(REPO / "modules" / "04_occupancy_network"))
from temporal_fusion import ConvGRUCell, OccupancyNetwork
from voxel_grid import VoxelGridConfig, generate_synthetic_3d_occupancy

print("Repo:", REPO)
print("module files:", sorted(p.name for p in (REPO / "modules" / "04_occupancy_network").glob("*.py")))
keep_inline()
Output
Repo: /tmp/course
module files: ['__init__.py', 'break_it_fix_it.py', 'run_occupancy.py', 'temporal_fusion.py', 'voxel_grid.py']

Picture a tiny room made of cubes: 4 along the road, 4 across, and 4 up. We mark a 2×2×2 block on the bottom two layers, like a car, and one extra cell two layers above a corner of that block, like a branch over empty air.

A 3D bounding box wraps every occupied cell. The brick includes the air inside it. Occupancy marks only the filled cubes.

A pillar hides the car for one frame. That frame alone would mark the car cell empty. Memory means keep the mark when the camera blinks: without memory the car cell goes visible, gone, visible; with memory it stays marked through the blink.

Predict: how many cells are in a 4×4×4 cube? How many are occupied? On three frames with a blind middle frame, what three numbers does the car cell print with no memory, and what three with memory?

In [2]
side = 4
cube = np.zeros((side, side, side), dtype=int)
for x in (1, 2):
    for y in (1, 2):
        for z in (0, 1):
            cube[x, y, z] = 1
cube[2, 2, 3] = 1

print("side:", side)
print("cells:", side * side * side)
print("occupied:", int(cube.sum()))
for x, y, z in np.argwhere(cube == 1):
    print(f"occupied ({int(x)}, {int(y)}, {int(z)})")

fig, axes = plt.subplots(1, side, figsize=(2.2 * side, 2.4))
for z in range(side):
    layer = cube[:, :, z].T
    axes[z].imshow(layer, origin="lower", cmap="Blues", vmin=0, vmax=1)
    axes[z].set_title(f"z={z}")
    axes[z].set_xticks(range(side))
    axes[z].set_yticks(range(side))
    for iy in range(side):
        for ix in range(side):
            val = cube[ix, iy, z]
            axes[z].text(ix, iy, "#" if val else ".", ha="center", va="center", color="white" if val else "0.4")
plt.suptitle("horizontal slices (x across, y up)")
plt.tight_layout()
plt.show()

colors = np.zeros(cube.shape + (4,))
z_index = np.arange(side)[None, None, :]
colors[(cube == 1) & (z_index <= 1)] = (0.30, 0.47, 0.66, 0.95)
colors[(cube == 1) & (z_index >= 3)] = (0.96, 0.52, 0.09, 0.95)
fig = plt.figure(figsize=(4.5, 4))
ax = fig.add_subplot(111, projection="3d")
ax.voxels(cube.astype(bool), facecolors=colors, edgecolor="0.25")
ax.set_xlabel("x")
ax.set_ylabel("y")
ax.set_zlabel("z")
ax.set_title("blue = low block, orange = high cell")
ax.view_init(elev=22, azim=-58)
plt.tight_layout()
plt.show()
print("drawn low cells: blue")
print("drawn high cell: orange")

xs, ys, zs = np.where(cube == 1)
x0, x1 = int(xs.min()), int(xs.max())
y0, y1 = int(ys.min()), int(ys.max())
z0, z1 = int(zs.min()), int(zs.max())
box_cells = (x1 - x0 + 1) * (y1 - y0 + 1) * (z1 - z0 + 1)
occupied = int(cube.sum())
print("occupied min corner:", (x0, y0, z0))
print("occupied max corner:", (x1, y1, z1))
print("box cells:", box_cells)
print("occupied cells:", occupied)
print("empty cells inside the box:", box_cells - occupied)

car = (2, 1, 1)
visible = [True, False, True]
print("car cell:", car)
no_memory = []
for t, seen in enumerate(visible):
    grid = np.zeros((4, 4, 4), dtype=int)
    if seen:
        grid[car] = 1
    no_memory.append(int(grid[car]))
    state = "visible" if seen else "behind pillar"
    print(f"frame {t} {state} car cell {no_memory[-1]}")

with_memory = []
mark = 0
for t, seen in enumerate(visible):
    if seen:
        mark = 1
    with_memory.append(mark)
    state = "visible" if seen else "behind pillar"
    print(f"frame {t} {state} kept car cell {with_memory[-1]}")

fig, axes = plt.subplots(1, 2, figsize=(7.5, 2.8))
titles = ["frame only (1, 0, 1)", "keep the mark (1, 1, 1)"]
series = [no_memory, with_memory]
colors_bar = ["#2f4f7a", "#c0392b", "#2f4f7a"]
for ax, vals, title in zip(axes, series, titles):
    ax.bar([0, 1, 2], vals, color=colors_bar)
    ax.set_ylim(0, 1.15)
    ax.set_xticks([0, 1, 2])
    ax.set_xticklabels(["frame 0", "frame 1", "frame 2"])
    ax.set_ylabel("car cell")
    ax.set_title(title)
    for xi, v in zip([0, 1, 2], vals):
        ax.text(xi, v + 0.04, str(v), ha="center")
plt.suptitle("car cell at (2, 1, 1) — pillar on frame 1")
plt.tight_layout()
plt.show()
print("without memory:", no_memory)
print("with memory:", with_memory)
Output
side: 4
cells: 64
occupied: 9
occupied (1, 1, 0)
occupied (1, 1, 1)
occupied (1, 2, 0)
occupied (1, 2, 1)
occupied (2, 1, 0)
occupied (2, 1, 1)
occupied (2, 2, 0)
occupied (2, 2, 1)
occupied (2, 2, 3)
Notebook output
Notebook output
Output
drawn low cells: blue
drawn high cell: orange
occupied min corner: (1, 1, 0)
occupied max corner: (2, 2, 3)
box cells: 16
occupied cells: 9
empty cells inside the box: 7
car cell: (2, 1, 1)
frame 0 visible car cell 1
frame 1 behind pillar car cell 0
frame 2 visible car cell 1
frame 0 visible kept car cell 1
frame 1 behind pillar kept car cell 1
frame 2 visible kept car cell 1
Notebook output
Output
without memory: [1, 0, 1]
with memory: [1, 1, 1]

The room has 64 cells and 9 occupied. The slice row at z=2 is all dots: empty air between the blue block and the orange cell at z=3. A floor plan would squash orange onto the same ground square as blue and could not show that gap.

The occupied corners run from (1, 1, 0) to (2, 2, 3). One box around them is 2×2×4, so box cells: 16 while only occupied cells: 9. The other empty cells inside the box: 7 are air the voxels can leave free.

The car sits at (2, 1, 1). Frame-only marks read 1, 0, 1 — visible, gone, visible. Keeping the mark reads 1, 1, 1. The bars use those same integers. For a real stack, a blank middle frame is the difference between trusting only what you see now and trusting what you saw a moment ago.

2. Driving-scale voxels

VoxelGridConfig in voxel_grid.py is the same cube idea in meters. An index is

ix = int((x - x_min) / voxel_size)

and the same for y and z.

Predict: default cell size 0.5 m, x from 0 to 32 m, y from -12 to 12 m, z from -1 to 3 m — how many cells along each axis? On the test grid (1 m cells, x 0–16, y -8–8, z -1–3), where does (5, 0, 1) m land?

In [3]
cfg = VoxelGridConfig()
print("x meters:", cfg.x_min, cfg.x_max)
print("y meters:", cfg.y_min, cfg.y_max)
print("z meters:", cfg.z_min, cfg.z_max)
print("voxel size meters:", cfg.voxel_size)
print("nx, ny, nz:", cfg.nx, cfg.ny, cfg.nz)
print("total voxels:", cfg.nx * cfg.ny * cfg.nz)

test_cfg = VoxelGridConfig(
    x_range=(0.0, 16.0),
    y_range=(-8.0, 8.0),
    z_range=(-1.0, 3.0),
    voxel_size=1.0,
)
print("test nx, ny, nz:", test_cfg.nx, test_cfg.ny, test_cfg.nz)
print("index of (5, 0, 1):", test_cfg.point_to_voxel_index(5.0, 0.0, 1.0))

scene = generate_synthetic_3d_occupancy(cfg, lead_x=16.0, lead_y=0.0, lead_vx=2.0, include_barrier=True)
occ = scene["occupancy"]
vel = scene["velocity"]
lead = vel[0, 0] != 0
static = (occ[0, 0] == 1) & (vel[0, 0] == 0)
overhang_z0 = cfg.z_min + 6 * cfg.voxel_size
overhang = int(occ[0, 0, :, :, 6:].sum().item())
static_count = int(static.sum().item())
print("occupancy shape:", tuple(occ.shape))
print("velocity shape:", tuple(vel.shape))
print("occupancy values:", [float(v) for v in torch.unique(occ).tolist()])
print("occupied cells:", int(occ.sum().item()))
print("lead cells:", int(lead.sum().item()))
print("lead vx:", f"{vel[0, 0][lead].unique().item():.1f}")
print("static occupied cells:", static_count)
print("overhang z start meters:", f"{overhang_z0:.1f}")
print("overhang cells:", overhang)
print("barrier cells:", static_count - overhang)
Output
x meters: 0.0 32.0
y meters: -12.0 12.0
z meters: -1.0 3.0
voxel size meters: 0.5
nx, ny, nz: 64 48 8
total voxels: 24576
test nx, ny, nz: 16 16 4
index of (5, 0, 1): (5, 8, 2)
occupancy shape: (1, 1, 64, 48, 8)
velocity shape: (1, 3, 64, 48, 8)
occupancy values: [0.0, 1.0]
occupied cells: 256
lead cells: 96
lead vx: 2.0
static occupied cells: 160
overhang z start meters: 2.0
overhang cells: 40
barrier cells: 120

Default extents are x 0.0 to 32.0, y -12.0 to 12.0, z -1.0 to 3.0, with voxel size meters: 0.5. Counts are nx, ny, nz: 64 48 8, and total voxels: 24576.

The test grid is test nx, ny, nz: 16 16 4. The point (5, 0, 1) m is index of (5, 0, 1): (5, 8, 2).

The synthetic scene is binary: occupancy values: [0.0, 1.0]. Shapes are occupancy shape: (1, 1, 64, 48, 8) and velocity shape: (1, 3, 64, 48, 8). occupied cells: 256. lead cells: 96 store lead vx: 2.0. static occupied cells: 160. The overhang starts at overhang z start meters: 2.0 with overhang cells: 40; the rest of the static volume is the barrier at barrier cells: 120. Those 40 high cells are the orange-cell idea at road scale.

The repo's ConvGRUCell blends the previous hidden map with a new candidate. One cell updates as

h_next = (1 - update_gate) * h_prev + update_gate * candidate

update_gate near 0 means trust memory. Near 1 means trust the new frame.

Predict: with h_prev = 0.8, candidate = 0.2, update_gate = 0.3, is h_next closer to 0.8 or 0.2? On a blank frame with the gate bias pushed to -8, does a hidden value of 1 at the car cell stay near 1 if you pass the previous map in?

In [4]
h_prev = 0.8
candidate = 0.2
update_gate = 0.3
h_next = (1.0 - update_gate) * h_prev + update_gate * candidate
print("h_prev:", h_prev)
print("candidate:", candidate)
print("update gate:", update_gate)
print("memory share:", f"{(1.0 - update_gate) * h_prev:.2f}")
print("new share:", f"{update_gate * candidate:.2f}")
print("h_next:", f"{h_next:.2f}")

torch.manual_seed(SEED)
cell = ConvGRUCell(in_channels=1, hidden_channels=1)
with torch.no_grad():
    cell.conv_gates.weight.zero_()
    cell.conv_gates.bias.zero_()
    cell.conv_gates.bias[1] = -8.0
    cell.conv_candidate.weight.zero_()
    cell.conv_candidate.bias.zero_()
print("update-gate bias:", f"{float(cell.conv_gates.bias[1].detach()):.1f}")

h_prev_map = torch.zeros(1, 1, 4, 4)
h_prev_map[0, 0, 2, 1] = 1.0
blank = torch.zeros(1, 1, 4, 4)
with torch.no_grad():
    forgotten = cell(blank, None)
    kept = cell(blank, h_prev_map)
    combined = torch.cat([blank, h_prev_map], dim=1)
    gates = torch.sigmoid(cell.conv_gates(combined))
    reset_gate, update_gate_map = torch.chunk(gates, 2, dim=1)
    candidate_state = torch.tanh(
        cell.conv_candidate(torch.cat([blank, reset_gate * h_prev_map], dim=1))
    )
print("update gate at car:", f"{update_gate_map[0, 0, 2, 1].item():.6f}")
print("candidate at car:", f"{candidate_state[0, 0, 2, 1].item():.6f}")
print("one minus update gate:", f"{(1.0 - update_gate_map[0, 0, 2, 1]).item():.6f}")
print("hidden if h_prev is None:", f"{forgotten[0, 0, 2, 1].item():.6f}")
print("hidden if h_prev is carried:", f"{kept[0, 0, 2, 1].item():.6f}")
Output
h_prev: 0.8
candidate: 0.2
update gate: 0.3
memory share: 0.56
new share: 0.06
h_next: 0.62
update-gate bias: -8.0
Output
update gate at car: 0.000335
candidate at car: 0.000000
one minus update gate: 0.999665
hidden if h_prev is None: 0.000000
hidden if h_prev is carried: 0.999665

The scalar blend gives h_next: 0.62 from memory share: 0.56 and new share: 0.06 — closer to memory 0.8 than to candidate 0.2.

With update-gate bias: -8.0, a blank frame opens the gate only a little: update gate at car: 0.000335. hidden if h_prev is None: 0.000000 is the section-1 pillar frame with no map to copy from. hidden if h_prev is carried: 0.999665 matches one minus update gate: 0.999665: the mark at the car cell barely moves.

OccupancyNetwork.forward expects the new features and this hidden map on the same grid. Nothing in temporal_fusion.py moves the memory when the car turns. If the vehicle yaws and you do not re-align the map first, the mark stays in the old cell.

4. Motion in the cube

The generator stores velocity on lead cells. The network's velocity head predicts one (vx, vy, vz) per ground column and copies it onto every height in that column.

Predict: which occupied cells have vx of 2 in the synthetic scene? On a blank feature map, will an untrained head's occupancy probabilities sit near 0 and 1, or near one half?

In [5]
torch.manual_seed(SEED)
net = OccupancyNetwork(in_channels=4, hidden_channels=8, nz=test_cfg.nz)
blank_bev = torch.zeros(1, 4, test_cfg.nx, test_cfg.ny)
with torch.no_grad():
    occ_probs, vel_3d, h_next = net(blank_bev, None)
print("network occupancy shape:", tuple(occ_probs.shape))
print("network velocity shape:", tuple(vel_3d.shape))
print("network hidden shape:", tuple(h_next.shape))
print("prob min:", f"{occ_probs.min().item():.4f}")
print("prob max:", f"{occ_probs.max().item():.4f}")
column = vel_3d[0, :, 3, 4, :]
print("vx at column (3, 4), every height:", [f"{v:.4f}" for v in column[0].tolist()])
print("vy at column (3, 4), every height:", [f"{v:.4f}" for v in column[1].tolist()])
print("vz at column (3, 4), every height:", [f"{v:.4f}" for v in column[2].tolist()])
print("same velocity at every height:", bool(torch.allclose(vel_3d[..., :1], vel_3d[..., 1:])))
Output
network occupancy shape: (1, 1, 16, 16, 4)
network velocity shape: (1, 3, 16, 16, 4)
network hidden shape: (1, 8, 16, 16)
prob min: 0.4498
prob max: 0.5501
vx at column (3, 4), every height: ['-0.1438', '-0.1438', '-0.1438', '-0.1438']
vy at column (3, 4), every height: ['0.1333', '0.1333', '0.1333', '0.1333']
vz at column (3, 4), every height: ['-0.0320', '-0.0320', '-0.0320', '-0.0320']
same velocity at every height: True

In section 2, the 96 lead cells are the ones with lead vx: 2.0. The 160 static cells store vx 0.

The untrained network on a blank map returns shapes (1, 1, 16, 16, 4), (1, 3, 16, 16, 4), and (1, 8, 16, 16). Probabilities run from prob min: 0.4498 to prob max: 0.5501 — one half, not a detector. Do not read 0.55 as "occupied."

At column (3, 4) every height prints the same vector; same velocity at every height: True. The numbers are an untrained head on zeros, not the generator's 2.0.

5. Exercises

Exercise — meters_to_index. Return the integer voxel index (ix, iy, iz) for a point in meters. Same formula as VoxelGridConfig.point_to_voxel_index. Leave the TODO as it is to use the reference method on the test grid.

In [6]
def meters_to_index_student(x, y, z, x_min, y_min, z_min, voxel_size):
    # TODO: integer voxel index
    raise NotImplementedError

def get_index_fn():
    try:
        meters_to_index_student(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0)
    except NotImplementedError:
        print("Using reference point_to_voxel_index (TODO not implemented)")
        def reference(x, y, z, x_min, y_min, z_min, voxel_size):
            return test_cfg.point_to_voxel_index(x, y, z)
        return reference
    print("Using your meters_to_index")
    return meters_to_index_student

index_fn = get_index_fn()
ix, iy, iz = index_fn(5.0, 0.0, 1.0, test_cfg.x_min, test_cfg.y_min, test_cfg.z_min, test_cfg.voxel_size)
print("point meters:", (5.0, 0.0, 1.0))
print("index:", (ix, iy, iz))
assert (ix, iy, iz) == test_cfg.point_to_voxel_index(5.0, 0.0, 1.0)
print("✅ correct: index", (ix, iy, iz))
Output
Using reference point_to_voxel_index (TODO not implemented)
point meters: (5.0, 0.0, 1.0)
index: (5, 8, 2)
✅ correct: index (5, 8, 2)

The check prints ✅. The point (5.0, 0.0, 1.0) m maps to (5, 8, 2), matching section 2. The reference ran because the TODO still raises.

Solution
ix = int((x - x_min) / voxel_size)
iy = int((y - y_min) / voxel_size)
iz = int((z - z_min) / voxel_size)
return ix, iy, iz

Exercise — blend_memory. One hidden value. Return (1 - update_gate) * h_prev + update_gate * candidate. Leave the TODO in place to use the reference blend. On 0.8, 0.2, and 0.3 the result is the 0.62 from section 3.

In [7]
def blend_memory_student(h_prev, candidate, update_gate):
    # TODO: mix previous memory with the new candidate
    raise NotImplementedError

def blend_memory_reference(h_prev, candidate, update_gate):
    return (1.0 - update_gate) * h_prev + update_gate * candidate

def get_blend_fn():
    try:
        blend_memory_student(0.0, 0.0, 0.0)
    except NotImplementedError:
        print("Using reference blend_memory (TODO not implemented)")
        return blend_memory_reference
    print("Using your blend_memory")
    return blend_memory_student

blend_fn = get_blend_fn()
blended = float(blend_fn(0.8, 0.2, 0.3))
print("h_prev:", 0.8)
print("candidate:", 0.2)
print("update gate:", 0.3)
print("h_next:", f"{blended:.2f}")
assert abs(blended - 0.62) < 1e-6
print("✅ correct: h_next =", f"{blended:.2f}")
Output
Using reference blend_memory (TODO not implemented)
h_prev: 0.8
candidate: 0.2
update gate: 0.3
h_next: 0.62
✅ correct: h_next = 0.62

The check prints ✅. h_next: 0.62 matches section 3. The reference ran because the TODO still raises.

Solution
return (1.0 - update_gate) * h_prev + update_gate * candidate

The module tests check this index, tensor shapes, and that probabilities stay between 0 and 1.

Predict: tests/test_occupancy.py has three tests. How many pass?

In [8]
completed = subprocess.run(
    [sys.executable, "-m", "pytest", "modules/04_occupancy_network/tests/test_occupancy.py", "-q"],
    cwd=REPO,
    capture_output=True,
    text=True,
)
print(completed.stdout)
if completed.stderr:
    print(completed.stderr)
assert completed.returncode == 0
Output
...                                                                      [100%]
3 passed in 1.26s

The summary line is 3 passed. Index math, synthetic shapes, and the temporal forward pass all succeeded.

6. Recap

  • A 4×4×4 cube has 64 cells. Nine were occupied: a low 2×2×2 block and one cell at (2, 2, 3), with empty air between them on the slices.
  • One box around those cells covers 16 cells and would mark 7 empty ones. Voxels leave that air free.
  • The default grid is 64×48×8 = 24576 voxels at 0.5 m. The test grid is 16×16×4 at 1 m. (5, 0, 1) m is index (5, 8, 2).
  • The synthetic scene has 256 occupied cells. 96 lead cells store vx 2.0. 120 are barrier. 40 are overhang from z = 2.0 m up.
  • Frame-only marks the car cell 1, 0, 1 behind a pillar; keeping the mark gives 1, 1, 1.
  • The gate blend turns 0.8 and 0.2 at gate 0.3 into 0.62. Inside ConvGRUCell, update gate 0.000335 on a blank frame leaves a carried hidden value at 0.999665; h_prev=None leaves 0.
  • The velocity head copies one vector onto every height. On blank input, occupancy sits between 0.4498 and 0.5501. The scene speed is the generator's 2.0 until you train the head.
  • This module does not move the hidden map when the car turns; the grid and the memory must already line up.

Go deeper