Coverage for nilearn/plotting/img_comparison.py: 0%
141 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-16 12:32 +0200
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-16 12:32 +0200
1"""Functions to compare volume or surface images."""
3import warnings
5import matplotlib.pyplot as plt
6import numpy as np
7from matplotlib import gridspec
8from scipy import stats
10from nilearn import DEFAULT_SEQUENTIAL_CMAP
11from nilearn._utils import (
12 check_niimg_3d,
13 constrained_layout_kwargs,
14 fill_doc,
15)
16from nilearn._utils.logger import find_stack_level
17from nilearn._utils.masker_validation import (
18 check_compatibility_mask_and_images,
19)
20from nilearn.maskers import NiftiMasker, SurfaceMasker
21from nilearn.plotting._utils import save_figure_if_needed
22from nilearn.surface.surface import SurfaceImage
23from nilearn.surface.utils import check_polymesh_equal
24from nilearn.typing import NiimgLike
27@fill_doc
28def plot_img_comparison(
29 ref_imgs,
30 src_imgs,
31 masker=None,
32 plot_hist=True,
33 log=True,
34 ref_label="image set 1",
35 src_label="image set 2",
36 output_dir=None,
37 axes=None,
38 colorbar=True,
39):
40 """Create plots to compare two lists of images and measure correlation.
42 The first plot displays linear correlation between :term:`voxel` values.
43 The second plot superimposes histograms to compare values distribution.
45 Parameters
46 ----------
47 ref_img : 3D Niimg-like object or :obj:`~nilearn.surface.SurfaceImage` \
48 or a :obj:`list` of \
49 3D Niimg-like object or :obj:`~nilearn.surface.SurfaceImage`
50 Reference image.
52 src_img : 3D Niimg-like object or :obj:`~nilearn.surface.SurfaceImage` \
53 or a :obj:`list` of \
54 3D Niimg-like object or :obj:`~nilearn.surface.SurfaceImage`
55 Source image.
56 Its type must match that of the ``ref_img``.
57 If the source image is Niimg-Like,
58 it will be resampled to match that or the source image.
60 masker : 3D Niimg-like binary mask or \
61 :obj:`~nilearn.maskers.NiftiMasker` or \
62 binary :obj:`~nilearn.surface.SurfaceImage` or \
63 or :obj:`~nilearn.maskers.SurfaceMasker` or \
64 None, default = None
65 Mask to be used on data.
66 Its type must be compatible with that of the ``ref_img``.
67 If ``None`` is passed,
68 an appropriate masker will be fitted
69 on the first reference image.
71 plot_hist : :obj:`bool`, default=True
72 If True then histograms of each img in ref_imgs will be plotted
73 along-side the histogram of the corresponding image in src_imgs.
75 log : :obj:`bool`, default=True
76 Passed to plt.hist.
78 ref_label : :obj:`str`, default='image set 1'
79 Name of reference images.
81 src_label : :obj:`str`, default='image set 2'
82 Name of source images.
84 output_dir : :obj:`str` or None, default=None
85 Directory where plotted figures will be stored.
87 axes : :obj:`list` of two matplotlib Axes objects, or None, default=None
88 Can receive a list of the form [ax1, ax2] to render the plots.
89 By default new axes will be created.
91 %(colorbar)s
92 default=True
94 Returns
95 -------
96 corrs : :class:`numpy.ndarray`
97 Pearson correlation between the images.
99 """
100 # Cast to list
101 if isinstance(ref_imgs, (*NiimgLike, SurfaceImage)):
102 ref_imgs = [ref_imgs]
103 if isinstance(src_imgs, (*NiimgLike, SurfaceImage)):
104 src_imgs = [src_imgs]
105 if not isinstance(ref_imgs, list) or not isinstance(src_imgs, list):
106 raise TypeError(
107 "'ref_imgs' and 'src_imgs' "
108 "must both be list of 3D Niimg-like or SurfaceImage.\n"
109 f"Got {type(ref_imgs)=} and {type(src_imgs)=}."
110 )
112 if all(isinstance(x, NiimgLike) for x in ref_imgs) and all(
113 isinstance(x, NiimgLike) for x in src_imgs
114 ):
115 image_type = "volume"
117 elif all(isinstance(x, SurfaceImage) for x in ref_imgs) and all(
118 isinstance(x, SurfaceImage) for x in src_imgs
119 ):
120 image_type = "surface"
121 else:
122 types_ref_imgs = {type(x) for x in ref_imgs}
123 types_src_imgs = {type(x) for x in src_imgs}
124 raise TypeError(
125 "'ref_imgs' and 'src_imgs' "
126 "must both be list of only 3D Niimg-like or SurfaceImage.\n"
127 f"Got {types_ref_imgs=} and {types_src_imgs=}."
128 )
130 masker = _sanitize_masker(masker, image_type, ref_imgs[0])
132 corrs = []
134 for i, (ref_img, src_img) in enumerate(zip(ref_imgs, src_imgs)):
135 if axes is None:
136 fig, (ax1, ax2) = plt.subplots(
137 1,
138 2,
139 figsize=(12, 5),
140 **constrained_layout_kwargs(),
141 )
142 else:
143 (ax1, ax2) = axes
144 fig = ax1.get_figure()
146 ref_data, src_data = _extract_data_2_images(
147 ref_img, src_img, masker=masker
148 )
150 if ref_data.shape != src_data.shape:
151 warnings.warn(
152 "Images are not shape-compatible",
153 stacklevel=find_stack_level(),
154 )
155 return
157 corr = stats.pearsonr(ref_data, src_data)[0]
158 corrs.append(corr)
160 # when plot_hist is False creates two empty axes
161 # and doesn't plot anything
162 if plot_hist:
163 gridsize = 100
165 lims = [
166 np.min(ref_data),
167 np.max(ref_data),
168 np.min(src_data),
169 np.max(src_data),
170 ]
172 hb = ax1.hexbin(
173 ref_data,
174 src_data,
175 bins="log",
176 cmap=DEFAULT_SEQUENTIAL_CMAP,
177 gridsize=gridsize,
178 extent=lims,
179 )
181 if colorbar:
182 cb = fig.colorbar(hb, ax=ax1)
183 cb.set_label("log10(N)")
185 x = np.linspace(*lims[:2], num=gridsize)
187 ax1.plot(x, x, linestyle="--", c="grey")
188 ax1.set_title(f"Pearson's R: {corr:.2f}")
189 ax1.grid("on")
190 ax1.set_xlabel(ref_label)
191 ax1.set_ylabel(src_label)
192 ax1.axis(lims)
194 ax2.hist(
195 ref_data, alpha=0.6, bins=gridsize, log=log, label=ref_label
196 )
197 ax2.hist(
198 src_data, alpha=0.6, bins=gridsize, log=log, label=src_label
199 )
201 ax2.set_title("Histogram of imgs values")
202 ax2.grid("on")
203 ax2.legend(loc="best")
205 output_file = (
206 output_dir / f"{int(i):04}.png" if output_dir else None
207 )
208 save_figure_if_needed(ax1, output_file)
210 return corrs
213@fill_doc
214def plot_bland_altman(
215 ref_img,
216 src_img,
217 masker=None,
218 ref_label="reference image",
219 src_label="source image",
220 figure=None,
221 title=None,
222 cmap=DEFAULT_SEQUENTIAL_CMAP,
223 colorbar=True,
224 gridsize=100,
225 lims=None,
226 output_file=None,
227):
228 """Create a Bland-Altman plot between 2 images.
230 Plot the the 2D distribution of voxel-wise differences
231 as a function of the voxel-wise mean,
232 along with an histogram for the distribution of each.
234 .. note::
236 Bland-Altman plots show
237 the difference between the statistic values (y-axis)
238 against the mean statistic value (x-axis) for all voxels.
240 The plots provide an assessment of the level of agreement
241 between two images about the magnitude of the statistic value
242 observed at each voxel.
244 If two images were in perfect agreement,
245 all points on the Bland-Altman plot would lie on the x-axis,
246 since the difference between the statistic values
247 at each voxel would be zero.
249 The degree of disagreement is therefore evaluated
250 by the perpendicular distance of points from the x-axis.
252 Parameters
253 ----------
254 ref_img : 3D Niimg-like object or :obj:`~nilearn.surface.SurfaceImage`
255 Reference image.
257 src_img : 3D Niimg-like object or :obj:`~nilearn.surface.SurfaceImage`
258 Source image.
259 Its type must match that of the ``ref_img``.
260 If the source image is Niimg-Like,
261 it will be resampled to match that or the source image.
263 masker : 3D Niimg-like binary mask or \
264 :obj:`~nilearn.maskers.NiftiMasker` or \
265 binary :obj:`~nilearn.surface.SurfaceImage` or \
266 or :obj:`~nilearn.maskers.SurfaceMasker` or \
267 None, default = None
268 Mask to be used on data.
269 Its type must be compatible with that of the ``ref_img``.
270 If ``None`` is passed,
271 an appropriate masker will be fitted on the reference image.
273 ref_label : :obj:`str`, default='reference image'
274 Name of reference image.
276 src_label : :obj:`str`, default='source image'
277 Name of source image.
279 %(figure)s
281 %(title)s
283 %(cmap)s
284 default="inferno"
286 %(colorbar)s
287 default=True
289 gridsize : :obj:`int` or :obj:`tuple` of 2 :obj:`int`, default=100
290 Dimension of the grid on which to display the main plot.
291 If a single value is passed, then the grid is square.
292 If a tuple is passed, the first value corresponds
293 to the length of the x axis,
294 and the second value corresponds to the length of the y axis.
296 lims : A :obj:`list` or :obj:`tuple` of 4 :obj:`int` or None, default=None
297 Determines the limit the central hexbin plot
298 and the marginal histograms.
299 Values in the list or tuple are: [-lim_x, lim_x, -lim_y, lim_y].
300 If ``None`` is passed values are determined based on the data.
302 %(output_file)s
304 Notes
305 -----
306 This function and the plot description was adapted
307 from :footcite:t:`Bowring2019`
308 and its associated `code base <https://github.com/AlexBowring/Software_Comparison/blob/master/figures/lib/bland_altman.py>`_.
311 References
312 ----------
314 .. footbibliography::
316 """
317 data_ref, data_src = _extract_data_2_images(
318 ref_img, src_img, masker=masker
319 )
321 mean = np.mean([data_ref, data_src], axis=0)
322 diff = data_ref - data_src
324 if lims is None:
325 lim_x = np.max(np.abs(mean))
326 if lim_x == 0:
327 lim_x = 1
328 lim_y = np.max(np.abs(diff))
329 if lim_y == 0:
330 lim_y = 1
331 lims = [-lim_x, lim_x, -lim_y, lim_y]
333 if (
334 not isinstance(lims, (list, tuple))
335 or len(lims) != 4
336 or any(x == 0 for x in lims)
337 ):
338 raise TypeError(
339 "'lims' must be a list or tuple of length == 4, "
340 "with all values different from 0."
341 )
343 if isinstance(gridsize, int):
344 gridsize = (gridsize, gridsize)
346 if figure is None:
347 figure = plt.figure(figsize=(6, 6))
349 gs0 = gridspec.GridSpec(1, 1)
351 gs = gridspec.GridSpecFromSubplotSpec(
352 5, 6, subplot_spec=gs0[0], hspace=0.5, wspace=0.8
353 )
355 ax1 = figure.add_subplot(gs[:-1, 1:5])
356 hb = ax1.hexbin(
357 mean,
358 diff,
359 bins="log",
360 cmap=cmap,
361 gridsize=gridsize,
362 extent=lims,
363 )
364 ax1.axis(lims)
365 ax1.axhline(linewidth=1, color="r")
366 ax1.axvline(linewidth=1, color="r")
367 if title:
368 ax1.set_title(title)
370 ax2 = figure.add_subplot(gs[:-1, 0], xticklabels=[], sharey=ax1)
371 ax2.set_ylim(lims[2:])
372 ax2.hist(
373 diff,
374 bins=gridsize[0],
375 range=lims[2:],
376 histtype="stepfilled",
377 orientation="horizontal",
378 color="gray",
379 )
380 ax2.invert_xaxis()
381 ax2.set_ylabel(f"Difference : ({ref_label} - {src_label})")
383 ax3 = figure.add_subplot(gs[-1, 1:5], yticklabels=[], sharex=ax1)
384 ax3.hist(
385 mean,
386 bins=gridsize[1],
387 range=lims[:2],
388 histtype="stepfilled",
389 orientation="vertical",
390 color="gray",
391 )
392 ax3.set_xlim(lims[:2])
393 ax3.invert_yaxis()
394 ax3.set_xlabel(f"Average : mean({ref_label}, {src_label})")
396 if colorbar:
397 ax4 = figure.add_subplot(gs[:-1, 5])
398 ax4.set_aspect(20)
399 pos1 = ax4.get_position()
400 ax4.set_position([pos1.x0 - 0.025, pos1.y0, pos1.width, pos1.height])
402 cb = figure.colorbar(hb, cax=ax4)
403 cb.set_label("log10(N)")
405 return save_figure_if_needed(figure, output_file)
408def _extract_data_2_images(ref_img, src_img, masker=None):
409 """Return data of 2 images as 2 vectors.
411 Parameters
412 ----------
413 ref_img : 3D Niimg-like object or :obj:`~nilearn.surface.SurfaceImage`
414 Reference image.
416 src_img : 3D Niimg-like object or :obj:`~nilearn.surface.SurfaceImage`
417 Source image. Its type must match that of the ``ref_img``.
418 If the source image is Niimg-Like,
419 it will be resampled to match that or the source image.
421 masker : 3D Niimg-like binary mask or \
422 :obj:`~nilearn.maskers.NiftiMasker` or \
423 binary :obj:`~nilearn.surface.SurfaceImage` or \
424 or :obj:`~nilearn.maskers.SurfaceMasker` or \
425 None
426 Mask to be used on data.
427 Its type must be compatible with that of the ``ref_img``.
428 If None is passed,
429 an appropriate masker will be fitted on the reference image.
431 """
432 if isinstance(ref_img, NiimgLike) and isinstance(src_img, NiimgLike):
433 image_type = "volume"
434 ref_img = check_niimg_3d(ref_img)
435 src_img = check_niimg_3d(src_img)
437 elif isinstance(ref_img, (SurfaceImage)) and isinstance(
438 src_img, (SurfaceImage)
439 ):
440 image_type = "surface"
441 ref_img.data._check_ndims(1)
442 src_img.data._check_ndims(1)
443 check_polymesh_equal(ref_img.mesh, src_img.mesh)
445 else:
446 raise TypeError(
447 "'ref_img' and 'src_img' "
448 "must both be Niimg-like or SurfaceImage.\n"
449 f"Got {type(src_img)=} and {type(ref_img)=}."
450 )
452 masker = _sanitize_masker(masker, image_type, ref_img)
454 data_ref = masker.transform(ref_img)
455 data_src = masker.transform(src_img)
457 data_ref = data_ref.ravel()
458 data_src = data_src.ravel()
460 return data_ref, data_src
463def _sanitize_masker(masker, image_type, ref_img):
464 """Return an appropriate fitted masker.
466 Raise exception
467 if there is type mismatch between the masker and ref_img.
468 """
469 if masker is None:
470 if image_type == "volume":
471 masker = NiftiMasker(
472 target_affine=ref_img.affine,
473 target_shape=ref_img.shape,
474 )
475 else:
476 masker = SurfaceMasker()
478 check_compatibility_mask_and_images(masker, ref_img)
480 if isinstance(masker, NiimgLike):
481 masker = NiftiMasker(
482 mask_img=masker,
483 target_affine=ref_img.affine,
484 target_shape=ref_img.shape,
485 )
486 elif isinstance(masker, SurfaceImage):
487 check_polymesh_equal(ref_img.mesh, masker.mesh)
488 masker = SurfaceMasker(
489 mask_img=masker,
490 )
492 if not masker.__sklearn_is_fitted__():
493 masker.fit(ref_img)
495 return masker