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:
@@ -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)
|
||||
Reference in New Issue
Block a user