Skip to content

Latest commit

Β 

History

35 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

BEV-Perception

Turn ordinary camera (and optionally Lidar) feeds into a live, top-down map of everything around a vehicle β€” cars, pedestrians, lanes, and obstacles β€” running on a plain CPU.

A lightweight, CPU-first Bird's-Eye View (BEV) road-scene perception system for embedded devices. It reconstructs the scene around the vehicle and draws it as a top-down map centered on the car (the Ego frame), the same way a self-driving car "thinks" about the world.

nuScenes Demo


πŸ€” Why is this hard? (Read this first)

A camera gives you a flat picture. But driving decisions happen in the real world: "That truck is 9 meters ahead and drifting into my lane." Going from pixels to meters is the whole problem, and it hides two traps:

  1. A single camera cannot truly measure distance. A photo is a projection β€” a toy car held close and a real car far away can occupy the exact same pixels. To guess distance from one camera, you must assume something (usually: "the ground is flat"). That assumption breaks on hills, bumps, and slopes, and the error grows the farther away you look (easily 1m+ at range). This is a fundamental limit of monocular vision β€” adding more cameras does not fix it.
  2. Lidar measures distance honestly, but sees the world full of holes. A Lidar spins and shoots laser rings, so even on a perfectly clear day it leaves gaps: blank stripes between rings, a blind cone right next to the car, dark surfaces that don't reflect, and anything past its range.

So neither sensor alone is enough. The core idea of this project is a division of labor:

Lidar says where (precise position). Camera says what (rich semantics) and fills the geometric holes Lidar leaves behind.

And when there's no Lidar at all (a cheap dashcam chip), the system gracefully falls back to camera-only mode β€” still useful, just honest that its distances are estimates, not measurements.


🧠 The big ideas (and the subtle bits)

These are the design decisions that make the system work. Understanding them is understanding the project.

1. Everything lives in the Ego frame β€” never on the camera image

The cardinal rule: the final result is a top-down map in meters, centered on the car, not annotations drawn on a camera frame. Overlaying boxes back onto the camera is only for debugging.

Why bother? Because a real car has 5–6 cameras, each covering a slice of the 360Β° view. If the "brain" of the system thinks in the shared ground-plane coordinate system, then adding cameras is just adding more sources that pour into the same map β€” the fusion and tracking logic never changes. If instead you reasoned per-image, you'd have to re-stitch everything every time the camera layout changed. Decoupling from the number of cameras is the whole reason this scales.

2. Lidar is honest but sparse; camera is dense but guesses β€” so fuse them

Both sensors ultimately produce the same currency: a list of 3D points in the Ego frame (points_ego). Lidar points are precise. Camera points (from monocular depth) are dense but approximate. They pour into one shared grid β€” a module that neither knows nor cares which sensor a point came from. This "sensor-agnostic" seam is what lets you add or remove a sensor without rewriting the pipeline.

3. The honesty about holes: Group A vs Group B

This is the most subtle β€” and most important β€” distinction in the whole system.

  • Group A holes are gaps where Lidar simply didn't sample, but the camera still has a clear line of sight (between rings, the near blind cone). Camera depth can fill these. βœ…
  • Group B holes are things physically blocked from view β€” the road hidden behind a truck. No sensor can fill these, because no sensor can see through solid objects.

A lot of naive systems quietly pretend to fill Group B and hallucinate obstacles (or empty space) that they can't actually see. This project deliberately only fills Group A and leaves Group B honestly unknown. Knowing what you can't know is a safety feature.

4. The occupancy grid remembers β€” log-odds accumulation

The world is noisy: a point might flicker in one frame and vanish the next. Instead of trusting each frame blindly, every grid cell holds a running log-odds belief ("how confident am I that this cell is occupied?"). Each frame nudges that belief up (saw something here) or down (saw through it). Consistent observations build strong confidence; one-off noise fades away. It's the same math a robot uses to build a map while driving through it.

5. Tracking happens in meters, not pixels

