# Copyright 2021 Huawei Technologies Co., Ltd
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ============================================================================
"""HausdorffDistance."""
from collections import abc
from abc import ABCMeta
from scipy.ndimage import morphology
import numpy as np
from mindspore.common.tensor import Tensor
from mindspore._checkparam import Validator as validator
from .metric import Metric, rearrange_inputs
class _ROISpatialData(metaclass=ABCMeta):
"""
Produce Region Of Interest (ROI). Support to crop ND spatial data. The center and size of the space should be
provided, if not, the start and end coordinates of the ROI must be provided.
Args:
roi_center (int): The central coordinates of the crop ROI.
roi_size (int): The size of the crop ROI.
roi_start (int): The start coordinates of the crop ROI.
roi_end (int): The end coordinates of the crop ROI.
"""
def __init__(self, roi_center=None, roi_size=None, roi_start=None, roi_end=None):
if roi_center is not None and roi_size is not None:
roi_center = np.asarray(roi_center, dtype=np.int16)
roi_size = np.asarray(roi_size, dtype=np.int16)
self.roi_start = np.maximum(roi_center - np.floor_divide(roi_size, 2), 0)
self.roi_end = np.maximum(self.roi_start + roi_size, self.roi_start)
else:
if roi_start is None or roi_end is None:
raise ValueError("Please provide the center coordinates, size or start coordinates and end coordinates"
" of ROI.")
self.roi_start = np.maximum(np.asarray(roi_start, dtype=np.int16), 0)
self.roi_end = np.maximum(np.asarray(roi_end, dtype=np.int16), self.roi_start)
def __call__(self, data):
"""
Transform the data, if the data is channel first, slicing is not applicable to channel dim.
Args:
data (np.ndarray): Data to be converted.
Returns:
np.ndarray, transform result.
"""
sd = min(len(self.roi_start), len(self.roi_end), len(data.shape[1:]))
slices = [slice(None)] + [slice(s, e) for s, e in zip(self.roi_start[:sd], self.roi_end[:sd])]
return data[tuple(slices)]
[docs]class HausdorffDistance(Metric):
r"""
Calculates the Hausdorff distance. Hausdorff distance is the maximum and minimum distance between two point sets.
Given two feature sets A and B, the Hausdorff distance between two point sets A and B is defined as follows:
.. math::
H(A, B) = \text{max}[h(A, B), h(B, A)]
h(A, B) = \underset{a \in A}{\text{max}}\{\underset{b \in B}{\text{min}} \rVert a - b \rVert \}
h(A, B) = \underset{b \in B}{\text{max}}\{\underset{a \in A}{\text{min}} \rVert b - a \rVert \}
Args:
distance_metric (string): The parameter of calculating Hausdorff distance supports three measurement methods,
"euclidean", "chessboard" or "taxicab". Default: "euclidean".
percentile (float): Floating point numbers between 0 and 100. Specify the percentile parameter to get the
percentile of the Hausdorff distance. Default: None.
directed (bool): It can be divided into directional and non directional Hausdorff distance,
and the default is non directional Hausdorff distance, specify the percentile parameter to get
the percentile of the Hausdorff distance. Default: False.
crop (bool): Crop input images and only keep the foregrounds. In order to maintain two inputs' shapes,
here the bounding box is achieved by (y_pred | y) which represents the union set of two images.
Default: True.
Supported Platforms:
``Ascend`` ``GPU`` ``CPU``
Examples:
>>> import numpy as np
>>> from mindspore import nn, Tensor
>>>
>>> x = Tensor(np.array([[3, 0, 1], [1, 3, 0], [1, 0, 2]]))
>>> y = Tensor(np.array([[0, 2, 1], [1, 2, 1], [0, 0, 1]]))
>>> metric = nn.HausdorffDistance()
>>> metric.clear()
>>> metric.update(x, y, 0)
>>> mean_average_distance = metric.eval()
>>> print(mean_average_distance)
1.4142135623730951
"""
def __init__(self, distance_metric="euclidean", percentile=None, directed=False, crop=True):
super(HausdorffDistance, self).__init__()
string_list = ["euclidean", "chessboard", "taxicab"]
distance_metric = validator.check_value_type("distance_metric", distance_metric, [str])
self.distance_metric = validator.check_string(distance_metric, string_list, "distance_metric")
self.percentile = percentile if percentile is None else validator.check_value_type("percentile",
percentile, [float])
self.directed = directed if directed is None else validator.check_value_type("directed", directed, [bool])
self.crop = crop if crop is None else validator.check_value_type("crop", crop, [bool])
self.clear()
def _is_tuple_rep(self, tup, dim):
"""
Returns the tup containing the dim value by shortening or repeating the input.
Raises:
ValueError: When tup is a sequence and tup length is not dim.
"""
result = None
if not self._is_iterable_sequence(tup):
result = (tup,) * dim
elif len(tup) == dim:
result = tuple(tup)
if result is None:
raise ValueError(f"The sequence length should be {dim}, but got {len(tup)}.")
return result
def _is_tuple(self, inputs):
"""
Returns a tuple of inputs.
"""
if not self._is_iterable_sequence(inputs):
inputs = (inputs,)
return tuple(inputs)
def _is_iterable_sequence(self, inputs):
"""
Determine if the input is an iterable sequence and it is not a string.
"""
if isinstance(inputs, Tensor):
return int(inputs.dim()) > 0
return isinstance(inputs, abc.Iterable) and not isinstance(inputs, str)
def _create_space_bounding_box(self, image, func=lambda x: x > 0, channel_indices=None, margin=0):
"""
The position of the space bounding box that generates the foreground in an image with start end.
The user can define any function to select the desired foreground from the whole image or the specified channel.
It can also add margins to each size of the bounding box.
Args:
image: source image to generate bounding box from.
func: function to select expected foreground, default is to select values > 0.
channel_indices: if defined, select foreground only on the specified channels
of image. if None, select foreground on the whole image.
margin: add margin value to spatial dims of the bounding box, if only a single value is provided,
use it for all dims.
"""
data = image[[*(self._is_tuple(channel_indices))]] if channel_indices is not None else image
data = np.any(func(data), axis=0)
nonzero_idx = np.nonzero(data)
margin = self._is_tuple_rep(margin, data.ndim)
box_start = list()
box_end = list()
for i in range(data.ndim):
if nonzero_idx[i].size <= 0:
raise ValueError("Did not find nonzero index at the spatial dim {}".format(i))
box_start.append(max(0, np.min(nonzero_idx[i]) - margin[i]))
box_end.append(min(data.shape[i], np.max(nonzero_idx[i]) + margin[i] + 1))
return box_start, box_end
def _calculate_percent_hausdorff_distance(self, y_pred_edges, y_edges):
"""
Calculate the directed Hausdorff distance.
Args:
y_pred_edges (np.ndarray): the edge of the predictions.
y_edges (np.ndarray): the edge of the ground truth.
"""
surface_distance = self._get_surface_distance(y_pred_edges, y_edges)
if surface_distance.shape == (0,):
return np.inf
if not self.percentile:
return surface_distance.max()
if 0 <= self.percentile <= 100:
return np.percentile(surface_distance, self.percentile)
raise ValueError(f"The percentile value should be between 0 and 100, but got {self.percentile}.")
def _get_surface_distance(self, y_pred_edges, y_edges):
"""
Calculate the surface distances from `y_pred_edges` to `y_edges`.
Args:
y_pred_edges (np.ndarray): the edge of the predictions.
y_edges (np.ndarray): the edge of the ground truth.
"""
if not np.any(y_pred_edges):
return np.array([])
if not np.any(y_edges):
dis = np.inf * np.ones_like(y_edges)
else:
if self.distance_metric == "euclidean":
dis = morphology.distance_transform_edt(~y_edges)
elif self.distance_metric == "chessboard" or self.distance_metric == "taxicab":
dis = morphology.distance_transform_cdt(~y_edges, metric=self.distance_metric)
surface_distance = dis[y_pred_edges]
return surface_distance
def _get_mask_edges_distance(self, y_pred, y):
"""
Do binary erosion and use XOR for input to get the edges. This function is helpful to further
calculate metrics such as Average Surface Distance and Hausdorff Distance.
Args:
y_pred (np.ndarray): the edge of the predictions.
y (np.ndarray): the edge of the ground truth.
"""
if self.crop:
if not np.any(y_pred | y):
res1 = np.zeros_like(y_pred)
res2 = np.zeros_like(y)
return res1, res2
y_pred, y = np.expand_dims(y_pred, 0), np.expand_dims(y, 0)
box_start, box_end = self._create_space_bounding_box(y_pred | y)
cropper = _ROISpatialData(roi_start=box_start, roi_end=box_end)
y_pred, y = np.squeeze(cropper(y_pred)), np.squeeze(cropper(y))
y_pred = morphology.binary_erosion(y_pred) ^ y_pred
y = morphology.binary_erosion(y) ^ y
return y_pred, y
[docs] def clear(self):
"""Clears the internal evaluation result."""
self.y_pred_edges = 0
self.y_edges = 0
self._is_update = False
[docs] @rearrange_inputs
def update(self, *inputs):
"""
Updates the internal evaluation result 'y_pred', 'y' and 'label_idx'.
Args:
inputs: Input 'y_pred', 'y' and 'label_idx'. 'y_pred' and 'y' are Tensor or numpy.ndarray. 'y_pred' is the
predicted binary image. 'y' is the actual binary image. 'label_idx', the data type of `label_idx`
is int.
Raises:
ValueError: If the number of the inputs is not 3.
"""
self._is_update = True
if len(inputs) != 3:
raise ValueError('The HausdorffDistance needs 3 inputs (y_pred, y, label), but got {}'.format(len(inputs)))
y_pred = self._convert_data(inputs[0])
y = self._convert_data(inputs[1])
label_idx = inputs[2]
if not isinstance(label_idx, (int, float)):
raise TypeError("The data type of label_idx must be int or float, but got {}.".format(type(label_idx)))
if label_idx not in y_pred and label_idx not in y:
raise ValueError("The label_idx should be in y_pred or y, but {} is not.".format(label_idx))
if y_pred.size == 0 or y_pred.shape != y.shape:
raise ValueError("Labelfields should have the same shape, but got {}, {}".format(y_pred.shape, y.shape))
y_pred = (y_pred == label_idx) if y_pred.dtype is not bool else y_pred
y = (y == label_idx) if y.dtype is not bool else y
self.y_pred_edges, self.y_edges = self._get_mask_edges_distance(y_pred, y)
[docs] def eval(self):
"""
Calculate the no-directed or directed Hausdorff distance.
Returns:
A float with hausdorff_distance.
Raises:
RuntimeError: If the update method is not called first, an error will be reported.
"""
if self._is_update is False:
raise RuntimeError('Call the update method before calling eval.')
hd = self._calculate_percent_hausdorff_distance(self.y_pred_edges, self.y_edges)
if self.directed:
return hd
hd2 = self._calculate_percent_hausdorff_distance(self.y_edges, self.y_pred_edges)
return max(hd, hd2)