Skip to content

Commit 8e53cfb

Browse files
azrabano23claude
andcommitted
Make NormalizeIntensity.inverse work with nonzero=True
With nonzero=True the inverse raised NotImplementedError, which made Compose.inverse and Invertd fail for any pipeline using the transform. Voxels that were zero on the forward pass are left at zero, so the forward mask can be rebuilt from the normalized image as `out != 0`. The only ambiguity is a non-zero voxel whose value equals the subtrahend: it becomes exactly zero. _normalize now records the flat indices of those voxels (usually an empty tensor) in the transform meta information as `zeroed_idx`, and inverse() restores `out * div + sub` on the rebuilt mask. Inversion is exact, including the value-equals-mean case. Also: all-zero inputs store identity stats (0.0/1.0) instead of None, so extra_info stays collate-safe; NormalizeIntensityd.inverse is typed on torch.Tensor to satisfy mypy; Google-style docstrings for the new methods; tests restore the track_meta state they change. Signed-off-by: Azra Bano <azrabano.work@gmail.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 1102656 commit 8e53cfb

4 files changed

Lines changed: 161 additions & 40 deletions

File tree

monai/transforms/intensity/array.py

Lines changed: 92 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
from monai.transforms.inverse import InvertibleTransform
3434
from monai.transforms.transform import RandomizableTransform, Transform
3535
from monai.transforms.utils import Fourier, equalize_hist, is_positive, rescale_array, soft_clip
36-
from monai.transforms.utils_pytorch_numpy_unification import clip, percentile, where
36+
from monai.transforms.utils_pytorch_numpy_unification import clip, nonzero, percentile, ravel, where
3737
from monai.utils.enums import TraceKeys, TransformBackends
3838
from monai.utils.misc import ensure_tuple, ensure_tuple_rep, ensure_tuple_size, fall_back_tuple
3939
from monai.utils.module import min_version, optional_import
@@ -847,8 +847,10 @@ class NormalizeIntensity(InvertibleTransform):
847847
848848
The subtrahend and divisor actually used (whether provided or computed) are stored in the
849849
transform's meta information, so the transform is invertible via :meth:`inverse`, recovering
850-
``img * divisor + subtrahend``. Inversion is not supported when ``nonzero=True``, because the
851-
zero-voxel mask would be required to reverse the operation exactly.
850+
``img * divisor + subtrahend``. With ``nonzero=True`` only the voxels that were non-zero on the
851+
forward pass are restored: they are identified as the non-zero voxels of the normalized image,
852+
plus the (usually empty) set of voxels whose value equalled the subtrahend exactly and therefore
853+
became zero, whose flat indices are also stored in the meta information.
852854
853855
Args:
854856
subtrahend: the amount to subtract by (usually the mean).
@@ -890,13 +892,28 @@ def _std(x):
890892
return x.item() if x.numel() == 1 else x
891893

892894
def _normalize(self, img: NdarrayOrTensor, sub=None, div=None):
895+
"""
896+
Normalize ``img`` in place where possible and report what was done, for :meth:`inverse`.
897+
898+
Args:
899+
img: image (or single channel when ``channel_wise=True``) to normalize.
900+
sub: subtrahend to use; computed as the mean of the (non-zero) voxels if None.
901+
div: divisor to use; computed as the std of the (non-zero) voxels if None.
902+
903+
Returns:
904+
a tuple ``(normalized, sub, div, zeroed_idx)``: the normalized image, the subtrahend and
905+
divisor actually used (identity ``0.0``/``1.0`` when ``nonzero=True`` and there is nothing to
906+
normalize), and, when ``nonzero=True``, the flat indices of voxels that were non-zero before but
907+
are exactly zero after normalization (``None`` when ``nonzero=False``).
908+
"""
893909
img, *_ = convert_data_type(img, dtype=torch.float32)
894910

