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.
Files changed (107) hide show
  1. pyNIBS-0.2024.8.dist-info/LICENSE +623 -0
  2. pyNIBS-0.2024.8.dist-info/METADATA +723 -0
  3. pyNIBS-0.2024.8.dist-info/RECORD +107 -0
  4. pyNIBS-0.2024.8.dist-info/WHEEL +5 -0
  5. pyNIBS-0.2024.8.dist-info/top_level.txt +1 -0
  6. pynibs/__init__.py +34 -0
  7. pynibs/coil.py +1367 -0
  8. pynibs/congruence/__init__.py +15 -0
  9. pynibs/congruence/congruence.py +1108 -0
  10. pynibs/congruence/ext_metrics.py +257 -0
  11. pynibs/congruence/stimulation_threshold.py +318 -0
  12. pynibs/data/configuration_exp0.yaml +59 -0
  13. pynibs/data/configuration_linear_MEP.yaml +61 -0
  14. pynibs/data/configuration_linear_RT.yaml +61 -0
  15. pynibs/data/configuration_sigmoid4.yaml +68 -0
  16. pynibs/data/network mapping configuration/configuration guide.md +238 -0
  17. pynibs/data/network mapping configuration/configuration_TEMPLATE.yaml +42 -0
  18. pynibs/data/network mapping configuration/configuration_for_testing.yaml +43 -0
  19. pynibs/data/network mapping configuration/configuration_modelTMS.yaml +43 -0
  20. pynibs/data/network mapping configuration/configuration_reg_isi_05.yaml +43 -0
  21. pynibs/data/network mapping configuration/output_documentation.md +185 -0
  22. pynibs/data/network mapping configuration/recommendations_for_accuracy_threshold.md +77 -0
  23. pynibs/data/neuron/models/L23_PC_cADpyr_biphasic_v1.csv +1281 -0
  24. pynibs/data/neuron/models/L23_PC_cADpyr_monophasic_v1.csv +1281 -0
  25. pynibs/data/neuron/models/L4_LBC_biphasic_v1.csv +1281 -0
  26. pynibs/data/neuron/models/L4_LBC_monophasic_v1.csv +1281 -0
  27. pynibs/data/neuron/models/L4_NBC_biphasic_v1.csv +1281 -0
  28. pynibs/data/neuron/models/L4_NBC_monophasic_v1.csv +1281 -0
  29. pynibs/data/neuron/models/L4_SBC_biphasic_v1.csv +1281 -0
  30. pynibs/data/neuron/models/L4_SBC_monophasic_v1.csv +1281 -0
  31. pynibs/data/neuron/models/L5_TTPC2_cADpyr_biphasic_v1.csv +1281 -0
  32. pynibs/data/neuron/models/L5_TTPC2_cADpyr_monophasic_v1.csv +1281 -0
  33. pynibs/expio/Mep.py +1518 -0
  34. pynibs/expio/__init__.py +8 -0
  35. pynibs/expio/brainsight.py +979 -0
  36. pynibs/expio/brainvis.py +71 -0
  37. pynibs/expio/cobot.py +239 -0
  38. pynibs/expio/exp.py +1876 -0
  39. pynibs/expio/fit_funs.py +287 -0
  40. pynibs/expio/localite.py +1987 -0
  41. pynibs/expio/signal_ced.py +51 -0
  42. pynibs/expio/visor.py +624 -0
  43. pynibs/freesurfer.py +502 -0
  44. pynibs/hdf5_io/__init__.py +10 -0
  45. pynibs/hdf5_io/hdf5_io.py +1857 -0
  46. pynibs/hdf5_io/xdmf.py +1542 -0
  47. pynibs/mesh/__init__.py +3 -0
  48. pynibs/mesh/mesh_struct.py +1394 -0
  49. pynibs/mesh/transformations.py +866 -0
  50. pynibs/mesh/utils.py +1103 -0
  51. pynibs/models/_TMS.py +211 -0
  52. pynibs/models/__init__.py +0 -0
  53. pynibs/muap.py +392 -0
  54. pynibs/neuron/__init__.py +2 -0
  55. pynibs/neuron/neuron_regression.py +284 -0
  56. pynibs/neuron/util.py +58 -0
  57. pynibs/optimization/__init__.py +5 -0
  58. pynibs/optimization/multichannel.py +278 -0
  59. pynibs/optimization/opt_mep.py +152 -0
  60. pynibs/optimization/optimization.py +1445 -0
  61. pynibs/optimization/workhorses.py +698 -0
  62. pynibs/pckg/__init__.py +0 -0
  63. pynibs/pckg/biosig/biosig4c++-1.9.5.src_fixed.tar.gz +0 -0
  64. pynibs/pckg/libeep/__init__.py +0 -0
  65. pynibs/pckg/libeep/pyeep.so +0 -0
  66. pynibs/regression/__init__.py +11 -0
  67. pynibs/regression/dual_node_detection.py +2375 -0
  68. pynibs/regression/regression.py +2984 -0
  69. pynibs/regression/score_types.py +0 -0
  70. pynibs/roi/__init__.py +2 -0
  71. pynibs/roi/roi.py +895 -0
  72. pynibs/roi/roi_structs.py +1233 -0
  73. pynibs/subject.py +1009 -0
  74. pynibs/tensor_scaling.py +144 -0
  75. pynibs/tests/data/InstrumentMarker20200225163611937.xml +19 -0
  76. pynibs/tests/data/TriggerMarkers_Coil0_20200225163443682.xml +14 -0
  77. pynibs/tests/data/TriggerMarkers_Coil1_20200225170337572.xml +6373 -0
  78. pynibs/tests/data/Xdmf.dtd +89 -0
  79. pynibs/tests/data/brainsight_niiImage_nifticoord.txt +145 -0
  80. pynibs/tests/data/brainsight_niiImage_nifticoord_largefile.txt +1434 -0
  81. pynibs/tests/data/brainsight_niiImage_niifticoord_mixedtargets.txt +47 -0
  82. pynibs/tests/data/create_subject_testsub.py +332 -0
  83. pynibs/tests/data/data.hdf5 +0 -0
  84. pynibs/tests/data/geo.hdf5 +0 -0
  85. pynibs/tests/test_coil.py +474 -0
  86. pynibs/tests/test_elements2nodes.py +100 -0
  87. pynibs/tests/test_hdf5_io/test_xdmf.py +61 -0
  88. pynibs/tests/test_mesh_transformations.py +123 -0
  89. pynibs/tests/test_mesh_utils.py +143 -0
  90. pynibs/tests/test_nnav_imports.py +101 -0
  91. pynibs/tests/test_quality_measures.py +117 -0
  92. pynibs/tests/test_regressdata.py +289 -0
  93. pynibs/tests/test_roi.py +17 -0
  94. pynibs/tests/test_rotations.py +86 -0
  95. pynibs/tests/test_subject.py +71 -0
  96. pynibs/tests/test_util.py +24 -0
  97. pynibs/tms_pulse.py +34 -0
  98. pynibs/util/__init__.py +4 -0
  99. pynibs/util/dosing.py +233 -0
  100. pynibs/util/quality_measures.py +562 -0
  101. pynibs/util/rotations.py +340 -0
  102. pynibs/util/simnibs.py +763 -0
  103. pynibs/util/util.py +727 -0
  104. pynibs/visualization/__init__.py +2 -0
  105. pynibs/visualization/para.py +4372 -0
  106. pynibs/visualization/plot_2D.py +137 -0
  107. pynibs/visualization/render_3D.py +347 -0
