Source code for fury.actor.slicer

"""Slicer actor for Fury."""

import numpy as np

from fury.actor import (
    Actor,
    Group,
    Mesh,
    Volume,
    set_group_opacity,
    set_group_visibility,
    show_slices,
)
from fury.actor._billboard import _BillboardEllipsoid, _max_ellipsoids_per_chunk
from fury.colormap import normalize_colors
from fury.geometry import buffer_to_geometry
from fury.lib import (
    Geometry,
    MeshPhongShader,
    Texture,
    VolumeSliceMaterial,
    WorldObject,
    gfx_wgpu,
    register_wgpu_render_function,
)
from fury.material import (
    SphGlyphMaterial,
    VectorFieldArrowMaterial,
    VectorFieldLineMaterial,
    VectorFieldThinLineMaterial,
    _create_vector_field_material,
    validate_opacity,
)
import fury.primitive as fp
from fury.shader import (
    SphGlyphComputeShader,
    VectorFieldArrowShader,
    VectorFieldComputeShader,
    VectorFieldShader,
    VectorFieldThinShader,
)
from fury.utils import (
    create_sh_basis_matrix,
    get_lmax,
    get_n_coeffs,
)


[docs] def data_slicer( data, *, value_range=None, opacity=1.0, interpolation="linear", visibility=(True, True, True), initial_slices=None, alpha_mode="auto", depth_write=False, ): """ Visualize a 3D volume data as a slice. Parameters ---------- data : ndarray, shape (X, Y, Z) or (X, Y, Z, 3) The 3D volume data to be sliced. value_range : tuple, optional The minimum and maximum values for the color mapping. If None, the range is determined from the data. opacity : float, optional The opacity of the slice. Takes values from 0 (fully transparent) to 1 (opaque). interpolation : str, optional The interpolation method for the slice. Options are 'linear' and 'nearest'. visibility : tuple, optional A tuple of three boolean values indicating the visibility of the slices in the x, y, and z dimensions, respectively. initial_slices : tuple, optional A tuple of three initial slice positions in the x, y, and z dimensions, respectively. If None, the slices are initialized to the middle of the volume. alpha_mode : str, optional The alpha mode for the material. Please see the below link for details: https://docs.pygfx.org/stable/_autosummary/materials/pygfx.materials.Material.html#pygfx.materials.Material.alpha_mode. depth_write : bool, optional Whether to write depth information for the material. Returns ------- Group An actor containing the generated slice with the specified properties. """ if value_range is None: value_range = (np.min(data), np.max(data)) if visibility is None: visibility = (True, True, True) if data.ndim < 3 or data.ndim > 4: raise ValueError( "Input data must be 3-dimensional or " "4-dimensional with last dimension of size 3." ) elif data.ndim == 4 and (data.shape[-1] != 3 and data.shape[-1] != 4): raise ValueError("Last dimension must be of size 3 or 4.") opacity = validate_opacity(opacity) data = data.astype(np.float32) data = np.swapaxes(data, 0, 2) data_shape = data.shape if initial_slices is None: initial_slices = ( data_shape[2] // 2, data_shape[1] // 2, data_shape[0] // 2, ) texture = Texture(data, dim=3) slices = [] for dim in [0, 1, 2]: # XYZ abcd = [0, 0, 0, 0] abcd[dim] = -1 abcd[-1] = data_shape[2 - dim] // 2 mat = VolumeSliceMaterial( abcd, clim=value_range, interpolation=interpolation, pick_write=True, alpha_mode=alpha_mode, depth_write=depth_write, ) geo = Geometry(grid=texture) plane = Volume(geo, mat) slices.append(plane) obj = Group(name="Slicer") obj.add(*slices) set_group_visibility(obj, visibility) show_slices(obj, initial_slices) set_group_opacity(obj, opacity) return obj
[docs] class VectorField(WorldObject, Actor): """ Class to visualize a vector field. Parameters ---------- field : ndarray, shape {(X, Y, Z, N, 3), (X, Y, Z, 3)} The vector field data, where X, Y, Z represent the position in 3D, N is the number of vectors per voxel, and 3 represents the vector actor_type : str, optional The type of vector field visualization. Options are "thin_line", "line", and "arrow". cross_section : list or tuple, shape (3,), optional A list or tuple representing the cross section dimensions. If None, the cross section will be ignored and complete field will be shown. colors : str, tuple, list or ndarray, optional Color for the vectors. Accepts a hex string, RGB(A) in [0, 1], or RGB(A) in [0, 255]. If None, the color is taken from the orientation. scales : {float, ndarray}, shape {(X, Y, Z, N) or (X, Y, Z)}, optional Scale factor for the vectors. If ndarray, it should match the shape of the field. opacity : float, optional Takes values from 0 (fully transparent) to 1 (opaque). thickness : float, optional The thickness of the lines in the vector field visualization. Only applicable for "line" and "arrow" types. visibility : tuple, optional A tuple of three boolean values indicating the visibility of the slices in the x, y, and z dimensions, respectively. """
[docs] def __init__( self, field, *, actor_type="thin_line", cross_section=None, colors=None, scales=1.0, opacity=1.0, thickness=1.0, visibility=None, ): """Initialize a vector field.""" super().__init__() if not (field.ndim == 5 or field.ndim == 4): raise ValueError( "Field must be 5D or 4D, " f"but got {field.ndim}D with shape {field.shape}" ) total_vectors = np.prod(field.shape[:-1]) if field.shape[-1] != 3: raise ValueError( f"Field must have last dimension as 3, but got {field.shape[-1]}" ) self.vectors = field.reshape(total_vectors, 3).astype(np.float32) self.field_shape = field.shape[:3] if field.ndim == 4: self.vectors_per_voxel = 1 else: self.vectors_per_voxel = field.shape[3] if isinstance(scales, (int, float)): self.scales = np.full((total_vectors, 1), scales, dtype=np.float32) elif scales.shape != field.shape[:-1]: raise ValueError( "Scales must match the shape of the field (X, Y, Z, N) or (X, Y, Z)," f" but got {scales.shape}" ) else: self.scales = scales.reshape(total_vectors, 1).astype(np.float32) pnts_per_vector = 2 pts = np.zeros((total_vectors * pnts_per_vector, 3), dtype=np.float32) pts[0] = self.field_shape if colors is None: colors = np.asarray((0, 0, 0), dtype=np.float32) else: colors = normalize_colors(colors)[0].astype(np.float32) colors = np.tile(colors, (total_vectors * pnts_per_vector, 1)) self.geometry = buffer_to_geometry(positions=pts, colors=colors) self.material = _create_vector_field_material( (0, 0, 0), visibility=visibility, material=actor_type, thickness=thickness, opacity=opacity, ) if cross_section is None: self.cross_section = np.asarray([-2, -2, -2], dtype=np.int32) else: self.cross_section = cross_section
[docs] def get_bounding_box(self): """ Get the bounding box of the vector field. Returns ------- list A list containing two elements, each a list of three floats representing the minimum and maximum coordinates of the bounding box. """ return [ [0, 0, 0], [self.field_shape[0] - 1, self.field_shape[1] - 1, self.field_shape[2] - 1], ]
@property def cross_section(self): """ Get the cross section of the vector field. Returns ------- ndarray The cross section of the vector field. """ return self.material.cross_section @cross_section.setter def cross_section(self, value): """ Set the cross section of the vector field. Parameters ---------- value : {list, tuple, ndarray} The cross section in world-space coordinates. When this actor is part of a chunked group its ``local.position`` offset is already baked in, so the caller always works in the same coordinate frame as the full field. """ if not isinstance(value, (list, tuple, np.ndarray)): raise ValueError( "Cross section must be a list, tuple, or ndarray, " f"but got {type(value)}" ) if len(value) != 3: raise ValueError(f"Cross section must have length 3, but got {len(value)}") value = np.asarray(value, dtype=np.float32) bounds = self.bounds if hasattr(self, "bounds") else self.get_bounding_box() world_min = np.asarray(bounds[0], dtype=np.float32) world_max = np.asarray(bounds[1], dtype=np.float32) value = np.maximum(world_min, value) value = np.minimum(world_max, value) self.material.cross_section = value.astype(np.int32) @property def visibility(self): """ Get the visibility of the vector field. Returns ------- tuple A tuple of three boolean values indicating the visibility of the slices in the x, y, and z dimensions, respectively. """ return self.material.visibility @visibility.setter def visibility(self, value): """ Set the visibility of the vector field. Parameters ---------- value : tuple A tuple of three boolean values indicating the visibility of the slices in the x, y, and z dimensions, respectively. """ if not isinstance(value, (list, tuple)) or len(value) != 3: raise ValueError("Visibility must be a tuple of three boolean values.") self.material.visibility = value
def _max_voxels_per_chunk(*, max_buffer_size, max_workgroups): """ Compute the maximum number of voxels that fit in one VectorField chunk. Two independent device limits constrain how large a single VectorField actor may be: * **Storage-buffer binding size** – the largest individual buffer that the GPU accepts. The bottleneck buffer holds two float32 (x, y, z) vertices per vector (start + end point), so it costs ``n_vectors × 2 × 3 × 4`` bytes. * **Compute workgroup dispatch dimension** – the compute pass dispatches ``ceil(n_voxels / workgroup_size)`` workgroups along the X axis. WebGPU limits this to ``max-compute-workgroups-per-dimension`` (typically 65 535). Parameters ---------- max_buffer_size : int Device limit ``max-storage-buffer-binding-size`` in bytes. max_workgroups : int Device limit ``max-compute-workgroups-per-dimension``. Returns ------- int Maximum number of voxels per chunk that satisfies both limits. """ _VECTOR_FIELD_WORKGROUP_SIZE = 64 from_buffer = max_buffer_size // (2 * 3 * 4) from_dispatch = max_workgroups * _VECTOR_FIELD_WORKGROUP_SIZE return min(from_buffer, from_dispatch) def _create_chunked_vector_field( field, *, group_name, scales, actor_params, chunk_actor_postprocess=None, ): """ Create a VectorField group, chunking when required by device limits. Parameters ---------- field : ndarray, shape {(X, Y, Z, N, 3), (X, Y, Z, 3)} Vector field data. group_name : str Name for the returned Group. scales : {float, ndarray} Scalar value or per-voxel scales array. actor_params : dict Keyword arguments passed to each VectorField actor. chunk_actor_postprocess : callable, optional Callback invoked as ``fn(actor, x_start)`` for each chunk actor after chunk positioning, used for chunk-specific updates. Returns ------- Group A Group containing one or more VectorField actors. """ wgpu_device = gfx_wgpu.get_shared().device max_buffer_size = wgpu_device.limits.get( "max-storage-buffer-binding-size", 256 * 1024 * 1024 ) max_workgroups = wgpu_device.limits.get( "max-compute-workgroups-per-dimension", 65535 ) total_voxels = int(np.prod(field.shape[:3])) max_voxels = _max_voxels_per_chunk( max_buffer_size=max_buffer_size, max_workgroups=max_workgroups, ) group = Group(name=group_name) if total_voxels <= max_voxels: actor = VectorField(field, scales=scales, **actor_params) group.add(actor) return group voxels_per_x_slice = int(np.prod(field.shape[1:3])) chunk_x = max(1, max_voxels // voxels_per_x_slice) x_size = field.shape[0] n_chunks = int(np.ceil(x_size / chunk_x)) for i in range(n_chunks): x_start = i * chunk_x x_end = min(x_start + chunk_x, x_size) chunk_field = field[x_start:x_end] if isinstance(scales, np.ndarray): chunk_scales = scales[x_start:x_end] else: chunk_scales = scales actor = VectorField(chunk_field, scales=chunk_scales, **actor_params) actor.local.position = [x_start, 0, 0] if chunk_actor_postprocess is not None: chunk_actor_postprocess(actor, x_start) group.add(actor) return group
[docs] def vector_field( field, *, actor_type="thin_line", colors=None, scales=1.0, opacity=1.0, thickness=1.0, ): """ Visualize a vector field with different features. Parameters ---------- field : ndarray, shape {(X, Y, Z, N, 3), (X, Y, Z, 3)} The vector field data, where X, Y, Z represent the position in 3D, N is the number of vectors per voxel, and 3 represents the vector actor_type : str, optional The type of vector field visualization. Options are "thin_line", "line", and "arrow". colors : str, tuple, list or ndarray, optional Color for the vectors. Accepts a hex string, RGB(A) in [0, 1], or RGB(A) in [0, 255]. If None, the color is taken from the orientation. scales : {float, ndarray}, shape {(X, Y, Z, N) or (X, Y, Z)}, optional Scale factor for the vectors. If ndarray, it should match the shape of the field. opacity : float, optional Takes values from 0 (fully transparent) to 1 (opaque). thickness : float, optional The thickness of the lines in the vector field visualization. Only applicable for "line" and "arrow" types. Returns ------- Group A Group of VectorField chunks. """ actor_params = { "actor_type": actor_type, "colors": colors, "opacity": opacity, "thickness": thickness, } return _create_chunked_vector_field( field, group_name="VectorField", scales=scales, actor_params=actor_params, )
[docs] def vector_field_slicer( field, *, actor_type="thin_line", cross_section=None, colors=None, scales=1.0, opacity=1.0, thickness=1.0, visibility=(True, True, True), ): """ Visualize a vector field with different features. Parameters ---------- field : ndarray, shape {(X, Y, Z, N, 3), (X, Y, Z, 3)} The vector field data, where X, Y, Z represent the position in 3D, N is the number of vectors per voxel, and 3 represents the vector actor_type : str, optional The type of vector field visualization. Options are "thin_line", "line", and "arrow". cross_section : list or tuple, shape (3,), optional A list or tuple representing the cross section dimensions. If None, the cross section will be ignored and complete field will be shown. colors : str, tuple, list or ndarray, optional Color for the vectors. Accepts a hex string, RGB(A) in [0, 1], or RGB(A) in [0, 255]. If None, the color is taken from the orientation. scales : {float, ndarray}, shape {(X, Y, Z, N) or (X, Y, Z)}, optional Scale factor for the vectors. If ndarray, it should match the shape of the field. opacity : float, optional Takes values from 0 (fully transparent) to 1 (opaque). thickness : float, optional The thickness of the lines in the vector field visualization. Only applicable for "line" and "arrow" types. visibility : tuple, optional A tuple of three boolean values indicating the visibility of the slices in the x, y, and z dimensions, respectively. Returns ------- VectorField or Group A single VectorField when the data fits within the device limits, or a Group of VectorField chunks otherwise. """ if cross_section is None: cross_section = np.asarray(field.shape[:3], dtype=np.int32) // 2 cross_section = np.asarray(cross_section, dtype=np.int32) actor_params = { "actor_type": actor_type, "cross_section": cross_section, "colors": colors, "opacity": opacity, "thickness": thickness, "visibility": visibility, } return _create_chunked_vector_field( field, group_name="VectorFieldSlicer", scales=scales, actor_params=actor_params, chunk_actor_postprocess=lambda actor, _: setattr( actor, "cross_section", cross_section.copy() ), )
class _EllipsoidField(_BillboardEllipsoid): """ Render a resident ellipsoid tile retaining its original CPU field views. Parameters ---------- lengths : ndarray, shape (X, Y, Z, 3) Tile view of the original semi-axis lengths. orientation_matrices : ndarray or None Uniform orientation or a spatial tile view of the orientation field. mask : ndarray or None Tile view of the boolean active-voxel mask. colors : str, tuple, list, ndarray or None Original uniform color or tile view of the spatial color field. normalized_colors : ndarray or None Uniform or tile-local float32 RGB(A) colors for GPU packing. None enables shader direction coloring with a white RGB multiplier. voxel_origin : tuple Integer XYZ origin of the tile in the full field. bounds : ndarray, shape (2, 3) Full-field world-space voxel-center bounds for slice clamping. scale : float, optional Validated finite nonnegative scalar applied only during tile packing. cross_section : list, tuple or ndarray, optional World-space slice positions. opacity : float, optional Material opacity multiplier. visibility : tuple, optional Visibility of the X, Y and Z slice planes. enable_picking : bool, optional Whether retained surface fragments write picking information. Attributes ---------- field_shape : tuple Spatial dimensions of the tile, used for C-order pick decoding. voxel_origin : tuple Original XYZ voxel coordinates of the tile's first slot. bounds : ndarray Shared full-field center bounds, independent of glyph extents. """ def __init__( self, lengths, *, orientation_matrices, mask, colors, normalized_colors, voxel_origin, bounds, scale=1.0, cross_section=None, opacity=1.0, visibility=(True, True, True), enable_picking=True, ): field_shape = lengths.shape[:3] centers = np.empty((*field_shape, 3), dtype=np.float32) centers[..., 0] = ( np.arange(field_shape[0], dtype=np.float32) + voxel_origin[0] )[:, None, None] centers[..., 1] = ( np.arange(field_shape[1], dtype=np.float32) + voxel_origin[1] )[None, :, None] centers[..., 2] = ( np.arange(field_shape[2], dtype=np.float32) + voxel_origin[2] )[None, None, :] orientation_colors = normalized_colors is None if orientation_colors: normalized_colors = (1, 1, 1) packed_lengths = lengths if scale != 1 or mask is not None: packed_lengths = np.zeros( lengths.shape, dtype=np.result_type( lengths.dtype, np.min_scalar_type(scale), np.float32 ), ) with np.errstate(over="ignore", invalid="ignore"): np.multiply( lengths, scale, out=packed_lengths, dtype=packed_lengths.dtype, where=True if mask is None else mask[..., None], ) orientations = orientation_matrices if orientations is not None and orientations.ndim == 5: orientations = orientations.reshape(-1, 3, 3) super().__init__( centers.reshape(-1, 3), orientation_matrices=orientations, lengths=packed_lengths.reshape(-1, 3), colors=normalized_colors, color_axis_indices=np.argmax(lengths, axis=-1).reshape(-1), opacity=opacity, enable_picking=enable_picking, ) self.material.orientation_colors = orientation_colors self.lengths = lengths self.orientation_matrices = orientation_matrices self.mask = mask self.colors = colors self.field_shape = field_shape self.voxel_origin = voxel_origin self.bounds = bounds self.visibility = visibility self.cross_section = ( (np.asarray(bounds[1], dtype=np.int32) + 1) // 2 if cross_section is None else cross_section ) @property def cross_section(self): """ Get the world-space slice positions shared by every field tile. Returns ------- ndarray, shape (3,) Integer positions of the X, Y and Z slice planes. """ return self.material.cross_section @cross_section.setter def cross_section(self, value): """ Set slice positions within the full field's world center bounds. Parameters ---------- value : list, tuple or ndarray, shape (3,) World-space plane positions, clamped before conversion to int32. """ if not isinstance(value, (list, tuple, np.ndarray)): raise ValueError("Cross section must be a list, tuple, or ndarray.") value = np.asarray(value, dtype=np.float32) if value.shape != (3,): raise ValueError("Cross section must have length 3.") value = np.maximum(np.asarray(self.bounds[0], dtype=np.float32), value) value = np.minimum(np.asarray(self.bounds[1], dtype=np.float32), value) self.material.cross_section = value.astype(np.int32) @property def visibility(self): """ Get slice-plane visibility, independent of object visibility. Returns ------- list of bool Visibility of the X, Y and Z planes. """ return self.material.visibility @visibility.setter def visibility(self, value): """ Set which slice planes contribute to the visible union. Parameters ---------- value : list or tuple Three booleans controlling the X, Y and Z planes. """ if not isinstance(value, (list, tuple)) or len(value) != 3: raise ValueError("Visibility must be a tuple of three boolean values.") self.material.visibility = value def _wgpu_get_pick_info(self, pick_value): """ Decode a tile glyph index into its original full-field voxel. Parameters ---------- pick_value : int Packed picking value written by the ellipsoid shader. Returns ------- dict Tile-local ``glyph_index`` and global XYZ ``voxel_index`` tuple. """ info = super()._wgpu_get_pick_info(pick_value) local_index = np.unravel_index(info["glyph_index"], self.field_shape, order="C") info["voxel_index"] = tuple( int(index) + origin for index, origin in zip(local_index, self.voxel_origin, strict=True) ) return info def _iter_ellipsoid_tiles(shape, max_glyphs): """ Yield deterministic spatial tiles whose voxel counts fit the capacity. Parameters ---------- shape : tuple Positive spatial dimensions in XYZ order. max_glyphs : int Positive maximum number of glyph records in one tile. Yields ------ tuple Three spatial slices, traversed in XYZ order. """ if ( isinstance(max_glyphs, (bool, np.bool_)) or not isinstance(max_glyphs, (int, np.integer)) or max_glyphs <= 0 ): raise ValueError("max_glyphs must be a positive integer.") x_size, y_size, z_size = shape tz = min(z_size, max_glyphs) ty = min(y_size, max_glyphs // tz) tx = min(x_size, max_glyphs // (ty * tz)) for x_start in range(0, x_size, tx): for y_start in range(0, y_size, ty): for z_start in range(0, z_size, tz): yield ( slice(x_start, min(x_start + tx, x_size)), slice(y_start, min(y_start + ty, y_size)), slice(z_start, min(z_start + tz, z_size)), ) def _create_chunked_ellipsoid_field( lengths, *, orientation_matrices, mask, colors, actor_params, scale=1.0, max_glyphs=None, ): """ Validate a field and pack bounded tiles without copying the full volume. Parameters ---------- lengths : array-like, shape (X, Y, Z, 3) Nonnegative semi-axis lengths at integer voxel centers. orientation_matrices : array-like or None Uniform (3, 3) orientation or an (X, Y, Z, 3, 3) field. mask : ndarray or None Boolean (X, Y, Z) active-voxel mask. colors : str, tuple, list, ndarray or None Uniform RGB(A) color or an (X, Y, Z, 3/4) color field. None enables WGSL ``orient2rgb`` coloring of the first longest original axes. actor_params : dict Slice, opacity, visibility and picking options for every tile. scale : float, optional Finite nonnegative real scalar multiplying semi-axis lengths only. max_glyphs : int, optional Positive private tile capacity, limited by the hardware capacity. Returns ------- Group Flat group of resident tiles retaining the original CPU input views. """ if isinstance(scale, np.ndarray) and scale.ndim == 0: scale = scale.item() try: scale = float(scale) except OverflowError as error: raise ValueError("Scale must be a finite, nonnegative scalar.") from error if not np.isfinite(scale) or scale < 0: raise ValueError("Scale must be a finite, nonnegative scalar.") lengths = np.asarray(lengths) if lengths.ndim != 4 or lengths.shape[-1] != 3: raise ValueError("Lengths must have shape (X, Y, Z, 3).") shape = lengths.shape[:3] if any(size == 0 for size in shape): raise ValueError("Field spatial dimensions must be positive.") if orientation_matrices is not None: orientation_matrices = np.asarray(orientation_matrices) if orientation_matrices.shape not in ((3, 3), (*shape, 3, 3)): raise ValueError( "Orientation matrices must have shape (3, 3) or (X, Y, Z, 3, 3)." ) if mask is not None: mask = np.asarray(mask) if mask.shape != shape or mask.dtype != np.bool_: raise ValueError("Mask must be a boolean array with shape (X, Y, Z).") if max_glyphs is not None and ( isinstance(max_glyphs, (bool, np.bool_)) or not isinstance(max_glyphs, (int, np.integer)) or max_glyphs <= 0 ): raise ValueError("max_glyphs must be a positive integer.") color_field = None if colors is not None and not isinstance(colors, str): color_array = np.asarray(colors) if color_array.ndim == 4: if color_array.shape[:3] != shape or color_array.shape[-1] not in (3, 4): raise ValueError("Color fields must have shape (X, Y, Z, 3 or 4).") color_field = color_array elif color_array.ndim == 0 or color_array.ndim > 2: raise ValueError("Colors must be a uniform color or a spatial color field.") uniform_color = None if colors is not None and color_field is None: uniform_color = normalize_colors(colors) if uniform_color.ndim != 2 or uniform_color.shape[0] != 1: raise ValueError("Colors must be a uniform color or a spatial color field.") limits = gfx_wgpu.get_shared().device.limits capacity = _max_ellipsoids_per_chunk( max_buffer_size=limits["max-buffer-size"], max_storage_buffer_binding_size=limits["max-storage-buffer-binding-size"], ) if max_glyphs is not None: capacity = min(capacity, int(max_glyphs)) scale_colors = False if color_field is not None: # Choose the convention once, including tiles with only low-valued colors. for tile in _iter_ellipsoid_tiles(shape, capacity): if np.any(color_field[tile] > 1): scale_colors = True break bounds = np.asarray( ((0, 0, 0), tuple(size - 1 for size in shape)), dtype=np.float32 ) params = dict(actor_params) if params.get("cross_section") is None: params["cross_section"] = np.asarray(shape, dtype=np.int32) // 2 group = Group(name="EllipsoidFieldSlicer") group.bounds = bounds for tile in _iter_ellipsoid_tiles(shape, capacity): tile_colors = colors if color_field is None else color_field[tile] if color_field is None: packed_colors = uniform_color else: packed_colors = np.asarray(tile_colors, dtype=np.float32) if scale_colors: packed_colors = packed_colors / 255.0 packed_colors = packed_colors.reshape(-1, color_field.shape[-1]) tile_orientations = orientation_matrices if orientation_matrices is not None and orientation_matrices.ndim == 5: tile_orientations = orientation_matrices[tile] tile_lengths = lengths[tile] tile_mask = None if mask is None else mask[tile] valid_lengths = np.isfinite(tile_lengths).all(axis=-1) & np.all( tile_lengths >= 0, axis=-1 ) if np.any(~valid_lengths if tile_mask is None else tile_mask & ~valid_lengths): raise ValueError("Lengths must be finite and nonnegative.") if tile_orientations is not None: active = np.all(tile_lengths > 0, axis=-1) if tile_mask is not None: active &= tile_mask valid_orientations = np.isfinite(tile_orientations).all(axis=(-2, -1)) if np.any(active & ~valid_orientations): raise ValueError("Active orientation matrices must be finite.") if scale > 0: max_length = np.max( tile_lengths, initial=0, where=True if tile_mask is None else tile_mask[..., None], ) if float(max_length) > float(np.finfo(np.float32).max) / scale: raise ValueError("Ellipsoid parameters must fit in float32 buffers.") child = _EllipsoidField( tile_lengths, orientation_matrices=tile_orientations, mask=tile_mask, colors=tile_colors, normalized_colors=packed_colors, voxel_origin=tuple(int(axis.start) for axis in tile), bounds=bounds, scale=scale, **params, ) group.add(child) return group
[docs] def ellipsoid_slicer( lengths, *, orientation_matrices=None, mask=None, colors=None, scale=1.0, cross_section=None, opacity=1.0, visibility=(True, True, True), enable_picking=True, ): """ Render an ellipsoid field on the union of enabled world-space slice planes. Parameters ---------- lengths : ndarray, shape (X, Y, Z, 3) Nonnegative semi-axis lengths at integer voxel centers. Zero axes have no renderable volume. orientation_matrices : ndarray, optional Orthonormal columns defining the semi-axes, as a uniform (3, 3) matrix or a field with shape (X, Y, Z, 3, 3). Either handedness is supported. mask : ndarray, shape (X, Y, Z), optional Boolean active-voxel mask. Masked lengths and orientations may contain invalid background values. colors : str, tuple, list or ndarray, optional Uniform RGB(A) or hex color, or an (X, Y, Z, 3/4) RGB(A) field. Numeric colors accept the [0, 1] and [0, 255] conventions. None uses WGSL ``orient2rgb`` on the longest unscaled semi-axis's input/model-frame orientation column. Ties select the first axis. scale : float, optional Finite nonnegative real scalar multiplying semi-axis lengths without changing centers, slice bounds or directional colors. Zero hides all glyphs without excusing invalid unmasked input lengths or orientations. All unmasked scaled semi-axis lengths must fit in float32 buffers. cross_section : list, tuple or ndarray, optional Three world-space plane positions, clamped to full-field center bounds and converted to integers. None selects the voxel-shape midpoint. opacity : float, optional Material opacity in [0, 1]. visibility : tuple, optional Visibility of the X, Y and Z slice planes. enable_picking : bool, optional Enable surface picking with the original full-field voxel index. Returns ------- Group A flat group of material-bearing resident ellipsoid tiles. Notes ----- Slice changes update uniforms without repacking glyphs. Chunks satisfy individual device buffer limits; total GPU residency grows with the field. Plane selection and center snapping follow vector-field distance semantics. """ return _create_chunked_ellipsoid_field( lengths, orientation_matrices=orientation_matrices, mask=mask, colors=colors, scale=scale, actor_params={ "cross_section": cross_section, "opacity": opacity, "visibility": visibility, "enable_picking": enable_picking, }, )
@register_wgpu_render_function(VectorField, VectorFieldThinLineMaterial) def register_vector_field_thin_shaders(wobject): """ Register PeaksActor shaders. Parameters ---------- wobject : VectorField The vector field object to register shaders for. Returns ------- tuple A tuple containing the compute shader and the render shader. """ compute_shader = VectorFieldComputeShader(wobject) render_shader = VectorFieldThinShader(wobject) return compute_shader, render_shader @register_wgpu_render_function(VectorField, VectorFieldLineMaterial) def register_vector_field_shaders(wobject): """ Register PeaksActor shaders. Parameters ---------- wobject : VectorField The vector field object to register shaders for. Returns ------- tuple A tuple containing the compute shader and the render shader. """ compute_shader = VectorFieldComputeShader(wobject) render_shader = VectorFieldShader(wobject) return compute_shader, render_shader @register_wgpu_render_function(VectorField, VectorFieldArrowMaterial) def register_vector_field_arrow_shaders(wobject): """ Register PeaksActor shaders. Parameters ---------- wobject : VectorField The vector field object to register shaders for. Returns ------- tuple A tuple containing the compute shader and the render shader. """ compute_shader = VectorFieldComputeShader(wobject) render_shader = VectorFieldArrowShader(wobject) return compute_shader, render_shader
[docs] class SphGlyph(Mesh): """ Visualize a spherical harmonic glyph with different features. Parameters ---------- coeffs : ndarray, shape (X, Y, Z, N) The spherical harmonics coefficients. X, Y, Z denotes the position and N represents the number of coefficients. sphere : tuple Vertices and faces of the sphere to use for the glyph. basis_type : str, optional The type of basis to use for the spherical harmonics. Options are 'standard', 'descoteaux07'. color_type : str, optional The type of color mapping to use for the spherical glyph. Options are 'sign' and 'orientation'. shininess : float, optional The shininess of the material for the spherical glyph. """
[docs] def __init__( self, coeffs, sphere, *, basis_type="standard", color_type="sign", shininess=50, ): """Visualize a spherical harmonic glyph with different features.""" super().__init__() if not isinstance(coeffs, np.ndarray): raise TypeError("The attribute 'coeffs' must be a numpy ndarray.") elif coeffs.ndim != 4: raise ValueError( ( "The attribute 'coeffs' must be a 4D numpy ndarray " "with shape (X, Y, Z, N)." ) ) elif coeffs.shape[-1] < 1: raise ValueError( "The last dimension of 'coeffs' must be greater than 0, " f"but got {coeffs.shape[-1]}" ) if not isinstance(sphere, tuple): raise TypeError( "The attribute 'sphere' must be a tuple containing vertices and faces." ) elif ( len(sphere) != 2 or not isinstance(sphere[0], np.ndarray) or not isinstance(sphere[1], np.ndarray) ): raise TypeError( "The attribute 'sphere' must be a tuple containing two numpy ndarrays " "(vertices, faces)." ) self.n_coeff = coeffs.shape[-1] self.data_shape = coeffs.shape[:3] self.basis_type = basis_type self._l_max = get_lmax(self.n_coeff, basis_type=basis_type) self.color_type = 0 if color_type == "sign" else 1 vertices, faces = sphere[0], sphere[1] positions = np.tile(vertices, (np.prod(self.data_shape), 1)).astype(np.float32) positions[0] = np.asarray(self.data_shape) self.scaled_vertices = np.zeros_like(positions, dtype=np.float32) self.vertices_per_glyph = vertices.shape[0] self.faces_per_glyph = faces.shape[0] self.indices = faces.reshape(-1).astype(np.int32) indices = np.tile(faces, (np.prod(self.data_shape), 1)).astype(np.int32) self.radii = np.zeros((self.vertices_per_glyph,), dtype=np.float32) for i in range(0, indices.shape[0], faces.shape[0]): start = i end = start + faces.shape[0] indices[start:end] += (i // faces.shape[0]) * self.vertices_per_glyph self.geometry = buffer_to_geometry( positions=positions.astype("float32"), indices=indices.astype("int32"), colors=np.ones_like(positions, dtype="float32"), normals=np.zeros_like(positions).astype("float32"), ) self.material = SphGlyphMaterial( n_coeffs=self.n_coeff, color_mode="vertex", flat_shading=False, shininess=shininess, specular="#494949", side="front", ) B_mat = create_sh_basis_matrix(vertices, self._l_max) self.sh_coeff = coeffs.reshape(-1).astype("float32") self.sf_func = B_mat.reshape(-1).astype("float32") self.sphere = vertices.astype("float32")
@property def l_max(self): """ Get the maximum degree of the spherical harmonics. Returns ------- int The maximum degree of the spherical harmonics used in the glyph. """ return self._l_max @l_max.setter def l_max(self, value): """ Set the maximum degree of the spherical harmonics. Parameters ---------- value : int The maximum degree of the spherical harmonics to set. Raises ------ ValueError If the provided value is not a positive integer. """ if not isinstance(value, int) or value < 0: raise ValueError("The attribute 'l_max' must be a positive integer.") self._l_max = value self.material.n_coeffs = get_n_coeffs(value, basis_type=self.basis_type) @property def scale(self): """ Get the scale of the spherical glyph. Returns ------- float The scale of the spherical glyph. """ return self.material.scale @scale.setter def scale(self, value): """ Set the scale of the spherical glyph. Parameters ---------- value : float The scale of the spherical glyph to set. """ self.material.scale = value
[docs] def sph_glyph( coeffs, *, sphere=None, basis_type="standard", color_type="sign", shininess=50 ): """ Visualize a spherical harmonic glyph with different features. Parameters ---------- coeffs : ndarray, shape (X, Y, Z, N) The spherical harmonics coefficients. X, Y, Z denotes the position and N represents the number of coefficients. sphere : {str, tuple}, optional The name of the sphere to use or a custom sphere tuple ``(phi, theta)``, where phi is the number of latitude segments and theta is the number of longitude segments: ``(latitude_segments, longitude_segments)``. Available options for the named spheres: * 'symmetric362' * 'symmetric642' * 'symmetric724' * 'repulsion724' * 'repulsion100' * 'repulsion200' basis_type : str, optional The type of basis to use for the spherical harmonics. Options are 'standard', 'descoteaux07'. color_type : str, optional The type of color mapping to use for the spherical glyph. Options are 'sign' and 'orientation'. shininess : float, optional The shininess of the material for the spherical glyph. Returns ------- SphGlyph A spherical glyph object. """ if not isinstance(coeffs, np.ndarray): raise TypeError("The attribute 'coeffs' must be a numpy ndarray.") elif coeffs.ndim != 4: raise ValueError( "The attribute 'coeffs' must be a 4D numpy ndarray with shape (X, Y, Z, N)." ) if sphere is None: sphere = "symmetric362" if isinstance(sphere, str): sphere = fp.prim_sphere(name=sphere) elif ( isinstance(sphere, tuple) and isinstance(sphere[0], int) and isinstance(sphere[1], int) ): sphere = fp.prim_sphere(gen_faces=True, phi=sphere[0], theta=sphere[1]) else: raise TypeError( "The attribute 'sphere' must be a string or tuple containing two integers." ) if not isinstance(basis_type, str): raise TypeError("The attribute 'basis_type' must be a string.") elif basis_type not in ["standard", "descoteaux07"]: raise ValueError( "The attribute 'basis_type' must be either 'standard' or 'descoteaux07'." ) if not isinstance(color_type, str): raise TypeError("The attribute 'color_type' must be a string.") if color_type not in ["sign", "orientation"]: raise ValueError( "The attribute 'color_type' must be either 'sign' or 'orientation'." ) if not isinstance(shininess, (int, float)): raise TypeError("The attribute 'shininess' must be an integer or float.") obj = SphGlyph( coeffs=coeffs, sphere=sphere, basis_type=basis_type, color_type=color_type, shininess=shininess, ) return obj
@register_wgpu_render_function(SphGlyph, SphGlyphMaterial) def register_glyph_shaders(wobject): """ Register Glyph shaders. Parameters ---------- wobject : VectorField The vector field object to register shaders for. Returns ------- tuple A tuple containing the compute shader and the render shader. """ compute_shader = SphGlyphComputeShader(wobject) render_shader = MeshPhongShader(wobject) return compute_shader, render_shader