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

1"""Functions for surface manipulation.""" 

2 

3import abc 

4import gzip 

5import pathlib 

6import warnings 

7from collections.abc import Iterable, Mapping 

8from pathlib import Path 

9from typing import Union 

10 

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 

18 

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 

24 

25 

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 

35 

36 

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) 

53 

54 

55def _face_outer_normals(mesh): 

56 """Get the normal to each triangle in a mesh. 

57 

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. 

61 

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 

74 

75 

76def _surrounding_faces(mesh): 

77 """Get matrix indicating which faces the nodes belong to. 

78 

79 i, j is set if node i is a vertex of triangle j. 

80 

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 ) 

93 

94 

95def _vertex_outer_normals(mesh): 

96 """Get the normal at each vertex in a triangular mesh. 

97 

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. 

101 

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) 

107 

108 

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 

114 

115 outer_vertices = load_surf_mesh(mesh).coordinates 

116 inner_vertices = load_surf_mesh(inner_mesh).coordinates 

117 

118 if depth is None: 

119 steps = np.linspace(0, 1, n_points)[:, None, None] 

120 else: 

121 steps = np.asarray(depth)[:, None, None] 

122 

123 sample_locations = outer_vertices + steps * ( 

124 inner_vertices - outer_vertices 

125 ) 

126 sample_locations = np.rollaxis(sample_locations, 1) 

127 

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 

134 

135 

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. 

140 

141 For each mesh vertex, the locations of `n_points` points evenly spread in a 

142 ball around the vertex are returned. 

143 

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) 

151 

152 affine : :obj:`numpy.ndarray` of shape (4, 4) 

153 Affine transformation from image voxels to the vertices' coordinate 

154 space. 

155 

156 ball_radius : :obj:`float`, default=3.0 

157 Size in mm of the neighbourhood around each vertex in which to draw 

158 samples. 

159 

160 n_points : :obj:`int`, default=20 

161 Number of samples to draw for each vertex. 

162 

163 depth : `None` 

164 Raises a `ValueError` if not `None` because incompatible with this 

165 sampling strategy. 

166 

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. 

175 

176 """ 

177 # Avoid circular import 

178 from nilearn.image.resampling import coord_transform 

179 

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 

204 

205 

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. 

210 

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. 

214 

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) 

222 

223 affine : :obj:`numpy.ndarray` of shape (4, 4) 

224 Affine transformation from image voxels to the vertices' coordinate 

225 space. 

226 

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. 

230 

231 n_points : :obj:`int`, default=10 

232 Number of samples to draw for each vertex. 

233 

234 depth : sequence of :obj:`float` or None, optional 

235 Cortical depth, expressed as a fraction of segment_half_width. 

236 Overrides n_points. 

237 

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. 

246 

247 """ 

248 # Avoid circular import 

249 from nilearn.image.resampling import coord_transform 

250 

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 

270 

271 

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 

281 

282 

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 

312 

313 

314def _masked_indices(sample_locations, img_shape, mask=None): 

315 """Get the indices of sample points which should be ignored. 

316 

317 Parameters 

318 ---------- 

319 sample_locations : :obj:`numpy.ndarray`, shape(n_sample_locations, 3) 

320 The coordinates of candidate interpolation points. 

321 

322 img_shape : :obj:`tuple` 

323 The dimensions of the image to be sampled. 

324 

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. 

327 

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). 

333 

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 

342 

343 

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. 

356 

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. 

366 

367 affine : :obj:`numpy.ndarray` of shape (4, 4) 

368 Affine transformation from image voxels to the vertices' coordinate 

369 space. 

370 

371 img_shape : 3-tuple of :obj:`int` 

372 The shape of the image to be projected. 

373 

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. 

377 

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. 

388 

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`. 

392 

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. 

400 

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. 

403 

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'). 

413 

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' 

417 

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. 

424 

425 See Also 

426 -------- 

427 nilearn.surface.vol_to_surf 

428 Compute the projection for one or several images. 

429 

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 

462 

463 

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 

475 

476 

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). 

490 

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 

516 

517 

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. 

530 

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. 

535 

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 

555 

556 

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. 

569 

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. 

575 

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 

606 

607 

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. 

620 

621 .. versionadded:: 0.4.0 

622 

623 Parameters 

624 ---------- 

625 img : Niimg-like object, 3d or 4d. 

626 See :ref:`extracting_data`. 

627 

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. 

635 

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. 

639 

640 interpolation : {'linear', 'nearest', 'nearest_most_frequent'}, \ 

641 default='linear' 

642 How the image intensity is measured at a sample point. 

643 

644 - 'linear': 

645 Use a trilinear interpolation of neighboring voxels. 

646 - 'nearest': 

647 Use the intensity of the nearest voxel. 

648 

649 .. versionchanged:: 0.11.2.dev 

650 

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>`. 

