Skip to content

Commit e31cb29

Browse files
authored
convert GaussianFilter kwargs to python native (#159)
* convert GaussianFilter kwargs to python native * bump version * add more tests
1 parent 1b238c8 commit e31cb29

5 files changed

Lines changed: 153 additions & 43 deletions

File tree

docs/changelog.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,22 @@ Changelog
44
2.x
55
---
66

7+
2.0.2 (Jul 29, 2026)
8+
^^^^^^^^^^^^^^^^^^^^
9+
10+
Bug fixes
11+
~~~~~~~~~
12+
13+
- **``GaussianFilterOperator``: fully fix tensor-valued ``sigma``.** The 2.0.1
14+
fix converted the input array to NumPy, but the actual trigger is a *tensor*
15+
``sigma``: ``scipy.ndimage`` builds the Gaussian kernel in ``sigma``'s array
16+
namespace, so a torch / CuPy tensor ``sigma`` yields a tensor kernel that
17+
``gaussian_filter1d`` reverses with a negative-step slice (``weights[::-1]``),
18+
which PyTorch does not support — failing even for a plain NumPy input array.
19+
``GaussianFilterOperator`` now coerces ``sigma`` (and other array-valued
20+
kwargs) to native Python values (the input NumPy-view safeguard from 2.0.1 is
21+
retained). This fully fixes downstream use via e.g. DeepInv.
22+
723
2.0.1 (Jul 24, 2026)
824
^^^^^^^^^^^^^^^^^^^^
925

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ build-backend = "hatchling.build"
1616
[project]
1717
name = "parallelproj"
1818
# IMPORTANT: bumping a release requires editing this one line.
19-
version = "2.0.2.dev0"
19+
version = "2.0.2"
2020
description = "Python tools for PET projection and reconstruction workflows."
2121
readme = "README.md"
2222
requires-python = ">=3.12"

src/parallelproj/operators.py

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,21 @@
1717
import numpy as np
1818
import array_api_compat
1919

20-
# GPU arrays (CuPy / PyTorch CUDA) are filtered with ``cupyx.scipy.ndimage``
21-
# directly (see GaussianFilterOperator); CPU arrays (NumPy, PyTorch CPU,
22-
# array-api-strict) are converted to a NumPy view first and then filtered with
23-
# ``scipy.ndimage``. Neither path relies on scipy's array-API *delegation*
24-
# (where scipy would compute natively in the input's namespace): under
25-
# delegation ``gaussian_filter1d`` reverses the kernel with a negative-step
26-
# slice ``weights[::-1]`` that e.g. PyTorch does not support. Converting to
27-
# NumPy ourselves makes the operator robust regardless of the scipy version or
28-
# the ``SCIPY_ARRAY_API`` env var (and independent of the scipy / parallelproj
29-
# import order).
20+
# GaussianFilterOperator filters GPU arrays (CuPy / PyTorch CUDA) with
21+
# ``cupyx.scipy.ndimage`` and CPU arrays (NumPy, PyTorch CPU, array-api-strict)
22+
# with ``scipy.ndimage``. Two precautions keep it backend-robust:
23+
# * filter kwargs such as ``sigma`` are coerced to native Python values
24+
# (see ``_to_builtin``). scipy builds the Gaussian kernel in ``sigma``'s
25+
# array namespace, so a *tensor* ``sigma`` yields a tensor kernel that
26+
# ``gaussian_filter1d`` then reverses with a negative-step slice
27+
# ``weights[::-1]`` -- unsupported by e.g. PyTorch. This is the root cause
28+
# of the failure; it fires even for a plain NumPy input array.
29+
# * on the CPU path the input is additionally converted to a NumPy view, so
30+
# scipy never relies on its array-API *delegation* (scipy >= 1.16 /
31+
# ``SCIPY_ARRAY_API``) to compute natively in the input's namespace.
32+
# Together the scipy call is always the canonical ``gaussian_filter(numpy,
33+
# float)`` form, independent of scipy version and scipy / parallelproj import
34+
# order.
3035
import scipy.ndimage as ndimage
3136
from array_api_compat import device, get_namespace
3237

@@ -541,6 +546,30 @@ def iscomplex(self) -> bool:
541546
) or self.xp.isdtype(self._values.dtype, self.xp.complex128)
542547

