From 07aa22d4b9fc8b907b6a73c12998d44b8178acbf Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Wed, 30 Nov 2022 17:46:58 -0500 Subject: [PATCH 01/29] Refactor hovernet post-processing Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/apps/pathology/transforms/post/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/monai/apps/pathology/transforms/post/__init__.py b/monai/apps/pathology/transforms/post/__init__.py index 836582b8a3..95fa2a1b97 100644 --- a/monai/apps/pathology/transforms/post/__init__.py +++ b/monai/apps/pathology/transforms/post/__init__.py @@ -18,7 +18,7 @@ GenerateSuccinctContour, GenerateWatershedMarkers, GenerateWatershedMask, - HoVerNetNuclearTypePostProcessing, + HoVerNetPostProcessing, Watershed, ) from .dictionary import ( @@ -46,9 +46,9 @@ GenerateWatershedMaskD, GenerateWatershedMaskd, GenerateWatershedMaskDict, - HoVerNetNuclearTypePostProcessingD, - HoVerNetNuclearTypePostProcessingd, - HoVerNetNuclearTypePostProcessingDict, + HoVerNetPostProcessingD, + HoVerNetPostProcessingd, + HoVerNetPostProcessingDict, WatershedD, Watershedd, WatershedDict, From 9ebd464317e4189a7c4cb962a942c2b05dd102c8 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Wed, 30 Nov 2022 17:50:35 -0500 Subject: [PATCH 02/29] Make type_prediction optional Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/apps/pathology/transforms/post/array.py | 138 +++++++++++------- 1 file changed, 86 insertions(+), 52 deletions(-) diff --git a/monai/apps/pathology/transforms/post/array.py b/monai/apps/pathology/transforms/post/array.py index e82e5b00f5..25326cda12 100644 --- a/monai/apps/pathology/transforms/post/array.py +++ b/monai/apps/pathology/transforms/post/array.py @@ -15,7 +15,15 @@ import torch from monai.config.type_definitions import DtypeLike, NdarrayOrTensor -from monai.transforms import Activations, AsDiscrete, BoundingRect, RemoveSmallObjects, SobelGradients +from monai.transforms import ( + Activations, + AsDiscrete, + BoundingRect, + FillHoles, + GaussianSmooth, + RemoveSmallObjects, + SobelGradients, +) from monai.transforms.transform import Transform from monai.transforms.utils_pytorch_numpy_unification import max, maximum, min, sum, unique from monai.utils import TransformBackends, convert_to_numpy, optional_import @@ -39,7 +47,7 @@ "GenerateInstanceContour", "GenerateInstanceCentroid", "GenerateInstanceType", - "HoVerNetNuclearTypePostProcessing", + "HoVerNetPostProcessing", ] @@ -91,7 +99,7 @@ class GenerateWatershedMask(Transform): Args: softmax: if True, apply a softmax function to the prediction. sigmoid: if True, apply a sigmoid function to the prediction. - threshold: if not None, threshold the float values to int number 0 or 1 with specified theashold. + threshold: if not None, threshold the float values to int number 0 or 1 with specified threshold. remove_small_objects: whether need to remove some objects in the marker. Defaults to True. min_size: objects smaller than this size are removed if `remove_small_objects` is True. Defaults to 10. dtype: target data content type to convert, default is np.uint8. @@ -625,77 +633,103 @@ def __call__( # type: ignore return (int(inst_type), float(type_prob)) -class HoVerNetNuclearTypePostProcessing(Transform): +class HoVerNetPostProcessing(Transform): """ - The whole post-procesing transform for nuclear type classification branch. It returns type prediction map and a - dict contains centroid, bounding box, type prediciton for each instance. + The whole post-processing transform for nuclear type classification branch. It returns type prediction map and a + dict contains centroid, bounding box, type prediction for each instance. Args: min_num_points: assumed that the created contour does not form a contour if it does not contain more points than the specified value. Defaults to 3. level: optional. Used in `skimage.measure.find_contours`. Value along which to find contours in the array. By default, the level is set to (max(image) + min(image)) / 2. - return_centroids: whether to return centroids for each instance. output_classes: number of the nuclear type classes. """ def __init__( self, + distance_smooth_fn: Callable = GaussianSmooth(), + watershed_postprocess_fn: Callable = FillHoles(), min_num_points: int = 3, level: Optional[float] = None, - return_centroids: Optional[bool] = False, - output_classes: Optional[int] = None, ) -> None: super().__init__() - self.return_centroids = return_centroids - self.output_classes = output_classes + # instance segmentation transforms + self.generate_watershed_mask = GenerateWatershedMask(softmax=True) + self.generate_instance_border = GenerateInstanceBorder(kernel_size=3) + self.generate_distance_map = GenerateDistanceMap(smooth_fn=distance_smooth_fn) + self.generate_watershed_markers = GenerateWatershedMarkers( + threshold=0.7, radius=2, postprocess_fn=watershed_postprocess_fn + ) + self.watershed = Watershed() + # instance self.generate_instance_contour = GenerateInstanceContour(min_num_points=min_num_points, level=level) self.generate_instance_centroid = GenerateInstanceCentroid() self.generate_instance_type = GenerateInstanceType() - def __call__( # type: ignore - self, type_pred: NdarrayOrTensor, instance_pred: NdarrayOrTensor - ) -> Union[Tuple[NdarrayOrTensor, Dict], Dict]: - type_pred = Activations(softmax=True)(type_pred) - type_pred = AsDiscrete(argmax=True)(type_pred) - - inst_id_list = np.unique(instance_pred)[1:] # exclude background - inst_info_dict = None - if self.return_centroids: - inst_info_dict = {} - for inst_id in inst_id_list: - inst_map = instance_pred == inst_id - inst_bbox = BoundingRect()(inst_map) - inst_map = inst_map[:, inst_bbox[0][0] : inst_bbox[0][1], inst_bbox[0][2] : inst_bbox[0][3]] - offset = [inst_bbox[0][2], inst_bbox[0][0]] - inst_contour = self.generate_instance_contour(inst_map, offset) - inst_centroid = self.generate_instance_centroid(inst_map, offset) - if inst_contour is not None: - inst_info_dict[inst_id] = { # inst_id should start at 1 - "bounding_box": inst_bbox, - "centroid": inst_centroid, - "contour": inst_contour, - "type_probability": None, - "type": None, - } - - if self.output_classes is not None: - for inst_id in list(inst_info_dict.keys()): - inst_type, type_prob = self.generate_instance_type( - bbox=inst_info_dict[inst_id]["bounding_box"], # type: ignore - type_pred=type_pred, - seg_pred=instance_pred, + # type post-processing + self.activation = Activations(softmax=True) + self.as_discrete = AsDiscrete(argmax=True) + + def _process_instance_segmentation(self, nuclear_prediction, hover_map): + """post-process instance segmentation branches (NP and HV) to generate instance segmentation map.""" + + # Process NP and HV branch using watershed algorithm + watershed_mask = self.generate_watershed_mask(nuclear_prediction) + instance_borders = self.generate_instance_border(watershed_mask, hover_map) + distance_map = self.generate_distance_map(watershed_mask, instance_borders) + watershed_markers = self.generate_watershed_markers(watershed_mask, instance_borders) + instance_seg_map = self.watershed(distance_map, watershed_markers, watershed_markers) + + # Create bounding boxes, contours and centroids + instance_ids = set(np.unique(instance_seg_map)) - {0} # exclude background + instance_info_dict = {} + for inst_id in instance_ids: + instance_mask = instance_seg_map == inst_id + instance_bbox = BoundingRect()(instance_mask) + + instance_mask = instance_mask[ + :, instance_bbox[0][0] : instance_bbox[0][1], instance_bbox[0][2] : instance_bbox[0][3] + ] + offset = [instance_bbox[0][2], instance_bbox[0][0]] + instance_contour = self.generate_instance_contour(instance_mask, offset) + if instance_contour is not None: + instance_centroid = self.generate_instance_centroid(instance_mask, offset) + instance_info_dict[inst_id] = { + "bounding_box": instance_bbox, + "centroid": instance_centroid, + "contour": instance_contour, + } + + return instance_info_dict, instance_seg_map + + def __call__( + self, + nuclear_prediction: NdarrayOrTensor, + hover_map: NdarrayOrTensor, + type_prediction: Optional[NdarrayOrTensor] = None, + ) -> Tuple[NdarrayOrTensor, Dict, Dict]: + # Process NP and HV branches to create final instance map + instance_info_dict, instance_seg_map = self._process_instance_segmentation(nuclear_prediction, hover_map) + + instance_type_map = convert_to_dst_type(torch.zeros(instance_seg_map.shape), instance_seg_map) + if type_prediction is not None: + # Process NC (type prediction) branch and add type and its probability + for inst_id in instance_info_dict: + type_prediction = self.activation(type_prediction) + type_prediction = self.as_discrete(type_prediction) + instance_type, instance_type_probability = self.generate_instance_type( + bbox=instance_info_dict[inst_id]["bounding_box"], + type_pred=type_prediction, + seg_pred=instance_seg_map, instance_id=inst_id, ) - inst_info_dict[inst_id]["type"] = inst_type # type: ignore - inst_info_dict[inst_id]["type_probability"] = type_prob # type: ignore - - instance_pred = convert_to_dst_type(instance_pred, type_pred)[0] - pred_type_map = torch.zeros_like(instance_pred) # type: ignore - for key, value in inst_info_dict.items(): - pred_type_map[instance_pred == key] = value["type"] # type: ignore + # update instance info dict with type data + instance_info_dict[inst_id]["type_probability"] = instance_type_probability + instance_info_dict[inst_id]["type"] = instance_type - return pred_type_map, inst_info_dict + # update instance type map + instance_type_map[instance_seg_map == inst_id] = instance_type - return inst_info_dict + return instance_info_dict, instance_seg_map, instance_type_map From bd9f36bf7421dae1a79d6d17723527b4518e6815 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Wed, 30 Nov 2022 21:18:44 -0500 Subject: [PATCH 03/29] Restructure Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/apps/pathology/transforms/post/array.py | 83 +++++++++++++------ 1 file changed, 58 insertions(+), 25 deletions(-) diff --git a/monai/apps/pathology/transforms/post/array.py b/monai/apps/pathology/transforms/post/array.py index 25326cda12..3757b3470d 100644 --- a/monai/apps/pathology/transforms/post/array.py +++ b/monai/apps/pathology/transforms/post/array.py @@ -648,10 +648,10 @@ class HoVerNetPostProcessing(Transform): def __init__( self, - distance_smooth_fn: Callable = GaussianSmooth(), - watershed_postprocess_fn: Callable = FillHoles(), min_num_points: int = 3, level: Optional[float] = None, + distance_smooth_fn: Callable = GaussianSmooth(), + watershed_postprocess_fn: Callable = FillHoles(), ) -> None: super().__init__() @@ -672,8 +672,13 @@ def __init__( self.activation = Activations(softmax=True) self.as_discrete = AsDiscrete(argmax=True) - def _process_instance_segmentation(self, nuclear_prediction, hover_map): - """post-process instance segmentation branches (NP and HV) to generate instance segmentation map.""" + def process_instance_segmentation(self, nuclear_prediction, hover_map) -> Tuple[Dict, NdarrayOrTensor]: + """post-process instance segmentation branches (NP and HV) to generate instance segmentation map. + + Args: + nuclear_prediction: the output of NP (nuclear prediction) branch of HoVerNet model + hover_map: the output of HV (hover map) branch of HoVerNet model + """ # Process NP and HV branch using watershed algorithm watershed_mask = self.generate_watershed_mask(nuclear_prediction) @@ -704,32 +709,60 @@ def _process_instance_segmentation(self, nuclear_prediction, hover_map): return instance_info_dict, instance_seg_map + def process_nuclear_type( + self, instance_info_dict, instance_seg_map, type_prediction + ) -> Tuple[Dict, NdarrayOrTensor]: + """Process NC (type prediction) branch and combine it with instance segmentation + It updates the instance_info_dict with instance type and associated probability, and generate instance type map. + + Args: + instance_info_dict: instance information dictionary, which is output of `self.instance_segmentation` + instance_seg_map: instance segmentation map, which is output of `self.instance_segmentation` + type_prediction: the output of NC (type prediction) branch of HoVerNet model + """ + + instance_type_map = convert_to_dst_type(torch.zeros(instance_seg_map.shape), instance_seg_map)[0] + + for inst_id in instance_info_dict: + type_prediction = self.activation(type_prediction) + type_prediction = self.as_discrete(type_prediction) + instance_type, instance_type_probability = self.generate_instance_type( + bbox=instance_info_dict[inst_id]["bounding_box"], + type_pred=type_prediction, + seg_pred=instance_seg_map, + instance_id=inst_id, + ) + # update instance info dict with type data + instance_info_dict[inst_id]["type_probability"] = instance_type_probability + instance_info_dict[inst_id]["type"] = instance_type + + # update instance type map + instance_type_map[instance_seg_map == inst_id] = instance_type + + return instance_info_dict, instance_type_map + def __call__( self, nuclear_prediction: NdarrayOrTensor, hover_map: NdarrayOrTensor, type_prediction: Optional[NdarrayOrTensor] = None, - ) -> Tuple[NdarrayOrTensor, Dict, Dict]: + ) -> Tuple[Dict, NdarrayOrTensor, NdarrayOrTensor]: + """ + Args: + nuclear_prediction: the output of NC (nuclear prediction) branch of HoVerNet model. + hover_map: the output of HV (hover map) branch of HoVerNet model. + type_prediction: the output of NC (type prediction) branch of HoVerNet model (optional). + If not provided, the type info and the type segmentation map is not generated. + + """ # Process NP and HV branches to create final instance map - instance_info_dict, instance_seg_map = self._process_instance_segmentation(nuclear_prediction, hover_map) + instance_info_dict, instance_seg_map = self.process_instance_segmentation(nuclear_prediction, hover_map) - instance_type_map = convert_to_dst_type(torch.zeros(instance_seg_map.shape), instance_seg_map) + type_seg_map = None + # Process NC branch to create segmentation map with types if type_prediction is not None: - # Process NC (type prediction) branch and add type and its probability - for inst_id in instance_info_dict: - type_prediction = self.activation(type_prediction) - type_prediction = self.as_discrete(type_prediction) - instance_type, instance_type_probability = self.generate_instance_type( - bbox=instance_info_dict[inst_id]["bounding_box"], - type_pred=type_prediction, - seg_pred=instance_seg_map, - instance_id=inst_id, - ) - # update instance info dict with type data - instance_info_dict[inst_id]["type_probability"] = instance_type_probability - instance_info_dict[inst_id]["type"] = instance_type - - # update instance type map - instance_type_map[instance_seg_map == inst_id] = instance_type - - return instance_info_dict, instance_seg_map, instance_type_map + instance_info_dict, type_seg_map = self.process_nuclear_type( + instance_info_dict, instance_seg_map, type_prediction + ) + + return instance_info_dict, instance_seg_map, type_seg_map From 3764ad1d082462b16790b6426a8772265dd6650c Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Wed, 30 Nov 2022 21:19:35 -0500 Subject: [PATCH 04/29] Update unittests Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- ...r_type_post_processing.py => test_hovernet_post_processing.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{test_hovernet_nuclear_type_post_processing.py => test_hovernet_post_processing.py} (100%) diff --git a/tests/test_hovernet_nuclear_type_post_processing.py b/tests/test_hovernet_post_processing.py similarity index 100% rename from tests/test_hovernet_nuclear_type_post_processing.py rename to tests/test_hovernet_post_processing.py From 43ce643262e4e9834ca03c793e8471313abba419 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Wed, 30 Nov 2022 21:48:55 -0500 Subject: [PATCH 05/29] Update dictionary wrapper Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/apps/pathology/transforms/__init__.py | 8 +- monai/apps/pathology/transforms/post/array.py | 20 ++-- .../pathology/transforms/post/dictionary.py | 97 +++++++++---------- 3 files changed, 60 insertions(+), 65 deletions(-) diff --git a/monai/apps/pathology/transforms/__init__.py b/monai/apps/pathology/transforms/__init__.py index 18b53d7f2a..377a22da64 100644 --- a/monai/apps/pathology/transforms/__init__.py +++ b/monai/apps/pathology/transforms/__init__.py @@ -18,7 +18,7 @@ GenerateSuccinctContour, GenerateWatershedMarkers, GenerateWatershedMask, - HoVerNetNuclearTypePostProcessing, + HoVerNetPostProcessing, Watershed, ) from .post.dictionary import ( @@ -46,9 +46,9 @@ GenerateWatershedMaskD, GenerateWatershedMaskd, GenerateWatershedMaskDict, - HoVerNetNuclearTypePostProcessingD, - HoVerNetNuclearTypePostProcessingd, - HoVerNetNuclearTypePostProcessingDict, + HoVerNetPostProcessingD, + HoVerNetPostProcessingd, + HoVerNetPostProcessingDict, WatershedD, Watershedd, WatershedDict, diff --git a/monai/apps/pathology/transforms/post/array.py b/monai/apps/pathology/transforms/post/array.py index 3757b3470d..8c618129e6 100644 --- a/monai/apps/pathology/transforms/post/array.py +++ b/monai/apps/pathology/transforms/post/array.py @@ -635,15 +635,17 @@ def __call__( # type: ignore class HoVerNetPostProcessing(Transform): """ - The whole post-processing transform for nuclear type classification branch. It returns type prediction map and a - dict contains centroid, bounding box, type prediction for each instance. + The post-processing transform for HoVerNet model, which includes all the three branches. + It generate a dictionary containing centroid, bounding box, type prediction for each instance. Also if requested, + it returns binary maps for instance segmentation and predicted types. Args: - min_num_points: assumed that the created contour does not form a contour if it does not contain more points - than the specified value. Defaults to 3. - level: optional. Used in `skimage.measure.find_contours`. Value along which to find contours in the array. - By default, the level is set to (max(image) + min(image)) / 2. - output_classes: number of the nuclear type classes. + min_num_points: minimum number of points to be considered as a contour. Defaults to 3. + level: optional value for `skimage.measure.find_contours`, to find contours in the array. + If not provided, the level is set to (max(image) + min(image)) / 2. + distance_smooth_fn: smoothing function for distance map. Defaults to :py:class:`monai.transforms.intensity.GaussianSmooth()`. + marker_post_process_fn: post-process function for watershed markers. Defaults to :py:class:`monai.transforms.post.FillHoles()`. + """ def __init__( @@ -651,7 +653,7 @@ def __init__( min_num_points: int = 3, level: Optional[float] = None, distance_smooth_fn: Callable = GaussianSmooth(), - watershed_postprocess_fn: Callable = FillHoles(), + marker_post_process_fn: Callable = FillHoles(), ) -> None: super().__init__() @@ -660,7 +662,7 @@ def __init__( self.generate_instance_border = GenerateInstanceBorder(kernel_size=3) self.generate_distance_map = GenerateDistanceMap(smooth_fn=distance_smooth_fn) self.generate_watershed_markers = GenerateWatershedMarkers( - threshold=0.7, radius=2, postprocess_fn=watershed_postprocess_fn + threshold=0.7, radius=2, postprocess_fn=marker_post_process_fn ) self.watershed = Watershed() # instance diff --git a/monai/apps/pathology/transforms/post/dictionary.py b/monai/apps/pathology/transforms/post/dictionary.py index ff4f2014ab..dabb792c3c 100644 --- a/monai/apps/pathology/transforms/post/dictionary.py +++ b/monai/apps/pathology/transforms/post/dictionary.py @@ -22,10 +22,11 @@ GenerateSuccinctContour, GenerateWatershedMarkers, GenerateWatershedMask, - HoVerNetNuclearTypePostProcessing, + HoVerNetPostProcessing, Watershed, ) from monai.config.type_definitions import DtypeLike, KeysCollection, NdarrayOrTensor +from monai.transforms import FillHoles, GaussianSmooth from monai.transforms.transform import MapTransform, Transform from monai.utils import convert_to_dst_type, optional_import from monai.utils.enums import HoVerNetBranch @@ -61,9 +62,9 @@ "GenerateInstanceTypeDict", "GenerateInstanceTypeD", "GenerateInstanceTyped", - "HoVerNetNuclearTypePostProcessingDict", - "HoVerNetNuclearTypePostProcessingD", - "HoVerNetNuclearTypePostProcessingd", + "HoVerNetPostProcessingDict", + "HoVerNetPostProcessingD", + "HoVerNetPostProcessingd", ] @@ -485,70 +486,62 @@ def __call__(self, data): return d -class HoVerNetNuclearTypePostProcessingd(Transform): +class HoVerNetPostProcessingd(Transform): """ - Dictionary-based wrapper of :py:class:`monai.apps.pathology.transforms.post.array.HoVerNetNuclearTypePostProcessing`. - Generate instance type and probability for each instance. + Dictionary-based wrapper for :py:class:`monai.apps.pathology.transforms.post.array.HoVerNetPostProcessing`. + It generate a dictionary containing centroid, bounding box, type prediction for each instance. Also if requested, + it returns binary maps for instance segmentation and predicted types. + + The output dictionary will have three new keys: + + - "instance_info" mapping each instance to their centroid, bounding box, predicted type and probability. + - "instance_seg_map" containing an instance segmentation map. + - "type_seg_map" containing a segmentation map with associated types for each pixel. Args: - type_pred_key: the key pointing to the pred type map to be transformed. - instance_pred_key: the key pointing to the pred distance map. - min_num_points: assumed that the created contour does not form a contour if it does not contain more points - than the specified value. Defaults to 3. - level: optional. Used in `skimage.measure.find_contours`. Value along which to find contours in the array. - By default, the level is set to (max(image) + min(image)) / 2. - return_binary: whether to return the binary segmentation prediction after `seg_postprocessing`. - pred_binary_key: if `return_binary` is True, this `pred_binary_key` is used to get the binary prediciton - from the output. - return_centroids: whether to return centroids for each instance. - output_classes: number of the nuclear type classes. - instance_info_dict_key: key use to record information for each instance. + level: optional value for `skimage.measure.find_contours`, to find contours in the array. + If not provided, the level is set to (max(image) + min(image)) / 2. + distance_smooth_fn: smoothing function for distance map. Defaults to :py:class:`monai.transforms.intensity.GaussianSmooth()`. + marker_post_process_fn: post-process function for watershed markers. Defaults to :py:class:`monai.transforms.post.FillHoles()`. allow_missing_keys: don't raise exception if key is missing. """ def __init__( self, - type_pred_key: str = HoVerNetBranch.NC.value, - instance_pred_key: str = "dist", + nuclear_prediction_key: str = HoVerNetBranch.NP.value, + hover_map_key: str = HoVerNetBranch.HV.value, + type_prediction_key: str = HoVerNetBranch.NC.value, min_num_points: int = 3, level: Optional[float] = None, - return_binary: Optional[bool] = True, - pred_binary_key: Optional[str] = "pred_binary", - return_centroids: Optional[bool] = False, - output_classes: Optional[int] = None, - instance_info_dict_key: Optional[str] = "instance_info_dict", + distance_smooth_fn: Callable = GaussianSmooth(), + marker_post_process_fn: Callable = FillHoles(), ) -> None: super().__init__() - self.converter = HoVerNetNuclearTypePostProcessing( - min_num_points=min_num_points, level=level, return_centroids=return_centroids, output_classes=output_classes + self.post_process = HoVerNetPostProcessing( + min_num_points=min_num_points, + level=level, + distance_smooth_fn=distance_smooth_fn, + marker_post_process_fn=marker_post_process_fn, ) - self.output_classes = output_classes - self.type_pred_key = type_pred_key - self.instance_pred_key = instance_pred_key - self.pred_binary_key = pred_binary_key - self.instance_info_dict_key = instance_info_dict_key - self.return_binary = return_binary + self.nuclear_prediction_key = nuclear_prediction_key + self.hover_map_key = hover_map_key + self.type_prediction_key = type_prediction_key def __call__(self, data): d = dict(data) - inst_pred = d[self.instance_pred_key] - type_pred = d[self.type_pred_key] - if self.output_classes is not None: - pred_type_map, inst_info_dict = self.converter(type_pred, inst_pred) - d[self.type_pred_key] = pred_type_map - else: - inst_info_dict = self.converter(type_pred, inst_pred) - - key_to_add = f"{self.instance_info_dict_key}" - if key_to_add in d: - raise KeyError(f"Type information with key {key_to_add} already exists.") - d[key_to_add] = inst_info_dict - - if self.return_binary: - d[self.pred_binary_key] = (inst_pred > 0).astype(int) - inst_pred = convert_to_dst_type(inst_pred, type_pred)[0] - d[self.instance_pred_key] = inst_pred + instance_info, instance_seg_map, type_seg_map = self.post_process( + d[self.nuclear_prediction_key], d[self.hover_map_key], d[self.type_prediction_key] + ) + + output_keys = ["instance_info", "instance_seg_map", "type_seg_map"] + for k in output_keys: + if k in d: + raise ValueError("The output key ['{k}'] already exists in the input dictionary!") + + d["instance_info"] = instance_info + d["instance_seg_map"] = instance_seg_map + d["type_seg_map"] = type_seg_map return d @@ -562,4 +555,4 @@ def __call__(self, data): GenerateInstanceContourDict = GenerateInstanceContourD = GenerateInstanceContourd GenerateInstanceCentroidDict = GenerateInstanceCentroidD = GenerateInstanceCentroidd GenerateInstanceTypeDict = GenerateInstanceTypeD = GenerateInstanceTyped -HoVerNetNuclearTypePostProcessingDict = HoVerNetNuclearTypePostProcessingD = HoVerNetNuclearTypePostProcessingd +HoVerNetPostProcessingDict = HoVerNetPostProcessingD = HoVerNetPostProcessingd From d015dc8b4eb8f3014832df4eccff55df6d551405 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Wed, 30 Nov 2022 21:49:07 -0500 Subject: [PATCH 06/29] Update docs Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- docs/source/apps.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/apps.rst b/docs/source/apps.rst index b3016acaa0..29c43c31d3 100644 --- a/docs/source/apps.rst +++ b/docs/source/apps.rst @@ -153,7 +153,7 @@ Applications :members: .. autoclass:: GenerateWatershedMarkers :members: -.. autoclass:: HoVerNetNuclearTypePostProcessing +.. autoclass:: HoVerNetPostProcessing :members: .. automodule:: monai.apps.pathology.transforms.post.dictionary @@ -175,7 +175,7 @@ Applications :members: .. autoclass:: GenerateWatershedMarkersd :members: -.. autoclass:: HoVerNetNuclearTypePostProcessingd +.. autoclass:: HoVerNetPostProcessingd :members: `Detection` From 220dfe21130ebd6c79c34a5900b2e80c1cea0a52 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Wed, 30 Nov 2022 21:56:29 -0500 Subject: [PATCH 07/29] Update unittests Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- ..._hovernet_nuclear_type_post_processingd.py | 79 ------------------- tests/test_hovernet_post_processing.py | 64 ++++++--------- tests/test_hovernet_post_processingd.py | 71 +++++++++++++++++ 3 files changed, 95 insertions(+), 119 deletions(-) delete mode 100644 tests/test_hovernet_nuclear_type_post_processingd.py create mode 100644 tests/test_hovernet_post_processingd.py diff --git a/tests/test_hovernet_nuclear_type_post_processingd.py b/tests/test_hovernet_nuclear_type_post_processingd.py deleted file mode 100644 index 63c4230af4..0000000000 --- a/tests/test_hovernet_nuclear_type_post_processingd.py +++ /dev/null @@ -1,79 +0,0 @@ -# Copyright (c) MONAI Consortium -# 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. - -import unittest - -import numpy as np -from parameterized import parameterized - -from monai.apps.pathology.transforms.post.dictionary import ( - GenerateDistanceMapd, - GenerateInstanceBorderd, - GenerateWatershedMarkersd, - GenerateWatershedMaskd, - HoVerNetNuclearTypePostProcessingd, - Watershedd, -) -from monai.transforms import Compose, ComputeHoVerMaps, FillHoles, GaussianSmooth -from monai.utils import min_version, optional_import -from monai.utils.enums import HoVerNetBranch -from tests.utils import TEST_NDARRAYS, assert_allclose - -_, has_scipy = optional_import("scipy", "1.8.1", min_version) -_, has_skimage = optional_import("skimage", "0.19.3", min_version) - -y, x = np.ogrid[0:30, 0:30] -image = (x - 10) ** 2 + (y - 10) ** 2 <= 5**2 - -seg_postpprocessing = [ - GenerateWatershedMaskd( - keys=HoVerNetBranch.NP.value, sigmoid=True, softmax=False, threshold=0.7, remove_small_objects=False - ), - GenerateInstanceBorderd( - keys="mask", hover_map_key=HoVerNetBranch.HV.value, kernel_size=3, remove_small_objects=False - ), - GenerateDistanceMapd(keys="mask", border_key="border", smooth_fn=GaussianSmooth()), - GenerateWatershedMarkersd(keys="mask", border_key="border", threshold=0.9, radius=2, postprocess_fn=FillHoles()), - Watershedd(keys="dist", mask_key="mask", markers_key="markers"), -] - -TEST_CASE_1 = [seg_postpprocessing, {"return_centroids": True, "output_classes": 1}, [image, [10, 10]]] -TEST_CASE_2 = [seg_postpprocessing, {"return_centroids": False, "output_classes": None}, [image]] - -TEST_CASE = [] -for p in TEST_NDARRAYS: - TEST_CASE.append([p, image] + TEST_CASE_1) - TEST_CASE.append([p, image] + TEST_CASE_2) - - -@unittest.skipUnless(has_scipy, "Requires scipy library.") -@unittest.skipUnless(has_skimage, "Requires scikit-image library.") -class TestHoVerNetNuclearTypePostProcessingd(unittest.TestCase): - @parameterized.expand(TEST_CASE) - def test_value(self, in_type, test_data, seg_postpprocessing, kwargs, expected): - hovermap = ComputeHoVerMaps()(test_data[None].astype(int)) - input = { - HoVerNetBranch.NP.value: in_type(test_data[None].astype(float)), - HoVerNetBranch.HV.value: in_type(hovermap), - HoVerNetBranch.NC.value: in_type(test_data[None]), - } - - class_trans = [HoVerNetNuclearTypePostProcessingd(**kwargs)] - post_transforms = Compose(seg_postpprocessing + class_trans) - out = post_transforms(input) - - assert_allclose(out["dist"].squeeze(), expected[0], type_test=False) - if out["instance_info_dict"] is not None: - assert_allclose(out["instance_info_dict"][1]["centroid"], expected[1], type_test=False) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_hovernet_post_processing.py b/tests/test_hovernet_post_processing.py index eaafb9f2ab..c362465b96 100644 --- a/tests/test_hovernet_post_processing.py +++ b/tests/test_hovernet_post_processing.py @@ -14,17 +14,9 @@ import numpy as np from parameterized import parameterized -from monai.apps.pathology.transforms.post.array import HoVerNetNuclearTypePostProcessing -from monai.apps.pathology.transforms.post.dictionary import ( - GenerateDistanceMapd, - GenerateInstanceBorderd, - GenerateWatershedMarkersd, - GenerateWatershedMaskd, - Watershedd, -) -from monai.transforms import Compose, ComputeHoVerMaps, FillHoles, GaussianSmooth +from monai.apps.pathology.transforms.post.array import HoVerNetPostProcessing +from monai.transforms import ComputeHoVerMaps, FillHoles, GaussianSmooth from monai.utils import min_version, optional_import -from monai.utils.enums import HoVerNetBranch from tests.utils import TEST_NDARRAYS, assert_allclose _, has_scipy = optional_import("scipy", "1.8.1", min_version) @@ -32,49 +24,41 @@ y, x = np.ogrid[0:30, 0:30] image = (x - 10) ** 2 + (y - 10) ** 2 <= 5**2 +image = image[None, ...].astype("uint8") -seg_postpprocessing = Compose( - [ - GenerateWatershedMaskd( - keys=HoVerNetBranch.NP.value, sigmoid=True, softmax=False, threshold=0.7, remove_small_objects=False - ), - GenerateInstanceBorderd( - keys="mask", hover_map_key=HoVerNetBranch.HV.value, kernel_size=3, remove_small_objects=False - ), - GenerateDistanceMapd(keys="mask", border_key="border", smooth_fn=GaussianSmooth()), - GenerateWatershedMarkersd( - keys="mask", border_key="border", threshold=0.9, radius=2, postprocess_fn=FillHoles() - ), - Watershedd(keys="dist", mask_key="mask", markers_key="markers"), - ] -) -TEST_CASE_1 = [seg_postpprocessing, {"return_centroids": True, "output_classes": 1}, [image, [10, 10]]] -TEST_CASE_2 = [seg_postpprocessing, {"return_centroids": False, "output_classes": None}, [image]] +TEST_CASE_1 = [{}, [{"1": [10, 10]}, np.zeros_like(image), np.zeros_like(image)]] +TEST_CASE_2 = [{"distance_smooth_fn": GaussianSmooth()}, [{"1": [10, 10]}, np.zeros_like(image), np.zeros_like(image)]] +TEST_CASE_3 = [{"marker_post_process_fn": FillHoles()}, [{"1": [10, 10]}, np.zeros_like(image), np.zeros_like(image)]] TEST_CASE = [] for p in TEST_NDARRAYS: TEST_CASE.append([p, image] + TEST_CASE_1) TEST_CASE.append([p, image] + TEST_CASE_2) + TEST_CASE.append([p, image] + TEST_CASE_3) @unittest.skipUnless(has_scipy, "Requires scipy library.") @unittest.skipUnless(has_skimage, "Requires scikit-image library.") -class TestHoVerNetNuclearTypePostProcessing(unittest.TestCase): +class TestHoVerNetPostProcessing(unittest.TestCase): @parameterized.expand(TEST_CASE) - def test_value(self, in_type, test_data, seg_postpprocessing, kwargs, expected): - hovermap = ComputeHoVerMaps()(test_data[None].astype(int)) - input = { - HoVerNetBranch.NP.value: in_type(test_data[None].astype(float)), - HoVerNetBranch.HV.value: in_type(hovermap), - HoVerNetBranch.NC.value: in_type(test_data[None]), - } + def test_value(self, in_type, test_data, kwargs, expected): + nuclear_prediction = in_type(test_data.astype(float)) + hover_map = in_type(ComputeHoVerMaps()(test_data.astype(int))) + nuclear_type = in_type(test_data) - pred = seg_postpprocessing(input) + pred_dict, inst_map, type_map = HoVerNetPostProcessing(**kwargs)(nuclear_prediction, hover_map, nuclear_type) + # instance prediction info + for key in pred_dict: + assert_allclose(pred_dict[key]["centroid"], expected[0][key], type_test=False) - post_transforms = HoVerNetNuclearTypePostProcessing(**kwargs) - out = post_transforms(type_pred=in_type(test_data[None]), instance_pred=pred["dist"]) - if out is not None: - assert_allclose(out[1][1]["centroid"], expected[1], type_test=False) + # instance map + assert_allclose(inst_map, expected[1], type_test=False) + + # type map + if expected[2] is None: + self.assertIsNone(type_map) + else: + assert_allclose(type_map, expected[2], type_test=False) if __name__ == "__main__": diff --git a/tests/test_hovernet_post_processingd.py b/tests/test_hovernet_post_processingd.py new file mode 100644 index 0000000000..055b361b38 --- /dev/null +++ b/tests/test_hovernet_post_processingd.py @@ -0,0 +1,71 @@ +# Copyright (c) MONAI Consortium +# 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. + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.apps.pathology.transforms.post.dictionary import HoVerNetPostProcessingd +from monai.transforms import ComputeHoVerMaps, FillHoles, GaussianSmooth +from monai.utils import min_version, optional_import +from monai.utils.enums import HoVerNetBranch +from tests.utils import TEST_NDARRAYS, assert_allclose + +_, has_scipy = optional_import("scipy", "1.8.1", min_version) +_, has_skimage = optional_import("skimage", "0.19.3", min_version) + +y, x = np.ogrid[0:30, 0:30] +image = (x - 10) ** 2 + (y - 10) ** 2 <= 5**2 +image = image[None, ...].astype("uint8") + + +TEST_CASE_1 = [{}, [{"1": [10, 10]}, np.zeros_like(image), np.zeros_like(image)]] +TEST_CASE_2 = [{"distance_smooth_fn": GaussianSmooth()}, [{"1": [10, 10]}, np.zeros_like(image), np.zeros_like(image)]] +TEST_CASE_3 = [{"marker_post_process_fn": FillHoles()}, [{"1": [10, 10]}, np.zeros_like(image), np.zeros_like(image)]] + + +TEST_CASE = [] +for p in TEST_NDARRAYS: + TEST_CASE.append([p, image] + TEST_CASE_1) + TEST_CASE.append([p, image] + TEST_CASE_2) + TEST_CASE.append([p, image] + TEST_CASE_3) + + +@unittest.skipUnless(has_scipy, "Requires scipy library.") +@unittest.skipUnless(has_skimage, "Requires scikit-image library.") +class TestHoVerNetPostProcessingd(unittest.TestCase): + @parameterized.expand(TEST_CASE) + def test_value(self, in_type, test_data, kwargs, expected): + input = { + HoVerNetBranch.NP.value: in_type(test_data.astype(float)), + HoVerNetBranch.HV.value: in_type(ComputeHoVerMaps()(test_data.astype(int))), + HoVerNetBranch.NC.value: in_type(test_data), + } + + outputs = HoVerNetPostProcessingd(**kwargs)(input) + + # instance prediction info + for key in outputs["instance_info"]: + assert_allclose(outputs["instance_info"][key]["centroid"], expected[0][key], type_test=False) + + # instance map + assert_allclose(outputs["instance_seg_map"], expected[1], type_test=False) + + # type map + if expected[2] is None: + self.assertIsNone(outputs["type_seg_map"]) + else: + assert_allclose(outputs["type_seg_map"], expected[2], type_test=False) + + +if __name__ == "__main__": + unittest.main() From b918839bcc15b796b7e2ce6d02847e3c095c29eb Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 1 Dec 2022 02:57:37 +0000 Subject: [PATCH 08/29] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- monai/apps/pathology/transforms/post/dictionary.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monai/apps/pathology/transforms/post/dictionary.py b/monai/apps/pathology/transforms/post/dictionary.py index dabb792c3c..c775c5da00 100644 --- a/monai/apps/pathology/transforms/post/dictionary.py +++ b/monai/apps/pathology/transforms/post/dictionary.py @@ -28,7 +28,7 @@ from monai.config.type_definitions import DtypeLike, KeysCollection, NdarrayOrTensor from monai.transforms import FillHoles, GaussianSmooth from monai.transforms.transform import MapTransform, Transform -from monai.utils import convert_to_dst_type, optional_import +from monai.utils import optional_import from monai.utils.enums import HoVerNetBranch find_contours, _ = optional_import("skimage.measure", name="find_contours") From ffdd4b570d089d85d8ddb9b1fd642cbc8d2e10e8 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Wed, 30 Nov 2022 22:26:46 -0500 Subject: [PATCH 09/29] Expand if statement to avoid type error Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/apps/pathology/transforms/post/array.py | 28 +++++++++++++------ .../pathology/transforms/post/dictionary.py | 13 +++++---- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/monai/apps/pathology/transforms/post/array.py b/monai/apps/pathology/transforms/post/array.py index 8c618129e6..07e058baa9 100644 --- a/monai/apps/pathology/transforms/post/array.py +++ b/monai/apps/pathology/transforms/post/array.py @@ -643,8 +643,10 @@ class HoVerNetPostProcessing(Transform): min_num_points: minimum number of points to be considered as a contour. Defaults to 3. level: optional value for `skimage.measure.find_contours`, to find contours in the array. If not provided, the level is set to (max(image) + min(image)) / 2. - distance_smooth_fn: smoothing function for distance map. Defaults to :py:class:`monai.transforms.intensity.GaussianSmooth()`. - marker_post_process_fn: post-process function for watershed markers. Defaults to :py:class:`monai.transforms.post.FillHoles()`. + distance_smooth_fn: smoothing function for distance map. + If not provided, :py:class:`monai.transforms.intensity.GaussianSmooth()` will be used.. + marker_post_process_fn: post-process function for watershed markers. + If not provided, :py:class:`monai.transforms.post.FillHoles()` will be used. """ @@ -652,17 +654,27 @@ def __init__( self, min_num_points: int = 3, level: Optional[float] = None, - distance_smooth_fn: Callable = GaussianSmooth(), - marker_post_process_fn: Callable = FillHoles(), + distance_smooth_fn: Optional[Callable] = None, + marker_post_process_fn: Optional[Callable] = None, ) -> None: super().__init__() + if distance_smooth_fn is not None: + self.distance_smooth_fn = distance_smooth_fn + else: + self.distance_smooth_fn = GaussianSmooth() + + if marker_post_process_fn is not None: + self.marker_post_process_fn = marker_post_process_fn + else: + self.marker_post_process_fn = FillHoles() + # instance segmentation transforms self.generate_watershed_mask = GenerateWatershedMask(softmax=True) self.generate_instance_border = GenerateInstanceBorder(kernel_size=3) - self.generate_distance_map = GenerateDistanceMap(smooth_fn=distance_smooth_fn) + self.generate_distance_map = GenerateDistanceMap(smooth_fn=self.distance_smooth_fn) self.generate_watershed_markers = GenerateWatershedMarkers( - threshold=0.7, radius=2, postprocess_fn=marker_post_process_fn + threshold=0.7, radius=2, postprocess_fn=self.marker_post_process_fn ) self.watershed = Watershed() # instance @@ -743,12 +755,12 @@ def process_nuclear_type( return instance_info_dict, instance_type_map - def __call__( + def __call__( # type: ignore self, nuclear_prediction: NdarrayOrTensor, hover_map: NdarrayOrTensor, type_prediction: Optional[NdarrayOrTensor] = None, - ) -> Tuple[Dict, NdarrayOrTensor, NdarrayOrTensor]: + ) -> Tuple[Dict, NdarrayOrTensor, Optional[NdarrayOrTensor]]: """ Args: nuclear_prediction: the output of NC (nuclear prediction) branch of HoVerNet model. diff --git a/monai/apps/pathology/transforms/post/dictionary.py b/monai/apps/pathology/transforms/post/dictionary.py index dabb792c3c..1d59dd38c9 100644 --- a/monai/apps/pathology/transforms/post/dictionary.py +++ b/monai/apps/pathology/transforms/post/dictionary.py @@ -26,9 +26,8 @@ Watershed, ) from monai.config.type_definitions import DtypeLike, KeysCollection, NdarrayOrTensor -from monai.transforms import FillHoles, GaussianSmooth from monai.transforms.transform import MapTransform, Transform -from monai.utils import convert_to_dst_type, optional_import +from monai.utils import optional_import from monai.utils.enums import HoVerNetBranch find_contours, _ = optional_import("skimage.measure", name="find_contours") @@ -501,8 +500,10 @@ class HoVerNetPostProcessingd(Transform): Args: level: optional value for `skimage.measure.find_contours`, to find contours in the array. If not provided, the level is set to (max(image) + min(image)) / 2. - distance_smooth_fn: smoothing function for distance map. Defaults to :py:class:`monai.transforms.intensity.GaussianSmooth()`. - marker_post_process_fn: post-process function for watershed markers. Defaults to :py:class:`monai.transforms.post.FillHoles()`. + distance_smooth_fn: smoothing function for distance map. + If not provided, :py:class:`monai.transforms.intensity.GaussianSmooth()` will be used.. + marker_post_process_fn: post-process function for watershed markers. + If not provided, :py:class:`monai.transforms.post.FillHoles()` will be used. allow_missing_keys: don't raise exception if key is missing. """ @@ -514,8 +515,8 @@ def __init__( type_prediction_key: str = HoVerNetBranch.NC.value, min_num_points: int = 3, level: Optional[float] = None, - distance_smooth_fn: Callable = GaussianSmooth(), - marker_post_process_fn: Callable = FillHoles(), + distance_smooth_fn: Optional[Callable] = None, + marker_post_process_fn: Optional[Callable] = None, ) -> None: super().__init__() self.post_process = HoVerNetPostProcessing( From 9115d7ec88f0c0f521078bc7be71af1b00401ec7 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Wed, 30 Nov 2022 22:34:20 -0500 Subject: [PATCH 10/29] Update names and fix typos Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/apps/pathology/transforms/post/array.py | 23 ++++++++++--------- .../pathology/transforms/post/dictionary.py | 6 ++--- tests/test_hovernet_post_processing.py | 2 +- tests/test_hovernet_post_processingd.py | 2 +- 4 files changed, 17 insertions(+), 16 deletions(-) diff --git a/monai/apps/pathology/transforms/post/array.py b/monai/apps/pathology/transforms/post/array.py index 07e058baa9..452ba35f57 100644 --- a/monai/apps/pathology/transforms/post/array.py +++ b/monai/apps/pathology/transforms/post/array.py @@ -278,11 +278,11 @@ class GenerateWatershedMarkers(Transform): Generate markers to be used in `watershed`. The watershed algorithm treats pixels values as a local topography (elevation). The algorithm floods basins from the markers until basins attributed to different markers meet on watershed lines. Generally, markers are chosen as local minima of the image, from which basins are flooded. - Here is the implementation from HoVerNet papar. + Here is the implementation from HoVerNet paper. For more details refer to papers: https://arxiv.org/abs/1812.06499. Args: - threshold: threshold the float values of foreground probability map to int 0 or 1 with specified theashold. + threshold: threshold the float values of foreground probability map to int 0 or 1 with specified threshold. It turns uncertain area to 1 and other area to 0. Defaults to 0.4. radius: the radius of the disk-shaped footprint used in `opening`. Defaults to 2. min_size: objects smaller than this size are removed if `remove_small_objects` is True. Defaults to 10. @@ -387,7 +387,7 @@ def _generate_contour_coord(self, current: np.ndarray, previous: np.ndarray) -> def _calculate_distance_from_topleft(self, sequence: Sequence[Tuple[int, int]]) -> int: """ Each sequence of coordinates describes a boundary between foreground and background starting and ending at two sides - of the bounding box. To order the sequences correctly, we compute the distance from the topleft of the bounding box + of the bounding box. To order the sequences correctly, we compute the distance from the top-left of the bounding box around the perimeter in a clockwise direction. Args: @@ -645,7 +645,7 @@ class HoVerNetPostProcessing(Transform): If not provided, the level is set to (max(image) + min(image)) / 2. distance_smooth_fn: smoothing function for distance map. If not provided, :py:class:`monai.transforms.intensity.GaussianSmooth()` will be used.. - marker_post_process_fn: post-process function for watershed markers. + marker_postprocess_fn: post-process function for watershed markers. If not provided, :py:class:`monai.transforms.post.FillHoles()` will be used. """ @@ -655,7 +655,7 @@ def __init__( min_num_points: int = 3, level: Optional[float] = None, distance_smooth_fn: Optional[Callable] = None, - marker_post_process_fn: Optional[Callable] = None, + marker_postprocess_fn: Optional[Callable] = None, ) -> None: super().__init__() @@ -664,25 +664,26 @@ def __init__( else: self.distance_smooth_fn = GaussianSmooth() - if marker_post_process_fn is not None: - self.marker_post_process_fn = marker_post_process_fn + if marker_postprocess_fn is not None: + self.marker_postprocess_fn = marker_postprocess_fn else: - self.marker_post_process_fn = FillHoles() + self.marker_postprocess_fn = FillHoles() # instance segmentation transforms self.generate_watershed_mask = GenerateWatershedMask(softmax=True) self.generate_instance_border = GenerateInstanceBorder(kernel_size=3) self.generate_distance_map = GenerateDistanceMap(smooth_fn=self.distance_smooth_fn) self.generate_watershed_markers = GenerateWatershedMarkers( - threshold=0.7, radius=2, postprocess_fn=self.marker_post_process_fn + threshold=0.7, radius=2, postprocess_fn=self.marker_postprocess_fn ) self.watershed = Watershed() - # instance + + # per instance transforms self.generate_instance_contour = GenerateInstanceContour(min_num_points=min_num_points, level=level) self.generate_instance_centroid = GenerateInstanceCentroid() self.generate_instance_type = GenerateInstanceType() - # type post-processing + # type prediction post-processing self.activation = Activations(softmax=True) self.as_discrete = AsDiscrete(argmax=True) diff --git a/monai/apps/pathology/transforms/post/dictionary.py b/monai/apps/pathology/transforms/post/dictionary.py index 1d59dd38c9..63f1d8f33c 100644 --- a/monai/apps/pathology/transforms/post/dictionary.py +++ b/monai/apps/pathology/transforms/post/dictionary.py @@ -502,7 +502,7 @@ class HoVerNetPostProcessingd(Transform): If not provided, the level is set to (max(image) + min(image)) / 2. distance_smooth_fn: smoothing function for distance map. If not provided, :py:class:`monai.transforms.intensity.GaussianSmooth()` will be used.. - marker_post_process_fn: post-process function for watershed markers. + marker_postprocess_fn: post-process function for watershed markers. If not provided, :py:class:`monai.transforms.post.FillHoles()` will be used. allow_missing_keys: don't raise exception if key is missing. @@ -516,14 +516,14 @@ def __init__( min_num_points: int = 3, level: Optional[float] = None, distance_smooth_fn: Optional[Callable] = None, - marker_post_process_fn: Optional[Callable] = None, + marker_postprocess_fn: Optional[Callable] = None, ) -> None: super().__init__() self.post_process = HoVerNetPostProcessing( min_num_points=min_num_points, level=level, distance_smooth_fn=distance_smooth_fn, - marker_post_process_fn=marker_post_process_fn, + marker_postprocess_fn=marker_postprocess_fn, ) self.nuclear_prediction_key = nuclear_prediction_key self.hover_map_key = hover_map_key diff --git a/tests/test_hovernet_post_processing.py b/tests/test_hovernet_post_processing.py index c362465b96..2bfe1e42c0 100644 --- a/tests/test_hovernet_post_processing.py +++ b/tests/test_hovernet_post_processing.py @@ -28,7 +28,7 @@ TEST_CASE_1 = [{}, [{"1": [10, 10]}, np.zeros_like(image), np.zeros_like(image)]] TEST_CASE_2 = [{"distance_smooth_fn": GaussianSmooth()}, [{"1": [10, 10]}, np.zeros_like(image), np.zeros_like(image)]] -TEST_CASE_3 = [{"marker_post_process_fn": FillHoles()}, [{"1": [10, 10]}, np.zeros_like(image), np.zeros_like(image)]] +TEST_CASE_3 = [{"marker_postprocess_fn": FillHoles()}, [{"1": [10, 10]}, np.zeros_like(image), np.zeros_like(image)]] TEST_CASE = [] for p in TEST_NDARRAYS: diff --git a/tests/test_hovernet_post_processingd.py b/tests/test_hovernet_post_processingd.py index 055b361b38..44ebcade3f 100644 --- a/tests/test_hovernet_post_processingd.py +++ b/tests/test_hovernet_post_processingd.py @@ -30,7 +30,7 @@ TEST_CASE_1 = [{}, [{"1": [10, 10]}, np.zeros_like(image), np.zeros_like(image)]] TEST_CASE_2 = [{"distance_smooth_fn": GaussianSmooth()}, [{"1": [10, 10]}, np.zeros_like(image), np.zeros_like(image)]] -TEST_CASE_3 = [{"marker_post_process_fn": FillHoles()}, [{"1": [10, 10]}, np.zeros_like(image), np.zeros_like(image)]] +TEST_CASE_3 = [{"marker_postprocess_fn": FillHoles()}, [{"1": [10, 10]}, np.zeros_like(image), np.zeros_like(image)]] TEST_CASE = [] From be9bad7798644b4ee317fca11b6aa75a12356ab6 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Thu, 1 Dec 2022 10:25:04 -0500 Subject: [PATCH 11/29] Refactor GenerateWatershedMask and HoVerNetPostProcessing Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- docs/source/apps.rst | 4 +- monai/apps/pathology/transforms/__init__.py | 8 +- .../pathology/transforms/post/__init__.py | 8 +- monai/apps/pathology/transforms/post/array.py | 203 ++++++++++-------- .../pathology/transforms/post/dictionary.py | 48 ++--- tests/test_generate_watershed_mask.py | 28 +-- tests/test_generate_watershed_maskd.py | 40 +--- tests/test_hovernet_post_processing.py | 28 ++- tests/test_hovernet_post_processingd.py | 6 +- 9 files changed, 184 insertions(+), 189 deletions(-) diff --git a/docs/source/apps.rst b/docs/source/apps.rst index 29c43c31d3..3fc5f05a47 100644 --- a/docs/source/apps.rst +++ b/docs/source/apps.rst @@ -153,7 +153,7 @@ Applications :members: .. autoclass:: GenerateWatershedMarkers :members: -.. autoclass:: HoVerNetPostProcessing +.. autoclass:: HoVerNetTypeMapPostProcessing :members: .. automodule:: monai.apps.pathology.transforms.post.dictionary @@ -175,7 +175,7 @@ Applications :members: .. autoclass:: GenerateWatershedMarkersd :members: -.. autoclass:: HoVerNetPostProcessingd +.. autoclass:: HoVerNetTypeMapPostProcessingd :members: `Detection` diff --git a/monai/apps/pathology/transforms/__init__.py b/monai/apps/pathology/transforms/__init__.py index 377a22da64..8a49eaee05 100644 --- a/monai/apps/pathology/transforms/__init__.py +++ b/monai/apps/pathology/transforms/__init__.py @@ -18,7 +18,7 @@ GenerateSuccinctContour, GenerateWatershedMarkers, GenerateWatershedMask, - HoVerNetPostProcessing, + HoVerNetTypeMapPostProcessing, Watershed, ) from .post.dictionary import ( @@ -46,9 +46,9 @@ GenerateWatershedMaskD, GenerateWatershedMaskd, GenerateWatershedMaskDict, - HoVerNetPostProcessingD, - HoVerNetPostProcessingd, - HoVerNetPostProcessingDict, + HoVerNetTypeMapPostProcessingD, + HoVerNetTypeMapPostProcessingd, + HoVerNetTypeMapPostProcessingDict, WatershedD, Watershedd, WatershedDict, diff --git a/monai/apps/pathology/transforms/post/__init__.py b/monai/apps/pathology/transforms/post/__init__.py index 95fa2a1b97..f7569c0c64 100644 --- a/monai/apps/pathology/transforms/post/__init__.py +++ b/monai/apps/pathology/transforms/post/__init__.py @@ -18,7 +18,7 @@ GenerateSuccinctContour, GenerateWatershedMarkers, GenerateWatershedMask, - HoVerNetPostProcessing, + HoVerNetTypeMapPostProcessing, Watershed, ) from .dictionary import ( @@ -46,9 +46,9 @@ GenerateWatershedMaskD, GenerateWatershedMaskd, GenerateWatershedMaskDict, - HoVerNetPostProcessingD, - HoVerNetPostProcessingd, - HoVerNetPostProcessingDict, + HoVerNetTypeMapPostProcessingD, + HoVerNetTypeMapPostProcessingd, + HoVerNetTypeMapPostProcessingDict, WatershedD, Watershedd, WatershedDict, diff --git a/monai/apps/pathology/transforms/post/array.py b/monai/apps/pathology/transforms/post/array.py index 452ba35f57..d544cca659 100644 --- a/monai/apps/pathology/transforms/post/array.py +++ b/monai/apps/pathology/transforms/post/array.py @@ -47,7 +47,7 @@ "GenerateInstanceContour", "GenerateInstanceCentroid", "GenerateInstanceType", - "HoVerNetPostProcessing", + "HoVerNetTypeMapPostProcessing", ] @@ -97,11 +97,10 @@ class GenerateWatershedMask(Transform): generate mask used in `watershed`. Only points at which mask == True will be labeled. Args: - softmax: if True, apply a softmax function to the prediction. - sigmoid: if True, apply a sigmoid function to the prediction. + activation: the activation layer to be applied on nuclear type branch. It can be "softmax" or "sigmoid" string, + or any callable. Defaults to "softmax". threshold: if not None, threshold the float values to int number 0 or 1 with specified threshold. - remove_small_objects: whether need to remove some objects in the marker. Defaults to True. - min_size: objects smaller than this size are removed if `remove_small_objects` is True. Defaults to 10. + min_object_size: objects smaller than this size are removed. Defaults to 10. dtype: target data content type to convert, default is np.uint8. """ @@ -110,23 +109,37 @@ class GenerateWatershedMask(Transform): def __init__( self, - softmax: bool = True, - sigmoid: bool = False, + activation: Union[str, Callable] = "softmax", threshold: Optional[float] = None, - remove_small_objects: bool = True, - min_size: int = 10, + min_object_size: int = 10, dtype: DtypeLike = np.uint8, ) -> None: - if sigmoid and threshold is None: - raise ValueError("Threshold is needed when using sigmoid activation.") - self.dtype = dtype - self.activations = Activations(sigmoid=sigmoid, softmax=softmax) - self.asdiscrete = AsDiscrete(threshold=threshold, argmax=softmax) - if remove_small_objects: - self.remove_small_objects = RemoveSmallObjects(min_size=min_size) + + # set activation layer + use_softmax = False + use_sigmoid = False + activation_fn = None + if isinstance(activation, str): + if activation.lower() == "softmax": + use_softmax = True + elif activation.lower() == "sigmoid": + use_sigmoid = True + else: + raise ValueError(f"The activation should be either 'softmax' or 'sigmoid'. '{activation}' was given.") + elif callable(activation): + activation_fn = activation else: - self.remove_small_objects = None # type: ignore + raise ValueError(f"The activation type should be either str or callable. '{type(activation)}' was given.") + self.activation = Activations(softmax=use_softmax, sigmoid=use_sigmoid, other=activation_fn) + + # set discretization transform + if threshold is None: + threshold = 0.5 + self.as_discrete = AsDiscrete(threshold=threshold, argmax=use_softmax) + + # set small object removal transform + self.remove_small_objects = RemoveSmallObjects(min_size=min_object_size) if min_object_size > 0 else lambda x: x def __call__(self, prob_map: NdarrayOrTensor) -> NdarrayOrTensor: """ @@ -134,14 +147,13 @@ def __call__(self, prob_map: NdarrayOrTensor) -> NdarrayOrTensor: prob_map: probability map of segmentation, shape must be [C, H, W, [D]] """ - pred = self.activations(prob_map) - pred = self.asdiscrete(pred) + pred = self.activation(prob_map) + pred = self.as_discrete(pred) pred = convert_to_numpy(pred) pred = label(pred)[0] - if self.remove_small_objects: - pred = self.remove_small_objects(pred) + pred = self.remove_small_objects(pred) pred[pred > 0] = 1 # type: ignore return convert_to_dst_type(pred, prob_map, dtype=self.dtype)[0] @@ -633,16 +645,13 @@ def __call__( # type: ignore return (int(inst_type), float(type_prob)) -class HoVerNetPostProcessing(Transform): +class HoVerNetInstanceMapPostProcessing(Transform): """ - The post-processing transform for HoVerNet model, which includes all the three branches. - It generate a dictionary containing centroid, bounding box, type prediction for each instance. Also if requested, - it returns binary maps for instance segmentation and predicted types. + The post-processing transform for HoVerNet model to generate instance segmentation map. + It generates an instance segmentation map as well as a dictionary containing centroid, bounding box, and contours + for each instance. Args: - min_num_points: minimum number of points to be considered as a contour. Defaults to 3. - level: optional value for `skimage.measure.find_contours`, to find contours in the array. - If not provided, the level is set to (max(image) + min(image)) / 2. distance_smooth_fn: smoothing function for distance map. If not provided, :py:class:`monai.transforms.intensity.GaussianSmooth()` will be used.. marker_postprocess_fn: post-process function for watershed markers. @@ -652,8 +661,10 @@ class HoVerNetPostProcessing(Transform): def __init__( self, - min_num_points: int = 3, - level: Optional[float] = None, + activation: Union[str, Callable] = "softmax", + threshold: Optional[float] = None, + min_object_size: int = 10, + mask_dtype: DtypeLike = np.uint8, distance_smooth_fn: Optional[Callable] = None, marker_postprocess_fn: Optional[Callable] = None, ) -> None: @@ -669,8 +680,9 @@ def __init__( else: self.marker_postprocess_fn = FillHoles() - # instance segmentation transforms - self.generate_watershed_mask = GenerateWatershedMask(softmax=True) + self.generate_watershed_mask = GenerateWatershedMask( + activation=activation, threshold=threshold, min_object_size=min_object_size, dtype=mask_dtype + ) self.generate_instance_border = GenerateInstanceBorder(kernel_size=3) self.generate_distance_map = GenerateDistanceMap(smooth_fn=self.distance_smooth_fn) self.generate_watershed_markers = GenerateWatershedMarkers( @@ -678,16 +690,9 @@ def __init__( ) self.watershed = Watershed() - # per instance transforms - self.generate_instance_contour = GenerateInstanceContour(min_num_points=min_num_points, level=level) - self.generate_instance_centroid = GenerateInstanceCentroid() - self.generate_instance_type = GenerateInstanceType() - - # type prediction post-processing - self.activation = Activations(softmax=True) - self.as_discrete = AsDiscrete(argmax=True) - - def process_instance_segmentation(self, nuclear_prediction, hover_map) -> Tuple[Dict, NdarrayOrTensor]: + def __call__( # type: ignore + self, nuclear_prediction: NdarrayOrTensor, hover_map: NdarrayOrTensor + ) -> Tuple[Dict, NdarrayOrTensor]: """post-process instance segmentation branches (NP and HV) to generate instance segmentation map. Args: @@ -704,7 +709,7 @@ def process_instance_segmentation(self, nuclear_prediction, hover_map) -> Tuple[ # Create bounding boxes, contours and centroids instance_ids = set(np.unique(instance_seg_map)) - {0} # exclude background - instance_info_dict = {} + instance_info = {} for inst_id in instance_ids: instance_mask = instance_seg_map == inst_id instance_bbox = BoundingRect()(instance_mask) @@ -716,68 +721,96 @@ def process_instance_segmentation(self, nuclear_prediction, hover_map) -> Tuple[ instance_contour = self.generate_instance_contour(instance_mask, offset) if instance_contour is not None: instance_centroid = self.generate_instance_centroid(instance_mask, offset) - instance_info_dict[inst_id] = { + instance_info[inst_id] = { "bounding_box": instance_bbox, "centroid": instance_centroid, "contour": instance_contour, } - return instance_info_dict, instance_seg_map + return instance_info, instance_seg_map + + +class HoVerNetTypeMapPostProcessing(Transform): + """ + The post-processing transform for HoVerNet model to generate a segmentation map with types. + It generate a segmentation map with associated type to each pixel. It also updates the input instance info + dictionary with information about type of the cells (type value and probability). + + Args: + min_num_points: minimum number of points to be considered as a contour. Defaults to 3. + level: optional value for `skimage.measure.find_contours`, to find contours in the array. + If not provided, the level is set to (max(image) + min(image)) / 2. + activation: the activation layer to be applied on nuclear type branch. It can be "softmax" or "sigmoid" string, + or any callable. Defaults to "softmax". + return_type_map: whether to calculate and return segmentation map with associated type to each pixel. + + """ + + def __init__( + self, + min_num_points: int = 3, + level: Optional[float] = None, + activation: Union[str, Callable] = "softmax", + threshold: Optional[float] = None, + return_type_map: bool = True, + ) -> None: + super().__init__() + + self.return_type_map = return_type_map + + # per instance transforms + self.generate_instance_contour = GenerateInstanceContour(min_num_points=min_num_points, level=level) + self.generate_instance_centroid = GenerateInstanceCentroid() + self.generate_instance_type = GenerateInstanceType() + + # type prediction post-processing + use_softmax = False + use_sigmoid = False + activation_fn = None + if isinstance(activation, str): + if activation.lower() == "softmax": + use_softmax = True + elif activation.lower() == "sigmoid": + use_sigmoid = True + else: + raise ValueError(f"The activation should be either 'softmax' or 'sigmoid'. '{activation}' was given.") + elif callable(activation): + activation_fn = activation + else: + raise ValueError(f"The activation type should be either str or callable. '{type(activation)}' was given.") + self.activation = Activations(softmax=use_softmax, sigmoid=use_sigmoid, other=activation_fn) + self.asdiscrete = AsDiscrete(threshold=threshold, argmax=use_softmax) - def process_nuclear_type( - self, instance_info_dict, instance_seg_map, type_prediction + def __call__( # type: ignore + self, instance_info, instance_seg_map, type_prediction ) -> Tuple[Dict, NdarrayOrTensor]: """Process NC (type prediction) branch and combine it with instance segmentation - It updates the instance_info_dict with instance type and associated probability, and generate instance type map. + It updates the instance_info with instance type and associated probability, and generate instance type map. Args: - instance_info_dict: instance information dictionary, which is output of `self.instance_segmentation` - instance_seg_map: instance segmentation map, which is output of `self.instance_segmentation` + instance_info: instance information dictionary, the output of :py:class:`HoVerNetInstanceMapPostProcessing` + instance_seg_map: instance segmentation map, the output of :py:class:`HoVerNetInstanceMapPostProcessing` type_prediction: the output of NC (type prediction) branch of HoVerNet model """ + type_map = None + if self.return_type_map: + type_map = convert_to_dst_type(torch.zeros(instance_seg_map.shape), instance_seg_map)[0] - instance_type_map = convert_to_dst_type(torch.zeros(instance_seg_map.shape), instance_seg_map)[0] - - for inst_id in instance_info_dict: + for inst_id in instance_info: type_prediction = self.activation(type_prediction) type_prediction = self.as_discrete(type_prediction) - instance_type, instance_type_probability = self.generate_instance_type( - bbox=instance_info_dict[inst_id]["bounding_box"], + instance_type, instance_type_prob = self.generate_instance_type( + bbox=instance_info[inst_id]["bounding_box"], type_pred=type_prediction, seg_pred=instance_seg_map, instance_id=inst_id, ) # update instance info dict with type data - instance_info_dict[inst_id]["type_probability"] = instance_type_probability - instance_info_dict[inst_id]["type"] = instance_type + instance_info[inst_id]["type_prob"] = instance_type_prob + instance_info[inst_id]["type"] = instance_type # update instance type map - instance_type_map[instance_seg_map == inst_id] = instance_type - - return instance_info_dict, instance_type_map - - def __call__( # type: ignore - self, - nuclear_prediction: NdarrayOrTensor, - hover_map: NdarrayOrTensor, - type_prediction: Optional[NdarrayOrTensor] = None, - ) -> Tuple[Dict, NdarrayOrTensor, Optional[NdarrayOrTensor]]: - """ - Args: - nuclear_prediction: the output of NC (nuclear prediction) branch of HoVerNet model. - hover_map: the output of HV (hover map) branch of HoVerNet model. - type_prediction: the output of NC (type prediction) branch of HoVerNet model (optional). - If not provided, the type info and the type segmentation map is not generated. - - """ - # Process NP and HV branches to create final instance map - instance_info_dict, instance_seg_map = self.process_instance_segmentation(nuclear_prediction, hover_map) - - type_seg_map = None - # Process NC branch to create segmentation map with types - if type_prediction is not None: - instance_info_dict, type_seg_map = self.process_nuclear_type( - instance_info_dict, instance_seg_map, type_prediction - ) + if self.return_type_map: + type_map[instance_seg_map == inst_id] = instance_type - return instance_info_dict, instance_seg_map, type_seg_map + return instance_info, type_map diff --git a/monai/apps/pathology/transforms/post/dictionary.py b/monai/apps/pathology/transforms/post/dictionary.py index 63f1d8f33c..5fa3cd72cd 100644 --- a/monai/apps/pathology/transforms/post/dictionary.py +++ b/monai/apps/pathology/transforms/post/dictionary.py @@ -9,7 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Callable, Dict, Hashable, Mapping, Optional +from typing import Callable, Dict, Hashable, Mapping, Optional, Union import numpy as np @@ -22,7 +22,7 @@ GenerateSuccinctContour, GenerateWatershedMarkers, GenerateWatershedMask, - HoVerNetPostProcessing, + HoVerNetTypeMapPostProcessing, Watershed, ) from monai.config.type_definitions import DtypeLike, KeysCollection, NdarrayOrTensor @@ -61,9 +61,9 @@ "GenerateInstanceTypeDict", "GenerateInstanceTypeD", "GenerateInstanceTyped", - "HoVerNetPostProcessingDict", - "HoVerNetPostProcessingD", - "HoVerNetPostProcessingd", + "HoVerNetTypeMapPostProcessingDict", + "HoVerNetTypeMapPostProcessingD", + "HoVerNetTypeMapPostProcessingd", ] @@ -125,12 +125,11 @@ class GenerateWatershedMaskd(MapTransform): Args: keys: keys of the corresponding items to be transformed. mask_key: the mask will be written to the value of `{mask_key}`. - softmax: if True, apply a softmax function to the prediction. - sigmoid: if True, apply a sigmoid function to the prediction. - threshold: if not None, threshold the float values to int number 0 or 1 with specified theashold. - remove_small_objects: whether need to remove some objects in the marker. Defaults to True. - min_size: objects smaller than this size are removed if `remove_small_objects` is True. Defaults to 10. - dtype: target data content type to convert. Defaults to np.uint8. + activation: the activation layer to be applied on nuclear type branch. It can be "softmax" or "sigmoid" string, + or any callable. Defaults to "softmax". + threshold: if not None, threshold the float values to int number 0 or 1 with specified threshold. + min_object_size: objects smaller than this size are removed. Defaults to 10. + dtype: target data content type to convert, default is np.uint8. allow_missing_keys: don't raise exception if key is missing. """ @@ -141,22 +140,18 @@ def __init__( self, keys: KeysCollection, mask_key: str = "mask", - softmax: bool = True, - sigmoid: bool = False, + activation: Union[str, Callable] = "softmax", threshold: Optional[float] = None, - remove_small_objects: bool = True, - min_size: int = 10, + min_object_size: int = 10, dtype: DtypeLike = np.uint8, allow_missing_keys: bool = False, ) -> None: super().__init__(keys, allow_missing_keys) self.mask_key = mask_key self.transform = GenerateWatershedMask( - softmax=softmax, - sigmoid=sigmoid, + activation=activation, threshold=threshold, - remove_small_objects=remove_small_objects, - min_size=min_size, + min_object_size=min_object_size, dtype=dtype, ) @@ -164,10 +159,9 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N d = dict(data) for key in self.key_iterator(d): mask = self.transform(d[key]) - key_to_add = f"{self.mask_key}" - if key_to_add in d: - raise KeyError(f"Mask with key {key_to_add} already exists.") - d[key_to_add] = mask + if self.mask_key in d: + raise KeyError(f"Mask with key {self.mask_key} already exists.") + d[self.mask_key] = mask return d @@ -485,9 +479,9 @@ def __call__(self, data): return d -class HoVerNetPostProcessingd(Transform): +class HoVerNetTypeMapPostProcessingd(Transform): """ - Dictionary-based wrapper for :py:class:`monai.apps.pathology.transforms.post.array.HoVerNetPostProcessing`. + Dictionary-based wrapper for :py:class:`monai.apps.pathology.transforms.post.array.HoVerNetTypeMapPostProcessing`. It generate a dictionary containing centroid, bounding box, type prediction for each instance. Also if requested, it returns binary maps for instance segmentation and predicted types. @@ -519,7 +513,7 @@ def __init__( marker_postprocess_fn: Optional[Callable] = None, ) -> None: super().__init__() - self.post_process = HoVerNetPostProcessing( + self.post_process = HoVerNetTypeMapPostProcessing( min_num_points=min_num_points, level=level, distance_smooth_fn=distance_smooth_fn, @@ -556,4 +550,4 @@ def __call__(self, data): GenerateInstanceContourDict = GenerateInstanceContourD = GenerateInstanceContourd GenerateInstanceCentroidDict = GenerateInstanceCentroidD = GenerateInstanceCentroidd GenerateInstanceTypeDict = GenerateInstanceTypeD = GenerateInstanceTyped -HoVerNetPostProcessingDict = HoVerNetPostProcessingD = HoVerNetPostProcessingd +HoVerNetTypeMapPostProcessingDict = HoVerNetTypeMapPostProcessingD = HoVerNetTypeMapPostProcessingd diff --git a/tests/test_generate_watershed_mask.py b/tests/test_generate_watershed_mask.py index 1e2d84c7d2..3a1b879d95 100644 --- a/tests/test_generate_watershed_mask.py +++ b/tests/test_generate_watershed_mask.py @@ -26,20 +26,14 @@ np.random.RandomState(123) -for p in TEST_NDARRAYS: - EXCEPTION_TESTS.append( - [ - {"softmax": False, "sigmoid": True, "remove_small_objects": True, "min_size": 10}, - p(np.random.rand(1, 5, 5)), - ValueError, - ] - ) +for array_type in TEST_NDARRAYS: + EXCEPTION_TESTS.append([{"activation": "incorrect"}, ValueError]) + EXCEPTION_TESTS.append([{"activation": 1}, ValueError]) -for p in TEST_NDARRAYS: TESTS.append( [ - {"softmax": True, "sigmoid": False, "threshold": None, "remove_small_objects": False, "min_size": 10}, - p( + {"activation": "softmax", "min_object_size": 0}, + array_type( [ [[0.5022, 0.3403, 0.9997], [0.8793, 0.5514, 0.2697], [0.6134, 0.6389, 0.0680]], [[0.5000, 0.3400, 0.9900], [0.8900, 0.5600, 0.2700], [0.6100, 0.6300, 0.0600]], @@ -52,8 +46,8 @@ TESTS.append( [ - {"softmax": False, "sigmoid": True, "threshold": 0.5, "remove_small_objects": False, "min_size": 10}, - p([[[0.5022, 0.3403, 0.9997], [0.8793, 0.5514, 0.2697], [-0.1134, -0.0389, -0.0680]]]), + {"activation": "sigmoid", "threshold": 0.5, "min_object_size": 0}, + array_type([[[0.5022, 0.3403, 0.9997], [0.8793, 0.5514, 0.2697], [-0.1134, -0.0389, -0.0680]]]), (1, 3, 3), [0, 1], ] @@ -63,13 +57,13 @@ @unittest.skipUnless(has_scipy, "Requires scipy library.") class TestGenerateWatershedMask(unittest.TestCase): @parameterized.expand(EXCEPTION_TESTS) - def test_value(self, argments, image, exception_type): + def test_value(self, arguments, exception_type): with self.assertRaises(exception_type): - GenerateWatershedMask(**argments)(image) + GenerateWatershedMask(**arguments) @parameterized.expand(TESTS) - def test_value2(self, argments, image, expected_shape, expected_value): - result = GenerateWatershedMask(**argments)(image) + def test_value2(self, arguments, image, expected_shape, expected_value): + result = GenerateWatershedMask(**arguments)(image) self.assertEqual(result.shape, expected_shape) if isinstance(result, torch.Tensor): diff --git a/tests/test_generate_watershed_maskd.py b/tests/test_generate_watershed_maskd.py index 4e3a2ee15c..b7596802fa 100644 --- a/tests/test_generate_watershed_maskd.py +++ b/tests/test_generate_watershed_maskd.py @@ -26,28 +26,14 @@ np.random.RandomState(123) -for p in TEST_NDARRAYS: - EXCEPTION_TESTS.append( - [ - {"keys": "img", "softmax": False, "sigmoid": True, "remove_small_objects": True, "min_size": 10}, - p(np.random.rand(1, 5, 5)), - ValueError, - ] - ) +for array_type in TEST_NDARRAYS: + EXCEPTION_TESTS.append([{"keys": "img", "activation": "incorrect"}, ValueError]) + EXCEPTION_TESTS.append([{"keys": "img", "activation": 1}, ValueError]) -for p in TEST_NDARRAYS: TESTS.append( [ - { - "keys": "img", - "mask_key": "mask", - "softmax": True, - "sigmoid": False, - "threshold": None, - "remove_small_objects": False, - "min_size": 10, - }, - p( + {"keys": "img", "mask_key": "mask", "activation": "softmax", "min_object_size": 0}, + array_type( [ [[0.5022, 0.3403, 0.9997], [0.8793, 0.5514, 0.2697], [0.6134, 0.6389, 0.0680]], [[0.5000, 0.3400, 0.9900], [0.8900, 0.5600, 0.2700], [0.6100, 0.6300, 0.0600]], @@ -60,16 +46,8 @@ TESTS.append( [ - { - "keys": "img", - "mask_key": "mask", - "softmax": False, - "sigmoid": True, - "threshold": 0.5, - "remove_small_objects": False, - "min_size": 10, - }, - p([[[0.5022, 0.3403, 0.9997], [0.8793, 0.5514, 0.2697], [-0.1134, -0.0389, -0.0680]]]), + {"keys": "img", "mask_key": "mask", "activation": "sigmoid", "threshold": 0.5, "min_object_size": 0}, + array_type([[[0.5022, 0.3403, 0.9997], [0.8793, 0.5514, 0.2697], [-0.1134, -0.0389, -0.0680]]]), (1, 3, 3), [0, 1], ] @@ -79,9 +57,9 @@ @unittest.skipUnless(has_scipy, "Requires scipy library.") class TestGenerateWatershedMaskd(unittest.TestCase): @parameterized.expand(EXCEPTION_TESTS) - def test_value(self, argments, image, exception_type): + def test_value(self, argments, exception_type): with self.assertRaises(exception_type): - GenerateWatershedMaskd(**argments)({"img": image}) + GenerateWatershedMaskd(**argments) @parameterized.expand(TESTS) def test_value2(self, argments, image, expected_shape, expected_value): diff --git a/tests/test_hovernet_post_processing.py b/tests/test_hovernet_post_processing.py index 2bfe1e42c0..9c4ffe6cbc 100644 --- a/tests/test_hovernet_post_processing.py +++ b/tests/test_hovernet_post_processing.py @@ -14,7 +14,7 @@ import numpy as np from parameterized import parameterized -from monai.apps.pathology.transforms.post.array import HoVerNetPostProcessing +from monai.apps.pathology.transforms.post.array import HoVerNetInstanceMapPostProcessing, HoVerNetTypeMapPostProcessing from monai.transforms import ComputeHoVerMaps, FillHoles, GaussianSmooth from monai.utils import min_version, optional_import from tests.utils import TEST_NDARRAYS, assert_allclose @@ -26,39 +26,35 @@ image = (x - 10) ** 2 + (y - 10) ** 2 <= 5**2 image = image[None, ...].astype("uint8") -TEST_CASE_1 = [{}, [{"1": [10, 10]}, np.zeros_like(image), np.zeros_like(image)]] -TEST_CASE_2 = [{"distance_smooth_fn": GaussianSmooth()}, [{"1": [10, 10]}, np.zeros_like(image), np.zeros_like(image)]] -TEST_CASE_3 = [{"marker_postprocess_fn": FillHoles()}, [{"1": [10, 10]}, np.zeros_like(image), np.zeros_like(image)]] +TEST_CASE_1 = [{}, {"1": {"type": 1, "type_prob": 1.0}}, np.zeros_like(image)] TEST_CASE = [] for p in TEST_NDARRAYS: TEST_CASE.append([p, image] + TEST_CASE_1) - TEST_CASE.append([p, image] + TEST_CASE_2) - TEST_CASE.append([p, image] + TEST_CASE_3) @unittest.skipUnless(has_scipy, "Requires scipy library.") @unittest.skipUnless(has_skimage, "Requires scikit-image library.") -class TestHoVerNetPostProcessing(unittest.TestCase): +class TestHoVerNetTypeMapPostProcessing(unittest.TestCase): @parameterized.expand(TEST_CASE) - def test_value(self, in_type, test_data, kwargs, expected): + def test_value(self, in_type, test_data, kwargs, expected_info, expected_map): nuclear_prediction = in_type(test_data.astype(float)) hover_map = in_type(ComputeHoVerMaps()(test_data.astype(int))) nuclear_type = in_type(test_data) - pred_dict, inst_map, type_map = HoVerNetPostProcessing(**kwargs)(nuclear_prediction, hover_map, nuclear_type) - # instance prediction info - for key in pred_dict: - assert_allclose(pred_dict[key]["centroid"], expected[0][key], type_test=False) + inst_info, inst_map = HoVerNetInstanceMapPostProcessing()(nuclear_prediction, hover_map) + inst_info, type_map = HoVerNetTypeMapPostProcessing(**kwargs)(inst_info, inst_map, nuclear_type) - # instance map - assert_allclose(inst_map, expected[1], type_test=False) + # instance prediction info + for key in inst_info: + self.assertEqual(inst_info[key]["type"], expected_info[key]["type"]) + self.assertEqual(inst_info[key]["type_prob"], expected_info[key]["type_prob"]) # type map - if expected[2] is None: + if expected_map is None: self.assertIsNone(type_map) else: - assert_allclose(type_map, expected[2], type_test=False) + assert_allclose(type_map, expected_map, type_test=False) if __name__ == "__main__": diff --git a/tests/test_hovernet_post_processingd.py b/tests/test_hovernet_post_processingd.py index 44ebcade3f..1503660929 100644 --- a/tests/test_hovernet_post_processingd.py +++ b/tests/test_hovernet_post_processingd.py @@ -14,7 +14,7 @@ import numpy as np from parameterized import parameterized -from monai.apps.pathology.transforms.post.dictionary import HoVerNetPostProcessingd +from monai.apps.pathology.transforms.post.dictionary import HoVerNetTypeMapPostProcessingd from monai.transforms import ComputeHoVerMaps, FillHoles, GaussianSmooth from monai.utils import min_version, optional_import from monai.utils.enums import HoVerNetBranch @@ -42,7 +42,7 @@ @unittest.skipUnless(has_scipy, "Requires scipy library.") @unittest.skipUnless(has_skimage, "Requires scikit-image library.") -class TestHoVerNetPostProcessingd(unittest.TestCase): +class TestHoVerNetTypeMapPostProcessingd(unittest.TestCase): @parameterized.expand(TEST_CASE) def test_value(self, in_type, test_data, kwargs, expected): input = { @@ -51,7 +51,7 @@ def test_value(self, in_type, test_data, kwargs, expected): HoVerNetBranch.NC.value: in_type(test_data), } - outputs = HoVerNetPostProcessingd(**kwargs)(input) + outputs = HoVerNetTypeMapPostProcessingd(**kwargs)(input) # instance prediction info for key in outputs["instance_info"]: From 41449962a404e264905df7d4d2ca5ac783bab9bd Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Thu, 1 Dec 2022 10:37:17 -0500 Subject: [PATCH 12/29] Refactor GenerateInstanceBorder Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/apps/pathology/transforms/post/array.py | 26 ++------- .../pathology/transforms/post/dictionary.py | 8 +-- tests/test_generate_instance_border.py | 56 +++---------------- tests/test_generate_instance_borderd.py | 38 ++----------- tests/test_generate_watershed_mask.py | 6 +- tests/test_generate_watershed_maskd.py | 6 +- 6 files changed, 27 insertions(+), 113 deletions(-) diff --git a/monai/apps/pathology/transforms/post/array.py b/monai/apps/pathology/transforms/post/array.py index d544cca659..3270b575aa 100644 --- a/monai/apps/pathology/transforms/post/array.py +++ b/monai/apps/pathology/transforms/post/array.py @@ -165,9 +165,7 @@ class GenerateInstanceBorder(Transform): the larger the grey scale value. The grey value of the instance's border will be larger. Args: - kernel_size: the size of the Sobel kernel. Defaults to 21. - min_size: objects smaller than this size are removed if `remove_small_objects` is True. Defaults to 10. - remove_small_objects: whether need to remove some objects in segmentation results. Defaults to True. + kernel_size: the size of the Sobel kernel. Defaults to 5. dtype: target data content type to convert, default is np.float32. @@ -179,26 +177,14 @@ class GenerateInstanceBorder(Transform): backend = [TransformBackends.NUMPY] - def __init__( - self, - kernel_size: int = 21, - min_size: int = 10, - remove_small_objects: bool = True, - dtype: DtypeLike = np.float32, - ) -> None: - + def __init__(self, kernel_size: int = 5, dtype: DtypeLike = np.float32) -> None: self.dtype = dtype - self.sobel_gradient = SobelGradients(kernel_size=kernel_size) - if remove_small_objects: - self.remove_small_objects = RemoveSmallObjects(min_size=min_size) - else: - self.remove_small_objects = None # type: ignore def __call__(self, mask: NdarrayOrTensor, hover_map: NdarrayOrTensor) -> NdarrayOrTensor: # type: ignore """ Args: - mask: binarized segmentation result. Shape must be [1, H, W]. + mask: binary segmentation map. Shape must be [1, H, W]. hover_map: horizontal and vertical distances of nuclear pixels to their centres of mass. Shape must be [2, H, W]. The first and second channel represent the horizontal and vertical maps respectively. For more details refer to papers: https://arxiv.org/abs/1812.06499. @@ -664,7 +650,7 @@ def __init__( activation: Union[str, Callable] = "softmax", threshold: Optional[float] = None, min_object_size: int = 10, - mask_dtype: DtypeLike = np.uint8, + kernel_size: int = 5, distance_smooth_fn: Optional[Callable] = None, marker_postprocess_fn: Optional[Callable] = None, ) -> None: @@ -681,9 +667,9 @@ def __init__( self.marker_postprocess_fn = FillHoles() self.generate_watershed_mask = GenerateWatershedMask( - activation=activation, threshold=threshold, min_object_size=min_object_size, dtype=mask_dtype + activation=activation, threshold=threshold, min_object_size=min_object_size ) - self.generate_instance_border = GenerateInstanceBorder(kernel_size=3) + self.generate_instance_border = GenerateInstanceBorder(kernel_size=kernel_size) self.generate_distance_map = GenerateDistanceMap(smooth_fn=self.distance_smooth_fn) self.generate_watershed_markers = GenerateWatershedMarkers( threshold=0.7, radius=2, postprocess_fn=self.marker_postprocess_fn diff --git a/monai/apps/pathology/transforms/post/dictionary.py b/monai/apps/pathology/transforms/post/dictionary.py index 5fa3cd72cd..8cb55c8d14 100644 --- a/monai/apps/pathology/transforms/post/dictionary.py +++ b/monai/apps/pathology/transforms/post/dictionary.py @@ -174,8 +174,6 @@ class GenerateInstanceBorderd(MapTransform): hover_map_key: keys of hover map used to generate probability map. border_key: the instance border map will be written to the value of `{border_key}`. kernel_size: the size of the Sobel kernel. Defaults to 21. - min_size: objects smaller than this size are removed if `remove_small_objects` is True. Defaults to 10. - remove_small_objects: whether need to remove some objects in segmentation results. Defaults to True. dtype: target data content type to convert, default is np.float32. allow_missing_keys: don't raise exception if key is missing. @@ -193,17 +191,13 @@ def __init__( hover_map_key: str = "hover_map", border_key: str = "border", kernel_size: int = 21, - min_size: int = 10, - remove_small_objects: bool = True, dtype: DtypeLike = np.float32, allow_missing_keys: bool = False, ) -> None: super().__init__(keys, allow_missing_keys) self.hover_map_key = hover_map_key self.border_key = border_key - self.transform = GenerateInstanceBorder( - kernel_size=kernel_size, remove_small_objects=remove_small_objects, min_size=min_size, dtype=dtype - ) + self.transform = GenerateInstanceBorder(kernel_size=kernel_size, dtype=dtype) def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) diff --git a/tests/test_generate_instance_border.py b/tests/test_generate_instance_border.py index 1cb7e39c31..ceff4a915e 100644 --- a/tests/test_generate_instance_border.py +++ b/tests/test_generate_instance_border.py @@ -23,61 +23,23 @@ np.random.RandomState(123) for p in TEST_NDARRAYS: - EXCEPTION_TESTS.append( - [ - {"kernel_size": 3, "remove_small_objects": False}, - p(np.random.rand(1, 5, 5, 5)), - p(np.random.rand(2, 5, 5)), - ValueError, - ] - ) + EXCEPTION_TESTS.append([{"kernel_size": 3}, p(np.random.rand(1, 5, 5, 5)), p(np.random.rand(2, 5, 5)), ValueError]) + EXCEPTION_TESTS.append([{"kernel_size": 3}, p(np.random.rand(1, 5, 5)), p(np.random.rand(1, 5, 5)), ValueError]) + EXCEPTION_TESTS.append([{"kernel_size": 3}, p(np.random.rand(2, 5, 5)), p(np.random.rand(2, 5, 5)), ValueError]) - EXCEPTION_TESTS.append( - [ - {"kernel_size": 3, "remove_small_objects": False}, - p(np.random.rand(1, 5, 5)), - p(np.random.rand(1, 5, 5)), - ValueError, - ] - ) - - EXCEPTION_TESTS.append( - [ - {"kernel_size": 3, "remove_small_objects": False}, - p(np.random.rand(2, 5, 5)), - p(np.random.rand(2, 5, 5)), - ValueError, - ] - ) - -for p in TEST_NDARRAYS: - TESTS.append( - [ - {"kernel_size": 3, "remove_small_objects": False}, - p(np.random.rand(1, 5, 5)), - p(np.random.rand(2, 5, 5)), - (1, 5, 5), - ] - ) - TESTS.append( - [ - {"kernel_size": 3, "remove_small_objects": False}, - p(np.random.rand(1, 5, 5)), - p(np.random.rand(2, 5, 5)), - (1, 5, 5), - ] - ) + TESTS.append([{"kernel_size": 3}, p(np.random.rand(1, 5, 5)), p(np.random.rand(2, 5, 5)), (1, 5, 5)]) + TESTS.append([{"kernel_size": 3}, p(np.random.rand(1, 5, 5)), p(np.random.rand(2, 5, 5)), (1, 5, 5)]) class TestGenerateInstanceBorder(unittest.TestCase): @parameterized.expand(EXCEPTION_TESTS) - def test_value(self, argments, mask, hover_map, exception_type): + def test_value(self, arguments, mask, hover_map, exception_type): with self.assertRaises(exception_type): - GenerateInstanceBorder(**argments)(mask, hover_map) + GenerateInstanceBorder(**arguments)(mask, hover_map) @parameterized.expand(TESTS) - def test_value2(self, argments, mask, hover_map, expected_shape): - result = GenerateInstanceBorder(**argments)(mask, hover_map) + def test_value2(self, arguments, mask, hover_map, expected_shape): + result = GenerateInstanceBorder(**arguments)(mask, hover_map) self.assertEqual(result.shape, expected_shape) diff --git a/tests/test_generate_instance_borderd.py b/tests/test_generate_instance_borderd.py index a4ee5221a6..9171cda312 100644 --- a/tests/test_generate_instance_borderd.py +++ b/tests/test_generate_instance_borderd.py @@ -24,48 +24,20 @@ for p in TEST_NDARRAYS: EXCEPTION_TESTS.append( - [ - {"keys": "mask", "kernel_size": 3, "remove_small_objects": True, "min_size": 10}, - p(np.random.rand(1, 5, 5, 5)), - p(np.random.rand(2, 5, 5)), - ValueError, - ] + [{"keys": "mask", "kernel_size": 3}, p(np.random.rand(1, 5, 5, 5)), p(np.random.rand(2, 5, 5)), ValueError] ) - EXCEPTION_TESTS.append( - [ - {"keys": "mask", "kernel_size": 3, "remove_small_objects": True, "min_size": 10}, - p(np.random.rand(1, 5, 5)), - p(np.random.rand(1, 5, 5)), - ValueError, - ] + [{"keys": "mask", "kernel_size": 3}, p(np.random.rand(1, 5, 5)), p(np.random.rand(1, 5, 5)), ValueError] ) - EXCEPTION_TESTS.append( - [ - {"keys": "mask", "kernel_size": 3, "remove_small_objects": True, "min_size": 10}, - p(np.random.rand(2, 5, 5)), - p(np.random.rand(2, 5, 5)), - ValueError, - ] + [{"keys": "mask", "kernel_size": 3}, p(np.random.rand(2, 5, 5)), p(np.random.rand(2, 5, 5)), ValueError] ) -for p in TEST_NDARRAYS: TESTS.append( - [ - {"keys": "mask", "kernel_size": 3, "remove_small_objects": False, "min_size": 10}, - p(np.random.rand(1, 5, 5)), - p(np.random.rand(2, 5, 5)), - (1, 5, 5), - ] + [{"keys": "mask", "kernel_size": 3}, p(np.random.rand(1, 5, 5)), p(np.random.rand(2, 5, 5)), (1, 5, 5)] ) TESTS.append( - [ - {"keys": "mask", "kernel_size": 3, "remove_small_objects": True, "min_size": 10}, - p(np.random.rand(1, 5, 5)), - p(np.random.rand(2, 5, 5)), - (1, 5, 5), - ] + [{"keys": "mask", "kernel_size": 3}, p(np.random.rand(1, 5, 5)), p(np.random.rand(2, 5, 5)), (1, 5, 5)] ) diff --git a/tests/test_generate_watershed_mask.py b/tests/test_generate_watershed_mask.py index 3a1b879d95..0fdb4fb428 100644 --- a/tests/test_generate_watershed_mask.py +++ b/tests/test_generate_watershed_mask.py @@ -26,14 +26,14 @@ np.random.RandomState(123) -for array_type in TEST_NDARRAYS: +for p in TEST_NDARRAYS: EXCEPTION_TESTS.append([{"activation": "incorrect"}, ValueError]) EXCEPTION_TESTS.append([{"activation": 1}, ValueError]) TESTS.append( [ {"activation": "softmax", "min_object_size": 0}, - array_type( + p( [ [[0.5022, 0.3403, 0.9997], [0.8793, 0.5514, 0.2697], [0.6134, 0.6389, 0.0680]], [[0.5000, 0.3400, 0.9900], [0.8900, 0.5600, 0.2700], [0.6100, 0.6300, 0.0600]], @@ -47,7 +47,7 @@ TESTS.append( [ {"activation": "sigmoid", "threshold": 0.5, "min_object_size": 0}, - array_type([[[0.5022, 0.3403, 0.9997], [0.8793, 0.5514, 0.2697], [-0.1134, -0.0389, -0.0680]]]), + p([[[0.5022, 0.3403, 0.9997], [0.8793, 0.5514, 0.2697], [-0.1134, -0.0389, -0.0680]]]), (1, 3, 3), [0, 1], ] diff --git a/tests/test_generate_watershed_maskd.py b/tests/test_generate_watershed_maskd.py index b7596802fa..32becf5a8d 100644 --- a/tests/test_generate_watershed_maskd.py +++ b/tests/test_generate_watershed_maskd.py @@ -26,14 +26,14 @@ np.random.RandomState(123) -for array_type in TEST_NDARRAYS: +for p in TEST_NDARRAYS: EXCEPTION_TESTS.append([{"keys": "img", "activation": "incorrect"}, ValueError]) EXCEPTION_TESTS.append([{"keys": "img", "activation": 1}, ValueError]) TESTS.append( [ {"keys": "img", "mask_key": "mask", "activation": "softmax", "min_object_size": 0}, - array_type( + p( [ [[0.5022, 0.3403, 0.9997], [0.8793, 0.5514, 0.2697], [0.6134, 0.6389, 0.0680]], [[0.5000, 0.3400, 0.9900], [0.8900, 0.5600, 0.2700], [0.6100, 0.6300, 0.0600]], @@ -47,7 +47,7 @@ TESTS.append( [ {"keys": "img", "mask_key": "mask", "activation": "sigmoid", "threshold": 0.5, "min_object_size": 0}, - array_type([[[0.5022, 0.3403, 0.9997], [0.8793, 0.5514, 0.2697], [-0.1134, -0.0389, -0.0680]]]), + p([[[0.5022, 0.3403, 0.9997], [0.8793, 0.5514, 0.2697], [-0.1134, -0.0389, -0.0680]]]), (1, 3, 3), [0, 1], ] From a2a17f8e28a7e040a292785203607a5eb9b5082f Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Thu, 1 Dec 2022 10:47:58 -0500 Subject: [PATCH 13/29] Update GenerateDistanceMap Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/apps/pathology/transforms/post/array.py | 35 +++++++++---------- tests/test_generate_distance_map.py | 2 -- tests/test_generate_distance_mapd.py | 2 -- 3 files changed, 16 insertions(+), 23 deletions(-) diff --git a/monai/apps/pathology/transforms/post/array.py b/monai/apps/pathology/transforms/post/array.py index 3270b575aa..2aae3000ea 100644 --- a/monai/apps/pathology/transforms/post/array.py +++ b/monai/apps/pathology/transforms/post/array.py @@ -166,7 +166,7 @@ class GenerateInstanceBorder(Transform): Args: kernel_size: the size of the Sobel kernel. Defaults to 5. - dtype: target data content type to convert, default is np.float32. + dtype: target data type to convert to. Defaults to np.float32. Raises: @@ -184,7 +184,7 @@ def __init__(self, kernel_size: int = 5, dtype: DtypeLike = np.float32) -> None: def __call__(self, mask: NdarrayOrTensor, hover_map: NdarrayOrTensor) -> NdarrayOrTensor: # type: ignore """ Args: - mask: binary segmentation map. Shape must be [1, H, W]. + mask: binary segmentation map, the output of :py:class:`GenerateWatershedMask`. Shape must be [1, H, W]. hover_map: horizontal and vertical distances of nuclear pixels to their centres of mass. Shape must be [2, H, W]. The first and second channel represent the horizontal and vertical maps respectively. For more details refer to papers: https://arxiv.org/abs/1812.06499. @@ -237,26 +237,30 @@ class GenerateDistanceMap(Transform): Generate distance map. In general, the instance map is calculated from the distance to the background. Here, we use 1 - "instance border map" to generate the distance map. - Nuclei values form mountains so inverse to get basins. + Nuclei values form mountains so inverse them to get basins. Args: - smooth_fn: execute smooth function on distance map. Defaults to None. You can specify - callable functions for smoothing. - For example, if you want apply gaussian smooth, you can specify `smooth_fn = GaussianSmooth()` - dtype: target data content type to convert, default is np.float32. + smooth_fn: smoothing function for distance map, which can be any callable object. + If not provided :py:class:`monai.transforms.GaussianSmooth()` is used. + dtype: target data type to convert to. Defaults to np.float32. """ backend = [TransformBackends.NUMPY] def __init__(self, smooth_fn: Optional[Callable] = None, dtype: DtypeLike = np.float32) -> None: - self.smooth_fn = smooth_fn + if smooth_fn is not None: + self.smooth_fn = smooth_fn + else: + self.smooth_fn = GaussianSmooth() + self.dtype = dtype def __call__(self, mask: NdarrayOrTensor, instance_border: NdarrayOrTensor) -> NdarrayOrTensor: # type: ignore """ Args: - mask: binarized segmentation result. Shape must be [1, H, W]. - instance_border: foreground probability map. Shape must be [1, H, W]. + mask: binary segmentation map, the output of :py:class:`GenerateWatershedMask`. Shape must be [1, H, W]. + instance_border: foreground probability map, the output of :py:class:`GenerateInstanceBorder`. + Shape must be [1, H, W]. """ if mask.shape[0] != 1 or mask.ndim != 3: raise ValueError(f"Input mask should be with size of [1, H, W], but got {mask.shape}") @@ -264,9 +268,7 @@ def __call__(self, mask: NdarrayOrTensor, instance_border: NdarrayOrTensor) -> N raise ValueError(f"Input instance_border should be with size of [1, H, W], but got {instance_border.shape}") distance_map = (1.0 - instance_border) * mask - - if callable(self.smooth_fn): - distance_map = self.smooth_fn(distance_map) + distance_map = self.smooth_fn(distance_map) return convert_to_dst_type(-distance_map, mask, dtype=self.dtype)[0] @@ -656,11 +658,6 @@ def __init__( ) -> None: super().__init__() - if distance_smooth_fn is not None: - self.distance_smooth_fn = distance_smooth_fn - else: - self.distance_smooth_fn = GaussianSmooth() - if marker_postprocess_fn is not None: self.marker_postprocess_fn = marker_postprocess_fn else: @@ -670,7 +667,7 @@ def __init__( activation=activation, threshold=threshold, min_object_size=min_object_size ) self.generate_instance_border = GenerateInstanceBorder(kernel_size=kernel_size) - self.generate_distance_map = GenerateDistanceMap(smooth_fn=self.distance_smooth_fn) + self.generate_distance_map = GenerateDistanceMap(smooth_fn=distance_smooth_fn) self.generate_watershed_markers = GenerateWatershedMarkers( threshold=0.7, radius=2, postprocess_fn=self.marker_postprocess_fn ) diff --git a/tests/test_generate_distance_map.py b/tests/test_generate_distance_map.py index 0be252dbf8..b4545c40b1 100644 --- a/tests/test_generate_distance_map.py +++ b/tests/test_generate_distance_map.py @@ -25,10 +25,8 @@ for p in TEST_NDARRAYS: EXCEPTION_TESTS.append([{}, p(np.random.rand(2, 5, 5)), p(np.random.rand(1, 5, 5)), ValueError]) - EXCEPTION_TESTS.append([{}, p(np.random.rand(1, 5, 5)), p(np.random.rand(2, 5, 5)), ValueError]) -for p in TEST_NDARRAYS: TESTS.append([{}, p(np.random.rand(1, 5, 5)), p(np.random.rand(1, 5, 5)), (1, 5, 5)]) TESTS.append( [{"smooth_fn": GaussianSmooth(sigma=0.4)}, p(np.random.rand(1, 5, 5)), p(np.random.rand(1, 5, 5)), (1, 5, 5)] diff --git a/tests/test_generate_distance_mapd.py b/tests/test_generate_distance_mapd.py index fb6e59f36b..6233755290 100644 --- a/tests/test_generate_distance_mapd.py +++ b/tests/test_generate_distance_mapd.py @@ -27,12 +27,10 @@ EXCEPTION_TESTS.append( [{"keys": "mask", "border_key": "border"}, p(np.random.rand(2, 5, 5)), p(np.random.rand(1, 5, 5)), ValueError] ) - EXCEPTION_TESTS.append( [{"keys": "mask", "border_key": "border"}, p(np.random.rand(1, 5, 5)), p(np.random.rand(2, 5, 5)), ValueError] ) -for p in TEST_NDARRAYS: TESTS.append( [{"keys": "mask", "border_key": "border"}, p(np.random.rand(1, 5, 5)), p(np.random.rand(1, 5, 5)), (1, 5, 5)] ) From f1e7047918d1befac767b1ddc3ab3b615aa00dce Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Thu, 1 Dec 2022 11:09:32 -0500 Subject: [PATCH 14/29] Update dict wrappers Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/apps/pathology/transforms/post/array.py | 34 +++++-------- .../pathology/transforms/post/dictionary.py | 50 ++++++++----------- tests/test_generate_distance_mapd.py | 25 ++++++++-- tests/test_generate_instance_borderd.py | 10 ++-- 4 files changed, 58 insertions(+), 61 deletions(-) diff --git a/monai/apps/pathology/transforms/post/array.py b/monai/apps/pathology/transforms/post/array.py index 2aae3000ea..5515a33d37 100644 --- a/monai/apps/pathology/transforms/post/array.py +++ b/monai/apps/pathology/transforms/post/array.py @@ -184,18 +184,11 @@ def __init__(self, kernel_size: int = 5, dtype: DtypeLike = np.float32) -> None: def __call__(self, mask: NdarrayOrTensor, hover_map: NdarrayOrTensor) -> NdarrayOrTensor: # type: ignore """ Args: - mask: binary segmentation map, the output of :py:class:`GenerateWatershedMask`. Shape must be [1, H, W]. + mask: binary segmentation map, the output of :py:class:`GenerateWatershedMask`. + Shape must be [1, H, W]. hover_map: horizontal and vertical distances of nuclear pixels to their centres of mass. Shape must be [2, H, W]. The first and second channel represent the horizontal and vertical maps respectively. For more details refer to papers: https://arxiv.org/abs/1812.06499. - - Return: - Instance border map. - - Raises: - ValueError: when the `hover_map` has only one value. - ValueError: when the `sobel gradient map` has only one value. - """ if len(mask.shape) != 3 or len(hover_map.shape) != 3: raise ValueError( @@ -258,7 +251,8 @@ def __init__(self, smooth_fn: Optional[Callable] = None, dtype: DtypeLike = np.f def __call__(self, mask: NdarrayOrTensor, instance_border: NdarrayOrTensor) -> NdarrayOrTensor: # type: ignore """ Args: - mask: binary segmentation map, the output of :py:class:`GenerateWatershedMask`. Shape must be [1, H, W]. + mask: binary segmentation map, the output of :py:class:`GenerateWatershedMask`. + Shape must be [1, H, W]. instance_border: foreground probability map, the output of :py:class:`GenerateInstanceBorder`. Shape must be [1, H, W]. """ @@ -282,11 +276,10 @@ class GenerateWatershedMarkers(Transform): For more details refer to papers: https://arxiv.org/abs/1812.06499. Args: - threshold: threshold the float values of foreground probability map to int 0 or 1 with specified threshold. + threshold: a float value to threshold to binarize foreground probability map. It turns uncertain area to 1 and other area to 0. Defaults to 0.4. radius: the radius of the disk-shaped footprint used in `opening`. Defaults to 2. - min_size: objects smaller than this size are removed if `remove_small_objects` is True. Defaults to 10. - remove_small_objects: whether need to remove some objects in the marker. Defaults to True. + min_object_size: objects smaller than this size are removed. Defaults to 10. postprocess_fn: execute additional post transformation on marker. Defaults to None. dtype: target data content type to convert, default is np.uint8. @@ -298,8 +291,7 @@ def __init__( self, threshold: float = 0.4, radius: int = 2, - min_size: int = 10, - remove_small_objects: bool = True, + min_object_size: int = 10, postprocess_fn: Optional[Callable] = None, dtype: DtypeLike = np.uint8, ) -> None: @@ -308,14 +300,15 @@ def __init__( self.postprocess_fn = postprocess_fn self.dtype = dtype - if remove_small_objects: - self.remove_small_objects = RemoveSmallObjects(min_size=min_size) + self.remove_small_objects = RemoveSmallObjects(min_size=min_object_size) if min_object_size > 0 else lambda x: x def __call__(self, mask: NdarrayOrTensor, instance_border: NdarrayOrTensor) -> NdarrayOrTensor: # type: ignore """ Args: - mask: binarized segmentation result. Shape must be [1, H, W]. - instance_border: instance border map. Shape must be [1, H, W]. + mask: binary segmentation map, the output of :py:class:`GenerateWatershedMask`. + Shape must be [1, H, W]. + instance_border: instance border map, the output of :py:class:`GenerateInstanceBorder`. + Shape must be [1, H, W]. """ if mask.shape[0] != 1 or mask.ndim != 3: raise ValueError(f"Input mask should be with size of [1, H, W], but got {mask.shape}") @@ -333,8 +326,7 @@ def __call__(self, mask: NdarrayOrTensor, instance_border: NdarrayOrTensor) -> N marker = opening(marker.squeeze(), disk(self.radius)) marker = label(marker)[0] - if self.remove_small_objects: - marker = self.remove_small_objects(marker[None]) + marker = self.remove_small_objects(marker[None]) return convert_to_dst_type(marker, mask, dtype=self.dtype)[0] diff --git a/monai/apps/pathology/transforms/post/dictionary.py b/monai/apps/pathology/transforms/post/dictionary.py index 8cb55c8d14..d856d14a45 100644 --- a/monai/apps/pathology/transforms/post/dictionary.py +++ b/monai/apps/pathology/transforms/post/dictionary.py @@ -165,12 +165,12 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N return d -class GenerateInstanceBorderd(MapTransform): +class GenerateInstanceBorderd(Transform): """ Dictionary-based wrapper of :py:class:`monai.apps.pathology.transforms.array.GenerateInstanceBorder`. Args: - keys: keys of the corresponding items to be transformed. + mask_key: the input key where the watershed mask is stored. Defaults to `"mask"`. hover_map_key: keys of hover map used to generate probability map. border_key: the instance border map will be written to the value of `{border_key}`. kernel_size: the size of the Sobel kernel. Defaults to 21. @@ -187,68 +187,58 @@ class GenerateInstanceBorderd(MapTransform): def __init__( self, - keys: KeysCollection, + mask_key: str = "mask", hover_map_key: str = "hover_map", border_key: str = "border", kernel_size: int = 21, dtype: DtypeLike = np.float32, - allow_missing_keys: bool = False, ) -> None: - super().__init__(keys, allow_missing_keys) + self.mask_key = mask_key self.hover_map_key = hover_map_key self.border_key = border_key self.transform = GenerateInstanceBorder(kernel_size=kernel_size, dtype=dtype) def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) - for key in self.key_iterator(d): - instance_border = self.transform(d[key], d[self.hover_map_key]) - key_to_add = f"{self.border_key}" - if key_to_add in d: - raise KeyError(f"Instance border map with key {key_to_add} already exists.") - d[key_to_add] = instance_border + if self.border_key in d: + raise KeyError(f"The key '{self.border_key}' for instance border map already exists.") + d[self.border_key] = self.transform(d[self.mask_key], d[self.hover_map_key]) return d -class GenerateDistanceMapd(MapTransform): +class GenerateDistanceMapd(Transform): """ Dictionary-based wrapper of :py:class:`monai.apps.pathology.transforms.array.GenerateDistanceMap`. Args: - keys: keys of the corresponding items to be transformed. - border_key: keys of the instance border map used to generate distance map. - dist_key: the distance map will be written to the value of `{dist_key}`. - smooth_fn: execute smooth function on distance map. Defaults to None. You can specify - callable functions for smoothing. - For example, if you want apply gaussian smooth, you can specify `smooth_fn = GaussianSmooth()` + mask_key: the input key where the watershed mask is stored. Defaults to `"mask"`. + border_key: the input key where instance border map is stored. Defaults to `"border"`. + dist_map_key: the output key where distance map is written. Defaults to `"dist_map"`. + smooth_fn: smoothing function for distance map, which can be any callable object. + If not provided :py:class:`monai.transforms.GaussianSmooth()` is used. dtype: target data content type to convert, default is np.float32. - allow_missing_keys: don't raise exception if key is missing. """ backend = GenerateDistanceMap.backend def __init__( self, - keys: KeysCollection, + mask_key: str = "mask", border_key: str = "border", - dist_key: str = "dist", + dist_map_key: str = "dist_map", smooth_fn: Optional[Callable] = None, dtype: DtypeLike = np.float32, - allow_missing_keys: bool = False, ) -> None: - super().__init__(keys, allow_missing_keys) + self.mask_key = mask_key self.border_key = border_key - self.dist_key = dist_key + self.dist_map_key = dist_map_key self.transform = GenerateDistanceMap(smooth_fn=smooth_fn, dtype=dtype) def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) - for key in self.key_iterator(d): - distance_map = self.transform(d[key], d[self.border_key]) - key_to_add = f"{self.dist_key}" - if key_to_add in d: - raise KeyError(f"Distance map with key {key_to_add} already exists.") - d[key_to_add] = distance_map + if self.dist_map_key in d: + raise KeyError(f"The key '{self.dist_map_key}' for distance map already exists.") + d[self.dist_map_key] = self.transform(d[self.mask_key], d[self.border_key]) return d diff --git a/tests/test_generate_distance_mapd.py b/tests/test_generate_distance_mapd.py index 6233755290..ce31f9d59a 100644 --- a/tests/test_generate_distance_mapd.py +++ b/tests/test_generate_distance_mapd.py @@ -25,18 +25,33 @@ for p in TEST_NDARRAYS: EXCEPTION_TESTS.append( - [{"keys": "mask", "border_key": "border"}, p(np.random.rand(2, 5, 5)), p(np.random.rand(1, 5, 5)), ValueError] + [ + {"mask_key": "mask", "border_key": "border"}, + p(np.random.rand(2, 5, 5)), + p(np.random.rand(1, 5, 5)), + ValueError, + ] ) EXCEPTION_TESTS.append( - [{"keys": "mask", "border_key": "border"}, p(np.random.rand(1, 5, 5)), p(np.random.rand(2, 5, 5)), ValueError] + [ + {"mask_key": "mask", "border_key": "border"}, + p(np.random.rand(1, 5, 5)), + p(np.random.rand(2, 5, 5)), + ValueError, + ] ) TESTS.append( - [{"keys": "mask", "border_key": "border"}, p(np.random.rand(1, 5, 5)), p(np.random.rand(1, 5, 5)), (1, 5, 5)] + [ + {}, + p(np.random.rand(1, 5, 5)), + p(np.random.rand(1, 5, 5)), + (1, 5, 5), + ] ) TESTS.append( [ - {"keys": "mask", "border_key": "border", "smooth_fn": GaussianSmooth(sigma=0.4)}, + {"mask_key": "mask", "border_key": "border", "smooth_fn": GaussianSmooth(sigma=0.4)}, p(np.random.rand(1, 5, 5)), p(np.random.rand(1, 5, 5)), (1, 5, 5), @@ -53,7 +68,7 @@ def test_value(self, argments, mask, border_map, exception_type): @parameterized.expand(TESTS) def test_value2(self, argments, mask, border_map, expected_shape): result = GenerateDistanceMapd(**argments)({"mask": mask, "border": border_map}) - self.assertEqual(result["dist"].shape, expected_shape) + self.assertEqual(result["dist_map"].shape, expected_shape) if __name__ == "__main__": diff --git a/tests/test_generate_instance_borderd.py b/tests/test_generate_instance_borderd.py index 9171cda312..24cd470923 100644 --- a/tests/test_generate_instance_borderd.py +++ b/tests/test_generate_instance_borderd.py @@ -24,20 +24,20 @@ for p in TEST_NDARRAYS: EXCEPTION_TESTS.append( - [{"keys": "mask", "kernel_size": 3}, p(np.random.rand(1, 5, 5, 5)), p(np.random.rand(2, 5, 5)), ValueError] + [{"mask_key": "mask", "kernel_size": 3}, p(np.random.rand(1, 5, 5, 5)), p(np.random.rand(2, 5, 5)), ValueError] ) EXCEPTION_TESTS.append( - [{"keys": "mask", "kernel_size": 3}, p(np.random.rand(1, 5, 5)), p(np.random.rand(1, 5, 5)), ValueError] + [{"mask_key": "mask", "kernel_size": 3}, p(np.random.rand(1, 5, 5)), p(np.random.rand(1, 5, 5)), ValueError] ) EXCEPTION_TESTS.append( - [{"keys": "mask", "kernel_size": 3}, p(np.random.rand(2, 5, 5)), p(np.random.rand(2, 5, 5)), ValueError] + [{"mask_key": "mask", "kernel_size": 3}, p(np.random.rand(2, 5, 5)), p(np.random.rand(2, 5, 5)), ValueError] ) TESTS.append( - [{"keys": "mask", "kernel_size": 3}, p(np.random.rand(1, 5, 5)), p(np.random.rand(2, 5, 5)), (1, 5, 5)] + [{"mask_key": "mask", "kernel_size": 3}, p(np.random.rand(1, 5, 5)), p(np.random.rand(2, 5, 5)), (1, 5, 5)] ) TESTS.append( - [{"keys": "mask", "kernel_size": 3}, p(np.random.rand(1, 5, 5)), p(np.random.rand(2, 5, 5)), (1, 5, 5)] + [{"mask_key": "mask", "kernel_size": 3}, p(np.random.rand(1, 5, 5)), p(np.random.rand(2, 5, 5)), (1, 5, 5)] ) From e36e0a7fb0a461afcf7a89ba4feac95acbbb9b15 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Thu, 1 Dec 2022 11:42:19 -0500 Subject: [PATCH 15/29] Update GenerateWatershedMarkersd Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/apps/pathology/transforms/post/array.py | 33 +++++++++-------- .../pathology/transforms/post/dictionary.py | 37 ++++++++----------- tests/test_generate_watershed_markers.py | 10 ++--- tests/test_generate_watershed_markersd.py | 34 +++++++++++------ 4 files changed, 60 insertions(+), 54 deletions(-) diff --git a/monai/apps/pathology/transforms/post/array.py b/monai/apps/pathology/transforms/post/array.py index 5515a33d37..c98d51b88c 100644 --- a/monai/apps/pathology/transforms/post/array.py +++ b/monai/apps/pathology/transforms/post/array.py @@ -280,8 +280,9 @@ class GenerateWatershedMarkers(Transform): It turns uncertain area to 1 and other area to 0. Defaults to 0.4. radius: the radius of the disk-shaped footprint used in `opening`. Defaults to 2. min_object_size: objects smaller than this size are removed. Defaults to 10. - postprocess_fn: execute additional post transformation on marker. Defaults to None. - dtype: target data content type to convert, default is np.uint8. + postprocess_fn: additional post-process function on the markers. + If not provided, :py:class:`monai.transforms.post.FillHoles()` will be used. + dtype: target data type to convert to. Defaults to np.uint8. """ @@ -299,7 +300,10 @@ def __init__( self.radius = radius self.postprocess_fn = postprocess_fn self.dtype = dtype - + if postprocess_fn is not None: + self.postprocess_fn = postprocess_fn + else: + self.postprocess_fn = FillHoles() self.remove_small_objects = RemoveSmallObjects(min_size=min_object_size) if min_object_size > 0 else lambda x: x def __call__(self, mask: NdarrayOrTensor, instance_border: NdarrayOrTensor) -> NdarrayOrTensor: # type: ignore @@ -319,8 +323,7 @@ def __call__(self, mask: NdarrayOrTensor, instance_border: NdarrayOrTensor) -> N marker = mask - convert_to_dst_type(instance_border, mask, np.uint8)[0] # certain foreground marker[marker < 0] = 0 # type: ignore - if self.postprocess_fn: - marker = self.postprocess_fn(marker) + marker = self.postprocess_fn(marker) marker = convert_to_numpy(marker) @@ -642,26 +645,26 @@ class HoVerNetInstanceMapPostProcessing(Transform): def __init__( self, activation: Union[str, Callable] = "softmax", - threshold: Optional[float] = None, + mask_threshold: Optional[float] = None, min_object_size: int = 10, - kernel_size: int = 5, + sobel_kernel_size: int = 5, distance_smooth_fn: Optional[Callable] = None, + marker_threshold: float = 0.4, + marker_radius: int = 2, marker_postprocess_fn: Optional[Callable] = None, ) -> None: super().__init__() - if marker_postprocess_fn is not None: - self.marker_postprocess_fn = marker_postprocess_fn - else: - self.marker_postprocess_fn = FillHoles() - self.generate_watershed_mask = GenerateWatershedMask( - activation=activation, threshold=threshold, min_object_size=min_object_size + activation=activation, threshold=mask_threshold, min_object_size=min_object_size ) - self.generate_instance_border = GenerateInstanceBorder(kernel_size=kernel_size) + self.generate_instance_border = GenerateInstanceBorder(kernel_size=sobel_kernel_size) self.generate_distance_map = GenerateDistanceMap(smooth_fn=distance_smooth_fn) self.generate_watershed_markers = GenerateWatershedMarkers( - threshold=0.7, radius=2, postprocess_fn=self.marker_postprocess_fn + threshold=marker_threshold, + radius=marker_radius, + postprocess_fn=marker_postprocess_fn, + min_object_size=min_object_size, ) self.watershed = Watershed() diff --git a/monai/apps/pathology/transforms/post/dictionary.py b/monai/apps/pathology/transforms/post/dictionary.py index d856d14a45..7cf7e3abcf 100644 --- a/monai/apps/pathology/transforms/post/dictionary.py +++ b/monai/apps/pathology/transforms/post/dictionary.py @@ -171,8 +171,8 @@ class GenerateInstanceBorderd(Transform): Args: mask_key: the input key where the watershed mask is stored. Defaults to `"mask"`. - hover_map_key: keys of hover map used to generate probability map. - border_key: the instance border map will be written to the value of `{border_key}`. + hover_map_key: the input key where hover map is stored. Defaults to `"hover_map"`. + border_key: the output key where instance border map is written. Defaults to `"border"`. kernel_size: the size of the Sobel kernel. Defaults to 21. dtype: target data content type to convert, default is np.float32. allow_missing_keys: don't raise exception if key is missing. @@ -242,19 +242,18 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N return d -class GenerateWatershedMarkersd(MapTransform): +class GenerateWatershedMarkersd(Transform): """ Dictionary-based wrapper of :py:class:`monai.apps.pathology.transforms.array.GenerateWatershedMarkers`. Args: - keys: keys of the corresponding items to be transformed. - border_key: keys of the instance border map used to generate markers. - markers_key: the markers will be written to the value of `{markers_key}`. - threshold: threshold the float values of instance border map to int 0 or 1 with specified theashold. + mask_key: the input key where the watershed mask is stored. Defaults to `"mask"`. + border_key: the input key where instance border map is stored. Defaults to `"border"`. + markers_key: the output key where markers is written. Defaults to `"markers"`. + threshold: threshold the float values of instance border map to int 0 or 1 with specified threshold. It turns uncertain area to 1 and other area to 0. Defaults to 0.4. radius: the radius of the disk-shaped footprint used in `opening`. Defaults to 2. - min_size: objects smaller than this size are removed if `remove_small_objects` is True. Defaults to 10. - remove_small_objects: whether need to remove some objects in the marker. Defaults to True. + min_object_size: objects smaller than this size are removed. Defaults to 10. postprocess_fn: execute additional post transformation on marker. Defaults to None. dtype: target data content type to convert, default is np.uint8. allow_missing_keys: don't raise exception if key is missing. @@ -264,37 +263,31 @@ class GenerateWatershedMarkersd(MapTransform): def __init__( self, - keys: KeysCollection, + mask_key: str = "mask", border_key: str = "border", markers_key: str = "markers", threshold: float = 0.4, radius: int = 2, - min_size: int = 10, - remove_small_objects: bool = True, + min_object_size: int = 10, postprocess_fn: Optional[Callable] = None, dtype: DtypeLike = np.uint8, - allow_missing_keys: bool = False, ) -> None: - super().__init__(keys, allow_missing_keys) + self.mask_key = mask_key self.border_key = border_key self.markers_key = markers_key self.transform = GenerateWatershedMarkers( threshold=threshold, radius=radius, - min_size=min_size, - remove_small_objects=remove_small_objects, + min_object_size=min_object_size, postprocess_fn=postprocess_fn, dtype=dtype, ) def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) - for key in self.key_iterator(d): - markers = self.transform(d[key], d[self.border_key]) - key_to_add = f"{self.markers_key}" - if key_to_add in d: - raise KeyError(f"Markers with key {key_to_add} already exists.") - d[key_to_add] = markers + if self.markers_key in d: + raise KeyError(f"The key '{self.markers_key}' for markers already exists.") + d[self.markers_key] = self.transform(d[self.mask_key], d[self.border_key]) return d diff --git a/tests/test_generate_watershed_markers.py b/tests/test_generate_watershed_markers.py index 7b046686e9..92b48eeef1 100644 --- a/tests/test_generate_watershed_markers.py +++ b/tests/test_generate_watershed_markers.py @@ -28,10 +28,8 @@ for p in TEST_NDARRAYS: EXCEPTION_TESTS.append([{}, p(np.random.rand(2, 5, 5)), p(np.random.rand(1, 5, 5)), ValueError]) - EXCEPTION_TESTS.append([{}, p(np.random.rand(1, 5, 5)), p(np.random.rand(2, 5, 5)), ValueError]) -for p in TEST_NDARRAYS: TESTS.append([{}, p(np.random.rand(1, 5, 5)), p(np.random.rand(1, 5, 5)), (1, 5, 5)]) @@ -39,13 +37,13 @@ @unittest.skipUnless(has_scipy, "Requires scipy library.") class TestGenerateWatershedMarkers(unittest.TestCase): @parameterized.expand(EXCEPTION_TESTS) - def test_value(self, argments, mask, probmap, exception_type): + def test_value(self, arguments, mask, probmap, exception_type): with self.assertRaises(exception_type): - GenerateWatershedMarkers(**argments)(mask, probmap) + GenerateWatershedMarkers(**arguments)(mask, probmap) @parameterized.expand(TESTS) - def test_value2(self, argments, mask, probmap, expected_shape): - result = GenerateWatershedMarkers(**argments)(mask, probmap) + def test_value2(self, arguments, mask, probmap, expected_shape): + result = GenerateWatershedMarkers(**arguments)(mask, probmap) self.assertEqual(result.shape, expected_shape) diff --git a/tests/test_generate_watershed_markersd.py b/tests/test_generate_watershed_markersd.py index cccb20c985..8d9e3ec99b 100644 --- a/tests/test_generate_watershed_markersd.py +++ b/tests/test_generate_watershed_markersd.py @@ -28,25 +28,37 @@ for p in TEST_NDARRAYS: EXCEPTION_TESTS.append( - [{"keys": "mask", "border_key": "border"}, p(np.random.rand(2, 5, 5)), p(np.random.rand(1, 5, 5)), ValueError] + [ + {"mask_key": "mask", "border_key": "border"}, + p(np.random.rand(2, 5, 5)), + p(np.random.rand(1, 5, 5)), + ValueError, + ] ) - EXCEPTION_TESTS.append( - [{"keys": "mask", "border_key": "border"}, p(np.random.rand(1, 5, 5)), p(np.random.rand(2, 5, 5)), ValueError] + [ + {"mask_key": "mask", "border_key": "border"}, + p(np.random.rand(1, 5, 5)), + p(np.random.rand(2, 5, 5)), + ValueError, + ] ) - EXCEPTION_TESTS.append( [ - {"keys": "mask", "border_key": "border", "markers_key": "old_markers"}, + {"mask_key": "mask", "border_key": "border", "markers_key": "old_markers"}, p(np.random.rand(1, 5, 5)), p(np.random.rand(1, 5, 5)), KeyError, ] ) -for p in TEST_NDARRAYS: TESTS.append( - [{"keys": "mask", "border_key": "border"}, p(np.random.rand(1, 5, 5)), p(np.random.rand(1, 5, 5)), (1, 5, 5)] + [ + {}, + p(np.random.rand(1, 5, 5)), + p(np.random.rand(1, 5, 5)), + (1, 5, 5), + ] ) @@ -54,13 +66,13 @@ @unittest.skipUnless(has_scipy, "Requires scipy library.") class TestGenerateWatershedMarkersd(unittest.TestCase): @parameterized.expand(EXCEPTION_TESTS) - def test_value(self, argments, mask, border_map, exception_type): + def test_value(self, arguments, mask, border_map, exception_type): with self.assertRaises(exception_type): - GenerateWatershedMarkersd(**argments)({"mask": mask, "border": border_map, "old_markers": 1}) + GenerateWatershedMarkersd(**arguments)({"mask": mask, "border": border_map, "old_markers": 1}) @parameterized.expand(TESTS) - def test_value2(self, argments, mask, border_map, expected_shape): - result = GenerateWatershedMarkersd(**argments)({"mask": mask, "border": border_map}) + def test_value2(self, arguments, mask, border_map, expected_shape): + result = GenerateWatershedMarkersd(**arguments)({"mask": mask, "border": border_map}) self.assertEqual(result["markers"].shape, expected_shape) From 826351427b0971c7a49dcb2eaa85fc042e84171e Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Thu, 1 Dec 2022 13:45:04 -0500 Subject: [PATCH 16/29] docs and formatting Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/apps/pathology/transforms/post/array.py | 135 +++++++++--------- .../pathology/transforms/post/dictionary.py | 4 +- tests/test_generate_distance_mapd.py | 9 +- tests/test_generate_watershed_markersd.py | 3 +- 4 files changed, 76 insertions(+), 75 deletions(-) diff --git a/monai/apps/pathology/transforms/post/array.py b/monai/apps/pathology/transforms/post/array.py index c98d51b88c..af7401cb10 100644 --- a/monai/apps/pathology/transforms/post/array.py +++ b/monai/apps/pathology/transforms/post/array.py @@ -57,7 +57,7 @@ class Watershed(Transform): See: https://scikit-image.org/docs/stable/api/skimage.segmentation.html#skimage.segmentation.watershed. Args: - connectivity: An array with the same number of dimensions as image whose non-zero elements indicate + connectivity: an array with the same number of dimensions as image whose non-zero elements indicate neighbors for connection. Following the scipy convention, default is a one-connected array of the dimension of the image. dtype: target data content type to convert, default is np.uint8. @@ -97,9 +97,9 @@ class GenerateWatershedMask(Transform): generate mask used in `watershed`. Only points at which mask == True will be labeled. Args: - activation: the activation layer to be applied on nuclear type branch. It can be "softmax" or "sigmoid" string, - or any callable. Defaults to "softmax". - threshold: if not None, threshold the float values to int number 0 or 1 with specified threshold. + activation: the activation layer to be applied on the input probability map. + It can be "softmax" or "sigmoid" string, or any callable. Defaults to "softmax". + threshold: a float value to threshold to binarize probability map. min_object_size: objects smaller than this size are removed. Defaults to 10. dtype: target data content type to convert, default is np.uint8. @@ -139,7 +139,7 @@ def __init__( self.as_discrete = AsDiscrete(threshold=threshold, argmax=use_softmax) # set small object removal transform - self.remove_small_objects = RemoveSmallObjects(min_size=min_object_size) if min_object_size > 0 else lambda x: x + self.remove_small_objects = RemoveSmallObjects(min_size=min_object_size) if min_object_size > 0 else None def __call__(self, prob_map: NdarrayOrTensor) -> NdarrayOrTensor: """ @@ -153,7 +153,8 @@ def __call__(self, prob_map: NdarrayOrTensor) -> NdarrayOrTensor: pred = convert_to_numpy(pred) pred = label(pred)[0] - pred = self.remove_small_objects(pred) + if self.remove_small_objects is not None: + pred = self.remove_small_objects(pred) pred[pred > 0] = 1 # type: ignore return convert_to_dst_type(pred, prob_map, dtype=self.dtype)[0] @@ -241,11 +242,8 @@ class GenerateDistanceMap(Transform): backend = [TransformBackends.NUMPY] def __init__(self, smooth_fn: Optional[Callable] = None, dtype: DtypeLike = np.float32) -> None: - if smooth_fn is not None: - self.smooth_fn = smooth_fn - else: - self.smooth_fn = GaussianSmooth() + self.smooth_fn = smooth_fn if smooth_fn is not None else GaussianSmooth() self.dtype = dtype def __call__(self, mask: NdarrayOrTensor, instance_border: NdarrayOrTensor) -> NdarrayOrTensor: # type: ignore @@ -253,7 +251,7 @@ def __call__(self, mask: NdarrayOrTensor, instance_border: NdarrayOrTensor) -> N Args: mask: binary segmentation map, the output of :py:class:`GenerateWatershedMask`. Shape must be [1, H, W]. - instance_border: foreground probability map, the output of :py:class:`GenerateInstanceBorder`. + instance_border: instance border map, the output of :py:class:`GenerateInstanceBorder`. Shape must be [1, H, W]. """ if mask.shape[0] != 1 or mask.ndim != 3: @@ -262,7 +260,7 @@ def __call__(self, mask: NdarrayOrTensor, instance_border: NdarrayOrTensor) -> N raise ValueError(f"Input instance_border should be with size of [1, H, W], but got {instance_border.shape}") distance_map = (1.0 - instance_border) * mask - distance_map = self.smooth_fn(distance_map) + distance_map = self.smooth_fn(distance_map) # type: ignore return convert_to_dst_type(-distance_map, mask, dtype=self.dtype)[0] @@ -276,7 +274,7 @@ class GenerateWatershedMarkers(Transform): For more details refer to papers: https://arxiv.org/abs/1812.06499. Args: - threshold: a float value to threshold to binarize foreground probability map. + threshold: a float value to threshold to binarize instance border map. It turns uncertain area to 1 and other area to 0. Defaults to 0.4. radius: the radius of the disk-shaped footprint used in `opening`. Defaults to 2. min_object_size: objects smaller than this size are removed. Defaults to 10. @@ -298,13 +296,12 @@ def __init__( ) -> None: self.threshold = threshold self.radius = radius - self.postprocess_fn = postprocess_fn self.dtype = dtype - if postprocess_fn is not None: - self.postprocess_fn = postprocess_fn - else: - self.postprocess_fn = FillHoles() - self.remove_small_objects = RemoveSmallObjects(min_size=min_object_size) if min_object_size > 0 else lambda x: x + if postprocess_fn is None: + postprocess_fn = FillHoles() + + self.postprocess_fn = postprocess_fn + self.remove_small_objects = RemoveSmallObjects(min_size=min_object_size) if min_object_size > 0 else None def __call__(self, mask: NdarrayOrTensor, instance_border: NdarrayOrTensor) -> NdarrayOrTensor: # type: ignore """ @@ -321,22 +318,22 @@ def __call__(self, mask: NdarrayOrTensor, instance_border: NdarrayOrTensor) -> N instance_border = instance_border >= self.threshold # uncertain area - marker = mask - convert_to_dst_type(instance_border, mask, np.uint8)[0] # certain foreground + marker = mask - convert_to_dst_type(instance_border, mask)[0] # certain foreground marker[marker < 0] = 0 # type: ignore marker = self.postprocess_fn(marker) - marker = convert_to_numpy(marker) marker = opening(marker.squeeze(), disk(self.radius)) marker = label(marker)[0] - marker = self.remove_small_objects(marker[None]) + if self.remove_small_objects is not None: + marker = self.remove_small_objects(marker[None]) return convert_to_dst_type(marker, mask, dtype=self.dtype)[0] class GenerateSuccinctContour(Transform): """ - Converts Scipy-style contours(generated by skimage.measure.find_contours) to a more succinct version which only includes + Converts SciPy-style contours (generated by skimage.measure.find_contours) to a more succinct version which only includes the pixels to which lines need to be drawn (i.e. not the intervening pixels along each line). Args: @@ -379,7 +376,7 @@ def _generate_contour_coord(self, current: np.ndarray, previous: np.ndarray) -> return row, col - def _calculate_distance_from_topleft(self, sequence: Sequence[Tuple[int, int]]) -> int: + def _calculate_distance_from_top_left(self, sequence: Sequence[Tuple[int, int]]) -> int: """ Each sequence of coordinates describes a boundary between foreground and background starting and ending at two sides of the bounding box. To order the sequences correctly, we compute the distance from the top-left of the bounding box @@ -474,7 +471,7 @@ def __call__(self, contours: List[np.ndarray]) -> np.ndarray: corners[corner] = True prev = coord - dist = self._calculate_distance_from_topleft(sequence) + dist = self._calculate_distance_from_top_left(sequence) sequences.append({"distance": dist, "sequence": sequence}) @@ -519,38 +516,38 @@ class GenerateInstanceContour(Transform): Args: min_num_points: assumed that the created contour does not form a contour if it does not contain more points than the specified value. Defaults to 3. - level: optional. Value along which to find contours in the array. By default, the level is set - to (max(image) + min(image)) / 2. + contour_level: an optional value for `skimage.measure.find_contours` to find contours in the array. + If not provided, the level is set to `(max(image) + min(image)) / 2`. """ backend = [TransformBackends.NUMPY] - def __init__(self, min_num_points: int = 3, level: Optional[float] = None) -> None: - self.level = level + def __init__(self, min_num_points: int = 3, contour_level: Optional[float] = None) -> None: + self.contour_level = contour_level self.min_num_points = min_num_points - def __call__(self, image: NdarrayOrTensor, offset: Optional[Sequence[int]] = (0, 0)) -> np.ndarray: + def __call__(self, inst_mask: NdarrayOrTensor, offset: Optional[Sequence[int]] = (0, 0)) -> np.ndarray: """ Args: - image: instance-level segmentation result. Shape should be [C, H, W] - offset: optional, offset of starting position of the instance in the array, default is (0, 0). + inst_mask: segmentation mask for a single instance. Shape should be [1, H, W, [D]] + offset: optional offset of starting position of the instance mask in the original array. Default to 0 for each dim. """ - image = image.squeeze() # squeeze channel dim - image = convert_to_numpy(image) - inst_contour_cv = find_contours(image, level=self.level) - generate_contour = GenerateSuccinctContour(image.shape[0], image.shape[1]) + inst_mask = inst_mask.squeeze() # squeeze channel dim + inst_mask = convert_to_numpy(inst_mask) + inst_contour_cv = find_contours(inst_mask, level=self.contour_level) + generate_contour = GenerateSuccinctContour(inst_mask.shape[0], inst_mask.shape[1]) inst_contour = generate_contour(inst_contour_cv) - # < `self.min_num_points` points don't make a contour, so skip, likely artifact too - # as the contours obtained via approximation => too small or sthg + # less than `self.min_num_points` points don't make a contour, so skip. + # They are likely to be artifacts as the contours obtained via approximation. if inst_contour.shape[0] < self.min_num_points: print(f"< {self.min_num_points} points don't make a contour, so skip") - return None # type: ignore + return np.array([]) # check for tricky shape elif len(inst_contour.shape) != 2: print(f"{len(inst_contour.shape)} != 2, check for tricky shape") - return None # type: ignore + return np.array([]) else: inst_contour[:, 0] += offset[0] # type: ignore inst_contour[:, 1] += offset[1] # type: ignore @@ -571,23 +568,23 @@ class GenerateInstanceCentroid(Transform): def __init__(self, dtype: Optional[DtypeLike] = int) -> None: self.dtype = dtype - def __call__(self, image: NdarrayOrTensor, offset: Union[Sequence[int], int] = 0) -> np.ndarray: + def __call__(self, inst_mask: NdarrayOrTensor, offset: Union[Sequence[int], int] = 0) -> NdarrayOrTensor: """ Args: - image: instance-level segmentation result. Shape should be [1, H, W, [D]] - offset: optional, offset of starting position of the instance in the array, default is 0 for each dim. + inst_mask: segmentation mask for a single instance. Shape should be [1, H, W, [D]] + offset: optional offset of starting position of the instance mask in the original array. Default to 0 for each dim. """ - image = convert_to_numpy(image) - image = image.squeeze(0) # squeeze channel dim - ndim = len(image.shape) + inst_mask = convert_to_numpy(inst_mask) + inst_mask = inst_mask.squeeze(0) # squeeze channel dim + ndim = len(inst_mask.shape) offset = ensure_tuple_rep(offset, ndim) - inst_centroid = centroid(image) + inst_centroid = centroid(inst_mask) for i in range(ndim): inst_centroid[i] += offset[i] - return convert_to_dst_type(inst_centroid, image, dtype=self.dtype)[0] # type: ignore + return convert_to_dst_type(inst_centroid, inst_mask, dtype=self.dtype)[0] class GenerateInstanceType(Transform): @@ -635,11 +632,22 @@ class HoVerNetInstanceMapPostProcessing(Transform): for each instance. Args: + activation: the activation layer to be applied on the input probability map. + It can be "softmax" or "sigmoid" string, or any callable. Defaults to "softmax". + mask_threshold: a float value to threshold to binarize probability map to generate mask. + min_object_size: objects smaller than this size are removed. Defaults to 10. + sobel_kernel_size: the size of the Sobel kernel used in :py:class:`GenerateInstanceBorder`. Defaults to 5. distance_smooth_fn: smoothing function for distance map. - If not provided, :py:class:`monai.transforms.intensity.GaussianSmooth()` will be used.. + If not provided, :py:class:`monai.transforms.intensity.GaussianSmooth()` will be used. + marker_threshold: a float value to threshold to binarize instance border map for markers. + It turns uncertain area to 1 and other area to 0. Defaults to 0.4. + marker_radius: the radius of the disk-shaped footprint used in `opening` of markers. Defaults to 2. marker_postprocess_fn: post-process function for watershed markers. If not provided, :py:class:`monai.transforms.post.FillHoles()` will be used. - + watershed_connectivity: `connectivity` argument of `skimage.segmentation.watershed`. + min_num_points: minimum number of points to be considered as a contour. Defaults to 3. + contour_level: an optional value for `skimage.measure.find_contours` to find contours in the array. + If not provided, the level is set to `(max(image) + min(image)) / 2`. """ def __init__( @@ -652,6 +660,9 @@ def __init__( marker_threshold: float = 0.4, marker_radius: int = 2, marker_postprocess_fn: Optional[Callable] = None, + watershed_connectivity: Optional[int] = 1, + min_num_points: int = 3, + contour_level: Optional[float] = None, ) -> None: super().__init__() @@ -666,7 +677,11 @@ def __init__( postprocess_fn=marker_postprocess_fn, min_object_size=min_object_size, ) - self.watershed = Watershed() + self.watershed = Watershed(connectivity=watershed_connectivity) + self.generate_instance_contour = GenerateInstanceContour( + min_num_points=min_num_points, contour_level=contour_level + ) + self.generate_instance_centroid = GenerateInstanceCentroid() def __call__( # type: ignore self, nuclear_prediction: NdarrayOrTensor, hover_map: NdarrayOrTensor @@ -715,9 +730,6 @@ class HoVerNetTypeMapPostProcessing(Transform): dictionary with information about type of the cells (type value and probability). Args: - min_num_points: minimum number of points to be considered as a contour. Defaults to 3. - level: optional value for `skimage.measure.find_contours`, to find contours in the array. - If not provided, the level is set to (max(image) + min(image)) / 2. activation: the activation layer to be applied on nuclear type branch. It can be "softmax" or "sigmoid" string, or any callable. Defaults to "softmax". return_type_map: whether to calculate and return segmentation map with associated type to each pixel. @@ -726,8 +738,6 @@ class HoVerNetTypeMapPostProcessing(Transform): def __init__( self, - min_num_points: int = 3, - level: Optional[float] = None, activation: Union[str, Callable] = "softmax", threshold: Optional[float] = None, return_type_map: bool = True, @@ -735,10 +745,7 @@ def __init__( super().__init__() self.return_type_map = return_type_map - - # per instance transforms - self.generate_instance_contour = GenerateInstanceContour(min_num_points=min_num_points, level=level) - self.generate_instance_centroid = GenerateInstanceCentroid() + # instance type transforms self.generate_instance_type = GenerateInstanceType() # type prediction post-processing @@ -757,11 +764,11 @@ def __init__( else: raise ValueError(f"The activation type should be either str or callable. '{type(activation)}' was given.") self.activation = Activations(softmax=use_softmax, sigmoid=use_sigmoid, other=activation_fn) - self.asdiscrete = AsDiscrete(threshold=threshold, argmax=use_softmax) + self.as_discrete = AsDiscrete(threshold=threshold, argmax=use_softmax) def __call__( # type: ignore - self, instance_info, instance_seg_map, type_prediction - ) -> Tuple[Dict, NdarrayOrTensor]: + self, instance_info: Dict[int, Dict], instance_seg_map: NdarrayOrTensor, type_prediction: NdarrayOrTensor + ) -> Tuple[Dict, Optional[NdarrayOrTensor]]: """Process NC (type prediction) branch and combine it with instance segmentation It updates the instance_info with instance type and associated probability, and generate instance type map. @@ -788,7 +795,7 @@ def __call__( # type: ignore instance_info[inst_id]["type"] = instance_type # update instance type map - if self.return_type_map: + if type_map is not None: type_map[instance_seg_map == inst_id] = instance_type return instance_info, type_map diff --git a/monai/apps/pathology/transforms/post/dictionary.py b/monai/apps/pathology/transforms/post/dictionary.py index 7cf7e3abcf..c3f05335ca 100644 --- a/monai/apps/pathology/transforms/post/dictionary.py +++ b/monai/apps/pathology/transforms/post/dictionary.py @@ -294,7 +294,7 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N class GenerateSuccinctContourd(MapTransform): """ Dictionary-based wrapper of :py:class:`monai.apps.pathology.transforms.post.array.GenerateSuccinctContour`. - Converts Scipy-style contours(generated by skimage.measure.find_contours) to a more succinct version which + Converts SciPy-style contours (generated by skimage.measure.find_contours) to a more succinct version which only includes the pixels to which lines need to be drawn (i.e. not the intervening pixels along each line). Args: @@ -350,7 +350,7 @@ def __init__( allow_missing_keys: bool = False, ) -> None: super().__init__(keys, allow_missing_keys) - self.converter = GenerateInstanceContour(min_num_points=min_num_points, level=level) + self.converter = GenerateInstanceContour(min_num_points=min_num_points, contour_level=level) self.contour_key_postfix = contour_key_postfix self.offset_key = offset_key diff --git a/tests/test_generate_distance_mapd.py b/tests/test_generate_distance_mapd.py index ce31f9d59a..6feb8de6ac 100644 --- a/tests/test_generate_distance_mapd.py +++ b/tests/test_generate_distance_mapd.py @@ -41,14 +41,7 @@ ] ) - TESTS.append( - [ - {}, - p(np.random.rand(1, 5, 5)), - p(np.random.rand(1, 5, 5)), - (1, 5, 5), - ] - ) + TESTS.append([{}, p(np.random.rand(1, 5, 5)), p(np.random.rand(1, 5, 5)), (1, 5, 5)]) TESTS.append( [ {"mask_key": "mask", "border_key": "border", "smooth_fn": GaussianSmooth(sigma=0.4)}, diff --git a/tests/test_generate_watershed_markersd.py b/tests/test_generate_watershed_markersd.py index 8d9e3ec99b..22ce7fae0a 100644 --- a/tests/test_generate_watershed_markersd.py +++ b/tests/test_generate_watershed_markersd.py @@ -52,9 +52,10 @@ ] ) + TESTS.append([{}, p(np.random.rand(1, 5, 5)), p(np.random.rand(1, 5, 5)), (1, 5, 5)]) TESTS.append( [ - {}, + {"threshold": 0.4, "radius": 1, "min_object_size": 0}, p(np.random.rand(1, 5, 5)), p(np.random.rand(1, 5, 5)), (1, 5, 5), From 1998d7f15d62943478769d3fa2c569021c9c17d8 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Thu, 1 Dec 2022 13:53:15 -0500 Subject: [PATCH 17/29] docs and formatting Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/apps/pathology/transforms/post/array.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/monai/apps/pathology/transforms/post/array.py b/monai/apps/pathology/transforms/post/array.py index af7401cb10..afddd6b433 100644 --- a/monai/apps/pathology/transforms/post/array.py +++ b/monai/apps/pathology/transforms/post/array.py @@ -324,9 +324,9 @@ def __call__(self, mask: NdarrayOrTensor, instance_border: NdarrayOrTensor) -> N marker = convert_to_numpy(marker) marker = opening(marker.squeeze(), disk(self.radius)) - marker = label(marker)[0] + marker = label(marker)[0][None] if self.remove_small_objects is not None: - marker = self.remove_small_objects(marker[None]) + marker = self.remove_small_objects(marker) return convert_to_dst_type(marker, mask, dtype=self.dtype)[0] From eb1a6f56e257940d385897d178e0450218870ef7 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Thu, 1 Dec 2022 13:55:41 -0500 Subject: [PATCH 18/29] add unittests Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- ...t_hovernet_instance_map_post_processing.py | 59 +++++++++++++++++++ ..._hovernet_instance_map_post_processingd.py | 59 +++++++++++++++++++ ...test_hovernet_type_map_post_processing.py} | 0 ...est_hovernet_type_map_post_processingd.py} | 0 4 files changed, 118 insertions(+) create mode 100644 tests/test_hovernet_instance_map_post_processing.py create mode 100644 tests/test_hovernet_instance_map_post_processingd.py rename tests/{test_hovernet_post_processing.py => test_hovernet_type_map_post_processing.py} (100%) rename tests/{test_hovernet_post_processingd.py => test_hovernet_type_map_post_processingd.py} (100%) diff --git a/tests/test_hovernet_instance_map_post_processing.py b/tests/test_hovernet_instance_map_post_processing.py new file mode 100644 index 0000000000..45d80a35af --- /dev/null +++ b/tests/test_hovernet_instance_map_post_processing.py @@ -0,0 +1,59 @@ +# Copyright (c) MONAI Consortium +# 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. + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.apps.pathology.transforms.post.array import HoVerNetInstanceMapPostProcessing +from monai.transforms import ComputeHoVerMaps, FillHoles, GaussianSmooth +from monai.utils import min_version, optional_import +from tests.utils import TEST_NDARRAYS, assert_allclose + +_, has_scipy = optional_import("scipy", "1.8.1", min_version) +_, has_skimage = optional_import("skimage", "0.19.3", min_version) + +y, x = np.ogrid[0:30, 0:30] +image = (x - 10) ** 2 + (y - 10) ** 2 <= 5**2 +image = image[None, ...].astype("uint8") + +TEST_CASE_1 = [{}, {"1": {"centroid": 1, "bbox": 1.0}}, np.zeros_like(image)] +TEST_CASE_2 = [{"distance_smooth_fn": GaussianSmooth()}, {"1": {"type": 1, "type_prob": 1.0}}, np.zeros_like(image)] +TEST_CASE_3 = [{"marker_postprocess_fn": FillHoles()}, {"1": {"type": 1, "type_prob": 1.0}}, np.zeros_like(image)] + +TEST_CASE = [] +for p in TEST_NDARRAYS: + TEST_CASE.append([p, image] + TEST_CASE_1) + # TEST_CASE.append([p, image] + TEST_CASE_2) + # TEST_CASE.append([p, image] + TEST_CASE_3) + + +@unittest.skipUnless(has_scipy, "Requires scipy library.") +@unittest.skipUnless(has_skimage, "Requires scikit-image library.") +class TestHoVerNetTypeMapPostProcessing(unittest.TestCase): + @parameterized.expand(TEST_CASE) + def test_value(self, in_type, test_data, kwargs, expected_info, expected_map): + nuclear_prediction = in_type(test_data.astype(float)) + hover_map = in_type(ComputeHoVerMaps()(test_data.astype(int))) + + inst_info, inst_map = HoVerNetInstanceMapPostProcessing()(nuclear_prediction, hover_map) + + # instance info + for key in inst_info: + assert_allclose(inst_info[key]["centroid"], expected_info[key]["centroid"], type_test=False) + + # instance map + assert_allclose(inst_map, expected_map, type_test=False) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hovernet_instance_map_post_processingd.py b/tests/test_hovernet_instance_map_post_processingd.py new file mode 100644 index 0000000000..45d80a35af --- /dev/null +++ b/tests/test_hovernet_instance_map_post_processingd.py @@ -0,0 +1,59 @@ +# Copyright (c) MONAI Consortium +# 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. + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.apps.pathology.transforms.post.array import HoVerNetInstanceMapPostProcessing +from monai.transforms import ComputeHoVerMaps, FillHoles, GaussianSmooth +from monai.utils import min_version, optional_import +from tests.utils import TEST_NDARRAYS, assert_allclose + +_, has_scipy = optional_import("scipy", "1.8.1", min_version) +_, has_skimage = optional_import("skimage", "0.19.3", min_version) + +y, x = np.ogrid[0:30, 0:30] +image = (x - 10) ** 2 + (y - 10) ** 2 <= 5**2 +image = image[None, ...].astype("uint8") + +TEST_CASE_1 = [{}, {"1": {"centroid": 1, "bbox": 1.0}}, np.zeros_like(image)] +TEST_CASE_2 = [{"distance_smooth_fn": GaussianSmooth()}, {"1": {"type": 1, "type_prob": 1.0}}, np.zeros_like(image)] +TEST_CASE_3 = [{"marker_postprocess_fn": FillHoles()}, {"1": {"type": 1, "type_prob": 1.0}}, np.zeros_like(image)] + +TEST_CASE = [] +for p in TEST_NDARRAYS: + TEST_CASE.append([p, image] + TEST_CASE_1) + # TEST_CASE.append([p, image] + TEST_CASE_2) + # TEST_CASE.append([p, image] + TEST_CASE_3) + + +@unittest.skipUnless(has_scipy, "Requires scipy library.") +@unittest.skipUnless(has_skimage, "Requires scikit-image library.") +class TestHoVerNetTypeMapPostProcessing(unittest.TestCase): + @parameterized.expand(TEST_CASE) + def test_value(self, in_type, test_data, kwargs, expected_info, expected_map): + nuclear_prediction = in_type(test_data.astype(float)) + hover_map = in_type(ComputeHoVerMaps()(test_data.astype(int))) + + inst_info, inst_map = HoVerNetInstanceMapPostProcessing()(nuclear_prediction, hover_map) + + # instance info + for key in inst_info: + assert_allclose(inst_info[key]["centroid"], expected_info[key]["centroid"], type_test=False) + + # instance map + assert_allclose(inst_map, expected_map, type_test=False) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hovernet_post_processing.py b/tests/test_hovernet_type_map_post_processing.py similarity index 100% rename from tests/test_hovernet_post_processing.py rename to tests/test_hovernet_type_map_post_processing.py diff --git a/tests/test_hovernet_post_processingd.py b/tests/test_hovernet_type_map_post_processingd.py similarity index 100% rename from tests/test_hovernet_post_processingd.py rename to tests/test_hovernet_type_map_post_processingd.py From 6fb1e5ebac95eac970a9851bc1e0c9788e1572fd Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Thu, 1 Dec 2022 14:48:48 -0500 Subject: [PATCH 19/29] Add HoVerNetTypeMapPostProcessingd and unittests Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- docs/source/apps.rst | 4 + monai/apps/pathology/transforms/__init__.py | 4 + .../pathology/transforms/post/__init__.py | 4 + .../pathology/transforms/post/dictionary.py | 89 +++++++++++++++++++ ...t_hovernet_instance_map_post_processing.py | 6 +- ..._hovernet_instance_map_post_processingd.py | 23 +++-- 6 files changed, 118 insertions(+), 12 deletions(-) diff --git a/docs/source/apps.rst b/docs/source/apps.rst index 3fc5f05a47..675deaeb64 100644 --- a/docs/source/apps.rst +++ b/docs/source/apps.rst @@ -155,6 +155,8 @@ Applications :members: .. autoclass:: HoVerNetTypeMapPostProcessing :members: +.. autoclass:: HoVerNetInstanceMapPostProcessing + :members: .. automodule:: monai.apps.pathology.transforms.post.dictionary .. autoclass:: GenerateSuccinctContourd @@ -175,6 +177,8 @@ Applications :members: .. autoclass:: GenerateWatershedMarkersd :members: +.. autoclass:: HoVerNetInstanceMapPostProcessingd + :members: .. autoclass:: HoVerNetTypeMapPostProcessingd :members: diff --git a/monai/apps/pathology/transforms/__init__.py b/monai/apps/pathology/transforms/__init__.py index 8a49eaee05..274be0bf04 100644 --- a/monai/apps/pathology/transforms/__init__.py +++ b/monai/apps/pathology/transforms/__init__.py @@ -19,6 +19,7 @@ GenerateWatershedMarkers, GenerateWatershedMask, HoVerNetTypeMapPostProcessing, + HoVerNetInstanceMapPostProcessing, Watershed, ) from .post.dictionary import ( @@ -49,6 +50,9 @@ HoVerNetTypeMapPostProcessingD, HoVerNetTypeMapPostProcessingd, HoVerNetTypeMapPostProcessingDict, + HoVerNetInstanceMapPostProcessingD, + HoVerNetInstanceMapPostProcessingd, + HoVerNetInstanceMapPostProcessingDict, WatershedD, Watershedd, WatershedDict, diff --git a/monai/apps/pathology/transforms/post/__init__.py b/monai/apps/pathology/transforms/post/__init__.py index f7569c0c64..240e8ebeb8 100644 --- a/monai/apps/pathology/transforms/post/__init__.py +++ b/monai/apps/pathology/transforms/post/__init__.py @@ -19,6 +19,7 @@ GenerateWatershedMarkers, GenerateWatershedMask, HoVerNetTypeMapPostProcessing, + HoVerNetInstanceMapPostProcessing, Watershed, ) from .dictionary import ( @@ -49,6 +50,9 @@ HoVerNetTypeMapPostProcessingD, HoVerNetTypeMapPostProcessingd, HoVerNetTypeMapPostProcessingDict, + HoVerNetInstanceMapPostProcessingD, + HoVerNetInstanceMapPostProcessingd, + HoVerNetInstanceMapPostProcessingDict, WatershedD, Watershedd, WatershedDict, diff --git a/monai/apps/pathology/transforms/post/dictionary.py b/monai/apps/pathology/transforms/post/dictionary.py index c3f05335ca..60696d378e 100644 --- a/monai/apps/pathology/transforms/post/dictionary.py +++ b/monai/apps/pathology/transforms/post/dictionary.py @@ -22,6 +22,7 @@ GenerateSuccinctContour, GenerateWatershedMarkers, GenerateWatershedMask, + HoVerNetInstanceMapPostProcessing, HoVerNetTypeMapPostProcessing, Watershed, ) @@ -456,6 +457,93 @@ def __call__(self, data): return d +class HoVerNetInstanceMapPostProcessingd(Transform): + """ + Dictionary-based wrapper for :py:class:`monai.apps.pathology.transforms.post.array.HoVerNetTypeMapPostProcessing`. + It generate a dictionary containing centroid, bounding box, type prediction for each instance. Also if requested, + it returns binary maps for instance segmentation and predicted types. + + The output dictionary will have three new keys: + + - "instance_info" mapping each instance to their centroid, bounding box, predicted type and probability. + - "instance_seg_map" containing an instance segmentation map. + - "type_seg_map" containing a segmentation map with associated types for each pixel. + + Args: + nuclear_prediction_key: the key for HoVerNet NC (nuclear prediction) branch. Defaults to `HoVerNetBranch.NP`. + hover_map_key: the key for HoVerNet NC (nuclear prediction) branch. Defaults to `HoVerNetBranch.HV`. + instance_info_key: the output key where instance information (contour, bounding boxes, and centroids) + is written. Defaults to `"instance_info"`. + instance_map_key: the output key where instance map is written. Defaults to `"instance_map"`. + activation: the activation layer to be applied on the input probability map. + It can be "softmax" or "sigmoid" string, or any callable. Defaults to "softmax". + mask_threshold: a float value to threshold to binarize probability map to generate mask. + min_object_size: objects smaller than this size are removed. Defaults to 10. + sobel_kernel_size: the size of the Sobel kernel used in :py:class:`GenerateInstanceBorder`. Defaults to 5. + distance_smooth_fn: smoothing function for distance map. + If not provided, :py:class:`monai.transforms.intensity.GaussianSmooth()` will be used. + marker_threshold: a float value to threshold to binarize instance border map for markers. + It turns uncertain area to 1 and other area to 0. Defaults to 0.4. + marker_radius: the radius of the disk-shaped footprint used in `opening` of markers. Defaults to 2. + marker_postprocess_fn: post-process function for watershed markers. + If not provided, :py:class:`monai.transforms.post.FillHoles()` will be used. + watershed_connectivity: `connectivity` argument of `skimage.segmentation.watershed`. + min_num_points: minimum number of points to be considered as a contour. Defaults to 3. + contour_level: an optional value for `skimage.measure.find_contours` to find contours in the array. + If not provided, the level is set to `(max(image) + min(image)) / 2`. + """ + + def __init__( + self, + nuclear_prediction_key: str = HoVerNetBranch.NP.value, + hover_map_key: str = HoVerNetBranch.HV.value, + instance_info_key: str = "instance_info", + instance_map_key: str = "instance_map", + activation: Union[str, Callable] = "softmax", + mask_threshold: Optional[float] = None, + min_object_size: int = 10, + sobel_kernel_size: int = 5, + distance_smooth_fn: Optional[Callable] = None, + marker_threshold: float = 0.4, + marker_radius: int = 2, + marker_postprocess_fn: Optional[Callable] = None, + watershed_connectivity: Optional[int] = 1, + min_num_points: int = 3, + contour_level: Optional[float] = None, + ) -> None: + super().__init__() + self.instance_map_post_process = HoVerNetInstanceMapPostProcessing( + activation=activation, + mask_threshold=mask_threshold, + min_object_size=min_object_size, + sobel_kernel_size=sobel_kernel_size, + distance_smooth_fn=distance_smooth_fn, + marker_threshold=marker_threshold, + marker_radius=marker_radius, + marker_postprocess_fn=marker_postprocess_fn, + watershed_connectivity=watershed_connectivity, + min_num_points=min_num_points, + contour_level=contour_level, + ) + self.nuclear_prediction_key = nuclear_prediction_key + self.hover_map_key = hover_map_key + self.instance_info_key = instance_info_key + self.instance_map_key = instance_map_key + + def __call__(self, data): + d = dict(data) + + for k in [self.instance_info_key, self.instance_map_key]: + if k in d: + raise ValueError("The output key ['{k}'] already exists in the input dictionary!") + + d[self.instance_info_key], d[self.instance_map_key] = self.instance_map_post_process( + d[self.nuclear_prediction_key], d[self.hover_map_key] + ) + + return d + + class HoVerNetTypeMapPostProcessingd(Transform): """ Dictionary-based wrapper for :py:class:`monai.apps.pathology.transforms.post.array.HoVerNetTypeMapPostProcessing`. @@ -527,4 +615,5 @@ def __call__(self, data): GenerateInstanceContourDict = GenerateInstanceContourD = GenerateInstanceContourd GenerateInstanceCentroidDict = GenerateInstanceCentroidD = GenerateInstanceCentroidd GenerateInstanceTypeDict = GenerateInstanceTypeD = GenerateInstanceTyped +HoVerNetInstanceMapPostProcessingDict = HoVerNetInstanceMapPostProcessingD = HoVerNetInstanceMapPostProcessingd HoVerNetTypeMapPostProcessingDict = HoVerNetTypeMapPostProcessingD = HoVerNetTypeMapPostProcessingd diff --git a/tests/test_hovernet_instance_map_post_processing.py b/tests/test_hovernet_instance_map_post_processing.py index 45d80a35af..1559f114b4 100644 --- a/tests/test_hovernet_instance_map_post_processing.py +++ b/tests/test_hovernet_instance_map_post_processing.py @@ -33,8 +33,8 @@ TEST_CASE = [] for p in TEST_NDARRAYS: TEST_CASE.append([p, image] + TEST_CASE_1) - # TEST_CASE.append([p, image] + TEST_CASE_2) - # TEST_CASE.append([p, image] + TEST_CASE_3) + TEST_CASE.append([p, image] + TEST_CASE_2) + TEST_CASE.append([p, image] + TEST_CASE_3) @unittest.skipUnless(has_scipy, "Requires scipy library.") @@ -45,7 +45,7 @@ def test_value(self, in_type, test_data, kwargs, expected_info, expected_map): nuclear_prediction = in_type(test_data.astype(float)) hover_map = in_type(ComputeHoVerMaps()(test_data.astype(int))) - inst_info, inst_map = HoVerNetInstanceMapPostProcessing()(nuclear_prediction, hover_map) + inst_info, inst_map = HoVerNetInstanceMapPostProcessing(**kwargs)(nuclear_prediction, hover_map) # instance info for key in inst_info: diff --git a/tests/test_hovernet_instance_map_post_processingd.py b/tests/test_hovernet_instance_map_post_processingd.py index 45d80a35af..0b6a6dc4b0 100644 --- a/tests/test_hovernet_instance_map_post_processingd.py +++ b/tests/test_hovernet_instance_map_post_processingd.py @@ -14,9 +14,10 @@ import numpy as np from parameterized import parameterized -from monai.apps.pathology.transforms.post.array import HoVerNetInstanceMapPostProcessing +from monai.apps.pathology.transforms.post.dictionary import HoVerNetInstanceMapPostProcessingd from monai.transforms import ComputeHoVerMaps, FillHoles, GaussianSmooth from monai.utils import min_version, optional_import +from monai.utils.enums import HoVerNetBranch from tests.utils import TEST_NDARRAYS, assert_allclose _, has_scipy = optional_import("scipy", "1.8.1", min_version) @@ -33,8 +34,8 @@ TEST_CASE = [] for p in TEST_NDARRAYS: TEST_CASE.append([p, image] + TEST_CASE_1) - # TEST_CASE.append([p, image] + TEST_CASE_2) - # TEST_CASE.append([p, image] + TEST_CASE_3) + TEST_CASE.append([p, image] + TEST_CASE_2) + TEST_CASE.append([p, image] + TEST_CASE_3) @unittest.skipUnless(has_scipy, "Requires scipy library.") @@ -42,17 +43,21 @@ class TestHoVerNetTypeMapPostProcessing(unittest.TestCase): @parameterized.expand(TEST_CASE) def test_value(self, in_type, test_data, kwargs, expected_info, expected_map): - nuclear_prediction = in_type(test_data.astype(float)) - hover_map = in_type(ComputeHoVerMaps()(test_data.astype(int))) + input = { + HoVerNetBranch.NP.value: in_type(test_data.astype(float)), + HoVerNetBranch.HV.value: in_type(ComputeHoVerMaps()(test_data.astype(int))), + } - inst_info, inst_map = HoVerNetInstanceMapPostProcessing()(nuclear_prediction, hover_map) + outputs = p = HoVerNetInstanceMapPostProcessingd(**kwargs)(input) + inst_info_key = kwargs.get("instance_info_key", "instance_info") + inst_map_key = kwargs.get("instance_map_key", "instance_map") # instance info - for key in inst_info: - assert_allclose(inst_info[key]["centroid"], expected_info[key]["centroid"], type_test=False) + for key in outputs[inst_info_key]: + assert_allclose(outputs[inst_info_key]["centroid"], expected_info[key]["centroid"], type_test=False) # instance map - assert_allclose(inst_map, expected_map, type_test=False) + assert_allclose(outputs[inst_map_key], expected_map, type_test=False) if __name__ == "__main__": From 18ea67b34ba2521eb28393e8afe42834c194d91f Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Thu, 1 Dec 2022 15:14:03 -0500 Subject: [PATCH 20/29] Refactor HoVerNetTypeMapPostProcessing and HoVerNetTypeMapPostProcessingd Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/apps/pathology/transforms/post/array.py | 35 ++++---- .../pathology/transforms/post/dictionary.py | 84 ++++++++----------- .../test_hovernet_type_map_post_processing.py | 2 +- ...test_hovernet_type_map_post_processingd.py | 20 ++--- 4 files changed, 64 insertions(+), 77 deletions(-) diff --git a/monai/apps/pathology/transforms/post/array.py b/monai/apps/pathology/transforms/post/array.py index afddd6b433..6fee87873d 100644 --- a/monai/apps/pathology/transforms/post/array.py +++ b/monai/apps/pathology/transforms/post/array.py @@ -99,7 +99,8 @@ class GenerateWatershedMask(Transform): Args: activation: the activation layer to be applied on the input probability map. It can be "softmax" or "sigmoid" string, or any callable. Defaults to "softmax". - threshold: a float value to threshold to binarize probability map. + threshold: an optional float value to threshold to binarize probability map. + If not provided, defaults to 0.5 when activation is not "softmax", otherwise None. min_object_size: objects smaller than this size are removed. Defaults to 10. dtype: target data content type to convert, default is np.uint8. @@ -134,7 +135,7 @@ def __init__( self.activation = Activations(softmax=use_softmax, sigmoid=use_sigmoid, other=activation_fn) # set discretization transform - if threshold is None: + if not use_softmax and threshold is None: threshold = 0.5 self.as_discrete = AsDiscrete(threshold=threshold, argmax=use_softmax) @@ -628,7 +629,7 @@ def __call__( # type: ignore class HoVerNetInstanceMapPostProcessing(Transform): """ The post-processing transform for HoVerNet model to generate instance segmentation map. - It generates an instance segmentation map as well as a dictionary containing centroid, bounding box, and contours + It generates an instance segmentation map as well as a dictionary containing centroids, bounding boxes, and contours for each instance. Args: @@ -698,13 +699,13 @@ def __call__( # type: ignore instance_borders = self.generate_instance_border(watershed_mask, hover_map) distance_map = self.generate_distance_map(watershed_mask, instance_borders) watershed_markers = self.generate_watershed_markers(watershed_mask, instance_borders) - instance_seg_map = self.watershed(distance_map, watershed_markers, watershed_markers) + instance_map = self.watershed(distance_map, watershed_markers, watershed_markers) # Create bounding boxes, contours and centroids - instance_ids = set(np.unique(instance_seg_map)) - {0} # exclude background + instance_ids = set(np.unique(instance_map)) - {0} # exclude background instance_info = {} for inst_id in instance_ids: - instance_mask = instance_seg_map == inst_id + instance_mask = instance_map == inst_id instance_bbox = BoundingRect()(instance_mask) instance_mask = instance_mask[ @@ -720,7 +721,7 @@ def __call__( # type: ignore "contour": instance_contour, } - return instance_info, instance_seg_map + return instance_info, instance_map class HoVerNetTypeMapPostProcessing(Transform): @@ -732,6 +733,8 @@ class HoVerNetTypeMapPostProcessing(Transform): Args: activation: the activation layer to be applied on nuclear type branch. It can be "softmax" or "sigmoid" string, or any callable. Defaults to "softmax". + threshold: an optional float value to threshold to binarize probability map. + If not provided, defaults to 0.5 when activation is not "softmax", otherwise None. return_type_map: whether to calculate and return segmentation map with associated type to each pixel. """ @@ -743,12 +746,10 @@ def __init__( return_type_map: bool = True, ) -> None: super().__init__() - self.return_type_map = return_type_map - # instance type transforms self.generate_instance_type = GenerateInstanceType() - # type prediction post-processing + # set activation layer use_softmax = False use_sigmoid = False activation_fn = None @@ -764,22 +765,26 @@ def __init__( else: raise ValueError(f"The activation type should be either str or callable. '{type(activation)}' was given.") self.activation = Activations(softmax=use_softmax, sigmoid=use_sigmoid, other=activation_fn) + + # set discretization transform + if not use_softmax and threshold is None: + threshold = 0.5 self.as_discrete = AsDiscrete(threshold=threshold, argmax=use_softmax) def __call__( # type: ignore - self, instance_info: Dict[int, Dict], instance_seg_map: NdarrayOrTensor, type_prediction: NdarrayOrTensor + self, type_prediction: NdarrayOrTensor, instance_info: Dict[int, Dict], instance_map: NdarrayOrTensor ) -> Tuple[Dict, Optional[NdarrayOrTensor]]: """Process NC (type prediction) branch and combine it with instance segmentation It updates the instance_info with instance type and associated probability, and generate instance type map. Args: instance_info: instance information dictionary, the output of :py:class:`HoVerNetInstanceMapPostProcessing` - instance_seg_map: instance segmentation map, the output of :py:class:`HoVerNetInstanceMapPostProcessing` + instance_map: instance segmentation map, the output of :py:class:`HoVerNetInstanceMapPostProcessing` type_prediction: the output of NC (type prediction) branch of HoVerNet model """ type_map = None if self.return_type_map: - type_map = convert_to_dst_type(torch.zeros(instance_seg_map.shape), instance_seg_map)[0] + type_map = convert_to_dst_type(torch.zeros(instance_map.shape), instance_map)[0] for inst_id in instance_info: type_prediction = self.activation(type_prediction) @@ -787,7 +792,7 @@ def __call__( # type: ignore instance_type, instance_type_prob = self.generate_instance_type( bbox=instance_info[inst_id]["bounding_box"], type_pred=type_prediction, - seg_pred=instance_seg_map, + seg_pred=instance_map, instance_id=inst_id, ) # update instance info dict with type data @@ -796,6 +801,6 @@ def __call__( # type: ignore # update instance type map if type_map is not None: - type_map[instance_seg_map == inst_id] = instance_type + type_map[instance_map == inst_id] = instance_type return instance_info, type_map diff --git a/monai/apps/pathology/transforms/post/dictionary.py b/monai/apps/pathology/transforms/post/dictionary.py index 60696d378e..e2a3797561 100644 --- a/monai/apps/pathology/transforms/post/dictionary.py +++ b/monai/apps/pathology/transforms/post/dictionary.py @@ -150,10 +150,7 @@ def __init__( super().__init__(keys, allow_missing_keys) self.mask_key = mask_key self.transform = GenerateWatershedMask( - activation=activation, - threshold=threshold, - min_object_size=min_object_size, - dtype=dtype, + activation=activation, threshold=threshold, min_object_size=min_object_size, dtype=dtype ) def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: @@ -459,18 +456,13 @@ def __call__(self, data): class HoVerNetInstanceMapPostProcessingd(Transform): """ - Dictionary-based wrapper for :py:class:`monai.apps.pathology.transforms.post.array.HoVerNetTypeMapPostProcessing`. - It generate a dictionary containing centroid, bounding box, type prediction for each instance. Also if requested, - it returns binary maps for instance segmentation and predicted types. - - The output dictionary will have three new keys: - - - "instance_info" mapping each instance to their centroid, bounding box, predicted type and probability. - - "instance_seg_map" containing an instance segmentation map. - - "type_seg_map" containing a segmentation map with associated types for each pixel. + Dictionary-based wrapper for :py:class:`monai.apps.pathology.transforms.post.array.HoVerNetInstanceMapPostProcessing`. + The post-processing transform for HoVerNet model to generate instance segmentation map. + It generates an instance segmentation map as well as a dictionary containing centroids, bounding boxes, and contours + for each instance. Args: - nuclear_prediction_key: the key for HoVerNet NC (nuclear prediction) branch. Defaults to `HoVerNetBranch.NP`. + nuclear_prediction_key: the key for HoVerNet NP (nuclear prediction) branch. Defaults to `HoVerNetBranch.NP`. hover_map_key: the key for HoVerNet NC (nuclear prediction) branch. Defaults to `HoVerNetBranch.HV`. instance_info_key: the output key where instance information (contour, bounding boxes, and centroids) is written. Defaults to `"instance_info"`. @@ -550,58 +542,48 @@ class HoVerNetTypeMapPostProcessingd(Transform): It generate a dictionary containing centroid, bounding box, type prediction for each instance. Also if requested, it returns binary maps for instance segmentation and predicted types. - The output dictionary will have three new keys: - - - "instance_info" mapping each instance to their centroid, bounding box, predicted type and probability. - - "instance_seg_map" containing an instance segmentation map. - - "type_seg_map" containing a segmentation map with associated types for each pixel. - Args: - level: optional value for `skimage.measure.find_contours`, to find contours in the array. - If not provided, the level is set to (max(image) + min(image)) / 2. - distance_smooth_fn: smoothing function for distance map. - If not provided, :py:class:`monai.transforms.intensity.GaussianSmooth()` will be used.. - marker_postprocess_fn: post-process function for watershed markers. - If not provided, :py:class:`monai.transforms.post.FillHoles()` will be used. - allow_missing_keys: don't raise exception if key is missing. + type_prediction_key: the key for HoVerNet NC (type prediction) branch. Defaults to `HoVerNetBranch.NC`. + instance_info_key: the key where instance information (contour, bounding boxes, and centroids) is stored. + Defaults to `"instance_info"`. + instance_map_key: the key where instance map is stored. Defaults to `"instance_map"`. + type_map_key: the output key where type map is written. Defaults to `"type_map"`. + """ def __init__( self, - nuclear_prediction_key: str = HoVerNetBranch.NP.value, - hover_map_key: str = HoVerNetBranch.HV.value, type_prediction_key: str = HoVerNetBranch.NC.value, - min_num_points: int = 3, - level: Optional[float] = None, - distance_smooth_fn: Optional[Callable] = None, - marker_postprocess_fn: Optional[Callable] = None, + instance_info_key: str = "instance_info", + instance_map_key: str = "instance_map", + type_map_key: str = "type_map", + activation: Union[str, Callable] = "softmax", + threshold: Optional[float] = None, + return_type_map: bool = True, ) -> None: super().__init__() - self.post_process = HoVerNetTypeMapPostProcessing( - min_num_points=min_num_points, - level=level, - distance_smooth_fn=distance_smooth_fn, - marker_postprocess_fn=marker_postprocess_fn, + self.type_post_process = HoVerNetTypeMapPostProcessing( + activation=activation, + threshold=threshold, + return_type_map=return_type_map, ) - self.nuclear_prediction_key = nuclear_prediction_key - self.hover_map_key = hover_map_key self.type_prediction_key = type_prediction_key + self.instance_info_key = instance_info_key + self.instance_map_key = instance_map_key + self.type_map_key = type_map_key + self.return_type_map = return_type_map def __call__(self, data): d = dict(data) - instance_info, instance_seg_map, type_seg_map = self.post_process( - d[self.nuclear_prediction_key], d[self.hover_map_key], d[self.type_prediction_key] - ) - output_keys = ["instance_info", "instance_seg_map", "type_seg_map"] - for k in output_keys: - if k in d: - raise ValueError("The output key ['{k}'] already exists in the input dictionary!") - - d["instance_info"] = instance_info - d["instance_seg_map"] = instance_seg_map - d["type_seg_map"] = type_seg_map + d[self.instance_info_key], type_map = self.type_post_process( + d[self.type_prediction_key], d[self.instance_info_key], d[self.instance_map_key] + ) + if self.return_type_map: + if self.type_map_key in d: + raise ValueError("The output key ['{self.type_map_key}'] already exists in the input dictionary!") + d[self.type_map_key] = type_map return d diff --git a/tests/test_hovernet_type_map_post_processing.py b/tests/test_hovernet_type_map_post_processing.py index 9c4ffe6cbc..e5acdf4704 100644 --- a/tests/test_hovernet_type_map_post_processing.py +++ b/tests/test_hovernet_type_map_post_processing.py @@ -43,7 +43,7 @@ def test_value(self, in_type, test_data, kwargs, expected_info, expected_map): nuclear_type = in_type(test_data) inst_info, inst_map = HoVerNetInstanceMapPostProcessing()(nuclear_prediction, hover_map) - inst_info, type_map = HoVerNetTypeMapPostProcessing(**kwargs)(inst_info, inst_map, nuclear_type) + inst_info, type_map = HoVerNetTypeMapPostProcessing(**kwargs)(nuclear_type, inst_info, inst_map) # instance prediction info for key in inst_info: diff --git a/tests/test_hovernet_type_map_post_processingd.py b/tests/test_hovernet_type_map_post_processingd.py index 1503660929..5f3b8b0c4c 100644 --- a/tests/test_hovernet_type_map_post_processingd.py +++ b/tests/test_hovernet_type_map_post_processingd.py @@ -14,8 +14,11 @@ import numpy as np from parameterized import parameterized -from monai.apps.pathology.transforms.post.dictionary import HoVerNetTypeMapPostProcessingd -from monai.transforms import ComputeHoVerMaps, FillHoles, GaussianSmooth +from monai.apps.pathology.transforms.post.dictionary import ( + HoVerNetTypeMapPostProcessingd, + HoVerNetInstanceMapPostProcessingd, +) +from monai.transforms import ComputeHoVerMaps from monai.utils import min_version, optional_import from monai.utils.enums import HoVerNetBranch from tests.utils import TEST_NDARRAYS, assert_allclose @@ -29,15 +32,11 @@ TEST_CASE_1 = [{}, [{"1": [10, 10]}, np.zeros_like(image), np.zeros_like(image)]] -TEST_CASE_2 = [{"distance_smooth_fn": GaussianSmooth()}, [{"1": [10, 10]}, np.zeros_like(image), np.zeros_like(image)]] -TEST_CASE_3 = [{"marker_postprocess_fn": FillHoles()}, [{"1": [10, 10]}, np.zeros_like(image), np.zeros_like(image)]] TEST_CASE = [] for p in TEST_NDARRAYS: TEST_CASE.append([p, image] + TEST_CASE_1) - TEST_CASE.append([p, image] + TEST_CASE_2) - TEST_CASE.append([p, image] + TEST_CASE_3) @unittest.skipUnless(has_scipy, "Requires scipy library.") @@ -51,20 +50,21 @@ def test_value(self, in_type, test_data, kwargs, expected): HoVerNetBranch.NC.value: in_type(test_data), } - outputs = HoVerNetTypeMapPostProcessingd(**kwargs)(input) + outputs = HoVerNetInstanceMapPostProcessingd()(input) + outputs = HoVerNetTypeMapPostProcessingd(**kwargs)(outputs) # instance prediction info for key in outputs["instance_info"]: assert_allclose(outputs["instance_info"][key]["centroid"], expected[0][key], type_test=False) # instance map - assert_allclose(outputs["instance_seg_map"], expected[1], type_test=False) + assert_allclose(outputs["instance_map"], expected[1], type_test=False) # type map if expected[2] is None: - self.assertIsNone(outputs["type_seg_map"]) + self.assertIsNone(outputs["type_map"]) else: - assert_allclose(outputs["type_seg_map"], expected[2], type_test=False) + assert_allclose(outputs["type_map"], expected[2], type_test=False) if __name__ == "__main__": From 027cb11d026633e94979b87b6690362b1c42f6ce Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 1 Dec 2022 20:15:25 +0000 Subject: [PATCH 21/29] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_hovernet_type_map_post_processing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_hovernet_type_map_post_processing.py b/tests/test_hovernet_type_map_post_processing.py index e5acdf4704..839f498c38 100644 --- a/tests/test_hovernet_type_map_post_processing.py +++ b/tests/test_hovernet_type_map_post_processing.py @@ -15,7 +15,7 @@ from parameterized import parameterized from monai.apps.pathology.transforms.post.array import HoVerNetInstanceMapPostProcessing, HoVerNetTypeMapPostProcessing -from monai.transforms import ComputeHoVerMaps, FillHoles, GaussianSmooth +from monai.transforms import ComputeHoVerMaps from monai.utils import min_version, optional_import from tests.utils import TEST_NDARRAYS, assert_allclose From 912f9e1839d8c7b0fcdceb28b8c6aa87203a8461 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Thu, 1 Dec 2022 15:23:30 -0500 Subject: [PATCH 22/29] formatting Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/apps/pathology/transforms/__init__.py | 8 ++++---- monai/apps/pathology/transforms/post/__init__.py | 8 ++++---- monai/apps/pathology/transforms/post/dictionary.py | 4 +--- tests/test_hovernet_type_map_post_processingd.py | 2 +- 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/monai/apps/pathology/transforms/__init__.py b/monai/apps/pathology/transforms/__init__.py index 274be0bf04..ed150c31d2 100644 --- a/monai/apps/pathology/transforms/__init__.py +++ b/monai/apps/pathology/transforms/__init__.py @@ -18,8 +18,8 @@ GenerateSuccinctContour, GenerateWatershedMarkers, GenerateWatershedMask, - HoVerNetTypeMapPostProcessing, HoVerNetInstanceMapPostProcessing, + HoVerNetTypeMapPostProcessing, Watershed, ) from .post.dictionary import ( @@ -47,12 +47,12 @@ GenerateWatershedMaskD, GenerateWatershedMaskd, GenerateWatershedMaskDict, - HoVerNetTypeMapPostProcessingD, - HoVerNetTypeMapPostProcessingd, - HoVerNetTypeMapPostProcessingDict, HoVerNetInstanceMapPostProcessingD, HoVerNetInstanceMapPostProcessingd, HoVerNetInstanceMapPostProcessingDict, + HoVerNetTypeMapPostProcessingD, + HoVerNetTypeMapPostProcessingd, + HoVerNetTypeMapPostProcessingDict, WatershedD, Watershedd, WatershedDict, diff --git a/monai/apps/pathology/transforms/post/__init__.py b/monai/apps/pathology/transforms/post/__init__.py index 240e8ebeb8..ba4e5e9279 100644 --- a/monai/apps/pathology/transforms/post/__init__.py +++ b/monai/apps/pathology/transforms/post/__init__.py @@ -18,8 +18,8 @@ GenerateSuccinctContour, GenerateWatershedMarkers, GenerateWatershedMask, - HoVerNetTypeMapPostProcessing, HoVerNetInstanceMapPostProcessing, + HoVerNetTypeMapPostProcessing, Watershed, ) from .dictionary import ( @@ -47,12 +47,12 @@ GenerateWatershedMaskD, GenerateWatershedMaskd, GenerateWatershedMaskDict, - HoVerNetTypeMapPostProcessingD, - HoVerNetTypeMapPostProcessingd, - HoVerNetTypeMapPostProcessingDict, HoVerNetInstanceMapPostProcessingD, HoVerNetInstanceMapPostProcessingd, HoVerNetInstanceMapPostProcessingDict, + HoVerNetTypeMapPostProcessingD, + HoVerNetTypeMapPostProcessingd, + HoVerNetTypeMapPostProcessingDict, WatershedD, Watershedd, WatershedDict, diff --git a/monai/apps/pathology/transforms/post/dictionary.py b/monai/apps/pathology/transforms/post/dictionary.py index e2a3797561..be237adbbb 100644 --- a/monai/apps/pathology/transforms/post/dictionary.py +++ b/monai/apps/pathology/transforms/post/dictionary.py @@ -564,9 +564,7 @@ def __init__( ) -> None: super().__init__() self.type_post_process = HoVerNetTypeMapPostProcessing( - activation=activation, - threshold=threshold, - return_type_map=return_type_map, + activation=activation, threshold=threshold, return_type_map=return_type_map ) self.type_prediction_key = type_prediction_key self.instance_info_key = instance_info_key diff --git a/tests/test_hovernet_type_map_post_processingd.py b/tests/test_hovernet_type_map_post_processingd.py index 5f3b8b0c4c..9cdcb1ac24 100644 --- a/tests/test_hovernet_type_map_post_processingd.py +++ b/tests/test_hovernet_type_map_post_processingd.py @@ -15,8 +15,8 @@ from parameterized import parameterized from monai.apps.pathology.transforms.post.dictionary import ( - HoVerNetTypeMapPostProcessingd, HoVerNetInstanceMapPostProcessingd, + HoVerNetTypeMapPostProcessingd, ) from monai.transforms import ComputeHoVerMaps from monai.utils import min_version, optional_import From e2f181b3b4846ec3a3c7be20498c21e4b3f2501d Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Thu, 1 Dec 2022 17:46:25 -0500 Subject: [PATCH 23/29] fix typo Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- tests/test_hovernet_instance_map_post_processingd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_hovernet_instance_map_post_processingd.py b/tests/test_hovernet_instance_map_post_processingd.py index 0b6a6dc4b0..34e560f01a 100644 --- a/tests/test_hovernet_instance_map_post_processingd.py +++ b/tests/test_hovernet_instance_map_post_processingd.py @@ -48,7 +48,7 @@ def test_value(self, in_type, test_data, kwargs, expected_info, expected_map): HoVerNetBranch.HV.value: in_type(ComputeHoVerMaps()(test_data.astype(int))), } - outputs = p = HoVerNetInstanceMapPostProcessingd(**kwargs)(input) + outputs = HoVerNetInstanceMapPostProcessingd(**kwargs)(input) inst_info_key = kwargs.get("instance_info_key", "instance_info") inst_map_key = kwargs.get("instance_map_key", "instance_map") From 831fdacb943b668a01638c2f7ece7c9c605eee54 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Thu, 1 Dec 2022 18:50:49 -0500 Subject: [PATCH 24/29] update type Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/apps/pathology/transforms/post/array.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/monai/apps/pathology/transforms/post/array.py b/monai/apps/pathology/transforms/post/array.py index 6fee87873d..4f2eabf833 100644 --- a/monai/apps/pathology/transforms/post/array.py +++ b/monai/apps/pathology/transforms/post/array.py @@ -699,7 +699,7 @@ def __call__( # type: ignore instance_borders = self.generate_instance_border(watershed_mask, hover_map) distance_map = self.generate_distance_map(watershed_mask, instance_borders) watershed_markers = self.generate_watershed_markers(watershed_mask, instance_borders) - instance_map = self.watershed(distance_map, watershed_markers, watershed_markers) + instance_map = self.watershed(distance_map, watershed_mask, watershed_markers) # Create bounding boxes, contours and centroids instance_ids = set(np.unique(instance_map)) - {0} # exclude background @@ -782,17 +782,18 @@ def __call__( # type: ignore instance_map: instance segmentation map, the output of :py:class:`HoVerNetInstanceMapPostProcessing` type_prediction: the output of NC (type prediction) branch of HoVerNet model """ + type_prediction = self.activation(type_prediction) + type_prediction = self.as_discrete(type_prediction) + type_map = None if self.return_type_map: type_map = convert_to_dst_type(torch.zeros(instance_map.shape), instance_map)[0] for inst_id in instance_info: - type_prediction = self.activation(type_prediction) - type_prediction = self.as_discrete(type_prediction) instance_type, instance_type_prob = self.generate_instance_type( - bbox=instance_info[inst_id]["bounding_box"], type_pred=type_prediction, seg_pred=instance_map, + bbox=instance_info[inst_id]["bounding_box"], instance_id=inst_id, ) # update instance info dict with type data From 3aa08bf5d4dfa4b7f2110301b8a1b6e15ddc1865 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Thu, 1 Dec 2022 20:25:18 -0500 Subject: [PATCH 25/29] Update watershedd test Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- tests/test_watershedd.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_watershedd.py b/tests/test_watershedd.py index 6474759de4..6e04409e1d 100644 --- a/tests/test_watershedd.py +++ b/tests/test_watershedd.py @@ -29,7 +29,7 @@ _, has_scipy = optional_import("scipy", "1.8.1", min_version) TESTS = [] -params = {"keys": "dist", "mask_key": "mask", "markers_key": "markers", "connectivity": 1} +params = {"keys": "dist_map", "mask_key": "mask", "markers_key": "markers", "connectivity": 1} for p in TEST_NDARRAYS: image = p(np.random.rand(1, 10, 10)) hover_map = p(np.random.rand(2, 10, 10)) @@ -53,15 +53,15 @@ def test_output(self, args, image, hover_map, expected_shape): trans = Compose( [ GenerateWatershedMaskd(keys="output"), - GenerateInstanceBorderd(keys="mask", hover_map_key="hover_map", kernel_size=3), - GenerateDistanceMapd(keys="mask", border_key="border"), - GenerateWatershedMarkersd(keys="mask", border_key="border"), + GenerateInstanceBorderd(mask_key="mask", hover_map_key="hover_map", kernel_size=3), + GenerateDistanceMapd(mask_key="mask", border_key="border"), + GenerateWatershedMarkersd(mask_key="mask", border_key="border"), Watershedd(**args), ] ) output = trans(data) - self.assertTupleEqual(output["dist"].shape, expected_shape) + self.assertTupleEqual(output["dist_map"].shape, expected_shape) if __name__ == "__main__": From 6890f707c8551ceb2f14a199344ce7e98c60845a Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Fri, 2 Dec 2022 10:01:15 -0500 Subject: [PATCH 26/29] address commnets Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- docs/source/apps.rst | 4 +- monai/apps/pathology/transforms/__init__.py | 8 ++-- .../pathology/transforms/post/__init__.py | 8 ++-- monai/apps/pathology/transforms/post/array.py | 37 +++++++++++-------- .../pathology/transforms/post/dictionary.py | 23 +++++++----- monai/transforms/post/array.py | 2 +- ...t_hovernet_instance_map_post_processing.py | 2 +- ..._hovernet_instance_map_post_processingd.py | 2 +- .../test_hovernet_type_map_post_processing.py | 9 +++-- ...test_hovernet_type_map_post_processingd.py | 6 +-- 10 files changed, 56 insertions(+), 45 deletions(-) diff --git a/docs/source/apps.rst b/docs/source/apps.rst index 675deaeb64..d0db65b3f5 100644 --- a/docs/source/apps.rst +++ b/docs/source/apps.rst @@ -153,7 +153,7 @@ Applications :members: .. autoclass:: GenerateWatershedMarkers :members: -.. autoclass:: HoVerNetTypeMapPostProcessing +.. autoclass:: HoVerNetNuclearTypePostProcessing :members: .. autoclass:: HoVerNetInstanceMapPostProcessing :members: @@ -179,7 +179,7 @@ Applications :members: .. autoclass:: HoVerNetInstanceMapPostProcessingd :members: -.. autoclass:: HoVerNetTypeMapPostProcessingd +.. autoclass:: HoVerNetNuclearTypePostProcessingd :members: `Detection` diff --git a/monai/apps/pathology/transforms/__init__.py b/monai/apps/pathology/transforms/__init__.py index ed150c31d2..c30d2c1a76 100644 --- a/monai/apps/pathology/transforms/__init__.py +++ b/monai/apps/pathology/transforms/__init__.py @@ -19,7 +19,7 @@ GenerateWatershedMarkers, GenerateWatershedMask, HoVerNetInstanceMapPostProcessing, - HoVerNetTypeMapPostProcessing, + HoVerNetNuclearTypePostProcessing, Watershed, ) from .post.dictionary import ( @@ -50,9 +50,9 @@ HoVerNetInstanceMapPostProcessingD, HoVerNetInstanceMapPostProcessingd, HoVerNetInstanceMapPostProcessingDict, - HoVerNetTypeMapPostProcessingD, - HoVerNetTypeMapPostProcessingd, - HoVerNetTypeMapPostProcessingDict, + HoVerNetNuclearTypePostProcessingD, + HoVerNetNuclearTypePostProcessingd, + HoVerNetNuclearTypePostProcessingDict, WatershedD, Watershedd, WatershedDict, diff --git a/monai/apps/pathology/transforms/post/__init__.py b/monai/apps/pathology/transforms/post/__init__.py index ba4e5e9279..c5b928b991 100644 --- a/monai/apps/pathology/transforms/post/__init__.py +++ b/monai/apps/pathology/transforms/post/__init__.py @@ -19,7 +19,7 @@ GenerateWatershedMarkers, GenerateWatershedMask, HoVerNetInstanceMapPostProcessing, - HoVerNetTypeMapPostProcessing, + HoVerNetNuclearTypePostProcessing, Watershed, ) from .dictionary import ( @@ -50,9 +50,9 @@ HoVerNetInstanceMapPostProcessingD, HoVerNetInstanceMapPostProcessingd, HoVerNetInstanceMapPostProcessingDict, - HoVerNetTypeMapPostProcessingD, - HoVerNetTypeMapPostProcessingd, - HoVerNetTypeMapPostProcessingDict, + HoVerNetNuclearTypePostProcessingD, + HoVerNetNuclearTypePostProcessingd, + HoVerNetNuclearTypePostProcessingDict, WatershedD, Watershedd, WatershedDict, diff --git a/monai/apps/pathology/transforms/post/array.py b/monai/apps/pathology/transforms/post/array.py index 4f2eabf833..6213789cf6 100644 --- a/monai/apps/pathology/transforms/post/array.py +++ b/monai/apps/pathology/transforms/post/array.py @@ -47,7 +47,8 @@ "GenerateInstanceContour", "GenerateInstanceCentroid", "GenerateInstanceType", - "HoVerNetTypeMapPostProcessing", + "HoVerNetInstanceMapPostProcessing", + "HoVerNetNuclearTypePostProcessing", ] @@ -101,7 +102,7 @@ class GenerateWatershedMask(Transform): It can be "softmax" or "sigmoid" string, or any callable. Defaults to "softmax". threshold: an optional float value to threshold to binarize probability map. If not provided, defaults to 0.5 when activation is not "softmax", otherwise None. - min_object_size: objects smaller than this size are removed. Defaults to 10. + min_object_size: objects smaller than this size (in pixel) are removed. Defaults to 10. dtype: target data content type to convert, default is np.uint8. """ @@ -127,7 +128,9 @@ def __init__( elif activation.lower() == "sigmoid": use_sigmoid = True else: - raise ValueError(f"The activation should be either 'softmax' or 'sigmoid'. '{activation}' was given.") + raise ValueError( + f"The activation should be 'softmax' or 'sigmoid' string, or any callable. '{activation}' was given." + ) elif callable(activation): activation_fn = activation else: @@ -278,7 +281,7 @@ class GenerateWatershedMarkers(Transform): threshold: a float value to threshold to binarize instance border map. It turns uncertain area to 1 and other area to 0. Defaults to 0.4. radius: the radius of the disk-shaped footprint used in `opening`. Defaults to 2. - min_object_size: objects smaller than this size are removed. Defaults to 10. + min_object_size: objects smaller than this size (in pixel) are removed. Defaults to 10. postprocess_fn: additional post-process function on the markers. If not provided, :py:class:`monai.transforms.post.FillHoles()` will be used. dtype: target data type to convert to. Defaults to np.uint8. @@ -528,7 +531,7 @@ def __init__(self, min_num_points: int = 3, contour_level: Optional[float] = Non self.contour_level = contour_level self.min_num_points = min_num_points - def __call__(self, inst_mask: NdarrayOrTensor, offset: Optional[Sequence[int]] = (0, 0)) -> np.ndarray: + def __call__(self, inst_mask: NdarrayOrTensor, offset: Optional[Sequence[int]] = (0, 0)) -> Optional[np.ndarray]: """ Args: inst_mask: segmentation mask for a single instance. Shape should be [1, H, W, [D]] @@ -543,12 +546,12 @@ def __call__(self, inst_mask: NdarrayOrTensor, offset: Optional[Sequence[int]] = # less than `self.min_num_points` points don't make a contour, so skip. # They are likely to be artifacts as the contours obtained via approximation. if inst_contour.shape[0] < self.min_num_points: - print(f"< {self.min_num_points} points don't make a contour, so skip") - return np.array([]) + print(f"< {self.min_num_points} points don't make a contour, so skipped!") + return None # check for tricky shape elif len(inst_contour.shape) != 2: - print(f"{len(inst_contour.shape)} != 2, check for tricky shape") - return np.array([]) + print(f"{len(inst_contour.shape)} != 2, check for tricky shapes!") + return None else: inst_contour[:, 0] += offset[0] # type: ignore inst_contour[:, 1] += offset[1] # type: ignore @@ -636,7 +639,7 @@ class HoVerNetInstanceMapPostProcessing(Transform): activation: the activation layer to be applied on the input probability map. It can be "softmax" or "sigmoid" string, or any callable. Defaults to "softmax". mask_threshold: a float value to threshold to binarize probability map to generate mask. - min_object_size: objects smaller than this size are removed. Defaults to 10. + min_object_size: objects smaller than this size (in pixel) are removed. Defaults to 10. sobel_kernel_size: the size of the Sobel kernel used in :py:class:`GenerateInstanceBorder`. Defaults to 5. distance_smooth_fn: smoothing function for distance map. If not provided, :py:class:`monai.transforms.intensity.GaussianSmooth()` will be used. @@ -724,18 +727,18 @@ def __call__( # type: ignore return instance_info, instance_map -class HoVerNetTypeMapPostProcessing(Transform): +class HoVerNetNuclearTypePostProcessing(Transform): """ - The post-processing transform for HoVerNet model to generate a segmentation map with types. - It generate a segmentation map with associated type to each pixel. It also updates the input instance info - dictionary with information about type of the cells (type value and probability). + The post-processing transform for HoVerNet model to generate nuclear type information. + It updates the input instance info dictionary with information about types of the nuclei (value and probability). + Also if requested (`return_type_map=True`), it generates a pixel-level type map. Args: activation: the activation layer to be applied on nuclear type branch. It can be "softmax" or "sigmoid" string, or any callable. Defaults to "softmax". threshold: an optional float value to threshold to binarize probability map. If not provided, defaults to 0.5 when activation is not "softmax", otherwise None. - return_type_map: whether to calculate and return segmentation map with associated type to each pixel. + return_type_map: whether to calculate and return pixel-level type map. """ @@ -759,7 +762,9 @@ def __init__( elif activation.lower() == "sigmoid": use_sigmoid = True else: - raise ValueError(f"The activation should be either 'softmax' or 'sigmoid'. '{activation}' was given.") + raise ValueError( + f"The activation should be 'softmax' or 'sigmoid' string, or any callable. '{activation}' was given." + ) elif callable(activation): activation_fn = activation else: diff --git a/monai/apps/pathology/transforms/post/dictionary.py b/monai/apps/pathology/transforms/post/dictionary.py index be237adbbb..ee9654eaca 100644 --- a/monai/apps/pathology/transforms/post/dictionary.py +++ b/monai/apps/pathology/transforms/post/dictionary.py @@ -23,7 +23,7 @@ GenerateWatershedMarkers, GenerateWatershedMask, HoVerNetInstanceMapPostProcessing, - HoVerNetTypeMapPostProcessing, + HoVerNetNuclearTypePostProcessing, Watershed, ) from monai.config.type_definitions import DtypeLike, KeysCollection, NdarrayOrTensor @@ -62,9 +62,12 @@ "GenerateInstanceTypeDict", "GenerateInstanceTypeD", "GenerateInstanceTyped", - "HoVerNetTypeMapPostProcessingDict", - "HoVerNetTypeMapPostProcessingD", - "HoVerNetTypeMapPostProcessingd", + "HoVerNetInstanceMapPostProcessingDict", + "HoVerNetInstanceMapPostProcessingD", + "HoVerNetInstanceMapPostProcessingd", + "HoVerNetNuclearTypePostProcessingDict", + "HoVerNetNuclearTypePostProcessingD", + "HoVerNetNuclearTypePostProcessingd", ] @@ -536,11 +539,11 @@ def __call__(self, data): return d -class HoVerNetTypeMapPostProcessingd(Transform): +class HoVerNetNuclearTypePostProcessingd(Transform): """ - Dictionary-based wrapper for :py:class:`monai.apps.pathology.transforms.post.array.HoVerNetTypeMapPostProcessing`. - It generate a dictionary containing centroid, bounding box, type prediction for each instance. Also if requested, - it returns binary maps for instance segmentation and predicted types. + Dictionary-based wrapper for :py:class:`monai.apps.pathology.transforms.post.array.HoVerNetNuclearTypePostProcessing`. + It updates the input instance info dictionary with information about types of the nuclei (value and probability). + Also if requested (`return_type_map=True`), it generates a pixel-level type map. Args: type_prediction_key: the key for HoVerNet NC (type prediction) branch. Defaults to `HoVerNetBranch.NC`. @@ -563,7 +566,7 @@ def __init__( return_type_map: bool = True, ) -> None: super().__init__() - self.type_post_process = HoVerNetTypeMapPostProcessing( + self.type_post_process = HoVerNetNuclearTypePostProcessing( activation=activation, threshold=threshold, return_type_map=return_type_map ) self.type_prediction_key = type_prediction_key @@ -596,4 +599,4 @@ def __call__(self, data): GenerateInstanceCentroidDict = GenerateInstanceCentroidD = GenerateInstanceCentroidd GenerateInstanceTypeDict = GenerateInstanceTypeD = GenerateInstanceTyped HoVerNetInstanceMapPostProcessingDict = HoVerNetInstanceMapPostProcessingD = HoVerNetInstanceMapPostProcessingd -HoVerNetTypeMapPostProcessingDict = HoVerNetTypeMapPostProcessingD = HoVerNetTypeMapPostProcessingd +HoVerNetNuclearTypePostProcessingDict = HoVerNetNuclearTypePostProcessingD = HoVerNetNuclearTypePostProcessingd diff --git a/monai/transforms/post/array.py b/monai/transforms/post/array.py index a3ff6b15c0..5cbeaeac43 100644 --- a/monai/transforms/post/array.py +++ b/monai/transforms/post/array.py @@ -360,7 +360,7 @@ class RemoveSmallObjects(Transform): Data should be one-hotted. Args: - min_size: objects smaller than this size are removed. + min_size: objects smaller than this size (in pixel) are removed. connectivity: Maximum number of orthogonal hops to consider a pixel/voxel as a neighbor. Accepted values are ranging from 1 to input.ndim. If ``None``, a full connectivity of ``input.ndim`` is used. For more details refer to linked scikit-image diff --git a/tests/test_hovernet_instance_map_post_processing.py b/tests/test_hovernet_instance_map_post_processing.py index 1559f114b4..db87332c13 100644 --- a/tests/test_hovernet_instance_map_post_processing.py +++ b/tests/test_hovernet_instance_map_post_processing.py @@ -39,7 +39,7 @@ @unittest.skipUnless(has_scipy, "Requires scipy library.") @unittest.skipUnless(has_skimage, "Requires scikit-image library.") -class TestHoVerNetTypeMapPostProcessing(unittest.TestCase): +class TestHoVerNetInstanceMapPostProcessing(unittest.TestCase): @parameterized.expand(TEST_CASE) def test_value(self, in_type, test_data, kwargs, expected_info, expected_map): nuclear_prediction = in_type(test_data.astype(float)) diff --git a/tests/test_hovernet_instance_map_post_processingd.py b/tests/test_hovernet_instance_map_post_processingd.py index 34e560f01a..d69d8f7c8b 100644 --- a/tests/test_hovernet_instance_map_post_processingd.py +++ b/tests/test_hovernet_instance_map_post_processingd.py @@ -40,7 +40,7 @@ @unittest.skipUnless(has_scipy, "Requires scipy library.") @unittest.skipUnless(has_skimage, "Requires scikit-image library.") -class TestHoVerNetTypeMapPostProcessing(unittest.TestCase): +class TestHoVerNetInstanceMapPostProcessing(unittest.TestCase): @parameterized.expand(TEST_CASE) def test_value(self, in_type, test_data, kwargs, expected_info, expected_map): input = { diff --git a/tests/test_hovernet_type_map_post_processing.py b/tests/test_hovernet_type_map_post_processing.py index 839f498c38..ae2b6dcdeb 100644 --- a/tests/test_hovernet_type_map_post_processing.py +++ b/tests/test_hovernet_type_map_post_processing.py @@ -14,7 +14,10 @@ import numpy as np from parameterized import parameterized -from monai.apps.pathology.transforms.post.array import HoVerNetInstanceMapPostProcessing, HoVerNetTypeMapPostProcessing +from monai.apps.pathology.transforms.post.array import ( + HoVerNetInstanceMapPostProcessing, + HoVerNetNuclearTypePostProcessing, +) from monai.transforms import ComputeHoVerMaps from monai.utils import min_version, optional_import from tests.utils import TEST_NDARRAYS, assert_allclose @@ -35,7 +38,7 @@ @unittest.skipUnless(has_scipy, "Requires scipy library.") @unittest.skipUnless(has_skimage, "Requires scikit-image library.") -class TestHoVerNetTypeMapPostProcessing(unittest.TestCase): +class TestHoVerNetNuclearTypePostProcessing(unittest.TestCase): @parameterized.expand(TEST_CASE) def test_value(self, in_type, test_data, kwargs, expected_info, expected_map): nuclear_prediction = in_type(test_data.astype(float)) @@ -43,7 +46,7 @@ def test_value(self, in_type, test_data, kwargs, expected_info, expected_map): nuclear_type = in_type(test_data) inst_info, inst_map = HoVerNetInstanceMapPostProcessing()(nuclear_prediction, hover_map) - inst_info, type_map = HoVerNetTypeMapPostProcessing(**kwargs)(nuclear_type, inst_info, inst_map) + inst_info, type_map = HoVerNetNuclearTypePostProcessing(**kwargs)(nuclear_type, inst_info, inst_map) # instance prediction info for key in inst_info: diff --git a/tests/test_hovernet_type_map_post_processingd.py b/tests/test_hovernet_type_map_post_processingd.py index 9cdcb1ac24..e6e675bc76 100644 --- a/tests/test_hovernet_type_map_post_processingd.py +++ b/tests/test_hovernet_type_map_post_processingd.py @@ -16,7 +16,7 @@ from monai.apps.pathology.transforms.post.dictionary import ( HoVerNetInstanceMapPostProcessingd, - HoVerNetTypeMapPostProcessingd, + HoVerNetNuclearTypePostProcessingd, ) from monai.transforms import ComputeHoVerMaps from monai.utils import min_version, optional_import @@ -41,7 +41,7 @@ @unittest.skipUnless(has_scipy, "Requires scipy library.") @unittest.skipUnless(has_skimage, "Requires scikit-image library.") -class TestHoVerNetTypeMapPostProcessingd(unittest.TestCase): +class TestHoVerNetNuclearTypePostProcessingd(unittest.TestCase): @parameterized.expand(TEST_CASE) def test_value(self, in_type, test_data, kwargs, expected): input = { @@ -51,7 +51,7 @@ def test_value(self, in_type, test_data, kwargs, expected): } outputs = HoVerNetInstanceMapPostProcessingd()(input) - outputs = HoVerNetTypeMapPostProcessingd(**kwargs)(outputs) + outputs = HoVerNetNuclearTypePostProcessingd(**kwargs)(outputs) # instance prediction info for key in outputs["instance_info"]: From cd9e3f20ea85b4138a5595607bfcb4e4ae5fcc6f Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Fri, 2 Dec 2022 10:10:26 -0500 Subject: [PATCH 27/29] Allow 2D masks Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/apps/pathology/transforms/post/array.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/monai/apps/pathology/transforms/post/array.py b/monai/apps/pathology/transforms/post/array.py index 6213789cf6..4540e1c977 100644 --- a/monai/apps/pathology/transforms/post/array.py +++ b/monai/apps/pathology/transforms/post/array.py @@ -190,17 +190,18 @@ def __call__(self, mask: NdarrayOrTensor, hover_map: NdarrayOrTensor) -> Ndarray """ Args: mask: binary segmentation map, the output of :py:class:`GenerateWatershedMask`. - Shape must be [1, H, W]. + Shape must be [1, H, W] or [H, W]. hover_map: horizontal and vertical distances of nuclear pixels to their centres of mass. Shape must be [2, H, W]. The first and second channel represent the horizontal and vertical maps respectively. For more details refer to papers: https://arxiv.org/abs/1812.06499. """ - if len(mask.shape) != 3 or len(hover_map.shape) != 3: - raise ValueError( - f"Suppose the mask and hover map should be with shape of [C, H, W], but got {mask.shape}, {hover_map.shape}" - ) - if mask.shape[0] != 1: - raise ValueError(f"Suppose the mask only has one channel, but got {mask.shape[0]}") + if len(hover_map.shape) != 3: + raise ValueError(f"The hover map should have the shape of [C, H, W], but got {hover_map.shape}.") + if len(mask.shape) == 3: + if mask.shape[0] != 1: + raise ValueError(f"The mask should have only one channel, but got {mask.shape[0]}.") + elif len(mask.shape) != 2: + raise ValueError(f"The mask should have the shape of [1, H, W] or [H, W], but got {mask.shape}.") if hover_map.shape[0] != 2: raise ValueError(f"Suppose the hover map only has two channels, but got {hover_map.shape[0]}") From 300341f613c9a3de0505a2155c3ae74b8b780767 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Fri, 2 Dec 2022 11:48:05 -0500 Subject: [PATCH 28/29] Allow more 2D masks Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/apps/pathology/transforms/post/array.py | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/monai/apps/pathology/transforms/post/array.py b/monai/apps/pathology/transforms/post/array.py index 4540e1c977..1b00e3273c 100644 --- a/monai/apps/pathology/transforms/post/array.py +++ b/monai/apps/pathology/transforms/post/array.py @@ -200,7 +200,9 @@ def __call__(self, mask: NdarrayOrTensor, hover_map: NdarrayOrTensor) -> Ndarray if len(mask.shape) == 3: if mask.shape[0] != 1: raise ValueError(f"The mask should have only one channel, but got {mask.shape[0]}.") - elif len(mask.shape) != 2: + elif len(mask.shape) == 2: + mask = mask[None] + else: raise ValueError(f"The mask should have the shape of [1, H, W] or [H, W], but got {mask.shape}.") if hover_map.shape[0] != 2: raise ValueError(f"Suppose the hover map only has two channels, but got {hover_map.shape[0]}") @@ -236,7 +238,7 @@ class GenerateDistanceMap(Transform): Generate distance map. In general, the instance map is calculated from the distance to the background. Here, we use 1 - "instance border map" to generate the distance map. - Nuclei values form mountains so inverse them to get basins. + Nuclei values form mountains so invert them to get basins. Args: smooth_fn: smoothing function for distance map, which can be any callable object. @@ -255,12 +257,17 @@ def __call__(self, mask: NdarrayOrTensor, instance_border: NdarrayOrTensor) -> N """ Args: mask: binary segmentation map, the output of :py:class:`GenerateWatershedMask`. - Shape must be [1, H, W]. + Shape must be [1, H, W] or [H, W]. instance_border: instance border map, the output of :py:class:`GenerateInstanceBorder`. Shape must be [1, H, W]. """ - if mask.shape[0] != 1 or mask.ndim != 3: - raise ValueError(f"Input mask should be with size of [1, H, W], but got {mask.shape}") + if len(mask.shape) == 3: + if mask.shape[0] != 1: + raise ValueError(f"The mask should have only one channel, but got {mask.shape[0]}.") + elif len(mask.shape) == 2: + mask = mask[None] + else: + raise ValueError(f"The mask should have the shape of [1, H, W] or [H, W], but got {mask.shape}.") if instance_border.shape[0] != 1 or instance_border.ndim != 3: raise ValueError(f"Input instance_border should be with size of [1, H, W], but got {instance_border.shape}") @@ -312,12 +319,17 @@ def __call__(self, mask: NdarrayOrTensor, instance_border: NdarrayOrTensor) -> N """ Args: mask: binary segmentation map, the output of :py:class:`GenerateWatershedMask`. - Shape must be [1, H, W]. + Shape must be [1, H, W] or [H, W]. instance_border: instance border map, the output of :py:class:`GenerateInstanceBorder`. Shape must be [1, H, W]. """ - if mask.shape[0] != 1 or mask.ndim != 3: - raise ValueError(f"Input mask should be with size of [1, H, W], but got {mask.shape}") + if len(mask.shape) == 3: + if mask.shape[0] != 1: + raise ValueError(f"The mask should have only one channel, but got {mask.shape[0]}.") + elif len(mask.shape) == 2: + mask = mask[None] + else: + raise ValueError(f"The mask should have the shape of [1, H, W] or [H, W], but got {mask.shape}.") if instance_border.shape[0] != 1 or instance_border.ndim != 3: raise ValueError(f"Input instance_border should be with size of [1, H, W], but got {instance_border.shape}") From de2ff968aa1a54a87507811af24fec88682fbbe4 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Sun, 4 Dec 2022 21:20:32 -0500 Subject: [PATCH 29/29] correct file names Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- tests/test_hovernet_instance_map_post_processingd.py | 2 +- ...cessing.py => test_hovernet_nuclear_type_post_processing.py} | 0 ...ssingd.py => test_hovernet_nuclear_type_post_processingd.py} | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename tests/{test_hovernet_type_map_post_processing.py => test_hovernet_nuclear_type_post_processing.py} (100%) rename tests/{test_hovernet_type_map_post_processingd.py => test_hovernet_nuclear_type_post_processingd.py} (100%) diff --git a/tests/test_hovernet_instance_map_post_processingd.py b/tests/test_hovernet_instance_map_post_processingd.py index d69d8f7c8b..2aa9f7ae64 100644 --- a/tests/test_hovernet_instance_map_post_processingd.py +++ b/tests/test_hovernet_instance_map_post_processingd.py @@ -40,7 +40,7 @@ @unittest.skipUnless(has_scipy, "Requires scipy library.") @unittest.skipUnless(has_skimage, "Requires scikit-image library.") -class TestHoVerNetInstanceMapPostProcessing(unittest.TestCase): +class TestHoVerNetInstanceMapPostProcessingd(unittest.TestCase): @parameterized.expand(TEST_CASE) def test_value(self, in_type, test_data, kwargs, expected_info, expected_map): input = { diff --git a/tests/test_hovernet_type_map_post_processing.py b/tests/test_hovernet_nuclear_type_post_processing.py similarity index 100% rename from tests/test_hovernet_type_map_post_processing.py rename to tests/test_hovernet_nuclear_type_post_processing.py diff --git a/tests/test_hovernet_type_map_post_processingd.py b/tests/test_hovernet_nuclear_type_post_processingd.py similarity index 100% rename from tests/test_hovernet_type_map_post_processingd.py rename to tests/test_hovernet_nuclear_type_post_processingd.py