657 

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>`. 

663 

664 .. versionadded:: 0.11.2.dev 

665 

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. 

669 

670 kind : {'auto', 'depth', 'line', 'ball'}, default='auto' 

671 The strategy used to sample image intensities around each vertex. 

672 

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. 

690 

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. 

698 

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. 

702 

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'). 

713 

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`. 

727 

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. 

735 

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. 

743 

744 Three strategies are available to select these positions. 

745 

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) 

762 

763 You can control how many samples are drawn by setting `n_samples`, or their 

764 position by setting `depth`. 

765 

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``. 

771 

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. 

780 

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. 

784 

785 .. important:: 

786 

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>`. 

791 

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:: 

797 

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 ... ) 

806 

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 

812 

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 ) 

823 

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 ) 

835 

836 img = load_img(img) 

837 

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 

852 

853 original_dimension = len(img.shape) 

854 

855 img = _utils.check_niimg(img, atleast_4d=True) 

856 

857 frames = np.rollaxis(get_vol_data(img), -1) 

858 

859 mesh = load_surf_mesh(surf_mesh) 

860 

861 if inner_mesh is not None: 

862 inner_mesh = load_surf_mesh(inner_mesh) 

863 

864 sampling = sampling_schemes[interpolation] 

865 

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 ) 

877 

878 if original_dimension == 3: 

879 texture = texture[0] 

880 return texture.T 

881 

882 

883def _load_surf_files_gifti_gzip(surf_file): 

884 """Load surface data Gifti files which are gzipped. 

885 

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 

894 

895 

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. 

899 

900 Used by load_surf_data function in common to surface sulcal data 

901 acceptable to .gii or .gii.gz 

902 

903 """ 

904 if not gifti_img.darrays: 

905 raise ValueError("Gifti must contain at least one data array") 

906 

907 if len(gifti_img.darrays) == 1: 

908 return np.asarray([gifti_img.darrays[0].data]).T.squeeze() 

909 

910 return np.asarray( 

911 [arr.data for arr in gifti_img.darrays], dtype=object 

912 ).T.squeeze() 

913 

914 

915FREESURFER_MESH_EXTENSIONS = ("orig", "pial", "sphere", "white", "inflated") 

916 

917FREESURFER_DATA_EXTENSIONS = ( 

918 "area", 

919 "curv", 

920 "sulc", 

921 "thickness", 

922 "label", 

923 "annot", 

924) 

925 

926DATA_EXTENSIONS = ("gii", "gii.gz", "mgz", "nii", "nii.gz") 

927 

928 

929def _stringify(word_list): 

930 sep = "', '." 

931 return f"'.{sep.join(word_list)[:-3]}'" 

932 

933 

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. 

937 

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. 

945 

946 Returns 

947 ------- 

948 data : :obj:`numpy.ndarray` 

949 An array containing surface data 

950 

951 """ 

952 # avoid circular import 

953 from nilearn.image import get_data as get_vol_data 

954 

955 # if the input is a filename, load it 

956 surf_data = stringify_path(surf_data) 

957 

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 ) 

967 

968 if isinstance(surf_data, str): 

969 # resolve globbing 

970 file_list = resolve_globbing(surf_data) 

971 # resolve_globbing handles empty lists 

972 

973 for i, surf_data in enumerate(file_list): 

974 surf_data = str(surf_data) 

975 

976 check_extensions( 

977 surf_data, DATA_EXTENSIONS, FREESURFER_DATA_EXTENSIONS 

978 ) 

979 

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) 

993 

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 ) 

1007 

1008 # if the input is a numpy array 

1009 elif isinstance(surf_data, np.ndarray): 

1010 data = surf_data 

1011 

1012 return np.squeeze(data) 

1013 

1014 

1015def check_extensions(surf_data, data_extensions, freesurfer_data_extensions): 

1016 """Check the extension of the input file. 

1017 

1018 Should either be one one of the supported data formats 

1019 or one of freesurfer data formats. 

1020 

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 ) 

1044 

1045 

1046def _gifti_img_to_mesh(gifti_img): 

1047 """Load surface image in nibabel.gifti.GiftiImage to data. 

1048 

1049 Used by load_surf_mesh function in common to surface mesh 

1050 acceptable to .gii or .gii.gz 

1051 

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 

1087 

1088 

1089def combine_hemispheres_meshes(mesh): 

1090 """Combine the left and right hemisphere meshes such that both are 

1091 represented in the same mesh. 

1092 

1093 Parameters 

1094 ---------- 

1095 mesh : :obj:`~nilearn.surface.PolyMesh` 

1096 The mesh object containing the left and right hemisphere meshes. 

1097 

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 

1110 

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) 

1125 

1126 

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 

1135 

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 

1156 

1157 

1158def check_mesh_and_data(mesh, data): 

1159 """Load surface :term:`mesh` and data, \ 

1160 check that they have compatible shapes. 

1161 

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. 

1178 

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) 

1187 

1188 _validate_mesh(mesh) 

1189 

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 ) 

1199 

1200 return mesh, data 

1201 

1202 

1203def _validate_mesh(mesh): 

1204 """Check mesh coordinates and faces. 

1205 

1206 Mesh coordinates and faces must be numpy arrays. 

1207 

1208 Coordinates must be finite values. 

1209 

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 ) 

1222 

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()}") 

1232 

1233 

1234# function to figure out datatype and load data 

1235def load_surf_mesh(surf_mesh): 

1236 """Load a surface :term:`mesh` geometry. 

1237 

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. 

1249 

1250 Returns 

1251 ------- 

1252 mesh : :obj:`~nilearn.surface.InMemoryMesh` 

1253 With the attributes "coordinates" and "faces", each containing a 

1254 :obj:`numpy.ndarray` 

1255 

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]) 

1269 

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) 

1315 

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 ) 

1326 

1327 return mesh 

1328 

1329 

1330class PolyData: 

1331 """A collection of data arrays. 

1332 

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"}. 

1335 

1336 .. versionadded:: 0.11.0 

1337 

1338 Parameters 

1339 ---------- 

1340 left : 1/2D :obj:`numpy.ndarray` or :obj:`str` or :obj:`pathlib.Path` \ 

1341 or None, default = None 

1342 

1343 right : 1/2D :obj:`numpy.ndarray` or :obj:`str` or :obj:`pathlib.Path` \ 

1344 or None, default = None 

1345 

1346 Attributes 

1347 ---------- 

1348 parts : :obj:`dict` of 2D :obj:`numpy.ndarray` (n_vertices, n_timepoints) 

1349 

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)``. 

1354 

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)> 

1368 

1369 >>> PolyData() 

1370 Traceback (most recent call last): 

1371 ... 

1372 ValueError: Cannot create an empty PolyData. ... 

1373 """ 

1374 

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 ) 

1381 

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 

1389 

1390 self._check_parts() 

1391 

1392 def _check_parts(self): 

1393 parts = self.parts 

1394 

1395 if len(parts) == 1: 

1396 return 

1397 

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 ) 

1408 

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 

1414 

1415 tmp = next(iter(self.parts.values())) 

1416 

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 ) 

1423 

1424 def __repr__(self): 

1425 return f"<{self.__class__.__name__} {self.shape}>" 

1426 

1427 def _get_min_max(self): 

1428 """Get min and max across parts. 

1429 

1430 Returns 

1431 ------- 

1432 vmin : float 

1433 

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 

1439 

1440 def _check_ndims(self, dim, var_name="img"): 

1441 """Check if the data is of a given dimension. 

1442 

1443 Raise error if not. 

1444 

1445 Parameters 

1446 ---------- 

1447 dim : int 

1448 Dimensions the data should have. 

1449 

1450 var_name : str, optional 

1451 Name of the variable to include in the error message. 

1452 

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 ) 

1464 

1465 def to_filename(self, filename): 

1466 """Save data to gifti. 

1467 

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) 

1480 

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 

1487 

1488 if "hemi-L" in filename.stem: 

1489 data = self.parts["left"] 

1490 if "hemi-R" in filename.stem: 

1491 data = self.parts["right"] 

1492 

1493 _data_to_gifti(data, filename) 

1494 

1495 

1496def at_least_2d(input): 

1497 """Force surface image or polydata to be 2d.""" 

1498 if len(input.shape) == 2: 

1499 return input 

1500 

1501 if isinstance(input, SurfaceImage): 

1502 input.data = at_least_2d(input.data) 

1503 return input 

1504 

1505 if len(input.shape) == 1: 

1506 for k, v in input.parts.items(): 

1507 input.parts[k] = v.reshape((v.shape[0], 1)) 

1508 

1509 return input 

1510 

1511 

1512class SurfaceMesh(abc.ABC): 

1513 """A surface :term:`mesh` having vertex, \ 

1514 coordinates and faces (triangles). 

1515 

1516 .. versionadded:: 0.11.0 

1517 

1518 Attributes 

1519 ---------- 

1520 n_vertices : int 

1521 number of vertices 

1522 """ 

1523 

1524 n_vertices: int 

1525 

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 

1531 

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 ) 

1538 

1539 def to_gifti(self, gifti_file): 

1540 """Write surface mesh to a Gifti file on disk. 

1541 

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) 

1548 

1549 

1550class InMemoryMesh(SurfaceMesh): 

1551 """A surface mesh stored as in-memory numpy arrays. 

1552 

1553 .. versionadded:: 0.11.0 

1554 

1555 Parameters 

1556 ---------- 

1557 coordinates : :obj:`numpy.ndarray` 

1558 

1559 faces : :obj:`numpy.ndarray` 

1560 

1561 Attributes 

1562 ---------- 

1563 n_vertices : int 

1564 number of vertices 

1565 """ 

1566 

1567 n_vertices: int 

1568 

1569 coordinates: np.ndarray 

1570 

1571 faces: np.ndarray 

1572 

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) 

1585 

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 ) 

1595 

1596 def __iter__(self): 

1597 return iter([self.coordinates, self.faces]) 

1598 

1599 

1600class FileMesh(SurfaceMesh): 

1601 """A surface mesh stored in a Gifti or Freesurfer file. 

1602 

1603 .. versionadded:: 0.11.0 

1604 

1605 Parameters 

1606 ---------- 

1607 file_path : :obj:`str` or :obj:`pathlib.Path` 

1608 Filename to read mesh from. 

1609 """ 

1610 

1611 n_vertices: int 

1612 

1613 file_path: pathlib.Path 

1614 

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] 

1618 

1619 @property 

1620 def coordinates(self): 

1621 """Get x, y, z, values for each mesh vertex. 

1622 

1623 Returns 

1624 ------- 

1625 :obj:`numpy.ndarray` 

1626 """ 

1627 mesh = load_surf_mesh(self.file_path) 

1628 _validate_mesh(mesh) 

1629 return mesh.coordinates 

1630 

1631 @property 

1632 def faces(self): 

1633 """Get array of adjacent vertices. 

1634 

1635 Returns 

1636 ------- 

1637 :obj:`numpy.ndarray` 

1638 """ 

1639 mesh = load_surf_mesh(self.file_path) 

1640 _validate_mesh(mesh) 

1641 return mesh.faces 

1642 

1643 def loaded(self): 

1644 """Load surface mesh into memory. 

1645 

1646 Returns 

1647 ------- 

1648 :obj:`nilearn.surface.InMemoryMesh` 

1649 """ 

1650 loaded = load_surf_mesh(self.file_path) 

1651 return InMemoryMesh(loaded.coordinates, loaded.faces) 

1652 

1653 

1654class PolyMesh: 

1655 """A collection of meshes. 

1656 

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"}. 

1659 

1660 .. versionadded:: 0.11.0 

1661 

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. 

1667 

1668 right : :obj:`str` or :obj:`pathlib.Path` \ 

1669 or :obj:`nilearn.surface.SurfaceMesh` or None, default=None 

1670 SurfaceMesh for the right hemisphere. 

1671 

1672 Attributes 

1673 ---------- 

1674 n_vertices : int 

1675 number of vertices 

1676 """ 

1677 

1678 n_vertices: int 

1679 

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 ) 

1686 

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 

1696 

1697 self.n_vertices = sum(p.n_vertices for p in self.parts.values()) 

1698 

1699 def to_filename(self, filename): 

1700 """Save mesh to gifti. 

1701 

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) 

1714 

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 

1721 

1722 if "hemi-L" in filename.stem: 

1723 mesh = self.parts["left"] 

1724 if "hemi-R" in filename.stem: 

1725 mesh = self.parts["right"] 

1726 

1727 mesh.to_gifti(filename) 

1728 

1729 

1730def _check_data_and_mesh_compat(mesh, data): 

1731 """Check that mesh and data have the same keys and that shapes match. 

1732 

1733 mesh : :obj:`nilearn.surface.PolyMesh` 

1734 

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 ) 

1750 

1751 

1752def _mesh_to_gifti(coordinates, faces, gifti_file): 

1753 """Write surface mesh to gifti file on disk. 

1754 

1755 Parameters 

1756 ---------- 

1757 coordinates : :obj:`numpy.ndarray` 

1758 a Numpy array containing the x-y-z coordinates of the mesh vertices 

1759 

1760 faces : :obj:`numpy.ndarray` 

1761 a Numpy array containing the indices (into coords) of the mesh faces. 

1762 

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) 

1777 

1778 

1779def _data_to_gifti(data, gifti_file): 

1780 """Save data from Polydata to a gifti file. 

1781 

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 

1791 

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) 

1801 

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) 

1811 

1812 gii = gifti.GiftiImage(darrays=[darray]) 

1813 gii.to_filename(Path(gifti_file)) 

1814 

1815 

1816def _sanitize_filename(filename): 

1817 """Check filenames to write gifti. 

1818 

1819 - add suffix .gii if missing 

1820 - make sure that there is only one hemi entity in the filename 

1821 

1822 Parameters 

1823 ---------- 

1824 filename : :obj:`str` or :obj:`pathlib.Path` 

1825 filename to check 

1826 

1827 Returns 

1828 ------- 

1829 :obj:`pathlib.Path` 

1830 """ 

1831 filename = Path(filename) 

1832 

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 ) 

1841 

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 

1849 

1850 

1851class SurfaceImage: 

1852 """Surface image containing meshes & data for both hemispheres. 

1853 

1854 .. versionadded:: 0.11.0 

1855 

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. 

1864 

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. 

1871 

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. 

1878 

1879 Attributes 

1880 ---------- 

1881 shape : (int, int) 

1882 shape of the surface data array 

1883 """ 

1884 

1885 def __init__(self, mesh, data): 

1886 """Create a SurfaceImage instance.""" 

1887 self.mesh = mesh if isinstance(mesh, PolyMesh) else PolyMesh(**mesh) 

1888 

1889 if not isinstance(data, (PolyData, dict)): 

1890 raise TypeError( 

1891 f"'data' must be one of[PolyData, dict].\nGot {type(data)}" 

1892 ) 

1893 

1894 if isinstance(data, PolyData): 

1895 self.data = data 

1896 elif isinstance(data, dict): 

1897 self.data = PolyData(**data) 

1898 

1899 _check_data_and_mesh_compat(self.mesh, self.data) 

1900 

1901 @property 

1902 def shape(self): 

1903 """Shape of the data.""" 

1904 return self.data.shape 

1905 

1906 def __repr__(self) -> str: 

1907 return f"<{self.__class__.__name__} {self.shape}>" 

1908 

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. 

1914 

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. 

1923 

1924 volume_img : Niimg-like object 

1925 3D or 4D volume image to project to the surface mesh. 

1926 

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`. 

