Source code for QVideo.filters.dejitter

'''Dejitter filter using phase-correlation image stabilization.'''
from qtpy import QtCore, QtWidgets
from pyqtgraph import SpinBox
from QVideo.lib.AsyncVideoFilter import AsyncVideoFilter
from QVideo.lib.QVideoFilter import QVideoFilter
from QVideo.lib.videotypes import Image
import numpy as np
import cv2


__all__ = ['DejitterFilter', 'QDejitterFilter']


[docs] class DejitterFilter(AsyncVideoFilter): '''Translation-only video stabilizer using phase correlation. Estimates the per-frame shift of the camera relative to a reference image via ``cv2.phaseCorrelate`` (FFT-based cross-correlation) and corrects it with ``cv2.warpAffine``. Two reference-update modes are supported: - **static**: the reference is the first frame seen after construction or :meth:`reset`. All subsequent frames are aligned to that fixed origin. Best for suppressing mechanical vibration around a fixed position. - **rolling**: the reference is updated each frame by an exponential moving average (weight *alpha* on the new frame). The reference therefore tracks slow drift, so only fast jitter is corrected. Best for long acquisitions where deliberate stage motion should be preserved. A Hanning window is applied before phase correlation to reduce spectral leakage. Parameters ---------- mode : str Reference update mode: ``'static'`` or ``'rolling'``. Default: ``'static'``. alpha : float EMA weight on the incoming frame for rolling-mode reference updates. Clamped to ``(0, 1]``. Ignored in static mode. Default: ``0.05``. ''' MODES = ('static', 'rolling') def __init__(self, mode: str = 'static', alpha: float = 0.05) -> None: if mode not in self.MODES: raise ValueError(f'mode must be one of {self.MODES}') self._mode = mode self._alpha = float(np.clip(alpha, 1e-6, 1.0)) self._reference: np.ndarray | None = None self._window: np.ndarray | None = None super().__init__() @property def mode(self) -> str: '''Reference update mode; one of :attr:`MODES`.''' return self._mode @mode.setter def mode(self, value: str) -> None: if value not in self.MODES: raise ValueError(f'mode must be one of {self.MODES}') if value != self._mode: self._mode = value self.reset() @property def alpha(self) -> float: '''EMA weight on incoming frame for rolling reference, in ``(0, 1]``.''' return self._alpha @alpha.setter def alpha(self, value: float) -> None: self._alpha = float(np.clip(value, 1e-6, 1.0))
[docs] def reset(self) -> None: '''Clear the reference frame and restart stabilization.''' self._reference = None self._window = None self._result = None
[docs] def process(self, image: Image) -> Image: '''Estimate and correct the translational shift of *image*. Called in the background thread. The first frame after construction or :meth:`reset` seeds the reference and is returned unchanged. Parameters ---------- image : Image Input frame (grayscale or BGR ``uint8``). Returns ------- Image Stabilized frame with the same shape and dtype as *image*. ''' h, w = image.shape[:2] gray = (cv2.cvtColor(image, cv2.COLOR_BGR2GRAY).astype(np.float32) if image.ndim == 3 else image.astype(np.float32)) if self._reference is None or self._reference.shape != gray.shape: self._reference = gray.copy() self._window = cv2.createHanningWindow((w, h), cv2.CV_32F) return image (dx, dy), _ = cv2.phaseCorrelate(self._reference.copy(), gray.copy(), self._window.copy()) M = np.float32([[1, 0, -dx], [0, 1, -dy]]) corrected = cv2.warpAffine(image, M, (w, h)) if self._mode == 'rolling': self._reference += self._alpha * (gray - self._reference) return corrected
[docs] class QDejitterFilter(QVideoFilter): '''Widget for :class:`DejitterFilter` with mode selector, alpha spinbox, and a reset button. Parameters ---------- parent : QtWidgets.QWidget or None Parent widget. ''' display_name = 'Dejitter' display_category = 'Preprocessing' def __init__(self, parent: QtWidgets.QWidget | None = None) -> None: super().__init__(parent, 'Dejitter', DejitterFilter()) def _setupUi(self) -> None: super()._setupUi() self._modeBox = QtWidgets.QComboBox() self._modeBox.addItems(['Static', 'Rolling']) self._layout.addWidget(self._modeBox) self._alphaBox = SpinBox(value=self.filter.alpha, bounds=(1e-6, 1.0), step=0.05, prefix='α ') self._alphaBox.setVisible(False) self._layout.addWidget(self._alphaBox) self._resetButton = QtWidgets.QPushButton('Reset') self._layout.addWidget(self._resetButton) def _connectSignals(self) -> None: super()._connectSignals() self._modeBox.currentTextChanged.connect(self._setMode) self._alphaBox.valueChanged.connect(self._setAlpha) self._resetButton.clicked.connect(self._resetReference) @QtCore.Slot(str) def _setMode(self, text: str) -> None: mode = text.lower() self.filter.mode = mode self._alphaBox.setVisible(mode == 'rolling') @QtCore.Slot(object) def _setAlpha(self, value: float) -> None: self.filter.alpha = value @QtCore.Slot() def _resetReference(self) -> None: self.filter.reset()
if __name__ == '__main__': # pragma: no cover QDejitterFilter.example()