Skip to content

Filters API

The IBSI 2 filters make response maps of an image. A filter runs as a filter step of the pipeline, or as a direct call (see Image Filtering):

# Pipeline usage
{"step": "filter", "params": {"type": "log", "sigma_mm": 1.5}}

# Direct usage
from pictologics.filters import laplacian_of_gaussian, BoundaryCondition
response = laplacian_of_gaussian(image.array, sigma_mm=1.5, spacing_mm=image.spacing)

Available Filters

Filter Function Use Case
Mean mean_filter Local averaging
Gaussian gaussian_filter Smoothing at a physical scale
LoG laplacian_of_gaussian Edge/blob detection
Laws laws_filter Texture energy
Gabor gabor_filter Directional patterns
Wavelet wavelet_transform Multi-resolution analysis
Simoncelli simoncelli_wavelet Non-separable wavelet
Riesz riesz_transform, riesz_log, riesz_simoncelli Rotation-equivariant transforms

Capability Metadata

A versioned, machine-readable record of what each filter supports: the input and kernel dimensions, plane-wise runs, the average over the orthogonal planes, rotation pooling, the supported and the effective boundaries, the Riesz orders, steering, and the behaviour with anisotropic spacing. Compliance tools read it in place of the signatures and the source.

supported_boundaries lists what a function accepts; effective_boundary tells what happens at the border. The FFT filters report as_specified_via_padding: a non-periodic boundary is made by a pad, the filter and a crop, and the transform stays periodic on the padded image.

pictologics.filters.CAPABILITIES_SCHEMA_VERSION = '1.1.0' module-attribute

Semantic version of the :data:FILTER_CAPABILITIES schema.

Bump the major component on breaking changes (field removal/retyping), the minor component when adding fields or filter entries, and the patch component for corrections that don't change the schema shape.

pictologics.filters.FilterCapability dataclass

Declarative capability record for one IBSI 2 filter (or Riesz variant).

Every field is grounded in the corresponding filter implementation; see :data:FILTER_CAPABILITIES for the per-filter values and the source evidence behind each one.

Attributes:

Name Type Description
input_dimensionality tuple[int, ...]

Dimensionality/dimensionalities of the top-level image array the filter function accepts, e.g. (3,) for 3D-only.

kernel_dimensionality int

Dimensionality of the convolution/transform kernel itself (2 or 3). May differ from input_dimensionality, e.g. Gabor uses a 2D kernel on a 3D input.

slice_plane_execution bool

True if the filter applies its kernel plane-wise (slice-by-slice) rather than as a single volumetric operation.

orthogonal_plane_averaging bool

True if the filter supports averaging its plane-wise response over the 3 orthogonal anatomical planes.

rotation_pooling tuple[str, ...]

Pooling method names accepted for pseudo-rotational invariance (e.g. ("max", "average", "min")), or () if the filter has no rotation-invariance/pooling mechanism.

supported_boundaries tuple[str, ...]

BoundaryCondition member names the filter actually accepts via its boundary parameter, or () if it has no such parameter.

effective_boundary str

What actually happens at the image border. One of "as_specified" (a spatial-convolution filter that applies the requested boundary directly), "as_specified_via_padding" (FFT-domain filters: the requested boundary is realised by a defined pad-filter-crop procedure — the transform itself remains periodic on the padded domain, so the boundary is honoured approximately rather than exactly: with the default pads the response differs from that of a 64-voxel pad by up to about 1e-1 of its range near the border and at the centre of a large uniform object), or "periodic" (periodic regardless of what is requested).

supported_riesz_orders Optional[str]

Description of the Riesz derivative orders accepted (Riesz-family filters only), or None if the filter has no Riesz order concept.

structure_tensor_steering bool

True if the filter can steer its kernel using a structure tensor. Currently False for every filter (no tensor_sigma/steering exists anywhere in the package).

anisotropic_spacing str

How the filter handles anisotropic spacing_mm: "supported" (correctly converts per-axis), "warns_uses_first_axis" (warns and derives scale from the first axis only), or "not_applicable" (no spacing_mm parameter / concept).

Source code in pictologics/filters/capabilities.py
@dataclass(frozen=True)
class FilterCapability:
    """
    Declarative capability record for one IBSI 2 filter (or Riesz variant).

    Every field is grounded in the corresponding filter implementation; see
    :data:`FILTER_CAPABILITIES` for the per-filter values and the source
    evidence behind each one.

    Attributes:
        input_dimensionality: Dimensionality/dimensionalities of the top-level
            image array the filter function accepts, e.g. ``(3,)`` for
            3D-only.
        kernel_dimensionality: Dimensionality of the convolution/transform
            kernel itself (2 or 3). May differ from `input_dimensionality`,
            e.g. Gabor uses a 2D kernel on a 3D input.
        slice_plane_execution: True if the filter applies its kernel
            plane-wise (slice-by-slice) rather than as a single volumetric
            operation.
        orthogonal_plane_averaging: True if the filter supports averaging its
            plane-wise response over the 3 orthogonal anatomical planes.
        rotation_pooling: Pooling method names accepted for pseudo-rotational
            invariance (e.g. ``("max", "average", "min")``), or ``()`` if the
            filter has no rotation-invariance/pooling mechanism.
        supported_boundaries: `BoundaryCondition` member names the filter
            actually accepts via its `boundary` parameter, or ``()`` if it has
            no such parameter.
        effective_boundary: What actually happens at the image border. One of
            ``"as_specified"`` (a spatial-convolution filter that applies the
            requested boundary directly), ``"as_specified_via_padding"``
            (FFT-domain filters: the requested boundary is realised by a
            defined pad-filter-crop procedure — the transform itself remains
            periodic on the padded domain, so the boundary is honoured
            approximately rather than exactly: with the default pads the response
            differs from that of a 64-voxel pad by up to about 1e-1 of its range
            near the border and at the centre of a large uniform object), or
            ``"periodic"`` (periodic regardless of what is requested).
        supported_riesz_orders: Description of the Riesz derivative orders
            accepted (Riesz-family filters only), or `None` if the filter has
            no Riesz order concept.
        structure_tensor_steering: True if the filter can steer its kernel
            using a structure tensor. Currently `False` for every filter (no
            `tensor_sigma`/steering exists anywhere in the package).
        anisotropic_spacing: How the filter handles anisotropic
            `spacing_mm`: ``"supported"`` (correctly converts per-axis),
            ``"warns_uses_first_axis"`` (warns and derives scale from the
            first axis only), or ``"not_applicable"`` (no `spacing_mm`
            parameter / concept).
    """

    input_dimensionality: tuple[int, ...]
    kernel_dimensionality: int
    slice_plane_execution: bool
    orthogonal_plane_averaging: bool
    rotation_pooling: tuple[str, ...]
    supported_boundaries: tuple[str, ...]
    effective_boundary: str
    supported_riesz_orders: Optional[str]
    structure_tensor_steering: bool
    anisotropic_spacing: str

pictologics.filters.FILTER_CAPABILITIES = {'mean': FilterCapability(input_dimensionality=(3,), kernel_dimensionality=3, slice_plane_execution=False, orthogonal_plane_averaging=False, rotation_pooling=(), supported_boundaries=_ALL_BOUNDARIES, effective_boundary='as_specified', supported_riesz_orders=None, structure_tensor_steering=False, anisotropic_spacing='not_applicable'), 'log': FilterCapability(input_dimensionality=(3,), kernel_dimensionality=3, slice_plane_execution=False, orthogonal_plane_averaging=False, rotation_pooling=(), supported_boundaries=_ALL_BOUNDARIES, effective_boundary='as_specified', supported_riesz_orders=None, structure_tensor_steering=False, anisotropic_spacing='supported'), 'gaussian': FilterCapability(input_dimensionality=(3,), kernel_dimensionality=3, slice_plane_execution=False, orthogonal_plane_averaging=False, rotation_pooling=(), supported_boundaries=_ALL_BOUNDARIES, effective_boundary='as_specified', supported_riesz_orders=None, structure_tensor_steering=False, anisotropic_spacing='supported'), 'laws': FilterCapability(input_dimensionality=(3,), kernel_dimensionality=3, slice_plane_execution=False, orthogonal_plane_averaging=False, rotation_pooling=('max', 'average', 'min'), supported_boundaries=_ALL_BOUNDARIES, effective_boundary='as_specified', supported_riesz_orders=None, structure_tensor_steering=False, anisotropic_spacing='not_applicable'), 'gabor': FilterCapability(input_dimensionality=(3,), kernel_dimensionality=2, slice_plane_execution=True, orthogonal_plane_averaging=True, rotation_pooling=('max', 'average', 'min'), supported_boundaries=_ALL_BOUNDARIES, effective_boundary='as_specified', supported_riesz_orders=None, structure_tensor_steering=False, anisotropic_spacing='supported'), 'wavelet': FilterCapability(input_dimensionality=(3,), kernel_dimensionality=3, slice_plane_execution=False, orthogonal_plane_averaging=False, rotation_pooling=('max', 'average', 'min'), supported_boundaries=_ALL_BOUNDARIES, effective_boundary='as_specified', supported_riesz_orders=None, structure_tensor_steering=False, anisotropic_spacing='not_applicable'), 'simoncelli': FilterCapability(input_dimensionality=(3,), kernel_dimensionality=3, slice_plane_execution=False, orthogonal_plane_averaging=False, rotation_pooling=(), supported_boundaries=_ALL_BOUNDARIES, effective_boundary='as_specified_via_padding', supported_riesz_orders=None, structure_tensor_steering=False, anisotropic_spacing='not_applicable'), 'riesz': FilterCapability(input_dimensionality=(3,), kernel_dimensionality=3, slice_plane_execution=False, orthogonal_plane_averaging=False, rotation_pooling=(), supported_boundaries=_ALL_BOUNDARIES, effective_boundary='as_specified_via_padding', supported_riesz_orders='Tuple[int, ...] (l1, ..., ld): any non-negative ints summing to L >= 1; see get_riesz_orders(max_order, ndim) to enumerate all combinations.', structure_tensor_steering=False, anisotropic_spacing='not_applicable'), 'riesz_log': FilterCapability(input_dimensionality=(3,), kernel_dimensionality=3, slice_plane_execution=False, orthogonal_plane_averaging=False, rotation_pooling=(), supported_boundaries=_ALL_BOUNDARIES, effective_boundary='as_specified_via_padding', supported_riesz_orders='Tuple[int, ...] (l1, ..., ld): any non-negative ints summing to L >= 1; see get_riesz_orders(max_order, ndim) to enumerate all combinations.', structure_tensor_steering=False, anisotropic_spacing='supported'), 'riesz_simoncelli': FilterCapability(input_dimensionality=(3,), kernel_dimensionality=3, slice_plane_execution=False, orthogonal_plane_averaging=False, rotation_pooling=(), supported_boundaries=_ALL_BOUNDARIES, effective_boundary='as_specified_via_padding', supported_riesz_orders='Tuple[int, ...] (l1, ..., ld): any non-negative ints summing to L >= 1; see get_riesz_orders(max_order, ndim) to enumerate all combinations.', structure_tensor_steering=False, anisotropic_spacing='not_applicable')} module-attribute

Capability record for every public filter, keyed by pipeline filter-type name.

See the module docstring for the "riesz" / "riesz_log" / "riesz_simoncelli" keying scheme.

pictologics.filters.get_filter_capabilities(name)

Look up the capability record for a filter by its pipeline filter-type name.

Parameters:

Name Type Description Default
name str

Pipeline filter-type name, e.g. "mean", "gaussian", "log", "laws", "gabor", "wavelet", "simoncelli", "riesz", "riesz_log", or "riesz_simoncelli".

required

Returns:

Type Description
FilterCapability

The FilterCapability record registered under name.

Raises:

Type Description
ValueError

If name is not a key in FILTER_CAPABILITIES.

Example
from pictologics.filters import get_filter_capabilities

