pyNIBS 0.2024.8__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.
- pyNIBS-0.2024.8.dist-info/LICENSE +623 -0
- pyNIBS-0.2024.8.dist-info/METADATA +723 -0
- pyNIBS-0.2024.8.dist-info/RECORD +107 -0
- pyNIBS-0.2024.8.dist-info/WHEEL +5 -0
- pyNIBS-0.2024.8.dist-info/top_level.txt +1 -0
- pynibs/__init__.py +34 -0
- pynibs/coil.py +1367 -0
- pynibs/congruence/__init__.py +15 -0
- pynibs/congruence/congruence.py +1108 -0
- pynibs/congruence/ext_metrics.py +257 -0
- pynibs/congruence/stimulation_threshold.py +318 -0
- pynibs/data/configuration_exp0.yaml +59 -0
- pynibs/data/configuration_linear_MEP.yaml +61 -0
- pynibs/data/configuration_linear_RT.yaml +61 -0
- pynibs/data/configuration_sigmoid4.yaml +68 -0
- pynibs/data/network mapping configuration/configuration guide.md +238 -0
- pynibs/data/network mapping configuration/configuration_TEMPLATE.yaml +42 -0
- pynibs/data/network mapping configuration/configuration_for_testing.yaml +43 -0
- pynibs/data/network mapping configuration/configuration_modelTMS.yaml +43 -0
- pynibs/data/network mapping configuration/configuration_reg_isi_05.yaml +43 -0
- pynibs/data/network mapping configuration/output_documentation.md +185 -0
- pynibs/data/network mapping configuration/recommendations_for_accuracy_threshold.md +77 -0
- pynibs/data/neuron/models/L23_PC_cADpyr_biphasic_v1.csv +1281 -0
- pynibs/data/neuron/models/L23_PC_cADpyr_monophasic_v1.csv +1281 -0
- pynibs/data/neuron/models/L4_LBC_biphasic_v1.csv +1281 -0
- pynibs/data/neuron/models/L4_LBC_monophasic_v1.csv +1281 -0
- pynibs/data/neuron/models/L4_NBC_biphasic_v1.csv +1281 -0
- pynibs/data/neuron/models/L4_NBC_monophasic_v1.csv +1281 -0
- pynibs/data/neuron/models/L4_SBC_biphasic_v1.csv +1281 -0
- pynibs/data/neuron/models/L4_SBC_monophasic_v1.csv +1281 -0
- pynibs/data/neuron/models/L5_TTPC2_cADpyr_biphasic_v1.csv +1281 -0
- pynibs/data/neuron/models/L5_TTPC2_cADpyr_monophasic_v1.csv +1281 -0
- pynibs/expio/Mep.py +1518 -0
- pynibs/expio/__init__.py +8 -0
- pynibs/expio/brainsight.py +979 -0
- pynibs/expio/brainvis.py +71 -0
- pynibs/expio/cobot.py +239 -0
- pynibs/expio/exp.py +1876 -0
- pynibs/expio/fit_funs.py +287 -0
- pynibs/expio/localite.py +1987 -0
- pynibs/expio/signal_ced.py +51 -0
- pynibs/expio/visor.py +624 -0
- pynibs/freesurfer.py +502 -0
- pynibs/hdf5_io/__init__.py +10 -0
- pynibs/hdf5_io/hdf5_io.py +1857 -0
- pynibs/hdf5_io/xdmf.py +1542 -0
- pynibs/mesh/__init__.py +3 -0
- pynibs/mesh/mesh_struct.py +1394 -0
- pynibs/mesh/transformations.py +866 -0
- pynibs/mesh/utils.py +1103 -0
- pynibs/models/_TMS.py +211 -0
- pynibs/models/__init__.py +0 -0
- pynibs/muap.py +392 -0
- pynibs/neuron/__init__.py +2 -0
- pynibs/neuron/neuron_regression.py +284 -0
- pynibs/neuron/util.py +58 -0
- pynibs/optimization/__init__.py +5 -0
- pynibs/optimization/multichannel.py +278 -0
- pynibs/optimization/opt_mep.py +152 -0
- pynibs/optimization/optimization.py +1445 -0
- pynibs/optimization/workhorses.py +698 -0
- pynibs/pckg/__init__.py +0 -0
- pynibs/pckg/biosig/biosig4c++-1.9.5.src_fixed.tar.gz +0 -0
- pynibs/pckg/libeep/__init__.py +0 -0
- pynibs/pckg/libeep/pyeep.so +0 -0
- pynibs/regression/__init__.py +11 -0
- pynibs/regression/dual_node_detection.py +2375 -0
- pynibs/regression/regression.py +2984 -0
- pynibs/regression/score_types.py +0 -0
- pynibs/roi/__init__.py +2 -0
- pynibs/roi/roi.py +895 -0
- pynibs/roi/roi_structs.py +1233 -0
- pynibs/subject.py +1009 -0
- pynibs/tensor_scaling.py +144 -0
- pynibs/tests/data/InstrumentMarker20200225163611937.xml +19 -0
- pynibs/tests/data/TriggerMarkers_Coil0_20200225163443682.xml +14 -0
- pynibs/tests/data/TriggerMarkers_Coil1_20200225170337572.xml +6373 -0
- pynibs/tests/data/Xdmf.dtd +89 -0
- pynibs/tests/data/brainsight_niiImage_nifticoord.txt +145 -0
- pynibs/tests/data/brainsight_niiImage_nifticoord_largefile.txt +1434 -0
- pynibs/tests/data/brainsight_niiImage_niifticoord_mixedtargets.txt +47 -0
- pynibs/tests/data/create_subject_testsub.py +332 -0
- pynibs/tests/data/data.hdf5 +0 -0
- pynibs/tests/data/geo.hdf5 +0 -0
- pynibs/tests/test_coil.py +474 -0
- pynibs/tests/test_elements2nodes.py +100 -0
- pynibs/tests/test_hdf5_io/test_xdmf.py +61 -0
- pynibs/tests/test_mesh_transformations.py +123 -0
- pynibs/tests/test_mesh_utils.py +143 -0
- pynibs/tests/test_nnav_imports.py +101 -0
- pynibs/tests/test_quality_measures.py +117 -0
- pynibs/tests/test_regressdata.py +289 -0
- pynibs/tests/test_roi.py +17 -0
- pynibs/tests/test_rotations.py +86 -0
- pynibs/tests/test_subject.py +71 -0
- pynibs/tests/test_util.py +24 -0
- pynibs/tms_pulse.py +34 -0
- pynibs/util/__init__.py +4 -0
- pynibs/util/dosing.py +233 -0
- pynibs/util/quality_measures.py +562 -0
- pynibs/util/rotations.py +340 -0
- pynibs/util/simnibs.py +763 -0
- pynibs/util/util.py +727 -0
- pynibs/visualization/__init__.py +2 -0
- pynibs/visualization/para.py +4372 -0
- pynibs/visualization/plot_2D.py +137 -0
- pynibs/visualization/render_3D.py +347 -0
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import gdist
|
|
3
|
+
from matplotlib import pyplot as plt
|
|
4
|
+
from scipy.spatial.transform import Rotation as rot
|
|
5
|
+
|
|
6
|
+
import pynibs
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def geodesic_dist(nodes, tris, source, source_is_node=True):
|
|
10
|
+
"""
|
|
11
|
+
Returns geodesic distance in mm from all nodes to source node (or triangle).
|
|
12
|
+
This is just a wrapper for the gdist package.
|
|
13
|
+
|
|
14
|
+
Example
|
|
15
|
+
-------
|
|
16
|
+
|
|
17
|
+
.. code-block:: python
|
|
18
|
+
|
|
19
|
+
with h5py.File(fn,'r') as f:
|
|
20
|
+
tris = f['/mesh/elm/triangle_number_list'][:]
|
|
21
|
+
nodes = f['/mesh/nodes/node_coord'][:]
|
|
22
|
+
nodes_dist_ged, tris_dist_ged = geodesic_dist(nodes, tris, 3017)
|
|
23
|
+
|
|
24
|
+
pynibs.write_data_hdf5(data_fn,
|
|
25
|
+
data=[tris_dist_ged, nodes_dist_ged],
|
|
26
|
+
data_names=["tris_dist_ged", "nodes_dist_ged"])
|
|
27
|
+
pynibs.write_xdmf(data_fn,hdf5_geo_fn=fn)
|
|
28
|
+
|
|
29
|
+
Parameters
|
|
30
|
+
----------
|
|
31
|
+
nodes : np.ndarray
|
|
32
|
+
(n_nodes,3) The nodes, determined by their x-y-z-coordinates.
|
|
33
|
+
tris : np.ndarray
|
|
34
|
+
(n_tris,3) Every triangle, determined by the node indices (vertices) that form it.
|
|
35
|
+
source : np.ndarray(int) or int
|
|
36
|
+
Geodesic distances of all nodes (or triangles) to this one will be computed.
|
|
37
|
+
source_is_node : bool
|
|
38
|
+
Whether source is a node idx or a triangle idx.
|
|
39
|
+
|
|
40
|
+
Returns
|
|
41
|
+
-------
|
|
42
|
+
nodes_dist : np.ndarray
|
|
43
|
+
(n_nodes,) Geodesic distance between source and all nodes in mm.
|
|
44
|
+
tris_dist : np.ndarray
|
|
45
|
+
(n_tris,) Geodesic distance between source and all triangles in mm.
|
|
46
|
+
"""
|
|
47
|
+
if source_is_node:
|
|
48
|
+
if type(source) is not np.ndarray:
|
|
49
|
+
source = np.array([source])
|
|
50
|
+
nodes_dist = gdist.compute_gdist(nodes.astype(np.float64),
|
|
51
|
+
tris.astype(np.int32),
|
|
52
|
+
source_indices=source.astype(np.int32))
|
|
53
|
+
|
|
54
|
+
else:
|
|
55
|
+
nodes = nodes.astype(np.float64)
|
|
56
|
+
tris = tris.astype(np.int32)
|
|
57
|
+
source = tris[source].astype(np.int32)
|
|
58
|
+
nodes_dist = gdist.compute_gdist(nodes,
|
|
59
|
+
tris,
|
|
60
|
+
source_indices=source)
|
|
61
|
+
# l = []
|
|
62
|
+
# for n in source:
|
|
63
|
+
# l.append(np.mean(gdist.compute_gdist(nodes,
|
|
64
|
+
# tris,
|
|
65
|
+
# source_indices=np.array([n]).astype(np.int32))[tris],axis=1))
|
|
66
|
+
# # a = np.array(l)
|
|
67
|
+
# nodes_dist = np.mean(l,axis=0)
|
|
68
|
+
# a.shape
|
|
69
|
+
tris_dist = np.mean(nodes_dist[tris], axis=1)
|
|
70
|
+
|
|
71
|
+
return nodes_dist, tris_dist
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def euclidean_dist(nodes, tris, source, source_is_node=True):
|
|
75
|
+
"""
|
|
76
|
+
Returns euclidean distance of all nodes to source node (triangle).
|
|
77
|
+
This is just a wrapper for the gdist package.
|
|
78
|
+
|
|
79
|
+
Example
|
|
80
|
+
-------
|
|
81
|
+
.. code-block:: python
|
|
82
|
+
|
|
83
|
+
with h5py.File(fn,'r') as f:
|
|
84
|
+
tris = f['/mesh/elm/triangle_number_list'][:]
|
|
85
|
+
nodes = f['/mesh/nodes/node_coord'][:]
|
|
86
|
+
nodes_dist_euc, tris_dist_euc = euclidean_dist(nodes, tris, 3017)
|
|
87
|
+
|
|
88
|
+
pynibs.write_data_hdf5(data_fn,
|
|
89
|
+
data=[tris_dist_euc, nodes_dist_euc],
|
|
90
|
+
data_names=["tris_dist_euc", "nodes_dist_euc", "])
|
|
91
|
+
pynibs.write_xdmf(data_fn,hdf5_geo_fn=fn)
|
|
92
|
+
|
|
93
|
+
Parameters
|
|
94
|
+
----------
|
|
95
|
+
nodes : np.ndarray
|
|
96
|
+
(n_nodes,3) The nodes, determined by their x-y-z-coordinates.
|
|
97
|
+
tris : np.ndarray
|
|
98
|
+
(n_tris,3) Every triangle, determined by the node indices (vertices) that form it.
|
|
99
|
+
source : np.ndarray(int) or int
|
|
100
|
+
Euclidean distances of all nodes (or triangles) to this one will be computed.
|
|
101
|
+
source_is_node : bool
|
|
102
|
+
Whether source is a node idx or a triangle idx.
|
|
103
|
+
|
|
104
|
+
Returns
|
|
105
|
+
-------
|
|
106
|
+
nodes_dist : np.ndarray
|
|
107
|
+
(n_nodes,) Geodesic distance between source and all nodes in mm.
|
|
108
|
+
tris_dist : np.ndarray
|
|
109
|
+
(n_tris,) Geodesic distance between source and all triangles in mm.
|
|
110
|
+
"""
|
|
111
|
+
if source_is_node:
|
|
112
|
+
nodes_dist = np.linalg.norm(nodes - nodes[source], axis=1)
|
|
113
|
+
else:
|
|
114
|
+
nodes_dist = np.zeros(nodes.shape[0], )
|
|
115
|
+
for node in nodes[tris[source]]:
|
|
116
|
+
nodes_dist += np.linalg.norm(nodes - node, axis=1)
|
|
117
|
+
nodes_dist /= 3
|
|
118
|
+
tris_dist = np.mean(nodes_dist[tris], axis=1)
|
|
119
|
+
|
|
120
|
+
return nodes_dist, tris_dist
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def nrmsd(array, array_ref, error_norm="relative", x_axis=False):
|
|
124
|
+
"""
|
|
125
|
+
Determine the normalized root-mean-square deviation between input data and reference data.
|
|
126
|
+
|
|
127
|
+
Notes
|
|
128
|
+
-----
|
|
129
|
+
nrmsd = np.sqrt(1.0 / n_points * np.sum((data - data_ref) ** 2)) / (max(array_ref) - min(array_ref))
|
|
130
|
+
|
|
131
|
+
Parameters
|
|
132
|
+
----------
|
|
133
|
+
array : np.ndarray
|
|
134
|
+
input data [ (x), y0, y1, y2 ... ].
|
|
135
|
+
array_ref : np.ndarray
|
|
136
|
+
reference data [ (x_ref), y0_ref, y1_ref, y2_ref ... ].
|
|
137
|
+
if array_ref is 1D, all sizes have to match.
|
|
138
|
+
error_norm : str, optional, default="relative"
|
|
139
|
+
Decide if error is determined "relative" or "absolute".
|
|
140
|
+
x_axis : bool, default: False
|
|
141
|
+
If True, the first column of array and array_ref is interpreted as the x-axis, where the data points are
|
|
142
|
+
evaluated. If False, the data points are assumed to be at the same location.
|
|
143
|
+
|
|
144
|
+
Returns
|
|
145
|
+
-------
|
|
146
|
+
normalized_rms : np.ndarray of float
|
|
147
|
+
([array.shape[1]]) Normalized root-mean-square deviation between the columns of array and array_ref.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
n_points = array.shape[0]
|
|
151
|
+
|
|
152
|
+
if x_axis:
|
|
153
|
+
# handle different array lengths
|
|
154
|
+
if len(array_ref.shape) == 1:
|
|
155
|
+
array_ref = array_ref[:, None]
|
|
156
|
+
if len(array.shape) == 1:
|
|
157
|
+
array = array[:, None]
|
|
158
|
+
|
|
159
|
+
# determine number of input arrays
|
|
160
|
+
if array_ref.shape[1] == 2:
|
|
161
|
+
n_data = array.shape[1] - 1
|
|
162
|
+
else:
|
|
163
|
+
n_data = array.shape[1]
|
|
164
|
+
|
|
165
|
+
# interpolate array on array_ref data if necessary
|
|
166
|
+
if array_ref.shape[1] == 1:
|
|
167
|
+
data = array
|
|
168
|
+
data_ref = array_ref
|
|
169
|
+
else:
|
|
170
|
+
# crop reference if it is longer than the axis of the data
|
|
171
|
+
data_ref = array_ref[(array_ref[:, 0] >= min(array[:, 0])) & (array_ref[:, 0] <= max(array[:, 0])), 1]
|
|
172
|
+
array_ref = array_ref[(array_ref[:, 0] >= min(array[:, 0])) & (array_ref[:, 0] <= max(array[:, 0])), 0]
|
|
173
|
+
|
|
174
|
+
data = np.zeros([len(array_ref), n_data])
|
|
175
|
+
for i_data in range(n_data):
|
|
176
|
+
data[:, i_data] = np.interp(array_ref, array[:, 0], array[:, i_data + 1])
|
|
177
|
+
else:
|
|
178
|
+
data_ref = array_ref
|
|
179
|
+
data = array
|
|
180
|
+
|
|
181
|
+
# determine "absolute" or "relative" error
|
|
182
|
+
if error_norm == "relative":
|
|
183
|
+
# max_min_idx = np.isclose(np.max(data_ref, axis=0), np.min(data_ref, axis=0))
|
|
184
|
+
delta = np.max(data_ref, axis=0) - np.min(data_ref, axis=0)
|
|
185
|
+
|
|
186
|
+
# if max_min_idx.any():
|
|
187
|
+
# delta[max_min_idx] = max(data_ref[max_min_idx])
|
|
188
|
+
elif error_norm == 'absolute':
|
|
189
|
+
delta = 1
|
|
190
|
+
else:
|
|
191
|
+
raise NotImplementedError
|
|
192
|
+
|
|
193
|
+
# determine normalized rms deviation and return
|
|
194
|
+
normalized_rms = np.sqrt(1.0 / n_points * np.sum((data - data_ref) ** 2, axis=0)) / delta
|
|
195
|
+
|
|
196
|
+
return normalized_rms
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def nrmse(array, array_ref, x_axis=False):
|
|
200
|
+
"""
|
|
201
|
+
Determine the normalized root-mean-square deviation between input data and reference data.
|
|
202
|
+
|
|
203
|
+
nrmse = np.linalg.norm(array - array_ref) / np.linalg.norm(array_ref)
|
|
204
|
+
|
|
205
|
+
Parameters
|
|
206
|
+
----------
|
|
207
|
+
array : np.ndarray
|
|
208
|
+
input data [ (x), y0, y1, y2 ... ].
|
|
209
|
+
array_ref : np.ndarray
|
|
210
|
+
reference data [ (x_ref), y0_ref, y1_ref, y2_ref ... ].
|
|
211
|
+
if array_ref is 1D, all sizes have to match.
|
|
212
|
+
error_norm : str, optional, default="relative"
|
|
213
|
+
Decide if error is determined "relative" or "absolute".
|
|
214
|
+
x_axis : bool, default: False
|
|
215
|
+
If True, the first column of array and array_ref is interpreted as the x-axis, where the data points are
|
|
216
|
+
evaluated. If False, the data points are assumed to be at the same location.
|
|
217
|
+
|
|
218
|
+
Returns
|
|
219
|
+
-------
|
|
220
|
+
nrmse: np.ndarray of float
|
|
221
|
+
([array.shape[1]]) Normalized root-mean-square deviation between the columns of array and array_ref.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
n_points = array.shape[0]
|
|
225
|
+
|
|
226
|
+
if x_axis:
|
|
227
|
+
# handle different array lengths
|
|
228
|
+
if len(array_ref.shape) == 1:
|
|
229
|
+
array_ref = array_ref[:, None]
|
|
230
|
+
if len(array.shape) == 1:
|
|
231
|
+
array = array[:, None]
|
|
232
|
+
|
|
233
|
+
# determine number of input arrays
|
|
234
|
+
if array_ref.shape[1] == 2:
|
|
235
|
+
n_data = array.shape[1] - 1
|
|
236
|
+
else:
|
|
237
|
+
n_data = array.shape[1]
|
|
238
|
+
|
|
239
|
+
# interpolate array on array_ref data if necessary
|
|
240
|
+
if array_ref.shape[1] == 1:
|
|
241
|
+
data = array
|
|
242
|
+
data_ref = array_ref
|
|
243
|
+
else:
|
|
244
|
+
# crop reference if it is longer than the axis of the data
|
|
245
|
+
data_ref = array_ref[(array_ref[:, 0] >= min(array[:, 0])) & (array_ref[:, 0] <= max(array[:, 0])), 1]
|
|
246
|
+
array_ref = array_ref[(array_ref[:, 0] >= min(array[:, 0])) & (array_ref[:, 0] <= max(array[:, 0])), 0]
|
|
247
|
+
|
|
248
|
+
data = np.zeros([len(array_ref), n_data])
|
|
249
|
+
for i_data in range(n_data):
|
|
250
|
+
data[:, i_data] = np.interp(array_ref, array[:, 0], array[:, i_data + 1])
|
|
251
|
+
else:
|
|
252
|
+
data_ref = array_ref
|
|
253
|
+
data = array
|
|
254
|
+
|
|
255
|
+
# determine normalized rms deviation and return
|
|
256
|
+
nrmse = np.linalg.norm(data - data_ref, axis=0) / np.linalg.norm(data_ref, axis=0)
|
|
257
|
+
|
|
258
|
+
return nrmse
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def c_map_comparison(c1, c2, t1, t2, nodes, tris):
|
|
262
|
+
"""
|
|
263
|
+
Compares two c-maps in terms of NRMSD and calculates the geodesic distance between the hotspots.
|
|
264
|
+
|
|
265
|
+
Parameters
|
|
266
|
+
----------
|
|
267
|
+
c1 : np.ndarray of float
|
|
268
|
+
(n_ele) First c-map.
|
|
269
|
+
c2 : np.ndarray of float
|
|
270
|
+
(n_ele) Second c-map (reference).
|
|
271
|
+
t1 : np.ndarray of float
|
|
272
|
+
(3) Coordinates of the hotspot in the first c-map.
|
|
273
|
+
t2 : np.ndarray of float
|
|
274
|
+
Coordinates of the hotspot in the second c-map.
|
|
275
|
+
nodes : np.ndarray of float
|
|
276
|
+
(n_nodes, 3) Node coordinates
|
|
277
|
+
tris : np.ndarray of float
|
|
278
|
+
(n_tris, 3) Connectivity of ROI elements
|
|
279
|
+
|
|
280
|
+
Returns
|
|
281
|
+
-------
|
|
282
|
+
nrmsd : float
|
|
283
|
+
Normalized root-mean-square deviation between the two c-maps in (%).
|
|
284
|
+
gdist : float
|
|
285
|
+
Geodesic distance between the two hotspots in (mm).
|
|
286
|
+
"""
|
|
287
|
+
# determine NRMSD between two c-maps
|
|
288
|
+
nrmsd_ = nrmsd(array=c1, array_ref=c2, error_norm="relative", x_axis=False) * 100
|
|
289
|
+
|
|
290
|
+
# determine geodesic distance between hotspots
|
|
291
|
+
tris_center = np.mean(nodes[tris,], axis=1)
|
|
292
|
+
|
|
293
|
+
t1_idx = np.argmin(np.linalg.norm(tris_center - t1, axis=1))
|
|
294
|
+
t2_idx = np.argmin(np.linalg.norm(tris_center - t2, axis=1))
|
|
295
|
+
gdists = geodesic_dist(nodes=nodes, tris=tris, source=t2_idx, source_is_node=False)[1]
|
|
296
|
+
gdist_ = gdists[t1_idx]
|
|
297
|
+
|
|
298
|
+
return nrmsd_, gdist_
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def calc_tms_motion_params(coil_positions, reference=None):
|
|
302
|
+
"""
|
|
303
|
+
Computes motion parameters for a set of ``coil_positions``, i.e., coil shifts in [mm] in RAS coordinate system and
|
|
304
|
+
rotations (pitch, roll, yaw) in [°].
|
|
305
|
+
Motion is computed w.r.t. the first ('absolute') and to the previous ('relative') stimulation.
|
|
306
|
+
|
|
307
|
+
Position shifts are quantified with respect to the subject/nifti-specific RAS coordinate system.
|
|
308
|
+
Rorational changes are quantified with respect to the coil axes as follows:
|
|
309
|
+
|
|
310
|
+
* pitch: rotation around left/right axis of coil
|
|
311
|
+
* roll: rotation around coil handle axis
|
|
312
|
+
* yaw: rotation around axis from center of coil towards head
|
|
313
|
+
Motion parameters for first coil position are set to 0.
|
|
314
|
+
|
|
315
|
+
Parameters
|
|
316
|
+
----------
|
|
317
|
+
coil_positions : np.ndarray of float
|
|
318
|
+
(4, 4, n_pulses).
|
|
319
|
+
reference : np.ndarray of float, optional
|
|
320
|
+
(4, 4) Reference coil placement. If None (default), the first placement from ``coil_positions`` is used.
|
|
321
|
+
|
|
322
|
+
Returns
|
|
323
|
+
-------
|
|
324
|
+
pos_diff_abs : np.ndarray
|
|
325
|
+
(3, n_pulses) Absolute position differences (R, A, S).
|
|
326
|
+
euler_rots_abs : np.ndarray
|
|
327
|
+
(3, n_pulses) Absolute rotation angles in euler angles (alpha, beta, gamma).
|
|
328
|
+
pos_diff_rel : np.ndarray
|
|
329
|
+
(3, n_pulses) Relative position differences (R, A, S).
|
|
330
|
+
euler_rots_rel : np.ndarray
|
|
331
|
+
(3, n_pulses) Relative rotation angles in euler angles (alpha, beta, gamma).
|
|
332
|
+
"""
|
|
333
|
+
np.set_printoptions(suppress=True)
|
|
334
|
+
if reference is None:
|
|
335
|
+
i_ref = 0
|
|
336
|
+
while np.isnan(coil_positions[:, :, i_ref]).any():
|
|
337
|
+
i_ref += 1
|
|
338
|
+
reference_pos = coil_positions[0:3, 3, i_ref].T
|
|
339
|
+
reference_rot = coil_positions[0:3, 0:3, i_ref]
|
|
340
|
+
|
|
341
|
+
# first rotation is zero because it is the reference
|
|
342
|
+
euler_rots_abs = [[0, 0, 0]]
|
|
343
|
+
else:
|
|
344
|
+
reference = np.squeeze(reference)
|
|
345
|
+
reference_pos = reference[0:3, 3].T
|
|
346
|
+
reference_rot = reference[0:3, 0:3]
|
|
347
|
+
|
|
348
|
+
# compute first rotation towards reference
|
|
349
|
+
rotmat_abs = pynibs.bases2rotmat(reference_rot, coil_positions[0:3, 0:3, 0])
|
|
350
|
+
if np.isnan(rotmat_abs).any():
|
|
351
|
+
euler_rots_abs = [[np.nan, np.nan, np.nan]]
|
|
352
|
+
else:
|
|
353
|
+
euler_rots_abs = [rot.from_matrix(rotmat_abs).as_euler('xyz', degrees=True).tolist()]
|
|
354
|
+
|
|
355
|
+
# compute absolute and relative position differences
|
|
356
|
+
pos_diff_abs = (reference_pos - coil_positions[0:3, 3, :].T).T
|
|
357
|
+
pos_diff_rel = np.diff(coil_positions[0:3, 3, :], 1, axis=1, prepend=coil_positions[0:3, 3, 0, np.newaxis])
|
|
358
|
+
|
|
359
|
+
# bring position differences into reference coordinate system
|
|
360
|
+
pos_diff_abs_rot = reference_rot.T @ pos_diff_abs
|
|
361
|
+
pos_diff_rel_rot = reference_rot.T @ pos_diff_rel
|
|
362
|
+
|
|
363
|
+
n_coilpos = coil_positions.shape[2]
|
|
364
|
+
euler_rots_rel = [[0, 0, 0]]
|
|
365
|
+
for i in range(0, n_coilpos - 1):
|
|
366
|
+
rotmat_abs = pynibs.bases2rotmat(reference_rot, coil_positions[0:3, 0:3, i + 1])
|
|
367
|
+
if np.isnan(rotmat_abs).any():
|
|
368
|
+
euler_abs = [np.nan, np.nan, np.nan]
|
|
369
|
+
else:
|
|
370
|
+
euler_abs = rot.from_matrix(rotmat_abs).as_euler('xyz', degrees=True).tolist()
|
|
371
|
+
euler_rots_abs.append(euler_abs)
|
|
372
|
+
|
|
373
|
+
rotmat_rel = pynibs.bases2rotmat(coil_positions[0:3, 0:3, i], coil_positions[0:3, 0:3, i + 1])
|
|
374
|
+
if np.isnan(rotmat_rel).any():
|
|
375
|
+
euler_rel = [np.nan, np.nan, np.nan]
|
|
376
|
+
else:
|
|
377
|
+
euler_rel = rot.from_matrix(rotmat_rel).as_euler('xyz', degrees=True).tolist()
|
|
378
|
+
euler_rots_rel.append(euler_rel)
|
|
379
|
+
|
|
380
|
+
euler_rots_rel = np.array(euler_rots_rel)
|
|
381
|
+
euler_rots_abs = np.array(euler_rots_abs)
|
|
382
|
+
|
|
383
|
+
return pos_diff_abs_rot, euler_rots_abs.T, pos_diff_rel_rot, euler_rots_rel.T
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def plot_tms_motion_parameter(pos_diff, euler_rots, pcd=None, fname=None):
|
|
387
|
+
"""
|
|
388
|
+
Plots TMS coil motion parameters.
|
|
389
|
+
|
|
390
|
+
.. figure:: ../../doc/images/pcd.png
|
|
391
|
+
:scale: 80 %
|
|
392
|
+
:alt: TMS motion parameters and PCD
|
|
393
|
+
|
|
394
|
+
Pulsewise Coil Displacement (PCD) is a compound metric to quantify TMS coil movements. Data for a double (space)
|
|
395
|
+
cTBS400 protocol is shown (with a break after 200 bursts).
|
|
396
|
+
|
|
397
|
+
Parameters
|
|
398
|
+
----------
|
|
399
|
+
pos_diff : np.ndarray
|
|
400
|
+
(3, n_pulses) Position differences (R, A, S).
|
|
401
|
+
euler_rots : np.ndarray
|
|
402
|
+
(3, n_pulses) Rotation angles in euler angles (alpha, beta, gamma).
|
|
403
|
+
pcd : np.ndarray, optional
|
|
404
|
+
(n_pulses,) pulsewise coil displacements.
|
|
405
|
+
fname : str, optional
|
|
406
|
+
Filename to save plot.
|
|
407
|
+
|
|
408
|
+
Returns
|
|
409
|
+
-------
|
|
410
|
+
axes : matplotlib.pyplot.axes
|
|
411
|
+
Figure axes.
|
|
412
|
+
"""
|
|
413
|
+
if pcd is not None:
|
|
414
|
+
n_plot_rows = 3
|
|
415
|
+
else:
|
|
416
|
+
n_plot_rows = 2
|
|
417
|
+
n_coilpos = pos_diff.shape[1]
|
|
418
|
+
fig, axes = plt.subplots(n_plot_rows, 2)
|
|
419
|
+
|
|
420
|
+
fig.suptitle('TMS coil displacement')
|
|
421
|
+
|
|
422
|
+
# let's order the plotting based on the data to have the dimension with the fewest changes on top
|
|
423
|
+
order_pos = np.argsort(np.std(pos_diff, axis=1))[::-1]
|
|
424
|
+
order_rot = np.argsort(np.std(euler_rots, axis=1))[::-1]
|
|
425
|
+
colors_pos = ['#12263A', '#50858B', '#99EDCC']
|
|
426
|
+
colors_rot = ['#725752', '#F2A541', '#F8DF8C']
|
|
427
|
+
labels_pos = ['X', 'Y', 'Z']
|
|
428
|
+
labels_rot = ['yaw', 'pitch', 'roll']
|
|
429
|
+
|
|
430
|
+
for i in order_pos:
|
|
431
|
+
axes[0, 0].plot(range(0, n_coilpos), pos_diff[i, :], c=colors_pos[i], label=labels_pos[i], linewidth=1)
|
|
432
|
+
axes[0, 1].plot(range(0, n_coilpos), np.nancumsum(pos_diff[i, :]), c=colors_pos[i], label=labels_pos[i],
|
|
433
|
+
linewidth=1)
|
|
434
|
+
for i in order_rot:
|
|
435
|
+
axes[1, 0].plot(range(0, n_coilpos), euler_rots[i, :], c=colors_rot[i], label=labels_rot[i], linewidth=1)
|
|
436
|
+
axes[1, 1].plot(range(0, n_coilpos), np.nancumsum(euler_rots[i, :]), c=colors_rot[i], label=labels_rot[i],
|
|
437
|
+
linewidth=1)
|
|
438
|
+
np.set_printoptions(suppress=True)
|
|
439
|
+
axes[0, 0].set_title('Relative movement [mm]')
|
|
440
|
+
axes[1, 0].set_title('Relative rotation [°]')
|
|
441
|
+
axes[0, 1].set_title('Cumulative movement [mm]')
|
|
442
|
+
axes[1, 1].set_title('Cumulative rotation [°]')
|
|
443
|
+
fig.delaxes(axes[2, 1])
|
|
444
|
+
if pcd is not None:
|
|
445
|
+
axes[2, 0].plot(range(0, n_coilpos), pcd, c='#843E84', label='PCD',
|
|
446
|
+
linewidth=1)
|
|
447
|
+
axes[2, 0].set_title('Pulsewise coil displacement [AU]')
|
|
448
|
+
|
|
449
|
+
# add some stats to plot
|
|
450
|
+
plt.figtext(0.505, 0.09, f" PCD\n"
|
|
451
|
+
f" Max: {np.nanmax(pcd).round(2):6.2f}\n"
|
|
452
|
+
f" Mean: {np.nanmean(pcd).round(2):6.2f}\n"
|
|
453
|
+
f"Median: {np.nanmedian(pcd).round(2):6.2f}\n"
|
|
454
|
+
f" SD: {np.nanstd(pcd).round(2):6.2f}\n"
|
|
455
|
+
f"n stim: {(~np.isnan(pcd)).sum(): >6}\n"
|
|
456
|
+
f"untracked: {(np.isnan(pcd)).sum(): >3}",
|
|
457
|
+
family='monospace'
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
fig.tight_layout()
|
|
461
|
+
handles_tmp, labels_tmp = axes[0, 0].get_legend_handles_labels()
|
|
462
|
+
fig.legend(handles_tmp, labels_tmp, ncols=1, loc=[0.7, 0.08], title='Shift')
|
|
463
|
+
|
|
464
|
+
handles_tmp, labels_tmp = axes[1, 0].get_legend_handles_labels()
|
|
465
|
+
fig.legend(handles_tmp, labels_tmp, ncols=1, loc=[0.83, 0.08], title='Rotation')
|
|
466
|
+
|
|
467
|
+
# handles_tmp, labels_tmp = axes[2, 0].get_legend_handles_labels()
|
|
468
|
+
# fig.legend(handles_tmp, labels_tmp, ncols=1, loc=[0.85, 0.12], title='PCD')
|
|
469
|
+
|
|
470
|
+
if fname is not None:
|
|
471
|
+
plt.savefig(fname)
|
|
472
|
+
|
|
473
|
+
return axes
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def compute_pcd(delta_pos, delta_rot, skin_cortex_distance=20):
|
|
477
|
+
"""
|
|
478
|
+
Computes _P_ulsewise _C_oil _D_isplacements (PCD) based on 3 position parameters (``delta_pos``) and
|
|
479
|
+
3 rotation parmeters (``delta_rot``).
|
|
480
|
+
|
|
481
|
+
The coil rotations (in euler angles) are transformed into a positional change projected on the cortex based on
|
|
482
|
+
``skin_cortex_distance`` as a proxy for the (local) change of the stimulation.
|
|
483
|
+
``delta_pos`` and ``delta_rot`` are expected to quantify motion w.r.t. the target coil position/roation, for example
|
|
484
|
+
the absolute deltas to the first stimulation.
|
|
485
|
+
|
|
486
|
+
Axes definitions
|
|
487
|
+
----------------
|
|
488
|
+
``delta_pos`` and ``delta_rot`` are supposed to follow this axes definition (SimNIBS):
|
|
489
|
+
|
|
490
|
+
- delta_pos
|
|
491
|
+
- X and Y: coil movement tangential to head surface
|
|
492
|
+
- Z: coil movement perpendicular to head surface
|
|
493
|
+
|
|
494
|
+
- delta_rot
|
|
495
|
+
* pitch: rotation around left/right axis of coil
|
|
496
|
+
* roll: rotation around coil handle axis
|
|
497
|
+
* yaw: rotation around axis from center of coil towards head
|
|
498
|
+
|
|
499
|
+
.. figure:: ../../doc/images/coil_axes_definition.png
|
|
500
|
+
:scale: 80 %
|
|
501
|
+
:alt: Coil axes definition, following SimNIBS conventions.
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
Example
|
|
505
|
+
-------
|
|
506
|
+
.. code-block:: python
|
|
507
|
+
|
|
508
|
+
# get TriggerMarkers from a Localite session
|
|
509
|
+
mats = pynibs.read_triggermarker_localite(tm_fn)[0]
|
|
510
|
+
|
|
511
|
+
# calculate absolute and relative coil displacements
|
|
512
|
+
delta_pos_abs, delta_rot_abs, delta_pos_rel, delta_rot_rel = pynibs.calc_tms_motion_params(mats)
|
|
513
|
+
|
|
514
|
+
# compute PCD
|
|
515
|
+
pcd, delta_pos, delta_rot = pynibs.compute_pcd(delta_pos_abs, delta_rot_abs)
|
|
516
|
+
|
|
517
|
+
# plot movement
|
|
518
|
+
axes = pynibs.plot_tms_motion_parameter(pos_diff_rel, euler_rots_rel, pcd)
|
|
519
|
+
matplotlib.pyplot.show()
|
|
520
|
+
|
|
521
|
+
Parameters
|
|
522
|
+
----------
|
|
523
|
+
delta_pos : np.ndarray
|
|
524
|
+
(3, n_pulses) Absolute position differences (R, A, S).
|
|
525
|
+
delta_rot : np.ndarray
|
|
526
|
+
(3, n_pulses) Absolute rotation angles in euler angles (alpha, beta, gamma).
|
|
527
|
+
skin_cortex_distance : int, default: 20
|
|
528
|
+
Cortical depth of target to adjust coil rotations for.
|
|
529
|
+
|
|
530
|
+
Returns
|
|
531
|
+
-------
|
|
532
|
+
pcd : np.ndarray
|
|
533
|
+
Pulsewise coil displacements.
|
|
534
|
+
delta_pos : np.ndarray
|
|
535
|
+
(n_pulses, ) sum(abs(delta_pos)) per pulse.
|
|
536
|
+
delta_rot : np.ndarray
|
|
537
|
+
(n_pulses, ) sum(abs(delta_rot projected by skin_cortex_distance)) per pulse.
|
|
538
|
+
"""
|
|
539
|
+
# delta_pos_summed = np.sum(np.abs(delta_pos[:2, :]), axis=0)
|
|
540
|
+
delta_pos_summed = np.sqrt(np.sum(delta_pos[: 2, :] ** 2, axis=0))
|
|
541
|
+
delta_pos_summed = np.squeeze(delta_pos_summed + delta_pos[2, :] ** 2 * np.sign(delta_pos[2, :]))
|
|
542
|
+
|
|
543
|
+
plane_n = np.array([0, 0, 1])
|
|
544
|
+
plane_p = np.array([0, 0, skin_cortex_distance]) # Any point on the plane
|
|
545
|
+
|
|
546
|
+
# compute shift of the center of coil ray
|
|
547
|
+
ray_origin = np.array([0, 0, 0]) # Any point along the ray
|
|
548
|
+
delta_rot_a = []
|
|
549
|
+
for idx, rots in enumerate(delta_rot.T):
|
|
550
|
+
# apply x and y rotation to [0,0,1]
|
|
551
|
+
# rots[2] = 0
|
|
552
|
+
r = rot.from_rotvec(rots, degrees=True)
|
|
553
|
+
ray_dir = r.apply([0, 0, 1])
|
|
554
|
+
intersec = pynibs.intersection_vec_plan(ray_dir, ray_origin, plane_n, plane_p, eps=1e-6)
|
|
555
|
+
|
|
556
|
+
# compute distance from new intersection with [0, 0, scd]
|
|
557
|
+
delta_rot_a.append(np.linalg.norm(plane_p - intersec))
|
|
558
|
+
|
|
559
|
+
# ray shift based on orientation doesn't include any rotations around z axis.
|
|
560
|
+
z_rotation_deltas = np.abs((skin_cortex_distance) * np.sin(np.deg2rad(delta_rot[2, :])))
|
|
561
|
+
delta_rot = np.array(delta_rot_a) + z_rotation_deltas
|
|
562
|
+
return delta_pos_summed + delta_rot, delta_pos_summed, delta_rot
|