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 TEXTURE_ATTRIBUTE_NAMES, TextureFeatureBase
GLRLM_FEATURE_NAMES = (
'rlm_sre',
'rlm_lre',
'rlm_lgre',
'rlm_hgre',
'rlm_srlge',
'rlm_srhge',
'rlm_lrlge',
'rlm_lrhge',
'rlm_glnu',
'rlm_glnu_norm',
'rlm_rlnu',
'rlm_rlnu_norm',
'rlm_r_perc',
'rlm_gl_var',
'rlm_rl_var',
'rlm_rl_entr',
)
[docs]
class GLRLM(TextureFeatureBase):
"""Grey level run length matrix features.
GLRLM features describe contiguous runs of equal discretized grey level
along predefined directions. They capture coarse versus fine texture and
low- versus high-grey-level run patterns.
Parameters
----------
aggr_dim : {"2D", "2.5D", "3D"}
Spatial dimensionality used to build run length 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):
super().__init__(slice_weight=slice_weight, slice_median=slice_median)
self.aggr_dim = aggr_dim
self.aggr_method = aggr_method
[docs]
def get_params(self):
"""Return the configuration parameters of this GLRLM 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 GLRLM feature names produced by this calculator.
Returns
-------
list of str
Feature names defined for the GLRLM family.
"""
return list(GLRLM_FEATURE_NAMES)
@staticmethod
def _map_feature_names(values):
return dict(zip(GLRLM_FEATURE_NAMES, [values[name] for name in TEXTURE_ATTRIBUTE_NAMES]))
@staticmethod
def _rle_1d(arr, lvl, rlm):
valid_idx = np.where(~np.isnan(arr))[0]
if valid_idx.size == 0:
return
splits = np.where(np.diff(valid_idx) != 1)[0] + 1
segments = np.split(valid_idx, splits)
for seg in segments:
seg_vals = arr[seg]
if seg_vals.size == 0:
continue
diff = np.diff(seg_vals)
run_breaks = np.where(diff != 0)[0] + 1
run_starts = np.concatenate(([0], run_breaks))
run_ends = np.concatenate((run_breaks, [seg_vals.size]))
run_lengths = run_ends - run_starts
for start, run_len in zip(run_starts, run_lengths):
if run_len - 1 < rlm.shape[1]:
gray = int(seg_vals[start])
rlm[gray, run_len - 1] += 1
@classmethod
def _process_horizontal(cls, z_slice, lvl):
rows, cols = z_slice.shape
rlm = np.zeros((lvl, max(rows, cols)), dtype=np.int64)
for i in range(rows):
cls._rle_1d(z_slice[i, :], lvl, rlm)
return rlm
@classmethod
def _process_vertical(cls, z_slice, lvl):
rows, cols = z_slice.shape
rlm = np.zeros((lvl, max(rows, cols)), dtype=np.int64)
for j in range(cols):
cls._rle_1d(z_slice[:, j], lvl, rlm)
return rlm
@classmethod
def _process_diagonal(cls, z_slice, lvl):
rows, cols = z_slice.shape
rlm = np.zeros((lvl, max(rows, cols)), dtype=np.int64)
for offset in range(-rows + 1, cols):
cls._rle_1d(np.diagonal(z_slice, offset=offset), lvl, rlm)
return rlm
@classmethod
def _process_antidiagonal(cls, z_slice, lvl):
rows, cols = z_slice.shape
rlm = np.zeros((lvl, max(rows, cols)), dtype=np.int64)
flipped = np.fliplr(z_slice)
for offset in range(-rows + 1, cols):
cls._rle_1d(np.diagonal(flipped, offset=offset), lvl, rlm)
return rlm
@classmethod
def _calc_2d_matrices(cls, image, lvl):
direction_funcs = (
cls._process_horizontal,
cls._process_vertical,
cls._process_diagonal,
cls._process_antidiagonal,
)
range_z = np.unique(np.where(~np.isnan(image))[2])
glrlm_2d_matrices = []
roi_voxel_counts = []
for z_slice_index in range_z:
z_slice = image[:, :, z_slice_index]
roi_voxel_count = int(np.count_nonzero(~np.isnan(z_slice)))
if roi_voxel_count == 0:
continue
roi_voxel_counts.append(roi_voxel_count)
glrlm_2d_matrices.append([func(z_slice, lvl) for func in direction_funcs])
return np.array(glrlm_2d_matrices, dtype=np.int64), np.array(roi_voxel_counts, dtype=float)
@staticmethod
def _same_neighbor_mask(image, valid_mask, direction):
same_neighbor = np.zeros(image.shape, dtype=bool)
current_slices = [slice(None)] * image.ndim
neighbor_slices = [slice(None)] * image.ndim
for axis, delta in enumerate(direction):
if delta > 0:
current_slices[axis] = slice(1, None)
neighbor_slices[axis] = slice(None, -1)
elif delta < 0:
current_slices[axis] = slice(None, -1)
neighbor_slices[axis] = slice(1, None)
current_slices = tuple(current_slices)
neighbor_slices = tuple(neighbor_slices)
same_neighbor[current_slices] = (
valid_mask[current_slices] & valid_mask[neighbor_slices] & (image[current_slices] == image[neighbor_slices])
)
return same_neighbor
@staticmethod
def _line_ids_and_positions(coords, shape, direction):
distances = []
for axis, delta in enumerate(direction):
if delta > 0:
distances.append(coords[axis])
elif delta < 0:
distances.append(shape[axis] - 1 - coords[axis])
positions = np.minimum.reduce(distances)
line_start_coords = [coords[axis] - positions * direction[axis] for axis in range(len(shape))]
line_ids = np.ravel_multi_index(line_start_coords, shape)
return line_ids, positions
@classmethod
def _rlm_for_direction(cls, image, valid_mask, direction, lvl, max_dim):
same_previous = cls._same_neighbor_mask(image, valid_mask, direction)
same_next = cls._same_neighbor_mask(image, valid_mask, tuple(-delta for delta in direction))
run_start_coords = np.where(valid_mask & ~same_previous)
run_end_coords = np.where(valid_mask & ~same_next)
if run_start_coords[0].size == 0:
return np.zeros((lvl, max_dim), dtype=np.int64)
start_line_ids, start_positions = cls._line_ids_and_positions(run_start_coords, image.shape, direction)
end_line_ids, end_positions = cls._line_ids_and_positions(run_end_coords, image.shape, direction)
start_order = np.lexsort((start_positions, start_line_ids))
end_order = np.lexsort((end_positions, end_line_ids))
run_lengths = end_positions[end_order] - start_positions[start_order] + 1
gray_levels = image[tuple(coord[start_order] for coord in run_start_coords)].astype(int)
flat_indices = gray_levels * max_dim + run_lengths - 1
return np.bincount(flat_indices, minlength=lvl * max_dim).reshape(lvl, max_dim)
@classmethod
def _calc_3d_matrices(cls, image, lvl):
directions = (
(0, 0, 1),
(0, 1, -1),
(0, 1, 0),
(0, 1, 1),
(1, -1, -1),
(1, -1, 0),
(1, -1, 1),
(1, 0, -1),
(1, 0, 0),
(1, 0, 1),
(1, 1, -1),
(1, 1, 0),
(1, 1, 1),
)
max_dim = max(image.shape)
valid_mask = ~np.isnan(image)
glrlm_3d_matrix = np.zeros((len(directions), lvl, max_dim), dtype=np.int64)
for d_idx, direction in enumerate(directions):
glrlm_3d_matrix[d_idx] = cls._rlm_for_direction(image, valid_mask, direction, lvl, max_dim)
return glrlm_3d_matrix
def _calc_2d_averaged_features(self, matrices, roi_voxel_counts, total_roi_voxels):
feature_dicts = []
weights = []
for slice_index in range(matrices.shape[0]):
for matrix in matrices[slice_index]:
if self.slice_weight:
if total_roi_voxels == 0:
raise DataStructureError(' Denominator is zero in calc_2d_averaged_glrlm_features.')
weights.append(roi_voxel_counts[slice_index] / total_roi_voxels)
else:
weights.append(1.0)
feature_dicts.append(self._matrix_feature_values(matrix, roi_voxel_counts[slice_index]))
return self._aggregate_feature_dicts(feature_dicts, None if self.slice_median else weights)
def _calc_2d_slice_merged_features(self, matrices, roi_voxel_counts, total_roi_voxels):
number_of_directions = matrices.shape[1]
if number_of_directions == 0:
raise DataStructureError(' Denominator is zero in calc_2d_slice_merged_glrlm_features.')
averaged_matrices = np.sum(matrices, axis=1)
feature_dicts = []
weights = []
for slice_index, matrix in enumerate(averaged_matrices):
if self.slice_weight:
if total_roi_voxels == 0:
raise DataStructureError(' Denominator is zero in calc_2d_slice_merged_glrlm_features.')
weights.append(roi_voxel_counts[slice_index] / total_roi_voxels)
else:
weights.append(1.0)
feature_dicts.append(
self._matrix_feature_values(matrix, roi_voxel_counts[slice_index] * number_of_directions)
)
return self._aggregate_feature_dicts(feature_dicts, None if self.slice_median else weights)
def _calc_2_5d_merged_features(self, matrices, roi_voxel_counts):
number_of_directions = matrices.shape[1]
if number_of_directions == 0:
raise DataStructureError(' Denominator is zero in calc_2_5d_merged_glrlm_features.')
matrix = np.sum(np.sum(matrices, axis=1), axis=0)
return self._matrix_feature_values(matrix, np.sum(roi_voxel_counts) * number_of_directions)
def _calc_2_5d_direction_merged_features(self, matrices, roi_voxel_counts):
number_of_directions = matrices.shape[1]
if number_of_directions == 0:
raise DataStructureError(' Denominator is zero in calc_2_5d_direction_merged_glrlm_features.')
averaged_glrlm = np.sum(matrices, axis=0)
values = [self._matrix_feature_values(matrix, np.sum(roi_voxel_counts)) for matrix in averaged_glrlm]
return self._mean_feature_dicts(values)
def _calc_3d_averaged_features(self, matrices, total_roi_voxels):
if matrices.shape[0] == 0:
raise DataStructureError(' Denominator is zero in calc_3d_averaged_glrlm_features.')
values = [self._matrix_feature_values(matrix, total_roi_voxels) for matrix in matrices]
return self._mean_feature_dicts(values)
def _calc_3d_merged_features(self, matrices, total_roi_voxels):
number_of_directions = matrices.shape[0]
if number_of_directions == 0:
raise DataStructureError(' Denominator is zero in calc_3d_merged_glrlm_features.')
matrix = np.sum(matrices, axis=0)
return self._matrix_feature_values(matrix, total_roi_voxels * number_of_directions)
[docs]
def calculate_features(self, discretized_image_array):
"""Calculate GLRLM 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 GLRLM 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':
matrices = self._calc_3d_matrices(discretized_image_array, lvl)
values = (
self._calc_3d_averaged_features(matrices, total_roi_voxels)
if self.aggr_method == 'AVER'
else self._calc_3d_merged_features(matrices, total_roi_voxels)
)
return self._map_feature_names(values)
matrices, roi_voxel_counts = self._calc_2d_matrices(discretized_image_array, lvl)
if self.aggr_method == 'DIR_MERG':
values = self._calc_2_5d_direction_merged_features(matrices, roi_voxel_counts)
elif self.aggr_method == 'MERG':
values = self._calc_2_5d_merged_features(matrices, roi_voxel_counts)
elif self.aggr_method == 'AVER':
values = self._calc_2d_averaged_features(matrices, roi_voxel_counts, total_roi_voxels)
elif self.aggr_method == 'SLICE_MERG':
values = self._calc_2d_slice_merged_features(matrices, roi_voxel_counts, total_roi_voxels)
else:
raise DataStructureError(
f'Unsupported GLRLM aggregation: aggr_dim={self.aggr_dim}, aggr_method={self.aggr_method}.'
)
return self._map_feature_names(values)
class GLRLMFeatureGroup(BaseFeatureGroup):
family = 'glrlm'
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(GLRLM_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(GLRLM_FEATURE_NAMES, output_names)))
return aliases
def calculate(self, context, prepared_data):
glrlm = GLRLM(
aggr_dim=context.aggr_dim,
aggr_method=context.aggr_method,
slice_weight=context.slice_weighting,
slice_median=context.slice_median,
)
feature_values = glrlm.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), GLRLM_FEATURE_NAMES)
}