Normalization and contrast in spatialdata-plot#

This tutorial covers the norm= argument: how spatialdata-plot turns scalar data values into colors, and every knob you have to control contrast. By the end you should be able to:

  • Explain what a norm does and when its limits are chosen for you.

  • Set fixed contrast limits and choose how out-of-range values are drawn.

  • Pick the right scaling — linear, logarithmic, power, or percentile — for your data.

  • Give each image channel its own contrast, and use norm on shapes, points, and labels.

  • Recognize the datashader caveat and the cases the renderer validates for you.

What a norm is#

A norm is a matplotlib.colors.Normalize instance. It maps scalar data values → [0, 1] before the colormap turns them into colors, and it does two things:

  1. Sets the contrast limits vmin / vmax — the data values that map to the bottom and top of the cmap.

  2. Sets the scaling in between — linear, log, power, percentile-clipped — chosen by the subclass.

If you don’t pass a norm, you get a plain Normalize whose vmin/vmax are autoscaled to the data min/max. That is the default everywhere and it is unchanged.

The one rule: when vmin/vmax are unset, they autoscale from the data, and how they autoscale is decided by the norm subclass. spatialdata-plot delegates to each norm’s own logic, so every Normalize subclass works — including the percentile one below — with no special-casing.

Which renderers take norm#

Renderer

norm accepts

Notes

render_images

a Normalize or a list[Normalize]

single = broadcast & autoscaled per channel; list = one per channel

render_shapes

a Normalize

scales a continuous color= column

render_points

a Normalize

continuous color=; resolved limits apply on the matplotlib backend

render_labels

a Normalize

scales a continuous color= column

Images are the only renderer that accepts a list, because an image has several channels that may each want their own contrast.

Setup#

import matplotlib.pyplot as plt
import numpy as np
import spatialdata as sd
import spatialdata_plot as sdp  # noqa: F401  (registers the .pl accessor)
from matplotlib.colors import LogNorm, Normalize, PowerNorm, SymLogNorm, TwoSlopeNorm
from spatialdata_plot import PercentileNormalize

sdata = sd.datasets.blobs()  # blobs_image has 3 channels: [0, 1, 2]
sdata
SpatialData object
├── Images
│     ├── 'blobs_image': DataArray[cyx] (3, 512, 512)
│     └── 'blobs_multiscale_image': DataTree[cyx] (3, 512, 512), (3, 256, 256), (3, 128, 128)
├── Labels
│     ├── 'blobs_labels': DataArray[yx] (512, 512)
│     └── 'blobs_multiscale_labels': DataTree[yx] (512, 512), (256, 256), (128, 128)
├── Points
│     └── 'blobs_points': DataFrame with shape: (<Delayed>, 4) (2D points)
├── Shapes
│     ├── 'blobs_circles': GeoDataFrame shape: (5, 2) (2D shapes)
│     ├── 'blobs_multipolygons': GeoDataFrame shape: (2, 1) (2D shapes)
│     └── 'blobs_polygons': GeoDataFrame shape: (5, 1) (2D shapes)
└── Tables
      └── 'table': AnnData (26, 3)
with coordinate systems:
    ▸ 'global', with elements:
        blobs_image (Images), blobs_multiscale_image (Images), blobs_labels (Labels), blobs_multiscale_labels (Labels), blobs_points (Points), blobs_circles (Shapes), blobs_multipolygons (Shapes), blobs_polygons (Shapes)

1. The default — per-channel min/max#

With no norm, every channel is scaled independently to its own min and max. This is the historical default.

blobs_image has three channels, so with the default colormap they are composited into a single RGB image — channel 0 → red, 1 → green, 2 → blue.

sdata.pl.render_images("blobs_image").pl.show()
../../_images/924cbad4e42aecdbf1f2d257c0f3b4247c045e7010ae51e0b198a2686e81d208.png

2. Fixed contrast limits — Normalize(vmin, vmax)#

Pin the window yourself. A single Normalize on a multi-channel image is broadcast to every channel. We render one channel with a colorbar so the limits are visible.

sdata.pl.render_images("blobs_image", channel=0, norm=Normalize(vmin=0.0, vmax=0.5), colorbar=True).pl.show()
../../_images/c79019ddb0af87833ee0af0ada967c81a2d6bdfeeb2c5abd19d99c8898caf455.png

3. Clipping — what happens outside [vmin, vmax]#

With clip=False (the default), values below vmin or above vmax are drawn in the colormap’s under/over colors (the special colors a colormap shows for values past each end, set via set_under / set_over). With clip=True, they are clamped to the ends instead. We use a cmap with distinct under/over colors to make the difference obvious.

First, clip=False — out-of-range pixels show magenta (under) and red (over):

cmap = plt.cm.viridis.copy()
cmap.set_under("magenta")
cmap.set_over("red")

