mlxradiomics 0.1.0.dev3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,81 @@
1
+ """PyRadiomics-compatible radiomics features computed with MLX on the Apple Silicon GPU.
2
+
3
+ Research software. Feature values are compared with a pinned PyRadiomics revision on
4
+ synthetic inputs (see docs/technical.md); no IBSI or clinical validation is claimed.
5
+
6
+ Names other than ``FEATURES`` and ``Settings`` are imported on first access, so
7
+ ``import mlxradiomics`` does not import MLX, SimpleITK or the PyRadiomics reference.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from importlib import import_module
13
+ from typing import TYPE_CHECKING, cast
14
+
15
+ from mlxradiomics.config import FEATURES, Settings
16
+
17
+ if TYPE_CHECKING:
18
+ from mlxradiomics.engine import Result as Result
19
+ from mlxradiomics.engine import extract as extract
20
+ from mlxradiomics.io import Volume as Volume
21
+ from mlxradiomics.lbp import LBPMethod as LBPMethod
22
+ from mlxradiomics.pipeline import ImageExtraction as ImageExtraction
23
+ from mlxradiomics.pipeline import extract_images as extract_images
24
+ from mlxradiomics.spatial import ImagePair as ImagePair
25
+ from mlxradiomics.spatial import ImageSettings as ImageSettings
26
+ from mlxradiomics.spatial import Interpolator as Interpolator
27
+ from mlxradiomics.spatial import extract_files as extract_files
28
+ from mlxradiomics.spatial import load_image_pair as load_image_pair
29
+ from mlxradiomics.transforms import FeatureImage as FeatureImage
30
+ from mlxradiomics.transforms import ImageType as ImageType
31
+ from mlxradiomics.transforms import TransformSettings as TransformSettings
32
+ from mlxradiomics.transforms import feature_images as feature_images
33
+
34
+ __version__ = "0.1.0.dev3"
35
+
36
+ _EXPORT_MODULES = {
37
+ "Result": "engine",
38
+ "extract": "engine",
39
+ "Volume": "io",
40
+ "ImagePair": "spatial",
41
+ "ImageSettings": "spatial",
42
+ "Interpolator": "spatial",
43
+ "extract_files": "spatial",
44
+ "load_image_pair": "spatial",
45
+ "ImageExtraction": "pipeline",
46
+ "extract_images": "pipeline",
47
+ "FeatureImage": "transforms",
48
+ "ImageType": "transforms",
49
+ "TransformSettings": "transforms",
50
+ "feature_images": "transforms",
51
+ "LBPMethod": "lbp",
52
+ }
53
+ __all__ = [
54
+ "FEATURES",
55
+ "Settings",
56
+ "Result",
57
+ "extract",
58
+ "Volume",
59
+ "ImagePair",
60
+ "ImageSettings",
61
+ "Interpolator",
62
+ "extract_files",
63
+ "load_image_pair",
64
+ "ImageExtraction",
65
+ "extract_images",
66
+ "FeatureImage",
67
+ "ImageType",
68
+ "TransformSettings",
69
+ "feature_images",
70
+ "LBPMethod",
71
+ ]
72
+
73
+
74
+ def __getattr__(name: str) -> object:
75
+ """Import a public name on first access and cache it in the module namespace."""
76
+ module = _EXPORT_MODULES.get(name)
77
+ if module is None:
78
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
79
+ value = cast(object, getattr(import_module(f"mlxradiomics.{module}"), name))
80
+ globals()[name] = value
81
+ return value
@@ -0,0 +1,28 @@
1
+ """The package executes only on Apple Silicon Metal through the shared runtime."""
2
+
3
+ from collections.abc import Callable
4
+ from platform import machine, system
5
+ from typing import TYPE_CHECKING, cast
6
+
7
+ import numpy as np
8
+ from medmlx_core import MissingDependencyError, import_mlx
9
+ from numpy.typing import NDArray
10
+
11
+ from mlxradiomics._typing import EvaluationRuntime
12
+
13
+ if TYPE_CHECKING:
14
+ import mlx.core as mx
15
+ else:
16
+ if system() != "Darwin" or machine() != "arm64":
17
+ raise MissingDependencyError(
18
+ "mlxradiomics requires macOS arm64 with an accessible Metal GPU",
19
+ hint="Run on Apple Silicon; host preprocessing and upstream recording need no Metal.",
20
+ )
21
+ mx = import_mlx()
22
+
23
+
24
+ __all__ = ["array_from_bool", "evaluate", "mx"]
25
+
26
+ # MLX accepts NumPy bool arrays; its DLPack constructor annotation omits them.
27
+ array_from_bool = cast(Callable[[NDArray[np.bool_]], mx.array], mx.array)
28
+ evaluate = cast(EvaluationRuntime, mx).eval
@@ -0,0 +1,173 @@
1
+ """Protocols for MLX/Metal calls and typed dictionaries for result and report payloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from typing import TYPE_CHECKING, Literal, NotRequired, Protocol, TypedDict
7
+
8
+ import numpy as np
9
+ from numpy.typing import NDArray
10
+
11
+ if TYPE_CHECKING:
12
+ from mlx.core import Dtype, array
13
+
14
+ type HostArray = NDArray[np.generic]
15
+ type FloatArray = NDArray[np.float64]
16
+ type Features = dict[str, float | FloatArray]
17
+
18
+
19
+ class Evaluate(Protocol):
20
+ def __call__(self, *arrays: array) -> None: ...
21
+
22
+
23
+ class EvaluationRuntime(Protocol):
24
+ @property
25
+ def eval(self) -> Evaluate: ...
26
+
27
+
28
+ class IndexedAddition(Protocol):
29
+ def add(self, value: array) -> array: ...
30
+
31
+
32
+ class MetalKernel(Protocol):
33
+ def __call__(
34
+ self,
35
+ *,
36
+ inputs: Sequence[array],
37
+ grid: tuple[int, int, int],
38
+ threadgroup: tuple[int, int, int],
39
+ output_shapes: Sequence[tuple[int, ...]],
40
+ output_dtypes: Sequence[Dtype],
41
+ init_value: float | None = None,
42
+ ) -> list[array]: ...
43
+
44
+
45
+ class SettingsPayload(TypedDict):
46
+ """Plain-dict copy of ``Settings`` (``dataclasses.asdict``), as stored in metadata."""
47
+
48
+ gldm_alpha: int
49
+ bin_width: float
50
+ bin_count: int | None
51
+ normalize: bool
52
+ normalize_scale: float
53
+ remove_outliers: float | None
54
+ resegment_range: tuple[float, ...] | None
55
+ resegment_mode: Literal["absolute", "relative", "sigma"]
56
+ resegment_shape: bool
57
+ label: int
58
+ distances: tuple[int, ...]
59
+ symmetrical_glcm: bool
60
+ force2d_axis: int | None
61
+ masked_kernel: bool
62
+ kernel_radius: int
63
+ voxel_array_shift: float
64
+ scratch_mib: int
65
+ max_gray_levels: int
66
+ features: tuple[str, ...]
67
+
68
+
69
+ class ImageGeometryPayload(TypedDict):
70
+ """Physical placement of the arrays that were processed.
71
+
72
+ ``shape`` and ``spacing`` follow NumPy array-axis order. ``origin`` and ``direction``
73
+ keep the ITK physical x/y/z order; ``direction`` is the flattened row-major matrix.
74
+ """
75
+
76
+ shape: tuple[int, ...]
77
+ spacing: tuple[float, ...]
78
+ origin: tuple[float, ...]
79
+ direction: tuple[float, ...]
80
+
81
+
82
+ class ExecutionMetadata(TypedDict):
83
+ """Provenance of one extraction, returned as ``Result.metadata``.
84
+
85
+ ``spacing`` is in the unit of the supplied spacing (mm for image files), one entry per
86
+ array axis. ``gray_levels`` is the highest gray-level bin index (1 when only shape
87
+ features were requested). ``batch_size`` and ``scratch_estimate_bytes`` are set in
88
+ voxel mode, ``gray_levels_by_image`` when derived images are extracted, and
89
+ ``image_geometry`` for file extraction.
90
+ """
91
+
92
+ settings: SettingsPayload
93
+ device: str
94
+ precision: str
95
+ voxel: bool
96
+ spacing: tuple[float, ...]
97
+ histogram_method: str
98
+ preprocessing: str
99
+ gray_levels: int
100
+ gray_levels_by_image: NotRequired[dict[str, int]]
101
+ batch_size: NotRequired[int]
102
+ scratch_estimate_bytes: NotRequired[int]
103
+ image_geometry: NotRequired[ImageGeometryPayload]
104
+
105
+
106
+ class VoxelMemory(TypedDict):
107
+ """Voxel-mode batch size (centers per batch) and the estimated scratch bytes it implies."""
108
+
109
+ batch_size: int
110
+ scratch_estimate_bytes: int
111
+
112
+
113
+ class Comparison(TypedDict):
114
+ """Per-feature comparison with the reference; ``max_absolute_error`` is over finite pairs."""
115
+
116
+ feature: str
117
+ passed: bool
118
+ compared_values: int
119
+ mismatches: int
120
+ max_absolute_error: float | None
121
+ reference_nan_count: int
122
+
123
+
124
+ class QualificationRecord(TypedDict):
125
+ """One synthetic case run in ROI or voxel mode against the pinned PyRadiomics.
126
+
127
+ ``median_speedup`` is the median PyRadiomics time divided by the median MLX time.
128
+ """
129
+
130
+ case: str
131
+ synthetic: bool
132
+ voxel: bool
133
+ settings: SettingsPayload
134
+ spacing: tuple[float, ...]
135
+ shape: tuple[int, ...]
136
+ selected_voxels: int
137
+ comparison: list[Comparison]
138
+ passed: bool
139
+ pyradiomics_seconds: list[float]
140
+ mlx_seconds: list[float]
141
+ median_speedup: float
142
+ execution: ExecutionMetadata
143
+ mlx_gpu_peak_memory_bytes: int
144
+
145
+
146
+ class QualificationReport(TypedDict):
147
+ """JSON report written by ``mlxradiomics-qualify`` (``schema_version`` 1)."""
148
+
149
+ schema_version: int
150
+ mlxradiomics_version: str
151
+ pyradiomics_commit: str
152
+ pyradiomics_version: str
153
+ mlx_version: str
154
+ numpy_version: str
155
+ python: str
156
+ platform: str
157
+ device: str
158
+ precision: str
159
+ histogram_method: str
160
+ workload: str
161
+ device_info: dict[str, str | int]
162
+ absolute_tolerance: float
163
+ relative_tolerance: float
164
+ passed: bool
165
+ records: list[QualificationRecord]
166
+ scope: str
167
+ performance_gate: str
168
+ ibsi_qualification: str
169
+ metal_qualification: str
170
+ reference_voxel_batch: int
171
+ reference_2d_representation: str
172
+ unsupported_cases: dict[str, str]
173
+ proposed_performance_targets: dict[str, float]
mlxradiomics/config.py ADDED
@@ -0,0 +1,187 @@
1
+ """Feature catalogue and extraction settings.
2
+
3
+ Unsupported settings raise ``ValueError``; nothing falls back to another backend.
4
+ """
5
+
6
+ from dataclasses import dataclass
7
+ from math import isfinite
8
+ from typing import Literal
9
+
10
+ # Feature names per family, spelled as in PyRadiomics (firstorder -> RadiomicsFirstOrder,
11
+ # glcm -> RadiomicsGLCM, gldm -> RadiomicsGLDM, ngtdm -> RadiomicsNGTDM,
12
+ # glrlm -> RadiomicsGLRLM, glszm -> RadiomicsGLSZM, shape -> RadiomicsShape,
13
+ # shape2D -> RadiomicsShape2D). Deprecated PyRadiomics aliases are not included.
14
+ FIRST_ORDER = (
15
+ "Mean", "Minimum", "Maximum", "Range", "Variance", "Skewness", "Kurtosis",
16
+ "Energy", "TotalEnergy", "RootMeanSquared", "Entropy", "Uniformity",
17
+ "10Percentile", "90Percentile", "Median", "InterquartileRange",
18
+ "MeanAbsoluteDeviation", "RobustMeanAbsoluteDeviation",
19
+ )
20
+ GLCM = (
21
+ "Autocorrelation", "Contrast", "Correlation", "DifferenceAverage",
22
+ "JointEnergy", "JointEntropy", "Id", "Idm", "JointAverage", "ClusterProminence",
23
+ "ClusterShade", "ClusterTendency", "DifferenceEntropy", "DifferenceVariance", "Imc1",
24
+ "Imc2", "Idmn", "Idn", "InverseVariance", "MaximumProbability", "SumAverage",
25
+ "SumEntropy", "SumSquares", "MCC",
26
+ )
27
+ GLDM = (
28
+ "SmallDependenceEmphasis", "LargeDependenceEmphasis", "GrayLevelNonUniformity",
29
+ "DependenceNonUniformity", "DependenceNonUniformityNormalized", "GrayLevelVariance",
30
+ "DependenceVariance", "DependenceEntropy", "LowGrayLevelEmphasis", "HighGrayLevelEmphasis",
31
+ "SmallDependenceLowGrayLevelEmphasis", "SmallDependenceHighGrayLevelEmphasis",
32
+ "LargeDependenceLowGrayLevelEmphasis", "LargeDependenceHighGrayLevelEmphasis",
33
+ )
34
+ NGTDM = ("Coarseness", "Contrast", "Busyness", "Complexity", "Strength")
35
+ GLRLM = (
36
+ "ShortRunEmphasis", "LongRunEmphasis", "GrayLevelNonUniformity",
37
+ "GrayLevelNonUniformityNormalized", "RunLengthNonUniformity",
38
+ "RunLengthNonUniformityNormalized", "RunPercentage", "GrayLevelVariance", "RunVariance",
39
+ "RunEntropy", "LowGrayLevelRunEmphasis", "HighGrayLevelRunEmphasis",
40
+ "ShortRunLowGrayLevelEmphasis", "ShortRunHighGrayLevelEmphasis",
41
+ "LongRunLowGrayLevelEmphasis", "LongRunHighGrayLevelEmphasis",
42
+ )
43
+ GLSZM = (
44
+ "SmallAreaEmphasis", "LargeAreaEmphasis", "GrayLevelNonUniformity",
45
+ "GrayLevelNonUniformityNormalized", "SizeZoneNonUniformity",
46
+ "SizeZoneNonUniformityNormalized", "ZonePercentage", "GrayLevelVariance", "ZoneVariance",
47
+ "ZoneEntropy", "LowGrayLevelZoneEmphasis", "HighGrayLevelZoneEmphasis",
48
+ "SmallAreaLowGrayLevelEmphasis", "SmallAreaHighGrayLevelEmphasis",
49
+ "LargeAreaLowGrayLevelEmphasis", "LargeAreaHighGrayLevelEmphasis",
50
+ )
51
+ SHAPE = ("MeshVolume", "VoxelVolume", "SurfaceArea", "SurfaceVolumeRatio", "Sphericity",
52
+ "Maximum3DDiameter", "Maximum2DDiameterSlice", "Maximum2DDiameterColumn",
53
+ "Maximum2DDiameterRow", "MajorAxisLength", "MinorAxisLength", "LeastAxisLength",
54
+ "Elongation", "Flatness")
55
+ SHAPE2D = ("MeshSurface", "PixelSurface", "Perimeter", "PerimeterSurfaceRatio", "Sphericity",
56
+ "MaximumDiameter", "MajorAxisLength", "MinorAxisLength", "Elongation")
57
+ FAMILIES = {"firstorder": FIRST_ORDER, "glcm": GLCM, "gldm": GLDM, "ngtdm": NGTDM,
58
+ "glrlm": GLRLM, "glszm": GLSZM, "shape": SHAPE, "shape2D": SHAPE2D}
59
+ # Selectable feature names, "<family>_<Name>"; result keys add an image-type prefix.
60
+ FEATURES = tuple(f"{family}_{name}" for family, names in FAMILIES.items() for name in names)
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class Settings:
65
+ """Feature selection and preprocessing options for one extraction.
66
+
67
+ Field names correspond to PyRadiomics settings (``bin_width`` is ``binWidth``,
68
+ ``gldm_alpha`` is ``gldm_a``, and so on). Intensities are in the image's own units
69
+ (HU for CT). ``distances`` and ``kernel_radius`` are in voxels, not physical units.
70
+
71
+ Attributes:
72
+ gldm_alpha: A neighbor counts as dependent when its gray level differs from the
73
+ center by at most this many bins (GLDM alpha).
74
+ bin_width: Fixed bin width. Bin edges are multiples of ``bin_width`` counted from
75
+ zero, not from the ROI minimum, as in PyRadiomics (this differs from the
76
+ IBSI fixed-bin-size definition). Ignored, and not validated, when
77
+ ``bin_count`` is set.
78
+ bin_count: Fixed number of bins between the sampled minimum and maximum. Takes
79
+ precedence over ``bin_width``; must not exceed ``max_gray_levels``.
80
+ normalize: Normalize the whole image (not just the ROI) to zero mean and unit
81
+ sample standard deviation, before resegmentation and binning.
82
+ normalize_scale: Factor applied after normalization (and after outlier clipping).
83
+ remove_outliers: Requires ``normalize``. Clip normalized values to plus or minus
84
+ this many standard deviations before scaling.
85
+ resegment_range: One or two thresholds, inclusive. One keeps voxels at or above
86
+ it; two keep voxels between them. Applied to the label's voxels after
87
+ normalization.
88
+ resegment_mode: ``"absolute"`` uses the thresholds as given, ``"relative"``
89
+ multiplies them by the maximum ROI intensity, and ``"sigma"`` uses
90
+ ``mean + threshold * std`` of the ROI intensities.
91
+ resegment_shape: If true, shape features use the resegmented mask; otherwise
92
+ they use the original label mask.
93
+ label: Mask value selecting the ROI; the mask is compared by equality.
94
+ distances: GLCM, GLDM and NGTDM neighbor distances. Each is a Chebyshev
95
+ (maximum-norm) distance in voxels, so distance 1 in 3-D gives 26 neighbors.
96
+ GLRLM and GLSZM always use distance 1, as PyRadiomics does.
97
+ symmetrical_glcm: Add each GLCM to its transpose before computing features.
98
+ force2d_axis: If set, neighbor directions stay in the plane perpendicular to this
99
+ NumPy axis (PyRadiomics ``force2D`` with ``force2Ddimension``), and
100
+ ``shape2D`` on a 3-D array uses the single slice along it.
101
+ masked_kernel: Voxel mode only. If true, local neighborhoods sample only ROI
102
+ voxels. If false, they sample the whole image, and gray-level discretization
103
+ also uses the whole image; output centers still lie in the ROI.
104
+ kernel_radius: Voxel mode only. Half-width in voxels of the cubic neighborhood
105
+ around each center (flattened along ``force2d_axis`` if set).
106
+ voxel_array_shift: Added to intensities before Energy, TotalEnergy and
107
+ RootMeanSquared only.
108
+ scratch_mib: Working-memory budget in MiB used to size voxel batches and shape-mesh
109
+ chunks. It is an estimate, not an operating-system limit.
110
+ max_gray_levels: Upper bound on the number of gray levels; larger values raise.
111
+ features: Names from ``FEATURES``. The default is every non-shape feature (93);
112
+ shape features must be listed explicitly.
113
+ """
114
+
115
+ gldm_alpha: int = 0
116
+ bin_width: float = 25.0
117
+ bin_count: int | None = None
118
+ normalize: bool = False
119
+ normalize_scale: float = 1.0
120
+ remove_outliers: float | None = None
121
+ resegment_range: tuple[float, ...] | None = None
122
+ resegment_mode: Literal["absolute", "relative", "sigma"] = "absolute"
123
+ resegment_shape: bool = False
124
+ label: int = 1
125
+ distances: tuple[int, ...] = (1,)
126
+ symmetrical_glcm: bool = True
127
+ force2d_axis: int | None = None
128
+ masked_kernel: bool = True
129
+ kernel_radius: int = 1
130
+ voxel_array_shift: float = 0.0
131
+ scratch_mib: int = 64
132
+ max_gray_levels: int = 256
133
+ features: tuple[str, ...] = tuple(n for n in FEATURES if not n.startswith("shape"))
134
+
135
+ def validate(self, ndim: int) -> None:
136
+ """Raise ``ValueError`` if any field is unsupported.
137
+
138
+ Args:
139
+ ndim: Dimensionality of the image the settings will be applied to; only used
140
+ to check that ``force2d_axis`` indexes an array axis.
141
+ """
142
+ self._validate_preprocessing()
143
+ if type(self.gldm_alpha) is not int or self.gldm_alpha < 0:
144
+ raise ValueError("gldm_alpha must be a nonnegative integer")
145
+ if self.bin_count is None and (not isfinite(self.bin_width) or self.bin_width <= 0):
146
+ raise ValueError("bin_width must be finite and positive")
147
+ if type(self.label) is not int or self.label <= 0:
148
+ raise ValueError("label must be a positive integer")
149
+ if not self.distances or any(type(x) is not int or x < 1 for x in self.distances):
150
+ raise ValueError("distances must contain positive integers")
151
+ if len(set(self.distances)) != len(self.distances):
152
+ raise ValueError("distances must be unique")
153
+ if self.force2d_axis is not None and self.force2d_axis not in range(ndim):
154
+ raise ValueError("force2d_axis must index a NumPy image axis")
155
+ if type(self.masked_kernel) is not bool or type(self.symmetrical_glcm) is not bool:
156
+ raise ValueError("masked_kernel and symmetrical_glcm must be booleans")
157
+ for name in ("kernel_radius", "scratch_mib", "max_gray_levels"):
158
+ if type(getattr(self, name)) is not int or getattr(self, name) < 1:
159
+ raise ValueError(f"{name} must be a positive integer")
160
+ if not isfinite(self.voxel_array_shift):
161
+ raise ValueError("voxel_array_shift must be finite")
162
+ if not self.features or len(set(self.features)) != len(self.features):
163
+ raise ValueError("features must be a nonempty unique selection")
164
+ unsupported = set(self.features) - set(FEATURES)
165
+ if unsupported:
166
+ raise ValueError(f"unsupported features: {sorted(unsupported)}")
167
+
168
+ def _validate_preprocessing(self) -> None:
169
+ if self.bin_count is not None and (
170
+ type(self.bin_count) is not int or not 1 <= self.bin_count <= self.max_gray_levels
171
+ ):
172
+ raise ValueError("bin_count must be a positive integer <= max_gray_levels")
173
+ if type(self.normalize) is not bool or type(self.resegment_shape) is not bool:
174
+ raise ValueError("normalize and resegment_shape must be booleans")
175
+ if not isfinite(self.normalize_scale) or self.normalize_scale <= 0:
176
+ raise ValueError("normalize_scale must be finite and positive")
177
+ if self.remove_outliers is not None and (
178
+ not isfinite(self.remove_outliers) or self.remove_outliers <= 0 or not self.normalize
179
+ ):
180
+ raise ValueError("remove_outliers requires normalization and a finite positive value")
181
+ if self.resegment_mode not in {"absolute", "relative", "sigma"}:
182
+ raise ValueError("resegment_mode must be absolute, relative, or sigma")
183
+ if self.resegment_range is not None and (
184
+ len(self.resegment_range) not in (1, 2)
185
+ or any(not isfinite(value) for value in self.resegment_range)
186
+ ):
187
+ raise ValueError("resegment_range must contain one or two finite thresholds")
@@ -0,0 +1,105 @@
1
+ """Features computed from count matrices: GLDM, GLRLM, GLSZM and NGTDM.
2
+
3
+ Inputs are integer counts held in float32 arrays with a leading batch axis (one row per
4
+ ROI or per voxel center). The formulas follow the PyRadiomics feature classes of the
5
+ same names; the tests compare them with PyRadiomics at rtol=atol=1e-4.
6
+ """
7
+
8
+ from mlxradiomics._runtime import mx
9
+ from mlxradiomics.reduction import sum_rows
10
+
11
+ # Same epsilon as PyRadiomics (numpy.spacing(1)); keeps log2 finite where p == 0.
12
+ EPS = 2.220446049250313e-16
13
+
14
+
15
+ def size_statistics(counts: mx.array, family: str) -> dict[str, mx.array]:
16
+ """Compute GLDM, GLRLM or GLSZM features for a batch of count matrices.
17
+
18
+ Args:
19
+ counts: Array ``(batch, Ng, Ns)``. Axis 1 is the gray level ``i = 1..Ng`` and axis
20
+ 2 the dependence size, run length or zone size ``j = 1..Ns``. Gray levels keep
21
+ their original indices, so levels absent from a row stay in the matrix with
22
+ zero counts.
23
+ family: ``"gldm"``, ``"glrlm"`` or ``"glszm"``. Selects the feature names, for
24
+ example ``SmallDependenceEmphasis``, ``ShortRunEmphasis`` or
25
+ ``SmallAreaEmphasis``.
26
+
27
+ Returns:
28
+ Feature name without the family prefix mapped to a ``(batch,)`` float32 array.
29
+ ``RunPercentage`` (GLRLM) and ``ZonePercentage`` (GLSZM) are included only for
30
+ those families.
31
+ """
32
+ counts = counts.astype(mx.float32)
33
+ total = sum_rows(counts)
34
+ p = counts / mx.maximum(total[:, None, None], 1)
35
+ i = mx.arange(1, counts.shape[1] + 1, dtype=mx.float32)[None, :, None]
36
+ j = mx.arange(1, counts.shape[2] + 1, dtype=mx.float32)[None, None, :]
37
+ pg, ps = mx.sum(counts, axis=2), mx.sum(counts, axis=1)
38
+ mean_i, mean_j = sum_rows(p * i), sum_rows(p * j)
39
+ size, small, large, low, high, entropy, variance, percentage = {
40
+ "gldm": ("Dependence", "SmallDependence", "LargeDependence", "LowGrayLevel",
41
+ "HighGrayLevel", "DependenceEntropy", "DependenceVariance", None),
42
+ "glrlm": ("RunLength", "ShortRun", "LongRun", "LowGrayLevelRun",
43
+ "HighGrayLevelRun", "RunEntropy", "RunVariance", "RunPercentage"),
44
+ "glszm": ("SizeZone", "SmallArea", "LargeArea", "LowGrayLevelZone",
45
+ "HighGrayLevelZone", "ZoneEntropy", "ZoneVariance", "ZonePercentage"),
46
+ }[family]
47
+ features = {
48
+ small + "Emphasis": sum_rows(p / j**2),
49
+ large + "Emphasis": sum_rows(p * j**2),
50
+ "GrayLevelNonUniformity": sum_rows(pg**2) / total,
51
+ "GrayLevelNonUniformityNormalized": sum_rows(pg**2) / total**2,
52
+ size + "NonUniformity": sum_rows(ps**2) / total,
53
+ size + "NonUniformityNormalized": sum_rows(ps**2) / total**2,
54
+ "GrayLevelVariance": sum_rows(p * (i - mean_i[:, None, None])**2),
55
+ variance: sum_rows(p * (j - mean_j[:, None, None])**2),
56
+ entropy: -sum_rows(p * mx.log2(p + EPS)),
57
+ low + "Emphasis": sum_rows(p / i**2),
58
+ high + "Emphasis": sum_rows(p * i**2),
59
+ small + "LowGrayLevelEmphasis": sum_rows(p / (i**2 * j**2)),
60
+ small + "HighGrayLevelEmphasis": sum_rows(p * i**2 / j**2),
61
+ large + "LowGrayLevelEmphasis": sum_rows(p * j**2 / i**2),
62
+ large + "HighGrayLevelEmphasis": sum_rows(p * i**2 * j**2),
63
+ }
64
+ if percentage is not None:
65
+ features[percentage] = total / sum_rows(counts * j)
66
+ return features
67
+
68
+
69
+ def ngtdm_statistics(counts: mx.array) -> dict[str, mx.array]:
70
+ """Compute NGTDM features for a batch of per-gray-level neighborhood statistics.
71
+
72
+ Args:
73
+ counts: Array ``(batch, Ng, 2)``. ``[..., 0]`` is the number of eligible center
74
+ voxels at gray level ``i`` (in the mask with at least one valid neighbor).
75
+ ``[..., 1]`` is the sum, over those voxels, of the absolute difference between
76
+ ``i`` and the mean gray level of their valid neighbors.
77
+
78
+ Returns:
79
+ Feature name without the ``ngtdm_`` prefix mapped to a ``(batch,)`` float32 array.
80
+ Coarseness is 1e6 when its denominator is zero; Contrast is 0 with a single gray
81
+ level present; Busyness and Strength are 0 when their denominators are zero.
82
+ """
83
+ n, s = counts[:, :, 0], counts[:, :, 1]
84
+ total = sum_rows(n)
85
+ p = n / mx.maximum(total[:, None], 1)
86
+ levels = mx.arange(1, n.shape[1] + 1, dtype=mx.float32)[None, :]
87
+ present = n > 0
88
+ ng = sum_rows(present.astype(mx.int32))
89
+ pi, pj = p[:, :, None], p[:, None, :]
90
+ si, sj = s[:, :, None], s[:, None, :]
91
+ i, j = levels[:, :, None], levels[:, None, :]
92
+ pairs = present[:, :, None] & present[:, None, :]
93
+ ps = sum_rows(p * s)
94
+ busyness = sum_rows(mx.where(pairs, mx.abs(i * pi - j * pj), 0))
95
+ contrast = sum_rows(pi * pj * (i - j)**2) * sum_rows(s) / mx.maximum(total, 1)
96
+ strength = sum_rows(mx.where(pairs, (pi + pj) * (i - j)**2, 0))
97
+ complexity = sum_rows(mx.where(pairs, mx.abs(i - j) * (pi * si + pj * sj)
98
+ / mx.maximum(pi + pj, EPS), 0))
99
+ return {
100
+ "Coarseness": mx.where(ps == 0, 1e6, 1 / ps),
101
+ "Contrast": mx.where(ng > 1, contrast / mx.maximum(ng * (ng - 1), 1), 0),
102
+ "Busyness": mx.where(busyness > 0, ps / busyness, 0),
103
+ "Complexity": complexity / mx.maximum(total, 1),
104
+ "Strength": mx.where(sum_rows(s) > 0, strength / sum_rows(s), 0),
105
+ }