543548

549+
def _to_builtin(value):
550+
"""Coerce an array/tensor-valued filter kwarg to native Python.
551+
552+
scipy.ndimage builds the Gaussian kernel in the array namespace of
553+
``sigma``, so a torch / CuPy / array-api tensor ``sigma`` yields a tensor
554+
kernel that ``gaussian_filter1d`` reverses with a negative-step slice
555+
(``weights[::-1]``), which PyTorch does not support. Passing native Python
556+
scalars / lists avoids this for every backend.
557+
558+
Plain Python scalars, strings and ``None`` are returned unchanged;
559+
lists / tuples are converted element-wise (preserving the container type);
560+
NumPy scalars and 0-d / 1-d arrays / tensors become a Python scalar or list.
561+
"""
562+
if isinstance(value, np.generic):
563+
return value.item()
564+
if value is None or isinstance(value, (bool, int, float, str)):
565+
return value
566+
if isinstance(value, (list, tuple)):
567+
return type(value)(_to_builtin(v) for v in value)
568+
# numpy / torch / cupy / array-api arrays (to_numpy_array always returns a
569+
# NumPy ndarray, incl. a device->host copy for GPU arrays)
570+
return to_numpy_array(value).tolist()
571+
572+
544573
class GaussianFilterOperator(LinearOperator):
545574
"""Isotropic Gaussian smoothing operator (self-adjoint).
546575
@@ -565,10 +594,15 @@ def __init__(self, in_shape: tuple[int, ...], **kwargs):
565594
**kwargs : dict
566595
passed to scipy.ndimage.gaussian_filter; most commonly ``sigma``
567596
(standard deviation in pixels), plus optional ``mode``, ``truncate``, etc.
597+
Array/tensor-valued arguments (e.g. a torch tensor ``sigma``) are
598+
coerced to native Python values so scipy builds the kernel in NumPy
599+
(see :func:`_to_builtin`).
568600
"""
569601
super().__init__()
570602
self._in_shape = in_shape
571-
self._kwargs = kwargs
603+
# coerce array/tensor kwargs (most importantly ``sigma``) to native
604+
# Python so scipy never builds the kernel in a tensor namespace.
605+
self._kwargs = {k: _to_builtin(v) for k, v in kwargs.items()}
572606

573607
@property
574608
def in_shape(self) -> tuple[int, ...]:
@@ -605,14 +639,12 @@ def _apply(self, x: Array) -> Array:
605639
return xp.asarray(xp.from_dlpack(y_cp))
606640

607641
# CPU arrays (NumPy, PyTorch CPU, array-api-strict): filter on a NumPy
608-
# view, then convert the result back to the input's namespace/device.
609-
# We convert to NumPy *ourselves* instead of passing ``x`` to scipy,
610-
# because modern (array-API-aware) scipy would otherwise compute
611-
# natively in the input's namespace, where ``gaussian_filter1d``
612-
# reverses the kernel with a negative-step slice ``weights[::-1]`` that
613-
# e.g. PyTorch does not support. On CPU the torch / array-api-strict
614-
# <-> NumPy conversions are zero-copy (shared buffer); only scipy's
615-
# output allocation remains, exactly as in the pure-NumPy path.
642+
# view so scipy never delegates to the input's array namespace (scipy
643+
# >= 1.16 / SCIPY_ARRAY_API), then convert the result back. ``sigma``
644+
# & co. were already coerced to native Python in __init__. On CPU the
645+
# torch / array-api-strict <-> NumPy conversions are zero-copy (shared
646+
# buffer); only scipy's output allocation remains, as in the pure-NumPy
647+
# path.
616648
x_np = np.asarray(to_numpy_array(x))
617649
result = ndimage.gaussian_filter(x_np, **self._kwargs)
618650
return xp.asarray(result, device=dev, dtype=x.dtype)

