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.
File without changes
File without changes
@@ -0,0 +1,78 @@
1
+ import numpy as np
2
+ import nibabel as nib
3
+
4
+ from nibabel.processing import resample_from_to
5
+ from .utils import compute_parcel_damage
6
+
7
+
8
+ def get_parcel_damage(
9
+ lesion,
10
+ atlas,
11
+ atlas_labels,
12
+ parcel_column,
13
+ out_path = None
14
+ ):
15
+ """Compute lesion damage for each parcel of an atlas.
16
+
17
+ The lesion is resampled to the atlas space, and the proportion of
18
+ each parcel overlapping with the lesion is computed. A parcel damage
19
+ map is created, where each parcel voxel is assigned the damage proportion
20
+ of its parcel.
21
+
22
+ Parameters
23
+ ----------
24
+ lesion : nibabel.Nifti1Image
25
+ NIfTI lesion mask. Voxels with values greater than zero are
26
+ considered lesioned.
27
+ atlas : nibabel.Nifti1Image
28
+ NIfTI parcellation atlas containing integer parcel labels.
29
+ atlas_labels : pandas.DataFrame
30
+ DataFrame containing the parcel labels corresponding to the
31
+ values in ``parcel_column``.
32
+ parcel_column : str
33
+ Name of the column in ``atlas_labels`` containing the parcel
34
+ labels used in the atlas.
35
+ out_path : pathlib.Path | None, optional
36
+ Directory where the parcel damage map and damage DataFrame are
37
+ saved. If None, no files are saved.
38
+
39
+ Returns
40
+ -------
41
+ pandas.DataFrame
42
+ Copy of ``atlas_labels`` with an additional ``PercentDamage``
43
+ column containing the proportion of each parcel affected by the
44
+ lesion.
45
+ """
46
+
47
+ lesion_resampled = resample_from_to(lesion,
48
+ atlas,
49
+ order=1)
50
+
51
+ lesion_volume = lesion_resampled.get_fdata()
52
+ atlas_volume = atlas.get_fdata().astype(np.int32)
53
+
54
+ parcel_damages = []
55
+ parcel_damage_map = np.zeros_like(atlas_volume, dtype=np.float32)
56
+
57
+ for parcel in atlas_labels[parcel_column]:
58
+ parcel_volume = atlas_volume == parcel
59
+ parcel_damage = compute_parcel_damage(parcel_volume, lesion_volume)
60
+
61
+ parcel_damages.append(parcel_damage)
62
+ parcel_damage_map[parcel_volume] = np.round(parcel_damage, 5)
63
+
64
+ if out_path:
65
+ out_path.mkdir(parents=True, exist_ok=True)
66
+
67
+ nib.save(
68
+ nib.Nifti1Image(parcel_damage_map, atlas.affine, atlas.header),
69
+ out_path / "parcel_damage_map.nii.gz"
70
+ )
71
+
72
+ damage_frame = atlas_labels.copy(deep=True)
73
+ damage_frame["PercentDamage"] = parcel_damages
74
+ if out_path:
75
+ damage_frame.to_csv(out_path / 'parcel_damage_frame.csv', index=False)
76
+
77
+ return damage_frame
78
+
@@ -0,0 +1,30 @@
1
+ import numpy as np
2
+
3
+ def compute_parcel_damage(
4
+ parcel_volume,
5
+ lesion_volume
6
+ ):
7
+ """Compute the percentage of overlap of a lesion on a parcel.
8
+
9
+ Parameters
10
+ ----------
11
+ parcel_volume : numpy.ndarray
12
+ Binary or boolean mask representing the voxels belonging to the
13
+ parcel.
14
+ lesion_volume : numpy.ndarray
15
+ Lesion mask. Voxels with values greater than zero are considered
16
+ lesioned.
17
+
18
+ Returns
19
+ -------
20
+ float
21
+ Proportion of parcel voxels overlapping with the lesion, between
22
+ 0 and 1. Returns 0 if the parcel contains no voxels.
23
+ """
24
+
25
+ parcel_size = np.count_nonzero(parcel_volume)
26
+ if parcel_size == 0:
27
+ return 0
28
+ overlap = np.count_nonzero(parcel_volume & (lesion_volume > 0))
29
+
30
+ return overlap / parcel_size
File without changes
@@ -0,0 +1,190 @@
1
+ import numpy as np
2
+ import networkx as nx
3
+ import pickle
4
+
5
+ from scipy.sparse import csr_matrix
6
+ from scipy.sparse.csgraph import shortest_path
7
+ from nibabel.processing import resample_from_to
8
+ from .utils import create_structural_connectivity
9
+
10
+ def get_parcel_wise_disconnection_severity(
11
+ aggregated_tracts,
12
+ aggregated_tracts_disconnected,
13
+ atlas,
14
+ out_path = None
15
+ ):
16
+ """Compute parcel-wise structural disconnection severity.
17
+
18
+ Structural connectivity is computed for all streamlines and for
19
+ disconnected streamlines separately. The disconnection severity is
20
+ then calculated as the proportion of connections that are disconnected
21
+ for each pair of atlas parcels.
22
+
23
+ Parameters
24
+ ----------
25
+ aggregated_tracts : StatefulTractogram
26
+ Tractogram containing all aggregated streamlines.
27
+ aggregated_tracts_disconnected : StatefulTractogram
28
+ Tractogram containing the subset of aggregated streamlines that
29
+ are disconnected.
30
+ atlas : nib.Nifti1Image
31
+ Parcellation atlas. The atlas is resampled to the tractogram
32
+ reference grid using nearest-neighbor interpolation.
33
+ out_path : Path | None, optional
34
+ Directory where the structural connectivity matrices and
35
+ disconnection measures are saved. If None, no files are saved.
36
+
37
+ Returns
38
+ -------
39
+ tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray, numpy.ndarray]
40
+ A tuple containing:
41
+
42
+ - structural_connectivity : numpy.ndarray
43
+ Structural connectivity matrix computed from all streamlines.
44
+ - structural_connectivity_disconnected : numpy.ndarray
45
+ Structural connectivity matrix computed from disconnected
46
+ streamlines.
47
+ - disconnections_severity : numpy.ndarray
48
+ Proportion of connections that are disconnected for each
49
+ pair of parcels.
50
+ - per_spared_connections : numpy.ndarray
51
+ Proportion of connections that remain spared for each pair
52
+ of parcels.
53
+ """
54
+
55
+ if out_path:
56
+ out_path.mkdir(parents=True, exist_ok=True)
57
+ sc_path = out_path / "structural_connectivity"
58
+ sc_disconnected_path = out_path / "structural_connectivity_disconnected"
59
+ else:
60
+ sc_path = None
61
+ sc_disconnected_path = None
62
+
63
+ atlas_resampled = resample_from_to(atlas,
64
+ aggregated_tracts.space_attributes[1::-1],
65
+ order=0)
66
+
67
+ structural_connectivity = create_structural_connectivity(tract=aggregated_tracts,
68
+ atlas_resampled=atlas_resampled,
69
+ out_path=sc_path)
70
+ structural_connectivity_disconnected = create_structural_connectivity(tract=aggregated_tracts_disconnected,
71
+ atlas_resampled=atlas_resampled,
72
+ out_path=sc_disconnected_path)
73
+
74
+ disconnections_severity = np.divide(structural_connectivity_disconnected,
75
+ structural_connectivity,
76
+ out=np.zeros_like(structural_connectivity, dtype=float),
77
+ where=structural_connectivity != 0
78
+ )
79
+
80
+ per_spared_connections = np.divide(
81
+ structural_connectivity-structural_connectivity_disconnected,
82
+ structural_connectivity,
83
+ out=np.zeros_like(structural_connectivity, dtype=float),
84
+ where=structural_connectivity != 0
85
+ )
86
+
87
+ if out_path:
88
+ np.save((out_path / "structural_connectivity.npy"), structural_connectivity)
89
+ np.save((out_path / "structural_connectivity_disconnected.npy"), structural_connectivity_disconnected)
90
+ np.save((out_path / "disconnections_severity.npy"), disconnections_severity)
91
+ np.save((out_path / "per_spared_connections.npy"), per_spared_connections)
92
+
93
+ return (structural_connectivity,
94
+ structural_connectivity_disconnected,
95
+ disconnections_severity,
96
+ per_spared_connections)
97
+
98
+
99
+ def get_sspl_increase(
100
+ structural_connectivity,
101
+ per_spared_connections,
102
+ out_path = None,
103
+ spared_threshold = 0.5
104
+ ):
105
+
106
+ """Compute the increase in shortest structural path length after damage.
107
+
108
+ The structural connectivity matrix is converted to a binary graph.
109
+ Connections are considered spared when their proportion of
110
+ remaining streamlines exceeds ``spared_threshold``. Shortest path
111
+ lengths are computed for the full and spared connectivity graphs,
112
+ and their difference is used to quantify the increase in shortest
113
+ structural path length.
114
+
115
+ Parameters
116
+ ----------
117
+ structural_connectivity : numpy.ndarray
118
+ Structural connectivity matrix representing the intact network.
119
+ Non-zero entries are treated as existing connections.
120
+ per_spared_connections : numpy.ndarray
121
+ Matrix containing the proportion of streamlines spared for each
122
+ connection.
123
+ out_path : Path | None, optional
124
+ Directory where the shortest path matrices and their differences
125
+ are saved. If None, no files are saved.
126
+ spared_threshold : float, optional
127
+ Minimum proportion of spared streamlines required for a connection
128
+ to be considered present in the spared network. Defaults to 0.5.
129
+
130
+ Returns
131
+ -------
132
+ tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray, numpy.ndarray]
133
+ A tuple containing:
134
+
135
+ - SSPL_full_connections : numpy.ndarray
136
+ Shortest structural path lengths in the intact connectivity
137
+ network.
138
+ - SSPL_spared_connections : numpy.ndarray
139
+ Shortest structural path lengths in the spared connectivity
140
+ network.
141
+ - SSPL_increase : numpy.ndarray
142
+ Increase in shortest structural path length between the intact
143
+ and spared networks.
144
+ - SSPL_indirect_increase : numpy.ndarray
145
+ SSPL increase for indirect connections only. Direct connections
146
+ are set to zero.
147
+ """
148
+
149
+
150
+ # fix inf values, maybe set them to max+1 as it is done for pathway that completely disconnects
151
+
152
+ binary_structural_connectivity = structural_connectivity > 0
153
+ binary_spared_connections = per_spared_connections > spared_threshold
154
+
155
+ structural_connectivity_graph = csr_matrix(binary_structural_connectivity)
156
+ spared_connections_graph = csr_matrix(binary_spared_connections)
157
+
158
+ # selects automatically the correct algorithm (Dijkstra, Floyd-Warshall, Bellman-Ford, Johnson).
159
+ # Only Dijkstra or Floyd-Warshall will be used since the graph should be only positive
160
+ SSPL_full_connections = shortest_path(
161
+ structural_connectivity_graph,
162
+ directed=False,
163
+ unweighted=True)
164
+ max_SSPL = np.max(SSPL_full_connections[np.isfinite(SSPL_full_connections)])
165
+ SSPL_full_connections[
166
+ np.isinf(SSPL_full_connections)
167
+ ] = max_SSPL + 1
168
+
169
+ direct_connections = (SSPL_full_connections == 1)
170
+
171
+ SSPL_spared_connections = shortest_path(
172
+ spared_connections_graph,
173
+ directed=False,
174
+ unweighted=True)
175
+ SSPL_spared_connections[
176
+ np.isinf(SSPL_spared_connections)
177
+ ] = max_SSPL + 1
178
+
179
+ SSPL_increase = SSPL_spared_connections - SSPL_full_connections
180
+ SSPL_indirect_increase = SSPL_increase.copy()
181
+ SSPL_indirect_increase[~direct_connections] = 0.0
182
+
183
+ if out_path:
184
+ out_path.mkdir(parents=True, exist_ok=True)
185
+ np.save((out_path / "SSPL_full_connections.npy"), SSPL_full_connections)
186
+ np.save((out_path / "SSPL_spared_connections.npy"), SSPL_spared_connections)
187
+ np.save((out_path / "SSPL_increase.npy"), SSPL_increase)
188
+ np.save((out_path / "SSPL_indirect_increase.npy"), SSPL_indirect_increase)
189
+
190
+ return SSPL_full_connections, SSPL_spared_connections, SSPL_increase, SSPL_indirect_increase
@@ -0,0 +1,104 @@
1
+ import numpy as np
2
+ import networkx as nx
3
+ import dipy.io.stateful_surface
4
+
5
+ from scipy.spatial.distance import cdist
6
+ from scipy import ndimage
7
+ from dipy.io.stateful_tractogram import StatefulTractogram
8
+
9
+ def create_structural_connectivity(
10
+ tract,
11
+ atlas_resampled,
12
+ threshold=5.0,
13
+ out_path=None
14
+ ):
15
+ """Compute a structural connectivity matrix from a tractogram and atlas.
16
+
17
+ Streamline endpoints are assigned to the first parcel crossed when
18
+ following the streamline from the endpoint inward. Streamlines are
19
+ only included if both endpoints are within ``threshold`` of a parcel.
20
+
21
+ Parameters
22
+ ----------
23
+ tract : StatefulTractogram
24
+ Tractogram containing the streamlines used to estimate structural
25
+ connectivity.
26
+ atlas_resampled : nibabel.Nifti1Image
27
+ Parcellation atlas resampled to the same space and grid as the
28
+ tractogram.
29
+ threshold : float | None, optional
30
+ Maximum distance along the streamline from each endpoint to the
31
+ first crossed parcel. If None, no distance threshold is applied.
32
+ out_path : pathlib.Path | None, optional
33
+ Path where the structural connectivity matrix should be saved
34
+ as a NumPy array. If None, the matrix is not saved.
35
+
36
+ Returns
37
+ -------
38
+ numpy.ndarray
39
+ Symmetric structural connectivity matrix with the background
40
+ label removed.
41
+ """
42
+ atlas = atlas_resampled.get_fdata().astype(int)
43
+ atlas_affine_inv = np.linalg.inv(atlas_resampled.affine)
44
+
45
+ n_parcels = int(np.max(atlas))
46
+
47
+ sc = np.zeros((n_parcels, n_parcels), dtype=np.int32)
48
+
49
+ for streamline in tract.streamlines:
50
+
51
+ points = np.asarray(streamline)
52
+ points_h = np.column_stack((points, np.ones(len(points))))
53
+
54
+ voxel_coords = (atlas_affine_inv @ points_h.T).T[:, :3]
55
+ voxel_coords = np.rint(voxel_coords).astype(int)
56
+
57
+ # Keep only points inside the atlas volume
58
+ valid = np.all((voxel_coords >= 0) & (voxel_coords < np.array(atlas.shape)), axis=1)
59
+
60
+ parcel_labels = np.zeros(len(points), dtype=int)
61
+ parcel_labels[valid] = atlas[
62
+ voxel_coords[valid, 0],
63
+ voxel_coords[valid, 1],
64
+ voxel_coords[valid, 2]
65
+ ]
66
+
67
+ endpoints = []
68
+
69
+ for indices in (range(len(points)), range(len(points) - 1, -1, -1)):
70
+
71
+ distance = 0.0
72
+ parcel = 0
73
+ previous = None
74
+
75
+ for i in indices:
76
+ if previous is not None:
77
+ distance += np.linalg.norm(
78
+ points[i] - points[previous]
79
+ )
80
+ if parcel_labels[i] > 0:
81
+ parcel = parcel_labels[i]
82
+ break
83
+ previous = i
84
+ endpoints.append((parcel, distance))
85
+
86
+ p1, d1 = endpoints[0]
87
+ p2, d2 = endpoints[1]
88
+
89
+ if p1 == 0 or p2 == 0:
90
+ continue
91
+ if threshold is not None and (d1 > threshold or d2 > threshold):
92
+ continue
93
+
94
+ p1 -= 1
95
+ p2 -= 1
96
+ sc[p1, p2] += 1
97
+ if p1 != p2:
98
+ sc[p2, p1] += 1
99
+
100
+ if out_path:
101
+ np.save(out_path, sc)
102
+
103
+ return sc
104
+
File without changes
@@ -0,0 +1,137 @@
1
+ import nibabel as nib
2
+ import pandas as pd
3
+ import json
4
+
5
+ from pathlib import Path
6
+
7
+ def atlas_cfg_read(atlas_cfg_path):
8
+ """Read an atlas configuration file and load the associated data.
9
+
10
+ Parameters
11
+ ----------
12
+ atlas_cfg_path : Path
13
+ Path to the .json atlas configuration file. The configuration must
14
+ contain the following fields:
15
+
16
+ - 'name':
17
+ a string naming the atlas
18
+ - 'atlas':
19
+ path to the nifti atlas file, relative to the folder
20
+ containing the config file
21
+ - 'labels':
22
+ path to the csv containing the labels and their associated
23
+ parcel name, relative to the folder containing the config file
24
+ - 'space':
25
+ standard space name available in templateflow or path
26
+ to reference nifti relative to the folder containing the config file
27
+
28
+ Returns
29
+ -------
30
+ tuple[str, nibabel.Nifti1Image, pandas.DataFrame, str]
31
+ A tuple containing:
32
+
33
+ - atlas_name : str
34
+ Name of the atlas.
35
+ - atlas : nibabel.Nifti1Image
36
+ Loaded atlas image.
37
+ - atlas_labels : pandas.DataFrame
38
+ DataFrame containing the atlas parcel labels.
39
+ - atlas_space : str
40
+ Space in which the atlas is defined.
41
+ """
42
+ with atlas_cfg_path.open("r") as f:
43
+ atlas_cfg = json.load(f)
44
+
45
+ atlas_name = atlas_cfg.get("name")
46
+ atlas_path = atlas_cfg_path.parent / atlas_cfg.get("atlas")
47
+ atlas_labels_path = atlas_cfg_path.parent / atlas_cfg.get("labels")
48
+ atlas_space = atlas_cfg.get("space")
49
+
50
+ atlas = nib.nifti1.load(atlas_path)
51
+ atlas_labels = pd.read_csv(atlas_labels_path)
52
+
53
+ return atlas_name, atlas, atlas_labels, atlas_space
54
+
55
+ def tracts_cfg_read(tracts_cfg_path):
56
+ """Read a tracts configuration file and load the associated data.
57
+
58
+ Parameters
59
+ ----------
60
+ tracts_cfg_path : Path
61
+ Path to the .json tracts configuration file. The configuration must
62
+ contain the following fields:
63
+
64
+ - 'name':
65
+ a string naming the tracts
66
+ - 'tracts_folder':
67
+ path to the folder containing .trk files, relative to
68
+ the folder containing the config file
69
+ - 'references_folder':
70
+ path to the folder containing nifti reference files
71
+ of the tracts, relative to the folder containing the config file
72
+ - 'space':
73
+ standard space name available in templateflow or path
74
+ to reference nifti relative to the folder containing the config file
75
+
76
+ Returns
77
+ -------
78
+ tuple[str, pathlib.Path, pathlib.Path, str]
79
+ A tuple containing:
80
+
81
+ - tracts_name : str
82
+ Name of the tracts.
83
+ - tracts_folder : pathlib.Path
84
+ Path to folder containing .trk tract files.
85
+ - references_folder : pathlib.Path
86
+ Path to folder containing .nii tract reference files.
87
+ - tracts_space : str
88
+ Space in which the tracts are defined.
89
+ """
90
+ with tracts_cfg_path.open("r") as f:
91
+ tracts_cfg = json.load(f)
92
+
93
+ tracts_name = tracts_cfg.get("name")
94
+ tracts_folder = Path(tracts_cfg_path.parent / tracts_cfg.get("tracts"))
95
+ references_folder = Path(tracts_cfg_path.parent / tracts_cfg.get("references"))
96
+ tracts_space = tracts_cfg.get("space")
97
+
98
+ return tracts_name, tracts_folder, references_folder, tracts_space
99
+
100
+ def lesion_cfg_read(lesion_cfg_path: Path):
101
+ """Read a tracts configuration file and load the associated data.
102
+
103
+ Parameters
104
+ ----------
105
+ tracts_cfg_path : Path
106
+ Path to the .json tracts configuration file. The configuration must
107
+ contain the following fields:
108
+
109
+ - 'cohort_name':
110
+ a string naming the tracts
111
+ - 'tracts_folder':
112
+ path to the folder containing segemented lesion files,
113
+ relative to the folder containing the config file
114
+ - 'space':
115
+ standard space name available in templateflow or path
116
+ to reference nifti relative to the folder containing the config file
117
+
118
+ Returns
119
+ -------
120
+ tuple[str, pathlib.Path, pathlib.Path, str]
121
+ A tuple containing:
122
+
123
+ - lesion_cohort_name : str
124
+ Name of the lesion cohort.
125
+ - lesion_folder : pathlib.Path
126
+ Path to folder containing nifti segmented lesion files.
127
+ - lesion_space : str
128
+ Space in which the lesions are defined.
129
+ """
130
+ with lesion_cfg_path.open("r") as f:
131
+ lesion_cfg = json.load(f)
132
+
133
+ lesion_cohort_name = lesion_cfg.get("cohort_name")
134
+ lesion_folder = Path(lesion_cfg_path.parent / lesion_cfg.get("lesion_folder"))
135
+ lesion_space = lesion_cfg.get("space")
136
+
137
+ return lesion_cohort_name, lesion_folder, lesion_space