All projects
Active DevelopmentComputer VisionAug 2026 — present

Vectra-180

A dual-fisheye dashcam for the Pi Compute Module 5.

A headless dashcam that records continuously from a dual-fisheye USB camera, protects the footage around an impact, and serves clips to a phone over Wi-Fi. Because the two lenses sit a fixed distance apart it can also compute a depth map — on request, never while a clip is being written.

Built with

PythonRaspberry Pi CM5systemdUVCOpenCV

Highlights

  • ▸Recording is treated as the one non-optional duty: preview, depth, panorama and the web UI are all pushed off the capture thread.
  • ▸Survives a camera that browns out on a bumpy road, and never fills the SD card.
  • ▸Computes stereoscopic depth from the fixed-baseline dual-fisheye lenses, but only when asked, so it cannot cost frames.
  • ▸Runs as a headless systemd service, and works on any Linux, macOS or Windows machine with a UVC camera — the Pi is simply where it lives in a car.

One rule the architecture follows from

A dashcam that drops frames while rendering a preview is not a dashcam. Every design decision here follows from making recording the duty and everything else optional: expensive work leaves the capture thread, depth is computed on request rather than continuously, and the web interface is a reader of finished clips rather than a participant in writing them.

From the README

Vectra-180

A dual-fisheye dashcam for the Raspberry Pi Compute Module 5, with stereoscopic depth on the side.

CI CodeQL Python 3.11+ Platforms License: Apache 2.0

Install · Use it · How it works · Configuration · HTTP API · Troubleshooting


Vectra-180 turns a 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 that happens when you ask for one, never while a clip is being written.

It runs headless as a systemd service, survives a camera that browns out on a bumpy road, and never fills the SD card.

Note

Nothing here is CM5-specific. It runs on any Linux, macOS or Windows machine with a UVC camera — the Pi is simply where it lives in a car. See Hardware for what has actually been run, and on what.

Contents

Two rules

Everything in the codebase follows from these, and reading them first makes the rest obvious.

Recording is the duty. Preview, depth, the panorama and the web UI are optional; footage is not. Anything expensive is pushed off the capture thread, so none of it can block a camera read or an encode — what a viewer, a slow card or a depth request costs first is a preview frame. It cannot invent cores, though: on a machine already at its limit they compete for CPU like anything else. Size yours with vectra180 doctor, which measures the sustained rate with nobody watching.

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.

What it does

Loop recordingFixed-length segments into a loop directory, pruned oldest-first against a size budget and a free-space floor.
Incident lockThe camera's IMU trips a threshold; the clip containing the moment is moved to events/, which the loop's pruning never touches — locked clips are reclaimed only against their own budget, oldest first.
TelemetryThe accelerometer and gyroscope block the camera embeds in each frame is decoded, filtered into roll/pitch/yaw, and written to a JSON sidecar beside every clip.
Web interfaceLive MJPEG preview, clip browser, downloads, storage meter and a lock button — all from one self-contained page, no internet needed.
Panoramic viewBoth eyes dewarped, joined and levelled on the horizon, on request. Clips stay raw.
Depth on demandStereo disparity from the two lenses, computed per request rather than per frame.
vectra180 doctorNine checks against the real capture and encode path — ending with five seconds of actual recording — each failure printed with the command that fixes it.

Install

On a Raspberry Pi

Installs into /opt/vectra180, runs as an unprivileged vectra user, and starts on boot.

git clone https://github.com/Life-Experimentalist/Vectra-180.git && sudo ./Vectra-180/deploy/install-pi.sh

Then check the machine over and watch it work:

sudo -u vectra /opt/vectra180/venv/bin/vectra180 doctor

The service listens on 127.0.0.1:8080 by default.

Warning

Reaching it from your phone needs a bind address and a token, not one or the other. Moving server.host off loopback without setting server.token puts your footage on the network for anyone else on it. See Reaching it from a phone.