895911
if self.nonzero:
896912
slices = img != 0
897913
masked_img = img[slices]
898914
if not slices.any():
899-
return img, None, None
915+
# nothing was normalized: store identity stats (keeps meta collate-safe) and no indices
916+
return img, 0.0, 1.0, nonzero(ravel(slices))
900917
else:
901918
slices = None
902919
masked_img = img
@@ -917,12 +934,16 @@ def _normalize(self, img: NdarrayOrTensor, sub=None, div=None):
917934
_div = _div[slices]
918935
_div[_div == 0.0] = 1.0
919936

937+
zeroed_idx = None
920938
if slices is not None:
921939
img[slices] = (masked_img - _sub) / _div
940+
# voxels that were non-zero but now equal zero (value == subtrahend) are indistinguishable
941+
# from the untouched zero voxels in the output, so record them for inverse().
942+
zeroed_idx = nonzero(ravel(slices & (img == 0)))
922943
else:
923944
img = (img - _sub) / _div
924945
# Return the subtrahend/divisor actually used so the transform can be inverted.
925-
return img, _sub, _div
946+
return img, _sub, _div, zeroed_idx
926947

927948
def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor:
928949
"""
@@ -931,9 +952,11 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor:
931952
img_t: torch.Tensor = convert_to_tensor(img, track_meta=get_track_meta()) # type: ignore[assignment]
932953
dtype = self.dtype or img.dtype
933954
img_len = len(img_t)
934-
# Subtrahend/divisor used per channel (channel_wise) or once (global), kept for inverse().
955+
# Subtrahend/divisor used per channel (channel_wise) or once (global), kept for inverse(),
956+
# plus (nonzero=True only) the indices of voxels that were zeroed by the normalization.
935957
subs: list = []
936958
divs: list = []
959+
zeroed: list = []
937960
if self.channel_wise:
938961
if self.subtrahend is not None and len(self.subtrahend) != img_len:
939962
raise ValueError(f"img has {img_len} channels, but subtrahend has {len(self.subtrahend)} components.")
@@ -944,31 +967,55 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor:
944967
img_t, *_ = convert_data_type(img_t, dtype=torch.float32)
945968

946969
for i, d in enumerate(img_t):
947-
img_t[i], _sub, _div = self._normalize( # type: ignore
970+
img_t[i], _sub, _div, _idx = self._normalize( # type: ignore
948971
d,
949972
sub=self.subtrahend[i] if self.subtrahend is not None else None,
950973
div=self.divisor[i] if self.divisor is not None else None,
951974
)
952975
subs.append(_sub)
953976
divs.append(_div)
977+
zeroed.append(_idx)
954978
else:
955-
img_t, _sub, _div = self._normalize(img_t, self.subtrahend, self.divisor) # type: ignore[assignment]
979+
img_t, _sub, _div, _idx = self._normalize(img_t, self.subtrahend, self.divisor) # type: ignore
956980
subs.append(_sub)
957981
divs.append(_div)
982+
zeroed.append(_idx)
958983

959984
out = convert_to_dst_type(img_t, img_t, dtype=dtype)[0]
960-
out = self._push_transform_with_stats(out, subs, divs)
961-
return out
985+
return self._push_transform_with_stats(out, subs, divs, zeroed)
962986

963-
def _to_storable(self, value):
964-
"""Convert a subtrahend/divisor to something storable in transform meta."""
987+
@staticmethod
988+
def _to_storable(value):
989+
"""
990+
Convert a value computed on the forward pass to something storable in the transform meta information.
991+
992+
Args:
993+
value: a subtrahend, divisor or index array; a python/numpy scalar, ``np.ndarray`` or ``torch.Tensor``.
994+
995+
Returns:
996+
a detached, plain (non-Meta) CPU ``torch.Tensor`` for array inputs, otherwise the value unchanged.
997+
"""
998+
if isinstance(value, MetaTensor):
999+
value = value.as_tensor()
9651000
if isinstance(value, torch.Tensor):
9661001
return value.detach().cpu()
9671002
if isinstance(value, np.ndarray):
9681003
return torch.as_tensor(value)
9691004
return value # python/numpy scalar
9701005

971-
def _push_transform_with_stats(self, out, subs: list, divs: list):
1006+
def _push_transform_with_stats(self, out: NdarrayOrTensor, subs: list, divs: list, zeroed: list) -> NdarrayOrTensor:
1007+
"""
1008+
Record the parameters needed by :meth:`inverse` in the transform meta information of ``out``.
1009+
1010+
Args:
1011+
out: the normalized image; only a ``MetaTensor`` (with meta tracking enabled) can carry the record.
1012+
subs: subtrahend used for each channel (``channel_wise=True``) or a single-element list.
1013+
divs: divisor used for each channel (``channel_wise=True``) or a single-element list.
1014+
zeroed: per-channel flat indices of voxels zeroed by the normalization (``nonzero=True`` only).
1015+
1016+
Returns:
1017+
``out``, with the transform pushed onto its applied operations when it is a ``MetaTensor``.
1018+
"""
9721019
if not isinstance(out, MetaTensor) or not get_track_meta():
9731020
return out
9741021
extra_info = {
@@ -977,33 +1024,51 @@ def _push_transform_with_stats(self, out, subs: list, divs: list):
9771024
"channel_wise": self.channel_wise,
9781025
"nonzero": self.nonzero,
9791026
}
1027+
if self.nonzero:
1028+
extra_info["zeroed_idx"] = [self._to_storable(z) for z in zeroed]
9801029
self.push_transform(out, extra_info=extra_info)
9811030
return out
9821031

9831032
def inverse(self, data: torch.Tensor) -> torch.Tensor:
1033+
"""
1034+
Undo the normalization recorded on ``data`` by :meth:`__call__`, i.e. ``img * divisor + subtrahend``.
1035+
With ``nonzero=True`` only the voxels that were normalized on the forward pass are restored.
1036+
1037+
Args:
1038+
data: a ``MetaTensor`` produced by this transform, with the transform still on its applied operations.
1039+
1040+
Returns:
1041+
the de-normalized image, of the same type as ``data``.
1042+
1043+
Raises:
1044+
RuntimeError: if the most recent applied operation on ``data`` was not made by this transform.
1045+
"""
9841046
transform = self.pop_transform(data)
9851047
info = transform[TraceKeys.EXTRA_INFO]
986-
if info["nonzero"]:
987-
raise NotImplementedError(
988-
"NormalizeIntensity.inverse is not supported when nonzero=True, because the "
989-
"zero-voxel mask is needed to reverse the normalization exactly."
990-
)
9911048
subs, divs = info["sub"], info["div"]
1049+
zeroed = info.get("zeroed_idx") if info["nonzero"] else None
9921050
out: torch.Tensor = convert_to_tensor(data, track_meta=get_track_meta()) # type: ignore[assignment]
9931051

994-
def _restore(x, sub, div):
995-
sub, *_ = convert_to_dst_type(sub, x)
996-
div, *_ = convert_to_dst_type(div, x)
997-
return x * div + sub
1052+
def _restore(x, sub, div, zeroed_idx=None):
1053+
if zeroed_idx is None:
1054+
sub, *_ = convert_to_dst_type(sub, x)
1055+
div, *_ = convert_to_dst_type(div, x)
1056+
return x * div + sub
1057+
# nonzero=True: the forward mask is the output's non-zero voxels plus the recorded zeroed ones
1058+
mask = x != 0
1059+
zeroed_idx, *_ = convert_to_dst_type(zeroed_idx, mask, dtype=torch.long)
1060+
mask.view(-1)[zeroed_idx] = True
1061+
vals = x[mask]
1062+
sub, *_ = convert_to_dst_type(sub, vals)
1063+
div, *_ = convert_to_dst_type(div, vals)
1064+
x[mask] = vals * div + sub
1065+
return x
9981066

9991067
if info["channel_wise"]:
10001068
for i in range(len(out)):
1001-
if subs[i] is None or divs[i] is None: # all-zero channel skipped on the forward pass
1002-
continue
1003-
out[i] = _restore(out[i], subs[i], divs[i])
1069+
out[i] = _restore(out[i], subs[i], divs[i], None if zeroed is None else zeroed[i])
10041070
else:
1005-
if subs[0] is not None and divs[0] is not None:
1006-
out = _restore(out, subs[0], divs[0])
1071+
out = _restore(out, subs[0], divs[0], None if zeroed is None else zeroed[0])
10071072
return out
10081073

10091074

monai/transforms/intensity/dictionary.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from collections.abc import Callable, Hashable, Mapping, Sequence
2121

2222
import numpy as np
23+
import torch
2324

2425
from monai.config import DtypeLike, KeysCollection
2526
from monai.config.type_definitions import NdarrayOrTensor
@@ -796,8 +797,8 @@ class NormalizeIntensityd(MapTransform, InvertibleTransform):
796797
"""
797798
Dictionary-based wrapper of :py:class:`monai.transforms.NormalizeIntensity`.
798799
This transform can normalize only non-zero values or entire image, and can also calculate
799-
mean and std on each channel separately. It is invertible via :meth:`inverse` (except when
800-
``nonzero=True``); see :py:class:`monai.transforms.NormalizeIntensity`.
800+
mean and std on each channel separately. It is invertible via :meth:`inverse`;
801+
see :py:class:`monai.transforms.NormalizeIntensity`.
801802
802803
Args:
803804
keys: keys of the corresponding items to be transformed.
@@ -832,7 +833,20 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, N
832833
d[key] = self.normalizer(d[key])
833834
return d
834835

