Source code for zrad.filtering.spatial

from functools import lru_cache
from itertools import permutations
from math import factorial

import cv2
import numpy as np
from scipy import fft as sp_fft
from scipy import ndimage as ndi

from .base import BaseFilter


[docs] class Mean(BaseFilter): """Mean filter for local intensity smoothing. The filter replaces each voxel by the average intensity in a square 2D or cubic 3D neighbourhood. Use it as a simple low-pass filter before feature extraction when local noise reduction is desired. Parameters ---------- padding_type : {"constant", "nearest", "wrap", "reflect"} Boundary handling mode used during convolution. support : int Kernel side length in voxels. dimensionality : {"2D", "3D"} Apply the filter slice-wise in 2D or volumetrically in 3D. """ def __init__(self, padding_type, support, dimensionality): super().__init__( filtering_method='Mean', padding_type=padding_type, support=support, dimensionality=dimensionality ) if dimensionality in ['2D', '3D']: self.dimensionality = dimensionality else: raise ValueError(f"Wrong dimensionality '{dimensionality}'. Available dimensions '2D' and '3D'.") if isinstance(support, int): self.support = support else: raise ValueError(f"Support should be int but '{type(support)}' detected.") if padding_type in ['constant', 'nearest', 'wrap', 'reflect']: self.padding_type = padding_type else: raise ValueError( f"Wrong padding type '{padding_type}'. " "Available padding types are: 'constant', 'nearest', 'wrap', and 'reflect'." ) def _apply_array(self, img): if self.dimensionality == "2D": filt_mat = np.ones([self.support, self.support]) filt_mat = filt_mat / np.prod(filt_mat.shape) filtered_img = np.ones(img.shape) for i in range(img.shape[2]): filtered_img[:, :, i] = ndi.convolve(input=img[:, :, i], weights=filt_mat, mode=self.padding_type) elif self.dimensionality == "3D": filt_mat = np.ones([self.support, self.support, self.support]) filt_mat = filt_mat / np.prod(filt_mat.shape) filtered_img = ndi.convolve(input=img, weights=filt_mat, mode=self.padding_type) else: filtered_img = None return filtered_img
[docs] class LoG(BaseFilter): """Laplacian-of-Gaussian filter for blob and edge enhancement. The image is Gaussian-smoothed at a physical scale and then transformed with the Laplacian operator. This highlights intensity transitions and blob-like structures at the configured scale. Parameters ---------- padding_type : {"constant", "nearest", "wrap", "reflect"} Boundary handling mode used during convolution. sigma_mm : float Gaussian standard deviation in millimetres. cutoff : float Kernel truncation radius in standard deviations. dimensionality : {"2D", "3D"} Apply the filter slice-wise in 2D or volumetrically in 3D. """ def __init__(self, padding_type, sigma_mm, cutoff, dimensionality): super().__init__( filtering_method='Laplacian of Gaussian', padding_type=padding_type, sigma_mm=sigma_mm, cutoff=cutoff, dimensionality=dimensionality, ) if dimensionality in ['2D', '3D']: self.dimensionality = dimensionality else: raise ValueError(f"Wrong dimensionality '{dimensionality}'. Available dimensions '2D' and '3D'.") if padding_type in ['constant', 'nearest', 'wrap', 'reflect']: self.padding_type = padding_type else: raise ValueError( f"Wrong padding type '{padding_type}'. " f"Available padding types are: 'constant', 'nearest', 'wrap', and 'reflect'." ) if isinstance(sigma_mm, (int, float)): self.sigma_mm = sigma_mm else: raise ValueError(f'Sigma (in mm) should be int or float but {type(sigma_mm)} detected.') if isinstance(cutoff, (int, float)): self.cutoff = cutoff else: raise ValueError(f'Cutoff should be int or float but {type(cutoff)} detected.') self.padding_constant = 0.0 self.res_mm = None def _prepare(self, image): try: self.res_mm = float(image.spacing[0]) except (AttributeError, ValueError, TypeError) as e: raise ValueError(f"Invalid image spacing data: {e}") def _apply_array(self, img): sigma = self.sigma_mm / self.res_mm if self.dimensionality == "3D": filtered_img = ndi.gaussian_laplace( img, sigma=sigma, mode=self.padding_type, cval=self.padding_constant, truncate=self.cutoff ) elif self.dimensionality == "2D": filtered_img = np.nan * np.ones(img.shape) for i in range(img.shape[2]): filtered_img[:, :, i] = ndi.gaussian_laplace( img[:, :, i], sigma=sigma, mode=self.padding_type, cval=self.padding_constant, truncate=self.cutoff ) else: filtered_img = None return filtered_img
[docs] class RieszLoG(LoG): """Laplacian-of-Gaussian followed by a normalized Riesz transform. This is a composition of the spatial LoG filter and the Fourier-domain Riesz operator. A second-order response can optionally be steered along the local structure-tensor direction. Parameters ---------- padding_type : {"constant", "nearest", "wrap", "reflect"} Boundary handling mode used by the LoG and Riesz operations. sigma_mm : float Gaussian standard deviation of the LoG filter in millimetres. cutoff : float LoG kernel truncation radius in standard deviations. dimensionality : {"2D", "3D"} Apply the composed filter slice-wise in 2D or volumetrically in 3D. riesz_order : tuple of int Non-negative Riesz multi-index in physical ``(x, y)`` or ``(x, y, z)`` axis order. Its length must match ``dimensionality`` and its total order must be positive. structure_tensor_sigma_mm : float, optional Gaussian scale in millimetres used to estimate the local structure tensor and steer the response. This is supported only for pure second-order 3D indices such as ``(2, 0, 0)``. If omitted, the Riesz response is evaluated along the fixed image axes. """ def __init__(self, padding_type, sigma_mm, cutoff, dimensionality, riesz_order, structure_tensor_sigma_mm=None): super().__init__(padding_type, sigma_mm, cutoff, dimensionality) dimensions = int(dimensionality[0]) if len(riesz_order) != dimensions or any( not isinstance(order, (int, np.integer)) or isinstance(order, (bool, np.bool_)) or order < 0 for order in riesz_order ): raise ValueError(f'riesz_order must contain {dimensions} non-negative integers.') if sum(riesz_order) == 0: raise ValueError('riesz_order must have a positive total order.') if structure_tensor_sigma_mm is not None and ( not isinstance(structure_tensor_sigma_mm, (int, float)) or structure_tensor_sigma_mm <= 0 ): raise ValueError('structure_tensor_sigma_mm must be a positive number.') if structure_tensor_sigma_mm is not None and (dimensions != 3 or sum(riesz_order) != 2): raise ValueError('Structure-tensor alignment is supported for second-order 3D Riesz transforms only.') if structure_tensor_sigma_mm is not None and 2 not in riesz_order: raise ValueError( 'Structure-tensor alignment supports pure second-order Riesz indices only; ' 'mixed-order indices have sign-ambiguous eigenvector steering.' ) self.filtering_method = 'Riesz-transformed LoG' self.riesz_order = tuple(int(order) for order in riesz_order) self.structure_tensor_sigma_mm = structure_tensor_sigma_mm self.filtering_params.update(riesz_order=self.riesz_order, structure_tensor_sigma_mm=structure_tensor_sigma_mm) @staticmethod def _riesz_transform(image, order): total_order = sum(order) spectrum = sp_fft.rfftn(image) frequency_axes = [2.0 * np.pi * np.fft.fftfreq(size) for size in image.shape[:-1]] last_axis = 2.0 * np.pi * np.fft.rfftfreq(image.shape[-1]) if image.shape[-1] % 2 == 0: last_axis[-1] *= -1.0 frequency_axes.append(last_axis) coordinates = np.meshgrid(*frequency_axes, indexing='ij', sparse=True) radius = np.zeros(spectrum.shape, dtype=np.float64) for coordinate in coordinates: radius += coordinate**2 np.sqrt(radius, out=radius) np.power(radius, total_order, out=radius) radius[(0,) * image.ndim] = np.inf coefficient = np.sqrt(factorial(total_order) / np.prod([factorial(value) for value in order])) spectrum *= (-1j) ** total_order * coefficient for coordinate, value in zip(coordinates, order): if value: spectrum *= coordinate**value spectrum /= radius # At self-conjugate Nyquist coordinates, an odd number of odd-axis # powers contributes only to the imaginary part of a full inverse FFT. cancel = np.zeros(spectrum.shape, dtype=bool) for axis, (size, value) in enumerate(zip(image.shape, order)): if size % 2 == 0 and value % 2: axis_shape = [1] * image.ndim axis_shape[axis] = spectrum.shape[axis] cancel ^= np.arange(spectrum.shape[axis]).reshape(axis_shape) == size // 2 spectrum[cancel] = 0.0 return sp_fft.irfftn(spectrum, s=image.shape) def _boundary_aware_riesz_transform(self, image, order): """Apply the Riesz transform without imposing unintended periodicity.""" if self.padding_type == 'wrap': return self._riesz_transform(image, order) if self.padding_type in ('constant', 'nearest'): # These boundary modes do not have a cosine-transform # representation. Extend the domain according to the selected # mode before applying the periodic Riesz transform. padding = tuple((size // 2, size - size // 2) for size in image.shape) mode = 'edge' if self.padding_type == 'nearest' else 'constant' padded = np.pad(image, padding, mode=mode) transformed = self._riesz_transform(padded, order) crop = tuple(slice(before, before + size) for (before, _), size in zip(padding, image.shape)) return transformed[crop] # A DCT represents the same even, non-periodic extension without # materializing a volume twice as large along every axis. Odd powers # map cosine modes into their sine/quadrature counterparts, while even # powers remain in the cosine basis. frequencies = np.meshgrid(*(np.pi * np.arange(size) / size for size in image.shape), indexing='ij', sparse=True) radius = np.sqrt(sum(frequency**2 for frequency in frequencies)) total_order = sum(order) coefficient = np.sqrt(factorial(total_order) / np.prod([factorial(value) for value in order])) multiplier = np.ones(image.shape, dtype=np.float64) for frequency, value in zip(frequencies, order): multiplier *= frequency**value nonzero = radius > 0 multiplier[nonzero] *= coefficient / radius[nonzero] ** total_order multiplier[~nonzero] = 0.0 multiplier *= (-1) ** sum(value // 2 for value in order) coefficients = sp_fft.dctn(image, type=2, norm='ortho') * multiplier for axis, value in enumerate(order): if value % 2: shifted = np.zeros_like(coefficients) source = [slice(None)] * image.ndim destination = [slice(None)] * image.ndim source[axis] = slice(1, None) destination[axis] = slice(None, -1) shifted[tuple(destination)] = coefficients[tuple(source)] coefficients = shifted result = coefficients for axis, value in enumerate(order): transform = sp_fft.idst if value % 2 else sp_fft.idct result = transform(result, type=2, axis=axis, norm='ortho') return result def _smooth_tensor_component(self, component, sigma, row, column): if self.padding_type != 'reflect' or row == column: return ndi.gaussian_filter(component, sigma=sigma, mode=self.padding_type) # Cross-components are odd across the two Riesz axes and even across # the remaining axis. Extend only a kernel halo along each odd axis, # with a sign change at every half-sample reflection (also for small # images whose smoothing kernel spans multiple reflections). if sigma <= 1e-15: return component.copy() radius = int(4.0 * sigma + 0.5) for axis, size in enumerate(component.shape): if axis not in (row, column) or radius == 0: component = ndi.gaussian_filter1d(component, sigma=sigma, axis=axis, mode='reflect') continue positions = np.arange(-radius, size + radius) reflected = (positions // size) % 2 != 0 indices = positions % size indices[reflected] = size - 1 - indices[reflected] extended = np.take(component, indices, axis=axis) axis_shape = [1] * component.ndim axis_shape[axis] = positions.size extended *= np.where(reflected, -1.0, 1.0).reshape(axis_shape) smoothed = ndi.gaussian_filter1d(extended, sigma=sigma, axis=axis, mode='constant') crop = [slice(None)] * component.ndim crop[axis] = slice(radius, radius + size) component = smoothed[tuple(crop)].copy() return component def _aligned_second_order_response(self, image, log_response, riesz_transform, crop=None): if crop is None: crop = (slice(None),) * image.ndim output_shape = image[crop].shape sigma = self.structure_tensor_sigma_mm / self.res_mm first_order_responses = [] for axis in range(3): order = [0, 0, 0] order[axis] = 1 first_order_responses.append(riesz_transform(image, order)) # Smooth on the full domain, then retain only the final field of view. # Eigensystems and steering are voxel-local and need no exterior tail. tensor = np.empty(output_shape + (3, 3)) for row in range(3): for column in range(row, 3): value = self._smooth_tensor_component( first_order_responses[row] * first_order_responses[column], sigma=sigma, row=row, column=column, ) tensor[..., row, column] = value[crop] tensor[..., column, row] = value[crop] del value, first_order_responses eigenvectors = np.linalg.eigh(tensor)[1] del tensor rotation = np.swapaxes(eigenvectors[..., ::-1], -1, -2) target_axes = np.repeat(np.arange(3), self.riesz_order) first_direction = rotation[..., target_axes[0], :] second_direction = rotation[..., target_axes[1], :] target_coefficient = np.sqrt(factorial(2) / np.prod([factorial(order) for order in self.riesz_order])) response = np.zeros(output_shape, dtype=image.dtype) for row in range(3): order = [0, 0, 0] order[row] = 2 response += ( target_coefficient * first_direction[..., row] * second_direction[..., row] * riesz_transform(log_response, order)[crop] ) for column in range(row + 1, 3): order = [0, 0, 0] order[row] = order[column] = 1 response += ( target_coefficient / np.sqrt(2.0) * ( first_direction[..., row] * second_direction[..., column] + first_direction[..., column] * second_direction[..., row] ) * riesz_transform(log_response, order)[crop] ) return response def _apply_composed(self, image, order): """Apply LoG and Riesz on one consistently bounded domain.""" crop = None if self.padding_type in ('constant', 'nearest'): padding = tuple((size // 2, size - size // 2) for size in image.shape) mode = 'edge' if self.padding_type == 'nearest' else 'constant' domain = np.pad(image, padding, mode=mode) crop = tuple(slice(before, before + size) for (before, _), size in zip(padding, image.shape)) riesz_transform = self._riesz_transform else: domain = image riesz_transform = self._boundary_aware_riesz_transform sigma = self.sigma_mm / self.res_mm log_response = ndi.gaussian_laplace( domain, sigma=sigma, mode=self.padding_type, cval=self.padding_constant, truncate=self.cutoff, ) if self.structure_tensor_sigma_mm is not None: return self._aligned_second_order_response(domain, log_response, riesz_transform, crop=crop) response = riesz_transform(log_response, order) return response if crop is None else response[crop].copy() def _apply_array(self, img): # Image arrays are handled internally as (y, x, z), while the public # multi-index follows the physical image axes (x, y, z). order = (self.riesz_order[1], self.riesz_order[0], *self.riesz_order[2:]) if self.dimensionality == '3D': return self._apply_composed(img, order) response = np.empty_like(img, dtype=np.result_type(img.dtype, np.float64)) for index in range(img.shape[2]): response[:, :, index] = self._apply_composed(img[:, :, index], order) return response
[docs] class Laws(BaseFilter): """Laws-kernel texture filtering in 2D or 3D. Laws filters combine separable 1D kernels such as level, edge, spot, wave, and ripple operators to form texture response maps. Optional energy maps summarize absolute responses in a local neighbourhood. Parameters ---------- response_map : str Kernel combination, for example ``"L5E5"`` in 2D or ``"L5E5S5"`` in 3D. Supported kernel letters are ``L``, ``E``, ``S``, ``W``, and ``R``. padding_type : {"constant", "nearest", "wrap", "reflect"} Boundary handling mode used during convolution. distance : int Radius of the local averaging window used when ``energy_map`` is true. energy_map : bool If true, return a local mean absolute response map. dimensionality : {"2D", "3D"} Apply 2D or 3D Laws filtering. rotation_invariance : bool, optional If true, combine responses over axis permutations and flips. pooling : {"avg", "max", None}, optional Pooling rule for rotation-invariant responses. """ def __init__( self, response_map, padding_type, distance, energy_map, dimensionality, rotation_invariance=False, pooling=None ): super().__init__( filtering_method='Laws Kernels', response_map=response_map, padding_type=padding_type, distance=distance, energy_map=energy_map, dimensionality=dimensionality, rotation_invariance=rotation_invariance, pooling=pooling, ) if dimensionality in ['2D', '3D']: self.dimensionality = dimensionality else: raise ValueError(f"Wrong dimensionality '{dimensionality}'. Available dimensions '2D' and '3D'.") if padding_type in ['constant', 'nearest', 'wrap', 'reflect']: self.padding_type = padding_type else: raise ValueError( f"Wrong padding type '{padding_type}'. " "Available padding types are: 'constant', 'nearest', 'wrap', and 'reflect'." ) if isinstance(distance, int): self.distance = distance else: raise ValueError(f"Distance should be 'int' but '{type(distance)}' detected.") if isinstance(energy_map, bool): self.energy_map = energy_map else: raise ValueError('Energy map can be only True or False.') if isinstance(rotation_invariance, bool): self.rotation_invariance = rotation_invariance else: raise ValueError(f"Rotation Invariance should be True or False but '{type(rotation_invariance)}' detected.") self.response_map = response_map self.pooling = pooling def _get_kernel(self, l_type, support): if l_type == "L": if support == 3: return 1 / np.sqrt(6) * np.array([1, 2, 1]) elif support == 5: return 1 / np.sqrt(70) * np.array([1, 4, 6, 4, 1]) elif l_type == "E": if support == 3: return 1 / np.sqrt(2) * np.array([-1, 0, 1]) elif support == 5: return 1 / np.sqrt(10) * np.array([-1, -2, 0, 2, 1]) elif l_type == "S": if support == 3: return 1 / np.sqrt(6) * np.array([-1, 2, -1]) elif support == 5: return 1 / np.sqrt(6) * np.array([-1, 0, 2, 0, -1]) elif l_type == "W": if support == 5: return 1 / np.sqrt(10) * np.array([-1, 2, 0, -2, 1]) elif l_type == "R": if support == 5: return 1 / np.sqrt(70) * np.array([1, -4, 6, -4, 1]) def _get_response_maps(self): parts = [self.response_map[i : i + 2] for i in range(0, len(self.response_map), 2)] return [''.join(e) for e in permutations(parts)] def _filter(self, img, response_map): if self.dimensionality == "3D": x_filt = self._get_kernel(response_map[0], int(response_map[1])) y_filt = self._get_kernel(response_map[2], int(response_map[3])) z_filt = self._get_kernel(response_map[4], int(response_map[5])) filtered_img = ndi.convolve1d(img, x_filt, axis=1, mode=self.padding_type) filtered_img = ndi.convolve1d(filtered_img, y_filt, axis=0, mode=self.padding_type) filtered_img = ndi.convolve1d(filtered_img, z_filt, axis=2, mode=self.padding_type) elif self.dimensionality == "2D": x_filt = self._get_kernel(response_map[0], int(response_map[1])) y_filt = self._get_kernel(response_map[2], int(response_map[3])) filtered_img = ndi.convolve1d(img, x_filt, axis=1, mode=self.padding_type) filtered_img = ndi.convolve1d(filtered_img, y_filt, axis=0, mode=self.padding_type) else: filtered_img = None return filtered_img def _apply_array(self, img): final_image = None if self.rotation_invariance: response_maps = self._get_response_maps() if self.pooling == "avg": final_image = np.nan * np.ones(img.shape) for response_map in response_maps: final_image += self._filter(img, response_map) final_image += self._filter(img[::-1, :, :], response_map)[::-1, :, :] final_image += self._filter(img[:, ::-1, :], response_map)[:, ::-1, :] final_image += self._filter(img[:, :, ::-1], response_map)[:, :, ::-1] final_image += self._filter(img[::-1, ::-1, :], response_map)[::-1, ::-1, :] final_image += self._filter(img[::-1, :, ::-1], response_map)[::-1, :, ::-1] final_image += self._filter(img[:, ::-1, ::-1], response_map)[:, ::-1, ::-1] final_image += self._filter(img[::-1, ::-1, ::-1], response_map)[::-1, ::-1, ::-1] final_image = final_image / 24 elif self.pooling == "max": final_image = -np.inf * np.ones(img.shape) for response_map in response_maps: final_image = np.maximum(final_image, self._filter(img, response_map)) final_image = np.maximum(final_image, self._filter(img[::-1, :, :], response_map)[::-1, :, :]) final_image = np.maximum(final_image, self._filter(img[:, ::-1, :], response_map)[:, ::-1, :]) final_image = np.maximum(final_image, self._filter(img[:, :, ::-1], response_map)[:, :, ::-1]) final_image = np.maximum(final_image, self._filter(img[::-1, ::-1, :], response_map)[::-1, ::-1, :]) final_image = np.maximum(final_image, self._filter(img[::-1, :, ::-1], response_map)[::-1, :, ::-1]) final_image = np.maximum(final_image, self._filter(img[:, ::-1, ::-1], response_map)[:, ::-1, ::-1]) final_image = np.maximum( final_image, self._filter(img[::-1, ::-1, ::-1], response_map)[::-1, ::-1, ::-1] ) else: final_image = self._filter(img, self.response_map) if self.energy_map: final_image = self._get_energy_map(final_image) return final_image def _get_energy_map(self, img): if self.dimensionality == "2D": filt_mat = np.ones([2 * self.distance + 1, 2 * self.distance + 1]) filt_mat = filt_mat / np.prod(filt_mat.shape) energy_map = np.nan * np.ones(img.shape) for i in range(img.shape[2]): energy_map[:, :, i] = ndi.convolve(input=np.abs(img[:, :, i]), weights=filt_mat, mode='reflect') elif self.dimensionality == "3D": filt_mat = np.ones([2 * self.distance + 1, 2 * self.distance + 1, 2 * self.distance + 1]) filt_mat = filt_mat / np.prod(filt_mat.shape) energy_map = ndi.convolve(input=np.abs(img), weights=filt_mat, mode='reflect') else: energy_map = None return energy_map
[docs] class Gabor(BaseFilter): """Gabor filtering with complex 2D kernels. Gabor filters measure oriented, frequency-selective texture. The implementation applies real and imaginary kernels slice-wise, returns their magnitude, and can average responses over orientations and orthogonal planes. Parameters ---------- padding_type : {"constant", "nearest", "reflect", "mirror", "wrap"} Boundary handling mode used by OpenCV. res_mm : float Voxel spacing in millimetres used to convert physical scales to pixels. sigma_mm : float Gaussian envelope standard deviation in millimetres. lambda_mm : float Sinusoidal wavelength in millimetres. gamma : float Spatial aspect ratio of the Gabor kernel. theta : float Orientation angle in radians, or angular step when ``rotation_invariance`` is true. rotation_invariance : bool, optional If true, average responses over orientations from 0 to ``2*pi``. orthogonal_planes : bool, optional If true, also evaluate the three orthogonal slice planes. n_stds : float or None, optional Kernel size in standard deviations. If ``None``, seven standard deviations are used. """ _PADDING_MAP = { 'reflect': cv2.BORDER_REFLECT, 'mirror': cv2.BORDER_REFLECT_101, 'constant': cv2.BORDER_CONSTANT, 'nearest': cv2.BORDER_REPLICATE, 'wrap': cv2.BORDER_WRAP, } def __init__( self, padding_type: str, res_mm: float, sigma_mm: float, lambda_mm: float, gamma: float, theta: float, rotation_invariance: bool = False, orthogonal_planes: bool = False, n_stds: float = None, ): super().__init__( filtering_method='Gabor', padding_type=padding_type, res_mm=res_mm, sigma_mm=sigma_mm, lambda_mm=lambda_mm, gamma=gamma, theta=theta, rotation_invariance=rotation_invariance, orthogonal_planes=orthogonal_planes, n_stds=n_stds, ) try: self._border = self._PADDING_MAP[padding_type] except KeyError: raise ValueError(f"padding_type must be one of {list(self._PADDING_MAP)}, got {padding_type!r}") self.rotation_invariance = rotation_invariance self.res_mm = res_mm self.theta = theta self.gamma = gamma self.lambda_mm = lambda_mm self.sigma_mm = sigma_mm self.padding_type = padding_type self.orthogonal_planes = orthogonal_planes self.n_stds = n_stds @lru_cache(maxsize=128) def _make_kernels(self, theta, ksize): if ksize % 2 == 0: ksize += 1 kern_real = cv2.getGaborKernel( (ksize, ksize), self.sigma_mm / self.res_mm, theta, self.lambda_mm / self.res_mm, self.gamma, 0, ktype=cv2.CV_32F, ) kern_imag = cv2.getGaborKernel( (ksize, ksize), self.sigma_mm / self.res_mm, theta, self.lambda_mm / self.res_mm, self.gamma, np.pi / 2, ktype=cv2.CV_32F, ) return kern_real, kern_imag def _filter(self, img, theta, plane2d=(0, 1)): axes = list(plane2d) + [i for i in range(3) if i not in plane2d] img_view = np.transpose(img, axes).astype(np.float32, copy=False) if self.n_stds is None: ksize = int(np.ceil(7 * (self.sigma_mm / self.res_mm))) | 1 else: ksize = int(np.ceil(self.n_stds * (self.sigma_mm / self.res_mm))) | 1 kern_r, kern_i = self._make_kernels(theta, ksize) out = np.empty_like(img_view) for z in range(img_view.shape[2]): slice_ = img_view[:, :, z] out_r = cv2.filter2D(slice_, -1, kern_r, borderType=self._border) out_i = cv2.filter2D(slice_, -1, kern_i, borderType=self._border) out[:, :, z] = np.hypot(out_r, out_i) return np.transpose(out, np.argsort(axes)) def _apply_array(self, img): if self.rotation_invariance: thetas = np.arange(0, 2 * np.pi, self.theta, dtype=np.float32) planes = [(0, 1), (0, 2), (1, 2)] if self.orthogonal_planes else [(0, 1)] resp = [self._filter(img, th, pl) for th in thetas for pl in planes] return np.mean(resp, axis=0, dtype=np.float32) return self._filter(img, self.theta)