Coverage for nilearn/surface/tests/test_surface.py: 0%
603 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# Tests for functions in surf_plotting.py
3import warnings
4from pathlib import Path
6import numpy as np
7import pytest
8from nibabel import Nifti1Image, freesurfer, gifti, load, nifti1
9from numpy.testing import assert_array_almost_equal, assert_array_equal
10from scipy.spatial import Delaunay
11from scipy.stats import pearsonr
12from sklearn.exceptions import EfficiencyWarning
14from nilearn import datasets, image
15from nilearn._utils import data_gen
16from nilearn.image import resampling
17from nilearn.surface.surface import (
18 FileMesh,
19 InMemoryMesh,
20 PolyData,
21 PolyMesh,
22 SurfaceImage,
23 _choose_kind,
24 _data_to_gifti,
25 _gifti_img_to_mesh,
26 _interpolation_sampling,
27 _load_surf_files_gifti_gzip,
28 _load_uniform_ball_cloud,
29 _masked_indices,
30 _mesh_to_gifti,
31 _nearest_voxel_sampling,
32 _projection_matrix,
33 _sample_locations,
34 _sample_locations_between_surfaces,
35 _uniform_ball_cloud,
36 _vertex_outer_normals,
37 check_mesh_and_data,
38 check_mesh_is_fsaverage,
39 check_surf_img,
40 extract_data,
41 get_data,
42 load_surf_data,
43 load_surf_mesh,
44 vol_to_surf,
45)
47datadir = Path(__file__).resolve().parent / "data"
50def flat_mesh(x_s: int, y_s: int, z=0) -> InMemoryMesh:
51 """Create a flat horizontal mesh."""
52 x, y = np.mgrid[:x_s, :y_s]
53 x, y = x.ravel(), y.ravel()
54 z = np.ones(len(x)) * z
55 vertices = np.asarray([x, y, z]).T
56 triangulation = Delaunay(vertices[:, :2]).simplices
57 return InMemoryMesh(coordinates=vertices, faces=triangulation)
60def z_const_img(x_s, y_s, z_s):
61 """Create an image that is constant in z direction."""
62 hslice = np.arange(x_s * y_s).reshape((x_s, y_s))
63 return np.ones((x_s, y_s, z_s)) * hslice[:, :, np.newaxis]
66def test_check_mesh():
67 mesh = check_mesh_is_fsaverage("fsaverage5")
68 assert mesh is check_mesh_is_fsaverage(mesh)
69 with pytest.raises(ValueError):
70 check_mesh_is_fsaverage("fsaverage2")
71 mesh.pop("pial_left")
72 with pytest.raises(ValueError):
73 check_mesh_is_fsaverage(mesh)
74 with pytest.raises(TypeError):
75 check_mesh_is_fsaverage(load_surf_mesh(mesh["pial_right"]))
78def test_check_mesh_and_data(rng, in_memory_mesh):
79 data = in_memory_mesh.coordinates[:, 0]
81 m, d = check_mesh_and_data(in_memory_mesh, data)
82 assert (m[0] == in_memory_mesh.coordinates).all()
83 assert (m[1] == in_memory_mesh.faces).all()
84 assert (d == data).all()
86 # Generate faces such that max index is larger than
87 # the length of coordinates array.
88 wrong_faces = rng.integers(in_memory_mesh.n_vertices + 1, size=(30, 3))
90 # Check that check_mesh_and_data raises an error
91 # with the resulting wrong mesh
92 with pytest.raises(
93 ValueError,
94 match="Mismatch between .* indices of faces .* number of nodes.",
95 ):
96 InMemoryMesh(in_memory_mesh.coordinates, wrong_faces)
98 # Alter the data and check that an error is raised
99 data = in_memory_mesh.coordinates[::2, 0]
100 with pytest.raises(
101 ValueError, match="Mismatch between number of nodes in mesh"
102 ):
103 check_mesh_and_data(in_memory_mesh, data)
106def test_load_surf_data_numpy_gt_1pt23():
107 """Test loading fsaverage surface.
109 Threw an error with numpy >=1.24.x
110 but only a deprecaton warning with numpy <1.24.x.
112 Regression test for
113 https://github.com/nilearn/nilearn/issues/3638
114 """
115 fsaverage = datasets.fetch_surf_fsaverage()
116 load_surf_data(fsaverage["pial_left"])
119def test_load_surf_data_array():
120 # test loading and squeezing data from numpy array
121 data_flat = np.zeros((20,))
122 data_squeeze = np.zeros((20, 1, 3))
123 assert_array_equal(load_surf_data(data_flat), np.zeros((20,)))
124 assert_array_equal(load_surf_data(data_squeeze), np.zeros((20, 3)))
127def test_load_surf_data_from_gifti_file(tmp_path):
128 filename_gii = tmp_path / "tmp.gii"
129 darray = gifti.GiftiDataArray(
130 data=np.zeros((20,)), datatype="NIFTI_TYPE_FLOAT32"
131 )
132 gii = gifti.GiftiImage(darrays=[darray])
133 gii.to_filename(filename_gii)
134 assert_array_equal(load_surf_data(filename_gii), np.zeros((20,)))
137def test_load_surf_data_from_empty_gifti_file(tmp_path):
138 filename_gii_empty = tmp_path / "tmp.gii"
139 gii_empty = gifti.GiftiImage()
140 gii_empty.to_filename(filename_gii_empty)
141 with pytest.raises(
142 ValueError, match="must contain at least one data array"
143 ):
144 load_surf_data(filename_gii_empty)
147def test_load_surf_data_from_nifti_file(tmp_path):
148 filename_nii = tmp_path / "tmp.nii"
149 filename_niigz = tmp_path / "tmp.nii.gz"
150 nii = Nifti1Image(np.zeros((20,)), affine=None)
151 nii.to_filename(filename_nii)
152 nii.to_filename(filename_niigz)
153 assert_array_equal(load_surf_data(filename_nii), np.zeros((20,)))
154 assert_array_equal(load_surf_data(filename_niigz), np.zeros((20,)))
157def test_load_surf_data_gii_gz():
158 # Test the loader `load_surf_data` with gzipped fsaverage5 files
160 # surface data
161 fsaverage = datasets.fetch_surf_fsaverage().sulc_left
162 gii = _load_surf_files_gifti_gzip(fsaverage)
163 assert isinstance(gii, gifti.GiftiImage)
165 data = load_surf_data(fsaverage)
166 assert isinstance(data, np.ndarray)
168 # surface mesh
169 fsaverage = datasets.fetch_surf_fsaverage().pial_left
170 gii = _load_surf_files_gifti_gzip(fsaverage)
171 assert isinstance(gii, gifti.GiftiImage)
174def test_load_surf_data_file_freesurfer(tmp_path):
175 # test loading of fake data from sulc and thickness files
176 # using load_surf_data.
177 # We test load_surf_data by creating fake data with function
178 # 'write_morph_data' that works only if nibabel
179 # version is recent with nibabel >= 2.1.0
180 filename_area = tmp_path / "tmp.area"
181 data = np.zeros((20,))
182 freesurfer.io.write_morph_data(filename_area, data)
183 assert_array_equal(load_surf_data(filename_area), np.zeros((20,)))
185 filename_curv = tmp_path / "tmp.curv"
186 freesurfer.io.write_morph_data(filename_curv, data)
187 assert_array_equal(load_surf_data(filename_curv), np.zeros((20,)))
189 filename_sulc = tmp_path / "tmp.sulc"
190 freesurfer.io.write_morph_data(filename_sulc, data)
191 assert_array_equal(load_surf_data(filename_sulc), np.zeros((20,)))
193 filename_thick = tmp_path / "tmp.thickness"
194 freesurfer.io.write_morph_data(filename_thick, data)
195 assert_array_equal(load_surf_data(filename_thick), np.zeros((20,)))
197 # test loading of data from real label and annot files
198 label_start = np.array([5900, 5899, 5901, 5902, 2638])
199 label_end = np.array([8756, 6241, 8757, 1896, 6243])
200 label = load_surf_data(datadir / "test.label")
201 assert_array_equal(label[:5], label_start)
202 assert_array_equal(label[-5:], label_end)
203 assert label.shape == (10,)
204 del label, label_start, label_end
206 annot_start = np.array([24, 29, 28, 27, 24, 31, 11, 25, 0, 12])
207 annot_end = np.array([16, 16, 16, 16, 16, 16, 16, 16, 16, 16])
208 annot = load_surf_data(datadir / "test.annot")
209 assert_array_equal(annot[:10], annot_start)
210 assert_array_equal(annot[-10:], annot_end)
211 assert annot.shape == (10242,)
212 del annot, annot_start, annot_end
215@pytest.mark.parametrize("suffix", [".vtk", ".obj", ".mnc", ".txt"])
216def test_load_surf_data_file_error(tmp_path, suffix):
217 # test if files with unexpected suffixes raise errors
218 data = np.zeros((20,))
219 filename_wrong = tmp_path / f"tmp{suffix}"
220 np.savetxt(filename_wrong, data)
221 with pytest.raises(ValueError, match="input type is not recognized"):
222 load_surf_data(filename_wrong)
225def test_load_surf_mesh_list(in_memory_mesh):
226 # test if correct list is returned
227 assert hasattr(load_surf_mesh(in_memory_mesh), "coordinates")
228 assert hasattr(load_surf_mesh(in_memory_mesh), "faces")
229 assert_array_equal(
230 load_surf_mesh(in_memory_mesh).coordinates, in_memory_mesh.coordinates
231 )
232 assert_array_equal(
233 load_surf_mesh(in_memory_mesh).faces, in_memory_mesh.faces
234 )
236 # test if incorrect list, array or dict raises error
237 with pytest.raises(ValueError, match="it must have two elements"):
238 load_surf_mesh([])
239 with pytest.raises(ValueError, match="it must have two elements"):
240 load_surf_mesh([in_memory_mesh.coordinates])
241 with pytest.raises(ValueError, match="it must have two elements"):
242 load_surf_mesh(
243 [
244 in_memory_mesh.coordinates,
245 in_memory_mesh.coordinates,
246 in_memory_mesh.faces,
247 ]
248 )
249 with pytest.raises(ValueError, match="input type is not recognized"):
250 load_surf_mesh(in_memory_mesh.coordinates)
251 with pytest.raises(ValueError, match="input type is not recognized"):
252 load_surf_mesh({})
255def test_gifti_img_to_mesh(in_memory_mesh):
256 coord_array = gifti.GiftiDataArray(
257 data=in_memory_mesh.coordinates, datatype="NIFTI_TYPE_FLOAT32"
258 )
259 coord_array.intent = nifti1.intent_codes["NIFTI_INTENT_POINTSET"]
261 face_array = gifti.GiftiDataArray(
262 data=in_memory_mesh.faces, datatype="NIFTI_TYPE_FLOAT32"
263 )
264 face_array.intent = nifti1.intent_codes["NIFTI_INTENT_TRIANGLE"]
266 gii = gifti.GiftiImage(darrays=[coord_array, face_array])
267 coords, faces = _gifti_img_to_mesh(gii)
268 assert_array_equal(coords, in_memory_mesh.coordinates)
269 assert_array_equal(faces, in_memory_mesh.faces)
272def test_load_surf_mesh_file_gii_gz():
273 # Test the loader `load_surf_mesh` with gzipped fsaverage5 files
274 fsaverage = datasets.fetch_surf_fsaverage().pial_left
275 mesh = load_surf_mesh(fsaverage)
276 coords = mesh.coordinates
277 faces = mesh.faces
278 assert isinstance(coords, np.ndarray)
279 assert isinstance(faces, np.ndarray)
282def test_load_surf_mesh_file_gii(tmp_path, in_memory_mesh):
283 # Test the loader `load_surf_mesh`
284 # test if correct gii is loaded into correct list
285 coord_array = gifti.GiftiDataArray(
286 data=in_memory_mesh.coordinates,
287 intent=nifti1.intent_codes["NIFTI_INTENT_POINTSET"],
288 datatype="NIFTI_TYPE_FLOAT32",
289 )
290 face_array = gifti.GiftiDataArray(
291 data=in_memory_mesh.faces,
292 intent=nifti1.intent_codes["NIFTI_INTENT_TRIANGLE"],
293 datatype="NIFTI_TYPE_FLOAT32",
294 )
296 gii = gifti.GiftiImage(darrays=[coord_array, face_array])
297 filename_gii_mesh = tmp_path / "tmp.gii"
298 gii.to_filename(filename_gii_mesh)
300 assert_array_almost_equal(
301 load_surf_mesh(filename_gii_mesh).coordinates,
302 in_memory_mesh.coordinates,
303 )
304 assert_array_almost_equal(
305 load_surf_mesh(filename_gii_mesh).faces, in_memory_mesh.faces
306 )
309def test_load_surf_mesh_file_gii_error(tmp_path, in_memory_mesh):
310 # test if incorrect gii raises error
311 coord_array = gifti.GiftiDataArray(
312 data=in_memory_mesh.coordinates,
313 intent=nifti1.intent_codes["NIFTI_INTENT_POINTSET"],
314 datatype="NIFTI_TYPE_FLOAT32",
315 )
316 face_array = gifti.GiftiDataArray(
317 data=in_memory_mesh.faces,
318 intent=nifti1.intent_codes["NIFTI_INTENT_TRIANGLE"],
319 datatype="NIFTI_TYPE_FLOAT32",
320 )
322 filename_gii_mesh_no_point = tmp_path / "tmp.gii"
323 gii = gifti.GiftiImage(darrays=[face_array, face_array])
324 gii.to_filename(filename_gii_mesh_no_point)
326 with pytest.raises(ValueError, match="NIFTI_INTENT_POINTSET"):
327 load_surf_mesh(filename_gii_mesh_no_point)
329 filename_gii_mesh_no_face = tmp_path / "tmp.gii"
330 gii = gifti.GiftiImage(darrays=[coord_array, coord_array])
331 gii.to_filename(filename_gii_mesh_no_face)
333 with pytest.raises(ValueError, match="NIFTI_INTENT_TRIANGLE"):
334 load_surf_mesh(filename_gii_mesh_no_face)
337@pytest.mark.parametrize(
338 "suffix", [".pial", ".inflated", ".white", ".orig", "sphere"]
339)
340def test_load_surf_mesh_file_freesurfer(suffix, tmp_path, in_memory_mesh):
341 filename_fs_mesh = tmp_path / f"tmp{suffix}"
342 freesurfer.write_geometry(
343 filename_fs_mesh, in_memory_mesh.coordinates, in_memory_mesh.faces
344 )
346 assert hasattr(load_surf_mesh(filename_fs_mesh), "coordinates")
347 assert hasattr(load_surf_mesh(filename_fs_mesh), "faces")
348 assert_array_almost_equal(
349 load_surf_mesh(filename_fs_mesh).coordinates,
350 in_memory_mesh.coordinates,
351 )
352 assert_array_almost_equal(
353 load_surf_mesh(filename_fs_mesh).faces, in_memory_mesh.faces
354 )
357@pytest.mark.parametrize("suffix", [".vtk", ".obj", ".mnc", ".txt"])
358def test_load_surf_mesh_file_error(suffix, tmp_path, in_memory_mesh):
359 # test if files with unexpected suffixes raise errors
360 filename_wrong = tmp_path / f"tmp{suffix}"
361 freesurfer.write_geometry(
362 filename_wrong, in_memory_mesh.coordinates, in_memory_mesh.faces
363 )
365 with pytest.raises(ValueError, match="input type is not recognized"):
366 load_surf_mesh(filename_wrong)
369def test_load_surf_mesh_file_glob(tmp_path, in_memory_mesh):
370 fname1 = tmp_path / "tmp1.pial"
371 freesurfer.write_geometry(
372 fname1, in_memory_mesh.coordinates, in_memory_mesh.faces
373 )
375 fname2 = tmp_path / "tmp2.pial"
376 freesurfer.write_geometry(
377 fname2, in_memory_mesh.coordinates, in_memory_mesh.faces
378 )
380 with pytest.raises(ValueError, match="More than one file matching path"):
381 load_surf_mesh(tmp_path / "*.pial")
382 with pytest.raises(ValueError, match="No files matching path"):
383 load_surf_mesh(tmp_path / "*.unlikelysuffix")
384 assert hasattr(load_surf_mesh(fname1), "coordinates")
385 assert hasattr(load_surf_mesh(fname1), "faces")
386 assert_array_almost_equal(
387 load_surf_mesh(fname1).coordinates, in_memory_mesh.coordinates
388 )
389 assert_array_almost_equal(
390 load_surf_mesh(fname1).faces, in_memory_mesh.faces
391 )
394def test_load_surf_data_file_glob(tmp_path):
395 data2D = np.ones((20, 3))
396 fnames = []
397 for f in range(3):
398 filename = tmp_path / f"glob_{f}_tmp.gii"
399 fnames.append(filename)
400 data2D[:, f] *= f
401 darray = gifti.GiftiDataArray(
402 data=data2D[:, f], datatype="NIFTI_TYPE_FLOAT32"
403 )
404 gii = gifti.GiftiImage(darrays=[darray])
405 gii.to_filename(fnames[f])
407 assert_array_equal(
408 load_surf_data(tmp_path / "glob*.gii"),
409 data2D,
410 )
412 # make one more gii file that has more than one dimension
413 filename = tmp_path / "glob_3_tmp.gii"
414 fnames.append(filename)
415 darray1 = gifti.GiftiDataArray(
416 data=np.ones((20,)), datatype="NIFTI_TYPE_FLOAT32"
417 )
418 gii = gifti.GiftiImage(darrays=[darray1, darray1, darray1])
419 gii.to_filename(fnames[-1])
421 data2D = np.concatenate((data2D, np.ones((20, 3))), axis=1)
422 assert_array_equal(
423 load_surf_data(tmp_path / "glob*.gii"),
424 data2D,
425 )
427 # make one more gii file that has a different shape in axis=0
428 filename = tmp_path / "glob_4_tmp.gii"
429 fnames.append(filename)
430 darray = gifti.GiftiDataArray(
431 data=np.ones((15, 1)), datatype="NIFTI_TYPE_FLOAT32"
432 )
433 gii = gifti.GiftiImage(darrays=[darray])
434 gii.to_filename(fnames[-1])
436 with pytest.raises(
437 ValueError, match="files must contain data with the same shape"
438 ):
439 load_surf_data(tmp_path / "*.gii")
442@pytest.mark.parametrize("xy", [(10, 7), (5, 5), (3, 2)])
443def test_flat_mesh(xy):
444 mesh = flat_mesh(xy[0], xy[1])
445 points = mesh.coordinates
446 triangles = mesh.faces
447 a, b, c = points[triangles[0]]
448 n = np.cross(b - a, c - a)
449 assert np.allclose(n, [0.0, 0.0, 1.0])
452def test_vertex_outer_normals():
453 # compute normals for a flat horizontal mesh, they should all be (0, 0, 1)
454 mesh = flat_mesh(5, 7)
455 computed_normals = _vertex_outer_normals(mesh)
456 true_normals = np.zeros((len(mesh.coordinates), 3))
457 true_normals[:, 2] = 1
458 assert_array_almost_equal(computed_normals, true_normals)
461def test_load_uniform_ball_cloud():
462 # Note: computed and shipped point clouds may differ since KMeans results
463 # change after
464 # https://github.com/scikit-learn/scikit-learn/pull/9288
465 # but the exact position of the points does not matter as long as they are
466 # well spread inside the unit ball
467 for n_points in [10, 20, 40, 80, 160]:
468 with warnings.catch_warnings(record=True) as w:
469 points = _load_uniform_ball_cloud(n_points=n_points)
470 assert_array_equal(points.shape, (n_points, 3))
471 assert len(w) == 0
472 with pytest.warns(EfficiencyWarning):
473 _load_uniform_ball_cloud(n_points=3)
474 for n_points in [3, 7]:
475 computed = _uniform_ball_cloud(n_points)
476 loaded = _load_uniform_ball_cloud(n_points)
477 assert_array_almost_equal(computed, loaded)
478 assert (np.std(computed, axis=0) > 0.1).all()
479 assert (np.linalg.norm(computed, axis=1) <= 1).all()
482def test_sample_locations():
483 # check positions of samples on toy example, with an affine != identity
484 # flat horizontal mesh
485 mesh = flat_mesh(5, 7)
486 affine = np.diagflat([10, 20, 30, 1])
487 inv_affine = np.linalg.inv(affine)
488 # transform vertices to world space
489 vertices = np.asarray(
490 resampling.coord_transform(*mesh.coordinates.T, affine=affine)
491 ).T
492 # compute by hand the true offsets in voxel space
493 # (transformed by affine^-1)
494 ball_offsets = _load_uniform_ball_cloud(10)
495 ball_offsets = np.asarray(
496 resampling.coord_transform(*ball_offsets.T, affine=inv_affine)
497 ).T
498 line_offsets = np.zeros((10, 3))
499 line_offsets[:, 2] = np.linspace(1, -1, 10)
500 line_offsets = np.asarray(
501 resampling.coord_transform(*line_offsets.T, affine=inv_affine)
502 ).T
503 # check we get the same locations
504 for kind, offsets in [("line", line_offsets), ("ball", ball_offsets)]:
505 locations = _sample_locations(
506 [vertices, mesh.faces], affine, 1.0, kind=kind, n_points=10
507 )
508 true_locations = np.asarray(
509 [vertex + offsets for vertex in mesh.coordinates]
510 )
511 assert_array_equal(locations.shape, true_locations.shape)
512 assert_array_almost_equal(true_locations, locations)
513 with pytest.raises(ValueError):
514 _sample_locations(mesh, affine, 1.0, kind="bad_kind")
517@pytest.mark.parametrize("depth", [(0.0,), (-1.0,), (1.0,), (-1.0, 0.0, 0.5)])
518@pytest.mark.parametrize("n_points", [None, 10])
519def test_sample_locations_depth(depth, n_points, affine_eye):
520 mesh = flat_mesh(5, 7)
521 radius = 8.0
522 locations = _sample_locations(
523 mesh, affine_eye, radius, n_points=n_points, depth=depth
524 )
525 offsets = np.asarray([[0.0, 0.0, -z * radius] for z in depth])
526 expected = np.asarray([vertex + offsets for vertex in mesh.coordinates])
527 assert np.allclose(locations, expected)
530@pytest.mark.parametrize(
531 "depth,n_points",
532 [
533 (None, 1),
534 (None, 7),
535 ([0.0], 8),
536 ([-1.0], 8),
537 ([1.0], 8),
538 ([-1.0, 0.0, 0.5], 8),
539 ],
540)
541def test_sample_locations_between_surfaces(depth, n_points, affine_eye):
542 inner = flat_mesh(5, 7)
543 outer = InMemoryMesh(
544 coordinates=inner.coordinates + np.asarray([0.0, 0.0, 1.0]),
545 faces=inner.faces,
546 )
547 locations = _sample_locations_between_surfaces(
548 outer, inner, affine_eye, n_points=n_points, depth=depth
549 )
551 if depth is None:
552 expected = np.asarray(
553 [
554 np.linspace(b, a, n_points)
555 for (a, b) in zip(
556 inner.coordinates.ravel(), outer.coordinates.ravel()
557 )
558 ]
559 )
560 expected = np.rollaxis(
561 expected.reshape((*outer.coordinates.shape, n_points)), 2, 1
562 )
564 else:
565 offsets = [[0.0, 0.0, -z] for z in depth]
566 expected = np.asarray(
567 [vertex + offsets for vertex in outer.coordinates]
568 )
570 assert np.allclose(locations, expected)
573def test_vol_to_surf_errors():
574 """Test errors thrown by vol_to_surf."""
575 img, *_ = data_gen.generate_mni_space_img()
576 mesh = load_surf_mesh(datasets.fetch_surf_fsaverage()["pial_left"])
578 with pytest.raises(ValueError, match=".*does not support.*"):
579 vol_to_surf(img, mesh, kind="ball", depth=[0.5])
581 with pytest.raises(ValueError, match=".*interpolation.*"):
582 vol_to_surf(img, mesh, interpolation="bad")
585@pytest.mark.parametrize("kind", ["line", "ball"])
586@pytest.mark.parametrize("n_scans", [1, 20])
587@pytest.mark.parametrize("use_mask", [True, False])
588def test_vol_to_surf(kind, n_scans, use_mask):
589 img, mask_img = data_gen.generate_mni_space_img(n_scans)
590 if not use_mask:
591 mask_img = None
592 if n_scans == 1:
593 img = image.new_img_like(img, image.get_data(img).squeeze())
595 fsaverage = datasets.fetch_surf_fsaverage()
597 mesh = load_surf_mesh(fsaverage["pial_left"])
598 inner_mesh = load_surf_mesh(fsaverage["white_left"])
599 center_mesh = (
600 np.mean([mesh.coordinates, inner_mesh.coordinates], axis=0),
601 mesh.faces,
602 )
604 proj = vol_to_surf(
605 img, mesh, kind="depth", inner_mesh=inner_mesh, mask_img=mask_img
606 )
607 other_proj = vol_to_surf(img, center_mesh, kind=kind, mask_img=mask_img)
609 correlation = pearsonr(proj.ravel(), other_proj.ravel())[0]
611 assert correlation > 0.99
614def test_vol_to_surf_nearest_most_frequent(img_labels):
615 """Test nearest most frequent interpolation method in vol_to_surf when
616 converting deterministic atlases with integer labels.
617 """
618 img_labels_data = img_labels.get_fdata()
619 uniques_vol = np.unique(img_labels_data)
621 mesh = flat_mesh(5, 7)
622 mesh_labels = vol_to_surf(
623 img_labels, mesh, interpolation="nearest_most_frequent"
624 )
626 uniques_surf = np.unique(mesh_labels)
627 assert set(uniques_surf) <= set(uniques_vol)
630def test_vol_to_surf_nearest_deprecation(img_labels):
631 """Test deprecation warning for nearest interpolation method in
632 vol_to_surf.
633 """
634 mesh = flat_mesh(5, 7)
635 with pytest.warns(
636 FutureWarning, match="interpolation method will be deprecated"
637 ):
638 vol_to_surf(img_labels, mesh, interpolation="nearest")
641def test_masked_indices():
642 mask = np.ones((4, 3, 8))
643 mask[:, :, ::2] = 0
644 locations = np.mgrid[:5, :3, :8].ravel().reshape((3, -1))
645 masked = _masked_indices(locations.T, mask.shape, mask)
646 # These elements are masked by the mask
647 assert (masked[::2] == 1).all()
648 # The last element of locations is one row beyond first image dimension
649 assert (masked[-24:] == 1).all()
650 # 4 * 3 * 8 / 2 elements should remain unmasked
651 assert (1 - masked).sum() == 48
654def test_projection_matrix(affine_eye):
655 mesh = flat_mesh(5, 7, 4)
656 img = z_const_img(5, 7, 13)
657 proj = _projection_matrix(
658 mesh, affine_eye, img.shape, radius=2.0, n_points=10
659 )
660 # proj matrix has shape (n_vertices, img_size)
661 assert proj.shape == (5 * 7, 5 * 7 * 13)
662 # proj.dot(img) should give the values of img at the vertices' locations
663 values = proj.dot(img.ravel()).reshape((5, 7))
664 assert_array_almost_equal(values, img[:, :, 0])
665 mesh = flat_mesh(5, 7)
666 proj = _projection_matrix(
667 mesh, affine_eye, (5, 7, 1), radius=0.1, n_points=10
668 )
669 assert_array_almost_equal(proj.toarray(), np.eye(proj.shape[0]))
670 mask = np.ones(img.shape, dtype=int)
671 mask[0] = 0
672 proj = _projection_matrix(
673 mesh, affine_eye, img.shape, radius=2.0, n_points=10, mask=mask
674 )
675 proj = proj.toarray()
676 # first row of the mesh is masked
677 assert_array_almost_equal(proj.sum(axis=1)[:7], np.zeros(7))
678 assert_array_almost_equal(proj.sum(axis=1)[7:], np.ones(proj.shape[0] - 7))
679 # mask and img should have the same shape
680 with pytest.raises(ValueError):
681 _projection_matrix(
682 mesh, affine_eye, img.shape, mask=np.ones((3, 3, 2))
683 )
686def test_sampling_affine(affine_eye, rng):
687 # check sampled (projected) values on a toy image
688 img = np.ones((4, 4, 4))
689 img[1, :, :] = 2
691 coords = np.asarray([[1, 1, 2], [10, 10, 20], [30, 30, 30]])
692 mesh = [coords, rng.integers(coords.shape[0], size=(30, 3))]
694 affine = 10 * affine_eye
695 affine[-1, -1] = 1
696 texture = _nearest_voxel_sampling(
697 [img], mesh, affine=affine, radius=1, kind="ball"
698 )
699 assert_array_almost_equal(texture[0], [1.0, 2.0, 1.0], decimal=15)
700 texture = _interpolation_sampling(
701 [img], mesh, affine=affine, radius=0, kind="ball"
702 )
703 assert_array_almost_equal(texture[0], [1.1, 2.0, 1.0], decimal=15)
706@pytest.mark.parametrize("kind", ["auto", "line", "ball"])
707@pytest.mark.parametrize("use_inner_mesh", [True, False])
708@pytest.mark.parametrize("projection", ["linear", "nearest"])
709def test_sampling(kind, use_inner_mesh, projection, affine_eye):
710 mesh = flat_mesh(5, 7, 4)
711 img = z_const_img(5, 7, 13)
712 mask = np.ones(img.shape, dtype=int)
713 mask[0] = 0
714 projector = {
715 "nearest": _nearest_voxel_sampling,
716 "linear": _interpolation_sampling,
717 }[projection]
718 inner_mesh = mesh if use_inner_mesh else None
719 projection = projector(
720 [img], mesh, affine_eye, kind=kind, radius=0.0, inner_mesh=inner_mesh
721 )
722 assert_array_almost_equal(projection.ravel(), img[:, :, 0].ravel())
723 projection = projector(
724 [img],
725 mesh,
726 affine_eye,
727 kind=kind,
728 radius=0.0,
729 mask=mask,
730 inner_mesh=inner_mesh,
731 )
732 assert_array_almost_equal(projection.ravel()[7:], img[1:, :, 0].ravel())
733 assert np.isnan(projection.ravel()[:7]).all()
736@pytest.mark.parametrize("projection", ["linear", "nearest"])
737def test_sampling_between_surfaces(projection, affine_eye):
738 projector = {
739 "nearest": _nearest_voxel_sampling,
740 "linear": _interpolation_sampling,
741 }[projection]
742 mesh = flat_mesh(13, 7, 3.0)
743 inner_mesh = flat_mesh(13, 7, 1)
744 img = z_const_img(5, 7, 13).T
745 projection = projector(
746 [img],
747 mesh,
748 affine_eye,
749 kind="auto",
750 n_points=100,
751 inner_mesh=inner_mesh,
752 )
753 assert_array_almost_equal(
754 projection.ravel(), img[:, :, 1:4].mean(axis=-1).ravel()
755 )
758def test_choose_kind():
759 kind = _choose_kind("abc", None)
760 assert kind == "abc"
761 kind = _choose_kind("abc", "mesh")
762 assert kind == "abc"
763 kind = _choose_kind("auto", None)
764 assert kind == "line"
765 kind = _choose_kind("auto", "mesh")
766 assert kind == "depth"
767 with pytest.raises(TypeError, match=".*sampling strategy"):
768 kind = _choose_kind("depth", None)
771def test_validate_mesh(rng):
772 """Ensures that invalid meshes cannot be instantiated."""
773 # valid mesh is fine
774 coords = rng.random((20, 3))
775 faces = rng.integers(coords.shape[0], size=(30, 3))
776 InMemoryMesh(coordinates=coords, faces=faces)
778 # Face is None
779 coords = rng.random((20, 3))
780 coords[0] = np.array([np.nan, np.nan, np.nan])
781 faces = None
783 with pytest.raises(TypeError, match="must be numpy arrays."):
784 InMemoryMesh(coordinates=coords, faces=faces)
786 # coordinates is None
787 faces = rng.integers(20, size=(30, 3))
788 coords = None
790 with pytest.raises(TypeError, match="must be numpy arrays."):
791 InMemoryMesh(coordinates=coords, faces=faces)
793 # coordinates with non finite values
794 coords = rng.random((20, 3))
795 coords[0] = np.array([np.nan, np.nan, np.nan])
796 faces = rng.integers(coords.shape[0], size=(30, 3))
798 with pytest.raises(ValueError, match="Mesh coordinates must be finite."):
799 InMemoryMesh(coordinates=coords, faces=faces)
801 # faces with indices that do not correspond to any coordinate
802 coords = rng.random((20, 3))
804 # values too high
805 faces = rng.integers(low=30, high=50, size=(30, 3))
807 with pytest.raises(
808 ValueError,
809 match="Mismatch between the indices of faces and the number of nodes.",
810 ):
811 InMemoryMesh(coordinates=coords, faces=faces)
813 # negative values
814 faces = rng.integers(low=-50, high=-30, size=(30, 3))
816 with pytest.raises(
817 ValueError,
818 match="Mismatch between the indices of faces and the number of nodes.",
819 ):
820 InMemoryMesh(coordinates=coords, faces=faces)
823@pytest.mark.parametrize(
824 "dtype",
825 [
826 np.uint16,
827 np.uint32,
828 np.uint64,
829 np.int8,
830 np.int16,
831 np.int32,
832 np.int64,
833 np.float32,
834 np.float64,
835 ],
836)
837def test_data_to_gifti(rng, tmp_path, dtype):
838 """Check saving several data type to gifti.
840 - check that strings and Path work
841 - make sure files can be loaded with nibabel
842 """
843 data = rng.random((5, 6)).astype(dtype)
844 _data_to_gifti(data=data, gifti_file=tmp_path / "data.gii")
845 _data_to_gifti(data=data, gifti_file=str(tmp_path / "data.gii"))
846 load(tmp_path / "data.gii")
849@pytest.mark.parametrize("dtype", [np.complex64, np.complex128])
850def test_data_to_gifti_unsupported_dtype(rng, tmp_path, dtype):
851 """Check saving unsupported data type raises an error."""
852 data = rng.random((5, 6)).astype(dtype)
853 with pytest.raises(ValueError, match="supports uint8, int32 and float32"):
854 _data_to_gifti(data=data, gifti_file=tmp_path / "data.gii")
857@pytest.mark.parametrize("shape", [(5,), (5, 1), (5, 2)])
858def test_polydata_shape(shape):
859 data = PolyData(left=np.ones(shape), right=np.ones(shape))
861 assert len(data.shape) == len(shape)
862 assert data.shape[0] == shape[0] * 2
864 data = PolyData(left=np.ones(shape))
866 assert len(data.shape) == len(shape)
867 assert data.shape[0] == shape[0]
870def test_polydata_1d_check_parts():
871 """Smoke test for check parts.
873 - passing a 1D array at instantiation is fine:
874 they are convertd to 2D
875 - _check_parts can be used make sure parts are fine
876 if assigned after instantiation.
877 """
878 data = PolyData(left=np.ones((7,)), right=np.ones((5,)))
880 data.parts["left"] = np.ones((1,))
882 data._check_parts()
885def test_mesh_to_gifti(single_mesh, tmp_path):
886 """Check saving mesh to gifti.
888 - check that strings and Path work
889 - make sure files can be loaded with nibabel
890 """
891 coordinates, faces = single_mesh
892 _mesh_to_gifti(
893 coordinates=coordinates, faces=faces, gifti_file=tmp_path / "mesh.gii"
894 )
895 _mesh_to_gifti(
896 coordinates=coordinates,
897 faces=faces,
898 gifti_file=str(tmp_path / "mesh.gii"),
899 )
900 load(tmp_path / "mesh.gii")
903def test_compare_file_and_inmemory_mesh(surf_mesh, tmp_path):
904 left = surf_mesh.parts["left"]
905 gifti_file = tmp_path / "left.gii"
906 left.to_gifti(gifti_file)
908 left_read = FileMesh(gifti_file)
909 left_read.__repr__() # for coverage
910 assert left.n_vertices == left_read.n_vertices
911 assert np.array_equal(left.coordinates, left_read.coordinates)
912 assert np.array_equal(left.faces, left_read.faces)
914 left_loaded = left_read.loaded()
915 assert isinstance(left_loaded, InMemoryMesh)
916 assert left.n_vertices == left_loaded.n_vertices
917 assert np.array_equal(left.coordinates, left_loaded.coordinates)
918 assert np.array_equal(left.faces, left_loaded.faces)
921@pytest.mark.parametrize("shape", [1, 3])
922def test_surface_image_shape(surf_img_2d, shape):
923 assert surf_img_2d(shape).shape == (9, shape)
926def test_data_shape_not_matching_mesh(surf_img_1d, flip_surf_img_parts):
927 with pytest.raises(ValueError, match="shape.*vertices"):
928 SurfaceImage(surf_img_1d.mesh, flip_surf_img_parts(surf_img_1d.data))
931def test_data_shape_inconsistent(surf_img_2d):
932 bad_data = {
933 "left": surf_img_2d(7).data.parts["left"],
934 "right": surf_img_2d(7).data.parts["right"][0][:4],
935 }
936 with pytest.raises(ValueError, match="incompatible shapes"):
937 SurfaceImage(surf_img_2d(7).mesh, bad_data)
940def test_data_keys_not_matching_mesh(surf_img_1d):
941 with pytest.raises(ValueError, match="same keys"):
942 SurfaceImage(
943 {"left": surf_img_1d.mesh.parts["left"]},
944 surf_img_1d.data,
945 )
948@pytest.mark.parametrize("use_path", [True, False])
949@pytest.mark.parametrize(
950 "output_filename, expected_files, unexpected_files",
951 [
952 ("foo.gii", ["foo_hemi-L.gii", "foo_hemi-L.gii"], ["foo.gii"]),
953 ("foo_hemi-L_T1w.gii", ["foo_hemi-L_T1w.gii"], ["foo_hemi-R_T1w.gii"]),
954 ("foo_hemi-R_T1w.gii", ["foo_hemi-R_T1w.gii"], ["foo_hemi-L_T1w.gii"]),
955 ],
956)
957def test_load_save_mesh(
958 tmp_path, output_filename, expected_files, unexpected_files, use_path
959):
960 """Load fsaverage5 from filename or Path and save.
962 Check that
963 - the appropriate hemisphere information is added to the filename
964 - only one hemisphere is saved if hemi- is in the filename
965 - the roundtrip does not change the data
966 """
967 mesh_right = datasets.fetch_surf_fsaverage().pial_right
968 mesh_left = datasets.fetch_surf_fsaverage().pial_left
969 data_right = datasets.fetch_surf_fsaverage().sulc_right
970 data_left = datasets.fetch_surf_fsaverage().sulc_left
972 if use_path:
973 img = SurfaceImage(
974 mesh={"left": Path(mesh_left), "right": Path(mesh_right)},
975 data={"left": Path(data_left), "right": Path(data_right)},
976 )
977 else:
978 img = SurfaceImage(
979 mesh={"left": mesh_left, "right": mesh_right},
980 data={"left": data_left, "right": data_right},
981 )
983 if use_path:
984 img.mesh.to_filename(tmp_path / output_filename)
985 else:
986 img.mesh.to_filename(str(tmp_path / output_filename))
988 for file in unexpected_files:
989 assert not (tmp_path / file).exists()
991 for file in expected_files:
992 assert (tmp_path / file).exists()
994 mesh = load_surf_mesh(tmp_path / file)
995 if "hemi-L" in file:
996 expected_mesh = load_surf_mesh(mesh_left)
997 elif "hemi-R" in file:
998 expected_mesh = load_surf_mesh(mesh_right)
999 assert np.array_equal(mesh.faces, expected_mesh.faces)
1000 assert np.array_equal(mesh.coordinates, expected_mesh.coordinates)
1003def test_save_mesh_default_suffix(tmp_path, surf_img_1d):
1004 """Check default .gii extension is added."""
1005 surf_img_1d.mesh.to_filename(
1006 tmp_path / "give_me_a_default_suffix_hemi-L_mesh"
1007 )
1008 assert (tmp_path / "give_me_a_default_suffix_hemi-L_mesh.gii").exists()
1011def test_save_mesh_error(tmp_path, surf_img_1d):
1012 with pytest.raises(ValueError, match="cannot contain both"):
1013 surf_img_1d.mesh.to_filename(
1014 tmp_path / "hemi-L_hemi-R_cannot_have_both.gii"
1015 )
1018def test_save_mesh_error_wrong_suffix(tmp_path, surf_img_1d):
1019 with pytest.raises(ValueError, match="with the extension '.gii'"):
1020 surf_img_1d.mesh.to_filename(
1021 tmp_path / "hemi-L_hemi-R_cannot_have_both.foo"
1022 )
1025@pytest.mark.parametrize("use_path", [True, False])
1026@pytest.mark.parametrize(
1027 "output_filename, expected_files, unexpected_files",
1028 [
1029 ("foo.gii", ["foo_hemi-L.gii", "foo_hemi-L.gii"], ["foo.gii"]),
1030 ("foo_hemi-L_T1w.gii", ["foo_hemi-L_T1w.gii"], ["foo_hemi-R_T1w.gii"]),
1031 ("foo_hemi-R_T1w.gii", ["foo_hemi-R_T1w.gii"], ["foo_hemi-L_T1w.gii"]),
1032 ],
1033)
1034def test_load_save_data(
1035 tmp_path, output_filename, expected_files, unexpected_files, use_path
1036):
1037 """Load and save gifti leaves them unchanged."""
1038 mesh_right = datasets.fetch_surf_fsaverage().pial_right
1039 mesh_left = datasets.fetch_surf_fsaverage().pial_left
1040 data_right = datasets.fetch_surf_fsaverage().sulc_right
1041 data_left = datasets.fetch_surf_fsaverage().sulc_left
1043 if use_path:
1044 img = SurfaceImage(
1045 mesh={"left": Path(mesh_left), "right": Path(mesh_right)},
1046 data={"left": Path(data_left), "right": Path(data_right)},
1047 )
1048 else:
1049 img = SurfaceImage(
1050 mesh={"left": mesh_left, "right": mesh_right},
1051 data={"left": data_left, "right": data_right},
1052 )
1054 if use_path:
1055 img.data.to_filename(tmp_path / output_filename)
1056 else:
1057 img.data.to_filename(str(tmp_path / output_filename))
1059 for file in unexpected_files:
1060 assert not (tmp_path / file).exists()
1062 for file in expected_files:
1063 assert (tmp_path / file).exists()
1065 data = load_surf_data(tmp_path / file)
1066 if "hemi-L" in file:
1067 expected_data = load_surf_data(data_left)
1068 elif "hemi-R" in file:
1069 expected_data = load_surf_data(data_right)
1070 assert np.array_equal(data, expected_data)
1073def test_load_save_data_1d(rng, tmp_path, surf_mesh):
1074 """Load and save 1D gifti leaves them unchanged."""
1075 data = {}
1076 for hemi in ["left", "right"]:
1077 size = (surf_mesh.parts[hemi].n_vertices,)
1078 data[hemi] = rng.random(size=size).astype(np.uint8)
1079 darray = gifti.GiftiDataArray(
1080 data=data[hemi], datatype="NIFTI_TYPE_UINT8"
1081 )
1082 gii = gifti.GiftiImage(darrays=[darray])
1083 gii.to_filename(tmp_path / f"original_{hemi}.gii")
1085 img = SurfaceImage(
1086 mesh=surf_mesh,
1087 data={
1088 "left": tmp_path / "original_left.gii",
1089 "right": tmp_path / "original_right.gii",
1090 },
1091 )
1093 img.data.to_filename(tmp_path / "nilearn.gii")
1095 for hemi in ["left", "right"]:
1096 original_surf_img = load(tmp_path / f"original_{hemi}.gii")
1097 nilearn_surf_img = load(
1098 tmp_path / f"nilearn_hemi-{hemi[0].upper()}.gii"
1099 )
1100 assert_array_equal(
1101 original_surf_img.agg_data(), nilearn_surf_img.agg_data()
1102 )
1105@pytest.mark.parametrize(
1106 "dtype",
1107 [
1108 np.uint16,
1109 np.uint32,
1110 np.uint64,
1111 np.int8,
1112 np.int16,
1113 np.int32,
1114 np.int64,
1115 np.float32,
1116 np.float64,
1117 ],
1118)
1119def test_save_dtype(surf_img_1d, tmp_path, dtype):
1120 """Check saving several data type."""
1121 surf_img_1d.data.parts["right"] = surf_img_1d.data.parts["right"].astype(
1122 dtype
1123 )
1124 surf_img_1d.data.to_filename(tmp_path / "data.gii")
1127def test_load_from_volume_3d_nifti(img_3d_mni, surf_mesh, tmp_path):
1128 """Instantiate surface image with 3D Niftiimage object or file for data."""
1129 SurfaceImage.from_volume(mesh=surf_mesh, volume_img=img_3d_mni)
1131 img_3d_mni.to_filename(tmp_path / "tmp.nii.gz")
1133 SurfaceImage.from_volume(
1134 mesh=surf_mesh,
1135 volume_img=tmp_path / "tmp.nii.gz",
1136 )
1139def test_load_from_volume_4d_nifti(img_4d_mni, surf_mesh, tmp_path):
1140 """Instantiate surface image with 4D Niftiimage object or file for data."""
1141 img = SurfaceImage.from_volume(mesh=surf_mesh, volume_img=img_4d_mni)
1142 # check that we have the correct number of time points
1143 assert img.shape[1] == img_4d_mni.shape[3]
1145 img_4d_mni.to_filename(tmp_path / "tmp.nii.gz")
1147 SurfaceImage.from_volume(
1148 mesh=surf_mesh,
1149 volume_img=tmp_path / "tmp.nii.gz",
1150 )
1153def test_surface_image_error():
1154 """Instantiate surface image with Niftiimage object or file for data."""
1155 mesh_right = datasets.fetch_surf_fsaverage().pial_right
1156 mesh_left = datasets.fetch_surf_fsaverage().pial_left
1158 with pytest.raises(TypeError, match="[PolyData, dict]"):
1159 SurfaceImage(mesh={"left": mesh_left, "right": mesh_right}, data=3)
1162def test_polydata_error():
1163 with pytest.raises(ValueError, match="Either left or right"):
1164 PolyData(left=None, right=None)
1167def test_polymesh_error():
1168 with pytest.raises(ValueError, match="Either left or right"):
1169 PolyMesh(left=None, right=None)
1172def test_inmemorymesh_index_error(in_memory_mesh):
1173 with pytest.raises(
1174 IndexError, match="Use 0 for coordinates and 1 for faces"
1175 ):
1176 in_memory_mesh[2]
1179def test_get_min_max(surf_img_2d):
1180 """Make sure we get the min and max across hemispheres."""
1181 img = surf_img_2d()
1182 img.data.parts["left"][:, 0] = np.zeros(shape=(4))
1183 img.data.parts["left"][0][0] = 10
1184 img.data.parts["right"][:, 0] = np.zeros(shape=(5))
1185 img.data.parts["right"][0][0] = -3.5
1187 vmin, vmax = img.data._get_min_max()
1189 assert vmin == -3.5
1190 assert vmax == 10
1193def test_extract_data_wrong_input():
1194 """Check that only SurfaceImage is accepted as input."""
1195 with pytest.raises(TypeError, match="Input must a be SurfaceImage"):
1196 extract_data(1, index=1)
1199def test_get_data(surf_img_1d):
1200 """Check that getting data from image or polydata gives same result."""
1201 data_from_image = get_data(surf_img_1d)
1202 data_from_polydata = get_data(surf_img_1d.data)
1203 assert_array_equal(data_from_image, data_from_polydata)
1206@pytest.mark.parametrize("ensure_finite", [True, False])
1207def test_get_data_ensure_finite(surf_img_1d, ensure_finite):
1208 """Check get data can deal with non finite values."""
1209 surf_img_1d.data.parts["left"][1] = np.nan
1210 surf_img_1d.data.parts["left"][2] = np.inf
1212 if ensure_finite is True:
1213 with pytest.warns(UserWarning, match="Non-finite values detected."):
1214 data_from_image = get_data(
1215 surf_img_1d, ensure_finite=ensure_finite
1216 )
1217 assert np.all(np.isfinite(data_from_image))
1218 else:
1219 data_from_image = get_data(surf_img_1d, ensure_finite=ensure_finite)
1220 assert np.logical_not(np.all(np.isfinite(data_from_image)))
1223def test_check_surf_img(surf_img_1d, surf_img_2d):
1224 """Check that surface image are properly validated."""
1225 check_surf_img(surf_img_1d)
1226 check_surf_img(surf_img_2d())
1228 data = {
1229 part: np.empty(0).reshape((surf_img_1d.data.parts[part].shape[0], 0))
1230 for part in surf_img_1d.data.parts
1231 }
1232 imgs = SurfaceImage(surf_img_1d.mesh, data)
1233 with pytest.raises(ValueError, match="empty"):
1234 check_surf_img(imgs)