835-
def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, NdarrayOrTensor]:
836+
def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> dict[Hashable, torch.Tensor]:
837+
"""
838+
Undo the normalization of every key in ``self.keys``.
839+
840+
Args:
841+
data: dictionary whose values for ``self.keys`` were produced by this transform.
842+
843+
Returns:
844+
a shallow copy of ``data`` with those values de-normalized.
845+
846+
Raises:
847+
RuntimeError: propagated from :meth:`NormalizeIntensity.inverse` if the most recent applied
848+
operation on a value was not made by this transform.
849+
"""
836850
d = dict(data)
837851
for key in self.key_iterator(d):
838852
d[key] = self.normalizer.inverse(d[key])

tests/transforms/test_normalize_intensity.py

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@
1717
import torch
1818
from parameterized import parameterized
1919

20-
from monai.data import MetaTensor, set_track_meta
21-
from monai.transforms import NormalizeIntensity
20+
from monai.data import MetaTensor, get_track_meta, set_track_meta
21+
from monai.transforms import Compose, NormalizeIntensity
2222
from tests.test_utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose
2323

2424
TESTS = []
@@ -145,23 +145,45 @@ def test_value_errors(self, im_type):
145145
["channelwise_computed", {"channel_wise": True}],
146146
["global_explicit", {"subtrahend": 2.0, "divisor": 3.0}],
147147
["channelwise_explicit", {"subtrahend": [1.0, 2.0, 3.0], "divisor": [2.0, 3.0, 4.0], "channel_wise": True}],
148+
["nonzero", {"nonzero": True}],
149+
["channelwise_nonzero", {"nonzero": True, "channel_wise": True}],
150+
["nonzero_explicit", {"nonzero": True, "subtrahend": 2.0, "divisor": 3.0}],
148151
]
149152
)
150153
def test_inverse(self, _, args):
154+
self.addCleanup(set_track_meta, get_track_meta())
151155
set_track_meta(True)
152156
img = MetaTensor(torch.randn(3, 6, 6) * 5 + 2)
157+
img[0, :2] = 0 # some zero voxels, which nonzero=True must leave untouched
158+
img[2] = 0 # an all-zero channel, where nonzero=True has nothing to normalize
153159
normalizer = NormalizeIntensity(**args)
154160
out = normalizer(img.clone())
155161
inv = normalizer.inverse(out)
156162
assert_allclose(inv, img, type_test=False, rtol=1e-4, atol=1e-4)
163+
self.assertEqual(len(inv.applied_operations), 0)
157164

