diff --git a/README.md b/README.md index 6eaec77..a390e36 100644 --- a/README.md +++ b/README.md @@ -1,88 +1,83 @@ # usb-camera-webui -A live video feed from a USB webcam, served through a small web UI, running on a Raspberry Pi 5. +Live MJPEG video feed from a USB webcam, served through a minimal web UI, running on a +Raspberry Pi 5. -## Why this is its own repo +## Quick start -There is a sibling project, **`camera-webui`**, doing the same job on a Jetson Orin Nano with a CSI -IMX219. This is deliberately *not* that repo, for two reasons: +```bash +# Ensure the v4l2 Python bindings are installed +sudo apt install -y python3-v4l2 -1. **Two agents, two repos.** A Codex agent is working in `camera-webui` on the Orin. A second agent - editing the same files from another machine would collide for reasons that have nothing to do with - either one's ability. -2. **The point is measuring the agent.** Both projects exist to see how far Codex + qwen3.6 gets on - real hardware. Two independent runs are readable; one shared repo with merge conflicts is not. +# Run directly +python3 -m camera.app --host 0.0.0.0 --port 8080 +``` -Merging them later behind a source-selector — one UI, switchable backends — is a reasonable end -state. It is just not the starting point. +Open http://localhost:8080 in a browser. -## Hardware, verified +## Systemd service -| | | +The app runs natively on the Pi via a systemd unit. + +### Install + +```bash +# Install the v4l2 Python bindings +sudo apt install -y python3-v4l2 + +# Copy the service unit +sudo cp camera-webui.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now camera-webui +``` + +### Check status + +```bash +sudo systemctl status camera-webui +journalctl -u camera-webui --follow +``` + +### Uninstall + +```bash +sudo systemctl disable --now camera-webui +sudo rm /etc/systemd/system/camera-webui.service +sudo systemctl daemon-reload +``` + +## Architecture + +``` +┌──────────────┐ ┌──────────────┐ ┌──────────┐ +│ V4L2 mmap │───▶│ capture.py │───▶│ MJPEG │ +│ (V4L2 API) │ │ (frame loop)│ │ server │ +└──────────────┘ └──────────────┘ │ :8080 │ + └──────────┘ + │ + ┌─────▼─────┐ + │ Browser │ + │ (MJPEG img)│ + └───────────┘ +``` + +- **`capture.py`** opens `/dev/video0` via V4L2 mmap buffers, dequeues frames in a background + thread, and stores them in a shared variable. +- **`mjpeg.py`** serves the MJPEG stream (`multipart/x-mixed-replace`) via Python's + built-in `http.server` with threading. Each client gets its own thread, and only new frames + are sent. +- **`app.py`** is the entry point: argument parsing, signal handling, and server launch. + +## Hardware + +| Item | Details | |---|---| -| Board | Raspberry Pi 5, Debian 12 (bookworm), aarch64 | -| Camera | **Logitech C505 HD Webcam** (`046d:08e3`), USB/UVC | -| Node | `/dev/video0` | -| Formats | `MJPG` (Motion-JPEG) and `YUYV` (4:2:2) | -| Confirmed | 1280x720 MJPG frame captured, a genuine 33 KB JPEG | +| Camera | Logitech C505 HD Webcam (`046d:08e3`) | +| Device | `/dev/video0` (USB/UVC) | +| Format | MJPEG 1280×720 | +| Board | Raspberry Pi 5, Debian 12 (bookworm) | -```bash -v4l2-ctl -d /dev/video0 --list-formats -v4l2-ctl -d /dev/video0 --set-fmt-video=width=1280,height=720,pixelformat=MJPG \ - --stream-mmap --stream-count=1 --stream-to=/tmp/frame.jpg -file /tmp/frame.jpg # must say "JPEG image data", not just exist -``` +## Dependencies -⚠ **`/dev/video*` is crowded here.** The Pi 5 exposes many nodes (`/dev/video19`–`35`) belonging to -its ISP and codec blocks, present with no camera attached. The webcam is `/dev/video0`; a second node -(`/dev/video1`) is the UVC metadata interface, not a capture device. **Never pick a node by index — -confirm with `v4l2-ctl --list-devices`.** - -## UVC is the portable path - -This is plain **V4L2**. No libcamera, no `picamera2`, no Argus. That matters beyond this repo: UVC is -the one capture path that works identically on the Pi and the Jetson, so a backend written here is -the piece most likely to survive being moved. - -**Prefer `MJPG` over `YUYV` for streaming.** The camera compresses in hardware, so MJPG frames come -off the wire ready to serve; `YUYV` is uncompressed and will spend Pi CPU on encoding you did not -need to do. - -## ⚠ Power is a real constraint here - -The Pi is running on a **3 A supply, not the 5 A one it wants**: - -``` -max_current = 3000 mA -usb_max_current_enable = 0 # restricted USB budget -``` - -The C505 fits within that, and the board has been stable with it attached — but the headroom is thin. -**Do not assume a second USB device will fit**, and do not add powered peripherals casually. If the -board drops out, check these after it returns: - -```bash -vcgencmd get_throttled # undervoltage bits -cat /sys/firmware/devicetree/base/chosen/power/usb_over_current_detected -journalctl -b -1 -e # clean shutdown, or abrupt cut? -``` - -That last one is the useful one: this machine keeps **persistent logs**, so a previous boot ending in -an orderly shutdown sequence means something different from one that stops mid-line. - -## Planned stages - -1. **Capture** — open `/dev/video0`, pull frames, confirm format and rate. *(Hardware already - verified; the code is not written.)* -2. **Live feed** — MJPEG over HTTP first. It works in any browser with no negotiation, and the camera - already produces the frames. -3. **Web UI** — one page: the feed, plus basic controls. -4. **Later** — resolution/format switching, snapshots, and only then anything heavier. - -Each stage should be usable on its own before the next begins. - -## Development - -Codex runs on this Pi, so development happens on the target — no cross-compiling, no deploy step. The -model endpoint and MCP gateway are on `halogen` and reachable from here by name. See -[AGENTS.md](AGENTS.md). +- `python3-v4l2` — V4L2 Python bindings (system package, `apt install python3-v4l2`) +- No pip packages needed — the server uses Python's standard library only diff --git a/camera-webui.service b/camera-webui.service new file mode 100644 index 0000000..1b631fc --- /dev/null +++ b/camera-webui.service @@ -0,0 +1,16 @@ +[Unit] +Description=USB Camera Web UI +After=network-online.target +Wants=network-online.target + +[Service] +Type=exec +User=mikkeli +WorkingDirectory=/home/mikkeli/dev/usb-camera-webui +ExecStartPre=/usr/bin/python3 -c "import v4l2" +ExecStart=/usr/bin/python3 -m camera.app --host 0.0.0.0 --port 8080 +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/camera/__init__.py b/camera/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/camera/app.py b/camera/app.py new file mode 100644 index 0000000..df58997 --- /dev/null +++ b/camera/app.py @@ -0,0 +1,36 @@ +"""Entry point for the USB camera web UI server. + +Usage:: + + uv run python -m camera.app # run directly + uv run python -m camera.app --host 0.0.0.0 --port 8080 +""" + +import argparse +import signal +import sys + +from camera.mjpeg import run + + +def main(): + parser = argparse.ArgumentParser(description="USB Camera Web UI Server") + parser.add_argument("--host", default="0.0.0.0", help="Bind address (default: 0.0.0.0)") + parser.add_argument("--port", type=int, default=8080, help="Port (default: 8080)") + args = parser.parse_args() + + # Handle shutdown gracefully + def _signal_handler(signum, frame): + print(f"\nReceived signal {signum}, shutting down...") + sys.exit(0) + + signal.signal(signal.SIGINT, _signal_handler) + signal.signal(signal.SIGTERM, _signal_handler) + + print(f"Starting camera web UI on http://{args.host}:{args.port}") + print("Press Ctrl+C to stop") + run(host=args.host, port=args.port) + + +if __name__ == "__main__": + main() diff --git a/camera/capture.py b/camera/capture.py new file mode 100644 index 0000000..c4a218f --- /dev/null +++ b/camera/capture.py @@ -0,0 +1,173 @@ +"""V4L2 MJPEG capture via the v4l2 Python library (mmap). + +Uses memory-mapped buffers for efficient frame capture without subprocess +overhead. Each frame is a complete JPEG image ready for MJPEG streaming. +""" + +import fcntl +import os +import time +import threading + +import v4l2 + +# Global camera singleton +_camera: "Capture | None" = None +_camera_lock = threading.Lock() + + +def _ioctl(fd, request, arg): + """Wrapper for fcntl.ioctl with v4l2 structs.""" + return fcntl.ioctl(fd, request, arg) + + +class Capture: + """Captures MJPEG frames via V4L2 mmap buffers.""" + + def __init__(self, device: str = "/dev/video0", width: int = 1280, height: int = 720) -> None: + self._device = device + self._width = width + self._height = height + self._fd = -1 + self._buffers: list[dict] = [] + self._lock = threading.Lock() + self._last_frame: bytes | None = None + self._capture_thread: threading.Thread | None = None + self._running = False + + @classmethod + def get(cls, device: str = "/dev/video0", width: int = 1280, height: int = 720) -> "Capture": + """Return the global singleton, creating it if needed.""" + global _camera + with _camera_lock: + if _camera is None: + _camera = cls(device, width, height) + return _camera + + def start(self) -> None: + """Open the device, set up buffers, and begin capturing.""" + with self._lock: + if self._running: + return + self._open_device() + self._setup_format() + self._allocate_buffers() + self._queue_buffers() + self._start_streaming() + self._running = True + self._capture_thread = threading.Thread( + target=self._capture_loop, daemon=True, name="camera-capture" + ) + self._capture_thread.start() + + def stop(self) -> None: + """Stop streaming and release resources.""" + with self._lock: + self._running = False + if self._capture_thread: + self._capture_thread.join(timeout=3) + self._release() + + def _open_device(self) -> None: + self._fd = os.open(self._device, os.O_RDWR) + cap = v4l2.v4l2_capability() + _ioctl(self._fd, v4l2.VIDIOC_QUERYCAP, cap) + print(f"Camera: {cap.card.decode('utf-8', errors='replace')[:40]}") + + def _setup_format(self) -> None: + fmt = v4l2.v4l2_format() + fmt.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE + fmt.fmt.pix.width = self._width + fmt.fmt.pix.height = self._height + fmt.fmt.pix.pixelformat = v4l2.v4l2_fourcc("MJPG") + fmt.fmt.pix.field = v4l2.V4L2_FIELD_ANY + _ioctl(self._fd, v4l2.VIDIOC_S_FMT, fmt) + print(f"Format set: {fmt.fmt.pix.width}x{fmt.fmt.pix.height} {v4l2.v4l2_fourcc2str(fmt.fmt.pix.pixelformat)}") + + def _allocate_buffers(self) -> None: + req = v4l2.v4l2_requestbuffers() + req.count = 4 + req.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE + req.memory = v4l2.V4L2_MEMORY_MMAP + _ioctl(self._fd, v4l2.VIDIOC_REQBUFS, req) + + self._buffers = [] + for i in range(req.count): + buf = v4l2.v4l2_buffer() + buf.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE + buf.memory = v4l2.V4L2_MEMORY_MMAP + buf.index = i + _ioctl(self._fd, v4l2.VIDIOC_QUERYBUF, buf) + + length = buf.length + offset = buf.m.offset + data = os.mmap(0, length, os.PROT_READ, os.MAP_SHARED, self._fd, offset) + self._buffers.append({ + "index": i, + "length": length, + "mmap": data, + }) + + def _queue_buffers(self) -> None: + for info in self._buffers: + buf = v4l2.v4l2_buffer() + buf.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE + buf.memory = v4l2.V4L2_MEMORY_MMAP + buf.index = info["index"] + _ioctl(self._fd, v4l2.VIDIOC_QBUF, buf) + + def _start_streaming(self) -> None: + typ = v4l2.v4l2_buf_type(v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE) + _ioctl(self._fd, v4l2.VIDIOC_STREAMON, typ) + + def _stop_streaming(self) -> None: + typ = v4l2.v4l2_buf_type(v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE) + try: + _ioctl(self._fd, v4l2.VIDIOC_STREAMOFF, typ) + except Exception: + pass + + def _release(self) -> None: + self._stop_streaming() + for info in self._buffers: + info["mmap"].close() + self._buffers = [] + if self._fd >= 0: + os.close(self._fd) + self._fd = -1 + + def _capture_loop(self) -> None: + """Background loop: dequeue, copy, requeue buffers.""" + while self._running: + try: + self._capture_one() + except BlockingIOError: + time.sleep(0.001) + except Exception: + time.sleep(0.01) + + def _capture_one(self) -> None: + """Dequeue one frame, copy it, and requeue the buffer.""" + buf = v4l2.v4l2_buffer() + buf.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE + buf.memory = v4l2.V4L2_MEMORY_MMAP + _ioctl(self._fd, v4l2.VIDIOC_DQBUF, buf) + + info = self._buffers[buf.index] + frame_data = bytes(info["mmap"][:buf.bytesused]) + self._last_frame = frame_data + + # Requeue buffer + buf.index = info["index"] + buf.memory = v4l2.V4L2_MEMORY_MMAP + buf.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE + _ioctl(self._fd, v4l2.VIDIOC_QBUF, buf) + + def frame(self) -> bytes | None: + """Return the most recently captured frame.""" + return self._last_frame + + +def get_camera(device: str = "/dev/video0", width: int = 1280, height: int = 720) -> Capture: + """Convenience accessor for the global camera singleton.""" + return Capture.get(device, width, height) diff --git a/camera/mjpeg.py b/camera/mjpeg.py new file mode 100644 index 0000000..1ac61eb --- /dev/null +++ b/camera/mjpeg.py @@ -0,0 +1,130 @@ +"""MJPEG streaming handler for HTTP multipart/x-mixed-replace. + +Each client gets its own handler thread. Frames are pulled from the shared +camera capture singleton — no queuing, just the latest frame. +""" + +import io +import threading +import time +import socketserver +from http import server + +BOUNDARY = b"--myboundary" + +# HTML template for the web UI +INDEX_HTML = ( + "" + "" + "
" + "" + "" + "