diff --git a/docs/examples/_valid_examples.toml b/docs/examples/_valid_examples.toml index b037dee0e..ba59b483b 100644 --- a/docs/examples/_valid_examples.toml +++ b/docs/examples/_valid_examples.toml @@ -44,6 +44,7 @@ files = [ "viz_contour.py", "viz_brownian_motion.py", "viz_network.py", + "viz_read_mesh.py", ] [ui] diff --git a/docs/examples/viz_read_mesh.py b/docs/examples/viz_read_mesh.py new file mode 100644 index 000000000..18f0a0918 --- /dev/null +++ b/docs/examples/viz_read_mesh.py @@ -0,0 +1,224 @@ +""" +================================= +Reading Meshes, Lines, and Points +================================= + +This example demonstrates how to fetch 3D data assets from the +``polyxios-data`` repository, read them into NumPy arrays using FURY's +dedicated I/O methods, and render them as distinct colored actors side by side: +a surface mesh, a collection of lines, and a point cloud. + +Polyxios is the lightweight, dependency-free I/O backend used by FURY. It +is designed to auto-detect file formats by extension and map complex scientific +surface structures (``.vtk``, ``.vtp``, ``.ply``, ``.obj``, ``.mesh``) +seamlessly into memory-contiguous NumPy arrays, and its ``fetch`` helper downloads +and caches sample assets so tutorials and tests can run without bundling large files. + +This example highlights three fundamental data archetypes: + +* **Surface Meshes (via ``read_mesh``):** Returns a triple of + ``(vertices, faces, colors)`` where ``vertices`` is an ``(N, 3)`` float32 array, + ``faces`` is an ``(M, 3)`` int32 array of triangulated indices, and ``colors`` is an + optional ``(N, 3)`` float32 color map. + +* **Line Streams (via ``read_lines``):** Returns a tuple of ``(lines, colors)``. Here, + ``lines`` is a list of independent ``(P, 3)`` float32 arrays (where each array + represents an individual continuous stroke or fiber pathway), and ``colors`` contains + a corresponding list of per-vertex color maps. + +* **Point Clouds (via ``read_points``):** Returns a tuple of ``(points, colors)`` where + ``points`` is an ``(N, 3)`` float32 coordinate block representing un-connected point + data, and ``colors`` is an optional per-point attribute array. +""" + +######################################################################################### +# Import the required libraries. +import numpy as np +import polyxios as px + +from fury import actor, ui, window +from fury.io import read_lines, read_mesh, read_points + +######################################################################################### +# Define helper to process geometry, normalize space, and apply fallback gradients. + + +def points_to_actor(file_path): + """Read a point cloud file and build a colored point actor.""" + points, colors = read_points(file_path) + + if points.size == 0: + raise ValueError(f"No points found in file: {file_path}") + + # Center on the origin and normalize the scale to roughly unit size + points = points - points.mean(axis=0) + points = (points / np.max(np.abs(points))).astype(np.float32) + + # Use the file's colors when present, otherwise apply a height-based gradient + if colors is None: + height = points[:, 1] + height_range = height.max() - height.min() + y_range = height_range if height_range > 0 else 1.0 + + t = ((height - height.min()) / y_range)[:, None] + low_color = np.array([0.10, 0.20, 0.70], dtype=np.float32) + high_color = np.array([0.95, 0.75, 0.20], dtype=np.float32) + colors = (low_color * (1.0 - t) + high_color * t).astype(np.float32) + + # ``actor.point`` turns an (N, 3) array of point positions and their + # matching per-point colors into an optimized graphic object. + point_actor = actor.point(points, colors=colors) + return point_actor, len(points) + + +def lines_to_actor(file_path): + """Read a lines file and build a colored stream/line actor.""" + lines, colors = read_lines(file_path) + + if not lines: + raise ValueError(f"No line elements found in file: {file_path}") + + # Stack all points temporarily to calculate overall centering and scaling metrics + all_points = np.vstack(lines) + center = all_points.mean(axis=0) + max_scale = np.max(np.abs(all_points - center)) + + # Center on the origin and normalize the scale to roughly unit size + normalized_lines = [ + ((line - center) / max_scale).astype(np.float32) for line in lines + ] + + # Use the file's colors when present, otherwise apply a height-based gradient + if colors is None: + # Re-stack lines to compute a global bounding box for the height gradient + all_norm_points = np.vstack(normalized_lines) + y_min = all_norm_points[:, 1].min() + y_max = all_norm_points[:, 1].max() + y_range = y_max - y_min if (y_max - y_min) > 0 else 1.0 + + low_color = np.array([0.10, 0.20, 0.70], dtype=np.float32) + high_color = np.array([0.95, 0.75, 0.20], dtype=np.float32) + + colors = [] + for line in normalized_lines: + heights = line[:, 1] + t = ((heights - y_min) / y_range)[:, None] + line_colors = (low_color * (1.0 - t) + high_color * t).astype(np.float32) + colors.append(line_colors) + + # ``actor.streamlines`` accepts a list of coordinate arrays and color arrays + line_actor = actor.streamlines(normalized_lines, colors=colors) + + # Calculate totals for reporting + total_vertices = sum(len(line) for line in lines) + total_lines = len(lines) + + return line_actor, total_vertices, total_lines + + +def mesh_to_actor(file_path): + """Read a mesh file and build a colored surface actor.""" + vertices, faces, colors = read_mesh(file_path) + + # Center on the origin and normalize the scale to roughly unit size. + vertices = vertices - vertices.mean(axis=0) + vertices = (vertices / np.max(np.abs(vertices))).astype(np.float32) + + # Use the file's colors when present, otherwise a height-based gradient. + if colors is None: + height = vertices[:, 1] + t = ((height - height.min()) / (height.max() - height.min()))[:, None] + low_color = np.array([0.10, 0.20, 0.70], dtype=np.float32) + high_color = np.array([0.95, 0.75, 0.20], dtype=np.float32) + colors = (low_color * (1.0 - t) + high_color * t).astype(np.float32) + + # ``actor.surface`` wraps the geometry, material and mesh creation for us, + # turning the vertices, faces and per-vertex colors into a ready actor. + surf = actor.surface(vertices, faces, colors=colors) + return surf, len(vertices), len(faces) + + +######################################################################################### +# Fetch the sample data assets via polyxios. +# +# * ``Human.vtp`` a surface mesh possessing native topological faces and color tracks. +# * ``hello.vtk`` contains line elements forming spatial lettering out of poly-line. +# * ``star.mesh`` handles plain point coordinates lacking explicitly structured links. +human_path = px.fetch("Human.vtp") +line_path = px.fetch("hello.vtk") +ball_path = px.fetch("star.mesh") + +# Generate the actors and fetch metadata counts +mesh_actor, mesh_nv, mesh_nf = mesh_to_actor(human_path) +line_actor, line_nv, line_nl = lines_to_actor(line_path) +point_actor, point_nv = points_to_actor(ball_path) + +print(f"Human.vtp (Mesh): {mesh_nv} vertices, {mesh_nf} faces") +print(f"hello.vtk (Lines): {line_nv} vertices across {line_nl} lines") +print(f"star.mesh (Points): {point_nv} points") + +######################################################################################### +# Coordinate alignment transforms. +# ``Human.vtp`` is modeled lying along its Z axis, so by default it faces the +# camera end-on. Every FURY actor exposes transform helpers (``rotate``, +# ``translate``, ``scale``), so we stand it upright with a -90 degrees rotation +# about the X axis, mapping its head-to-toe axis to the vertical and apply 180 degrees +# so it faces the camera. +mesh_actor.rotate((-90, 180, 0)) + +######################################################################################### +# Place the three actors side by side (Left, Center, Right) to avoid overlapping. +mesh_actor.local.position = (-3.0, 0.0, 0.0) +line_actor.local.position = (0.0, 0.0, 0.0) +point_actor.local.position = (3.0, 0.0, 0.0) + +######################################################################################### +# Add matching 3D text labels directly beneath each of the three objects. +labels_actor = actor.text( + ["Mesh (Human.vtp)", "Lines (hello.vtk)", "Points (star.mesh)"], + position=[(-3.0, -1.25, 0.0), (0.0, -1.25, 0.0), (3.0, -1.25, 0.0)], + colors=(0.9, 0.9, 0.95), + font_size=0.16, + anchor="top-center", +) + +######################################################################################### +# Set up the 3D scene and register all visual elements. +scene = window.Scene(background=(0.05, 0.05, 0.08)) +scene.add(mesh_actor) +scene.add(line_actor) +scene.add(point_actor) +scene.add(labels_actor) + +######################################################################################### +# Add a 2D text HUD overlay detailing what each object represents. +info_text = ( + f"FURY x Polyxios Geometry Reader\n" + f"Left: Human.vtp (Mesh: {mesh_nv} verts, {mesh_nf} faces)\n" + f"Center: hello.vtk (Lines: {line_nv} total vertices, {line_nl} lines)\n" + f"Right: star.mesh (Points: {point_nv} coordinates)\n" + f"Decoupled data paths processed into native NumPy arrays." +) + +hud_label = ui.TextBlock2D( + text=info_text, + position=(20, 20), + font_size=16, + color=(0.9, 0.9, 0.95), + bold=False, + dynamic_bbox=True, +) +scene.add(hud_label) + +######################################################################################### +# Initialize the ShowManager, frame the camera to fit the wider 3-element layout, +# and initialize the rendering window loop. +show_m = window.ShowManager( + scene=scene, size=(1280, 728), title="FURY Multimodal Reader" +) + +camera = show_m.screens[0].camera +camera.local.position = (0.0, -0.15, 6.0) +camera.look_at((0.0, -0.15, 0.0)) + +show_m.start() diff --git a/fury/io.py b/fury/io.py index f098f5db3..03a939040 100644 --- a/fury/io.py +++ b/fury/io.py @@ -1,18 +1,14 @@ """I/O functions for loading and saving images, textures.""" import os - -# from tempfile import TemporaryDirectory as InTemporaryDirectory from urllib.request import urlretrieve # import warnings from PIL import Image import numpy as np +import polyxios as px -# from fury.decorators import warn_on_args_to_kwargs from fury.lib import Texture, wgpu - -# from fury.utils import set_input from fury.network.parser import parse_network, stringify_network @@ -341,6 +337,121 @@ def save_network(network_data, file_path, format=None): f.write(data) +def read_mesh(file_path, *, format=None): + """ + Read a mesh from a file using polyxios. + + Parameters + ---------- + file_path : str + The path to the mesh file. + format : str, optional + The specific file format override (e.g. '.vtk'). Inferred from the file + extension when None. + + Returns + ------- + tuple + A tuple containing: + + - vertices (np.ndarray): Shape (N, 3) float32 array of vertex positions. + - faces (np.ndarray or None): Shape (M, 3) int32 array of triangle face + indices, or None when the mesh has no surface elements. + - colors (np.ndarray or None): Shape (N, 3) float32 array of per-vertex + RGB colors in [0, 1], or None when no vertex colors are present. + """ + poly = px.read(file_path, fmt=format) + + vertices = np.asarray(poly.vertices, dtype=np.float32) + + faces = poly.faces + if faces is not None: + faces = np.asarray(faces, dtype=np.int32) + + colors = px.transforms.vertex_colors(poly) + if colors is not None: + colors = np.asarray(colors, dtype=np.float32) + + return vertices, faces, colors + + +def read_points(file_path, *, format=None): + """ + Read point coordinates from a file using polyxios. + + Parameters + ---------- + file_path : str + The path to the mesh file. + format : str, optional + The specific file format override (e.g. '.vtk'). Inferred from the file + extension when None. + + Returns + ------- + tuple + A tuple containing: + + - points (np.ndarray): Shape (N, 3) float32 array of point positions. + - colors (np.ndarray or None): Shape (N, 3) float32 array of per-point + RGB colors in [0, 1], or None when no vertex colors are present. + """ + poly = px.read(file_path, fmt=format) + + points = np.asarray(poly.vertices, dtype=np.float32) + + colors = px.transforms.vertex_colors(poly) + if colors is not None: + colors = np.asarray(colors, dtype=np.float32) + + return points, colors + + +def read_lines(file_path, *, format=None): + """ + Read line segments from a file using polyxios. + + Each line or poly_line element is translated into an array of its vertex + positions, ready to be consumed by FURY line actors. + + Parameters + ---------- + file_path : str + The path to the mesh file. + format : str, optional + The specific file format override (e.g. '.vtk'). Inferred from the file + extension when None. + + Returns + ------- + tuple + A tuple containing: + + - lines (list of np.ndarray): One Shape (P, 3) float32 array of vertex + positions per line. Empty list when the file has no line elements. + - colors (list of np.ndarray or None): One Shape (P, 3) float32 array of + per-vertex RGB colors in [0, 1] per line, or None when no vertex + colors are present. + """ + poly = px.read(file_path, fmt=format) + + line_indices = poly.lines + if line_indices is None: + return [], None + + vertices = np.asarray(poly.vertices, dtype=np.float32) + lines = [vertices[idx] for idx in line_indices] + + vertex_colors = px.transforms.vertex_colors(poly) + if vertex_colors is None: + colors = None + else: + vertex_colors = np.asarray(vertex_colors, dtype=np.float32) + colors = [vertex_colors[idx] for idx in line_indices] + + return lines, colors + + # def load_polydata(file_name): # """Load a vtk polydata to a supported format file. diff --git a/fury/tests/test_io.py b/fury/tests/test_io.py index 5f945ba72..8a79a34a6 100644 --- a/fury/tests/test_io.py +++ b/fury/tests/test_io.py @@ -7,6 +7,8 @@ import numpy.testing as npt # import pytest +import polyxios as px + # from fury.decorators import skip_osx from fury.data import fetch_viz_cubemaps, read_viz_cubemap from fury.io import ( @@ -19,6 +21,9 @@ # load_polydata, # load_sprite_sheet, # load_text, + read_lines, + read_mesh, + read_points, save_image, save_network, ) @@ -207,6 +212,82 @@ def test_save_and_load_network(): ) +def test_read_mesh(): + vertices = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0]], dtype=np.float64) + faces = np.array([[0, 1, 2], [1, 3, 2]], dtype=np.int32) + colors = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1], [1, 1, 0]], dtype=np.float32) + + poly = px.make_polydata( + vertices, + [("triangle", faces)], + vertex_attrs={"colors": colors}, + ) + + with InTemporaryDirectory() as odir: + fname_path = pjoin(odir, "temp-mesh.vtk") + px.write(poly, fname_path) + + out_vertices, out_faces, out_colors = read_mesh(fname_path) + + npt.assert_equal(out_vertices.dtype, np.float32) + npt.assert_equal(out_faces.dtype, np.int32) + npt.assert_array_almost_equal(out_vertices, vertices) + npt.assert_array_equal(out_faces, faces) + npt.assert_array_almost_equal(out_colors, colors) + + +def test_read_points(): + vertices = np.array([[0, 0, 0], [1, 2, 3], [4, 5, 6], [7, 8, 9]], dtype=np.float64) + connectivity = np.array([[0], [1], [2], [3]], dtype=np.int32) + + poly = px.make_polydata(vertices, [("vertex", connectivity)]) + + with InTemporaryDirectory() as odir: + fname_path = pjoin(odir, "temp-points.vtk") + px.write(poly, fname_path) + + out_points, out_colors = read_points(fname_path) + + npt.assert_equal(out_points.dtype, np.float32) + npt.assert_equal(out_points.shape, (4, 3)) + npt.assert_array_almost_equal(out_points, vertices) + npt.assert_equal(out_colors, None) + + +def test_read_lines(): + vertices = np.array([[0, 0, 0], [1, 0, 0], [2, 0, 0], [3, 0, 0]], dtype=np.float64) + line = np.array([[0, 1, 2, 3]], dtype=np.int32) + + poly = px.make_polydata(vertices, [("poly_line", line)]) + + with InTemporaryDirectory() as odir: + fname_path = pjoin(odir, "temp-lines.vtk") + px.write(poly, fname_path) + + out_lines, out_colors = read_lines(fname_path) + + npt.assert_equal(len(out_lines), 1) + npt.assert_equal(out_lines[0].dtype, np.float32) + npt.assert_array_almost_equal(out_lines[0], vertices) + npt.assert_equal(out_colors, None) + + +def test_read_lines_without_line_elements(): + vertices = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0]], dtype=np.float64) + faces = np.array([[0, 1, 2]], dtype=np.int32) + + poly = px.make_polydata(vertices, [("triangle", faces)]) + + with InTemporaryDirectory() as odir: + fname_path = pjoin(odir, "temp-no-lines.vtk") + px.write(poly, fname_path) + + out_lines, out_colors = read_lines(fname_path) + + npt.assert_equal(out_lines, []) + npt.assert_equal(out_colors, None) + + def test_save_load_image(): l_ext = ["png", "jpeg", "jpg", "bmp", "tiff"] fury_logo_link = ( diff --git a/pyproject.toml b/pyproject.toml index b2eb86e68..bf5442220 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,8 @@ dependencies = [ "lazy_loader>=0.4", "pygfx>=0.16.0", "glfw>=2.7.0", - "aiohttp" + "aiohttp", + "polyxios>=0.2.0", ] dynamic = ["version"] diff --git a/requirements/default.txt b/requirements/default.txt index 7663e5103..d99a400bf 100644 --- a/requirements/default.txt +++ b/requirements/default.txt @@ -8,3 +8,4 @@ lazy_loader>=0.4 pygfx>=0.16.0 glfw>=2.7.0 aiohttp +polyxios>=0.2.0