Coverage for nilearn/regions/region_extractor.py: 13%

156 statements  

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

1"""Better brain parcellations for Region of Interest analysis.""" 

2 

3import collections.abc 

4import numbers 

5from copy import deepcopy 

6 

7import numpy as np 

8from scipy.ndimage import label 

9from scipy.stats import scoreatpercentile 

10 

11from nilearn import masking 

12from nilearn._utils import ( 

13 check_niimg, 

14 check_niimg_3d, 

15 check_niimg_4d, 

16 fill_doc, 

17) 

18from nilearn._utils.helpers import ( 

19 rename_parameters, 

20) 

21from nilearn._utils.ndimage import peak_local_max 

22from nilearn._utils.niimg import safe_get_data 

23from nilearn._utils.niimg_conversions import check_same_fov 

24from nilearn._utils.param_validation import check_params 

25from nilearn._utils.segmentation import random_walker 

26from nilearn.image.image import ( 

27 concat_imgs, 

28 new_img_like, 

29 smooth_array, 

30 threshold_img, 

31) 

32from nilearn.image.resampling import resample_img 

33from nilearn.maskers import NiftiMapsMasker 

34 

35 

36def _threshold_maps_ratio(maps_img, threshold): 

37 """Automatic thresholding of atlas maps image. 

38 

39 Considers the given threshold as a ratio to the total number of voxels 

40 in the brain volume. This gives a certain number within the data 

41 voxel size which means that nonzero voxels which fall above than this 

42 size will be kept across all the maps. 

43 

44 Parameters 

45 ---------- 

46 maps_img : Niimg-like object 

47 An image of brain atlas maps. 

48 

49 threshold : float 

50 If float, value is used as a ratio to n_voxels 

51 to get a certain threshold size in number to threshold the image. 

52 The value should be positive and 

53 within the range of number of maps (i.e. n_maps in 4th dimension). 

54 

55 Returns 

56 ------- 

57 threshold_maps_img : Nifti1Image 

58 Gives us thresholded image. 

59 

60 """ 

61 maps = check_niimg(maps_img) 

62 n_maps = maps.shape[-1] 

63 if ( 

64 not isinstance(threshold, numbers.Real) 

65 or threshold <= 0 

66 or threshold > n_maps 

67 ): 

68 raise ValueError( 

69 "threshold given as ratio to the number of voxels must " 

70 "be Real number and should be positive and between 0 and " 

71 f"total number of maps i.e. n_maps={n_maps}. " 

72 f"You provided {threshold}" 

73 ) 

74 else: 

75 ratio = threshold 

76 

77 # Get a copy of the data 

78 maps_data = safe_get_data(maps, ensure_finite=True, copy_data=True) 

79 

80 abs_maps = np.abs(maps_data) 

81 # thresholding 

82 cutoff_threshold = scoreatpercentile( 

83 abs_maps, 100.0 - (100.0 / n_maps) * ratio 

84 ) 

85 maps_data[abs_maps < cutoff_threshold] = 0.0 

86 

87 threshold_maps_img = new_img_like(maps, maps_data) 

88 

89 return threshold_maps_img 

90 

91 

92def _remove_small_regions(input_data, affine, min_size): 

93 """Remove small regions in volume from input_data of specified min_size. 

94 

95 min_size should be specified in mm^3 (region size in volume). 

96 

97 Parameters 

98 ---------- 

99 input_data : numpy.ndarray 

100 Values inside the regions defined by labels contained in input_data 

101 are summed together to get the size and compare with given min_size. 

102 For example, see scipy.ndimage.label. 

103 

104 affine : numpy.ndarray 

105 Affine of input_data is used to convert size in voxels to size in 

106 volume of region in mm^3. 

107 

108 min_size : float in mm^3 

109 Size of regions in input_data which falls below the specified min_size 

110 of volume in mm^3 will be discarded. 

111 

112 Returns 

113 ------- 

114 out : numpy.ndarray 

115 Data returned will have regions removed specified by min_size 

116 Otherwise, if criterion is not met then same input data will be 

117 returned. 

118 

119 """ 

