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

1"""Fixtures for decomposition tests.""" 

2 

3from typing import Union 

4 

5import numpy as np 

6import pytest 

7from nibabel import Nifti1Image 

8 

9from nilearn.maskers import MultiNiftiMasker, SurfaceMasker 

10from nilearn.surface import PolyMesh, SurfaceImage 

11from nilearn.surface.tests.test_surface import flat_mesh 

12 

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 

21 

22 

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 ) 

29 

30 

31@pytest.fixture 

32def decomposition_mesh() -> PolyMesh: 

33 """Return a mesh to use for decomposition tests.""" 

34 return _decomposition_mesh() 

35 

36 

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) 

55 

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) 

74 

75 

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

86 

87 

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

111 

112 return surf_imgs 

113 

114 

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 

142 

143 return SurfaceImage(mesh=mesh, data=data) 

144 

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 

149 

150 return Nifti1Image(this_img, affine) 

151 

152 

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 ] 

174 

175 

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 ) 

194 

195 

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 ) 

215 

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 ) 

222 

223 

224@pytest.fixture 

225def _make_canica_components( 

226 decomposition_mesh, shape_3d_large, data_type 

227) -> np.ndarray: 

228 """Create 4 components. 

229 

230 3D images unraveled for volume, 2D for surface 

231 """ 

232 if data_type == "nifti": 

233 return _canica_components_volume(shape_3d_large) 

234 

235 else: 

236 shape = (decomposition_mesh.n_vertices, 1) 

237 

238 component1 = np.zeros(shape) 

239 component1[:5] = 1 

240 component1[5:10] = -1 

241 

242 component2 = np.zeros(shape) 

243 component2[:5] = 1 

244 component2[5:10] = -1 

245 

246 component3 = np.zeros(shape) 

247 component3[-5:] = 1 

248 component3[-10:-5] = -1 

249 

250 component4 = np.zeros(shape) 

251 component4[-5:] = 1 

252 component4[-10:-5] = -1 

253 

254 return np.vstack( 

255 ( 

256 component1.ravel(), 

257 component2.ravel(), 

258 component3.ravel(), 

259 component4.ravel(), 

260 ) 

261 ) 

262 

263 

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 

269 

270 component2 = np.zeros(shape) 

271 component2[:5, -10:] = 1 

272 component2[5:10, -10:] = -1 

273 

274 component3 = np.zeros(shape) 

275 component3[-5:, -10:] = 1 

276 component3[-10:-5, -10:] = -1 

277 

278 component4 = np.zeros(shape) 

279 component4[-5:, :10] = 1 

280 component4[-10:-5, :10] = -1 

281 

282 return np.vstack( 

283 ( 

284 component1.ravel(), 

285 component2.ravel(), 

286 component3.ravel(), 

287 component4.ravel(), 

288 ) 

289 ) 

290 

291 

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] 

302 

303 data = [] 

304 

305 # TODO 

306 # changing this value leads makes tests overall faster but makes 

307 # test_canica_square_img to fail 

308 magic_number = 40 

309 

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) 

315 

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) 

319 

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

325 

326 data.append(Nifti1Image(this_data, affine)) 

327 

328 return data 

329 

330 

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 

336 

337 for mp in components: 

338 assert mp.max() <= -mp.min() # Goal met ? 

339 

340 return components 

341 

342 

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] 

347 

348 

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 

353 

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) 

359 

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) 

365 

366 assert estimator.components_img_.shape == check_shape