Full hardware runbook, including camera choice, storage, power and thermals: deploy/README.md.

From PyPI

Important

Not published yet. The release workflow builds and uploads on a version tag, and only when the repository variable PUBLISH_TO_PYPI is set to true, so this is what the command will be rather than what works today. Until then, install from a checkout.

pip install vectra-180

Add the desktop control panel with pip install "vectra-180[desktop]". Leave it out on a Pi — a headless recorder has no business installing a GUI toolkit.

As a container

Important

Not published yet either — the image is pushed to GHCR by the release workflow on a version tag, and no version has been tagged. docker compose build works from a checkout today; the pull below is what it will be.

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. docker-compose.yml builds the image locally with the same guards, and refuses to start without a token at all.

The image is built for linux/amd64 and linux/arm64 — the second is the one a 64-bit Raspberry Pi OS runs.

For development
git clone https://github.com/Life-Experimentalist/Vectra-180.git && cd Vectra-180 && ./install.sh

install.ps1 does the same on Windows. Both install uv, sync the environment, install the pre-commit hooks and run the checks. After that, make help lists every task.

Use it

vectra180 doctor          # can this machine record?
vectra180 devices         # what cameras are attached?
vectra180 run             # record and serve until stopped
vectra180 config          # what settings are actually in effect?
vectra180 decode shot.jpg # is there really an IMU block in this frame?
vectra180 view            # desktop control panel (needs the desktop extra)

vectra is installed as a shorter alias for the same command — vectra doctor and vectra180 doctor are identical. From a checkout, prefix either with uv run to use the project environment without activating it:

uv run vectra doctor
The flags worth knowing

Every subcommand takes --config PATH, --camera N, --device PATH, --backend NAME, --recording-dir PATH, and -v / -q. --camera is an index; --device is a path — passing a path to --camera will not work.

CommandAlso takesFor
run--duration SECONDSstop after a while instead of forever, which is how you take a test recording
--no-recordserve the live view without writing clips
--no-serverecord with no web interface at all
--host --port --tokenoverride the server without touching the config file
devices--max-index Nhow far up to probe
--jsonmachine-readable
doctor--no-cameraskip everything that needs hardware attached
--jsonmachine-readable
decode--metadata-width PXhow wide the metadata strip is on this module
--jsonmachine-readable
config--jsonJSON instead of TOML
--show-secretsprint the token instead of redacting it
--pathalso report which file was read, on stderr

Every subcommand and flag in full: docs/cli.md.

How it works

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.

Frame

metadata strip

picture

lock

on request

Dual-fisheye
USB camera

CameraSource
capture/source.py

strip_metadata
imaging/layout.py

TelemetryDecoder
telemetry/decoder.py

published snapshot

OrientationFilter

IncidentDetector

SegmentRecorder
recorder/segmenter.py

clips + sidecars

preview · panorama · depth

HTTP service

Solid edges are the capture thread — the part that must never stall. Dashed edges run on HTTP handler threads, only while someone is watching.

The three threads, and what separates them
vectra-httpvectra-recorderbounded queuevectra-capturevectra-httpvectra-recorderbounded queuevectra-captureloop[every frame]preview, panorama and depth arecomputed on the request's own threadread, strip metadata, decode IMU1submit(frame) — never blocks2publish snapshot for viewers3frame4scale, burn timestamp, encode5roll a segment when it is long enough6latest published snapshot7

The queue between the capture thread and the encoder is bounded twice over — by frame count (two seconds of footage at the rate the clips declare) and by a byte budget — and submit() never blocks. If the encoder cannot keep up, frames are dropped there and counted, because a capture thread waiting on a full queue is a camera that has stopped being read.

Dropped frames are not hidden: the count lands in the sidecar, doctor reports it, and /api/status exposes it live.

What a segment goes through

first frame of a segment

frames appended

a frame arrives segment_seconds after the first

JSON written beside the clip

stays in normal/

an incident covered this clip

moved to events/