120 # with return_counts argument is introduced from numpy 1.9.0. 

121 # _, region_sizes = np.unique(input_data, return_counts=True) 

122 

123 # For now, to count the region sizes, we use return_inverse from 

124 # np.unique and then use np.bincount to count the region sizes. 

125 

126 _, region_indices = np.unique(input_data, return_inverse=True) 

127 region_sizes = np.bincount(region_indices.ravel()) 

128 size_in_vox = min_size / np.abs(np.linalg.det(affine[:3, :3])) 

129 labels_kept = region_sizes > size_in_vox 

130 if not np.all(labels_kept): 

131 # Put to zero the indices not kept 

132 rejected_labels_mask = np.isin( 

133 input_data, np.where(np.logical_not(labels_kept))[0] 

134 ).reshape(input_data.shape) 

135 # Avoid modifying the input: 

136 input_data = input_data.copy() 

137 input_data[rejected_labels_mask] = 0 

138 # Reorder the indices to avoid gaps 

139 input_data = np.searchsorted(np.unique(input_data), input_data) 

140 return input_data 

141 

142 

143@fill_doc 

144def connected_regions( 

145 maps_img, 

146 min_region_size=1350, 

147 extract_type="local_regions", 

148 smoothing_fwhm=6, 

149 mask_img=None, 

150): 

151 """Extract brain connected regions into separate regions. 

152 

153 .. note:: 

154 The region size should be defined in mm^3. 

155 See the documentation for more details. 

156 

157 .. versionadded:: 0.2 

158 

159 Parameters 

160 ---------- 

161 maps_img : Niimg-like object 

162 An image of brain activation or atlas maps to be extracted into set of 

163 separate brain regions. 

164 

165 min_region_size : :obj:`float`, default=1350 

166 Minimum volume in mm3 for a region to be kept. 

167 For example, if the :term:`voxel` size is 3x3x3 mm 

168 then the volume of the :term:`voxel` is 27mm^3. 

169 Default=1350mm^3, which means 

170 we take minimum size of 1350 / 27 = 50 voxels. 

171 %(extract_type)s 

172 %(smoothing_fwhm)s 

173 Use this parameter to smooth an image to extract most sparser regions. 

174 

175 .. note:: 

176 

177 This parameter is passed to `nilearn.image.image.smooth_array`. 

178 It will be used only if ``extract_type='local_regions'``. 

179 

180 Default=6. 

181 

182 mask_img : Niimg-like object, default=None 

183 If given, mask image is applied to input data. 

184 If None, no masking is applied. 

185 

186 Returns 

187 ------- 

188 regions_extracted_img : :class:`nibabel.nifti1.Nifti1Image` 

189 Gives the image in 4D of extracted brain regions. 

190 Each 3D image consists of only one separated region. 

191 

192 index_of_each_map : :class:`numpy.ndarray` 

193 An array of list of indices where each index denotes the identity 

194 of each extracted region to their family of brain maps. 

195 

196 See Also 

197 -------- 

198 nilearn.regions.connected_label_regions : A function can be used for 

199 extraction of regions on labels based atlas images. 

200 

201 nilearn.regions.RegionExtractor : A class can be used for both 

202 region extraction on continuous type atlas images and 

203 also time series signals extraction from regions extracted. 

204 """ 

205 all_regions_imgs = [] 

206 index_of_each_map = [] 

207 maps_img = check_niimg(maps_img, atleast_4d=True) 

208 maps = safe_get_data(maps_img, copy_data=True) 

209 affine = maps_img.affine 

210 min_region_size = min_region_size / np.abs(np.linalg.det(affine[:3, :3])) 

211 

212 allowed_extract_types = ["connected_components", "local_regions"] 

213 if extract_type not in allowed_extract_types: 

214 message = ( 

215 "'extract_type' should be given " 

216 f"either of these {allowed_extract_types} " 

217 f"You provided extract_type='{extract_type}'" 

218 ) 

