Source code for QVideo.filters.circletransform
'''Orientation alignment transform filter — detects ring-like features.'''
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
from numpy.typing import NDArray
from scipy.signal import savgol_filter
try:
from scipy.fft import fft2, ifft2, fftshift
except ImportError:
from scipy.fftpack import fft2, ifft2, fftshift
__all__ = ['CircleTransformFilter', 'QCircleTransformFilter']
[docs]
class CircleTransformFilter(AsyncVideoFilter):
'''Orientation alignment transform (OAT) ring-detection filter.
Detects circularly symmetric ring-like features by computing the
orientation alignment transform of Krishnatreya & Grier (2014).
Each pixel's gradient orientation is compared against the expected
orientation for a ring centred at every candidate position; the
result is a detection map whose peaks locate ring centres.
The transform integrates evidence from all ring radii simultaneously,
so no radius parameter is required. Computation runs in a background
thread via :class:`~QVideo.lib.AsyncVideoFilter.AsyncVideoFilter`.
Parameters
----------
window : int
Savitzky-Golay derivative window size [pixels]. Must be odd and
greater than *polyorder*. Larger values smooth noise but reduce
spatial resolution of detected ring centres. Default: ``13``.
polyorder : int
Savitzky-Golay polynomial order. Must be less than *window*.
Default: ``3``.
Notes
-----
The OAT kernel :math:`K(\\mathbf{k}) = e^{-2i\\theta_k}/|\\mathbf{k}|`
is cached by frame shape and recomputed only when the shape changes.
The output is normalised per-frame to ``[0, 255]`` and returned as
``uint8``. Peak brightness indicates likely ring centres.
References
----------
B. J. Krishnatreya and D. G. Grier,
'Fast feature identification for holographic tracking: the
orientation alignment transform,'
*Optics Express* **22**, 12773–12778 (2014).
'''
def __init__(self, window: int = 13, polyorder: int = 3) -> None:
self._kernel = np.ones((1, 1))
self.window = window
self.polyorder = polyorder
super().__init__()
@property
def window(self) -> int:
'''Savitzky-Golay derivative window [pixels], always odd and ≥ 3.'''
return self._window
@window.setter
def window(self, window: int) -> None:
window = max(3, int(window))
self._window = window + (1 - window % 2)
@property
def polyorder(self) -> int:
'''Savitzky-Golay polynomial order, always ≥ 1.'''
return self._polyorder
@polyorder.setter
def polyorder(self, polyorder: int) -> None:
self._polyorder = max(1, int(polyorder))
def _kernel_for(self, shape: tuple[int, int]) -> NDArray:
if shape == self._kernel.shape:
return self._kernel
ny, nx = shape
kx = fftshift(np.linspace(-1., 1., nx, endpoint=False))
ky = fftshift(np.linspace(-1., 1., ny, endpoint=False))
k = np.hypot.outer(ky, kx) + 0.001
kernel = np.subtract.outer(1.j * ky, kx) / k
kernel *= kernel / k
self._kernel = kernel
return kernel
[docs]
def process(self, image: Image) -> Image:
'''Compute the OAT of *image* and return a uint8 heat map.
Called in the background thread. Converts colour input to float
grayscale, computes orientational order gradients via
Savitzky-Golay differentiation, then convolves with the OAT kernel
in Fourier space.
Parameters
----------
image : Image
Input frame (grayscale or colour uint8).
Returns
-------
Image
OAT heat map, same spatial shape as *image*, dtype ``uint8``.
Bright peaks indicate ring centres.
'''
gray = image.mean(axis=2) if image.ndim == 3 else image.astype(float)
psi = np.empty(gray.shape, dtype=complex)
psi.real = savgol_filter(gray, self._window, self._polyorder, 1, axis=1)
psi.imag = savgol_filter(gray, self._window, self._polyorder, 1, axis=0)
psi *= psi
psi = fft2(psi, workers=-1, overwrite_x=True)
psi *= self._kernel_for(gray.shape)
psi = ifft2(psi, workers=-1, overwrite_x=True)
c = psi.real ** 2 + psi.imag ** 2
cmax = c.max()
if cmax > np.finfo(float).eps:
c *= 255.0 / cmax
return np.round(c).clip(0, 255).astype(np.uint8)
[docs]
class QCircleTransformFilter(QVideoFilter):
'''Widget for :class:`CircleTransformFilter` with a window spinbox.
Parameters
----------
parent : QtWidgets.QWidget or None
Parent widget.
'''
display_name = 'Circle Transform'
display_category = 'Feature Detection'
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
super().__init__(parent, 'Circle Transform', CircleTransformFilter())
def _setupUi(self) -> None:
super()._setupUi()
self._layout.addWidget(QtWidgets.QLabel('window'))
self._spinbox = SpinBox(value=self.filter.window,
step=2, int=True)
self._spinbox.setMinimum(3)
self._layout.addWidget(self._spinbox)
def _connectSignals(self) -> None:
super()._connectSignals()
self._spinbox.valueChanged.connect(self._setWindow)
@QtCore.Slot(object)
def _setWindow(self, window: int) -> None:
self.filter.window = window
with QtCore.QSignalBlocker(self._spinbox):
self._spinbox.setValue(self.filter.window)
if __name__ == '__main__': # pragma: no cover
QCircleTransformFilter.example()