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
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-20 10:58 +0200
1"""
2Functions for extracting region-defined signals.
4Two ways of defining regions are supported: as labels in a single 3D image,
5or as weights in one image per region (maps).
6"""
8import warnings
10import numpy as np
11from nibabel import Nifti1Image
12from scipy import linalg, ndimage
14from nilearn import _utils, masking
15from nilearn._utils.logger import find_stack_level
16from nilearn._utils.param_validation import check_reduction_strategy
18from .._utils.niimg import safe_get_data
19from ..image import new_img_like
21INF = 1000 * np.finfo(np.float32).eps
24def _check_shape_compatibility(img1, img2, dim=None):
25 """Check that shapes match for dimensions going from 0 to dim-1.
27 Parameters
28 ----------
29 img1 : Niimg-like object
30 See :ref:`extracting_data`.
31 Image to extract the data from.
33 img2 : Niimg-like object, optional
34 See :ref:`extracting_data`.
35 Contains map or mask.
37 dim : :obj:`int`, optional
38 Integer slices a mask for a specific dimension.
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.")
49def _check_affine_equality(img1, img2):
50 """Validate affines of 2 images.
52 Parameters
53 ----------
54 img1 : Niimg-like object
55 See :ref:`extracting_data`.
56 Image to extract the data from.
58 img2 : Niimg-like object, optional
59 See :ref:`extracting_data`.
60 Contains map or mask.
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.")
70def _check_shape_and_affine_compatibility(img1, img2=None, dim=None):
71 """Validate shapes and affines of 2 images.
73 Check that the provided images:
74 - have the same shape
75 - have the same affine matrix.
77 Parameters
78 ----------
79 img1 : Niimg-like object
80 See :ref:`extracting_data`.
81 Image to extract the data from.
83 img2 : Niimg-like object, optional
84 See :ref:`extracting_data`.
85 Contains map or mask.
87 dim : :obj:`int`, optional
88 Integer slices a mask for a specific dimension.
90 Returns
91 -------
92 non_empty : :obj:`bool`,
93 Is only true for non-empty img.
95 """
96 if img2 is None:
97 return False
99 _check_shape_compatibility(img1, img2, dim=dim)
101 if dim is None:
102 img2 = _utils.check_niimg_3d(img2)
103 _check_affine_equality(img1, img2)
105 return True
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.
118 Ensures that labels, imgs and mask shapes and affines fit,
119 then extracts the data from it.
121 Parameters
122 ----------
123 target_img : Niimg-like object
124 See :ref:`extracting_data`.
125 Image to extract the data from.
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.
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).
139 background_label : number, default=0
140 Number representing background in labels_img.
142 dim : :obj:`int`, optional
143 Integer slices mask for a specific dimension.
144 %(keep_masked_labels)s
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].
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
157 See Also
158 --------
159 nilearn.regions.signals_to_img_labels
160 nilearn.regions.img_to_signals_labels
162 """
163 _check_shape_and_affine_compatibility(target_img, labels_img)
165 labels_data = safe_get_data(labels_img, ensure_finite=True)
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 )
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 )
206 if not keep_masked_labels:
207 labels = list(np.unique(labels_data))
209 if background_label in labels:
210 labels.remove(background_label)
212 return labels, labels_data
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.
229 This function is applicable to regions defined by labels.
231 labels, imgs and mask shapes and affines must fit. This function
232 performs no resampling.
234 Parameters
235 ----------
236 %(imgs)s
237 Input images.
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.
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).
250 background_label : number, default=0
251 Number representing background in labels_img.
253 order : :obj:`str`, default='F'
254 Ordering of output array ("C" or "F").
256 %(strategy)s
258 %(keep_masked_labels)s
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.
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)
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].
277 masked_atlas : Niimg-like object
278 Regions definition as labels after applying the mask.
279 returned if `return_masked_atlas` is True.
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
288 """
289 labels_img = _utils.check_niimg_3d(labels_img)
291 check_reduction_strategy(strategy)
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 )
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
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
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.
345 The same region signal is used for each :term:`voxel` of the
346 corresponding 3D volume.
348 labels_img, mask_img must have the same shapes and affines.
350 .. versionchanged:: 0.9.2
351 Support 1D signals.
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).
362 labels_img : Niimg-like object
363 See :ref:`extracting_data`.
364 Region definitions using labels.
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.
371 background_label : number, default=0
372 Label to use for "no region".
374 order : :obj:`str`, default='F'
375 Ordering of output array ("C" or "F").
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.
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
390 """
391 labels_img = _utils.check_niimg_3d(labels_img)
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 )
401 signals = np.asarray(signals)
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])
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]
425 return new_img_like(labels_img, data, labels_img.affine)
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.
432 This function is applicable to regions defined by maps.
434 Parameters
435 ----------
436 %(imgs)s
437 Input images.
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, )
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
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)
457 labels : :obj:`list`
458 maps_img[..., labels[n]] is the region that has been used to extract
459 signal region_signals[:, n].
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
468 """
469 maps_img = _utils.check_niimg_4d(maps_img)
470 imgs = _utils.check_niimg_4d(imgs)
472 _check_shape_and_affine_compatibility(imgs, maps_img, 3)
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)
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 )
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
521 return region_signals, list(labels)
524def signals_to_img_maps(region_signals, maps_img, mask_img=None):
525 """Create image from region signals defined as maps.
527 region_signals, mask_img must have the same shapes and affines.
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:
535 .. code-block:: python
537 region_signals.shape[1] == maps_img.shape[-1]
539 maps_img : Niimg-like object
540 See :ref:`extracting_data`.
541 Region definitions using maps.
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.
548 Returns
549 -------
550 img : :class:`nibabel.nifti1.Nifti1Image`
551 Reconstructed image. affine and shape are those of maps_img.
553 See Also
554 --------
555 nilearn.regions.signals_to_img_labels
556 nilearn.regions.img_to_signals_maps
557 nilearn.maskers.NiftiMapsMasker
559 """
560 maps_img = _utils.check_niimg_4d(maps_img)
561 maps_data = safe_get_data(maps_img, ensure_finite=True)
563 maps_mask = np.ones(maps_data.shape[:3], dtype=bool)
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]
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 )
582def _trim_maps(maps, mask, keep_empty=False, order="F"):
583 """Crop maps using a mask.
585 No consistency check is performed (esp. on affine). Every required check
586 must be performed before calling this function.
588 Parameters
589 ----------
590 maps : :class:`numpy.ndarray`
591 Set of maps, defining some regions.
593 mask : :class:`numpy.ndarray`
594 Definition of a mask. The shape must match that of a single map.
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.
601 order : "F" or "C", default="F"
602 Ordering of the output maps array (trimmed_maps).
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.
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.
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].
623 """
624 maps = maps.copy()
625 sums = abs(maps[_utils.as_ndarray(mask, dtype=bool), :]).sum(axis=0)
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)
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
644 indices = (
645 np.arange(trimmed_maps.shape[-1], dtype=int)
646 if keep_empty
647 else np.where(sums > 0)[0]
648 )
650 return trimmed_maps, maps_mask, indices