Coverage for nilearn/regions/signal_extraction.py: 11%

142 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-06-20 10:58 +0200

1""" 

2Functions for extracting region-defined signals. 

3 

4Two ways of defining regions are supported: as labels in a single 3D image, 

5or as weights in one image per region (maps). 

6""" 

7 

8import warnings 

9 

10import numpy as np 

11from nibabel import Nifti1Image 

12from scipy import linalg, ndimage 

13 

14from nilearn import _utils, masking 

15from nilearn._utils.logger import find_stack_level 

16from nilearn._utils.param_validation import check_reduction_strategy 

17 

18from .._utils.niimg import safe_get_data 

19from ..image import new_img_like 

20 

21INF = 1000 * np.finfo(np.float32).eps 

22 

23 

24def _check_shape_compatibility(img1, img2, dim=None): 

25 """Check that shapes match for dimensions going from 0 to dim-1. 

26 

27 Parameters 

28 ---------- 

29 img1 : Niimg-like object 

30 See :ref:`extracting_data`. 

31 Image to extract the data from. 

32 

33 img2 : Niimg-like object, optional 

34 See :ref:`extracting_data`. 

35 Contains map or mask. 

36 

37 dim : :obj:`int`, optional 

38 Integer slices a mask for a specific dimension. 

39 

40 """ 

41 if dim is None: 

42 img2 = _utils.check_niimg_3d(img2) 

43 if img1.shape[:3] != img2.shape: 

44 raise ValueError("Images have incompatible shapes.") 

45 elif img1.shape[:dim] != img2.shape[:dim]: 

46 raise ValueError("Images have incompatible shapes.") 

47 

48 

49def _check_affine_equality(img1, img2): 

50 """Validate affines of 2 images. 

51 

52 Parameters 

53 ---------- 

54 img1 : Niimg-like object 

55 See :ref:`extracting_data`. 

56 Image to extract the data from. 

57 

58 img2 : Niimg-like object, optional 

59 See :ref:`extracting_data`. 

60 Contains map or mask. 

61 

62 """ 

63 if ( 

64 img1.affine.shape != img2.affine.shape 

65 or abs(img1.affine - img2.affine).max() > INF 

66 ): 

67 raise ValueError("Images have different affine matrices.") 

68 

69 

70def _check_shape_and_affine_compatibility(img1, img2=None, dim=None): 

71 """Validate shapes and affines of 2 images. 

72 

73 Check that the provided images: 

74 - have the same shape 

75 - have the same affine matrix. 

76 

77 Parameters 

78 ---------- 

79 img1 : Niimg-like object 

80 See :ref:`extracting_data`. 

81 Image to extract the data from. 

82 

83 img2 : Niimg-like object, optional 

84 See :ref:`extracting_data`. 

85 Contains map or mask. 

86 

87 dim : :obj:`int`, optional 

88 Integer slices a mask for a specific dimension. 

89 

90 Returns 

91 ------- 

92 non_empty : :obj:`bool`, 

93 Is only true for non-empty img. 

94 

95 """ 

96 if img2 is None: 

97 return False 

98 

99 _check_shape_compatibility(img1, img2, dim=dim) 

100 

101 if dim is None: 

102 img2 = _utils.check_niimg_3d(img2) 

103 _check_affine_equality(img1, img2) 

104 

105 return True 

106 

107 

108def _get_labels_data( 

109 target_img, 

110 labels_img, 

111 mask_img=None, 

112 background_label=0, 

113 dim=None, 

114 keep_masked_labels=True, 

115): 

