Source code for QVideo.filters.darkframe

'''Dark frame subtraction 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__ = ['DarkFrameFilter', 'QDarkFrameFilter']


[docs] class DarkFrameFilter(VideoFilter): '''Dark frame subtraction filter. Subtracts a stored dark frame from every incoming image to remove camera baseline noise: thermal electrons, fixed-pattern noise, and amplifier offset. Call :meth:`capture` to start accumulating :attr:`nFrames` frames with no illumination. Once accumulated the mean is stored as the dark reference and :attr:`captured` is emitted. Every subsequent frame is dark-subtracted; negative values are clipped to zero. Call :meth:`reset` to clear the stored reference. Frames pass through unchanged until a new capture completes. If the incoming frame shape changes after capture the reference is incompatible; :meth:`get` returns the raw frame until a new capture is performed. 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._dark: 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 dark frame capture is in progress.''' return self._captureCount > 0
[docs] def capture(self) -> None: '''Start accumulating a dark frame. 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 dark frame and any ongoing capture. Frames pass through unchanged until a new capture completes. ''' self._dark = 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 stored as the dark frame and :attr:`captured` is emitted. 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: self._dark = ( self._accumulator / self._nFrames ).astype(np.uint8) self._accumulator = None self.captured.emit() self.data = image
[docs] def get(self) -> Image | None: '''Return the dark-subtracted frame. Returns ``None`` before the first :meth:`add`, the raw frame if no dark reference is stored or if the frame shape does not match the reference, and the clipped dark-subtracted frame otherwise. Returns ------- Image or None ''' if self.data is None: return None if (self._dark is None or self.data.shape != self._dark.shape): return self.data return np.clip( self.data.astype(np.int16) - self._dark.astype(np.int16), 0, 255).astype(np.uint8)
[docs] class QDarkFrameFilter(QVideoFilter): '''Widget for :class:`DarkFrameFilter` 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 dark frame. The *Capture* button is disabled during accumulation and re-enabled on completion. Parameters ---------- parent : QtWidgets.QWidget or None Parent widget. ''' display_name = 'Dark Frame' display_category = 'Calibration' def __init__( self, parent: QtWidgets.QWidget | None = None) -> None: super().__init__(parent, 'Dark Frame', DarkFrameFilter()) 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 QDarkFrameFilter.example()