1933 

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`. 

1937 

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 

1943 

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 = {}, {} 

1966 

1967 if isinstance(volume_img, (str, Path)): 

1968 volume_img = check_niimg(volume_img) 

1969 

1970 texture_left = vol_to_surf( 

1971 volume_img, mesh.parts["left"], **vol_to_surf_kwargs, **left_kwargs 

1972 ) 

1973 

1974 texture_right = vol_to_surf( 

1975 volume_img, 

1976 mesh.parts["right"], 

1977 **vol_to_surf_kwargs, 

1978 **right_kwargs, 

1979 ) 

1980 

1981 data = PolyData(left=texture_left, right=texture_right) 

1982 

1983 return cls(mesh=mesh, data=data) 

1984 

1985 

1986def check_surf_img(img: Union[SurfaceImage, Iterable[SurfaceImage]]) -> None: 

1987 """Validate SurfaceImage. 

1988 

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 

1995 

1996 if hasattr(img, "__iter__"): 

1997 for x in img: 

1998 check_surf_img(x) 

1999 

2000 

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. 

2004 

2005 Parameters 

2006 ---------- 

2007 img : :obj:`~surface.SurfaceImage` or :obj:`~surface.PolyData` 

2008 SurfaceImage whose data to concatenate and extract. 

2009 

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. 

2013 

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 ) 

2027 

2028 data = np.concatenate(list(data.parts.values()), axis=0) 

2029 

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 

2039 

2040 return data 

2041 

2042 

2043def extract_data(img, index): 

2044 """Extract data of a SurfaceImage a specified indices. 

2045 

2046 Parameters 

2047 ---------- 

2048 img : SurfaceImage object 

2049 

2050 index : Any type compatible with numpy array indexing 

2051 Used for indexing the 2D data array in the 2nd dimension. 

2052 

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 

2062 

2063 last_dim = 1 if isinstance(index, int) else len(index) 

2064 

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 }