oldest-first, over max_bytes or under min_free_bytes

oldest-first, over max_event_bytes only

Writing

Closed

Sidecar

Loop

Locked

Events

Pruned

A segment rolls on the timestamps of frames that survived the queue, not on wall-clock time — so a machine that drops frames writes fewer, longer-covering clips rather than silently truncating them. That is what covers_seconds in the sidecar is for.

The full picture, including how a segment rolls over and what a sidecar contains: docs/architecture.md.

What a recording looks like

Segments accumulate in normal/ and are pruned oldest-first; an incident moves one into events/, which the loop never prunes.

Clips are named from the wall clock at the first frame, and every clip gets a sidecar of the same name:

recordings/
├── normal/
│   ├── VEC_20260811_182927.mp4
│   ├── VEC_20260811_182927.json
│   └── …
└── events/
    ├── VEC_20260811_181402.mp4
    └── VEC_20260811_181402.json
A real sidecar, from a real run
{
  "clip": "VEC_20260811_182927.mp4",
  "started_at": "2026-08-11T18:29:27.131706+00:00",
  "duration_seconds": 41.385,
  "covers_seconds": 41.385,
  "dropped_frames": 0,
  "continuous": true,
  "frames": 538,
  "fps": 13.0,
  "width": 1992,
  "height": 600,
  "locked": false,
  "lock_reasons": [],
  "telemetry": []
}

Three fields are there to keep the clip honest about itself:

  • duration_seconds — how long the file plays, which is frames ÷ fps.
  • covers_seconds — how much real time passed while it was recorded.
  • continuous — true only when nothing was dropped and the two figures agree. When they disagree, the footage is a time-lapse of the incident rather than a record of it, and you should know that before you rely on it.

telemetry is empty here because the module used for this run does not embed an IMU block — see the telemetry check in Checking a machine.

Segments, sidecars, incidents and both storage budgets in full: docs/recording.md.

The web interface

vectra180 run serves a single self-contained page — no CDN, no internet, no build step. It works on a phone parked next to the Pi with nothing else around.

Every route
MethodRouteReturns
GET/healthzliveness and version, answered before auth so a supervisor never needs the operator's token
GET/the control page
GET/static/<file>its CSS and JS, served from inside the package
GET/snapshot.jpgone frame, JPEG
GET/stream.mjpglive preview, MJPEG, paced at server.preview_fps
GET/depth.jpga stereo depth map, computed for this request
GET/api/statusrecording state, rate, dropped frames, attitude, uptime
GET/api/configthe effective configuration, token redacted
GET/api/storageboth budgets, what is used, what is free
GET/api/clipsevery clip, both categories, newest first
GET/api/clips/<name>the clip itself, as a download
POST/api/clips/<name>/protectmove that clip into events/
POST/api/locklock whatever is recording right now
POST/api/recording/startstart writing clips
POST/api/recording/stopstop writing clips, keep serving the preview
DELETE/api/clips/<name>delete a clip and its sidecar

/snapshot.jpg and /stream.mjpg take ?view=pano for the joined, levelled panorama and ?overlay=0 to drop the HUD. When server.token is set, every route except /healthz requires it — as an Authorization header or, because a <video> tag cannot send headers, as ?token=.

Parameters, response shapes and status codes: docs/api.md.

Configuration

Settings resolve in four layers, each overriding the last.

built-in defaults

TOML file

VECTRA_* environment

command-line flags

effective config

# /etc/vectra180/config.toml

[camera]
device = "/dev/video0"
width = 2560          # both fisheye views side by side; 0/0 = whatever
height = 720          # mode the driver opens in
fps = 30

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

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

[server]
host = "127.0.0.1"    # 0.0.0.0 reaches your phone -- and everyone else's
token = ""            # set this before you change the line above

vectra180 config prints the merged result with the token redacted, and vectra180 config --path also says which file it read.

Where the file lives, and the settings worth reaching for first

