Datashader reductions in spatialdata-plot#

The datashader backend renders large data by binning primitives onto a pixel grid. When several points fall in the same pixel and you colour by a continuous column, datashader has to combine their values into one — and datashader_reduction chooses how: take the maximum, the mean, the sum, a count, and so on. By the end you should be able to:

  • Understand why a continuous colour needs a reduction on the datashader backend.

  • Compare max, mean, sum, count, and std and read them off the colorbar.

  • Use method="datashader" for large images.

  • Recognise the datashader contrast caveat.

We use the real Xenium cells dataset from squidpy (its transcripts element and the morphology_focus image), downloaded and cached on first use.

Watch out: on the datashader backend a PercentileNormalize does not clip to its percentiles — the single most common datashader-contrast surprise, covered in section 5.

Setup#

import squidpy as sq

import spatialdata_plot  # noqa: F401  # registers the .pl accessor

sdata = sq.datasets.cells()
sdata
INFO     Loading existing dataset from data/spatialdata/cells.zarr
SpatialData object, with associated Zarr store: /Users/tim.treis/Documents/GitHub/spatialdata-plot/docs/notebooks/examples/data/spatialdata/cells.zarr
├── Images
│     ├── 'he_aligned': DataTree[cyx] (3, 430, 540), (3, 215, 270)
│     ├── 'he_image': DataTree[cyx] (3, 423, 339), (3, 211, 169)
│     └── 'morphology_focus': DataTree[cyx] (4, 430, 540), (4, 215, 270)
├── Labels
│     ├── 'cell_labels': DataTree[yx] (430, 540), (215, 270)
│     ├── 'nucleus_labels': DataTree[yx] (430, 540), (215, 270)
│     └── 'tissue_labels': DataTree[yx] (430, 540), (215, 270)
├── Points
│     └── 'transcripts': DataFrame with shape: (<Delayed>, 13) (3D points)
├── Shapes
│     ├── 'cell_boundaries': GeoDataFrame shape: (94, 1) (2D shapes)
│     └── 'nucleus_boundaries': GeoDataFrame shape: (94, 2) (2D shapes)
└── Tables
      └── 'table': AnnData (94, 5101)
with coordinate systems:
    ▸ 'global', with elements:
        he_aligned (Images), he_image (Images), morphology_focus (Images), cell_labels (Labels), nucleus_labels (Labels), tissue_labels (Labels), transcripts (Points), cell_boundaries (Shapes), nucleus_boundaries (Shapes)

The cells dataset is a small (~3 MB) crop of a real 10x Xenium section (also used in the Colouring points example). The reductions below aggregate its transcripts points, coloured by the quality column qv:

list(sdata["transcripts"].columns)
['x',
 'y',
 'z',
 'feature_name',
 'cell_id',
 'fov_name',
 'codeword_index',
 'qv',
 'transcript_id',
 'is_gene',
 'codeword_category',
 'nucleus_distance',
 'overlaps_nucleus']

1. Why a reduction is needed#

On the datashader backend the transcripts are aggregated into pixels. Colouring by the quality qv, each pixel may cover many transcripts — datashader_reduction decides which value the pixel shows. The default is max.

sdata.pl.render_points(
    "transcripts", color="qv", method="datashader", datashader_reduction="max"
).pl.show()
../../_images/3460335688385c9541a1d81a03fabfe7011f68b232f5ab0e6c7f4a1251a17a66.png

2. max vs mean vs sum#

The reduction changes both the picture and the colorbar scale. max reports the single brightest value in a pixel (bounded by the data max), mean averages, and sum accumulates — so its colorbar runs far higher wherever transcripts pile up.

As a rule of thumb: sum for abundance, mean for a typical value, max for the brightest signal, and std (below) for heterogeneity.

for reduction in ["max", "mean", "sum"]:
    sdata.pl.render_points(
        "transcripts", color="qv", method="datashader", datashader_reduction=reduction
    ).pl.show()
../../_images/3460335688385c9541a1d81a03fabfe7011f68b232f5ab0e6c7f4a1251a17a66.png ../../_images/6eccb5dd9fd3d862cf30d57e6bc2e79e914ef6559ceb88f8c05894a63053b658.png ../../_images/8008440b002dba1e1c110a3e2dbe21a225be33313ff47c91f92a897a5798ad22.png

3. count and std — structure without a value column#

count ignores the colour value and shows how many primitives fall in each pixel (a density — equivalent to density=True with no density_how, as in the Point density maps notebook). std shows the spread of the values per pixel — useful for finding heterogeneous regions.

for reduction in ["count", "std"]:
    sdata.pl.render_points(
        "transcripts", color="qv", method="datashader", datashader_reduction=reduction
    ).pl.show()
../../_images/d6505cac2c48a52761c1366fac08142bf7642ae8fb941ebb3d0edb197f886de4.png ../../_images/89b4cbbdf18004b36e795364832f1d1f7c8e86e24af970f30f0993f71657e8d5.png

4. Datashader for large images — method="datashader"#

Reductions also apply to images: method="datashader" renders large or sparse rasters by aggregating pixels, instead of handing the full array to matplotlib.

The image path takes a slightly different reduction vocabulary — it adds mode/first/last and has no count/any — though here we leave datashader_reduction at its default.

sdata.pl.render_images(
    "morphology_focus", channel="DAPI", method="datashader"
).pl.show()
../../_images/46b32c40c6f175f89748e43432c2b77b4339d3bdd12dfef5626e808cdc2382fa.png

5. The datashader contrast caveat#

On the datashader backend, contrast autoscales to the aggregated value range, not to a norm’s percentiles — a PercentileNormalize does not clip the way it does on the matplotlib backend (see the Normalization and contrast tutorial). Explicit vmin/vmax are still honoured. For percentile-driven contrast, render on the matplotlib backend.

from spatialdata_plot import PercentileNormalize

# On datashader the percentile clip is not applied; contrast follows the aggregate range.
sdata.pl.render_points(
    "transcripts", color="qv", method="datashader",
    datashader_reduction="mean", norm=PercentileNormalize(5, 95),
).pl.show()
../../_images/6eccb5dd9fd3d862cf30d57e6bc2e79e914ef6559ceb88f8c05894a63053b658.png

Summary#

  • Contrast caveat first: datashader contrast follows the aggregate range, not norm percentiles — use the matplotlib backend when you need percentile (or any non-vmin/vmax) contrast.

  • On the datashader backend, a continuous colour needs a reduction to combine the primitives that share a pixel; datashader_reduction sets it (default max).

  • max/mean/sum change the picture and the colorbar scale; count gives a density (same as density=True) and std the per-pixel spread.

  • method="datashader" applies to images too, for large or sparse rasters (with a slightly different reduction vocabulary).

For reproducibility#

# ruff: noqa: F401, F811, I001, E402
# fmt: off
import warnings
import spatialdata_plot

%load_ext watermark
# fmt: on

%watermark -v -m -p spatialdata,spatialdata_plot,squidpy,datashader,matplotlib
Python implementation: CPython
Python version       : 3.14.6
IPython version      : 9.14.1

spatialdata     : 0.7.3
spatialdata_plot: 0.4.1
squidpy         : 1.8.2
datashader      : 0.19.1
matplotlib      : 3.11.0

Compiler    : Clang 20.1.8 
OS          : Darwin
Release     : 25.2.0
Machine     : arm64
Processor   : arm
CPU cores   : 8
Architecture: 64bit