Skip to content

fix(image): guard spectral_angle_mapper against NaN on zero-norm pixels#3372

Open
xodn348 wants to merge 1 commit intoLightning-AI:masterfrom
xodn348:fix/sam-zero-norm-nan
Open

fix(image): guard spectral_angle_mapper against NaN on zero-norm pixels#3372
xodn348 wants to merge 1 commit intoLightning-AI:masterfrom
xodn348:fix/sam-zero-norm-nan

Conversation

@xodn348
Copy link
Copy Markdown

@xodn348 xodn348 commented May 5, 2026

Summary

spectral_angle_mapper (and the SpectralAngleMapper class) silently returns NaN when any
spatial pixel has an all-zero channel vector — for example a constant-black pixel in an image.
The SAM score is computed as acos(dot / (|preds| * |target|)), and when either norm is zero the
quotient is 0/0 = NaN, which then propagates through torch.clamp and acos into the final
reduction. The fix clamps preds_norm * target_norm to torch.finfo(dtype).eps before dividing,
matching the convention used by torch.nn.functional.cosine_similarity. A zero-norm pixel now
contributes acos(0) = π/2 (90°) to the mean score instead of poisoning it with NaN.

Issue

Fixes #3322

Local verification

$ cd /tmp/torchmetrics
$ python -m pytest tests/unittests/image/test_sam.py -v -k "not ddp and not half"
============================= test session starts ==============================
platform linux -- Python 3.11.15, pytest-9.0.3, pluggy-1.6.0 -- /usr/local/bin/python
cachedir: .pytest_cache
rootdir: /tmp/torchmetrics
configfile: pyproject.toml
plugins: xdist-3.8.0, doctestplus-1.7.1
collected 45 items / 24 deselected / 21 selected

tests/unittests/image/test_sam.py::TestSpectralAngleMapper::test_sam[False-preds0-target0-sum] PASSED [  4%]
tests/unittests/image/test_sam.py::TestSpectralAngleMapper::test_sam[False-preds0-target0-elementwise_mean] PASSED [  9%]
tests/unittests/image/test_sam.py::TestSpectralAngleMapper::test_sam[False-preds1-target1-sum] PASSED [ 14%]
tests/unittests/image/test_sam.py::TestSpectralAngleMapper::test_sam[False-preds1-target1-elementwise_mean] PASSED [ 19%]
tests/unittests/image/test_sam.py::TestSpectralAngleMapper::test_sam[False-preds2-target2-sum] PASSED [ 23%]
tests/unittests/image/test_sam.py::TestSpectralAngleMapper::test_sam[False-preds2-target2-elementwise_mean] PASSED [ 28%]
tests/unittests/image/test_sam.py::TestSpectralAngleMapper::test_sam[False-preds3-target3-sum] PASSED [ 33%]
tests/unittests/image/test_sam.py::TestSpectralAngleMapper::test_sam[False-preds3-target3-elementwise_mean] PASSED [ 38%]
tests/unittests/image/test_sam.py::TestSpectralAngleMapper::test_sam_functional[preds0-target0-sum] PASSED [ 42%]
tests/unittests/image/test_sam.py::TestSpectralAngleMapper::test_sam_functional[preds0-target0-elementwise_mean] PASSED [ 47%]
tests/unittests/image/test_sam.py::TestSpectralAngleMapper::test_sam_functional[preds1-target1-sum] PASSED [ 52%]
tests/unittests/image/test_sam.py::TestSpectralAngleMapper::test_sam_functional[preds1-target1-elementwise_mean] PASSED [ 57%]
tests/unittests/image/test_sam.py::TestSpectralAngleMapper::test_sam_functional[preds2-target2-sum] PASSED [ 61%]
tests/unittests/image/test_sam.py::TestSpectralAngleMapper::test_sam_functional[preds2-target2-elementwise_mean] PASSED [ 66%]
tests/unittests/image/test_sam.py::TestSpectralAngleMapper::test_sam_functional[preds3-target3-sum] PASSED [ 71%]
tests/unittests/image/test_sam.py::TestSpectralAngleMapper::test_sam_functional[preds3-target3-elementwise_mean] PASSED [ 76%]
tests/unittests/image/test_sam.py::test_error_on_different_shape PASSED        [ 80%]
tests/unittests/image/test_sam.py::test_error_on_invalid_shape PASSED          [ 85%]
tests/unittests/image/test_sam.py::test_error_on_invalid_type PASSED           [ 90%]
tests/unittests/image/test_sam.py::test_error_on_grayscale_image PASSED        [ 95%]
tests/unittests/image/test_sam.py::test_sam_no_nan_on_zero_pixel PASSED        [100%]

================ 21 passed, 24 deselected, 48 warnings in 3.41s ================
=== LOCAL_TEST_PASSED ===

Risk

The only changed line is in _sam_compute; existing random-valued inputs never produce a
zero-norm pixel so all current tests pass unchanged. The behaviour for non-degenerate inputs
is numerically identical because preds_norm * target_norm ≥ eps already holds. Any caller
that was deliberately testing for NaN on zero-input would observe π/2 instead, which is a
breaking change only in that narrow and arguably-incorrect use case.


📚 Documentation preview 📚: https://torchmetrics--3372.org.readthedocs.build/en/3372/

…xels

When any spatial pixel in `preds` or `target` has an all-zero channel
vector its L2-norm is zero, making the cosine quotient 0/0 = NaN.
Clamping preds_norm * target_norm to machine epsilon (dtype-aware,
matching the convention of F.cosine_similarity) returns acos(0) = π/2
for those degenerate pixels instead of propagating NaN through the
reduction.

Fixes Lightning-AI#3322
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spectral_angle_mapper NaNs even with one zero pixel

1 participant