sdata.pl.render_images("blobs_image", channel=0, cmap=cmap, norm=Normalize(0.1, 0.5, clip=False)).pl.show()
../../_images/2491ea3d80d06edd6182422be82a9ce230cd03b9b0ca135d0ecb7eee3e5c3b0d.png

Now clip=True — the same out-of-range pixels are clamped to the cmap ends instead:

sdata.pl.render_images("blobs_image", channel=0, cmap=cmap, norm=Normalize(0.1, 0.5, clip=True)).pl.show()
../../_images/a3e3a8e6989a75eb1f9fd17f1118659580cc5f34123a9506f718e293f7bbe120.png

4. Logarithmic scaling — LogNorm#

For data spanning orders of magnitude. On the non-image renderers (shapes, points, labels), spatialdata-plot resolves LogNorm limits from the strictly-positive data and falls back to a valid domain when none exist, so a non-positive vmin never raises a late “Invalid vmin or vmax”. On images, as below, LogNorm is applied directly by matplotlib.

sdata.pl.render_images("blobs_image", channel=0, norm=LogNorm(), colorbar=True).pl.show()
../../_images/67ad1100f42937c9f3fcd2dbd5c8ee7fa4f718c9f78868a0cd9b3b0d652c13ce.png

5. Other matplotlib norms work too#

Because the limits come from each norm’s own logic, any Normalize subclass plugs in. PowerNorm applies gamma correction; SymLogNorm is log scaling with a linear region around zero; and the diverging TwoSlopeNorm maps data on either side of a reference vcenter to the two halves of a diverging colormap — ideal for “above vs below” a baseline, with independent slopes above and below.

sdata.pl.render_images("blobs_image", channel=0, norm=PowerNorm(gamma=0.5), colorbar=True).pl.show()
../../_images/1e8a1bcb0218bfb7984175aad2526ce9653740de102fbf98ef3c1e850861c029.png
sdata.pl.render_images("blobs_image", channel=0, norm=SymLogNorm(linthresh=0.05), colorbar=True).pl.show()
../../_images/23f1cfdb5b8bc5fe2161f48a769251fea61e3da990d76d4344256edcba017296.png
sdata.pl.render_images(
    "blobs_image", channel=0, cmap="RdBu_r",
    norm=TwoSlopeNorm(vcenter=0.2, vmin=0.0, vmax=0.8), colorbar=True,
).pl.show()
../../_images/d0d3912e9831a9d379e95103dfe67e49f388390dbe59d24b77d77ed9a7a3790b.png

6. Percentile contrast — PercentileNormalize#

Heavy-tailed images (fluorescence, Xenium morphology) often look dim: a single bright outlier sets the min/max vmax and crushes the rest of the signal to near-black. PercentileNormalize(pmin, pmax) derives the limits from data percentiles instead — the same idea as the contrast sliders in viewers like Xenium Explorer.

  • pmin/pmax are validated to satisfy 0 <= pmin < pmax <= 100.

  • NaN / inf / masked pixels are excluded from the percentile computation.

  • A single instance is autoscaled independently per channel.

  • It also accepts clip (forwarded to Normalize, §3), and setting vmin/vmax explicitly overrides the corresponding percentile.

Here is the default min/max rendering of one channel:

sdata.pl.render_images("blobs_image", channel=0).pl.show()
../../_images/8055391fa865be765114d61578209790da4bb8ce6d1bdff265c15b41a1ff1fc2.png

And the same channel with the top 10% of values clipped, lifting the bulk of the signal:

sdata.pl.render_images("blobs_image", channel=0, norm=PercentileNormalize(0, 90)).pl.show()
../../_images/36a0c429e9f7d3cc149b6b707e7755b745b96e9267b1aa504104754a22b4ce24.png

7. Per-channel norms for images#

Pass a list of norms, one per channel (its length must equal the number of channels). You can mix subclasses freely — give each channel exactly the contrast it needs.

norms = [PercentileNormalize(0, 99), PercentileNormalize(0, 90), PercentileNormalize(0, 80)]
sdata.pl.render_images("blobs_image", channel=[0, 1, 2], norm=norms).pl.show()
../../_images/13786564ab2513bb60c4e3d6e1cf1713e490ad6373dab265fffd6678af563f66.png
mixed = [Normalize(0, 0.5), LogNorm(), PercentileNormalize(2, 98)]
sdata.pl.render_images("blobs_image", channel=[0, 1, 2], norm=mixed, cmap=[plt.cm.gray] * 3).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/d432d9fa745d4b45072a7f2c5dc5a72a85c9fc5c9dd9368404de1e083b9e4adc.png

8. Combining with transfunc#

transfunc transforms the raw array first, then norm scales the result. So transfunc=np.sqrt with a norm operates on the square-rooted data — handy for pairing a fixed transform with explicit limits.

