Source code for numpyradiomics.mod_ngtdm

import numpy as np
from scipy.ndimage import convolve

[docs] def ngtdm(image, mask, binWidth=25, distance=1): """ Compute 5 Pyradiomics-style NGTDM (Neighborhood Gray Tone Difference Matrix) features. The NGTDM quantifies the difference between a gray value and the average gray value of its neighbors within a defined distance. Args: image (np.ndarray): 3D image array containing voxel intensities. mask (np.ndarray): 3D mask array (same shape as image), where non-zero values indicate the ROI. binWidth (float, optional): Width of bins for discretization. Default is 25. distance (int, optional): The distance (radius) of the neighborhood kernel. Default is 1. Returns: dict: Dictionary containing the 5 NGTDM features: - **Coarseness**: Measures the average difference between the center voxel and its neighborhood. Higher values indicate a lower spatial change rate and locally more uniform texture. - **Contrast**: Measures the spatial intensity change rate. Higher values indicate large changes in intensity between neighboring areas. - **Busyness**: Measures the rapid changes of intensity between pixels and their neighborhood. High values indicate a "busy" image (rapid changes). - **Complexity**: Measures the information content of the image. High values indicate non-uniform and rapid changes in gray levels. - **Strength**: Measures the distinctness of the primitives in the image. High values indicate easily definable and visible structures. Example: >>> import numpyradiomics as npr >>> # Generate a noisy ellipsoid >>> img, mask = npr.dro.noisy_ellipsoid(radii_mm=(12, 12, 12), intensity_range=(0, 100)) >>> >>> # Compute NGTDM features >>> feats = npr.ngtdm(img, mask, binWidth=10, distance=1) >>> >>> print(f"Coarseness: {feats['Coarseness']:.6f}") Coarseness: 0.001234 """ roi_mask = mask > 0 if not np.any(roi_mask): raise ValueError("Mask contains no voxels.") roi_image = image.copy() # Restrict ROI for min/max to match PyRadiomics ROI-based binning masked_pixels = roi_image[roi_mask] min_val = np.min(masked_pixels) max_val = np.max(masked_pixels) # Handle degenerate case (Flat Region) if max_val == min_val: # Coarseness caps at 1e6 for flat regions, others are 0 return { "Coarseness": 1000000.0, "Contrast": 0.0, "Busyness": 0.0, "Complexity": 0.0, "Strength": 0.0 } # --- 1. Quantization (PyRadiomics Style) --- # PyRadiomics uses floor((x - min) / binWidth) + 1 # resulting in 1-based indices: 1, 2, ..., N_bins # Calculate exact number of bins needed N_bins = int(np.floor((max_val - min_val) / binWidth)) + 1 # Discretize # We use float temporarily to handle the division pixel_bins = np.floor((roi_image - min_val) / binWidth) + 1 # Clip to ensure numerical stability at the max_val edge pixel_bins = np.clip(pixel_bins, 1, N_bins).astype(int) # Apply Mask (0 is background/ignored now, 1..N are valid bins) img_quant = pixel_bins * roi_mask # --- 2. Neighborhood Pre-processing --- # Kernel: 3D cube with 0 at center k_size = 2 * distance + 1 kernel = np.ones((k_size, k_size, k_size), dtype=np.float64) kernel[distance, distance, distance] = 0 # Calculate sum and count of neighbors # Note: img_quant has values 1..N inside mask, 0 outside. # convolve with cval=0 treats outside as 0 (ignored). neighbor_sum = convolve(img_quant.astype(float), kernel, mode='constant', cval=0) neighbor_count = convolve(roi_mask.astype(float), kernel, mode='constant', cval=0) # Valid neighborhood: inside mask AND has neighbors valid_mask = (neighbor_count > 0) & roi_mask # Calculate average neighborhood intensity (A_bar) # Note: neighbor_sum sums indices (1..N). This matches PyRadiomics # which calculates texture features on the discretized indices. local_mean = np.zeros_like(neighbor_sum) local_mean[valid_mask] = neighbor_sum[valid_mask] / neighbor_count[valid_mask] # --- 3. Build NGTDM Matrix (N_i and S_i) --- # We need vectors of length N_bins (indices 0..N-1 mapped to levels 1..N) N_i = np.zeros(N_bins, dtype=np.float64) S_i = np.zeros(N_bins, dtype=np.float64) valid_voxels = img_quant[valid_mask] # Values are 1..N valid_means = local_mean[valid_mask] # Vectorized accumulation # Subtract 1 from voxel values to get 0-based array indices voxel_indices = valid_voxels - 1 # Count occurrences (N_i) N_i = np.bincount(voxel_indices, minlength=N_bins).astype(np.float64) # Calculate S_i: Sum of absolute differences |i - mean| # We iterate because bincount cannot sum a derived float difference easily for i in range(N_bins): # i is 0-based index, corresponding to Gray Level (i + 1) level_val = i + 1 matches = (valid_voxels == level_val) if np.any(matches): # Difference between Integer Level and Float Mean S_i[i] = np.sum(np.abs(level_val - valid_means[matches])) # --- 4. Compute Features --- N_total = np.sum(N_i) # N_vp (Valid Pixels) if N_total == 0: return {"Coarseness": 1e6, "Contrast": 0.0, "Busyness": 0.0, "Complexity": 0.0, "Strength": 0.0} p_i = N_i / N_total # Gray Level Vector: PyRadiomics uses 1-based indices (1, 2, 3...) for features i_vec = np.arange(1, N_bins + 1, dtype=np.float64) # Identify non-zeros for efficiency (Ngp calculation) nz_indices = np.where(p_i > 0)[0] Ngp = len(nz_indices) # --- Coarseness --- sum_pi_si = np.sum(p_i * S_i) coarseness = 1.0 / sum_pi_si if sum_pi_si != 0 else 1000000.0 # --- Contrast --- if Ngp > 1: # Standard deviation term involves double sum over p_i * p_j * (i-j)^2 # Vectorized: Outer product i_diff = i_vec[:, None] - i_vec[None, :] p_prod = np.outer(p_i, p_i) term1 = np.sum(p_prod * (i_diff**2)) / (Ngp * (Ngp - 1)) term2 = np.sum(S_i) / N_total contrast = term1 * term2 else: contrast = 0.0 # --- Busyness --- # Denom: sum |i*p_i - j*p_j| ip = i_vec * p_i denom_busy = np.sum(np.abs(ip[:, None] - ip[None, :])) busyness = np.sum(p_i * S_i) / denom_busy if denom_busy != 0 else 0.0 # --- Complexity --- # Term: |i-j| * ( (p_i s_i + p_j s_j) / (p_i + p_j) ) p_sum = p_i[:, None] + p_i[None, :] p_sum[p_sum == 0] = 1.0 # Avoid div by zero (numerator will be 0 anyway) pi_si = p_i * S_i num_complex = pi_si[:, None] + pi_si[None, :] term_complex = (np.abs(i_diff) * num_complex) / p_sum complexity = np.sum(term_complex) / N_total # --- Strength --- # Num: (p_i + p_j) * (i-j)^2 # Denom: sum(S_i) # Force p_sum to be clean again just in case p_sum = p_i[:, None] + p_i[None, :] strength_num = np.sum(p_sum * (i_diff**2)) strength_denom = np.sum(S_i) strength = strength_num / strength_denom if strength_denom != 0 else 0.0 return { "Coarseness": coarseness, "Contrast": contrast, "Busyness": busyness, "Complexity": complexity, "Strength": strength }
[docs] def ngtdm_units(intensity_unit='HU'): """ Returns units for NGTDM features. Args: intensity_unit (str, optional): The unit of pixel intensity (e.g., 'HU', 'GV', ''). Default is 'HU'. Returns: dict: Dictionary mapping feature names to their units. Example: >>> from numpyradiomics import ngtdm_units >>> units = ngtdm_units(intensity_unit='HU') >>> print(units['Contrast']) HU^3 """ base_unit = intensity_unit # Variables: # i = Intensity (base_unit) # P = Probability (dimensionless) # S = Sum of absolute differences (base_unit) return { "Coarseness": f"{base_unit}^-1", # 1 / sum(P * S) -> 1 / (1 * U) -> U^-1 "Contrast": f"{base_unit}^3", # Variance(U^2) * NormSum(S)(U) -> U^3 "Busyness": "", # sum(P*S) / sum(|iP - jP|) -> U / U -> Dimensionless "Complexity": f"{base_unit}^2", # sum(|i-j| * (PS+PS)/(P+P)) -> U * U / 1 -> U^2 "Strength": base_unit # sum((P+P)*(i-j)^2) / sum(S) -> (1 * U^2) / U -> U }