mrzerocore 0.4.3__cp37-abi3-musllinux_1_2_aarch64.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.
- MRzeroCore/__init__.py +22 -0
- MRzeroCore/_prepass.abi3.so +0 -0
- MRzeroCore/phantom/brainweb/.gitignore +1 -0
- MRzeroCore/phantom/brainweb/__init__.py +192 -0
- MRzeroCore/phantom/brainweb/brainweb_data.json +92 -0
- MRzeroCore/phantom/brainweb/brainweb_data_sources.txt +74 -0
- MRzeroCore/phantom/brainweb/output/.gitkeep +0 -0
- MRzeroCore/phantom/custom_voxel_phantom.py +240 -0
- MRzeroCore/phantom/nifti_phantom.py +210 -0
- MRzeroCore/phantom/sim_data.py +200 -0
- MRzeroCore/phantom/tissue_dict.py +269 -0
- MRzeroCore/phantom/voxel_grid_phantom.py +610 -0
- MRzeroCore/pulseq/exporter.py +374 -0
- MRzeroCore/pulseq/exporter_v2.py +650 -0
- MRzeroCore/pulseq/helpers.py +228 -0
- MRzeroCore/pulseq/pulseq_exporter.py +553 -0
- MRzeroCore/pulseq/pulseq_loader/__init__.py +66 -0
- MRzeroCore/pulseq/pulseq_loader/adc.py +48 -0
- MRzeroCore/pulseq/pulseq_loader/helpers.py +75 -0
- MRzeroCore/pulseq/pulseq_loader/pulse.py +80 -0
- MRzeroCore/pulseq/pulseq_loader/pulseq_file/__init__.py +235 -0
- MRzeroCore/pulseq/pulseq_loader/pulseq_file/adc.py +68 -0
- MRzeroCore/pulseq/pulseq_loader/pulseq_file/block.py +98 -0
- MRzeroCore/pulseq/pulseq_loader/pulseq_file/definitons.py +68 -0
- MRzeroCore/pulseq/pulseq_loader/pulseq_file/gradient.py +70 -0
- MRzeroCore/pulseq/pulseq_loader/pulseq_file/helpers.py +156 -0
- MRzeroCore/pulseq/pulseq_loader/pulseq_file/rf.py +91 -0
- MRzeroCore/pulseq/pulseq_loader/pulseq_file/trap.py +69 -0
- MRzeroCore/pulseq/pulseq_loader/spoiler.py +33 -0
- MRzeroCore/reconstruction.py +104 -0
- MRzeroCore/sequence.py +747 -0
- MRzeroCore/simulation/isochromat_sim.py +254 -0
- MRzeroCore/simulation/main_pass.py +286 -0
- MRzeroCore/simulation/pre_pass.py +192 -0
- MRzeroCore/simulation/sig_to_mrd.py +362 -0
- MRzeroCore/util.py +884 -0
- MRzeroCore.libs/libgcc_s-39080030.so.1 +0 -0
- mrzerocore-0.4.3.dist-info/METADATA +121 -0
- mrzerocore-0.4.3.dist-info/RECORD +41 -0
- mrzerocore-0.4.3.dist-info/WHEEL +4 -0
- mrzerocore-0.4.3.dist-info/licenses/LICENSE +661 -0
MRzeroCore/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import numpy
|
|
2
|
+
if not hasattr(numpy, "int"):
|
|
3
|
+
numpy.int = int
|
|
4
|
+
if not hasattr(numpy, "float"):
|
|
5
|
+
numpy.float = float
|
|
6
|
+
if not hasattr(numpy, "complex"):
|
|
7
|
+
numpy.complex = complex
|
|
8
|
+
|
|
9
|
+
from .sequence import PulseUsage, Pulse, Repetition, Sequence, chain
|
|
10
|
+
from .phantom.voxel_grid_phantom import VoxelGridPhantom
|
|
11
|
+
from .phantom.custom_voxel_phantom import CustomVoxelPhantom
|
|
12
|
+
from .phantom.sim_data import SimData
|
|
13
|
+
from .phantom.brainweb import generate_brainweb_phantoms
|
|
14
|
+
from .phantom.nifti_phantom import NiftiPhantom, NiftiTissue, NiftiRef, NiftiMapping
|
|
15
|
+
from .phantom.tissue_dict import TissueDict
|
|
16
|
+
from .simulation.isochromat_sim import isochromat_sim
|
|
17
|
+
from .simulation.pre_pass import compute_graph, compute_graph_ext, Graph
|
|
18
|
+
from .simulation.main_pass import execute_graph
|
|
19
|
+
from .simulation.sig_to_mrd import sig_to_mrd
|
|
20
|
+
from .reconstruction import reco_adjoint
|
|
21
|
+
from .pulseq.exporter import pulseq_write_cartesian
|
|
22
|
+
from . import util
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
brainweb*/
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def load_tissue(subject: int, alias: str, cache_dir: Path) -> np.ndarray:
|
|
6
|
+
import os
|
|
7
|
+
import requests
|
|
8
|
+
import gzip
|
|
9
|
+
|
|
10
|
+
download_alias = f"subject{subject:02d}_{alias}"
|
|
11
|
+
file_name = download_alias + ".i8.gz" # 8 bit signed int, gnuzip
|
|
12
|
+
file_path = cache_dir / file_name
|
|
13
|
+
|
|
14
|
+
# Download and cache file if it doesn't exist yet
|
|
15
|
+
if not os.path.exists(file_path):
|
|
16
|
+
response = requests.post(
|
|
17
|
+
"https://brainweb.bic.mni.mcgill.ca/cgi/brainweb1",
|
|
18
|
+
data={
|
|
19
|
+
"do_download_alias": download_alias,
|
|
20
|
+
"format_value": "raw_byte",
|
|
21
|
+
"zip_value": "gnuzip"
|
|
22
|
+
}
|
|
23
|
+
)
|
|
24
|
+
with open(file_path, "wb") as f:
|
|
25
|
+
f.write(response.content)
|
|
26
|
+
|
|
27
|
+
# Load the raw BrainWeb data and add it to the return array
|
|
28
|
+
with gzip.open(file_path) as f:
|
|
29
|
+
# BrainWeb says this data is unsigned, which is a lie
|
|
30
|
+
tmp = np.frombuffer(f.read(), np.uint8) + 128
|
|
31
|
+
|
|
32
|
+
# Vessel bugfix: most of background is 1 instead of zero
|
|
33
|
+
if alias == "ves":
|
|
34
|
+
tmp[tmp == 1] = 0
|
|
35
|
+
|
|
36
|
+
# Convert to RAS+ [x, y, z] indexing and [0..1] range
|
|
37
|
+
data = tmp.reshape(362, 434, 362).swapaxes(0, 2).astype(np.float32) / 255.0
|
|
38
|
+
|
|
39
|
+
return data
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def generate_B0_B1(mask):
|
|
43
|
+
"""Generate a somewhat plausible B0 and B1 map.
|
|
44
|
+
|
|
45
|
+
Visually fitted to look similar to the numerical_brain_cropped
|
|
46
|
+
"""
|
|
47
|
+
x_pos, y_pos, z_pos = np.meshgrid(
|
|
48
|
+
np.linspace(-1, 1, mask.shape[0]),
|
|
49
|
+
np.linspace(-1, 1, mask.shape[1]),
|
|
50
|
+
np.linspace(-1, 1, mask.shape[2]),
|
|
51
|
+
indexing="ij"
|
|
52
|
+
)
|
|
53
|
+
B1 = np.exp(-(0.4*x_pos**2 + 0.2*y_pos**2 + 0.3*z_pos**2))
|
|
54
|
+
dist2 = (0.4*x_pos**2 + 0.2*(y_pos - 0.7)**2 + 0.3*z_pos**2)
|
|
55
|
+
B0 = 7 / (0.05 + dist2) - 45 / (0.3 + dist2)
|
|
56
|
+
|
|
57
|
+
# Normalize such that the average over the mask is 0 or 1
|
|
58
|
+
|
|
59
|
+
B0 -= B0[mask].mean()
|
|
60
|
+
B1 /= B1[mask].mean()
|
|
61
|
+
B0[~mask] = 0
|
|
62
|
+
B1[~mask] = 0
|
|
63
|
+
|
|
64
|
+
return B0, B1
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def generate_brainweb_phantoms(output_dir: str, subject_count: int | None = None):
|
|
68
|
+
"""Generate BrainWeb phantom maps for the selected configuration.
|
|
69
|
+
|
|
70
|
+
Raw tissue segmentation data is provided by the BrainWeb Database:
|
|
71
|
+
http://www.bic.mni.mcgill.ca/brainweb/
|
|
72
|
+
|
|
73
|
+
The generated phantoms are stored as MR-zero
|
|
74
|
+
[NIfTI phantoms](https://mrsources.github.io/MRzero-Core/nifti-overview.html).
|
|
75
|
+
Settings for the generated phantoms are stored in `brainweb_data.json`.
|
|
76
|
+
|
|
77
|
+
Parameters
|
|
78
|
+
----------
|
|
79
|
+
output_dir: str
|
|
80
|
+
The directory where the generated phantoms will be stored to. In
|
|
81
|
+
addition, a `cache` folder will be generated there too, which contains
|
|
82
|
+
all the data downloaded from BrainWeb to avoid repeating the download
|
|
83
|
+
for all configurations or when generating phantoms again.
|
|
84
|
+
subject_count: int
|
|
85
|
+
Number of phantoms to generate. BrainWeb provides 20 different segmented
|
|
86
|
+
phantoms. If you don't need as many, you can lower this number to only
|
|
87
|
+
generate the first `count` phantoms. If `None`, all phantoms are generated.
|
|
88
|
+
"""
|
|
89
|
+
import os
|
|
90
|
+
import json
|
|
91
|
+
from tqdm import tqdm
|
|
92
|
+
import nibabel as nib
|
|
93
|
+
from ..nifti_phantom import NiftiPhantom, NiftiTissue, NiftiRef, NiftiMapping
|
|
94
|
+
|
|
95
|
+
cache_dir = Path(output_dir) / "cache"
|
|
96
|
+
config_file = Path(__file__).parent / "brainweb_data.json"
|
|
97
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
98
|
+
|
|
99
|
+
# Load the brainweb data file that contains info about tissues and subjects
|
|
100
|
+
with open(config_file) as f:
|
|
101
|
+
config = json.load(f)
|
|
102
|
+
|
|
103
|
+
if subject_count is None:
|
|
104
|
+
subject_count = len(config["subjects"])
|
|
105
|
+
|
|
106
|
+
# Voxel index to physical coordinates in millimeters:
|
|
107
|
+
# Brainweb has 0.5 mm voxel size; we center the brain.
|
|
108
|
+
affine = np.array(
|
|
109
|
+
[
|
|
110
|
+
[0.5, 0, 0, -90.5], # X: Right
|
|
111
|
+
[0, 0.5, 0, -108.5], # Y: Anterior
|
|
112
|
+
[0, 0, 0.5, -90.5], # Z: Superior
|
|
113
|
+
[0, 0, 0, 0], # ignored
|
|
114
|
+
]
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
number_of_downloads = subject_count * sum(len(maps) for maps in config["download-aliases"].values())
|
|
119
|
+
number_of_saves = subject_count * 3 # density, B0, B1
|
|
120
|
+
number_of_jsons = subject_count * len(config["fields"])
|
|
121
|
+
total = number_of_downloads + number_of_saves + number_of_jsons
|
|
122
|
+
|
|
123
|
+
with tqdm(total=total) as pbar:
|
|
124
|
+
for subject in config["subjects"][:subject_count]:
|
|
125
|
+
phantom_name = f"brainweb-subj{subject:02d}"
|
|
126
|
+
phantom_dir = Path(output_dir) / phantom_name
|
|
127
|
+
|
|
128
|
+
pbar.set_description(str(phantom_dir))
|
|
129
|
+
os.makedirs(phantom_dir, exist_ok=True)
|
|
130
|
+
|
|
131
|
+
tissue_indices = {}
|
|
132
|
+
density_maps = []
|
|
133
|
+
for tissue, density in config["density"].items():
|
|
134
|
+
density_map = 0
|
|
135
|
+
|
|
136
|
+
for alias in config["download-aliases"][tissue]:
|
|
137
|
+
pbar.set_postfix_str(f"loading 'subject{subject:02d}_{alias}'")
|
|
138
|
+
density_map += density * load_tissue(subject, alias, cache_dir)
|
|
139
|
+
pbar.update()
|
|
140
|
+
|
|
141
|
+
tissue_indices[tissue] = len(density_maps)
|
|
142
|
+
density_maps.append(density_map)
|
|
143
|
+
|
|
144
|
+
pbar.set_postfix_str(f"saving '{phantom_name}.nii.gz'")
|
|
145
|
+
nib.loadsave.save(
|
|
146
|
+
nib.nifti1.Nifti1Image(np.stack(density_maps, -1), affine),
|
|
147
|
+
phantom_dir / f"{phantom_name}.nii.gz"
|
|
148
|
+
)
|
|
149
|
+
pbar.update()
|
|
150
|
+
|
|
151
|
+
B0, B1 = generate_B0_B1(sum(density_maps) > 0)
|
|
152
|
+
pbar.set_postfix_str(f"saving '{phantom_name}_dB0.nii.gz'")
|
|
153
|
+
nib.loadsave.save(
|
|
154
|
+
nib.nifti1.Nifti1Image(B0[..., None], affine),
|
|
155
|
+
phantom_dir / f"{phantom_name}_dB0.nii.gz"
|
|
156
|
+
)
|
|
157
|
+
pbar.update()
|
|
158
|
+
pbar.set_postfix_str(f"saving '{phantom_name}_B1+.nii.gz'")
|
|
159
|
+
nib.loadsave.save(
|
|
160
|
+
nib.nifti1.Nifti1Image(B1[..., None], affine),
|
|
161
|
+
phantom_dir / f"{phantom_name}_B1+.nii.gz"
|
|
162
|
+
)
|
|
163
|
+
pbar.update()
|
|
164
|
+
|
|
165
|
+
for field in config["fields"]:
|
|
166
|
+
phantom = NiftiPhantom.default(B0=field)
|
|
167
|
+
pbar.set_postfix_str(f"saving '{phantom_name}-{field}T.json'")
|
|
168
|
+
density_maps = []
|
|
169
|
+
|
|
170
|
+
for tissue in config["tissues"]:
|
|
171
|
+
tissue_config = config["props"][str(field)][tissue]
|
|
172
|
+
|
|
173
|
+
phantom.tissues[tissue] = NiftiTissue(
|
|
174
|
+
density=NiftiRef(Path(f"{phantom_name}.nii.gz"), tissue_indices[tissue]),
|
|
175
|
+
T1=tissue_config["T1"],
|
|
176
|
+
T2=tissue_config["T2"],
|
|
177
|
+
T2dash=tissue_config["T2'"],
|
|
178
|
+
ADC=tissue_config["ADC"],
|
|
179
|
+
dB0=NiftiRef(Path(f"{phantom_name}_dB0.nii.gz"), 0),
|
|
180
|
+
B1_tx=[NiftiRef(Path(f"{phantom_name}_B1+.nii.gz"), 0)],
|
|
181
|
+
B1_rx=[1.0],
|
|
182
|
+
)
|
|
183
|
+
if tissue == "fat":
|
|
184
|
+
phantom.tissues["fat"].dB0 = NiftiMapping(
|
|
185
|
+
file=NiftiRef(Path(f"{phantom_name}_dB0.nii.gz"), 0),
|
|
186
|
+
func=config["dB0-fat-remap"][str(field)]
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
phantom.save(phantom_dir / f"{phantom_name}-{field}T.json")
|
|
190
|
+
pbar.update()
|
|
191
|
+
|
|
192
|
+
pbar.close()
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
{
|
|
2
|
+
"subjects": [
|
|
3
|
+
4, 5, 6, 18, 20, 38, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54
|
|
4
|
+
],
|
|
5
|
+
"fields": [3, 7],
|
|
6
|
+
"tissues": ["gm", "wm", "csf", "vessels", "fat"],
|
|
7
|
+
"download-aliases": {
|
|
8
|
+
"gm": ["gry"],
|
|
9
|
+
"wm": ["wht"],
|
|
10
|
+
"csf": ["csf"],
|
|
11
|
+
"vessels": ["ves"],
|
|
12
|
+
"fat": ["fat", "mus", "m-s", "dura", "fat2"]
|
|
13
|
+
},
|
|
14
|
+
"dB0-fat-remap" : {
|
|
15
|
+
"3": "x - 440",
|
|
16
|
+
"7": "x - 1020"
|
|
17
|
+
},
|
|
18
|
+
"density": {
|
|
19
|
+
"gm": 0.8,
|
|
20
|
+
"wm": 0.7,
|
|
21
|
+
"csf": 1.0,
|
|
22
|
+
"vessels": 1.0,
|
|
23
|
+
"fat": 1.0
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
"props": {
|
|
27
|
+
"3": {
|
|
28
|
+
"gm": {
|
|
29
|
+
"T1": 1.56,
|
|
30
|
+
"T2": 0.083,
|
|
31
|
+
"T2'": 0.32,
|
|
32
|
+
"ADC": 0.83
|
|
33
|
+
},
|
|
34
|
+
"wm": {
|
|
35
|
+
"T1": 0.83,
|
|
36
|
+
"T2": 0.075,
|
|
37
|
+
"T2'": 0.18,
|
|
38
|
+
"ADC": 0.65
|
|
39
|
+
},
|
|
40
|
+
"csf": {
|
|
41
|
+
"T1": 4.16,
|
|
42
|
+
"T2": 1.65,
|
|
43
|
+
"T2'": 0.059,
|
|
44
|
+
"ADC": 3.19
|
|
45
|
+
},
|
|
46
|
+
"vessels": {
|
|
47
|
+
"T1": 4.16,
|
|
48
|
+
"T2": 1.65,
|
|
49
|
+
"T2'": 0.059,
|
|
50
|
+
"ADC": 3.19
|
|
51
|
+
},
|
|
52
|
+
"fat": {
|
|
53
|
+
"T1": 0.37,
|
|
54
|
+
"T2": 0.125,
|
|
55
|
+
"T2'": 0.012,
|
|
56
|
+
"ADC": 0.1
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"7": {
|
|
60
|
+
"gm": {
|
|
61
|
+
"T1": 1.67,
|
|
62
|
+
"T2": 0.043,
|
|
63
|
+
"T2'": 0.82,
|
|
64
|
+
"ADC": 0.83
|
|
65
|
+
},
|
|
66
|
+
"wm": {
|
|
67
|
+
"T1": 1.22,
|
|
68
|
+
"T2": 0.037,
|
|
69
|
+
"T2'": 0.65,
|
|
70
|
+
"ADC": 0.65
|
|
71
|
+
},
|
|
72
|
+
"csf": {
|
|
73
|
+
"T1": 4.0,
|
|
74
|
+
"T2": 0.8,
|
|
75
|
+
"T2'": 0.204,
|
|
76
|
+
"ADC": 3.19
|
|
77
|
+
},
|
|
78
|
+
"vessels": {
|
|
79
|
+
"T1": 4.0,
|
|
80
|
+
"T2": 0.8,
|
|
81
|
+
"T2'": 0.204,
|
|
82
|
+
"ADC": 3.19
|
|
83
|
+
},
|
|
84
|
+
"fat": {
|
|
85
|
+
"T1": 0.374,
|
|
86
|
+
"T2": 0.125,
|
|
87
|
+
"T2'": 0.0117,
|
|
88
|
+
"ADC": 0.1
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
! NOTE
|
|
2
|
+
7T maps are not checked as thrououghly. Only source so far:
|
|
3
|
+
https://cds.ismrm.org/protected/14MProceedings/PDFfiles/3208.pdf
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# T1 and T2 times, taken from:
|
|
7
|
+
# https://mri-q.com/uploads/3/4/5/7/34572113/normal_relaxation_times_at_3t.pdf
|
|
8
|
+
# Value taken from paper with most participants (draw: closest to mean of all)
|
|
9
|
+
# Studies that are outliers are ignored (WM T2 time)
|
|
10
|
+
|
|
11
|
+
# T2' calculated from T2 and T2*, taken from:
|
|
12
|
+
# https://www.sciencedirect.com/science/article/pii/S0730725X07001701?via%3Dihub
|
|
13
|
+
|
|
14
|
+
# Water / Fat T2': https://link.springer.com/article/10.1007/s00723-015-0737-5 (4.7 T)
|
|
15
|
+
|
|
16
|
+
# Errors are uncertenties of studies, not data on variation in one measurement
|
|
17
|
+
|
|
18
|
+
Brainweb Tissues:
|
|
19
|
+
- CSF
|
|
20
|
+
T1: 4163 ± 263
|
|
21
|
+
T2: 1650 (approx, taken from https://www.ncbi.nlm.nih.gov/pmc/articles/PMC5973950/)
|
|
22
|
+
T2*: 57.1 -> T2': 59.1
|
|
23
|
+
|
|
24
|
+
- Gray Matter
|
|
25
|
+
T1: 1558 ± 88
|
|
26
|
+
T2: 83 ± 4
|
|
27
|
+
T2*: 66.0 ± 1.4 -> T2': 322
|
|
28
|
+
|
|
29
|
+
- White Matter
|
|
30
|
+
T1: 830 ± 0
|
|
31
|
+
T2: 75 ± 3
|
|
32
|
+
T2*: 53.2 ± 1.2 -> T2': 183
|
|
33
|
+
T2': 56 ± 1
|
|
34
|
+
|
|
35
|
+
- Fat
|
|
36
|
+
T1: 374 ± 45
|
|
37
|
+
T2: 125
|
|
38
|
+
T2* = 10.7 -> T2': 11.7
|
|
39
|
+
|
|
40
|
+
# The following values are not used for phantom generation
|
|
41
|
+
|
|
42
|
+
- Muscle
|
|
43
|
+
T1: 1100 ± 59
|
|
44
|
+
T2: 40
|
|
45
|
+
|
|
46
|
+
- Muscle/Skin
|
|
47
|
+
|
|
48
|
+
- Skull
|
|
49
|
+
Probably similar to Bone marrow, but they don't overlap in the images
|
|
50
|
+
|
|
51
|
+
- Blood vessels
|
|
52
|
+
Probably similar to CSF (mostly water)
|
|
53
|
+
|
|
54
|
+
- Connective (region around fat)
|
|
55
|
+
Overlaps nearly fully with Fat
|
|
56
|
+
|
|
57
|
+
- Dura matter
|
|
58
|
+
Very little volume, probably not noticable at lower resolutions
|
|
59
|
+
|
|
60
|
+
- Bone marrow
|
|
61
|
+
T1: 586 ± 73
|
|
62
|
+
T2: 127
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
Diffusion:
|
|
66
|
+
Values taken from https://onlinelibrary.wiley.com/doi/10.1002/jmri.1076
|
|
67
|
+
10^-3 mm² / s
|
|
68
|
+
CSF: 3.19 ± 0.10
|
|
69
|
+
WM: 0.65 ± 0.03
|
|
70
|
+
GM: 0.83 ± 0.05
|
|
71
|
+
|
|
72
|
+
No great source for fat, https://onlinelibrary.wiley.com/doi/10.1002/mrm.24535
|
|
73
|
+
says it barely diffuses
|
|
74
|
+
FAT: ~0.1
|
|
File without changes
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Callable
|
|
3
|
+
import torch
|
|
4
|
+
from numpy import pi
|
|
5
|
+
import matplotlib.pyplot as plt
|
|
6
|
+
from .sim_data import SimData
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def heaviside(t: torch.Tensor, size: torch.Tensor) -> torch.Tensor:
|
|
10
|
+
"""Sinc voxel (real space) dephasing function.
|
|
11
|
+
|
|
12
|
+
The size describes the distance from the center to the first zero crossing.
|
|
13
|
+
This is not differentiable because of the discontinuity at ±size/2.
|
|
14
|
+
"""
|
|
15
|
+
return torch.prod(torch.heaviside(
|
|
16
|
+
0.5/size - t.abs(), torch.tensor(0.5)
|
|
17
|
+
), dim=1)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def sigmoid(t: torch.Tensor, size: torch.Tensor) -> torch.Tensor:
|
|
21
|
+
"""Differentiable approximation of the sinc voxel dephasing function.
|
|
22
|
+
|
|
23
|
+
The size describes the distance from the center to the first zero crossing.
|
|
24
|
+
A narrow sigmoid is used to avoid the discontinuity of a heaviside.
|
|
25
|
+
"""
|
|
26
|
+
return torch.prod(torch.sigmoid((0.5/size - t.abs() + 0.5) * 100), dim=1)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def sinc(t: torch.Tensor, size: torch.Tensor) -> torch.Tensor:
|
|
30
|
+
"""Box voxel (real space) dephasing function.
|
|
31
|
+
|
|
32
|
+
The size describes the total extends of the box shape.
|
|
33
|
+
"""
|
|
34
|
+
return torch.prod(torch.sinc(t * size), dim=1)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def gauss(t: torch.Tensor, size: torch.Tensor) -> torch.Tensor:
|
|
38
|
+
"""Normal distribution shaped voxel dephasing function.
|
|
39
|
+
|
|
40
|
+
This function is not normalized. The size describes the variance.
|
|
41
|
+
"""
|
|
42
|
+
return torch.prod(torch.exp(-0.5 * size * t**2), dim=1)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class CustomVoxelPhantom:
|
|
46
|
+
"""Class for manually specifying phantoms from a list of voxels.
|
|
47
|
+
|
|
48
|
+
This can be useful to test the simulation or to simulate the point spread
|
|
49
|
+
function. All voxels have the same size and shape but can have different
|
|
50
|
+
physical properties.
|
|
51
|
+
|
|
52
|
+
Attributes
|
|
53
|
+
----------
|
|
54
|
+
voxel_pos : torch.Tensor
|
|
55
|
+
(voxel_count, 3) tensor of voxel positions in SI units [m]
|
|
56
|
+
PD : torch.Tensor
|
|
57
|
+
1D tensor containing the Proton Density of all voxels
|
|
58
|
+
T1 : torch.Tensor
|
|
59
|
+
1D tensor containing the T1 relaxation of all voxels
|
|
60
|
+
T2 : torch.Tensor
|
|
61
|
+
1D tensor containing the T2 relaxation of all voxels
|
|
62
|
+
T2dash : torch.Tensor
|
|
63
|
+
1D tensor containing the T2' dephasing of all voxels
|
|
64
|
+
D : torch.Tensor
|
|
65
|
+
1D tensor containing the Diffusion coefficient of all voxels
|
|
66
|
+
B0 : torch.Tensor
|
|
67
|
+
1D tensor containing the dB0 off-resonance
|
|
68
|
+
B1 : torch.Tensor
|
|
69
|
+
1D tensor containing the relative B1 field strength
|
|
70
|
+
voxel_shape : str
|
|
71
|
+
Can be one of ``["sinc", "exact_sinc", "box", "gauss"]``
|
|
72
|
+
voxel_size : torch.Tensor
|
|
73
|
+
3-element tensor containing the size of a voxel
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self,
|
|
77
|
+
pos: list[list[float]] | torch.Tensor,
|
|
78
|
+
PD: float | list[float] | torch.Tensor = 1.0,
|
|
79
|
+
T1: float | list[float] | torch.Tensor = 1.5,
|
|
80
|
+
T2: float | list[float] | torch.Tensor = 0.1,
|
|
81
|
+
T2dash: float | list[float] | torch.Tensor = 0.05,
|
|
82
|
+
D: float | list[float] | torch.Tensor = 1,
|
|
83
|
+
B0: float | list[float] | torch.Tensor = 0,
|
|
84
|
+
B1: float | list[float] | torch.Tensor = 1,
|
|
85
|
+
voxel_size=0.1, voxel_shape="sinc"
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Create a phantom consisting of manually placed voxels.
|
|
88
|
+
|
|
89
|
+
See :class:`CustomVoxelPhantom` attributes for explanation of the
|
|
90
|
+
parameters. They can be single floats to set all voxels, or anything
|
|
91
|
+
that can be converted to a 1D torch.Tensor for individual values for
|
|
92
|
+
every voxel.
|
|
93
|
+
"""
|
|
94
|
+
pos = torch.tensor(pos)
|
|
95
|
+
assert pos.ndim == 2
|
|
96
|
+
assert pos.shape[1] == 3
|
|
97
|
+
|
|
98
|
+
voxel_size = torch.tensor(voxel_size).squeeze()
|
|
99
|
+
if voxel_size.ndim == 0:
|
|
100
|
+
voxel_size = torch.full((3, ), voxel_size)
|
|
101
|
+
assert voxel_size.ndim == 1 and voxel_size.numel() == 3
|
|
102
|
+
|
|
103
|
+
assert voxel_shape in ["sinc", "exact_sinc", "box", "gauss"]
|
|
104
|
+
|
|
105
|
+
def expand(t) -> torch.Tensor:
|
|
106
|
+
# Input must either be a scalar or a 1D tensor with the correct len
|
|
107
|
+
t = torch.tensor(t).squeeze()
|
|
108
|
+
if t.ndim == 0:
|
|
109
|
+
return torch.full((pos.shape[0], ), t)
|
|
110
|
+
elif t.ndim == 1:
|
|
111
|
+
assert t.numel() == pos.shape[0]
|
|
112
|
+
return t
|
|
113
|
+
else:
|
|
114
|
+
assert False
|
|
115
|
+
|
|
116
|
+
self.voxel_pos = pos
|
|
117
|
+
self.PD = expand(PD)
|
|
118
|
+
self.T1 = expand(T1)
|
|
119
|
+
self.T2 = expand(T2)
|
|
120
|
+
self.T2dash = expand(T2dash)
|
|
121
|
+
self.D = expand(D)
|
|
122
|
+
self.B0 = expand(B0)
|
|
123
|
+
self.B1 = expand(B1)
|
|
124
|
+
self.voxel_shape = voxel_shape
|
|
125
|
+
self.voxel_size = voxel_size
|
|
126
|
+
|
|
127
|
+
def build(self) -> SimData:
|
|
128
|
+
"""Build a :class:`SimData` instance for simulation."""
|
|
129
|
+
# TODO: until the dephasing func fix is here, this only works on the
|
|
130
|
+
# device self.voxel_size happens to be on
|
|
131
|
+
size = self.voxel_pos.max(0).values - self.voxel_pos.min(0).values
|
|
132
|
+
|
|
133
|
+
return SimData(
|
|
134
|
+
self.PD,
|
|
135
|
+
self.T1,
|
|
136
|
+
self.T2,
|
|
137
|
+
self.T2dash,
|
|
138
|
+
self.D,
|
|
139
|
+
self.B0,
|
|
140
|
+
self.B1[None, :],
|
|
141
|
+
torch.ones(1, self.PD.numel()),
|
|
142
|
+
size,
|
|
143
|
+
self.voxel_pos,
|
|
144
|
+
torch.tensor([float('inf'), float('inf'), float('inf')]),
|
|
145
|
+
build_dephasing_func(self.voxel_shape, self.voxel_size),
|
|
146
|
+
recover_func=lambda d: recover(self.voxel_size, self.voxel_shape, d)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def generate_PD_map(self) -> torch.Tensor:
|
|
150
|
+
"""Convenience function for MRTwin_pulseq to generate a PD map."""
|
|
151
|
+
return self.generate_maps([self.PD, ])[0].abs()[:, :, None]
|
|
152
|
+
|
|
153
|
+
def generate_maps(self, props) -> list[torch.Tensor]:
|
|
154
|
+
# Best way to accurately plot this is to generate a k-space -> FFT
|
|
155
|
+
# We only render a 2D image with a FOV of 1
|
|
156
|
+
kx, ky = torch.meshgrid(
|
|
157
|
+
torch.linspace(-64, 63, 128),
|
|
158
|
+
torch.linspace(-64, 63, 128),
|
|
159
|
+
)
|
|
160
|
+
trajectory = torch.stack([
|
|
161
|
+
kx.flatten(),
|
|
162
|
+
ky.flatten(),
|
|
163
|
+
torch.zeros(kx.numel()),
|
|
164
|
+
], dim=1)
|
|
165
|
+
|
|
166
|
+
# All voxels have the same shape -> same intra-voxel dephasing
|
|
167
|
+
dephasing = build_dephasing_func(
|
|
168
|
+
self.voxel_shape, self.voxel_size
|
|
169
|
+
)(trajectory, None)
|
|
170
|
+
|
|
171
|
+
kspaces = [torch.zeros(128, 128, dtype=torch.cfloat) for _ in range(len(props))]
|
|
172
|
+
# Iterate over all voxels and render them into the kspaces
|
|
173
|
+
for pos in self.voxel_pos:
|
|
174
|
+
rot = torch.exp(-2j*pi * (trajectory @ pos))
|
|
175
|
+
|
|
176
|
+
for i in range(len(props)):
|
|
177
|
+
kspaces[i] += props[i] * (rot * dephasing).view(128, 128)
|
|
178
|
+
|
|
179
|
+
# FFT the rendered k-spaces to get the maps
|
|
180
|
+
norm = self.voxel_size[0] * self.voxel_size[1] # 2D plot, ignore thickness
|
|
181
|
+
return [
|
|
182
|
+
torch.fft.fftshift(torch.fft.ifft2(kspace * norm, norm="forward"))
|
|
183
|
+
for kspace in kspaces
|
|
184
|
+
]
|
|
185
|
+
|
|
186
|
+
def plot(self) -> None:
|
|
187
|
+
"""Print and plot all data stored in this phantom."""
|
|
188
|
+
maps = self.generate_maps([self.PD, self.T1, self.T2, self.T2dash, self.D])
|
|
189
|
+
titles = ["PD", "T1", "T2", "T2'", "D"]
|
|
190
|
+
|
|
191
|
+
print("CustomVoxelPhantom")
|
|
192
|
+
print(f"Voxel shape: {self.voxel_shape}")
|
|
193
|
+
print(f"Voxel size: {self.voxel_size}")
|
|
194
|
+
plt.figure(figsize=(12, 6))
|
|
195
|
+
for i in range(5):
|
|
196
|
+
plt.subplot(231 + i)
|
|
197
|
+
plt.title(titles[i])
|
|
198
|
+
plt.imshow(maps[i].abs().T, origin="lower", vmin=0)
|
|
199
|
+
plt.xticks([-0.5, 63.5, 127.5], [-1, 0, 1])
|
|
200
|
+
plt.yticks([-0.5, 63.5, 127.5], [-1, 0, 1])
|
|
201
|
+
plt.grid()
|
|
202
|
+
plt.colorbar()
|
|
203
|
+
plt.show()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# TODO: dephasing funcs can store tensors that need to switch device on demand.
|
|
207
|
+
# Maybe it is necessary to make a dephasing func class that can do that.
|
|
208
|
+
def build_dephasing_func(shape: str, size: torch.Tensor,
|
|
209
|
+
) -> Callable[[torch.Tensor, torch.Tensor], torch.Tensor]:
|
|
210
|
+
"""Helper function to get the correct dephasing function."""
|
|
211
|
+
if shape == "sinc":
|
|
212
|
+
return lambda t, _: sigmoid(t, size)
|
|
213
|
+
elif shape == "exact_sinc":
|
|
214
|
+
return lambda t, _: heaviside(t, size)
|
|
215
|
+
elif shape == "box":
|
|
216
|
+
return lambda t, _: sinc(t, size)
|
|
217
|
+
elif shape == "gauss":
|
|
218
|
+
return lambda t, _: gauss(t, size)
|
|
219
|
+
else:
|
|
220
|
+
raise ValueError("shape not implemented:", self.voxel_shape)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def recover(voxel_size: torch.Tensor, voxel_shape: str, sim_data: SimData
|
|
224
|
+
) -> CustomVoxelPhantom:
|
|
225
|
+
"""Provided to :class:`SimData` to reverse the ``build()``"""
|
|
226
|
+
# We don't recover B0, B1 and coil_sens per design - but that could change
|
|
227
|
+
return CustomVoxelPhantom(
|
|
228
|
+
# These can be taken directly from the phantom
|
|
229
|
+
sim_data.voxel_pos.cpu(),
|
|
230
|
+
sim_data.PD.cpu(),
|
|
231
|
+
sim_data.T1.cpu(),
|
|
232
|
+
sim_data.T2.cpu(),
|
|
233
|
+
sim_data.T2dash.cpu(),
|
|
234
|
+
sim_data.D.cpu(),
|
|
235
|
+
0, # B0
|
|
236
|
+
1, # B1
|
|
237
|
+
# These must be provided (captured in the recover_func lambda)
|
|
238
|
+
voxel_size,
|
|
239
|
+
voxel_shape
|
|
240
|
+
)
|