A camera daemon for Raspberry Pi sensor networks.
When deploying camera-based monitoring at scale, the challenge is not capturing frames—it's making them available to multiple consumers without dropping any. A motion detector, a machine learning classifier, a human operator, and a long-term archive all want the same frames, but at different times and in different formats.
ws-camerad captures continuously and serves many consumers from a single camera. Stills and video clips can be extracted from any point in the rolling buffer, including frames that have already passed.
Only one process can own the camera. This is the fundamental problem.
| Tool | Multi-process | Pre-event video | Low-latency stills | Notes |
|---|---|---|---|---|
| rpicam-apps | No | No | No | Single output only |
| motion | Web clients only | Yes | No | End-user app, high CPU |
| GStreamer | Via tee | DIY | DIY | Requires deep expertise |
| PiCamera2 | Same process only | DIY | Yes | Python GIL limits parallelism |
| FFmpeg | Single output | No | No | Encoder, not a daemon |
ws-camerad is infrastructure, not an application. The daemon owns the camera; consumers connect via shared memory (zero-copy frames), Unix socket (control), or RTSP (remote streaming). If a consumer crashes, the daemon continues. Other consumers are unaffected.
Use rpicam-apps for simple capture. Use motion for a complete surveillance system. Use ws-camerad when building something custom that needs reliable camera infrastructure without reinventing it.
- Continuous capture without frame drops
- Multiple concurrent consumers (C++ and Python)
- On-demand still capture (<25ms latency)
- Pre/post-event video clips from rolling buffer
- Remote streaming (RTSP)
- Hardware H.264 encoding via V4L2
- Automatic software H.264 fallback when no V4L2 M2M encoder is available
- Zero-copy frame sharing via shared memory
- Frame rotation (0°/90°/180°/270°) with NEON SIMD
- Burst capture for rapid multi-still sequences
- Virtual camera output via v4l2loopback
┌────────────────────────────┐
│ ws-camerad │
│ (C++, libcamera core) │
└────────────┬───────────────┘
│
┌───────────┼─────────────┬───────────────┐
│ │ │ │
│ Shared Memory UNIX Socket Network Stream
│ (frames / H.264) (control) (RTSP)
│ │ │ │
┌▼────────┐ ┌▼────────┐ ┌──▼────────┐ ┌───▼─────────┐
│ C++ CV │ │ Python │ │ CLI tools │ │ Remote │
│ consumer│ │ consumer│ │ / scripts │ │ server / NVR│
└─────────┘ └─────────┘ └───────────┘ └─────────────┘
Dependencies (Raspberry Pi OS / Raspbian):
# Fresh image only: refresh package indexes first to avoid 404 package fetch errors.
sudo apt update
sudo apt install -y cmake build-essential libcamera-dev libjpeg-dev pkg-config \
libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libgstrtspserver-1.0-dev \
gstreamer1.0-rtsp gstreamer1.0-plugins-bad gstreamer1.0-libav \
libgtest-devBuild:
mkdir build && cd build
cmake ..
make -j1Install:
sudo make install
sudo mkdir -p /var/lib/ws-camerad/{stills,clips}
sudo systemctl daemon-reloadBest practice:
- Use
/etc/ws/camerad/ws-camerad.confas the source of truth for paths and runtime options. - Prefer running via systemd for production (
ws-camerad.servicefor single camera,ws-camerad@.servicefor multi-camera).
# Run directly
mkdir -p /run/ws-camerad
./ws-camerad
# With options
./ws-camerad -W 1920 -H 1080 -f 30 -d
# As a service
sudo systemctl start ws-camerad| Option | Description |
|---|---|
-c, --config FILE |
Configuration file path |
-s, --socket PATH |
Control socket path |
-W, --width |
Video width (default: 1280) |
-H, --height |
Video height (default: 960) |
-f, --framerate |
Frame rate (default: 30) |
-b, --bitrate |
Bitrate (default: 4000000) |
-t, --tuning-file |
Tuning file for NoIR modules |
-o, --rotation |
Frame rotation (0, 90, 180, 270) |
--hflip |
Horizontal flip (mirror) |
--vflip |
Vertical flip |
-r, --rtsp-port |
RTSP server port (default: 8554) |
-R, --no-rtsp |
Disable RTSP streaming |
-d, --debug |
Enable debug logging |
Connect to the control socket:
python3 examples/still_client.py
./clip_client -5 5| Command | Description |
|---|---|
STILL [offset] |
Capture JPEG (offset: 0=now, negative=past) |
BURST <count> [interval_ms] |
Capture multiple stills |
CLIP <start> <end> |
Extract video clip (offsets relative to now) |
SET <key> <value> |
Set camera parameter (see below) |
GET STATUS |
Get daemon status |
SET parameters:
- Camera controls (instant, per-frame):
exposure,gain,brightness,contrast,sharpness,saturation,ae_enable,awb_enable,exposure_value - Tuning file (warm restart, ~0.5-1s gap):
SET tuning_file imx219_noir.json
Clip examples:
CLIP -5 5— 10 seconds: 5s before to 5s after nowCLIP -10 0— 10 seconds: all from buffer
Responses are JSON:
{"ok":true,"path":"/var/lib/ws-camerad/stills/still_20260209_173407_11.jpg"}
{"ok":true,"data":{"running":true,"capture":{"frames":826,"fps":29.97}}}
{"ok":false,"error":"Invalid command"}Python:
from ws_camerad import CameraClient
with CameraClient() as client:
response = client.capture_still()
print(response.path)
response = client.capture_clip(-5, 5)
print(response.path)Shared memory consumer:
./frame_consumer
python3 examples/opencv_analysis.py --statsRTSP stream:
ffplay rtsp://raspberry-pi:8554/camera
ffmpeg -rtsp_transport tcp -i rtsp://raspberry-pi:8554/camera -c copy output.mp4/etc/ws/camerad/ws-camerad.conf:
[daemon]
socket_path = /run/ws-camerad/control.sock
stills_dir = /var/lib/ws-camerad/stills
clips_dir = /var/lib/ws-camerad/clips
ring_buffer_seconds = 30
enable_rtsp = true
rtsp_port = 8554
[camera]
width = 1280
height = 960
framerate = 30
bitrate = 4000000
jpeg_quality = 90
# rotation = 0
# tuning_file = imx219_noir.jsonws-camerad outputs frames to v4l2loopback devices. Any V4L2-compatible application (OpenCV, FFmpeg, OBS, browsers) can consume the feed as a standard video device.
# Install v4l2loopback
sudo apt install v4l2loopback-dkms v4l2loopback-utils
# Load module
sudo modprobe v4l2loopback devices=2 video_nr=10,11 card_label="Virtual Camera 1,Virtual Camera 2"Configuration:
[camera]
rotation = 90
[virtual_camera.0]
device = /dev/video10
enabled = true
[virtual_camera.1]
device = /dev/video11
width = 640
height = 480
enabled = trueVirtual cameras can output at a lower resolution than the source. Set width and height per virtual camera to downsample the YUV420 frames automatically. Omit or set to 0 to output at full camera resolution.
Performance (Pi 4, 1280×960 @ 30fps, 90° rotation):
| Virtual Cameras | Processing Time | Headroom |
|---|---|---|
| 1 | ~11ms | 22ms |
| 5 | ~14ms | 19ms |
| 8 | ~17ms | 17ms |
The module supports up to 8 devices. Each uses ~1.8MB at 1280×960.
Persistent loading:
echo "v4l2loopback" | sudo tee /etc/modules-load.d/v4l2loopback.conf
echo "options v4l2loopback devices=4 video_nr=10,11,12,13" | sudo tee /etc/modprobe.d/v4l2loopback.confRun separate daemon instances with distinct configurations. Each camera needs its own socket, shared memory name, and RTSP port.
# /etc/ws/camerad/cam0.conf
[daemon]
socket_path = /run/ws-camerad/cam0.sock
stills_dir = /var/lib/ws-camerad/cam0/stills
clips_dir = /var/lib/ws-camerad/cam0/clips
shm_name = /ws_camerad_frames_cam0
enable_rtsp = true
rtsp_port = 8554
[camera]
camera_id = 0
width = 1280
height = 960
framerate = 30
bitrate = 4000000
jpeg_quality = 90Optional — systemd template unit (requires a config file per instance name):
sudo cat > /etc/systemd/system/ws-camerad@.service << 'EOF'
[Unit]
Description=ws-camerad (%i)
After=network.target
[Service]
Type=simple
ExecStart=/usr/bin/ws-camerad -c /etc/ws/camerad/%i.conf
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
# Enable only when config files are in place:
sudo systemctl enable --now ws-camerad@cam0
sudo systemctl enable --now ws-camerad@cam1Note: The installed
ws-camerad.serviceunit is disabled by default. Do not enable it without a valid config at/etc/ws/camerad/ws-camerad.conf; use the template unit above instead.
Each daemon instance exposes its own Unix control socket. Commands sent to one socket affect only that camera instance.
cam0instance listens on/run/ws-camerad/cam0.sockcam1instance listens on/run/ws-camerad/cam1.sock- There is no shared global control socket in multi-camera mode unless you configure multiple instances to use the same path (not recommended)
Example: query each camera independently
echo "GET STATUS" | socat - UNIX-CONNECT:/run/ws-camerad/cam0.sock
echo "GET STATUS" | socat - UNIX-CONNECT:/run/ws-camerad/cam1.sockClient usage:
from ws_camerad import CameraClient
from concurrent.futures import ThreadPoolExecutor
cameras = {
"cam0": CameraClient("/run/ws-camerad/cam0.sock"),
"cam1": CameraClient("/run/ws-camerad/cam1.sock"),
}
# Capture from all cameras in parallel
with ThreadPoolExecutor() as pool:
futures = {name: pool.submit(cam.capture_still) for name, cam in cameras.items()}
results = {name: f.result() for name, f in futures.items()}Resources per instance: ~4-8% CPU at 1080p30, 30-50 MB memory. Practical limits on Pi 4: 2-3 cameras at 1080p30, 4-6 at 720p30.
List cameras: libcamera-hello --list-cameras
ws-camerad --rotation 90
# Or in config
[camera]
rotation = 90| Rotation | Method | Cost |
|---|---|---|
| 0° | Identity | Free |
| 180° | ISP hardware flip | Free |
| 90°/270° | NEON SIMD 8×8 transpose | ~7ms |
The Pi's ISP only supports horizontal and vertical flip. For 90°/270°, the daemon rotates all three YUV420 planes in software using ARM NEON 8×8 block transpose with parallel processing of Y, U, and V planes.
Performance at 1280×960 @ 30fps on Cortex-A72:
| Metric | rotation=0 | rotation=90 |
|---|---|---|
| CPU per frame | ~0ms (DMABUF zero-copy) | ~7ms (NEON rotate) |
| Memory | No extra buffer | +1.8MB |
| Encoder input | DMABUF | USERPTR |
| Output dimensions | 1280×960 | 960×1280 |
All downstream consumers receive rotated frames automatically.
For NoIR camera modules (pink tint with standard AWB):
ws-camerad --tuning-file imx219_noir.jsonAvailable tuning files (resolved to /usr/share/libcamera/ipa/rpi/vc4/):
imx219_noir.json— Camera Module v2 NoIRimx477_noir.json— HQ Camera NoIRimx708_noir.json— Camera Module v3 NoIR
The tuning file can be changed while the daemon is running. This performs a warm restart of the camera and encoder (~0.5-1s frame gap) while keeping RTSP streams, shared memory, and virtual cameras alive. Clients see a brief stall then seamless recovery.
# Switch to NoIR profile at sunset
echo "SET tuning_file imx219_noir.json" | socat - UNIX-CONNECT:/run/ws-camerad/control.sock
# Switch back to standard profile at sunrise
echo "SET tuning_file imx219.json" | socat - UNIX-CONNECT:/run/ws-camerad/control.sock
# Multi-camera: send to the instance socket, e.g. /run/ws-camerad/cam0.sockAutomate with cron:
# crontab -e
30 6 * * * echo "SET tuning_file imx219.json" | socat - UNIX-CONNECT:/run/ws-camerad/control.sock
30 18 * * * echo "SET tuning_file imx219_noir.json" | socat - UNIX-CONNECT:/run/ws-camerad/control.sock| Path | Purpose |
|---|---|
/run/ws-camerad/control.sock |
Control socket |
/var/lib/ws-camerad/stills/ |
JPEG stills (default packaged config) |
/var/lib/ws-camerad/clips/ |
Video clips (default packaged config) |
/etc/ws/camerad/ |
Configuration |
/ws_camerad_frames |
Raw-frame shared memory |
/ws_camerad_frames_bgr |
BGR shared memory |
| Metric | Target | Typical |
|---|---|---|
| Frame drops | 0 | 0 |
| Capture latency | <40ms | ~33ms |
| Still capture | <25ms | ~15ms |
| CPU (720p30) | <15% | ~10% |
| Memory | Bounded | ~50MB |
GPL-2.0-or-later