capability = get_filter_capabilities("riesz_log")
print(capability.anisotropic_spacing)
# supported
Source code in pictologics/filters/capabilities.py
def get_filter_capabilities(name: str) -> FilterCapability:
    """
    Look up the capability record for a filter by its pipeline filter-type name.

    Args:
        name: Pipeline filter-type name, e.g. ``"mean"``, ``"gaussian"``, ``"log"``,
            ``"laws"``, ``"gabor"``, ``"wavelet"``, ``"simoncelli"``, ``"riesz"``,
            ``"riesz_log"``, or ``"riesz_simoncelli"``.

    Returns:
        The `FilterCapability` record registered under `name`.

    Raises:
        ValueError: If `name` is not a key in `FILTER_CAPABILITIES`.

    Example:
        ```python
        from pictologics.filters import get_filter_capabilities

        capability = get_filter_capabilities("riesz_log")
        print(capability.anisotropic_spacing)
        # supported
        ```
    """
    try:
        return FILTER_CAPABILITIES[name]
    except KeyError as exc:
        valid = ", ".join(sorted(FILTER_CAPABILITIES))
        raise ValueError(f"Unknown filter name {name!r}; valid names are: {valid}") from exc

Boundary Conditions

A filter step takes the boundary as a name: "mirror", "nearest", "periodic" (or "wrap") or "zero" (or "constant"). A direct call also takes the enum. See Boundaries and Padding.

pictologics.filters.BoundaryCondition

Bases: Enum

IBSI 2 boundary conditions for image padding (GBYQ).

Maps to scipy.ndimage mode parameter values.

Example
from pictologics.filters import BoundaryCondition

boundary = BoundaryCondition.MIRROR
print(boundary.value)
# "reflect"

# By name (the filters take the names, in any case)
boundary = BoundaryCondition["MIRROR"]
# By the scipy mode string of get_scipy_mode
boundary = BoundaryCondition("reflect")
Source code in pictologics/filters/base.py
class BoundaryCondition(Enum):
    """
    IBSI 2 boundary conditions for image padding (GBYQ).

    Maps to scipy.ndimage mode parameter values.

    Example:
        ```python
        from pictologics.filters import BoundaryCondition

        boundary = BoundaryCondition.MIRROR
        print(boundary.value)
        # "reflect"

        # By name (the filters take the names, in any case)
        boundary = BoundaryCondition["MIRROR"]
        # By the scipy mode string of get_scipy_mode
        boundary = BoundaryCondition("reflect")
        ```
    """

    ZERO = "constant"  # Constant value padding (Z3VE): 0, or a filter's padding_value
    NEAREST = "nearest"  # Nearest value padding (SIJG)
    PERIODIC = "wrap"  # Periodic/wrap padding (Z7YO)
    MIRROR = "reflect"  # Mirror/symmetric padding (ZDTV)
    CONSTANT = "constant"  # Another name of ZERO

Results and Kernels

pictologics.filters.FilterResult dataclass

Container for filter response maps and metadata.

Example
import numpy as np
from pictologics.filters.base import FilterResult

result = FilterResult(
    response_map=np.zeros((4, 4, 4), dtype=np.float32),
    filter_name="mean",
    filter_params={"support": 3},
)
print(result.shape, result.dtype)
# (4, 4, 4) float32
Source code in pictologics/filters/base.py
@dataclass
class FilterResult:
    """Container for filter response maps and metadata.

    Example:
        ```python
        import numpy as np
        from pictologics.filters.base import FilterResult

        result = FilterResult(
            response_map=np.zeros((4, 4, 4), dtype=np.float32),
            filter_name="mean",
            filter_params={"support": 3},
        )
        print(result.shape, result.dtype)
        # (4, 4, 4) float32
        ```
    """

    response_map: npt.NDArray[np.floating[Any]]
    filter_name: str
    filter_params: Dict[str, Any]

    @property
    def shape(self) -> tuple[int, ...]:
        """Shape of the response map."""
        return self.response_map.shape  # type: ignore[no-any-return]

    @property
    def dtype(self) -> np.dtype[Any]:
        """Data type of the response map."""
        return self.response_map.dtype  # type: ignore[no-any-return]

dtype property

Data type of the response map.

shape property

Shape of the response map.

pictologics.filters.LAWS_KERNELS = _LAWS_KERNELS module-attribute

Dictionary of normalized Laws kernels (IBSI 2 Table 6).

Filter Functions

pictologics.filters.mean_filter(image, support=15, boundary=BoundaryCondition.ZERO, source_mask=None, padding_value=0.0)

mean_filter(
    image: npt.NDArray[np.floating[Any]],
    support: int = ...,
    boundary: Union[BoundaryCondition, str] = ...,
    source_mask: None = ...,
    padding_value: float = ...,
) -> npt.NDArray[np.floating[Any]]
mean_filter(
    image: npt.NDArray[np.floating[Any]],
    support: int = ...,
    boundary: Union[BoundaryCondition, str] = ...,
    source_mask: npt.NDArray[np.bool_] = ...,
    padding_value: float = ...,
) -> tuple[
    npt.NDArray[np.floating[Any]], npt.NDArray[np.bool_]
]

Apply 3D mean filter (IBSI code: S60F).

The mean filter computes the average intensity over an M×M×M spatial support. Per IBSI 2 Eq. 2.

Parameters:

Name Type Description Default
image NDArray[floating[Any]]

3D input image array

required
support int

Filter support M in voxels (must be odd, YNOF)

15
boundary Union[BoundaryCondition, str]

Boundary condition for padding (GBYQ)

ZERO
source_mask Optional[NDArray[bool_]]

Optional boolean mask where True = valid voxel. When provided, uses normalized convolution to exclude invalid (sentinel) voxels from mean computation.

None
padding_value float

The constant of constant value padding (Z3VE), with the ZERO (constant) boundary. Default 0.

0.0

Returns:

Type Description
Union[NDArray[floating[Any]], tuple[NDArray[floating[Any]], NDArray[bool_]]]

If source_mask is None: Response map with same dimensions as input

Union[NDArray[floating[Any]], tuple[NDArray[floating[Any]], NDArray[bool_]]]

If source_mask provided: Tuple of (response_map, output_valid_mask)

Raises:

Type Description
ValueError

If support is not an odd positive integer

Example

Apply Mean filter with 15-voxel support:

import numpy as np
from pictologics.filters import mean_filter

# Create dummy 3D image
image = np.random.rand(50, 50, 50)

# Apply filter
response = mean_filter(image, support=15, boundary="zero")

# With source_mask for sentinel exclusion
mask = image > -1000  # Valid voxels
response, valid_mask = mean_filter(image, support=15, source_mask=mask)
Note

Support M is defined in voxel units as per IBSI specification.

Source code in pictologics/filters/mean.py
def mean_filter(
    image: npt.NDArray[np.floating[Any]],
    support: int = 15,
    boundary: Union[BoundaryCondition, str] = BoundaryCondition.ZERO,
    source_mask: Optional[npt.NDArray[np.bool_]] = None,
    padding_value: float = 0.0,
) -> Union[
    npt.NDArray[np.floating[Any]],
    tuple[npt.NDArray[np.floating[Any]], npt.NDArray[np.bool_]],
]:
    """
    Apply 3D mean filter (IBSI code: S60F).

    The mean filter computes the average intensity over an M×M×M
    spatial support. Per IBSI 2 Eq. 2.

    Args:
        image: 3D input image array
        support: Filter support M in voxels (must be odd, YNOF)
        boundary: Boundary condition for padding (GBYQ)
        source_mask: Optional boolean mask where True = valid voxel.
            When provided, uses normalized convolution to exclude invalid
            (sentinel) voxels from mean computation.
        padding_value: The constant of constant value padding (Z3VE), with the ZERO
            (constant) boundary. Default 0.

    Returns:
        If source_mask is None: Response map with same dimensions as input
        If source_mask provided: Tuple of (response_map, output_valid_mask)

    Raises:
        ValueError: If support is not an odd positive integer

    Example:
        Apply Mean filter with 15-voxel support:

        ```python
        import numpy as np
        from pictologics.filters import mean_filter

        # Create dummy 3D image
        image = np.random.rand(50, 50, 50)

        # Apply filter
        response = mean_filter(image, support=15, boundary="zero")

        # With source_mask for sentinel exclusion
        mask = image > -1000  # Valid voxels
        response, valid_mask = mean_filter(image, support=15, source_mask=mask)
        ```

    Note:
        Support M is defined in voxel units as per IBSI specification.
    """
    source_mask = _bool_mask(source_mask)  # a mask of another type: != 0
    # Validate support
    if support < 1 or support % 2 == 0:
        raise ValueError(f"Support must be an odd positive integer, got {support}")

    # The passes run in float32. The normalized convolution of a source mask keeps the
    # image type: it divides two filtered sums.
    image = ensure_float32(image) if source_mask is not None else as_float32(image)

    # Handle string boundary condition
    boundary = resolve_boundary(boundary)

    problem = _padding_value_problem(boundary, padding_value)
    if problem:
        raise ValueError(problem)
    if padding_value and source_mask is not None:
        return _constant_padded(
            mean_filter, image, source_mask, padding_value, support // 2, support=support, boundary=boundary
        )  # fmt: skip
    mode = get_scipy_mode(boundary)

    if source_mask is not None:
        # Use normalized convolution for source masking
        return _normalized_uniform_filter(image, source_mask, size=support, mode=mode)
    else:
        # A float32 response: each pass sums its lines in double and writes float32. The
        # passes read the padding value beyond the image (no padded copy).
        cval = float(image.dtype.type(padding_value))
        return _uniform_filter(image, support, mode, cval=cval)

pictologics.filters.gaussian_filter(image, sigma_mm, spacing_mm=1.0, truncate=4.0, boundary=BoundaryCondition.ZERO, source_mask=None, padding_value=0.0)

gaussian_filter(
    image: npt.NDArray[np.floating[Any]],
    sigma_mm: float,
    spacing_mm: Union[
        float, Tuple[float, float, float]
    ] = ...,
    truncate: float = ...,
    boundary: Union[BoundaryCondition, str] = ...,
    source_mask: None = ...,
    padding_value: float = ...,
) -> npt.NDArray[np.floating[Any]]
gaussian_filter(
    image: npt.NDArray[np.floating[Any]],
    sigma_mm: float,
    spacing_mm: Union[
        float, Tuple[float, float, float]
    ] = ...,
    truncate: float = ...,
    boundary: Union[BoundaryCondition, str] = ...,
    source_mask: npt.NDArray[np.bool_] = ...,
    padding_value: float = ...,
) -> Tuple[
    npt.NDArray[np.floating[Any]], npt.NDArray[np.bool_]
]

Apply a 3D Gaussian filter (IBSI code: 8BC3): a smoothing low-pass filter.

Parameters:

Name Type Description Default
image NDArray[floating[Any]]

3D input image array

required
sigma_mm float

Scale σ* in mm (41LN)

required
spacing_mm Union[float, Tuple[float, float, float]]

Voxel spacing in mm (scalar or a value for each axis)

1.0
truncate float

Filter size cutoff in σ units (default 4.0, WGPM)

4.0
boundary Union[BoundaryCondition, str]

Boundary condition for padding (GBYQ)

ZERO
source_mask Optional[NDArray[bool_]]

Optional boolean mask where True = valid voxel. When provided, normalized convolution leaves out the invalid voxels.

None
padding_value float

The constant of constant value padding (Z3VE), with the ZERO (constant) boundary. Default 0.

0.0

Returns:

Type Description
Union[NDArray[floating[Any]], Tuple[NDArray[floating[Any]], NDArray[bool_]]]

If source_mask is None: Response map (float32) with the dimensions of the input.

Union[NDArray[floating[Any]], Tuple[NDArray[floating[Any]], NDArray[bool_]]]

If source_mask is given: (response map, output valid mask).

Raises:

Type Description
ValueError

If padding_value is not 0 with another boundary than ZERO.

Example
import numpy as np
from pictologics.filters import gaussian_filter

