diff --git a/examples/volume-3d.py b/examples/volume-3d.py new file mode 100644 index 0000000..a45a983 --- /dev/null +++ b/examples/volume-3d.py @@ -0,0 +1,66 @@ +# Package: Graphic Server Protocol / Matplotlib +# Authors: Nicolas P .Rougier +# License: BSD 3 clause +""" +From https://datoviz.org/gallery/visuals/volume/ +""" + +# Experiment to handle intellisense in VSCode +# from gsp.core.types import Color +import matplotlib.pyplot as plt +import numpy as np +import common.asset_downloader as asset_downloader +from gsp_matplotlib import glm +from common.launcher import parse_args + +############################ +# Download/read the volume data +# +import common.asset_downloader as asset_downloader +import gzip + +# Parse command line arguments +core, visual, render = parse_args() + +#################################################### +# Download/read the volume data +# + +volume_path = asset_downloader.download_data("volumes/allen_mouse_brain_rgba.npy.gz") +print(f"Loaded point cloud data from {volume_path}") +with gzip.open(volume_path, "rb") as f: + volume_data = np.load(f, allow_pickle=True) + +# Normalize volume_data colors from [0, 255] to [0, 1] +volume_data = volume_data / 255.0 + +#################################################### +# Create canvas+viewport for the GSP scene +# +canvas = core.Canvas(width=512, height=512, dpi=250.0) +viewport = core.Viewport(canvas=canvas, x=0, y=0, width=512, height=512, color=(0, 0, 0, 1)) + +###################################################### +# Create a texture from the volume data +# + +texture_3d = core.Texture(volume_data, volume_data.shape) + +##################################################### +# Create a volume from the texture +# + +bound_x = (-1, 1) +bound_y = (-1, 1) +bound_z = (-1, 1) +volume = visual.Volume( + texture_3d=texture_3d, + bounds_3d=(bound_x, bound_y, bound_z), + downsample_ratio=0.00005, + jitter_position_factor=0.000, + point_size=200.0, +) + +############################ + +render(canvas, [viewport], [volume]) diff --git a/gsp/core/__init__.py b/gsp/core/__init__.py index 67ca6fa..2390f37 100644 --- a/gsp/core/__init__.py +++ b/gsp/core/__init__.py @@ -2,11 +2,12 @@ # Authors: Nicolas P .Rougier # License: BSD 3 clause -from . data import Data -from . list import List -from . buffer import Buffer -from . canvas import Canvas -from . viewport import Viewport -from . types import Color, Marker, Measure -from . types import Matrix, Vec2, Vec3, Vec4 -from . types import LineCap, LineStyle, LineJoin +from .data import Data +from .list import List +from .buffer import Buffer +from .canvas import Canvas +from .viewport import Viewport +from .texture import Texture +from .types import Color, Marker, Measure +from .types import Matrix, Vec2, Vec3, Vec4 +from .types import LineCap, LineStyle, LineJoin diff --git a/gsp/core/texture.py b/gsp/core/texture.py new file mode 100644 index 0000000..e17fb95 --- /dev/null +++ b/gsp/core/texture.py @@ -0,0 +1,30 @@ +# Package: Graphic Server Protocol +# Authors: Nicolas P .Rougier +# License: BSD 3 clause +from __future__ import annotations + +from gsp import Object +from gsp.io.command import command +import numpy as np + + +class Texture(Object): + """ + A texture is a rectangular two-dimensional image that can be + applied to a surface in 3D space. + """ + + @command("core.Texture") + def __init__(self, texture_data: np.ndarray, shape: tuple): + """ + A texture is a rectangular two-dimensional image. + + Parameters + ---------- + + texture_data: + The image data of the texture. + shape: + The shape of the texture (height, width, channels). + """ + Object.__init__(self) diff --git a/gsp/visual/__init__.py b/gsp/visual/__init__.py index a24e2e7..8c8e943 100644 --- a/gsp/visual/__init__.py +++ b/gsp/visual/__init__.py @@ -2,12 +2,14 @@ # Authors: Nicolas P .Rougier # License: BSD 3 clause -from . visual import Visual -from . pixels import Pixels -from . points import Points -from . markers import Markers -from . segments import Segments -from . paths import Paths -from . polygons import Polygons +from .visual import Visual +from .pixels import Pixels +from .points import Points +from .markers import Markers +from .segments import Segments +from .paths import Paths +from .polygons import Polygons + # from . image import Image # from . mesh import Mesh +from .volume import Volume diff --git a/gsp/visual/volume.py b/gsp/visual/volume.py new file mode 100644 index 0000000..2890118 --- /dev/null +++ b/gsp/visual/volume.py @@ -0,0 +1,43 @@ +# Package: Graphic Server Protocol +# Authors: Nicolas P .Rougier +# License: BSD 3 clause + +import numpy as np +from gsp.visual import Visual +from gsp.core import Buffer, Color +from gsp.transform import Transform +from gsp.io.command import command + + +class Volume(Visual): + """ + A volume is a three-dimensional shape composed of a set of voxels. + """ + + @command("visual.Volume") + def __init__( + self, + positions: Transform | Buffer, + sizes: Transform | Buffer | float, + fill_colors: Transform | Buffer | Color, + ): + super().__init__() + + # These variables are available prior to rendering and may be + # tracked + self._in_variables = { + "positions": positions, + "fill_colors": fill_colors, + "sizes": sizes, + "viewport": None, + } + + # These variables exists only during rendering and are + # available on server side only. We have thus to make + # sure they are not tracked. + n = len(positions) + self._out_variables = { + "screen[positions]": np.empty((n, 3), np.float32), + "fill_colors": np.empty((n, 4), np.float32), + "sizes": np.empty(n, np.float32), + } diff --git a/gsp_matplotlib/core/__init__.py b/gsp_matplotlib/core/__init__.py index 0b30ae8..8284da8 100644 --- a/gsp_matplotlib/core/__init__.py +++ b/gsp_matplotlib/core/__init__.py @@ -2,10 +2,12 @@ # Authors: Nicolas P .Rougier # License: BSD 3 clause -#from . data import Data -from . list import List -from . buffer import Buffer -from . canvas import Canvas -from . viewport import Viewport +# from . data import Data +from .list import List +from .buffer import Buffer +from .canvas import Canvas +from .viewport import Viewport +from .texture import Texture from gsp.core import Color, Marker, Measure -#from . types import LineCap, LineStyle, LineJoin + +# from . types import LineCap, LineStyle, LineJoin diff --git a/gsp_matplotlib/core/texture.py b/gsp_matplotlib/core/texture.py new file mode 100644 index 0000000..ace80f8 --- /dev/null +++ b/gsp_matplotlib/core/texture.py @@ -0,0 +1,26 @@ +# Package: Graphic Server Protocol / Matplotlib +# Authors: Nicolas P .Rougier +# License: BSD 3 clause +# from __future__ import annotations +import numpy as np +from gsp import core + + +class Texture(core.Texture): + + __doc__ = core.Texture.__doc__ + + def __init__(self, texture_data: np.ndarray, shape: tuple): + + super().__init__(texture_data=texture_data, shape=shape) + + self._texture_data = texture_data.flatten() + self._shape = shape + + @property + def data(self) -> np.ndarray: + return self._texture_data + + @property + def shape(self) -> tuple: + return self._shape diff --git a/gsp_matplotlib/visual/__init__.py b/gsp_matplotlib/visual/__init__.py index 4f8c89d..8f0c550 100644 --- a/gsp_matplotlib/visual/__init__.py +++ b/gsp_matplotlib/visual/__init__.py @@ -2,11 +2,13 @@ # Authors: Nicolas P .Rougier # License: BSD 3 clause -from . pixels import Pixels -from . points import Points -from . markers import Markers -from . segments import Segments -from . paths import Paths -from . polygons import Polygons +from .pixels import Pixels +from .points import Points +from .markers import Markers +from .segments import Segments +from .paths import Paths +from .polygons import Polygons + # from . image import Image # from . mesh import Mesh +from .volume import Volume diff --git a/gsp_matplotlib/visual/volume.py b/gsp_matplotlib/visual/volume.py new file mode 100644 index 0000000..ddd5120 --- /dev/null +++ b/gsp_matplotlib/visual/volume.py @@ -0,0 +1,180 @@ +# Package: Graphic Server Protocol / Matplotlib +# Authors: Nicolas P .Rougier +# License: BSD 3 clause + +import numpy as np +from gsp import visual +from gsp.io.command import command +import gsp_matplotlib.core as mpl_core +import gsp_matplotlib.glm as glm + + +class Volume(visual.Volume): + """ + 3D Volume visual representation. + """ + + __doc__ = visual.Volume.__doc__ + + @command("visual.Volume") + def __init__( + self, + texture_3d: mpl_core.Texture, + bounds_3d: tuple = ((-1, 1), (-1, 1), (-1, 1)), + point_size: float = 10.0, + downsample_ratio: float = 1.0, + alpha_factor: float = 1.0, + jitter_position_factor: float = 0.0, + remove_invisible_points_enabled: bool = True, + ): + """ + Initialize a 3D volume. + + Args: + texture_3d (mpl_core.Texture): The 3D texture to use for the volume. + bounds_3d (tuple[tuple[float, float], tuple[float, float], tuple[float, float]]): The 3D bounds of the volume. + point_size (float): The size of the points in the volume. + downsample_ratio (float): The ratio to downsample the volume. percent of original number of point to keep + alpha_factor (float): The factor to apply to the alpha channel of each point color. + jitter_position_factor (float): The factor to apply to the jittering of positions. It helps to reduce moire patterns. + remove_invisible_points_enabled (bool): Whether to remove invisible points aka points with alpha = 0. + """ + volume_depth = texture_3d.shape[0] + volume_height = texture_3d.shape[1] + volume_width = texture_3d.shape[2] + + ############ + # Sanity checks for __init__ arguments + # + + # sanity check - bounds_3d shape MUST be (3, 2) + assert np.shape(bounds_3d) == (3, 2) + + # sanity check - volume shape + assert volume_depth > 0 + assert volume_height > 0 + assert volume_width > 0 + + ############# + # Convert volume_data in a grid for `positions` and `fill_colors` + # + + # Create a grid of normalized coordinates directly in meshgrid + x_min, x_max = bounds_3d[0] + y_min, y_max = bounds_3d[1] + z_min, z_max = bounds_3d[2] + coordinate_z, coordinate_y, coordinate_x = np.meshgrid( + np.linspace(z_min, z_max, volume_depth), + np.linspace(y_min, y_max, volume_height), + np.linspace(x_min, x_max, volume_width), + indexing="ij", # ensures (z,y,x) ordering + ) + + # Stack into (N, 3) array of positions + positions = np.stack([coordinate_x.ravel(), coordinate_y.ravel(), coordinate_z.ravel()], axis=-1).reshape(-1, 3) + fill_colors = texture_3d.data.reshape(-1, 4) # rgba per point + + ############ + # Downsample the positions and fill_colors + # + + point_to_keep = int(downsample_ratio * len(positions)) + indices = np.random.choice(len(positions), size=point_to_keep, replace=False) + positions = positions[indices] + fill_colors = fill_colors[indices] + + ############ + # optimisation: remove all positions and fill_colors where alpha is 0. it would be invisible anyways + # + + if remove_invisible_points_enabled: + positions = positions[fill_colors[..., 3] > 0] + fill_colors = fill_colors[fill_colors[..., 3] > 0] + + ############ + # multiply alpha (the forth dimension) of fill_colors + # + + fill_colors[..., 3] *= alpha_factor + + ############ + # Fake way to remove moire patterns + # + + if jitter_position_factor != 1: + positions += jitter_position_factor * np.random.normal(0, 1, positions.shape) + + ############ + # Initialize the parent class + # + + super().__init__( + positions=positions, + sizes=point_size, + fill_colors=fill_colors, + __no_command__=True, + ) + + def render(self, viewport=None, model=None, view=None, proj=None): + """ + Render the volume in the given viewport. + Heavily inspired by `visual.points` + """ + + super().render(viewport, model, view, proj) + model = model if model is not None else self._model + view = view if view is not None else self._view + proj = proj if proj is not None else self._proj + + # Disable tracking for newly created glm.ndarray (or else, + # this will create GSP buffers) + tracker = glm.ndarray.tracked.__tracker_class__ + glm.ndarray.tracked.__tracker_class__ = None + + # Create the collection if necessary + if viewport not in self._viewports: + collection = viewport._axes.scatter([], []) + collection.set_antialiaseds(True) + collection.set_linewidths(0) + self._viewports[viewport] = collection + viewport._axes.add_collection(collection, autolim=False) + + # This is necessary for measure transforms that need to be + # kept up to date with canvas size + canvas = viewport._canvas._figure.canvas + canvas.mpl_connect("resize_event", lambda event: self.render(viewport)) + + # If render has been called without model/view/proj, we don't + # render Such call is only used to declare that this visual is + # to be rendered on that viewport. + if self._transform is None: + # Restore tracking + glm.ndarray.tracked.__tracker_class__ = tracker + return + + collection = self._viewports[viewport] + positions = self.eval_variable("positions") + positions = positions.reshape(-1, 3) + positions = glm.to_vec3(glm.to_vec4(positions) @ self._transform.T) + + # Invert depth buffer values before sorting + # This in place inversion is important for subsequent transforms + positions[:, 2] = 1 - positions[:, 2] + sort_indices = np.argsort(positions[:, 2]) + collection.set_offsets(positions[sort_indices, :2]) + self.set_variable("screen[positions]", positions) + + fill_colors = self.eval_variable("fill_colors") + if isinstance(fill_colors, np.ndarray) and (len(fill_colors) == len(positions)): + collection.set_facecolors(fill_colors[sort_indices]) + else: + collection.set_facecolors(fill_colors) + + sizes = self.eval_variable("sizes") + if isinstance(sizes, np.ndarray) and (len(sizes) == len(positions)): + collection.set_sizes(sizes[sort_indices]) + else: + collection.set_sizes([sizes] * len(positions)) + + # Restore tracking + glm.ndarray.tracked.__tracker_class__ = tracker