X Tutup
Skip to content

Voxels as mappable for colorbar & changed return type of voxels#30982

Closed
trygvrad wants to merge 1 commit intomatplotlib:mainfrom
trygvrad:voxels_3d_streamlined
Closed

Voxels as mappable for colorbar & changed return type of voxels#30982
trygvrad wants to merge 1 commit intomatplotlib:mainfrom
trygvrad:voxels_3d_streamlined

Conversation

@trygvrad
Copy link
Copy Markdown
Contributor

PR summary

This PR is in response to and closes #22969 [ENH]: Voxels as mappable for colorbar

This change allows axes3d.voxels() to take scalar data as input, and map it to color using a colorbar. The colors are applied to the voxels before shading.

The following code demonstrates the new functionality:

import numpy as np
import matplotlib.pyplot as plt

#Mock data
X,Y,Z = np.meshgrid(np.linspace(0,5,10), np.linspace(0,5,10), np.linspace(0,5,10), indexing="ij")
Hist = np.zeros((9,9,9))
Hist[2,5,5], Hist[5,5,3], Hist[5,7,5], Hist[5,0,5] = 1, 55, 4, 39
Hist[5,1,5] = 39
Hist[Hist == 0] = np.nan

# plot
fig=plt.figure()
ax = fig.add_subplot(projection='3d')
res = ax.voxels(X, Y, Z, Hist, cmap='rainbow', norm='log', alpha=0.5)
fig.colorbar(res)
res.colorizer.norm.vmin = 3
image

The technical implementation is such that:

  • scalar data can be input instead of boolean for the filled argument
  filled : 3D np.array of bool or float
       If bool, with truthy values indicate which voxels to fill.
       If float, voxels with finite values are shown with color
       mapped via *cmap* and *norm*, while voxels with nan are ignored.
  • The return type is changed from a dict of Poly3DCollection to a single Poly3DCollection. This change is needed so that we can call fig.colorbar() on the return value.
  • Poly3DCollection gets a new member function .update_scalarmappable() which overloads Collection.update_scalarmappable(). The new member function is needed to combine the color from the colormap with the shading. This has the additional impact that we can also allow other 3d surface plots to use both cmap and shading [ax.surface(), ax.plot_trisurf()].

Next step

Before we move further with this I would like to have some discussion with the maintainers if it is OK to change the return type here. I think is is really important that the user is able to call fig.colorbar() in an easy way, but there may be other ways to achieve this goal. It is probably a good idea to discuss this at a weekly developer meeting.

If we choose to move on with this I will need to add an example to the docs, a release note, and I think it would also be prudent to add more testing.


NOTE on the modification of the test images:
The change in return type change impacts how the rendering is executed, which leads to a very minor change in how PNGs are rendered [notably, no differences are observed when exporting as SVG]. This change causes all existing image-comparison tests to fail. I believe the current behaviour is a [relatively insignificant] bug, and the changes in this PR improves the rendering quality ever so slightly.

The change is illustrated using the following test image:
voxels-xyz

Rendered with 300 DPI and zoomed in:
before:
image
after:
image

I have added a line as a guide to the eye, so that the difference is visible. The discrepancy becomes greater with lower DPI, but it is also more difficult to see what is going on if the DPI is lower.

PR checklist

@scottshambaugh
Copy link
Copy Markdown
Contributor

I think breaking the return type is a tough ask, though I'm not sure how to better structure it.

No worries on refreshing the comparison images, it's not too many. Though to my eye it seems like voxels-scalar-xyz.png and voxels-scalar.png are the same? Could potentially drop one of them and compare against a single image.

@story645
Copy link
Copy Markdown
Member

I think breaking the return type is a tough ask, though I'm not sure how to better structure it.

#30733 technically did that for pie and my guess is pie is probably used a lot more than voxels. I think the big thing there was making sure that the return object was still interable, not sure if there's an equivalent here.

@trygvrad
Copy link
Copy Markdown
Contributor Author

trygvrad commented Jan 18, 2026

@story645 @scottshambaugh Thank you for the feedback, please let me know what you think of the following proposition :)

One possibility is to change the return type of ax.voxel() to a subclass of dict, but which can also act as a mappable for a colorbar:

