From 900456109f3871b94c64d5e78123d2a7dc7fc743 Mon Sep 17 00:00:00 2001 From: Alessia Saccardo <s212246@dtu.dk> Date: Mon, 17 Feb 2025 14:51:04 +0100 Subject: [PATCH] remove all trimesh code and add new functions with pygel3d --- qim3d/features/_common_features_methods.py | 175 +-------------------- qim3d/io/_loading.py | 14 +- qim3d/io/_saving.py | 45 ++++-- qim3d/mesh/__init__.py | 2 +- qim3d/mesh/_common_mesh_methods.py | 76 +-------- qim3d/viz/_k3d.py | 141 +++++++++-------- test_mesh.ipynb | Bin 74439407 -> 74439618 bytes 7 files changed, 124 insertions(+), 329 deletions(-) diff --git a/qim3d/features/_common_features_methods.py b/qim3d/features/_common_features_methods.py index 62a7611e..aef19d81 100644 --- a/qim3d/features/_common_features_methods.py +++ b/qim3d/features/_common_features_methods.py @@ -1,173 +1,10 @@ import numpy as np import qim3d.processing from qim3d.utils._logger import log -import trimesh import qim3d from pygel3d import hmesh - -def volume(obj: np.ndarray|trimesh.Trimesh, - **mesh_kwargs - ) -> float: - """ - Compute the volume of a 3D volume or mesh. - - Args: - obj (np.ndarray or trimesh.Trimesh): Either a np.ndarray volume or a mesh object of type trimesh.Trimesh. - **mesh_kwargs (Any): Additional arguments for mesh creation if the input is a volume. - - Returns: - volume (float): The volume of the object. - - Example: - Compute volume from a mesh: - ```python - import qim3d - - # Load a mesh from a file - mesh = qim3d.io.load_mesh('path/to/mesh.obj') - - # Compute the volume of the mesh - vol = qim3d.features.volume(mesh) - print('Volume:', vol) - ``` - - Compute volume from a np.ndarray: - ```python - import qim3d - - # Generate a 3D blob - synthetic_blob = qim3d.generate.noise_object(noise_scale = 0.015) - synthetic_blob = qim3d.generate.noise_object(noise_scale = 0.015) - - # Compute the volume of the blob - volume = qim3d.features.volume(synthetic_blob, level=0.5) - volume = qim3d.features.volume(synthetic_blob, level=0.5) - print('Volume:', volume) - ``` - - """ - if isinstance(obj, np.ndarray): - log.info("Converting volume to mesh.") - obj = qim3d.mesh.from_volume(obj, **mesh_kwargs) - - return obj.volume - - -def area(obj: np.ndarray|trimesh.Trimesh, - **mesh_kwargs - ) -> float: - """ - Compute the surface area of a 3D volume or mesh. - - Args: - obj (np.ndarray or trimesh.Trimesh): Either a np.ndarray volume or a mesh object of type trimesh.Trimesh. - **mesh_kwargs (Any): Additional arguments for mesh creation if the input is a volume. - - Returns: - area (float): The surface area of the object. - - Example: - Compute area from a mesh: - ```python - import qim3d - - # Load a mesh from a file - mesh = qim3d.io.load_mesh('path/to/mesh.obj') - - # Compute the surface area of the mesh - area = qim3d.features.area(mesh) - area = qim3d.features.area(mesh) - print(f"Area: {area}") - ``` - - Compute area from a np.ndarray: - ```python - import qim3d - - # Generate a 3D blob - synthetic_blob = qim3d.generate.noise_object(noise_scale = 0.015) - synthetic_blob = qim3d.generate.noise_object(noise_scale = 0.015) - - # Compute the surface area of the blob - area = qim3d.features.area(synthetic_blob, level=0.5) - area = qim3d.features.area(synthetic_blob, level=0.5) - print('Area:', area) - ``` - """ - if isinstance(obj, np.ndarray): - log.info("Converting volume to mesh.") - obj = qim3d.mesh.from_volume(obj, **mesh_kwargs) - - return obj.area - - -def sphericity(obj: np.ndarray|trimesh.Trimesh, - **mesh_kwargs - ) -> float: - """ - Compute the sphericity of a 3D volume or mesh. - - Sphericity is a measure of how spherical an object is. It is defined as the ratio - of the surface area of a sphere with the same volume as the object to the object's - actual surface area. - - Args: - obj (np.ndarray or trimesh.Trimesh): Either a np.ndarray volume or a mesh object of type trimesh.Trimesh. - **mesh_kwargs (Any): Additional arguments for mesh creation if the input is a volume. - - Returns: - sphericity (float): A float value representing the sphericity of the object. - - Example: - Compute sphericity from a mesh: - ```python - import qim3d - - # Load a mesh from a file - mesh = qim3d.io.load_mesh('path/to/mesh.obj') - - # Compute the sphericity of the mesh - sphericity = qim3d.features.sphericity(mesh) - sphericity = qim3d.features.sphericity(mesh) - ``` - - Compute sphericity from a np.ndarray: - ```python - import qim3d - - # Generate a 3D blob - synthetic_blob = qim3d.generate.noise_object(noise_scale = 0.015) - synthetic_blob = qim3d.generate.noise_object(noise_scale = 0.015) - - # Compute the sphericity of the blob - sphericity = qim3d.features.sphericity(synthetic_blob, level=0.5) - sphericity = qim3d.features.sphericity(synthetic_blob, level=0.5) - ``` - - !!! info "Limitations due to pixelation" - Sphericity is particularly sensitive to the resolution of the mesh, as it directly impacts the accuracy of surface area and volume calculations. - Since the mesh is generated from voxel-based 3D volume data, the discrete nature of the voxels leads to pixelation effects that reduce the precision of sphericity measurements. - Higher resolution meshes may mitigate these errors but often at the cost of increased computational demands. - """ - if isinstance(obj, np.ndarray): - log.info("Converting volume to mesh.") - obj = qim3d.mesh.from_volume(obj, **mesh_kwargs) - - volume = qim3d.features.volume(obj) - area = qim3d.features.area(obj) - volume = qim3d.features.volume(obj) - area = qim3d.features.area(obj) - - if area == 0: - log.warning("Surface area is zero, sphericity is undefined.") - return np.nan - - sphericity = (np.pi ** (1 / 3) * (6 * volume) ** (2 / 3)) / area - log.info(f"Sphericity: {sphericity}") - return sphericity - -def volume_pygel3d(obj: np.ndarray|hmesh.Manifold) -> float: +def volume(obj: np.ndarray|hmesh.Manifold) -> float: """ Compute the volume of a 3D mesh using the Pygel3D library. @@ -180,11 +17,11 @@ def volume_pygel3d(obj: np.ndarray|hmesh.Manifold) -> float: if isinstance(obj, np.ndarray): log.info("Converting volume to mesh.") - obj = qim3d.mesh.from_volume_pygel3d(obj) + obj = qim3d.mesh.from_volume(obj) return hmesh.volume(obj) -def area_pygel3d(obj: np.ndarray|hmesh.Manifold) -> float: +def area(obj: np.ndarray|hmesh.Manifold) -> float: """ Compute the surface area of a 3D mesh using the Pygel3D library. @@ -197,7 +34,7 @@ def area_pygel3d(obj: np.ndarray|hmesh.Manifold) -> float: if isinstance(obj, np.ndarray): log.info("Converting volume to mesh.") - obj = qim3d.mesh.from_volume_pygel3d(obj) + obj = qim3d.mesh.from_volume(obj) return hmesh.area(obj) @@ -216,8 +53,8 @@ def sphericity_pygel3d(obj: np.ndarray|hmesh.Manifold) -> float: log.info("Converting volume to mesh.") obj = qim3d.mesh.from_volume_pygel3d(obj) - volume = volume_pygel3d(obj) - area = area_pygel3d(obj) + volume = volume(obj) + area = area(obj) if area == 0: log.warning("Surface area is zero, sphericity is undefined.") diff --git a/qim3d/io/_loading.py b/qim3d/io/_loading.py index df8320c9..0ed9ae83 100644 --- a/qim3d/io/_loading.py +++ b/qim3d/io/_loading.py @@ -28,7 +28,7 @@ from qim3d.utils._misc import get_file_size, sizeof, stringify_path from qim3d.utils import Memory from qim3d.utils._progress_bar import FileLoadingProgressBar import trimesh - +from pygel3d import hmesh from typing import Optional, Dict dask.config.set(scheduler="processes") @@ -865,15 +865,16 @@ def load( return data -def load_mesh(filename: str) -> trimesh.Trimesh: + +def load_mesh(filename: str) -> hmesh.Manifold: """ - Load a mesh from an .obj file using trimesh. + Load a mesh from an an X3D/OBJ/OFF/PLY file. Args: filename (str or os.PathLike): The path to the .obj file. Returns: - mesh (trimesh.Trimesh): A trimesh object containing the mesh data (vertices and faces). + mesh (hmesh.Manifold or None): A hmesh object containing the mesh data or None if loading failed. Example: ```python @@ -882,5 +883,6 @@ def load_mesh(filename: str) -> trimesh.Trimesh: mesh = qim3d.io.load_mesh("path/to/mesh.obj") ``` """ - mesh = trimesh.load(filename) - return mesh + mesh = hmesh.load(filename) + + return mesh \ No newline at end of file diff --git a/qim3d/io/_saving.py b/qim3d/io/_saving.py index 0a1fae81..6dd770b5 100644 --- a/qim3d/io/_saving.py +++ b/qim3d/io/_saving.py @@ -36,7 +36,7 @@ import zarr from pydicom.dataset import FileDataset, FileMetaDataset from pydicom.uid import UID import trimesh - +from pygel3d import hmesh from qim3d.utils import log from qim3d.utils._misc import sizeof, stringify_path @@ -464,16 +464,41 @@ def save( ).save(path, data) -def save_mesh( - filename: str, - mesh: trimesh.Trimesh - ) -> None: - """ - Save a trimesh object to an .obj file. +# def save_mesh( +# filename: str, +# mesh: trimesh.Trimesh +# ) -> None: +# """ +# Save a trimesh object to an .obj file. + +# Args: +# filename (str or os.PathLike): The name of the file to save the mesh. +# mesh (trimesh.Trimesh): A trimesh.Trimesh object representing the mesh. + +# Example: +# ```python +# import qim3d + +# vol = qim3d.generate.noise_object(base_shape=(32, 32, 32), +# final_shape=(32, 32, 32), +# noise_scale=0.05, +# order=1, +# gamma=1.0, +# max_value=255, +# threshold=0.5) +# mesh = qim3d.mesh.from_volume(vol) +# qim3d.io.save_mesh("mesh.obj", mesh) +# ``` +# """ +# # Export the mesh to the specified filename +# mesh.export(filename) + +def save_mesh(filename: str, mesh: hmesh.Manifold) -> None: + """Save a mesh object to an X3D/OBJ/OFF file. The file format is determined by the file extension. Args: filename (str or os.PathLike): The name of the file to save the mesh. - mesh (trimesh.Trimesh): A trimesh.Trimesh object representing the mesh. + mesh (hmesh.Manifold): A hmesh.Manifold object representing the mesh. Example: ```python @@ -481,7 +506,7 @@ def save_mesh( vol = qim3d.generate.noise_object(base_shape=(32, 32, 32), final_shape=(32, 32, 32), - noise_scale=0.05, + noise_scale=0.05,mesh.export(filename) order=1, gamma=1.0, max_value=255, @@ -491,4 +516,4 @@ def save_mesh( ``` """ # Export the mesh to the specified filename - mesh.export(filename) \ No newline at end of file + hmesh.save(filename, mesh) \ No newline at end of file diff --git a/qim3d/mesh/__init__.py b/qim3d/mesh/__init__.py index ffa1fd60..932dfd9e 100644 --- a/qim3d/mesh/__init__.py +++ b/qim3d/mesh/__init__.py @@ -1 +1 @@ -from ._common_mesh_methods import from_volume, from_volume_pygel3d +from ._common_mesh_methods import from_volume diff --git a/qim3d/mesh/_common_mesh_methods.py b/qim3d/mesh/_common_mesh_methods.py index c6b1266a..101539ab 100644 --- a/qim3d/mesh/_common_mesh_methods.py +++ b/qim3d/mesh/_common_mesh_methods.py @@ -1,6 +1,5 @@ import numpy as np from skimage import measure, filters -import trimesh from pygel3d import hmesh from typing import Tuple, Any from qim3d.utils._logger import log @@ -8,80 +7,7 @@ from qim3d.utils._logger import log def from_volume( volume: np.ndarray, - level: float = None, - step_size: int = 1, - allow_degenerate: bool = False, - padding: Tuple[int, int, int] = (2, 2, 2), - **kwargs: Any, -) -> trimesh.Trimesh: - """ - Convert a volume to a mesh using the Marching Cubes algorithm, with optional thresholding and padding. - - Args: - volume (np.ndarray): The 3D numpy array representing the volume. - level (float, optional): The threshold value for Marching Cubes. If None, Otsu's method is used. - step_size (int, optional): The step size for the Marching Cubes algorithm. - allow_degenerate (bool, optional): Whether to allow degenerate (i.e. zero-area) triangles in the end-result. If False, degenerate triangles are removed, at the cost of making the algorithm slower. Default False. - padding (tuple of ints, optional): Padding to add around the volume. - **kwargs: Additional keyword arguments to pass to `skimage.measure.marching_cubes`. - - Returns: - mesh (trimesh.Trimesh): The generated mesh. - - Example: - ```python - import qim3d - vol = qim3d.generate.noise_object(base_shape=(128,128,128), - final_shape=(128,128,128), - noise_scale=0.03, - order=1, - gamma=1, - max_value=255, - threshold=0.5, - dtype='uint8' - ) - mesh = qim3d.mesh.from_volume(vol, step_size=3) - qim3d.viz.mesh(mesh.vertices, mesh.faces) - ``` - <iframe src="https://platform.qim.dk/k3d/mesh_visualization.html" width="100%" height="500" frameborder="0"></iframe> - """ - if volume.ndim != 3: - raise ValueError("The input volume must be a 3D numpy array.") - - # Compute the threshold level if not provided - if level is None: - level = filters.threshold_otsu(volume) - log.info(f"Computed level using Otsu's method: {level}") - - # Apply padding to the volume - if padding is not None: - pad_z, pad_y, pad_x = padding - padding_value = np.min(volume) - volume = np.pad( - volume, - ((pad_z, pad_z), (pad_y, pad_y), (pad_x, pad_x)), - mode="constant", - constant_values=padding_value, - ) - log.info(f"Padded volume with {padding} to shape: {volume.shape}") - - # Call skimage.measure.marching_cubes with user-provided kwargs - verts, faces, normals, values = measure.marching_cubes( - volume, level=level, step_size=step_size, allow_degenerate=allow_degenerate, **kwargs - ) - - # Create the Trimesh object - mesh = trimesh.Trimesh(vertices=verts, faces=faces) - - # Fix face orientation to ensure normals point outwards - trimesh.repair.fix_inversion(mesh, multibody=True) - - return mesh - - -def from_volume_pygel3d( - volume: np.ndarray, - **Kwargs + **Kwargs ) -> hmesh.Manifold: if volume.ndim != 3: diff --git a/qim3d/viz/_k3d.py b/qim3d/viz/_k3d.py index 6fa107d4..2bf4aa54 100644 --- a/qim3d/viz/_k3d.py +++ b/qim3d/viz/_k3d.py @@ -12,7 +12,9 @@ import matplotlib.pyplot as plt from matplotlib.colors import Colormap from qim3d.utils._logger import log from qim3d.utils._misc import downscale_img, scale_to_float16 - +from pygel3d import hmesh +from pygel3d import jupyter_display as jd +import k3d def volumetric( img: np.ndarray, @@ -81,7 +83,6 @@ def volumetric( ``` """ - import k3d pixel_count = img.shape[0] * img.shape[1] * img.shape[2] # target is 60fps on m1 macbook pro, using test volume: https://data.qim.dk/pages/foam.html @@ -172,10 +173,9 @@ def volumetric( else: return plot - def mesh( - verts: np.ndarray, - faces: np.ndarray, + mesh, + backend: str = "k3d", wireframe: bool = True, flat_shading: bool = True, grid_visible: bool = False, @@ -183,76 +183,81 @@ def mesh( save: bool = False, **kwargs, ): - """ - Visualizes a 3D mesh using K3D. - + """Visualize a 3D mesh using `k3d` or `pygel3d`. + Args: - verts (numpy.ndarray): A 2D array (Nx3) containing the vertices of the mesh. - faces (numpy.ndarray): A 2D array (Mx3) containing the indices of the mesh faces. - wireframe (bool, optional): If True, the mesh is rendered as a wireframe. Defaults to True. - flat_shading (bool, optional): If True, flat shading is applied to the mesh. Defaults to True. - grid_visible (bool, optional): If True, the grid is visible in the plot. Defaults to False. - show (bool, optional): If True, displays the visualization inline. Defaults to True. + mesh (pygel3d.hmesh.HMesh): The input mesh object. + backend (str, optional): The visualization backend to use. + Choose between `"k3d"` (default) and `"pygel3d"`. + wireframe (bool, optional): If True, displays the mesh as a wireframe. + Works both with `"k3d"` and `"pygel3d"`. Defaults to True. + flat_shading (bool, optional): If True, applies flat shading to the mesh. + Works only with `"k3d"`. Defaults to True. + grid_visible (bool, optional): If True, shows a grid in the visualization. + Works only with `"k3d"`. Defaults to False. + show (bool, optional): If True, displays the visualization inline. + Works for both `"k3d"` and `"pygel3d"`. Defaults to True. save (bool or str, optional): If True, saves the visualization as an HTML file. If a string is provided, it's interpreted as the file path where the HTML - file will be saved. Defaults to False. - **kwargs (Any): Additional keyword arguments to be passed to the `k3d.plot` function. + file will be saved. Works only with `"k3d"`. Defaults to False. + **kwargs (Any): Additional keyword arguments specific to the chosen backend: + + - `k3d.plot` kwargs: Arguments that customize the `k3d.plot` visualization. + See full reference: https://k3d-jupyter.org/reference/factory.plot.html + + - `pygel3d.display` kwargs: Arguments for `pygel3d` visualization, such as: + - `smooth` (bool, default=True): Enables smooth shading. + - `data` (optional): Allows embedding custom data in the visualization. + See full reference: https://www2.compute.dtu.dk/projects/GEL/PyGEL/pygel3d/jupyter_display.html#display Returns: - plot (k3d.plot): If `show=False`, returns the K3D plot object. + k3d.Plot or None: + - If `backend="k3d"`, returns a `k3d.Plot` object. + - If `backend="pygel3d"`, the function displays the mesh but does not return a plot object. + """ - Example: - ```python - import qim3d - vol = qim3d.generate.noise_object(base_shape=(128,128,128), - final_shape=(128,128,128), - noise_scale=0.03, - order=1, - gamma=1, - max_value=255, - threshold=0.5, - dtype='uint8' - ) - mesh = qim3d.mesh.from_volume(vol, step_size=3) - qim3d.viz.mesh(mesh.vertices, mesh.faces) - ``` - <iframe src="https://platform.qim.dk/k3d/mesh_visualization.html" width="100%" height="500" frameborder="0"></iframe> - """ - import k3d - - # Validate the inputs - if verts.shape[1] != 3: - raise ValueError("Vertices array must have shape (N, 3)") - if faces.shape[1] != 3: - raise ValueError("Faces array must have shape (M, 3)") - - # Ensure the correct data types and memory layout - verts = np.ascontiguousarray( - verts.astype(np.float32) - ) # Cast and ensure C-contiguous layout - faces = np.ascontiguousarray( - faces.astype(np.uint32) - ) # Cast and ensure C-contiguous layout - - # Create the mesh plot - plt_mesh = k3d.mesh( - vertices=verts, - indices=faces, - wireframe=wireframe, - flat_shading=flat_shading, - ) + if backend not in ["k3d", "pygel3d"]: + raise ValueError("Invalid backend. Choose 'k3d' or 'pygel3d'.") - # Create plot - plot = k3d.plot(grid_visible=grid_visible, **kwargs) - plot += plt_mesh + # Extract vertex positions and face indices + face_indices = list(mesh.faces()) + vertices_array = np.array(mesh.positions()) - if save: - # Save html to disk - with open(str(save), "w", encoding="utf-8") as fp: - fp.write(plot.get_snapshot()) + # Extract face vertex indices + face_vertices = [ + list(mesh.circulate_face(int(fid), mode="v"))[:3] for fid in face_indices + ] + face_vertices = np.array(face_vertices, dtype=np.uint32) - if show: - plot.display() - else: - return plot + # Validate the mesh structure + if vertices_array.shape[1] != 3 or face_vertices.shape[1] != 3: + raise ValueError("Vertices must have shape (N, 3) and faces (M, 3)") + + # Separate valid kwargs for each backend + valid_k3d_kwargs = {k: v for k, v in kwargs.items() if k not in ["smooth", "data"]} + valid_pygel_kwargs = {k: v for k, v in kwargs.items() if k in ["smooth", "data"]} + + if backend == "k3d": + vertices_array = np.ascontiguousarray(vertices_array.astype(np.float32)) + face_vertices = np.ascontiguousarray(face_vertices) + + mesh_plot = k3d.mesh( + vertices=vertices_array, + indices=face_vertices, + wireframe=wireframe, + flat_shading=flat_shading, + ) + + plot = k3d.plot(grid_visible=grid_visible, **valid_k3d_kwargs) + plot += mesh_plot + + if save: + with open(str(save), "w", encoding="utf-8") as fp: + fp.write(plot.get_snapshot()) + + return plot.display() if show else plot + + elif backend == "pygel3d": + jd.set_export_mode(True) + return jd.display(mesh, **valid_pygel_kwargs) diff --git a/test_mesh.ipynb b/test_mesh.ipynb index c32c3fca598dffdf80d630ee86e3dc025252420f..6893bcf0e769a2931e9a729b9d32f589ae463a1a 100644 GIT binary patch delta 6908 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 AKmY&$ delta 6442 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 Bged?3 -- GitLab