219 raise ValueError(message) 

220 

221 if mask_img is not None: 

222 if not check_same_fov(maps_img, mask_img): 

223 # TODO switch to force_resample=True 

224 # when bumping to version > 0.13 

225 mask_img = resample_img( 

226 mask_img, 

227 target_affine=maps_img.affine, 

228 target_shape=maps_img.shape[:3], 

229 interpolation="nearest", 

230 copy_header=True, 

231 force_resample=False, 

232 ) 

233 mask_data, _ = masking.load_mask_img(mask_img) 

234 # Set as 0 to the values which are outside of the mask 

235 maps[mask_data == 0.0] = 0.0 

236 

237 for index in range(maps.shape[-1]): 

238 regions = [] 

239 map_3d = maps[..., index] 

240 # Mark the seeds using random walker 

241 if extract_type == "local_regions": 

242 smooth_map = smooth_array( 

243 map_3d, affine=affine, fwhm=smoothing_fwhm 

244 ) 

245 seeds = peak_local_max(smooth_map) 

246 seeds_label, _ = label(seeds) 

247 # Assign -1 to values which are 0. to indicate to ignore 

248 seeds_label[map_3d == 0.0] = -1 

249 rw_maps = random_walker(map_3d, seeds_label) 

250 # Now simply replace "-1" with "0" for regions separation 

251 rw_maps[rw_maps == -1] = 0.0 

252 label_maps = rw_maps 

253 else: 

254 # Connected component extraction 

255 label_maps, n_labels = label(map_3d) 

256 

257 # Takes the size of each labelized region data 

258 labels_size = np.bincount(label_maps.ravel()) 

259 # set background labels sitting in zero index to zero 

260 labels_size[0] = 0.0 

261 for label_id, label_size in enumerate(labels_size): 

262 if label_size > min_region_size: 

263 region_data = (label_maps == label_id) * map_3d 

264 region_img = new_img_like(maps_img, region_data) 

265 regions.append(region_img) 

266 

267 index_of_each_map.extend([index] * len(regions)) 

268 all_regions_imgs.extend(regions) 

269 

270 regions_extracted_img = concat_imgs(all_regions_imgs) 

271 

272 return regions_extracted_img, index_of_each_map 

273 

274 

275@fill_doc 

276class RegionExtractor(NiftiMapsMasker): 

