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
« 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."""
3import collections.abc
4import numbers
5from copy import deepcopy
7import numpy as np
8from scipy.ndimage import label
9from scipy.stats import scoreatpercentile
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
36def _threshold_maps_ratio(maps_img, threshold):
37 """Automatic thresholding of atlas maps image.
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.
44 Parameters
45 ----------
46 maps_img : Niimg-like object
47 An image of brain atlas maps.
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).
55 Returns
56 -------
57 threshold_maps_img : Nifti1Image
58 Gives us thresholded image.
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
77 # Get a copy of the data
78 maps_data = safe_get_data(maps, ensure_finite=True, copy_data=True)
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
87 threshold_maps_img = new_img_like(maps, maps_data)
89 return threshold_maps_img
92def _remove_small_regions(input_data, affine, min_size):
93 """Remove small regions in volume from input_data of specified min_size.
95 min_size should be specified in mm^3 (region size in volume).
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.
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.
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.
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.
119 """
120 # with return_counts argument is introduced from numpy 1.9.0.
121 # _, region_sizes = np.unique(input_data, return_counts=True)
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.
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
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.
153 .. note::
154 The region size should be defined in mm^3.
155 See the documentation for more details.
157 .. versionadded:: 0.2
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.
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.
175 .. note::
177 This parameter is passed to `nilearn.image.image.smooth_array`.
178 It will be used only if ``extract_type='local_regions'``.
180 Default=6.
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.
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.
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.
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.
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]))
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)
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
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)
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)
267 index_of_each_map.extend([index] * len(regions))
268 all_regions_imgs.extend(regions)
270 regions_extracted_img = concat_imgs(all_regions_imgs)
272 return regions_extracted_img, index_of_each_map
275@fill_doc
276class RegionExtractor(NiftiMapsMasker):
277 """Class for brain region extraction.
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.
285 See :footcite:t:`Abraham2014`.
287 .. versionadded:: 0.2
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.
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.
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.
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.
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.
327 two_sided : :obj:`bool`, default=False
328 Whether the thresholding should yield both positive and negative
329 part of the maps.
331 .. versionadded:: 0.11.1
333 %(extractor)s
334 %(smoothing_fwhm)s
335 Use this parameter to smooth an image
336 to extract most sparser regions.
338 .. note::
340 This parameter is passed to
341 :func:`nilearn.regions.connected_regions`.
342 It will be used only if ``extractor='local_regions'``.
344 .. note::
346 Please set this parameter according to maps resolution,
347 otherwise extraction will fail.
349 Default=6mm.
350 %(standardize_false)s
352 .. note::
353 Recommended to set to True if signals are not already standardized.
354 Passed to :class:`~nilearn.maskers.NiftiMapsMasker`.
356 %(standardize_confounds)s
358 %(detrend)s
360 .. note::
361 Passed to :func:`nilearn.signal.clean`.
363 Default=False.
365 %(low_pass)s
367 .. note::
368 Passed to :func:`nilearn.signal.clean`.
370 %(high_pass)s
372 .. note::
373 Passed to :func:`nilearn.signal.clean`.
375 %(t_r)s
377 .. note::
378 Passed to :func:`nilearn.signal.clean`.
380 %(memory)s
381 %(memory_level)s
382 %(verbose0)s
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.
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.
394 References
395 ----------
396 .. footbibliography::
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.
403 """
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
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.
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.
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)
465 self.mask_img_ = self._load_mask(imgs)
467 if imgs is not None:
468 check_niimg(imgs)
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)
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 )
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 )
511 self._maps_img = self.regions_img_
512 super().fit(imgs)
514 return self
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).
523 For each label in a :term:`parcellation`, separates out connected
524 components and assigns to each separated region a unique label.
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.
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.
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.
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.
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).
557 Returns
558 -------
559 new_labels_img : :class:`nibabel.nifti1.Nifti1Image`
560 A new image comprising of regions extracted on an input labels_img.
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.
566 See Also
567 --------
568 nilearn.datasets.fetch_atlas_harvard_oxford : For an example of atlas with
569 labels.
571 nilearn.regions.RegionExtractor : A class can be used for region extraction
572 on continuous type atlas images.
574 nilearn.regions.connected_regions : A function used for region extraction
575 on continuous type atlas images.
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
582 check_unique_labels = np.unique(labels_data)
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 )
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)
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 = []
622 this_labels = [None] * len(unique_labels) if labels is None else labels
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))
637 if min_size is not None:
638 regions = _remove_small_regions(regions, affine, min_size=min_size)
639 this_n_labels = regions.max()
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)
647 new_labels_img = new_img_like(labels_img, new_labels_data, affine=affine)
649 return (
650 (new_labels_img, new_names) if labels is not None else new_labels_img
651 )