Skip to content

Commit e1f6041

Browse files
committed
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 <soumya_snigdha.kundu@kcl.ac.uk>
1 parent 15fd428 commit e1f6041

File tree

3 files changed

+169
-15
lines changed

3 files changed

+169
-15
lines changed

monai/apps/deepedit/__init__.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,37 @@
88
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
99
# See the License for the specific language governing permissions and
1010
# limitations under the License.
11+
12+
from .transforms import (
13+
AddGuidanceFromPointsDeepEditd,
14+
AddGuidanceSignalDeepEditd,
15+
AddInitialSeedPointDeepEditd,
16+
AddInitialSeedPointMissingLabelsd,
17+
AddRandomGuidanceDeepEditd,
18+
DiscardAddGuidanced,
19+
FindAllValidSlicesDeepEditd,
20+
FindAllValidSlicesMissingLabelsd,
21+
FindDiscrepancyRegionsDeepEditd,
22+
NormalizeLabelsInDatasetd,
23+
RemapLabelsToSequentiald,
24+
ResizeGuidanceMultipleLabelDeepEditd,
25+
SingleLabelSelectiond,
26+
SplitPredsLabeld,
27+
)
28+
29+
__all__ = [
30+
"AddGuidanceFromPointsDeepEditd",
31+
"AddGuidanceSignalDeepEditd",
32+
"AddInitialSeedPointDeepEditd",
33+
"AddInitialSeedPointMissingLabelsd",
34+
"AddRandomGuidanceDeepEditd",
35+
"DiscardAddGuidanced",
36+
"FindAllValidSlicesDeepEditd",
37+
"FindAllValidSlicesMissingLabelsd",
38+
"FindDiscrepancyRegionsDeepEditd",
39+
"NormalizeLabelsInDatasetd",
40+
"RemapLabelsToSequentiald",
41+
"ResizeGuidanceMultipleLabelDeepEditd",
42+
"SingleLabelSelectiond",
43+
"SplitPredsLabeld",
44+
]

monai/apps/deepedit/transforms.py

