| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | from __future__ import absolute_import |
| | from __future__ import division |
| | from __future__ import print_function |
| |
|
| | import lookup_tables |
| | import numpy as np |
| | from scipy import ndimage |
| |
|
| | """ |
| | |
| | surface_distance.py |
| | |
| | all of the surface_distance functions are borrowed from DeepMind surface_distance repository |
| | |
| | """ |
| | def _assert_is_numpy_array(name, array): |
| | """Raises an exception if `array` is not a numpy array.""" |
| | if not isinstance(array, np.ndarray): |
| | raise ValueError("The argument {!r} should be a numpy array, not a " |
| | "{}".format(name, type(array))) |
| |
|
| |
|
| | def _check_nd_numpy_array(name, array, num_dims): |
| | """Raises an exception if `array` is not a `num_dims`-D numpy array.""" |
| | if len(array.shape) != num_dims: |
| | raise ValueError("The argument {!r} should be a {}D array, not of " |
| | "shape {}".format(name, num_dims, array.shape)) |
| |
|
| |
|
| | def _check_2d_numpy_array(name, array): |
| | _check_nd_numpy_array(name, array, num_dims=2) |
| |
|
| |
|
| | def _check_3d_numpy_array(name, array): |
| | _check_nd_numpy_array(name, array, num_dims=3) |
| |
|
| |
|
| | def _assert_is_bool_numpy_array(name, array): |
| | _assert_is_numpy_array(name, array) |
| | if array.dtype != np.bool: |
| | raise ValueError("The argument {!r} should be a numpy array of type bool, " |
| | "not {}".format(name, array.dtype)) |
| |
|
| |
|
| | def _compute_bounding_box(mask): |
| | """Computes the bounding box of the masks. |
| | This function generalizes to arbitrary number of dimensions great or equal |
| | to 1. |
| | Args: |
| | mask: The 2D or 3D numpy mask, where '0' means background and non-zero means |
| | foreground. |
| | Returns: |
| | A tuple: |
| | - The coordinates of the first point of the bounding box (smallest on all |
| | axes), or `None` if the mask contains only zeros. |
| | - The coordinates of the second point of the bounding box (greatest on all |
| | axes), or `None` if the mask contains only zeros. |
| | """ |
| | num_dims = len(mask.shape) |
| | bbox_min = np.zeros(num_dims, np.int64) |
| | bbox_max = np.zeros(num_dims, np.int64) |
| |
|
| | |
| | proj_0 = np.amax(mask, axis=tuple(range(num_dims))[1:]) |
| | idx_nonzero_0 = np.nonzero(proj_0)[0] |
| | if len(idx_nonzero_0) == 0: |
| | return None, None |
| |
|
| | bbox_min[0] = np.min(idx_nonzero_0) |
| | bbox_max[0] = np.max(idx_nonzero_0) |
| |
|
| | |
| | for axis in range(1, num_dims): |
| | max_over_axes = list(range(num_dims)) |
| | max_over_axes.pop(axis) |
| | max_over_axes = tuple(max_over_axes) |
| | proj = np.amax(mask, axis=max_over_axes) |
| | idx_nonzero = np.nonzero(proj)[0] |
| | bbox_min[axis] = np.min(idx_nonzero) |
| | bbox_max[axis] = np.max(idx_nonzero) |
| |
|
| | return bbox_min, bbox_max |
| |
|
| |
|
| | def _crop_to_bounding_box(mask, bbox_min, bbox_max): |
| | """Crops a 2D or 3D mask to the bounding box specified by `bbox_{min,max}`.""" |
| | |
| | |
| | |
| | |
| | |
| | cropmask = np.zeros((bbox_max - bbox_min) + 2, np.uint8) |
| |
|
| | num_dims = len(mask.shape) |
| | |
| | if num_dims == 2: |
| | cropmask[0:-1, 0:-1] = mask[bbox_min[0]:bbox_max[0] + 1, |
| | bbox_min[1]:bbox_max[1] + 1] |
| | elif num_dims == 3: |
| | cropmask[0:-1, 0:-1, 0:-1] = mask[bbox_min[0]:bbox_max[0] + 1, |
| | bbox_min[1]:bbox_max[1] + 1, |
| | bbox_min[2]:bbox_max[2] + 1] |
| | |
| | else: |
| | assert False |
| |
|
| | return cropmask |
| |
|
| |
|
| | def _sort_distances_surfels(distances, surfel_areas): |
| | """Sorts the two list with respect to the tuple of (distance, surfel_area). |
| | Args: |
| | distances: The distances from A to B (e.g. `distances_gt_to_pred`). |
| | surfel_areas: The surfel areas for A (e.g. `surfel_areas_gt`). |
| | Returns: |
| | A tuple of the sorted (distances, surfel_areas). |
| | """ |
| | sorted_surfels = np.array(sorted(zip(distances, surfel_areas))) |
| | return sorted_surfels[:, 0], sorted_surfels[:, 1] |
| |
|
| |
|
| | def compute_surface_distances(mask_gt, |
| | mask_pred, |
| | spacing_mm): |
| | """Computes closest distances from all surface points to the other surface. |
| | This function can be applied to 2D or 3D tensors. For 2D, both masks must be |
| | 2D and `spacing_mm` must be a 2-element list. For 3D, both masks must be 3D |
| | and `spacing_mm` must be a 3-element list. The description is done for the 2D |
| | case, and the formulation for the 3D case is present is parenthesis, |
| | introduced by "resp.". |
| | Finds all contour elements (resp surface elements "surfels" in 3D) in the |
| | ground truth mask `mask_gt` and the predicted mask `mask_pred`, computes their |
| | length in mm (resp. area in mm^2) and the distance to the closest point on the |
| | other contour (resp. surface). It returns two sorted lists of distances |
| | together with the corresponding contour lengths (resp. surfel areas). If one |
| | of the masks is empty, the corresponding lists are empty and all distances in |
| | the other list are `inf`. |
| | Args: |
| | mask_gt: 2-dim (resp. 3-dim) bool Numpy array. The ground truth mask. |
| | mask_pred: 2-dim (resp. 3-dim) bool Numpy array. The predicted mask. |
| | spacing_mm: 2-element (resp. 3-element) list-like structure. Voxel spacing |
| | in x0 anx x1 (resp. x0, x1 and x2) directions. |
| | Returns: |
| | A dict with: |
| | "distances_gt_to_pred": 1-dim numpy array of type float. The distances in mm |
| | from all ground truth surface elements to the predicted surface, |
| | sorted from smallest to largest. |
| | "distances_pred_to_gt": 1-dim numpy array of type float. The distances in mm |
| | from all predicted surface elements to the ground truth surface, |
| | sorted from smallest to largest. |
| | "surfel_areas_gt": 1-dim numpy array of type float. The length of the |
| | of the ground truth contours in mm (resp. the surface elements area in |
| | mm^2) in the same order as distances_gt_to_pred. |
| | "surfel_areas_pred": 1-dim numpy array of type float. The length of the |
| | of the predicted contours in mm (resp. the surface elements area in |
| | mm^2) in the same order as distances_gt_to_pred. |
| | Raises: |
| | ValueError: If the masks and the `spacing_mm` arguments are of incompatible |
| | shape or type. Or if the masks are not 2D or 3D. |
| | """ |
| | |
| | |
| | |
| |
|
| | _assert_is_bool_numpy_array("mask_gt", mask_gt) |
| | _assert_is_bool_numpy_array("mask_pred", mask_pred) |
| |
|
| | if not len(mask_gt.shape) == len(mask_pred.shape) == len(spacing_mm): |
| | raise ValueError("The arguments must be of compatible shape. Got mask_gt " |
| | "with {} dimensions ({}) and mask_pred with {} dimensions " |
| | "({}), while the spacing_mm was {} elements.".format( |
| | len(mask_gt.shape), |
| | mask_gt.shape, len( |
| | mask_pred.shape), mask_pred.shape, |
| | len(spacing_mm))) |
| |
|
| | num_dims = len(spacing_mm) |
| | if num_dims == 2: |
| | _check_2d_numpy_array("mask_gt", mask_gt) |
| | _check_2d_numpy_array("mask_pred", mask_pred) |
| |
|
| | |
| | |
| | neighbour_code_to_surface_area = ( |
| | lookup_tables.create_table_neighbour_code_to_contour_length(spacing_mm)) |
| | kernel = lookup_tables.ENCODE_NEIGHBOURHOOD_2D_KERNEL |
| | full_true_neighbours = 0b1111 |
| | elif num_dims == 3: |
| | _check_3d_numpy_array("mask_gt", mask_gt) |
| | _check_3d_numpy_array("mask_pred", mask_pred) |
| |
|
| | |
| | |
| | neighbour_code_to_surface_area = ( |
| | lookup_tables.create_table_neighbour_code_to_surface_area(spacing_mm)) |
| | kernel = lookup_tables.ENCODE_NEIGHBOURHOOD_3D_KERNEL |
| | full_true_neighbours = 0b11111111 |
| | else: |
| | raise ValueError("Only 2D and 3D masks are supported, not " |
| | "{}D.".format(num_dims)) |
| |
|
| | |
| | |
| | bbox_min, bbox_max = _compute_bounding_box(mask_gt | mask_pred) |
| | |
| | if bbox_min is None: |
| | return { |
| | "distances_gt_to_pred": np.array([]), |
| | "distances_pred_to_gt": np.array([]), |
| | "surfel_areas_gt": np.array([]), |
| | "surfel_areas_pred": np.array([]), |
| | } |
| |
|
| | |
| | cropmask_gt = _crop_to_bounding_box(mask_gt, bbox_min, bbox_max) |
| | cropmask_pred = _crop_to_bounding_box(mask_pred, bbox_min, bbox_max) |
| |
|
| | |
| | |
| | |
| | |
| | neighbour_code_map_gt = ndimage.filters.correlate( |
| | cropmask_gt.astype(np.uint8), kernel, mode="constant", cval=0) |
| | neighbour_code_map_pred = ndimage.filters.correlate( |
| | cropmask_pred.astype(np.uint8), kernel, mode="constant", cval=0) |
| |
|
| | |
| | borders_gt = ((neighbour_code_map_gt != 0) & |
| | (neighbour_code_map_gt != full_true_neighbours)) |
| | borders_pred = ((neighbour_code_map_pred != 0) & |
| | (neighbour_code_map_pred != full_true_neighbours)) |
| |
|
| | |
| | |
| | if borders_gt.any(): |
| | distmap_gt = ndimage.morphology.distance_transform_edt( |
| | ~borders_gt, sampling=spacing_mm) |
| | else: |
| | distmap_gt = np.Inf * np.ones(borders_gt.shape) |
| |
|
| | if borders_pred.any(): |
| | distmap_pred = ndimage.morphology.distance_transform_edt( |
| | ~borders_pred, sampling=spacing_mm) |
| | else: |
| | distmap_pred = np.Inf * np.ones(borders_pred.shape) |
| |
|
| | |
| | surface_area_map_gt = neighbour_code_to_surface_area[neighbour_code_map_gt] |
| | surface_area_map_pred = neighbour_code_to_surface_area[ |
| | neighbour_code_map_pred] |
| |
|
| | |
| | distances_gt_to_pred = distmap_pred[borders_gt] |
| | distances_pred_to_gt = distmap_gt[borders_pred] |
| | surfel_areas_gt = surface_area_map_gt[borders_gt] |
| | surfel_areas_pred = surface_area_map_pred[borders_pred] |
| |
|
| | |
| | if distances_gt_to_pred.shape != (0,): |
| | distances_gt_to_pred, surfel_areas_gt = _sort_distances_surfels( |
| | distances_gt_to_pred, surfel_areas_gt) |
| |
|
| | if distances_pred_to_gt.shape != (0,): |
| | distances_pred_to_gt, surfel_areas_pred = _sort_distances_surfels( |
| | distances_pred_to_gt, surfel_areas_pred) |
| |
|
| | return { |
| | "distances_gt_to_pred": distances_gt_to_pred, |
| | "distances_pred_to_gt": distances_pred_to_gt, |
| | "surfel_areas_gt": surfel_areas_gt, |
| | "surfel_areas_pred": surfel_areas_pred, |
| | } |
| |
|
| |
|
| | def compute_average_surface_distance(surface_distances): |
| | """Returns the average surface distance. |
| | Computes the average surface distances by correctly taking the area of each |
| | surface element into account. Call compute_surface_distances(...) before, to |
| | obtain the `surface_distances` dict. |
| | Args: |
| | surface_distances: dict with "distances_gt_to_pred", "distances_pred_to_gt" |
| | "surfel_areas_gt", "surfel_areas_pred" created by |
| | compute_surface_distances() |
| | Returns: |
| | A tuple with two float values: |
| | - the average distance (in mm) from the ground truth surface to the |
| | predicted surface |
| | - the average distance from the predicted surface to the ground truth |
| | surface. |
| | """ |
| | distances_gt_to_pred = surface_distances["distances_gt_to_pred"] |
| | distances_pred_to_gt = surface_distances["distances_pred_to_gt"] |
| | surfel_areas_gt = surface_distances["surfel_areas_gt"] |
| | surfel_areas_pred = surface_distances["surfel_areas_pred"] |
| | average_distance_gt_to_pred = ( |
| | np.sum(distances_gt_to_pred * surfel_areas_gt) / np.sum(surfel_areas_gt)) |
| | average_distance_pred_to_gt = ( |
| | np.sum(distances_pred_to_gt * surfel_areas_pred) / |
| | np.sum(surfel_areas_pred)) |
| | return (average_distance_gt_to_pred, average_distance_pred_to_gt) |
| |
|
| |
|
| | def compute_robust_hausdorff(surface_distances, percent): |
| | """Computes the robust Hausdorff distance. |
| | Computes the robust Hausdorff distance. "Robust", because it uses the |
| | `percent` percentile of the distances instead of the maximum distance. The |
| | percentage is computed by correctly taking the area of each surface element |
| | into account. |
| | Args: |
| | surface_distances: dict with "distances_gt_to_pred", "distances_pred_to_gt" |
| | "surfel_areas_gt", "surfel_areas_pred" created by |
| | compute_surface_distances() |
| | percent: a float value between 0 and 100. |
| | Returns: |
| | a float value. The robust Hausdorff distance in mm. |
| | """ |
| | distances_gt_to_pred = surface_distances["distances_gt_to_pred"] |
| | distances_pred_to_gt = surface_distances["distances_pred_to_gt"] |
| | surfel_areas_gt = surface_distances["surfel_areas_gt"] |
| | surfel_areas_pred = surface_distances["surfel_areas_pred"] |
| | if len(distances_gt_to_pred) > 0: |
| | surfel_areas_cum_gt = np.cumsum( |
| | surfel_areas_gt) / np.sum(surfel_areas_gt) |
| | idx = np.searchsorted(surfel_areas_cum_gt, percent/100.0) |
| | perc_distance_gt_to_pred = distances_gt_to_pred[ |
| | min(idx, len(distances_gt_to_pred)-1)] |
| | else: |
| | perc_distance_gt_to_pred = np.Inf |
| |
|
| | if len(distances_pred_to_gt) > 0: |
| | surfel_areas_cum_pred = (np.cumsum(surfel_areas_pred) / |
| | np.sum(surfel_areas_pred)) |
| | idx = np.searchsorted(surfel_areas_cum_pred, percent/100.0) |
| | perc_distance_pred_to_gt = distances_pred_to_gt[ |
| | min(idx, len(distances_pred_to_gt)-1)] |
| | else: |
| | perc_distance_pred_to_gt = np.Inf |
| |
|
| | return max(perc_distance_gt_to_pred, perc_distance_pred_to_gt) |
| |
|
| |
|
| | def compute_surface_overlap_at_tolerance(surface_distances, tolerance_mm): |
| | """Computes the overlap of the surfaces at a specified tolerance. |
| | Computes the overlap of the ground truth surface with the predicted surface |
| | and vice versa allowing a specified tolerance (maximum surface-to-surface |
| | distance that is regarded as overlapping). The overlapping fraction is |
| | computed by correctly taking the area of each surface element into account. |
| | Args: |
| | surface_distances: dict with "distances_gt_to_pred", "distances_pred_to_gt" |
| | "surfel_areas_gt", "surfel_areas_pred" created by |
| | compute_surface_distances() |
| | tolerance_mm: a float value. The tolerance in mm |
| | Returns: |
| | A tuple of two float values. The overlap fraction in [0.0, 1.0] of the |
| | ground truth surface with the predicted surface and vice versa. |
| | """ |
| | distances_gt_to_pred = surface_distances["distances_gt_to_pred"] |
| | distances_pred_to_gt = surface_distances["distances_pred_to_gt"] |
| | surfel_areas_gt = surface_distances["surfel_areas_gt"] |
| | surfel_areas_pred = surface_distances["surfel_areas_pred"] |
| | rel_overlap_gt = ( |
| | np.sum(surfel_areas_gt[distances_gt_to_pred <= tolerance_mm]) / |
| | np.sum(surfel_areas_gt)) |
| | rel_overlap_pred = ( |
| | np.sum(surfel_areas_pred[distances_pred_to_gt <= tolerance_mm]) / |
| | np.sum(surfel_areas_pred)) |
| | return (rel_overlap_gt, rel_overlap_pred) |
| |
|
| |
|
| | def compute_surface_dice_at_tolerance(surface_distances, tolerance_mm): |
| | """Computes the _surface_ DICE coefficient at a specified tolerance. |
| | Computes the _surface_ DICE coefficient at a specified tolerance. Not to be |
| | confused with the standard _volumetric_ DICE coefficient. The surface DICE |
| | measures the overlap of two surfaces instead of two volumes. A surface |
| | element is counted as overlapping (or touching), when the closest distance to |
| | the other surface is less or equal to the specified tolerance. The DICE |
| | coefficient is in the range between 0.0 (no overlap) to 1.0 (perfect overlap). |
| | Args: |
| | surface_distances: dict with "distances_gt_to_pred", "distances_pred_to_gt" |
| | "surfel_areas_gt", "surfel_areas_pred" created by |
| | compute_surface_distances() |
| | tolerance_mm: a float value. The tolerance in mm |
| | Returns: |
| | A float value. The surface DICE coefficient in [0.0, 1.0]. |
| | """ |
| | distances_gt_to_pred = surface_distances["distances_gt_to_pred"] |
| | distances_pred_to_gt = surface_distances["distances_pred_to_gt"] |
| | surfel_areas_gt = surface_distances["surfel_areas_gt"] |
| | surfel_areas_pred = surface_distances["surfel_areas_pred"] |
| | overlap_gt = np.sum(surfel_areas_gt[distances_gt_to_pred <= tolerance_mm]) |
| | overlap_pred = np.sum( |
| | surfel_areas_pred[distances_pred_to_gt <= tolerance_mm]) |
| | surface_dice = (overlap_gt + overlap_pred) / ( |
| | np.sum(surfel_areas_gt) + np.sum(surfel_areas_pred)) |
| | return surface_dice |
| |
|
| |
|
| | def compute_dice_coefficient(mask_gt, mask_pred): |
| | """Computes soerensen-dice coefficient. |
| | compute the soerensen-dice coefficient between the ground truth mask `mask_gt` |
| | and the predicted mask `mask_pred`. |
| | Args: |
| | mask_gt: 3-dim Numpy array of type bool. The ground truth mask. |
| | mask_pred: 3-dim Numpy array of type bool. The predicted mask. |
| | Returns: |
| | the dice coeffcient as float. If both masks are empty, the result is NaN. |
| | """ |
| | volume_sum = mask_gt.sum() + mask_pred.sum() |
| | if volume_sum == 0: |
| | return np.NaN |
| | volume_intersect = (mask_gt & mask_pred).sum() |
| | return 2*volume_intersect / volume_sum |