277 """Class for brain region extraction. 

278 

279 Region Extraction is a post processing technique which 

280 is implemented to automatically segment each brain atlas maps 

281 into different set of separated brain activated region. 

282 Particularly, to show that each decomposed brain maps can be 

283 used to focus on a target specific Regions of Interest analysis. 

284 

285 See :footcite:t:`Abraham2014`. 

286 

287 .. versionadded:: 0.2 

288 

289 Parameters 

290 ---------- 

291 maps_img : 4D Niimg-like object or None, default=None 

292 Image containing a set of whole brain atlas maps or statistically 

293 decomposed brain maps. 

294 

295 mask_img : Niimg-like object or None, optional 

296 Mask to be applied to input data, passed to NiftiMapsMasker. 

297 If None, no masking is applied. 

298 

299 min_region_size : :obj:`float`, default=1350 

300 Minimum volume in mm3 for a region to be kept. 

301 For example, if the voxel size is 3x3x3 mm 

302 then the volume of the voxel is 27mm^3. 

303 The default of 1350mm^3 means 

304 we take minimum size of 1350 / 27 = 50 voxels. 

305 

306 threshold : number, default=1.0 

307 A value used either in ratio_n_voxels or img_value or percentile 

308 `thresholding_strategy` based upon the choice of selection. 

309 

310 thresholding_strategy : :obj:`str` \ 

311 {'ratio_n_voxels', 'img_value', 'percentile'}, \ 

312 default='ratio_n_voxels' 

313 If default 'ratio_n_voxels', we apply thresholding that will keep 

314 the more intense nonzero brain voxels (denoted as n_voxels) 

315 across all maps (n_voxels being the number of voxels in the brain 

316 volume). A float value given in `threshold` parameter indicates 

317 the ratio of voxels to keep meaning (if float=2. then maps will 

318 together have 2. x n_voxels non-zero voxels). If set to 

319 'percentile', images are thresholded based on the score obtained 

320 with the given percentile on the data and the voxel intensities 

321 which are survived above this obtained score will be kept. If set 

322 to 'img_value', we apply thresholding based on the non-zero voxel 

323 intensities across all maps. A value given in `threshold` 

324 parameter indicates that we keep only those voxels which have 

325 intensities more than this value. 

326 

327 two_sided : :obj:`bool`, default=False 

328 Whether the thresholding should yield both positive and negative 

329 part of the maps. 

330 

331 .. versionadded:: 0.11.1 

332 

333 %(extractor)s 

334 %(smoothing_fwhm)s 

335 Use this parameter to smooth an image 

336 to extract most sparser regions. 

337 

338 .. note:: 

339 

340 This parameter is passed to 

341 :func:`nilearn.regions.connected_regions`. 

342 It will be used only if ``extractor='local_regions'``. 

343 

344 .. note:: 

345 

346 Please set this parameter according to maps resolution, 

347 otherwise extraction will fail. 

348 

349 Default=6mm. 

350 %(standardize_false)s 

351 

352 .. note:: 

353 Recommended to set to True if signals are not already standardized. 

354 Passed to :class:`~nilearn.maskers.NiftiMapsMasker`. 

355 

356 %(standardize_confounds)s 

357 

358 %(detrend)s 

359 

360 .. note:: 

361 Passed to :func:`nilearn.signal.clean`. 

362 

363 Default=False. 

364 

365 %(low_pass)s 

366 

367 .. note:: 

368 Passed to :func:`nilearn.signal.clean`. 

369 

370 %(high_pass)s 

371 

372 .. note:: 

373 Passed to :func:`nilearn.signal.clean`. 

374 

375 %(t_r)s 

376 

377 .. note:: 

378 Passed to :func:`nilearn.signal.clean`. 

379 

380 %(memory)s 

381 %(memory_level)s 

382 %(verbose0)s 

383 

384 Attributes 

385 ---------- 

386 index_ : :class:`numpy.ndarray` 

387 Array of list of indices where each index value is assigned to 

388 each separate region of its corresponding family of brain maps. 

389 

390 regions_img_ : :class:`nibabel.nifti1.Nifti1Image` 

391 List of separated regions with each region lying on an 

392 original volume concatenated into a 4D image. 

393 

394 References 

395 ---------- 

396 .. footbibliography:: 

397 

398 See Also 

399 -------- 

400 nilearn.regions.connected_label_regions : A function can be readily 

401 used for extraction of regions on labels based atlas images. 

402 

403 """ 

404 

405 def __init__( 

406 self, 

407 maps_img=None, 

408 mask_img=None, 

409 min_region_size=1350, 

410 threshold=1.0, 

411 thresholding_strategy="ratio_n_voxels", 

412 two_sided=False, 

413 extractor="local_regions", 

414 smoothing_fwhm=6, 

415 standardize=False, 

416 standardize_confounds=True, 

417 detrend=False, 

418 low_pass=None, 

419 high_pass=None, 

420 t_r=None, 

421 memory=None, 

422 memory_level=0, 

423 verbose=0, 

424 ): 

425 super().__init__( 

426 maps_img=maps_img, 

427 mask_img=mask_img, 

428 smoothing_fwhm=smoothing_fwhm, 

429 standardize=standardize, 

430 standardize_confounds=standardize_confounds, 

431 detrend=detrend, 

432 low_pass=low_pass, 

433 high_pass=high_pass, 

434 t_r=t_r, 

435 memory=memory, 

436 memory_level=memory_level, 

437 verbose=verbose, 

438 ) 

439 self.maps_img = maps_img 

