Multichannel and fluorescence images in spatialdata-plot#

A fluorescence or multiplexed-imaging experiment (Xenium morphology, CODEX, IMC, CyCIF) produces an image with several channels — one per stain or marker — rather than a single RGB photo. render_images renders these directly, and gives you per-channel control over which channels to show, what colour each gets, and how each is contrasted. By the end you should be able to:

  • Inspect an image’s named channels and render a single one by name.

  • Composite several channels at once and give each its own colour with palette (a lookup table, or LUT).

  • Fix the dim, heavy-tailed look of raw fluorescence with per-channel contrast.

  • Use grayscale and channels_as_legend to finish a publication-ready panel.

We use the Xenium cells dataset from squidpy (morphology_focus is a real four-channel fluorescence image), downloaded and cached on first use.

Setup#

import squidpy as sq

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

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/tutorials/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)

1. Inspect the channels#

A multichannel image carries a named channel axis. Here are the four markers of the Xenium morphology_focus image.

image = sdata.images["morphology_focus"]
# multiscale images are a datatree; read the channel names off the full-resolution level
full_res = image["scale0"].image if hasattr(image, "keys") else image
channels = [str(c) for c in full_res.coords["c"].values]
channels
['DAPI', 'ATP1A1/CD45/E-Cadherin', '18S', 'AlphaSMA/Vimentin']

2. A single channel — channel=#

Pass a channel name (or index) to render just that marker. DAPI stains nuclei.

sdata.pl.render_images("morphology_focus", channel="DAPI").pl.show()
../../_images/1b223296493105bef4131783a10c0ef281f46e0953dc6426527401aaa8cc88e5.png

3. The default composite#

With no channel, all channels are composited into one image, each assigned a colour automatically. This already shows the tissue structure.

sdata.pl.render_images("morphology_focus").pl.show()
../../_images/865af9f0918d6a488926850c101e0cdabb1739f54fa0458804bc279ce91cf802.png

4. Per-channel contrast — the fluorescence essential#

Raw fluorescence is heavy-tailed: a few bright pixels stretch the range and crush everything else toward black. Pass a list of norms — one per channel — to set contrast independently. PercentileNormalize clips to data percentiles, which is exactly what viewers like Xenium Explorer do. See the Normalization and contrast tutorial for norms in depth.

norms = [PercentileNormalize(1, 99)] * len(channels)
sdata.pl.render_images("morphology_focus", norm=norms).pl.show()
../../_images/ccd9e425b70a8595f5fa79056d8cd987449cd83e3fc3789ad26ea654d38e70da.png

5. Recolouring channels — cmap vs palette#

You can hand each channel its own colourmap with a list of cmaps. For fluorescence this emits a warning: colourmaps run to white, so overlapping channels wash each other out. For additive compositing, prefer palette (next section).

Mechanically, a cmap list averages the channels (dividing by their count), which dims the result, whereas palette adds them — the source of the cleaner composite in the next section.

sdata.pl.render_images(
    "morphology_focus",
    channel=["DAPI", "18S"],
    cmap=["Blues", "Greens"],
    norm=[PercentileNormalize(1, 99)] * 2,
).pl.show()
WARNING  render_images: You're blending multiple cmaps. If the plot doesn't look like you expect, it might be      
         because your cmaps go from a given color to 'white', and not to 'transparent'. Therefore, the 'white' of  
         higher layers will overlay the lower layers. Consider using 'palette' instead.
../../_images/e0f59846ec8960857703a992faa291d1fcbced633ac746dec8dfcc230b266c96.png

6. Fluorescence LUTs — per-channel palette#

palette assigns each channel a single colour that fades to black, and the channels are then added together, so they combine cleanly — the lookup-table (LUT) model used in microscopy. Assign a colour per marker to build a readable composite.

sdata.pl.render_images(
    "morphology_focus",
    channel=["DAPI", "ATP1A1/CD45/E-Cadherin", "AlphaSMA/Vimentin"],
    palette=["cyan", "magenta", "yellow"],
    norm=[PercentileNormalize(1, 99)] * 3,
).pl.show()
../../_images/994e8cd0f9a8307204bbe5fedf0f6dbe1004fb1de3732ad28aa80918369202b2.png

7. A desaturated view — grayscale#

grayscale=True collapses a three-channel selection into a single grayscale intensity — useful for a structural, colour-free view. It requires exactly three channels, and takes a single norm (broadcast across them) rather than a per-channel list.

The three channels are mapped to red, green and blue and combined with the standard luminance weights (Rec. 601: 0.299 / 0.587 / 0.114), so the order you list them in changes the result. Those weights are tuned for natural RGB photographs, not fluorescence, so read the grayscale view as structural rather than quantitative. A selection that is not exactly three channels raises an error.

sdata.pl.render_images(
    "morphology_focus",
    channel=["DAPI", "ATP1A1/CD45/E-Cadherin", "18S"],
    grayscale=True,
    norm=PercentileNormalize(1, 99),
).pl.show()
../../_images/25c8d7413ea6353939e3ab3b221ca9681b54723fab09a1422a1b243469180391.png

8. Labelling the composite — channels_as_legend#

channels_as_legend=True adds a legend mapping each channel to its rendered colour — handy when a composite would otherwise be ambiguous.

The channel legend applies to per-channel composites; it has no effect on a true RGB image, or when a single shared cmap is used across channels.

sdata.pl.render_images(
    "morphology_focus",
    norm=[PercentileNormalize(1, 99)] * len(channels),
    channels_as_legend=True,
).pl.show()
../../_images/5a016ada2dfcd15fb49e279bb51c21186235b03eec4d91339e24073343b4a7e0.png

Summary#

  • A multichannel image has a named channel axis; render one with channel="name" or composite several by passing a list (or omitting channel).

  • Fluorescence is heavy-tailed — pass a list of norms (PercentileNormalize per channel) so each marker is contrasted independently.

  • For additive fluorescence compositing use palette (colour-to-black additive LUTs), not cmap (colour-to-white), which washes overlapping channels out.

  • grayscale gives a colour-free three-channel view; channels_as_legend labels the composite.

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,matplotlib,numpy
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
matplotlib      : 3.11.0
numpy           : 2.4.6

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