Source code for pylinac.core.roi

import enum
import warnings
from typing import Union, Tuple, Optional

import numpy as np
import matplotlib.pyplot as plt
from cached_property import cached_property
from matplotlib.patches import Circle as mpl_Circle

from skimage.measure._regionprops import _RegionProperties

from .decorators import lru_cache
from .geometry import Circle, Point, Rectangle
from .image import ArrayImage


[docs]def bbox_center(region: _RegionProperties) -> Point: """Return the center of the bounding box of an scikit-image region. Parameters ---------- region A scikit-image region as calculated by skimage.measure.regionprops(). Returns ------- point : :class:`~pylinac.core.geometry.Point` """ bbox = region.bbox y = abs(bbox[0] - bbox[2]) / 2 + min(bbox[0], bbox[2]) x = abs(bbox[1] - bbox[3]) / 2 + min(bbox[1], bbox[3]) return Point(x, y)
[docs]class Contrast(enum.Enum): """Contrast calculation technique. See :ref:`visibility`""" MICHELSON = "Michelson" #: WEBER = 'Weber' #: RATIO = 'Ratio' #:
[docs]class DiskROI(Circle): """An class representing a disk-shaped Region of Interest.""" def __init__(self, array: np.ndarray, angle: Union[float, int], roi_radius: Union[float, int], dist_from_center: Union[float, int], phantom_center: Union[Tuple, Point]): """ Parameters ---------- array : ndarray The 2D array representing the image the disk is on. angle : int, float The angle of the ROI in degrees from the phantom center. roi_radius : int, float The radius of the ROI from the center of the phantom. dist_from_center : int, float The distance of the ROI from the phantom center. phantom_center : tuple The location of the phantom center. """ center = self._get_shifted_center(angle, dist_from_center, phantom_center) super().__init__(center_point=center, radius=roi_radius) self._array = array @staticmethod def _get_shifted_center(angle: Union[float, int], dist_from_center: Union[float, int], phantom_center: Point) -> Point: """The center of the ROI; corrects for phantom dislocation and roll.""" y_shift = np.sin(np.deg2rad(angle)) * dist_from_center x_shift = np.cos(np.deg2rad(angle)) * dist_from_center return Point(phantom_center.x + x_shift, phantom_center.y + y_shift) @cached_property def pixel_value(self) -> float: """The median pixel value of the ROI.""" masked_img = self.circle_mask() return float(np.nanmedian(masked_img)) @cached_property def std(self) -> float: """The standard deviation of the pixel values.""" masked_img = self.circle_mask() return float(np.nanstd(masked_img))
[docs] @lru_cache() def circle_mask(self) -> np.ndarray: """Return a mask of the image, only showing the circular ROI.""" # http://scikit-image.org/docs/dev/auto_examples/plot_camera_numpy.html masked_array = np.copy(self._array).astype(float) l_x, l_y = self._array.shape[0], self._array.shape[1] X, Y = np.ogrid[:l_x, :l_y] outer_disk_mask = (X - self.center.y) ** 2 + (Y - self.center.x) ** 2 > self.radius ** 2 masked_array[outer_disk_mask] = np.NaN return masked_array
[docs] def plot2axes(self, axes=None, edgecolor: str='black', fill: bool=False) -> None: """Plot the Circle on the axes. Parameters ---------- axes : matplotlib.axes.Axes An MPL axes to plot to. edgecolor : str The color of the circle. fill : bool Whether to fill the circle with color or leave hollow. """ if axes is None: fig, axes = plt.subplots() axes.imshow(self._array) axes.add_patch(mpl_Circle((self.center.x, self.center.y), edgecolor=edgecolor, radius=self.radius, fill=fill))
[docs]class LowContrastDiskROI(DiskROI): """A class for analyzing the low-contrast disks.""" contrast_threshold: Optional[float] cnr_threshold: Optional[float] contrast_reference: Optional[float] def __init__(self, array: Union[np.ndarray, ArrayImage], angle: float, roi_radius: float, dist_from_center: float, phantom_center: Union[tuple, Point], contrast_threshold: Optional[float] = None, contrast_reference: Optional[float] = None, cnr_threshold: Optional[float] = None, contrast_method: Contrast = Contrast.MICHELSON, visibility_threshold: Optional[float] = 0.1): """ Parameters ---------- contrast_threshold : float, int The threshold for considering a bubble to be "seen". """ super().__init__(array, angle, roi_radius, dist_from_center, phantom_center) self.contrast_threshold = contrast_threshold self.cnr_threshold = cnr_threshold self.contrast_reference = contrast_reference self.contrast_method = contrast_method self.visibility_threshold = visibility_threshold @property def signal_to_noise(self) -> float: """The signal to noise ratio.""" return self.pixel_value / self.std @property def contrast_to_noise(self) -> float: """The contrast to noise ratio of the ROI""" return self.contrast / self.std @property def contrast(self) -> float: """The contrast of the bubble. Uses the contrast method passed in the constructor. See https://en.wikipedia.org/wiki/Contrast_(vision).""" if self.contrast_method == Contrast.MICHELSON: return abs((self.pixel_value - self.contrast_reference) / (self.pixel_value + self.contrast_reference)) elif self.contrast_method == Contrast.WEBER: return abs(self.pixel_value - self.contrast_reference) / self.contrast_reference elif self.contrast_method == Contrast.RATIO: return self.pixel_value/self.contrast_reference @property def cnr_constant(self) -> float: """The contrast-to-noise value times the bubble diameter.""" DeprecationWarning("The 'cnr_constant' property will be deprecated in a future release. Use .visibility instead.") return self.contrast_to_noise * self.diameter @property def visibility(self): """The visual perception of CNR. Uses the model from A Rose: https://www.osapublishing.org/josa/abstract.cfm?uri=josa-38-2-196. See also here: https://howradiologyworks.com/x-ray-cnr/. Finally, a review paper here: http://xrm.phys.northwestern.edu/research/pdf_papers/1999/burgess_josaa_1999.pdf Importantly, the Rose model is not applicable for high-contrast use cases.""" return self.contrast * np.sqrt(self.radius**2 * np.pi) / self.std @property def contrast_constant(self) -> float: """The contrast value times the bubble diameter.""" DeprecationWarning("The 'contrast_constant' property will be deprecated in a future release. Use .visibility instead.") return self.contrast * self.diameter @property def passed(self) -> bool: """Whether the disk ROI contrast passed.""" return self.contrast > self.contrast_threshold @property def passed_visibility(self) -> bool: """Whether the disk ROI's visibility passed.""" return self.visibility > self.visibility_threshold @property def passed_contrast_constant(self) -> bool: """Boolean specifying if ROI pixel value was within tolerance of the nominal value.""" return self.contrast_constant > self.contrast_threshold @property def passed_cnr_constant(self) -> bool: """Boolean specifying if ROI pixel value was within tolerance of the nominal value.""" return self.cnr_constant > self.cnr_threshold @property def plot_color(self) -> str: """Return one of two colors depending on if ROI passed.""" return 'green' if self.passed_visibility else 'red' @property def plot_color_constant(self) -> str: """Return one of two colors depending on if ROI passed.""" return 'green' if self.passed_contrast_constant else 'red' @property def plot_color_cnr(self) -> str: """Return one of two colors depending on if ROI passed.""" return 'green' if self.passed_cnr_constant else 'red'
[docs]class HighContrastDiskROI(DiskROI): """A class for analyzing the high-contrast disks.""" contrast_threshold: Optional[float] def __init__(self, array: np.ndarray, angle: float, roi_radius: float, dist_from_center: float, phantom_center: Union[tuple, Point], contrast_threshold: float): """ Parameters ---------- contrast_threshold : float, int The threshold for considering a bubble to be "seen". """ super().__init__(array, angle, roi_radius, dist_from_center, phantom_center) self.contrast_threshold = contrast_threshold @cached_property def max(self) -> np.ndarray: """The max pixel value of the ROI.""" masked_img = self.circle_mask() return np.nanmax(masked_img) @cached_property def min(self) -> np.ndarray: """The min pixel value of the ROI.""" masked_img = self.circle_mask() return np.nanmin(masked_img)
[docs]class RectangleROI(Rectangle): """Class that represents a rectangular ROI.""" def __init__(self, array, width, height, angle, dist_from_center, phantom_center): y_shift = np.sin(np.deg2rad(angle)) * dist_from_center x_shift = np.cos(np.deg2rad(angle)) * dist_from_center center = Point(phantom_center.x + x_shift, phantom_center.y + y_shift) super().__init__(width, height, center, as_int=True) self._array = array @cached_property def pixel_array(self) -> np.ndarray: """The pixel array within the ROI.""" return self._array[self.bl_corner.y:self.tr_corner.y, self.bl_corner.x:self.tr_corner.x]