class VoxelDict(dict):
    """
    A dictionary subclass indexed by coordinate, where ``faces[i, j, k]``
    is a `.Poly3DCollection` of the faces drawn for the voxel
    ``filled[i, j, k]``. If no faces were drawn for a given voxel,
    either because it was not asked to be drawn, or it is fully
    occluded, then ``(i, j, k) not in faces``.

    This class also supports the functionality required to act as a mappable
    for a colorbar.
    """

    def __init__(self, axes, colorizer):
        """
        Parameters
        ----------
        axes : `mplot3d.axes3d.Axes3D`
            The axes the voxels are contained in.

        colorizer : `mpl.colorizer.Colorizer`
            The colorizer uset to convert data to color.
        """
        super().__init__(self)
        self.axes = axes
        self.colorizer = colorizer
        self._callbacks = cbook.CallbackRegistry(signals=["changed"])
        self._A = None

I made a new branch with the required changes in a single commit here: main...trygvrad:matplotlib:voxels_3d_colorizer_v2

The class needs to have the following functions:

    def cmap(self):
    def norm(self):
    def callbacks(self):
    def get_array(self):
    def set_array(self, A):
    def add_callback(self, func):
    def remove_callback(self, oid):
    def changed(self, *args):
    def get_alpha(self):
    def autoscale(self, A):
    def autoscale_None(self):

where we have an additional layer of callbacks to connect the voxels [Poly3DCollections] to the colorbar.


As an alternative I tried to make the new return type also inherit from ColorizingArtist, i.e.

class VoxelDict(dict, ColorizingArtist):
    ...

I was able to make this work, and this simplified the class, but I observed much decreased performance. I think something was wrong with the callback. Ultimately, I found that the architecture is easier to read if the new class does not subclass from ColorizingArtist

@timhoffm
Copy link
Copy Markdown
Member

timhoffm commented Jan 18, 2026

The class needs to have the following functions:

If we are not subclassing ColorizingArtist, we should at least have a Protocol that defines what something colorizable must implement.

One possibility is to change the return type of ax.voxel() to a subclass of dict [...]

It's typically not a good idea to inherit from dict. For our purposes, it should be enough to inherit from collections.abc.Mapping.


Generally, we are aspiring to move to semantic Artists that capture the logic structure of the represented data, not just a bunch of basic artists that can be used to draw the data. For example in the pie() case mentioned above, the result had been just a bunch of wedges and texts. Once created, it's basically impossible to update the data values, because number, position and size of the wedges non-trivially depends on data values. So updating data will only be possible when we have an Artist that knows how the data is translated into basic Artists.

Voxels are in a similar situation: They can only be colored as a single collection, but are currently crated as multiple Poly3dColletions. Here, just putting them all in one Poly3dCollection is still not good enough, because that does not track the individual voxel, so a hard-coded set_facecolors([voxel1_color, ..., voxeln_color]) is still not possible.

This is just to give direction, it hasn't to be done all in one step. PieContainer is currently also a very shallow container, but is designed to be extendable in the above described direction.

On an additional note, composition or inheritance can both work for the semantic Artists. PieContainer uses composition, StepPatch in contrast was created as a semantic Artist for stairs() and uses inheritance.

@trygvrad
Copy link
Copy Markdown
Contributor Author

Thank you @timhoffm

Just a quick question, I'm not quite sure what you mean by:

Voxels are in a similar situation: They can only be colored as a single collection, but are currently crated as multiple Poly3dColletions.

They are created as multiple Poly3dColletions, but since they share a single colorizer, we can update the data either on the Poly3dColletions themselves, or via a helper function in the VoxelData type:

    def set_array(self, A):
        """
        Set the value array from array-like *A*.

        Parameters
        ----------
        A : array-like of length equal to the number of voxels.
            The values that are mapped to colors.

        """

        self._A = A
        for a, k in zip(A, self.keys()):
            self[k].set_array(a)

We could easily do the same for set_facecolor(), which would allow the syntax set_facecolors([voxel1_color, ..., voxeln_color]).


I'll take a look the other feedback you provided later :)
Perhaps we would also like to have an artist where we can update which voxels are rendered after creation...

@timhoffm
Copy link
Copy Markdown
Member

My bad, I was still thinking in the ScalarMappable world where you couldn't reasonably share the colorizing config across multiple artists.

@trygvrad trygvrad mentioned this pull request Jan 24, 2026
5 tasks
@trygvrad
Copy link
Copy Markdown
Contributor Author

Closing this as I have opened #31032 instead

@trygvrad trygvrad closed this Jan 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[ENH]: Voxels as mappable for colorbar

4 participants

X Tutup