1717import numpy as np
1818import 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.
3035import scipy .ndimage as ndimage
3136from 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+
544573class 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 )
0 commit comments