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.
- mlxradiomics/__init__.py +81 -0
- mlxradiomics/_runtime.py +28 -0
- mlxradiomics/_typing.py +173 -0
- mlxradiomics/config.py +187 -0
- mlxradiomics/distributions.py +105 -0
- mlxradiomics/eigen.py +121 -0
- mlxradiomics/engine.py +515 -0
- mlxradiomics/fixtures.py +165 -0
- mlxradiomics/histogram.py +67 -0
- mlxradiomics/io.py +366 -0
- mlxradiomics/lbp.py +133 -0
- mlxradiomics/lbp3d.py +143 -0
- mlxradiomics/medmlx.py +262 -0
- mlxradiomics/mesh_tables.json +2564 -0
- mlxradiomics/pipeline.py +135 -0
- mlxradiomics/precision.py +101 -0
- mlxradiomics/preprocessing.py +205 -0
- mlxradiomics/qualification.py +205 -0
- mlxradiomics/recursive_gaussian.py +190 -0
- mlxradiomics/reduction.py +68 -0
- mlxradiomics/reference.py +153 -0
- mlxradiomics/shape.py +267 -0
- mlxradiomics/spatial.py +282 -0
- mlxradiomics/spline.py +127 -0
- mlxradiomics/statistics.py +201 -0
- mlxradiomics/texture.py +262 -0
- mlxradiomics/transforms.py +322 -0
- mlxradiomics/wavelet_tables.json +1 -0
- mlxradiomics-0.1.0.dev3.dist-info/METADATA +66 -0
- mlxradiomics-0.1.0.dev3.dist-info/RECORD +34 -0
- mlxradiomics-0.1.0.dev3.dist-info/WHEEL +4 -0
- mlxradiomics-0.1.0.dev3.dist-info/entry_points.txt +5 -0
- mlxradiomics-0.1.0.dev3.dist-info/licenses/LICENSE +202 -0
- mlxradiomics-0.1.0.dev3.dist-info/licenses/THIRD_PARTY_NOTICES.md +57 -0
mlxradiomics/__init__.py
ADDED
|
@@ -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
|
mlxradiomics/_runtime.py
ADDED
|
@@ -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
|
mlxradiomics/_typing.py
ADDED
|
@@ -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
|
+
}
|