"""NIfTI-backed volume slicers with linked UI controls for Skyline."""
import nibabel as nib
import numpy as np
from dipy.io.utils import has_rgb_dtype, is_rgb_compatible_data, unpack_rgb_array
from dipy.utils.logging import logger
from dipy.utils.optpkg import optional_package
from dipy.viz.skyline.UI.elements import (
dropdown,
render_group,
segmented_switch,
thin_slider,
toggle_button,
two_disk_slider,
)
from dipy.viz.skyline.UI.theme import THEME
from dipy.viz.skyline.render.renderer import (
Visualization,
format_affine_info,
slice_slider_bounds,
slice_slider_values_from_state,
slice_state_from_slider_values,
)
fury_trip_msg = (
"Skyline requires Fury version 2.0.0 or higher."
" Please upgrade Fury by `pip install -U fury --pre` to use Skyline."
)
fury, has_fury_v2, _ = optional_package(
"fury",
min_version="2.0.0",
trip_msg=fury_trip_msg,
)
if has_fury_v2:
from fury.actor import (
set_group_opacity,
set_group_visibility,
show_slices,
volume_slicer,
)
from fury.colormap import distinguishable_colormap
from fury.lib import gfx
imgui_bundle, has_imgui, _ = optional_package(
"imgui_bundle", min_version="1.92.600", max_version="1.92.801"
)
if has_imgui:
imgui = imgui_bundle.imgui
[docs]
def create_image_visualization(
input,
idx,
*,
interpolation="linear",
render_callback=None,
opacity=100,
rgb=None,
value_percentiles=(2, 98),
colormap="Gray",
sync_callabck=None,
):
"""Create an Image3D visualization from already-loaded image data.
Parameters
----------
input : tuple
Tuple of ``(data, affine, filename)`` or ``(data, affine)`` where
``data`` is a nibabel image or ndarray. The filename is a display label.
idx : int
Index of the image for naming purposes when filename is not provided.
interpolation : str, optional
Interpolation method for volume rendering. Options are "linear" or "nearest".
render_callback : callable, optional
Callback function to be called after rendering.
opacity : int, optional
Opacity of the volume rendering.
rgb : bool or None, optional
``None``: auto-detect from structured NIfTI ``DT_RGB24``
dtype; show toggle for other 4D volumes with 3 or 4 channels.
``True``: force RGB mode. ``False``: never treat as RGB.
value_percentiles : tuple, optional
Percentiles for intensity value range. For example, (2, 98) will set the
intensity range to be between the 2nd and 98th percentiles of the image
intensities.
colormap : str, optional
The colormap to use for rendering. Options include "Gray", "Inferno", "Magma",
"Plasma", and "Viridis". This parameter is ignored if rgb=True.
sync_callabck : callable, optional
Callback function to synchronize slice positions across visualizations.
Returns
-------
Image3D
The created Image3D object.
Raises
------
ValueError
If the input is not a tuple of length 2 or 3.
"""
if not isinstance(input, tuple) or len(input) not in (2, 3):
raise ValueError(
"Input must be a tuple containing (data, affine, filename) or "
"(data, affine) for image visualization."
)
if len(input) == 2:
data, affine = input
filename = f"Image_{idx}"
else:
data, affine, filename = input
return Image3D(
filename,
data,
affine=affine,
interpolation=interpolation,
render_callback=render_callback,
opacity=opacity,
rgb=rgb,
value_percentiles=value_percentiles,
colormap=colormap,
sync_callabck=sync_callabck,
)
[docs]
class Image3D(Visualization):
"""A NIfTI-backed volume slicer with linked opacity and colormap controls.
Renders orthogonal image slices with ``fury.actor.volume_slicer`` and
exposes opacity, slice position, intensity range, colormap, and (for
non-RGB 4D data) directional-volume controls through ``render_widgets``.
Parameters
----------
name : str
Display name used in the Skyline UI.
volume : nibabel.spatialimages.SpatialImage or ndarray
Input image or array with shape ``(X, Y, Z)`` or ``(X, Y, Z, N)``.
affine : ndarray, optional
Rendering affine; uses the image affine when omitted for image inputs.
interpolation : str, optional
Slice interpolation mode (``"linear"`` or ``"nearest"``).
render_callback : callable, optional
Callback used to request a render/update.
opacity : int, optional
Slice opacity in percent, expected in ``[0, 100]``.
rgb : bool or None, optional
``None``: auto-detect from structured NIfTI ``DT_RGB24``
dtype; show toggle for other 4D volumes with 3 or 4 channels.
``True``: force RGB mode. ``False``: never treat as RGB.
Colormap and directional-volume controls are ignored when RGB.
value_percentiles : tuple(float, float), optional
Low/high percentiles used to compute scalar intensity limits.
colormap : str, optional
Colormap used for scalar volumes; ignored when ``rgb`` is True.
sync_callabck : callable, optional
Callback used to synchronize state across views.
"""
def __init__(
self,
name,
volume,
*,
affine=None,
interpolation="linear",
render_callback=None,
opacity=100,
rgb=None,
value_percentiles=(2, 98),
colormap="Gray",
sync_callabck=None,
):
"""Initialize the volume slicer visualization.
Parameters
----------
name : str
Display name used in the Skyline UI.
volume : nibabel.spatialimages.SpatialImage or ndarray
Input image or array with shape ``(X, Y, Z)`` or ``(X, Y, Z, N)``.
affine : ndarray, optional
Rendering affine; uses the image affine when omitted for image inputs.
interpolation : str, optional
Slice interpolation mode (``"linear"`` or ``"nearest"``).
render_callback : callable, optional
Callback used to request a render/update.
opacity : int, optional
Slice opacity in percent, expected in ``[0, 100]``.
rgb : bool or None, optional
``None``: auto-detect from structured NIfTI ``DT_RGB24``
dtype; show toggle for other 4D volumes with 3 or 4 channels.
``True``: force RGB mode. ``False``: never treat as RGB.
value_percentiles : tuple(float, float), optional
Low/high percentiles used to compute scalar intensity limits.
colormap : str, optional
Colormap used for scalar volumes; ignored when ``rgb`` is True.
sync_callabck : callable, optional
Callback used to synchronize state across views.
"""
self._img = (
volume if isinstance(volume, nib.spatialimages.SpatialImage) else None
)
if self._img is not None:
self.dwi = np.asanyarray(self._img.dataobj)
self.affine = self._img.affine if affine is None else affine
else:
self.dwi = volume
self.affine = affine
self._rgb_user = rgb
if rgb is None:
rgb = has_rgb_dtype(self.dwi)
self.dwi = unpack_rgb_array(self.dwi)
self._rgb_capable = is_rgb_compatible_data(self.dwi)
if rgb and not self._rgb_capable:
logger.error(
"RGB mode requires a 4D volume whose last dimension "
"is 3 (RGB) or 4 (RGBA). Falling back to rgb=False."
)
rgb = False
self.rgb = rgb
self._has_directions = self.dwi.ndim == 4 and not rgb
self._volume_idx = 0
self.interpolation = interpolation or "linear"
self._value_percentiles = value_percentiles
self._colormap_options = (
"Gray",
"Inferno",
"Magma",
"Plasma",
"Viridis",
"Cool",
"Hot",
"Bone",
"Copper",
"Pink",
"Spring",
"Summer",
"Autumn",
"Winter",
"Jet",
"Cividis",
"Distinct",
"Divergent",
)
self.colormap = colormap
self._picked_voxel = None
self._picked_intensity = None
self._slice_visibility = [True, True, True]
self._synchronize = True
self._sync_callabck = sync_callabck
super().__init__(name, render_callback)
self._create_slicer_actor()
self.opacity = opacity
def _pick_voxel(self, event):
"""Record the picked voxel index and intensity from a pointer event.
Parameters
----------
event : Event
Interaction event from the renderer callback.
"""
info = event.pick_info
voxel = info["index"]
self._picked_voxel = voxel
self._picked_intensity = self.active_volume[voxel]
def _create_slicer_actor(self):
"""Create the volume-slicer actor for the active volume.
Builds a new ``volume_slicer`` for :attr:`active_volume`, applies the
current colormap, recomputes the bounds and slice state, attaches the
voxel-pick handler, shows the initial slices, and requests a render.
"""
volume = self.active_volume
self._slicer = volume_slicer(
volume,
affine=self.affine,
interpolation=self.interpolation,
alpha_mode="bayer",
depth_write=True,
)
self._apply_colormap(self.colormap)
self.bounds = self._slicer.get_bounding_box()
if getattr(self, "state", None) is None:
self.state = np.mean(self.bounds, axis=0)
else:
self.state = np.clip(self.state, self.bounds[0], self.bounds[1])
self._slicer.add_event_handler(self._pick_voxel, "pointer_down")
show_slices(self._slicer, self.state)
self.render()
def _is_divergent_colormap(self):
"""Return whether the active colormap is the divergent colormap.
Returns
-------
bool
True when the active colormap is divergent.
"""
return self.colormap.lower() == "divergent"
def _is_distinct_colormap(self):
"""Return whether the active colormap is the distinct colormap.
Returns
-------
bool
True when the active colormap is distinct.
"""
return self.colormap.lower() == "distinct"
def _value_range_from_percentile(self, volume):
"""Compute the scalar intensity range of ``volume`` from percentiles.
Parameters
----------
volume : ndarray
Input image volume with shape ``(X, Y, Z)`` or ``(X, Y, Z, N)``.
Returns
-------
vmin : float
Intensity value at the lower percentile in :attr:`_value_percentiles`.
vmax : float
Intensity value at the upper percentile in :attr:`_value_percentiles`.
"""
p_low, p_high = self._value_percentiles
vmin, vmax = np.percentile(volume, (p_low, p_high))
return vmin, vmax
def _apply_colormap(self, colormap):
"""Apply ``colormap`` to the slicer actor and update the intensity range.
Recomputes :attr:`value_range` from the active volume, then assigns
the colormap to every slice actor's material. ``"Gray"`` uses the raw
scalar clim; ``"Divergent"`` maps a signed range centered on zero and
switches interpolation to nearest-neighbor; ``"Distinct"`` assigns a
discrete per-label colormap and also switches interpolation to
nearest-neighbor; any other name is looked up on ``fury.lib.gfx.cm``.
Parameters
----------
colormap : str
Name of the colormap to apply.
"""
self.colormap = colormap
self.value_range = self._value_range_from_percentile(self.active_volume)
if self.colormap.lower() == "gray":
for actor in self._slicer.children:
actor.material.map = None
actor.material.clim = self.value_range
elif self.colormap.lower() == "divergent":
map_colors = np.array([[0, 0, 1], [1, 0, 0]], dtype=np.float32)
map = gfx.cm.create_colormap(map_colors, n=2)
max_abs = np.max(np.abs(self.active_volume))
if max_abs == 0:
max_abs = 1.0
for actor in self._slicer.children:
actor.material.map = map
actor.material.clim = (-float(max_abs), float(max_abs))
self.interpolation = "nearest"
actor.material.interpolation = "nearest"
elif self.colormap.lower() == "distinct":
map_colors = np.asarray(
distinguishable_colormap(nb_colors=256), dtype=np.float32
)
map = gfx.cm.create_colormap(map_colors, n=256)
for actor in self._slicer.children:
actor.material.map = map
actor.material.interpolation = "nearest"
self.interpolation = "nearest"
actor.material.clim = self.value_range
else:
for actor in self._slicer.children:
actor.material.map = getattr(gfx.cm, self.colormap.lower())
actor.material.clim = self.value_range
@property
def actor(self):
"""The volume-slicer actor rendering the current slices.
Returns
-------
VolumeSlicer
The actor of the image visualization.
"""
return self._slicer
@property
def active_volume(self):
"""The 3D volume currently used for slicing and display.
Returns
-------
ndarray
``dwi[..., volume_idx]`` when the source data has a fourth
(directional) axis and RGB mode is disabled, otherwise ``dwi``.
"""
return self.dwi[..., self._volume_idx] if self._has_directions else self.dwi
def _populate_info(self):
"""Build the informational text describing the loaded volume.
Returns
-------
str
Multi-line text with volume dimensions, direction count (if
any), data type, and affine information (if available).
"""
np.set_printoptions(suppress=True, precision=2)
shape = self._img.shape if self._img is not None else self.dwi.shape
dtype = self._img.get_data_dtype() if self._img is not None else self.dwi.dtype
info = f"Dimensions: {shape[:3]}"
if self._has_directions:
info += f"\nDirections: {self.dwi.shape[3]}"
info += f"\nData Type: {dtype}"
if self.affine is not None:
info += "\n" + format_affine_info(self.affine)
np.set_printoptions()
return info
[docs]
def update_state(self, new_state):
"""Synchronize slice positions and volume index from another view.
Only applies when :attr:`_synchronize` is enabled. Updates the
displayed slices to ``new_state[:3]``; if a fourth value is present
and the volume has that many directions, also switches the active
directional volume, rebuilding the slicer actor.
Parameters
----------
new_state : array-like
New synchronized state as ``(x, y, z)`` or
``(x, y, z, volume_idx)``.
"""
if self._synchronize:
self.state = new_state[:3]
self.apply_scene_op(show_slices, self._slicer, self.state)
if (
len(new_state) == 4
and self._has_directions
and self.dwi.shape[-1] > new_state[3]
):
new_volume_idx = int(new_state[3])
if new_volume_idx != self._volume_idx:
self._volume_idx = new_volume_idx
self.apply_scene_op(self._create_slicer_actor)
def _set_opacity(self, opacity):
"""Set the slice opacity and toggle depth-write and blend mode.
Below 100% opacity, depth writes are disabled and the alpha mode
switches to ``"blend"``; at 100% opacity, depth writes are
re-enabled and the mode switches back to ``"bayer"``.
Parameters
----------
opacity : int
Slice opacity in percent, expected in ``[0, 100]``.
"""
set_group_opacity(self._slicer, opacity / 100.0)
if opacity < 100:
for actor in self._slicer.children:
actor.material.depth_write = False
actor.material.alpha_mode = "blend"
else:
for actor in self._slicer.children:
actor.material.depth_write = True
actor.material.alpha_mode = "bayer"
def _set_slice_state(self, visibility, state):
"""Update per-axis slice visibility and slice positions.
No-op if the slicer actor has not been created yet.
Parameters
----------
visibility : tuple(bool, bool, bool)
Per-axis visibility flags for X/Y/Z slices.
state : array-like
Current slice state for X/Y/Z.
"""
if self._slicer is None:
return
set_group_visibility(self._slicer, visibility)
show_slices(self._slicer, state)
def _set_clim(self, value_range):
"""Set the scalar display range on every slice actor's material.
No-op if the slicer actor has not been created yet.
Parameters
----------
value_range : tuple(float, float)
Scalar range used for display intensity limits.
"""
if self._slicer is None:
return
for actor in self._slicer.children:
actor.material.clim = value_range
def _set_interpolation(self, interpolation):
"""Set the slice interpolation mode on every slice actor's material.
No-op if the slicer actor has not been created yet.
Parameters
----------
interpolation : str
Slice interpolation mode (``"linear"`` or ``"nearest"``).
"""
if self._slicer is None:
return
for actor in self._slicer.children:
actor.material.interpolation = interpolation
self.interpolation = interpolation
if not has_fury_v2:
create_image_visualization = Image3D = fury