158-
def test_inverse_nonzero_not_implemented(self):
165+
@parameterized.expand([["global", {}], ["channelwise", {"channel_wise": True}]])
166+
def test_inverse_nonzero_value_equal_to_mean(self, _, args):
167+
"""A non-zero voxel equal to the mean becomes exactly 0 and must still be restored."""
168+
self.addCleanup(set_track_meta, get_track_meta())
159169
set_track_meta(True)
160-
img = MetaTensor(torch.randn(2, 5, 5))
161-
normalizer = NormalizeIntensity(nonzero=True)
170+
# mean of the non-zero voxels is 2 globally and in each channel, so the voxels equal to 2 become 0
171+
img = MetaTensor(torch.tensor([[0.0, 1.0, 2.0, 3.0], [0.0, 0.0, 0.0, 2.0]]))
172+
normalizer = NormalizeIntensity(nonzero=True, **args)
162173
out = normalizer(img.clone())
163-
with self.assertRaises(NotImplementedError):
164-
normalizer.inverse(out)
174+
self.assertEqual(out[0, 2].item(), 0.0)
175+
self.assertEqual(out[1, 3].item(), 0.0)
176+
inv = normalizer.inverse(out)
177+
assert_allclose(inv, img, type_test=False, rtol=0, atol=0)
178+
179+
def test_inverse_nonzero_in_compose(self):
180+
self.addCleanup(set_track_meta, get_track_meta())
181+
set_track_meta(True)
182+
img = MetaTensor(torch.randn(2, 5, 5))
183+
img[0, 0] = 0
184+
transform = Compose([NormalizeIntensity(nonzero=True)])
185+
inv = transform.inverse(transform(img.clone()))
186+
assert_allclose(inv, img, type_test=False, rtol=1e-4, atol=1e-4)
165187

