Source code for giant.image_processing.peak_finders

"""
This module provides functionality for finding the (subpixel) peak of a 2d surface or of 1d lines.
"""

import numpy as np

from numpy.typing import NDArray

import cv2

from giant._typing import ARRAY_LIKE


[docs] def pixel_level_peak_finder_2d(surface: ARRAY_LIKE, blur: bool = True) -> NDArray[np.integer]: """ This function returns a numpy array containing the (x, y) location of the maximum surface value to pixel level accuracy. Optionally, a blur can be applied to the surface before locating the peak to attempt to remove high frequency noise. :param surface: A surface, or image, to use :param blur: A flag to indicate whether to apply Gaussian blur to image :return: The (x, y) location of the maximum surface values to pixel level accuracy. :raises ValueError: If the provided surface is not 2 dimensional """ array_surface: NDArray = np.asanyarray(surface) if array_surface.ndim != 2: raise ValueError('The surface must be 2d to use this function') if blur: valid_dtypes = (np.uint8, np.uint16, np.int16, np.float32, np.float64) if not any(np.issubdtype(array_surface.dtype, vdtype) for vdtype in valid_dtypes): array_surface = array_surface.astype(np.float64) # Do this to try to avoid spikes due to noise aligning array_surface = cv2.GaussianBlur(array_surface, (5, 5), 1, None, 1, cv2.BORDER_CONSTANT) return np.flipud(np.unravel_index(np.argmax(array_surface), array_surface.shape))
[docs] def quadric_peak_finder_2d(surface: ARRAY_LIKE, fit_size: int = 1, blur: bool = True, shift_limit: int = 3) -> NDArray[np.float64]: r""" This function returns a numpy array containing the (x, y) location of the maximum surface value which corresponds to the peak of the fitted quadric surface to subpixel accuracy. First, this function calls :func:`pixel_level_peak_finder_2d` to identify the pixel location of the peak of the correlation surface. It then fits a 2D quadric to the pixels around the peak and solves for the center of the quadric to be the peak value. The quadric equation that is fit is .. math:: z = Ax^2+By^2+Cxy+Dx+Ey+F where :math:`z` is the height of the correlation surface at location :math:`(x,y)`, and :math:`A--F` are the coefficients to be fit. The fit is performed in an algebraic least squares sense. The location of the peak of the surface is then given by: .. math:: \left[\begin{array}{c}x_p \\ y_p\end{array}\right] = \frac{1}{4AB-C^2}\left[\begin{array}{c} CE-2BD\\ CD-2AE\end{array}\right] where :math:`(x_p,y_p)` is the location of the peak. If the peak is invalid because it is too close to the edge, the fit failed, or the parabolic fit moved the peak too far from the pixel level peak then the result is returned as NaNs. :param surface: A surface, or image, to use :param fit_size: Number of pixels around the peak that are used in fitting the paraboloid :param blur: A flag to indicate whether to apply Gaussian blur to the correlation surface to filter out high frequency noise :param shift_limit: maximum difference from the pixel level peak to the fitted peak for the fitted peak to be accepted :return: The (x, y) location corresponding to the peak of fitted quadric surface to subpixel accuracy :raises ValueError: If the provided surface is not 2 dimensional """ # make sure we have an array surface = np.asarray(surface) # find the pixel level peak max_col, max_row = pixel_level_peak_finder_2d(surface, blur=blur) # if we're too close to the edge return NaNs if ((max_row - fit_size) < 0) or ((max_row + fit_size) >= surface.shape[0]) or \ ((max_col - fit_size) < 0) or ((max_col + fit_size) >= surface.shape[1]): return np.array([np.nan, np.nan]) # set up the columns/rows we will fit the peak to deltas = np.arange(-fit_size, fit_size + 1) cols, rows = np.meshgrid(max_col + deltas, max_row + deltas) cols = cols.ravel() rows = rows.ravel() # form the jacobian matrix for the least squares jac_matrix = np.array([cols * cols, rows * rows, cols * rows, cols, rows, np.ones(rows.shape)]).T # perform the least squares fit coefs = np.linalg.lstsq(jac_matrix, surface[rows, cols].ravel(), rcond=None)[0] # extract the peak column and row peak_col = (coefs[2] * coefs[4] - 2 * coefs[1] * coefs[3]) / (4 * coefs[0] * coefs[1] - coefs[2] ** 2) peak_row = (coefs[2] * coefs[3] - 2 * coefs[0] * coefs[4]) / (4 * coefs[0] * coefs[1] - coefs[2] ** 2) # Check if peak of fitted parabolic surface is outside the correlation surface: if peak_col > (surface.shape[1] - 1) or peak_row > (surface.shape[0] - 1): return np.array([np.nan, np.nan]) # Check if peak pixel and peak of fitted parabolic surface are reasonably close: if (abs(max_col - peak_col) > shift_limit) or (abs(max_row - peak_row) > shift_limit): return np.array([np.nan, np.nan]) # Fit is valid, return the fit: return np.array([peak_col, peak_row])
[docs] def pixel_level_peak_finder_1d(correlation_lines: np.ndarray) -> NDArray[np.integer]: """ This function returns a numpy array containing the location of the maximum surface value to pixel level accuracy for each row of the input matrix. :return: The location of the maximum surface values to pixel level accuracy. """ out = np.argmax(correlation_lines, axis=-1)[..., np.newaxis] return out
[docs] def parabolic_peak_finder_1d(correlation_lines: np.ndarray, fit_size: int = 1) -> NDArray[np.float64]: r""" Find the subpixel maximum location along each row. First, this function calls :func:`pixel_level_peak_finder_1d` to identify the location of the peak of each row. It then fits a parabola to the values around the peak and solves for the center of the parabola to be the peak value. The parabola equation that is fit is .. math:: y = Ax^2+Bx+C where :math:`y` is the value of the correlation line at location :math:`x`, and :math:`A-C` are the coefficients to be fit. The fit is performed in an algebraic least squares sense. The location of the peak of the surface is then given by: .. math:: x_p = \frac{-B}{2A} where :math:`x_p` is the location of the peak. :param correlation_lines: array of correlation lines :param fit_size: number of values on each side to include in the parabola fit :return: array of subpixel centers for each row """ # Get the pixel level correlation surface max_cols = pixel_level_peak_finder_1d(correlation_lines) # Determine which values to include in the parabola fit deltas = np.arange(-fit_size, fit_size + 1) # Get the original shape of the correlation lines base_shape = correlation_lines.shape[:-1] # Reshape the correlation lines to be only 2d correlation_lines = correlation_lines.reshape(np.prod(base_shape), -1) # Get the column indices for the fit cols = (max_cols.reshape((np.prod(base_shape), 1)) + deltas.reshape(1, -1)) # Build the jacobian matrix jac_matrix = np.rollaxis(np.array([cols * cols, cols, np.ones(cols.shape)], dtype=np.float64), 0, -1) # Build the rhs rhs: NDArray[np.float64] = correlation_lines[np.ogrid[:correlation_lines.shape[0], :0][:1] + (None, cols)] # Fit the paraboloid using LLS solus = np.linalg.solve(jac_matrix @ jac_matrix.swapaxes(-1, -2), jac_matrix @ rhs) # Return the subpixel center return (-solus[..., 1, :] / (2 * solus[..., 0, :])).reshape(base_shape + (-1,))