116 """Get the label data. 

117 

118 Ensures that labels, imgs and mask shapes and affines fit, 

119 then extracts the data from it. 

120 

121 Parameters 

122 ---------- 

123 target_img : Niimg-like object 

124 See :ref:`extracting_data`. 

125 Image to extract the data from. 

126 

127 labels_img : Niimg-like object 

128 See :ref:`extracting_data`. 

129 Regions definition as labels. 

130 By default, the label zero is used to denote an absence of region. 

131 Use background_label to change it. 

132 

133 mask_img : Niimg-like object, optional 

134 See :ref:`extracting_data`. 

135 Mask to apply to labels before extracting signals. 

136 Every point outside the mask is considered as background 

137 (i.e. no region). 

138 

139 background_label : number, default=0 

140 Number representing background in labels_img. 

141 

142 dim : :obj:`int`, optional 

143 Integer slices mask for a specific dimension. 

144 %(keep_masked_labels)s 

145 

146 Returns 

147 ------- 

148 labels : :obj:`list` or :obj:`tuple` 

149 Corresponding labels for each signal. 

150 signal[:, n] was extracted from the region with label labels[n]. 

151 

152 labels_data : numpy.ndarray 

153 Extracted data for each region within the mask. 

154 Data outside the mask are assigned to the background 

155 label to restrict signal extraction 

156 

157 See Also 

158 -------- 

159 nilearn.regions.signals_to_img_labels 

160 nilearn.regions.img_to_signals_labels 

161 

162 """ 

163 _check_shape_and_affine_compatibility(target_img, labels_img) 

164 

165 labels_data = safe_get_data(labels_img, ensure_finite=True) 

166 

167 if keep_masked_labels: 

168 labels = list(np.unique(labels_data)) 

169 warnings.warn( 

170 'Applying "mask_img" before ' 

171 "signal extraction may result in empty region signals in the " 

172 "output. These are currently kept. " 

173 "Starting from version 0.13, the default behavior will be " 

174 "changed to remove them by setting " 

175 '"keep_masked_labels=False". ' 

176 '"keep_masked_labels" parameter will be removed ' 

177 "in version 0.15.", 

178 DeprecationWarning, 

179 stacklevel=find_stack_level(), 

180 ) 

181 

182 # Consider only data within the mask 

183 use_mask = _check_shape_and_affine_compatibility(target_img, mask_img, dim) 

184 if use_mask: 

185 mask_img = _utils.check_niimg_3d(mask_img) 

186 mask_data = safe_get_data(mask_img, ensure_finite=True) 

187 labels_data = labels_data.copy() 

188 labels_before_mask = {int(label) for label in np.unique(labels_data)} 

189 # Applying mask on labels_data 

190 labels_data[np.logical_not(mask_data)] = background_label 

191 labels_after_mask = {int(label) for label in np.unique(labels_data)} 

192 labels_diff = labels_before_mask.difference(labels_after_mask) 

193 # Raising a warning if any label is removed due to the mask 

194 if labels_diff and not keep_masked_labels: 

195 warnings.warn( 

196 "After applying mask to the labels image, " 

197 "the following labels were " 

198 f"removed: {labels_diff}. " 

199 f"Out of {len(labels_before_mask)} labels, the " 

200 "masked labels image only contains " 

201 f"{len(labels_after_mask)} labels " 

202 "(including background).", 

203 stacklevel=find_stack_level(), 

204 ) 

205 

206 if not keep_masked_labels: 

207 labels = list(np.unique(labels_data)) 

208 

209 if background_label in labels: 

210 labels.remove(background_label) 

211 

212 return labels, labels_data 

213 

214 

215# FIXME: naming scheme is not really satisfying. Any better idea appreciated. 

216@_utils.fill_doc 

217def img_to_signals_labels( 

218 imgs, 

219 labels_img, 

220 mask_img=None, 

221 background_label=0, 

222 order="F", 

223 strategy="mean", 

224 keep_masked_labels=True, 

225 return_masked_atlas=False, 

226): 

