Coverage for nilearn/surface/surface.py: 13%
549 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-20 10:58 +0200
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-20 10:58 +0200
1"""Functions for surface manipulation."""
3import abc
4import gzip
5import pathlib
6import warnings
7from collections.abc import Iterable, Mapping
8from pathlib import Path
9from typing import Union
11import numpy as np
12import sklearn.cluster
13import sklearn.preprocessing
14from nibabel import freesurfer as fs
15from nibabel import gifti, load, nifti1
16from scipy import interpolate, sparse
17from sklearn.exceptions import EfficiencyWarning
19from nilearn import _utils
20from nilearn._utils import stringify_path
21from nilearn._utils.logger import find_stack_level
22from nilearn._utils.niimg_conversions import check_niimg
23from nilearn._utils.path_finding import resolve_globbing
26def _uniform_ball_cloud(n_points=20, dim=3, n_monte_carlo=50000):
27 """Get points uniformly spaced in the unit ball."""
28 rng = np.random.RandomState(0)
29 mc_cube = rng.uniform(-1, 1, size=(n_monte_carlo, dim))
30 mc_ball = mc_cube[(mc_cube**2).sum(axis=1) <= 1.0]
31 centroids, *_ = sklearn.cluster.k_means(
32 mc_ball, n_clusters=n_points, random_state=0
33 )
34 return centroids
37def _load_uniform_ball_cloud(n_points=20):
38 stored_points = (
39 Path(__file__, "..", "data", f"ball_cloud_{n_points}_samples.csv")
40 ).resolve()
41 if stored_points.is_file():
42 points = np.loadtxt(stored_points)
43 return points
44 warnings.warn(
45 "Cached sample positions are provided for "
46 "n_samples = 10, 20, 40, 80, 160. Since the number of samples does "
47 "have a big impact on the result, we strongly recommend using one "
48 'of these values when using kind="ball" for much better performance.',
49 EfficiencyWarning,
50 stacklevel=find_stack_level(),
51 )
52 return _uniform_ball_cloud(n_points=n_points)
55def _face_outer_normals(mesh):
56 """Get the normal to each triangle in a mesh.
58 They are the outer normals if the mesh respects the convention that the
59 direction given by the direct order of a triangle's vertices (right-hand
60 rule) points outwards.
62 """
63 mesh = load_surf_mesh(mesh)
64 vertices = mesh.coordinates
65 faces = mesh.faces
66 face_vertices = vertices[faces]
67 # The right-hand rule gives the direction of the outer normal
68 normals = np.cross(
69 face_vertices[:, 1, :] - face_vertices[:, 0, :],
70 face_vertices[:, 2, :] - face_vertices[:, 0, :],
71 )
72 normals = sklearn.preprocessing.normalize(normals)
73 return normals
76def _surrounding_faces(mesh):
77 """Get matrix indicating which faces the nodes belong to.
79 i, j is set if node i is a vertex of triangle j.
81 """
82 mesh = load_surf_mesh(mesh)
83 vertices = mesh.coordinates
84 faces = mesh.faces
85 n_faces = faces.shape[0]
86 return sparse.csr_matrix(
87 (
88 np.ones(3 * n_faces),
89 (faces.ravel(), np.tile(np.arange(n_faces), (3, 1)).T.ravel()),
90 ),
91 (vertices.shape[0], n_faces),
92 )
95def _vertex_outer_normals(mesh):
96 """Get the normal at each vertex in a triangular mesh.
98 They are the outer normals if the mesh respects the convention that the
99 direction given by the direct order of a triangle's vertices (right-hand
100 rule) points outwards.
102 """
103 vertex_faces = _surrounding_faces(mesh)
104 face_normals = _face_outer_normals(mesh)
105 normals = vertex_faces.dot(face_normals)
106 return sklearn.preprocessing.normalize(normals)
109def _sample_locations_between_surfaces(
110 mesh, inner_mesh, affine, n_points=10, depth=None
111):
112 # Avoid circular import
113 from nilearn.image.resampling import coord_transform
115 outer_vertices = load_surf_mesh(mesh).coordinates
116 inner_vertices = load_surf_mesh(inner_mesh).coordinates
118 if depth is None:
119 steps = np.linspace(0, 1, n_points)[:, None, None]
120 else:
121 steps = np.asarray(depth)[:, None, None]
123 sample_locations = outer_vertices + steps * (
124 inner_vertices - outer_vertices
125 )
126 sample_locations = np.rollaxis(sample_locations, 1)
128 sample_locations_voxel_space = np.asarray(
129 coord_transform(
130 *np.vstack(sample_locations).T, affine=np.linalg.inv(affine)
131 )
132 ).T.reshape(sample_locations.shape)
133 return sample_locations_voxel_space
136def _ball_sample_locations(
137 mesh, affine, ball_radius=3.0, n_points=20, depth=None
138):
139 """Locations to draw samples from to project volume data onto a mesh.
141 For each mesh vertex, the locations of `n_points` points evenly spread in a
142 ball around the vertex are returned.
144 Parameters
145 ----------
146 mesh : pair of :obj:`numpy.ndarray`s.
147 `mesh[0]` contains the 3d coordinates of the vertices
148 (shape n_vertices, 3)
149 `mesh[1]` contains, for each triangle,
150 the indices into `mesh[0]` of its vertices (shape n_triangles, 3)
152 affine : :obj:`numpy.ndarray` of shape (4, 4)
153 Affine transformation from image voxels to the vertices' coordinate
154 space.
156 ball_radius : :obj:`float`, default=3.0
157 Size in mm of the neighbourhood around each vertex in which to draw
158 samples.
160 n_points : :obj:`int`, default=20
161 Number of samples to draw for each vertex.
163 depth : `None`
164 Raises a `ValueError` if not `None` because incompatible with this
165 sampling strategy.
167 Returns
168 -------
169 sample_location_voxel_space : :obj:`numpy.ndarray`, \
170 shape (n_vertices, n_points, 3)
171 The locations, in voxel space, from which to draw samples.
172 First dimension iterates over mesh vertices, second dimension iterates
173 over the sample points associated to a vertex, third dimension is x, y,
174 z in voxel space.
176 """
177 # Avoid circular import
178 from nilearn.image.resampling import coord_transform
180 if depth is not None:
181 raise ValueError(
182 "The 'ball' sampling strategy does not support "
183 "the 'depth' parameter.\n"
184 "To avoid this error with this strategy, set 'depth' to None."
185 )
186 vertices = load_surf_mesh(mesh).coordinates
187 offsets_world_space = (
188 _load_uniform_ball_cloud(n_points=n_points) * ball_radius
189 )
190 mesh_voxel_space = np.asarray(
191 coord_transform(*vertices.T, affine=np.linalg.inv(affine))
192 ).T
193 linear_map = np.eye(affine.shape[0])
194 linear_map[:-1, :-1] = affine[:-1, :-1]
195 offsets_voxel_space = np.asarray(
196 coord_transform(
197 *offsets_world_space.T, affine=np.linalg.inv(linear_map)
198 )
199 ).T
200 sample_locations_voxel_space = (
201 mesh_voxel_space[:, np.newaxis, :] + offsets_voxel_space[np.newaxis, :]
202 )
203 return sample_locations_voxel_space
206def _line_sample_locations(
207 mesh, affine, segment_half_width=3.0, n_points=10, depth=None
208):
209 """Locations to draw samples from to project volume data onto a mesh.
211 For each mesh vertex, the locations of `n_points` points evenly spread in a
212 segment of the normal to the vertex are returned. The line segment has
213 length 2 * `segment_half_width` and is centered at the vertex.
215 Parameters
216 ----------
217 mesh : pair of :obj:`numpy.ndarray`
218 `mesh[0]` contains the 3d coordinates of the vertices
219 (shape n_vertices, 3)
220 `mesh[1]` contains, for each triangle,
221 the indices into `mesh[0]` of its vertices (shape n_triangles, 3)
223 affine : :obj:`numpy.ndarray` of shape (4, 4)
224 Affine transformation from image voxels to the vertices' coordinate
225 space.
227 segment_half_width : :obj:`float`, default=3.0
228 Size in mm of the neighbourhood around each vertex in which to draw
229 samples.
231 n_points : :obj:`int`, default=10
232 Number of samples to draw for each vertex.
234 depth : sequence of :obj:`float` or None, optional
235 Cortical depth, expressed as a fraction of segment_half_width.
236 Overrides n_points.
238 Returns
239 -------
240 sample_location_voxel_space : :obj:`numpy.ndarray`, \
241 shape (n_vertices, n_points, 3)
242 The locations, in voxel space, from which to draw samples.
243 First dimension iterates over mesh vertices, second dimension iterates
244 over the sample points associated to a vertex, third dimension is x, y,
245 z in voxel space.
247 """
248 # Avoid circular import
249 from nilearn.image.resampling import coord_transform
251 vertices = load_surf_mesh(mesh).coordinates
252 normals = _vertex_outer_normals(mesh)
253 if depth is None:
254 offsets = np.linspace(
255 segment_half_width, -segment_half_width, n_points
256 )
257 else:
258 offsets = -segment_half_width * np.asarray(depth)
259 sample_locations = (
260 vertices[np.newaxis, :, :]
261 + normals * offsets[:, np.newaxis, np.newaxis]
262 )
263 sample_locations = np.rollaxis(sample_locations, 1)
264 sample_locations_voxel_space = np.asarray(
265 coord_transform(
266 *np.vstack(sample_locations).T, affine=np.linalg.inv(affine)
267 )
268 ).T.reshape(sample_locations.shape)
269 return sample_locations_voxel_space
272def _choose_kind(kind, inner_mesh):
273 if kind == "depth" and inner_mesh is None:
274 raise TypeError(
275 "'inner_mesh' must be provided to use "
276 "the 'depth' sampling strategy"
277 )
278 if kind == "auto":
279 kind = "line" if inner_mesh is None else "depth"
280 return kind
283def _sample_locations(
284 mesh,
285 affine,
286 radius,
287 kind="auto",
288 n_points=None,
289 inner_mesh=None,
290 depth=None,
291):
292 """Get either ball or line sample locations."""
293 kind = _choose_kind(kind, inner_mesh)
294 kwargs = {} if n_points is None else {"n_points": n_points}
295 projectors = {
296 "line": (_line_sample_locations, {"segment_half_width": radius}),
297 "ball": (_ball_sample_locations, {"ball_radius": radius}),
298 "depth": (
299 _sample_locations_between_surfaces,
300 {"inner_mesh": inner_mesh},
301 ),
302 }
303 if kind not in projectors:
304 raise ValueError(f'"kind" must be one of {tuple(projectors.keys())}')
305 projector, extra_kwargs = projectors[kind]
306 # let the projector choose the default for n_points
307 # (for example a ball probably needs more than a line)
308 sample_locations = projector(
309 mesh=mesh, affine=affine, depth=depth, **kwargs, **extra_kwargs
310 )
311 return sample_locations
314def _masked_indices(sample_locations, img_shape, mask=None):
315 """Get the indices of sample points which should be ignored.
317 Parameters
318 ----------
319 sample_locations : :obj:`numpy.ndarray`, shape(n_sample_locations, 3)
320 The coordinates of candidate interpolation points.
322 img_shape : :obj:`tuple`
323 The dimensions of the image to be sampled.
325 mask : :obj:`numpy.ndarray` of shape img_shape or `None`, optional
326 Part of the image to be masked. If `None`, don't apply any mask.
328 Returns
329 -------
330 array of shape (n_sample_locations,)
331 True if this particular location should be ignored (outside of image or
332 masked).
334 """
335 kept = (sample_locations >= 0).all(axis=1)
336 for dim, size in enumerate(img_shape):
337 kept = np.logical_and(kept, sample_locations[:, dim] < size)
338 if mask is not None:
339 indices = np.asarray(np.floor(sample_locations[kept]), dtype=int)
340 kept[kept] = mask[indices[:, 0], indices[:, 1], indices[:, 2]] != 0
341 return ~kept
344def _projection_matrix(
345 mesh,
346 affine,
347 img_shape,
348 kind="auto",
349 radius=3.0,
350 n_points=None,
351 mask=None,
352 inner_mesh=None,
353 depth=None,
354):
355 """Get a sparse matrix that projects volume data onto a mesh.
357 Parameters
358 ----------
359 mesh : :obj:`str` or :obj:`numpy.ndarray`
360 Either a file containing surface mesh geometry (valid formats
361 are .gii or Freesurfer specific files such as .orig, .pial,
362 .sphere, .white, .inflated) or a list of two Numpy arrays,
363 the first containing the x-y-z coordinates of the mesh
364 vertices, the second containing the indices (into coords)
365 of the mesh faces.
367 affine : :obj:`numpy.ndarray` of shape (4, 4)
368 Affine transformation from image voxels to the vertices' coordinate
369 space.
371 img_shape : 3-tuple of :obj:`int`
372 The shape of the image to be projected.
374 kind : {'auto', 'depth', 'line', 'ball'}, default='auto'
375 The strategy used to sample image intensities around each vertex.
376 Ignored if `inner_mesh` is not None.
378 - 'auto':
379 'depth' if `inner_mesh` is not `None`, otherwise 'line.
380 - 'depth':
381 Sampled at the specified cortical depths between corresponding
382 nodes of `mesh` and `inner_mesh`.
383 - 'line':
384 Samples are placed along the normal to the mesh.
385 - 'ball':
386 Samples are regularly spaced inside a ball centered at the mesh
387 vertex.
389 radius : :obj:`float`, default=3.0
390 The size (in mm) of the neighbourhood from which samples are drawn
391 around each node. Ignored if `inner_mesh` is not `None`.
393 n_points : :obj:`int` or None, default=20
394 How many samples are drawn around each vertex and averaged. If `None`,
395 use a reasonable default for the chosen sampling strategy (20 for
396 'ball' or 10 for lines ie using `line` or an `inner_mesh`).
397 For performance reasons, if using kind="ball", choose `n_points` in
398 [10, 20, 40, 80, 160], because cached positions are
399 available.
401 mask : :obj:`numpy.ndarray` of shape img_shape or `None`, optional
402 Part of the image to be masked. If `None`, don't apply any mask.
404 inner_mesh : :obj:`str` or :obj:`numpy.ndarray`, optional
405 Either a file containing surface mesh or a pair of ndarrays
406 (coordinates, triangles). If provided this is an inner surface that is
407 nested inside the one represented by `mesh` -- e.g. `mesh` is a pial
408 surface and `inner_mesh` a white matter surface. In this case nodes in
409 both meshes must correspond: node i in `mesh` is just across the gray
410 matter thickness from node i in `inner_mesh`. Image values for index i
411 are then sampled along the line joining these two points (if `kind` is
412 'auto' or 'depth').
414 depth : sequence of :obj:`float` or `None`, optional
415 Cortical depth, expressed as a fraction of segment_half_width.
416 overrides n_points. Should be None if kind is 'ball'
418 Returns
419 -------
420 proj : :obj:`scipy.sparse.csr_matrix`
421 Shape (n_voxels, n_mesh_vertices). The dot product of this matrix with
422 an image (represented as a column vector) gives the projection onto mesh
423 vertices.
425 See Also
426 --------
427 nilearn.surface.vol_to_surf
428 Compute the projection for one or several images.
430 """
431 # A user might want to call this function directly so check mask size.
432 if mask is not None and tuple(mask.shape) != img_shape:
433 raise ValueError("mask should have shape img_shape")
434 mesh = load_surf_mesh(mesh)
435 sample_locations = _sample_locations(
436 mesh,
437 affine,
438 kind=kind,
439 radius=radius,
440 n_points=n_points,
441 inner_mesh=inner_mesh,
442 depth=depth,
443 )
444 sample_locations = np.asarray(np.round(sample_locations), dtype=int)
445 n_vertices, n_points, _ = sample_locations.shape
446 masked = _masked_indices(np.vstack(sample_locations), img_shape, mask=mask)
447 sample_locations = np.rollaxis(sample_locations, -1)
448 sample_indices = np.ravel_multi_index(
449 sample_locations, img_shape, mode="clip"
450 ).ravel()
451 row_indices, _ = np.mgrid[:n_vertices, :n_points]
452 row_indices = row_indices.ravel()
453 row_indices = row_indices[~masked]
454 sample_indices = sample_indices[~masked]
455 weights = np.ones(len(row_indices))
456 proj = sparse.csr_matrix(
457 (weights, (row_indices, sample_indices.ravel())),
458 shape=(n_vertices, np.prod(img_shape)),
459 )
460 proj = sklearn.preprocessing.normalize(proj, axis=1, norm="l1")
461 return proj
464def _mask_sample_locations(sample_locations, img_shape, mesh_n_vertices, mask):
465 """Mask sample locations without changing to indices."""
466 sample_locations = np.asarray(np.round(sample_locations), dtype=int)
467 masks = _masked_indices(np.vstack(sample_locations), img_shape, mask=mask)
468 masks = np.split(masks, mesh_n_vertices)
469 # mask sample locations and make a list of masked indices because
470 # masked locations are not necessarily of the same length
471 masked_sample_locations = [
472 sample_locations[idx][~mask] for idx, mask in enumerate(masks)
473 ]
474 return masked_sample_locations
477def _nearest_most_frequent(
478 images,
479 mesh,
480 affine,
481 kind="auto",
482 radius=3.0,
483 n_points=None,
484 mask=None,
485 inner_mesh=None,
486 depth=None,
487):
488 """Use the most frequent value of 'n_samples' nearest voxels instead of
489 taking the mean value (as in the _nearest_voxel_sampling function).
491 This is useful when the image is a deterministic atlas.
492 """
493 data = np.asarray(images)
494 sample_locations = _sample_locations(
495 mesh,
496 affine,
497 kind=kind,
498 radius=radius,
499 n_points=n_points,
500 inner_mesh=inner_mesh,
501 depth=depth,
502 )
503 sample_locations = _mask_sample_locations(
504 sample_locations, images[0].shape, mesh.n_vertices, mask
505 )
506 texture = np.zeros((mesh.n_vertices, images.shape[0]))
507 for img in range(images.shape[0]):
508 for loc, sample_location in enumerate(sample_locations):
509 possible_values = [
510 data[img][coords[0], coords[1], coords[2]]
511 for coords in sample_location
512 ]
513 unique, counts = np.unique(possible_values, return_counts=True)
514 texture[loc, img] = unique[np.argmax(counts)]
515 return texture.T
518def _nearest_voxel_sampling(
519 images,
520 mesh,
521 affine,
522 kind="auto",
523 radius=3.0,
524 n_points=None,
525 mask=None,
526 inner_mesh=None,
527 depth=None,
528):
529 """In each image, measure the intensity at each node of the mesh.
531 Image intensity at each sample point is that of the nearest voxel.
532 A 2-d array is returned, where each row corresponds to an image and each
533 column to a mesh vertex.
534 See documentation of vol_to_surf for details.
536 """
537 data = np.asarray(images).reshape(len(images), -1).T
538 proj = _projection_matrix(
539 mesh,
540 affine,
541 images[0].shape,
542 kind=kind,
543 radius=radius,
544 n_points=n_points,
545 mask=mask,
546 inner_mesh=inner_mesh,
547 depth=depth,
548 )
549 texture = proj.dot(data)
550 # if all samples around a mesh vertex are outside the image,
551 # there is no reasonable value to assign to this vertex.
552 # in this case we return NaN for this vertex.
553 texture[np.asarray(proj.sum(axis=1) == 0).ravel()] = np.nan
554 return texture.T
557def _interpolation_sampling(
558 images,
559 mesh,
560 affine,
561 kind="auto",
562 radius=3,
563 n_points=None,
564 mask=None,
565 inner_mesh=None,
566 depth=None,
567):
568 """In each image, measure the intensity at each node of the mesh.
570 Image intensity at each sample point is computed with trilinear
571 interpolation.
572 A 2-d array is returned, where each row corresponds to an image and each
573 column to a mesh vertex.
574 See documentation of vol_to_surf for details.
576 """
577 sample_locations = _sample_locations(
578 mesh,
579 affine,
580 kind=kind,
581 radius=radius,
582 n_points=n_points,
583 inner_mesh=inner_mesh,
584 depth=depth,
585 )
586 n_vertices, n_points, _ = sample_locations.shape
587 grid = [np.arange(size) for size in images[0].shape]
588 interp_locations = np.vstack(sample_locations)
589 masked = _masked_indices(interp_locations, images[0].shape, mask=mask)
590 # loop over images rather than building a big array to use less memory
591 all_samples = []
592 for img in images:
593 interpolator = interpolate.RegularGridInterpolator(
594 grid, img, bounds_error=False, method="linear", fill_value=None
595 )
596 samples = interpolator(interp_locations)
597 # if all samples around a mesh vertex are outside the image,
598 # there is no reasonable value to assign to this vertex.
599 # in this case we return NaN for this vertex.
600 samples[masked] = np.nan
601 all_samples.append(samples)
602 all_samples = np.asarray(all_samples)
603 all_samples = all_samples.reshape((len(images), n_vertices, n_points))
604 texture = np.nanmean(all_samples, axis=2)
605 return texture
608def vol_to_surf(
609 img,
610 surf_mesh,
611 radius=3.0,
612 interpolation="linear",
613 kind="auto",
614 n_samples=None,
615 mask_img=None,
616 inner_mesh=None,
617 depth=None,
618):
619 """Extract surface data from a Nifti image.
621 .. versionadded:: 0.4.0
623 Parameters
624 ----------
625 img : Niimg-like object, 3d or 4d.
626 See :ref:`extracting_data`.
628 surf_mesh : :obj:`str`, :obj:`pathlib.Path`, :obj:`numpy.ndarray`, or \
629 :obj:`~nilearn.surface.InMemoryMesh`
630 Either a file containing surface :term:`mesh` geometry
631 (valid formats are .gii or Freesurfer specific files
632 such as .orig, .pial, .sphere, .white, .inflated)
633 or a :obj:`~nilearn.surface.InMemoryMesh` object with "coordinates"
634 and "faces" attributes.
636 radius : :obj:`float`, default=3.0
637 The size (in mm) of the neighbourhood from which samples are drawn
638 around each node. Ignored if `inner_mesh` is provided.
640 interpolation : {'linear', 'nearest', 'nearest_most_frequent'}, \
641 default='linear'
642 How the image intensity is measured at a sample point.
644 - 'linear':
645 Use a trilinear interpolation of neighboring voxels.
646 - 'nearest':
647 Use the intensity of the nearest voxel.
649 .. versionchanged:: 0.11.2.dev
651 The 'nearest' interpolation method will be removed in
652 version 0.13.0. It is recommended to use 'linear' for
653 statistical maps and
654 :term:`probabilistic atlases<Probabilistic atlas>` and
655 'nearest_most_frequent' for
656 :term:`deterministic atlases<Deterministic atlas>`.
658 - 'nearest_most_frequent':
659 Use the most frequent value in the neighborhood (out of the
660 `n_samples` samples) instead of the mean value. This is useful
661 when the image is a
662 :term:`deterministic atlas<Deterministic atlas>`.
664 .. versionadded:: 0.11.2.dev
666 For one image, the speed difference is small, 'linear' takes about x1.5
667 more time. For many images, 'nearest' scales much better, up to x20
668 faster.
670 kind : {'auto', 'depth', 'line', 'ball'}, default='auto'
671 The strategy used to sample image intensities around each vertex.
673 - 'auto':
674 Chooses 'depth' if `inner_mesh` is provided and 'line' otherwise.
675 - 'depth':
676 `inner_mesh` must be a :term:`mesh`
677 whose nodes correspond to those in `surf_mesh`.
678 For example, `inner_mesh` could be a white matter
679 surface mesh and `surf_mesh` a pial surface :term:`mesh`.
680 Samples are placed between each pair of corresponding nodes
681 at the specified cortical depths
682 (regularly spaced by default, see `depth` parameter).
683 - 'line':
684 Samples are placed along the normal to the mesh, at the positions
685 specified by `depth`, or by default regularly spaced over the
686 interval [- `radius`, + `radius`].
687 - 'ball':
688 Samples are regularly spaced inside a ball centered at the mesh
689 vertex.
691 n_samples : :obj:`int` or `None`, default=None
692 How many samples are drawn around each :term:`vertex` and averaged.
693 If `None`, use a reasonable default for the chosen sampling strategy
694 (20 for 'ball' or 10 for 'line').
695 For performance reasons, if using `kind` ="ball", choose `n_samples` in
696 [10, 20, 40, 80, 160] (defaults to 20 if None is passed),
697 because cached positions are available.
699 mask_img : Niimg-like object or `None`, default=None
700 Samples falling out of this mask or out of the image are ignored.
701 If `None`, don't apply any mask.
703 inner_mesh : :obj:`str` or :obj:`numpy.ndarray` or None, default=None
704 Either a file containing a surface :term:`mesh` or a pair of ndarrays
705 (coordinates, triangles). If provided this is an inner surface that is
706 nested inside the one represented by `surf_mesh` -- e.g. `surf_mesh` is
707 a pial surface and `inner_mesh` a white matter surface. In this case
708 nodes in both :term:`meshes<mesh>` must correspond:
709 node i in `surf_mesh` is just across the gray matter thickness
710 from node i in `inner_mesh`.
711 Image values for index i are then sampled along the line
712 joining these two points (if `kind` is 'auto' or 'depth').
714 depth : sequence of :obj:`float` or `None`, default=None
715 The cortical depth of samples. If provided, n_samples is ignored.
716 When `inner_mesh` is provided, each element of `depth` is a fraction of
717 the distance from `mesh` to `inner_mesh`: 0 is exactly on the outer
718 surface, .5 is halfway, 1. is exactly on the inner surface. `depth`
719 entries can be negative or greater than 1.
720 When `inner_mesh` is not provided and `kind` is "line", each element of
721 `depth` is a fraction of `radius` along the inwards normal at each mesh
722 node. For example if `radius==1` and `depth==[-.5, 0.]`, for each node
723 values will be sampled .5 mm outside of the surface and exactly at the
724 node position.
725 This parameter is not supported for the "ball" strategy so passing
726 `depth` when `kind=="ball"` results in a `ValueError`.
728 Returns
729 -------
730 texture : :obj:`numpy.ndarray`, 1d or 2d.
731 If 3D image is provided, a 1d vector is returned, containing one value
732 for each :term:`mesh` node.
733 If 4D image is provided, a 2d array is returned, where each row
734 corresponds to a :term:`mesh` node.
736 Notes
737 -----
738 This function computes a value for each vertex of the :term:`mesh`.
739 In order to do so,
740 it selects a few points in the volume surrounding that vertex,
741 interpolates the image intensities at these sampling positions,
742 and averages the results.
744 Three strategies are available to select these positions.
746 - with 'depth', data is sampled at various cortical depths between
747 corresponding nodes of `surface_mesh` and `inner_mesh` (which can be,
748 for example, a pial surface and a white matter surface). This is the
749 recommended strategy when both the pial and white matter surfaces are
750 available, which is the case for the fsaverage :term:`meshes<mesh>`.
751 - 'ball' uses points regularly spaced in a ball centered
752 at the :term:`mesh` vertex.
753 The radius of the ball is controlled by the parameter `radius`.
754 - 'line' starts by drawing the normal to the :term:`mesh`
755 passing through this vertex.
756 It then selects a segment of this normal,
757 centered at the vertex, of length 2 * `radius`.
758 Image intensities are measured at points regularly spaced
759 on this normal segment, or at positions determined by `depth`.
760 - ('auto' chooses 'depth' if `inner_mesh` is provided and 'line'
761 otherwise)
763 You can control how many samples are drawn by setting `n_samples`, or their
764 position by setting `depth`.
766 Once the sampling positions are chosen, those that fall outside of the 3d
767 image (or outside of the mask if you provided one) are discarded. If all
768 sample positions are discarded (which can happen, for example, if the
769 vertex itself is outside of the support of the image), the projection at
770 this vertex will be ``numpy.nan``.
772 The 3d image then needs to be interpolated at each of the remaining points.
773 Two options are available: 'nearest' selects the value of the nearest
774 voxel, and 'linear' performs trilinear interpolation of neighboring
775 voxels. 'linear' may give better results - for example, the projected
776 values are more stable when resampling the 3d image or applying affine
777 transformations to it. For one image, the speed difference is small,
778 'linear' takes about x1.5 more time. For many images, 'nearest' scales much
779 better, up to x20 faster.
781 Once the 3d image has been interpolated at each sample point, the
782 interpolated values are averaged to produce the value associated to this
783 particular :term:`mesh` vertex.
785 .. important::
787 When using the 'nearest_most_frequent' interpolation, each vertex will
788 be assigned the most frequent value in the neighborhood (out of the
789 `n_samples` samples) instead of the mean value. This option works
790 better if `img` is a :term:`deterministic atlas<Deterministic atlas>`.
792 Examples
793 --------
794 When both the pial and white matter surface are available, the recommended
795 approach is to provide the `inner_mesh` to rely on the 'depth' sampling
796 strategy::
798 >>> from nilearn import datasets, surface
799 >>> fsaverage = datasets.fetch_surf_fsaverage("fsaverage5")
800 >>> img = datasets.load_mni152_template(2)
801 >>> surf_data = surface.vol_to_surf(
802 ... img,
803 ... surf_mesh=fsaverage["pial_left"],
804 ... inner_mesh=fsaverage["white_left"],
805 ... )
807 """
808 # avoid circular import
809 from nilearn.image import get_data as get_vol_data
810 from nilearn.image import load_img
811 from nilearn.image.resampling import resample_to_img
813 sampling_schemes = {
814 "linear": _interpolation_sampling,
815 "nearest": _nearest_voxel_sampling,
816 "nearest_most_frequent": _nearest_most_frequent,
817 }
818 if interpolation not in sampling_schemes:
819 raise ValueError(
820 "'interpolation' should be one of "
821 f"{tuple(sampling_schemes.keys())}"
822 )
824 # deprecate nearest interpolation in 0.13.0
825 if interpolation == "nearest":
826 warnings.warn(
827 "The 'nearest' interpolation method will be deprecated in 0.13.0. "
828 "To disable this warning, select either 'linear' or "
829 "'nearest_most_frequent'. If your image is a deterministic atlas "
830 "'nearest_most_frequent' is recommended. Otherwise, use 'linear'. "
831 "See the documentation for more information.",
832 FutureWarning,
833 stacklevel=find_stack_level(),
834 )
836 img = load_img(img)
838 if mask_img is not None:
839 mask_img = _utils.check_niimg(mask_img)
840 mask = get_vol_data(
841 resample_to_img(
842 mask_img,
843 img,
844 interpolation="nearest",
845 copy=False,
846 force_resample=False, # TODO update to True in 0.13.0
847 copy_header=True,
848 )
849 )
850 else:
851 mask = None
853 original_dimension = len(img.shape)
855 img = _utils.check_niimg(img, atleast_4d=True)
857 frames = np.rollaxis(get_vol_data(img), -1)
859 mesh = load_surf_mesh(surf_mesh)
861 if inner_mesh is not None:
862 inner_mesh = load_surf_mesh(inner_mesh)
864 sampling = sampling_schemes[interpolation]
866 texture = sampling(
867 frames,
868 mesh,
869 img.affine,
870 radius=radius,
871 kind=kind,
872 n_points=n_samples,
873 mask=mask,
874 inner_mesh=inner_mesh,
875 depth=depth,
876 )
878 if original_dimension == 3:
879 texture = texture[0]
880 return texture.T
883def _load_surf_files_gifti_gzip(surf_file):
884 """Load surface data Gifti files which are gzipped.
886 This function is used by load_surf_mesh and load_surf_data for
887 extracting gzipped files.
888 """
889 with gzip.open(surf_file) as f:
890 as_bytes = f.read()
891 parser = gifti.GiftiImage.parser()
892 parser.parse(as_bytes)
893 return parser.img
896def _gifti_img_to_data(gifti_img):
897 """Load surface image e.g. sulcal depth or statistical map \
898 in nibabel.gifti.GiftiImage to data.
900 Used by load_surf_data function in common to surface sulcal data
901 acceptable to .gii or .gii.gz
903 """
904 if not gifti_img.darrays:
905 raise ValueError("Gifti must contain at least one data array")
907 if len(gifti_img.darrays) == 1:
908 return np.asarray([gifti_img.darrays[0].data]).T.squeeze()
910 return np.asarray(
911 [arr.data for arr in gifti_img.darrays], dtype=object
912 ).T.squeeze()
915FREESURFER_MESH_EXTENSIONS = ("orig", "pial", "sphere", "white", "inflated")
917FREESURFER_DATA_EXTENSIONS = (
918 "area",
919 "curv",
920 "sulc",
921 "thickness",
922 "label",
923 "annot",
924)
926DATA_EXTENSIONS = ("gii", "gii.gz", "mgz", "nii", "nii.gz")
929def _stringify(word_list):
930 sep = "', '."
931 return f"'.{sep.join(word_list)[:-3]}'"
934# function to figure out datatype and load data
935def load_surf_data(surf_data):
936 """Load data to be represented on a surface mesh.
938 Parameters
939 ----------
940 surf_data : :obj:`str`, :obj:`pathlib.Path`, or :obj:`numpy.ndarray`
941 Either a file containing surface data (valid format are .gii,
942 .gii.gz, .mgz, .nii, .nii.gz, or Freesurfer specific files such as
943 .thickness, .curv, .sulc, .annot, .label), lists of 1D data files are
944 returned as 2D arrays, or a Numpy array containing surface data.
946 Returns
947 -------
948 data : :obj:`numpy.ndarray`
949 An array containing surface data
951 """
952 # avoid circular import
953 from nilearn.image import get_data as get_vol_data
955 # if the input is a filename, load it
956 surf_data = stringify_path(surf_data)
958 if not isinstance(surf_data, (str, np.ndarray)):
959 raise ValueError(
960 "The input type is not recognized. "
961 "Valid inputs are a Numpy array or one of the "
962 "following file formats: "
963 f"{_stringify(DATA_EXTENSIONS)}, "
964 "Freesurfer specific files such as "
965 f"{_stringify(FREESURFER_DATA_EXTENSIONS)}."
966 )
968 if isinstance(surf_data, str):
969 # resolve globbing
970 file_list = resolve_globbing(surf_data)
971 # resolve_globbing handles empty lists
973 for i, surf_data in enumerate(file_list):
974 surf_data = str(surf_data)
976 check_extensions(
977 surf_data, DATA_EXTENSIONS, FREESURFER_DATA_EXTENSIONS
978 )
980 if surf_data.endswith(("nii", "nii.gz", "mgz")):
981 data_part = np.squeeze(get_vol_data(load(surf_data)))
982 elif surf_data.endswith(("area", "curv", "sulc", "thickness")):
983 data_part = fs.io.read_morph_data(surf_data)
984 elif surf_data.endswith("annot"):
985 data_part = fs.io.read_annot(surf_data)[0]
986 elif surf_data.endswith("label"):
987 data_part = fs.io.read_label(surf_data)
988 elif surf_data.endswith("gii"):
989 data_part = _gifti_img_to_data(load(surf_data))
990 elif surf_data.endswith("gii.gz"):
991 gii = _load_surf_files_gifti_gzip(surf_data)
992 data_part = _gifti_img_to_data(gii)
994 if len(data_part.shape) == 1:
995 data_part = data_part[:, np.newaxis]
996 if i == 0:
997 data = data_part
998 else:
999 try:
1000 data = np.concatenate((data, data_part), axis=1)
1001 except ValueError:
1002 raise ValueError(
1003 "When more than one file is input, "
1004 "all files must contain data "
1005 "with the same shape in axis=0."
1006 )
1008 # if the input is a numpy array
1009 elif isinstance(surf_data, np.ndarray):
1010 data = surf_data
1012 return np.squeeze(data)
1015def check_extensions(surf_data, data_extensions, freesurfer_data_extensions):
1016 """Check the extension of the input file.
1018 Should either be one one of the supported data formats
1019 or one of freesurfer data formats.
1021 Raises
1022 ------
1023 ValueError
1024 When the input is a string or a path with an extension
1025 that does not match one of the supported ones.
1026 """
1027 if isinstance(surf_data, Path):
1028 surf_data = str(surf_data)
1029 if isinstance(surf_data, str) and (
1030 not any(
1031 surf_data.endswith(x)
1032 for x in data_extensions + freesurfer_data_extensions
1033 )
1034 ):
1035 raise ValueError(
1036 "The input type is not recognized. "
1037 f"{surf_data!r} was given "
1038 "while valid inputs are a Numpy array "
1039 "or one of the following file formats: "
1040 f"{_stringify(data_extensions)}, "
1041 "Freesurfer specific files such as "
1042 f"{_stringify(freesurfer_data_extensions)}."
1043 )
1046def _gifti_img_to_mesh(gifti_img):
1047 """Load surface image in nibabel.gifti.GiftiImage to data.
1049 Used by load_surf_mesh function in common to surface mesh
1050 acceptable to .gii or .gii.gz
1052 """
1053 error_message = (
1054 "The surf_mesh input is not recognized. "
1055 "Valid Freesurfer surface mesh inputs are: "
1056 f"{_stringify(FREESURFER_MESH_EXTENSIONS)}."
1057 "You provided input which have "
1058 "no {0} or of empty value={1}"
1059 )
1060 try:
1061 coords = gifti_img.get_arrays_from_intent(
1062 nifti1.intent_codes["NIFTI_INTENT_POINTSET"]
1063 )[0].data
1064 except IndexError:
1065 raise ValueError(
1066 error_message.format(
1067 "NIFTI_INTENT_POINTSET",
1068 gifti_img.get_arrays_from_intent(
1069 nifti1.intent_codes["NIFTI_INTENT_POINTSET"]
1070 ),
1071 )
1072 )
1073 try:
1074 faces = gifti_img.get_arrays_from_intent(
1075 nifti1.intent_codes["NIFTI_INTENT_TRIANGLE"]
1076 )[0].data
1077 except IndexError:
1078 raise ValueError(
1079 error_message.format(
1080 "NIFTI_INTENT_TRIANGLE",
1081 gifti_img.get_arrays_from_intent(
1082 nifti1.intent_codes["NIFTI_INTENT_TRIANGLE"]
1083 ),
1084 )
1085 )
1086 return coords, faces
1089def combine_hemispheres_meshes(mesh):
1090 """Combine the left and right hemisphere meshes such that both are
1091 represented in the same mesh.
1093 Parameters
1094 ----------
1095 mesh : :obj:`~nilearn.surface.PolyMesh`
1096 The mesh object containing the left and right hemisphere meshes.
1098 Returns
1099 -------
1100 combined_mesh : :obj:`~nilearn.surface.InMemoryMesh`
1101 The combined mesh object containing both left and right hemisphere
1102 meshes.
1103 """
1104 # calculate how much the right hemisphere should be offset
1105 left_max_x = mesh.parts["left"].coordinates[:, 0].max()
1106 right_min_x = mesh.parts["right"].coordinates[:, 0].min()
1107 offset = (
1108 left_max_x - right_min_x + 1
1109 ) # add a small buffer to avoid touching
1111 combined_coords = np.concatenate(
1112 (
1113 mesh.parts["left"].coordinates,
1114 mesh.parts["right"].coordinates + np.asarray([offset, 0, 0]),
1115 )
1116 )
1117 combined_faces = np.concatenate(
1118 (
1119 mesh.parts["left"].faces,
1120 mesh.parts["right"].faces
1121 + mesh.parts["left"].coordinates.shape[0],
1122 )
1123 )
1124 return InMemoryMesh(combined_coords, combined_faces)
1127def check_mesh_is_fsaverage(mesh):
1128 """Check that :term:`mesh` data is either a :obj:`str`, or a :obj:`dict`
1129 with sufficient entries. Basically ensures that the mesh data is
1130 Freesurfer-like fsaverage data.
1131 """
1132 if isinstance(mesh, str):
1133 # avoid circular imports
1134 from nilearn.datasets import fetch_surf_fsaverage
1136 return fetch_surf_fsaverage(mesh)
1137 if not isinstance(mesh, Mapping):
1138 raise TypeError(
1139 "The mesh should be a str or a dictionary, "
1140 f"you provided: {type(mesh).__name__}."
1141 )
1142 missing = {
1143 "pial_left",
1144 "pial_right",
1145 "sulc_left",
1146 "sulc_right",
1147 "infl_left",
1148 "infl_right",
1149 }.difference(mesh.keys())
1150 if missing:
1151 raise ValueError(
1152 f"{missing} {'are' if len(missing) > 1 else 'is'} "
1153 "missing from the provided mesh dictionary"
1154 )
1155 return mesh
1158def check_mesh_and_data(mesh, data):
1159 """Load surface :term:`mesh` and data, \
1160 check that they have compatible shapes.
1162 Parameters
1163 ----------
1164 mesh : :obj:`str` or :obj:`numpy.ndarray` or \
1165 :obj:`~nilearn.surface.InMemoryMesh`
1166 Either a file containing surface :term:`mesh` geometry (valid formats
1167 are .gii .gii.gz or Freesurfer specific files such as .orig, .pial,
1168 .sphere, .white, .inflated) or two Numpy arrays organized in a list,
1169 tuple or a namedtuple with the fields "coordinates" and "faces", or a
1170 :obj:`~nilearn.surface.InMemoryMesh` object with "coordinates" and
1171 "faces" attributes.
1172 data : :obj:`str` or :obj:`numpy.ndarray`
1173 Either a file containing surface data (valid format are .gii,
1174 .gii.gz, .mgz, .nii, .nii.gz, or Freesurfer specific files such as
1175 .thickness, .area, .curv, .sulc, .annot, .label),
1176 lists of 1D data files are returned as 2D arrays,
1177 or a Numpy array containing surface data.
1179 Returns
1180 -------
1181 mesh : :obj:`~nilearn.surface.InMemoryMesh`
1182 Checked :term:`mesh`.
1183 data : :obj:`numpy.ndarray`
1184 Checked data.
1185 """
1186 mesh = load_surf_mesh(mesh)
1188 _validate_mesh(mesh)
1190 data = load_surf_data(data)
1191 # Check that mesh coordinates has a number of nodes
1192 # equal to the size of the data.
1193 if len(data) != len(mesh.coordinates):
1194 raise ValueError(
1195 "Mismatch between number of nodes "
1196 f"in mesh ({len(mesh.coordinates)}) and "
1197 f"size of surface data ({len(data)})"
1198 )
1200 return mesh, data
1203def _validate_mesh(mesh):
1204 """Check mesh coordinates and faces.
1206 Mesh coordinates and faces must be numpy arrays.
1208 Coordinates must be finite values.
1210 Check that the indices of faces are consistent
1211 with the mesh coordinates.
1212 That is, we shouldn't have an index
1213 - larger or equal to the length of the coordinates array
1214 - negative
1215 """
1216 non_finite_mask = np.logical_not(np.isfinite(mesh.coordinates))
1217 if non_finite_mask.any():
1218 raise ValueError(
1219 "Mesh coordinates must be finite. "
1220 "Current coordinates contains NaN or Inf values."
1221 )
1223 msg = (
1224 "Mismatch between the indices of faces and the number of nodes.\n"
1225 "Indices into the points and must be in the "
1226 f"range 0 <= i < {len(mesh.coordinates)} but found value "
1227 )
1228 if mesh.faces.max() >= len(mesh.coordinates):
1229 raise ValueError(f"{msg}{mesh.faces.max()}")
1230 if mesh.faces.min() < 0:
1231 raise ValueError(f"{msg}{mesh.faces.min()}")
1234# function to figure out datatype and load data
1235def load_surf_mesh(surf_mesh):
1236 """Load a surface :term:`mesh` geometry.
1238 Parameters
1239 ----------
1240 surf_mesh : :obj:`str`, :obj:`pathlib.Path`, or \
1241 :obj:`numpy.ndarray` or :obj:`~nilearn.surface.InMemoryMesh`
1242 Either a file containing surface :term:`mesh` geometry
1243 (valid formats are .gii .gii.gz or Freesurfer specific files
1244 such as .orig, .pial, .sphere, .white, .inflated)
1245 or two Numpy arrays organized in a list,
1246 tuple or a namedtuple with the fields "coordinates" and "faces",
1247 or an :obj:`~nilearn.surface.InMemoryMesh` object with "coordinates"
1248 and "faces" attributes.
1250 Returns
1251 -------
1252 mesh : :obj:`~nilearn.surface.InMemoryMesh`
1253 With the attributes "coordinates" and "faces", each containing a
1254 :obj:`numpy.ndarray`
1256 """
1257 # if input is a filename, try to load it
1258 surf_mesh = stringify_path(surf_mesh)
1259 if isinstance(surf_mesh, str):
1260 # resolve globbing
1261 file_list = resolve_globbing(surf_mesh)
1262 if len(file_list) > 1:
1263 # empty list is handled inside resolve_globbing function
1264 raise ValueError(
1265 f"More than one file matching path: {surf_mesh}\n"
1266 "load_surf_mesh can only load one file at a time."
1267 )
1268 surf_mesh = str(file_list[0])
1270 if any(surf_mesh.endswith(x) for x in FREESURFER_MESH_EXTENSIONS):
1271 coords, faces, header = fs.io.read_geometry(
1272 surf_mesh, read_metadata=True
1273 )
1274 # See https://github.com/nilearn/nilearn/pull/3235
1275 if "cras" in header:
1276 coords += header["cras"]
1277 mesh = InMemoryMesh(coordinates=coords, faces=faces)
1278 elif surf_mesh.endswith("gii"):
1279 coords, faces = _gifti_img_to_mesh(load(surf_mesh))
1280 mesh = InMemoryMesh(coordinates=coords, faces=faces)
1281 elif surf_mesh.endswith("gii.gz"):
1282 gifti_img = _load_surf_files_gifti_gzip(surf_mesh)
1283 coords, faces = _gifti_img_to_mesh(gifti_img)
1284 mesh = InMemoryMesh(coordinates=coords, faces=faces)
1285 else:
1286 raise ValueError(
1287 "The input type is not recognized. "
1288 f"{surf_mesh!r} was given "
1289 "while valid inputs are one of the following "
1290 "file formats: .gii, .gii.gz, "
1291 "Freesurfer specific files such as "
1292 f"{_stringify(FREESURFER_MESH_EXTENSIONS)}, "
1293 "two Numpy arrays organized in a list, tuple "
1294 "or a namedtuple with the "
1295 'fields "coordinates" and "faces".'
1296 )
1297 elif isinstance(surf_mesh, (list, tuple)):
1298 try:
1299 coords, faces = surf_mesh
1300 mesh = InMemoryMesh(coordinates=coords, faces=faces)
1301 except Exception:
1302 raise ValueError(
1303 "\nIf a list or tuple is given as input, "
1304 "it must have two elements,\n"
1305 "the first is a Numpy array containing the x-y-z coordinates "
1306 "of the mesh vertices,\n"
1307 "the second is a Numpy array "
1308 "containing the indices (into coords) of the mesh faces.\n"
1309 f"The input was a {surf_mesh.__class__.__name__} with "
1310 f"{len(surf_mesh)} elements: {[type(x) for x in surf_mesh]}."
1311 )
1312 elif hasattr(surf_mesh, "faces") and hasattr(surf_mesh, "coordinates"):
1313 coords, faces = surf_mesh.coordinates, surf_mesh.faces
1314 mesh = InMemoryMesh(coordinates=coords, faces=faces)
1316 else:
1317 raise ValueError(
1318 "The input type is not recognized. "
1319 "Valid inputs are one of the following file "
1320 "formats: .gii, .gii.gz, "
1321 "Freesurfer specific files such as "
1322 f"{_stringify(FREESURFER_MESH_EXTENSIONS)} "
1323 "or two Numpy arrays organized in a list, tuple or "
1324 'a namedtuple with the fields "coordinates" and "faces"'
1325 )
1327 return mesh
1330class PolyData:
1331 """A collection of data arrays.
1333 It is a shallow wrapper around the ``parts`` dictionary, which cannot be
1334 empty and whose keys must be a subset of {"left", "right"}.
1336 .. versionadded:: 0.11.0
1338 Parameters
1339 ----------
1340 left : 1/2D :obj:`numpy.ndarray` or :obj:`str` or :obj:`pathlib.Path` \
1341 or None, default = None
1343 right : 1/2D :obj:`numpy.ndarray` or :obj:`str` or :obj:`pathlib.Path` \
1344 or None, default = None
1346 Attributes
1347 ----------
1348 parts : :obj:`dict` of 2D :obj:`numpy.ndarray` (n_vertices, n_timepoints)
1350 shape : :obj:`tuple` of :obj:`int`
1351 The first dimension corresponds to the vertices:
1352 the typical shape of the
1353 data for a hemisphere is ``(n_vertices, n_time_points)``.
1355 Examples
1356 --------
1357 >>> import numpy as np
1358 >>> from nilearn.surface import PolyData
1359 >>> n_time_points = 10
1360 >>> n_left_vertices = 5
1361 >>> n_right_vertices = 7
1362 >>> left = np.ones((n_left_vertices, n_time_points))
1363 >>> right = np.ones((n_right_vertices, n_time_points))
1364 >>> PolyData(left=left, right=right)
1365 <PolyData (12, 10)>
1366 >>> PolyData(right=right)
1367 <PolyData (7, 10)>
1369 >>> PolyData()
1370 Traceback (most recent call last):
1371 ...
1372 ValueError: Cannot create an empty PolyData. ...
1373 """
1375 def __init__(self, left=None, right=None):
1376 if left is None and right is None:
1377 raise ValueError(
1378 "Cannot create an empty PolyData. "
1379 "Either left or right (or both) must be provided."
1380 )
1382 parts = {}
1383 for hemi, param in zip(["left", "right"], [left, right]):
1384 if param is not None:
1385 if not isinstance(param, np.ndarray):
1386 param = load_surf_data(param)
1387 parts[hemi] = param
1388 self.parts = parts
1390 self._check_parts()
1392 def _check_parts(self):
1393 parts = self.parts
1395 if len(parts) == 1:
1396 return
1398 if len(parts["left"].shape) != len(parts["right"].shape) or (
1399 len(parts["left"].shape) > 1
1400 and len(parts["right"].shape) > 1
1401 and parts["left"].shape[-1] != parts["right"].shape[-1]
1402 ):
1403 raise ValueError(
1404 f"Data arrays for keys 'left' and 'right' "
1405 "have incompatible shapes: "
1406 f"{parts['left'].shape} and {parts['right'].shape}"
1407 )
1409 @property
1410 def shape(self):
1411 """Shape of the data."""
1412 if len(self.parts) == 1:
1413 return next(iter(self.parts.values())).shape
1415 tmp = next(iter(self.parts.values()))
1417 sum_vertices = sum(p.shape[0] for p in self.parts.values())
1418 return (
1419 (sum_vertices, tmp.shape[1])
1420 if len(tmp.shape) == 2
1421 else (sum_vertices,)
1422 )
1424 def __repr__(self):
1425 return f"<{self.__class__.__name__} {self.shape}>"
1427 def _get_min_max(self):
1428 """Get min and max across parts.
1430 Returns
1431 -------
1432 vmin : float
1434 vmax : float
1435 """
1436 vmin = min(x.min() for x in self.parts.values())
1437 vmax = max(x.max() for x in self.parts.values())
1438 return vmin, vmax
1440 def _check_ndims(self, dim, var_name="img"):
1441 """Check if the data is of a given dimension.
1443 Raise error if not.
1445 Parameters
1446 ----------
1447 dim : int
1448 Dimensions the data should have.
1450 var_name : str, optional
1451 Name of the variable to include in the error message.
1453 Returns
1454 -------
1455 raise ValueError if the data of the SurfaceImage is not of the given
1456 dimension.
1457 """
1458 if any(x.ndim != dim for x in self.parts.values()):
1459 msg = [f"{v.ndim}D for {k}" for k, v in self.parts.items()]
1460 raise ValueError(
1461 f"Data for each part of {var_name} should be {dim}D. "
1462 f"Found: {', '.join(msg)}."
1463 )
1465 def to_filename(self, filename):
1466 """Save data to gifti.
1468 Parameters
1469 ----------
1470 filename : :obj:`str` or :obj:`pathlib.Path`
1471 If the filename contains `hemi-L`
1472 then only the left part of the mesh will be saved.
1473 If the filename contains `hemi-R`
1474 then only the right part of the mesh will be saved.
1475 If the filename contains neither of those,
1476 then `_hemi-L` and `_hemi-R`
1477 will be appended to the filename and both will be saved.
1478 """
1479 filename = _sanitize_filename(filename)
1481 if "hemi-L" not in filename.stem and "hemi-R" not in filename.stem:
1482 for hemi in ["L", "R"]:
1483 self.to_filename(
1484 filename.with_stem(f"{filename.stem}_hemi-{hemi}")
1485 )
1486 return None
1488 if "hemi-L" in filename.stem:
1489 data = self.parts["left"]
1490 if "hemi-R" in filename.stem:
1491 data = self.parts["right"]
1493 _data_to_gifti(data, filename)
1496def at_least_2d(input):
1497 """Force surface image or polydata to be 2d."""
1498 if len(input.shape) == 2:
1499 return input
1501 if isinstance(input, SurfaceImage):
1502 input.data = at_least_2d(input.data)
1503 return input
1505 if len(input.shape) == 1:
1506 for k, v in input.parts.items():
1507 input.parts[k] = v.reshape((v.shape[0], 1))
1509 return input
1512class SurfaceMesh(abc.ABC):
1513 """A surface :term:`mesh` having vertex, \
1514 coordinates and faces (triangles).
1516 .. versionadded:: 0.11.0
1518 Attributes
1519 ----------
1520 n_vertices : int
1521 number of vertices
1522 """
1524 n_vertices: int
1526 # TODO those are properties are for compatibility with plot_surf_img.
1527 # But they should probably become functions as they can take some time to
1528 # return or even fail
1529 coordinates: np.ndarray
1530 faces: np.ndarray
1532 def __repr__(self):
1533 return (
1534 f"<{self.__class__.__name__} with "
1535 f"{self.n_vertices} vertices and "
1536 f"{len(self.faces)} faces.>"
1537 )
1539 def to_gifti(self, gifti_file):
1540 """Write surface mesh to a Gifti file on disk.
1542 Parameters
1543 ----------
1544 gifti_file : :obj:`str` or :obj:`pathlib.Path`
1545 Filename to save the mesh to.
1546 """
1547 _mesh_to_gifti(self.coordinates, self.faces, gifti_file)
1550class InMemoryMesh(SurfaceMesh):
1551 """A surface mesh stored as in-memory numpy arrays.
1553 .. versionadded:: 0.11.0
1555 Parameters
1556 ----------
1557 coordinates : :obj:`numpy.ndarray`
1559 faces : :obj:`numpy.ndarray`
1561 Attributes
1562 ----------
1563 n_vertices : int
1564 number of vertices
1565 """
1567 n_vertices: int
1569 coordinates: np.ndarray
1571 faces: np.ndarray
1573 def __init__(self, coordinates, faces):
1574 if not isinstance(coordinates, np.ndarray) or not isinstance(
1575 faces, np.ndarray
1576 ):
1577 raise TypeError(
1578 "Mesh coordinates and faces must be numpy arrays.\n"
1579 f"Got {type(coordinates)=} and {type(faces)=}."
1580 )
1581 self.coordinates = coordinates
1582 self.faces = faces
1583 self.n_vertices = coordinates.shape[0]
1584 _validate_mesh(self)
1586 def __getitem__(self, index):
1587 if index == 0:
1588 return self.coordinates
1589 elif index == 1:
1590 return self.faces
1591 else:
1592 raise IndexError(
1593 "Index out of range. Use 0 for coordinates and 1 for faces."
1594 )
1596 def __iter__(self):
1597 return iter([self.coordinates, self.faces])
1600class FileMesh(SurfaceMesh):
1601 """A surface mesh stored in a Gifti or Freesurfer file.
1603 .. versionadded:: 0.11.0
1605 Parameters
1606 ----------
1607 file_path : :obj:`str` or :obj:`pathlib.Path`
1608 Filename to read mesh from.
1609 """
1611 n_vertices: int
1613 file_path: pathlib.Path
1615 def __init__(self, file_path):
1616 self.file_path = pathlib.Path(file_path)
1617 self.n_vertices = load_surf_mesh(self.file_path).coordinates.shape[0]
1619 @property
1620 def coordinates(self):
1621 """Get x, y, z, values for each mesh vertex.
1623 Returns
1624 -------
1625 :obj:`numpy.ndarray`
1626 """
1627 mesh = load_surf_mesh(self.file_path)
1628 _validate_mesh(mesh)
1629 return mesh.coordinates
1631 @property
1632 def faces(self):
1633 """Get array of adjacent vertices.
1635 Returns
1636 -------
1637 :obj:`numpy.ndarray`
1638 """
1639 mesh = load_surf_mesh(self.file_path)
1640 _validate_mesh(mesh)
1641 return mesh.faces
1643 def loaded(self):
1644 """Load surface mesh into memory.
1646 Returns
1647 -------
1648 :obj:`nilearn.surface.InMemoryMesh`
1649 """
1650 loaded = load_surf_mesh(self.file_path)
1651 return InMemoryMesh(loaded.coordinates, loaded.faces)
1654class PolyMesh:
1655 """A collection of meshes.
1657 It is a shallow wrapper around the ``parts`` dictionary, which cannot be
1658 empty and whose keys must be a subset of {"left", "right"}.
1660 .. versionadded:: 0.11.0
1662 Parameters
1663 ----------
1664 left : :obj:`str` or :obj:`pathlib.Path` \
1665 or :obj:`nilearn.surface.SurfaceMesh` or None, default=None
1666 SurfaceMesh for the left hemisphere.
1668 right : :obj:`str` or :obj:`pathlib.Path` \
1669 or :obj:`nilearn.surface.SurfaceMesh` or None, default=None
1670 SurfaceMesh for the right hemisphere.
1672 Attributes
1673 ----------
1674 n_vertices : int
1675 number of vertices
1676 """
1678 n_vertices: int
1680 def __init__(self, left=None, right=None) -> None:
1681 if left is None and right is None:
1682 raise ValueError(
1683 "Cannot create an empty PolyMesh. "
1684 "Either left or right (or both) must be provided."
1685 )
1687 self.parts = {}
1688 if left is not None:
1689 if not isinstance(left, SurfaceMesh):
1690 left = FileMesh(left).loaded()
1691 self.parts["left"] = left
1692 if right is not None:
1693 if not isinstance(right, SurfaceMesh):
1694 right = FileMesh(right).loaded()
1695 self.parts["right"] = right
1697 self.n_vertices = sum(p.n_vertices for p in self.parts.values())
1699 def to_filename(self, filename):
1700 """Save mesh to gifti.
1702 Parameters
1703 ----------
1704 filename : :obj:`str` or :obj:`pathlib.Path`
1705 If the filename contains `hemi-L`
1706 then only the left part of the mesh will be saved.
1707 If the filename contains `hemi-R`
1708 then only the right part of the mesh will be saved.
1709 If the filename contains neither of those,
1710 then `_hemi-L` and `_hemi-R`
1711 will be appended to the filename and both will be saved.
1712 """
1713 filename = _sanitize_filename(filename)
1715 if "hemi-L" not in filename.stem and "hemi-R" not in filename.stem:
1716 for hemi in ["L", "R"]:
1717 self.to_filename(
1718 filename.with_stem(f"{filename.stem}_hemi-{hemi}")
1719 )
1720 return None
1722 if "hemi-L" in filename.stem:
1723 mesh = self.parts["left"]
1724 if "hemi-R" in filename.stem:
1725 mesh = self.parts["right"]
1727 mesh.to_gifti(filename)
1730def _check_data_and_mesh_compat(mesh, data):
1731 """Check that mesh and data have the same keys and that shapes match.
1733 mesh : :obj:`nilearn.surface.PolyMesh`
1735 data : :obj:`nilearn.surface.PolyData`
1736 """
1737 data_keys, mesh_keys = set(data.parts.keys()), set(mesh.parts.keys())
1738 if data_keys != mesh_keys:
1739 diff = data_keys.symmetric_difference(mesh_keys)
1740 raise ValueError(
1741 f"Data and mesh do not have the same keys. Offending keys: {diff}"
1742 )
1743 for key in mesh_keys:
1744 if data.parts[key].shape[0] != mesh.parts[key].n_vertices:
1745 raise ValueError(
1746 f"Data shape does not match number of vertices for '{key}':\n"
1747 f"- data shape: {data.parts[key].shape}\n"
1748 f"- n vertices: {mesh.parts[key].n_vertices}"
1749 )
1752def _mesh_to_gifti(coordinates, faces, gifti_file):
1753 """Write surface mesh to gifti file on disk.
1755 Parameters
1756 ----------
1757 coordinates : :obj:`numpy.ndarray`
1758 a Numpy array containing the x-y-z coordinates of the mesh vertices
1760 faces : :obj:`numpy.ndarray`
1761 a Numpy array containing the indices (into coords) of the mesh faces.
1763 gifti_file : :obj:`str` or :obj:`pathlib.Path`
1764 name for the output gifti file.
1765 """
1766 gifti_file = Path(gifti_file)
1767 gifti_img = gifti.GiftiImage()
1768 coords_array = gifti.GiftiDataArray(
1769 coordinates, intent="NIFTI_INTENT_POINTSET", datatype="float32"
1770 )
1771 faces_array = gifti.GiftiDataArray(
1772 faces, intent="NIFTI_INTENT_TRIANGLE", datatype="int32"
1773 )
1774 gifti_img.add_gifti_data_array(coords_array)
1775 gifti_img.add_gifti_data_array(faces_array)
1776 gifti_img.to_filename(gifti_file)
1779def _data_to_gifti(data, gifti_file):
1780 """Save data from Polydata to a gifti file.
1782 Parameters
1783 ----------
1784 data : :obj:`numpy.ndarray`
1785 The data will be cast to np.uint8, np.int32) or np.float32
1786 as only the following are 'supported' for now:
1787 - NIFTI_TYPE_UINT8
1788 - NIFTI_TYPE_INT32
1789 - NIFTI_TYPE_FLOAT32
1790 See https://github.com/nipy/nibabel/blob/master/nibabel/gifti/gifti.py
1792 gifti_file : :obj:`str` or :obj:`pathlib.Path`
1793 name for the output gifti file.
1794 """
1795 if data.dtype in [np.uint16, np.uint32, np.uint64]:
1796 data = data.astype(np.uint8)
1797 elif data.dtype in [np.int8, np.int16, np.int64]:
1798 data = data.astype(np.int32)
1799 elif data.dtype in [np.float64]:
1800 data = data.astype(np.float32)
1802 if data.dtype == np.uint8:
1803 datatype = "NIFTI_TYPE_UINT8"
1804 elif data.dtype == np.int32:
1805 datatype = "NIFTI_TYPE_INT32"
1806 elif data.dtype == np.float32:
1807 datatype = "NIFTI_TYPE_FLOAT32"
1808 else:
1809 datatype = None
1810 darray = gifti.GiftiDataArray(data=data, datatype=datatype)
1812 gii = gifti.GiftiImage(darrays=[darray])
1813 gii.to_filename(Path(gifti_file))
1816def _sanitize_filename(filename):
1817 """Check filenames to write gifti.
1819 - add suffix .gii if missing
1820 - make sure that there is only one hemi entity in the filename
1822 Parameters
1823 ----------
1824 filename : :obj:`str` or :obj:`pathlib.Path`
1825 filename to check
1827 Returns
1828 -------
1829 :obj:`pathlib.Path`
1830 """
1831 filename = Path(filename)
1833 if not filename.suffix:
1834 filename = filename.with_suffix(".gii")
1835 if filename.suffix != ".gii":
1836 raise ValueError(
1837 "SurfaceMesh / Data should be saved as gifti files "
1838 "with the extension '.gii'.\n"
1839 f"Got '{filename.suffix}'."
1840 )
1842 if "hemi-L" in filename.stem and "hemi-R" in filename.stem:
1843 raise ValueError(
1844 "'filename' cannot contain both "
1845 "'hemi-L' and 'hemi-R'.\n"
1846 f"Got: {filename}"
1847 )
1848 return filename
1851class SurfaceImage:
1852 """Surface image containing meshes & data for both hemispheres.
1854 .. versionadded:: 0.11.0
1856 Parameters
1857 ----------
1858 mesh : :obj:`nilearn.surface.PolyMesh`, \
1859 or :obj:`dict` of \
1860 :obj:`nilearn.surface.SurfaceMesh`, \
1861 :obj:`str`, \
1862 :obj:`pathlib.Path`
1863 Meshes for the both hemispheres.
1865 data : :obj:`nilearn.surface.PolyData`, \
1866 or :obj:`dict` of \
1867 :obj:`numpy.ndarray`, \
1868 :obj:`str`, \
1869 :obj:`pathlib.Path`
1870 Data for the both hemispheres.
1872 squeeze_on_save : :obj:`bool` or None, default=None
1873 If ``True`` axes of length one from the data
1874 will be removed before saving them to file.
1875 If ``None`` is passed,
1876 then the value will be set to ``True``
1877 if any of the data parts is one dimensional.
1879 Attributes
1880 ----------
1881 shape : (int, int)
1882 shape of the surface data array
1883 """
1885 def __init__(self, mesh, data):
1886 """Create a SurfaceImage instance."""
1887 self.mesh = mesh if isinstance(mesh, PolyMesh) else PolyMesh(**mesh)
1889 if not isinstance(data, (PolyData, dict)):
1890 raise TypeError(
1891 f"'data' must be one of[PolyData, dict].\nGot {type(data)}"
1892 )
1894 if isinstance(data, PolyData):
1895 self.data = data
1896 elif isinstance(data, dict):
1897 self.data = PolyData(**data)
1899 _check_data_and_mesh_compat(self.mesh, self.data)
1901 @property
1902 def shape(self):
1903 """Shape of the data."""
1904 return self.data.shape
1906 def __repr__(self) -> str:
1907 return f"<{self.__class__.__name__} {self.shape}>"
1909 @classmethod
1910 def from_volume(
1911 cls, mesh, volume_img, inner_mesh=None, **vol_to_surf_kwargs
1912 ):
1913 """Create surface image from volume image.
1915 Parameters
1916 ----------
1917 mesh : :obj:`nilearn.surface.PolyMesh` \
1918 or :obj:`dict` of \
1919 :obj:`nilearn.surface.SurfaceMesh`, \
1920 :obj:`str`, \
1921 :obj:`pathlib.Path`
1922 Surface mesh.
1924 volume_img : Niimg-like object
1925 3D or 4D volume image to project to the surface mesh.
1927 inner_mesh : :obj:`nilearn.surface.PolyMesh` \
1928 or :obj:`dict` of \
1929 :obj:`nilearn.surface.SurfaceMesh`, \
1930 :obj:`str`, \
1931 :obj:`pathlib.Path`, default=None
1932 Inner mesh to pass to :func:`nilearn.surface.vol_to_surf`.
1934 vol_to_surf_kwargs : :obj:`dict` [ :obj:`str` , Any]
1935 Dictionary of extra key-words arguments to pass
1936 to :func:`nilearn.surface.vol_to_surf`.
1938 Examples
1939 --------
1940 >>> from nilearn.surface import SurfaceImage
1941 >>> from nilearn.datasets import load_fsaverage
1942 >>> from nilearn.datasets import load_sample_motor_activation_image
1944 >>> fsavg = load_fsaverage()
1945 >>> vol_img = load_sample_motor_activation_image()
1946 >>> img = SurfaceImage.from_volume(fsavg["white_matter"], vol_img)
1947 >>> img
1948 <SurfaceImage (20484,)>
1949 >>> img = SurfaceImage.from_volume(
1950 ... fsavg["white_matter"], vol_img, inner_mesh=fsavg["pial"]
1951 ... )
1952 >>> img
1953 <SurfaceImage (20484,)>
1954 """
1955 mesh = mesh if isinstance(mesh, PolyMesh) else PolyMesh(**mesh)
1956 if inner_mesh is not None:
1957 inner_mesh = (
1958 inner_mesh
1959 if isinstance(inner_mesh, PolyMesh)
1960 else PolyMesh(**inner_mesh)
1961 )
1962 left_kwargs = {"inner_mesh": inner_mesh.parts["left"]}
1963 right_kwargs = {"inner_mesh": inner_mesh.parts["right"]}
1964 else:
1965 left_kwargs, right_kwargs = {}, {}
1967 if isinstance(volume_img, (str, Path)):
1968 volume_img = check_niimg(volume_img)
1970 texture_left = vol_to_surf(
1971 volume_img, mesh.parts["left"], **vol_to_surf_kwargs, **left_kwargs
1972 )
1974 texture_right = vol_to_surf(
1975 volume_img,
1976 mesh.parts["right"],
1977 **vol_to_surf_kwargs,
1978 **right_kwargs,
1979 )
1981 data = PolyData(left=texture_left, right=texture_right)
1983 return cls(mesh=mesh, data=data)
1986def check_surf_img(img: Union[SurfaceImage, Iterable[SurfaceImage]]) -> None:
1987 """Validate SurfaceImage.
1989 Equivalent to check_niimg for volumes.
1990 """
1991 if isinstance(img, SurfaceImage):
1992 if get_data(img).size == 0:
1993 raise ValueError("The image is empty.")
1994 return None
1996 if hasattr(img, "__iter__"):
1997 for x in img:
1998 check_surf_img(x)
2001def get_data(img, ensure_finite=False) -> np.ndarray:
2002 """Concatenate the data of a SurfaceImage across hemispheres and return
2003 as a numpy array.
2005 Parameters
2006 ----------
2007 img : :obj:`~surface.SurfaceImage` or :obj:`~surface.PolyData`
2008 SurfaceImage whose data to concatenate and extract.
2010 ensure_finite : bool, Default=False
2011 If True, non-finite values such as (NaNs and infs) found in the
2012 image will be replaced by zeros.
2014 Returns
2015 -------
2016 :obj:`~numpy.ndarray`
2017 Concatenated data across hemispheres.
2018 """
2019 if isinstance(img, SurfaceImage):
2020 data = img.data
2021 elif isinstance(img, PolyData):
2022 data = img
2023 else:
2024 raise TypeError(
2025 f"Expected PolyData or SurfaceImage. Got {img.__class__.__name__}."
2026 )
2028 data = np.concatenate(list(data.parts.values()), axis=0)
2030 if ensure_finite:
2031 non_finite_mask = np.logical_not(np.isfinite(data))
2032 if non_finite_mask.any(): # any non_finite_mask values?
2033 warnings.warn(
2034 "Non-finite values detected. "
2035 "These values will be replaced with zeros.",
2036 stacklevel=find_stack_level(),
2037 )
2038 data[non_finite_mask] = 0
2040 return data
2043def extract_data(img, index):
2044 """Extract data of a SurfaceImage a specified indices.
2046 Parameters
2047 ----------
2048 img : SurfaceImage object
2050 index : Any type compatible with numpy array indexing
2051 Used for indexing the 2D data array in the 2nd dimension.
2053 Returns
2054 -------
2055 a dict where each value contains the data extracted
2056 for each part
2057 """
2058 if not isinstance(img, SurfaceImage):
2059 raise TypeError("Input must a be SurfaceImage.")
2060 mesh = img.mesh
2061 data = img.data
2063 last_dim = 1 if isinstance(index, int) else len(index)
2065 return {
2066 hemi: data.parts[hemi][:, index]
2067 .copy()
2068 .reshape(mesh.parts[hemi].n_vertices, last_dim)
2069 for hemi in data.parts
2070 }