mrzerocore 0.4.3__cp37-abi3-musllinux_1_2_armv7l.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 (41) hide show
  1. MRzeroCore/__init__.py +22 -0
  2. MRzeroCore/_prepass.abi3.so +0 -0
  3. MRzeroCore/phantom/brainweb/.gitignore +1 -0
  4. MRzeroCore/phantom/brainweb/__init__.py +192 -0
  5. MRzeroCore/phantom/brainweb/brainweb_data.json +92 -0
  6. MRzeroCore/phantom/brainweb/brainweb_data_sources.txt +74 -0
  7. MRzeroCore/phantom/brainweb/output/.gitkeep +0 -0
  8. MRzeroCore/phantom/custom_voxel_phantom.py +240 -0
  9. MRzeroCore/phantom/nifti_phantom.py +210 -0
  10. MRzeroCore/phantom/sim_data.py +200 -0
  11. MRzeroCore/phantom/tissue_dict.py +269 -0
  12. MRzeroCore/phantom/voxel_grid_phantom.py +610 -0
  13. MRzeroCore/pulseq/exporter.py +374 -0
  14. MRzeroCore/pulseq/exporter_v2.py +650 -0
  15. MRzeroCore/pulseq/helpers.py +228 -0
  16. MRzeroCore/pulseq/pulseq_exporter.py +553 -0
  17. MRzeroCore/pulseq/pulseq_loader/__init__.py +66 -0
  18. MRzeroCore/pulseq/pulseq_loader/adc.py +48 -0
  19. MRzeroCore/pulseq/pulseq_loader/helpers.py +75 -0
  20. MRzeroCore/pulseq/pulseq_loader/pulse.py +80 -0
  21. MRzeroCore/pulseq/pulseq_loader/pulseq_file/__init__.py +235 -0
  22. MRzeroCore/pulseq/pulseq_loader/pulseq_file/adc.py +68 -0
  23. MRzeroCore/pulseq/pulseq_loader/pulseq_file/block.py +98 -0
  24. MRzeroCore/pulseq/pulseq_loader/pulseq_file/definitons.py +68 -0
  25. MRzeroCore/pulseq/pulseq_loader/pulseq_file/gradient.py +70 -0
  26. MRzeroCore/pulseq/pulseq_loader/pulseq_file/helpers.py +156 -0
  27. MRzeroCore/pulseq/pulseq_loader/pulseq_file/rf.py +91 -0
  28. MRzeroCore/pulseq/pulseq_loader/pulseq_file/trap.py +69 -0
  29. MRzeroCore/pulseq/pulseq_loader/spoiler.py +33 -0
  30. MRzeroCore/reconstruction.py +104 -0
  31. MRzeroCore/sequence.py +747 -0
  32. MRzeroCore/simulation/isochromat_sim.py +254 -0
  33. MRzeroCore/simulation/main_pass.py +286 -0
  34. MRzeroCore/simulation/pre_pass.py +192 -0
  35. MRzeroCore/simulation/sig_to_mrd.py +362 -0
  36. MRzeroCore/util.py +884 -0
  37. MRzeroCore.libs/libgcc_s-262c4f60.so.1 +0 -0
  38. mrzerocore-0.4.3.dist-info/METADATA +121 -0
  39. mrzerocore-0.4.3.dist-info/RECORD +41 -0
  40. mrzerocore-0.4.3.dist-info/WHEEL +4 -0
  41. 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
+ )