spatialdata-plot icon indicating copy to clipboard operation
spatialdata-plot copied to clipboard

Add example for plotting without axes decorations

Open aeisenbarth opened this issue 1 year ago • 0 comments

As a user, I (may) want to render just the data, without title/legend/axes.

This is related to https://github.com/scverse/spatialdata-plot/issues/189. The spatialdata-plot defaults produce a classic matplotlib plot with title, labelled axes and padding, as desired for a publication. The internet is seemingly "full" of questions by matplotlib users asking how to remove the padding and other components afterwards, and apparently there is still no reliable, single parameter to avoid adding all of them in the first place.

When not done correctly, matplotlib's layout engine interferes and pushes plot components around, so that the plot area is smaller than desired and the figure size can become bigger than wanted or has a different aspect ratio. An additional complication is that the user has to specify the physical plot size and dpi instead of pixel sizes.

I think it is not really spatialdata-plot's task to solve this, so either an optional utility function or just documentation with example code would be sufficient.


I figured out the following code to achieve an image to be reliably rendered in data size.

Example: Figure with data size

import numpy as np
from matplotlib.backends.backend_agg import FigureCanvasAgg
from matplotlib.axes import Axes
from matplotlib.figure import Figure


def create_figure_and_axes(shape: tuple[int, int], resolution: float) -> Axes:
    """
    Create a Matplotlib figure with axes that is fully filled by the plot, no padding/labels/legend.

    Args:
        shape: Shape of the plot area in data units
        resolution: Scale factor for units of the shape to image pixels

    Returns:
        The axes object to plot into
    """
    dpi = 100
    image_size_yx = np.asarray(shape) * resolution
    fig = Figure(dpi=dpi, figsize=np.flip(image_size_yx) / dpi, frameon=False, layout="tight")
    # The Agg backend is required for later accessing FigureCanvasAgg.buffer_rgba()
    fig.canvas.switch_backends(FigureCanvasAgg)
    fig.bbox_inches = fig.get_tightbbox().padded(0)
    ax: Axes = fig.add_axes(rect=(0.0, 0.0, 1.0, 1.0))
    return ax


bounding_box = np.array([[0, 0], [100, 100]])
resolution = 1.0
# Select the bounding box of what to render.
cropped_sdata = sdata.query.bounding_box(
    axes=("y", "x"),
    min_coordinate=bounding_box[0],
    max_coordinate=bounding_box[1],
    target_coordinate_system="global",
)
# Execute rendering commands
# cropped_sdata.pl.render_something()
# Render the image to the desired bounding box
ax = create_figure_and_axes(shape=tuple(np.diff(bounding_box, axis=0)), resolution=resolution)
cropped_sdata.pl.show(coordinate_systems="global", ax=ax)
# Due to discrepancies between the bounding box and the actual cropped sdata, the axes size can
# mismatch the figure size after the decimal point, potentially leading to a white (bottom)
# edge on the image. Set the size again. Since data limits (xlim/ylim) can be inverted, use set_ybound, not set_ylim.
ax.set_ybound(bounding_box[0][0], bounding_box[1][0])
ax.set_xbound(bounding_box[0][1], bounding_box[1][1])

# Now we can do with the rendered axes whatever we want.
ax.figure.savefig(file_path)

Example: Plot to array, not file

import numpy as np
from matplotlib.backends.backend_agg import FigureCanvasAgg
from matplotlib.figure import Figure
from spatialdata.models import Image2DModel
from spatialdata.transforations import Scale, Sequence, Translation

def matplotlib_figure_to_array(fig: Figure) -> np.ndarray:
    """
    Render a Matplotlib figure to a Numpy array.

    Args:
        fig: A figure. The figure's canvas must be a FigureCanvasAgg

    Returns:
        An RGB Numpy array
    """
    # From https://stackoverflow.com/a/7821917
    # matplotlib.pyplot.switch_backend("agg")

    # If we haven't already shown or saved the plot, then we need to
    # draw the figure first...
    fig.canvas: FigureCanvasAgg
    fig.canvas.draw()

    # Now we can save it to a Numpy array.
    array = np.frombuffer(fig.canvas.buffer_rgba(), dtype=np.uint8)
    image_rgba = array.reshape(fig.canvas.get_width_height()[::-1] + (4,))
    image_rgb = image_rgba[:, :, :3]
    return image_rgb

# Using ax from above with already rendered image (after show).
image_array = matplotlib_figure_to_array(ax.figure)
# Now we can do with the image array whatever we want, even add it to SpatialData:
image = Image2DModel.parse(
    image_array,
    transformations={
        "global": Sequence(
            [
                Scale([1.0 / resolution, 1.0 / resolution], axes=("y", "x")),
                Translation(bounding_box[0], axes=("y", "x"))
            ]
        )
    },
    dims=("y", "x", "c"),
    c_coords=("r", "g", "b"),
    # rgb=True,
)
sdata.add_image(name="rendered_image", image=image)

On a side-note, I found the following issue(s):

  • show accepts a fig argument that is not used. When a user passes fig, spatialdata-plot still creates a new figure and the result is not what the user expected. Probably it should be removed in favor of ax.
  • I'd also rather strip most other keyword arguments from show to a bare minimum because they create a huge number of code paths and combinations of options that are hard to maintain and all need to be tested (or remain untested). There are many edge cases like when an option to figure is provided, but also a figure or axes object. It's better to use composition, that means if a user wants the figure to have certain advanced options, don't pass them through show but create the figure first, then pass the figure/axes to show.
  • show accepts frameon, but it did not (fully) achieve what I needed. I am not sure how useful it is.

aeisenbarth avatar Dec 31 '23 14:12 aeisenbarth