diff --git a/CHANGELOG.md b/CHANGELOG.md index 9518750113a..1ff42986da3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fixed malformed LaTeX in `CLIPScore` and `HausdorffDistance` docstring math so it renders correctly ([#3427](https://github.com/Lightning-AI/torchmetrics/pull/3427)) +- Fixed `NaN` in `SpectralAngleMapper` / `spectral_angle_mapper` when a pixel has zero norm ([#3424](https://github.com/Lightning-AI/torchmetrics/pull/3424)) --- diff --git a/src/torchmetrics/functional/image/sam.py b/src/torchmetrics/functional/image/sam.py index af5edb5f41e..15e986f9eaf 100644 --- a/src/torchmetrics/functional/image/sam.py +++ b/src/torchmetrics/functional/image/sam.py @@ -74,7 +74,9 @@ def _sam_compute( dot_product = (preds * target).sum(dim=1) preds_norm = preds.norm(dim=1) target_norm = target.norm(dim=1) - sam_score = torch.clamp(dot_product / (preds_norm * target_norm), -1, 1).acos() + # Clamp denominator to avoid NaN when a pixel has zero norm (all-zero channels) + denom = (preds_norm * target_norm).clamp(min=torch.finfo(preds.dtype).eps) + sam_score = torch.clamp(dot_product / denom, -1, 1).acos() return reduce(sam_score, reduction) diff --git a/tests/unittests/image/test_sam.py b/tests/unittests/image/test_sam.py index 57a4dee1b95..982b71e6c7b 100644 --- a/tests/unittests/image/test_sam.py +++ b/tests/unittests/image/test_sam.py @@ -130,3 +130,27 @@ def test_error_on_grayscale_image(metric_class=SpectralAngleMapper): metric = metric_class() with pytest.raises(ValueError, match="Expected channel dimension of `preds` and `target` to be larger than 1.*"): metric(torch.randn([16, 1, 16, 16]), torch.randn([16, 1, 16, 16])) + + +def test_no_nan_on_zero_pixel(): + """Regression test for https://github.com/Lightning-AI/torchmetrics/issues/3322. + + spectral_angle_mapper should not produce NaN when a pixel has zero norm + (all channels zero). Previously, the zero-norm denominator caused 0/0 = NaN. + """ + preds = torch.ones(1, 3, 8, 8) # N, C, H, W + target = torch.ones(1, 3, 8, 8) + preds[:, :, 5, 3] = 0 # zero-norm pixel — exact reproducer from the issue + + # functional interface + result = spectral_angle_mapper(preds, target) + assert torch.isfinite(result).all(), f"spectral_angle_mapper returned non-finite value: {result}" + + # class interface + metric = SpectralAngleMapper() + result_cls = metric(preds, target) + assert torch.isfinite(result_cls).all(), f"SpectralAngleMapper returned non-finite value: {result_cls}" + + # Result should be a valid angle in [0, pi/2] + assert (result >= 0).all(), f"result is negative: {result}" + assert (result <= torch.pi / 2).all(), f"result exceeds pi/2: {result}"