diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30b6d3d..07c96a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: strategy: matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - name: Checkout @@ -44,7 +44,7 @@ jobs: - name: Install dependencies if: steps.cache.outputs.cache-hit != 'true' run: | - pip install --disable-pip-version-check -e . --group=dev + pip install --disable-pip-version-check -e . --group=dev_no_qt - name: Linting run: | ruff check --diff --output-format=github linuxpy tests examples @@ -56,7 +56,7 @@ jobs: - name: Tests id: tests run: | - pytest + pytest -k "not qt" - name: Upload coverage data uses: actions/upload-artifact@v4 diff --git a/linuxpy/codegen/video.py b/linuxpy/codegen/video.py index 5c9c579..bc552e1 100644 --- a/linuxpy/codegen/video.py +++ b/linuxpy/codegen/video.py @@ -120,7 +120,7 @@ class StandardID(enum.IntFlag): CEnum("TimeCodeType", "V4L2_TC_TYPE_"), CEnum("TimeCodeFlag", "V4L2_TC_FLAG_", "IntFlag"), CEnum("EventSubscriptionFlag", "V4L2_EVENT_SUB_FL_", "IntFlag"), - CEnum("EventControlChange", "V4L2_EVENT_CTRL_CH_"), + CEnum("EventControlChange", "V4L2_EVENT_CTRL_CH_", "IntFlag"), CEnum("EventType", "V4L2_EVENT_"), CEnum("MbusFrameFormatFlag", "V4L2_MBUS_FRAMEFMT_", "IntFlag"), # It is very dificult to match just only these two values using prefix, so put whole name there diff --git a/linuxpy/device.py b/linuxpy/device.py index 4667c90..c1f02ac 100644 --- a/linuxpy/device.py +++ b/linuxpy/device.py @@ -81,10 +81,11 @@ class BaseDevice(ReentrantOpen): PREFIX = None - def __init__(self, name_or_file, read_write=True, io=IO): + def __init__(self, name_or_file, read_write=True, io=IO, blocking=False): super().__init__() self.io = io - if isinstance(name_or_file, (str, pathlib.Path)): + self.blocking = blocking + if isinstance(name_or_file, str | pathlib.Path): filename = pathlib.Path(name_or_file) self._read_write = read_write self._fobj = None @@ -121,12 +122,12 @@ def open(self): """Open the device if not already open. Triggers _on_open after the underlying OS open has succeeded""" if not self._fobj: self.log.info("opening %s", self.filename) - self._fobj = self.io.open(self.filename, self._read_write) + self._fobj = self.io.open(self.filename, self._read_write, blocking=self.blocking) self._on_open() self.log.info("opened %s", self.filename) def close(self): - """Closes the device if not already closed. Triggers _on_close before the underlying OS open has succeeded""" + """Closes the device if not already closed. Triggers _on_close before the underlying OS close has succeeded""" if not self.closed: self._on_close() self.log.info("closing %s", self.filename) @@ -149,4 +150,12 @@ def is_blocking(self) -> bool: True if the underlying OS is opened in blocking mode. Raises error if device is not opened. """ - return os.get_blocking(self.fileno()) + return self.io.os.get_blocking(self.fileno()) + + def set_blocking(self, yes_no: bool) -> None: + """ + Turns on/off blocking mode. + Raises error if device is not opened. + """ + self.io.os.set_blocking(self.fileno(), yes_no) + self.blocking = yes_no diff --git a/linuxpy/io.py b/linuxpy/io.py index d0a109b..44d8216 100644 --- a/linuxpy/io.py +++ b/linuxpy/io.py @@ -5,6 +5,7 @@ # Distributed under the GPLv3 license. See LICENSE for more info. import functools +import importlib import os import select @@ -33,19 +34,34 @@ def opener(path, flags): class IO: open = functools.partial(fopen, blocking=False) - select = select.select + os = os + select = select + + +class GeventModule: + def __init__(self, name): + self.name = name + self._module = None + + @property + def module(self): + if self._module is None: + self._module = importlib.import_module(f"gevent.{self.name}") + return self._module + + def __getattr__(self, name): + attr = getattr(self.module, name) + setattr(self, name, attr) + return attr class GeventIO: @staticmethod - def open(path, rw=False): + def open(path, rw=False, blocking=False): mode = "rb+" if rw else "rb" import gevent.fileobject return gevent.fileobject.FileObject(path, mode, buffering=0) - @staticmethod - def select(*args, **kwargs): - import gevent.select - - return gevent.select.select(*args, **kwargs) + os = GeventModule("os") + select = GeventModule("select") diff --git a/linuxpy/ioctl.py b/linuxpy/ioctl.py index a6486d5..2792b34 100644 --- a/linuxpy/ioctl.py +++ b/linuxpy/ioctl.py @@ -6,6 +6,7 @@ """ioctl helper functions""" +import enum import fcntl import logging @@ -34,7 +35,7 @@ def IOC(direction, magic, number, size): magic = ord(magic) if isinstance(size, str): size = calcsize(size) - elif size == int: + elif size is int: size = 4 elif not isinstance(size, int): size = sizeof(size) @@ -63,6 +64,7 @@ def IOWR(magic, number, size): def ioctl(fd, request, *args): - log.debug("%s, request=%s, arg=%s", fd, request, args) + req = request.name if isinstance(request, enum.Enum) else request + log.debug("request=%s, arg=%s", req, args) fcntl.ioctl(fd, request, *args) return args and args[0] or None diff --git a/linuxpy/midi/device.py b/linuxpy/midi/device.py index ed94a31..b12e8e1 100644 --- a/linuxpy/midi/device.py +++ b/linuxpy/midi/device.py @@ -529,7 +529,7 @@ def wait_read(self) -> Sequence["Event"]: non-blocking variants transperently """ if self.io.select is not None: - self.io.select((self,), (), ()) + self.io.select.select((self,), (), ()) return self.raw_read() def read(self) -> Sequence["Event"]: diff --git a/linuxpy/video/device.py b/linuxpy/video/device.py index e50cfa8..998b718 100644 --- a/linuxpy/video/device.py +++ b/linuxpy/video/device.py @@ -68,7 +68,6 @@ def _enum(name, prefix, klass=enum.IntEnum): InputType = raw.InputType PixelFormat = raw.PixelFormat MetaFormat = raw.MetaFormat -FrameSizeType = raw.Frmsizetypes Memory = raw.Memory InputStatus = raw.InputStatus OutputType = raw.OutputType @@ -113,6 +112,8 @@ def human_pixel_format(ifmt): Standard = collections.namedtuple("Standard", "index id name frameperiod framelines") +FrameSize = collections.namedtuple("FrameSize", "index pixel_format type info") + CROP_BUFFER_TYPES = { BufferType.VIDEO_CAPTURE, @@ -251,20 +252,24 @@ def iter_read_frame_intervals(fd, fmt, w, h): ) -def iter_read_discrete_frame_sizes(fd, pixel_format): +def iter_read_frame_sizes(fd, pixel_format): size = raw.v4l2_frmsizeenum() size.index = 0 size.pixel_format = pixel_format for val in iter_read(fd, IOC.ENUM_FRAMESIZES, size): - if size.type != FrameSizeType.DISCRETE: - break - yield val + type = FrameSizeType(val.type) + if type == FrameSizeType.DISCRETE: + info = val.m1.discrete + else: + info = val.m1.stepwise + yield FrameSize(val.index, PixelFormat(val.pixel_format), FrameSizeType(val.type), info) def iter_read_pixel_formats_frame_intervals(fd, pixel_formats): for pixel_format in pixel_formats: - for size in iter_read_discrete_frame_sizes(fd, pixel_format): - yield from iter_read_frame_intervals(fd, pixel_format, size.discrete.width, size.discrete.height) + for size in iter_read_frame_sizes(fd, pixel_format): + if size.type == FrameSizeType.DISCRETE: + yield from iter_read_frame_intervals(fd, pixel_format, size.info.width, size.info.height) def read_capabilities(fd): @@ -882,10 +887,10 @@ def enqueue_buffers(fd, buffer_type: BufferType, memory: Memory, count: int) -> class Device(BaseDevice): PREFIX = "/dev/video" - def __init__(self, name_or_file, read_write=True, io=IO): + def __init__(self, name_or_file, read_write=True, io=IO, blocking=False): self.info = None self.controls = None - super().__init__(name_or_file, read_write=read_write, io=io) + super().__init__(name_or_file, read_write=read_write, io=io, blocking=blocking) def __iter__(self): with VideoCapture(self) as stream: @@ -1008,6 +1013,12 @@ def set_std(self, std): def query_std(self) -> StandardID: return query_std(self) + def clone(self) -> Self: + fd = os.dup(self.fileno()) + fobj = self.io.os.fdopen(fd, "rb+", buffering=0) + fobj.name = self._fobj.name + return type(self)(fobj) + class SubDevice(BaseDevice): def get_format(self, pad: int = 0) -> SubdevFormat: @@ -1030,7 +1041,7 @@ def __init__(self, device: Device): def _init_if_needed(self): if not self._initialized: - self._load() + self.refresh() self.__dict__["_initialized"] = True def __getitem__(self, name): @@ -1041,7 +1052,7 @@ def __len__(self): self._init_if_needed() return super().__len__() - def _load(self): + def refresh(self): ctrl_type_map = { ControlType.BOOLEAN: BooleanControl, ControlType.INTEGER: IntegerControl, @@ -1092,6 +1103,10 @@ def __missing__(self, key): return v raise KeyError(key) + def items(self): + self._init_if_needed() + return super().items() + def values(self): self._init_if_needed() return super().values() @@ -1128,7 +1143,7 @@ def set_clipping(self, clipping: bool) -> None: class BaseControl: - def __init__(self, device, info, control_class): + def __init__(self, device, info, control_class, klass=None): self.device = device self._info = info self.id = self._info.id @@ -1137,6 +1152,9 @@ def __init__(self, device, info, control_class): self.control_class = control_class self.type = ControlType(self._info.type) self.flags = ControlFlag(self._info.flags) + self.minimum = self._info.minimum + self.maximum = self._info.maximum + self.step = self._info.step try: self.standard = ControlID(self.id) @@ -1293,9 +1311,6 @@ class BaseNumericControl(BaseMonoControl): def __init__(self, device, info, control_class, clipping=True): super().__init__(device, info, control_class) - self.minimum = self._info.minimum - self.maximum = self._info.maximum - self.step = self._info.step self.clipping = clipping if self.minimum < self.lower_bound: @@ -1438,6 +1453,9 @@ def value(self): def value(self, value): set_controls_values(self.device, ((self._info, value),)) + def set_to_default(self): + self.value = self.default + class DeviceHelper: def __init__(self, device: Device): @@ -1533,8 +1551,17 @@ def formats(self): for image_format in iter_read_formats(self.device, buffer_type) ] - @property + def format_frame_sizes(self, pixel_format): + return [copy.copy(val) for val in iter_read_frame_sizes(self.device, pixel_format)] + def frame_sizes(self): + results = [] + for fmt in self.formats: + results.extend(self.format_frame_sizes(fmt.pixel_format)) + return results + + @property + def frame_types(self): pixel_formats = {fmt.pixel_format for fmt in self.formats} return list(iter_read_pixel_formats_frame_intervals(self.device, pixel_formats)) @@ -1818,7 +1845,7 @@ def raw_read(self) -> Frame: def wait_read(self) -> Frame: device = self.device if device.io.select is not None: - device.io.select((device,), (), ()) + device.io.select.select((device,), (), ()) return self.raw_read() def read(self) -> Frame: @@ -1913,7 +1940,7 @@ def raw_write(self, data: Buffer) -> raw.v4l2_buffer: def wait_write(self, data: Buffer) -> raw.v4l2_buffer: device = self.device if device.io.select is not None: - _, r, _ = device.io.select((), (device,), ()) + _, r, _ = device.io.select.select((), (device,), ()) return self.raw_write(data) def write(self, data: Buffer) -> raw.v4l2_buffer: @@ -1963,8 +1990,9 @@ def prepare_buffers(self): self.device.log.info("Buffers reserved") -class EventReader: +class EventReader(ReentrantOpen): def __init__(self, device: Device, max_queue_size=100): + super().__init__() self.device = device self._loop = None self._selector = None @@ -1994,14 +2022,15 @@ async def __aiter__(self): yield await self.aread() def __iter__(self): - while True: - yield self.read() - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_value, tb): - pass + device = self.device + with self: + if device.is_blocking: + while True: + yield device.deque_event() + else: + while True: + for _ in self._selector.poll(): + yield device.deque_event() def _on_event(self): task = self._loop.create_future() @@ -2018,12 +2047,27 @@ def _on_event(self): buffer.popleft() buffer.put_nowait(task) - def read(self, timeout=None): + def open(self): if not self.device.is_blocking: - _, _, exc = self.device.io.select((), (), (self.device,), timeout) + self._selector = select.epoll() + self._selector.register(self.device, select.EPOLLPRI) + + def close(self): + if self._selector: + self._selector.close() + self._selector = None + + def fileno(self) -> int: + """Return the underlying file descriptor (an integer) of the stream if it exists""" + return self._selector.fileno() + + def read(self, timeout=None): + device = self.device + if not device.is_blocking: + _, _, exc = device.io.select.select((), (), (self.device,), timeout) if not exc: return - return self.device.deque_event() + return device.deque_event() async def aread(self): """Wait for next event or return last event in queue""" @@ -2089,7 +2133,7 @@ def _on_event(self) -> None: def read(self, timeout: Optional[float] = None) -> Frame: if not self.device.is_blocking: - read, _, _ = self.device.io.select((self.device,), (), (), timeout) + read, _, _ = self.device.io.select.select((self.device,), (), (), timeout) if not read: return return self.raw_read() diff --git a/linuxpy/video/qt.py b/linuxpy/video/qt.py index caf592f..2d35fd0 100644 --- a/linuxpy/video/qt.py +++ b/linuxpy/video/qt.py @@ -10,11 +10,95 @@ You'll need to install linuxpy qt optional dependencies (ex: `$pip install linuxpy[qt]`) """ +import collections +import contextlib import logging +import select from qtpy import QtCore, QtGui, QtWidgets -from linuxpy.video.device import Device, Frame, PixelFormat, VideoCapture +from linuxpy.video.device import ( + BaseControl, + ControlType, + Device, + EventControlChange, + EventType, + Frame, + PixelFormat, + VideoCapture, +) + +log = logging.getLogger(__name__) + + +def stream(epoll): + while True: + yield from epoll.poll() + + +@contextlib.contextmanager +def signals_blocked(widget: QtWidgets.QWidget): + if widget.signalsBlocked(): + yield + else: + widget.blockSignals(True) + try: + yield + finally: + widget.blockSignals(False) + + +class Dispatcher: + def __init__(self): + self.epoll = select.epoll() + self.cameras = {} + self._task = None + + def ensure_task(self): + if self._task is None: + self._task = QtCore.QThread() + self._task.run = self.loop + self._task.start() + self._task.finished.connect(self.on_quit) + return self._task + + def on_quit(self): + for fd in self.cameras: + self.epoll.unregister(fd) + self.cameras = {} + + def register(self, camera, type): + self.ensure_task() + fd = camera.device.fileno() + event_mask = select.EPOLLHUP + if type == "all" or type == "frame": + event_mask |= select.EPOLLIN + if type == "all" or type == "control": + event_mask |= select.EPOLLPRI + if fd in self.cameras: + self.epoll.modify(fd, event_mask) + else: + self.epoll.register(fd, event_mask) + self.cameras[fd] = camera + + def loop(self): + errno = 0 + for fd, event_type in stream(self.epoll): + camera = self.cameras[fd] + if event_type & select.EPOLLHUP: + print("Unregister!", fd) + self.epoll.unregister(fd) + else: + if event_type & select.EPOLLPRI: + camera.handle_event() + if event_type & select.EPOLLIN: + camera.handle_frame() + if event_type & select.EPOLLERR: + errno += 1 + print("ERROR", errno) + + +dispatcher = Dispatcher() class QCamera(QtCore.QObject): @@ -27,13 +111,27 @@ def __init__(self, device: Device): self.capture = VideoCapture(device) self._stop = False self._stream = None - self._notifier = None self._state = "stopped" + dispatcher.register(self, "control") + self.controls = {} - def on_frame(self): + def handle_frame(self): frame = next(self._stream) self.frameChanged.emit(frame) + def handle_event(self): + event = self.device.deque_event() + if event.type == EventType.CTRL: + evt = event.u.ctrl + if not EventControlChange.VALUE & evt.changes: + # Skip non value changes (flags, ranges, dimensions) + log.info("Skip event %s", event) + return + ctrl, controls = self.controls[event.id] + value = None if evt.type == ControlType.BUTTON else ctrl.value + for control in controls: + control.valueChanged.emit(value) + def setState(self, state): self._state = state self.stateChanged.emit(state) @@ -45,37 +143,291 @@ def start(self): if self._state != "stopped": raise RuntimeError(f"Cannot start when camera is {self._state}") self.setState("running") - self.device.open() self.capture.open() + dispatcher.register(self, "all") self._stream = iter(self.capture) - self._notifier = QtCore.QSocketNotifier(self.device.fileno(), QtCore.QSocketNotifier.Type.Read) - self._notifier.activated.connect(self.on_frame) def pause(self): if self._state != "running": raise RuntimeError(f"Cannot pause when camera is {self._state}") + dispatcher.register(self, "control") self.setState("paused") def resume(self): if self._state != "paused": raise RuntimeError(f"Cannot resume when camera is {self._state}") - self._notifier.setEnabled(True) + dispatcher.register(self, "all") self.setState("running") def stop(self): if self._state == "stopped": raise RuntimeError(f"Cannot stop when camera is {self._state}") - if self._notifier is not None: - self._notifier.setEnabled(False) - self._notifier = None + dispatcher.register(self, "control") if self._stream is not None: self._stream.close() self._stream = None self.capture.close() - self.device.close() - self._notifier = None self.setState("stopped") + def qcontrol(self, name_or_id: str | int): + ctrl = self.device.controls[name_or_id] + qctrl = QControl(ctrl) + if (info := self.controls.get(ctrl.id)) is None: + info = ctrl, [] + self.controls[ctrl.id] = info + info[1].append(qctrl) + self.device.subscribe_event(EventType.CTRL, ctrl.id) + return qctrl + + +class QStrValidator(QtGui.QValidator): + def __init__(self, ctrl): + super().__init__() + self.ctrl = ctrl + + def validate(self, text, pos): + size = len(text) + if size < self.ctrl.minimum: + return QtGui.QValidator.State.Intermediate, text, pos + if size > self.ctrl.maximum: + return QtGui.QValidator.State.Invalid, text, pos + return QtGui.QValidator.State.Acceptable, text, pos + + +def hbox(*widgets): + panel = QtWidgets.QWidget() + layout = QtWidgets.QHBoxLayout(panel) + layout.setContentsMargins(0, 0, 0, 0) + for widget in widgets: + layout.addWidget(widget) + return panel + + +def _reset_control(control): + reset_button = QtWidgets.QToolButton() + reset_button.setIcon(QtGui.QIcon.fromTheme(QtGui.QIcon.ThemeIcon.EditClear)) + reset_button.clicked.connect(control.ctrl.set_to_default) + return reset_button + + +def menu_control(control): + ctrl = control.ctrl + combo = QtWidgets.QComboBox() + idx_map = {} + key_map = {} + for idx, (key, name) in enumerate(ctrl.data.items()): + combo.addItem(str(name), key) + idx_map[idx] = key + key_map[key] = idx + combo.setCurrentIndex(key_map[ctrl.value]) + control.valueChanged.connect(lambda key: combo.setCurrentIndex(key_map[key])) + combo.textActivated.connect(lambda txt: control.setValue(idx_map[combo.currentIndex()])) + reset_button = _reset_control(control) + reset_button.clicked.connect(lambda: combo.setCurrentIndex(key_map[ctrl.default])) + return hbox(combo, reset_button) + + +def text_control(control): + ctrl = control.ctrl + line_edit = QtWidgets.QLineEdit() + validator = QStrValidator(ctrl) + line_edit.setValidator(validator) + line_edit.setText(ctrl.value) + control.valueChanged.connect(line_edit.setText) + line_edit.editingFinished.connect(lambda: control.setValue(line_edit.text())) + reset_button = _reset_control(control) + reset_button.clicked.connect(lambda: line_edit.setText(ctrl.default)) + return hbox(line_edit, reset_button) + + +def bool_control(control): + ctrl = control.ctrl + widget = QtWidgets.QCheckBox() + widget.setChecked(ctrl.value) + control.valueChanged.connect(widget.setChecked) + widget.clicked.connect(control.setValue) + reset_button = _reset_control(control) + reset_button.clicked.connect(lambda: widget.setChecked(ctrl.default)) + return hbox(widget, reset_button) + + +def button_control(control): + ctrl = control.ctrl + widget = QtWidgets.QPushButton(ctrl.name) + widget.clicked.connect(ctrl.push) + control.valueChanged.connect(lambda _: log.info(f"Someone clicked {ctrl.name}")) + return widget + + +def integer_control(control): + ctrl = control.ctrl + value = ctrl.value + slider = QtWidgets.QSlider() + slider.setOrientation(QtCore.Qt.Orientation.Horizontal) + slider.setRange(ctrl.minimum, ctrl.maximum) + slider.setValue(value) + spin = QtWidgets.QSpinBox() + spin.setRange(ctrl.minimum, ctrl.maximum) + spin.setValue(value) + + def on_slider_value(v): + control.setValue(v) + with signals_blocked(spin): + spin.setValue(v) + + def on_spin_value(v): + control.setValue(v) + with signals_blocked(slider): + slider.setValue(v) + + def on_ctrl_value(v): + with signals_blocked(spin): + spin.setValue(v) + with signals_blocked(slider): + slider.setValue(v) + + control.valueChanged.connect(on_ctrl_value) + slider.valueChanged.connect(on_slider_value) + spin.valueChanged.connect(on_spin_value) + reset_button = _reset_control(control) + + def reset(): + on_ctrl_value(ctrl.default) + ctrl.set_to_default() + + reset_button.clicked.connect(reset) + return hbox(slider, spin, reset_button) + + +class QControl(QtCore.QObject): + valueChanged = QtCore.Signal(object) + + def __init__(self, ctrl: BaseControl): + super().__init__() + self.ctrl = ctrl + + def setValue(self, value): + log.info("set value %r to %s", self.ctrl.name, value) + self.ctrl.value = value + + def create_widget(self): + ctrl = self.ctrl + widget = None + if ctrl.is_flagged_has_payload and ctrl.type != ControlType.STRING: + return + if ctrl.type in {ControlType.INTEGER, ControlType.U8, ControlType.U16, ControlType.U32}: + widget = integer_control(self) + elif ctrl.type == ControlType.INTEGER64: + pass # TODO + elif ctrl.type == ControlType.BOOLEAN: + widget = bool_control(self) + elif ctrl.type == ControlType.STRING: + widget = text_control(self) + elif ctrl.type in {ControlType.MENU, ControlType.INTEGER_MENU}: + widget = menu_control(self) + elif ctrl.type == ControlType.BUTTON: + widget = button_control(self) + if widget is not None: + widget.setToolTip(ctrl.name) + widget.setEnabled(ctrl.is_writeable) + return widget + + +class QControlPanel(QtWidgets.QTabWidget): + def __init__(self, camera: QCamera): + super().__init__() + self.camera = camera + self.setWindowTitle(f"{camera.device.info.card} @ {camera.device.filename}") + self.fill() + + def fill(self): + camera = self.camera + + group_widgets = collections.defaultdict(list) + for ctrl_id, ctrl in camera.device.controls.items(): + if ctrl.is_flagged_has_payload and ctrl.type != ControlType.STRING: + continue + qctrl = camera.qcontrol(ctrl_id) + if (widget := qctrl.create_widget()) is None: + continue + if (klass := ctrl.control_class) is None: + name = "Generic" + else: + name = klass.name.decode() + group_widgets[name].append((qctrl, widget)) + + for name, widgets in group_widgets.items(): + tab = QtWidgets.QWidget() + tab.setWindowTitle(name) + layout = QtWidgets.QGridLayout() + tab.setLayout(layout) + self.addTab(tab, name) + nb_cols = 1 + nb_controls = len(widgets) + if nb_controls > 50: + nb_cols = 3 + elif nb_controls > 10: + nb_cols = 2 + nb_rows = (len(widgets) + nb_cols - 1) // nb_cols + for idx, (qctrl, widget) in enumerate(widgets): + row, col = idx % nb_rows, idx // nb_rows + if qctrl.ctrl.type == ControlType.BUTTON: + layout.addWidget(widget, row, col * 2, 1, 2) + else: + layout.addWidget(QtWidgets.QLabel(f"{qctrl.ctrl.name}:"), row, col * 2) + layout.addWidget(widget, row, col * 2 + 1) + layout.setRowStretch(row + 1, 1) + + +def fill_info_panel(camera: QCamera, widget): + device = camera.device + info = device.info + layout = QtWidgets.QFormLayout(widget) + layout.addRow("Device:", QtWidgets.QLabel(str(device.filename))) + layout.addRow("Card:", QtWidgets.QLabel(info.card)) + layout.addRow("Driver:", QtWidgets.QLabel(info.driver)) + layout.addRow("Bus:", QtWidgets.QLabel(info.bus_info)) + layout.addRow("Version:", QtWidgets.QLabel(info.version)) + + +def frame_sizes(camera: QCamera): + result = set() + for frame_size in camera.device.info.frame_types: + result.add((frame_size.width, frame_size.height)) + return sorted(result) + + +def fill_inputs_panel(camera: QCamera, widget): + device = camera.device + info = device.info + layout = QtWidgets.QFormLayout(widget) + inputs_combo = QtWidgets.QComboBox() + for inp in info.inputs: + inputs_combo.addItem(inp.name, inp.index) + inputs_combo.currentIndexChanged.connect(lambda: device.set_input(inputs_combo.currentData())) + layout.addRow("Input:", inputs_combo) + frame_size_combo = QtWidgets.QComboBox() + for width, height in frame_sizes(camera): + frame_size_combo.addItem(f"{width}x{height}") + layout.addRow("Frame Size:", frame_size_combo) + + +class QSettingsPanel(QtWidgets.QWidget): + def __init__(self, camera: QCamera): + super().__init__() + self.camera = camera + self.fill() + + def fill(self): + layout = QtWidgets.QVBoxLayout(self) + info = QtWidgets.QGroupBox("General Information") + fill_info_panel(self.camera, info) + layout.addWidget(info) + if self.camera.device.info.inputs: + inputs = QtWidgets.QGroupBox("Input Settings") + fill_inputs_panel(self.camera, inputs) + layout.addWidget(inputs) + def to_qpixelformat(pixel_format: PixelFormat) -> QtGui.QPixelFormat | None: if pixel_format == PixelFormat.YUYV: @@ -86,7 +438,7 @@ def frame_to_qimage(frame: Frame) -> QtGui.QImage: """Translates a Frame to a QImage""" data = frame.data if frame.pixel_format == PixelFormat.MJPEG: - return QtGui.QImage.fromData(data, b"JPG") + return QtGui.QImage.fromData(data, "JPG") fmt = QtGui.QImage.Format.Format_BGR888 if frame.pixel_format == PixelFormat.RGB24: fmt = QtGui.QImage.Format.Format_RGB888 @@ -94,13 +446,20 @@ def frame_to_qimage(frame: Frame) -> QtGui.QImage: fmt = QtGui.QImage.Format.Format_RGB32 elif frame.pixel_format == PixelFormat.ARGB32: fmt = QtGui.QImage.Format.Format_ARGB32 + elif frame.pixel_format == PixelFormat.GREY: + fmt = QtGui.QImage.Format.Format_Grayscale8 elif frame.pixel_format == PixelFormat.YUYV: import cv2 data = frame.array data.shape = frame.height, frame.width, -1 data = cv2.cvtColor(data, cv2.COLOR_YUV2BGR_YUYV) - return QtGui.QImage(data, frame.width, frame.height, fmt) + else: + return None + qimage = QtGui.QImage(data, frame.width, frame.height, fmt) + if fmt not in {QtGui.QImage.Format.Format_RGB32}: + qimage.convertTo(QtGui.QImage.Format.Format_RGB32) + return qimage def frame_to_qpixmap(frame: Frame) -> QtGui.QPixmap: @@ -125,24 +484,28 @@ def draw_frame(paint_device, width, height, line_width=4, color="red"): painter.drawRect(half_line_width, half_line_width, width - line_width, height - line_width) -def draw_no_image(paint_device, width=None, height=None, line_width=4): - if width is None: - width = paint_device.width() - if height is None: - height = paint_device.height() +def draw_no_image_rect(painter, rect, line_width=4): color = QtGui.QColor(255, 0, 0, 100) pen = QtGui.QPen(color, line_width) - painter = QtGui.QPainter(paint_device) painter.setPen(pen) painter.setBrush(QtCore.Qt.NoBrush) painter.drawLines( ( - QtCore.QLine(0, height, width, 0), - QtCore.QLine(0, 0, width, height), + QtCore.QLineF(rect.topLeft(), rect.bottomRight()), + QtCore.QLineF(rect.bottomLeft(), rect.topRight()), ) ) half_line_width = line_width // 2 - painter.drawRect(half_line_width, half_line_width, width - line_width, height - line_width) + rect.setLeft(rect.left() + half_line_width) + rect.setRight(rect.right() - half_line_width) + rect.setTop(rect.top() + half_line_width) + rect.setBottom(rect.bottom() - half_line_width) + painter.drawRect(rect) + + +def draw_no_image(painter, width, height, line_width=4): + rect = QtCore.QRectF(0, 0, width, height) + return draw_no_image_rect(painter, rect, line_width) class BaseCameraControl: @@ -279,7 +642,7 @@ def set_camera(self, camera: QCamera | None = None): class QVideo(QtWidgets.QWidget): frame = None - pixmap = None + qimage = None def __init__(self, camera: QCamera | None = None): super().__init__() @@ -295,33 +658,81 @@ def set_camera(self, camera: QCamera | None = None): def on_frame_changed(self, frame): self.frame = frame - self.pixmap = None + self.qimage = None self.update() def paintEvent(self, _): frame = self.frame + painter = QtGui.QPainter(self) if frame is None: - draw_no_image(self) + draw_no_image(painter, self.width(), self.height()) return - if self.pixmap is None: - self.pixmap = frame_to_qpixmap(frame) - if self.pixmap is not None: + if self.qimage is None: + self.qimage = frame_to_qimage(frame) + if self.qimage is not None: width, height = self.width(), self.height() - scaled_pixmap = self.pixmap.scaled(width, height, QtCore.Qt.AspectRatioMode.KeepAspectRatio) - pix_width, pix_height = scaled_pixmap.width(), scaled_pixmap.height() + scaled_image = self.qimage.scaled(width, height, QtCore.Qt.AspectRatioMode.KeepAspectRatio) + pix_width, pix_height = scaled_image.width(), scaled_image.height() x, y = 0, 0 if width > pix_width: x = int((width - pix_width) / 2) if height > pix_height: y = int((height - pix_height) / 2) - painter = QtGui.QPainter(self) - painter.drawPixmap(QtCore.QPoint(x, y), scaled_pixmap) - self.scaled_pixmap = scaled_pixmap + painter.drawImage(QtCore.QPoint(x, y), scaled_image) def minimumSizeHint(self): return QtCore.QSize(160, 120) +class QVideoItem(QtWidgets.QGraphicsObject): + frame = None + qimage = None + imageChanged = QtCore.Signal(object) + + def __init__(self, camera: QCamera | None = None): + super().__init__() + self.camera = None + self.set_camera(camera) + + def set_camera(self, camera: QCamera | None = None): + if self.camera: + self.camera.frameChanged.disconnect(self.on_frame_changed) + self.camera = camera + if self.camera: + self.camera.frameChanged.connect(self.on_frame_changed) + + def on_frame_changed(self, frame): + self.frame = frame + self.qimage = None + self.update() + + def boundingRect(self): + if self.frame: + width = self.frame.width + height = self.frame.height + elif self.camera: + fmt = self.camera.capture.get_format() + width = fmt.width + height = fmt.height + else: + width = 640 + height = 480 + return QtCore.QRectF(0.0, 0.0, width, height) + + def paint(self, painter, style, *args): + frame = self.frame + rect = self.boundingRect() + if frame is None: + draw_no_image_rect(painter, rect) + return + changed = self.qimage is None + if changed: + self.qimage = frame_to_qimage(frame) + painter.drawImage(rect, self.qimage) + if changed: + self.imageChanged.emit(self.qimage) + + class QVideoWidget(QtWidgets.QWidget): def __init__(self, camera=None): super().__init__() @@ -340,6 +751,10 @@ def set_camera(self, camera: QCamera | None = None): def main(): import argparse + def stop(): + if camera.state() != "stopped": + camera.stop() + parser = argparse.ArgumentParser() parser.add_argument("--log-level", choices=["debug", "info", "warning", "error"], default="info") parser.add_argument("device", type=int) @@ -347,12 +762,20 @@ def main(): fmt = "%(threadName)-10s %(asctime)-15s %(levelname)-5s %(name)s: %(message)s" logging.basicConfig(level=args.log_level.upper(), format=fmt) app = QtWidgets.QApplication([]) - device = Device.from_id(args.device) - camera = QCamera(device) - widget = QVideoWidget(camera) - app.aboutToQuit.connect(camera.stop) - widget.show() - app.exec() + with Device.from_id(args.device, blocking=False) as device: + camera = QCamera(device) + window = QtWidgets.QWidget() + layout = QtWidgets.QHBoxLayout(window) + widget = QVideoWidget(camera) + widget.setMinimumSize(640, 480) + panel = QControlPanel(camera) + settings = QSettingsPanel(camera) + layout.addWidget(settings) + layout.addWidget(widget) + layout.addWidget(panel) + window.show() + app.aboutToQuit.connect(stop) + app.exec() if __name__ == "__main__": diff --git a/linuxpy/video/raw.py b/linuxpy/video/raw.py index fcadea4..27f1e77 100644 --- a/linuxpy/video/raw.py +++ b/linuxpy/video/raw.py @@ -974,7 +974,7 @@ class EventSubscriptionFlag(enum.IntFlag): ALLOW_FEEDBACK = 1 << 1 -class EventControlChange(enum.IntEnum): +class EventControlChange(enum.IntFlag): VALUE = 1 << 0 FLAGS = 1 << 1 RANGE = 1 << 2 diff --git a/pyproject.toml b/pyproject.toml index 3b5abe9..cdf0f2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,26 +39,37 @@ Homepage = "https://github.com/tiagocoutinho/linuxpy" Repository = "https://github.com/tiagocoutinho/linuxpy" [dependency-groups] -dev = [ - "build>=0.10.0", +test = [ "gevent>=21", - "twine>=4.0.2", "pytest>=8.1", "pytest-asyncio>=1.0.0", "pytest-cov>=6", - "ruff>=0.3.0", "numpy>=1.1", ] +qt = [ + "qtpy", + "pyqt6", + "opencv-python", +] +test-qt = [ + {include-group = "test"}, + {include-group = "qt"}, + "pytest-qt>=4.5.0,<5", +] +build-deploy = [ + "build>=0.10.0", + "ruff>=0.3.0", + "twine>=4.0.2", +] examples = [ + {include-group = "qt"}, + "numpy>=1.1", "flask>=2,<4", "fastapi<1", - "opencv-python", - "qtpy", - "pyqt6", "gunicorn", "gevent", "uvicorn", - "numpy", + "opencv-python", "Pillow", ] docs = [ @@ -69,6 +80,16 @@ docs = [ "pymdown-extensions", "mkdocs-coverage", ] +dev = [ + {include-group = "test-qt"}, + {include-group = "build-deploy"}, + {include-group = "docs"}, +] +dev_no_qt = [ + {include-group = "test"}, + {include-group = "build-deploy"}, + {include-group = "docs"}, +] [project.scripts] linuxpy-codegen = "linuxpy.codegen.cli:main" diff --git a/tests/test_video.py b/tests/test_video.py index 0603ce4..1ccf6a4 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -277,7 +277,14 @@ def mmap(self, fd, length, offset): assert self.fd == fd return MemoryMap(self) - def select(self, readers, writers, other, timeout=None): + @property + def select(self): + class select: + select = self._select + + return select + + def _select(self, readers, writers, other, timeout=None): assert readers[0].fileno() == self.fd return readers, writers, other @@ -746,7 +753,7 @@ def test_info_with_vivid(): capture_dev.set_input(0) - assert len(capture_dev.info.frame_sizes) > 10 + assert len(capture_dev.info.frame_types) > 10 assert len(capture_dev.info.formats) > 10 inputs = capture_dev.info.inputs @@ -779,7 +786,7 @@ def test_info_with_vivid(): text = repr(output_dev.info) assert "driver = " in text - assert len(output_dev.info.frame_sizes) == 0 + assert len(output_dev.info.frame_types) == 0 assert len(output_dev.info.formats) > 10 inputs = output_dev.info.inputs @@ -802,7 +809,7 @@ def test_info_with_vivid(): meta_capture_dev.set_input(0) - assert len(meta_capture_dev.info.frame_sizes) == 0 + assert len(meta_capture_dev.info.frame_types) == 0 assert len(meta_capture_dev.info.formats) > 0 meta_fmt = meta_capture_dev.get_format(BufferType.META_CAPTURE) diff --git a/tests/test_video_qt.py b/tests/test_video_qt.py index fecd5ff..d5a8dd7 100644 --- a/tests/test_video_qt.py +++ b/tests/test_video_qt.py @@ -95,7 +95,7 @@ def test_qvideo_widget(qtbot, pixel_format): assert widget.video.frame is frames.args[0] assert play_button.toolTip() == "Camera is running. Press to stop it" - qtbot.waitUntil(lambda: widget.video.pixmap is not None) + qtbot.waitUntil(lambda: widget.video.qimage is not None) with qtbot.waitSignal(qcamera.stateChanged, timeout=10) as status: pause_button.click() diff --git a/uv.lock b/uv.lock index fcb6331..e4e059a 100644 --- a/uv.lock +++ b/uv.lock @@ -703,14 +703,29 @@ dependencies = [ ] [package.dev-dependencies] +build-deploy = [ + { name = "build" }, + { name = "ruff" }, + { name = "twine" }, +] dev = [ { 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 = "opencv-python" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "pyqt6" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, + { name = "pytest-qt" }, + { name = "qtpy" }, { name = "ruff" }, { name = "twine" }, ] @@ -735,18 +750,58 @@ examples = [ { name = "qtpy" }, { name = "uvicorn" }, ] +qt = [ + { name = "opencv-python" }, + { name = "pyqt6" }, + { name = "qtpy" }, +] +test = [ + { name = "gevent" }, + { 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 = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, +] +test-qt = [ + { name = "gevent" }, + { 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 = "opencv-python" }, + { name = "pyqt6" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-qt" }, + { name = "qtpy" }, +] [package.metadata] requires-dist = [{ name = "typing-extensions", marker = "python_full_version < '3.12'", specifier = ">=4.6,<5" }] [package.metadata.requires-dev] +build-deploy = [ + { name = "build", specifier = ">=0.10.0" }, + { name = "ruff", specifier = ">=0.3.0" }, + { name = "twine", specifier = ">=4.0.2" }, +] dev = [ { 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 = "opencv-python" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "pyqt6" }, { name = "pytest", specifier = ">=8.1" }, { name = "pytest-asyncio", specifier = ">=1.0.0" }, { name = "pytest-cov", specifier = ">=6" }, + { name = "pytest-qt", specifier = ">=4.5.0,<5" }, + { name = "qtpy" }, { name = "ruff", specifier = ">=0.3.0" }, { name = "twine", specifier = ">=4.0.2" }, ] @@ -763,13 +818,36 @@ examples = [ { name = "flask", specifier = ">=2,<4" }, { name = "gevent" }, { name = "gunicorn" }, - { name = "numpy" }, + { name = "numpy", specifier = ">=1.1" }, { name = "opencv-python" }, { name = "pillow" }, { name = "pyqt6" }, { name = "qtpy" }, { name = "uvicorn" }, ] +qt = [ + { name = "opencv-python" }, + { name = "pyqt6" }, + { name = "qtpy" }, +] +test = [ + { name = "gevent", specifier = ">=21" }, + { name = "numpy", specifier = ">=1.1" }, + { name = "pytest", specifier = ">=8.1" }, + { name = "pytest-asyncio", specifier = ">=1.0.0" }, + { name = "pytest-cov", specifier = ">=6" }, +] +test-qt = [ + { name = "gevent", specifier = ">=21" }, + { name = "numpy", specifier = ">=1.1" }, + { name = "opencv-python" }, + { name = "pyqt6" }, + { name = "pytest", specifier = ">=8.1" }, + { name = "pytest-asyncio", specifier = ">=1.0.0" }, + { name = "pytest-cov", specifier = ">=6" }, + { name = "pytest-qt", specifier = ">=4.5.0,<5" }, + { name = "qtpy" }, +] [[package]] name = "markdown" @@ -1608,6 +1686,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644, upload-time = "2025-06-12T10:47:45.932Z" }, ] +[[package]] +name = "pytest-qt" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pluggy" }, + { name = "pytest" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/61/8bdec02663c18bf5016709b909411dce04a868710477dc9b9844ffcf8dd2/pytest_qt-4.5.0.tar.gz", hash = "sha256:51620e01c488f065d2036425cbc1cbcf8a6972295105fd285321eb47e66a319f", size = 128702, upload-time = "2025-07-01T17:24:39.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/d0/8339b888ad64a3d4e508fed8245a402b503846e1972c10ad60955883dcbb/pytest_qt-4.5.0-py3-none-any.whl", hash = "sha256:ed21ea9b861247f7d18090a26bfbda8fb51d7a8a7b6f776157426ff2ccf26eff", size = 37214, upload-time = "2025-07-01T17:24:38.226Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0"