With no --config and no VECTRA_CONFIG, the platform config directory is used:

Linux/etc/vectra180/config.toml when it exists — which is where the Pi installer puts it — otherwise ~/.config/vectra180/config.toml
macOS~/Library/Application Support/Vectra180/config.toml
Windows%APPDATA%\Vectra180\config.toml

The four that fix most problems:

SettingEnvironment variableReach for it when
recording.fpsVECTRA_RECORDING_FPSthe machine cannot sustain the camera's rate — this is the rate the clip declares, so setting it makes the footage real time again
recording.scaleVECTRA_RECORDING_SCALEencoding a full-size stereo frame is the bottleneck
camera.fourccVECTRA_CAPTURE_FOURCCthe module will not deliver its full rate; MJPG suits most, a few need ""
telemetry.metadata_widthVECTRA_METADATA_WIDTHyour module writes a differently sized block into the frame's leading columns

Every key, every default, every environment variable and every validation rule: docs/configuration.md.

Checking a machine

vectra180 doctor does not read the configuration and tell you what it says. It opens the camera, decodes what it sends, encodes it, and records for five seconds — then reports what actually happened, with the fix beside each problem.

CheckWhat it proves
environmentthe versions actually loaded — Python, OpenCV, NumPy, the OS
deviceswhich indices open, at what size, on which backend — and which open but never deliver a frame
camerathe configured camera opens, and the rate it really sustains
telemetrywhether an IMU block can be decoded out of those frames
ffmpegwhere the encoder binary is
storagefree space, and what the two budgets are holding
encoderhow fast the writer encodes frames of the size you will record
pipelinefive seconds of real recording — capture, prepare and encode together
servicethe bind address, and whether a token guards it

Warnings do not fail the run; failures set a non-zero exit status, so it can gate a deployment. --no-camera skips everything that needs hardware, and --json makes it machine-readable.

What a good run looks like

From a dual-fisheye UVC module on a Windows development machine, at half scale and a declared 13 fps:

[ ok ] environment: vectra180 1.0.0 on Windows AMD64, python 3.12.10, opencv 5.0.0, numpy 2.5.1
[ ok ] ffmpeg: ...\ffmpeg-9.0-full_build\bin\ffmpeg.EXE
[ ok ] storage: C:\Users\...\Videos\Vectra180: 198.1 GB free, 8 loop clip(s), 0 locked clip(s)
[ ok ] service: http://127.0.0.1:8080 (loopback only, no token)
[warn] devices: msmf[0] Camera 0 (index 0) 640x480 -- no frames; msmf[1] Camera 1 (index 1) 4000x1200; ...
         -> the entries marked 'no frames' opened but streamed nothing, which usually means another program is holding them
[warn] camera: 4000x1200 via msmf, 16.5 fps measured (30 requested)
         -> the USB link or the pixel format is the bottleneck: camera.fourcc asked for MJPG and the driver will not say which format it settled on...
[warn] telemetry: no IMU block decoded from the metadata strip
         -> not every dual-fisheye module embeds this IMU block, and some write a different one into the same corner...
[ ok ] encoder: FFmpegWriter at 1984x600 preset 'ultrafast': 83.6 fps (30 needed)
[ ok ] pipeline: 13.0 fps captured, prepared and encoded together (13 requested), from a camera asked for 30

All critical checks passed with 3 warning(s).

Three warnings, and all three are the machine rather than the software: another program is holding the laptop's own webcam, the USB link will not carry 4000×1200 at 30 fps, and this module does not embed an IMU block. The clips it writes are real time and continuous, which is the part that matters.

Symptom by symptom: docs/troubleshooting.md.

Documentation

ArchitectureThreads, data flow, and why the pipeline is shaped this way.
ConfigurationEvery setting, default, environment variable and validation rule.
Recording and retentionSegments, sidecars, incidents, and how the two storage budgets are enforced.
Telemetry formatThe wire format of the IMU block, byte by byte.
HTTP APIEvery route, parameter and response shape.
Command lineEvery subcommand and flag.
TroubleshootingSymptoms, causes, fixes.
Deployment runbookHardware, wiring, power, thermals, phone access.
Security postureWhat is protected, what is not, and how to report a flaw.
ContributingHow to work on this.

