# -*- coding: utf-8 -*-
# Copyright (C) 2024-2026 by Brendt Wohlberg <brendt@ieee.org>
# All rights reserved. BSD 3-clause License.
# This file is part of the komplot package. Details of the copyright
# and user license can be found in the 'LICENSE.txt' file distributed
# with the package.
"""Volume viewer."""
from dataclasses import dataclass
from typing import Optional, Tuple, Union
import numpy as np
from matplotlib.axes import Axes
from matplotlib.backend_bases import Event
from matplotlib.colors import Colormap, Normalize
from matplotlib.widgets import Slider
from mpl_toolkits.axes_grid1.axes_divider import (
AxesDivider, # type: ignore[import-untyped]
)
from ._event import FigureEventManager, figure_event_manager
from ._imview import ImageView, ImageViewEventManager, _image_view
try:
pass
except ImportError:
HAVE_MPLCRS = False
else:
HAVE_MPLCRS = True
# kw_only only supported from Python 3.10
KW_ONLY = {"kw_only": True} if "kw_only" in (dataclass.__kwdefaults__ or {}) else {}
@dataclass(repr=False, **KW_ONLY)
class VolumeView(ImageView):
"""State of volview plot.
Args:
figure: Plot figure.
axes: Plot axes.
axesimage: The :class:`~matplotlib.image.AxesImage` associated
with the colorbar.
divider: The :class:`~mpl_toolkits.axes_grid1.axes_divider.AxesDivider`
used to create axes for the colorbar.
cbar_axes: The axes of the colorbar.
volume: The volume array.
slice_index: The index of the volume slice.
slider_axes: The axes of the volume slice index selection slider.
slider: The volume slice index selection slider widget.
"""
volume: Optional[np.ndarray] = None
slice_index: int = 0
slider_axes: Optional[Axes] = None
slider: Optional[Slider] = None
[docs]
def set_volume_slice(
self, index: int, update_slider: bool = True, suppress_events: bool = False
):
"""Set the volume slice index.
Set the volume slice index.
Args:
index: Index of volume slice to display.
update_slider: If ``True`` also update the volume slice
selection sider widget to the selected index.
suppress_events: If ``True``, suppress events generated by
the slider update.
"""
self.slice_index = index
if self.slider is not None and update_slider:
if suppress_events:
self.slider.eventson = False
self.slider.set_val(self.slice_index)
if suppress_events:
self.slider.eventson = True
im = self.axesimage
assert self.volume is not None
im.set_data(self.volume[self.slice_index])
self.axes.figure.canvas.draw_idle()
msg = f"Slice index {self.slice_index} of {self.volume.shape[0]}"
self.toolbar_message(msg)
class VolumeViewEventManager(ImageViewEventManager):
"""Manager for axes-based events.
Manage mouse scroll and slider widget events. The following
interactive features are supported:
*Mouse wheel scroll*
Zoom in or out at current cursor location.
*Mouse wheel scroll with shift key depressed*
Shift the displayed slice when displaying a volume.
*Click or drag slider widget*
Change the displayed slice when displaying a volume.
*Mouse wheel scroll in bottom half of colorbar*
Increase or decrease colormap :code:`vmin`.
*Mouse wheel scroll in top half of colorbar*
Increase or decrease colormap :code:`vmax`.
"""
plot: VolumeView
def __init__(
self,
axes: Axes,
fig_event_man: FigureEventManager,
iview: VolumeView,
zoom_scale: float = 2.0,
cmap_delta: float = 0.02,
):
"""
Args:
axes: Axes to which this manager is attached.
fig_event_man: The figure event manage for the figure to
which :code:`axes` belong.
iview: A plot state of type :class:`VolumeView`.
zoom_scale: Scaling factor for mouse wheel zoom.
cmap_delta: Fraction of colormap range for vmin/vmax shifts.
"""
super().__init__(axes, fig_event_man, iview, zoom_scale=zoom_scale)
if iview.slider is not None:
iview.slider.on_changed(lambda val: self.slider_event_handler(int(val)))
[docs]
def shift_slice_event_handler(self, event: Event):
"""Handle shift slice event."""
assert hasattr(event, "button")
index = self.plot.slice_index
assert self.plot.volume is not None
if event.button == "up":
if self.plot.slice_index < self.plot.volume.shape[0] - 1:
index += 1
elif event.button == "down":
if self.plot.slice_index > 0:
index -= 1
self.plot.set_volume_slice(index, update_slider=True, suppress_events=True)
[docs]
def slider_event_handler(self, val: int):
"""Calback for slider widget changes."""
if self.fig_event_man.slice_share_axes: # Slice display axes are shared
# Iterate over all slice display axes for this figure
for ssax in self.fig_event_man.slice_share_axes:
# Change displayed slice for all shared axes
axevman = self.fig_event_man.get_axevman_for_axes(ssax)
axevman.plot.set_volume_slice(val, update_slider=False)
# Ensure that slider is updated for axes other than the one on
# which the slice change was triggered
if ssax != self.axes:
axevman.plot.slider.eventson = False
axevman.plot.slider.set_val(val)
axevman.plot.slider.eventson = True
else: # Slice display axes are not shared
self.plot.set_volume_slice(val, update_slider=False)
def _create_slider(
divider: AxesDivider, volume: np.ndarray, orient: str, pad: float = 0.1
) -> Tuple[Axes, Slider]:
"""Create a volume slice slider attached to the displayed slice."""
pos = "left" if orient == "vertical" else "bottom"
sax = divider.append_axes(pos, size="5%", pad=pad)
slider = Slider(
ax=sax,
label="Slice",
valmin=0,
valmax=volume.shape[0] - 1,
valstep=range(volume.shape[0]),
valinit=0,
orientation=orient, # type: ignore[arg-type]
)
return sax, slider
def volview(
volume: np.ndarray,
*,
slice_axis: int = 0,
interpolation: str = "nearest",
origin: str = "upper",
vmin_quantile: float = 0.0,
norm: Optional[Normalize] = None,
show_cbar: Optional[bool] = False,
cmap: Optional[Union[Colormap, str]] = None,
title: Optional[str] = None,
figsize: Optional[Tuple[int, int]] = None,
fignum: Optional[int] = None,
ax: Optional[Axes] = None,
) -> VolumeView:
"""Display a slice of a volume.
Display a slice of a volume. Pixel values are displayed when the
pointer is over valid image data. Supports the following features:
- If an axes is not specified (via parameter :code:`ax`), a new
figure and axes are created, and
:meth:`~matplotlib.figure.Figure.show` is called after drawing the
plot.
- Interactive features provided by :class:`FigureEventManager` and
:class:`VolumeViewEventManager` are supported in addition to the
standard `matplotlib <https://matplotlib.org/>`__
`interactive features <https://matplotlib.org/stable/users/explain/figure/interactive.html#interactive-navigation>`__.
Args:
volume: Volume to display. It should be three or four dimensional.
If four dimensional, the final dimension after exclusion of
the axis identified by :code:`slice_axis` represents color
and opacity channels, should have size 3 or 4.
slice_axis: The axis of :code:`volume`, if any, from which to
select volume slices for display.
interpolation: Specify type of interpolation used to display
image (see :code:`interpolation` parameter of
:meth:`~matplotlib.axes.Axes.imshow`).
origin: Specify the origin of the image support. Valid values are
"upper" and "lower" (see :code:`origin` parameter of
:meth:`~matplotlib.axes.Axes.imshow`). The location of the
plot x-ticks indicates which of these options was selected.
vmin_quantile: Specify color map :code:`vmin` and :code:`vmax`
based on pixel value quantiles. The default of 0.0
corresponds to setting :code:`vmin` and :code:`vmax` to the
minimum and maximum pixel value respectively. If it is
non-zero, :code:`vmin` and :code:`vmax` are set to the
:code:`vmin_quantile` quantile and the 1 -
:code:`vmin_quantile` respectively.
norm: Specify the :class:`~matplotlib.colors.Normalize` instance
used to scale pixel values for input to the color map. If not
``None``, it is used to define the color map range instead of
the :code:`vmin` and :code:`vmax` determined by
:code:`vmin_quantile`.
show_cbar: Flag indicating whether to display a colorbar. If set
to ``None``, create an invisible colorbar so that the image
occupies the same amount of space in a subplot as one with a
visible colorbar.
cmap: Color map for image or volume slices. If none specifed,
defaults to :code:`matplotlib.cm.Greys_r` for monochrome
image.
title: Figure title.
figsize: Specify dimensions of figure to be creaed as a tuple
(`width`, `height`) in inches.
fignum: Figure number of figure to be created.
ax: Plot in specified axes instead of creating one.
Returns:
Volume view state object.
Raises:
ValueError: If the input array is not of the required shape.
"""
if slice_axis < 0:
slice_axis = volume.ndim + slice_axis
slice_shape = volume.shape[0:slice_axis] + volume.shape[slice_axis + 1 :] # type: ignore
if volume.ndim not in (3, 4) or (
volume.ndim == 4 and slice_shape[-1] not in (3, 4)
):
raise ValueError(
f"Argument volume shape {volume.shape} not appropriate for volume slice "
f"display with slice_axis={slice_axis}."
)
assert isinstance(slice_axis, int)
volume = np.transpose(
volume,
(slice_axis,)
+ tuple(range(0, slice_axis))
+ tuple(range(slice_axis + 1, volume.ndim)), # move slice axis to position 0
)
image = volume[0] # current slice
if norm is None:
if vmin_quantile == 0.0:
vmin, vmax = volume.min(), volume.max()
else:
vmin, vmax = np.quantile(volume, [vmin_quantile, 1.0 - vmin_quantile]) # type: ignore
kwargs = {"vmin": vmin, "vmax": vmax}
else:
kwargs = {"norm": norm}
fig, ax, show, axim, divider, cax, cbar_orient = _image_view(
image,
interpolation=interpolation,
origin=origin,
imshow_kwargs=kwargs,
make_divider=True,
show_cbar=show_cbar,
cmap=cmap,
title=title,
figsize=figsize,
fignum=fignum,
ax=ax,
)
pad = 0.35 if show_cbar and cbar_orient == "horizontal" else 0.1
if image.shape[0] >= 2 * image.shape[1]:
slider_orient = "vertical"
pad = 0.25
else:
slider_orient = "horizontal"
sax, vol_slider = _create_slider(divider, volume, orient=slider_orient, pad=pad)
if show:
fig.show()
vlvw = VolumeView(
figure=fig,
axes=ax,
axesimage=axim,
divider=divider,
cbar_axes=cax,
volume=volume,
slider_axes=sax,
slider=vol_slider,
)
if not hasattr(fig, "_event_manager"):
fem = FigureEventManager(fig) # constructed object attaches itself to fig
else:
fem = figure_event_manager(fig)
if not hasattr(ax, "_event_manager"):
VolumeViewEventManager(
ax, fem, vlvw
) # constructed object attaches itself to ax
return vlvw