Most trackers follow boxes around the image. This one tracks objects in the Ego frame in meters, using a Kalman filter (a constant-velocity motion model that predicts where things go next) and Hungarian matching (optimally pairing this frame's detections to existing tracks). Two payoffs: (a) predicted trajectories come out in real-world meters, ready to feed planning; and (b) it's naturally multi-camera β€” an object crossing from one camera's view to the next is still one object at one ground position, so the track survives the handoff.

6. The scale bridge β€” Lidar teaches the camera its scale

Monocular depth models output relative depth ("this is farther than that") but don't know the true scale in meters. When Lidar is present, its precise ranges are used to fit a per-frame affine correction (a simple scale-and-shift) onto the camera's depth. The result: the camera's gap-filling points line up metrically with the Lidar points around them, instead of floating at the wrong distance.


πŸ“Ί Reading the demo (three panels)

The nuScenes demo shows three synchronized views side-by-side:

  • Panel A (Left) β€” Camera view + debug overlay: the raw camera with results projected back on top for inspection only.
    • Bounding boxes: dynamic objects (cars = green, trucks = orange, pedestrians = yellow) with a tracking ID and the real metric distance (x, y) in meters from the car.
    • Lane lines: light-grey lines tracing detected lane boundaries.
    • Height bars: vertical bars from the ground to the top of each obstacle. Solid = precise Lidar measurement; dashed = camera estimate (the visual language of "measured vs. guessed" runs through the whole demo).
  • Panel B (Middle) β€” BEV occupancy grid: the top-down map, the real output.
    • Ego vehicle: a white triangle pointing forward at bottom-center; concentric rings mark every 5 meters.
    • Teal cells: obstacles measured directly by Lidar (high accuracy β€” true measurement).
    • Amber cells: obstacles estimated from camera depth (gap-filling β€” prediction), covering Lidar's structural blind spots (Group A holes).
    • Tracks: colored dots = current positions, trailing lines = movement history, leading white dashes = the Kalman-predicted future path.
  • Panel C (Right) β€” BEV height map (Anchored-Z): absolute obstacle height above the road, from 0.4m to 6.0m.
    • Colormap: Green (low) β†’ Yellow (mid) β†’ Red (high), with a reference colorbar on the right.
    • Lidar is drawn at full opacity; camera estimates are faded β€” again, measured vs. predicted at a glance.

πŸ“Œ Table of Contents


πŸ—οΈ Architecture

Three clean layers, deliberately decoupled from the sensor layout. The trick that makes it scale: Layers 2 and 3 have no idea how many cameras exist β€” they only see Ego-frame data.

graph TD
    subgraph "Layer 1: Per-Camera Pipeline (repeated per camera)"
        C1[Camera 1] --> P1[YOLO / UFLDv2 / Depth] --> Proj1[IPM / Calib Project] --> EgoDets1[Ego-frame Detections]
        C2[Camera N] --> P2[YOLO / UFLDv2 / Depth] --> Proj2[IPM / Calib Project] --> EgoDets2[Ego-frame Detections]
    end

    subgraph "Layer 2: Fusion & Occupancy (camera-count agnostic)"
        EgoDets1 & EgoDets2 --> Fuse[Ego NMS / Fusion]
        Fuse --> Tracker[Ego-space Kalman Tracker]
    
        Lidar[Lidar Point Cloud] --> LidarOcc[Lidar Occupancy] --> OccGrid[Unified Occupancy Grid]
        Proj1 & Proj2 --> DepthOcc[Depth Occupancy] --> OccGrid
    end

    subgraph "Layer 3: Render"
        Tracker --> Render[BEV Renderer]
        OccGrid --> Render
        Render --> BEV[BEV Top-Down Output]
        Render --> Overlay[Debug Overlay on Camera]
    end
Loading
  1. Per-Camera Layer (runs once per camera): 2D detection, lane segmentation, and depth. Each result is projected into the shared Ego frame (meters, centered on the car) using that camera's own calibration.
  2. Fusion Layer (independent of camera count): merges detections from all cameras, removes duplicates in overlap zones via Ego-frame NMS, tracks objects with an Ego-space Kalman filter, and accumulates the sensor-agnostic Occupancy Grid.
  3. Render Layer: draws the top-down BEV map and (for debugging) projects tracked bounds back onto each camera.

🌟 Key Features

  • Embedded-First (CPU only): built for GPU-less targets using ONNX models quantized to INT8. No torch/ultralytics at runtime.
  • Sensor-Agnostic Occupancy Grid: one grid fed by Lidar points (primary position spine) and dense monocular Depth Anything V2 points (gap-filling / fallback).
  • Ego-Space Tracking: constant-velocity Kalman tracker with Mahalanobis-distance gating and Hungarian matching, entirely in metric coordinates β€” never in image space.
  • Scale Calibration Bridge: fits a per-frame affine correction onto monocular depth using sparse Lidar ranges, so camera points sit at the right metric distance.
  • Fail-Soft Degradation: when Lidar is absent, degrades smoothly to a self-contained camera-only IPM mode β€” the only mode deployable today.

πŸ› οΈ Tech Stack & Model Zoo

Dependencies

  • Core Processing: numpy, scipy (Kalman/Hungarian tracker)
  • Deep Learning Inference: onnxruntime (strictly CPU on deployment targets)
  • Image/Video I/O: opencv-python
  • Config & CLI: pyyaml, tqdm

Model Zoo (place in models/)

  • Object Detector: YOLOv8 (ONNX, nano/small, INT8-quantized)
  • Lane Detector: Ultra-Fast-Lane-Detection-V2 (UFLDv2 ONNX)
  • Semantic Segmenter: lightweight CNN (e.g. BiSeNet/PIDNet ONNX)
  • Depth Estimator: Depth Anything V2 (ViT-S ONNX, CPU-friendly)

πŸš€ Getting Started

Prerequisites

  • Python 3.10+
  • macOS or Linux

Installation

  1. Clone the repository:

    git clone https://github.com/your-username/BEV-Perception.git
    cd BEV-Perception
  2. Create and activate a virtual environment:

    python -m venv venv
    source venv/bin/activate
  3. Install dependencies:

    pip install -r requirements.txt
  4. Download the models (and, for the nuScenes demo, the dataset) β€” see Assets & Data Setup below.


πŸ’‘ Usage

πŸ“¦ 0. Assets & Data Setup (models + dataset)

The heavy files live outside git (models/ and data/ are gitignored), packaged on Google Drive as two archives:

Archive Extracts to Size Needed for
models.zip models/ (the .onnx weights) ~1 GB all modes
nuscenes.zip data/nuscenes/ (nuScenes-mini: 6 cams + Lidar GT) ~5 GB the nuScenes demo only

πŸ“ Source folder: Google Drive

Note

Both gdown and the nuScenes dataset are dev/eval only β€” neither ships to the embedded runtime.

Option A β€” download helper (recommended):

# One-time: install the dev-only download tool (not a runtime dependency)
./venv/bin/pip install gdown

# Grab everything (models + dataset), extract to the right folders, and verify
./venv/bin/python tools/download_assets.py

# ...or just one:
./venv/bin/python tools/download_assets.py --only models
./venv/bin/python tools/download_assets.py --only nuscenes

The script skips anything already present; add --force to re-download. Model paths must match configs/pipeline.yaml.

Option B β€” manual: download the two zips from the Drive folder and extract them so the trees look exactly like this:

models/                         data/nuscenes/
β”œβ”€β”€ yolov8s.onnx                β”œβ”€β”€ maps/
β”œβ”€β”€ deep_anything_v2.onnx       β”œβ”€β”€ samples/     # keyframe images (6 cams) + LIDAR_TOP + 5 radars
β”œβ”€β”€ ufldv2_..._320x1600.onnx    β”œβ”€β”€ sweeps/      # intermediate frames between keyframes
β”œβ”€β”€ pidnet_s_cityscapes.onnx    └── v1.0-mini/   # metadata JSON (sample.json, calibrated_sensor.json, ...)
└── ...

Verify either/both at any time (checks the tree without downloading):

./venv/bin/python tools/download_assets.py --verify-only

The official nuScenes copy is also at nuscenes.org/download (v1.0-mini) if you prefer the source; extract it to the same data/nuscenes/ path.

πŸš— 1. Multi-Sensor Evaluation on nuScenes (Recommended)

Important

This is the primary demo and evaluation mode. It uses the nuScenes-mini dataset (6 surround 360Β° cameras + ground-truth Lidar) to show multi-camera semantic fusion and Lidar-primary occupancy mapping.

Run the full pipeline on nuScenes-mini:

./venv/bin/python tools/run_nuscenes.py --config configs/pipeline.yaml

Quantify IPM projection error against ground-truth Lidar distances (this is how "the truck looks ~15m away by IPM but Lidar says ~9m" becomes an actual number):

./venv/bin/python tools/eval_ipm_error.py

πŸ“Ή 2. Dashcam 1-Camera Demo (Camera-Only / Fallback)

Simulates a single front camera on a GPU-less, Lidar-less chip β€” the real embedded deploy floor. It runs purely on camera input using flat-ground IPM and monocular depth.

Run on a custom dashcam video:

./venv/bin/python demo.py --video data/sample_dashcam.mp4 --config configs/pipeline.yaml
  • --save out.mp4: save the output video.
  • --no-show: disable the GUI window (for headless / batch runs).

πŸ“ Camera Calibration Guide for Custom Videos

BEV accuracy lives and dies by calibration. Rather than hand-guessing focal lengths and mounting angles (whose errors compound), use make_calibration.py to compute a single 3Γ—3 ground homography that captures intrinsics and extrinsics together.

What's a homography? It's the exact mathematical link between "a point on the flat road in the image" and "that point's real position in meters." Give it a few known correspondences and it learns to map the whole ground plane.

Step 1: Export a sample frame from your video
# Save frame 0 to a scratch folder
./venv/bin/python tools/make_calibration.py --video data/sample_dashcam.mp4 --frame 0 --image scratch/calib_frame.png
Step 2: Calibrate (pick one method)
  • Method 1: Guided Mode (beginner-friendly) β€” Recommended Calibrate from the four corners of a known rectangular lane segment on the road:

    ./venv/bin/python tools/make_calibration.py --image scratch/calib_frame.png --out configs/camera/dashcam_front.yaml --guided
    1. The terminal asks for: the lane width (e.g. a standard 3.5 m), the distance from the car's front to the near edge of the rectangle (e.g. 6.0 m), and to the far edge (e.g. 20.0 m).
    2. An image window opens. Click the 4 corners of your lane segment in this order: Near-Left β†’ Near-Right β†’ Far-Left β†’ Far-Right (on-screen hints included).
    3. Press ENTER or q to process and save.
  • Method 2: Interactive Mode (arbitrary points)

    ./venv/bin/python tools/make_calibration.py --image scratch/calib_frame.png --out configs/camera/dashcam_front.yaml --interactive

    Click at least 4 ground-plane points, press ENTER, then type each point's real Ego position (x y in meters, e.g. -1.75 20.0).

  • Method 3: Headless Mode (YAML point files) Pre-define pixel and ground coordinates in a YAML file:

    ./venv/bin/python tools/make_calibration.py --image scratch/calib_frame.png --points configs/points_dashcam_front.yaml --out configs/camera/dashcam_front.yaml
Step 3: Verify the result

The tool writes your calibration file plus a check image configs/camera/dashcam_front.verify.png, showing a metric BEV grid re-projected onto the camera view. If the grid lines don't sit cleanly on the road markings, re-run and click more precisely.

πŸ§ͺ 3. Running Unit Tests

Tests live under test/ and run fully offline:

./venv/bin/python -m unittest discover -s test -v

πŸ—ΊοΈ Roadmap

  • Branch A: Multi-Camera Expansion
    • Phase 1: 1-camera pipeline validation (YOLO + Tracker + Depth + IPM)
    • Phase 2: Per-camera calibration config; simulated multi-camera fusion
    • Phase 3: Real 5–6 camera surround setup with LSS/BEVFormer
  • Branch B: Lidar-Primary Fusion (Active focus)
    • Stage 1: nuScenes multi-modal loader & camera-Lidar alignment check
    • Stage 2: IPM quantitative error evaluation vs. Lidar ground truth
    • Stage 3: Lidar occupancy rasterization & integration into the OccupancyGrid
    • Stage 4: Dynamic scale bridge & smooth degradation arbiter

πŸ“ Project Structure

.
β”œβ”€β”€ bev_tracker/              # Fusion & Ego-frame object tracking
β”‚   β”œβ”€β”€ bev_tracker.py        # Track life-cycle management + Hungarian data association
β”‚   β”œβ”€β”€ fusion.py             # Multi-camera detection fusion & overlap suppression (Ego NMS)
β”‚   β”œβ”€β”€ kalman.py             # Constant-velocity Kalman filter in 2D ego-metric coordinates
β”‚   └── types.py              # Shared data structures (Detection, Track, Lane, Landmark)
β”œβ”€β”€ configs/                  # Calibration & pipeline configuration
β”‚   β”œβ”€β”€ camera/               # Per-camera YAML calibration (intrinsic & extrinsic)
β”‚   └── pipeline.yaml         # Main run config (model paths, thresholds, grid sizes)
β”œβ”€β”€ data/                     # Local data workspace (dashcam clip or nuScenes-mini, gitignored)
β”œβ”€β”€ datasets/                 # Dataset parsing & loading
β”‚   └── nuscenes_loader.py    # Custom JSON parsing of nuScenes (no official devkit dependency)
β”œβ”€β”€ models/                   # Exported ONNX model weights
β”œβ”€β”€ perception/               # Per-Camera Layer & sensor interfaces
β”‚   β”œβ”€β”€ calibration.py        # Projection logic & coordinate transforms (2D pixel <-> 3D Ego)
β”‚   β”œβ”€β”€ camera_pipeline.py    # Sequential per-camera pipeline projecting to Ego coordinates
β”‚   β”œβ”€β”€ depth.py              # Depth Anything V2 monocular depth interface (ONNX CPU)
β”‚   β”œβ”€β”€ depth_mono_fallback.py# Geometric distance approximation when Lidar is offline
β”‚   β”œβ”€β”€ depth_occupancy.py    # Projects monocular depth into 3D Ego occupancy points
β”‚   β”œβ”€β”€ detector.py           # 2D object detection (YOLOv8 ONNX CPU)
β”‚   β”œβ”€β”€ height.py             # Approximates target height from projected geometry
β”‚   β”œβ”€β”€ ipm.py                # Inverse Perspective Mapping for the flat ground plane
β”‚   β”œβ”€β”€ lane_detector.py      # Lane detection with UFLDv2 (ONNX)
β”‚   β”œβ”€β”€ lane_fit.py           # Polynomial lane fitting & smoothing
β”‚   β”œβ”€β”€ lidar_occupancy.py    # Filters & rasterizes Lidar point clouds into Ego occupancy points
β”‚   β”œβ”€β”€ lidar_provider.py     # Matches sparse Lidar ranges to camera 2D detections
β”‚   β”œβ”€β”€ scale_bridge.py       # Per-frame affine calibration of mono-depth scale from Lidar
β”‚   β”œβ”€β”€ semantic_seg.py       # Road-scene semantic segmentation (building, vegetation, pole)
β”‚   └── static_extractor.py   # Extracts static boundaries from the seg mask onto the BEV grid
β”œβ”€β”€ render/                   # Visualization & BEV rendering
β”‚   β”œβ”€β”€ bev_renderer.py       # Core BEV renderer (vehicles, lanes, landmarks, grid)
β”‚   β”œβ”€β”€ debug_overlay.py      # Projects Ego 3D bounds & lanes back onto the camera for debug
β”‚   β”œβ”€β”€ height_color.py       # Maps height values to color arrays for static models
β”‚   └── occupancy_grid.py     # Binary + log-odds temporal grid mapping (OccupancyGrid)
β”œβ”€β”€ scratch/                  # Temporary outputs, frames/videos, throwaway scripts (gitignored)
β”œβ”€β”€ test/                     # Unit tests
β”œβ”€β”€ tools/                    # Evaluation & dev utilities
β”‚   β”œβ”€β”€ download_assets.py    # Fetches models.zip + nuscenes.zip from Google Drive & verifies (dev-only)
β”‚   β”œβ”€β”€ eval_ipm_error.py     # Quantifies IPM error vs. ground-truth Lidar distances
β”‚   β”œβ”€β”€ run_nuscenes.py       # Main evaluation driver over nuScenes-mini
β”‚   └── quantize_onnx.py      # Model quantization helper (INT8)
└── requirements.txt          # Python dependencies

About

A lightweight, CPU-optimized Bird's-Eye View (BEV) road-scene perception system featuring Camera-Lidar sensor fusion, occupancy grids, and object tracking designed for embedded devices

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages