Source code for QVideo.lib.QVideoScreen

'''Live video display widget with mouse-aware graphical overlay support.'''
from qtpy import QtCore, QtGui, QtWidgets
from QVideo.lib.QVideoSource import QVideoSource
from QVideo.lib.QFilterBank import QFilterBank
from QVideo.lib.videotypes import Image
import numpy as np
import pyqtgraph as pg
from pyqtgraph import GraphicsLayoutWidget, ImageItem
import logging


logger = logging.getLogger(__name__)

__all__ = ['QVideoScreen']


[docs] class QVideoScreen(GraphicsLayoutWidget): '''Video display widget. Displays frames from a :class:`~QVideo.lib.QVideoSource` in real time, with optional frame-rate throttling and image filtering via :class:`~QVideo.lib.QFilterBank`. Inherits from :class:`pyqtgraph.GraphicsLayoutWidget`. Parameters ---------- size : tuple[int, int] Initial widget dimensions in pixels. Default: ``(640, 480)``. framerate : int | None Maximum display frame rate in frames per second. ``None`` means no throttling. Default: ``None``. *args : Forwarded to :class:`pyqtgraph.GraphicsLayoutWidget`. **kwargs : Forwarded to :class:`pyqtgraph.GraphicsLayoutWidget`. Properties ---------- framerate : int | None Maximum display frame rate [fps]. Setting this updates the throttle interval. source : QVideoSource The video source. Setting this connects the source to the display. fps : float | None Frame rate of the connected source [fps]. ``None`` when no source is connected. Read-only. composite : bool Controls what :attr:`newFrame` emits. When ``False`` (default), :attr:`newFrame` carries the filtered video frame. When ``True``, it carries the rendered ViewBox scene (video + overlays) as an ``(H, W, 4)`` RGBA uint8 array. colormap : str | None Colormap name for false-color display of grayscale frames. Accepts any matplotlib colormap name (e.g. ``'inferno'``, ``'viridis'``) or a pyqtgraph built-in name. Set to ``None`` (default) to display in grayscale. Has no effect on color (3-channel) frames. Signals ------- newFrame(numpy.ndarray) Emitted after each displayed frame. Carries either the filtered video frame or the rendered composite scene, depending on :attr:`composite`. ''' newFrame = QtCore.Signal(np.ndarray) def __init__(self, *args, size: tuple[int, int] = (640, 480), framerate: int | None = None, **kwargs) -> None: super().__init__(*args, size=size, **kwargs) self.framerate = framerate self._colormap: str | None = None self._ready = True self._pending: Image | None = None self._overlays: list[object] = [] self._composite = False self._videoShape: QtCore.QSize | None = None self._source: QVideoSource | None = None self._timer = QtCore.QTimer(self) self._timer.setSingleShot(True) self._timer.timeout.connect(self._setready) self._setupUi() self.filter = QFilterBank(self) self.filter.setVisible(False) def _setupUi(self) -> None: self.ci.layout.setContentsMargins(0, 0, 0, 0) self.view = self.addViewBox(enableMenu=False, enableMouse=False) self.view.invertY(True) self.view.setAspectLocked(True) self.view.setDefaultPadding(0) sz = self.size() self.view.setRange(xRange=(0, sz.width()), yRange=(0, sz.height()), padding=0, update=True) self.image = ImageItem(axisOrder='row-major') self.view.addItem(self.image) @property def source(self) -> QVideoSource | None: '''The video source providing frames to display.''' return self._source @source.setter def source(self, source: QVideoSource | None) -> None: if self._source is not None: self._source.shapeChanged.disconnect(self.updateShape) self._source.newFrame.disconnect(self.setImage) self._source = source if source is None: return if source.shape is not None: self.updateShape(source.shape) self._source.shapeChanged.connect(self.updateShape) self._source.newFrame.connect(self.setImage) @property def framerate(self) -> int | None: '''Maximum display frame rate [fps]. Accommodate high-speed cameras by throttling the rate at which frames are displayed. ``None`` for no throttling.''' return self._framerate @framerate.setter def framerate(self, framerate: int | None) -> None: if framerate is not None and framerate <= 0: raise ValueError('framerate must be positive, ' f'got {framerate}') self._framerate = framerate self._interval = 0 if framerate is None else int(1000 / framerate) @property def colormap(self) -> str | None: '''Colormap name for false-color display of grayscale frames. Set to a colormap name (matplotlib or pyqtgraph built-in) to apply false-color mapping. Set to ``None`` to restore grayscale. Has no effect on color (3-channel) frames. ''' return self._colormap @colormap.setter def colormap(self, name: str | None) -> None: self._colormap = name if name is None: self.image.setLookupTable(None) return try: cm = pg.colormap.get(name, source='matplotlib') except (KeyError, FileNotFoundError, ImportError): cm = pg.colormap.get(name) self.image.setColorMap(cm) def _setready(self) -> None: self._ready = True if self._pending is not None: self.setImage(self._pending)
[docs] @QtCore.Slot(np.ndarray) def setImage(self, image: Image) -> None: '''Display a new video frame and emit :attr:`newFrame`. Passes the frame through :attr:`filter` before display. If the throttle interval has not yet elapsed, the frame is buffered as the most recent pending frame; when the interval expires the buffered frame is displayed immediately so no extra latency accumulates. :attr:`newFrame` is emitted with the filtered frame, or with the rendered composite scene when :attr:`composite` is ``True``. Parameters ---------- image : Image The frame to display. ''' if self._ready: filtered = self.filter(image) self.image.setImage(filtered, autoLevels=False) self.newFrame.emit( self._renderComposite() if self._composite else filtered) self._ready = False self._pending = None self._timer.start(self._interval) else: self._pending = image
@property def fps(self) -> float | None: '''Effective display frame rate [frames per second]. Returns :attr:`framerate` when display throttling is active, otherwise delegates to the source frame rate. This is the rate at which :attr:`newFrame` fires, and therefore the correct value to use when the screen is used as the source for a recorder. Returns ``None`` when no source is connected. ''' if self._framerate is not None: return float(self._framerate) if self._source is not None: return self._source.fps return None @property def composite(self) -> bool: '''Emit the rendered scene via :attr:`newFrame` instead of the raw frame.''' return self._composite @composite.setter def composite(self, value: bool) -> None: self._composite = bool(value) def _renderComposite(self) -> Image: '''Capture the widget (video + overlays) as an RGBA numpy array. Uses :meth:`QWidget.grab` to snapshot the widget's current visual state. This avoids painter conflicts that arise from rendering the :class:`~pyqtgraph.GraphicsScene` directly while pyqtgraph may have its own internal painter active. Returns ------- numpy.ndarray Array of shape ``(H, W, 4)`` and dtype ``uint8``. Returns an empty ``(0, 0, 4)`` array if the widget has no size. ''' pixmap = self.grab() if pixmap.isNull(): return np.empty((0, 0, 4), dtype=np.uint8) try: fmt = QtGui.QImage.Format.Format_RGBA8888 except AttributeError: fmt = QtGui.QImage.Format_RGBA8888 qimage = pixmap.toImage().convertToFormat(fmt) w, h = qimage.width(), qimage.height() ptr = qimage.bits() if hasattr(ptr, 'setsize'): ptr.setsize(h * w * 4) return np.frombuffer(ptr, np.uint8).reshape(h, w, 4).copy()
[docs] def addOverlay(self, item: object) -> None: '''Add a graphics item to the view Register overlays for visibility control. Parameters ---------- item : pyqtgraph.GraphicsObject The overlay item to add. ''' self.view.addItem(item) self._overlays.append(item)
[docs] def removeOverlay(self, item: object) -> None: '''Remove a previously added graphics item from the view. Parameters ---------- item : pyqtgraph.GraphicsObject The overlay item to remove. ''' self.view.removeItem(item) self._overlays.remove(item)
@property def overlaysVisible(self) -> bool: '''Whether any registered overlay is currently visible.''' return any(item.isVisible() for item in self._overlays) @overlaysVisible.setter def overlaysVisible(self, visible: bool) -> None: for item in self._overlays: item.setVisible(visible)
[docs] def sizeHint(self) -> QtCore.QSize: '''Return the source frame size as the preferred widget size.''' if self._videoShape is not None: return self._videoShape return super().sizeHint()
[docs] def hasHeightForWidth(self) -> bool: '''Return True once a video frame shape is known.''' return self._videoShape is not None
[docs] def heightForWidth(self, width: int) -> int: '''Return the height that preserves the source aspect ratio.''' if self._videoShape is None: return super().heightForWidth(width) return width * self._videoShape.height() // self._videoShape.width()
[docs] @QtCore.Slot(QtCore.QSize) def updateShape(self, shape: QtCore.QSize) -> None: '''Resize the display to match the video frame dimensions. Ignores shapes with zero width or height, which :class:`QCamera` emits as a sentinel before ``width`` / ``height`` are registered as camera properties. Parameters ---------- shape : QtCore.QSize New frame dimensions. ''' if shape.width() == 0 or shape.height() == 0: return logger.debug(f'Resizing to {shape}') self._videoShape = shape self.updateGeometry() widget = self while (widget := widget.parentWidget()) is not None: widget.updateGeometry() self._fitToVideo()
@QtCore.Slot() def _fitToVideo(self) -> None: '''Resize the containing window to fit the video at native resolution. Caps at the full available area of whichever screen the window is on, so the result is the same whether the window has been shown or not. Both width and height are adjusted while preserving the video aspect ratio. ''' if not self.hasHeightForWidth(): return shape = self._videoShape window = self.window() screen = (QtWidgets.QApplication.screenAt(window.pos()) or QtWidgets.QApplication.primaryScreen()) available = screen.availableGeometry() sh = window.sizeHint() w_extra = sh.width() - shape.width() h_extra = sh.height() - shape.height() ideal_w = min(shape.width(), available.width() - w_extra) ideal_h = min(shape.height(), available.height() - h_extra) if ideal_w * shape.height() > ideal_h * shape.width(): ideal_w = ideal_h * shape.width() // shape.height() else: ideal_h = ideal_w * shape.height() // shape.width() new_w = max(1, ideal_w) + w_extra new_h = max(1, ideal_h) + h_extra self.setMinimumSize(min(shape.width() // 2, ideal_w), min(shape.height() // 2, ideal_h)) if (new_w, new_h) != (window.width(), window.height()): window.resize(new_w, new_h) # resizeEvent will call view.setRange after the viewport shrinks else: self.view.setRange(xRange=(0, shape.width()), yRange=(0, shape.height()), padding=0, update=True)
[docs] def resizeEvent(self, event: QtGui.QResizeEvent) -> None: '''Update the ViewBox range to match the video after the viewport is resized.''' super().resizeEvent(event) shape = getattr(self, '_videoShape', None) if shape is not None: self.view.setRange( xRange=(0, shape.width()), yRange=(0, shape.height()), padding=0, update=True)
[docs] @classmethod def example(cls: type['QVideoScreen']) -> None: # pragma: no cover '''Demonstrate the video screen with a noise source.''' import pyqtgraph as pg from QVideo.cameras.Noise import QNoiseSource app = pg.mkQApp() screen = cls() source = QNoiseSource(blacklevel=48, whitelevel=128) screen.source = source.start() screen.show() pg.exec()
if __name__ == '__main__': # pragma: no cover QVideoScreen.example()