mrax 0.1.0__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.
Files changed (67) hide show
  1. mrax/__init__.py +3 -0
  2. mrax/analysis/__init__.py +107 -0
  3. mrax/analysis/dce.py +273 -0
  4. mrax/analysis/dti.py +401 -0
  5. mrax/analysis/group.py +288 -0
  6. mrax/analysis/roi.py +279 -0
  7. mrax/analysis/saturation_transfer.py +218 -0
  8. mrax/analysis/stats.py +155 -0
  9. mrax/analysis/timecourse.py +134 -0
  10. mrax/analysis/timecourse_processing.py +391 -0
  11. mrax/core/__init__.py +22 -0
  12. mrax/core/batch.py +109 -0
  13. mrax/core/fit_sanity.py +480 -0
  14. mrax/core/loss_builder.py +921 -0
  15. mrax/core/runtime.py +24 -0
  16. mrax/core/study_runner.py +165 -0
  17. mrax/core/synthetic.py +139 -0
  18. mrax/data_struct/__init__.py +46 -0
  19. mrax/data_struct/dataset.py +36 -0
  20. mrax/data_struct/dimensions.py +485 -0
  21. mrax/data_struct/experiment.py +237 -0
  22. mrax/data_struct/joint.py +426 -0
  23. mrax/data_struct/model.py +333 -0
  24. mrax/data_struct/quantity.py +59 -0
  25. mrax/data_struct/storage.py +1032 -0
  26. mrax/data_struct/study.py +209 -0
  27. mrax/gui/__init__.py +39 -0
  28. mrax/gui/_deps.py +73 -0
  29. mrax/gui/app.py +13591 -0
  30. mrax/gui/masks.py +602 -0
  31. mrax/gui/models.py +288 -0
  32. mrax/gui/registration.py +631 -0
  33. mrax/gui/session.py +2680 -0
  34. mrax/io/__init__.py +42 -0
  35. mrax/io/bruker_import.py +6713 -0
  36. mrax/io/dipy.py +61 -0
  37. mrax/io/import_definitions.py +1005 -0
  38. mrax/io/nifti.py +281 -0
  39. mrax/io/pulseq_import.py +918 -0
  40. mrax/io/study_selection.py +164 -0
  41. mrax/library/__init__.py +45 -0
  42. mrax/library/default_experiments.py +158 -0
  43. mrax/library/default_models.py +1678 -0
  44. mrax/library/parameter_library.py +191 -0
  45. mrax/library/parameter_library.toml +159 -0
  46. mrax/opt/__init__.py +38 -0
  47. mrax/opt/ops.py +88 -0
  48. mrax/opt/optimizers.py +208 -0
  49. mrax/opt/regularizers.py +329 -0
  50. mrax/opt/wavelets.py +53 -0
  51. mrax/preprocessing/__init__.py +62 -0
  52. mrax/preprocessing/denoise.py +436 -0
  53. mrax/preprocessing/drift.py +898 -0
  54. mrax/preprocessing/motion.py +637 -0
  55. mrax/reco/__init__.py +87 -0
  56. mrax/reco/jax_fft.py +40 -0
  57. mrax/reco/observables.py +251 -0
  58. mrax/reco/pipeline.py +380 -0
  59. mrax/reco/processing.py +280 -0
  60. mrax/reco/utils.py +75 -0
  61. mrax/reco/variational.py +1736 -0
  62. mrax-0.1.0.dist-info/METADATA +177 -0
  63. mrax-0.1.0.dist-info/RECORD +67 -0
  64. mrax-0.1.0.dist-info/WHEEL +5 -0
  65. mrax-0.1.0.dist-info/entry_points.txt +2 -0
  66. mrax-0.1.0.dist-info/licenses/LICENSE +21 -0
  67. mrax-0.1.0.dist-info/top_level.txt +1 -0
