diff --git a/examples/video/qt/widget.py b/examples/video/qt/widget.py index 3c1a53d..972fc7b 100644 --- a/examples/video/qt/widget.py +++ b/examples/video/qt/widget.py @@ -16,12 +16,12 @@ import cv2 from qtpy import QtCore, QtGui, QtWidgets -from linuxpy.video.device import Capability, Device, PixelFormat, VideoCapture +from linuxpy.video.device import Device, MemoryMap, PixelFormat, ReadSource, VideoCapture MODES = { "auto": None, - "mmap": Capability.STREAMING, - "read": Capability.READWRITE, + "mmap": MemoryMap, + "read": ReadSource, } @@ -153,7 +153,7 @@ def update(): timer.timeout.connect(update) with device: - capture = VideoCapture(device, source=MODES[args.mode], size=args.nb_buffers) + capture = VideoCapture(device, buffer_type=MODES[args.mode], size=args.nb_buffers) capture.set_fps(args.frame_rate) capture.set_format(width, height, args.frame_format) with capture: diff --git a/linuxpy/video/device.py b/linuxpy/video/device.py index a7b05c7..6832d46 100644 --- a/linuxpy/video/device.py +++ b/linuxpy/video/device.py @@ -96,7 +96,7 @@ def human_pixel_format(ifmt): MetaFmt = collections.namedtuple("MetaFmt", "format max_buffer_size width height bytes_per_line") -Format = collections.namedtuple("Format", "width height pixel_format size") +Format = collections.namedtuple("Format", "width height pixel_format size bytes_per_line") CropCapability = collections.namedtuple("CropCapability", "type bounds defrect pixel_aspect") @@ -495,6 +495,7 @@ def get_format(fd, buffer_type) -> Union[Format, MetaFmt]: height=f.fmt.pix.height, pixel_format=PixelFormat(f.fmt.pix.pixelformat), size=f.fmt.pix.sizeimage, + bytes_per_line=f.fmt.pix.bytesperline, ) diff --git a/linuxpy/video/qt.py b/linuxpy/video/qt.py index 3fc4c9a..c9bd674 100644 --- a/linuxpy/video/qt.py +++ b/linuxpy/video/qt.py @@ -78,11 +78,18 @@ def register(self, camera, type): event_mask |= select.EPOLLIN if type == "all" or type == "control": event_mask |= select.EPOLLPRI - if fd in self.cameras: + registered = fd in self.cameras + self.cameras[fd] = camera + if registered: self.epoll.modify(fd, event_mask) else: self.epoll.register(fd, event_mask) - self.cameras[fd] = camera + + def unregister(self, camera): + fd = camera.device.fileno() + camera = self.cameras.pop(fd, None) + if camera: + self.epoll.unregister(fd) def loop(self): errno = 0 @@ -104,27 +111,32 @@ def loop(self): dispatcher = Dispatcher() -class MemMap(MemoryMap): +class QimageMemory(MemoryMap): def open(self): super().open() - self.opencv = False + self.data = None + self.opencv = None format = self.format - if pixel_format := FORMAT_MAP.get(format.pixel_format): + if format.pixel_format in FORMAT_MAP: self.data = bytearray(format.size) - self.qimage = QtGui.QImage(self.data, format.width, format.height, pixel_format) - elif format in OPENCV_FORMATS: - self.data = bytearray(format.width * format.height * 3) - self.qimage = QtGui.QImage(self.data, format.width, format.height, QtGui.QImage.Format.Format_RGB888) - self.opencv = True - else: - self.data = None - self.qimage = None + elif format.pixel_format in OPENCV_FORMATS: + self.data = bytearray(format.size) + self.opencv = create_opencv_buffer(format) def raw_read(self, into=None): frame = super().raw_read(self.data) - if self.data and frame.pixel_format in OPENCV_FORMATS: - frame_to_rgb24(frame, self.data) - frame.user_data = self.qimage + qimage = None + format = self.format + if self.data is not None: + if self.opencv is None: + data = self.data + qformat = FORMAT_MAP.get(format.pixel_format) + else: + data = self.opencv + opencv_frame_to_rgb24(frame, data) + qformat = QtGui.QImage.Format.Format_RGB888 + qimage = QtGui.QImage(data, format.width, format.height, qformat) + frame.user_data = qimage return frame @@ -135,13 +147,31 @@ class QCamera(QtCore.QObject): def __init__(self, device: Device): super().__init__() self.device = device - self.capture = VideoCapture(device, buffer_type=MemMap) + self.capture = VideoCapture(device, buffer_type=QimageMemory) self._stop = False self._stream = None self._state = "stopped" - dispatcher.register(self, "control") self.controls = {} + def __enter__(self): + self.device.open() + dispatcher.register(self, "control") + return self + + def __exit__(self, *exc): + dispatcher.unregister(self) + self.device.close() + + @classmethod + def from_id(cls, did: int, **kwargs): + device = Device.from_id(did, **kwargs) + return cls(device) + + @classmethod + def from_path(cls, path, **kwargs): + device = Device(path, **kwargs) + return cls(device) + def handle_frame(self): if self._stream is None: return @@ -210,7 +240,6 @@ def qcontrol(self, name_or_id: str | int): class QVideoStream(QtCore.QObject): - qimage = None imageChanged = QtCore.Signal(object) def __init__(self, camera: QCamera | None = None): @@ -219,8 +248,8 @@ def __init__(self, camera: QCamera | None = None): self.set_camera(camera) def on_frame(self, frame): - self.qimage = frame.user_data or frame_to_qimage(frame) - self.imageChanged.emit(self.qimage) + qimage = frame.user_data or frame_to_qimage(frame) + self.imageChanged.emit(qimage) def set_camera(self, camera: QCamera | None = None): if self.camera: @@ -610,7 +639,15 @@ def to_qpixelformat(pixel_format: PixelFormat) -> QtGui.QPixelFormat | None: } -def frame_to_rgb24(frame, into=None): +def create_opencv_buffer(format): + import numpy + + depth = 3 + dtype = numpy.ubyte + return numpy.empty((format.height, format.width, depth), dtype=dtype) + + +def opencv_frame_to_rgb24(frame, into=None): import cv2 YUV_MAP = { @@ -623,11 +660,12 @@ def frame_to_rgb24(frame, into=None): } data = frame.array fmt = frame.pixel_format - if fmt in {PixelFormat.NV12, PixelFormat.YUV420}: + if fmt in {PixelFormat.NV12, PixelFormat.NV21, PixelFormat.YUV420}: data.shape = frame.height * 3 // 2, frame.width, -1 else: data.shape = frame.height, frame.width, -1 - return cv2.cvtColor(data, YUV_MAP[fmt], dst=into) + result = cv2.cvtColor(data, YUV_MAP[fmt], dst=into) + return result def frame_to_qimage(frame: Frame) -> QtGui.QImage | None: @@ -637,7 +675,7 @@ def frame_to_qimage(frame: Frame) -> QtGui.QImage | None: return QtGui.QImage.fromData(data, "JPG") if frame.pixel_format in OPENCV_FORMATS: - data = frame_to_rgb24(frame) + data = opencv_frame_to_rgb24(frame) fmt = QtGui.QImage.Format.Format_RGB888 else: if (fmt := FORMAT_MAP.get(frame.pixel_format)) is None: @@ -678,9 +716,6 @@ def draw_no_image(painter, width, height, line_width=4): return draw_no_image_rect(painter, rect, line_width) -def draw_frame(widget, frame): ... - - class BaseCameraControl: def __init__(self, camera: QCamera | None = None): self.camera = None @@ -892,8 +927,7 @@ def stop(): fmt = "%(threadName)-10s %(asctime)-15s %(levelname)-5s %(name)s: %(message)s" logging.basicConfig(level=args.log_level.upper(), format=fmt) app = QtWidgets.QApplication([]) - with Device.from_id(args.device, blocking=False) as device: - camera = QCamera(device) + with QCamera.from_id(args.device, blocking=False) as camera: window = QtWidgets.QWidget() layout = QtWidgets.QHBoxLayout(window) video = QVideoWidget(camera) diff --git a/tests/test_video_qt.py b/tests/test_video_qt.py index d5a8dd7..fb40487 100644 --- a/tests/test_video_qt.py +++ b/tests/test_video_qt.py @@ -6,7 +6,7 @@ except Exception: qt = None -from linuxpy.video.device import Capability, Device, PixelFormat, VideoCapture +from linuxpy.video.device import Capability, PixelFormat, VideoCapture qt_only = pytest.mark.skipif(qt is None, reason="qt not properly installed") pytestmark = [qt_only, vivid_only] @@ -18,14 +18,14 @@ def check_cannot(action, state): getattr(qcamera, action)() assert error.value.args[0] == f"Cannot {action} when camera is {state}" - with Device(VIVID_CAPTURE_DEVICE) as capture_dev: + with qt.QCamera.from_path(VIVID_CAPTURE_DEVICE) as qcamera: + capture_dev = qcamera.device capture_dev.set_input(0) width, height, pixel_format = 640, 480, PixelFormat.RGB24 capture = VideoCapture(capture_dev) capture.set_format(width, height, pixel_format) capture.set_fps(30) - qcamera = qt.QCamera(capture_dev) assert qcamera.state() == "stopped" check_cannot("pause", "stopped") @@ -68,7 +68,8 @@ def check_cannot(action, state): @pytest.mark.parametrize("pixel_format", [PixelFormat.RGB24, PixelFormat.RGB32, PixelFormat.ARGB32, PixelFormat.YUYV]) def test_qvideo_widget(qtbot, pixel_format): - with Device(VIVID_CAPTURE_DEVICE) as capture_dev: + with qt.QCamera.from_path(VIVID_CAPTURE_DEVICE) as qcamera: + capture_dev = qcamera.device capture_dev.set_input(0) width, height = 640, 480 capture = VideoCapture(capture_dev) @@ -92,7 +93,6 @@ def test_qvideo_widget(qtbot, pixel_format): assert status.args[0] == "running" assert qcamera.state() == "running" check_frame(frames.args[0], width, height, pixel_format, Capability.STREAMING) - assert widget.video.frame is frames.args[0] assert play_button.toolTip() == "Camera is running. Press to stop it" qtbot.waitUntil(lambda: widget.video.qimage is not None) diff --git a/uv.lock b/uv.lock index e4e059a..973c9d7 100644 --- a/uv.lock +++ b/uv.lock @@ -729,6 +729,23 @@ dev = [ { name = "ruff" }, { name = "twine" }, ] +dev-no-qt = [ + { name = "build" }, + { name = "gevent" }, + { name = "mkdocs" }, + { name = "mkdocs-coverage" }, + { name = "mkdocs-material" }, + { name = "mkdocstrings", extra = ["python"] }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "twine" }, +] docs = [ { name = "mkdocs" }, { name = "mkdocs-coverage" }, @@ -805,6 +822,22 @@ dev = [ { name = "ruff", specifier = ">=0.3.0" }, { name = "twine", specifier = ">=4.0.2" }, ] +dev-no-qt = [ + { name = "build", specifier = ">=0.10.0" }, + { name = "gevent", specifier = ">=21" }, + { name = "mkdocs" }, + { name = "mkdocs-coverage" }, + { name = "mkdocs-material" }, + { name = "mkdocstrings", extras = ["python"] }, + { name = "numpy", specifier = ">=1.1" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "pytest", specifier = ">=8.1" }, + { name = "pytest-asyncio", specifier = ">=1.0.0" }, + { name = "pytest-cov", specifier = ">=6" }, + { name = "ruff", specifier = ">=0.3.0" }, + { name = "twine", specifier = ">=4.0.2" }, +] docs = [ { name = "mkdocs" }, { name = "mkdocs-coverage" }, @@ -1639,6 +1672,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b0/1e/c6a28a142f14e735088534cc92951c3f48cccd77cdd4f3b10d7996be420f/pyqt6_sip-13.10.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:3dde8024d055f496eba7d44061c5a1ba4eb72fc95e5a9d7a0dbc908317e0888b", size = 303833, upload-time = "2025-05-23T12:26:41.075Z" }, { url = "https://files.pythonhosted.org/packages/89/63/e5adf350c1c3123d4865c013f164c5265512fa79f09ad464fb2fdf9f9e61/pyqt6_sip-13.10.2-cp313-cp313-win_amd64.whl", hash = "sha256:0b097eb58b4df936c4a2a88a2f367c8bb5c20ff049a45a7917ad75d698e3b277", size = 53527, upload-time = "2025-05-23T12:26:42.625Z" }, { url = "https://files.pythonhosted.org/packages/58/74/2df4195306d050fbf4963fb5636108a66e5afa6dc05fd9e81e51ec96c384/pyqt6_sip-13.10.2-cp313-cp313-win_arm64.whl", hash = "sha256:cc6a1dfdf324efaac6e7b890a608385205e652845c62130de919fd73a6326244", size = 45373, upload-time = "2025-05-23T12:26:43.536Z" }, + { url = "https://files.pythonhosted.org/packages/23/57/74b4eb7a51b9133958daa8409b55de95e44feb694d4e2e3eba81a070ca20/pyqt6_sip-13.10.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8a76a06a8e5c5b1f17a3f6f3c834ca324877e07b960b18b8b9bbfd9c536ec658", size = 112354, upload-time = "2025-10-08T08:44:00.22Z" }, + { url = "https://files.pythonhosted.org/packages/f2/cb/fdef02e0d6ee8443a9683a43650d61c6474b634b6ae6e1c6f097da6310bf/pyqt6_sip-13.10.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9128d770a611200529468397d710bc972f1dcfe12bfcbb09a3ccddcd4d54fa5b", size = 323488, upload-time = "2025-10-08T08:44:01.965Z" }, + { url = "https://files.pythonhosted.org/packages/8c/5b/8ede8d6234c3ea884cbd097d7d47ff9910fb114efe041af62b4453acd23b/pyqt6_sip-13.10.2-cp314-cp314-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:d820a0fae7315932c08f27dc0a7e33e0f50fe351001601a8eb9cf6f22b04562e", size = 303881, upload-time = "2025-10-08T08:44:04.086Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/b5e78b072d1594643b0f1ff348f2bf54d4adb5a3f9b9f0989c54e33238d6/pyqt6_sip-13.10.2-cp314-cp314-win_amd64.whl", hash = "sha256:3213bb6e102d3842a3bb7e59d5f6e55f176c80880ff0b39d0dac0cfe58313fb3", size = 55098, upload-time = "2025-10-08T08:44:08.943Z" }, + { url = "https://files.pythonhosted.org/packages/e2/91/357e9fcef5d830c3d50503d35e0357818aca3540f78748cc214dfa015d00/pyqt6_sip-13.10.2-cp314-cp314-win_arm64.whl", hash = "sha256:ce33ff1f94960ad4b08035e39fa0c3c9a67070bec39ffe3e435c792721504726", size = 46088, upload-time = "2025-10-08T08:44:10.014Z" }, ] [[package]]