v1.0.0 Unreleased · not yet road-proven · Apache 2.0

A dashcam that sees in two eyes.

Vectra-180 turns a Raspberry Pi Compute Module 5 and a dual-fisheye USB camera into a dashcam that records continuously, protects the footage around an impact, and serves the whole thing to your phone over Wi-Fi. Because the camera has two lenses a fixed distance apart, it can also compute a depth map — but only when you ask for one.

  • Runs headless as a systemd service
  • Python 3.11+
  • No web framework, no cloud, no account
VECTRA-180 · dual fisheye → dewarped
2560 × 720 · MJPG · 30 fps
raw frame

A live shader running the same equidistant fisheye projection (r = f·θ) the dewarper uses. Drag the slider. The scene is synthetic — it is the maths that is real.

Two rules

Everything else follows from these

Read them first and the rest of the codebase stops being surprising. They are not slogans — each one is a constraint that decided how the threads are arranged.

Recording is the duty

Preview, depth, the panorama and the web interface are optional. Footage is not. Anything expensive is pushed off the capture thread, so a viewer, a slow card or a depth request can cost you a preview frame — never a recorded one.

The frame carries the clock

Every frame arrives stamped with both a monotonic time and a wall time. Pacing and segment length use the monotonic one because it cannot jump; filenames and sidecars use the wall one. A Pi with no RTC leaps forward the moment NTP settles, and that leap must not cut a clip in half.

Architecture

One thread owns the camera

Its loop is short on purpose — read, decode the telemetry strip, hand the frame to the recorder, publish it for viewers — and everything else hangs off the side.

CAPTURE THREAD — must never stall lock Dual-fisheye USB camera CameraSource capture/source.py strip_metadata imaging/layout.py TelemetryDecoder telemetry/decoder.py IncidentDetector OrientationFilter published frame engine.py SegmentRecorder bounded queue → ffmpeg clips + sidecars HTTP HANDLER THREADS — only while someone is watching preview · panorama · depth computed per request HTTP service service/app.py the path a recorded frame takes optional, and cancellable without losing footage
Solid edges are the capture thread. Dashed edges run on HTTP handler threads, only while someone is watching. Nothing on the dashed side can block the solid side.

Read

The source hands back a Frame carrying the image, a monotonic timestamp and a wall clock. If the camera browns out over a bump it reconnects on its own and keeps going.

Split

The narrow metadata strip is cut off the left edge and handed to the telemetry decoder. The picture that remains is what everything else sees.

Hand off

The frame goes into a queue holding about two seconds of footage. If the encoder falls behind, the frame is dropped and counted — the camera is never made to wait.

Frame layout

Twenty bytes hidden in the picture

Some dual-fisheye modules embed an accelerometer and gyroscope reading in a narrow strip down the left edge of every frame — one byte per row, in the first pixel column. Vectra-180 decodes it, filters it into roll, pitch and yaw, and writes it to a JSON sidecar next to the clip.

left eye right eye 30 px 2530 px of picture · 720 tall strip first pixel column, top to bottom → timestamp µs (8, LE) accel x/y/z (6, BE) gyro x/y/z (6, BE)
The strip is cropped away before anything is recorded — a clip contains the two eyes and nothing else. The 30-pixel default is configurable, and vectra180 decode will tell you what your camera actually uses.

It is checked, not trusted

Ordinary image data can look like a plausible payload. A sample is accepted only once a second frame continues its timeline, which costs one frame of startup latency and rejects essentially every false positive.

Filtered, not integrated

Gyro rates are integrated for responsiveness and corrected toward gravity when the accelerometer is actually measuring gravity rather than cornering force. Yaw is bled to zero, because without a magnetometer it is a turn rate, not a compass.

Written down

Every clip gets a .json sidecar with the frame count, the true duration measured from the frame clock, and the telemetry samples that arrived while it was open.

What it does

Built to be left alone in a car

Every one of these exists because of something that goes wrong in a vehicle: power that sags, cards that fill, clocks that jump, cameras that drop off the bus.

Loop recording

Fixed-length segments into a loop directory, pruned oldest-first against a size budget and a free-space floor. Only files matching its own naming pattern are ever deleted — your files on the same card are invisible to it.

Incident lock

The IMU trips a configurable threshold and the clip containing the moment moves to events/, which the loop pruner never touches — locked clips are reclaimed only against their own budget, oldest first. A button in the web interface does the same thing by hand.

Telemetry sidecars

Acceleration, rotation and derived attitude, sampled per frame and written as JSON beside every clip. The clip is evidence; the sidecar is the argument.

Web interface

Live MJPEG preview, a clip browser with range-request downloads, a storage meter and a lock button — one self-contained page served from the Pi. No internet, no account, no app.

