Source code for giant.image_processing.edge_detection.pae_subpixel_edge_detector

from dataclasses import dataclass

import numpy as np
from numpy.typing import NDArray

from giant.utilities.options import UserOptions
from giant.utilities.mixin_classes.user_option_configured import UserOptionConfigured

from giant.image_processing.edge_detection.edge_detector_base import EdgeDetector
from giant.image_processing.edge_detection.pixel_edge_detector import PixelEdgeDetector

from giant._typing import DOUBLE_ARRAY


@dataclass
class PAESubpixelEdgeDetectorOptions(UserOptions):
    a01: float = 0.125
    """
    The 0, 1 coefficient (upper middle) of the Gaussian kernel representing the blurring experienced in the images being 
    processed for the PAE sub-pixel edge method.

    By default this is set to 0.125 assuming a 2D gaussian kernel with a sigma of 1 pixel in each axis.  If you know a 
    better approximation of the gaussian kernel that represents the point spread function in the image (combined with any 
    gaussian blurring applied to the image to smooth out noise) then you may get better results from the PAE method by 
    updating this value.

    https://www.researchgate.net/publication/233397974_Accurate_Subpixel_Edge_Location_based_on_Partial_Area_Effect
    """
    
    a11: float = 0.0625
    """
    The 1, 1 coefficient (upper right) of the Gaussian kernel representing the blurring experienced in the images being 
    processed for the PAE sub-pixel edge method.

    By default this is set to 0.0625 assuming a 2D gaussian kernel with a sigma of 1 pixel in each axis.  If you know a 
    better approximation of the gaussian kernel that represents the point spread function in the image (combined with any 
    gaussian blurring applied to the image to smooth out noise) then you may get better results from the PAE method by 
    updating this value.

    https://www.researchgate.net/publication/233397974_Accurate_Subpixel_Edge_Location_based_on_Partial_Area_Effect
    """
    
    order: int = 2
    """
    This specifies whether to fit a linear (1) or quadratic (2) to the limb in the PAE method.  
    
    Typically quadratic produces the best results.
    """

