Add MJPEG camera server with systemd deployment
- camera/capture.py: V4L2 mmap-based MJPEG frame capture with background thread - camera/mjpeg.py: MJPEG streaming server (multipart/x-mixed-replace) + minimal web UI - camera/app.py: Entry point with argument parsing and signal handling - pyproject.toml: uv project config (no external dependencies) - camera-webui.service: systemd unit for native Pi deployment - README.md: Updated for systemd-only direction with install instructions
This commit is contained in:
@@ -1,88 +1,83 @@
|
|||||||
# usb-camera-webui
|
# 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
|
```bash
|
||||||
IMX219. This is deliberately *not* that repo, for two reasons:
|
# 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
|
# Run directly
|
||||||
editing the same files from another machine would collide for reasons that have nothing to do with
|
python3 -m camera.app --host 0.0.0.0 --port 8080
|
||||||
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.
|
|
||||||
|
|
||||||
Merging them later behind a source-selector — one UI, switchable backends — is a reasonable end
|
Open http://localhost:8080 in a browser.
|
||||||
state. It is just not the starting point.
|
|
||||||
|
|
||||||
## 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`) |
|
||||||
| Camera | **Logitech C505 HD Webcam** (`046d:08e3`), USB/UVC |
|
| Device | `/dev/video0` (USB/UVC) |
|
||||||
| Node | `/dev/video0` |
|
| Format | MJPEG 1280×720 |
|
||||||
| Formats | `MJPG` (Motion-JPEG) and `YUYV` (4:2:2) |
|
| Board | Raspberry Pi 5, Debian 12 (bookworm) |
|
||||||
| Confirmed | 1280x720 MJPG frame captured, a genuine 33 KB JPEG |
|
|
||||||
|
|
||||||
```bash
|
## Dependencies
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
⚠ **`/dev/video*` is crowded here.** The Pi 5 exposes many nodes (`/dev/video19`–`35`) belonging to
|
- `python3-v4l2` — V4L2 Python bindings (system package, `apt install python3-v4l2`)
|
||||||
its ISP and codec blocks, present with no camera attached. The webcam is `/dev/video0`; a second node
|
- No pip packages needed — the server uses Python's standard library only
|
||||||
(`/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).
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
+130
@@ -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 = (
|
||||||
|
"<!DOCTYPE html>"
|
||||||
|
"<html lang='en'>"
|
||||||
|
"<head>"
|
||||||
|
"<meta charset='utf-8'>"
|
||||||
|
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
||||||
|
"<title>Camera Feed</title>"
|
||||||
|
"<style>"
|
||||||
|
"* { margin: 0; padding: 0; box-sizing: border-box; }"
|
||||||
|
"html, body { width: 100%; height: 100%; background: #111; overflow: hidden; }"
|
||||||
|
"#feed { display: block; width: 100vw; height: 100vh; object-fit: contain; }"
|
||||||
|
"#status { position: fixed; top: 12px; left: 12px; z-index: 10;"
|
||||||
|
" color: #ccc; font: 13px/1 system-ui, sans-serif; display: flex;"
|
||||||
|
" align-items: center; gap: 6px; opacity: 1; transition: opacity 0.3s; }"
|
||||||
|
"#status.hidden { opacity: 0; }"
|
||||||
|
"#spinner { width: 12px; height: 12px; border: 2px solid #555;"
|
||||||
|
" border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite;"
|
||||||
|
" flex-shrink: 0; }"
|
||||||
|
"@keyframes spin { to { transform: rotate(360deg); } }"
|
||||||
|
"</style>"
|
||||||
|
"</head>"
|
||||||
|
"<body>"
|
||||||
|
"<div id='status'><div id='spinner'></div><span id='label'>Connecting...</span></div>"
|
||||||
|
"<img id='feed' src='/stream' onerror='document.getElementById(\"label\").textContent=\"Stream error\"' />"
|
||||||
|
"</body>"
|
||||||
|
"</html>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MJPEGHandler(server.BaseHTTPRequestHandler):
|
||||||
|
"""Serve a single MJPEG stream to one HTTP client."""
|
||||||
|
|
||||||
|
server_version = "CameraWebUI/0.1"
|
||||||
|
|
||||||
|
def log_message(self, format, *args): # noqa: A002
|
||||||
|
"""Suppress per-request logging to reduce noise."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path == "/" or self.path == "":
|
||||||
|
self._serve_index()
|
||||||
|
elif self.path == "/stream":
|
||||||
|
self._serve_stream()
|
||||||
|
else:
|
||||||
|
self.send_error(404)
|
||||||
|
|
||||||
|
def _serve_index(self):
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(INDEX_HTML.encode("utf-8"))
|
||||||
|
|
||||||
|
def _serve_stream(self):
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header(
|
||||||
|
"Content-Type", "multipart/x-mixed-replace; boundary=%s" % BOUNDARY.decode()
|
||||||
|
)
|
||||||
|
self.send_header("Cache-Control", "no-cache")
|
||||||
|
self.send_header("Connection", "close")
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
from camera.capture import get_camera
|
||||||
|
|
||||||
|
cam = get_camera()
|
||||||
|
try:
|
||||||
|
last_frame = None
|
||||||
|
while True:
|
||||||
|
frame = cam.frame()
|
||||||
|
if frame is not None and frame is not last_frame:
|
||||||
|
self._write_frame(frame)
|
||||||
|
last_frame = frame
|
||||||
|
else:
|
||||||
|
time.sleep(0.05)
|
||||||
|
except (BrokenPipeError, ConnectionResetError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _write_frame(self, frame: bytes) -> None:
|
||||||
|
"""Write one MJPEG frame with boundary and headers."""
|
||||||
|
w = self.wfile
|
||||||
|
w.write(BOUNDARY)
|
||||||
|
w.write(b"\r\n")
|
||||||
|
w.write(b"Content-Type: image/jpeg\r\n")
|
||||||
|
w.write(b"Content-Length: %d\r\n\r\n" % len(frame))
|
||||||
|
w.write(frame)
|
||||||
|
w.write(b"\r\n")
|
||||||
|
w.flush()
|
||||||
|
|
||||||
|
|
||||||
|
class ThreadedServer(socketserver.ThreadingMixIn, server.HTTPServer):
|
||||||
|
"""HTTP server that handles each request in a new thread."""
|
||||||
|
|
||||||
|
daemon_threads = True
|
||||||
|
allow_reuse_address = True
|
||||||
|
|
||||||
|
|
||||||
|
def run(host: str = "0.0.0.0", port: int = 8080):
|
||||||
|
"""Start the HTTP server and block."""
|
||||||
|
from camera.capture import get_camera
|
||||||
|
|
||||||
|
cam = get_camera()
|
||||||
|
try:
|
||||||
|
cam.start()
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"Error: {cam._device} not found — is the camera connected?")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
server = ThreadedServer((host, port), MJPEGHandler)
|
||||||
|
print(f"Server running on http://{host}:{port}")
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
cam.stop()
|
||||||
|
server.server_close()
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[project]
|
||||||
|
name = "usb-camera-webui"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Live MJPEG video feed from a USB webcam, served via a minimal web UI"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
camera-webui = "camera.app:main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
Reference in New Issue
Block a user