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

1"""Functions to compare volume or surface images.""" 

2 

3import warnings 

4 

5import matplotlib.pyplot as plt 

6import numpy as np 

7from matplotlib import gridspec 

8from scipy import stats 

9 

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 

25 

26 

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. 

41 

42 The first plot displays linear correlation between :term:`voxel` values. 

43 The second plot superimposes histograms to compare values distribution. 

44 

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. 

51 

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. 

59 

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. 

70 

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. 

74 

75 log : :obj:`bool`, default=True 

76 Passed to plt.hist. 

77 

78 ref_label : :obj:`str`, default='image set 1' 

79 Name of reference images. 

80 

81 src_label : :obj:`str`, default='image set 2' 

82 Name of source images. 

83 

84 output_dir : :obj:`str` or None, default=None 

85 Directory where plotted figures will be stored. 

86 

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. 

90 

91 %(colorbar)s 

92 default=True 

93 

94 Returns 

95 ------- 

96 corrs : :class:`numpy.ndarray` 

97 Pearson correlation between the images. 

98 

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 ) 

111 

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" 

116 

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 ) 

129 

130 masker = _sanitize_masker(masker, image_type, ref_imgs[0]) 

131 

132 corrs = [] 

133 

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() 

145 

146 ref_data, src_data = _extract_data_2_images( 

147 ref_img, src_img, masker=masker 

148 ) 

149 

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 

156 

157 corr = stats.pearsonr(ref_data, src_data)[0] 

158 corrs.append(corr) 

159 

160 # when plot_hist is False creates two empty axes 

161 # and doesn't plot anything 

162 if plot_hist: 

163 gridsize = 100 

164 

165 lims = [ 

166 np.min(ref_data), 

167 np.max(ref_data), 

168 np.min(src_data), 

169 np.max(src_data), 

170 ] 

171 

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 ) 

180 

181 if colorbar: 

182 cb = fig.colorbar(hb, ax=ax1) 

183 cb.set_label("log10(N)") 

184 

185 x = np.linspace(*lims[:2], num=gridsize) 

186 

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) 

193 

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 ) 

200 

201 ax2.set_title("Histogram of imgs values") 

202 ax2.grid("on") 

203 ax2.legend(loc="best") 

204 

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) 

209 

210 return corrs 

211 

212 

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. 

229 

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. 

233 

234 .. note:: 

235 

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. 

239 

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. 

243 

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. 

248 

249 The degree of disagreement is therefore evaluated 

250 by the perpendicular distance of points from the x-axis. 

251 

252 Parameters 

253 ---------- 

254 ref_img : 3D Niimg-like object or :obj:`~nilearn.surface.SurfaceImage` 

255 Reference image. 

256 

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. 

262 

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. 

272 

273 ref_label : :obj:`str`, default='reference image' 

274 Name of reference image. 

275 

276 src_label : :obj:`str`, default='source image' 

277 Name of source image. 

278 

279 %(figure)s 

280 

281 %(title)s 

282 

283 %(cmap)s 

284 default="inferno" 

285 

286 %(colorbar)s 

287 default=True 

288 

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. 

295 

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. 

301 

302 %(output_file)s 

303 

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>`_. 

309 

310 

311 References 

312 ---------- 

313 

314 .. footbibliography:: 

315 

316 """ 

317 data_ref, data_src = _extract_data_2_images( 

318 ref_img, src_img, masker=masker 

319 ) 

320 

321 mean = np.mean([data_ref, data_src], axis=0) 

322 diff = data_ref - data_src 

323 

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] 

332 

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 ) 

342 

343 if isinstance(gridsize, int): 

344 gridsize = (gridsize, gridsize) 

345 

346 if figure is None: 

347 figure = plt.figure(figsize=(6, 6)) 

348 

349 gs0 = gridspec.GridSpec(1, 1) 

350 

351 gs = gridspec.GridSpecFromSubplotSpec( 

352 5, 6, subplot_spec=gs0[0], hspace=0.5, wspace=0.8 

353 ) 

354 

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) 

369 

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})") 

382 

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})") 

395 

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]) 

401 

402 cb = figure.colorbar(hb, cax=ax4) 

403 cb.set_label("log10(N)") 

404 

405 return save_figure_if_needed(figure, output_file) 

406 

407 

408def _extract_data_2_images(ref_img, src_img, masker=None): 

409 """Return data of 2 images as 2 vectors. 

410 

411 Parameters 

412 ---------- 

413 ref_img : 3D Niimg-like object or :obj:`~nilearn.surface.SurfaceImage` 

414 Reference image. 

415 

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. 

420 

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. 

430 

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) 

436 

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) 

444 

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 ) 

451 

452 masker = _sanitize_masker(masker, image_type, ref_img) 

453 

454 data_ref = masker.transform(ref_img) 

455 data_src = masker.transform(src_img) 

456 

457 data_ref = data_ref.ravel() 

458 data_src = data_src.ravel() 

459 

460 return data_ref, data_src 

461 

462 

463def _sanitize_masker(masker, image_type, ref_img): 

464 """Return an appropriate fitted masker. 

465 

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() 

477 

478 check_compatibility_mask_and_images(masker, ref_img) 

479 

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 ) 

491 

492 if not masker.__sklearn_is_fitted__(): 

493 masker.fit(ref_img) 

494 

495 return masker