Coverage for nilearn/decomposition/tests/conftest.py: 0%
140 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"""Fixtures for decomposition tests."""
3from typing import Union
5import numpy as np
6import pytest
7from nibabel import Nifti1Image
9from nilearn.maskers import MultiNiftiMasker, SurfaceMasker
10from nilearn.surface import PolyMesh, SurfaceImage
11from nilearn.surface.tests.test_surface import flat_mesh
13SHAPE_SURF = {"left": (15, 5), "right": (10, 4)}
14RANDOM_STATE = 42
15N_SUBJECTS = 3
16# TODO
17# some fixtures or tests start breaking if some of those values below
18# are changed
19N_SAMPLES = 5
20N_COMPONENTS = 4
23def _decomposition_mesh() -> PolyMesh:
24 """Return a mesh to use for decomposition tests."""
25 return PolyMesh(
26 left=flat_mesh(*SHAPE_SURF["left"]),
27 right=flat_mesh(*SHAPE_SURF["right"]),
28 )
31@pytest.fixture
32def decomposition_mesh() -> PolyMesh:
33 """Return a mesh to use for decomposition tests."""
34 return _decomposition_mesh()
37@pytest.fixture
38def decomposition_mask_img(
39 data_type: str,
40 decomposition_mesh: PolyMesh,
41 affine_eye: np.ndarray,
42 shape_3d_large,
43) -> Union[SurfaceImage, Nifti1Image]:
44 """Return a mask for decomposition."""
45 if data_type == "surface":
46 mask_data = {
47 "left": np.ones(
48 (decomposition_mesh.parts["left"].coordinates.shape[0],)
49 ),
50 "right": np.ones(
51 (decomposition_mesh.parts["right"].coordinates.shape[0],)
52 ),
53 }
54 return SurfaceImage(mesh=decomposition_mesh, data=mask_data)
56 # TODO
57 # setting the shape of the mask to be a bit different
58 # shape_3d_large that is used for the data
59 # to force resampling
60 # shape = (
61 # shape_3d_large[0] - 1,
62 # shape_3d_large[1] - 1,
63 # shape_3d_large[2] - 1,
64 # )
65 shape = shape_3d_large
66 mask = np.ones(shape, dtype=np.int8)
67 mask[:5] = 0
68 mask[-5:] = 0
69 mask[:, :5] = 0
70 mask[:, -5:] = 0
71 mask[..., -2:] = 0
72 mask[..., :2] = 0
73 return Nifti1Image(mask, affine_eye)
76@pytest.fixture
77def decomposition_masker(
78 decomposition_mask_img: Union[SurfaceImage, Nifti1Image],
79 img_3d_ones_eye: Nifti1Image,
80 data_type: str,
81) -> Union[SurfaceMasker, MultiNiftiMasker]:
82 """Return the proper masker for test with volume of surface."""
83 if data_type == "surface":
84 return SurfaceMasker(mask_img=decomposition_mask_img).fit()
85 return MultiNiftiMasker(mask_img=img_3d_ones_eye).fit()
88def _decomposition_images_surface(
89 rng, decomposition_mesh, with_activation
90) -> list[SurfaceImage]:
91 surf_imgs = []
92 for _ in range(N_SUBJECTS):
93 data = {
94 "left": rng.standard_normal(
95 size=(
96 decomposition_mesh.parts["left"].coordinates.shape[0],
97 N_SAMPLES,
98 )
99 ),
100 "right": rng.standard_normal(
101 size=(
102 decomposition_mesh.parts["right"].coordinates.shape[0],
103 N_SAMPLES,
104 )
105 ),
106 }
107 if with_activation:
108 data["left"][2:4, :] += 10
109 data["right"][2:4, :] += 10
110 surf_imgs.append(SurfaceImage(mesh=decomposition_mesh, data=data))
112 return surf_imgs
115def _decomposition_img(
116 data_type,
117 rng,
118 mesh,
119 shape,
120 affine,
121 with_activation: bool = True,
122) -> Union[SurfaceImage, Nifti1Image]:
123 """Return a single image for decomposition."""
124 if data_type == "surface":
125 data = {
126 "left": rng.standard_normal(
127 size=(
128 mesh.parts["left"].coordinates.shape[0],
129 N_SAMPLES,
130 )
131 ),
132 "right": rng.standard_normal(
133 size=(
134 mesh.parts["right"].coordinates.shape[0],
135 N_SAMPLES,
136 )
137 ),
138 }
139 if with_activation:
140 data["left"][2:4, :] += 10
141 data["right"][2:4, :] += 10
143 return SurfaceImage(mesh=mesh, data=data)
145 shape = (*shape, N_SAMPLES)
146 this_img = rng.normal(size=shape)
147 if with_activation:
148 this_img[2:4, 2:4, 2:4, :] += 10
150 return Nifti1Image(this_img, affine)
153@pytest.fixture
154def decomposition_images(
155 data_type,
156 rng,
157 decomposition_mesh,
158 shape_3d_large,
159 affine_eye,
160 with_activation=True,
161):
162 """Create "multi-subject" dataset with fake activation."""
163 return [
164 _decomposition_img(
165 data_type,
166 rng,
167 decomposition_mesh,
168 shape_3d_large,
169 affine_eye,
170 with_activation,
171 )
172 for _ in range(N_SUBJECTS)
173 ]
176@pytest.fixture
177def decomposition_img(
178 data_type,
179 rng,
180 decomposition_mesh,
181 shape_3d_large,
182 affine_eye,
183 with_activation: bool = True,
184) -> Union[SurfaceImage, Nifti1Image]:
185 """Return a single image for decomposition."""
186 return _decomposition_img(
187 data_type,
188 rng,
189 decomposition_mesh,
190 shape_3d_large,
191 affine_eye,
192 with_activation,
193 )
196@pytest.fixture
197def canica_data(
198 rng,
199 _make_canica_components: np.ndarray,
200 shape_3d_large,
201 affine_eye,
202 decomposition_mesh,
203 data_type: str,
204 n_subjects=N_SUBJECTS,
205) -> Union[list[Nifti1Image], list[SurfaceImage]]:
206 """Create a "multi-subject" dataset."""
207 if data_type == "nifti":
208 return _make_volume_data_from_components(
209 _make_canica_components,
210 affine_eye,
211 shape_3d_large,
212 rng,
213 n_subjects,
214 )
216 else:
217 # TODO for now we generate random data
218 # rather than data based on actual components.
219 return _decomposition_images_surface(
220 rng, decomposition_mesh, with_activation=True
221 )
224@pytest.fixture
225def _make_canica_components(
226 decomposition_mesh, shape_3d_large, data_type
227) -> np.ndarray:
228 """Create 4 components.
230 3D images unraveled for volume, 2D for surface
231 """
232 if data_type == "nifti":
233 return _canica_components_volume(shape_3d_large)
235 else:
236 shape = (decomposition_mesh.n_vertices, 1)
238 component1 = np.zeros(shape)
239 component1[:5] = 1
240 component1[5:10] = -1
242 component2 = np.zeros(shape)
243 component2[:5] = 1
244 component2[5:10] = -1
246 component3 = np.zeros(shape)
247 component3[-5:] = 1
248 component3[-10:-5] = -1
250 component4 = np.zeros(shape)
251 component4[-5:] = 1
252 component4[-10:-5] = -1
254 return np.vstack(
255 (
256 component1.ravel(),
257 component2.ravel(),
258 component3.ravel(),
259 component4.ravel(),
260 )
261 )
264def _canica_components_volume(shape):
265 """Create 4 volume components."""
266 component1 = np.zeros(shape)
267 component1[:5, :10] = 1
268 component1[5:10, :10] = -1
270 component2 = np.zeros(shape)
271 component2[:5, -10:] = 1
272 component2[5:10, -10:] = -1
274 component3 = np.zeros(shape)
275 component3[-5:, -10:] = 1
276 component3[-10:-5, -10:] = -1
278 component4 = np.zeros(shape)
279 component4[-5:, :10] = 1
280 component4[-10:-5, :10] = -1
282 return np.vstack(
283 (
284 component1.ravel(),
285 component2.ravel(),
286 component3.ravel(),
287 component4.ravel(),
288 )
289 )
292def _make_volume_data_from_components(
293 components,
294 affine,
295 shape,
296 rng,
297 n_subjects,
298):
299 """Create a "multi-subject" dataset of volume data."""
300 background = -0.01 * rng.normal(size=shape) - 2
301 background = background[..., np.newaxis]
303 data = []
305 # TODO
306 # changing this value leads makes tests overall faster but makes
307 # test_canica_square_img to fail
308 magic_number = 40
310 for _ in range(n_subjects):
311 this_data = np.dot(
312 rng.normal(size=(magic_number, N_COMPONENTS)), components
313 )
314 this_data += 0.01 * rng.normal(size=this_data.shape)
316 # Get back into 3D for CanICA
317 this_data = np.reshape(this_data, (magic_number, *shape))
318 this_data = np.rollaxis(this_data, 0, N_COMPONENTS)
320 # Put the border of the image to zero, to mimic a brain image
321 this_data[:5] = background[:5]
322 this_data[-5:] = background[-5:]
323 this_data[:, :5] = background[:, :5]
324 this_data[:, -5:] = background[:, -5:]
326 data.append(Nifti1Image(this_data, affine))
328 return data
331@pytest.fixture
332def canica_components(rng, _make_canica_components) -> np.ndarray:
333 """Create noisy non-positive components data."""
334 components = _make_canica_components
335 components[rng.standard_normal(components.shape) > 0.8] *= -2.0
337 for mp in components:
338 assert mp.max() <= -mp.min() # Goal met ?
340 return components
343@pytest.fixture
344def canica_data_single_img(canica_data) -> Nifti1Image:
345 """Create a canonical ICA data for testing purposes."""
346 return canica_data[0]
349def check_decomposition_estimator(estimator, data_type):
350 """Run several standard checks on decomposition estimators."""
351 assert estimator.mask_img_ == estimator.masker_.mask_img_
352 assert estimator.components_.shape[0] == estimator.n_components
354 if data_type == "nifti":
355 assert isinstance(estimator.mask_img_, Nifti1Image)
356 assert isinstance(estimator.components_img_, Nifti1Image)
357 assert isinstance(estimator.masker_, MultiNiftiMasker)
358 check_shape = (*estimator.mask_img_.shape, estimator.n_components)
360 elif data_type == "surface":
361 assert isinstance(estimator.mask_img_, SurfaceImage)
362 assert isinstance(estimator.components_img_, SurfaceImage)
363 assert isinstance(estimator.masker_, SurfaceMasker)
364 check_shape = (estimator.mask_img_.shape[0], estimator.n_components)
366 assert estimator.components_img_.shape == check_shape