227 """Extract region signals from image. 

228 

229 This function is applicable to regions defined by labels. 

230 

231 labels, imgs and mask shapes and affines must fit. This function 

232 performs no resampling. 

233 

234 Parameters 

235 ---------- 

236 %(imgs)s 

237 Input images. 

238 

239 labels_img : Niimg-like object 

240 See :ref:`extracting_data`. 

241 Regions definition as labels. By default, the label zero is used to 

242 denote an absence of region. Use background_label to change it. 

243 

244 mask_img : Niimg-like object, default=None 

245 See :ref:`extracting_data`. 

246 Mask to apply to labels before extracting signals. 

247 Every point outside the mask is considered 

248 as background (i.e. no region). 

249 

250 background_label : number, default=0 

251 Number representing background in labels_img. 

252 

253 order : :obj:`str`, default='F' 

254 Ordering of output array ("C" or "F"). 

255 

256 %(strategy)s 

257 

258 %(keep_masked_labels)s 

259 

260 return_masked_atlas : :obj:`bool`, default=False 

261 If True, the masked atlas is returned. 

262 deprecated in version 0.13, to be removed in 0.15. 

263 after 0.13, the masked atlas will always be returned. 

264 

265 Returns 

266 ------- 

267 signals : :class:`numpy.ndarray` 

268 Signals extracted from each region. One output signal is the mean 

269 of all input signals in a given region. If some regions are entirely 

270 outside the mask, the corresponding signal is zero. 

271 Shape is: (scan number, number of regions) 

272 

273 labels : :obj:`list` or :obj:`tuple` 

274 Corresponding labels for each signal. signal[:, n] was extracted from 

275 the region with label labels[n]. 

276 

277 masked_atlas : Niimg-like object 

278 Regions definition as labels after applying the mask. 

279 returned if `return_masked_atlas` is True. 

280 

281 See Also 

282 -------- 

283 nilearn.regions.signals_to_img_labels 

284 nilearn.regions.img_to_signals_maps 

285 nilearn.maskers.NiftiLabelsMasker : Signal extraction on labels images 

286 e.g. clusters 

287 

288 """ 

289 labels_img = _utils.check_niimg_3d(labels_img) 

290 

291 check_reduction_strategy(strategy) 

292 

293 # TODO: Make a special case for list of strings 

294 # (load one image at a time). 

295 imgs = _utils.check_niimg_4d(imgs) 

296 labels, labels_data = _get_labels_data( 

297 imgs, 

298 labels_img, 

299 mask_img, 

300 background_label, 

301 keep_masked_labels=keep_masked_labels, 

302 ) 

303 

304 data = safe_get_data(imgs, ensure_finite=True) 

305 target_datatype = np.float32 if data.dtype == np.float32 else np.float64 

306 # Nilearn issue: 2135, PR: 2195 for why this is necessary. 

307 signals = np.ndarray( 

308 (data.shape[-1], len(labels)), order=order, dtype=target_datatype 

309 ) 

310 reduction_function = getattr(ndimage, strategy) 

311 for n, img in enumerate(np.rollaxis(data, -1)): 

312 signals[n] = np.asarray( 

313 reduction_function(img, labels=labels_data, index=labels) 

314 ) 

315 # Set to zero signals for missing labels. Workaround for Scipy behavior 

316 if keep_masked_labels: 

317 missing_labels = set(labels) - set(np.unique(labels_data)) 

318 labels_index = {l: n for n, l in enumerate(labels)} 

319 for this_label in missing_labels: 

320 signals[:, labels_index[this_label]] = 0 

321 

322 if return_masked_atlas: 

323 # finding the new labels image 

324 masked_atlas = Nifti1Image( 

325 labels_data.astype(np.int8), labels_img.affine 

326 ) 

327 return signals, labels, masked_atlas 

328 else: 

329 warnings.warn( 

330 'After version 0.13. "img_to_signals_labels" will also return the ' 

331 '"masked_atlas". Meanwhile "return_masked_atlas" parameter can be ' 

332 "used to toggle this behavior. In version 0.15, " 

333 '"return_masked_atlas" parameter will be removed.', 

334 DeprecationWarning, 

335 stacklevel=find_stack_level(), 

336 ) 

337 return signals, labels 

338 

339 

340def signals_to_img_labels( 

341 signals, labels_img, mask_img=None, background_label=0, order="F" 

342): 

