# -*- coding: utf-8 -*-
"""Core actor functionality for FURY."""
from PIL import Image as PILImage
import numpy as np
from scipy.spatial.transform import Rotation as Rot
from fury.actor import set_opacity
from fury.colormap import normalize_colors
from fury.geometry import (
buffer_to_geometry,
line_buffer_separator,
)
from fury.lib import (
Geometry,
ImageBasicMaterial,
MeshBasicMaterial,
MeshPhongMaterial,
PointsGaussianBlobMaterial,
PointsMarkerMaterial,
PointsMaterial,
TextMaterial,
Texture,
gfx,
)
from fury.material import _create_line_material, _create_mesh_material
import fury.primitive as fp
from fury.transform import rotate, scale, translate
[docs]
class Actor:
"""Base Actor class for making APIs user-friendly."""
[docs]
def rotate(self, rotation):
"""
Rotate the actor by the given rotation.
Parameters
----------
rotation : tuple
Rotation angles (in degrees) around the x, y, and z axes.
"""
rotation = np.asarray(rotation, dtype=np.float32)
if rotation.shape != (3,):
raise ValueError("Rotation must contain three angles (degrees).")
quaternion = self._euler_to_quaternion(np.radians(rotation))
rotate(quaternion, actor=self)
[docs]
def translate(self, translation):
"""
Translate the actor by the given translation vector.
Parameters
----------
translation : tuple
Translation vector along the x, y, and z axes.
"""
if not isinstance(translation, (list, tuple, np.ndarray)):
raise ValueError("Translation must be a sequence of three values.")
translation = np.asarray(translation, dtype=np.float32)
if translation.shape != (3,):
raise ValueError("Translation must contain three values.")
translate(translation, actor=self)
[docs]
def scale(self, scales):
"""
Scale the actor by the given scale factors.
Parameters
----------
scales : tuple or float
Scale factors along the x, y, and z axes. If a single float
is provided, uniform scaling is applied.
"""
if isinstance(scales, (int, float)):
scales = (scales, scales, scales)
elif not isinstance(scales, (list, tuple, np.ndarray)):
raise ValueError(
"Scale must be a sequence of three values or a single float."
)
scales = np.asarray(scales, dtype=np.float32)
if scales.shape != (3,):
raise ValueError("Scale must contain three values.")
scale(scales, actor=self)
@property
def opacity(self):
"""
Get the opacity of the actor.
Returns
-------
float
Opacity value between 0 (fully transparent) and 1 (fully opaque).
"""
if isinstance(self, Group):
if len(self.children) == 0:
return 1.0
return self.children[0].material.opacity
return self.material.opacity
@opacity.setter
def opacity(self, opacity):
"""
Set the opacity of the actor.
Parameters
----------
opacity : float
Opacity value between 0 (fully transparent) and 1 (fully opaque).
"""
set_opacity(self, opacity)
@staticmethod
def _euler_to_quaternion(rotation):
"""
Convert XYZ Euler angles (radians) to a quaternion.
Parameters
----------
rotation : tuple or ndarray
Rotation angles (in radians) around the x, y, and z axes.
Returns
-------
ndarray
Quaternion representing the rotation.
"""
return Rot.from_euler("xyz", rotation).as_quat()
[docs]
class Mesh(gfx.Mesh, Actor):
"""Mesh actor class."""
[docs]
class Points(gfx.Points, Actor):
"""Points actor class."""
[docs]
class Line(gfx.Line, Actor):
"""Line actor class."""
[docs]
class Text(gfx.Text, Actor):
"""Text actor class."""
[docs]
class Image(gfx.Image, Actor):
"""Image actor class."""
[docs]
class Volume(gfx.Volume, Actor):
"""Volume actor class."""
[docs]
class Group(gfx.Group, Actor):
"""Group actor class."""
[docs]
def create_mesh(geometry, material):
"""
Create a mesh object.
Parameters
----------
geometry : Geometry
The geometry object.
material : Material
The material object. Must be either MeshPhongMaterial or MeshBasicMaterial.
Returns
-------
Mesh
The mesh object.
Raises
------
TypeError
If geometry is not an instance of Geometry or material is not an
instance of MeshPhongMaterial or MeshBasicMaterial.
"""
if not isinstance(geometry, Geometry):
raise TypeError("geometry must be an instance of Geometry.")
if not isinstance(material, (MeshPhongMaterial, MeshBasicMaterial)):
raise TypeError(
"material must be an instance of MeshPhongMaterial or MeshBasicMaterial."
)
mesh = Mesh(geometry=geometry, material=material)
return mesh
[docs]
def create_line(geometry, material):
"""
Create a line object.
Parameters
----------
geometry : Geometry
The geometry object.
material : Material
The material object.
Returns
-------
Line
The line object.
"""
line = Line(geometry=geometry, material=material)
return line
[docs]
def create_point(geometry, material):
"""
Create a point object.
Parameters
----------
geometry : Geometry
The geometry object.
material : Material
The material object. Must be either PointsMaterial, PointsGaussianBlobMaterial,
or PointsMarkerMaterial.
Returns
-------
Points
The point object.
Raises
------
TypeError
If geometry is not an instance of Geometry or material is not an
instance of PointsMaterial, PointsGaussianBlobMaterial, or PointsMarkerMaterial.
"""
if not isinstance(geometry, Geometry):
raise TypeError("geometry must be an instance of Geometry.")
if not isinstance(
material, (PointsMaterial, PointsGaussianBlobMaterial, PointsMarkerMaterial)
):
raise TypeError(
"material must be an instance of PointsMaterial, "
"PointsGaussianBlobMaterial or PointsMarkerMaterial."
)
point = Points(geometry=geometry, material=material)
return point
[docs]
def create_text(text, material, **kwargs):
"""
Create a text object.
Parameters
----------
text : str
The text content.
material : TextMaterial
The material object.
**kwargs : dict
Additional properties like font_size, anchor, etc.
Returns
-------
Text
The text object.
Raises
------
TypeError
If text is not a string or material is not an instance of TextMaterial.
Examples
--------
>>> mat = TextMaterial()
>>> t = create_text("Hello", mat)
"""
if not isinstance(text, str):
raise TypeError("text must be a string.")
if not isinstance(material, TextMaterial):
raise TypeError("material must be an instance of TextMaterial.")
return Text(text=text, material=material, **kwargs)
[docs]
def create_image(image_input, material, **kwargs):
"""
Create an image object.
Parameters
----------
image_input : str or np.ndarray, optional
The image content.
material : Material
The material object.
**kwargs : dict, optional
Additional properties like position, visible, etc.
Returns
-------
Image
The image object.
"""
if isinstance(image_input, str):
image = np.flipud(np.array(PILImage.open(image_input)).astype(np.float32))
elif isinstance(image_input, np.ndarray):
if image_input.ndim not in (2, 3):
raise ValueError("image_input must be a 2D or 3D NumPy array.")
if image_input.ndim == 3 and image_input.shape[2] not in (1, 3, 4):
raise ValueError("image_input must have 1, 3, or 4 channels.")
image = image_input
else:
raise TypeError("image_input must be a file path (str) or a NumPy array.")
if image.ndim != 2:
raise ValueError("Only 2D grayscale images are supported.")
if image.max() > 1.0 or image.min() < 0.0:
if image.max() == image.min():
raise ValueError("Cannot normalize an image with constant pixel values.")
image = (image - image.min()) / (image.max() - image.min())
if not isinstance(material, ImageBasicMaterial):
raise TypeError("material must be an instance of ImageBasicMaterial.")
image = Image(
Geometry(grid=Texture(image.astype(np.float32), dim=2)), material=material
)
return image
[docs]
def actor_from_primitive(
vertices,
faces,
centers,
*,
colors=(1, 0, 0),
scales=(1, 1, 1),
directions=(1, 0, 0),
opacity=None,
material="phong",
smooth=False,
enable_picking=True,
repeat_primitive=True,
have_tiled_verts=False,
wireframe=False,
wireframe_thickness=1.0,
):
"""
Build an actor from a primitive.
Parameters
----------
vertices : ndarray
Vertices of the primitive.
faces : ndarray
Faces of the primitive.
centers : ndarray, shape (N, 3)
Primitive positions.
colors : ndarray, shape (N, 3) or (N, 4) or tuple (3,) or tuple (4,), optional
RGB or RGBA colors. Accepts values in [0, 255] (int), [0, 1] (float),
or hex strings (e.g. "#FF0000"). Values above 1.0 are treated as
[0, 255] and normalized internally.
scales : ndarray, shape (N, 3) or tuple (3,) or float, optional
The size of the primitive in each dimension. If a single value is provided,
the same size will be used for all primitives.
directions : ndarray, shape (N, 3) or tuple (3,), optional
The orientation vector of the primitive.
opacity : float, optional
Takes values from 0 (fully transparent) to 1 (opaque).
If both `opacity` and RGBA are provided, the final alpha will be:
final_alpha = alpha_in_RGBA * opacity.
material : str, optional
The material type for the primitive. Options are 'phong' and 'basic'.
smooth : bool, optional
Whether to create a smooth primitive or a faceted primitive.
enable_picking : bool, optional
Whether the primitive should be pickable in a 3D scene.
repeat_primitive : bool, optional
Whether to repeat the primitive for each center. If False,
only one instance of the primitive is created at the first center.
have_tiled_verts : bool, optional
If True, vertices are already tiled (one set per center) and should
not be duplicated again inside ``repeat_primitive``.
wireframe : bool, optional
Whether to render the mesh as a wireframe.
wireframe_thickness : float, optional
The thickness of the wireframe lines.
Returns
-------
Actor
A mesh actor containing the generated primitive, with the specified
material and properties.
"""
if repeat_primitive:
colors = normalize_colors(colors, n_points=len(centers))
else:
colors = normalize_colors(colors)
if repeat_primitive:
res = fp.repeat_primitive(
vertices,
faces,
centers,
directions=directions,
colors=colors,
scales=scales,
have_tiled_verts=have_tiled_verts,
)
big_vertices, big_faces, big_colors, _ = res
else:
big_vertices = vertices
big_faces = faces
big_colors = colors
prim_count = len(centers)
if isinstance(opacity, (int, float)):
if big_colors.shape[1] == 3:
big_colors = np.hstack(
(big_colors, np.full((big_colors.shape[0], 1), opacity))
)
else:
big_colors[:, 3] *= opacity
is_transparent = False
if isinstance(opacity, (int, float)) and opacity < 1.0:
is_transparent = True
elif big_colors.shape[1] == 4 and np.any(big_colors[:, 3] < 1.0):
is_transparent = True
geo = buffer_to_geometry(
indices=big_faces.astype("int32"),
positions=big_vertices.astype("float32"),
texcoords=big_vertices.astype("float32"),
colors=big_colors.astype("float32"),
)
mat = _create_mesh_material(
material=material,
enable_picking=enable_picking,
flat_shading=not smooth,
wireframe=wireframe,
wireframe_thickness=wireframe_thickness,
alpha_mode="weighted_blend" if is_transparent else "auto",
depth_write=not is_transparent,
)
obj = create_mesh(geometry=geo, material=mat)
if not repeat_primitive:
obj.local.position = centers[0]
obj.prim_count = prim_count
return obj
[docs]
def arrow(
centers,
*,
directions=(0, 0, 0),
colors=(1, 1, 1),
height=1.0,
resolution=10,
tip_length=0.35,
tip_radius=0.1,
shaft_radius=0.03,
scales=(1, 1, 1),
opacity=None,
material="phong",
enable_picking=True,
):
"""
Create one or many arrows with different features.
Parameters
----------
centers : ndarray, shape (N, 3)
Arrow positions.
directions : ndarray, shape (N, 3) or tuple (3,), optional
The orientation vector of the arrow.
colors : ndarray, shape (N, 3) or (N, 4) or tuple (3,) or tuple (4,), optional
RGB or RGBA colors. Accepts values in [0, 255] (int), [0, 1] (float),
or hex strings (e.g. "#FF0000"). Values above 1.0 are treated as
[0, 255] and normalized internally.
height : float or ndarray, shape (N,), optional
The total height of the arrow, including the shaft and tip. A single
value applies to all arrows, while an array specifies a value per arrow.
resolution : int, optional
The number of divisions along the arrow's circular cross-sections.
Higher values produce smoother arrows.
tip_length : float or ndarray, shape (N,), optional
The length of the arrowhead tip relative to the total height. A single
value applies to all arrows, while an array specifies a value per arrow.
tip_radius : float or ndarray, shape (N,), optional
The radius of the arrowhead tip. A single value applies to all arrows,
while an array specifies a value per arrow.
shaft_radius : float or ndarray, shape (N,), optional
The radius of the arrow shaft. A single value applies to all arrows,
while an array specifies a value per arrow.
scales : ndarray, shape (N, 3) or tuple (3,) or float, optional
The size of the arrow in each dimension. If a single value is
provided, the same size will be used for all arrows.
opacity : float, optional
Takes values from 0 (fully transparent) to 1 (opaque).
If both `opacity` and RGBA are provided, the final alpha will be:
final_alpha = alpha_in_RGBA * opacity.
material : str, optional
The material type for the arrows. Options are 'phong' and 'basic'.
enable_picking : bool, optional
Whether the arrows should be pickable in a 3D scene.
Returns
-------
Actor
A mesh actor containing the generated arrows, with the specified
material and properties.
Examples
--------
>>> from fury import window, actor
>>> import numpy as np
>>> scene = window.Scene()
>>> centers = np.random.rand(5, 3) * 10
>>> colors = np.random.rand(5, 3)
>>> arrow_actor = actor.arrow(centers=centers, colors=colors)
>>> _ = scene.add(arrow_actor)
>>> show_manager = window.ShowManager(scene=scene, size=(600, 600))
>>> show_manager.start()
"""
n_centers = len(centers)
height_arr = fp._normalize_geom_param(height, n_centers, "height")
tip_length_arr = fp._normalize_geom_param(tip_length, n_centers, "tip_length")
tip_radius_arr = fp._normalize_geom_param(tip_radius, n_centers, "tip_radius")
shaft_radius_arr = fp._normalize_geom_param(shaft_radius, n_centers, "shaft_radius")
all_uniform = (
np.all(height_arr == height_arr[0])
and np.all(tip_length_arr == tip_length_arr[0])
and np.all(tip_radius_arr == tip_radius_arr[0])
and np.all(shaft_radius_arr == shaft_radius_arr[0])
)
if all_uniform:
vertices, faces = fp.prim_arrow(
height=height_arr[0],
resolution=resolution,
tip_length=tip_length_arr[0],
tip_radius=tip_radius_arr[0],
shaft_radius=shaft_radius_arr[0],
)
return actor_from_primitive(
vertices,
faces,
centers=centers,
colors=colors,
scales=scales,
directions=directions,
opacity=opacity,
material=material,
enable_picking=enable_picking,
)
_, faces = fp.prim_arrow(
height=height_arr[0],
resolution=resolution,
tip_length=tip_length_arr[0],
tip_radius=tip_radius_arr[0],
shaft_radius=shaft_radius_arr[0],
)
all_verts = [
fp.prim_arrow(
height=height_arr[i],
resolution=resolution,
tip_length=tip_length_arr[i],
tip_radius=tip_radius_arr[i],
shaft_radius=shaft_radius_arr[i],
)[0]
for i in range(n_centers)
]
vertices = np.concatenate(all_verts)
return actor_from_primitive(
vertices,
faces,
centers=centers,
colors=colors,
scales=scales,
directions=directions,
opacity=opacity,
material=material,
enable_picking=enable_picking,
have_tiled_verts=True,
)
[docs]
def axes(
*,
scale=(1.0, 1.0, 1.0),
color_x=(1.0, 0.0, 0.0),
color_y=(0.0, 1.0, 0.0),
color_z=(0.0, 0.0, 1.0),
opacity=1.0,
):
"""
Create coordinate system axes using colored arrows.
The axes are represented as arrows with different colors:
red = X-axis, green = Y-axis, blue = Z-axis.
Parameters
----------
scale : tuple (3,), optional
The size (length) of each axis in the x, y, and z directions.
color_x : tuple (3,), optional
Color for the X-axis.
color_y : tuple (3,), optional
Color for the Y-axis.
color_z : tuple (3,), optional
Color for the Z-axis.
opacity : float, optional
Takes values from 0 (fully transparent) to 1 (opaque).
Returns
-------
Actor
An axes actor representing the coordinate axes with the specified
material and properties.
Examples
--------
>>> from fury import window, actor
>>> scene = window.Scene()
>>> axes_actor = actor.axes()
>>> _ = scene.add(axes_actor)
>>> show_manager = window.ShowManager(scene=scene, size=(600, 600))
>>> show_manager.start()
"""
centers = np.zeros((3, 3))
directions = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]])
colors = np.array(
[color_x + (opacity,), color_y + (opacity,), color_z + (opacity,)]
)
scales = np.asarray(scale)
obj = arrow(centers=centers, directions=directions, colors=colors, scales=scales)
return obj
[docs]
def create_axes_helper(
*,
labels=None,
colors=None,
thickness=2,
center_disk_radius=0.11,
endpoint_disk_radius=0.33,
label_font_size=0.4,
):
"""
Create actors composing a UI axes helper.
This returns the helper group and related actor lists so callers can
attach callbacks and place it in scene-specific coordinate systems.
Parameters
----------
labels : list of str, optional
Labels for [-X, +X, -Y, +Y, -Z, +Z].
colors : list of tuple, optional
RGB colors for each axis endpoint.
thickness : float, optional
Thickness for endpoint lines.
center_disk_radius : float, optional
Radius of the center disk.
endpoint_disk_radius : float, optional
Radius of endpoint disks.
label_font_size : float, optional
Font size for endpoint labels.
Returns
-------
dict
A dictionary containing:
- group
- center_disk
- disks
- labels
- lines
- line_points
- axis_vectors
"""
from fury.actor.curved import streamlines
from fury.actor.planar import disk, text
if labels is None:
labels = ["-X", "+X", "-Y", "+Y", "-Z", "+Z"]
elif labels is not None and len(labels) != 6:
raise ValueError(
"labels must be a list of 6 strings for [-X, +X, -Y, +Y, -Z, +Z]."
)
if colors is None:
colors = [
(0.9, 0.3, 0.23),
(0.9, 0.3, 0.23),
(0.5, 0.7, 0),
(0.5, 0.7, 0),
(0, 0, 0.7),
(0, 0, 0.7),
]
elif colors is not None and len(colors) != 6:
raise ValueError(
"colors must be a list of 6 RGB tuples for [-X, +X, -Y, +Y, -Z, +Z]."
)
group = Group(name="Axes Helper")
centers = [
np.array([-1.0, 0.0, 0.0], dtype=np.float32),
np.array([1.0, 0.0, 0.0], dtype=np.float32),
np.array([0.0, -1.0, 0.0], dtype=np.float32),
np.array([0.0, 1.0, 0.0], dtype=np.float32),
np.array([0.0, 0.0, -1.0], dtype=np.float32),
np.array([0.0, 0.0, 1.0], dtype=np.float32),
]
center_disk = disk(
np.asarray([[0.0, 0.0, 0.0]], dtype=np.float32),
radii=center_disk_radius,
colors=(0.5, 0.5, 0.5),
material="basic",
)
group.add(center_disk)
disks = []
labels_actors = []
lines = []
line_points = []
for i, endpoint_center in enumerate(centers):
disk_actor = disk(
np.asarray([[0.0, 0.0, 0.0]], dtype=np.float32),
radii=endpoint_disk_radius,
colors=colors[i],
material="basic",
)
disk_actor.local.position = endpoint_center.tolist()
label_actor = text(
labels[i],
position=endpoint_center.tolist(),
font_size=label_font_size,
)
axis_dir = endpoint_center / np.linalg.norm(endpoint_center)
line_start = (axis_dir * center_disk_radius).tolist()
line_end = (endpoint_center - axis_dir * endpoint_disk_radius).tolist()
line_actor = streamlines(
[[line_start, line_end]],
colors=[colors[i]],
thickness=thickness,
outline_thickness=0,
)
disks.append(disk_actor)
labels_actors.append(label_actor)
lines.append(line_actor)
line_points.append((line_start, line_end))
# Due to the convention of the z -ve is forward.
axis_vectors = centers[:4] + centers[5:] + centers[4:5]
group.add(disk_actor)
group.add(label_actor)
group.add(line_actor)
return {
"group": group,
"center_disk": center_disk,
"disks": disks,
"labels": labels_actors,
"lines": lines,
"line_points": line_points,
"axis_vectors": axis_vectors,
}
[docs]
def line(
lines,
*,
colors=(1, 0, 0),
opacity=None,
material="basic",
enable_picking=True,
):
"""
Visualize one or many lines with different colors.
Parameters
----------
lines : list of ndarray of shape (P, 3) or ndarray of shape (N, P, 3)
Lines points.
colors : ndarray, shape (N, 3) or (N, 4) or tuple (3,) or tuple (4,), optional
RGB or RGBA colors. Accepts values in [0, 255] (int), [0, 1] (float),
or hex strings (e.g. "#FF0000"). Values above 1.0 are treated as
[0, 255] and normalized internally.
opacity : float, optional
Takes values from 0 (fully transparent) to 1 (opaque).
material : str, optional
The material type for the lines. Options are 'basic', 'segment', 'arrow',
'thin', and 'thin_segment'.
enable_picking : bool, optional
Whether the lines should be pickable in a 3D scene.
Returns
-------
Actor
A mesh actor containing the generated lines, with the specified
material and properties.
Examples
--------
>>> from fury import window, actor
>>> import numpy as np
>>> scene = window.Scene()
>>> lines = [np.random.rand(10, 3) for _ in range(5)]
>>> colors = np.random.rand(5, 3)
>>> line_actor = actor.line(lines=lines, colors=colors)
>>> _ = scene.add(line_actor)
>>> show_manager = window.ShowManager(scene=scene, size=(600, 600))
>>> show_manager.start()
"""
if colors is not None:
colors = normalize_colors(colors)
if colors.ndim == 2 and len(colors) == 1:
colors = colors[0]
lines_positions, lines_colors = line_buffer_separator(lines, color=colors)
geo = buffer_to_geometry(
positions=lines_positions.astype("float32"),
colors=lines_colors.astype("float32"),
)
mat = _create_line_material(
material=material,
enable_picking=enable_picking,
mode="vertex",
opacity=opacity,
)
obj = create_line(geometry=geo, material=mat)
obj.local.position = lines_positions[0]
obj.prim_count = len(lines)
return obj