Skip to content

Make NormalizeIntensity invertible (#5647)#8905

Open
azrabano23 wants to merge 1 commit into
Project-MONAI:devfrom
azrabano23:normalize-intensity-inverse
Open

Make NormalizeIntensity invertible (#5647)#8905
azrabano23 wants to merge 1 commit into
Project-MONAI:devfrom
azrabano23:normalize-intensity-inverse

Conversation

@azrabano23

Copy link
Copy Markdown

Fixes #5647

Description

NormalizeIntensity (and its dict wrapper NormalizeIntensityd) is now invertible. As requested in #5647, the subtrahend and divisor actually used — whether passed in or computed from the image mean/std — are stored in the transform's meta information, so the normalization can be reversed (img * divisor + subtrahend). This is useful for reconstruction problems and for logging/recovering original intensities.

Implementation follows the maintainer's suggestion in the issue thread (store the stats via push_transform(..., extra_info=...)):

  • NormalizeIntensity now subclasses InvertibleTransform. _normalize returns the sub/div it used; __call__ collects them (per channel when channel_wise=True, once otherwise) and pushes them onto the output MetaTensor's transform stack. A new inverse() reverses the operation.
  • NormalizeIntensityd subclasses InvertibleTransform and delegates inverse() to the array transform per key.
  • Transform tracking is only performed when the output is a MetaTensor and get_track_meta() is True, so behaviour is unchanged when meta tracking is off.

Scope note (nonzero=True): inversion is intentionally not supported when nonzero=True, because reversing the masked normalization exactly would require storing the per-voxel zero mask. In that case inverse() raises a clear NotImplementedError rather than returning an incorrect result. Happy to extend this (e.g. by optionally storing the mask) if maintainers prefer.

Types of changes

  • Non-breaking change (fix or new feature that would not break existing functionality).
  • New tests added to cover the changes.
  • Integration tests passed locally by running ./runtests.sh -f -u --net --coverage. (ran the relevant unit tests + formatting/lint locally — see below)
  • Quick tests passed locally by running ./runtests.sh --quick --unittests --disttests. (ran the affected suites)
  • In-line docstrings updated.

Testing

python -m pytest tests/transforms/test_normalize_intensity.py tests/transforms/test_normalize_intensityd.py
# 106 passed (99 existing + 7 new inverse tests)

New tests assert round-trip inverse(transform(x)) ≈ x for global and channel-wise modes with both computed and explicitly-provided sub/div, and that nonzero=True raises NotImplementedError on inverse. isort, black, and ruff check all pass on the changed files.

Signed-off-by: azrabano23 <ab2895@scarletmail.rutgers.edu>
@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

NormalizeIntensity changes from a non-invertible Transform to an InvertibleTransform. The _normalize method now returns the normalized image alongside the actual subtrahend and divisor used. During __call__, these parameters are collected and stored in MetaTensor metadata. The new inverse() method retrieves stored parameters to restore original values via img * div + sub. When nonzero=True, inverse() raises NotImplementedError. NormalizeIntensityd gains the same invertibility by inheriting from InvertibleTransform and delegating inverse() to the underlying normalizer. Tests validate round-trip correctness and unsupported mode behavior.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.27% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed Title accurately captures the main change: making NormalizeIntensity invertible, matching the core PR objective.
Description check ✅ Passed Description comprehensively covers the change, implementation approach, scope limitations (nonzero=True), testing, and all required template sections are addressed.
Linked Issues check ✅ Passed Code changes fully implement issue #5647: transforms now store computed sub/div in meta information and inverse() reverses the operation as requested.
Out of Scope Changes check ✅ Passed All changes directly support invertibility: NormalizeIntensity and NormalizeIntensityd now subclass InvertibleTransform, store normalization parameters, and implement inverse(). Tests validate the new behavior.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
monai/transforms/intensity/dictionary.py (1)

835-839: ⚡ Quick win

Document NormalizeIntensityd.inverse() with a Google-style docstring.

The new inverse definition should describe input mapping, return mapping, and propagated exceptions from self.normalizer.inverse.

As per coding guidelines, “Docstrings should be present for all definition which describe each variable, return value, and raised exception in the appropriate section of the Google-style of docstrings.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@monai/transforms/intensity/dictionary.py` around lines 835 - 839, Add a
Google-style docstring to the NormalizeIntensityd.inverse method that documents
the input parameter `data` (Mapping[Hashable, NdarrayOrTensor]) and states that
the method returns a dict[Hashable, NdarrayOrTensor] with normalized values, and
explicitly documents that any exceptions raised by `self.normalizer.inverse`
(propagated through the loop) may be raised by this method; include brief
descriptions for Args, Returns, and Raises sections and reference
`self.normalizer.inverse` in the Raises section so callers know which errors can
surface.

Source: Coding guidelines

monai/transforms/intensity/array.py (1)

966-985: ⚡ Quick win

Add Google-style docstrings for new methods.

_to_storable, _push_transform_with_stats, and inverse should include Args/Returns/Raises sections to match the repo rule for new definitions.

As per coding guidelines, “Docstrings should be present for all definition which describe each variable, return value, and raised exception in the appropriate section of the Google-style of docstrings.”

Also applies to: 986-1010

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@monai/transforms/intensity/array.py` around lines 966 - 985, Add Google-style
docstrings for the new methods _to_storable, _push_transform_with_stats, and
inverse: for each function include an Args section describing each parameter
(type and meaning), a Returns section describing the return value and its type,
and a Raises section for any exceptions the method may raise (or state "None" if
it does not raise). Reference the existing method names (_to_storable,
_push_transform_with_stats, inverse) and ensure the docstrings follow the repo's
Google-style format and cover torch.Tensor/np.ndarray handling, MetaTensor/out
behavior, and any preconditions like get_track_meta() or expected types.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@monai/transforms/intensity/array.py`:
- Around line 902-903: The code is storing None stats into invertible extra_info
(via _push_transform_with_stats) when _normalize() returns None for nonzero=True
with all-zero masks; update the logic so that when _normalize(...) returns None
you do not add any stats entries to extra_info (i.e., skip serializing or
setting keys) — modify callers (e.g., where _normalize is invoked in the
normalization transform) or update _push_transform_with_stats to check for None
and return early/omit adding the stats entry so extra_info never contains None
values.

In `@tests/transforms/test_normalize_intensity.py`:
- Around line 151-164: These tests call set_track_meta(True) but never restore
previous global state; wrap the body of each test (the ones creating MetaTensor
and using NormalizeIntensity/out/inv) by capturing the prior state with
get_track_meta() (or equivalent getter), call set_track_meta(True), then run the
test assertions in a try/finally and restore the original state with
set_track_meta(prev) in the finally block so global metadata tracking is always
returned to its prior value after test execution.

---

Nitpick comments:
In `@monai/transforms/intensity/array.py`:
- Around line 966-985: Add Google-style docstrings for the new methods
_to_storable, _push_transform_with_stats, and inverse: for each function include
an Args section describing each parameter (type and meaning), a Returns section
describing the return value and its type, and a Raises section for any
exceptions the method may raise (or state "None" if it does not raise).
Reference the existing method names (_to_storable, _push_transform_with_stats,
inverse) and ensure the docstrings follow the repo's Google-style format and
cover torch.Tensor/np.ndarray handling, MetaTensor/out behavior, and any
preconditions like get_track_meta() or expected types.

In `@monai/transforms/intensity/dictionary.py`:
- Around line 835-839: Add a Google-style docstring to the
NormalizeIntensityd.inverse method that documents the input parameter `data`
(Mapping[Hashable, NdarrayOrTensor]) and states that the method returns a
dict[Hashable, NdarrayOrTensor] with normalized values, and explicitly documents
that any exceptions raised by `self.normalizer.inverse` (propagated through the
loop) may be raised by this method; include brief descriptions for Args,
Returns, and Raises sections and reference `self.normalizer.inverse` in the
Raises section so callers know which errors can surface.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f6b1bc07-d035-40aa-863c-bf7b8c626aaa

📥 Commits

Reviewing files that changed from the base of the PR and between 8a89dd5 and 2b7ea77.

📒 Files selected for processing (4)
  • monai/transforms/intensity/array.py
  • monai/transforms/intensity/dictionary.py
  • tests/transforms/test_normalize_intensity.py
  • tests/transforms/test_normalize_intensityd.py

Comment on lines +902 to 903
return img, None, None
else:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid storing None in invertible extra_info.

For nonzero=True + all-zero mask, _normalize() returns None stats, then _push_transform_with_stats() serializes them into extra_info. That can break transform-history collation in batched pipelines.

Suggested fix
-            if not slices.any():
-                return img, None, None
+            if not slices.any():
+                # keep metadata collate-safe by storing identity normalization params
+                return img, 0.0, 1.0

Also applies to: 977-980

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@monai/transforms/intensity/array.py` around lines 902 - 903, The code is
storing None stats into invertible extra_info (via _push_transform_with_stats)
when _normalize() returns None for nonzero=True with all-zero masks; update the
logic so that when _normalize(...) returns None you do not add any stats entries
to extra_info (i.e., skip serializing or setting keys) — modify callers (e.g.,
where _normalize is invoked in the normalization transform) or update
_push_transform_with_stats to check for None and return early/omit adding the
stats entry so extra_info never contains None values.

Comment on lines +151 to +164
set_track_meta(True)
img = MetaTensor(torch.randn(3, 6, 6) * 5 + 2)
normalizer = NormalizeIntensity(**args)
out = normalizer(img.clone())
inv = normalizer.inverse(out)
assert_allclose(inv, img, type_test=False, rtol=1e-4, atol=1e-4)

def test_inverse_nonzero_not_implemented(self):
set_track_meta(True)
img = MetaTensor(torch.randn(2, 5, 5))
normalizer = NormalizeIntensity(nonzero=True)
out = normalizer(img.clone())
with self.assertRaises(NotImplementedError):
normalizer.inverse(out)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Restore track_meta global state after each test.

These tests set global metadata tracking to True and never restore prior state, which can make later tests order-dependent.

Suggested fix
-from monai.data import MetaTensor, set_track_meta
+from monai.data import MetaTensor, get_track_meta, set_track_meta
...
     def test_inverse(self, _, args):
-        set_track_meta(True)
+        prev = get_track_meta()
+        self.addCleanup(set_track_meta, prev)
+        set_track_meta(True)
...
     def test_inverse_nonzero_not_implemented(self):
-        set_track_meta(True)
+        prev = get_track_meta()
+        self.addCleanup(set_track_meta, prev)
+        set_track_meta(True)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
set_track_meta(True)
img = MetaTensor(torch.randn(3, 6, 6) * 5 + 2)
normalizer = NormalizeIntensity(**args)
out = normalizer(img.clone())
inv = normalizer.inverse(out)
assert_allclose(inv, img, type_test=False, rtol=1e-4, atol=1e-4)
def test_inverse_nonzero_not_implemented(self):
set_track_meta(True)
img = MetaTensor(torch.randn(2, 5, 5))
normalizer = NormalizeIntensity(nonzero=True)
out = normalizer(img.clone())
with self.assertRaises(NotImplementedError):
normalizer.inverse(out)
prev = get_track_meta()
self.addCleanup(set_track_meta, prev)
set_track_meta(True)
img = MetaTensor(torch.randn(3, 6, 6) * 5 + 2)
normalizer = NormalizeIntensity(**args)
out = normalizer(img.clone())
inv = normalizer.inverse(out)
assert_allclose(inv, img, type_test=False, rtol=1e-4, atol=1e-4)
def test_inverse_nonzero_not_implemented(self):
prev = get_track_meta()
self.addCleanup(set_track_meta, prev)
set_track_meta(True)
img = MetaTensor(torch.randn(2, 5, 5))
normalizer = NormalizeIntensity(nonzero=True)
out = normalizer(img.clone())
with self.assertRaises(NotImplementedError):
normalizer.inverse(out)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/transforms/test_normalize_intensity.py` around lines 151 - 164, These
tests call set_track_meta(True) but never restore previous global state; wrap
the body of each test (the ones creating MetaTensor and using
NormalizeIntensity/out/inv) by capturing the prior state with get_track_meta()
(or equivalent getter), call set_track_meta(True), then run the test assertions
in a try/finally and restore the original state with set_track_meta(prev) in the
finally block so global metadata tracking is always returned to its prior value
after test execution.

@aymuos15

aymuos15 commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

What about the nonzero=True case? it'll always raise inside Compose.inverse/Invertd right.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

track sub and div and compute the NormalizeIntensity inverse

2 participants