mrax/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """MRax package."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,107 @@
1
+ """Post-fitting and reconstruction analysis helpers."""
2
+
3
+ from .dce import (
4
+ DCEAnalysisResult,
5
+ analyze_dce_series,
6
+ infer_dce_times_seconds,
7
+ replace_isolated_spikes,
8
+ spgr_signal_ratio_to_concentration,
9
+ )
10
+ from .dti import (
11
+ DTIMaps,
12
+ compute_dti_maps,
13
+ dti_adc_fa,
14
+ dti_eigendecomposition,
15
+ dti_metrics_from_eigenvalues,
16
+ tensor_from_fields,
17
+ )
18
+ from .group import (
19
+ GroupSpec,
20
+ analyze_groups,
21
+ collect_subject_roi_stats,
22
+ compare_group_stats,
23
+ discover_study_manifests,
24
+ resolve_groups,
25
+ summarize_group_stats,
26
+ )
27
+ from .roi import (
28
+ ROI,
29
+ broadcast_mask,
30
+ collect_roi_stats,
31
+ extract_roi_values,
32
+ match_spatial_mask,
33
+ normalize_stats,
34
+ roi_stats,
35
+ summarize_values,
36
+ )
37
+ from .saturation_transfer import (
38
+ ZSpectraResult,
39
+ choose_z_reference_scan,
40
+ compute_z_spectra,
41
+ dataset_with_z_spectra,
42
+ experiment_with_ab_mt_z_prev,
43
+ get_z_spectra,
44
+ prepare_ab_mt_z_spectra,
45
+ )
46
+ from .stats import cohens_d, describe_values, fdr_bh, hedges_g, nonparametric_test, ttest
47
+ from .timecourse import extract_timecourse, extract_timecourse_from_experiment
48
+ from .timecourse_processing import (
49
+ apply_baseline,
50
+ apply_dilution_correction,
51
+ estimate_timecourse_baseline,
52
+ interpolate_timecourses,
53
+ mean_timecourses_by_label,
54
+ nan_gaussian_filter,
55
+ stack_timecourses_by_group,
56
+ )
57
+
58
+ __all__ = [
59
+ "GroupSpec",
60
+ "ROI",
61
+ "ZSpectraResult",
62
+ "analyze_groups",
63
+ "broadcast_mask",
64
+ "choose_z_reference_scan",
65
+ "cohens_d",
66
+ "collect_roi_stats",
67
+ "collect_subject_roi_stats",
68
+ "compare_group_stats",
69
+ "compute_z_spectra",
70
+ "describe_values",
71
+ "dataset_with_z_spectra",
72
+ "discover_study_manifests",
73
+ "DTIMaps",
74
+ "DCEAnalysisResult",
75
+ "apply_baseline",
76
+ "apply_dilution_correction",
77
+ "analyze_dce_series",
78
+ "estimate_timecourse_baseline",
79
+ "extract_roi_values",
80
+ "extract_timecourse",
81
+ "extract_timecourse_from_experiment",
82
+ "fdr_bh",
83
+ "experiment_with_ab_mt_z_prev",
84
+ "get_z_spectra",
85
+ "hedges_g",
86
+ "interpolate_timecourses",
87
+ "infer_dce_times_seconds",
88
+ "mean_timecourses_by_label",
89
+ "match_spatial_mask",
90
+ "nan_gaussian_filter",
91
+ "nonparametric_test",
92
+ "normalize_stats",
93
+ "prepare_ab_mt_z_spectra",
94
+ "resolve_groups",
95
+ "roi_stats",
96
+ "compute_dti_maps",
97
+ "dti_adc_fa",
98
+ "dti_eigendecomposition",
99
+ "dti_metrics_from_eigenvalues",
100
+ "tensor_from_fields",
101
+ "stack_timecourses_by_group",
102
+ "replace_isolated_spikes",
103
+ "spgr_signal_ratio_to_concentration",
104
+ "summarize_group_stats",
105
+ "summarize_values",
106
+ "ttest",
107
+ ]
mrax/analysis/dce.py ADDED
@@ -0,0 +1,273 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Sequence
5
+
6
+ import numpy as np
7
+
8
+ from mrax.analysis.roi import match_spatial_mask
9
+ from mrax.analysis.timecourse_processing import mean_timecourses_by_label, nan_gaussian_filter
10
+ from mrax.data_struct.quantity import Quantity
11
+
12
+ DEFAULT_GADOLINIUM_RELAXIVITY_MMM_MS = 4.1 / 1000.0
13
+ DEFAULT_DCE_TR_MS = 12.5
14
+ DEFAULT_DCE_FLIP_ANGLE_DEG = 15.0
15
+
16
+
17
+ @dataclass
18
+ class DCEAnalysisResult:
19
+ """Processed DCE concentration series and optional ROI summaries."""
20
+
21
+ concentration_mM: np.ndarray
22
+ signal_ratio: np.ndarray
23
+ reference_image: np.ndarray
24
+ times_seconds: np.ndarray
25
+ frame_time_seconds: float
26
+ baseline_frames: int
27
+ timecourses_mM: np.ndarray | None = None
28
+ labels: list[int] | None = None
29
+
30
+
31
+ def _as_array(value: object) -> np.ndarray:
32
+ if isinstance(value, Quantity):
33
+ return np.asarray(value.values)
34
+ return np.asarray(value)
35
+
36
+
37
+ def _first_finite_scalar(value: object | None) -> float | None:
38
+ if value is None:
39
+ return None
40
+ arr = _as_array(value).astype(float, copy=False).ravel()
41
+ arr = arr[np.isfinite(arr)]
42
+ if arr.size == 0:
43
+ return None
44
+ return float(arr[0])
45
+
46
+
47
+ def infer_dce_times_seconds(
48
+ frame_count: int,
49
+ *,
50
+ total_scan_time_ms: float | None = None,
51
+ frame_time_ms: float | None = None,
52
+ centered: bool = True,
53
+ ) -> tuple[np.ndarray, float]:
54
+ """Infer DCE frame times in seconds from total scan time or frame spacing."""
55
+
56
+ if frame_count <= 0:
57
+ raise ValueError("frame_count must be positive")
58
+
59
+ dt_seconds: float | None = None
60
+ if total_scan_time_ms is not None and np.isfinite(total_scan_time_ms) and total_scan_time_ms > 0:
61
+ dt_seconds = float(total_scan_time_ms) * 1e-3 / float(frame_count)
62
+ elif frame_time_ms is not None and np.isfinite(frame_time_ms) and frame_time_ms > 0:
63
+ dt_seconds = float(frame_time_ms) * 1e-3
64
+ else:
65
+ dt_seconds = 1.0
66
+
67
+ if centered:
68
+ times = (np.arange(frame_count, dtype=float) + 0.5) * dt_seconds
69
+ else:
70
+ times = np.arange(frame_count, dtype=float) * dt_seconds
71
+ return times, float(dt_seconds)
72
+
73
+
74
+ def replace_isolated_spikes(
75
+ image_series: np.ndarray,
76
+ *,
77
+ mask: np.ndarray | None = None,
78
+ threshold: float = 1.05,
79
+ ) -> np.ndarray:
80
+ """Replace isolated high-intensity frames using neighboring frames."""
81
+
82
+ series = np.asarray(image_series, dtype=float)
83
+ if series.ndim < 2 or series.shape[0] < 3 or threshold <= 1.0:
84
+ return series.copy()
85
+
86
+ mask_arr = None
87
+ if mask is not None:
88
+ mask_arr = np.asarray(mask, dtype=bool)
89
+ if mask_arr.shape != series.shape[1:]:
90
+ mask_arr = match_spatial_mask(mask_arr, series.shape[1:])
91
+
92
+ reduced = series.reshape((series.shape[0], -1))
93
+ if mask_arr is not None:
94
+ reduced = series[:, mask_arr]
95
+ frame_means = np.nanmean(reduced, axis=1)
96
+
97
+ out = series.copy()
98
+ for idx in range(1, series.shape[0] - 1):
99
+ cur = float(frame_means[idx])
100
+ prev = float(frame_means[idx - 1])
101
+ nxt = float(frame_means[idx + 1])
102
+ if not np.isfinite(cur):
103
+ continue
104
+ high_prev = np.isfinite(prev) and cur > prev * threshold
105
+ high_next = np.isfinite(nxt) and cur > nxt * threshold
106
+ if high_prev and high_next:
107
+ out[idx] = 0.5 * (series[idx - 1] + series[idx + 1])
108
+ elif high_prev:
109
+ out[idx] = series[idx + 1]
110
+ elif high_next:
111
+ out[idx] = series[idx - 1]
112
+ return out
113
+
114
+
115
+ def spgr_signal_ratio_to_concentration(
116
+ signal_ratio: np.ndarray,
117
+ t1_map_ms: np.ndarray,
118
+ *,
119
+ repetition_time_ms: float = DEFAULT_DCE_TR_MS,
120
+ flip_angle_deg: float = DEFAULT_DCE_FLIP_ANGLE_DEG,
121
+ relaxivity_mM_ms: float = DEFAULT_GADOLINIUM_RELAXIVITY_MMM_MS,
122
+ eps: float = 1e-8,
123
+ ) -> np.ndarray:
124
+ """Convert baseline-normalized SPGR signal ratios to CA concentration in mM."""
125
+
126
+ ratio = np.asarray(signal_ratio, dtype=float)
127
+ t1_ms = np.asarray(t1_map_ms, dtype=float)
128
+ if ratio.ndim != t1_ms.ndim + 1:
129
+ raise ValueError("signal_ratio must be time-first and t1_map_ms must match its spatial shape")
130
+
131
+ theta = np.deg2rad(float(flip_angle_deg))
132
+ tr_ms = float(repetition_time_ms)
133
+ r1 = float(relaxivity_mM_ms)
134
+
135
+ valid_t1 = np.isfinite(t1_ms) & (t1_ms > eps)
136
+ t1_safe = np.where(valid_t1, t1_ms, np.nan)
137
+
138
+ e10 = np.exp(-tr_ms / t1_safe)
139
+ s_star = (1.0 - e10) / (1.0 - np.cos(theta) * e10)
140
+
141
+ numer = 1.0 - np.cos(theta) * s_star[None, ...] * ratio
142
+ denom = 1.0 - s_star[None, ...] * ratio
143
+ numer = np.where(np.isfinite(numer), np.clip(numer, eps, None), np.nan)
144
+ denom = np.where(np.isfinite(denom), np.clip(denom, eps, None), np.nan)
145
+
146
+ with np.errstate(divide="ignore", invalid="ignore"):
147
+ r1_t = np.log(numer / denom) / tr_ms
148
+ concentration = (r1_t - (1.0 / t1_safe)[None, ...]) / r1
149
+ concentration[:, ~valid_t1] = np.nan
150
+ return concentration
151
+
152
+
153
+ def analyze_dce_series(
154
+ image_series: np.ndarray,
155
+ t1_map_ms: np.ndarray | Quantity,
156
+ *,
157
+ label_mask: np.ndarray | None = None,
158
+ mask: np.ndarray | None = None,
159
+ times_seconds: Sequence[float] | None = None,
160
+ total_scan_time_ms: float | None = None,
161
+ frame_time_ms: float | None = None,
162
+ repetition_time_ms: float = DEFAULT_DCE_TR_MS,
163
+ flip_angle_deg: float = DEFAULT_DCE_FLIP_ANGLE_DEG,
164
+ relaxivity_mM_ms: float = DEFAULT_GADOLINIUM_RELAXIVITY_MMM_MS,
165
+ infusion_time_seconds: float | None = None,
166
+ baseline_seconds: float | None = None,
167
+ baseline_frames: int | None = None,
168
+ min_baseline_frames: int = 5,
169
+ smooth_sigma_frames: float = 2.0,
170
+ spike_threshold: float = 1.05,
171
+ ) -> DCEAnalysisResult:
172
+ """Preprocess a DCE FLASH series and compute CA concentration maps/timecourses."""
173
+
174
+ series = np.asarray(image_series, dtype=float)
175
+ if series.ndim < 2:
176
+ raise ValueError("image_series must be time-first with at least one spatial dimension")
177
+
178
+ spatial_shape = series.shape[1:]
179
+ t1_arr = _as_array(t1_map_ms).astype(float, copy=False)
180
+ if t1_arr.shape != spatial_shape:
181
+ t1_arr = match_spatial_mask(t1_arr, spatial_shape)
182
+
183
+ mask_arr = None
184
+ if label_mask is not None:
185
+ label_mask = match_spatial_mask(np.asarray(label_mask), spatial_shape)
186
+ if mask is None:
187
+ mask = np.asarray(label_mask) != 0
188
+ if mask is not None:
189
+ mask_arr = match_spatial_mask(np.asarray(mask, dtype=bool), spatial_shape)
190
+ else:
191
+ mask_arr = np.isfinite(t1_arr) & (t1_arr > 0)
192
+
193
+ if times_seconds is None:
194
+ times_arr, frame_time_seconds = infer_dce_times_seconds(
195
+ series.shape[0],
196
+ total_scan_time_ms=total_scan_time_ms,
197
+ frame_time_ms=frame_time_ms,
198
+ )
199
+ else:
200
+ times_arr = np.asarray(times_seconds, dtype=float)
201
+ if times_arr.ndim != 1 or times_arr.shape[0] != series.shape[0]:
202
+ raise ValueError("times_seconds must be a 1D array matching the number of frames")
203
+ if times_arr.shape[0] >= 2:
204
+ frame_time_seconds = float(np.nanmedian(np.diff(times_arr)))
205
+ else:
206
+ frame_time_seconds = float((frame_time_ms or 1000.0) * 1e-3)
207
+
208
+ if baseline_seconds is None and infusion_time_seconds is not None:
209
+ baseline_seconds = float(infusion_time_seconds)
210
+ if baseline_frames is None:
211
+ if baseline_seconds is not None and np.isfinite(baseline_seconds) and baseline_seconds > 0:
212
+ baseline_sel = times_arr <= float(baseline_seconds)
213
+ baseline_frames = int(np.sum(baseline_sel))
214
+ else:
215
+ baseline_frames = 0
216
+ baseline_frames = max(int(baseline_frames), int(min_baseline_frames))
217
+ baseline_frames = min(baseline_frames, int(series.shape[0]))
218
+ baseline_slice = slice(0, baseline_frames)
219
+
220
+ cleaned = replace_isolated_spikes(series, mask=mask_arr, threshold=spike_threshold)
221
+ reference_image = np.nanmean(cleaned[baseline_slice], axis=0)
222
+
223
+ baseline = np.nanmean(cleaned[baseline_slice], axis=0)
224
+ baseline = np.where(np.isfinite(baseline) & (baseline > 0), baseline, np.nan)
225
+ with np.errstate(divide="ignore", invalid="ignore"):
226
+ signal_ratio = cleaned / baseline[None, ...]
227
+ signal_ratio = np.where(np.isfinite(signal_ratio), signal_ratio, np.nan)
228
+
229
+ if smooth_sigma_frames and smooth_sigma_frames > 0:
230
+ sigma = (float(smooth_sigma_frames),) + (0.0,) * (signal_ratio.ndim - 1)
231
+ signal_ratio = nan_gaussian_filter(signal_ratio, sigma=sigma)
232
+
233
+ concentration = spgr_signal_ratio_to_concentration(
234
+ signal_ratio,
235
+ t1_arr,
236
+ repetition_time_ms=repetition_time_ms,
237
+ flip_angle_deg=flip_angle_deg,
238
+ relaxivity_mM_ms=relaxivity_mM_ms,
239
+ )
240
+ concentration = np.where(mask_arr[None, ...], concentration, np.nan)
241
+
242
+ roi_timecourses = None
243
+ labels: list[int] | None = None
244
+ if label_mask is not None:
245
+ roi_timecourses, labels = mean_timecourses_by_label(
246
+ concentration,
247
+ label_mask,
248
+ baseline_frames=0,
249
+ smooth_sigma=0.0,
250
+ )
251
+
252
+ return DCEAnalysisResult(
253
+ concentration_mM=np.asarray(concentration),
254
+ signal_ratio=np.asarray(signal_ratio),
255
+ reference_image=np.asarray(reference_image),
256
+ times_seconds=np.asarray(times_arr),
257
+ frame_time_seconds=float(frame_time_seconds),
258
+ baseline_frames=int(baseline_frames),
259
+ timecourses_mM=None if roi_timecourses is None else np.asarray(roi_timecourses),
260
+ labels=labels,
261
+ )
262
+
263
+
264
+ __all__ = [
265
+ "DCEAnalysisResult",
266
+ "DEFAULT_DCE_FLIP_ANGLE_DEG",
267
+ "DEFAULT_DCE_TR_MS",
268
+ "DEFAULT_GADOLINIUM_RELAXIVITY_MMM_MS",
269
+ "analyze_dce_series",
270
+ "infer_dce_times_seconds",
271
+ "replace_isolated_spikes",
272
+ "spgr_signal_ratio_to_concentration",
273
+ ]