"""SH glyph slicer for Skyline -- the entry point for ODF visualization.
Builds a GPU-accelerated 3-D visualization of orientation distribution
functions (ODFs) from a 4-D array of spherical-harmonic coefficients and a
voxel-to-world affine. ``create_shm_visualization`` unpacks the input tuple
into a :class:`SHGlyph3D`, which owns a :class:`SHSlicer` that builds the
billboard actor via
:func:`~dipy.viz.skyline.render.sh_billboard.sph_glyph_billboard_sliced`
and drives its per-axis slice uniforms. See individual class/method
docstrings for coordinate-space and slicing details.
"""
import numpy as np
from dipy.reconst.shm import calculate_max_order
from dipy.utils.optpkg import optional_package
from dipy.viz.skyline.UI.elements import (
create_numeric_input,
render_group,
thin_slider,
toggle_button,
)
from dipy.viz.skyline.render.renderer import (
Visualization,
affine_voxel_sizes,
format_affine_info,
slice_slider_bounds,
slice_slider_values_from_state,
slice_state_from_slider_values,
)
from dipy.viz.skyline.render.sh_billboard import sph_glyph_billboard_sliced
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 import apply_transformation
from fury.actor import Group
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_shm_visualization(
input,
idx,
*,
render_callback=None,
scale=1.3,
l_max=8,
lut_res=8,
basis_type="descoteaux07",
color_type="orientation",
mask=None,
sync_callback=None,
):
"""Create SH glyph visualization from input.
Parameters
----------
input : tuple
Tuple of one of the following forms:
- ``(coeffs, affine, filename, basis_type)``
- ``(coeffs, affine, filename)``
- ``(coeffs, affine)``
A ``basis_type`` present as the 4th tuple element overrides the
``basis_type`` keyword argument.
idx : int
Index used for naming when filename is not provided.
render_callback : callable, optional
Callback function to be called after rendering.
scale : float, optional
Initial per-glyph scale.
l_max : int, optional
Maximum SH order.
lut_res : int, optional
LUT resolution.
basis_type : str, optional
SH basis convention. Ignored if provided in ``input`` as 4th element.
color_type : str, optional
Color mapping type.
mask : ndarray, optional
Boolean mask of valid voxels.
sync_callback : callable, optional
Callback to trigger when synchronization is available.
Returns
-------
SHGlyph3D
The created SH glyph visualization object.
Raises
------
ValueError
If input is not a tuple of length 2, 3, or 4.
"""
if not isinstance(input, tuple) or len(input) not in (2, 3, 4):
raise ValueError(
"Input must be a tuple containing (coeffs, affine, filename, basis_type), "
"(coeffs, affine, filename), or (coeffs, affine) for SH visualization."
)
if len(input) == 2:
coeffs, affine = input
filename = f"SH_Glyphs_{idx}"
input_basis_type = basis_type
elif len(input) == 3:
coeffs, affine, filename = input
input_basis_type = basis_type
else:
coeffs, affine, filename, input_basis_type = input
return SHGlyph3D(
filename,
coeffs,
affine=affine,
render_callback=render_callback,
scale=scale,
l_max=l_max,
lut_res=lut_res,
basis_type=input_basis_type,
color_type=color_type,
mask=mask,
sync_callback=sync_callback,
)
def _descoteaux_to_fury_standard(coeffs_4d, sh_order):
"""Convert even-order descoteaux07 SH coefficients to Fury's standard basis.
The legacy descoteaux07 basis uses Im(Y) for m>0 and Re(Y) for m<0, while
FURY uses cos(mφ) for m>0 and sin(|m|φ) for m<0. Coefficients satisfy
``c_fury(l, m) = c_desc(l, -m)``.
Parameters
----------
coeffs_4d : ndarray
Volume storing descoteaux07 coefficients along the last axis.
sh_order : int
Maximum even spherical harmonic order present in the volume.
Returns
-------
ndarray
Array with the same leading shape as ``coeffs_4d`` and
``(sh_order + 1) ** 2`` standard-basis coefficients on the last axis.
"""
n_std = (sh_order + 1) ** 2
out = np.zeros(coeffs_4d.shape[:-1] + (n_std,), dtype=coeffs_4d.dtype)
desc_idx = 0
for l_val in range(0, sh_order + 1, 2):
for m in range(-l_val, l_val + 1):
fury_m = -m
fury_idx = l_val * l_val + l_val + fury_m
out[..., fury_idx] = coeffs_4d[..., desc_idx]
desc_idx += 1
return out
[docs]
class SHSlicer:
"""Build and drive the single billboard actor backing an ODF visualization.
Owns the flattened, non-zero-only glyph data (coefficients, model-space
centers, voxel indices) passed to :func:`sph_glyph_billboard_sliced`,
and forwards per-axis slice/visibility/scale/opacity changes to that
actor's material without ever rebuilding the geometry.
Parameters
----------
coeffs_4d : ndarray, shape (X, Y, Z, C)
SH coefficients per voxel. Converted from ``descoteaux07`` to
Fury's standard basis on construction if needed.
scale : float, optional
Uniform billboard size multiplier relative to estimated SH radii.
l_max : int, optional
Maximum SH order to shade. For ``descoteaux``/``descoteaux07``
input, capped to the order implied by ``coeffs_4d``'s last axis
when that is lower; for ``standard`` input it must not exceed that
order (raises ``ValueError`` downstream otherwise).
lut_res : int, optional
Cube-map Hermite LUT resolution per face edge.
mask : ndarray of bool, shape (X, Y, Z), optional
When given, voxels outside the mask are excluded even if their
coefficients are non-zero.
basis_type : {"standard", "descoteaux", "descoteaux07"}, optional
SH basis convention of ``coeffs_4d``.
color_type : {"orientation", "sign"}, optional
Glyph coloring: direction-mapped hue, or a two-color sign split.
"""
def __init__(
self,
coeffs_4d,
*,
scale=1.0,
l_max=8,
lut_res=32,
mask=None,
basis_type="standard",
color_type="orientation",
):
"""Initialize the billboard actor driver.
Parameters
----------
coeffs_4d : ndarray, shape (X, Y, Z, C)
SH coefficients per voxel. Converted from ``descoteaux07`` to
Fury's standard basis on construction if needed.
scale : float, optional
Uniform billboard size multiplier relative to estimated SH radii.
l_max : int, optional
Maximum SH order to shade. For ``descoteaux``/``descoteaux07``
input, capped to the order implied by ``coeffs_4d``'s last axis
when that is lower; for ``standard`` input it must not exceed
that order (raises ``ValueError`` downstream otherwise).
lut_res : int, optional
Cube-map Hermite LUT resolution per face edge.
mask : ndarray of bool, shape (X, Y, Z), optional
When given, voxels outside the mask are excluded even if their
coefficients are non-zero.
basis_type : {"standard", "descoteaux", "descoteaux07"}, optional
SH basis convention of ``coeffs_4d``.
color_type : {"orientation", "sign"}, optional
Glyph coloring: direction-mapped hue, or a two-color sign split.
"""
if basis_type in ("descoteaux", "descoteaux07"):
data_sh_order = calculate_max_order(coeffs_4d.shape[-1])
l_max = min(l_max, data_sh_order)
coeffs_4d = _descoteaux_to_fury_standard(coeffs_4d, l_max)
basis_type = "standard"
self.coeffs_4d = coeffs_4d
self.shape = coeffs_4d.shape[:3]
self.n_coeffs = coeffs_4d.shape[-1]
self.scale = scale
self.l_max = l_max
self.lut_res = lut_res
self.mask = mask
self.basis_type = basis_type
self.color_type = color_type
self._cur = {"x": -1, "y": -1, "z": -1}
self._opacity = 1.0
self.actor = Group()
self._glyph_actor = None
[docs]
def build(self):
"""Build the billboard actor and add it to :attr:`actor`.
Safe to call when every voxel is zero (or masked out): the group
is then left empty and :attr:`_glyph_actor` stays ``None``.
Returns
-------
Group
The (possibly empty) parent group holding the billboard actor.
"""
self._glyph_actor = self._build_volume_actor()
if self._glyph_actor is not None:
self.actor.add(self._glyph_actor)
return self.actor
def _build_volume_actor(self):
"""Flatten non-zero voxels and build the billboard actor for them.
Model-space glyph centers are the raw integer voxel indices
``(ix, iy, iz)``; the caller (:class:`SHGlyph3D`) applies the full
voxel-to-world affine once, as a group transform, on top of this.
Returns
-------
SphGlyphBillboard or None
``None`` when no voxel has non-zero coefficients (after
masking), otherwise the actor from
:func:`sph_glyph_billboard_sliced`.
"""
X, Y, Z = self.shape
flat_coeffs = self.coeffs_4d.reshape(-1, self.n_coeffs)
valid = np.any(flat_coeffs != 0, axis=1)
if self.mask is not None:
valid &= self.mask.ravel()
if not np.any(valid):
return None
ix, iy, iz = np.meshgrid(
np.arange(X, dtype=np.int32),
np.arange(Y, dtype=np.int32),
np.arange(Z, dtype=np.int32),
indexing="ij",
)
voxel_coords = np.column_stack([ix.ravel(), iy.ravel(), iz.ravel()])
centers = voxel_coords.astype(np.float32)
coeffs_valid = flat_coeffs[valid]
centers_valid = centers[valid]
voxel_valid = voxel_coords[valid]
glyph = sph_glyph_billboard_sliced(
coeffs_valid,
centers_valid,
voxel_valid,
scale=self.scale,
l_max=self.l_max,
color_type=self.color_type,
lut_res=self.lut_res,
)
return glyph
[docs]
def set_slice(self, axis, idx):
"""Move the active slice plane on one axis to a world-space position.
A no-op when ``idx`` matches the axis's current position, so
repeated calls from a UI slider don't trigger redundant GPU
uniform uploads.
Parameters
----------
axis : {"x", "y", "z"}
Which per-axis slice-position uniform to update.
idx : float
World-space coordinate of the new slice plane along ``axis``.
"""
if idx == self._cur[axis]:
return
if self._glyph_actor is not None:
attr = f"active_slice_{axis}"
setattr(self._glyph_actor.material, attr, idx)
self._cur[axis] = idx
[docs]
def hide_axis(self, axis):
"""Hide all slices for *axis*.
Parameters
----------
axis : {"x", "y", "z"}
Which axis's slice-visibility uniform to clear.
"""
if self._glyph_actor is not None:
setattr(self._glyph_actor.material, f"vis_{axis}", 0)
self._cur[axis] = -1
[docs]
def show_axis(self, axis):
"""Enable axis visibility.
Parameters
----------
axis : {"x", "y", "z"}
Which axis's slice-visibility uniform to set.
"""
if self._glyph_actor is not None:
setattr(self._glyph_actor.material, f"vis_{axis}", 1)
[docs]
def set_scale(self, new_scale):
"""Update scale on the actor.
Parameters
----------
new_scale : float
New uniform billboard size multiplier.
"""
ratio = float(new_scale) / float(self.scale) if self.scale > 0 else 1.0
if abs(ratio - 1.0) < 1e-6:
return
self.scale = float(new_scale)
a = self._glyph_actor
if a is not None:
a.material.scale = float(new_scale)
a.geometry.normals.data[:, :2] *= ratio
a.geometry.normals.update_full()
[docs]
def set_opacity(self, opacity):
"""Set opacity.
Parameters
----------
opacity : float
Glyph opacity as a fraction, expected in ``[0, 1]``. Below
``1.0`` the material's ``alpha_mode`` switches to ``"blend"``.
"""
self._opacity = float(opacity)
a = self._glyph_actor
if a is not None:
a.material.opacity = float(opacity)
a.material.alpha_mode = "blend" if opacity < 1.0 else "solid"
[docs]
class SHGlyph3D(Visualization):
"""High-level ODF visualization: UI widgets, sync, and slice state.
Wraps a single :class:`SHSlicer` and converts the shared, world-space
``state`` vector (synchronized across every Skyline visualization) into
the per-axis slice positions the billboard shader expects.
Parameters
----------
name : str
Display name used in the Skyline UI.
coeffs : ndarray, shape (X, Y, Z, C)
SH coefficients per voxel.
affine : ndarray, optional
Voxel-to-world affine used to position slices in world coordinates.
When ``None``, ``state``/slice positions are voxel indices instead.
render_callback : callable, optional
Callback used to request a render/update.
scale : float, optional
Per-glyph scale used only when ``affine`` is ``None``; otherwise
the scale is derived from the affine's voxel sizes.
l_max : int, optional
Maximum SH order to shade.
lut_res : int, optional
Cube-map Hermite LUT resolution per face edge.
basis_type : {"standard", "descoteaux", "descoteaux07"}, optional
SH basis convention of ``coeffs``.
color_type : {"orientation", "sign"}, optional
Glyph coloring: direction-mapped hue, or a two-color sign split.
mask : ndarray of bool, optional
Boolean mask of valid voxels.
sync_callback : callable, optional
Callback used to synchronize state across views.
"""
def __init__(
self,
name,
coeffs,
*,
affine=None,
render_callback=None,
scale=2.0,
l_max=8,
lut_res=8,
basis_type="standard",
color_type="orientation",
mask=None,
sync_callback=None,
):
"""Initialize the ODF visualization.
Parameters
----------
name : str
Display name used in the Skyline UI.
coeffs : ndarray, shape (X, Y, Z, C)
SH coefficients per voxel.
affine : ndarray, optional
Voxel-to-world affine used to position slices in world
coordinates. When ``None``, ``state``/slice positions are
voxel indices instead.
render_callback : callable, optional
Callback used to request a render/update.
scale : float, optional
Per-glyph scale used only when ``affine`` is ``None``;
otherwise the scale is derived from the affine's voxel sizes.
l_max : int, optional
Maximum SH order to shade.
lut_res : int, optional
Cube-map Hermite LUT resolution per face edge.
basis_type : {"standard", "descoteaux", "descoteaux07"}, optional
SH basis convention of ``coeffs``.
color_type : {"orientation", "sign"}, optional
Glyph coloring: direction-mapped hue, or a two-color sign split.
mask : ndarray of bool, optional
Boolean mask of valid voxels.
sync_callback : callable, optional
Callback used to synchronize state across views.
"""
self.affine = affine
if self.affine is not None:
default_scale = float(np.mean(affine_voxel_sizes(self.affine)))
else:
default_scale = float(scale)
self.shape = coeffs.shape[:3]
self._slicer = SHSlicer(
coeffs,
scale=default_scale,
l_max=l_max,
lut_res=lut_res,
mask=mask,
basis_type=basis_type,
color_type=color_type,
)
self._slicer.build()
if affine is not None:
self._slicer.actor.transform(self.affine)
super().__init__(name, render_callback)
self._scale = float(default_scale)
self._opacity = 100
self._slice_visibility = [True, True, True]
self._synchronize = True
self._sync_callback = sync_callback
self._last_state = [-1, -1, -1]
lower_bounds = np.zeros(3)
upper_bounds = np.array(coeffs.shape[:3]) - 1
if self.affine is not None:
self.bounds = apply_transformation(
np.array([lower_bounds, upper_bounds]), self.affine
)
self.state = np.asarray(self.bounds).mean(axis=0).astype(int)
else:
self.bounds = np.asarray([lower_bounds, upper_bounds])
self.state = [self.shape[0] // 2, self.shape[1] // 2, self.shape[2] // 2]
self.set_slices()
@property
def actor(self):
"""Group actor to add to the scene; delegates to the slicer.
Returns
-------
Group
Parent group containing the billboard actor.
"""
return self._slicer.actor
def _populate_info(self):
"""Build the multi-line summary shown in the info panel.
Returns
-------
str
Dimensions, SH coefficient count and order, plus voxel sizes,
voxel order, and affine when an affine is available.
"""
info = f"Dimensions: {self.shape}"
info += f"\nSH Coefficients: {self._slicer.n_coeffs}"
info += f"\nSH Order: {self._slicer.l_max}"
if self.affine is not None:
info += "\n" + format_affine_info(self.affine)
return info
def _voxel_from_world_state(self, world_state):
"""Snap a world-space state vector to the nearest in-bounds voxel index.
Parameters
----------
world_state : array-like
World-space state vector to map into voxel coordinates.
Returns
-------
np.ndarray
Integer voxel index, clipped to the volume bounds.
"""
if self.affine is None:
return np.clip(
np.round(world_state).astype(int), 0, np.array(self.shape) - 1
)
voxel = apply_transformation(
np.array([world_state], dtype=np.float32), np.linalg.inv(self.affine)
)[0]
return np.clip(np.round(voxel).astype(int), 0, np.array(self.shape) - 1)
[docs]
def set_slices(self):
"""Push the current ``state`` to the billboard material's slice uniforms.
Snaps ``state`` to the nearest voxel (:meth:`_voxel_from_world_state`),
then forward-transforms that voxel back to world space (when an
affine is present) before writing it to each axis's
``active_slice_*`` uniform. This mirrors how ``Peak3D`` derives its
cross section, and pairs with the vertex shader's own snap-onto-plane
logic to keep the rendered slice crisp for any affine, including
rotated or axis-swapped ones.
"""
voxel = self._voxel_from_world_state(self.state)
if self.affine is not None:
slice_state = apply_transformation(
np.array([voxel], dtype=np.float32), self.affine
)[0]
else:
slice_state = voxel.astype(float)
for i, axis in enumerate(("x", "y", "z")):
self._slicer.set_slice(axis, float(slice_state[i]))
self._last_state[i] = self.state[i]
[docs]
def update_state(self, new_state):
"""Apply a synchronized world-space state from another visualization.
Ignored when :attr:`_synchronize` is off (per-view slice sync toggle).
Parameters
----------
new_state : array-like
New shared world-space (x, y, z) state; only the first 3
components are used.
"""
if self._synchronize:
self.state = new_state[:3]
self.apply_scene_op(self.set_slices)
[docs]
def set_slice_visibility(self):
"""Show/hide each axis's slice per :attr:`_slice_visibility`."""
for i, axis in enumerate(("x", "y", "z")):
if self._slice_visibility[i]:
self._slicer.show_axis(axis)
self._last_state[i] = self.state[i]
else:
self._slicer.hide_axis(axis)
self._last_state[i] = -1
if not has_fury_v2:
create_shm_visualization = SHSlicer = SHGlyph3D = fury