Panoramic view

Both eyes dewarped, joined and levelled against the horizon — on request. Clips stay raw, because the raw frame is the closest thing to what the sensors saw.

Depth on demand

Stereo disparity from the two lenses, computed per request rather than per frame. Useful for parking and for arguing about distance after the fact; never in the way of recording.

Honest diagnostics

vectra180 doctor runs nine checks against the real capture and encode path, ending with five seconds of actual recording, and prints the command that fixes each failure. It exits non-zero, so a provisioning script can depend on it.

Locked down by default

Binds to 127.0.0.1, refuses to go public without a token, compares tokens in constant time, blocks cross-origin writes, and serves the UI under a default-src 'self' policy.

Clean shutdown

Ctrl+C and systemctl stop both finalise the open segment and write its sidecar, rather than leaving a truncated file behind.

Install

Four ways in

The Pi installer is the one that matters: it creates an unprivileged service user, installs into /opt/vectra180, and starts on boot.

Raspberry Pi

Raspberry Pi OS Bookworm · aarch64
# clone, then run the installer as root
git clone https://github.com/Life-Experimentalist/Vectra-180.git \
  && sudo ./Vectra-180/deploy/install-pi.sh

# check the machine over before trusting it
sudo -u vectra /opt/vectra180/venv/bin/vectra180 doctor

The service listens on 127.0.0.1:8080 by default. Reaching it from a phone needs a bind address and a token — not one or the other. The deployment runbook covers camera choice, storage, ignition wiring and thermals.

PyPI

any Linux, macOS or Windows machine with a UVC camera
pip install vectra-180

# optional desktop control panel -- leave this off on a Pi
pip install "vectra-180[desktop]"

Two runtime dependencies: OpenCV and NumPy. ffmpeg is used for encoding when it is on PATH, and there is an OpenCV fallback writer when it is not.

Docker

ghcr.io/life-experimentalist/vectra-180
docker run --rm --device /dev/video0 \
  -p 127.0.0.1:8080:8080 \
  -v vectra-footage:/recordings \
  -e VECTRA_SERVER_TOKEN=choose-a-secret \
  ghcr.io/life-experimentalist/vectra-180:1.0.0

Publishing the port to anything but 127.0.0.1 puts your footage on the network, so set a token first. The bundled docker-compose.yml builds locally with the same guards and refuses to start without a token at all.

From source

development environment
git clone https://github.com/Life-Experimentalist/Vectra-180.git
cd Vectra-180 && ./install.sh   # install.ps1 on Windows

make gate      # lint, typecheck, full suite -- exactly what CI runs

Both installers bootstrap uv, sync the environment, install the pre-commit hooks and run the checks. The suite runs without a camera and without a display.

Diagnostics

Find out on the bench, not on the motorway

The Compute Module 5 has no hardware H.264 encoder, so libx264 runs on the CPU and 2560×720 at 30 fps is genuinely close to the limit. doctor opens the camera, reads real frames, measures the rate the driver actually delivers, and times the encoder at the resolution it will actually receive.

  • environment — versions of everything that matters
  • ffmpeg — present, or the fallback writer will be used
  • storage — writable, and how much room is left
  • service — fails outright if public without a token
  • devices — what responded to a probe
  • camera — opens, delivers frames, at the mode you asked for
  • telemetry — whether there is really an IMU block in there
  • encoder — measured frames per second, against what you need
vectra180 doctor
[ ok ] environment: vectra180 1.0.0 on Linux aarch64,
       python 3.11.2, opencv 4.10.0, numpy 1.26.4
[ ok ] ffmpeg: /usr/bin/ffmpeg
[ ok ] storage: /var/lib/vectra180/recordings: 38.4 GB free,
       573 loop clip(s), 12 locked clip(s)
[ ok ] service: http://0.0.0.0:8080 (network, token required)
[ ok ] devices: v4l2[0] USB 2.0 Camera (/dev/video0) 2560x720
[ ok ] camera: 2560x720 via v4l2, 29.8 fps measured (30 requested)
[ ok ] telemetry: IMU present: 1.00 g total,
       gyro +0.01/-0.00/+0.02 rad/s
[warn] encoder: FFmpegWriter at 2530x720 preset 'ultrafast':
       34.2 fps (30 needed)
         -> there is little headroom; a warm cabin or a
            background task could push it under

All critical checks passed with 1 warning(s).

Every non-ok line carries a remedy underneath it — the thing to actually do, not a restatement of the problem.

Interface

One page, served from the car

No framework, no bundler, no CDN — the whole UI is one HTML file the service hands out, so it works on a phone parked in a field with no signal.

