Files
usb-camera-webui/camera/capture.py
T
mikkeli b1306bac21 Fix V4L2 mmap capture and service configuration
- capture.py: use mmap.mmap instead of non-existent os.mmap
- capture.py: fix v4l2_fourcc() to pass 4 char arguments
- camera-webui.service: add Group=video and PATH env var
2026-08-06 09:21:02 +09:00

178 lines
5.8 KiB
Python

"""V4L2 MJPEG capture via mmap buffers.
Opens /dev/video0 and uses memory-mapped V4L2 buffers for efficient frame
capture. Each frame is a complete JPEG image ready for MJPEG streaming.
"""
import fcntl
import os
import time
import threading
import mmap
import v4l2
# Fourcc for Motion JPEG
MJPG = v4l2.v4l2_fourcc("M", "J", "P", "G")
# 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 = MJPG
fmt.fmt.pix.field = v4l2.V4L2_FIELD_ANY
_ioctl(self._fd, v4l2.VIDIOC_S_FMT, fmt)
print(f"Format: {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 = mmap.mmap(self._fd, length, mmap.MAP_SHARED, mmap.PROT_READ, 0, 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)