Source code for zrad.radiomics.ngtdm

import numpy as np
from scipy.ndimage import convolve

from ..exceptions import DataStructureError
from .base import BaseFeatureGroup
from .texture_aggregation import format_texture_feature_names

NGTDM_FEATURE_NAMES = (
    'ngt_coarseness',
    'ngt_contrast',
    'ngt_busyness',
    'ngt_complexity',
    'ngt_strength',
)


[docs] class NGTDM: """Neighbouring grey tone difference matrix features. NGTDM features compare each discretized grey level with the average grey level in its local neighbourhood. They quantify coarseness, contrast, busyness, complexity, and strength. Parameters ---------- aggr_dim : {"2D", "2.5D", "3D"} Spatial dimensionality used to build neighbouring difference matrices. slice_weight : bool, default=False Weight 2D slice-wise averages by slice ROI voxel count. slice_median : bool, default=False Aggregate 2D slice-wise values by median instead of mean. """ def __init__(self, aggr_dim, slice_weight=False, slice_median=False): self.aggr_dim = aggr_dim self.slice_weight = slice_weight self.slice_median = slice_median
[docs] def get_params(self): """Return the configuration parameters of this NGTDM calculator. Returns ------- dict Parameter names mapped to their configured values. """ return { 'aggr_dim': self.aggr_dim, 'slice_weight': self.slice_weight, 'slice_median': self.slice_median, }
[docs] def get_feature_names(self): """Return the NGTDM feature names produced by this calculator. Returns ------- list of str Feature names defined for the NGTDM family. """ return list(NGTDM_FEATURE_NAMES)
@staticmethod def _calc_3d_matrix(image, lvl): valid = ~np.isnan(image) img_filled = np.where(valid, image, 0.0) kernel = np.ones((3, 3, 3), dtype=np.int8) kernel[1, 1, 1] = 0 neighbor_sum = convolve(img_filled, kernel, mode='constant', cval=0.0) neighbor_count = convolve(valid.astype(np.int8), kernel, mode='constant', cval=0) ngtdm = np.zeros((lvl, 2), dtype=np.float64) for gray_level in range(lvl): mask_lvl = image == gray_level mask_good = mask_lvl & (neighbor_count > 0) n_i = np.count_nonzero(mask_good) if n_i > 0: mean_nb = neighbor_sum[mask_good] / neighbor_count[mask_good] s_i = np.sum(np.abs(gray_level - mean_nb)) else: s_i = 0.0 ngtdm[gray_level, 0] = n_i ngtdm[gray_level, 1] = s_i return ngtdm @staticmethod def _calc_2d_matrices(image, lvl): kernel2d = np.ones((3, 3), dtype=np.int8) kernel2d[1, 1] = 0 range_z = np.unique(np.where(~np.isnan(image))[2]) slice_matrices = [] slice_voxel_counts = [] for z_index in range_z: z_slice = image[:, :, z_index] valid = ~np.isnan(z_slice) roi_voxel_count = int(valid.sum()) if roi_voxel_count == 0: continue slice_voxel_counts.append(roi_voxel_count) filled = np.where(valid, z_slice, 0.0) neighbor_sum = convolve(filled, kernel2d, mode='constant', cval=0.0) neighbor_count = convolve(valid.astype(np.int8), kernel2d, mode='constant', cval=0) ngtdm_slice = np.zeros((lvl, 2), dtype=np.float64) for gray_level in range(lvl): mask = (z_slice == gray_level) & (neighbor_count > 0) n_i = mask.sum() if n_i > 0: mean_nb = neighbor_sum[mask] / neighbor_count[mask] s_i = np.abs(gray_level - mean_nb).sum() else: s_i = 0.0 ngtdm_slice[gray_level, 0] = n_i ngtdm_slice[gray_level, 1] = s_i slice_matrices.append(ngtdm_slice) return np.array(slice_matrices), np.array(slice_voxel_counts, dtype=float) @staticmethod def _calc_coarseness(matrix): num = np.sum(matrix[:, 0]) denum = np.sum(matrix[:, 0] * matrix[:, 1]) return 1_000_000 if denum == 0 else num / denum @staticmethod def _calc_contrast(matrix): n = np.sum(matrix[:, 0]) if n == 0: raise DataStructureError(' Denominator is zero in calc_contrast.') n_g = np.sum(matrix[:, 0] != 0) s_1 = 0.0 s_2 = np.sum(matrix[:, 1]) for i in range(matrix.shape[0]): for j in range(matrix.shape[0]): s_1 += (matrix[i, 0] * matrix[j, 0] * (i - j) ** 2) / (n**2) denum = n_g * (n_g - 1) * np.sum(matrix[:, 0]) return 0 if denum == 0 else (s_1 * s_2) / denum @staticmethod def _calc_busyness(matrix): n = np.sum(matrix[:, 0]) if n == 0: raise DataStructureError(' Denominator is zero in calc_busyness.') num = 0.0 denum = 0.0 for i in range(matrix.shape[0]): num += (matrix[i, 0] * matrix[i, 1]) / n for j in range(matrix.shape[0]): if matrix[i, 0] != 0 and matrix[j, 0] != 0: denum += abs(i * matrix[i, 0] - j * matrix[j, 0]) / n return 0 if denum == 0 else num / denum @staticmethod def _calc_complexity(matrix): n = np.sum(matrix[:, 0]) if n == 0: return 0 sum_compl = 0.0 for i in range(matrix.shape[0]): p_i, s_i = matrix[i, 0], matrix[i, 1] if p_i == 0: continue for j in range(matrix.shape[0]): p_j, s_j = matrix[j, 0], matrix[j, 1] if p_j == 0: continue num = (p_i * s_i + p_j * s_j) * abs(i - j) / n den = (p_i + p_j) / n sum_compl += num / den return sum_compl / n @staticmethod def _calc_strength(matrix): n = np.sum(matrix[:, 0]) if n == 0: raise DataStructureError(' Denominator is zero in calc_strength.') num = 0.0 denum = np.sum(matrix[:, 1]) for i in range(matrix.shape[0]): for j in range(matrix.shape[0]): if matrix[i, 0] != 0 and matrix[j, 0] != 0: num += ((matrix[i, 0] + matrix[j, 0]) * (i - j) ** 2) / n return 0 if denum == 0 else num / denum @classmethod def _matrix_feature_values(cls, matrix): return { 'ngt_coarseness': cls._calc_coarseness(matrix), 'ngt_contrast': cls._calc_contrast(matrix), 'ngt_busyness': cls._calc_busyness(matrix), 'ngt_complexity': cls._calc_complexity(matrix), 'ngt_strength': cls._calc_strength(matrix), } def _aggregate_feature_dicts(self, feature_dicts, weights=None): if not feature_dicts: raise DataStructureError('No NGTDM matrices available for aggregation.') if self.slice_median: if self.slice_weight and weights is not None: raise DataStructureError('Weighted median is not supported for NGTDM aggregation.') return {name: float(np.median([values[name] for values in feature_dicts])) for name in NGTDM_FEATURE_NAMES} return { name: float(np.average([values[name] for values in feature_dicts], weights=weights)) for name in NGTDM_FEATURE_NAMES } def _calc_2d_features(self, matrices, slice_voxel_counts, total_roi_voxels): feature_dicts = [] weights = [] for slice_index, matrix in enumerate(matrices): if self.slice_weight: if total_roi_voxels == 0: raise DataStructureError(' Denominator is zero in calc_2d_ngtdm_features.') weights.append(slice_voxel_counts[slice_index] / total_roi_voxels) else: weights.append(1.0) feature_dicts.append(self._matrix_feature_values(matrix)) return self._aggregate_feature_dicts(feature_dicts, None if self.slice_median else weights) @classmethod def _calc_2_5d_features(cls, matrices): return cls._matrix_feature_values(np.sum(matrices, axis=0)) @classmethod def _calc_3d_features(cls, matrix): return cls._matrix_feature_values(matrix)
[docs] def calculate_features(self, discretized_image_array): """Calculate NGTDM features for a prepared discretized intensity array. Parameters ---------- discretized_image_array : numpy.ndarray Prepared discretized intensity array with voxels outside the ROI set to ``NaN``. Returns ------- dict Mapping of NGTDM feature names to calculated values. """ discretized_image_array = np.asarray(discretized_image_array) lvl = int(np.nanmax(discretized_image_array) + 1) total_roi_voxels = int(np.sum(~np.isnan(discretized_image_array))) if self.aggr_dim == '3D': return self._calc_3d_features(self._calc_3d_matrix(discretized_image_array, lvl)) matrices, slice_voxel_counts = self._calc_2d_matrices(discretized_image_array, lvl) if self.aggr_dim == '2.5D': return self._calc_2_5d_features(matrices) return self._calc_2d_features(matrices, slice_voxel_counts, total_roi_voxels)
class NGTDMFeatureGroup(BaseFeatureGroup): family = 'ngtdm' requirements = frozenset({'analysis_masks', 'discretized_intensity_image'}) def supports(self, context): return context.roi_data.texture_discretized_image is not None def output_names(self, context): return format_texture_feature_names(NGTDM_FEATURE_NAMES, context.aggr_dim) def feature_aliases(self, context): output_names = self.output_names(context) aliases = {name: name for name in output_names} aliases.update(dict(zip(NGTDM_FEATURE_NAMES, output_names))) return aliases def calculate(self, context, prepared_data): ngtdm = NGTDM( aggr_dim=context.aggr_dim, slice_weight=context.slice_weighting, slice_median=context.slice_median, ) feature_values = ngtdm.calculate_features(prepared_data.require_discretized_intensity_image().array.T) return { output_name: feature_values[base_name] for output_name, base_name in zip(self.output_names(context), NGTDM_FEATURE_NAMES) }