Layout

src/vectra180/
├── capture/     opening the camera, reading frames, reconnecting
├── imaging/     dewarp, stitch, stabilise, depth, HUD, frame layout
├── telemetry/   decoding the IMU block and filtering it into attitude
├── recorder/    segmenting, encoding, incidents, retention
├── service/     the HTTP service and its self-contained web UI
├── ui/          the optional DearPyGui desktop panel
├── config.py    the four-layer settings merge
├── engine.py    the capture thread everything else hangs off
├── doctor.py    the diagnostics
└── cli.py       the command line

Development

make install    # environment and dev tools
make gate       # lint, typecheck, full suite -- exactly what CI runs
make test-fast  # skip the integration tests

make help lists all of them: install, hooks, format, lint, typecheck, test, test-fast, gate, doctor, run, view, build, docker, clean.

The suite runs without a camera and without a display: FakeCameraSource replays synthetic frames, and a fake DearPyGui is substituted into sys.modules so the desktop panel is exercised on a headless runner. Two markers narrow a run — integration for the tests that drive real files and sockets, and hardware, reserved for anything that would need a camera attached and excluded in CI.

See CONTRIBUTING.md before opening a pull request.

Hardware

Built for:

  • Raspberry Pi Compute Module 5 on the CM5 IO board, Raspberry Pi OS Bookworm
  • A dual-fisheye USB (UVC) camera delivering both views side by side in one frame

Where it has actually run: a dual-fisheye UVC module on a Windows development machine, plus the full test suite on Linux, macOS and Windows in CI. The CM5 is what the deployment runbook, the systemd unit and the encoder defaults are designed around, but this release has not yet recorded a drive on one. Treat the Pi instructions as tested-by-construction rather than road-proven, and see deploy/README.md § Verify for what to confirm on your own board before trusting it.

If your camera works — or doesn't — please file a hardware report so the next person knows what to buy.

Questions

Do I need a special camera?

Any UVC camera records. The dual-fisheye part matters for two features: the panorama needs both eyes in one frame, and depth needs them a known distance apart. A single-lens webcam records and previews perfectly well — but both of those views split the frame down the middle and assume the halves are two eyes, so on one lens they produce nonsense rather than an error.

vectra180 devices lists what is attached and at what size; a module that reports something like 2560×720 or 4000×1200 is delivering both views side by side.

Why is the footage playing faster than real time?

Because the machine could not sustain the rate the clip declares. Run vectra180 doctor and read the pipeline check: it measures capture, prepare and encode together, and its remedy names the recording.fps to set. Once set, the clips declare the rate the machine actually holds and play back in real time — nothing is thrown away by setting it, because the frames were never captured.

Where does my telemetry go if the camera has no IMU?

The sidecar's telemetry array stays empty and incident locking has nothing to trip on — everything else is unaffected. If your module does embed a block but in a different width, set telemetry.metadata_width; vectra180 decode on a still frame will tell you whether it is being read.

Can I reach it from my phone?

Yes, and it needs two changes together: server.host off loopback and server.token set. The service will bind wherever you tell it, so setting the first without the second serves your footage to everyone on the network. The runbook walks through it: Reaching it from a phone.

Will it fill my card?

No. Loop footage is pruned oldest-first once it passes recording.max_bytes or once free space drops below recording.min_free_bytes. Locked clips are exempt from that and have a budget of their own, recording.max_event_bytes, pruned oldest-first in turn — so a run of incidents cannot grow without bound either.

Licence

Apache License 2.0. See LICENSE.md.

Local Storage Notice

This site uses local storage to cache GitHub data for better performance, and an anonymous visit counter. No personal data is stored or shared.