"""Surface actors for FURY."""
import logging
import os
import numpy as np
from fury.actor import Group, create_mesh
from fury.colormap import normalize_colors
from fury.geometry import buffer_to_geometry
from fury.io import load_image, load_image_texture
from fury.lib import Texture, TextureMap
from fury.material import _create_mesh_material, validate_opacity
from fury.primitive import prim_sphere
from fury.utils import generate_planar_uvs, voxel_mesh_by_object
[docs]
def surface(
vertices,
faces,
*,
material="phong",
material_params=None,
colors=None,
texture=None,
texture_axis="xy",
texture_coords=None,
normals=None,
opacity=1.0,
):
"""
Create a surface mesh actor from vertices and faces.
Parameters
----------
vertices : ndarray, shape (N, 3)
The vertex positions of the surface mesh.
faces : ndarray, shape (M, 3)
The indices of the vertices that form each triangular face.
material : str, optional
The material type for the surface mesh. Options are 'phong', 'basic',
'standard' and 'physical'. The last two are physically based (PBR)
materials.
material_params : dict, optional
Extra properties forwarded to the material, e.g.
``{'metalness': 1.0, 'roughness': 0.2}`` for the PBR materials. See
:func:`fury.material._create_mesh_material` for the supported keys.
colors : str, tuple, list or ndarray, optional
A per-vertex ``(N, 3)``/``(N, 4)`` array, or a single color as a hex
string, RGB(A) in [0, 1], or RGB(A) in [0, 255].
texture : str, optional
Path to the texture image file. For spherical imagery, use
:func:`texture_on_sphere`.
texture_axis : str, optional
The axis to generate UV coordinates for the texture. Options are 'xy', 'yz',
and 'xz'. This option only works with texture is passed.
texture_coords : ndarray, shape (N, 2), optional
Predefined UV coordinates for the texture mapping. If not provided, they will
be generated based on the `texture_axis`.
normals : ndarray, shape (N, 3), optional
The normal vectors for each vertex. If not provided, normals will be
computed automatically.
opacity : float, optional
Takes values from 0 (fully transparent) to 1 (opaque).
Returns
-------
Mesh
A mesh actor containing the generated surface with the specified properties.
"""
geo = None
mat = None
material_params = material_params or {}
opacity = validate_opacity(opacity)
if colors is not None:
if texture is not None:
logging.warning("Texture will be ignored when colors are provided.")
is_per_vertex = (
isinstance(colors, np.ndarray) and colors.shape[0] == vertices.shape[0]
)
if not is_per_vertex:
# A single color: normalize hex / [0, 255] / [0, 1] to RGB.
colors = normalize_colors(colors)[0][:3]
if isinstance(colors, np.ndarray) and colors.shape[0] == vertices.shape[0]:
geo = buffer_to_geometry(
positions=vertices.astype("float32"),
indices=faces.astype("int32"),
colors=colors,
normals=normals.astype("float32") if normals is not None else None,
)
mat = _create_mesh_material(
material=material, mode="vertex", opacity=opacity, **material_params
)
elif isinstance(colors, (tuple, list, np.ndarray)) and len(colors) == 3:
geo = buffer_to_geometry(
positions=vertices.astype("float32"),
indices=faces.astype("int32"),
normals=normals.astype("float32") if normals is not None else None,
)
mat = _create_mesh_material(
material=material,
mode="auto",
opacity=opacity,
color=colors,
**material_params,
)
else:
raise ValueError(
"Colors must be either an ndarray with shape (N, 3) or (N, 4), "
"or a tuple/list of length 3 for RGB colors."
)
elif texture is not None:
if not os.path.exists(texture):
raise FileNotFoundError(f"Texture file '{texture}' not found.")
tex = load_image_texture(texture)
if texture_coords is None:
logging.warning(
"texture option currently only supports planar projection,"
" if the texture_coords are not provided, the plane can be provided"
" by texture_axis parameter."
)
texture_coords = generate_planar_uvs(vertices, axis=texture_axis)
elif (
texture_coords.shape[0] != vertices.shape[0] or texture_coords.shape[1] != 2
):
raise ValueError(
"texture_coords must be an ndarray with shape (N, 2) "
"where N is the number of vertices."
)
geo = buffer_to_geometry(
positions=vertices.astype("float32"),
indices=faces.astype("int32"),
texcoords=texture_coords.astype("float32"),
normals=normals.astype("float32") if normals is not None else None,
)
mat = _create_mesh_material(
material=material,
texture=tex,
opacity=opacity,
mode="auto",
**material_params,
)
else:
geo = buffer_to_geometry(
positions=vertices.astype("float32"), indices=faces.astype("int32")
)
mat = _create_mesh_material(
material=material, opacity=opacity, **material_params
)
obj = create_mesh(geo, mat)
return obj
[docs]
def texture_on_sphere(
texture=None,
*,
theta=60,
phi=60,
interpolation="linear",
material="phong",
material_params=None,
opacity=1.0,
):
"""
Map an equirectangular image onto a smooth unit sphere.
Parameters
----------
texture : str, os.PathLike or ndarray, optional
Local image filename or uint8 RGB/RGBA array of shape (H, W, 3) or
(H, W, 4). None leaves the material color and environment in control.
theta : int, optional
Number of longitude segments, at least 3.
phi : int, optional
Number of latitude segments, at least 3.
interpolation : str, optional
Image sampling method, either 'linear' or 'nearest'.
material : str, optional
Mesh material: 'phong', 'basic', 'standard' or 'physical'.
material_params : dict, optional
Extra material properties, including PBR parameters. See
:func:`fury.material._create_mesh_material`.
opacity : float, optional
Opacity from 0 (transparent) to 1 (opaque).
Returns
-------
Mesh
Origin-centered sphere of radius 1, with poles on the Y axis.
Raises
------
TypeError
If the texture type or dtype is unsupported, or a resolution is not
an integer.
ValueError
If an image is empty or not RGB/RGBA, a resolution is below 3, or
interpolation, material properties or opacity are invalid.
FileNotFoundError
If the local image file does not exist.
Notes
-----
U increases around Y from the seam at -Z through +Z at U=0.5.
V runs from 0 at +Y to 1 at -Y; image rows are not flipped.
Separate UV endpoints, radial normals and pole-safe tangents avoid
geometric shading seams. The image's left and right edges must still
match for its content to appear continuous.
Use the mesh's ``local`` transform to change its radius or placement.
Smooth shading is used unless overridden in ``material_params``.
Examples
--------
>>> import numpy as np
>>> from fury import actor
>>> image = np.full((32, 64, 3), 255, dtype=np.uint8)
>>> globe = actor.texture_on_sphere(image)
>>> globe.local.scale = 2.0
"""
for name, resolution in (("theta", theta), ("phi", phi)):
if isinstance(resolution, (bool, np.bool_)) or not isinstance(
resolution, (int, np.integer)
):
raise TypeError(f"{name} must be an integer.")
if resolution < 3:
raise ValueError(f"{name} must be at least 3.")
if interpolation not in ("linear", "nearest"):
raise ValueError("interpolation must be 'linear' or 'nearest'.")
texture_map = None
if texture is not None:
if isinstance(texture, (str, os.PathLike)):
filename = os.fspath(texture)
if not os.path.isfile(filename):
raise FileNotFoundError(f"Texture file '{filename}' not found.")
pixels = load_image(filename)
elif isinstance(texture, np.ndarray):
pixels = texture
else:
raise TypeError("texture must be a local filename or NumPy array.")
if pixels.dtype != np.uint8:
raise TypeError("texture must have dtype uint8.")
if (
pixels.ndim != 3
or pixels.shape[2] not in (3, 4)
or pixels.shape[0] == 0
or pixels.shape[1] == 0
):
raise ValueError("texture must have nonempty shape (H, W, 3) or (H, W, 4).")
texture_map = TextureMap(
Texture(pixels, dim=2, generate_mipmaps=True),
filter=interpolation,
wrap_s="repeat",
wrap_t="clamp",
)
vertices, faces = prim_sphere(phi=phi, theta=theta)
normals = (vertices / np.linalg.norm(vertices, axis=1, keepdims=True)).astype(
np.float32
)
u = np.linspace(0, 1, theta + 1)
texcoords = np.empty((phi + 1, theta + 1, 2), dtype=np.float32)
texcoords[:, :, 0] = u
texcoords[:, :, 1] = np.linspace(1, 0, phi + 1)[:, None]
longitude = 2 * np.pi * u - np.pi
tangents = np.empty((phi + 1, theta + 1, 4), dtype=np.float32)
tangents[:, :, 0] = np.cos(longitude)
tangents[:, :, 1] = 0
tangents[:, :, 2] = -np.sin(longitude)
# cross(normal, tangent) points north; image V increases southward.
tangents[:, :, 3] = -1
geometry = buffer_to_geometry(
positions=vertices.astype(np.float32),
indices=faces,
normals=normals,
texcoords=texcoords.reshape(-1, 2),
tangents=tangents.reshape(-1, 4),
)
params = {"flat_shading": False, **(material_params or {})}
mesh_material = _create_mesh_material(
material=material,
texture=texture_map,
mode="auto",
opacity=opacity,
**params,
)
return create_mesh(geometry, mesh_material)
[docs]
def contour_from_volume(
data, *, color=(1, 0, 0), opacity=0.5, material="phong", material_params=None
):
"""
Generate surface actor from a binary ROI.
Parameters
----------
data : ndarray, shape (X, Y, Z)
An ROI file that will be binarized and displayed.
color : str, tuple, list or ndarray, optional
The output color of the contour. Accepts a hex string, RGB(A) in
[0, 1], or RGB(A) in [0, 255].
opacity : float, optional
The opacity of the contour.
Takes values from 0 (fully transparent) to 1 (opaque).
material : str, optional
The material type for the contour mesh. Options are 'phong', 'basic',
'standard' and 'physical'. The last two are physically based (PBR)
materials.
material_params : dict, optional
Extra properties forwarded to the material, e.g.
``{'metalness': 1.0, 'roughness': 0.2}`` for the PBR materials. See
:func:`fury.material._create_mesh_material` for the supported keys.
Returns
-------
Group
A group of actors containing the generated contours from the volume data.
"""
if color is None:
raise ValueError("Color must be a tuple of three values (R, G, B).")
color = normalize_colors(color)[0][:3]
surface_data = voxel_mesh_by_object(data, connectivity=1)
contours = Group()
for surf in surface_data.values():
surface_actor = surface(
surf["verts"],
surf["faces"],
colors=color,
opacity=opacity,
material=material,
material_params=material_params,
)
contours.add(surface_actor)
return contours