Source code for zrad.radiomics.glcm

import numpy as np

from ..exceptions import DataStructureError
from .base import BaseFeatureGroup
from .texture_aggregation import format_cm_rlm_feature_names
from .texture_base import crop_to_valid_bbox

GLCM_FEATURE_NAMES = (
    'cm_joint_max',
    'cm_joint_avg',
    'cm_joint_var',
    'cm_joint_entr',
    'cm_diff_avg',
    'cm_diff_var',
    'cm_diff_entr',
    'cm_sum_avg',
    'cm_sum_var',
    'cm_sum_entr',
    'cm_energy',
    'cm_contrast',
    'cm_dissimilarity',
    'cm_inv_diff',
    'cm_inv_diff_norm',
    'cm_inv_diff_mom',
    'cm_inv_diff_mom_norm',
    'cm_inv_var',
    'cm_corr',
    'cm_auto_corr',
    'cm_clust_tend',
    'cm_clust_shade',
    'cm_clust_prom',
    'cm_info_corr1',
    'cm_info_corr2',
)


[docs] class GLCM: """Grey level co-occurrence matrix features. GLCM features summarize how often pairs of discretized grey levels occur at fixed neighbour offsets. The class supports IBSI-style 2D, 2.5D, and 3D directional aggregation. Parameters ---------- aggr_dim : {"2D", "2.5D", "3D"} Spatial dimensionality used to build co-occurrence matrices. aggr_method : {"MERG", "AVER", "SLICE_MERG", "DIR_MERG"} Strategy used to combine matrices across directions and slices. slice_weight : bool, default=False Weight slice-wise averages by slice ROI voxel count. slice_median : bool, default=False Aggregate slice-wise values by median instead of mean. """ def __init__(self, aggr_dim, aggr_method, slice_weight=False, slice_median=False): self.aggr_dim = aggr_dim self.aggr_method = aggr_method self.slice_weight = slice_weight self.slice_median = slice_median
[docs] def get_params(self): """Return the configuration parameters of this GLCM calculator. Returns ------- dict Parameter names mapped to their configured values. """ return { 'aggr_dim': self.aggr_dim, 'aggr_method': self.aggr_method, 'slice_weight': self.slice_weight, 'slice_median': self.slice_median, }
[docs] def get_feature_names(self): """Return the GLCM feature names produced by this calculator. Returns ------- list of str Feature names defined for the GLCM family. """ return list(GLCM_FEATURE_NAMES)
[docs] def calculate_features(self, discretized_image_array): """Calculate GLCM features for a prepared discretized intensity array. Parameters ---------- discretized_image_array : numpy.ndarray Prepared discretized intensity array with ROI voxels represented by integer grey levels and voxels outside the ROI set to ``NaN``. Returns ------- dict Mapping of GLCM feature names to calculated values. """ discretized_image_array = np.asarray(discretized_image_array) lvl = int(np.nanmax(discretized_image_array) + 1) tot_no_of_roi_voxels = int(np.sum(~np.isnan(discretized_image_array))) if self.aggr_dim == '3D': glcm_3d_matrices = self._calc_3d_matrices(discretized_image_array, lvl) if self.aggr_method == 'AVER': return self._calc_3d_averaged_glcm_features(glcm_3d_matrices) if self.aggr_method == 'MERG': return self._calc_3d_merged_glcm_features(glcm_3d_matrices) else: glcm_2d_matrices, slice_no_of_roi_voxels = self._calc_2d_matrices(discretized_image_array, lvl) if self.aggr_method == 'DIR_MERG': return self._calc_2_5d_direction_merged_glcm_features(glcm_2d_matrices) if self.aggr_method == 'MERG': return self._calc_2_5d_merged_glcm_features(glcm_2d_matrices) if self.aggr_method == 'AVER': return self._calc_2d_averaged_glcm_features( glcm_2d_matrices, slice_no_of_roi_voxels, tot_no_of_roi_voxels, ) if self.aggr_method == 'SLICE_MERG': return self._calc_2d_slice_merged_glcm_features( glcm_2d_matrices, slice_no_of_roi_voxels, tot_no_of_roi_voxels, ) raise DataStructureError( f'Unsupported GLCM aggregation: aggr_dim={self.aggr_dim}, aggr_method={self.aggr_method}.' )
@staticmethod def _calc_2d_matrices(image, lvl): def calc_2d_glcm_slice(image_slice, direction): dx, dy, *_ = direction rows, cols = image_slice.shape glcm_slice = np.zeros((lvl, lvl), dtype=int) nan_mask = np.isnan(image_slice) valid_i = np.arange(rows - dx) if dx >= 0 else np.arange(-dx, rows) valid_j = np.arange(cols - dy) if dy >= 0 else np.arange(-dy, cols) i_grid, j_grid = np.meshgrid(valid_i, valid_j, indexing='ij') row_pixels = image_slice[i_grid, j_grid] col_pixels = image_slice[i_grid + dx, j_grid + dy] valid_pairs = ~nan_mask[i_grid, j_grid] & ~nan_mask[i_grid + dx, j_grid + dy] np.add.at( glcm_slice, (row_pixels[valid_pairs].astype(int), col_pixels[valid_pairs].astype(int)), 1, ) return glcm_slice glcm_2d_matrices = [] slice_no_of_roi_voxels = [] for z_index in range(image.shape[2]): if np.all(np.isnan(image[:, :, z_index])): continue slice_no_of_roi_voxels.append(int(np.sum(~np.isnan(image[:, :, z_index])))) z_slice_matrices = [] for direction_2d in ([1, 0, 0], [1, 1, 0], [0, 1, 0], [-1, 1, 0]): glcm = calc_2d_glcm_slice(image[:, :, z_index], direction_2d) z_slice_matrices.append(glcm + glcm.T) glcm_2d_matrices.append(z_slice_matrices) return np.array(glcm_2d_matrices), np.array(slice_no_of_roi_voxels, dtype=float) @staticmethod def _calc_3d_matrices(image, lvl): image = crop_to_valid_bbox(image) glcm_3d_matrices = [] for direction_3d in ( [0, 0, 1], [0, 1, 0], [1, 0, 0], [0, 1, 1], [0, 1, -1], [1, 0, 1], [1, 0, -1], [1, 1, 0], [1, -1, 0], [1, 1, 1], [1, 1, -1], [1, -1, 1], [1, -1, -1], ): co_matrix = np.zeros((lvl, lvl), dtype=np.float64) depth, height, width = image.shape min_i = max(0, -direction_3d[2]) min_y = max(0, -direction_3d[1]) min_x = max(0, -direction_3d[0]) max_i = min(depth, depth - direction_3d[2]) max_y = min(height, height - direction_3d[1]) max_x = min(width, width - direction_3d[0]) arr1 = image[min_i:max_i, min_y:max_y, min_x:max_x] arr2 = image[ min_i + direction_3d[2] : max_i + direction_3d[2], min_y + direction_3d[1] : max_y + direction_3d[1], min_x + direction_3d[0] : max_x + direction_3d[0], ] not_nan_mask = np.logical_and(~np.isnan(arr1), ~np.isnan(arr2)) y_cm_values = arr1[not_nan_mask].astype(int) x_cm_values = arr2[not_nan_mask].astype(int) if y_cm_values.size: flat_indices = y_cm_values * lvl + x_cm_values reverse_flat_indices = x_cm_values * lvl + y_cm_values co_matrix += np.bincount( np.concatenate((flat_indices, reverse_flat_indices)), minlength=lvl * lvl, ).reshape(lvl, lvl) glcm_3d_matrices.append(co_matrix) return np.array(glcm_3d_matrices) @staticmethod def _calc_p_minus(matrix): n_g = matrix.shape[0] p_minus = np.zeros(n_g) for k in range(n_g): mask = np.abs(np.subtract.outer(np.arange(n_g), np.arange(n_g))) == k p_minus[k] = matrix[mask].sum() return p_minus @staticmethod def _calc_p_plus(matrix): n_g = matrix.shape[0] p_plus = np.zeros(2 * n_g - 1) for k in range(2 * n_g - 1): mask = np.add.outer(np.arange(n_g), np.arange(n_g)) == k p_plus[k] = matrix[mask].sum() return p_plus @staticmethod def _calc_mu_i_and_sigma_i(matrix): p_i = np.sum(matrix, axis=0) indices = np.arange(len(p_i)) mu_i = np.sum(p_i * indices) sigma_i = np.sqrt(np.sum(((indices - mu_i) ** 2) * p_i)) return mu_i, sigma_i @classmethod def _calc_correlation(cls, matrix): i, j = np.indices(matrix.shape) mu_i, sigma_i = cls._calc_mu_i_and_sigma_i(matrix) if sigma_i == 0: raise DataStructureError('Sigma_i in correlation is zero.') return (np.sum(matrix * i * j) - mu_i**2) / sigma_i**2 @classmethod def _calc_cluster_tendency_shade_prominence(cls, matrix, power): mu_i, _ = cls._calc_mu_i_and_sigma_i(matrix) i, j = np.indices(matrix.shape) return np.sum((i + j - 2 * mu_i) ** power * matrix) @staticmethod def _calc_information_correlation_1(matrix): non_zero_mask = matrix != 0 hxy = (-1) * np.sum(matrix[non_zero_mask] * np.log2(matrix[non_zero_mask])) p_i = np.sum(matrix, axis=0) non_zero_mask_p_i = p_i != 0 hx = (-1) * np.sum(p_i[non_zero_mask_p_i] * np.log2(p_i[non_zero_mask_p_i])) hxy_1 = 0 for i in range(len(matrix)): for j in range(len(matrix)): if p_i[i] != 0 and p_i[j] != 0: hxy_1 += matrix[i][j] * np.log2(p_i[i] * p_i[j]) hxy_1 *= -1 if hx == 0: raise DataStructureError('hx in information correlation 1 is zero.') return (hxy - hxy_1) / hx @staticmethod def _calc_information_correlation_2(matrix): non_zero_mask = matrix != 0 hxy = (-1) * np.sum(matrix[non_zero_mask] * np.log2(matrix[non_zero_mask])) p_i = np.sum(matrix, axis=0) hxy_2 = 0 for i in range(len(matrix)): for j in range(len(matrix)): if p_i[i] != 0 and p_i[j] != 0: hxy_2 += p_i[i] * p_i[j] * np.log2(p_i[i] * p_i[j]) hxy_2 *= -1 return np.sqrt(1 - np.exp(-2 * (hxy_2 - hxy))) @staticmethod def _calc_joint_average(matrix): i, _ = np.indices(matrix.shape) return np.sum(matrix * i) @staticmethod def _calc_joint_var(matrix, mu): i, _ = np.indices(matrix.shape) return np.sum(matrix * (i - mu) ** 2) @staticmethod def _calc_joint_entropy(matrix): non_zero_mask = matrix != 0 return (-1) * np.sum(matrix[non_zero_mask] * np.log2(matrix[non_zero_mask])) @staticmethod def _calc_diff_average(p_minus): k = np.indices(p_minus.shape) return np.sum(p_minus * k) @staticmethod def _calc_dif_var(p_minus, mu): k = np.indices(p_minus.shape) return np.sum(p_minus * (k - mu) ** 2) @staticmethod def _calc_diff_entropy(p_minus): non_zero_mask = p_minus != 0 return (-1) * np.sum(p_minus[non_zero_mask] * np.log2(p_minus[non_zero_mask])) @staticmethod def _calc_sum_average(p_plus): k = np.indices(p_plus.shape) return np.sum(p_plus * k) @staticmethod def _calc_sum_var(p_plus, mu): k = np.indices(p_plus.shape) return np.sum(p_plus * (k - mu) ** 2) @staticmethod def _calc_sum_entropy(p_plus): non_zero_mask = p_plus != 0 return (-1) * np.sum(p_plus[non_zero_mask] * np.log2(p_plus[non_zero_mask])) @staticmethod def _calc_second_moment(matrix): return np.sum(matrix * matrix) @staticmethod def _calc_contrast(matrix): i, j = np.indices(matrix.shape) return np.sum(matrix * (i - j) ** 2) @staticmethod def _calc_dissimilarity(matrix): i, j = np.indices(matrix.shape) return np.sum(matrix * abs(i - j)) @staticmethod def _calc_inverse_diff(matrix): i, j = np.indices(matrix.shape) return np.sum(matrix / (1 + abs(i - j))) @staticmethod def _calc_norm_inv_diff(matrix): n_g = len(matrix) - 1 i, j = np.indices(matrix.shape) if n_g == 0: raise DataStructureError('n_g in calc_norm_inv_diff is zero.') return np.sum(matrix / (1 + abs(i - j) / n_g)) @staticmethod def _calc_inv_diff_moment(p_minus): k = np.indices(p_minus.shape) return np.sum(p_minus / (1 + k**2)) @staticmethod def _calc_norm_inv_diff_moment(p_minus): k = np.indices(p_minus.shape) n_g = len(p_minus) - 1 if n_g == 0: raise DataStructureError('n_g in calc_norm_inv_diff_moment is zero.') return np.sum(p_minus / (1 + (k / n_g) ** 2)) @staticmethod def _calc_inv_variance(p_minus): k = np.indices(p_minus.shape) non_zero_mask = k != 0 return np.sum(p_minus[1:] / (k[non_zero_mask] ** 2)) @staticmethod def _calc_autocor(matrix): i, j = np.indices(matrix.shape) return np.sum(matrix * i * j) @staticmethod def _normalize_matrix(matrix, context_name): norm = np.sum(matrix) if norm == 0: raise DataStructureError(f'Denominator is zero in {context_name}.') return matrix / norm @classmethod def _feature_values(cls, matrix): joint_average = cls._calc_joint_average(matrix) p_minus = cls._calc_p_minus(matrix) diff_average = cls._calc_diff_average(p_minus) p_plus = cls._calc_p_plus(matrix) sum_average = cls._calc_sum_average(p_plus) return { 'cm_joint_max': np.max(matrix), 'cm_joint_avg': joint_average, 'cm_joint_var': cls._calc_joint_var(matrix, joint_average), 'cm_joint_entr': cls._calc_joint_entropy(matrix), 'cm_diff_avg': diff_average, 'cm_diff_var': cls._calc_dif_var(p_minus, diff_average), 'cm_diff_entr': cls._calc_diff_entropy(p_minus), 'cm_sum_avg': sum_average, 'cm_sum_var': cls._calc_sum_var(p_plus, sum_average), 'cm_sum_entr': cls._calc_sum_entropy(p_plus), 'cm_energy': cls._calc_second_moment(matrix), 'cm_contrast': cls._calc_contrast(matrix), 'cm_dissimilarity': cls._calc_dissimilarity(matrix), 'cm_inv_diff': cls._calc_inverse_diff(matrix), 'cm_inv_diff_norm': cls._calc_norm_inv_diff(matrix), 'cm_inv_diff_mom': cls._calc_inv_diff_moment(p_minus), 'cm_inv_diff_mom_norm': cls._calc_norm_inv_diff_moment(p_minus), 'cm_inv_var': cls._calc_inv_variance(p_minus), 'cm_corr': cls._calc_correlation(matrix), 'cm_auto_corr': cls._calc_autocor(matrix), 'cm_clust_tend': cls._calc_cluster_tendency_shade_prominence(matrix, 2), 'cm_clust_shade': cls._calc_cluster_tendency_shade_prominence(matrix, 3), 'cm_clust_prom': cls._calc_cluster_tendency_shade_prominence(matrix, 4), 'cm_info_corr1': cls._calc_information_correlation_1(matrix), 'cm_info_corr2': cls._calc_information_correlation_2(matrix), } def _aggregate_feature_dicts(self, feature_dicts, weights=None): if not feature_dicts: raise DataStructureError('No GLCM matrices available for aggregation.') if self.slice_median: if weights is not None: raise DataStructureError('Weighted median is not supported for GLCM aggregation.') return { feature_name: float(np.median([values[feature_name] for values in feature_dicts])) for feature_name in GLCM_FEATURE_NAMES } return { feature_name: float(np.average([values[feature_name] for values in feature_dicts], weights=weights)) for feature_name in GLCM_FEATURE_NAMES } def _calc_2d_averaged_glcm_features(self, glcm_2d_matrices, slice_no_of_roi_voxels, tot_no_of_roi_voxels): feature_dicts = [] weights = [] for slice_index in range(glcm_2d_matrices.shape[0]): for direction_index in range(glcm_2d_matrices.shape[1]): glcm_slice = self._normalize_matrix( glcm_2d_matrices[slice_index][direction_index], 'calc_2d_averaged_glcm_features', ) feature_dicts.append(self._feature_values(glcm_slice)) if self.slice_weight: if tot_no_of_roi_voxels == 0: raise DataStructureError('tot_no_of_roi_voxels in calc_2d_averaged_glcm_features is zero.') weights.append(slice_no_of_roi_voxels[slice_index] / tot_no_of_roi_voxels) else: weights.append(1.0) return self._aggregate_feature_dicts( feature_dicts, None if self.slice_median and not self.slice_weight else weights, ) def _calc_2d_slice_merged_glcm_features(self, glcm_2d_matrices, slice_no_of_roi_voxels, tot_no_of_roi_voxels): averaged_glcm = np.sum(glcm_2d_matrices, axis=1) feature_dicts = [] weights = [] for slice_index in range(averaged_glcm.shape[0]): glcm_slice = self._normalize_matrix( averaged_glcm[slice_index], 'calc_2d_slice_merged_glcm_features', ) feature_dicts.append(self._feature_values(glcm_slice)) if self.slice_weight: if tot_no_of_roi_voxels == 0: raise DataStructureError('tot_no_of_roi_voxels in calc_2d_slice_merged_glcm_features is zero.') weights.append(slice_no_of_roi_voxels[slice_index] / tot_no_of_roi_voxels) else: weights.append(1.0) return self._aggregate_feature_dicts( feature_dicts, None if self.slice_median and not self.slice_weight else weights, ) def _calc_2_5d_merged_glcm_features(self, glcm_2d_matrices): glcm = self._normalize_matrix( np.sum(np.sum(glcm_2d_matrices, axis=1), axis=0), 'calc_2_5d_merged_glcm_features', ) return self._feature_values(glcm) def _calc_2_5d_direction_merged_glcm_features(self, glcm_2d_matrices): averaged_glcm = np.sum(glcm_2d_matrices, axis=0) feature_dicts = [] for direction_index in range(averaged_glcm.shape[0]): direction_matrix = self._normalize_matrix( averaged_glcm[direction_index], 'calc_2_5d_direction_merged_glcm_features', ) feature_dicts.append(self._feature_values(direction_matrix)) return self._aggregate_feature_dicts(feature_dicts) def _calc_3d_averaged_glcm_features(self, glcm_3d_matrices): feature_dicts = [ self._feature_values(self._normalize_matrix(matrix, 'calc_3d_averaged_glcm_features')) for matrix in glcm_3d_matrices ] return self._aggregate_feature_dicts(feature_dicts) def _calc_3d_merged_glcm_features(self, glcm_3d_matrices): matrix = self._normalize_matrix( np.sum(glcm_3d_matrices, axis=0), 'calc_3d_merged_glcm_features', ) return self._feature_values(matrix)
class GLCMFeatureGroup(BaseFeatureGroup): family = 'glcm' 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_cm_rlm_feature_names(GLCM_FEATURE_NAMES, context.aggr_dim, context.aggr_method) def feature_aliases(self, context): output_names = self.output_names(context) aliases = {name: name for name in output_names} aliases.update(dict(zip(GLCM_FEATURE_NAMES, output_names))) return aliases def calculate(self, context, prepared_data): glcm = GLCM( aggr_dim=context.aggr_dim, aggr_method=context.aggr_method, slice_weight=context.slice_weighting, slice_median=context.slice_median, ) feature_values = glcm.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), GLCM_FEATURE_NAMES) }