From d999603a17da006d5dc3afb0ddc19494ad701267 Mon Sep 17 00:00:00 2001 From: Soumya Snigdha Kundu Date: Sat, 3 Jan 2026 16:22:26 +0000 Subject: [PATCH 1/3] Rename NormalizeLabelsInDatasetd to RemapLabelsToSequentiald and fix label ordering bug ### Description Rename NormalizeLabelsInDatasetd to RemapLabelsToSequentiald to better describe its actual functionality. The old name was confusing as it suggests normalization when it actually remaps arbitrary label values to sequential indices (0, 1, 2, 3, ...). ### Bug Fix Fixed a bug where the order of labels in the input dictionary affected the output. Previously, if background appeared first (e.g., `{background: 0, organ1: 1, organ2: 2}`), the transform would skip index 1 and produce `{background: 0, organ1: 2, organ2: 3}`. This was caused by enumerate starting at 1 for all items but skipping background without adjusting the index. The fix excludes background from enumeration and handles it separately. ### Changes - Renamed NormalizeLabelsInDatasetd to RemapLabelsToSequentiald - Fixed label ordering bug by excluding background from enumeration - Kept NormalizeLabelsInDatasetd as deprecated alias for backward compatibility - Enhanced documentation to clearly explain remapping behavior - Added alphabetical sorting for deterministic output ordering - Added tests for deprecated name warning and proper remapping ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality) - [x] New tests added to cover the changes Signed-off-by: Soumya Snigdha Kundu --- monai/apps/deepedit/transforms.py | 85 +++++++++++++++---- .../apps/deepedit/test_deepedit_transforms.py | 65 ++++++++++++++ 2 files changed, 135 insertions(+), 15 deletions(-) diff --git a/monai/apps/deepedit/transforms.py b/monai/apps/deepedit/transforms.py index 5af082e2b0..f19f5adc34 100644 --- a/monai/apps/deepedit/transforms.py +++ b/monai/apps/deepedit/transforms.py @@ -84,18 +84,44 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> dict[Hashable, np.nda return d -class NormalizeLabelsInDatasetd(MapTransform): +class RemapLabelsToSequentiald(MapTransform): + """ + Remap label values from a dataset-specific schema to sequential indices (0, 1, 2, 3, ...). + + This transform takes labels with arbitrary values defined in a label dictionary and remaps them + to a sequential range starting from 1 (with background always set to 0). This is useful for + standardizing labels across different datasets or ensuring labels are in a contiguous range. + + The output label indices are assigned in alphabetical order by label name to ensure + deterministic behavior regardless of input dictionary ordering. + + Args: + keys: The ``keys`` parameter will be used to get and set the actual data item to transform + label_names: Dictionary mapping label names to their current values in the dataset. + For example: {"spleen": 1, "liver": 6, "background": 0} + Will be remapped to: {"background": 0, "liver": 1, "spleen": 2} + (alphabetically sorted, excluding background) + allow_missing_keys: If True, missing keys in the data dictionary will not raise an error + + Example: + >>> transform = RemapLabelsToSequentiald( + ... keys="label", + ... label_names={"liver": 6, "spleen": 1, "background": 0} + ... ) + >>> # Input label has values [0, 1, 6] + >>> # Output label will have values [0, 1, 2] (background=0, liver=1, spleen=2) + >>> # And updates d["label_names"] to {"background": 0, "liver": 1, "spleen": 2} + + Note: + - Background label (if present) is always mapped to 0 + - Non-background labels are mapped to sequential indices 1, 2, 3, ... in alphabetical order + - Undefined labels (not in label_names) will be set to 0 (background) + - The transform updates the data dictionary with a new "label_names" key containing the remapped values + """ def __init__( self, keys: KeysCollection, label_names: dict[str, int] | None = None, allow_missing_keys: bool = False ): - """ - Normalize label values according to label names dictionary - - Args: - keys: The ``keys`` parameter will be used to get and set the actual data item to transform - label_names: all label names - """ super().__init__(keys, allow_missing_keys) self.label_names = label_names or {} @@ -106,13 +132,20 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> dict[Hashable, np.nda # Dictionary containing new label numbers new_label_names = {} label = np.zeros(d[key].shape) - # Making sure the range values and number of labels are the same - for idx, (key_label, val_label) in enumerate(self.label_names.items(), start=1): - if key_label != "background": - new_label_names[key_label] = idx - label[d[key] == val_label] = idx - if key_label == "background": - new_label_names["background"] = 0 + + # Sort label names to ensure deterministic ordering (exclude background) + sorted_labels = sorted( + [(k, v) for k, v in self.label_names.items() if k != "background"] + ) + + # Always set background to 0 first + if "background" in self.label_names: + new_label_names["background"] = 0 + + # Assign sequential indices to sorted non-background labels + for idx, (key_label, val_label) in enumerate(sorted_labels, start=1): + new_label_names[key_label] = idx + label[d[key] == val_label] = idx d["label_names"] = new_label_names if isinstance(d[key], MetaTensor): @@ -122,6 +155,28 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> dict[Hashable, np.nda return d +class NormalizeLabelsInDatasetd(RemapLabelsToSequentiald): + """ + .. deprecated:: 1.5.0 + `NormalizeLabelsInDatasetd` is deprecated. Use :class:`RemapLabelsToSequentiald` instead. + + This class is maintained for backward compatibility. Please use RemapLabelsToSequentiald + which better describes the transform's functionality. + """ + + def __init__( + self, keys: KeysCollection, label_names: dict[str, int] | None = None, allow_missing_keys: bool = False + ): + warnings.warn( + "NormalizeLabelsInDatasetd is deprecated and will be removed in a future version. " + "Please use RemapLabelsToSequentiald instead, which better describes what the transform does: " + "remapping label values to sequential indices (0, 1, 2, 3, ...).", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(keys, label_names, allow_missing_keys) + + class SingleLabelSelectiond(MapTransform): def __init__( diff --git a/tests/apps/deepedit/test_deepedit_transforms.py b/tests/apps/deepedit/test_deepedit_transforms.py index 18d6567fd7..e367b59e03 100644 --- a/tests/apps/deepedit/test_deepedit_transforms.py +++ b/tests/apps/deepedit/test_deepedit_transforms.py @@ -25,6 +25,7 @@ FindAllValidSlicesMissingLabelsd, FindDiscrepancyRegionsDeepEditd, NormalizeLabelsInDatasetd, + RemapLabelsToSequentiald, ResizeGuidanceMultipleLabelDeepEditd, SingleLabelSelectiond, SplitPredsLabeld, @@ -282,6 +283,70 @@ def test_correct_results(self, arguments, input_data, expected_result): result = add_fn(input_data) self.assertEqual(len(np.unique(result["label"])), expected_result) + def test_ordering_determinism(self): + """Test that different input ordering produces the same output (alphabetical)""" + # Create a label array with different label values + label = np.array([[[0, 1, 6, 3]]]) # background=0, spleen=1, liver=6, kidney=3 + + # Test case 1: liver first, then kidney, then spleen + data1 = {"label": label.copy()} + transform1 = RemapLabelsToSequentiald( + keys="label", + label_names={"liver": 6, "kidney": 3, "spleen": 1, "background": 0} + ) + result1 = transform1(data1) + + # Test case 2: spleen first, then kidney, then liver (different order) + data2 = {"label": label.copy()} + transform2 = RemapLabelsToSequentiald( + keys="label", + label_names={"spleen": 1, "kidney": 3, "liver": 6, "background": 0} + ) + result2 = transform2(data2) + + # Both should produce the same output (alphabetically sorted) + # Expected mapping: background=0, kidney=1, liver=2, spleen=3 + np.testing.assert_array_equal(result1["label"], result2["label"]) + + # Verify the actual mapping is alphabetical + expected_output = np.array([[[0, 3, 2, 1]]]) # kidney=1, liver=2, spleen=3, background=0 + np.testing.assert_array_equal(result1["label"], expected_output) + + # Verify label_names is correct + self.assertEqual(result1["label_names"], {"background": 0, "kidney": 1, "liver": 2, "spleen": 3}) + self.assertEqual(result2["label_names"], {"background": 0, "kidney": 1, "liver": 2, "spleen": 3}) + + def test_multiple_labels(self): + """Test with multiple non-background labels""" + label = np.array([[[0, 1, 2, 5]]]) # background, spleen, kidney, liver + data = {"label": label.copy()} + transform = RemapLabelsToSequentiald( + keys="label", + label_names={"spleen": 1, "kidney": 2, "liver": 5, "background": 0} + ) + result = transform(data) + + # Expected: background=0, kidney=1, liver=2, spleen=3 (alphabetical) + expected = np.array([[[0, 3, 1, 2]]]) + np.testing.assert_array_equal(result["label"], expected) + self.assertEqual(result["label_names"], {"background": 0, "kidney": 1, "liver": 2, "spleen": 3}) + + def test_deprecated_name_warning(self): + """Test that using the deprecated name raises a warning""" + import warnings + + data = {"label": np.array([[[0, 1]]])} + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + transform = NormalizeLabelsInDatasetd(keys="label", label_names={"spleen": 1, "background": 0}) + _ = transform(data) # Call to trigger the warning + + # Check that a deprecation warning was raised + self.assertEqual(len(w), 1) + self.assertTrue(issubclass(w[0].category, DeprecationWarning)) + self.assertIn("RemapLabelsToSequentiald", str(w[0].message)) + class TestResizeGuidanceMultipleLabelCustomd(unittest.TestCase): From a42905105ee7987141fcc067114020d520cae6a7 Mon Sep 17 00:00:00 2001 From: Soumya Snigdha Kundu Date: Wed, 7 Jan 2026 15:33:37 +0000 Subject: [PATCH 2/3] Use MONAI deprecated decorator Signed-off-by: Soumya Snigdha Kundu --- monai/apps/deepedit/transforms.py | 20 ++++++------------- .../apps/deepedit/test_deepedit_transforms.py | 17 +++++++++++----- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/monai/apps/deepedit/transforms.py b/monai/apps/deepedit/transforms.py index f19f5adc34..a5c56cd39a 100644 --- a/monai/apps/deepedit/transforms.py +++ b/monai/apps/deepedit/transforms.py @@ -24,7 +24,7 @@ from monai.data import MetaTensor from monai.networks.layers import GaussianFilter from monai.transforms.transform import MapTransform, Randomizable, Transform -from monai.utils import min_version, optional_import +from monai.utils import deprecated, min_version, optional_import measure, _ = optional_import("skimage.measure", "0.14.2", min_version) @@ -155,26 +155,18 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> dict[Hashable, np.nda return d +@deprecated(since="1.6", removed="1.8", msg_suffix="Use `RemapLabelsToSequentiald` instead.") class NormalizeLabelsInDatasetd(RemapLabelsToSequentiald): """ - .. deprecated:: 1.5.0 - `NormalizeLabelsInDatasetd` is deprecated. Use :class:`RemapLabelsToSequentiald` instead. + .. deprecated:: 1.6.0 + `NormalizeLabelsInDatasetd` is deprecated and will be removed in version 1.8.0. + Use :class:`RemapLabelsToSequentiald` instead. This class is maintained for backward compatibility. Please use RemapLabelsToSequentiald which better describes the transform's functionality. """ - def __init__( - self, keys: KeysCollection, label_names: dict[str, int] | None = None, allow_missing_keys: bool = False - ): - warnings.warn( - "NormalizeLabelsInDatasetd is deprecated and will be removed in a future version. " - "Please use RemapLabelsToSequentiald instead, which better describes what the transform does: " - "remapping label values to sequential indices (0, 1, 2, 3, ...).", - DeprecationWarning, - stacklevel=2, - ) - super().__init__(keys, label_names, allow_missing_keys) + pass class SingleLabelSelectiond(MapTransform): diff --git a/tests/apps/deepedit/test_deepedit_transforms.py b/tests/apps/deepedit/test_deepedit_transforms.py index e367b59e03..3bbdb76aed 100644 --- a/tests/apps/deepedit/test_deepedit_transforms.py +++ b/tests/apps/deepedit/test_deepedit_transforms.py @@ -332,19 +332,26 @@ def test_multiple_labels(self): self.assertEqual(result["label_names"], {"background": 0, "kidney": 1, "liver": 2, "spleen": 3}) def test_deprecated_name_warning(self): - """Test that using the deprecated name raises a warning""" + """Test that using the deprecated name raises a warning when version >= 1.6""" import warnings + from monai.utils import deprecated + + # Create a test class with version_val to simulate version 1.6 + @deprecated(since="1.6", removed="1.8", msg_suffix="Use `RemapLabelsToSequentiald` instead.", version_val="1.6") + class TestDeprecatedClass(RemapLabelsToSequentiald): + pass + data = {"label": np.array([[[0, 1]]])} with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") - transform = NormalizeLabelsInDatasetd(keys="label", label_names={"spleen": 1, "background": 0}) - _ = transform(data) # Call to trigger the warning + transform = TestDeprecatedClass(keys="label", label_names={"spleen": 1, "background": 0}) + _ = transform(data) # Invoke the transform to confirm functionality - # Check that a deprecation warning was raised + # Check that a deprecation warning was raised (deprecated decorator uses FutureWarning) self.assertEqual(len(w), 1) - self.assertTrue(issubclass(w[0].category, DeprecationWarning)) + self.assertTrue(issubclass(w[0].category, FutureWarning)) self.assertIn("RemapLabelsToSequentiald", str(w[0].message)) From 4916f3a0c80d6da070c48d6081034d209580795e Mon Sep 17 00:00:00 2001 From: Soumya Snigdha Kundu Date: Wed, 7 Jan 2026 19:27:26 +0000 Subject: [PATCH 3/3] Fix code formatting and improve deprecation test - Apply black formatting to transforms.py and test file - Improve test_deprecated_name_warning docstring to explain the testing approach - Add verification that actual NormalizeLabelsInDatasetd class works - Rename test class to DeprecatedNormalizeLabels for clarity Signed-off-by: Soumya Snigdha Kundu --- monai/apps/deepedit/transforms.py | 4 +- .../apps/deepedit/test_deepedit_transforms.py | 49 ++++++++++++++----- 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/monai/apps/deepedit/transforms.py b/monai/apps/deepedit/transforms.py index a5c56cd39a..14c37be860 100644 --- a/monai/apps/deepedit/transforms.py +++ b/monai/apps/deepedit/transforms.py @@ -134,9 +134,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> dict[Hashable, np.nda label = np.zeros(d[key].shape) # Sort label names to ensure deterministic ordering (exclude background) - sorted_labels = sorted( - [(k, v) for k, v in self.label_names.items() if k != "background"] - ) + sorted_labels = sorted([(k, v) for k, v in self.label_names.items() if k != "background"]) # Always set background to 0 first if "background" in self.label_names: diff --git a/tests/apps/deepedit/test_deepedit_transforms.py b/tests/apps/deepedit/test_deepedit_transforms.py index 3bbdb76aed..db4d872d56 100644 --- a/tests/apps/deepedit/test_deepedit_transforms.py +++ b/tests/apps/deepedit/test_deepedit_transforms.py @@ -291,16 +291,14 @@ def test_ordering_determinism(self): # Test case 1: liver first, then kidney, then spleen data1 = {"label": label.copy()} transform1 = RemapLabelsToSequentiald( - keys="label", - label_names={"liver": 6, "kidney": 3, "spleen": 1, "background": 0} + keys="label", label_names={"liver": 6, "kidney": 3, "spleen": 1, "background": 0} ) result1 = transform1(data1) # Test case 2: spleen first, then kidney, then liver (different order) data2 = {"label": label.copy()} transform2 = RemapLabelsToSequentiald( - keys="label", - label_names={"spleen": 1, "kidney": 3, "liver": 6, "background": 0} + keys="label", label_names={"spleen": 1, "kidney": 3, "liver": 6, "background": 0} ) result2 = transform2(data2) @@ -321,8 +319,7 @@ def test_multiple_labels(self): label = np.array([[[0, 1, 2, 5]]]) # background, spleen, kidney, liver data = {"label": label.copy()} transform = RemapLabelsToSequentiald( - keys="label", - label_names={"spleen": 1, "kidney": 2, "liver": 5, "background": 0} + keys="label", label_names={"spleen": 1, "kidney": 2, "liver": 5, "background": 0} ) result = transform(data) @@ -332,28 +329,54 @@ def test_multiple_labels(self): self.assertEqual(result["label_names"], {"background": 0, "kidney": 1, "liver": 2, "spleen": 3}) def test_deprecated_name_warning(self): - """Test that using the deprecated name raises a warning when version >= 1.6""" + """Test that NormalizeLabelsInDatasetd is properly deprecated. + + The deprecation warning only triggers when MONAI version >= 1.6 (since="1.6"). + This test verifies: + 1. The actual NormalizeLabelsInDatasetd class is marked as deprecated in docstring + 2. The class is a subclass of RemapLabelsToSequentiald + 3. The deprecation mechanism works correctly (tested via version_val simulation) + 4. The actual class functions correctly + """ import warnings from monai.utils import deprecated - # Create a test class with version_val to simulate version 1.6 - @deprecated(since="1.6", removed="1.8", msg_suffix="Use `RemapLabelsToSequentiald` instead.", version_val="1.6") - class TestDeprecatedClass(RemapLabelsToSequentiald): + # Verify NormalizeLabelsInDatasetd docstring indicates deprecation + self.assertIn("deprecated", NormalizeLabelsInDatasetd.__doc__.lower()) + self.assertIn("RemapLabelsToSequentiald", NormalizeLabelsInDatasetd.__doc__) + + # Verify NormalizeLabelsInDatasetd is a subclass of RemapLabelsToSequentiald + self.assertTrue(issubclass(NormalizeLabelsInDatasetd, RemapLabelsToSequentiald)) + + # Test the deprecation mechanism using version_val to simulate version 1.6 + # This verifies the @deprecated decorator behavior that NormalizeLabelsInDatasetd uses + @deprecated( + since="1.6", + removed="1.8", + msg_suffix="Use `RemapLabelsToSequentiald` instead.", + version_val="1.6", # Simulate version 1.6 to trigger warning + ) + class DeprecatedNormalizeLabels(RemapLabelsToSequentiald): pass data = {"label": np.array([[[0, 1]]])} with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") - transform = TestDeprecatedClass(keys="label", label_names={"spleen": 1, "background": 0}) - _ = transform(data) # Invoke the transform to confirm functionality + transform = DeprecatedNormalizeLabels(keys="label", label_names={"spleen": 1, "background": 0}) + _ = transform(data) - # Check that a deprecation warning was raised (deprecated decorator uses FutureWarning) + # Check that a deprecation warning was raised self.assertEqual(len(w), 1) self.assertTrue(issubclass(w[0].category, FutureWarning)) self.assertIn("RemapLabelsToSequentiald", str(w[0].message)) + # Verify the actual NormalizeLabelsInDatasetd class works correctly + transform_actual = NormalizeLabelsInDatasetd(keys="label", label_names={"spleen": 1, "background": 0}) + result = transform_actual({"label": np.array([[[0, 1]]])}) + self.assertIn("label", result) + class TestResizeGuidanceMultipleLabelCustomd(unittest.TestCase):