pyLQT 1.0.dev1__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.
@@ -0,0 +1,250 @@
1
+ import zipfile
2
+ import json
3
+ import warnings
4
+ import requests
5
+ import shutil
6
+ import gzip
7
+ import nibabel as nib
8
+ import numpy as np
9
+
10
+ from templateflow import api as tf
11
+ from pathlib import Path
12
+
13
+ def create_tract_list(
14
+ references_folder,
15
+ tracts_folder
16
+ ):
17
+ """Create paired lists of tractogram and reference image paths.
18
+
19
+ Tractograms and their corresponding NIfTI reference images are matched
20
+ based on their relative paths within their respective folders. The
21
+ function also checks that all reference images share the same voxel
22
+ grid and affine.
23
+
24
+ Parameters
25
+ ----------
26
+ references_folder : Path
27
+ Root directory containing the NIfTI reference images.
28
+ tracts_folder : Path
29
+ Root directory containing the tractogram files.
30
+
31
+ Returns
32
+ -------
33
+ tuple[list[Path], list[Path]]
34
+ Two lists containing:
35
+
36
+ - tracts_pathlist : list[Path]
37
+ Paths to the tractogram files.
38
+ - references_pathlist : list[Path]
39
+ Paths to the corresponding NIfTI reference images.
40
+
41
+ Only tractograms with a corresponding NIfTI reference are included.
42
+ Warnings are issued for missing pairs and for reference images with
43
+ mismatched grids or affines.
44
+ """
45
+ trk_paths = sorted(tracts_folder.rglob("*.trk.gz"))
46
+ nifti_paths = sorted(references_folder.rglob("*.nii.gz"))
47
+
48
+ # Check that every TRK has a corresponding NIfTI
49
+ nifti_relatives = {
50
+ p.relative_to(references_folder)
51
+ for p in nifti_paths
52
+ }
53
+
54
+ for trk_path in trk_paths:
55
+ trk_relative = trk_path.relative_to(tracts_folder)
56
+ expected_nifti = Path(
57
+ str(trk_relative).replace(".trk.gz", ".nii.gz")
58
+ )
59
+
60
+ if expected_nifti not in nifti_relatives:
61
+ warnings.warn(f"No NIfTI found for {expected_nifti}")
62
+
63
+ # Check that every NIfTI has a corresponding TRK
64
+ trk_relatives = {
65
+ p.relative_to(tracts_folder)
66
+ for p in trk_paths
67
+ }
68
+
69
+ for nifti_path in nifti_paths:
70
+ nifti_relative = nifti_path.relative_to(references_folder)
71
+ expected_trk = Path(
72
+ str(nifti_relative).replace(".nii.gz", ".trk.gz")
73
+ )
74
+
75
+ if expected_trk not in trk_relatives:
76
+ warnings.warn(f"No TRK found for {expected_trk}")
77
+
78
+ tracts_pathlist = []
79
+ references_pathlist = []
80
+
81
+ for trk_path in trk_paths:
82
+ trk_relative = trk_path.relative_to(tracts_folder)
83
+
84
+ nifti_path = (
85
+ references_folder
86
+ / Path(str(trk_relative).replace(".trk.gz", ".nii.gz"))
87
+ )
88
+
89
+ if not nifti_path.exists():
90
+ continue
91
+
92
+ tracts_pathlist.append(trk_path)
93
+ references_pathlist.append(nifti_path)
94
+
95
+ # Verify that all NIfTIs are on the same grid
96
+ reference = None
97
+
98
+ for nifti_path in references_pathlist:
99
+ nifti = nib.nifti1.load(nifti_path)
100
+
101
+ if reference is None:
102
+ reference = nifti
103
+ else:
104
+ if nifti.shape != reference.shape:
105
+ warnings.warn(f"NIfTI grid mismatch: {nifti_path}")
106
+ if not np.allclose(nifti.affine, reference.affine):
107
+ warnings.warn(f"NIfTI affine mismatch: {nifti_path}")
108
+
109
+ return tracts_pathlist, references_pathlist
110
+
111
+ def uncompress_trk_gz(
112
+ trk_path,
113
+ out_path
114
+ ):
115
+ """Uncompress a .trk file if needed and save it to the output path.
116
+
117
+ Parameters
118
+ ----------
119
+ trk_path : Path
120
+ Path to the input .trk or compressed .trk.gz file.
121
+ out_path : Path
122
+ Path where the uncompressed .trk file is saved.
123
+
124
+ Returns
125
+ -------
126
+ Path
127
+ Path to the resulting .trk file.
128
+ """
129
+ if trk_path.suffix == ".gz":
130
+ with gzip.open(trk_path, "rb") as f_in:
131
+ with open(out_path, "wb") as f_out:
132
+ shutil.copyfileobj(f_in, f_out)
133
+ else:
134
+ shutil.copyfile(trk_path, out_path)
135
+
136
+ return out_path
137
+
138
+ def default_download(out_folder: Path):
139
+ """Download the default HCP1065 tractography dataset and create its configuration.
140
+
141
+ The function downloads the HCP1065 average tractograms and their
142
+ corresponding NIfTI reference images, extracts them into the output
143
+ directory, and creates a JSON configuration file describing the
144
+ downloaded dataset. Existing folders are reused.
145
+
146
+ Parameters
147
+ ----------
148
+ out_folder : pathlib.Path
149
+ Directory where the HCP1065 dataset, reference images, and
150
+ configuration file are stored.
151
+
152
+ Returns
153
+ -------
154
+ pathlib.Path
155
+ Path to the generated HCP1065 configuration JSON file.
156
+ """
157
+
158
+ tracts_cfg_folder = out_folder / "HCP1065_cfg"
159
+ tracts_cfg_folder.mkdir(parents=True, exist_ok=True)
160
+
161
+ tracts_folder = tracts_cfg_folder / "hcp1065_avg_tracts_trk"
162
+
163
+ if tracts_folder.exists():
164
+ warnings.warn(
165
+ f"Tracts folder already exists, using it: {tracts_folder}"
166
+ )
167
+ else:
168
+ url = "https://github.com/data-others/atlas/releases/download/hcp1065/hcp1065_avg_tracts_trk.zip"
169
+ zip_path = out_folder / "hcp1065_avg_tracts_trk.zip"
170
+
171
+ response = requests.get(url, stream=True, timeout=60)
172
+ response.raise_for_status()
173
+
174
+ with zip_path.open("wb") as f:
175
+ for chunk in response.iter_content(chunk_size=1024*1024):
176
+ if chunk:
177
+ f.write(chunk)
178
+
179
+ with zipfile.ZipFile(zip_path) as zip_file:
180
+ zip_file.extractall(tracts_folder)
181
+
182
+ zip_path.unlink()
183
+
184
+ references_folder = tracts_cfg_folder / "hcp1065_avg_tracts"
185
+
186
+ if references_folder.exists():
187
+ warnings.warn(
188
+ f"References folder already exists, using it: {references_folder}"
189
+ )
190
+ else:
191
+ url = "https://github.com/data-others/atlas/releases/download/hcp1065/hcp1065_avg_tracts_nifti.zip"
192
+ zip_path = out_folder / "hcp1065_avg_tracts_nifti.zip"
193
+
194
+ response = requests.get(url, stream=True, timeout=60)
195
+ response.raise_for_status()
196
+
197
+ with zip_path.open("wb") as f:
198
+ for chunk in response.iter_content(chunk_size=1024*1024):
199
+ if chunk:
200
+ f.write(chunk)
201
+
202
+ with zipfile.ZipFile(zip_path) as zip_file:
203
+ zip_file.extractall(zip_path.parent)
204
+
205
+ zip_path.unlink()
206
+
207
+ references_folder = zip_path.parent / "nifti"
208
+ references_folder.rename(
209
+ zip_path.parent / "hcp1065_avg_tracts"
210
+ )
211
+
212
+ tracts_name = "HCP1065"
213
+ tracts_space = "MNI152NLin2009aAsym"
214
+
215
+ config = {"name": tracts_name,
216
+ "tracts": "hcp1065_avg_tracts_trk",
217
+ "references": "hcp1065_avg_tracts",
218
+ "space": tracts_space}
219
+
220
+ tracts_cfg = Path(tracts_cfg_folder / "HCP1065_config.json")
221
+
222
+ with open(tracts_cfg, "w") as f:
223
+ json.dump(config, f, indent=4)
224
+
225
+ return tracts_cfg
226
+
227
+ def get_ref_space_tf(space: str, res: int = 1):
228
+ """Load a templateflow T1w template and apply its brain mask.
229
+
230
+ Parameters
231
+ ----------
232
+ space : str
233
+ TemplateFlow template identifier.
234
+ res : int, optional
235
+ Template resolution to load. Defaults to 1.
236
+
237
+ Returns
238
+ -------
239
+ nibabel.Nifti1Image
240
+ T1w template with voxels outside the brain mask set to zero.
241
+ """
242
+ t1w_ref_path = tf.get(space, resolution=res, desc=None, suffix="T1w")
243
+ brain_mask_path = tf.get(space, resolution=res, desc="brain", suffix="mask")
244
+ t1w_ref = nib.load(t1w_ref_path)
245
+ brain_mask = nib.load(brain_mask_path)
246
+ ref_space_volume = t1w_ref.get_fdata().copy()
247
+ ref_space_volume[brain_mask.get_fdata() <= 0] = 0
248
+ ref_space = nib.Nifti1Image(ref_space_volume, t1w_ref.affine, t1w_ref.header)
249
+
250
+ return ref_space
@@ -0,0 +1,106 @@
1
+ from dipy.align.imaffine import AffineRegistration, MutualInformationMetric, transform_centers_of_mass
2
+ from dipy.align.transforms import TranslationTransform3D, RigidTransform3D, AffineTransform3D
3
+ from dipy.align.imwarp import SymmetricDiffeomorphicRegistration
4
+ from dipy.align.metrics import CCMetric
5
+ from dipy.align import write_mapping
6
+
7
+ def register_affine(
8
+ moving,
9
+ static,
10
+ out_path=None
11
+ ):
12
+ """Register a moving image to a static image using affine registration.
13
+
14
+ Parameters
15
+ ----------
16
+ moving : nibabel.Nifti1Image
17
+ Image to be registered to the static image.
18
+ static : nibabel.Nifti1Image
19
+ Reference image defining the target space for the registration.
20
+ out_path : pathlib.Path | None, optional
21
+ Path where the resulting registration mapping is saved. If None,
22
+ the mapping is not saved.
23
+
24
+ Returns
25
+ -------
26
+ AffineMap
27
+ Affine mapping from the moving image space to the static image space.
28
+ """
29
+ metric = MutualInformationMetric()
30
+
31
+ affreg = AffineRegistration(metric=metric)
32
+
33
+ transform = TranslationTransform3D()
34
+ translation = affreg.optimize(
35
+ static.get_fdata(),
36
+ moving.get_fdata(),
37
+ transform,
38
+ params0=None,
39
+ static_grid2world=static.affine,
40
+ moving_grid2world=moving.affine,
41
+ )
42
+
43
+ transform = RigidTransform3D()
44
+ rigid = affreg.optimize(
45
+ static.get_fdata(),
46
+ moving.get_fdata(),
47
+ transform,
48
+ starting_affine=translation.affine,
49
+ params0=None,
50
+ static_grid2world=static.affine,
51
+ moving_grid2world=moving.affine,
52
+ )
53
+
54
+ transform = AffineTransform3D()
55
+ mapping = affreg.optimize(
56
+ static.get_fdata(),
57
+ moving.get_fdata(),
58
+ transform,
59
+ starting_affine=rigid.affine,
60
+ params0=None,
61
+ static_grid2world=static.affine,
62
+ moving_grid2world=moving.affine,
63
+ )
64
+
65
+ if out_path:
66
+ write_mapping(mapping, out_path)
67
+
68
+ return mapping
69
+
70
+ def register_syn(
71
+ moving,
72
+ static,
73
+ out_path = None
74
+ ):
75
+ """Register a moving image to a static image using SyN registration,
76
+ with default settings from dipy.
77
+
78
+ Parameters
79
+ ----------
80
+ moving : nibabel.Nifti1Image
81
+ Image to be registered to the static image.
82
+ static : nibabel.Nifti1Image
83
+ Reference image defining the target space for the registration.
84
+ out_path : pathlib.Path | None, optional
85
+ Path where the resulting registration mapping is saved. If None,
86
+ the mapping is not saved.
87
+
88
+ Returns
89
+ -------
90
+ DiffeomorphicMap
91
+ Diffeomorphic mapping from the moving image space to the static
92
+ image space.
93
+ """
94
+ metric = CCMetric(dim=3)
95
+
96
+ syn = SymmetricDiffeomorphicRegistration(metric)
97
+
98
+ mapping = syn.optimize(static.get_fdata(),
99
+ moving.get_fdata(),
100
+ static_grid2world=static.affine,
101
+ moving_grid2world=moving.affine)
102
+
103
+ if out_path:
104
+ write_mapping(mapping, out_path)
105
+
106
+ return mapping
File without changes
@@ -0,0 +1,160 @@
1
+ import nibabel as nib
2
+ import pandas as pd
3
+ import numpy as np
4
+
5
+ from nibabel.processing import resample_from_to
6
+ from .utils import get_tract, get_tdi, get_streamlines_disconnection
7
+
8
+ from pathlib import Path
9
+
10
+
11
+ def get_tracts_disconnections_severity(
12
+ tracts_path,
13
+ references_path,
14
+ lesion,
15
+ disc_tracts_out_path = None,
16
+ disc_frame_out_path = None
17
+ ):
18
+
19
+ """Compute the proportion of disconnected streamlines for a list of tracts.
20
+
21
+ Parameters
22
+ ----------
23
+ tracts_path : list[Path]
24
+ Paths to the tractogram files in order.
25
+ references_path : list[Path] | Path
26
+ Paths to the NIfTI reference images corresponding to the tractograms.
27
+ If a single path is provided, it is used as the reference for all
28
+ tractograms. If a list, it should be in the same order as tracts_path.
29
+ lesion : nib.Nifti1Image
30
+ NIfTI lesion mask. Voxels with values greater than zero are considered
31
+ part of the lesion.
32
+ disc_tracts_out_path : Path | None, optional
33
+ Directory where tractograms containing the disconnected streamlines
34
+ are saved. If None, disconnected tractograms are not saved.
35
+ disc_frame_out_path : Path | None, optional
36
+ Directory where the resulting CSV file containing the disconnection
37
+ severity for each tract is saved. If None, the results are not saved.
38
+
39
+ Returns
40
+ -------
41
+ pandas.DataFrame
42
+ DataFrame containing the disconnection proportion for each tract,
43
+ together with its tract and pathway (parent directory) names. The ``Discon`` column
44
+ contains the proportion of streamlines intersecting the lesion.
45
+ """
46
+
47
+ if disc_tracts_out_path:
48
+ disc_tracts_out_path.mkdir(parents=True, exist_ok=True)
49
+ disconnections = []
50
+ if isinstance(references_path, Path):
51
+ references_path = [references_path] * len(tracts_path)
52
+ for tract_path, reference_path in zip(tracts_path, references_path):
53
+
54
+ tract_name = tract_path.name.removesuffix(".gz").removesuffix(".trk")
55
+ pathway_name = tract_path.parent.name
56
+
57
+ reference = nib.nifti1.load(reference_path)
58
+
59
+ # grid can differ between lesion and average tracts (due to voxel size notably),
60
+ # resample_from_to shifts/scales the grid of the lesion to match the one of the tract
61
+ lesion_resampled = resample_from_to(lesion,
62
+ reference,
63
+ order=1)
64
+
65
+ lesion_mask = lesion_resampled.get_fdata() > 0
66
+ tract_mask = reference.get_fdata() > 0
67
+
68
+ if not np.any(tract_mask & lesion_mask):
69
+ disconnections.append({
70
+ "Tract": tract_name,
71
+ "Discon": np.round(0.0, 3),
72
+ "Pathway": pathway_name,
73
+ })
74
+ continue
75
+
76
+ if disc_tracts_out_path:
77
+ disconnected_tract_path = disc_tracts_out_path / f"{tract_name}_disconnected.trk"
78
+ else:
79
+ disconnected_tract_path = None
80
+
81
+ tract = get_tract(tract_path=tract_path, reference_path=reference_path)
82
+ disconnected_tract, discon = get_streamlines_disconnection(tract=tract,
83
+ lesion=lesion,
84
+ out_path=disconnected_tract_path)
85
+
86
+ disconnections.append({
87
+ "Tract": tract_name,
88
+ "Discon": np.round(discon, 5),
89
+ "Pathway": pathway_name,
90
+ })
91
+
92
+ disconnections_frame = pd.DataFrame(disconnections)
93
+
94
+ if disc_frame_out_path:
95
+ disc_frame_out_path.mkdir(parents=True, exist_ok=True)
96
+ out_csv = disc_frame_out_path / "percent_discon_tract.csv"
97
+ disconnections_frame.to_csv(out_csv, index=False)
98
+
99
+ return disconnections_frame
100
+
101
+
102
+ def get_tracts_disconnections_map(
103
+ tracts,
104
+ tracts_disconnected,
105
+ out_path = None
106
+ ):
107
+ """Compute a voxel-wise tracts disconnection map.
108
+
109
+ The disconnection map is computed as the ratio of the tract density
110
+ of disconnected streamlines to the tract density of all streamlines.
111
+ Voxels with no streamlines are not considered.
112
+
113
+ Parameters
114
+ ----------
115
+ tracts : StatefulTractogram
116
+ Tractogram containing all streamlines.
117
+ tracts_disconnected : StatefulTractogram
118
+ Tractogram containing the streamlines disconnected.
119
+ out_path : Path | None, optional
120
+ Directory where the TDI images and disconnection map are saved.
121
+ If None, no files are saved.
122
+
123
+ Returns
124
+ -------
125
+ tuple[nib.Nifti1Image, nib.Nifti1Image, nib.Nifti1Image]
126
+ A tuple containing:
127
+
128
+ - disconnections_map : nib.Nifti1Image
129
+ Voxel-wise ratio of disconnected to total streamline density.
130
+ - tdi_aggregated : nib.Nifti1Image
131
+ Track density image of all streamlines.
132
+ - tdi_disconnected : nib.Nifti1Image
133
+ Track density image of disconnected streamlines.
134
+ """
135
+
136
+
137
+ if out_path:
138
+ out_path.mkdir(parents=True, exist_ok=True)
139
+ tdi_path = out_path / "tdi_mask.nii.gz"
140
+ tdi_disconnected_path = out_path / "tdi_disconnected_mask.nii.gz"
141
+ else:
142
+ tdi_path = None
143
+ tdi_disconnected_path = None
144
+
145
+ tdi_aggregated = get_tdi(tracts, out_path=tdi_path)
146
+ tdi_disconnected = get_tdi(tracts_disconnected, out_path=tdi_disconnected_path)
147
+
148
+ disconnections_map = np.divide(
149
+ tdi_disconnected.get_fdata(),
150
+ tdi_aggregated.get_fdata(),
151
+ out=np.zeros_like(tdi_aggregated.get_fdata(), dtype=float),
152
+ where=tdi_aggregated.get_fdata() != 0
153
+ )
154
+
155
+ disconnections_map = nib.Nifti1Image(disconnections_map, tdi_aggregated.affine, tdi_aggregated.header)
156
+
157
+ if out_path:
158
+ nib.nifti1.save(disconnections_map, (out_path / "disconnections_map.nii.gz"))
159
+
160
+ return disconnections_map, tdi_aggregated, tdi_disconnected