"""Shared FURY window factory and base class for Skyline visualizations."""
from pathlib import Path
import sys
import numpy as np
from dipy.utils.logging import logger
from dipy.utils.optpkg import optional_package
from dipy.viz.skyline.UI.elements import render_section_header
from dipy.viz.skyline.UI.theme import LOGO_SMALL
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 PIL import Image
from fury import window
import glfw
else:
window = fury
Image = fury
glfw = fury
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 affine_voxel_sizes(affine):
"""Return voxel sizes from an affine matrix.
Parameters
----------
affine : ndarray
Voxel-to-world affine.
Returns
-------
ndarray
Per-axis voxel sizes from the affine columns.
"""
return np.linalg.norm(np.asarray(affine)[:3, :3], axis=0)
[docs]
def slice_slider_bounds(shape, *, affine=None):
"""Return affine-aware integer bounds for slice sliders.
Parameters
----------
shape : tuple(int, int, int)
Original spatial data shape.
affine : ndarray, optional
Voxel-to-world affine used to position slices in world coordinates.
Returns
-------
tuple(tuple(int, int), tuple(int, int), tuple(int, int))
Per-axis inclusive slider bounds.
"""
spatial_shape = np.asarray(shape[:3], dtype=float)
if affine is None:
max_bounds = spatial_shape
else:
voxel_sizes = affine_voxel_sizes(affine)
max_bounds = np.where(
voxel_sizes >= 1.0, spatial_shape * voxel_sizes, spatial_shape
)
max_bounds = np.maximum(np.ceil(max_bounds - 1e-12).astype(int), 0)
return tuple((0, int(max_bound)) for max_bound in max_bounds)
[docs]
def slice_state_from_slider_values(slider_values, *, affine=None):
"""Convert slice slider values to slicing state coordinates.
Parameters
----------
slider_values : array-like
Per-axis values displayed by the slice sliders.
affine : ndarray, optional
Voxel-to-world affine used by the visualization.
Returns
-------
ndarray
Slicing state in world coordinates when affine is provided, otherwise
voxel coordinates.
"""
slider_values = np.asarray(slider_values[:3], dtype=float)
if affine is None:
return slider_values
voxel_sizes = affine_voxel_sizes(affine)
scaled_axes = voxel_sizes >= 1.0
voxel_values = slider_values.copy()
np.divide(
slider_values,
voxel_sizes,
out=voxel_values,
where=scaled_axes,
)
return (np.asarray(affine) @ np.r_[voxel_values, 1.0])[:3]
[docs]
def slice_slider_values_from_state(state, *, affine=None):
"""Convert slicing state coordinates to slice slider values.
Parameters
----------
state : array-like
Current slicing state in world coordinates when affine is provided,
otherwise voxel coordinates.
affine : ndarray, optional
Voxel-to-world affine used by the visualization.
Returns
-------
ndarray
Per-axis values to display in slice sliders.
"""
state = np.asarray(state[:3], dtype=float)
if affine is None:
return state
voxel_values = voxel_values_from_slice_state(state, affine=affine)
voxel_sizes = affine_voxel_sizes(affine)
return np.where(voxel_sizes >= 1.0, voxel_values * voxel_sizes, voxel_values)
[docs]
def voxel_values_from_slice_state(state, *, affine=None):
"""Convert slicing state coordinates to voxel coordinates.
Parameters
----------
state : array-like
Current slicing state in world coordinates when affine is provided,
otherwise voxel coordinates.
affine : ndarray, optional
Voxel-to-world affine used by the visualization.
Returns
-------
ndarray
Per-axis voxel coordinates.
"""
state = np.asarray(state[:3], dtype=float)
if affine is None:
return state
return (np.linalg.inv(np.asarray(affine)) @ np.r_[state, 1.0])[:3]
[docs]
class Visualization:
"""Represent ``Visualization`` in Skyline.
Parameters
----------
path : str or Path
Path to the resource on disk.
render_callback : callable, optional
Callback used to request a render/update.
"""
def __init__(self, path, render_callback):
"""Represent ``Visualization`` in Skyline.
Parameters
----------
path : str or Path
Path to the resource on disk.
render_callback : callable, optional
Callback used to request a render/update.
"""
self._render_callback = render_callback
self._scene_op_callback = None
self.path = path if path is not None else "Unnamed Visualization"
if self.__class__.__name__ == "ROI3D":
self.name = f"ROI ({Path(self.path).name})"
elif self.__class__.__name__ == "SHGlyph3D":
self.name = f"ODFs ({Path(self.path).name})"
else:
self.name = Path(self.path).name
self.active = False
self._visible = True
self._info = self._populate_info()
[docs]
def render(self):
"""Request a window redraw through :attr:`_render_callback` when set."""
if self._render_callback is not None:
self._render_callback()
[docs]
def apply_scene_op(self, func, *args, **kwargs):
"""Run ``func`` immediately or defer it via :attr:`_scene_op_callback`."""
if self._scene_op_callback is not None:
self._scene_op_callback(func, *args, **kwargs)
return
func(*args, **kwargs)
def _set_actor_visible(self, visible):
"""Handle set actor visible for ``Visualization``.
Parameters
----------
visible : bool
Whether the actor should be visible.
"""
self.actor.visible = visible
@property
def actor(self):
"""Handle actor for ``Visualization``.
Raises
------
NotImplementedError
if the method is not implemented in the subclass.
"""
raise NotImplementedError("Subclasses must implement the actor property.")
@property
def viz_type(self):
"""Return the visualization type identifier string."""
name = self.__class__.__name__
if name == "Image3D":
return "image"
elif name == "Surface":
return "surface"
elif name == "Peak3D":
return "peak"
elif name == "ROI3D":
return "roi"
elif name in ("Streamline3D", "ClusterStreamline3D"):
return "tractography"
elif name == "SHGlyph3D":
return "sh_glyph"
return None
[docs]
def renderer(self, is_open, *, group_visible=True):
"""Draw the sidebar header and optional widget body for this layer.
Parameters
----------
is_open : bool
Whether the collapsible section should start expanded this frame.
group_visible : bool, optional
Whether the parent group is visible. When False, the actor is
hidden regardless of the individual visibility toggle.
Returns
-------
is_open : bool
Updated expanded state after handling input.
is_removed : bool
True if the user requested removal.
should_enable_group : bool
True if a hidden group must be re-enabled because visibility was toggled.
"""
viz_type = self.viz_type
if viz_type is None:
logger.warning(
f"Visualization type '{self.__class__.__name__}' is not recognized. "
"UI rendering may not be fully functional for this visualization."
)
effective_visible = self._visible and group_visible
is_open, new_visible, is_removed, is_selected = render_section_header(
self.name,
is_open=is_open,
is_visible=effective_visible,
info=self._info,
type=viz_type,
)
should_enable_group = False
if group_visible:
self._visible = new_visible
elif new_visible:
# Group is hidden but user clicked visibility ON
# Enable the group and show this item
self._visible = True
should_enable_group = True
# If group hidden and user kept it off (new_visible=False), do nothing
self.apply_scene_op(self._set_actor_visible, self._visible and group_visible)
self.active = is_selected
if is_open:
padding = 20
imgui.begin_group()
imgui.push_style_var(imgui.StyleVar_.window_padding, (padding, padding / 2))
child_flags = (
imgui.ChildFlags_.always_use_window_padding
| imgui.ChildFlags_.auto_resize_y
)
if imgui.begin_child(f"{self.name}_content_child", (0, 0), child_flags):
self.render_widgets()
imgui.end_child()
imgui.pop_style_var()
imgui.dummy((0, padding / 2))
imgui.end_group()
return is_open, is_removed, should_enable_group
def _populate_info(self):
"""Handle populate info for ``Visualization``.
None
Returns
-------
object
Returned value.
"""
return self.name
[docs]
def create_window(
*,
visualizer_type="standalone",
size=(1200, 1000),
screen_config=None,
title="DIPY SKYLINE",
):
"""Create a FURY ShowManager based on the visualizer type.
Used to host the main scene, optional ImGui overlay, and multi-viewport
layouts in Skyline.
Parameters
----------
visualizer_type : str, optional
Type of visualizer to create. The options are:
- "standalone": A standalone window with full interactivity.
- "gui": A Qt-based GUI window.
- "jupyter": An inline Jupyter notebook visualizer.
- "stealth": An offscreen visualizer without GUI.
size : tuple, optional
Size of the window
screen_config : list, optional
Defines the screen layout. Can be a list of integers (vertical/horizontal
sections) or a list of explicit bounding box tuples (x, y, w, h).
title : str, optional
Window title; in stealth mode may be combined with ``out_dir`` upstream.
Returns
-------
ShowManager
An instance of FURY's ShowManager configured according to the
specified visualizer type.
"""
if visualizer_type == "standalone":
window_type = "default"
elif visualizer_type == "gui":
window_type = "qt"
elif visualizer_type == "jupyter":
window_type = "jupyter"
elif visualizer_type == "stealth":
window_type = "offscreen"
else:
logger.error(
f"Visualizer type '{visualizer_type}' is not recognized. "
"Please provide one of the following: "
"'standalone', 'gui', 'jupyter', 'stealth'."
)
sys.exit(1)
if visualizer_type != "stealth":
show_m = window.ShowManager(
title=title,
size=size,
window_type=window_type,
screen_config=screen_config,
imgui=True,
imgui_draw_function=lambda: None,
pixel_ratio=1.5,
)
if window_type == "default":
with Image.open(LOGO_SMALL) as img:
img = img.convert("RGBA")
glfw.set_window_icon(show_m.window._window, 1, [(img)])
glfw.poll_events()
img.close()
else:
show_m = window.ShowManager(
title=title,
size=size,
window_type=window_type,
screen_config=screen_config,
pixel_ratio=1.5,
imgui=False,
)
if hasattr(show_m, "show_axes_gizmo"):
show_m.show_axes_gizmo(labels=["L", "R", "P", "A", "S", "I"])
logger.info(
"Created visualizer currently assumes Neurological convention for axes."
)
return show_m