tests/test_gaussian_filter_torch.py

Lines changed: 48 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
1-
"""Regression tests for :class:`GaussianFilterOperator` with PyTorch CPU tensors.
2-
3-
Guards against a scipy array-API *delegation* issue: when scipy computes
4-
natively on a torch tensor, ``scipy.ndimage.gaussian_filter1d`` reverses the
5-
Gaussian kernel with a negative-step slice (``weights[::-1]``) that PyTorch does
6-
not support (https://github.com/pytorch/pytorch/issues/175240). parallelproj
7-
must therefore filter on a NumPy view, so this never happens regardless of the
8-
scipy version or the ``SCIPY_ARRAY_API`` env var.
9-
10-
These tests are torch-CPU specific and are intentionally *not* parametrized over
1+
"""Regression tests for :class:`GaussianFilterOperator` with PyTorch.
2+
3+
Root cause guarded here: ``scipy.ndimage`` builds the Gaussian kernel in the
4+
array namespace of ``sigma``. A *tensor* ``sigma`` therefore yields a tensor
5+
kernel that ``gaussian_filter1d`` reverses with a negative-step slice
6+
(``weights[::-1]``), which PyTorch does not support
7+
(https://github.com/pytorch/pytorch/issues/175240) -- this fires even for a
8+
plain NumPy input array. As defence-in-depth against scipy's array-API
9+
delegation (scipy >= 1.16 / ``SCIPY_ARRAY_API``), the CPU input is also filtered
10+
via a NumPy view. parallelproj must therefore (a) coerce ``sigma`` to native
11+
Python and (b) not rely on scipy's delegation.
12+
13+
These tests are torch specific and intentionally *not* parametrized over
1114
``(xp, dev)`` (so they do not import ``config.pytestmark``).
1215
"""
1316

@@ -26,8 +29,36 @@
2629
pytestmark = pytest.mark.skipif(not torch_available, reason="torch not installed")
2730

2831

29-
def test_gaussian_filter_operator_torch_cpu() -> None:
30-
"""A torch CPU tensor stays a torch CPU tensor and matches the NumPy result."""
32+
def test_gaussian_filter_operator_tensor_sigma() -> None:
33+
"""A tensor-valued ``sigma`` must not break the filter (the DeepInv bug).
34+
35+
Uses a plain NumPy input on purpose to show the failure is driven by the
36+
``sigma`` type, not the input array type.
37+
"""
38+
import numpy as np
39+
import torch
40+
from parallelproj.operators import GaussianFilterOperator
41+
42+
shape = (8, 8, 4)
43+
x = np.ones(shape, dtype=np.float32)
44+
45+
ref = GaussianFilterOperator(shape, sigma=1.5)(x)
46+
47+
# scalar tensor sigma and per-axis tensor sigma
48+
y_scalar = GaussianFilterOperator(shape, sigma=torch.tensor(1.5))(x)
49+
y_vec = GaussianFilterOperator(shape, sigma=torch.tensor([1.5, 1.5, 1.5]))(x)
50+
assert np.allclose(np.asarray(y_scalar), ref, atol=1e-6)
51+
assert np.allclose(np.asarray(y_vec), ref, atol=1e-6)
52+
53+
# and with a torch input array (result stays a torch CPU tensor)
54+
xt = torch.ones(shape, dtype=torch.float32)
55+
yt = GaussianFilterOperator(shape, sigma=torch.tensor(1.5))(xt)
56+
assert isinstance(yt, torch.Tensor) and yt.device.type == "cpu"
57+
assert np.allclose(np.asarray(yt), ref, atol=1e-6)
58+
59+
60+
def test_gaussian_filter_operator_torch_cpu_float_sigma() -> None:
61+
"""Basic backend check: torch CPU input + float sigma stays a torch tensor."""
3162
import numpy as np
3263
import torch
3364
from scipy.ndimage import gaussian_filter
@@ -36,15 +67,10 @@ def test_gaussian_filter_operator_torch_cpu() -> None:
3667

3768
shape = (8, 8, 4)
3869
op = GaussianFilterOperator(shape, sigma=1.5)
70+
y = op(torch.ones(shape, dtype=torch.float32))
3971

40-
x = torch.ones(shape, dtype=torch.float32)
41-
y = op(x)
42-
43-
assert isinstance(y, torch.Tensor)
44-
assert y.device.type == "cpu"
72+
assert isinstance(y, torch.Tensor) and y.device.type == "cpu"
4573
assert tuple(y.shape) == shape
46-
assert bool(torch.isfinite(y).all())
47-
4874
ref = gaussian_filter(np.ones(shape, dtype=np.float32), sigma=1.5)
4975
assert np.allclose(np.asarray(y), ref, atol=1e-6)
5076

@@ -53,10 +79,9 @@ def test_gaussian_filter_operator_torch_cpu_scipy_array_api() -> None:
5379
"""Force scipy's array-API delegation via ``SCIPY_ARRAY_API=1``.
5480
5581
scipy reads ``SCIPY_ARRAY_API`` at import time, so this runs in a fresh
56-
interpreter with the env var set (which also keeps the rest of the suite on
57-
scipy's default behavior). Under delegation the pre-fix operator hit the
58-
unsupported ``weights[::-1]`` negative-step slice on modern scipy; with the
59-
NumPy-view fix it succeeds.
82+
interpreter with the env var set (keeping the rest of the suite on scipy's
83+
default behaviour). Guards the CPU NumPy-view path against scipy computing
84+
natively in a torch input's namespace.
6085
"""
6186
code = textwrap.dedent(
6287
"""

tests/test_operators.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,3 +484,40 @@ def test_resolve_namespace_device_requires_xp(xp: ModuleType, dev: str) -> None:
484484
rxp, _ = mop._resolve_namespace_device(None, None)
485485
assert rxp is mop.xp
486486
assert mop._resolve_namespace_device(xp, "explicit-dev") == (xp, "explicit-dev")
487+
488+
489+
def test_gaussian_filter_to_builtin(xp: ModuleType, dev: str) -> None:
490+
"""Cover every branch of ``operators._to_builtin`` (sigma coercion).
491+
492+
Ensures array/tensor-valued filter kwargs are turned into native Python so
493+
scipy never builds the Gaussian kernel in a tensor namespace.
494+
"""
495+
import numpy as realnp
496+
497+
to_builtin = ppo._to_builtin
498+
499+
# plain python scalars / str / None / bool pass through unchanged
500+
assert to_builtin(1.5) == 1.5 and isinstance(to_builtin(1.5), float)
501+
assert to_builtin(3) == 3
502+
assert to_builtin("reflect") == "reflect"
503+
assert to_builtin(None) is None
504+
assert to_builtin(True) is True
505+
506+
# numpy scalar (np.generic) -> python scalar
507+
b = to_builtin(realnp.float32(1.5))
508+
assert b == 1.5 and isinstance(b, float)
509+
510+
# list / tuple recurse, preserving container type and coercing elements
511+
assert to_builtin((1.5, 2.5)) == (1.5, 2.5)
512+
assert to_builtin([realnp.float32(1.0), 2]) == [1.0, 2]
513+
514+
# array / tensor branch (per backend): 1-d -> list, 0-d -> scalar
515+
assert to_builtin(xp.asarray([1.5, 2.0, 2.5], device=dev)) == [1.5, 2.0, 2.5]
516+
assert to_builtin(xp.asarray(1.5, device=dev)) == 1.5
517+
518+
# end-to-end: the operator accepts an array/tensor sigma without error
519+
op = ppo.GaussianFilterOperator(
520+
(6, 6, 4), sigma=xp.asarray([1.0, 1.0, 1.0], device=dev)
521+
)
522+
y = op(xp.ones((6, 6, 4), dtype=xp.float32, device=dev))
523+
assert tuple(y.shape) == (6, 6, 4)

0 commit comments

Comments
 (0)