@@ -0,0 +1,1233 @@
1
+ """
2
+ Classes to cope with cortical region of interests (ROIs).
3
+ """
4
+ import os
5
+ import math
6
+ import scipy
7
+ import skimage
8
+ import tqdm
9
+ import trimesh
10
+ import warnings
11
+ import numpy as np
12
+ import nibabel as nib
13
+ import scipy.interpolate
14
+
15
+ try:
16
+ import simnibs
17
+ except ModuleNotFoundError:
18
+ pass
19
+
20
+ import pynibs
21
+
22
+
23
+ class CorticalLayer:
24
+ __create_key = object()
25
+
26
+ class Settings:
27
+ ROI_SIZE_OFFSET = 5
28
+ GRID_POINTS_PER_MM = 1.5
29
+ TAG_WHITE_MATTER_VOL = 1
30
+ TAG_GRAY_MATTER_VOL = 2
31
+ TAG_WHITE_MATTER_SURF = 1001
32
+ TAG_GRAY_MATTER_SURF = 1002
33
+ NUM_TRIANGLE_SMOOTHING_STEPS = 30
34
+
35
+ def __init__(self, create_key, layer_id, volumetric_mesh=None, roi=None, depth=None, path=None, surface=None,
36
+ id=None):
37
+ """
38
+ Constructor of the cortical layer class. Three optional ways of construction a CorticalLayer-instance
39
+ a) by providing a path to an already existing layer (simnibs.Msh)
40
+ b) by proving a simnibs.Msh of an already existing layer.
41
+ c) by providing the bounding box of the ROI in which the layer should be created.
42
+
43
+ Parameters
44
+ ----------
45
+ layer_id : str
46
+ Identifier of the layer.
47
+ volumetric_mesh : simnibs.Msh, optional
48
+ The tetrahedral volume mesh, in which the layer should be generated.
49
+ roi : pynibs.RegionOfInterestSurface
50
+ RegionOfInterestSurface.
51
+ depth: float, optional
52
+ Normalized distance of the layer from gray matter surface.
53
+ Provide values in the open interval (0,1).
54
+ path : str, optional
55
+ File path to a region of interest surfe (e.g. midlayer).
56
+ surface : simnibs.Msh, optional
57
+ The surface representation of an already existing layer (e.g. midlayer).
58
+ id : str, optional, deprecated
59
+ This was replaced by layer_id.
60
+ """
61
+ if id is not None:
62
+ warnings.warn(DeprecationWarning("The parameter 'id' is deprecated and will be removed in future versions. "
63
+ "use 'layer_id' instead."))
64
+ assert layer_id is None, "Please use 'layer_id' instead of 'id'."
65
+ layer_id = id
66
+
67
+ # trying to mimic a private constructor here, from: https://stackoverflow.com/a/46459300
68
+ assert create_key == CorticalLayer.__create_key, \
69
+ "Direct construction not allowed. Please use factory methods " \
70
+ "'init_from_file', 'init_from_surface', 'create_in_bbox'"
71
+ self.id = layer_id
72
+
73
+ CorticalLayer.Settings.ROI_SIZE_OFFSET = 5
74
+
75
+ if path is not None:
76
+ self.surface = simnibs.read_msh(path)
77
+ self.roi = CorticalLayer.roi_bbox_from_points(self.surface.nodes.node_coord,
78
+ CorticalLayer.Settings.ROI_SIZE_OFFSET)
79
+ elif surface is not None:
80
+ self.surface = surface
81
+ self.roi = CorticalLayer.roi_bbox_from_points(self.surface.nodes.node_coord,
82
+ CorticalLayer.Settings.ROI_SIZE_OFFSET)
83
+ elif roi is not None and depth is not None:
84
+ bbox = [
85
+ np.min(roi.node_coord_mid[:, 0]),
86
+ np.max(roi.node_coord_mid[:, 0]),
87
+ np.min(roi.node_coord_mid[:, 1]),
88
+ np.max(roi.node_coord_mid[:, 1]),
89
+ np.min(roi.node_coord_mid[:, 2]),
90
+ np.max(roi.node_coord_mid[:, 2])
91
+ ]
92
+
93
+ self.volumetric_mesh = CorticalLayer.crop_mesh_with_box(
94
+ volumetric_mesh,
95
+ bbox,
96
+ True
97
+ )
98
+
99
+ self.surface = None
100
+ self.roi = roi
101
+ self.generate_layer(depth, roi)
102
+ else:
103
+ raise ValueError('At least one set of optional parameters must be assigned')
104
+
105
+ @classmethod
106
+ def init_from_file(cls, layer_id, fn):
107
+ """
108
+ Factory method for constructing a CorticalLayer-object from a file.
109
+
110
+ Parameters
111
+ ----------
112
+ layer_id : str
113
+ Identifier of the layer.
114
+ fn : str
115
+ File path to a region of interest surfe (e.g. midlayer).
116
+ """
117
+ return CorticalLayer(cls.__create_key, layer_id=layer_id, path=fn)
118
+
119
+ @classmethod
120
+ def init_from_surface(cls, layer_id, surf):
121
+ """
122
+ Factory method for constructing a CorticalLayer-object from a Simnibs-surface object.
123
+
124
+ Parameters
125
+ ----------
126
+ layer_id : str
127
+ Identifier of the layer.
128
+ surf : simnibs.Msh
129
+ The surface representation of an already existing layer (e.g. midlayer).
130
+ """
131
+ return CorticalLayer(cls.__create_key, layer_id=layer_id, surface=surf)
132
+
133
+ @classmethod
134
+ def create_in_roi(cls, layer_id, roi, depth, volmesh):
135
+ """
136
+ Factory method for constructing a CorticalLayer-object
137
+ within a region-of-interest and a specified cortical depth.
138
+
139
+ Parameters
140
+ ----------
141
+ layer_id : str
142
+ Identifier of the layer.
143
+ roi : pynibs.RegionOfInterestSurface
144
+ RegionOfInterestSurface.
145
+ depth: float
146
+ Normalized distance of the layer from gray matter surface.
147
+ Provide values in the open interval (0,1).
148
+ volmesh : simnibs.Msh
149
+ The tetrahedral volume mesh, in which the layer should be generated.
150
+ """
151
+ return CorticalLayer(cls.__create_key, layer_id=layer_id, volumetric_mesh=volmesh, roi=roi, depth=depth)
152
+
153
+ @classmethod
154
+ def create_in_bbox(cls, layer_id, bbox, depth, volmesh):
155
+ """
156
+ Factory method for constructing a CorticalLayer-object
157
+ within a region-of-interest and a specified cortical depth.
158
+
159
+ Parameters
160
+ ----------
161
+ layer_id : str
162
+ Identifier of the layer.
163
+ bbox : typing.List[float]
164
+ List of bounding values around the ROI box: [x_min, x_max, y_min, y_max, z_min, z_max].
165
+ depth: float
166
+ Normalized distance of the layer from gray matter surface.
167
+ Provide values in the open interval (0,1).
168
+ volmesh : simnibs.Msh
169
+ The tetrahedral volume mesh, in which the layer should be generated.
170
+ """
171
+ return CorticalLayer(cls.__create_key, layer_id=layer_id, volumetric_mesh=volmesh, roi=bbox, depth=depth)
172
+
173
+ @staticmethod
174
+ def roi_bbox_from_points(points, offset=0):
175
+ """
176
+ Find the minimal bounding box around the provided points.
177
+
178
+ Parameters
179
+ ----------
180
+ points : simnibs.Msh
181
+ The tetrahedral volume mesh, in which the layer should be generated.
182
+ offset: float
183
+ Normalized distance of the layer from gray matter surface.
184
+ Provide values in the open interval (0,1).
185
+
186
+ Returns
187
+ -------
188
+ bounding_box : typing.List[float]
189
+ List of bounding values around the provided points: [x_min, x_max, y_min, y_max, z_min, z_max].
190
+ """
191
+ return [points[:, 0].min() - offset, points[:, 0].max() + offset,
192
+ points[:, 1].min() - offset, points[:, 1].max() + offset,
193
+ points[:, 2].min() - offset, points[:, 2].max() + offset]
194
+
195
+ @staticmethod
196
+ def crop_mesh_with_box(mesh, roi, keep_elements=False):
197
+ """
198
+ Returns the cropped mesh with all points that are inside the region of interest
199
+
200
+ Parameters
201
+ ----------
202
+ keep_elements : bool, default = False
203
+ If True, keeps elements with at least one point in roi, else removes them.
204
+ mesh: simnibs.Msh
205
+ The mesh that is supposed to be cropped.
206
+ roi: typing.List[float]
207
+ The bounding box of the region of interest which the mesh should be cropped to.
208
+ [x-min, x-max, y-min, y-max, z-min, z-max].
209
+
210
+ Returns
211
+ -------
212
+ mesh_cropped : simnibs.Msh
213
+ The cropped mesh.
214
+ """
215
+ node_keep_indexes = np.where(
216
+ np.all(
217
+ np.logical_and(
218
+ mesh.nodes.node_coord <= [roi[1], roi[3], roi[5]],
219
+ [roi[0], roi[2], roi[4]] <= mesh.nodes.node_coord
220
+ ),
221
+ axis=1
222
+ )
223
+ )[0] + 1
224
+
225
+ if keep_elements: # crop using node-based indices
226
+ return mesh.crop_mesh(nodes=node_keep_indexes)
227
+ else: # crop using element-based indices
228
+ elements_to_keep = np.where(
229
+ np.all(
230
+ np.isin(
231
+ mesh.elm.node_number_list,
232
+ node_keep_indexes
233
+ ).reshape(-1, 4),
234
+ axis=1
235
+ )
236
+ )[0] + 1
237
+
238
+ return mesh.crop_mesh(elements=elements_to_keep)
239
+
240
+ @staticmethod
241
+ def crop_mesh_with_surface(mesh, roi, keep_elements=False, radius=3):
242
+ """
243
+ Returns the cropped mesh with all points that are close to the surface of interest
244
+
245
+ Parameters
246
+ ----------
247
+ keep_elements : bool, default = False
248
+ If True, keeps elements with at least one point in roi, else removes them.
249
+ mesh: simnibs.Msh
250
+ The mesh that is supposed to be cropped.
251
+ roi: RegionOfInterestSurface instance
252
+ RegionOfInterestSurface.
253
+ radius : float, default = 3
254
+ Search radius of mesh elements around ROI nodes.
255
+
256
+ Returns
257
+ -------
258
+ mesh_cropped : simnibs.Msh
259
+ The cropped mesh.
260
+ """
261
+ node_keep = np.zeros(mesh.nodes.node_coord.shape[0]).astype(bool)
262
+
263
+ for vertex in roi.node_coord_mid:
264
+ node_keep = np.logical_or(node_keep,
265
+ np.linalg.norm(mesh.nodes.node_coord - vertex, axis=1) < radius)
266
+ node_keep_indexes = np.where(node_keep)[0] + 1
267
+
268
+ if keep_elements: # crop using node-based indices
269
+ return mesh.crop_mesh(nodes=node_keep_indexes)
270
+ else: # crop using element-based indices
271
+ elements_to_keep = np.where(
272
+ np.all(
273
+ np.isin(
274
+ mesh.elm.node_number_list,
275
+ node_keep_indexes
276
+ ).reshape(-1, 4),
277
+ axis=1
278
+ )
279
+ )[0] + 1
280
+
281
+ return mesh.crop_mesh(elements=elements_to_keep)
282
+
283
+ def generate_layer(self, depth, roi):
284
+ """
285
+ Create the geometry of the layer at the specified depth using marching cubes.
286
+
287
+ Parameters
288
+ ----------
289
+ depth : float
290
+ The depth below the GM surface at which the layer should be generated; in [0,1].
291
+ roi : RegionOfInterestSurface instance
292
+ RegionOfInterestSurface.
293
+ """
294
+ bbox = [
295
+ np.min(roi.node_coord_mid[:, 0]),
296
+ np.max(roi.node_coord_mid[:, 0]),
297
+ np.min(roi.node_coord_mid[:, 1]),
298
+ np.max(roi.node_coord_mid[:, 1]),
299
+ np.min(roi.node_coord_mid[:, 2]),
300
+ np.max(roi.node_coord_mid[:, 2])
301
+ ]
302
+
303
+ roi_extended = [
304
+ bbox[0] - CorticalLayer.Settings.ROI_SIZE_OFFSET,
305
+ bbox[1] + CorticalLayer.Settings.ROI_SIZE_OFFSET,
306
+ bbox[2] - CorticalLayer.Settings.ROI_SIZE_OFFSET,
307
+ bbox[3] + CorticalLayer.Settings.ROI_SIZE_OFFSET,
308
+ bbox[4] - CorticalLayer.Settings.ROI_SIZE_OFFSET,
309
+ bbox[5] + CorticalLayer.Settings.ROI_SIZE_OFFSET
310
+ ]
311
+
312
+ # 1) Create grid points used for interpolation.
313
+ grid_x = np.linspace(roi_extended[0], roi_extended[1],
314
+ int(math.fabs(
315
+ roi_extended[0] - roi_extended[1]) * CorticalLayer.Settings.GRID_POINTS_PER_MM))
316
+ grid_y = np.linspace(roi_extended[2], roi_extended[3],
317
+ int(math.fabs(
318
+ roi_extended[2] - roi_extended[3]) * CorticalLayer.Settings.GRID_POINTS_PER_MM))
319
+ grid_z = np.linspace(roi_extended[4], roi_extended[5],
320
+ int(math.fabs(
321
+ roi_extended[4] - roi_extended[5]) * CorticalLayer.Settings.GRID_POINTS_PER_MM))
322
+ grid_points = np.stack(np.meshgrid(grid_x, grid_y, grid_z, indexing='ij'), axis=-1).reshape(-1, 3)
323
+
324
+ # 2) Find points in interpolation grid that are inside/outside the gray matter.
325
+ tet_idcs = self.volumetric_mesh.find_tetrahedron_with_points(grid_points, compute_baricentric=False)
326
+
327
+ point_indices_in_volume = np.where(tet_idcs != -1)[0]
328
+
329
+ point_tissue_tag = np.ones(tet_idcs.shape) * -1
330
+
331
+ point_tissue_tag[point_indices_in_volume] = self.volumetric_mesh.elm.tag1[
332
+ tet_idcs[point_indices_in_volume] - 1 # make indices 0-based
333
+ ]
334
+ grid_point_idcs_outside_gm_wm = np.where(
335
+ (point_tissue_tag != CorticalLayer.Settings.TAG_WHITE_MATTER_VOL)
336
+ &
337
+ (point_tissue_tag != CorticalLayer.Settings.TAG_GRAY_MATTER_VOL)
338
+ )[0]
339
+ grid_point_idcs_inside_gm = np.where(point_tissue_tag == CorticalLayer.Settings.TAG_GRAY_MATTER_VOL)[0]
340
+
341
+ # Define vertices of the ROI bounding box and associated data.
342
+ bounding_roi_pts = np.stack(
343
+ np.meshgrid(roi_extended[0:2], roi_extended[2:4], roi_extended[4:6], indexing='ij'),
344
+ axis=-1
345
+ ).reshape(-1, 3)
346
+
347
+ bbox_tet_idcs = self.volumetric_mesh.find_tetrahedron_with_points(bounding_roi_pts, compute_baricentric=False)
348
+ bounding_roi_pts_tissue_tags = self.volumetric_mesh.elm.tag1[
349
+ bbox_tet_idcs - 1 # make indices 0-based
350
+ ]
351
+ # init interpolation points with 1 for WM and 0 outside WM
352
+ bounding_roi_pts_init_vals = np.ones(bounding_roi_pts_tissue_tags.shape)
353
+ # max non-WM bounding box points
354
+ bounding_roi_pts_init_vals[bounding_roi_pts_tissue_tags != CorticalLayer.Settings.TAG_WHITE_MATTER_VOL] = 0
355
+ # if bounding box point is located outside the volume mesh, treat it as non-WM as well (even if it is WM tissue)
356
+ bounding_roi_pts_init_vals[np.where(bbox_tet_idcs == -1)] = 0
357
+
358
+ # 3) Prepare interpolator and interpolate on grid points that are inside GM.
359
+ wm_surface_nodes = self.volumetric_mesh.crop_mesh(CorticalLayer.Settings.TAG_WHITE_MATTER_SURF).nodes.node_coord
360
+ gm_surface_nodes = self.volumetric_mesh.crop_mesh(CorticalLayer.Settings.TAG_GRAY_MATTER_SURF).nodes.node_coord
361
+
362
+ # Create a gradient (from 0 to 1) between GM and WM.
363
+ data = [1] * len(wm_surface_nodes) # init interpolation points with 1 for grid points inside WM
364
+ data += [0] * len(gm_surface_nodes) # init interpolation points with 0 for grid points inside GM
365
+ # use interpolation points at bbox as "outside points" to ensure
366
+ # there is always a defined interpolation point also outside the GM volume
367
+ data += bounding_roi_pts_init_vals.tolist()
368
+
369
+ data_points = np.concatenate((wm_surface_nodes, gm_surface_nodes, bounding_roi_pts), axis=0)
370
+
371
+ interpolation = scipy.interpolate.LinearNDInterpolator(data_points, data, fill_value=-1)
372
+ gray_matter_interpolation = interpolation(grid_points[grid_point_idcs_inside_gm])
373
+
374
+ # 4) Marching-cubes-based surface creation.
375
+ volume_data = np.empty((len(grid_x), len(grid_y), len(grid_z)))
376
+ volume_data.fill(1)
377
+ outside_x = (grid_point_idcs_outside_gm_wm / (len(grid_z) * len(grid_y))).astype(int)
378
+ outside_y = ((grid_point_idcs_outside_gm_wm / len(grid_z)) % len(grid_y)).astype(int)
379
+ outside_z = (grid_point_idcs_outside_gm_wm % len(grid_z)).astype(int)
380
+ volume_data[(outside_x, outside_y, outside_z)] = 0
381
+ inside_gray_matter_x = (grid_point_idcs_inside_gm / (len(grid_z) * len(grid_y))).astype(int)
382
+ inside_gray_matter_y = ((grid_point_idcs_inside_gm / len(grid_z)) % len(grid_y)).astype(int)
383
+ inside_gray_matter_z = (grid_point_idcs_inside_gm % len(grid_z)).astype(int)
384
+ volume_data[(inside_gray_matter_x, inside_gray_matter_y, inside_gray_matter_z)] = gray_matter_interpolation
385
+ vertices, faces, _, _ = skimage.measure.marching_cubes(
386
+ volume_data,
387
+ level=depth,
388
+ spacing=tuple(
389
+ np.array(
390
+ [grid_x[1] - grid_x[0], grid_y[1] - grid_y[0], grid_z[1] - grid_z[0]],
391
+ dtype='float32'
392
+ )
393
+ ),
394
+ step_size=1,
395
+ allow_degenerate=False
396
+ )
397
+
398
+ # 5) prepare surface for output
399
+ self.surface = simnibs.Msh(
400
+ simnibs.Nodes(vertices),
401
+ simnibs.Elements(faces + 1)
402
+ )
403
+ self.remove_unconnected_surfaces()
404
+ self.surface.nodes.node_coord = self.surface.nodes.node_coord + [(roi_extended[0]), (roi_extended[2]),
405
+ (roi_extended[4])]
406
+
407
+ # Crop again for a more precise crop-boundary (earlier cropping was done with larger elements)
408
+ self.surface = CorticalLayer.crop_mesh_with_box(self.surface, bbox, keep_elements=True)
409
+ self.surface = CorticalLayer.crop_mesh_with_surface(self.surface, roi, keep_elements=True)
410
+ self.remove_unconnected_surfaces()
411
+
412
+ # from simnibs.Msh.fix_surface_orientation (not implemented in SimNIBS < v.4)
413
+ idx_tr = self.surface.elm.elm_type == 2
414
+ normals = self.surface.triangle_normals()[:]
415
+ baricenters = self.surface.elements_baricenters()[idx_tr]
416
+ CoG = np.mean(baricenters, axis=0)
417
+
418
+ nr_inward = sum(np.einsum("ij,ij->i", normals, baricenters - CoG) < 0)
419
+
420
+ if nr_inward / sum(idx_tr) > 0.5:
421
+ buffer = self.surface.elm.node_number_list[idx_tr, 1].copy()
422
+ self.surface.elm.node_number_list[idx_tr, 1] = self.surface.elm.node_number_list[idx_tr, 2]
423
+ self.surface.elm.node_number_list[idx_tr, 2] = buffer
424
+
425
+ def get_smoothed_normals(self):
426
+ """
427
+ Computed the smoothed normals of the surface representation of this layer.
428
+
429
+ Note: For the later stages, we don't want a smoothed surface, but smooth
430
+ normals in order to maintain the location of the cells, but orient
431
+ them more smoothly. Therefore, we use smoothed normals, e.g. for the
432
+ computation of the theta angle, but do not smooth the entire layer
433
+ surface.
434
+
435
+ Returns
436
+ -------
437
+ normals : np.ndarray
438
+ The tetrahedral volume mesh, in which the layer should be generated.
439
+ """
440
+ return self.surface.triangle_normals(smooth=CorticalLayer.Settings.NUM_TRIANGLE_SMOOTHING_STEPS).value
441
+
442
+ def save(self, fn):
443
+ """
444
+ Save the current surface representation of this CorticalLayer instance at the specified location.
445
+
446
+ Parameters
447
+ ----------
448
+ fn : str
449
+ Target file name of the surface-file of this layer.
450
+ """
451
+ self.surface.write(fn)
452
+
453
+ def remove_unconnected_surfaces(self):
454
+ """
455
+ Remove elements small unconnected element-clusters from this layer.
456
+ """
457
+ surfaces = self.surface.elm.connected_components()
458
+ surfaces.sort(key=len)
459
+ self.surface = self.surface.crop_mesh(elements=surfaces[-1])
460
+
461
+ def get_evenly_spaced_element_subset(self, elements_per_square_mm):
462
+ """
463
+ Subsample the surface representation of the ayer.
464
+
465
+ Parameters
466
+ ----------
467
+ elements_per_square_mm : float
468
+ Number of triangles per mm^2 in the layer.
469
+
470
+ Returns
471
+ -------
472
+ selected elements : Typing.List[int]
473
+ List of indices of selected elements as a result of the subsampling.
474
+ """
475
+ centers = self.surface.elements_baricenters().value
476
+ min_distance_square = (1 / elements_per_square_mm) * math.sqrt(2) / 2 # = radius of circumference
477
+ selected_elements = np.array([0])
478
+
479
+ # For each element: add to the list of 'selected_elements' if the distance to the already
480
+ # selected elements is larger than the minimum element density.
481
+ for element_index in range(self.surface.elm.nr):
482
+ selected_elements_centers = centers[selected_elements]
483
+ element_center = centers[element_index]
484
+ distances_square = (selected_elements_centers[:, 0] - element_center[0]) ** 2 + \
485
+ (selected_elements_centers[:, 1] - element_center[1]) ** 2 + \
486
+ (selected_elements_centers[:, 2] - element_center[2]) ** 2
487
+ if distances_square.min() > min_distance_square:
488
+ selected_elements = np.append(selected_elements, element_index)
489
+
490
+ return np.array(selected_elements)
491
+
492
+
493
+ class RegionOfInterestSurface:
494
+ """
495
+ Region of interest (surface).
496
+
497
+ Attributes
498
+ ----------
499
+ node_coord_up : np.ndarray
500
+ (N_points, 3) Coordinates (x,y,z) of upper surface nodes.
501
+ node_coord_mid : np.ndarray
502
+ (N_points, 3) Coordinates (x,y,z) of middle surface nodes.
503
+ node_coord_low : np.ndarray
504
+ (N_points, 3) Coordinates (x,y,z) of lower surface nodes.
505
+ node_number_list : np.ndarray
506
+ (N_points, 3) Connectivity matrix of triangles.
507
+ delta : float
508
+ Distance parameter between WM and GM (0 -> WM, 1 -> GM).
509
+ tet_idx_tri_center_up : np.ndarray [N_points]
510
+ Tetrahedra indices of TetrahedraLinear object instance where the center points of the triangles of the
511
+ upper surface are.
512
+ tet_idx_tri_center_mid : np.ndarray [N_points]
513
+ Tetrahedra indices of TetrahedraLinear object instance where the center points of the triangles of the
514
+ middle surface are.
515
+ tet_idx_tri_center_low : np.ndarray [N_points]
516
+ Tetrahedra indices of TetrahedraLinear object instance where the center points of the triangles of the
517
+ lower surface are.
518
+ tet_idx_node_coord_mid : np.ndarray
519
+ (N_tri,) Tetrahedra indices of TetrahedraLinear object instance where the nodes of the middle surface are.
520
+ tri_center_coord_up : np.ndarray
521
+ (N_tri, 3) Coordinates of roi triangle center of upper surface
522
+ tri_center_coord_mid : np.ndarray
523
+ (N_tri, 3) Coordinates of roi triangle center of middle surface
524
+ tri_center_coord_low : np.ndarray
525
+ (N_tri, 3) Coordinates of roi triangle center of lower surface
526
+ fn_mask : string
527
+ Filename for surface mask in subject space. .mgh file or freesurfer surface file.
528
+ fn_mask_avg : string
529
+ Filename for .mgh mask in fsaverage space. Absolute path or relative to mesh folder.
530
+ fn_mask_nii : string
531
+ Filename for .nii or .nii.gz mask. Absolute path or relative to mesh folder.
532
+ X_ROI : list of float
533
+ Region of interest [Xmin, Xmax], whole X range if empty [0,0] or None
534
+ (left - right)
535
+ Y_ROI : list of float
536
+ Region of interest [Ymin, Ymax], whole Y range if empty [0,0] or None
537
+ (anterior - posterior)
538
+ Z_ROI : list of float
539
+ Region of interest [Zmin, Zmax], whole Z range if empty [0,0] or None
540
+ (inferior - superior)
541
+ template : str
542
+ 'MNI', 'fsaverage', 'subject'
543
+ center : list of float
544
+ Center coordinates for spherical ROI in self.template space
545
+ radius : float
546
+ Radius in [mm] for spherical ROI
547
+ gm_surf_fname : str or list of str
548
+ Filename(s) of GM surface generated by freesurfer (lh and/or rh)
549
+ (e.g. in mri2msh: .../fs_ID/surf/lh.pial)
550
+ wm_surf_fname : str or list of str
551
+ Filename(s) of WM surface generated by freesurfer (lh and/or rh)
552
+ (e.g. in mri2msh: .../fs_ID/surf/lh.white)
553
+ layer : int
554
+ Define the number of layers:
555
+
556
+ * 1: one layer
557
+ * 3: additionally upper and lower layers are generated around the central midlayer
558
+ """
559
+ def __init__(self):
560
+ """
561
+ Initialize RegionOfInterestSurface class instance
562
+ """
563
+
564
+ self.node_coord_up = np.empty(0)
565
+ self.node_coord_mid = np.empty(0)
566
+ self.node_coord_low = np.empty(0)
567
+
568
+ self.node_number_list = np.empty(0)
569
+ self.delta = []
570
+
571
+ self.tet_idx_tri_center_up = np.empty(0)
572
+ self.tet_idx_tri_center_mid = np.empty(0)
573
+ self.tet_idx_tri_center_low = np.empty(0)
574
+
575
+ self.tet_idx_node_coord_mid = np.empty(0)
576
+
577
+ self.tri_center_coord_up = []
578
+ self.tri_center_coord_mid = []
579
+ self.tri_center_coord_low = []
580
+
581
+ self.template = None
582
+ self.fn_mask = []
583
+ self.fn_mask_avg = None
584
+ self.fn_mask_nii = None
585
+
586
+ self.X_ROI = []
587
+ self.Y_ROI = []
588
+ self.Z_ROI = []
589
+
590
+ self.center = None
591
+ self.radius = None
592
+
593
+ self.gm_surf_fname = []
594
+ self.wm_surf_fname = []
595
+ self.midlayer_surf_fname = []
596
+ self.layer = []
597
+ self.mesh_folder = []
598
+ self.refine = []
599
+
600
+ self.n_tris = -1
601
+ self.n_nodes = -1
602
+ self.n_tets = -1
603
+
604
+ self.layers = []
605
+
606
+ def project_on_midlayer(self, target, verbose=False):
607
+ """
608
+ Project a coordinate on the nearest midlayer node
609
+
610
+ Parameters
611
+ ----------
612
+ target : np.ndarray
613
+ Coordinate to project as (3,) array
614
+ verbose : bool
615
+ Print some verbosity information. Default: False
616
+
617
+ Returns
618
+ -------
619
+ target_proj : np.ndarray
620
+ Node coordinate of nearest midlayer node.
621
+ """
622
+ # find midlayer node that is nearest to the target
623
+ target_node_coord_mid = np.where(np.linalg.norm(self.node_coord_mid - target, axis=1) == np.min(
624
+ np.linalg.norm(self.node_coord_mid - target, axis=1)))[0][0]
625
+
626
+ # get coordinates of that node
627
+ target_proj = self.node_coord_mid[target_node_coord_mid]
628
+
629
+ if verbose:
630
+ print(f"Projected {target} to {target_proj} (Dist: {np.linalg.norm(target - target_proj):2.2f}mm)")
631
+
632
+ return target_proj
633
+
634
+ def make_GM_WM_surface(self, gm_surf_fname=None, wm_surf_fname=None, midlayer_surf_fname=None, mesh_folder=None,
635
+ delta=0.5,
636
+ x_roi=None, y_roi=None, z_roi=None,
637
+ layer=1,
638
+ fn_mask=None, refine=False):
639
+ """
640
+ Generating a surface between WM and GM in a distance of delta 0...1 for ROI,
641
+ given by Freesurfer mask or coordinates.
642
+
643
+ Parameters
644
+ ----------
645
+ gm_surf_fname : str or list of str
646
+ Filename(s) of GM FreeSurfer surface(s) (lh and/or rh).
647
+ Either relative to mesh_folder (fs_ID/surf/lh.pial) or absolute (/full/path/to/lh.pial)
648
+ wm_surf_fname : str or list of str
649
+ Filename(s) of WM FreeSurfer surface(s) (lh and/or rh)
650
+ Either relative to mesh_folder (fs_ID/surf/lh.white) or absolute (/full/path/to/lh.white)
651
+ midlayer_surf_fname : str or list of str
652
+ Filename(s) of midlayer surface (lh and/or rh)
653
+ Either relative to mesh_folder (fs_ID/surf/lh.central) or absolute (/full/path/to/lh.central)
654
+ mesh_folder : str
655
+ Root folder of mesh, Needed if paths above are given relative, or refine=True
656
+ [defunct] m2m_mat_fname : str
657
+ Filename of mri2msh transformation matrix
658
+ (e.g. in mri2msh: .../m2m_ProbandID/MNI2conform_6DOF.mat)
659
+ delta : float
660
+ Distance parameter where surface is generated 0...1 (default: 0.5)
661
+
662
+ * 0 -> WM surface
663
+ * 1 -> GM surface
664
+ x_roi : list of float
665
+ Region of interest [Xmin, Xmax], whole X range if empty [0,0] or None
666
+ (left - right)
667
+ y_roi : list of float
668
+ Region of interest [Ymin, Ymax], whole Y range if empty [0,0] or None
669
+ (anterior - posterior)
670
+ z_roi : list of float
671
+ Region of interest [Zmin, Zmax], whole Z range if empty [0,0] or None
672
+ (inferior - superior)
673
+ layer : int
674
+ Define the number of layers:
675
+
676
+ * 1: one layer
677
+ * 3: additionally upper and lower layers are generated around the central midlayer
678
+ fn_mask : str
679
+ Filename for FreeSurfer .mgh mask.
680
+ refine : bool, optional, default: False
681
+ Refine ROI by splitting elements
682
+
683
+ Returns
684
+ -------
685
+ node_coord_up : np.ndarray of float [N_roi_points x 3]
686
+ Node coordinates (x, y, z) of upper epsilon layer of ROI surface
687
+ node_coord_mid : np.ndarray of float [N_roi_points x 3]
688
+ Node coordinates (x, y, z) of ROI surface
689
+ node_coord_low : np.ndarray of float [N_roi_points x 3]
690
+ Node coordinates (x, y, z) of lower epsilon layer of ROI surface
691
+ node_number_list : np.ndarray of int [N_roi_tri x 3]
692
+ Connectivity matrix of intermediate surface layer triangles
693
+ delta : float
694
+ Distance parameter where surface is generated 0...1 (default: 0.5)
695
+
696
+ * 0 -> WM surface
697
+ * 1 -> GM surface
698
+ tri_center_coord_up : np.ndarray of float [N_roi_tri x 3]
699
+ Coordinates (x, y, z) of triangle center of upper epsilon layer of ROI surface
700
+ tri_center_coord_mid : np.ndarray of float [N_roi_tri x 3]
701
+ Coordinates (x, y, z) of triangle center of ROI surface
702
+ tri_center_coord_low : np.ndarray of float [N_roi_tri x 3]
703
+ Coordinates (x, y, z) of triangle center of lower epsilon layer of ROI surface
704
+ fn_mask : str
705
+ Filename for freesurfer mask. If given, this is used instead of *_ROIs
706
+ X_ROI : list of float
707
+ Region of interest [Xmin, Xmax], whole X range if empty [0,0] or None
708
+ (left - right)
709
+ Y_ROI : list of float
710
+ Region of interest [Ymin, Ymax], whole Y range if empty [0,0] or None
711
+ (anterior - posterior)
712
+ Z_ROI : list of float
713
+ Region of interest [Zmin, Zmax], whole Z range if empty [0,0] or None
714
+ (inferior - superior)
715
+
716
+ Example
717
+ -------
718
+ .. code-block:: python
719
+
720
+ make_GM_WM_surface(self, gm_surf_fname, wm_surf_fname, delta, X_ROI, Y_ROI, Z_ROI)
721
+ make_GM_WM_surface(self, gm_surf_fname, wm_surf_fname, delta, mask_fn, layer=3)
722
+ """
723
+ self.gm_surf_fname = gm_surf_fname
724
+ self.wm_surf_fname = wm_surf_fname
725
+ self.midlayer_surf_fname = midlayer_surf_fname
726
+ self.layer = layer
727
+ self.mesh_folder = mesh_folder
728
+ self.fn_mask = fn_mask
729
+ self.delta = delta
730
+ self.X_ROI = x_roi
731
+ self.Y_ROI = y_roi
732
+ self.Z_ROI = z_roi
733
+ self.refine = refine
734
+
735
+ if type(gm_surf_fname) is not list:
736
+ gm_surf_fname = [gm_surf_fname]
737
+
738
+ if type(wm_surf_fname) is not list:
739
+ wm_surf_fname = [wm_surf_fname]
740
+
741
+ if type(midlayer_surf_fname) is not list:
742
+ midlayer_surf_fname = [midlayer_surf_fname]
743
+
744
+ if len(gm_surf_fname) != len(wm_surf_fname):
745
+ raise ValueError('provide equal number of GM and WM surfaces!')
746
+
747
+ # load surface data
748
+ points_gm = [None for _ in range(len(gm_surf_fname))]
749
+ points_wm = [None for _ in range(len(wm_surf_fname))]
750
+ points_mid = [None for _ in range(len(midlayer_surf_fname))]
751
+ con_gm = [None for _ in range(len(gm_surf_fname))]
752
+ con_mid = [None for _ in range(len(midlayer_surf_fname))]
753
+
754
+ max_idx_gm = 0
755
+ max_idx_mid = 0
756
+
757
+ def read_geom(fn, max_idx):
758
+ """
759
+ Read freesurfer geometries, either in .central format or as .gii
760
+ """
761
+ if not fn.startswith(os.sep):
762
+ fn = os.path.join(mesh_folder, fn)
763
+
764
+ # charm uses .gii files
765
+ if fn.endswith('.gii'):
766
+ # FIX: add_data returns DataArrays in the order of appearance in the file
767
+ # This does not necessarily have to be first points then triangles.
768
+ # So "points, con = nib.load(fn).agg_data()" may lead to swapped points and con.
769
+ con = nib.load(fn).agg_data('NIFTI_INTENT_TRIANGLE')
770
+ points = nib.load(fn).agg_data('NIFTI_INTENT_POINTSET')
771
+
772
+ # headreco uses .central files
773
+ else:
774
+ points, con = nib.freesurfer.read_geometry(fn)
775
+
776
+ con += max_idx
777
+ max_idx += points.shape[0] # np.max(con_gm[i]) + 2
778
+ return points, con, max_idx
779
+
780
+ for i in range(len(gm_surf_fname)):
781
+ if gm_surf_fname[i] is not None:
782
+ points_gm[i], con_gm[i], max_idx_gm = read_geom(gm_surf_fname[i], max_idx_gm)
783
+
784
+ if wm_surf_fname[i] is not None:
785
+ points_wm[i], _, max_idx_wm = read_geom(wm_surf_fname[i], max_idx_gm)
786
+
787
+ if midlayer_surf_fname[i] is not None:
788
+ points_mid[i], con_mid[i], max_idx_mid = read_geom(midlayer_surf_fname[i], max_idx_mid)
789
+
790
+ points_gm = np.vstack(points_gm)
791
+ points_wm = np.vstack(points_wm)
792
+ points_mid = np.vstack(points_mid)
793
+ con_gm = np.vstack(con_gm)
794
+ con_mid = np.vstack(con_mid)
795
+
796
+ # Determine 3 layer midlayer if GM and WM surfaces are present otherwise use provided midlayer data
797
+ if gm_surf_fname[0] is not None and wm_surf_fname[0] is not None:
798
+ # determine vector pointing from wm surface to gm surface
799
+ wm_gm_vector = points_gm - points_wm
800
+
801
+ eps_0 = 0.025
802
+ eps, surface_points_upper, surface_points_lower = (False,) * 3
803
+ surface_points_upper = False
804
+ if layer == 3:
805
+ # set epsilon range for upper and lower surface
806
+ if delta < eps_0:
807
+ eps = delta / 2
808
+ elif delta > (1 - eps_0):
809
+ eps = (1 - delta) / 2
810
+ else:
811
+ eps = eps_0
812
+
813
+ # determine wm-gm surfaces
814
+ surface_points_middle = points_wm + wm_gm_vector * delta # type: np.ndarray
815
+ if layer == 3:
816
+ surface_points_upper = surface_points_middle + wm_gm_vector * eps
817
+ surface_points_lower = surface_points_middle - wm_gm_vector * eps
818
+
819
+ con = con_gm
820
+
821
+ elif midlayer_surf_fname[0] is not None:
822
+ self.layer = 1
823
+
824
+ surface_points_upper = None
825
+ surface_points_lower = None
826
+ surface_points_middle = points_mid
827
+
828
+ con = con_mid
829
+
830
+ else:
831
+ raise IOError("Please provide GM and WM surfaces or midlayer "
832
+ "surface directly for midlayer calculation...")
833
+
834
+ # crop region if desired
835
+ x_roi_wb, y_roi_wb, z_roi_wb = (False,) * 3
836
+ if fn_mask is None:
837
+ # crop to region of interest
838
+ if x_roi is None or x_roi == [0, 0]:
839
+ x_roi = [-np.inf, np.inf]
840
+ x_roi_wb = True
841
+ if y_roi is None or y_roi == [0, 0]:
842
+ y_roi = [-np.inf, np.inf]
843
+ y_roi_wb = True
844
+ if z_roi is None or z_roi == [0, 0]:
845
+ z_roi = [-np.inf, np.inf]
846
+ z_roi_wb = True
847
+
848
+ roi_mask_bool = (surface_points_middle[:, 0] > min(x_roi)) & (surface_points_middle[:, 0] < max(x_roi)) & \
849
+ (surface_points_middle[:, 1] > min(y_roi)) & (surface_points_middle[:, 1] < max(y_roi)) & \
850
+ (surface_points_middle[:, 2] > min(z_roi)) & (surface_points_middle[:, 2] < max(z_roi))
851
+ roi_mask_idx = np.where(roi_mask_bool)
852
+
853
+ else:
854
+ if x_roi is not None or y_roi is not None or z_roi is not None:
855
+ raise ValueError(f"Either provide X_ROI, Y_ROI, Z_ROI or fn_mask, not both.")
856
+
857
+ # read mask from freesurfer mask file
858
+ if not fn_mask.startswith(os.sep):
859
+ fn_mask = os.path.join(mesh_folder, fn_mask)
860
+ if fn_mask.endswith('.mgh') or fn_mask.endswith('.mgz'):
861
+ mask = nib.freesurfer.mghformat.MGHImage.from_filename(fn_mask).dataobj[:]
862
+ else:
863
+ mask = nib.freesurfer.read_morph_data(fn_mask)[:, np.newaxis, np.newaxis]
864
+ roi_mask_idx = np.where(mask > 0.5)
865
+
866
+ # redefine connectivity matrix for cropped points (reindexing)
867
+ # get row index where all points are lying inside ROI
868
+ if not (x_roi_wb and y_roi_wb and z_roi_wb):
869
+ con_row_idx = [i for i in range(con.shape[0]) if len(np.intersect1d(con[i, ], roi_mask_idx)) == 3]
870
+ # crop connectivity matrix to ROI
871
+ con_cropped = con[con_row_idx, ]
872
+ else:
873
+ con_cropped = con
874
+
875
+ # evaluate new indices of cropped connectivity matrix
876
+ point_idx_before, point_idx_after = np.unique(con_cropped, return_inverse=True)
877
+ con_cropped_reform = np.reshape(point_idx_after, (con_cropped.shape[0], con_cropped.shape[1]))
878
+
879
+ # crop points to ROI
880
+ surface_points_middle = surface_points_middle[point_idx_before, ]
881
+ if self.layer == 3:
882
+ surface_points_upper = surface_points_upper[point_idx_before, ]
883
+ surface_points_lower = surface_points_lower[point_idx_before, ]
884
+
885
+ # refine
886
+ if refine:
887
+ if not os.path.exists(os.path.join(self.mesh_folder, "roi", "tmp")):
888
+ os.makedirs(os.path.join(self.mesh_folder, "roi", "tmp"))
889
+
890
+ mesh = trimesh.Trimesh(vertices=surface_points_middle,
891
+ faces=con_cropped_reform)
892
+ roi_fn = os.path.join(self.mesh_folder, "roi", "tmp", "roi.stl")
893
+ mesh.export(roi_fn)
894
+
895
+ roi_refined_fn = os.path.join(self.mesh_folder, "roi", "tmp", "roi_refined.stl")
896
+ pynibs.refine_surface(fn_surf=roi_fn,
897
+ fn_surf_refined=roi_refined_fn,
898
+ center=[0, 0, 0],
899
+ radius=np.inf,
900
+ verbose=True,
901
+ repair=False)
902
+
903
+ roi = trimesh.load(roi_refined_fn)
904
+ con_cropped_reform = roi.faces
905
+ surface_points_middle = roi.vertices
906
+
907
+ if self.layer == 3:
908
+ roi = []
909
+
910
+ for p in [surface_points_upper, surface_points_lower]:
911
+ mesh = trimesh.Trimesh(vertices=p,
912
+ faces=con_cropped_reform)
913
+ mesh.export(roi_fn)
914
+
915
+ pynibs.refine_surface(fn_surf=roi_fn,
916
+ fn_surf_refined=roi_refined_fn,
917
+ center=[0, 0, 0],
918
+ radius=np.inf,
919
+ verbose=True,
920
+ repair=False)
921
+
922
+ roi.append(trimesh.load(roi_refined_fn))
923
+
924
+ surface_points_upper = roi[0].vertices
925
+ surface_points_lower = roi[1].vertices
926
+
927
+ self.node_coord_mid = surface_points_middle
928
+ self.node_number_list = con_cropped_reform
929
+
930
+ if self.layer == 3:
931
+ self.node_coord_up = surface_points_upper
932
+ self.node_coord_low = surface_points_lower
933
+ self.tri_center_coord_up = np.average(self.node_coord_up[self.node_number_list], axis=1)
934
+ self.tri_center_coord_low = np.average(self.node_coord_low[self.node_number_list], axis=1)
935
+
936
+ self.tri_center_coord_mid = np.average(self.node_coord_mid[self.node_number_list], axis=1)
937
+
938
+ if self.layer == 3:
939
+ return surface_points_upper, surface_points_middle, surface_points_lower, con_cropped_reform
940
+ else:
941
+ return surface_points_middle, con_cropped_reform
942
+
943
+ def generate_cortical_laminae(self, # use tuple instead of list for immutability
944
+ head_model_mesh,
945
+ bbox=None,
946
+ laminae=(0.06, 0.4, 0.55, 0.65, 0.85),
947
+ layer_ids=("L1", "L23", "L4", "L5", "L6")
948
+ ):
949
+ """
950
+ Create the cortical layering with the provided laminar depths.
951
+
952
+ Defaults to the standard depths of the laminae in the neo-cortex from layer I to VI
953
+ from "Simulation of transcranial magnetic stimulation in head model with morphologically-realistic
954
+ cortical neurons", Aberra et al., https://doi.org/10.1016/j.brs.2019.10.002
955
+
956
+ Parameters
957
+ ----------
958
+ head_model_mesh : simnibs.Msh
959
+ The head model volume mesh.
960
+ Inside the GM compartment of this mesh, the layering will be generated.
961
+ bbox : np.ndarray, optional
962
+ Bounding coordinates of the region of interest.
963
+ Optional, if the mid-layer surface is already existing
964
+ (and can thus be used to determine the bounding coordinates).
965
+ laminae : list of float or tuple of float, default: (0.06, 0.4, 0.55, 0.65, 0.85)
966
+ List of depths of the individual to-be created lamiae.
967
+ layer_ids : typing.List[str], default: ("L1", "L23", "L4", "L5", "L6")
968
+ List of layer identifiers.
969
+ """
970
+ assert bbox is not None or self.node_coord_mid is not None, \
971
+ "Neither mid-layer surface was initialized nor explicit bounding box was provided to construct layer."
972
+
973
+ for idx, lam in tqdm.tqdm(enumerate(laminae)):
974
+ self.layers.append(
975
+ CorticalLayer.create_in_roi(
976
+ layer_id=layer_ids[idx],
977
+ roi=self,
978
+ depth=lam,
979
+ volmesh=head_model_mesh
980
+ )
981
+ )
982
+
983
+ def determine_element_idx_in_mesh(self, msh):
984
+ """
985
+ Determines tetrahedra indices of msh where the triangle center points of upper, middle and lower surface
986
+ and the nodes of middle surface are
987
+
988
+ Parameters
989
+ ----------
990
+ msh : pynibs.mesh.mesh_struct.TetrahedraLinear
991
+ TetrahedraLinear object.
992
+
993
+ Returns
994
+ -------
995
+ RegionOfInterestSurface.tet_idx_tri_center_up : np.ndarray
996
+ (N_points) Tetrahedra indices of TetrahedraLinear object instance where the center points of the
997
+ triangles of the upper surface are.
998
+ RegionOfInterestSurface.tet_idx_tri_center_mid : np.ndarray
999
+ (N_points) Tetrahedra indices of TetrahedraLinear object instance where the center points of the triangles
1000
+ of the middle surface are.
1001
+ RegionOfInterestSurface.tet_idx_tri_center_low : np.ndarray
1002
+ (N_points) Tetrahedra indices of TetrahedraLinear object instance where the center points of the
1003
+ triangles of the lower surface are.
1004
+ RegionOfInterestSurface.tet_idx_node_coord_mid : np.ndarray
1005
+ (N_tri) Tetrahedra indices of TetrahedraLinear object instance where the nodes of the middle
1006
+ surface are.
1007
+ """
1008
+ # determine tetrahedra indices of triangle center points of upper, middle and lower surface
1009
+ if self.tri_center_coord_low != [] and self.tri_center_coord_up != []:
1010
+ points = [self.tri_center_coord_low,
1011
+ self.tri_center_coord_mid,
1012
+ self.tri_center_coord_up]
1013
+ else:
1014
+ points = [self.tri_center_coord_mid]
1015
+
1016
+ tet_idx = pynibs.determine_element_idx_in_mesh(fname=None,
1017
+ msh=msh,
1018
+ points=points,
1019
+ compute_baricentric=False)
1020
+
1021
+ if self.tri_center_coord_low != [] and self.tri_center_coord_up != []:
1022
+ self.tet_idx_tri_center_low = tet_idx[:, 0]
1023
+ self.tet_idx_tri_center_mid = tet_idx[:, 1]
1024
+ self.tet_idx_tri_center_up = tet_idx[:, 2]
1025
+ else:
1026
+ self.tet_idx_tri_center_low = None
1027
+ self.tet_idx_tri_center_mid = tet_idx[:, 0]
1028
+ self.tet_idx_tri_center_up = None
1029
+
1030
+ # determine tetrahedra indices of nodes of middle surface
1031
+ self.tet_idx_node_coord_mid = pynibs.determine_element_idx_in_mesh(fname=None,
1032
+ msh=msh,
1033
+ points=self.node_coord_mid,
1034
+ compute_baricentric=False)
1035
+
1036
+ def decimate(self, fraction=.075):
1037
+ """
1038
+ Subsample ROI surface based on a decimation factor and return element indices.
1039
+ (no Freesurfer surfaces associated with the ROI surface required)
1040
+
1041
+ Parameters
1042
+ ----------
1043
+ fraction : float, default: .075
1044
+ Multiplied by the total number of ROI elements determines
1045
+ (approximately) the number of remaining ROI elements after decimation.
1046
+
1047
+ Returns
1048
+ -------
1049
+ ele_idx : np.ndarray of float
1050
+ [approx. fraction * n_ele] Element indices of the subsampled surface; sorted.
1051
+ """
1052
+
1053
+ mesh = trimesh.Trimesh(
1054
+ vertices=self.node_coord_mid,
1055
+ faces=self.node_number_list
1056
+ )
1057
+
1058
+ pts, subsample_idcs = trimesh.sample.sample_surface_even(
1059
+ mesh=mesh,
1060
+ count=int(self.node_number_list.shape[0] * fraction)
1061
+ )
1062
+
1063
+ return np.sort(subsample_idcs)
1064
+
1065
+ def subsample(self, dist=10, fn_sphere=None):
1066
+ """
1067
+ Subsample ROI surface based on a spacing and return element indices
1068
+ (Freesurfer surfaces associatd with the ROI surface required)
1069
+
1070
+ Parameters
1071
+ ----------
1072
+ dist : float
1073
+ Distance in mm the subsampled points lie apart.
1074
+ fn_sphere : str
1075
+ Name of ?.sphere file (freesurfer).
1076
+
1077
+ Returns
1078
+ -------
1079
+ ele_idx : ndarray of float
1080
+ (n_ele) Element indices of the subsampled surface.
1081
+ """
1082
+
1083
+ # load sphere surface
1084
+ sphere_coords, sphere_faces = nib.freesurfer.io.read_geometry(fn_sphere)
1085
+
1086
+ # sample sphere equally
1087
+ r = np.linalg.norm(sphere_coords[0, :])
1088
+ a0 = 4 * np.pi * r ** 2
1089
+
1090
+ sphere_points_sampled = pynibs.mesh.utils.sample_sphere(n_points=int(np.ceil(a0 / dist ** 2) // 2 * 2 + 1),
1091
+ r=np.linalg.norm(sphere_coords[0, :]))
1092
+
1093
+ # read mask from freesurfer mask file
1094
+ mask = nib.freesurfer.mghformat.MGHImage.from_filename(
1095
+ os.path.join(self.mesh_folder, self.fn_mask)).dataobj[:].flatten()
1096
+ roi_mask_idx = np.where(mask > 0.5)
1097
+
1098
+ con_row_idx = [i for i in range(sphere_faces.shape[0]) if
1099
+ len(np.intersect1d(sphere_faces[i, ], roi_mask_idx)) == 3]
1100
+
1101
+ # crop connectivity matrix to ROI
1102
+ con_cropped = sphere_faces[con_row_idx, ]
1103
+
1104
+ p1_tri = sphere_coords[con_cropped[:, 0], :]
1105
+ p2_tri = sphere_coords[con_cropped[:, 1], :]
1106
+ p3_tri = sphere_coords[con_cropped[:, 2], :]
1107
+ tri_center = 1.0 / 3 * (p1_tri + p2_tri + p3_tri)
1108
+ ele_idx = []
1109
+
1110
+ for i_p_ss in range(sphere_points_sampled.shape[0]):
1111
+ dist = np.linalg.norm(tri_center - sphere_points_sampled[i_p_ss], axis=1)
1112
+
1113
+ if np.min(dist) < 1:
1114
+ ele_idx.append(np.argmin(dist))
1115
+
1116
+ return ele_idx
1117
+
1118
+
1119
+ class RegionOfInterestVolume:
1120
+ """
1121
+ Region of interest (volume) class
1122
+
1123
+ Attributes
1124
+ ----------
1125
+ node_coord : np.ndarray
1126
+ (N_points, 3) Coordinates (x,y,z) of ROI tetrahedra nodes.
1127
+ tet_node_number_list : np.ndarray
1128
+ (N_tet_roi, 3) Connectivity matrix of ROI tetrahedra.
1129
+ tri_node_number_list : np.ndarray
1130
+ (N_tri_roi, 3) Connectivity matrix of ROI tetrahedra.
1131
+ tet_idx_node_coord : np.ndarray
1132
+ (N_points) Tetrahedra indices of TetrahedraLinear object instance where the ROI nodes are.
1133
+ tet_idx_tetrahedra_center : np.ndarray
1134
+ (N_tet_roi) Tetrahedra indices of TetrahedraLinear object instance where the center points of the ROI
1135
+ tetrahedra are.
1136
+ tet_idx_triangle_center : np.ndarray
1137
+ (N_tri_roi) Tetrahedra indices of TetrahedraLinear object instance where the center points of the ROI triangle
1138
+ are. If the ROI is directly generated from the msh instance using "make_roi_volume_from_msh", these
1139
+ indices are the triangle indices of the head mesh since the ROI mesh and the head mesh are overlapping. If
1140
+ the ROI mesh is not the same as the head mesh, the triangle center of the ROI mesh are always lying in a
1141
+ tetrahedra of the head mesh (these indices are given in this case).
1142
+ """
1143
+
1144
+ def __init__(self):
1145
+ """ Initialize RegionOfInterestVolume class instance """
1146
+
1147
+ self.node_coord = []
1148
+ self.tet_node_number_list = []
1149
+ self.tri_node_number_list = []
1150
+ self.tet_idx_node_coord = []
1151
+ self.tet_idx_tetrahedra_center = []
1152
+ self.tet_idx_triangle_center = []
1153
+
1154
+ def make_roi_volume_from_msh(self, msh, volume_type='box', x_roi=None, y_roi=None, z_roi=None):
1155
+ """
1156
+ Generate region of interest (volume) and extract nodes, triangles and tetrahedra from msh instance.
1157
+
1158
+ Parameters
1159
+ ----------
1160
+ msh: pynibs.mesh.mesh_struct.TetrahedraLinear
1161
+ Mesh object instance of type TetrahedraLinear
1162
+ volume_type: str
1163
+ Type of ROI ('box' or 'sphere')
1164
+ x_roi: list of float
1165
+
1166
+ - type = 'box': [Xmin, Xmax] (in mm), whole X range if empty [0,0] or None (left - right)
1167
+ - type = 'sphere': origin [x,y,z]
1168
+ y_roi: list of float
1169
+
1170
+ - type = 'box': [Ymin, Ymax] (in mm), whole Y range if empty [0,0] or None (anterior - posterior)
1171
+ - type = 'sphere': radius (in mm)
1172
+ z_roi: list of float
1173
+
1174
+ - type = 'box': [Zmin, Zmax] (in mm), whole Z range if empty [0,0] or None (inferior - superior)
1175
+ - type = 'sphere': None
1176
+
1177
+ Returns
1178
+ -------
1179
+ RegionOfInterestVolume.node_coord : np.ndarray [N_points x 3]
1180
+ Coordinates (x,y,z) of ROI tetrahedra nodes
1181
+ RegionOfInterestVolume.tet_node_number_list : np.ndarray [N_tet_roi x 3]
1182
+ Connectivity matrix of ROI tetrahedra
1183
+ RegionOfInterestVolume.tri_node_number_list : np.ndarray [N_tri_roi x 3]
1184
+ Connectivity matrix of ROI tetrahedra
1185
+ RegionOfInterestVolume.tet_idx_node_coord : np.ndarray [N_points]
1186
+ Tetrahedra indices of TetrahedraLinear object instance where the ROI nodes are lying in
1187
+ RegionOfInterestVolume.tet_idx_tetrahedra_center : np.ndarray [N_tet_roi]
1188
+ Tetrahedra indices of TetrahedraLinear object instance where the center points of the ROI tetrahedra are
1189
+ lying in
1190
+ RegionOfInterestVolume.tet_idx_triangle_center : np.ndarray [N_tri_roi]
1191
+ Tetrahedra indices of TetrahedraLinear object instance where the center points of the ROI triangle are
1192
+ . If the ROI is directly generated from the msh instance using "make_roi_volume_from_msh", these
1193
+ indices are the triangle indices of the head mesh since the ROI mesh and the head mesh are overlapping. If
1194
+ the ROI mesh is not the same as the head mesh, the triangle center of the ROI mesh are always. a
1195
+ tetrahedra of the head mesh (these indices are given in this case)
1196
+ """
1197
+
1198
+ if volume_type == 'box':
1199
+ roi_mask_bool = (msh.points[:, 0] > min(x_roi)) & (msh.points[:, 0] < max(x_roi)) & \
1200
+ (msh.points[:, 1] > min(y_roi)) & (msh.points[:, 1] < max(y_roi)) & \
1201
+ (msh.points[:, 2] > min(z_roi)) & (msh.points[:, 2] < max(z_roi))
1202
+ roi_mask_idx = np.where(roi_mask_bool)[0]
1203
+
1204
+ elif volume_type == 'sphere':
1205
+ roi_mask_bool = np.linalg.norm(msh.points - np.array(x_roi), axis=1) <= y_roi
1206
+ roi_mask_idx = np.where(roi_mask_bool)[0]
1207
+
1208
+ else:
1209
+ raise Exception('region of interest type not specified correctly (either box or sphere)')
1210
+
1211
+ self.node_coord = msh.points[roi_mask_idx, :]
1212
+
1213
+ # crop connectivity matrices of tetrahedra and triangles to ROI
1214
+ tet_con_row_idx = [i for i in range(msh.tetrahedra.shape[0]) if
1215
+ len(np.intersect1d(msh.tetrahedra[i, ], roi_mask_idx)) == 3]
1216
+ tet_con_cropped = msh.tetrahedra[tet_con_row_idx,]
1217
+
1218
+ tri_con_row_idx = [i for i in range(msh.triangles.shape[0]) if
1219
+ len(np.intersect1d(msh.triangles[i, ], roi_mask_idx)) == 3]
1220
+ tri_con_cropped = msh.triangles[tri_con_row_idx, ]
1221
+
1222
+ # evaluate new indices of cropped connectivity matrices of tetrahedra and triangles (starts from 0)
1223
+ tet_point_idx_before, tet_point_idx_after = np.unique(tet_con_cropped, return_inverse=True)
1224
+ self.tet_node_number_list = np.reshape(tet_point_idx_after,
1225
+ (tet_con_cropped.shape[0], tet_con_cropped.shape[1]))
1226
+
1227
+ tri_point_idx_before, tri_point_idx_after = np.unique(tri_con_cropped, return_inverse=True)
1228
+ self.tri_node_number_list = np.reshape(tri_point_idx_after,
1229
+ (tri_con_cropped.shape[0], tri_con_cropped.shape[1]))
1230
+
1231
+ self.tet_idx_node_coord = None
1232
+ self.tet_idx_tetrahedra_center = np.array(tet_con_row_idx)
1233
+ self.tet_idx_triangle_center = np.array(tri_con_row_idx)