mriforge 0.3.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.
mrif/__init__.py ADDED
@@ -0,0 +1,56 @@
1
+ # File created by: Eisa Hedayati
2
+ # Date: 12/29/2023
3
+ # Description: This file is developed at CMRR
4
+
5
+ from importlib import import_module
6
+
7
+ from ._version import __version__
8
+
9
+ __author__ = "Eisa Hedayati"
10
+
11
+ __all__ = [
12
+ "__version__",
13
+ "utilities",
14
+ "utils",
15
+ "io",
16
+ "visualization",
17
+ "vis",
18
+ "segmentation",
19
+ "seg",
20
+ "quantitative_MRI",
21
+ "qmr",
22
+ "reconstruction",
23
+ "recon",
24
+ "dicom",
25
+ ]
26
+
27
+
28
+ _MODULE_ALIASES = {
29
+ "utilities": ".utilities",
30
+ "utils": ".utilities",
31
+ "io": ".utilities.io",
32
+ "visualization": ".visualization",
33
+ "vis": ".visualization",
34
+ "segmentation": ".segmentation",
35
+ "seg": ".segmentation",
36
+ "quantitative_MRI": ".quantitative_MRI",
37
+ "qmr": ".quantitative_MRI",
38
+ "reconstruction": ".reconstruction",
39
+ "recon": ".reconstruction",
40
+ "dicom": ".dicom",
41
+ }
42
+
43
+
44
+ def __getattr__(name: str):
45
+ if name in _MODULE_ALIASES:
46
+ module = import_module(_MODULE_ALIASES[name], __name__)
47
+ globals()[name] = module
48
+ return module
49
+
50
+ raise AttributeError(
51
+ f"module {__name__!r} has no attribute {name!r}"
52
+ )
53
+
54
+
55
+ def __dir__():
56
+ return sorted(set(globals()) | set(__all__))
mrif/_version.py ADDED
@@ -0,0 +1,33 @@
1
+ # src/mrif/_version.py
2
+ from __future__ import annotations
3
+ from importlib.metadata import PackageNotFoundError, version as _dist_version
4
+
5
+ # Name of the installed distribution (usually your [project].name)
6
+ _DIST_NAME = "mriforge"
7
+
8
+ def _from_pyproject() -> str:
9
+ # Fallback for editable/dev checkouts where the dist isn’t installed yet
10
+ import sys, pathlib
11
+ root = pathlib.Path(__file__).resolve().parents[2] # .../src/mrif -> project root?
12
+ # Walk up until we find pyproject.toml
13
+ while root != root.parent and not (root / "pyproject.toml").exists():
14
+ root = root.parent
15
+ pp = root / "pyproject.toml"
16
+ if not pp.exists():
17
+ return "0+unknown"
18
+ try:
19
+ if sys.version_info >= (3, 11):
20
+ import tomllib # stdlib on 3.11+
21
+ else:
22
+ import tomli as tomllib # add 'tomli' as a dev dep if you need this fallback
23
+ with pp.open("rb") as f:
24
+ data = tomllib.load(f)
25
+ return data.get("project", {}).get("version", "0+unknown")
26
+ except Exception:
27
+ return "0+unknown"
28
+
29
+ try:
30
+ __version__ = _dist_version(_DIST_NAME)
31
+ except PackageNotFoundError:
32
+ __version__ = _from_pyproject()
33
+
mrif/cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ # File created by: Eisa Hedayati
2
+ # Date: 8/25/2026
3
+ # Description: This file is developed at CMRR
@@ -0,0 +1,113 @@
1
+ """
2
+ Command-line interface for MRIForge DICOM to NIfTI conversion.
3
+ """
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+
8
+
9
+ def _build_parser():
10
+ parser = argparse.ArgumentParser(
11
+ prog="mriforge-dicom-to-nifti",
12
+ description=(
13
+ "Convert a DICOM series to NIfTI and create a "
14
+ "matching JSON metadata sidecar."
15
+ ),
16
+ )
17
+
18
+ parser.add_argument(
19
+ "dicom_directory",
20
+ type=Path,
21
+ help="Path to the directory containing the DICOM series.",
22
+ )
23
+
24
+ parser.add_argument(
25
+ "nifti_file",
26
+ nargs="?",
27
+ type=Path,
28
+ default=None,
29
+ help=(
30
+ "Output NIfTI file. If omitted, the output is written "
31
+ "to ./<series_directory_name>.nii.gz."
32
+ ),
33
+ )
34
+
35
+ parser.add_argument(
36
+ "--verbose",
37
+ action=argparse.BooleanOptionalAction,
38
+ default=True,
39
+ help=(
40
+ "Show conversion progress. "
41
+ "Enabled by default; use --no-verbose to disable."
42
+ ),
43
+ )
44
+
45
+ return parser
46
+
47
+
48
+ def main():
49
+ parser = _build_parser()
50
+ args = parser.parse_args()
51
+
52
+ dicom_directory = args.dicom_directory.expanduser()
53
+
54
+ if not dicom_directory.exists():
55
+ parser.error(
56
+ f"DICOM directory does not exist: "
57
+ f"{dicom_directory}"
58
+ )
59
+
60
+ if not dicom_directory.is_dir():
61
+ parser.error(
62
+ f"DICOM input must be a directory: "
63
+ f"{dicom_directory}"
64
+ )
65
+
66
+ if args.nifti_file is None:
67
+ series_name = dicom_directory.resolve().name
68
+
69
+ nifti_file = (
70
+ Path.cwd()
71
+ / f"{series_name}.nii.gz"
72
+ )
73
+ else:
74
+ nifti_file = args.nifti_file.expanduser()
75
+
76
+ # Import lazily so importing the CLI itself does not load
77
+ # the heavier DICOM/NIfTI dependencies.
78
+ try:
79
+ from mrif.utilities.io import dicom_to_nifti
80
+
81
+ except ModuleNotFoundError as exc:
82
+ if exc.name in {
83
+ "nibabel",
84
+ "pydicom",
85
+ "SimpleITK",
86
+ "h5py",
87
+ }:
88
+ parser.error(
89
+ "DICOM/NIfTI support requires the MRIForge I/O "
90
+ "dependencies. Install them with:\n"
91
+ " pip install 'mriforge[io]'"
92
+ )
93
+
94
+ raise
95
+
96
+ if args.verbose:
97
+ print("MRIForge DICOM to NIfTI conversion")
98
+ print(f"DICOM series: {dicom_directory}")
99
+ print(f"NIfTI output: {nifti_file}")
100
+ print("Reading DICOM series...")
101
+
102
+ dicom_to_nifti(
103
+ dicom_directory,
104
+ nifti_file,
105
+ verbose=args.verbose,
106
+ )
107
+
108
+ if args.verbose:
109
+ print("Conversion complete.")
110
+
111
+
112
+ if __name__ == "__main__":
113
+ main()
mrif/cli/t2star.py ADDED
@@ -0,0 +1,469 @@
1
+ # File created by: Eisa Hedayati
2
+ # Date: 8/26/2026
3
+ # Description: This file is developed at CMRR
4
+ """
5
+ Command-line interface for 3D T2* calculation.
6
+
7
+ Input:
8
+ 4D multi-echo NIfTI: X x Y x Z x echoes
9
+ JSON sidecar containing Acquisition.EchoTimes
10
+
11
+ Output:
12
+ 3D T2* map
13
+ 3D S0 map
14
+ """
15
+
16
+ import argparse
17
+ import json
18
+ from pathlib import Path
19
+
20
+ import numpy as np
21
+
22
+
23
+ def _nifti_stem(path: Path) -> str:
24
+ """Return filename without .nii or .nii.gz."""
25
+
26
+ name = path.name
27
+
28
+ if name.endswith(".nii.gz"):
29
+ return name[:-7]
30
+
31
+ if name.endswith(".nii"):
32
+ return name[:-4]
33
+
34
+ raise ValueError(
35
+ "Input must have a .nii or .nii.gz extension."
36
+ )
37
+
38
+
39
+ def _build_parser():
40
+
41
+ parser = argparse.ArgumentParser(
42
+ prog="mriforge-t2star",
43
+ description=(
44
+ "Calculate 3D T2* and S0 maps from a 4D "
45
+ "multi-echo NIfTI and JSON metadata sidecar."
46
+ ),
47
+ )
48
+
49
+ parser.add_argument(
50
+ "nifti_file",
51
+ type=Path,
52
+ help=(
53
+ "4D multi-echo NIfTI. "
54
+ "Echoes must be in the last dimension."
55
+ ),
56
+ )
57
+
58
+ parser.add_argument(
59
+ "--json",
60
+ dest="json_file",
61
+ type=Path,
62
+ default=None,
63
+ help=(
64
+ "JSON metadata file. If omitted, a JSON file "
65
+ "with the same basename as the NIfTI is used."
66
+ ),
67
+ )
68
+
69
+ parser.add_argument(
70
+ "-o",
71
+ "--output-prefix",
72
+ type=Path,
73
+ default=None,
74
+ help=(
75
+ "Output prefix. Default: input NIfTI basename."
76
+ ),
77
+ )
78
+
79
+ parser.add_argument(
80
+ "--device",
81
+ default="auto",
82
+ help=(
83
+ "PyTorch device: auto, cpu, cuda, cuda:0, etc. "
84
+ "Default: auto."
85
+ ),
86
+ )
87
+
88
+ parser.add_argument(
89
+ "--iterations",
90
+ type=int,
91
+ default=10000,
92
+ help="Number of fitting iterations. Default: 10000.",
93
+ )
94
+
95
+ parser.add_argument(
96
+ "--lr",
97
+ type=float,
98
+ default=0.01,
99
+ help="Initial Adam learning rate. Default: 0.01.",
100
+ )
101
+
102
+ parser.add_argument(
103
+ "--lr-decay-factor",
104
+ type=float,
105
+ default=0.1,
106
+ help="Learning-rate decay factor. Default: 0.1.",
107
+ )
108
+
109
+ parser.add_argument(
110
+ "--patience",
111
+ type=int,
112
+ default=100,
113
+ help="Learning-rate scheduler patience. Default: 100.",
114
+ )
115
+
116
+ parser.add_argument(
117
+ "--initial-t2star",
118
+ type=float,
119
+ default=20.0,
120
+ help="Initial T2* value in ms. Default: 20.",
121
+ )
122
+
123
+ return parser
124
+
125
+
126
+ def main():
127
+
128
+ parser = _build_parser()
129
+ args = parser.parse_args()
130
+
131
+ # ---------------------------------------------------------
132
+ # Input NIfTI
133
+ # ---------------------------------------------------------
134
+
135
+ nifti_file = args.nifti_file.expanduser()
136
+
137
+ if not nifti_file.exists():
138
+ parser.error(
139
+ f"NIfTI file does not exist: {nifti_file}"
140
+ )
141
+
142
+ try:
143
+ stem = _nifti_stem(nifti_file)
144
+ except ValueError as exc:
145
+ parser.error(str(exc))
146
+
147
+ # ---------------------------------------------------------
148
+ # JSON sidecar
149
+ # ---------------------------------------------------------
150
+
151
+ if args.json_file is None:
152
+
153
+ json_file = nifti_file.with_name(
154
+ f"{stem}.json"
155
+ )
156
+
157
+ else:
158
+
159
+ json_file = args.json_file.expanduser()
160
+
161
+ if not json_file.exists():
162
+
163
+ parser.error(
164
+ f"JSON sidecar does not exist: {json_file}"
165
+ )
166
+
167
+ with json_file.open(
168
+ "r",
169
+ encoding="utf-8",
170
+ ) as f:
171
+
172
+ metadata = json.load(f)
173
+
174
+ acquisition = metadata.get(
175
+ "Acquisition",
176
+ {},
177
+ )
178
+
179
+ echo_times = acquisition.get(
180
+ "EchoTimes"
181
+ )
182
+
183
+ if echo_times is None:
184
+
185
+ parser.error(
186
+ "JSON does not contain "
187
+ "Acquisition.EchoTimes."
188
+ )
189
+
190
+ echo_times = np.asarray(
191
+ echo_times,
192
+ dtype=np.float32,
193
+ )
194
+
195
+ if echo_times.ndim != 1:
196
+
197
+ parser.error(
198
+ "Acquisition.EchoTimes must be a 1D list."
199
+ )
200
+
201
+ if len(echo_times) < 2:
202
+
203
+ parser.error(
204
+ "At least two echo times are required."
205
+ )
206
+
207
+ if not np.all(np.isfinite(echo_times)):
208
+
209
+ parser.error(
210
+ "Echo times contain NaN or infinite values."
211
+ )
212
+
213
+ if np.unique(echo_times).size != echo_times.size:
214
+
215
+ parser.error(
216
+ "Echo times must be unique."
217
+ )
218
+
219
+ # ---------------------------------------------------------
220
+ # Time units
221
+ # ---------------------------------------------------------
222
+
223
+ time_unit = acquisition.get(
224
+ "TimeUnit"
225
+ )
226
+
227
+ if time_unit is None:
228
+
229
+ parser.error(
230
+ "JSON does not contain "
231
+ "Acquisition.TimeUnit."
232
+ )
233
+
234
+ time_unit = str(
235
+ time_unit
236
+ ).lower()
237
+
238
+ if time_unit in {
239
+ "ms",
240
+ "millisecond",
241
+ "milliseconds",
242
+ }:
243
+
244
+ pass
245
+
246
+ elif time_unit in {
247
+ "s",
248
+ "sec",
249
+ "second",
250
+ "seconds",
251
+ }:
252
+
253
+ echo_times *= 1000.0
254
+
255
+ else:
256
+
257
+ parser.error(
258
+ f"Unsupported TimeUnit: {time_unit}"
259
+ )
260
+
261
+ # ---------------------------------------------------------
262
+ # Heavy imports
263
+ # ---------------------------------------------------------
264
+
265
+ try:
266
+
267
+ import nibabel as nib
268
+ import torch
269
+
270
+ from mrif.quantitative_MRI.mapping import (
271
+ t2_star_two_parametric_3D,
272
+ )
273
+
274
+ from mrif.utilities.io import (
275
+ save_scalar_map_like,
276
+ )
277
+
278
+ except ModuleNotFoundError as exc:
279
+
280
+ parser.error(
281
+ f"Missing dependency: {exc.name}"
282
+ )
283
+
284
+ # ---------------------------------------------------------
285
+ # Load NIfTI
286
+ # ---------------------------------------------------------
287
+
288
+ image = nib.load(
289
+ str(nifti_file)
290
+ )
291
+
292
+ if image.ndim != 4:
293
+
294
+ parser.error(
295
+ "Input must be a 4D multi-echo NIfTI. "
296
+ f"Got shape {image.shape}."
297
+ )
298
+
299
+ if image.shape[-1] != len(echo_times):
300
+
301
+ parser.error(
302
+ "Echo count mismatch: "
303
+ f"NIfTI contains {image.shape[-1]} volumes "
304
+ f"but JSON contains {len(echo_times)} echo times."
305
+ )
306
+
307
+ data = image.get_fdata(
308
+ dtype=np.float32
309
+ )
310
+
311
+ # ---------------------------------------------------------
312
+ # Sort echoes
313
+ #
314
+ # This is important:
315
+ # if TE order changes, the 4D volumes must change with it.
316
+ # ---------------------------------------------------------
317
+
318
+ order = np.argsort(
319
+ echo_times
320
+ )
321
+
322
+ if not np.array_equal(
323
+ order,
324
+ np.arange(len(echo_times)),
325
+ ):
326
+
327
+ print(
328
+ "Echo times are not sorted. "
329
+ "Reordering echoes and volumes together."
330
+ )
331
+
332
+ echo_times = echo_times[
333
+ order
334
+ ]
335
+
336
+ data = data[
337
+ ...,
338
+ order
339
+ ]
340
+
341
+ # ---------------------------------------------------------
342
+ # Device
343
+ # ---------------------------------------------------------
344
+
345
+ if args.device == "auto":
346
+
347
+ device = None
348
+
349
+ display_device = (
350
+ "cuda"
351
+ if torch.cuda.is_available()
352
+ else "cpu"
353
+ )
354
+
355
+ else:
356
+
357
+ device = args.device
358
+ display_device = args.device
359
+
360
+ if (
361
+ str(display_device).startswith("cuda")
362
+ and not torch.cuda.is_available()
363
+ ):
364
+
365
+ parser.error(
366
+ "CUDA requested, but PyTorch "
367
+ "does not detect a CUDA device."
368
+ )
369
+
370
+ # ---------------------------------------------------------
371
+ # Fit
372
+ # ---------------------------------------------------------
373
+
374
+ print("MRIForge 3D T2* calculation")
375
+ print(f"NIfTI: {nifti_file}")
376
+ print(f"JSON: {json_file}")
377
+ print(
378
+ f"Echo times (ms): "
379
+ f"{echo_times.tolist()}"
380
+ )
381
+ print(f"Device: {display_device}")
382
+ print("Calculating T2* and S0...")
383
+
384
+ result = t2_star_two_parametric_3D(
385
+ echo_times,
386
+ data,
387
+ num_iterations=args.iterations,
388
+ initial_lr=args.lr,
389
+ lr_decay_factor=args.lr_decay_factor,
390
+ patience=args.patience,
391
+ initial_T2_star=args.initial_t2star,
392
+ plot_error=False,
393
+ return_RMSE=False,
394
+ device=device,
395
+ )
396
+
397
+ # Current fitter returns torch tensors.
398
+ t2star = (
399
+ result["T2_star_map"]
400
+ .detach()
401
+ .cpu()
402
+ .numpy()
403
+ )
404
+
405
+ s0 = (
406
+ result["S0_map"]
407
+ .detach()
408
+ .cpu()
409
+ .numpy()
410
+ )
411
+
412
+ # ---------------------------------------------------------
413
+ # Output names
414
+ # ---------------------------------------------------------
415
+
416
+ if args.output_prefix is None:
417
+
418
+ output_prefix = nifti_file.with_name(
419
+ stem
420
+ )
421
+
422
+ else:
423
+
424
+ output_prefix = (
425
+ args.output_prefix.expanduser()
426
+ )
427
+
428
+ output_prefix.parent.mkdir(
429
+ parents=True,
430
+ exist_ok=True,
431
+ )
432
+
433
+ t2star_file = Path(
434
+ f"{output_prefix}_T2star.nii.gz"
435
+ )
436
+
437
+ s0_file = Path(
438
+ f"{output_prefix}_S0.nii.gz"
439
+ )
440
+
441
+ # ---------------------------------------------------------
442
+ # Save maps
443
+ # ---------------------------------------------------------
444
+
445
+ save_scalar_map_like(
446
+ image,
447
+ t2star,
448
+ str(t2star_file),
449
+ dtype=np.float32,
450
+ descrip="MRIForge T2* map (ms)",
451
+ intent_name="T2star",
452
+ )
453
+
454
+ save_scalar_map_like(
455
+ image,
456
+ s0,
457
+ str(s0_file),
458
+ dtype=np.float32,
459
+ descrip="MRIForge S0 map",
460
+ intent_name="S0",
461
+ )
462
+
463
+ print("Done.")
464
+ print(f"T2*: {t2star_file}")
465
+ print(f"S0: {s0_file}")
466
+
467
+
468
+ if __name__ == "__main__":
469
+ main()