343 """Create image from region signals defined as labels. 

344 

345 The same region signal is used for each :term:`voxel` of the 

346 corresponding 3D volume. 

347 

348 labels_img, mask_img must have the same shapes and affines. 

349 

350 .. versionchanged:: 0.9.2 

351 Support 1D signals. 

352 

353 Parameters 

354 ---------- 

355 signals : :class:`numpy.ndarray` 

356 1D or 2D array. 

357 If this is a 1D array, it must have as many elements as there are 

358 regions in the labels_img. 

359 If it is 2D, it should have the shape 

360 (number of scans, number of regions in labels_img). 

361 

362 labels_img : Niimg-like object 

363 See :ref:`extracting_data`. 

364 Region definitions using labels. 

365 

366 mask_img : Niimg-like object, default=None 

367 See :ref:`extracting_data`. 

368 Boolean array giving voxels to process. integer arrays also accepted, 

369 In this array, zero means False, non-zero means True. 

370 

371 background_label : number, default=0 

372 Label to use for "no region". 

373 

374 order : :obj:`str`, default='F' 

375 Ordering of output array ("C" or "F"). 

376 

377 Returns 

378 ------- 

379 img : :class:`nibabel.nifti1.Nifti1Image` 

380 Reconstructed image. dtype is that of "signals", affine and shape are 

381 those of labels_img. 

382 

383 See Also 

384 -------- 

385 nilearn.regions.img_to_signals_labels 

386 nilearn.regions.signals_to_img_maps 

387 nilearn.maskers.NiftiLabelsMasker : Signal extraction on labels 

388 images e.g. clusters 

389 

390 """ 

391 labels_img = _utils.check_niimg_3d(labels_img) 

392 

393 labels, labels_data = _get_labels_data( 

394 labels_img, 

395 labels_img, 

396 mask_img, 

397 background_label, 

398 keep_masked_labels=False, 

399 ) 

400 

401 signals = np.asarray(signals) 

402 

403 target_shape = labels_img.shape[:3] 

404 # nditer is not available in numpy 1.3: using multiple loops. 

405 # Using these loops still gives a much faster code (6x) than this one: 

406 # for n, label in enumerate(labels): 

407 # data[labels_data == label, :] = signals[:, n] 

408 if signals.ndim == 2: 

409 target_shape = (*target_shape, signals.shape[0]) 

410 

411 data = np.zeros(target_shape, dtype=signals.dtype, order=order) 

412 labels_dict = {label: n for n, label in enumerate(labels)} 

413 # optimized for "data" in F order. 

414 for k in range(labels_data.shape[2]): 

415 for j in range(labels_data.shape[1]): 

416 for i in range(labels_data.shape[0]): 

417 label = labels_data[i, j, k] 

418 num = labels_dict.get(label) 

419 if num is not None: 

420 if signals.ndim == 2: 

421 data[i, j, k, :] = signals[:, num] 

422 else: 

423 data[i, j, k] = signals[num] 

424 

425 return new_img_like(labels_img, data, labels_img.affine) 

426 

427 

428@_utils.fill_doc 

429def img_to_signals_maps(imgs, maps_img, mask_img=None, keep_masked_maps=True): 

430 """Extract region signals from image. 

431 

432 This function is applicable to regions defined by maps. 

433 

434 Parameters 

435 ---------- 

436 %(imgs)s 

437 Input images. 

438 

439 maps_img : Niimg-like object 

440 See :ref:`extracting_data`. 

441 Regions definition as maps (array of weights). 

442 shape: imgs.shape + (region number, ) 

443 

444 mask_img : Niimg-like object, default=None 

445 See :ref:`extracting_data`. 

446 Mask to apply to regions before extracting signals. 

447 Every point outside the mask is considered 

448 as background (i.e. outside of any region). 

449 %(keep_masked_maps)s 

450 

451 Returns 

452 ------- 

453 region_signals : :class:`numpy.ndarray` 

454 Signals extracted from each region. 

455 Shape is: (scans number, number of regions intersecting mask) 

456 

457 labels : :obj:`list` 

458 maps_img[..., labels[n]] is the region that has been used to extract 

459 signal region_signals[:, n]. 

460 

461 See Also 

462 -------- 

463 nilearn.regions.img_to_signals_labels 

464 nilearn.regions.signals_to_img_maps 

465 nilearn.maskers.NiftiMapsMasker : Signal extraction on probabilistic 

466 maps e.g. ICA 

467 

468 """ 