image = np.random.rand(50, 50, 50)
smoothed = gaussian_filter(image, sigma_mm=2.0, spacing_mm=(0.8, 0.8, 2.0))
Note
  • σ is converted from mm to voxels: σ_voxels = σ_mm / spacing_mm
  • The kernel of each axis has the radius ⌊truncate × σ_voxels + 0.5⌋ and sums to 1
Source code in pictologics/filters/gaussian.py
def gaussian_filter(
    image: npt.NDArray[np.floating[Any]],
    sigma_mm: float,
    spacing_mm: Union[float, Tuple[float, float, float]] = 1.0,
    truncate: float = 4.0,
    boundary: Union[BoundaryCondition, str] = BoundaryCondition.ZERO,
    source_mask: Optional[npt.NDArray[np.bool_]] = None,
    padding_value: float = 0.0,
) -> Union[
    npt.NDArray[np.floating[Any]],
    Tuple[npt.NDArray[np.floating[Any]], npt.NDArray[np.bool_]],
]:
    """
    Apply a 3D Gaussian filter (IBSI code: 8BC3): a smoothing low-pass filter.

    Args:
        image: 3D input image array
        sigma_mm: Scale σ* in mm (41LN)
        spacing_mm: Voxel spacing in mm (scalar or a value for each axis)
        truncate: Filter size cutoff in σ units (default 4.0, WGPM)
        boundary: Boundary condition for padding (GBYQ)
        source_mask: Optional boolean mask where True = valid voxel. When provided,
            normalized convolution leaves out the invalid voxels.
        padding_value: The constant of constant value padding (Z3VE), with the ZERO
            (constant) boundary. Default 0.

    Returns:
        If source_mask is None: Response map (float32) with the dimensions of the input.
        If source_mask is given: (response map, output valid mask).

    Raises:
        ValueError: If `padding_value` is not 0 with another boundary than ZERO.

    Example:
        ```python
        import numpy as np
        from pictologics.filters import gaussian_filter

        image = np.random.rand(50, 50, 50)
        smoothed = gaussian_filter(image, sigma_mm=2.0, spacing_mm=(0.8, 0.8, 2.0))
        ```

    Note:
        - σ is converted from mm to voxels: σ_voxels = σ_mm / spacing_mm
        - The kernel of each axis has the radius ⌊truncate × σ_voxels + 0.5⌋ and sums to 1
    """
    source_mask = _bool_mask(source_mask)  # a mask of another type: != 0
    # The passes run in float32. The normalized convolution of a source mask keeps the
    # image type: it divides two filtered sums.
    image = ensure_float32(image) if source_mask is not None else as_float32(image)
    if isinstance(spacing_mm, (int, float)):
        spacing_mm = (float(spacing_mm),) * 3
    sigma_voxels = tuple(sigma_mm / s for s in spacing_mm)
    boundary = resolve_boundary(boundary)
    problem = _padding_value_problem(boundary, padding_value)
    if problem:
        raise ValueError(problem)
    if padding_value and source_mask is not None:
        reach = tuple(int(truncate * s + 0.5) for s in sigma_voxels)
        return _constant_padded(
            gaussian_filter, image, source_mask, padding_value, reach,
            sigma_mm=sigma_mm, spacing_mm=spacing_mm, truncate=truncate, boundary=boundary,
        )  # fmt: skip
    mode = get_scipy_mode(boundary)
    if source_mask is not None:
        return _normalized_gaussian(image, source_mask, sigma_voxels, mode, truncate)
    # A float32 response: each pass sums its lines in double and writes float32. The passes
    # read the padding value beyond the image (no padded copy).
    cval = float(image.dtype.type(padding_value))
    return _gaussian_filter(image, sigma_voxels, mode, truncate, cval=cval)

pictologics.filters.laplacian_of_gaussian(image, sigma_mm, spacing_mm=1.0, truncate=4.0, boundary=BoundaryCondition.ZERO, source_mask=None, padding_value=0.0)

laplacian_of_gaussian(
    image: npt.NDArray[np.floating[Any]],
    sigma_mm: float,
    spacing_mm: Union[
        float, Tuple[float, float, float]
    ] = ...,
    truncate: float = ...,
    boundary: Union[BoundaryCondition, str] = ...,
    source_mask: None = ...,
    padding_value: float = ...,
) -> npt.NDArray[np.floating[Any]]
laplacian_of_gaussian(
    image: npt.NDArray[np.floating[Any]],
    sigma_mm: float,
    spacing_mm: Union[
        float, Tuple[float, float, float]
    ] = ...,
    truncate: float = ...,
    boundary: Union[BoundaryCondition, str] = ...,
    source_mask: npt.NDArray[np.bool_] = ...,
    padding_value: float = ...,
) -> tuple[
    npt.NDArray[np.floating[Any]], npt.NDArray[np.bool_]
]

Apply 3D Laplacian of Gaussian filter (IBSI code: L6PA).

The LoG is a band-pass, spherically symmetric operator. Per IBSI 2 Eq. 3.

Parameters:

Name Type Description Default
image NDArray[floating[Any]]

3D input image array

required
sigma_mm float

Standard deviation in mm (σ*, 41LN)

required
spacing_mm Union[float, Tuple[float, float, float]]

Voxel spacing in mm (scalar for isotropic, or tuple)

1.0
truncate float

Filter size cutoff in σ units (default 4.0, WGPM)

4.0
boundary Union[BoundaryCondition, str]

Boundary condition for padding (GBYQ)

ZERO
source_mask Optional[NDArray[bool_]]

Optional boolean mask where True = valid voxel. When provided, uses normalized convolution to exclude invalid (sentinel) voxels from computation.

None
padding_value float

The constant of constant value padding (Z3VE), with the ZERO (constant) boundary. Default 0.

0.0

Returns:

Type Description
Union[NDArray[floating[Any]], tuple[NDArray[floating[Any]], NDArray[bool_]]]

If source_mask is None: Response map with same dimensions as input

Union[NDArray[floating[Any]], tuple[NDArray[floating[Any]], NDArray[bool_]]]

If source_mask provided: Tuple of (response_map, output_valid_mask)

Raises:

Type Description
ValueError

If sigma_mm, an entry of spacing_mm or truncate is not a finite positive number, or if padding_value is not 0 with another boundary than ZERO.

Example

Apply LoG filter with 5.0mm sigma on an image with 2.0mm spacing:

import numpy as np
from pictologics.filters import laplacian_of_gaussian

# Create dummy 3D image
image = np.random.rand(50, 50, 50)

# Apply filter
response = laplacian_of_gaussian(
    image,
    sigma_mm=5.0,
    spacing_mm=(2.0, 2.0, 2.0),
    truncate=4.0
)

# With source_mask for sentinel exclusion
mask = image > -1000
response, valid_mask = laplacian_of_gaussian(
    image, sigma_mm=5.0, spacing_mm=2.0, source_mask=mask
)
Note
  • σ is converted from mm to voxels: σ_voxels = σ_mm / spacing_mm
  • Filter size: M = 1 + 2⌊d×σ + 0.5⌋ where d=truncate
  • The kernel should sum to approximately 0 (zero-mean)
  • A float64 image of 131,072 voxels (about 51³) or more runs as one FFT convolution with the same truncated kernels and boundary: about 2 times faster than the separable passes, with values within about 1e-15 of the largest response value.
  • A smaller float64 image runs the passes in float32, as a float32 image does: faster, with values within about 1e-7 of the largest response value.
  • With a padding_value, the padded image counts.
Source code in pictologics/filters/log.py
def laplacian_of_gaussian(
    image: npt.NDArray[np.floating[Any]],
    sigma_mm: float,
    spacing_mm: Union[float, Tuple[float, float, float]] = 1.0,
    truncate: float = 4.0,
    boundary: Union[BoundaryCondition, str] = BoundaryCondition.ZERO,
    source_mask: Optional[npt.NDArray[np.bool_]] = None,
    padding_value: float = 0.0,
) -> Union[
    npt.NDArray[np.floating[Any]],
    tuple[npt.NDArray[np.floating[Any]], npt.NDArray[np.bool_]],
]:
    """
    Apply 3D Laplacian of Gaussian filter (IBSI code: L6PA).

    The LoG is a band-pass, spherically symmetric operator. Per IBSI 2 Eq. 3.

    Args:
        image: 3D input image array
        sigma_mm: Standard deviation in mm (σ*, 41LN)
        spacing_mm: Voxel spacing in mm (scalar for isotropic, or tuple)
        truncate: Filter size cutoff in σ units (default 4.0, WGPM)
        boundary: Boundary condition for padding (GBYQ)
        source_mask: Optional boolean mask where True = valid voxel.
            When provided, uses normalized convolution to exclude invalid
            (sentinel) voxels from computation.
        padding_value: The constant of constant value padding (Z3VE), with the ZERO
            (constant) boundary. Default 0.

    Returns:
        If source_mask is None: Response map with same dimensions as input
        If source_mask provided: Tuple of (response_map, output_valid_mask)

    Raises:
        ValueError: If `sigma_mm`, an entry of `spacing_mm` or `truncate` is not a finite
            positive number, or if `padding_value` is not 0 with another boundary than
            ZERO.

    Example:
        Apply LoG filter with 5.0mm sigma on an image with 2.0mm spacing:

        ```python
        import numpy as np
        from pictologics.filters import laplacian_of_gaussian

        # Create dummy 3D image
        image = np.random.rand(50, 50, 50)

        # Apply filter
        response = laplacian_of_gaussian(
            image,
            sigma_mm=5.0,
            spacing_mm=(2.0, 2.0, 2.0),
            truncate=4.0
        )

        # With source_mask for sentinel exclusion
        mask = image > -1000
        response, valid_mask = laplacian_of_gaussian(
            image, sigma_mm=5.0, spacing_mm=2.0, source_mask=mask
        )
        ```

    Note:
        - σ is converted from mm to voxels: σ_voxels = σ_mm / spacing_mm
        - Filter size: M = 1 + 2⌊d×σ + 0.5⌋ where d=truncate
        - The kernel should sum to approximately 0 (zero-mean)
        - A float64 image of 131,072 voxels (about 51³) or more runs as one FFT
          convolution with the same truncated kernels and boundary: about 2 times faster
          than the separable passes, with values within about 1e-15 of the largest
          response value.
        - A smaller float64 image runs the passes in float32, as a float32 image does:
          faster, with values within about 1e-7 of the largest response value.
        - With a `padding_value`, the padded image counts.
    """
    source_mask = _bool_mask(source_mask)  # a mask of another type: != 0
    # Handle scalar spacing
    if isinstance(spacing_mm, (int, float)):
        spacing_mm = (float(spacing_mm),) * 3
    problem = _log_parameter_problem(sigma_mm, spacing_mm, truncate)
    if problem:
        raise ValueError(problem)

    # Convert to float32 as required by IBSI
    image = ensure_float32(image)

    # Convert sigma from mm to voxels for each axis
    sigma_voxels = tuple(sigma_mm / s for s in spacing_mm)

    # Handle string boundary condition
    boundary = resolve_boundary(boundary)

    problem = _padding_value_problem(boundary, padding_value)
    if problem:
        raise ValueError(problem)
    if padding_value:
        reach = tuple(int(truncate * s + 0.5) for s in sigma_voxels)
        return _constant_padded(
            laplacian_of_gaussian, image, source_mask, padding_value, reach,
            sigma_mm=sigma_mm, spacing_mm=spacing_mm, truncate=truncate, boundary=boundary,
        )  # fmt: skip
    mode = get_scipy_mode(boundary)

    if source_mask is not None:
        # Use normalized convolution for source masking
        return _normalized_gaussian_laplace(
            image, source_mask, sigma=sigma_voxels, mode=mode, truncate=truncate
        )
    else:
        # A float64 image below _LOG_FFT_MIN voxels runs its 8 passes in float32 (faster;
        # the FFT path of a larger float64 image keeps float64). The response is float32.
        if image.dtype == np.float64 and image.size < _LOG_FFT_MIN:
            image = as_float32(image)
        result = _gaussian_laplace(image, sigma_voxels, mode, truncate, fft_dtype=np.float32)
        if result.dtype == np.float32:
            return result
        return _float32_cut(result, None)

