From 8a9563c256176e0e372cd0e45d00e998e6c7a508 Mon Sep 17 00:00:00 2001 From: Abhigyan Kumar Singh Date: Mon, 26 Oct 2026 12:40:20 +0000 Subject: [PATCH 1/2] Restore min_area and do_reconstruction in nuclick transforms (#9083) `SplitLabeld.min_area` and `PostFilterLabeld.do_reconstruction` were used when the transforms were added (#4266) but stopped being read in #5563, so setting them has no effect. - `SplitLabeld.min_area` removes connected components of `others` smaller than `min_area` pixels. The default changes from 5 to 0 so that the default output is unchanged. - `PostFilterLabeld.do_reconstruction` keeps only the parts of each patch mask connected to its click point (`nuc_points`), using `skimage.morphology.reconstruction`, as in the NuClick reference post-processing. If the click is outside the mask, the mask is kept and a warning is emitted. The default (`False`) is unchanged. Fixes #9083 Assisted-by: Claude Code Signed-off-by: Abhigyan Kumar Singh --- monai/apps/nuclick/transforms.py | 34 +++++- tests/apps/nuclick/test_nuclick_transforms.py | 101 ++++++++++++++++++ 2 files changed, 130 insertions(+), 5 deletions(-) diff --git a/monai/apps/nuclick/transforms.py b/monai/apps/nuclick/transforms.py index 6b6542308a7..dd63e230db5 100644 --- a/monai/apps/nuclick/transforms.py +++ b/monai/apps/nuclick/transforms.py @@ -12,6 +12,7 @@ from __future__ import annotations import math +import warnings from typing import Any import numpy as np @@ -137,7 +138,8 @@ class SplitLabeld(MapTransform): label: key of the label source others: other labels storage key, defaults to ``"others"`` mask_value: the mask_value that will be kept for binarization of the label, defaults to ``"mask_value"`` - min_area: The smallest allowable object size. + min_area: The smallest allowable size (in pixels) of an object in ``others``; connected components of + ``others`` smaller than this are removed. Defaults to ``0``, which keeps all the objects. others_value: Value/class for other nuclei; Use this to separate core nuclei vs others. to_binary_mask: Convert mask to binary; Set it false to restore original class values """ @@ -147,7 +149,7 @@ def __init__( keys: KeysCollection, others: str = NuclickKeys.OTHERS, mask_value: str | None = NuclickKeys.MASK_VALUE, - min_area: int = 5, + min_area: int = 0, others_value: int = 0, to_binary_mask: bool = True, ): @@ -184,6 +186,11 @@ def __call__(self, data): others[others > 0] = 1 if torch.count_nonzero(others): others = measure.label(convert_to_numpy(others)[0], connectivity=1) + if self.min_area > 0: + areas = np.bincount(others.ravel()) + small = areas < self.min_area + small[0] = False + others[small[others]] = 0 others = torch.from_numpy(others)[None] label = mask.type(torch.uint8) if isinstance(mask, torch.Tensor) else mask @@ -528,7 +535,10 @@ class PostFilterLabeld(MapTransform): thresh: probability threshold for classifying a pixel as a mask min_size: min_size objects that will be removed from the image, refer skimage remove_small_objects min_hole: min_hole that will be removed from the image, refer skimage remove_small_holes - do_reconstruction: Boolean Flag, Perform a morphological reconstruction of an image, refer skimage + nuc_points: key of the click map of each patch, an array of shape (N, 1, H, W) or (N, H, W) where N is + the number of bounding boxes; only read when ``do_reconstruction`` is ``True``. + do_reconstruction: Boolean Flag, if ``True``, keep only the parts of each predicted mask that are connected + to its click point (``nuc_points``), using a morphological reconstruction, refer skimage allow_missing_keys: don't raise exception if key is missing. pred_classes: List of Predicted class for each instance """ @@ -569,15 +579,29 @@ def __call__(self, data): for key in self.keys: label = d[key].astype(np.uint8) - masks = self.post_processing(label, self.thresh, self.min_size, self.min_hole) + masks = self.post_processing( + label, + self.thresh, + self.min_size, + self.min_hole, + do_reconstruction=self.do_reconstruction, + nuc_points=d[self.nuc_points] if self.do_reconstruction else None, + ) d[key] = self.gen_instance_map(masks, bounding_boxes, x, y, pred_classes=pred_classes).astype(np.uint8) return d - def post_processing(self, preds, thresh=0.33, min_size=10, min_hole=30): + def post_processing(self, preds, thresh=0.33, min_size=10, min_hole=30, do_reconstruction=False, nuc_points=None): masks = preds > thresh for i in range(preds.shape[0]): masks[i] = morphology.remove_small_objects(masks[i], min_size=min_size) masks[i] = morphology.remove_small_holes(masks[i], area_threshold=min_hole) + if do_reconstruction: + marker = convert_to_numpy(nuc_points[i]).reshape(masks[i].shape) > 0 + seed = marker & masks[i] + if seed.any(): + masks[i] = morphology.reconstruction(seed, masks[i], footprint=morphology.disk(1)) > 0 + elif masks[i].any(): + warnings.warn(f"Nuclei reconstruction skipped for patch #{i}: click point is outside the mask.") return masks def gen_instance_map(self, masks, bounding_boxes, x, y, flatten=True, pred_classes=None): diff --git a/tests/apps/nuclick/test_nuclick_transforms.py b/tests/apps/nuclick/test_nuclick_transforms.py index a6e66c36586..6a09c89d29f 100644 --- a/tests/apps/nuclick/test_nuclick_transforms.py +++ b/tests/apps/nuclick/test_nuclick_transforms.py @@ -65,6 +65,21 @@ LABEL_4 = np.array([[[4, 4, 4, 4], [4, 4, 4, 4], [4, 4, 4, 4], [4, 4, 4, 4]]], dtype=np.uint8) +# target nucleus (1), a 9-pixel neighbor (2) and a 1-pixel fragment of another neighbor (3) +LABEL_5 = np.array( + [ + [ + [1, 1, 0, 0, 0, 0], + [1, 1, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0], + [0, 0, 0, 2, 2, 2], + [0, 0, 0, 2, 2, 2], + [3, 0, 0, 2, 2, 2], + ] + ], + dtype=np.uint8, +) + IL_IMAGE_1 = np.array( [ [[0, 0, 0, 0, 0], [0, 1, 0, 0, 0], [0, 0, 1, 1, 1], [0, 0, 1, 1, 1], [0, 0, 1, 1, 1]], @@ -89,6 +104,19 @@ IL_LABEL_2 = np.array([[[0, 0, 0], [0, 1, 0], [0, 0, 0]]], dtype=np.uint8) +# two 16-pixel blobs in one 10x10 patch +PRED_2 = np.zeros((1, 10, 10), dtype=np.float32) +PRED_2[0, 1:5, 1:5] = 1.0 +PRED_2[0, 6:10, 6:10] = 1.0 + +# click inside the first blob +NUC_POINTS_2 = np.zeros((1, 1, 10, 10), dtype=np.float32) +NUC_POINTS_2[0, 0, 2, 2] = 1.0 + +# click outside both blobs +NUC_POINTS_3 = np.zeros((1, 1, 10, 10), dtype=np.float32) +NUC_POINTS_3[0, 0, 0, 9] = 1.0 + PRED_1 = np.array( [[[1, 1, 1, 1, 1], [2, 0, 0, 0, 2], [3, 0, 0, 0, 3], [4, 0, 0, 0, 4], [5, 5, 5, 5, 5]]], dtype=np.float32 ) @@ -112,6 +140,7 @@ DATA_SPLIT_1 = {"label": LABEL_3, "mask_value": 1} DATA_SPLIT_2 = {"label": LABEL_4, "mask_value": 4} +DATA_SPLIT_3 = {"label": LABEL_5, "mask_value": 1} DATA_GUIDANCE_1 = {"image": IL_IMAGE_1, "label": IL_LABEL_1, "others": IL_OTHERS_1, "centroid": (2, 2)} @@ -125,6 +154,14 @@ "img_width": 6, } +DATA_LABEL_FILTER_2 = { + "pred": PRED_2, + "nuc_points": NUC_POINTS_2, + "bounding_boxes": [[0, 0, 10, 10]], + "img_height": 10, + "img_width": 10, +} + # Result Definitions EXTRACT_RESULT_TC1 = np.array([[[0, 0, 0], [0, 0, 0], [0, 0, 1]]], dtype=np.uint8) EXTRACT_RESULT_TC2 = np.array([[[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]]], dtype=np.uint8) @@ -132,6 +169,25 @@ SPLIT_RESULT_TC1 = np.array([[[1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]]], dtype=np.uint8) SPLIT_RESULT_TC2 = np.array([[[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]]], dtype=np.uint8) +SPLIT_OTHERS_ALL = np.array( + [ + [ + [0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0], + [0, 0, 0, 1, 1, 1], + [0, 0, 0, 1, 1, 1], + [2, 0, 0, 1, 1, 1], + ] + ], + dtype=np.uint8, +) +SPLIT_OTHERS_NO_FRAGMENT = SPLIT_OTHERS_ALL * (SPLIT_OTHERS_ALL == 1) + +LABEL_FILTER_BOTH_BLOBS = (PRED_2[0] > 0).astype(np.uint8) +LABEL_FILTER_CLICKED_BLOB = np.zeros((10, 10), dtype=np.uint8) +LABEL_FILTER_CLICKED_BLOB[1:5, 1:5] = 1 + # Test Case Definitions FILTER_IMAGE_TEST_CASE_1 = [{"keys": "image", "min_size": 1}, DATA_FILTER_1, [3, 3, 3]] @@ -155,6 +211,12 @@ SPLIT_TEST_CASE_1 = [{"keys": ["label"], "mask_value": "mask_value", "min_area": 1}, DATA_SPLIT_1, SPLIT_RESULT_TC1] SPLIT_TEST_CASE_2 = [{"keys": ["label"], "mask_value": "mask_value", "min_area": 3}, DATA_SPLIT_2, SPLIT_RESULT_TC2] +SPLIT_OTHERS_TEST_CASE_1 = [{"keys": ["label"]}, DATA_SPLIT_3, SPLIT_OTHERS_ALL] +SPLIT_OTHERS_TEST_CASE_2 = [{"keys": ["label"], "min_area": 1}, DATA_SPLIT_3, SPLIT_OTHERS_ALL] +SPLIT_OTHERS_TEST_CASE_3 = [{"keys": ["label"], "min_area": 2}, DATA_SPLIT_3, SPLIT_OTHERS_NO_FRAGMENT] +SPLIT_OTHERS_TEST_CASE_4 = [{"keys": ["label"], "min_area": 9}, DATA_SPLIT_3, SPLIT_OTHERS_NO_FRAGMENT] +SPLIT_OTHERS_TEST_CASE_5 = [{"keys": ["label"], "min_area": 10}, DATA_SPLIT_3, np.zeros_like(SPLIT_OTHERS_ALL)] + GUIDANCE_TEST_CASE_1 = [{"image": "image", "label": "label", "others": "others"}, DATA_GUIDANCE_1, [5, 5, 5]] GUIDANCE_TEST_CASE_2 = [ {"image": "image", "label": "label", "others": "others", "gaussian": True, "use_distance": True}, @@ -171,6 +233,18 @@ LABEL_FILTER_TEST_CASE_1 = [{"keys": ["pred"]}, DATA_LABEL_FILTER_1, [6, 6]] +LABEL_FILTER_RESULT_TEST_CASE_1 = [{"keys": ["pred"]}, DATA_LABEL_FILTER_2, LABEL_FILTER_BOTH_BLOBS] +LABEL_FILTER_RESULT_TEST_CASE_2 = [ + {"keys": ["pred"], "do_reconstruction": True}, + DATA_LABEL_FILTER_2, + LABEL_FILTER_CLICKED_BLOB, +] +LABEL_FILTER_RESULT_TEST_CASE_3 = [ + {"keys": ["pred"], "do_reconstruction": True}, + {**DATA_LABEL_FILTER_2, "nuc_points": NUC_POINTS_2[:, 0]}, + LABEL_FILTER_CLICKED_BLOB, +] + LABEL_GUIDANCE_TEST_CASE_1 = [{"keys": ["image"], "source": "label"}, DATA_GUIDANCE_1, [4, 5, 5]] LABEL_CLASS_TEST_CASE_1 = [{"keys": ["label"], "offset": 2}, DATA_GUIDANCE_1, 3] @@ -214,6 +288,20 @@ def test_correct_results(self, arguments, input_data, expected_result): result = SplitLabeld(**arguments)(input_data) np.testing.assert_equal(result["label"], expected_result) + @parameterized.expand( + [ + SPLIT_OTHERS_TEST_CASE_1, + SPLIT_OTHERS_TEST_CASE_2, + SPLIT_OTHERS_TEST_CASE_3, + SPLIT_OTHERS_TEST_CASE_4, + SPLIT_OTHERS_TEST_CASE_5, + ] + ) + def test_min_area(self, arguments, input_data, expected_others): + result = SplitLabeld(**arguments)(input_data) + np.testing.assert_equal(result["label"], (LABEL_5 == 1).astype(np.uint8)) + np.testing.assert_equal(result["others"], expected_others) + class TestGuidanceSignal(unittest.TestCase): @@ -238,6 +326,19 @@ def test_correct_shape(self, arguments, input_data, expected_shape): result = PostFilterLabeld(**arguments)(input_data) np.testing.assert_equal(result["pred"].shape, expected_shape) + @parameterized.expand( + [LABEL_FILTER_RESULT_TEST_CASE_1, LABEL_FILTER_RESULT_TEST_CASE_2, LABEL_FILTER_RESULT_TEST_CASE_3] + ) + def test_correct_results(self, arguments, input_data, expected_result): + result = PostFilterLabeld(**arguments)(input_data) + np.testing.assert_equal(result["pred"], expected_result) + + def test_reconstruction_click_outside_mask(self): + data = {**DATA_LABEL_FILTER_2, "nuc_points": NUC_POINTS_3} + with self.assertWarns(UserWarning): + result = PostFilterLabeld(keys=["pred"], do_reconstruction=True)(data) + np.testing.assert_equal(result["pred"], LABEL_FILTER_BOTH_BLOBS) + class TestAddLabelAsGuidance(unittest.TestCase): From 968cc57041f57007366828130da20ed59e714dfe Mon Sep 17 00:00:00 2001 From: Abhigyan Kumar Singh Date: Tue, 10 Nov 2026 04:53:45 +0000 Subject: [PATCH 2/2] Add docstring to PostFilterLabeld.post_processing Addresses review feedback on #9265. Assisted-by: Claude Code (claude-sonnet-5-5) Signed-off-by: Abhigyan Kumar Singh --- monai/apps/nuclick/transforms.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/monai/apps/nuclick/transforms.py b/monai/apps/nuclick/transforms.py index dd63e230db5..541afe8aa74 100644 --- a/monai/apps/nuclick/transforms.py +++ b/monai/apps/nuclick/transforms.py @@ -591,6 +591,25 @@ def __call__(self, data): return d def post_processing(self, preds, thresh=0.33, min_size=10, min_hole=30, do_reconstruction=False, nuc_points=None): + """ + Threshold the predictions and clean up each mask. + + Args: + preds: predicted probabilities, shape (N, H, W). + thresh: threshold applied to ``preds`` to obtain binary masks. + min_size: objects smaller than this are removed. + min_hole: holes smaller than this are filled. + do_reconstruction: if True, keep only the parts of each mask connected to its click point in + ``nuc_points``. A patch whose click is outside the mask is left unchanged, with a warning. + nuc_points: click points, one per patch (``nuc_points[i]`` is reshaped to the mask shape). + Required when ``do_reconstruction`` is True. + + Returns: + Boolean masks with the same shape as ``preds``. + + Raises: + TypeError: if ``do_reconstruction`` is True and ``nuc_points`` is None. + """ masks = preds > thresh for i in range(preds.shape[0]): masks[i] = morphology.remove_small_objects(masks[i], min_size=min_size)