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 TCP (remote). 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 (TCP)
- Hardware H.264 encoding via V4L2
- 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) (TCP)
β β β β
ββΌβββββββββ ββΌβββββββββ ββββΌβββββββββ βββββΌββββββββββ
β C++ CV β β Python β β CLI tools β β Remote β
β consumerβ β consumerβ β / scripts β β server / NVRβ
βββββββββββ βββββββββββ βββββββββββββ βββββββββββββββ
Dependencies (Raspberry Pi OS):
sudo apt update
sudo apt install -y cmake build-essential libcamera-dev libjpeg-dev pkg-configBuild:
mkdir build && cd build
cmake ..
make -j4Install:
sudo make install
sudo mkdir -p /var/ws/camerad/{stills,clips}
sudo cp config/camera-daemon.service /etc/systemd/system/
sudo systemctl daemon-reload# Run directly
./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) |
-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/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/camera_client.py framesTCP stream:
ffplay tcp://raspberry-pi:8554
ffmpeg -i tcp://raspberry-pi:8554 -c copy output.mp4/etc/ws/camerad/ws-camerad.conf:
[daemon]
socket_path = /run/ws-camerad/control.sock
stills_dir = /var/ws/camerad/stills
clips_dir = /var/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/front_door.conf
[daemon]
socket_path = /run/ws-camerad/front_door.sock
stills_dir = /var/ws/camerad/front_door/stills
clips_dir = /var/ws/camerad/front_door/clips
shm_name = /ws_camerad_frames_front_door
rtsp_port = 8554
[camera]
camera_id = 0systemd template unit:
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=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now ws-camerad@front_door
sudo systemctl enable --now ws-camerad@backyardClient usage:
from ws_camerad import CameraClient
from concurrent.futures import ThreadPoolExecutor
cameras = {
"front_door": CameraClient("/run/ws-camerad/front_door.sock"),
"backyard": CameraClient("/run/ws-camerad/backyard.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.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/ws/camerad/stills/ |
JPEG stills |
/var/ws/camerad/clips/ |
Video clips |
/etc/ws/camerad/ |
Configuration |
/camera_frames |
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