166188

167189
if __name__ == "__main__":

tests/transforms/test_normalize_intensityd.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@
1717
import torch
1818
from parameterized import parameterized
1919

20-
from monai.data import MetaTensor, set_track_meta
21-
from monai.transforms import NormalizeIntensityd
20+
from monai.data import MetaTensor, get_track_meta, set_track_meta
21+
from monai.transforms import Compose, Invertd, NormalizeIntensityd
2222
from tests.test_utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose
2323

2424
TESTS = []
@@ -78,17 +78,37 @@ def test_channel_wise(self, im_type):
7878
expected = np.array([[0.0, -1.0, 0.0, 1.0], [0.0, -1.0, 0.0, 1.0]])
7979
assert_allclose(normalized, im_type(expected), type_test="tensor")
8080

81-
@parameterized.expand([["global", {}], ["channelwise", {"channel_wise": True}]])
81+
@parameterized.expand(
82+
[
83+
["global", {}],
84+
["channelwise", {"channel_wise": True}],
85+
["nonzero", {"nonzero": True}],
86+
["channelwise_nonzero", {"nonzero": True, "channel_wise": True}],
87+
]
88+
)
8289
def test_inverse(self, _, args):
90+
self.addCleanup(set_track_meta, get_track_meta())
8391
set_track_meta(True)
8492
key = "img"
8593
normalizer = NormalizeIntensityd(keys=key, **args)
8694
data = {key: MetaTensor(torch.randn(3, 6, 6) * 4 + 1)}
95+
data[key][0, :2] = 0
8796
original = data[key].clone()
8897
out = normalizer(dict(data))
8998
inv = normalizer.inverse(out)
9099
assert_allclose(inv[key], original, type_test=False, rtol=1e-4, atol=1e-4)
91100

101+
def test_invertd_nonzero(self):
102+
self.addCleanup(set_track_meta, get_track_meta())
103+
set_track_meta(True)
104+
key = "img"
105+
transform = Compose([NormalizeIntensityd(keys=key, nonzero=True)])
106+
original = MetaTensor(torch.randn(2, 5, 5))
107+
original[0, 0] = 0
108+
out = transform({key: original.clone()})
109+
inv = Invertd(keys=key, transform=transform, orig_keys=key)(out)
110+
assert_allclose(inv[key], original, type_test=False, rtol=1e-4, atol=1e-4)
111+
92112

93113
if __name__ == "__main__":
94114
unittest.main()

0 commit comments

Comments
 (0)