Extending QVideo: Filters and Overlays#
QVideo is designed to be extended. Adding a new image-processing filter or a new analysis overlay requires implementing one or two small classes and no changes to the framework itself.
Filters#
Architecture#
The filter pipeline is split into two layers so that image-processing logic stays separate from UI concerns.
VideoFilterThe pure image-processing layer. Its interface is a two-stage
add/getcycle:add()— receives one frame and updates internal state.get()— returns the processed result (which may depend on multiple past frames).__call__()— chainsaddandget, so a filter can be used as a plain callable:output = my_filter(frame).
The default
addstores the frame; the defaultgetreturns it unchanged (passthrough). Subclasses overridegetfor stateless transforms, or bothaddandgetfor stateful ones.QVideoFilterThe Qt widget layer. Wraps a
VideoFilterin a checkableQGroupBox. When the box is checked the filter is applied; when unchecked frames pass through unchanged.Subclasses extend the UI by overriding
_setupUi(): callsuper()._setupUi()first, then add controls toself._layout(a horizontalQHBoxLayout).QFilterBankAn ordered stack of
QVideoFilterwidgets.register()appends a filter; the bank applies them left-to-right when called. AQVideoScreenowns one internally (screen.filter); register filters there to have them applied automatically on every displayed frame.
Writing a stateless filter#
A stateless filter transforms each frame independently. Override only
get().
The example below inverts a frame:
import numpy as np
from QVideo.lib.QVideoFilter import VideoFilter, QVideoFilter
from QVideo.lib.videotypes import Image
class InvertFilter(VideoFilter):
'''Invert all pixel values.'''
def get(self) -> Image | None:
if self.data is None:
return None
return 255 - self.data
class QInvertFilter(QVideoFilter):
'''Widget for :class:`InvertFilter`.'''
def __init__(self, parent=None) -> None:
super().__init__(parent, 'Invert', InvertFilter())
The __init__ passes three arguments to
QVideoFilter:
parent — the Qt parent widget (may be
None)title — the string shown in the group box border
videoFilter — an instance of your
VideoFilter
That is the complete implementation. QInvertFilter is immediately usable
anywhere a QVideoFilter is accepted.
Adding parameters with controls#
Override _setupUi() to add spinboxes, sliders, or any other widget.
import numpy as np
from qtpy import QtCore, QtWidgets
from pyqtgraph import SpinBox
from QVideo.lib.QVideoFilter import VideoFilter, QVideoFilter
from QVideo.lib.videotypes import Image
class BrightnessFilter(VideoFilter):
'''Multiply every pixel by a gain factor.
Parameters
----------
gain : float
Multiplicative gain. Default: ``1.0``.
'''
def __init__(self, gain: float = 1.0) -> None:
super().__init__()
self.gain = gain
def get(self) -> Image | None:
if self.data is None:
return None
return np.clip(self.data * self.gain, 0, 255).astype(self.data.dtype)
class QBrightnessFilter(QVideoFilter):
'''Widget for :class:`BrightnessFilter` with a gain spinbox.'''
def __init__(self, parent=None) -> None:
super().__init__(parent, 'Brightness', BrightnessFilter())
def _setupUi(self) -> None:
super()._setupUi() # creates self._layout
self._layout.addWidget(QtWidgets.QLabel('gain'))
self._spinbox = SpinBox(self, value=self.filter.gain, step=0.1)
self._spinbox.valueChanged.connect(self._setGain)
self._layout.addWidget(self._spinbox)
@QtCore.Slot(object)
def _setGain(self, value: float) -> None:
self.filter.gain = value
The key points:
super()._setupUi()must be called first — it createsself._layoutand configures the group box.self.filteris theVideoFilterinstance passed to the constructor.Use
pyqtgraph.SpinBoxinstead ofQDoubleSpinBoxfor numeric controls — it integrates more naturally with the pyqtgraph UI style.
Writing a stateful filter#
Stateful filters accumulate information across multiple frames before
producing output. Override both add()
and get().
The example below computes a frame-by-frame difference:
import numpy as np
from QVideo.lib.QVideoFilter import VideoFilter, QVideoFilter
from QVideo.lib.videotypes import Image
class DifferenceFilter(VideoFilter):
'''Absolute difference between the current frame and the previous one.'''
def __init__(self) -> None:
super().__init__()
self._prev: Image | None = None
def add(self, image: Image) -> None:
self._prev = self.data # shift current → previous
self.data = image # store new frame
def get(self) -> Image | None:
if self.data is None or self._prev is None:
return self.data # not enough frames yet — return as-is
diff = self.data.astype(np.int16) - self._prev.astype(np.int16)
return np.abs(diff).astype(np.uint8)
class QDifferenceFilter(QVideoFilter):
'''Widget for :class:`DifferenceFilter`.'''
def __init__(self, parent=None) -> None:
super().__init__(parent, 'Frame Difference', DifferenceFilter())
The add override shifts the old frame to self._prev before storing
the new one. get uses integer arithmetic to avoid uint8 wrap-around,
then clips back to 8-bit.
Supporting pipeline export#
exportPipeline() generates a
standalone Python file from the rack’s current settings. It calls
to_code() on each enabled filter
and assembles the results. The default implementation returns None, which
means the filter is silently omitted. Implementing to_code makes a
custom filter participate in export.
to_code returns a FilterCode instance
with three fields:
importsA
frozensetof complete import lines required by the generated code, e.g.frozenset({'import cv2', 'import numpy as np'}).linesA
listof source lines (no leading indentation). The variableimageholds the current frame on entry and must hold the result on exit. Temporary variables should use a leading underscore to avoid collisions with the surrounding function scope.commentAn optional one-line description included as a
# commentabove the generated block.
Stateless filter example — the InvertFilter from the previous section:
from QVideo.lib.QVideoFilter import VideoFilter, FilterCode, QVideoFilter
from QVideo.lib.videotypes import Image
import numpy as np
class InvertFilter(VideoFilter):
'''Invert all pixel values.'''
def get(self) -> Image | None:
if self.data is None:
return None
return 255 - self.data
def to_code(self) -> FilterCode:
return FilterCode(
imports=frozenset({'import numpy as np'}),
lines=['image = 255 - image'],
comment='invert pixel values',
)
Filter with parameters — embed the current parameter values directly in the generated source so the exported function is self-contained:
class BrightnessFilter(VideoFilter):
'''Multiply every pixel by a gain factor.'''
def __init__(self, gain: float = 1.0) -> None:
super().__init__()
self.gain = gain
def get(self) -> Image | None:
if self.data is None:
return None
return np.clip(self.data * self.gain, 0, 255).astype(self.data.dtype)
def to_code(self) -> FilterCode:
return FilterCode(
imports=frozenset({'import numpy as np'}),
lines=[
f'image = np.clip(image * {self.gain}, 0, 255).astype(image.dtype)',
],
comment=f'brightness gain={self.gain}',
)
Stateful filter — a filter that accumulates state across frames cannot be
expressed as a single-frame pure function. Leave to_code returning None
(the default) and the rack will note the omission in a comment:
# NOTE: the following filters are stateful and were omitted:
# DifferenceFilter
Using filters in a pipeline#
Via the screen’s built-in filter bank (simplest):
screen = QVideoScreen()
screen.filter.register(QBrightnessFilter())
screen.filter.register(QEdgeFilter())
screen.filter.setVisible(True) # show the filter panel
Every frame displayed by the screen passes through the bank automatically.
setVisible() controls whether
the filter widgets appear in the layout.
Via a standalone filter bank (useful when the bank itself is a UI component):
from QVideo.lib import QFilterBank
from QVideo.filters import QSmoothingFilter, QEdgeFilter
bank = QFilterBank()
bank.register(QSmoothingFilter())
bank.register(QEdgeFilter())
source.newFrame.connect(bank.updateFrame)
bank.newFrame.connect(screen.setImage)
By name (when the filter class is not imported directly):
screen.filter.registerByName('QBrightnessFilter')
Overlays#
Architecture#
Overlays draw analysis results on top of the live video inside
QVideoScreen. Each overlay has three
components:
- Worker (
QObject, runs in aQThread) Performs the heavy computation off the GUI thread. Receives frames via a signal, processes them, and emits results via another signal. Keeping analysis off the GUI thread ensures the video display never stutters.
- Graphics item (
pyqtgraph.GraphicsObjectorpyqtgraph.ScatterPlotItem) Draws markers in the
QVideoScreenscene. Its coordinate system matches the video frame: x increases right, y increases downward, with the origin at the top-left corner of the frame. Register it withaddOverlay().- Widget (
QGroupBox) Wires the worker and graphics item together and exposes user-facing controls. Exposes a
sourceproperty that, when set, connects the video source to the worker. Exposes anoverlayproperty that returns the graphics item for registration with a screen.
Writing a simple overlay#
The example below marks the brightest pixel in each frame with a crosshair:
import numpy as np
from qtpy import QtCore, QtWidgets
import pyqtgraph as pg
from QVideo.lib.videotypes import Image
class _BrightSpotWorker(QtCore.QObject):
newData = QtCore.Signal(object) # emits (x, y) tuple or None
@QtCore.Slot(np.ndarray)
def process(self, image: Image) -> None:
gray = np.mean(image, axis=2) if image.ndim == 3 else image
row, col = np.unravel_index(np.argmax(gray), gray.shape)
self.newData.emit((col, row)) # (x, y) in pixel coords
class _BrightSpotOverlay(pg.ScatterPlotItem):
def __init__(self) -> None:
super().__init__(pen=pg.mkPen('r'), brush=pg.mkBrush(None),
symbol='+', size=20, pxMode=True)
@QtCore.Slot(object)
def setPosition(self, pos) -> None:
if pos is None:
self.setData([], [])
else:
self.setData(x=[pos[0]], y=[pos[1]])
class QBrightSpotWidget(QtWidgets.QGroupBox):
'''Overlay that marks the brightest pixel in each frame.'''
_process = QtCore.Signal(np.ndarray)
def __init__(self, parent=None) -> None:
super().__init__('Bright Spot', parent)
self.setCheckable(True)
self.setChecked(False)
self._overlay = _BrightSpotOverlay()
self._worker = _BrightSpotWorker()
self._thread = QtCore.QThread(self)
self._worker.moveToThread(self._thread)
self._process.connect(self._worker.process)
self._worker.newData.connect(self._overlay.setPosition)
self.toggled.connect(self._overlay.setVisible)
self._thread.start()
self._source = None
QtCore.QCoreApplication.instance().aboutToQuit.connect(
self._cleanup)
def _cleanup(self) -> None:
self._thread.quit()
self._thread.wait()
@property
def overlay(self) -> pg.ScatterPlotItem:
'''The graphics item to register with a screen.'''
return self._overlay
@property
def source(self):
'''The video source supplying frames to this overlay.'''
return self._source
@source.setter
def source(self, source) -> None:
if self._source is not None:
self._source.newFrame.disconnect(self._onNewFrame)
self._source = source
if source is not None:
source.newFrame.connect(self._onNewFrame)
@QtCore.Slot(np.ndarray)
def _onNewFrame(self, frame: Image) -> None:
if self.isChecked():
self._process.emit(frame)
Key design decisions:
The
_processsignal is a privateSignalon the widget rather than connectingsource.newFramedirectly to the worker. This lets_onNewFramegate dispatch onisChecked(), so the worker thread is idle when the overlay is disabled._cleanupgracefully stops the worker thread when the application exits. Connect it toQCoreApplication.instance().aboutToQuit.toggled.connect(self._overlay.setVisible)hides the markers when the group box is unchecked, giving immediate visual feedback.
Wiring an overlay into an application#
import pyqtgraph as pg
from QVideo.cameras.Noise import QNoiseSource
from QVideo.lib import QVideoScreen
from qtpy.QtWidgets import QApplication, QHBoxLayout, QWidget
app = QApplication([])
source = QNoiseSource()
screen = QVideoScreen()
source.newFrame.connect(screen.setImage)
widget = QBrightSpotWidget()
screen.addOverlay(widget.overlay) # register graphics item with screen
widget.source = source # connect source to worker
window = QWidget()
layout = QHBoxLayout(window)
layout.addWidget(screen)
layout.addWidget(widget) # add control widget to UI
window.show()
source.start()
app.exec()
addOverlay() places the graphics
item in the screen’s coordinate space. The item is shown and hidden via
toggled() rather than by registering
or unregistering it, so it appears and disappears without any lag.
Composite recording#
When screen.composite = True, newFrame
emits the fully rendered scene — video and all overlay markers — as an
(H, W, 4) RGBA array. Connect that signal to a DVR writer to record
the annotated video:
from QVideo.dvr import QHDF5Writer
screen.composite = True
fps = screen.fps or 24
writer = QHDF5Writer('annotated.h5', fps=fps)
screen.newFrame.connect(writer.write)
# call writer.close() or connect finished signal when done
Set screen.composite = False to revert to recording raw (unannotated) frames.