Source code for QVideo.filters.flatfield

'''Flat field normalization filter and companion Qt widget.'''
from qtpy import QtCore, QtWidgets
from pyqtgraph import SpinBox
from QVideo.lib.QVideoFilter import VideoFilter, QVideoFilter
from QVideo.lib.videotypes import Image
import numpy as np


__all__ = ['FlatFieldFilter', 'QFlatFieldFilter']


[docs] class FlatFieldFilter(VideoFilter): '''Flat field normalization filter. Corrects pixel-wise sensitivity variation by dividing each frame by a stored flat field reference. The reference is the mean of :attr:`nFrames` frames captured under uniform illumination, normalized so that its mean equals 1.0. Division restores uniform response across the sensor. For best results, place this filter after :class:`~QVideo.filters.darkframe.DarkFrameFilter` in the pipeline. When dark-subtracted frames are used during the reference capture the flat field is automatically dark-corrected, and subsequent frames arrive already dark-subtracted. Call :meth:`capture` to start accumulating the flat field reference. Call :meth:`reset` to clear it. Frames pass through unchanged until a reference is captured. Pixels where the normalized flat field is zero are passed through without correction. If the incoming frame shape does not match the stored reference :meth:`get` returns the raw frame until a new capture is performed. Emits :attr:`captured` when a new flat field capture completes. Parameters ---------- nFrames : int Number of frames to average during capture. Default: ``16``. ''' captured = QtCore.Signal() def __init__(self, nFrames: int = 16) -> None: super().__init__() self._flat: np.ndarray | None = None self._accumulator: np.ndarray | None = None self._captureCount: int = 0 self.nFrames = nFrames @property def nFrames(self) -> int: '''Number of frames averaged during capture (≥ 1).''' return self._nFrames @nFrames.setter def nFrames(self, value: int) -> None: self._nFrames = max(1, int(value)) @property def isCapturing(self) -> bool: '''``True`` while a flat field capture is in progress.''' return self._captureCount > 0
[docs] def capture(self) -> None: '''Start accumulating a flat field reference. Resets the accumulator and counts down :attr:`nFrames` calls to :meth:`add`. :attr:`captured` is emitted on completion. ''' self._accumulator = None self._captureCount = self._nFrames
[docs] def reset(self) -> None: '''Clear the stored flat field reference and any ongoing capture. Frames pass through unchanged until a new capture completes. ''' self._flat = None self._accumulator = None self._captureCount = 0
[docs] def add(self, image: Image) -> None: '''Incorporate a new frame into the filter state. During a capture, accumulates frames into a running sum. When the target count is reached the mean is normalized (divided by its own mean) and stored. If the mean is zero, no reference is stored. :attr:`captured` is emitted on completion. Parameters ---------- image : Image Input frame. ''' if self._captureCount > 0: if self._accumulator is None: self._accumulator = image.astype(np.float32) else: self._accumulator += image.astype(np.float32) self._captureCount -= 1 if self._captureCount == 0: flat = self._accumulator / self._nFrames mean = float(flat.mean()) self._flat = flat / mean if mean > 0 else None self._accumulator = None self.captured.emit() self.data = image
[docs] def get(self) -> Image | None: '''Return the flat-field-corrected frame. Returns ``None`` before the first :meth:`add`, the raw frame if no reference is stored or if the frame shape does not match the reference, and the corrected (clipped) frame otherwise. Pixels where the flat field is zero pass through unchanged. Returns ------- Image or None ''' if self.data is None: return None if (self._flat is None or self.data.shape != self._flat.shape): return self.data safe = np.where(self._flat > 0, self._flat, 1.0) corrected = np.where( self._flat > 0, self.data.astype(np.float32) / safe, self.data.astype(np.float32)) return np.clip(corrected, 0, 255).astype(np.uint8)
[docs] class QFlatFieldFilter(QVideoFilter): '''Widget for :class:`FlatFieldFilter` with capture controls. A checkable group box with a *frames* spinbox setting how many frames are averaged, a *Capture* button to start accumulation, and a *Reset* button to clear the stored reference. The *Capture* button is disabled during accumulation and re-enabled on completion. Parameters ---------- parent : QtWidgets.QWidget or None Parent widget. ''' display_name = 'Flat Field' display_category = 'Calibration' def __init__( self, parent: QtWidgets.QWidget | None = None) -> None: super().__init__(parent, 'Flat Field', FlatFieldFilter()) def _setupUi(self) -> None: super()._setupUi() self._layout.addWidget(QtWidgets.QLabel('frames')) self._nFramesBox = SpinBox( value=self.filter.nFrames, bounds=(1, 256), int=True) self._layout.addWidget(self._nFramesBox) self._captureButton = QtWidgets.QPushButton('Capture', self) self._layout.addWidget(self._captureButton) self._resetButton = QtWidgets.QPushButton('Reset', self) self._layout.addWidget(self._resetButton) def _connectSignals(self) -> None: super()._connectSignals() self._nFramesBox.valueChanged.connect(self._setNFrames) self._captureButton.clicked.connect(self._capture) self._resetButton.clicked.connect(self._reset) self.filter.captured.connect(self._onCaptured) @QtCore.Slot(object) def _setNFrames(self, value: int) -> None: self.filter.nFrames = int(value) with QtCore.QSignalBlocker(self._nFramesBox): self._nFramesBox.setValue(self.filter.nFrames) @QtCore.Slot(bool) def _capture(self, _checked: bool = False) -> None: self._captureButton.setEnabled(False) self.filter.capture() @QtCore.Slot() def _onCaptured(self) -> None: self._captureButton.setEnabled(True) @QtCore.Slot(bool) def _reset(self, _checked: bool = False) -> None: self.filter.reset()
if __name__ == '__main__': # pragma: no cover QFlatFieldFilter.example()