469 maps_img = _utils.check_niimg_4d(maps_img) 

470 imgs = _utils.check_niimg_4d(imgs) 

471 

472 _check_shape_and_affine_compatibility(imgs, maps_img, 3) 

473 

474 maps_data = safe_get_data(maps_img, ensure_finite=True) 

475 maps_mask = np.ones(maps_data.shape[:3], dtype=bool) 

476 labels = np.arange(maps_data.shape[-1], dtype=int) 

477 

478 use_mask = _check_shape_and_affine_compatibility(imgs, mask_img) 

479 if use_mask: 

480 mask_img = _utils.check_niimg_3d(mask_img) 

481 labels_before_mask = {int(label) for label in labels} 

482 maps_data, maps_mask, labels = _trim_maps( 

483 maps_data, 

484 safe_get_data(mask_img, ensure_finite=True), 

485 keep_empty=keep_masked_maps, 

486 ) 

487 maps_mask = _utils.as_ndarray(maps_mask, dtype=bool) 

488 if keep_masked_maps: 

489 warnings.warn( 

490 'Applying "mask_img" before ' 

491 "signal extraction may result in empty region signals in the " 

492 "output. These are currently kept. " 

493 "Starting from version 0.13, the default behavior will be " 

494 "changed to remove them by setting " 

495 '"keep_masked_maps=False". ' 

496 '"keep_masked_maps" parameter will be removed ' 

497 "in version 0.15.", 

498 DeprecationWarning, 

499 stacklevel=find_stack_level(), 

500 ) 

501 else: 

502 labels_after_mask = {int(label) for label in labels} 

503 labels_diff = labels_before_mask.difference(labels_after_mask) 

504 # Raising a warning if any map is removed due to the mask 

505 if labels_diff: 

506 warnings.warn( 

507 "After applying mask to the maps image, " 

508 "maps with the following indices were " 

509 f"removed: {labels_diff}. " 

510 f"Out of {len(labels_before_mask)} maps, the " 

511 "masked map image only contains " 

512 f"{len(labels_after_mask)} maps.", 

513 stacklevel=find_stack_level(), 

514 ) 

515 

516 data = safe_get_data(imgs, ensure_finite=True) 

517 region_signals = linalg.lstsq(maps_data[maps_mask, :], data[maps_mask, :])[ 

518 0 

519 ].T 

520 

521 return region_signals, list(labels) 

522 

523 

524def signals_to_img_maps(region_signals, maps_img, mask_img=None): 

525 """Create image from region signals defined as maps. 

526 

527 region_signals, mask_img must have the same shapes and affines. 

528 

529 Parameters 

530 ---------- 

531 region_signals : :class:`numpy.ndarray` 

532 signals to process, as a 2D array. A signal is a column. 

533 There must be as many signals as maps: 

534 

535 .. code-block:: python 

536 

537 region_signals.shape[1] == maps_img.shape[-1] 

538 

539 maps_img : Niimg-like object 

540 See :ref:`extracting_data`. 

541 Region definitions using maps. 

542 

543 mask_img : Niimg-like object, default=None 

544 See :ref:`extracting_data`. 

545 Boolean array giving :term:`voxels<voxel>` to process. 

546 Integer arrays also accepted, zero meaning False. 

547 

548 Returns 

549 ------- 

550 img : :class:`nibabel.nifti1.Nifti1Image` 

551 Reconstructed image. affine and shape are those of maps_img. 

552 

553 See Also 

554 -------- 

555 nilearn.regions.signals_to_img_labels 

556 nilearn.regions.img_to_signals_maps 

557 nilearn.maskers.NiftiMapsMasker 

558 

559 """ 