pictologics.filters.laws_filter(image, kernels, boundary=BoundaryCondition.ZERO, rotation_invariant=False, pooling='max', compute_energy=False, energy_distance=7, use_parallel=None, source_mask=None, padding_value=0.0)

laws_filter(
    image: npt.NDArray[np.floating[Any]],
    kernels: str,
    boundary: Union[BoundaryCondition, str] = ...,
    rotation_invariant: bool = ...,
    pooling: str = ...,
    compute_energy: bool = ...,
    energy_distance: int = ...,
    use_parallel: Union[bool, None] = ...,
    source_mask: None = ...,
    padding_value: float = ...,
) -> npt.NDArray[np.floating[Any]]
laws_filter(
    image: npt.NDArray[np.floating[Any]],
    kernels: str,
    boundary: Union[BoundaryCondition, str] = ...,
    rotation_invariant: bool = ...,
    pooling: str = ...,
    compute_energy: bool = ...,
    energy_distance: int = ...,
    use_parallel: Union[bool, None] = ...,
    source_mask: npt.NDArray[np.bool_] = ...,
    padding_value: float = ...,
) -> Tuple[
    npt.NDArray[np.floating[Any]], npt.NDArray[np.bool_]
]

Apply 3D Laws kernel filter (IBSI code: JTXT).

Laws kernels detect texture patterns via separable 1D filters combined into 2D/3D filters via outer products.

Parameters:

Name Type Description Default
image NDArray[floating[Any]]

3D input image array

required
kernels str

Kernel specification as string, e.g., "E5L5S5" for 3D

required
boundary Union[BoundaryCondition, str]

Boundary condition for padding (GBYQ)

ZERO
rotation_invariant bool

If True, apply pseudo-rotational invariance (O1AQ) using max pooling over 24 right-angle rotations

False
pooling str

Pooling method for rotation invariance ("max", "average", "min")

'max'
compute_energy bool

If True, compute texture energy image (PQSD)

False
energy_distance int

Chebyshev distance δ for energy computation (I176)

7
use_parallel Union[bool, None]

If True, use parallel processing for rotation_invariant mode. If None (default), auto-enables for images > 20,000 voxels. Only affects rotation_invariant mode.

None
source_mask Optional[NDArray[bool_]]

Optional boolean mask where True = valid voxel. In non-rotation-invariant mode, uses normalized separable convolution to exclude invalid (sentinel) voxels. In rotation-invariant mode, invalid voxels are zero-filled as a first-order approximation (the rotated kernels preclude normalized convolution).

None
padding_value float

The constant of constant value padding (Z3VE), with the ZERO (constant) boundary. Default 0.

0.0

Returns:

Type Description
Union[NDArray[floating[Any]], Tuple[NDArray[floating[Any]], NDArray[bool_]]]

If source_mask is None: Response map (or energy image if compute_energy=True)

Union[NDArray[floating[Any]], Tuple[NDArray[floating[Any]], NDArray[bool_]]]

If source_mask provided: Tuple of (response_map, output_valid_mask)

Raises:

Type Description
ValueError

If kernels does not parse into exactly 3 Laws kernel codes (wrong count or malformed string), if any parsed kernel code is not a recognized name (see LAWS_KERNELS), or if rotation_invariant=True and pooling is not "max", "average", or "min".

RuntimeError

Defensive check raised if no response was computed; not expected to occur in normal use.

Example

Apply Laws E5L5S5 kernel with rotation invariance and texture energy:

import numpy as np
from pictologics.filters import laws_filter

# Create dummy 3D image
image = np.random.rand(50, 50, 50)