[docs] class PAESubpixelEdgeDetector(UserOptionConfigured[PAESubpixelEdgeDetectorOptions], EdgeDetector[DOUBLE_ARRAY], PAESubpixelEdgeDetectorOptions): """ This class can be used to identify and refine to the subpixel location of edges in an image using the partial area effect method. Edges are defined as places in the image where the illumination values abruptly transition from light to dark or dark to light. The algorithms in this method are based off of the Partial Area Effect as discussed in http://www.sciencedirect.com/science/article/pii/S0262885612001850 First edges are detected at the pixel level by using a gradient based edge detection method. The edges are then refined to subpixel accuracy using the PAE. Tests have shown that the PAE achieves accuracy better than 0.1 pixels in most cases. There are three tuning parameters for the PAE technique. The first tuning parameter is the :attr:`.order`, which specifies whether a linear or quadratic fit is used to refine the edge location. It should have a value of 1 or 2. The second and third are the :attr:`.a01` and :att:`.a11` attributes. These specify the upper middle and upper right coeficients of the expected 3x3 Gaussian PSF that described how edges are blurred in the image. """ def __init__(self, options: PAESubpixelEdgeDetectorOptions | None) -> None: super().__init__(PAESubpixelEdgeDetectorOptions, options=options) # create a local pixel level edge instance to do the initial detection self._pixel_edge_detector = PixelEdgeDetector() @staticmethod def _split_pos_neg_edges(horizontal_gradient: DOUBLE_ARRAY, vertical_gradient: DOUBLE_ARRAY, edges: DOUBLE_ARRAY) -> tuple[DOUBLE_ARRAY, DOUBLE_ARRAY]: """ This function splits diagonal edges into positive/negative bins :param horizontal_gradient: The horizontal gradient array :param vertical_gradient: The vertical gradient array :param edges: The edge array containing the pixel location of the edges as [x, y] :return: The edges split into positive and negative groupings """ # check with edges are positive edges positive_check = horizontal_gradient[edges[1], edges[0]] * vertical_gradient[edges[1], edges[0]] > 0 # split and return the binned edges return edges[:, positive_check], edges[:, ~positive_check] def _compute_pae_delta(self, sum_a: NDArray, sum_b: NDArray, sum_c: NDArray, int_a: NDArray, int_b: NDArray) -> DOUBLE_ARRAY: """ This method computes the subpixel location of an edge using the pae method within a pixel. This method is vectorized so multiple edges can be refined at the same time. Essentially this method either fits a line or a parabola to the edge based off of the intensity data surrounding the edge. if :attr:`pae_order` is set to 1, then a linear fit is made. If it is set to 2 then a parabola fit is made. :param sum_a: The sum of the first row or first column (depending on whether this is a horizontal or vertical edge) :param sum_b: The sum of the middle row or column (depending on whether this is a horizontal or vertical edge) :param sum_c: The sum of the final row or column (depending on whether this is a horizontal or vertical edge) :param int_a: The average intensity to the positive side of the edge :param int_b: The average intensity to the negative side of the edge :return: The offset in the local pixel for the subpixel edge locations. """ a_coef = (self.order - 1) * (sum_a + sum_c - 2 * sum_b) / (2 * (int_b - int_a)) c_coef = ((2 * sum_b - 7 * (int_b + int_a)) / (2 * (int_b - int_a)) - a_coef * (1 + 24 * self.a01 + 48 * self.a11) / 12) c_coef[(np.abs(c_coef) > 1) | ~np.isfinite(c_coef)] = 0 return c_coef
[docs] def refine_edges(self, image: NDArray, edges: NDArray[np.int64]) -> DOUBLE_ARRAY: """ This method refines pixel level edges to subpixel level using the PAE method. The PAE method is explained at https://www.sciencedirect.com/science/article/pii/S0262885612001850 and is not discussed in detail here. In brief, a linear or parabolic function is fit to the edge data based off of the intensity data in the pixels surrounding the edge locations. To use this method, you must input the image, as well as a 2xn array of [[x], [y]] edges to be refined. The edges are refined and returned as a 2D array with the x locations in the first row and the y locations in the second row. :param image: The image the edges are being extracted from :param edges: The pixel level edges from the image as a 2D array with x in the first row and y in the second row :return: The subpixel edge locations as a 2d array with the x values in the first row and the y values in the second row (col [x], row [y]) """ if (self.horizontal_mask is None or self.vertical_mask is None or self.horizontal_gradient is None or self.vertical_gradient is None or self.gradient_magnitude is None): self.prepare_edge_inputs(image) assert (self.horizontal_gradient is not None and self.horizontal_mask is not None and self.vertical_gradient is not None and self.vertical_mask is not None and self.gradient_magnitude is not None), "This should never happen" image = image.astype(np.float64) edges = edges.T # split into horizontal and vertical edges horizontal_edges = edges[self.horizontal_mask[edges[:, 1], edges[:, 0]]].T # type: ignore vertical_edges = edges[self.vertical_mask[edges[:, 1], edges[:, 0]]].T # type: ignore horiz_pos_edges, horiz_neg_edges = self._split_pos_neg_edges(self.horizontal_gradient, self.vertical_gradient, horizontal_edges) vert_pos_edges, vert_neg_edges = self._split_pos_neg_edges(self.horizontal_gradient, self.vertical_gradient, vertical_edges) # process the horizontal edges # precompute the indices prm4 = horiz_pos_edges[1] - 4 prm3 = horiz_pos_edges[1] - 3 prm2 = horiz_pos_edges[1] - 2 prm1 = horiz_pos_edges[1] - 1 pr = horiz_pos_edges[1] prp1 = horiz_pos_edges[1] + 1 prp2 = horiz_pos_edges[1] + 2 prp3 = horiz_pos_edges[1] + 3 prp4 = horiz_pos_edges[1] + 4 pcm1 = horiz_pos_edges[0] - 1 pc = horiz_pos_edges[0] pcp1 = horiz_pos_edges[0] + 1 nrm4 = horiz_neg_edges[1] - 4 nrm3 = horiz_neg_edges[1] - 3 nrm2 = horiz_neg_edges[1] - 2 nrm1 = horiz_neg_edges[1] - 1 nr = horiz_neg_edges[1] nrp1 = horiz_neg_edges[1] + 1 nrp2 = horiz_neg_edges[1] + 2 nrp3 = horiz_neg_edges[1] + 3 nrp4 = horiz_neg_edges[1] + 4 ncm1 = horiz_neg_edges[0] - 1 nc = horiz_neg_edges[0] ncp1 = horiz_neg_edges[0] + 1 # calculate the average intensity on either side of the edge # above the edge for positive sloped edges int_top_pos = image[[prm3, prm4, prm4], [pcm1, pcm1, pc]].sum(axis=0) / 3 # below the edge for positive sloped edges int_bot_pos = image[[prp3, prp4, prp4], [pcp1, pcp1, pc]].sum(axis=0) / 3 # above the edge for negative sloped edges int_top_neg = image[[nrm3, nrm4, nrm4], [ncp1, ncp1, nc]].sum(axis=0) / 3 # below the edge for negative sloped edges int_bot_neg = image[[nrp3, nrp4, nrp4], [ncm1, ncm1, nc]].sum(axis=0) / 3 # sum the columns of intensity for the positive slop edges sum_left_pos_slope = image[[prm2, prm1, pr, prp1, prp2, prp3, prp4], [pcm1, pcm1, pcm1, pcm1, pcm1, pcm1, pcm1]].sum(axis=0) sum_mid_pos_slope = image[[prm3, prm2, prm1, pr, prp1, prp2, prp3], [pc, pc, pc, pc, pc, pc, pc]].sum(axis=0) sum_right_pos_slope = image[[prm4, prm3, prm2, prm1, pr, prp1, prp2], [pcp1, pcp1, pcp1, pcp1, pcp1, pcp1, pcp1]].sum(axis=0) # sum the columns of intensity for the negative slop edges sum_left_neg_slope = image[[nrm4, nrm3, nrm2, nrm1, nr, nrp1, nrp2], [ncm1, ncm1, ncm1, ncm1, ncm1, ncm1, ncm1]].sum(axis=0) sum_mid_neg_slope = image[[nrm3, nrm2, nrm1, nr, nrp1, nrp2, nrp3], [nc, nc, nc, nc, nc, nc, nc]].sum(axis=0) sum_right_neg_slope = image[[nrm2, nrm1, nr, nrp1, nrp2, nrp3, nrp4], [ncp1, ncp1, ncp1, ncp1, ncp1, ncp1, ncp1]].sum(axis=0) # calculate the coefficient for the partial area for the positive slopes dy_pos_slope = self._compute_pae_delta(sum_left_pos_slope, sum_mid_pos_slope, sum_right_pos_slope, int_top_pos, int_bot_pos) # calculate the subpixel edge locations for the positive slope edges sp_horiz_edges_pos = horiz_pos_edges.astype(np.float64) sp_horiz_edges_pos[1] -= dy_pos_slope # calculate the coefficient for the partial area for the positive slopes dy_neg_slope = self._compute_pae_delta(sum_left_neg_slope, sum_mid_neg_slope, sum_right_neg_slope, int_top_neg, int_bot_neg) # calculate the subpixel edge locations for the negative slope edges sp_horiz_edges_neg = horiz_neg_edges.astype(np.float64) sp_horiz_edges_neg[1] -= dy_neg_slope # process the vertical edges # precompute the indices pcm4 = vert_pos_edges[0] - 4 pcm3 = vert_pos_edges[0] - 3 pcm2 = vert_pos_edges[0] - 2 pcm1 = vert_pos_edges[0] - 1 pc = vert_pos_edges[0] pcp1 = vert_pos_edges[0] + 1 pcp2 = vert_pos_edges[0] + 2 pcp3 = vert_pos_edges[0] + 3 pcp4 = vert_pos_edges[0] + 4 prm1 = vert_pos_edges[1] - 1 pr = vert_pos_edges[1] prp1 = vert_pos_edges[1] + 1 ncm4 = vert_neg_edges[0] - 4 ncm3 = vert_neg_edges[0] - 3 ncm2 = vert_neg_edges[0] - 2 ncm1 = vert_neg_edges[0] - 1 nc = vert_neg_edges[0] ncp1 = vert_neg_edges[0] + 1 ncp2 = vert_neg_edges[0] + 2 ncp3 = vert_neg_edges[0] + 3 ncp4 = vert_neg_edges[0] + 4 nrm1 = vert_neg_edges[1] - 1 nr = vert_neg_edges[1] nrp1 = vert_neg_edges[1] + 1 # calculate the average intensity on either side of the edge # left of the edge for positive sloped edges int_left_pos = image[[prm1, prm1, pr], [pcm3, pcm4, pcm4]].sum(axis=0) / 3 # right of the edge for positive sloped edges int_right_pos = image[[prp1, prp1, pr], [pcp3, pcp4, pcp4]].sum(axis=0) / 3 # left of the edge for negative sloped edges int_left_neg = image[[nrp1, nrp1, nr], [ncm3, ncm4, ncm4]].sum(axis=0) / 3 # right of the edge for negative sloped edges int_right_neg = image[[nrm1, nrm1, nr], [ncp3, ncp4, ncp4]].sum(axis=0) / 3 # sum the rows of intensity for the positive slop edges sum_top_pos_slope = image[[prm1, prm1, prm1, prm1, prm1, prm1, prm1], [pcm2, pcm1, pc, pcp1, pcp2, pcp3, pcp4]].sum(axis=0) sum_mid_pos_slope = image[[pr, pr, pr, pr, pr, pr, pr], [pcm3, pcm2, pcm1, pc, pcp1, pcp2, pcp3]].sum(axis=0) sum_bottom_pos_slope = image[[prp1, prp1, prp1, prp1, prp1, prp1, prp1], [pcm4, pcm3, pcm2, pcm1, pc, pcp1, pcp2]].sum(axis=0) # sum the rows of intensity for the negative slop edges sum_top_neg_slope = image[[nrm1, nrm1, nrm1, nrm1, nrm1, nrm1, nrm1], [ncm4, ncm3, ncm2, ncm1, nc, ncp1, ncp2]].sum(axis=0) sum_mid_neg_slope = image[[nr, nr, nr, nr, nr, nr, nr], [ncm3, ncm2, ncm1, nc, ncp1, ncp2, ncp3]].sum(axis=0) sum_bottom_neg_slope = image[[nrp1, nrp1, nrp1, nrp1, nrp1, nrp1, nrp1], [ncm2, ncm1, nc, ncp1, ncp2, ncp3, ncp4]].sum(axis=0) # calculate the coefficient for the partial area for the positive slopes dx_pos_slope = self._compute_pae_delta(sum_top_pos_slope, sum_mid_pos_slope, sum_bottom_pos_slope, int_left_pos, int_right_pos) # calculate the subpixel edge locations for the positive slope edges sp_vert_edges_pos = vert_pos_edges.astype(np.float64) sp_vert_edges_pos[0] -= dx_pos_slope # calculate the coefficient for the partial area for the positive slopes dx_neg_slope = self._compute_pae_delta(sum_top_neg_slope, sum_mid_neg_slope, sum_bottom_neg_slope, int_left_neg, int_right_neg) # calculate the subpixel edge locations for the negative slope edges sp_vert_edges_neg = vert_neg_edges.astype(np.float64) sp_vert_edges_neg[0] -= dx_neg_slope # return the subpixel edges return np.hstack([sp_horiz_edges_pos, sp_horiz_edges_neg, sp_vert_edges_pos, sp_vert_edges_neg])
[docs] def identify_edges(self, image: NDArray) -> DOUBLE_ARRAY: """ This method identifies pixel level edges in the image and then refines them to the subpixel locations using the PAE method. The pixel edges are detected using the :class:`.PixelEdgeDetector`. They are then refined using :meth:`.refine_edges`. :param image: The image to detect the edges in. :return: The subpixel edges as a 2xn array with x (column) components in the first axis and y (row) components in the second axis """ if self.horizontal_mask is None or self.vertical_mask is None or self.horizontal_gradient is None or self.vertical_gradient is None: self.prepare_edge_inputs(image) # copy over the infomration to the pixel edge detector self._pixel_edge_detector.horizontal_gradient = self.horizontal_gradient self._pixel_edge_detector.vertical_gradient= self.vertical_gradient self._pixel_edge_detector.horizontal_mask = self.horizontal_mask self._pixel_edge_detector.vertical_mask = self.vertical_mask self._pixel_edge_detector.gradient_magnitude = self.gradient_magnitude # get the pixel level edges and refine them return self.refine_edges(image, self._pixel_edge_detector.identify_edges(image))