560 maps_img = _utils.check_niimg_4d(maps_img) 

561 maps_data = safe_get_data(maps_img, ensure_finite=True) 

562 

563 maps_mask = np.ones(maps_data.shape[:3], dtype=bool) 

564 

565 use_mask = _check_shape_and_affine_compatibility(maps_img, mask_img) 

566 if use_mask: 

567 mask_img = _utils.check_niimg_3d(mask_img) 

568 maps_data, maps_mask, _ = _trim_maps( 

569 maps_data, 

570 safe_get_data(mask_img, ensure_finite=True), 

571 keep_empty=True, 

572 ) 

573 maps_mask = _utils.as_ndarray(maps_mask, dtype=bool) 

574 assert maps_mask.shape == maps_data.shape[:3] 

575 

576 data = np.dot(region_signals, maps_data[maps_mask, :].T) 

577 return masking.unmask( 

578 data, new_img_like(maps_img, maps_mask, maps_img.affine) 

579 ) 

580 

581 

582def _trim_maps(maps, mask, keep_empty=False, order="F"): 

583 """Crop maps using a mask. 

584 

585 No consistency check is performed (esp. on affine). Every required check 

586 must be performed before calling this function. 

587 

588 Parameters 

589 ---------- 

590 maps : :class:`numpy.ndarray` 

591 Set of maps, defining some regions. 

592 

593 mask : :class:`numpy.ndarray` 

594 Definition of a mask. The shape must match that of a single map. 

595 

596 keep_empty : :obj:`bool`, default=False 

597 If False, maps that lie completely outside the mask are dropped from 

598 the output. If True, they are kept, meaning that maps that are 

599 completely zero can occur in the output. 

600 

601 order : "F" or "C", default="F" 

602 Ordering of the output maps array (trimmed_maps). 

603 

604 Returns 

605 ------- 

606 trimmed_maps : :class:``numpy.ndarray` 

607 New set of maps, computed as intersection of each input map and mask. 

608 Empty maps are discarded if keep_empty is False, thus the number of 

609 output maps is not necessarily the same as the number of input maps. 

610 shape: mask.shape + (output maps number,). Data ordering depends 

611 on the "order" parameter. 

612 

613 maps_mask : :class:`numpy.ndarray` 

614 Union of all output maps supports. One non-zero value in this 

615 array guarantees that there is at least one output map that is 

616 non-zero at this voxel. 

617 shape: mask.shape. Order is always C. 

618 

619 indices : :class:`numpy.ndarray` 

620 Indices of regions that have an non-empty intersection with the 

621 given mask. len(indices) == trimmed_maps.shape[-1]. 

622 

623 """ 

624 maps = maps.copy() 

625 sums = abs(maps[_utils.as_ndarray(mask, dtype=bool), :]).sum(axis=0) 

626 

627 n_regions = maps.shape[-1] if keep_empty else (sums > 0).sum() 

628 trimmed_maps = np.zeros( 

629 maps.shape[:3] + (n_regions,), dtype=maps.dtype, order=order 

630 ) 

631 # use int8 instead of np.bool for Nifti1Image 

632 maps_mask = np.zeros(mask.shape, dtype=np.int8) 

633 

634 # iterate on maps 

635 p = 0 

636 mask = _utils.as_ndarray(mask, dtype=bool, order="C") 

637 for n, _ in enumerate(np.rollaxis(maps, -1)): 

638 if not keep_empty and sums[n] == 0: 

639 continue 

640 trimmed_maps[mask, p] = maps[mask, n] 

641 maps_mask[trimmed_maps[..., p] > 0] = 1 

642 p += 1 

643 

644 indices = ( 

645 np.arange(trimmed_maps.shape[-1], dtype=int) 

646 if keep_empty 

647 else np.where(sums > 0)[0] 

648 ) 

649 

650 return trimmed_maps, maps_mask, indices