Search code examples
pythonplot3dopen3dvispy

How to plot many 3D cubes with different colors and opacities


Goal

I want to plot a large number of cubes (arranged in a 3D grid) with different colors and opacities.

Current State and question

I have come up with a solution using vispy, but the performance is very poor - drawing takes very long and the window is very unresponsive. Also, there seem to be some glitches in the visualization, but I could live with those.

Is there a more efficient/elegant way to implement that? I am open to using other packages (I have tried open3d but found it difficult to specify colors and opacities - the documentation is not very verbose). However, I need to use python.

What I did so far

The first problem I had to solve with vispy was that I was unable to create cubes at custom positions. I therefore wrote a subclass that can do that:

import vispy.visuals
from vispy.geometry import create_box

class PositionedCubeVisual(vispy.visuals.BoxVisual):
    
    def __init__(self, size=1, position=(0, 0, 0), width_segments=1,
                 height_segments=1, depth_segments=1, planes=None,
                 vertex_colors=None, face_colors=None,
                 color=(0.5, 0.5, 1, 1), edge_color=None, **kwargs):
        
        vertices, filled_indices, outline_indices = create_box(
            size, size, size, width_segments, height_segments,
            depth_segments, planes)
        
        for column, pos in zip(vertices['position'].T, position):
            column += pos
        
        self._mesh = vispy.visuals.MeshVisual(vertices['position'], filled_indices,
                                vertex_colors, face_colors, color)
        if edge_color:
            self._border = vispy.visuals.MeshVisual(vertices['position'], outline_indices,
                                      color=edge_color, mode='lines')
        else:
            self._border = vispy.visuals.MeshVisual()

        vispy.visuals.CompoundVisual.__init__(self, [self._mesh, self._border], **kwargs)
        self.mesh.set_gl_state(polygon_offset_fill=True,
                               polygon_offset=(1, 1), depth_test=True)
        
PositionedCube = vispy.scene.visuals.create_visual_node(PositionedCubeVisual)

I then plot the cubes as follows:

import numpy as np
import vispy.scene

def plot_grid_cubes(x, y, z, c=None, size=1, alpha=0.1, edge_color="black", 
                    cmap="viridis", bgcolor="#FFFFFF"):
    
    canvas = vispy.scene.SceneCanvas(keys='interactive', show=True)
    
    view = canvas.central_widget.add_view()
    view.bgcolor = bgcolor
    view.camera = 'turntable'
    
    c = get_color_array(c, alpha, min(len(x), len(y), len(z)), cmap)
    
    for xx, yy, zz, cc in zip(x, y, z, c):
        
        cube = PositionedCube(size, (xx, yy, zz), color=cc, edge_color=edge_color, parent=view.scene)
    
    canvas.app.run()


def get_color_array(c, alpha, size, cmap):
    if c is not None:
        cmap = cm.get_cmap(cmap)
        if hasattr(c, "__iter__"):
            c = np.array(c, copy=True, dtype=float)
            c -= c.min() 
            c *= 255/c.max()
        return cmap(c.astype(int), alpha)
    else:
        color = np.ones((size, 4))
        color[:, 3] = alpha
        return color

This can then be applied as follows:

plot_grid_cubes([0, 1], [0, 1], [0, 1], c=[0.3, 0.5], alpha=[0.3, 0.8])

The example above works great, but it becomes poor if I plot thousands of cubes.


Solution

  • Inspired by @djhoese's answer, I have adjusted my initial solution attempt to build a single mesh only. The idea is to add the meshes of the individual cubes to one large mesh, which is then shown. The performance of the interactive visualization is as good as may be expected (some glitches remain); more efficient mesh creation algorithms are definitely possible.

    First define a visual for multiple cubes:

    from vispy.geometry import create_box
    from vispy.scene import visuals
    import vispy.scene
    import vispy.visuals
    from itertools import repeat
    
    class MultiCubeVisual(vispy.visuals.BoxVisual):
        
        def __init__(self, size=1, positions=[(0, 0, 0)], width_segments=1,
                     height_segments=1, depth_segments=1, planes=None,
                     vertex_colors=None, face_colors=None,
                     color=(0.5, 0.5, 1, 0.5), edge_color=None, **kwargs):
            
            vertices = []
            filled_indices = []
            outline_indices = []
            all_vertex_colors = [] 
            all_face_colors = []
            
            if vertex_colors is None:
                vertex_colors = repeat(vertex_colors)
                all_vertex_colors = None
            
            if face_colors is None:
                face_colors = repeat(face_colors)
                all_face_colors = None
            
            maxIndex = 0
            for position, vertex_color, face_color in zip(positions, vertex_colors, face_colors):
                box_vertices, box_filled_indices, box_outline_indices = create_box(
                    size, size, size, width_segments, height_segments,
                    depth_segments, planes)
                
                for column, pos in zip(box_vertices['position'].T, position):
                    column += pos
    
                box_filled_indices += maxIndex
                box_outline_indices += maxIndex
                vertexNumber = len(box_vertices)
                maxIndex += vertexNumber
                vertices.append(box_vertices)
                filled_indices.append(box_filled_indices)
                outline_indices.append(box_outline_indices)
                
                if vertex_color is not None:
                    all_vertex_colors.extend([vertex_color] * vertexNumber)
                if face_color is not None:
                    all_face_colors.extend([face_color] * len(box_filled_indices))
            
            vertices = np.concatenate(vertices)
            filled_indices = np.vstack(filled_indices)
            outline_indices = np.vstack(outline_indices)
            
            self._mesh = vispy.visuals.MeshVisual(vertices['position'], filled_indices,
                                    all_vertex_colors, all_face_colors, color)
            if edge_color:
                self._border = vispy.visuals.MeshVisual(vertices['position'], outline_indices,
                                          color=edge_color, mode='lines')
            else:
                self._border = vispy.visuals.MeshVisual()
    
            vispy.visuals.CompoundVisual.__init__(self, [self._mesh, self._border], **kwargs)
            self.mesh.set_gl_state(polygon_offset_fill=True,
                                   polygon_offset=(1, 1), depth_test=True)
    
    
    MultiCube = vispy.scene.visuals.create_visual_node(MultiCubeVisual)
    

    Now plotting works like this:

    def get_color_array(c, alpha, size, cmap="viridis"):
        if c is not None:
            cmap = cm.get_cmap(cmap, 256)
            if hasattr(c, "__iter__"):
                c = np.array(c, copy=True, dtype=float)
                c -= c.min() 
                c *= 255/c.max()
            return cmap(c.astype(int), alpha)
        else:
            color = np.ones((size, 4))
            color[:, 3] = alpha
            return color
        
    
    def plot_grid_cubes(x, y, z, c=None, size=1, alpha=0.1, edge_color="black", 
                        cmap="viridis", bgcolor="#FFFFFF", figure=None, show=True):
        
        if figure is None:
            figure = VispyFigure(bgcolor)
            
        mergedData = np.vstack((x, y, z)).T
        
        c = get_color_array(c, alpha, mergedData.shape[1], cmap)
        cubes = MultiCube(size, mergedData, edge_color=edge_color, face_colors=c, 
                          parent=figure.view.scene)
    

    This can be applied as suggested in the question:

    plot_grid_cubes([0, 1], [0, 1], [0, 1], c=[0.3, 0.5], alpha=[0.3, 0.8])