Lines changed: 70 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -84,18 +84,44 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> dict[Hashable, np.nda
8484
return d
8585

8686

87-
class NormalizeLabelsInDatasetd(MapTransform):
87+
class RemapLabelsToSequentiald(MapTransform):
88+
"""
89+
Remap label values from a dataset-specific schema to sequential indices (0, 1, 2, 3, ...).
90+
91+
This transform takes labels with arbitrary values defined in a label dictionary and remaps them
92+
to a sequential range starting from 1 (with background always set to 0). This is useful for
93+
standardizing labels across different datasets or ensuring labels are in a contiguous range.
94+
95+
The output label indices are assigned in alphabetical order by label name to ensure
96+
deterministic behavior regardless of input dictionary ordering.
97+
98+
Args:
99+
keys: The ``keys`` parameter will be used to get and set the actual data item to transform
100+
label_names: Dictionary mapping label names to their current values in the dataset.
101+
For example: {"spleen": 1, "liver": 6, "background": 0}
102+
Will be remapped to: {"background": 0, "liver": 1, "spleen": 2}
103+
(alphabetically sorted, excluding background)
104+
allow_missing_keys: If True, missing keys in the data dictionary will not raise an error
105+
106+
Example:
107+
>>> transform = RemapLabelsToSequentiald(
108+
... keys="label",
109+
... label_names={"liver": 6, "spleen": 1, "background": 0}
110+
... )
111+
>>> # Input label has values [0, 1, 6]
112+
>>> # Output label will have values [0, 1, 2] (background=0, liver=1, spleen=2)
113+
>>> # And updates d["label_names"] to {"background": 0, "liver": 1, "spleen": 2}
114+
115+
Note:
116+
- Background label (if present) is always mapped to 0
117+
- Non-background labels are mapped to sequential indices 1, 2, 3, ... in alphabetical order
118+
- Undefined labels (not in label_names) will be set to 0 (background)
119+
- The transform updates the data dictionary with a new "label_names" key containing the remapped values
120+
"""
88121

89122
def __init__(
90123
self, keys: KeysCollection, label_names: dict[str, int] | None = None, allow_missing_keys: bool = False
91124
):
92-
"""
93-
Normalize label values according to label names dictionary
94-
95-
Args:
96-
keys: The ``keys`` parameter will be used to get and set the actual data item to transform
97-
label_names: all label names
98-
"""
99125
super().__init__(keys, allow_missing_keys)
100126

101127
self.label_names = label_names or {}
@@ -106,13 +132,20 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> dict[Hashable, np.nda
106132
# Dictionary containing new label numbers
107133
new_label_names = {}
108134
label = np.zeros(d[key].shape)
109-
# Making sure the range values and number of labels are the same
110-
for idx, (key_label, val_label) in enumerate(self.label_names.items(), start=1):
111-
if key_label != "background":
112-
new_label_names[key_label] = idx
113-
label[d[key] == val_label] = idx
114-
if key_label == "background":
115-
new_label_names["background"] = 0
135+
136+
# Sort label names to ensure deterministic ordering (exclude background)
137+
sorted_labels = sorted(
138+
[(k, v) for k, v in self.label_names.items() if k != "background"]
139+
)
140+
141+
# Always set background to 0 first
142+
if "background" in self.label_names:
143+
new_label_names["background"] = 0
144+
145+
# Assign sequential indices to sorted non-background labels
146+
for idx, (key_label, val_label) in enumerate(sorted_labels, start=1):
147+
new_label_names[key_label] = idx
148+
label[d[key] == val_label] = idx
116149

117150
d["label_names"] = new_label_names
118151
if isinstance(d[key], MetaTensor):
@@ -122,6 +155,28 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> dict[Hashable, np.nda
122155
return d
123156

124157

158+
class NormalizeLabelsInDatasetd(RemapLabelsToSequentiald):
159+
"""
160+
.. deprecated:: 1.5.0
161+
`NormalizeLabelsInDatasetd` is deprecated. Use :class:`RemapLabelsToSequentiald` instead.
162+
163+
This class is maintained for backward compatibility. Please use RemapLabelsToSequentiald
164+
which better describes the transform's functionality.
165+
"""
166+
167+
def __init__(
168+
self, keys: KeysCollection, label_names: dict[str, int] | None = None, allow_missing_keys: bool = False
169+
):
170+
warnings.warn(
171+
"NormalizeLabelsInDatasetd is deprecated and will be removed in a future version. "
172+
"Please use RemapLabelsToSequentiald instead, which better describes what the transform does: "
173+
"remapping label values to sequential indices (0, 1, 2, 3, ...).",
174+
DeprecationWarning,
175+
stacklevel=2,
176+
)
177+
super().__init__(keys, label_names, allow_missing_keys)
178+
179+
125180
class SingleLabelSelectiond(MapTransform):
126181

127182
def __init__(

tests/apps/deepedit/test_deepedit_transforms.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
FindAllValidSlicesMissingLabelsd,
2626
FindDiscrepancyRegionsDeepEditd,
2727
NormalizeLabelsInDatasetd,
28+
RemapLabelsToSequentiald,
2829
ResizeGuidanceMultipleLabelDeepEditd,
2930
SingleLabelSelectiond,
3031
SplitPredsLabeld,
@@ -282,6 +283,70 @@ def test_correct_results(self, arguments, input_data, expected_result):
282283
result = add_fn(input_data)
283284
self.assertEqual(len(np.unique(result["label"])), expected_result)
284285

286+
def test_ordering_determinism(self):
287+
"""Test that different input ordering produces the same output (alphabetical)"""
288+
# Create a label array with different label values
289+
label = np.array([[[0, 1, 6, 3]]]) # background=0, spleen=1, liver=6, kidney=3
290+
291+
# Test case 1: liver first, then kidney, then spleen
292+
data1 = {"label": label.copy()}
293+
transform1 = RemapLabelsToSequentiald(
294+
keys="label",
295+
label_names={"liver": 6, "kidney": 3, "spleen": 1, "background": 0}
296+
)
297+
result1 = transform1(data1)
298+
299+
# Test case 2: spleen first, then kidney, then liver (different order)
300+
data2 = {"label": label.copy()}
301+
transform2 = RemapLabelsToSequentiald(
302+
keys="label",
303+
label_names={"spleen": 1, "kidney": 3, "liver": 6, "background": 0}
304+
)
305+
result2 = transform2(data2)
306+
307+
# Both should produce the same output (alphabetically sorted)
308+
# Expected mapping: background=0, kidney=1, liver=2, spleen=3
309+
np.testing.assert_array_equal(result1["label"], result2["label"])
310+
311+
# Verify the actual mapping is alphabetical
312+
expected_output = np.array([[[0, 3, 2, 1]]]) # kidney=1, liver=2, spleen=3, background=0
313+
np.testing.assert_array_equal(result1["label"], expected_output)
314+
315+
# Verify label_names is correct
316+
self.assertEqual(result1["label_names"], {"background": 0, "kidney": 1, "liver": 2, "spleen": 3})
317+
self.assertEqual(result2["label_names"], {"background": 0, "kidney": 1, "liver": 2, "spleen": 3})
318+
319+
def test_multiple_labels(self):
320+
"""Test with multiple non-background labels"""
321+
label = np.array([[[0, 1, 2, 5]]]) # background, spleen, kidney, liver
322+
data = {"label": label.copy()}
323+
transform = RemapLabelsToSequentiald(
324+
keys="label",
325+
label_names={"spleen": 1, "kidney": 2, "liver": 5, "background": 0}
326+
)
327+
result = transform(data)
328+
329+
# Expected: background=0, kidney=1, liver=2, spleen=3 (alphabetical)
330+
expected = np.array([[[0, 3, 1, 2]]])
331+
np.testing.assert_array_equal(result["label"], expected)
332+
self.assertEqual(result["label_names"], {"background": 0, "kidney": 1, "liver": 2, "spleen": 3})
333+
334+
def test_deprecated_name_warning(self):
335+
"""Test that using the deprecated name raises a warning"""
336+
import warnings
337+
338+
data = {"label": np.array([[[0, 1]]])}
339+
340+
with warnings.catch_warnings(record=True) as w:
341+
warnings.simplefilter("always")
342+
transform = NormalizeLabelsInDatasetd(keys="label", label_names={"spleen": 1, "background": 0})
343+
result = transform(data)
344+
345+
# Check that a deprecation warning was raised
346+
self.assertEqual(len(w), 1)
347+
self.assertTrue(issubclass(w[0].category, DeprecationWarning))
348+
self.assertIn("RemapLabelsToSequentiald", str(w[0].message))
349+
285350

286351
class TestResizeGuidanceMultipleLabelCustomd(unittest.TestCase):
287352

0 commit comments

Comments
 (0)