440 self.min_region_size = min_region_size 

441 self.thresholding_strategy = thresholding_strategy 

442 self.threshold = threshold 

443 self.two_sided = two_sided 

444 self.extractor = extractor 

445 self.smoothing_fwhm = smoothing_fwhm 

446 

447 @fill_doc 

448 @rename_parameters(replacement_params={"X": "imgs"}, end_version="0.13.2") 

449 def fit(self, imgs=None, y=None): 

450 """Prepare signal extraction from regions. 

451 

452 Parameters 

453 ---------- 

454 imgs : :obj:`list` of Niimg-like objects or None, default=None 

455 See :ref:`extracting_data`. 

456 Image data passed to the reporter. 

457 

458 %(y_dummy)s 

459 """ 

460 del y 

461 check_params(self.__dict__) 

462 maps_img = deepcopy(self.maps_img) 

463 maps_img = check_niimg_4d(maps_img) 

464 

465 self.mask_img_ = self._load_mask(imgs) 

466 

467 if imgs is not None: 

468 check_niimg(imgs) 

469 

470 list_of_strategies = ["ratio_n_voxels", "img_value", "percentile"] 

471 if self.thresholding_strategy not in list_of_strategies: 

472 message = ( 

473 "'thresholding_strategy' should be " 

474 f"either of these {list_of_strategies}" 

475 ) 

476 raise ValueError(message) 

477 

478 if self.threshold is None or isinstance(self.threshold, str): 

479 raise ValueError( 

480 "The given input to threshold is not valid. " 

481 "Please submit a valid number specific to either of " 

482 f"the strategy in {list_of_strategies}" 

483 ) 

484 elif isinstance(self.threshold, numbers.Number): 

485 # foreground extraction 

486 if self.thresholding_strategy == "ratio_n_voxels": 

487 threshold_maps = _threshold_maps_ratio( 

488 maps_img, self.threshold 

489 ) 

490 else: 

491 if self.thresholding_strategy == "percentile": 

492 self.threshold = f"{self.threshold}%" 

493 threshold_maps = threshold_img( 

494 maps_img, 

495 mask_img=self.mask_img_, 

496 copy=True, 

497 threshold=self.threshold, 

498 two_sided=self.two_sided, 

499 copy_header=True, 

500 ) 

501 

502 # connected component extraction 

503 self.regions_img_, self.index_ = connected_regions( 

504 threshold_maps, 

505 self.min_region_size, 

506 self.extractor, 

507 self.smoothing_fwhm, 

508 mask_img=self.mask_img_, 

509 ) 

510 

511 self._maps_img = self.regions_img_ 

512 super().fit(imgs) 

513 

514 return self 

515 

516 

517def connected_label_regions( 

518 labels_img, min_size=None, connect_diag=True, labels=None 

519): 

520 """Extract connected regions from a brain atlas image \ 

521 defined by labels (integers). 

522 

523 For each label in a :term:`parcellation`, separates out connected 

524 components and assigns to each separated region a unique label. 

525 

526 Parameters 

527 ---------- 

528 labels_img : Nifti-like image 

529 A 3D image which contains regions denoted as labels. Each region 

530 is assigned with integers. 

531 

532 min_size : :obj:`float`, default=None 

533 Minimum region size (in mm^3) in volume required 

534 to keep after extraction. 

535 Removes small or spurious regions. 

536 

537 connect_diag : :obj:`bool`, default=True 

538 If 'connect_diag' is True, two voxels are considered in the same region 

539 if they are connected along the diagonal (26-connectivity). If it is 

540 False, two voxels are considered connected only if they are within the 

541 same x, y, or z direction. 

542 

543 labels : 1D :class:`numpy.ndarray` or :obj:`list` of :obj:`str`, \ 

544 default=None 

545 Each string in a list or array denote the name of the brain atlas 

546 regions given in labels_img input. If provided, same names will be 

547 re-assigned corresponding to each connected component based extraction 

548 of regions relabelling. The total number of names should match with the 

549 number of labels assigned in the image. 

550 

551 Notes 

552 ----- 

553 The order of the names given in labels should be appropriately matched with 

554 the unique labels (integers) assigned to each region given in labels_img 

555 (also excluding 'Background' label). 

556 

557 Returns 

558 ------- 

559 new_labels_img : :class:`nibabel.nifti1.Nifti1Image` 

560 A new image comprising of regions extracted on an input labels_img. 

561 

562 new_labels : :obj:`list`, optional 

563 If labels are provided, new labels assigned to region extracted will 

564 be returned. Otherwise, only new labels image will be returned. 

565 

566 See Also 

567 -------- 

568 nilearn.datasets.fetch_atlas_harvard_oxford : For an example of atlas with 

569 labels. 

570 

571 nilearn.regions.RegionExtractor : A class can be used for region extraction 

572 on continuous type atlas images. 

573 

574 nilearn.regions.connected_regions : A function used for region extraction 

575 on continuous type atlas images. 

576 

577 """ 

