import math
import numpy as np
from scipy.fft import irfftn, next_fast_len, rfftn
from scipy.spatial import ConvexHull
from scipy.spatial.distance import cdist, pdist
from scipy.special import legendre
from skimage import measure
from ..exceptions import DataStructureError
from .base import BaseFeatureGroup
# Algorithm-selection constants, independent of any resource-management policy.
_CORRELATION_TINY_ROI = 256
_CORRELATION_BLOCK_SIZE = 256
_CORRELATION_FFT_COST_FACTOR = 2
def _correlation_method(n, fft_shape):
"""Choose exact spatial evaluation from valid count and padded geometry.
Keep geometry separate from evaluation: the same N and FFT shape can be
used by a future general extraction policy without changing either algorithm.
"""
padded_size = math.prod(fft_shape)
pairs = n * (n - 1) // 2
fft_cost = _CORRELATION_FFT_COST_FACTOR * padded_size * math.log2(max(padded_size, 2))
if n > _CORRELATION_TINY_ROI and pairs > fft_cost:
return 'fft'
return 'blocked'
def _pca_eigenvalues(points: np.ndarray) -> np.ndarray:
"""Return eigenvalues of the covariance matrix of ``points`` sorted descending."""
if points.ndim != 2 or points.shape[1] != 3:
raise ValueError("Expected an (n_samples, 3) array of points")
n_samples = points.shape[0]
if n_samples < 3:
raise ValueError("At least three points are required to compute PCA")
points = points.astype(np.float64, copy=False)
centered = points - np.mean(points, axis=0, keepdims=True)
cov = np.cov(centered, rowvar=False, bias=False)
eigenvalues, _ = np.linalg.eigh(cov)
order = np.argsort(eigenvalues)[::-1]
return eigenvalues[order]
[docs]
class MorphologicalFeatures:
"""Morphological and shape descriptors for a 3D region of interest.
Features describe ROI volume, surface area, compactness, principal axes,
convex-hull density, and related shape measures. Calculations use physical
voxel spacing so outputs are expressed in image-world units where
applicable.
Parameters
----------
spacing : sequence of float
Physical voxel spacing along the three array axes.
"""
def __init__(self, spacing):
self.spacing = spacing
self.unit_vol = self.spacing[0] * self.spacing[1] * self.spacing[2]
[docs]
def get_params(self):
"""Return the configuration parameters of this morphology calculator.
Returns
-------
dict
Parameter names mapped to their configured values.
"""
return {
'spacing': self.spacing,
}
[docs]
def get_feature_names(self):
"""Return the morphology feature names produced by this calculator.
Returns
-------
list of str
Feature names defined for the morphology family.
"""
return list(MORPHOLOGY_FEATURE_NAMES)
def _calc_mesh(self, mask_array):
padded_mask = np.pad(mask_array, 1, mode='constant')
mesh_verts, mesh_faces, _, _ = measure.marching_cubes(padded_mask, level=0.5)
mesh_verts -= np.array([1, 1, 1])
return mesh_verts * self.spacing, mesh_faces
@staticmethod
def _calc_vol_and_area_mesh(mesh_verts, mesh_faces):
faces = np.asarray(mesh_faces)
verts = np.asarray(mesh_verts)
a, b, c = verts[faces[:, 0]], verts[faces[:, 1]], verts[faces[:, 2]]
cross_bc = np.cross(b, c)
cross_ba_ca = np.cross(b - a, c - a)
vol_mesh = abs(np.einsum('ij,ij->i', a, cross_bc).sum() / 6)
area_mesh = np.linalg.norm(cross_ba_ca, axis=1).sum() / 2
return vol_mesh, area_mesh
def _calc_vol_count(self, mask_array):
return np.sum(mask_array) * self.unit_vol
@staticmethod
def _calc_surf_to_vol_ratio(area_mesh, vol_mesh):
return area_mesh / vol_mesh
@staticmethod
def _calc_compactness_1(vol_mesh, area_mesh):
return vol_mesh / (np.pi ** (1 / 2) * area_mesh ** (3 / 2))
@staticmethod
def _calc_compactness_2(vol_mesh, area_mesh):
return 36 * np.pi * (vol_mesh**2 / area_mesh**3)
@staticmethod
def _calc_spherical_disproportion(area_mesh, vol_mesh):
return area_mesh / (36 * np.pi * vol_mesh**2) ** (1 / 3)
@staticmethod
def _calc_sphericity(vol_mesh, area_mesh):
return (36 * np.pi * vol_mesh**2) ** (1 / 3) / area_mesh
@staticmethod
def _calc_asphericity(area_mesh, vol_mesh):
return (area_mesh**3 / (36 * np.pi * vol_mesh**2)) ** (1 / 3) - 1
def _calc_centre_of_shift(self, mask_array, image_array):
dx, dy, dz = self.spacing
morph_voxels = np.argwhere(mask_array)
morph_voxels_scaled = morph_voxels * [dx, dy, dz]
com_geom = np.mean(morph_voxels_scaled, axis=0)
intensity_voxels = np.argwhere(~np.isnan(image_array))
intensities = image_array[intensity_voxels[:, 0], intensity_voxels[:, 1], intensity_voxels[:, 2]]
intensity_voxels_scaled = intensity_voxels * [dx, dy, dz]
com_gl = np.average(intensity_voxels_scaled, axis=0, weights=intensities)
return np.linalg.norm(com_geom - com_gl)
@staticmethod
def _calc_convex_hull(mesh_verts):
return ConvexHull(mesh_verts)
@staticmethod
def _calc_max_diameter(conv_hull, mesh_verts):
hull_verts = mesh_verts[conv_hull.vertices]
if hull_verts.shape[0] < 2:
return 0
return np.max(pdist(hull_verts))
def _calc_pca(self, mask_array):
voxel_indices = np.argwhere(mask_array == 1)
scaled_voxel_indices = voxel_indices.astype(np.float64)
scaled_voxel_indices *= self.spacing
return _pca_eigenvalues(scaled_voxel_indices)
@staticmethod
def _calc_major_minor_least_axes_len(pca_eigenvalues):
return (
4 * np.sqrt(pca_eigenvalues[0]),
4 * np.sqrt(pca_eigenvalues[1]),
4 * np.sqrt(pca_eigenvalues[2]),
)
@staticmethod
def _calc_elongation(pca_eigenvalues):
if pca_eigenvalues[0] == 0:
raise DataStructureError("PCA eigenvalue is zero. ")
return np.sqrt(pca_eigenvalues[1] / pca_eigenvalues[0])
@staticmethod
def _calc_flatness(pca_eigenvalues):
if pca_eigenvalues[0] == 0:
raise DataStructureError("PCA eigenvalue is zero. ")
return np.sqrt(pca_eigenvalues[2] / pca_eigenvalues[0])
def _calc_vol_and_area_densities_aabb(self, mask_array, vol_mesh, area_mesh):
x_dim, y_dim, z_dim = self.spacing
x_coords, y_coords, z_coords = np.where(mask_array == 1)
x_min, x_max = x_coords.min(), x_coords.max()
y_min, y_max = y_coords.min(), y_coords.max()
z_min, z_max = z_coords.min(), z_coords.max()
aabb_x_dim = (x_max - x_min + 1) * x_dim
aabb_y_dim = (y_max - y_min + 1) * y_dim
aabb_z_dim = (z_max - z_min + 1) * z_dim
aabb_volume = aabb_x_dim * aabb_y_dim * aabb_z_dim
aabb_surface_area = 2 * (aabb_x_dim * aabb_y_dim + aabb_x_dim * aabb_z_dim + aabb_y_dim * aabb_z_dim)
return vol_mesh / aabb_volume, area_mesh / aabb_surface_area
@staticmethod
def _calc_vol_density_aee(vol_mesh, major_axis_len, minor_axis_len, least_axis_len):
if major_axis_len == 0 or minor_axis_len == 0 or least_axis_len == 0:
raise DataStructureError("One of the axis is zero. ")
return (8 * 3 * vol_mesh) / (4 * np.pi * major_axis_len * minor_axis_len * least_axis_len)
@staticmethod
def _calc_area_density_aee(area_mesh, major_axis_len, minor_axis_len, least_axis_len):
a = major_axis_len / 2
b = minor_axis_len / 2
c = least_axis_len / 2
alpha = np.sqrt(1 - (b**2 / a**2))
beta = np.sqrt(1 - (c**2 / a**2))
if alpha == 0 or beta == 0:
raise DataStructureError("Alpha or beta in area density (AEE) is zero.")
sum_series = 0
max_nu = 20 # Def by IBSI
for nu in range(max_nu + 1):
p_nu = legendre(nu)
sum_series += ((alpha * beta) ** nu / (1 - (4 * nu**2))) * p_nu((alpha**2 + beta**2) / (2 * alpha * beta))
area_aee = 4 * np.pi * a * b * sum_series
return area_mesh / area_aee
@staticmethod
def _calc_vol_density_ch(vol_mesh, conv_hull):
return vol_mesh / conv_hull.volume
@staticmethod
def _calc_area_density_ch(area_mesh, conv_hull):
return area_mesh / conv_hull.area
@staticmethod
def _calc_integrated_intensity(image_array, vol_mesh):
return np.nanmean(image_array) * vol_mesh
[docs]
def calculate_features(self, mask_array, image_array, *, include_correlation=True):
"""Calculate morphology features for prepared mask and intensity arrays.
Parameters
----------
mask_array : numpy.ndarray
Prepared binary ROI mask array.
image_array : numpy.ndarray
Prepared intensity image aligned with ``mask_array`` where voxels
outside the ROI can be represented by ``NaN``.
include_correlation : bool, default=True
Include the joint Moran's I and Geary's C calculation.
Returns
-------
dict
Mapping of morphology feature names to calculated values.
"""
mask_array = np.asarray(mask_array)
image_array = np.asarray(image_array)
mesh_verts, mesh_faces = self._calc_mesh(mask_array)
vol_mesh, area_mesh = self._calc_vol_and_area_mesh(mesh_verts, mesh_faces)
vol_count = self._calc_vol_count(mask_array)
surf_to_vol_ratio = self._calc_surf_to_vol_ratio(area_mesh, vol_mesh)
compactness_1 = self._calc_compactness_1(vol_mesh, area_mesh)
compactness_2 = self._calc_compactness_2(vol_mesh, area_mesh)
spherical_disproportion = self._calc_spherical_disproportion(area_mesh, vol_mesh)
sphericity = self._calc_sphericity(vol_mesh, area_mesh)
asphericity = self._calc_asphericity(area_mesh, vol_mesh)
centre_of_shift = self._calc_centre_of_shift(mask_array, image_array)
conv_hull = self._calc_convex_hull(mesh_verts)
max_diameter = self._calc_max_diameter(conv_hull, mesh_verts)
pca_eigenvalues = self._calc_pca(mask_array)
major_axis_len, minor_axis_len, least_axis_len = self._calc_major_minor_least_axes_len(pca_eigenvalues)
elongation = self._calc_elongation(pca_eigenvalues)
flatness = self._calc_flatness(pca_eigenvalues)
vol_density_aabb, area_density_aabb = self._calc_vol_and_area_densities_aabb(mask_array, vol_mesh, area_mesh)
vol_density_aee = self._calc_vol_density_aee(vol_mesh, major_axis_len, minor_axis_len, least_axis_len)
area_density_aee = self._calc_area_density_aee(area_mesh, major_axis_len, minor_axis_len, least_axis_len)
vol_density_ch = self._calc_vol_density_ch(vol_mesh, conv_hull)
area_density_ch = self._calc_area_density_ch(area_mesh, conv_hull)
integrated_intensity = self._calc_integrated_intensity(image_array, vol_mesh)
values = [
vol_mesh,
vol_count,
area_mesh,
surf_to_vol_ratio,
compactness_1,
compactness_2,
spherical_disproportion,
sphericity,
asphericity,
centre_of_shift,
max_diameter,
major_axis_len,
minor_axis_len,
least_axis_len,
elongation,
flatness,
vol_density_aabb,
area_density_aabb,
vol_density_aee,
area_density_aee,
vol_density_ch,
area_density_ch,
integrated_intensity,
]
features = dict(zip(MORPHOLOGY_FEATURE_NAMES, values))
if include_correlation:
features.update(MorphologyCorrelationFeatures(self.spacing).calculate_features(mask_array, image_array))
return features
[docs]
class MorphologyCorrelationFeatures:
"""Spatial autocorrelation descriptors for a 3D region of interest.
Moran's I and Geary's C summarize how intensity values vary with physical
distance between ROI voxels. They are morphological features and require both a morphology mask
and aligned intensity image.
Parameters
----------
spacing : sequence of float
Physical voxel spacing along the three array axes.
"""
def __init__(self, spacing):
self.spacing = spacing
[docs]
def get_params(self):
"""Return the configuration parameters of this morphology correlation calculator.
Returns
-------
dict
Parameter names mapped to their configured values.
"""
return {
'spacing': self.spacing,
}
[docs]
def get_feature_names(self):
"""Return the morphology correlation feature names produced by this calculator.
Returns
-------
list of str
Feature names defined for the morphology correlation family.
"""
return list(MORPHOLOGY_CORRELATION_FEATURE_NAMES)
def _blocked_sums(self, coordinates, centered):
"""Return ordered total weight, Moran numerator and Geary numerator."""
points = coordinates * np.asarray(self.spacing, dtype=np.float64)
n = len(centered)
total_weight = moran_numerator = geary_numerator = 0.0
block = _CORRELATION_BLOCK_SIZE
for i in range(0, n, block):
zi = centered[i : i + block]
for j in range(i, n, block):
zj = centered[j : j + block]
weights = cdist(points[i : i + block], points[j : j + block])
if i == j:
np.fill_diagonal(weights, np.inf)
np.reciprocal(weights, out=weights)
multiplicity = 1 if i == j else 2
total_weight += multiplicity * weights.sum()
moran_numerator += multiplicity * np.einsum('i,ij,j->', zi, weights, zj)
differences = zi[:, None] - zj[None, :]
np.square(differences, out=differences)
geary_numerator += multiplicity * np.einsum('ij,ij->', weights, differences)
return total_weight, moran_numerator, geary_numerator
def _fft_sums(self, coordinates, centered, shape, fft_shape):
"""Joint hybrid convolution/Parseval sums, with full inverse-distance weights.
Padding to at least 2L-1 preserves all ROI displacements without wraparound.
Only the mask convolution is inverted: S0 = sum(m * (k*m)),
B = sum(z**2 * (k*m)), A = sum(K * abs(FFT(z))**2) / P.
Symmetry gives the ordered Geary numerator G = 2(B-A).
"""
axes = []
for length, spacing in zip(fft_shape, self.spacing):
offsets = np.arange(length, dtype=np.float64)
axes.append((np.minimum(offsets, length - offsets) * spacing) ** 2)
kernel = axes[0][:, None, None] + axes[1][None, :, None] + axes[2][None, None, :]
kernel[0, 0, 0] = np.inf
np.sqrt(kernel, out=kernel)
np.reciprocal(kernel, out=kernel)
# The wrapped kernel is real and even; retain only its real spectrum.
kernel_spectrum = rfftn(kernel, workers=1).real.copy()
del kernel
indices = tuple(coordinates.T)
grid = np.zeros(shape, dtype=np.float64)
grid[indices] = 1
spectrum = rfftn(grid, s=fft_shape, workers=1)
spectrum *= kernel_spectrum
degrees = irfftn(spectrum, s=fft_shape, overwrite_x=True, workers=1)[indices]
total_weight = degrees.sum()
degree_square_sum = np.sum(centered * centered * degrees)
del degrees, spectrum
grid[indices] = centered
spectrum = rfftn(grid, s=fft_shape, workers=1)
# rFFT omits negative frequencies on the last axis. DC and, for an
# even transform length, Nyquist occur once; interior frequencies twice.
multiplicities = np.full(spectrum.shape[-1], 2.0)
multiplicities[0] = 1
if fft_shape[-1] % 2 == 0:
multiplicities[-1] = 1
moran_numerator = 0.0
for plane, kernel_plane in zip(spectrum, kernel_spectrum):
power = plane.real**2 + plane.imag**2
moran_numerator += np.einsum('ij,ij,j->', power, kernel_plane, multiplicities)
moran_numerator /= math.prod(fft_shape)
return total_weight, moran_numerator, 2 * (degree_square_sum - moran_numerator)
[docs]
def calculate_features(self, mask_array, image_array):
"""Calculate both features jointly from aligned 3D mask and intensity arrays.
Nonzero mask voxels with non-NaN intensities form the population. Raw
intensities are converted to float64; texture discretization is not used.
Uses exact hybrid FFT/Parseval or blocked pair sums according to valid
voxel count and padded bounding-box geometry. Fewer than two observations,
nonfinite intensities, or zero intensity variance yield NaN for both features.
"""
mask_array = np.asarray(mask_array)
image_array = np.asarray(image_array)
if mask_array.ndim != 3 or image_array.shape != mask_array.shape:
raise ValueError('Morphology correlation requires aligned 3D mask and intensity arrays.')
spacing = np.asarray(self.spacing, dtype=np.float64)
if spacing.shape != (3,) or not np.all(np.isfinite(spacing) & (spacing > 0)):
raise ValueError('Morphology correlation requires three positive finite voxel spacings.')
coordinates = np.argwhere((mask_array != 0) & ~np.isnan(image_array))
values = np.asarray(image_array[tuple(coordinates.T)], dtype=np.float64)
n = len(values)
if n < 2 or not np.all(np.isfinite(values)):
# Infinity was not excluded by the old NaN-only population rule;
# it propagated to NaN results. Preserve that behavior explicitly.
return dict.fromkeys(MORPHOLOGY_CORRELATION_FEATURE_NAMES, np.nan)
# A rounded mean can differ from every value in a constant float array.
# Test equality directly; a tolerance would discard real low contrast.
if np.all(values == values[0]):
return dict.fromkeys(MORPHOLOGY_CORRELATION_FEATURE_NAMES, np.nan)
centered = values - np.mean(values)
variance_sum = np.sum(centered**2)
del values
if variance_sum == 0:
# Undefined statistics must not discard other valid morphology results.
return dict.fromkeys(MORPHOLOGY_CORRELATION_FEATURE_NAMES, np.nan)
coordinates -= coordinates.min(axis=0)
shape = tuple(int(value) + 1 for value in coordinates.max(axis=0))
fft_shape = tuple(next_fast_len(2 * length - 1) for length in shape)
method = _correlation_method(n, fft_shape)
if method == 'fft':
total_weight, moran_numerator, geary_numerator = self._fft_sums(
coordinates,
centered,
shape,
fft_shape,
)
else:
total_weight, moran_numerator, geary_numerator = self._blocked_sums(coordinates, centered)
return {
'morph_moran_i': (n / total_weight) * (moran_numerator / variance_sum),
'morph_geary_c': ((n - 1) / (2 * total_weight)) * (geary_numerator / variance_sum),
}
MORPHOLOGY_FEATURE_NAMES = (
'morph_volume',
'morph_vol_approx',
'morph_area_mesh',
'morph_av',
'morph_comp_1',
'morph_comp_2',
'morph_sph_dispr',
'morph_sphericity',
'morph_asphericity',
'morph_com',
'morph_diam',
'morph_pca_maj_axis',
'morph_pca_min_axis',
'morph_pca_least_axis',
'morph_pca_elongation',
'morph_pca_flatness',
'morph_vol_dens_aabb',
'morph_area_dens_aabb',
'morph_vol_dens_aee',
'morph_area_dens_aee',
'morph_vol_dens_conv_hull',
'morph_area_dens_conv_hull',
'morph_integ_int',
'morph_moran_i',
'morph_geary_c',
)
MORPHOLOGY_CORRELATION_FEATURE_NAMES = (
'morph_moran_i',
'morph_geary_c',
)
class MorphologyFeatureGroup(BaseFeatureGroup):
family = 'morphology'
requirements = frozenset({'base_masks'})
def supports(self, context):
return not context.is_slice_2d
def default_enabled(self, context):
return self.supports(context)
def output_names(self, context):
return MORPHOLOGY_FEATURE_NAMES
def feature_aliases(self, context):
return {name: name for name in self.output_names(context)}
def calculate(self, context, prepared_data):
masks = prepared_data.require_base_masks()
morphology = MorphologicalFeatures(
masks.morphological_mask.spacing[::-1],
)
return morphology.calculate_features(
masks.morphological_mask.array,
masks.intensity_mask.array,
)
def calculate_selected(self, context, prepared_data, selected_features):
masks = prepared_data.require_base_masks()
spacing = masks.morphological_mask.spacing[::-1]
correlation_names = set(MORPHOLOGY_CORRELATION_FEATURE_NAMES)
selected = set(selected_features)
if selected <= correlation_names:
return MorphologyCorrelationFeatures(spacing).calculate_features(
masks.morphological_mask.array,
masks.intensity_mask.array,
)
return MorphologicalFeatures(spacing).calculate_features(
masks.morphological_mask.array,
masks.intensity_mask.array,
include_correlation=bool(selected & correlation_names),
)