spatialdata-plot
spatialdata-plot copied to clipboard
Add example for plotting without axes decorations
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):
showaccepts afigargument that is not used. When a user passesfig, spatialdata-plot still creates a new figure and the result is not what the user expected. Probably it should be removed in favor ofax.- I'd also rather strip most other keyword arguments from
showto 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 throughshowbut create the figure first, then pass the figure/axes toshow. showacceptsframeon, but it did not (fully) achieve what I needed. I am not sure how useful it is.