From bfbedf75502172f4f6cf8741ec18f9b29d5d2729 Mon Sep 17 00:00:00 2001 From: Mohamed Abouagour <63170874+m-agour@users.noreply.github.com> Date: Thu, 19 Dec 2024 20:03:54 -0500 Subject: [PATCH] NF: Sphere, Geometry & Material (#946) NF: add sphere actor w/ materials NF: mesh material NF: geometry & mesh --- fury/actor.py | 102 +++++++++++++ fury/geometry.py | 43 ++++++ fury/material.py | 64 ++++++++ fury/tests/test_actor.py | 74 ++++++++++ fury/tests/test_geometry.py | 32 ++++ fury/tests/test_material.py | 284 ++++-------------------------------- 6 files changed, 342 insertions(+), 257 deletions(-) create mode 100644 fury/actor.py create mode 100644 fury/geometry.py create mode 100644 fury/material.py create mode 100644 fury/tests/test_actor.py create mode 100644 fury/tests/test_geometry.py diff --git a/fury/actor.py b/fury/actor.py new file mode 100644 index 000000000..fbeb0498c --- /dev/null +++ b/fury/actor.py @@ -0,0 +1,102 @@ +import numpy as np + +from fury.geometry import buffer_to_geometry, create_mesh +from fury.material import _create_mesh_material +import fury.primitive as fp + + +def sphere( + centers, + colors, + *, + radii=1.0, + phi=16, + theta=16, + opacity=None, + material="phong", + enable_picking=True, +): + """ + Visualize one or many spheres with different colors and radii. + + Parameters + ---------- + centers : ndarray, shape (N, 3) + Spheres positions. + colors : ndarray, shape (N, 3) or (N, 4) or tuple (3,) or tuple (4,) + RGB or RGBA (for opacity) R, G, B, and A should be in the range [0, 1]. + radii : float or ndarray, shape (N,), optional + Sphere radius. Can be a single value for all spheres or an array of + radii for each sphere. + phi : int, optional + The number of segments in the longitude direction. + theta : int, optional + The number of segments in the latitude direction. + 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 spheres. Options are 'phong' and 'basic'. + enable_picking : bool, optional + Whether the spheres should be pickable in a 3D scene. + + Returns + ------- + mesh_actor : Actor + A mesh actor containing the generated spheres, 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) + >>> radii = np.random.rand(5) + >>> sphere_actor = actor.sphere(centers=centers, colors=colors, radii=radii) + >>> scene.add(sphere_actor) + >>> show_manager = window.ShowManager(scene=scene, size=(600, 600)) + >>> show_manager.start() + """ + + scales = radii + directions = (1, 0, 0) + + vertices, faces = fp.prim_sphere(phi=phi, theta=theta) + + res = fp.repeat_primitive( + vertices, + faces, + directions=directions, + centers=centers, + colors=colors, + scales=scales, + ) + big_vertices, big_faces, big_colors, _ = res + + prim_count = len(centers) + + big_colors = big_colors / 255.0 + + 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 + + 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) + obj = create_mesh(geometry=geo, material=mat) + obj.local.position = centers[0] + obj.prim_count = prim_count + return obj diff --git a/fury/geometry.py b/fury/geometry.py new file mode 100644 index 000000000..af0b4ed44 --- /dev/null +++ b/fury/geometry.py @@ -0,0 +1,43 @@ +from pygfx import Geometry, Mesh + + +def buffer_to_geometry(positions, **kwargs): + """ + Convert a buffer to a geometry object. + + Parameters + ---------- + positions : array_like + The positions buffer. + kwargs : dict + A dict of attributes to define on the geometry object. Keys can be + "colors", "normals", "texcoords", + "indices", ... + + Returns + ------- + geo : Geometry + The geometry object. + """ + geo = Geometry(positions=positions, **kwargs) + return geo + + +def create_mesh(geometry, material): + """ + Create a mesh object. + + Parameters + ---------- + geometry : Geometry + The geometry object. + material : Material + The material object. + + Returns + ------- + mesh : Mesh + The mesh object. + """ + mesh = Mesh(geometry=geometry, material=material) + return mesh diff --git a/fury/material.py b/fury/material.py new file mode 100644 index 000000000..be26d0023 --- /dev/null +++ b/fury/material.py @@ -0,0 +1,64 @@ +import pygfx as gfx + + +def _create_mesh_material( + *, material="phong", enable_picking=True, color=None, opacity=1.0, mode="vertex" +): + """ + Create a mesh material. + + Parameters + ---------- + material : str, optional + The type of material to create. Options are 'phong' (default) and + 'basic'. + enable_picking : bool, optional + Whether the material should be pickable in a scene. + color : tuple or None, optional + The color of the material, represented as an RGBA tuple. If None, the + default color is used. + opacity : float, optional + The opacity of the material, from 0 (transparent) to 1 (opaque). + If RGBA is provided, the final alpha will be: + final_alpha = alpha_in_RGBA * opacity + mode : str, optional + The color mode of the material. Options are 'auto' and 'vertex'. + + Returns + ------- + gfx.MeshMaterial + A mesh material object of the specified type with the given properties. + """ + + if not (0 <= opacity <= 1): + raise ValueError("Opacity must be between 0 and 1.") + + if color is None and mode == "auto": + raise ValueError("Color must be specified when mode is 'auto'.") + + elif color is not None: + if len(color) == 3: + color = (*color, opacity) + elif len(color) == 4: + color = color + color = (*color[:3], color[3] * opacity) + else: + raise ValueError("Color must be a tuple of length 3 or 4.") + + if mode == "vertex": + color = (1, 1, 1) + + if material == "phong": + return gfx.MeshPhongMaterial( + pick_write=enable_picking, + color_mode=mode, + color=color, + ) + elif material == "basic": + return gfx.MeshBasicMaterial( + pick_write=enable_picking, + color_mode=mode, + color=color, + ) + else: + raise ValueError(f"Unsupported material type: {material}") diff --git a/fury/tests/test_actor.py b/fury/tests/test_actor.py new file mode 100644 index 000000000..3b0871926 --- /dev/null +++ b/fury/tests/test_actor.py @@ -0,0 +1,74 @@ +from PIL import Image +import numpy as np +import numpy.testing as npt + +from fury import actor, window + + +def test_sphere(): + scene = window.Scene() + centers = np.array([[0, 0, 0]]) + colors = np.array([[1, 0, 0]]) + radii = np.array([1]) + + sphere_actor = actor.sphere(centers=centers, colors=colors, radii=radii) + scene.add(sphere_actor) + + window.record(scene=scene, fname="sphere_test_1.png") + + img = Image.open("sphere_test_1.png") + img_array = np.array(img) + + mean_r, mean_g, mean_b, _ = np.mean( + img_array.reshape(-1, img_array.shape[2]), axis=0 + ) + + assert mean_r > mean_b and mean_r > mean_g + assert 0 <= mean_r <= 255 and 0 <= mean_g <= 255 and 0 <= mean_b <= 255 + + npt.assert_array_equal(sphere_actor.local.position, centers[0]) + + assert sphere_actor.prim_count == 1 + + center_pixel = img_array[img_array.shape[0] // 2, img_array.shape[1] // 2] + npt.assert_array_equal(center_pixel[0], colors[0][0] * 255) + + phi, theta = 100, 100 + sphere_actor_2 = actor.sphere( + centers=centers, + colors=colors, + radii=radii, + opacity=1, + material="basic", + phi=phi, + theta=theta, + ) + scene.remove(sphere_actor) + scene.add(sphere_actor_2) + + window.record(scene=scene, fname="sphere_test_2.png") + + img = Image.open("sphere_test_2.png") + img_array = np.array(img) + + mean_r, mean_g, mean_b, mean_a = np.mean( + img_array.reshape(-1, img_array.shape[2]), axis=0 + ) + + assert mean_r > mean_b and mean_r > mean_g + assert 0 <= mean_r <= 255 and 0 <= mean_g <= 255 and 0 <= mean_b <= 255 + assert mean_a == 255.0 + assert mean_b == 0.0 + assert mean_g == 0.0 + + vertices = sphere_actor_2.geometry.positions.view + faces = sphere_actor_2.geometry.indices.view + colors = sphere_actor_2.geometry.colors.view + + vertices_mean = np.mean(vertices, axis=0) + + npt.assert_array_almost_equal(vertices_mean, centers[0]) + + assert len(vertices) == len(colors) + + npt.assert_array_almost_equal(len(faces), (2 * phi * (theta - 2))) diff --git a/fury/tests/test_geometry.py b/fury/tests/test_geometry.py new file mode 100644 index 000000000..ca1d5fea0 --- /dev/null +++ b/fury/tests/test_geometry.py @@ -0,0 +1,32 @@ +import numpy as np +import numpy.testing as npt + +from fury import geometry, material + + +def test_buffer_to_geometry(): + positions = np.array([[0, 0, 0], [1, 1, 1], [2, 2, 2]]).astype("float32") + geo = geometry.buffer_to_geometry(positions) + npt.assert_array_equal(geo.positions.view, positions) + + normals = np.array([[0, 0, 0], [1, 1, 1], [2, 2, 2]]).astype("float32") + colors = np.array([[0, 0, 0], [1, 1, 1], [2, 2, 2]]).astype("float32") + indices = np.array([[0, 1, 2]]).astype("int32") + geo = geometry.buffer_to_geometry( + positions, colors=colors, normals=normals, indices=indices + ) + + npt.assert_array_equal(geo.colors.view, colors) + npt.assert_array_equal(geo.normals.view, normals) + npt.assert_array_equal(geo.indices.view, indices) + + +def test_create_mesh(): + positions = np.array([[0, 0, 0], [1, 1, 1], [2, 2, 2]]).astype("float32") + geo = geometry.buffer_to_geometry(positions) + mat = material._create_mesh_material( + material="phong", color=(1, 0, 0), opacity=0.5, mode="auto" + ) + mesh = geometry.create_mesh(geometry=geo, material=mat) + assert mesh.geometry == geo + assert mesh.material == mat diff --git a/fury/tests/test_material.py b/fury/tests/test_material.py index 2d9c32143..f93066318 100644 --- a/fury/tests/test_material.py +++ b/fury/tests/test_material.py @@ -1,265 +1,35 @@ -import numpy as np -import numpy.testing as npt +from fury import material -from fury import actor, material, window -from fury.optpkg import optional_package -dipy, have_dipy, _ = optional_package("dipy") - - -def test_manifest_pbr_vtk(): - # Test non-supported property - test_actor = actor.text_3d("Test") - npt.assert_warns(UserWarning, material.manifest_pbr, test_actor) - - # Test non-supported PBR interpolation - test_actor = actor.scalar_bar() - npt.assert_warns(UserWarning, material.manifest_pbr, test_actor) - - # Create tmp dir to save and query images - # with TemporaryDirectory() as out_dir: - # tmp_fname = os.path.join(out_dir, 'tmp_img.png') # Tmp image to test - - scene = window.Scene() # Setup scene - - test_actor = actor.square( - np.array([[0, 0, 0]]), directions=(0, 0, 0), colors=(0, 0, 1) +def test_create_mesh_material(): + color = (1, 0, 0) + mat = material._create_mesh_material( + material="phong", color=color, opacity=0.5, mode="auto" ) + assert type(mat) == material.gfx.MeshPhongMaterial + assert mat.color == color + (0.5,) + assert mat.color_mode == "auto" - scene.add(test_actor) - - # Test basic actor - # window.record(scene, out_path=tmp_fname, size=(200, 200), - # reset_camera=True) - ss = window.snapshot(scene, size=(200, 200)) - # npt.assert_equal(os.path.exists(tmp_fname), True) - # ss = load_image(tmp_fname) - actual = ss[100, 100, :] / 1000 - desired = np.array([0, 0, 255]) / 1000 - npt.assert_array_almost_equal(actual, desired, decimal=2) - actual = ss[40, 40, :] / 1000 - npt.assert_array_almost_equal(actual, desired, decimal=2) - - # Test default parameters - material.manifest_pbr(test_actor) - - ss = window.snapshot(scene, size=(200, 200)) - # window.record(scene, out_path=tmp_fname, size=(200, 200), - # reset_camera=True) - # npt.assert_equal(os.path.exists(tmp_fname), True) - # ss = load_image(tmp_fname) - actual = ss[100, 100, :] / 1000 - desired = np.array([66, 66, 165]) / 1000 - npt.assert_array_almost_equal(actual, desired, decimal=2) - actual = ss[40, 40, :] / 1000 - desired = np.array([40, 40, 157]) / 1000 - npt.assert_array_almost_equal(actual, desired, decimal=2) - - # Test roughness - material.manifest_pbr(test_actor, roughness=0) - - ss = window.snapshot(scene, size=(200, 200)) - # window.record(scene, out_path=tmp_fname, size=(200, 200), - # reset_camera=True) - # npt.assert_equal(os.path.exists(tmp_fname), True) - # ss = load_image(tmp_fname) - actual = ss[100, 100, :] / 1000 - desired = np.array([0, 0, 155]) / 1000 - npt.assert_array_almost_equal(actual, desired, decimal=2) - actual = ss[40, 40, :] / 1000 - desired = np.array([0, 0, 153]) / 1000 - npt.assert_array_almost_equal(actual, desired, decimal=2) - - # Test metallicity - material.manifest_pbr(test_actor, metallic=1) - ss = window.snapshot(scene, size=(200, 200)) - # window.record(scene, out_path=tmp_fname, size=(200, 200), - # reset_camera=True) - # npt.assert_equal(os.path.exists(tmp_fname), True) - # ss = load_image(tmp_fname) - actual = ss[100, 100, :] / 1000 - desired = np.array([0, 0, 255]) / 1000 - npt.assert_array_almost_equal(actual, desired, decimal=2) - actual = ss[40, 40, :] / 1000 - desired = np.array([0, 0, 175]) / 1000 - npt.assert_array_almost_equal(actual, desired, decimal=2) - - -def test_manifest_principled(): - # Test non-supported property - test_actor = actor.text_3d("Test") - npt.assert_warns(UserWarning, material.manifest_principled, test_actor) - - center = np.array([[0, 0, 0]]) - - # Test expected parameters - expected_principled_params = { - "subsurface": 0, - "metallic": 0, - "specular": 0, - "specular_tint": 0, - "roughness": 0, - "anisotropic": 0, - "anisotropic_direction": [0, 1, 0.5], - "sheen": 0, - "sheen_tint": 0, - "clearcoat": 0, - "clearcoat_gloss": 0, - } - test_actor = actor.square(center, directions=(1, 1, 1), colors=(0, 0, 1)) - actual_principled_params = material.manifest_principled(test_actor) - npt.assert_equal(actual_principled_params, expected_principled_params) - - -def test_manifest_standard(): - # Test non-supported property - test_actor = actor.text_3d("Test") - npt.assert_warns(UserWarning, material.manifest_standard, test_actor) - - center = np.array([[0, 0, 0]]) - - # Test non-supported interpolation method - test_actor = actor.square(center, directions=(1, 1, 1), colors=(0, 0, 1)) - npt.assert_warns( - UserWarning, material.manifest_standard, test_actor, interpolation="test" + color = (1, 0, 0, 0.5) + mat = material._create_mesh_material( + material="phong", color=color, opacity=0.5, mode="auto" ) + assert type(mat) == material.gfx.MeshPhongMaterial + assert mat.color == (1, 0, 0, 0.25) + assert mat.color_mode == "auto" - scene = window.Scene() # Setup scene - - test_actor = actor.box(center, directions=(1, 1, 1), colors=(0, 0, 1), scales=1) - scene.add(test_actor) - - # scene.reset_camera() - # window.show(scene) - ss = window.snapshot(scene, size=(200, 200)) - actual = ss[125, 100, :] / 1000 - desired = np.array([0, 0, 201]) / 1000 - npt.assert_array_almost_equal(actual, desired, decimal=2) - actual = ss[75, 75, :] / 1000 - npt.assert_array_almost_equal(actual, desired, decimal=2) - actual = ss[75, 75, :] / 1000 - desired = np.array([0, 0, 85]) / 1000 - # TODO: check if camera affects this assert - # npt.assert_array_almost_equal(actual, desired, decimal=2) - - # Test ambient level - material.manifest_standard(test_actor, ambient_level=1) - ss = window.snapshot(scene, size=(200, 200)) - actual = ss[125, 100, :] / 1000 - desired = np.array([0, 0, 255]) / 1000 - npt.assert_array_almost_equal(actual, desired, decimal=2) - actual = ss[75, 125, :] / 1000 - npt.assert_array_almost_equal(actual, desired, decimal=2) - actual = ss[75, 75, :] / 1000 - npt.assert_array_almost_equal(actual, desired, decimal=2) - - # Test ambient color - material.manifest_standard(test_actor, ambient_level=0.5, ambient_color=(1, 0, 0)) - ss = window.snapshot(scene, size=(200, 200)) - actual = ss[125, 100, :] / 1000 - npt.assert_array_almost_equal(actual, desired, decimal=2) - actual = ss[75, 125, :] / 1000 - npt.assert_array_almost_equal(actual, desired, decimal=2) - actual = ss[75, 75, :] / 1000 - desired = np.array([0, 0, 212]) / 1000 - # TODO: check what affects this - # npt.assert_array_almost_equal(actual, desired, decimal=2) - - # Test diffuse level - material.manifest_standard(test_actor, diffuse_level=0.75) - ss = window.snapshot(scene, size=(200, 200)) - actual = ss[125, 100, :] / 1000 - desired = np.array([0, 0, 151]) / 1000 - npt.assert_array_almost_equal(actual, desired, decimal=2) - actual = ss[75, 125, :] / 1000 - desired = np.array([0, 0, 110]) / 1000 - npt.assert_array_almost_equal(actual, desired, decimal=2) - actual = ss[75, 75, :] / 1000 - desired = np.array([0, 0, 151]) / 1000 - # TODO: check what affects this - # npt.assert_array_almost_equal(actual, desired, decimal=2) - - # Test diffuse color - material.manifest_standard(test_actor, diffuse_level=0.5, diffuse_color=(1, 0, 0)) - ss = window.snapshot(scene, size=(200, 200)) - actual = ss[125, 100, :] / 1000 - desired = np.array([0, 0, 101]) / 1000 - npt.assert_array_almost_equal(actual, desired, decimal=2) - actual = ss[75, 75, :] / 1000 - npt.assert_array_almost_equal(actual, desired, decimal=2) - actual = ss[75, 125, :] / 1000 - desired = np.array([0, 0, 74]) / 1000 - npt.assert_array_almost_equal(actual, desired, decimal=2) - - # Test specular level - material.manifest_standard(test_actor, specular_level=1) - ss = window.snapshot(scene, size=(200, 200)) - actual = ss[125, 100, :] / 1000 - desired = np.array([201, 201, 255]) / 1000 - npt.assert_array_almost_equal(actual, desired, decimal=2) - actual = ss[75, 75, :] / 1000 - npt.assert_array_almost_equal(actual, desired, decimal=2) - actual = ss[75, 125, :] / 1000 - desired = np.array([147, 147, 255]) / 1000 - npt.assert_array_almost_equal(actual, desired, decimal=2) - - # Test specular power - material.manifest_standard(test_actor, specular_level=1, specular_power=5) - ss = window.snapshot(scene, size=(200, 200)) - actual = ss[125, 100, :] / 1000 - desired = np.array([78, 78, 255]) / 1000 - npt.assert_array_almost_equal(actual, desired, decimal=2) - actual = ss[75, 75, :] / 1000 - npt.assert_array_almost_equal(actual, desired, decimal=2) - actual = ss[75, 125, :] / 1000 - desired = np.array([16, 16, 163]) / 1000 - npt.assert_array_almost_equal(actual, desired, decimal=2) - - # Test specular color - material.manifest_standard( - test_actor, specular_level=1, specular_color=(1, 0, 0), specular_power=5 + color = (1, 0, 0) + mat = material._create_mesh_material( + material="phong", color=color, opacity=0.5, mode="vertex" ) - ss = window.snapshot(scene, size=(200, 200)) - actual = ss[125, 100, :] / 1000 - desired = np.array([78, 0, 201]) / 1000 - npt.assert_array_almost_equal(actual, desired, decimal=2) - actual = ss[75, 75, :] / 1000 - npt.assert_array_almost_equal(actual, desired, decimal=2) - actual = ss[75, 125, :] / 1000 - desired = np.array([16, 0, 147]) / 1000 - npt.assert_array_almost_equal(actual, desired, decimal=2) - - scene = window.Scene() + assert type(mat) == material.gfx.MeshPhongMaterial + assert mat.color == (1, 1, 1) + assert mat.color_mode == "vertex" - # Special case: Contour from roi - data = np.zeros((50, 50, 50)) - data[20:30, 25, 25] = 1.0 - data[25, 20:30, 25] = 1.0 - test_actor = actor.contour_from_roi(data, color=np.array([1, 0, 1])) - scene.add(test_actor) - - ss = window.snapshot(scene, size=(200, 200)) - actual = ss[100, 106, :] / 1000 - desired = np.array([253, 0, 253]) / 1000 - npt.assert_array_almost_equal(actual, desired, decimal=2) - actual = ss[100, 150, :] / 1000 - desired = np.array([180, 0, 180]) / 1000 - npt.assert_array_almost_equal(actual, desired, decimal=2) - - material.manifest_standard(test_actor) - ss = window.snapshot(scene, size=(200, 200)) - actual = ss[100, 106, :] / 1000 - desired = np.array([253, 253, 253]) / 1000 - npt.assert_array_almost_equal(actual, desired, decimal=2) - actual = ss[100, 150, :] / 1000 - desired = np.array([180, 180, 180]) / 1000 - npt.assert_array_almost_equal(actual, desired, decimal=2) - - material.manifest_standard(test_actor, diffuse_color=(1, 0, 1)) - ss = window.snapshot(scene, size=(200, 200)) - actual = ss[100, 106, :] / 1000 - desired = np.array([253, 0, 253]) / 1000 - npt.assert_array_almost_equal(actual, desired, decimal=2) - actual = ss[100, 150, :] / 1000 - desired = np.array([180, 0, 180]) / 1000 - npt.assert_array_almost_equal(actual, desired, decimal=2) + color = (1, 0, 0) + mat = material._create_mesh_material( + material="basic", color=color, mode="vertex", enable_picking=False + ) + assert type(mat) == material.gfx.MeshBasicMaterial + assert mat.color == (1, 1, 1) + assert mat.color_mode == "vertex"