From 99e8530e26b19cffa0f43e6a7f66613f5d315466 Mon Sep 17 00:00:00 2001 From: kwagstyl Date: Thu, 3 Aug 2023 08:50:23 +0100 Subject: [PATCH] [MRG] [ENH] Radiological view for cross-sectional MRI plotting (#3172) * radiological view * add plotting radiological example * Update whats_new.rst * Update nilearn/plotting/displays/_axes.py Co-authored-by: Gensollen * Update examples/01_plotting/plot_demo_plotting.py Co-authored-by: Gensollen * Update nilearn/plotting/img_plotting.py Co-authored-by: Gensollen * Update nilearn/plotting/img_plotting.py Co-authored-by: Gensollen * Update nilearn/plotting/img_plotting.py Co-authored-by: Gensollen * Update nilearn/plotting/displays/_axes.py Co-authored-by: Gensollen * line removed * indent corrected * update latest.rst * Update whats_new.rst * Update nilearn/plotting/displays/_axes.py Co-authored-by: bthirion * added spaces * update spacing * update formatting * added name * formatting * formatting * formatting fixes * Update _axes.py * Update docs.py * doc string shortcut * Update nilearn/_utils/docs.py Co-authored-by: Gensollen * Update nilearn/plotting/img_plotting.py Co-authored-by: Gensollen * Update img_plotting.py * Update docs.py * update docs * Fix merge of docs.py * Remove extra default statement * Resolve formatting issues * Add radiological argument to GlassBrainAxes * Add smoke tests for relevant plotting funcs * Remove unneeded argument * Improve testing * Update whatsnew * Change test data --------- Co-authored-by: Gensollen Co-authored-by: bthirion Co-authored-by: ymzayek --- CITATION.cff | 3 + doc/changes/latest.rst | 2 + examples/01_plotting/plot_demo_plotting.py | 8 +++ nilearn/_utils/docs.py | 8 +++ nilearn/plotting/displays/_axes.py | 19 ++++-- nilearn/plotting/glass_brain.py | 2 +- nilearn/plotting/img_plotting.py | 59 +++++++++++++------ .../test_img_plotting/test_img_plotting.py | 20 +++++++ .../test_img_plotting/test_plot_markers.py | 7 +++ .../test_img_plotting/test_plot_prob_atlas.py | 11 ++++ 10 files changed, 114 insertions(+), 25 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index bc44a5e210..e73b8ada54 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -247,6 +247,9 @@ authors: - given-names: Koen family-names: Helwegen website: https://github.com/koenhelwegen + - given-names: Konrad + family-names: Wagstyl + website: https://github.com/kwagstyl - given-names: Konstantin family-names: Shmelkov website: https://github.com/kshmelkov diff --git a/doc/changes/latest.rst b/doc/changes/latest.rst index 94f74801a4..87e07bc683 100644 --- a/doc/changes/latest.rst +++ b/doc/changes/latest.rst @@ -8,6 +8,8 @@ NEW --- +- Volume plotting functions like :func:`~plotting.plot_img` now have an optional ``radiological`` parameter, defaulting to ``False``. If ``True``, this will invert the x-axis and ``L`` and ``R`` annotations to confirm to radiological conventional view. (:gh:`3172` by `Konrad Wagstyl`_ and `Yasmin Mzayek`_). + Fixes ----- - Fix bug in ``nilearn.plotting.surf_plotting._plot_surf_matplotlib`` that would make vertices transparent when saving in PDF or SVG format (:gh:`3860` by `Mathieu Dugré`_). diff --git a/examples/01_plotting/plot_demo_plotting.py b/examples/01_plotting/plot_demo_plotting.py index f34ab0be21..bc5d9b1702 100644 --- a/examples/01_plotting/plot_demo_plotting.py +++ b/examples/01_plotting/plot_demo_plotting.py @@ -47,6 +47,7 @@ threshold=3, title="plot_stat_map", cut_coords=[36, -27, 66]) + ############################################################################### # Making interactive visualizations with function `view_img` # ---------------------------------------------------------- @@ -106,6 +107,13 @@ # Visualizing mean image (3D) plotting.plot_epi(mean_haxby_img, title="plot_epi") +# It's also possible to visualize volumes in a LR-flipped "radiological" view +# Just set radiological=True +plotting.plot_stat_map(stat_img, + threshold=3, title="plot_stat_map", + cut_coords=[36, -27, 66], + radiological=True) + ############################################################################### # A call to plotting.show is needed to display the plots when running # in script mode (ie outside IPython) diff --git a/nilearn/_utils/docs.py b/nilearn/_utils/docs.py index 224416a7a0..6ffe4fa378 100644 --- a/nilearn/_utils/docs.py +++ b/nilearn/_utils/docs.py @@ -600,6 +600,14 @@ def custom_function(vertices): and the display is closed. """ +# radiological +docdict['radiological'] = """ +radiological : :obj:`bool`, default=False + Invert x axis and R L labels to plot sections as a radiological view. + If False (default), the left hemisphere is on the left of a coronal image. + If True, left hemisphere is on the right. +""" + # random_state docdict['random_state'] = """ random_state : :obj:`int` or RandomState, optional diff --git a/nilearn/plotting/displays/_axes.py b/nilearn/plotting/displays/_axes.py index a25bcc26f0..cac9240d3e 100644 --- a/nilearn/plotting/displays/_axes.py +++ b/nilearn/plotting/displays/_axes.py @@ -25,14 +25,16 @@ class BaseAxes: coord : :obj:`float` The coordinate along the direction of the cut. + %(radiological)s """ - def __init__(self, ax, direction, coord): + def __init__(self, ax, direction, coord, radiological=False): self.ax = ax self.direction = direction self.coord = coord self._object_bounds = list() self.shape = None + self.radiological = radiological def transform_to_2d(self, data, affine): """Transform to a 2D.""" @@ -109,7 +111,13 @@ def draw_left_right(self, size, bg_color, **kwargs): if self.direction in 'xlr': return ax = self.ax - ax.text(.1, .95, 'L', + annotation_on_left = "L" + annotation_on_right = "R" + if self.radiological: + ax.invert_xaxis() + annotation_on_left = "R" + annotation_on_right = "L" + ax.text(.1, .95, annotation_on_left, transform=ax.transAxes, horizontalalignment='left', verticalalignment='top', @@ -118,7 +126,7 @@ def draw_left_right(self, size, bg_color, **kwargs): ec=bg_color, fc=bg_color, alpha=1), **kwargs) - ax.text(.9, .95, 'R', + ax.text(.9, .95, annotation_on_right, transform=ax.transAxes, horizontalalignment='right', verticalalignment='top', @@ -349,8 +357,9 @@ class GlassBrainAxes(BaseAxes): """ - def __init__(self, ax, direction, coord, plot_abs=True, **kwargs): - super().__init__(ax, direction, coord) + def __init__(self, ax, direction, coord, plot_abs=True, + radiological=False, **kwargs): + super().__init__(ax, direction, coord, radiological=radiological) self._plot_abs = plot_abs if ax is not None: object_bounds = plot_brain_schematics(ax, direction, **kwargs) diff --git a/nilearn/plotting/glass_brain.py b/nilearn/plotting/glass_brain.py index b5c0c6b3c3..af5b9aafc8 100644 --- a/nilearn/plotting/glass_brain.py +++ b/nilearn/plotting/glass_brain.py @@ -140,7 +140,7 @@ def _get_object_bounds(json_content, transform): def plot_brain_schematics(ax, direction, **kwargs): - """Create matplotlib patches from a json custom format and plots them \ + """Create matplotlib patches from a json custom format and plot them \ on a matplotlib Axes. Parameters diff --git a/nilearn/plotting/img_plotting.py b/nilearn/plotting/img_plotting.py index 8e4f2bf65f..36b2f866e4 100644 --- a/nilearn/plotting/img_plotting.py +++ b/nilearn/plotting/img_plotting.py @@ -126,6 +126,7 @@ def _plot_img_with_bg(img, bg_img=None, cut_coords=None, cbar_tick_format="%.2g", brain_color=(0.5, 0.5, 0.5), decimals=False, + radiological=False, **kwargs): """Refer to the docstring of plot_img for parameters not listed below. @@ -193,6 +194,7 @@ def _plot_img_with_bg(img, bg_img=None, cut_coords=None, black_bg=black_bg, colorbar=colorbar, brain_color=brain_color, + radiological=radiological, ) if bg_img is not None: bg_img = _utils.check_niimg_3d(bg_img) @@ -227,7 +229,7 @@ def plot_img(img, cut_coords=None, output_file=None, display_mode='ortho', annotate=True, draw_cross=True, black_bg=False, colorbar=False, cbar_tick_format="%.2g", resampling_interpolation='continuous', - bg_img=None, vmin=None, vmax=None, **kwargs): + bg_img=None, vmin=None, vmax=None, radiological=False, **kwargs): """Plot cuts of a given image. By default Frontal, Axial, and Lateral. @@ -263,6 +265,7 @@ def plot_img(img, cut_coords=None, output_file=None, display_mode='ortho', Default=None. %(vmin)s %(vmax)s + %(radiological)s kwargs : extra keyword arguments, optional Extra keyword arguments passed to matplotlib.pyplot.imshow. @@ -276,7 +279,8 @@ def plot_img(img, cut_coords=None, output_file=None, display_mode='ortho', resampling_interpolation=resampling_interpolation, black_bg=black_bg, colorbar=colorbar, cbar_tick_format=cbar_tick_format, - bg_img=bg_img, vmin=vmin, vmax=vmax, **kwargs) + bg_img=bg_img, vmin=vmin, vmax=vmax, radiological=radiological, + **kwargs) return display @@ -418,8 +422,8 @@ def plot_anat(anat_img=MNI152TEMPLATE, cut_coords=None, output_file=None, display_mode='ortho', figure=None, axes=None, title=None, annotate=True, threshold=None, draw_cross=True, black_bg='auto', dim='auto', cmap=plt.cm.gray, - colorbar=False, cbar_tick_format="%.2g", vmin=None, - vmax=None, **kwargs): + colorbar=False, cbar_tick_format="%.2g", radiological=False, + vmin=None, vmax=None, **kwargs): """Plot cuts of an anatomical image. By default 3 cuts: Frontal, Axial, and Lateral. @@ -453,6 +457,7 @@ def plot_anat(anat_img=MNI152TEMPLATE, cut_coords=None, Controls how to format the tick labels of the colorbar. Ex: use "%%i" to display as integers. Default is '%%.2g' for scientific notation. + %(radiological)s %(vmin)s %(vmax)s @@ -479,7 +484,8 @@ def plot_anat(anat_img=MNI152TEMPLATE, cut_coords=None, threshold=threshold, annotate=annotate, draw_cross=draw_cross, black_bg=black_bg, colorbar=colorbar, cbar_tick_format=cbar_tick_format, - vmin=vmin, vmax=vmax, cmap=cmap, **kwargs) + vmin=vmin, vmax=vmax, cmap=cmap, + radiological=radiological, **kwargs) return display @@ -488,7 +494,8 @@ def plot_epi(epi_img=None, cut_coords=None, output_file=None, display_mode='ortho', figure=None, axes=None, title=None, annotate=True, draw_cross=True, black_bg=True, colorbar=False, cbar_tick_format="%.2g", - cmap=plt.cm.nipy_spectral, vmin=None, vmax=None, **kwargs): + cmap=plt.cm.nipy_spectral, vmin=None, vmax=None, + radiological=False, **kwargs): """Plot cuts of an EPI image. By default 3 cuts: Frontal, Axial, and Lateral. @@ -518,6 +525,7 @@ def plot_epi(epi_img=None, cut_coords=None, output_file=None, Default=`plt.cm.nipy_spectral`. %(vmin)s %(vmax)s + %(radiological)s Notes ----- @@ -530,7 +538,8 @@ def plot_epi(epi_img=None, cut_coords=None, output_file=None, threshold=None, annotate=annotate, draw_cross=draw_cross, black_bg=black_bg, colorbar=colorbar, cbar_tick_format=cbar_tick_format, - cmap=cmap, vmin=vmin, vmax=vmax, **kwargs) + cmap=cmap, vmin=vmin, vmax=vmax, + radiological=radiological, **kwargs) return display @@ -588,7 +597,7 @@ def plot_roi(roi_img, bg_img=MNI152TEMPLATE, cut_coords=None, threshold=0.5, alpha=0.7, cmap=plt.cm.gist_ncar, dim='auto', colorbar=False, cbar_tick_format="%i", vmin=None, vmax=None, resampling_interpolation='nearest', view_type='continuous', - linewidths=2.5, **kwargs): + linewidths=2.5, radiological=False, **kwargs): """Plot cuts of an ROI/mask image. By default 3 cuts: Frontal, Axial, and Lateral. @@ -642,6 +651,7 @@ def plot_roi(roi_img, bg_img=MNI152TEMPLATE, cut_coords=None, Default='continuous'. %(linewidths)s Default=2.5. + %(radiological)s Notes ----- @@ -677,7 +687,8 @@ def plot_roi(roi_img, bg_img=MNI152TEMPLATE, cut_coords=None, threshold=threshold, bg_vmin=bg_vmin, bg_vmax=bg_vmax, resampling_interpolation=resampling_interpolation, colorbar=colorbar, cbar_tick_format=cbar_tick_format, - alpha=alpha, cmap=cmap, vmin=vmin, vmax=vmax, **kwargs) + alpha=alpha, cmap=cmap, vmin=vmin, vmax=vmax, + radiological=radiological, **kwargs) if view_type == 'contours': display = _plot_roi_contours(display, img, cmap=cmap, alpha=alpha, @@ -694,7 +705,7 @@ def plot_prob_atlas(maps_img, bg_img=MNI152TEMPLATE, view_type='auto', draw_cross=True, black_bg='auto', dim='auto', colorbar=False, cmap=plt.cm.gist_rainbow, vmin=None, vmax=None, - alpha=0.7, **kwargs): + alpha=0.7, radiological=False, **kwargs): """Plot the probabilistic atlases onto the anatomical image \ by default MNI template. @@ -762,6 +773,7 @@ def plot_prob_atlas(maps_img, bg_img=MNI152TEMPLATE, view_type='auto', alpha : float between 0 and 1, optional Alpha sets the transparency of the color inside the filled contours. Default=0.7. + %(radiological)s See Also -------- @@ -772,7 +784,8 @@ def plot_prob_atlas(maps_img, bg_img=MNI152TEMPLATE, view_type='auto', display_mode=display_mode, figure=figure, axes=axes, title=title, annotate=annotate, draw_cross=draw_cross, - black_bg=black_bg, dim=dim, **kwargs) + black_bg=black_bg, dim=dim, radiological=radiological, + **kwargs) maps_img = _utils.check_niimg_4d(maps_img) n_maps = maps_img.shape[3] @@ -871,7 +884,7 @@ def plot_stat_map(stat_map_img, bg_img=MNI152TEMPLATE, cut_coords=None, title=None, threshold=1e-6, annotate=True, draw_cross=True, black_bg='auto', cmap=cm.cold_hot, symmetric_cbar="auto", dim='auto', vmax=None, resampling_interpolation='continuous', - **kwargs): + radiological=False, **kwargs): """Plot cuts of an ROI/mask image. By default 3 cuts: Frontal, Axial, and Lateral. @@ -916,6 +929,7 @@ def plot_stat_map(stat_map_img, bg_img=MNI152TEMPLATE, cut_coords=None, %(vmax)s %(resampling_interpolation)s Default='continuous'. + %(radiological)s Notes ----- @@ -951,7 +965,8 @@ def plot_stat_map(stat_map_img, bg_img=MNI152TEMPLATE, cut_coords=None, bg_vmin=bg_vmin, bg_vmax=bg_vmax, cmap=cmap, vmin=vmin, vmax=vmax, colorbar=colorbar, cbar_tick_format=cbar_tick_format, cbar_vmin=cbar_vmin, cbar_vmax=cbar_vmax, - resampling_interpolation=resampling_interpolation, **kwargs) + resampling_interpolation=resampling_interpolation, + radiological=radiological, **kwargs) return display @@ -969,6 +984,7 @@ def plot_glass_brain(stat_map_img, plot_abs=True, symmetric_cbar="auto", resampling_interpolation='continuous', + radiological=False, **kwargs): """Plot 2d projections of an ROI/mask image (by default 3 projections: Frontal, Axial, and Lateral). The brain glass schematics @@ -1025,7 +1041,8 @@ def plot_glass_brain(stat_map_img, Default='auto'. %(resampling_interpolation)s Default='continuous'. - + %(radiological)s + Notes ----- Arrays should be passed in numpy convention: (x, y, z) ordered. @@ -1062,7 +1079,8 @@ def display_factory(display_mode): black_bg=black_bg, threshold=threshold, cmap=cmap, colorbar=colorbar, cbar_tick_format=cbar_tick_format, display_factory=display_factory, vmin=vmin, vmax=vmax, cbar_vmin=cbar_vmin, cbar_vmax=cbar_vmax, - resampling_interpolation=resampling_interpolation, **kwargs) + resampling_interpolation=resampling_interpolation, + radiological=radiological, **kwargs) if stat_map_img is None and 'l' in display.axes: display.axes['l'].ax.invert_xaxis() @@ -1081,7 +1099,7 @@ def plot_connectome(adjacency_matrix, node_coords, annotate=True, black_bg=False, alpha=0.7, edge_kwargs=None, node_kwargs=None, - colorbar=False): + colorbar=False, radiological=False): """Plot connectome on top of the brain glass schematics. The plotted image should be in MNI space for this function to work @@ -1149,6 +1167,7 @@ def plot_connectome(adjacency_matrix, node_coords, the nodes in one go. %(colorbar)s Default=False. + %(radiological)s See Also -------- @@ -1163,7 +1182,7 @@ def plot_connectome(adjacency_matrix, node_coords, figure=figure, axes=axes, title=title, annotate=annotate, black_bg=black_bg, - alpha=alpha) + alpha=alpha, radiological=radiological) display.add_graph(adjacency_matrix, node_coords, node_color=node_color, node_size=node_size, @@ -1187,7 +1206,7 @@ def plot_markers(node_values, node_coords, node_size='auto', node_threshold=None, alpha=0.7, output_file=None, display_mode="ortho", figure=None, axes=None, title=None, annotate=True, black_bg=False, node_kwargs=None, - colorbar=True): + colorbar=True, radiological=False): """Plot network nodes (markers) on top of the brain glass schematics. Nodes are color coded according to provided nodal measure. Nodal measure @@ -1243,6 +1262,7 @@ def plot_markers(node_values, node_coords, node_size='auto', the nodes in one go %(colorbar)s Default=True. + %(radiological)s """ node_values = np.array(node_values).flatten() @@ -1258,7 +1278,8 @@ def plot_markers(node_values, node_coords, node_size='auto', display = plot_glass_brain(None, display_mode=display_mode, figure=figure, axes=axes, title=title, - annotate=annotate, black_bg=black_bg) + annotate=annotate, black_bg=black_bg, + radiological=radiological) if isinstance(node_size, str) and node_size == 'auto': node_size = min(1e4 / len(node_coords), 100) diff --git a/nilearn/plotting/tests/test_img_plotting/test_img_plotting.py b/nilearn/plotting/tests/test_img_plotting/test_img_plotting.py index f829443923..435047bce3 100644 --- a/nilearn/plotting/tests/test_img_plotting/test_img_plotting.py +++ b/nilearn/plotting/tests/test_img_plotting/test_img_plotting.py @@ -224,3 +224,23 @@ def test_plotting_functions_with_display_mode_tiled( else: plot_func(testdata_3d_for_plotting["img"], display_mode="tiled") plt.close() + + +@pytest.mark.parametrize( + "plotting_func", + [ + plot_img, + plot_anat, + plot_stat_map, + plot_roi, + plot_epi, + plot_glass_brain, + ], +) +def test_plotting_functions_radiological_view( + testdata_3d_for_plotting, plotting_func +): + """Smoke test for radiological view.""" + result = plotting_func(testdata_3d_for_plotting["img"], radiological=True) + assert result.axes.get("y").radiological is True + plt.close() diff --git a/nilearn/plotting/tests/test_img_plotting/test_plot_markers.py b/nilearn/plotting/tests/test_img_plotting/test_plot_markers.py index 39f13cfa79..3716c7869c 100644 --- a/nilearn/plotting/tests/test_img_plotting/test_plot_markers.py +++ b/nilearn/plotting/tests/test_img_plotting/test_plot_markers.py @@ -181,3 +181,10 @@ def test_plot_markers_single_node_value(): """Regression test for Issue #3253.""" plot_markers([1], [[1, 1, 1]]) plt.close() + + +def test_plot_markers_radiological_view(): + """Smoke test for radiological view.""" + result = plot_markers([1], [[1, 1, 1]], radiological=True) + assert result.axes.get("y").radiological is True + plt.close() diff --git a/nilearn/plotting/tests/test_img_plotting/test_plot_prob_atlas.py b/nilearn/plotting/tests/test_img_plotting/test_plot_prob_atlas.py index 622f180df1..35c839c8b1 100644 --- a/nilearn/plotting/tests/test_img_plotting/test_plot_prob_atlas.py +++ b/nilearn/plotting/tests/test_img_plotting/test_plot_prob_atlas.py @@ -28,3 +28,14 @@ def test_plot_prob_atlas(params): data_rng = rng.normal(size=(6, 8, 10, 5)) plot_prob_atlas(Nifti1Image(data_rng, np.eye(4)), **params) plt.close() + + +def test_plot_prob_atlas_radiological_view(): + """Smoke test for radiological view.""" + rng = np.random.RandomState(42) + data_rng = rng.normal(size=(6, 8, 10, 5)) + result = plot_prob_atlas( + Nifti1Image(data_rng, np.eye(4)), radiological=True + ) + assert result.axes.get("y").radiological is True + plt.close()