sdata.pl.render_images("blobs_image", channel=0, transfunc=np.sqrt, norm=Normalize(0.0, 0.7), colorbar=True).pl.show()
../../_images/5b175bd3e60c35f53f0697e0bd50b086f7a55ddf57f190b74aae2399ce37b22b.png

9. True RGB images#

When the channels are literally named {r, g, b(, a)}, the RGB path composites them with one shared scale to preserve hue balance. If you pass a norm with explicit vmin/vmax, it is applied per channel and clipped to [0, 1]; otherwise channels are scaled by their dtype.

rac = sd.datasets.raccoon()  # a real RGB photo
rac.pl.render_images("raccoon").pl.show()
../../_images/57d81f09694e0b2904d82684a0523af81f5d25ebb72b533a1630f97947ca5404.png
rac.pl.render_images("raccoon", norm=Normalize(0.0, 0.5, clip=True)).pl.show()
../../_images/7c9527ccb983d8b27e2c67ee35b0bff3b6728a1445982f3ec5cedcee9d9b4f1b.png

10. Norms on shapes, points, and labels#

For non-image renderers, norm scales a continuous color= column. The same resolved norm drives both the fill colors and the colorbar, so the bar always matches the pixels — a LogNorm colorbar stays logarithmic.

Shapes, colored by a continuous geometry column:

sdata.pl.render_shapes("blobs_circles", color="radius", norm=Normalize(), colorbar=True).pl.show()
../../_images/091b1f7084babaefff5b87a911759d4c1dc7afdf70a7a2aaeccd29722c91537c.png
sdata.pl.render_shapes("blobs_circles", color="radius", norm=PercentileNormalize(5, 95), colorbar=True).pl.show()
../../_images/091b1f7084babaefff5b87a911759d4c1dc7afdf70a7a2aaeccd29722c91537c.png

Points, colored by a continuous column:

sdata.pl.render_points("blobs_points", color="instance_id", norm=Normalize(), colorbar=True).pl.show()
../../_images/875a2f06b105cc131a74d7f6d172097ce47965ce2e1941127700da7fc23e4e8b.png

Labels, colored by a continuous table column:

sdata.pl.render_labels("blobs_labels", color="channel_0_sum", norm=PercentileNormalize(2, 98), colorbar=True).pl.show()
../../_images/cc5464a74f7b8b71c9010f7728c9b7c9d5863a936e3d5ac508501d288b7843a4.png

11. The datashader caveat#

On the datashader backend, contrast autoscales to the aggregated value range, not to a norm’s percentiles. Explicit vmin/vmax are still honored. For percentile-driven contrast on points, use the default matplotlib backend.

The cell below passes PercentileNormalize(5, 95), but on datashader those percentile limits are ignored — the contrast follows the aggregate range instead.

sdata.pl.render_points("blobs_points", color="instance_id", method="datashader", norm=PercentileNormalize(5, 95)).pl.show()
../../_images/d4a1b2ed4a26f3cddc31724e437013a1f3a2373720b37eaa1f977f1dfc7488ff.png

12. Edge cases the renderer handles for you#

The renderer validates norm inputs up front and fails with an actionable message rather than deep inside matplotlib.

A per-channel norm list of the wrong length is rejected with a clear message:

try:
    sdata.pl.render_images("blobs_image", channel=[0, 1, 2], norm=[Normalize(0, 1)] * 2, cmap=[plt.cm.gray] * 3).pl.show()
except ValueError as e:
    print("ValueError:", e)
ValueError: Length of 'norm' list (2) must match the number of channels (3).

And invalid percentile bounds are caught at construction time:

for bad in [(50, 50), (90, 10), (-1, 50), (0, 101)]:
    try:
        PercentileNormalize(*bad)
    except ValueError as e:
        print(f"PercentileNormalize{bad} -> {e}")
PercentileNormalize(50, 50) -> Require 0 <= pmin < pmax <= 100, got pmin=50, pmax=50.
PercentileNormalize(90, 10) -> Require 0 <= pmin < pmax <= 100, got pmin=90, pmax=10.
PercentileNormalize(-1, 50) -> Require 0 <= pmin < pmax <= 100, got pmin=-1, pmax=50.
PercentileNormalize(0, 101) -> Require 0 <= pmin < pmax <= 100, got pmin=0, pmax=101.

Summary#

  • norm is a matplotlib Normalize; the default is per-channel min/max and is unchanged.

  • Limits come from each norm’s own logic, so Normalize, LogNorm, PowerNorm, SymLogNorm, and PercentileNormalize all just work.

  • PercentileNormalize(pmin, pmax) fixes heavy-tailed dimness with no new parameter — it’s opt-in.

  • Only render_images accepts a list of norms (one per channel); single instances broadcast.

  • The colorbar always reflects the resolved norm; datashader autoscales to the aggregate.