578 labels_img = check_niimg_3d(labels_img) 

579 labels_data = safe_get_data(labels_img, ensure_finite=True) 

580 affine = labels_img.affine 

581 

582 check_unique_labels = np.unique(labels_data) 

583 

584 if min_size is not None and not isinstance(min_size, numbers.Number): 

585 raise ValueError( 

586 "Expected 'min_size' to be specified as integer. " 

587 f"You provided {min_size}" 

588 ) 

589 if not isinstance(connect_diag, bool): 

590 raise ValueError( 

591 "'connect_diag' must be specified as True or False. " 

592 f"You provided {connect_diag}" 

593 ) 

594 if np.any(check_unique_labels < 0): 

595 raise ValueError( 

596 "The 'labels_img' you provided has unknown/negative " 

597 f"integers as labels {check_unique_labels} assigned to regions. " 

598 "All regions in an image should have positive " 

599 "integers assigned as labels." 

600 ) 

601 

602 unique_labels = set(check_unique_labels) 

603 # check for background label indicated as 0 

604 if np.any(check_unique_labels == 0): 

605 unique_labels.remove(0) 

606 

607 if labels is not None: 

608 if not isinstance(labels, collections.abc.Iterable) or isinstance( 

609 labels, str 

610 ): 

611 labels = [labels] 

612 if len(unique_labels) != len(labels): 

613 raise ValueError( 

614 f"The number of labels: {len(labels)} provided as input " 

615 f"in labels={labels} does not match with the number " 

616 f"of unique labels in labels_img: {len(unique_labels)}. " 

617 "Please provide appropriate match with unique " 

618 "number of labels in labels_img." 

619 ) 

620 new_names = [] 

621 

622 this_labels = [None] * len(unique_labels) if labels is None else labels 

623 

624 new_labels_data = np.zeros(labels_data.shape, dtype=np.int32) 

625 current_max_label = 0 

626 for label_id, name in zip(unique_labels, this_labels): 

627 this_label_mask = labels_data == label_id 

628 # Extract regions assigned to each label id 

629 if connect_diag: 

630 structure = np.ones((3, 3, 3), dtype=np.int32) 

631 regions, this_n_labels = label( 

632 this_label_mask.astype(np.int32), structure=structure 

633 ) 

634 else: 

635 regions, this_n_labels = label(this_label_mask.astype(np.int32)) 

636 

637 if min_size is not None: 

638 regions = _remove_small_regions(regions, affine, min_size=min_size) 

639 this_n_labels = regions.max() 

640 

641 cur_regions = regions[regions != 0] + current_max_label 

642 new_labels_data[regions != 0] = cur_regions 

643 current_max_label += this_n_labels 

644 if name is not None: 

645 new_names.extend([name] * this_n_labels) 

646 

647 new_labels_img = new_img_like(labels_img, new_labels_data, affine=affine) 

648 

649 return ( 

650 (new_labels_img, new_names) if labels is not None else new_labels_img 

651 )