# Apply filter
response = laws_filter(
    image,
    "E5L5S5",
    rotation_invariant=True,
    pooling="max",
    compute_energy=True,
    energy_distance=7
)
Note
  • Kernels are normalized (deviate from Laws' original unnormalized)
  • Energy is computed as: mean(|h|) over δ neighborhood
  • For rotation invariance, energy is computed after pooling
  • Uses separable 1D convolutions for ~8x speedup over full 3D
Source code in pictologics/filters/laws.py
def laws_filter(
    image: npt.NDArray[np.floating[Any]],
    kernels: str,
    boundary: Union[BoundaryCondition, str] = BoundaryCondition.ZERO,
    rotation_invariant: bool = False,
    pooling: str = "max",
    compute_energy: bool = False,
    energy_distance: int = 7,
    use_parallel: Union[bool, None] = None,
    source_mask: Optional[npt.NDArray[np.bool_]] = None,
    padding_value: float = 0.0,
) -> Union[
    npt.NDArray[np.floating[Any]],
    Tuple[npt.NDArray[np.floating[Any]], npt.NDArray[np.bool_]],
]:
    """
    Apply 3D Laws kernel filter (IBSI code: JTXT).

    Laws kernels detect texture patterns via separable 1D filters combined
    into 2D/3D filters via outer products.

    Args:
        image: 3D input image array
        kernels: Kernel specification as string, e.g., "E5L5S5" for 3D
        boundary: Boundary condition for padding (GBYQ)
        rotation_invariant: If True, apply pseudo-rotational invariance (O1AQ)
                            using max pooling over 24 right-angle rotations
        pooling: Pooling method for rotation invariance ("max", "average", "min")
        compute_energy: If True, compute texture energy image (PQSD)
        energy_distance: Chebyshev distance δ for energy computation (I176)
        use_parallel: If True, use parallel processing for rotation_invariant mode.
            If None (default), auto-enables for images > 20,000 voxels.
            Only affects rotation_invariant mode.
        source_mask: Optional boolean mask where True = valid voxel.
            In non-rotation-invariant mode, uses normalized separable convolution
            to exclude invalid (sentinel) voxels. In rotation-invariant mode,
            invalid voxels are zero-filled as a first-order approximation (the
            rotated kernels preclude normalized convolution).
        padding_value: The constant of constant value padding (Z3VE), with the ZERO
            (constant) boundary. Default 0.

    Returns:
        If source_mask is None: Response map (or energy image if compute_energy=True)
        If source_mask provided: Tuple of (response_map, output_valid_mask)

    Raises:
        ValueError: If `kernels` does not parse into exactly 3 Laws kernel
            codes (wrong count or malformed string), if any parsed kernel
            code is not a recognized name (see `LAWS_KERNELS`), or if
            `rotation_invariant=True` and `pooling` is not "max", "average",
            or "min".
        RuntimeError: Defensive check raised if no response was computed;
            not expected to occur in normal use.

    Example:
        Apply Laws E5L5S5 kernel with rotation invariance and texture energy:

        ```python
        import numpy as np
        from pictologics.filters import laws_filter

        # Create dummy 3D image
        image = np.random.rand(50, 50, 50)

        # Apply filter
        response = laws_filter(
            image,
            "E5L5S5",
            rotation_invariant=True,
            pooling="max",
            compute_energy=True,
            energy_distance=7
        )
        ```

    Note:
        - Kernels are normalized (deviate from Laws' original unnormalized)
        - Energy is computed as: mean(|h|) over δ neighborhood
        - For rotation invariance, energy is computed after pooling
        - Uses separable 1D convolutions for ~8x speedup over full 3D
    """

    source_mask = _bool_mask(source_mask)  # a mask of another type: != 0
    # The passes run in float32. The normalized convolution of a source mask (without
    # rotation invariance) keeps the image type: it divides two filtered sums.
    masked_sums = source_mask is not None and not rotation_invariant
    image = ensure_float32(image) if masked_sums else as_float32(image)

    # Parse kernel names (e.g., "E5L5S5" -> ["E5", "L5", "S5"])
    kernel_names = _parse_kernel_string(kernels)
    if len(kernel_names) != 3:
        raise ValueError(f"Expected 3 kernel names for 3D, got {len(kernel_names)}: {kernel_names}")

    # Handle boundary condition
    boundary = resolve_boundary(boundary)
    mode = get_scipy_mode(boundary)

    # Validate pooling method if used
    if rotation_invariant and pooling not in ("max", "average", "min"):
        raise ValueError(f"Unknown pooling method: {pooling}")

    # Get 1D kernels for separable convolution
    try:
        g1 = LAWS_KERNELS[kernel_names[0]].astype(np.float32)
        g2 = LAWS_KERNELS[kernel_names[1]].astype(np.float32)
        g3 = LAWS_KERNELS[kernel_names[2]].astype(np.float32)
    except KeyError as exc:
        valid = ", ".join(sorted(LAWS_KERNELS))
        raise ValueError(
            f"Unknown Laws kernel {exc.args[0]!r}; valid kernels are: {valid}"
        ) from exc
    problem = _padding_value_problem(boundary, padding_value)
    if problem:
        raise ValueError(problem)
    # The convolution reads the constant beyond the image; the energy step then pads the
    # response with 0 by the boundary, as with a padding value of 0. A source mask takes a
    # padded copy.
    cval = 0.0
    if padding_value and source_mask is None:
        cval = float(image.dtype.type(padding_value))
    elif padding_value:
        padded = _constant_padded(
            laws_filter, image, source_mask, padding_value, max(g1.size, g2.size, g3.size) // 2,
            kernels=kernels, boundary=boundary, rotation_invariant=rotation_invariant,
            pooling=pooling, use_parallel=use_parallel,
        )  # fmt: skip
        if not compute_energy:
            return padded
        response, valid_part = padded if isinstance(padded, tuple) else (padded, None)
        energy = _laws_energy(response, energy_distance, mode)
        return energy if valid_part is None else (energy, valid_part)

    # Auto-detect parallel mode based on image size
    if use_parallel is None:
        use_parallel = image.size > _PARALLEL_THRESHOLD

    result: npt.NDArray[np.floating[Any]] | None = None

    if rotation_invariant:
        # Normalized convolution isn't available on the rotated kernels, so zero-fill
        # invalid voxels as a first-order approximation (same approach the FFT-based
        # filters use). The output validity mask is the input mask (zero-fill does
        # not shrink it, unlike normalized convolution).
        if source_mask is not None:
            image = _prepare_masked_image(image, source_mask)
            valid_mask = source_mask
        else:
            valid_mask = None

        rotations = _get_rotation_permutations_3d()

        # Every Laws 1D kernel is odd-length and either symmetric (L, S, R) or
        # antisymmetric (E, W). Reversing a symmetric kernel is a no-op; reversing an
        # antisymmetric one negates it. So each of the 24 rotated separable
        # convolutions equals ±(a convolution with the kernels permuted but not
        # flipped). We therefore compute only the (at most 6) distinct permutations
        # and recover every rotation's response with a sign flip.
        kernel_arrays = [g1, g2, g3]
        antisym = [bool(np.allclose(k, -k[::-1])) for k in kernel_arrays]

        # One step per rotation: the permuted kernels (the key) and the sign.
        steps: List[Tuple[Tuple[str, str, str], int]] = []
        base_perms: Dict[Tuple[str, str, str], Tuple[int, int, int]] = {}
        for perm, flips in rotations:
            key = (kernel_names[perm[0]], kernel_names[perm[1]], kernel_names[perm[2]])
            sign = 1
            for i, do_flip in enumerate(flips):
                if do_flip and antisym[perm[i]]:
                    sign = -sign
            steps.append((key, sign))
            base_perms.setdefault(key, perm)
        if pooling != "average":
            # A (key, sign) step that comes again leaves a max or a min unchanged.
            steps = list(dict.fromkeys(steps))

        # The bases run in a pool (at most _BASES_IN_FLIGHT and about 2 GB of them at a
        # time), and each one has its share of the threads for its passes.
        threads = get_num_threads() if use_parallel else 1
        # A small image has no slab threads in its passes, so all its bases run at once
        in_flight = _BASES_IN_FLIGHT if image.size >= _SLAB_MIN_SIZE else threads
        workers = max(1, min(len(base_perms), in_flight, threads, (2 << 30) // (4 * image.size)))

        def _base(perm: Tuple[int, int, int]) -> npt.NDArray[np.floating[Any]]:
            return _separable_convolve_3d(
                image,
                kernel_arrays[perm[0]],
                kernel_arrays[perm[1]],
                kernel_arrays[perm[2]],
                mode,
                threads=threads // workers,
                cval=cval,
            )

        bases = _ordered_map(_base, base_perms.values(), workers)
        result = _pool_rotations(bases, steps, pooling)

        # Finalize average pooling (all bases are done: the slab threads are free)
        if pooling == "average" and result is not None:
            _slab_ufunc(np.true_divide, (result, len(rotations)), result)
    else:
        # Non-rotation-invariant: single separable convolution
        if source_mask is not None:
            result, valid_mask = _normalized_separable_convolve_3d(
                image, source_mask, g1, g2, g3, mode
            )
        else:
            # The passes of the float32 image give the float32 response
            result = _separable_convolve_3d(image, g1, g2, g3, mode, cval=cval)
            valid_mask = None

    # Compute energy image if requested
    if compute_energy:
        if result is None:  # pragma: no cover
            raise RuntimeError("Result should not be None")

        result = _laws_energy(result, energy_distance, mode)

    if result is None:  # pragma: no cover
        raise RuntimeError("Result should not be None")
    # float32, as the other filters: the rotation-invariant and masked paths pool or divide
    # in float64, and a large result copies in threads
    if result.dtype != np.float32:
        result = _float32_cut(result, None)

    if source_mask is not None and valid_mask is not None:
        return result, valid_mask
    return result  # type: ignore[no-any-return]

pictologics.filters.gabor_filter(image, sigma_mm, lambda_mm, gamma=1.0, theta=0.0, spacing_mm=1.0, boundary=BoundaryCondition.ZERO, rotation_invariant=False, delta_theta=None, pooling='average', average_over_planes=False, use_parallel=None, source_mask=None, region=None, response='modulus', padding_value=0.0)

Apply 2D Gabor filter to 3D image (IBSI code: Q88H).

The Gabor filter is applied in the axial plane (k1, k2) and optionally averaged over orthogonal planes. Per IBSI 2 Eq. 9.

Parameters:

Name Type Description Default
image NDArray[floating[Any]]

3D input image array

required
sigma_mm float

Standard deviation of Gaussian envelope in mm (41LN)

required
lambda_mm float

Wavelength in mm (S4N6)

required
gamma float

Spatial aspect ratio (GDR5), typically 0.5 to 2.0

1.0
theta float

Orientation angle in radians (FQER), clockwise in (k1,k2)

0.0
spacing_mm Union[float, Tuple[float, float, float]]

Voxel spacing in mm (scalar or per-axis tuple). Each plane's kernel is built from that plane's own two in-plane axis spacings, so anisotropic spacing (including anisotropic z, relevant when average_over_planes=True) is handled correctly.

1.0
boundary Union[BoundaryCondition, str]

Boundary condition for padding (GBYQ)

ZERO
rotation_invariant bool

If True, average over orientations

False
delta_theta Optional[float]

Orientation step for rotation invariance (XTGK)

None
pooling str

Pooling method ("average", "max", "min")

'average'
average_over_planes bool

If True, average 2D responses over 3 orthogonal planes

False
use_parallel Union[bool, None]

If True, process slices in parallel. If None (default), auto-enables when the slices to filter hold more than ~46³ voxels.

None
source_mask Optional[NDArray[bool_]]

Optional boolean mask where True = valid voxel. When provided, zeros out invalid (sentinel) voxels before FFT-based convolution to prevent contamination.

None
region Optional[Tuple[slice, slice, slice]]

Optional box of the image (one slice per axis). Only the response in the box is computed, and the result has the box shape. Each plane still filters whole slices, so the values are those of the whole image.

None
response str

The part of the complex response (5P3T): "modulus" (0J1M, default), "angle" (V530, in radians), "real" (F7DI) or "imaginary" (LI3Y). The orientations pool this part.

'modulus'
padding_value float

The constant of constant value padding (Z3VE), with the ZERO (constant) boundary. Default 0.

0.0

Returns:

Type Description
NDArray[floating[Any]]

Response map (the response part of the complex response)

Raises:

Type Description
ValueError

If pooling is not "max", "average", or "min", if rotation_invariant=True is set without providing delta_theta, if response is not one of the four parts, if padding_value is not 0 with another boundary than ZERO, if sigma_mm, lambda_mm or gamma is not a finite positive number, if an entry of spacing_mm is not, or if delta_theta (with rotation_invariant=True) is not in (0, 2 pi].

Example

Apply Gabor filter with rotation invariance over orthogonal planes:

import numpy as np
from pictologics.filters import gabor_filter

# Create dummy 3D image
image = np.random.rand(50, 50, 50)

# Apply filter
response = gabor_filter(
    image,
    sigma_mm=10.0,
    lambda_mm=4.0,
    gamma=0.5,
    rotation_invariant=True,
    delta_theta=0.7853981633974483,  # pi/4
    average_over_planes=True
)
Note
  • Returns the part of h = g ⊗ f that response names (default the modulus |h|)
  • 2D filter applied slice-by-slice, then optionally over planes
  • Uses single complex FFT convolution for ~2x speedup
  • Each plane's kernel uses that plane's own two in-plane spacings. When they are equal (the isotropic-in-plane case, including the default axial-only plane under typical (x, y, z) spacing with x == y), the kernel is built on a voxel-unit grid. When they differ, the kernel is built on a physical-coordinate (mm) grid with a per-axis radius, giving a rectangular kernel that follows the true spacing of each axis.
Source code in pictologics/filters/gabor.py
def gabor_filter(
    image: npt.NDArray[np.floating[Any]],
    sigma_mm: float,
    lambda_mm: float,
    gamma: float = 1.0,
    theta: float = 0.0,
    spacing_mm: Union[float, Tuple[float, float, float]] = 1.0,
    boundary: Union[BoundaryCondition, str] = BoundaryCondition.ZERO,
    rotation_invariant: bool = False,
    delta_theta: Optional[float] = None,
    pooling: str = "average",
    average_over_planes: bool = False,
    use_parallel: Union[bool, None] = None,
    source_mask: Optional[npt.NDArray[np.bool_]] = None,
    region: Optional[Tuple[slice, slice, slice]] = None,
    response: str = "modulus",
    padding_value: float = 0.0,
) -> npt.NDArray[np.floating[Any]]:
    """
    Apply 2D Gabor filter to 3D image (IBSI code: Q88H).

    The Gabor filter is applied in the axial plane (k1, k2) and optionally
    averaged over orthogonal planes. Per IBSI 2 Eq. 9.

    Args:
        image: 3D input image array
        sigma_mm: Standard deviation of Gaussian envelope in mm (41LN)
        lambda_mm: Wavelength in mm (S4N6)
        gamma: Spatial aspect ratio (GDR5), typically 0.5 to 2.0
        theta: Orientation angle in radians (FQER), clockwise in (k1,k2)
        spacing_mm: Voxel spacing in mm (scalar or per-axis tuple). Each
            plane's kernel is built from that plane's own two in-plane
            axis spacings, so anisotropic spacing (including anisotropic
            z, relevant when `average_over_planes=True`) is handled
            correctly.
        boundary: Boundary condition for padding (GBYQ)
        rotation_invariant: If True, average over orientations
        delta_theta: Orientation step for rotation invariance (XTGK)
        pooling: Pooling method ("average", "max", "min")
        average_over_planes: If True, average 2D responses over 3 orthogonal planes
        use_parallel: If True, process slices in parallel. If None (default),
            auto-enables when the slices to filter hold more than ~46³ voxels.
        source_mask: Optional boolean mask where True = valid voxel.
            When provided, zeros out invalid (sentinel) voxels before
            FFT-based convolution to prevent contamination.
        region: Optional box of the image (one slice per axis). Only the response
            in the box is computed, and the result has the box shape. Each plane
            still filters whole slices, so the values are those of the whole image.
        response: The part of the complex response (5P3T): "modulus" (0J1M, default),
            "angle" (V530, in radians), "real" (F7DI) or "imaginary" (LI3Y). The
            orientations pool this part.
        padding_value: The constant of constant value padding (Z3VE), with the ZERO
            (constant) boundary. Default 0.

    Returns:
        Response map (the `response` part of the complex response)

    Raises:
        ValueError: If `pooling` is not "max", "average", or "min", if
            `rotation_invariant=True` is set without providing `delta_theta`, if
            `response` is not one of the four parts, if `padding_value` is not 0
            with another boundary than ZERO, if `sigma_mm`, `lambda_mm` or `gamma` is
            not a finite positive number, if an entry of `spacing_mm` is not, or if
            `delta_theta` (with `rotation_invariant=True`) is not in (0, 2 pi].

    Example:
        Apply Gabor filter with rotation invariance over orthogonal planes:

        ```python
        import numpy as np
        from pictologics.filters import gabor_filter

        # Create dummy 3D image
        image = np.random.rand(50, 50, 50)

        # Apply filter
        response = gabor_filter(
            image,
            sigma_mm=10.0,
            lambda_mm=4.0,
            gamma=0.5,
            rotation_invariant=True,
            delta_theta=0.7853981633974483,  # pi/4
            average_over_planes=True
        )
        ```

    Note:
        - Returns the part of h = g ⊗ f that `response` names (default the modulus |h|)
        - 2D filter applied slice-by-slice, then optionally over planes
        - Uses single complex FFT convolution for ~2x speedup
        - Each plane's kernel uses that plane's own two in-plane spacings.
          When they are equal (the isotropic-in-plane case, including the
          default axial-only plane under typical (x, y, z) spacing with
          x == y), the kernel is built on a voxel-unit grid. When they
          differ, the kernel is built on a physical-coordinate (mm) grid
          with a per-axis radius, giving a rectangular kernel that follows
          the true spacing of each axis.
    """
    source_mask = _bool_mask(source_mask)  # a mask of another type: != 0
    # Handle spacing. The mm -> voxel/physical conversion is deferred to
    # _apply_gabor_to_plane, which is per-plane: each plane's in-plane axes
    # (and therefore in-plane spacings) depend on plane_axis.
    if isinstance(spacing_mm, (int, float)):
        spacing_mm = (float(spacing_mm),) * 3
    for name, value in (("sigma_mm", sigma_mm), ("lambda_mm", lambda_mm), ("gamma", gamma)):
        if not (math.isfinite(value) and value > 0):
            raise ValueError(f"{name} must be a positive number, not {value!r}")
    if not all(math.isfinite(s) and s > 0 for s in spacing_mm):
        raise ValueError(f"spacing_mm must hold positive numbers, not {spacing_mm!r}")
    if (
        rotation_invariant
        and delta_theta is not None
        and not (math.isfinite(delta_theta) and 0 < delta_theta <= 2 * np.pi)
    ):
        raise ValueError(f"delta_theta must lie in (0, 2 pi] radians, not {delta_theta!r}")

    # Convert to float32
    image = ensure_float32(image)

    # Apply source_mask preprocessing (zero out invalid voxels for FFT-based filter)
    if source_mask is not None:
        image = _prepare_masked_image(image, source_mask)

    # Handle boundary
    boundary = resolve_boundary(boundary)
    mode = get_scipy_mode(boundary)

    # Validate pooling parameter early
    valid_poolings = ("max", "average", "min")
    if pooling not in valid_poolings:
        raise ValueError(f"Unknown pooling: {pooling}. Must be one of {valid_poolings}")
    if response not in _RESPONSE_PARTS:
        raise ValueError(f"Unknown response: {response}. Must be one of {tuple(_RESPONSE_PARTS)}")
    problem = _padding_value_problem(boundary, padding_value)
    if problem:
        raise ValueError(problem)

    if rotation_invariant:
        if delta_theta is None:
            raise ValueError(
                "rotation_invariant=True requires delta_theta (the orientation step in radians)"
            )
        # Generate orientations from 0 to 2π. A step given with few digits (0.785398 for
        # π/4) still means a whole number of orientations, with the exact step 2π / n;
        # ceil would add one orientation.
        steps = 2 * np.pi / delta_theta
        n_orientations = round(steps)
        if n_orientations >= 1 and abs(steps - n_orientations) < 1e-3:
            delta_theta = 2 * np.pi / n_orientations
        else:
            n_orientations = int(np.ceil(steps))
        thetas = [i * delta_theta for i in range(n_orientations)]
        # The Gabor response modulus is π-periodic in theta: kernel(θ+π) = conj(kernel(θ))
        # and the image is real, so |response| (and its real part) is identical for θ
        # and θ+π. When the orientation set is closed under +π (n even and spans exactly
        # 2π), the second half duplicates the first; drop it (max/min/average pooling
        # are unchanged). The imaginary part and the angle change sign at θ+π.
        closed = n_orientations % 2 == 0 and abs(n_orientations * delta_theta - 2 * np.pi) < 1e-9
        if closed and response in ("modulus", "real"):
            thetas = thetas[: n_orientations // 2]
    else:
        thetas = [theta]

    def _plane(plane_axis: int) -> npt.NDArray[np.floating[Any]]:
        """The response of one plane; with a region, of the region only (each slice
        through it is cut to the region, grown by the kernel radius)."""
        part = image
        window = None
        size = image.size
        if region is not None:
            part = image[tuple(region[a] if a == plane_axis else slice(None) for a in range(3))]
            window = (region[0], region[1], region[2])
            size = math.prod(r.stop - r.start for r in window)
        # Auto-detect parallel mode based on the size of the part filtered
        parallel = size > _PARALLEL_THRESHOLD if use_parallel is None else use_parallel
        return _apply_gabor_to_plane(
            part,
            sigma_mm,
            lambda_mm,
            gamma,
            thetas,
            plane_axis=plane_axis,
            spacing_mm=spacing_mm,
            mode=mode,
            pooling=pooling,
            use_parallel=parallel,
            response=response,
            padding_value=padding_value,
            window=None
            if window is None
            else tuple(window[a] for a in range(3) if a != plane_axis),
        )

    if average_over_planes:
        # Apply to all 3 orthogonal planes and average: the sum runs in float64, in plane
        # order. The copy, the sums, the division and the cast run on slabs in the slab
        # threads (each value as in one numpy call), as the plane responses of axes 1 and 2
        # are strided views.
        first = _plane(0)
        result = np.empty(first.shape, dtype=np.float64)
        _slab_pass(_copy_pass, first, first.ndim - 1, result)
        del first
        for plane_axis in (1, 2):
            _slab_ufunc(np.add, (result, _plane(plane_axis)), result)
        _slab_ufunc(np.true_divide, (result, 3.0), result)
        return _float32_cut(result, None)
    else:
        # Apply only to axial plane (axis 2 = k3 slices): the result in row order, as every
        # other filter returns it
        return _axial_row_order(_plane(2))

pictologics.filters.wavelet_transform(image, wavelet='db2', level=1, decomposition='LHL', boundary=BoundaryCondition.ZERO, rotation_invariant=False, pooling='average', use_parallel=None, source_mask=None, padding_value=0.0)

Apply 3D separable wavelet transform (undecimated/stationary).

Uses the à trous algorithm for undecimated wavelet decomposition. The transform is translation-invariant (unlike decimated transform). A NaN or inf voxel spreads over the non-zero taps of the kernel only, not over its whole span.

Supported wavelets
  • "haar" (UOUE): Haar wavelet
  • "db2", "db3": Daubechies wavelets
  • "coif1": Coiflet wavelet

Parameters:

Name Type Description Default
image NDArray[floating[Any]]

3D input image array

required
wavelet str

Wavelet name (e.g., "db2", "coif1", "haar")

'db2'
level int

Decomposition level (GCEK)

1
decomposition str

Which response map to return, e.g., "LHL", "HHH"

'LHL'
boundary Union[BoundaryCondition, str]

Boundary condition for padding

ZERO
rotation_invariant bool

If True, average over 24 rotations

False
pooling str

Pooling method for rotation invariance

'average'
use_parallel Union[bool, None]

If True, the 24 rotations of rotation_invariant mode and their passes run in numba's threads; False runs them in one thread. If None (default), True for images of more than 15,000 voxels.

None
source_mask Optional[NDArray[bool_]]

Optional boolean mask where True = valid voxel. When provided, zeros out invalid (sentinel) voxels before wavelet decomposition to prevent contamination.

None
padding_value float

The constant of constant value padding (Z3VE), with the ZERO (constant) boundary. Default 0.

0.0

Returns:

Type Description
NDArray[floating[Any]]

Response map for the specified decomposition

Raises:

Type Description
ValueError

If level is not a whole number of 1 or more, decomposition is not three letters of L or H, boundary is unknown, padding_value is not 0 with a boundary other than the constant one, or rotation_invariant=True and pooling is not "max", "average", or "min".

Example

Apply Daubechies 2 wavelet transform at level 1, returning LHL coefficients:

import numpy as np
from pictologics.filters import wavelet_transform

# Create dummy 3D image
image = np.random.rand(50, 50, 50)

# Apply transform
response = wavelet_transform(
    image,
    wavelet="db2",
    level=1,
    decomposition="LHL"
)
Source code in pictologics/filters/wavelets.py
def wavelet_transform(
    image: npt.NDArray[np.floating[Any]],
    wavelet: str = "db2",
    level: int = 1,
    decomposition: str = "LHL",
    boundary: Union[BoundaryCondition, str] = BoundaryCondition.ZERO,
    rotation_invariant: bool = False,
    pooling: str = "average",
    use_parallel: Union[bool, None] = None,
    source_mask: Optional[npt.NDArray[np.bool_]] = None,
    padding_value: float = 0.0,
) -> npt.NDArray[np.floating[Any]]:
    """
    Apply 3D separable wavelet transform (undecimated/stationary).

    Uses the à trous algorithm for undecimated wavelet decomposition.
    The transform is translation-invariant (unlike decimated transform). A NaN or inf
    voxel spreads over the non-zero taps of the kernel only, not over its whole span.

    Supported wavelets:
        - "haar" (UOUE): Haar wavelet
        - "db2", "db3": Daubechies wavelets
        - "coif1": Coiflet wavelet

    Args:
        image: 3D input image array
        wavelet: Wavelet name (e.g., "db2", "coif1", "haar")
        level: Decomposition level (GCEK)
        decomposition: Which response map to return, e.g., "LHL", "HHH"
        boundary: Boundary condition for padding
        rotation_invariant: If True, average over 24 rotations
        pooling: Pooling method for rotation invariance
        use_parallel: If True, the 24 rotations of rotation_invariant mode and their passes
            run in numba's threads; False runs them in one thread. If None (default),
            True for images of more than 15,000 voxels.
        source_mask: Optional boolean mask where True = valid voxel.
            When provided, zeros out invalid (sentinel) voxels before
            wavelet decomposition to prevent contamination.
        padding_value: The constant of constant value padding (Z3VE), with the ZERO
            (constant) boundary. Default 0.

    Returns:
        Response map for the specified decomposition

    Raises:
        ValueError: If `level` is not a whole number of 1 or more, `decomposition` is not
            three letters of L or H, `boundary` is unknown, `padding_value` is not 0 with a
            boundary other than the constant one, or `rotation_invariant=True` and
            `pooling` is not "max", "average", or "min".

    Example:
        Apply Daubechies 2 wavelet transform at level 1, returning LHL coefficients:

        ```python
        import numpy as np
        from pictologics.filters import wavelet_transform

        # Create dummy 3D image
        image = np.random.rand(50, 50, 50)

        # Apply transform
        response = wavelet_transform(
            image,
            wavelet="db2",
            level=1,
            decomposition="LHL"
        )
        ```
    """
    source_mask = _bool_mask(source_mask)  # a mask of another type: != 0
    problem = _wavelet_problem(level, decomposition)
    if problem:
        raise ValueError(problem)
    level = int(level)
    decomposition = decomposition.upper()
    boundary = resolve_boundary(boundary)
    problem = _padding_value_problem(boundary, padding_value)
    if problem:
        raise ValueError(problem)
    if padding_value:
        # The a trous passes of all levels read this far on each side
        n = pywt.Wavelet(wavelet).dec_len
        reach = sum((n - 1) * 2 ** (j - 1) + 1 for j in range(1, level + 1))
        padded = _constant_padded(
            wavelet_transform, image, source_mask, padding_value, reach, wavelet=wavelet,
            level=level, decomposition=decomposition, boundary=boundary,
            rotation_invariant=rotation_invariant, pooling=pooling, use_parallel=use_parallel,
        )  # fmt: skip
        return cast(npt.NDArray[np.floating[Any]], padded)

    # The passes run in float32 (the numba passes sum each line in double, the bits of
    # scipy's convolve1d; a NaN or inf voxel spreads over the non-zero taps of an a trous
    # kernel only)
    # With a source mask, the invalid voxels become 0 in the same copy
    if source_mask is not None:
        image = _prepare_masked_image(image, source_mask, dtype=np.float32)
    else:
        image = as_float32(image)

    mode = get_scipy_mode(boundary)

    # Get wavelet filters
    w = pywt.Wavelet(wavelet)
    lo = np.array(w.dec_lo, dtype=np.float32)  # Low-pass decomposition filter
    hi = np.array(w.dec_hi, dtype=np.float32)  # High-pass decomposition filter

    # Auto-detect parallel mode based on image size
    if use_parallel is None:
        use_parallel = image.size > _PARALLEL_THRESHOLD

    if rotation_invariant:
        if pooling not in ("max", "average", "min"):
            raise ValueError(f"Unknown pooling: {pooling}")
        mixed_tree = image.size >= _MIXED_TREE_MIN
        if decomposition in ("LLL", "HHH") or mixed_tree:
            threads = get_num_threads() if use_parallel else 1
            return _rotation_tree(image, lo, hi, level, decomposition, mode, pooling, threads)

        result: npt.NDArray[np.floating[Any]] | None = None

        def _pool(response: npt.NDArray[np.floating[Any]]) -> None:
            nonlocal result
            if result is None:
                result = response.astype(np.float64) if pooling == "average" else response
            elif pooling == "max":
                np.maximum(result, response, out=result)
            elif pooling == "average":
                result += response
            else:  # "min"
                np.minimum(result, response, out=result)

        def rotated_response(
            rotation: Tuple[Tuple[int, int, int], Tuple[bool, bool, bool]],
        ) -> npt.NDArray[np.floating[Any]]:
            """The response of one rotation, computed on the image itself (no rotated copy)."""
            return _apply_undecimated_wavelet_3d(
                image, lo, hi, level, decomposition, mode, threads, rotation=rotation
            )

        # Rotations run in a pool: one rotation after another, each pass in all threads,
        # is up to 4.5x slower up to 128^3 (a pass splits into threads from 2^18 voxels).
        # Pool the responses in rotation order, so the result does not depend on thread
        # timing. Each response is dropped once pooled. `workers` rotations are in flight,
        # each pass in `threads` threads: one rotation per thread for a small image, two
        # threads per pass from _PAIRED_PASSES_MIN voxels on (half the responses in
        # memory). At most about 2 GB of responses are in flight.
        rotations = _get_rotation_perms()
        workers, threads = 1, 1
        if use_parallel:
            n = get_num_threads()
            workers = n if image.size < _PAIRED_PASSES_MIN else max(1, n // 2)
            workers = min(len(rotations), workers, max(2, (2 << 30) // image.nbytes))
            threads = max(1, n // workers)
        for response in _ordered_map(rotated_response, rotations, workers):
            _pool(response)
            del response  # freed before the next rotation starts

        # Finalize average pooling
        if pooling == "average" and result is not None:
            result /= 24
        return result.astype(np.float32)  # type: ignore[union-attr]
    else:
        return _apply_undecimated_wavelet_3d(
            image, lo, hi, level, decomposition, mode, last_dtype=np.float32
        )

pictologics.filters.simoncelli_wavelet(image, level=1, boundary=BoundaryCondition.PERIODIC, source_mask=None, padding_value=0.0)

Apply Simoncelli non-separable wavelet (IBSI code: PRT7).

The Simoncelli wavelet is isotropic (spherically symmetric) and implemented in the Fourier domain. Per IBSI 2 Eq. 27.

For decomposition level N, the frequency band is scaled by j = N-1: - Level 1 (j=0): band [π/4, π] (highest frequencies) - Level 2 (j=1): band [π/8, π/2] - Level 3 (j=2): band [π/16, π/4]

Parameters:

Name Type Description Default
image NDArray[floating[Any]]

3D input image array

required
level int

Decomposition level (1 = highest frequency band)

1
boundary Union[BoundaryCondition, str]

Boundary condition. The filter is inherently periodic (FFT-based), so BoundaryCondition.PERIODIC (the default) runs it directly on image. Any other condition is approximated via pad-filter-crop (see _apply_with_boundary_padding and _simoncelli_pad_width).

PERIODIC
source_mask Optional[NDArray[bool_]]

Optional boolean mask where True = valid voxel

None
padding_value float

The constant of constant value padding (Z3VE), with the ZERO (constant) boundary. Default 0.

0.0

Returns:

Type Description
NDArray[floating[Any]]

Band-pass response map (B map) for the specified level

Raises:

Type Description
ValueError

If level is not a whole number of 1 or more, boundary is not a valid BoundaryCondition member name, or padding_value is not 0 with a boundary other than the constant one.

Example

Apply first-level Simoncelli wavelet (highest frequency band):

import numpy as np
from pictologics.filters import simoncelli_wavelet

# Create dummy 3D image
image = np.random.rand(50, 50, 50)

# Apply wavelet
response = simoncelli_wavelet(image, level=1)
Source code in pictologics/filters/wavelets.py
def simoncelli_wavelet(
    image: npt.NDArray[np.floating[Any]],
    level: int = 1,
    boundary: Union[BoundaryCondition, str] = BoundaryCondition.PERIODIC,
    source_mask: Optional[npt.NDArray[np.bool_]] = None,
    padding_value: float = 0.0,
) -> npt.NDArray[np.floating[Any]]:
    """
    Apply Simoncelli non-separable wavelet (IBSI code: PRT7).

    The Simoncelli wavelet is isotropic (spherically symmetric) and
    implemented in the Fourier domain. Per IBSI 2 Eq. 27.

    For decomposition level N, the frequency band is scaled by j = N-1:
        - Level 1 (j=0): band [π/4, π] (highest frequencies)
        - Level 2 (j=1): band [π/8, π/2]
        - Level 3 (j=2): band [π/16, π/4]

    Args:
        image: 3D input image array
        level: Decomposition level (1 = highest frequency band)
        boundary: Boundary condition. The filter is inherently periodic (FFT-based),
            so `BoundaryCondition.PERIODIC` (the default) runs it directly on
            `image`. Any other condition is approximated via pad-filter-crop (see
            `_apply_with_boundary_padding` and `_simoncelli_pad_width`).
        source_mask: Optional boolean mask where True = valid voxel
        padding_value: The constant of constant value padding (Z3VE), with the ZERO
            (constant) boundary. Default 0.

    Returns:
        Band-pass response map (B map) for the specified level

    Raises:
        ValueError: If `level` is not a whole number of 1 or more, `boundary` is not a
            valid `BoundaryCondition` member name, or `padding_value` is not 0 with a
            boundary other than the constant one.

    Example:
        Apply first-level Simoncelli wavelet (highest frequency band):

        ```python
        import numpy as np
        from pictologics.filters import simoncelli_wavelet

        # Create dummy 3D image
        image = np.random.rand(50, 50, 50)

        # Apply wavelet
        response = simoncelli_wavelet(image, level=1)
        ```
    """
    source_mask = _bool_mask(source_mask)  # a mask of another type: != 0
    problem = _wavelet_problem(level)
    if problem:
        raise ValueError(problem)
    level = int(level)
    boundary = resolve_boundary(boundary)
    problem = _padding_value_problem(boundary, padding_value)
    if problem:
        raise ValueError(problem)

    # Convert to float32
    image = ensure_float32(image)

    # Apply source_mask preprocessing (zero out invalid voxels for FFT-based filter)
    if source_mask is not None:
        image = _prepare_masked_image(image, source_mask)

    def _core(
        arr: npt.NDArray[np.floating[Any]], crop: Optional[Tuple[slice, ...]]
    ) -> npt.NDArray[np.floating[Any]]:
        shape = tuple(arr.shape)

        # Transfer function depends only on (shape, level) — never on image values or
        # the source mask — so it is built once and cached (see _simoncelli_transfer).
        g_sim = _simoncelli_transfer(shape, level)

        # Apply filter in frequency domain using Real FFT (the table is the even part of
        # the band, see _simoncelli_transfer). scipy.fft with numba's thread count is
        # multithreaded and matches np.fft to float32 precision.
        workers = get_num_threads()
        spectrum = _times_mirrored(scipy.fft.rfftn(arr, workers=workers), g_sim, shape[0], 1)
        response, scale, crop = _irfftn_unscaled(spectrum, shape, workers, crop)
        del spectrum  # freed before the cut: the peak holds the response and the cut
        return _float32_cut(response, crop, scale)

    return _apply_with_boundary_padding(
        _core, image, boundary, _simoncelli_pad_width(level), padding_value
    )

pictologics.filters.riesz_transform(image, order, boundary=BoundaryCondition.PERIODIC, source_mask=None, padding_value=0.0)

Apply Riesz transform (IBSI code: AYRS).

The Riesz transform computes higher-order all-pass image derivatives in the Fourier domain. Per IBSI 2 Eq. 34.

Parameters:

Name Type Description Default
image NDArray[floating[Any]]

3D input image array

required
order Tuple[int, ...]

Tuple (l1, l2, l3) specifying derivative order per axis e.g., (1,0,0) = first-order along k1 (gradient-like) (2,0,0), (1,1,0), (0,2,0) = second-order (Hessian-like)

required
boundary Union[BoundaryCondition, str]

Boundary condition. The filter is inherently periodic (FFT-based), so BoundaryCondition.PERIODIC (the default) runs it directly on image. Any other condition is approximated via pad-filter-crop (see _apply_with_boundary_padding and _RIESZ_BASE_PAD).

PERIODIC
source_mask Optional[NDArray[bool_]]

Optional boolean mask where True = valid voxel. When provided, zeros out invalid (sentinel) voxels before FFT-based transform to prevent contamination.

None
padding_value float

The constant of constant value padding (Z3VE), with the ZERO (constant) boundary. Default 0.

0.0

Returns:

Type Description
NDArray[floating[Any]]

Riesz-transformed image (real part)

Raises:

Type Description
ValueError

If order is not one whole number of 0 or more for each axis with a sum above 0 (a sum of 0 is the identity), if boundary is a string that is not a valid BoundaryCondition member name, or if padding_value is not 0 with a boundary other than the constant one.

Example

Compute first-order Riesz transform along the k1 axis:

import numpy as np
from pictologics.filters import riesz_transform

# Create dummy 3D image
image = np.random.rand(50, 50, 50)

# Apply transform (gradient-like along axis 0)
response = riesz_transform(image, order=(1, 0, 0))
Note
  • First-order Riesz components form the image gradient
  • Second-order Riesz components form the image Hessian
  • All-pass: doesn't amplify high frequencies like regular derivatives
Source code in pictologics/filters/riesz.py
def riesz_transform(
    image: npt.NDArray[np.floating[Any]],
    order: Tuple[int, ...],
    boundary: Union[BoundaryCondition, str] = BoundaryCondition.PERIODIC,
    source_mask: Optional[npt.NDArray[np.bool_]] = None,
    padding_value: float = 0.0,
) -> npt.NDArray[np.floating[Any]]:
    """
    Apply Riesz transform (IBSI code: AYRS).

    The Riesz transform computes higher-order all-pass image derivatives
    in the Fourier domain. Per IBSI 2 Eq. 34.

    Args:
        image: 3D input image array
        order: Tuple (l1, l2, l3) specifying derivative order per axis
               e.g., (1,0,0) = first-order along k1 (gradient-like)
                     (2,0,0), (1,1,0), (0,2,0) = second-order (Hessian-like)
        boundary: Boundary condition. The filter is inherently periodic (FFT-based),
            so `BoundaryCondition.PERIODIC` (the default) runs it directly on
            `image`. Any other condition is approximated via pad-filter-crop (see
            `_apply_with_boundary_padding` and `_RIESZ_BASE_PAD`).
        source_mask: Optional boolean mask where True = valid voxel.
            When provided, zeros out invalid (sentinel) voxels before
            FFT-based transform to prevent contamination.
        padding_value: The constant of constant value padding (Z3VE), with the ZERO
            (constant) boundary. Default 0.

    Returns:
        Riesz-transformed image (real part)

    Raises:
        ValueError: If `order` is not one whole number of 0 or more for each axis with a
            sum above 0 (a sum of 0 is the identity), if `boundary` is a string that is not
            a valid `BoundaryCondition` member name, or if `padding_value` is not 0 with a
            boundary other than the constant one.

    Example:
        Compute first-order Riesz transform along the k1 axis:

        ```python
        import numpy as np
        from pictologics.filters import riesz_transform

        # Create dummy 3D image
        image = np.random.rand(50, 50, 50)

        # Apply transform (gradient-like along axis 0)
        response = riesz_transform(image, order=(1, 0, 0))
        ```

    Note:
        - First-order Riesz components form the image gradient
        - Second-order Riesz components form the image Hessian
        - All-pass: doesn't amplify high frequencies like regular derivatives
    """
    source_mask = _bool_mask(source_mask)  # a mask of another type: != 0
    boundary = resolve_boundary(boundary)
    problem = _padding_value_problem(boundary, padding_value)
    if problem:
        raise ValueError(problem)

    # Convert to float32
    image = ensure_float32(image)

    # Apply source_mask preprocessing (zero out invalid voxels for FFT-based filter)
    if source_mask is not None:
        image = _prepare_masked_image(image, source_mask)

    order = _riesz_order(order, image.ndim)
    return _apply_with_boundary_padding(
        _riesz_response, image, boundary, _RIESZ_BASE_PAD, padding_value, order=order
    )

pictologics.filters.riesz_log(image, sigma_mm, spacing_mm=1.0, order=(1, 0, 0), truncate=4.0, boundary=BoundaryCondition.PERIODIC, source_mask=None, padding_value=0.0)

Apply Riesz transform to LoG-filtered image.

Combines multi-scale analysis (LoG) with directional analysis (Riesz). First applies LoG filtering, then applies Riesz transform.

Parameters:

Name Type Description Default
image NDArray[floating[Any]]

3D input image array

required
sigma_mm float

LoG scale in mm

required
spacing_mm Union[float, Tuple[float, float, float]]

Voxel spacing in mm

1.0
order Tuple[int, ...]

Riesz order tuple (l1, l2, l3)

(1, 0, 0)
truncate float

LoG truncation parameter

4.0
boundary Union[BoundaryCondition, str]

Boundary condition for the whole LoG-then-Riesz chain. PERIODIC (the default) runs the LoG with its own ZERO boundary, then the Riesz transform on the image as it is. Another boundary pads the image once with that boundary (see _riesz_log_pad_width, widened to a fast FFT size of the same parity). It runs the LoG and the Riesz transform in one FFT round trip on the padded image, and cuts the result back. A 2-D image keeps the two stages.

PERIODIC
source_mask Optional[NDArray[bool_]]

Optional boolean mask where True = valid voxel. With PERIODIC, the LoG uses normalized convolution, and the mask zeroes the invalid voxels again before the Riesz stage. With another boundary, the mask zeroes the invalid voxels of the final response.

None
padding_value float

The constant of constant value padding (Z3VE), with the ZERO (constant) boundary. Default 0.

0.0

Returns:

Type Description
NDArray[floating[Any]]

Riesz-transformed LoG response

Raises:

Type Description
ValueError

If order is not valid (see riesz_transform), if boundary is a string that is not a valid BoundaryCondition member name, or if padding_value is not 0 with a boundary other than the constant one. Also if sigma_mm, an entry of spacing_mm or truncate is not a finite positive number.

Example

Compute first-order Riesz transform of LoG-filtered image at 5mm scale:

import numpy as np
from pictologics.filters import riesz_log

# Create dummy 3D image
image = np.random.rand(50, 50, 50)

# Apply filter
response = riesz_log(
    image,
    sigma_mm=5.0,
    spacing_mm=(2.0, 2.0, 2.0),
    order=(1, 0, 0)
)
Source code in pictologics/filters/riesz.py
def riesz_log(
    image: npt.NDArray[np.floating[Any]],
    sigma_mm: float,
    spacing_mm: Union[float, Tuple[float, float, float]] = 1.0,
    order: Tuple[int, ...] = (1, 0, 0),
    truncate: float = 4.0,
    boundary: Union[BoundaryCondition, str] = BoundaryCondition.PERIODIC,
    source_mask: Optional[npt.NDArray[np.bool_]] = None,
    padding_value: float = 0.0,
) -> npt.NDArray[np.floating[Any]]:
    """
    Apply Riesz transform to LoG-filtered image.

    Combines multi-scale analysis (LoG) with directional analysis (Riesz).
    First applies LoG filtering, then applies Riesz transform.

    Args:
        image: 3D input image array
        sigma_mm: LoG scale in mm
        spacing_mm: Voxel spacing in mm
        order: Riesz order tuple (l1, l2, l3)
        truncate: LoG truncation parameter
        boundary: Boundary condition for the whole LoG-then-Riesz chain. PERIODIC (the
            default) runs the LoG with its own ZERO boundary, then the Riesz transform on
            the image as it is. Another boundary pads the image once with that boundary
            (see `_riesz_log_pad_width`, widened to a fast FFT size of the same parity).
            It runs the LoG and the Riesz transform in one FFT round trip on the padded
            image, and cuts the result back. A 2-D image keeps the two stages.
        source_mask: Optional boolean mask where True = valid voxel. With PERIODIC, the
            LoG uses normalized convolution, and the mask zeroes the invalid voxels again
            before the Riesz stage. With another boundary, the mask zeroes the invalid
            voxels of the final response.
        padding_value: The constant of constant value padding (Z3VE), with the ZERO
            (constant) boundary. Default 0.

    Returns:
        Riesz-transformed LoG response

    Raises:
        ValueError: If `order` is not valid (see `riesz_transform`), if `boundary` is a
            string that is not a valid `BoundaryCondition` member name, or if
            `padding_value` is not 0 with a boundary other than the constant one. Also
            if `sigma_mm`, an entry of `spacing_mm` or `truncate` is not a finite
            positive number.

    Example:
        Compute first-order Riesz transform of LoG-filtered image at 5mm scale:

        ```python
        import numpy as np
        from pictologics.filters import riesz_log

        # Create dummy 3D image
        image = np.random.rand(50, 50, 50)

        # Apply filter
        response = riesz_log(
            image,
            sigma_mm=5.0,
            spacing_mm=(2.0, 2.0, 2.0),
            order=(1, 0, 0)
        )
        ```
    """
    source_mask = _bool_mask(source_mask)  # a mask of another type: != 0
    from .log import laplacian_of_gaussian

    boundary = resolve_boundary(boundary)
    problem = _padding_value_problem(boundary, padding_value) or _log_parameter_problem(
        sigma_mm, _spacing_tuple(spacing_mm), truncate
    )
    if problem:
        raise ValueError(problem)
    order = _riesz_order(order, image.ndim)
    pad_width = _riesz_log_pad_width(sigma_mm, spacing_mm, truncate)
    if boundary is not BoundaryCondition.PERIODIC and image.ndim == 3:
        result = _riesz_log_fft(
            image, sigma_mm, spacing_mm, truncate, order, boundary, pad_width, padding_value
        )
        return result if source_mask is None else _prepare_masked_image(result, source_mask)

    def _core(
        arr: npt.NDArray[np.floating[Any]], crop: Optional[Tuple[slice, ...]]
    ) -> npt.NDArray[np.floating[Any]]:
        # `_core` runs on `image` unchanged when boundary is PERIODIC (the default,
        # no padding), and on a *padded* array otherwise. `source_mask` always has
        # `image`'s original, unpadded shape, so it can only be forwarded to the
        # internal calls below in the PERIODIC case; the non-PERIODIC case masks
        # once, after cropping, below.
        if boundary is BoundaryCondition.PERIODIC:
            log_response = laplacian_of_gaussian(
                arr,
                sigma_mm=sigma_mm,
                spacing_mm=spacing_mm,
                truncate=truncate,
                source_mask=source_mask,
            )
            mask = source_mask
        else:
            # Also forward `boundary` here so LoG's own edge handling (ZERO by
            # default) matches the requested condition instead of silently
            # staying at ZERO.
            log_response = laplacian_of_gaussian(
                arr,
                sigma_mm=sigma_mm,
                spacing_mm=spacing_mm,
                truncate=truncate,
                boundary=boundary,
                source_mask=None,
            )
            mask = None

        # Handle tuple return from LoG if source_mask was used
        if isinstance(log_response, tuple):
            log_response = log_response[0]

        # Then apply Riesz transform. The mask zeroes the invalid regions again
        # (PERIODIC case only; though LoG normalized convolution might have filled
        # them, Riesz is global). The Riesz stage is periodic: the outer
        # pad-filter-crop below already accounts for the boundary once for the chain.
        if mask is not None:
            log_response = _prepare_masked_image(log_response, mask)
        return _riesz_response(log_response, crop, order)

    result = _apply_with_boundary_padding(_core, image, boundary, pad_width, padding_value)

    if source_mask is not None and boundary is not BoundaryCondition.PERIODIC:
        result = _prepare_masked_image(result, source_mask)  # a 2-D image

    return result

pictologics.filters.riesz_simoncelli(image, level=1, order=(1, 0, 0), boundary=BoundaryCondition.PERIODIC, source_mask=None, padding_value=0.0)

Apply Riesz transform to Simoncelli wavelet-filtered image.

Combines isotropic multi-scale analysis (Simoncelli) with directional analysis (Riesz) for rotation-invariant directional features.

Parameters:

Name Type Description Default
image NDArray[floating[Any]]

3D input image array

required
level int

Simoncelli decomposition level

1
order Tuple[int, ...]

Riesz order tuple (l1, l2, l3)

(1, 0, 0)
boundary Union[BoundaryCondition, str]

Boundary condition for the whole Simoncelli-then-Riesz chain. With the default BoundaryCondition.PERIODIC, both stages run periodically, with no outer padding at all. Any other condition pads image once (one boundary treatment for both FFT stages), runs the chain on the padded array (each stage keeping its own PERIODIC default), and crops back (see _apply_with_boundary_padding).

PERIODIC
source_mask Optional[NDArray[bool_]]

Optional boolean mask where True = valid voxel. Because source_mask shares image's (unpadded) shape, the mid-chain re-zeroing this function otherwise performs before the Riesz stage is only applied when no padding occurs (the default PERIODIC case); for any other boundary, the mask is instead applied once to the final, already-cropped response.

None
padding_value float

The constant of constant value padding (Z3VE), with the ZERO (constant) boundary. Default 0.

0.0

Returns:

Type Description
NDArray[floating[Any]]

Riesz-transformed Simoncelli response

Raises:

Type Description
ValueError

If level or order is not valid (see simoncelli_wavelet and riesz_transform), if boundary is a string that is not a valid BoundaryCondition member name, or if padding_value is not 0 with a boundary other than the constant one.

Example

Compute second-order Riesz transform (Hessian-like) of Simoncelli level 2:

import numpy as np
from pictologics.filters import riesz_simoncelli

# Create dummy 3D image
image = np.random.rand(50, 50, 50)

# Apply filter
response = riesz_simoncelli(
    image,
    level=2,
    order=(2, 0, 0)
)
Source code in pictologics/filters/riesz.py
def riesz_simoncelli(
    image: npt.NDArray[np.floating[Any]],
    level: int = 1,
    order: Tuple[int, ...] = (1, 0, 0),
    boundary: Union[BoundaryCondition, str] = BoundaryCondition.PERIODIC,
    source_mask: Optional[npt.NDArray[np.bool_]] = None,
    padding_value: float = 0.0,
) -> npt.NDArray[np.floating[Any]]:
    """
    Apply Riesz transform to Simoncelli wavelet-filtered image.

    Combines isotropic multi-scale analysis (Simoncelli) with
    directional analysis (Riesz) for rotation-invariant directional features.

    Args:
        image: 3D input image array
        level: Simoncelli decomposition level
        order: Riesz order tuple (l1, l2, l3)
        boundary: Boundary condition for the whole Simoncelli-then-Riesz chain.
            With the default `BoundaryCondition.PERIODIC`, both stages run
            periodically, with no outer padding at all. Any other condition pads
            `image` once (one boundary treatment for both FFT stages), runs the
            chain on the padded array (each stage keeping its own PERIODIC
            default), and crops back (see `_apply_with_boundary_padding`).
        source_mask: Optional boolean mask where True = valid voxel. Because
            `source_mask` shares `image`'s (unpadded) shape, the mid-chain
            re-zeroing this function otherwise performs before the Riesz stage is
            only applied when no padding occurs (the default `PERIODIC` case);
            for any other boundary, the mask is instead applied once to the final,
            already-cropped response.
        padding_value: The constant of constant value padding (Z3VE), with the ZERO
            (constant) boundary. Default 0.

    Returns:
        Riesz-transformed Simoncelli response

    Raises:
        ValueError: If `level` or `order` is not valid (see `simoncelli_wavelet` and
            `riesz_transform`), if `boundary` is a string that is not a valid
            `BoundaryCondition` member name, or if `padding_value` is not 0 with a
            boundary other than the constant one.

    Example:
        Compute second-order Riesz transform (Hessian-like) of Simoncelli level 2:

        ```python
        import numpy as np
        from pictologics.filters import riesz_simoncelli

        # Create dummy 3D image
        image = np.random.rand(50, 50, 50)

        # Apply filter
        response = riesz_simoncelli(
            image,
            level=2,
            order=(2, 0, 0)
        )
        ```
    """
    source_mask = _bool_mask(source_mask)  # a mask of another type: != 0
    from .wavelets import _simoncelli_pad_width, _simoncelli_transfer, simoncelli_wavelet

    boundary = resolve_boundary(boundary)
    problem = _padding_value_problem(boundary, padding_value)
    if problem:
        raise ValueError(problem)
    order = _riesz_order(order, image.ndim)

    # Preprocess once: float32 conversion + source mask zeroing
    image = ensure_float32(image)
    if source_mask is not None:
        image = _prepare_masked_image(image, source_mask)

    def _core(
        arr: npt.NDArray[np.floating[Any]], crop: Optional[Tuple[slice, ...]]
    ) -> npt.NDArray[np.floating[Any]]:
        # Re-apply source_mask (PERIODIC case only, see docstring): Simoncelli's
        # global FFT spreads energy back into the invalid regions, and the Riesz
        # transform is likewise global, so re-zero before it (mirrors riesz_log).
        if source_mask is not None and boundary is BoundaryCondition.PERIODIC:
            sim_response = simoncelli_wavelet(arr, level=level)
            sim_response = _prepare_masked_image(sim_response, source_mask)
            return _riesz_response(sim_response, crop, order)
        # Else both filters in one FFT round trip (see _simoncelli_transfer)
        return _riesz_response(arr, crop, order, _simoncelli_transfer(tuple(arr.shape), level))

    pad_width = _simoncelli_pad_width(level) + _RIESZ_BASE_PAD
    result = _apply_with_boundary_padding(_core, image, boundary, pad_width, padding_value)

    if source_mask is not None and boundary is not BoundaryCondition.PERIODIC:
        result = _prepare_masked_image(result, source_mask)

    return result