An interface map, not a screenshot — it names the real routes and the real controls.

Selected HTTP routes
Route What it gives you
/healthz Liveness, without a token — for systemd and uptime checks
/snapshot.jpg One frame. ?view=pano for the panorama
/stream.mjpg Live multipart MJPEG preview
/depth.jpg A colourised disparity map, computed for this request
/api/status Frame rate, telemetry, recorder counters, incidents, storage
/api/clips Every clip with its duration, size and lock state
/api/clips/<name> Download, with byte-range support so a phone can seek
/api/lock Protect the current segment by hand

Every route and status code: docs/api.md. Writes are guarded against cross-origin requests, so a page open in the driver's browser cannot stop the recording.

Hardware

What it was built around

Nothing here is CM5-specific — it runs on any machine with a UVC camera. The Pi is simply where it lives in a car.

Reference build
Part What and why
Compute Raspberry Pi Compute Module 5 on the CM5 IO board, Raspberry Pi OS Bookworm. A heatsink is not optional — a throttled CM5 drops frames it handled fine on the bench.
Camera A dual-fisheye USB (UVC) camera delivering both views side by side in one frame. Give it a dedicated USB 3 port and a short, thick cable.
Storage Footage on its own disk, not the boot card. An hour of default settings is roughly 3.6 GB.
Power A supply that survives cranking, and a clean shutdown path on ignition-off so the last segment is finalised rather than truncated.
Time Network time, or an RTC on the IO board's battery header. Without either, the first clips of a drive are named from the wrong wall time.
/etc/vectra180/config.toml
[camera]
device = "/dev/video0"
width  = 2560       # both eyes side by side
height = 720
fps    = 30

[recording]
segment_seconds = 60
max_bytes       = 34359738368  # 32 GiB loop
min_free_bytes  = 2147483648   # never fill the card
max_event_bytes = 8589934592   # 8 GiB for locked clips

[incident]
threshold_g = 0.6   # deviation that counts as an impact

[server]
host  = "127.0.0.1"  # 0.0.0.0 reaches your phone --
token = ""           # and everyone else's

Settings resolve in four layers, each overriding the last: built-in defaults, this file, VECTRA_* environment variables, then command-line flags. vectra180 config prints the merged result with the token redacted.

Questions

Before you buy anything

Do I need a Raspberry Pi Compute Module 5?

No. Nothing in Vectra-180 is CM5-specific. It runs on any Linux, macOS or Windows machine with a UVC camera. The CM5 is simply the board it was built around, because it is the one that fits in a car.

Which camera does it need?

A dual-fisheye USB (UVC) camera that delivers both views side by side in a single frame — Vectra-180 splits that frame down the middle. A single-lens webcam records perfectly well, but the panorama and the depth map both need two lenses. If your camera works, or doesn't, please file a hardware report so the next person knows what to buy.

Does the depth map slow down recording?

Not on a machine with headroom. Depth is computed on an HTTP handler thread, only when a request asks for one, never per frame, and recording runs on its own thread behind a bounded queue — so a depth request cannot block a camera read or an encode, and what it costs first is a preview frame. It does still compete for CPU, so on a machine already at its limit the contention shows up as dropped frames like any other shortfall. Run vectra180 doctor: it measures the sustained rate with nobody watching, which is the number to size the machine against.

What happens when the SD card fills up?

Loop footage is pruned oldest-first against two limits at once: a total size budget and a free-space floor. Clips locked by an incident live in a separate directory with its own budget that the loop pruner never touches. Only files matching Vectra-180's own naming pattern are ever considered for deletion, so anything else on the same card is safe.

Does my camera actually contain an IMU?

Some dual-fisheye modules embed a 20-byte accelerometer and gyroscope block in the metadata strip, and some do not. vectra180 doctor will tell you, and vectra180 decode will let you inspect a captured frame byte by byte. Without one you lose automatic incident detection — the manual lock button, recording, preview, panorama, depth and the web interface all work exactly the same.

Is it safe to expose the web interface?

It binds to 127.0.0.1 by default. Binding to a network address requires setting an auth token as well: doctor fails, rather than warns, if the service is public without one — otherwise anyone on the network could download and delete your footage. Treat it as a device on your own Wi-Fi, not something to put on the open internet.

Is a dashcam recording legal where I live?

That depends entirely on where you are, and this project cannot answer it for you. Rules on dashcams, on recording audio, and on what you may do with footage of other people vary by country and sometimes by state. Check your local law before mounting anything.

How do I contribute?

Read CONTRIBUTING.md, then run make gate — lint, type-check and the full suite, exactly what CI runs. The tests need no camera and no display.

Put it in the car

One command to install, one to check the machine over, and then it looks after itself until you want the footage.