def segment_from_labels(edges, markers, labels_img, atlas_img=None, exclude_labels=None, mask_filt=config.SmoothingModes.opening, mask_filt_size=2): """Segment an image using markers from a labels image. Labels images may have been generally manually and thus may not perfectly match the underlying image. As a way to check or augment the label image, segment the underlying image using the labels as the seeds to prescribe the number and initial location of each label. Args: edges (:obj:`np.ndarray`): Image as a Numpy array to segment, typically an edge-detected image of the main atlas. markers (:obj:`np.ndarray`): Image as an integer Numpy array of same shape as ``img`` to use as seeds for the watershed segmentation. This array is generally constructed from an array similar to ``labels_img``. labels_img (:obj:`np.ndarray`): Labels image as Numpy array of same shape as ``img``, used to generate a mask for the watershed. If None, a mask will be generated from a thresholded version of ``atlas_img``, so should only be None if ``atlas_img`` is not None. atlas_img (:obj:`np.ndarray`): Atlas image as a Numpy array to use for finding foreground; defaults to None. If both ``labels_img`` and ``atlas_img`` are not None, their combined volume will be used as a mask. exclude_labels (List[int]): Sequence of labels to exclude from the segmentation; defaults to None. mask_filt (:obj:`config.SmoothingModes`): Enumeration for a filter mode to use for the watershed mask; defaults to :obj:`config.SmoothingModes.opening`. Ignored if ``atlas_img`` or both ``atlas_img`` and ``labels_img`` are given to generate the mask. mask_filt_size (int): Size of structuring element for the filter specified by ``mask_filt``; defaults to 2. Returns: :obj:`np.ndarray`: Segmented image of the same shape as ``img`` with the same number of labels as in ``markers``. """ # generate mask for watershed if atlas_img is not None and labels_img is not None: # broad mask from both atlas and labels mask = mask_atlas(atlas_img, labels_img) elif atlas_img is not None: # otsu seems to give more inclusive threshold for these atlases _, mask = cv_nd.carve(atlas_img, thresh=filters.threshold_otsu(atlas_img), holes_area=5000) else: # default to using label foreground mask = labels_img != 0 fn_mask = None if mask_filt is config.SmoothingModes.opening: # default filter opens the mask to prevent spillover across # artifacts that may bridge otherwise separate structures fn_mask = morphology.binary_opening elif mask_filt is config.SmoothingModes.closing: fn_mask = morphology.binary_closing if fn_mask and mask_filt_size: print("Filtering watershed mask with {}, size {}".format( fn_mask, mask_filt_size)) mask = fn_mask(mask, cv_nd.get_selem(labels_img.ndim)(mask_filt_size)) exclude = None if exclude_labels is not None: # remove excluded labels from mask exclude = np.isin(labels_img, exclude_labels) mask[exclude] = False # WORKAROUND: remove excluded markers from marker image itself for # apparent Scikit-image bug (see PR 3809, fixed in 0.15) markers[np.isin(markers, exclude_labels)] = 0 watershed = watershed_distance(edges == 0, markers, compactness=0.005, mask=mask) if exclude is not None: # add excluded labels directly to watershed image watershed[exclude] = labels_img[exclude] return watershed
def erode_label( cls, label_id: int, filter_size: int, target_frac: float = None, min_filter_size: int = 1, use_min_filter: bool = False, skel_eros_filt_size: Union[int, bool] = False, wt: float = None ) -> Tuple[Tuple[int, np.ndarray, np.ndarray, Any], Union[ Optional[List[slice]], Any], Any]: """Convert a label to a marker as an eroded version of the label. By default, labels will be eroded with the given ``filter_size`` as long as their final size is > 20% of the original volume. If the eroded volume is below threshold, ``filter_size`` will be progressively decreased until the filter cannot be reduced further. Skeletonization of the labels recovers some details by partially preserving the original labels' extent, including thin regions that would be eroded away, thus serving a similar function as that of adaptive morphological filtering. ``skel_eros_filt_size`` allows titrating the amount of the labels` extent to be preserved. If :attr:`wt_dists` is present, the label's distance will be used to weight the starting filter size. Args: label_id: ID of label to erode. filter_size: Size of structing element to start erosion. target_frac: Target fraction of original label to erode. Erosion will start with ``filter_size`` and use progressively smaller filters until remaining above this target. Defaults to None to use a fraction of 0.2. Titrates the relative amount of erosion allowed. min_filter_size: Minimum filter size, below which the original, uneroded label will be used instead. Defaults to 1. Use 0 to erode at size 1 even if below ``target_frac``. Titrates the absolute amount of erosion allowed. use_min_filter: True to erode at ``min_filter_size`` if a smaller filter size would otherwise be required; defaults to False to revert to original, uneroded size if a filter smaller than ``min_filter_size`` would be needed. skel_eros_filt_size: Erosion filter size before skeletonization to balance how much of the labels' extent will be preserved during skeletonization. Increase to reduce the skeletonization. Defaults to False, which will cause skeletonization to be skipped. wt: Multiplier weight for ``filter_size``. Defaults to None, in which case the weighte will be calculated from :attr:``wt_dists`` if available, or ignored if not. Returns: Tuple of stats,including ``label_id`` for reference and sizes of labels; list of slices denoting where to insert the eroded label; and the eroded label itself. Raises: ValueError: if ``region`` is None and :attr:`labels_img` is not available. """ if cls.labels_img is None: cls.labels_img = cls.convert_shared_arr(config.RegNames.IMG_LABELS) if (wt is None and cls.wt_dists is not None and cls.labels_img is not None): # weight the filter size by the fractional distance from median # of label distance and max dist wt = cls.meas_wt(cls.labels_img, label_id, cls.wt_dists) if wt is not None: filter_size = int(filter_size * wt) print(f"Label {label_id}: distance weight {wt}, adjusted filter " f"size to {filter_size}") if use_min_filter and filter_size < min_filter_size: filter_size = min_filter_size # get region as mask; assume that label exists and will yield a # bounding box since labels here are generally derived from the # labels image itself region, slices = cv_nd.extract_region(cls.labels_img, label_id) label_mask_region = region == label_id region_size = np.sum(label_mask_region) filtered, chosen_selem_size = cv_nd.filter_adaptive_size( label_mask_region, morphology.binary_erosion, filter_size, min_filter_size, use_min_filter, target_frac, f"Label ID: {label_id}") region_size_filtered = np.sum(filtered) if skel_eros_filt_size is not False and np.sum(filtered) > 0: # skeletonize the labels to recover details from erosion; # need another labels erosion before skeletonization to avoid # preserving too much of the original labels' extent label_mask_region = morphology.binary_erosion( label_mask_region, cv_nd.get_selem(label_mask_region.ndim)(skel_eros_filt_size)) filtered = np.logical_or( filtered, morphology.skeletonize_3d(label_mask_region).astype(bool)) stats_eros = (label_id, region_size, region_size_filtered, chosen_selem_size) return stats_eros, slices, filtered
def erode_label(cls, label_id, filter_size, target_frac=None, min_filter_size=1, use_min_filter=False, skel_eros_filt_size=0): """Convert a label to a marker as an eroded version of the label. By default, labels will be eroded with the given ``filter_size`` as long as their final size is > 20% of the original volume. If the eroded volume is below threshold, ``filter_size`` will be progressively decreased until the filter cannot be reduced further. Skeletonization of the labels recovers some details by partially preserving the original labels' extent, including thin regions that would be eroded away, thus serving a similar function as that of adaptive morphological filtering. ``skel_eros_filt_size`` allows titrating the amount of the labels` extent to be preserved. If :attr:`wt_dists` is present, the label's distance will be used to weight the starting filter size. Args: label_id (int): ID of label to erode. filter_size (int): Size of structing element to start erosion. target_frac (float): Target fraction of original label to erode. Erosion will start with ``filter_size`` and use progressively smaller filters until remaining above this target. Defaults to None to use a fraction of 0.2. Titrates the relative amount of erosion allowed. min_filter_size (int): Minimum filter size, below which the original, uneroded label will be used instead. Defaults to 1. Use 0 to erode at size 1 even if below ``target_frac``. Titrates the absolute amount of erosion allowed. use_min_filter (bool): True to erode at ``min_filter_size`` if a smaller filter size would otherwise be required; defaults to False to revert to original, uneroded size if a filter smaller than ``min_filter_size`` would be needed. skel_eros_filt_size (int): Erosion filter size before skeletonization to balance how much of the labels' extent will be preserved during skeletonization. Increase to reduce the skeletonization. Defaults to 0, which will cause skeletonization to be skipped. Returns: :obj:`pd.DataFrame`, List[slice], :obj:`np.ndarray`: stats, including ``label_id`` for reference and sizes of labels; list of slices denoting where to insert the eroded label; and the eroded label itself. """ if cls.wt_dists is not None: # weight the filter size by the fractional distance from median # of label distance and max dist wt = (np.median(cls.wt_dists[cls.labels_img == label_id]) / np.amax(cls.wt_dists)) filter_size = int(filter_size * wt) print("label {}: distance weight {}, adjusted filter size to {}". format(label_id, wt, filter_size)) if use_min_filter and filter_size < min_filter_size: filter_size = min_filter_size # get region as mask; assume that label exists and will yield a # bounding box since labels here are generally derived from the # labels image itself bbox = cv_nd.get_label_bbox(cls.labels_img, label_id) _, slices = cv_nd.get_bbox_region(bbox) region = cls.labels_img[tuple(slices)] label_mask_region = region == label_id region_size = np.sum(label_mask_region) region_size_filtered = region_size fn_selem = cv_nd.get_selem(cls.labels_img.ndim) # erode the labels, starting with the given filter size and decreasing # if the resulting label size falls below a given size ratio chosen_selem_size = np.nan filtered = label_mask_region size_ratio = 1 for selem_size in range(filter_size, -1, -1): if selem_size < min_filter_size: if not use_min_filter: print("label {}: could not erode without dropping below " "minimum filter size of {}, reverting to original " "region size of {}".format(label_id, min_filter_size, region_size)) filtered = label_mask_region region_size_filtered = region_size chosen_selem_size = np.nan break # erode check size ratio filtered = morphology.binary_erosion(label_mask_region, fn_selem(selem_size)) region_size_filtered = np.sum(filtered) size_ratio = region_size_filtered / region_size thresh = 0.2 if target_frac is None else target_frac chosen_selem_size = selem_size if region_size_filtered < region_size and size_ratio > thresh: # stop eroding if underwent some erosion but stayed above # threshold size; skimage erosion treats border outside image # as True, so images may not undergo erosion and should # continue until lowest filter size is taken (eg NaN) break if not np.isnan(chosen_selem_size): print("label {}: changed num of pixels from {} to {} " "(size ratio {}), initial filter size {}, chosen {}".format( label_id, region_size, region_size_filtered, size_ratio, filter_size, chosen_selem_size)) if skel_eros_filt_size and np.sum(filtered) > 0: # skeletonize the labels to recover details from erosion; # need another labels erosion before skeletonization to avoid # preserving too much of the original labels' extent label_mask_region = morphology.binary_erosion( label_mask_region, fn_selem(skel_eros_filt_size)) filtered = np.logical_or( filtered, morphology.skeletonize_3d(label_mask_region).astype(bool)) stats_eros = (label_id, region_size, region_size_filtered, chosen_selem_size) return stats_eros, slices, filtered
def segment_from_labels(edges, markers, labels_img, atlas_img=None, exclude_labels=None): """Segment an image using markers from a labels image. Labels images may have been generally manually and thus may not perfectly match the underlying image. As a way to check or augment the label image, segment the underlying image using the labels as the seeds to prescribe the number and initial location of each label. Args: edges (:obj:`np.ndarray`): Image as a Numpy array to segment, typically an edge-detected image of the main atlas. markers (:obj:`np.ndarray`): Image as an integer Numpy array of same shape as ``img`` to use as seeds for the watershed segmentation. This array is generally constructed from an array similar to ``labels_img``. labels_img (:obj:`np.ndarray`): Labels image as Numpy array of same shape as ``img``, used to generate a mask for the watershed. If None, a mask will be generated from a thresholded version of ``atlas_img``, so should only be None if ``atlas_img`` is not None. atlas_img (:obj:`np.ndarray`): Atlas image as a Numpy array to use for finding foreground; defaults to None. If both ``labels_img`` and ``atlas_img`` are not None, their combined volume will be used as a mask. exclude_labels (List[int]): Sequence of labels to exclude from the segmentation; defaults to None. Returns: :obj:`np.ndarray`: Segmented image of the same shape as ``img`` with the same number of labels as in ``markers``. """ if atlas_img is not None and labels_img is not None: # broad mask from both atlas and labels mask = mask_atlas(atlas_img, labels_img) elif atlas_img is not None: # otsu seems to give more inclusive threshold for these atlases _, mask = cv_nd.carve( atlas_img, thresh=filters.threshold_otsu(atlas_img), holes_area=5000) else: # default to using labels, opening them up small holes to prevent # spillover across artifacts that may bridge them fn_selem = cv_nd.get_selem(labels_img.ndim) mask = morphology.binary_opening(labels_img != 0, fn_selem(2)) exclude = None if exclude_labels is not None: # remove excluded labels from mask exclude = np.isin(labels_img, exclude_labels) mask[exclude] = False # WORKAROUND: remove excluded markers from marker image itself for # apparent Scikit-image bug (see PR 3809, fixed in 0.15) markers[np.isin(markers, exclude_labels)] = 0 watershed = watershed_distance( edges == 0, markers, compactness=0.005, mask=mask) if exclude is not None: # add excluded labels directly to watershed image watershed[exclude] = labels_img[exclude] return watershed