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
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.

sdata.pl.render_images("blobs_image").pl.show()
../../_images/4db0a6d3c549d6354700021a9c27edd64854e65de19fc15ba57c88f6bfec5c2d.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/3a926f7b0c36a53044ff91547d8f8c69cbacfdecf9057a19da14c9420c01a592.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. 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/17fcd67aa1e291c294a053e320be992dfc3e1cbb34bd3411e2f2a0e565cdf5a5.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/7503afa9f4ab1b84cff03625bc167833d48c1e58b7deb5eec3b4fd76b134c17c.png

4. Logarithmic scaling — LogNorm#

For data spanning orders of magnitude. spatialdata-plot guards the log domain: limits autoscale from the strictly-positive values only, so a non-positive vmin never raises a late “Invalid vmin or vmax”.

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

5. Other matplotlib norms work too#

Because the limits come from each norm’s own logic, any Normalize subclass plugs in. Two common ones: PowerNorm (gamma correction) and SymLogNorm (log scaling with a linear region around zero).

sdata.pl.render_images("blobs_image", channel=0, norm=PowerNorm(gamma=0.5), colorbar=True).pl.show()
../../_images/e273a20d47725eb6f9a150717e249caec7b11f0a0ee6fe308fe3960c120c251c.png
sdata.pl.render_images("blobs_image", channel=0, norm=SymLogNorm(linthresh=0.05), colorbar=True).pl.show()
../../_images/66c7da7d1eac25daa7b4b0905f005356f3450101659bc52939076d5cc6d6ecb5.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.

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

sdata.pl.render_images("blobs_image", channel=0).pl.show()
../../_images/85e7e85857abc236037e275b00334432405cb799e3395527da1098dca03c268b.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/f1d87596f53fba24ed5edbf142c1d75ac76447bbced2d4af9c577e7602badf2b.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/34db5c05940bc54907d7ef5dcf6b822ae4402e6375b0e136aaa9b9b41fccf130.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/1914c537e7659074d1e3b370ddbf3384006e3e07f1a8cb3a25b7497f73a6435b.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/1832ba6620df8c85c535b182ee0ca4bdfcce7d979ea32dec06d5f9922973e5e2.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()
INFO     Transposing `data` of type: <class 'dask.array.core.Array'> to ('c',   
         'y', 'x').
../../_images/30d12761a86c5927679cc20ffc26be300fbfac506fa64451e06147f47a62f487.png
rac.pl.render_images("raccoon", norm=Normalize(0.0, 0.5, clip=True)).pl.show()
../../_images/6d0eb441abd240abb3a3f1fe27ad20e1ddc3de94af3b3f15c234666846d131c2.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/c7e56dbf28e470cfe5543471b7c9f34959fffa2f8ca2937ddfff0fb1410128ba.png
sdata.pl.render_shapes("blobs_circles", color="radius", norm=PercentileNormalize(5, 95), colorbar=True).pl.show()
../../_images/c7e56dbf28e470cfe5543471b7c9f34959fffa2f8ca2937ddfff0fb1410128ba.png

Points, colored by a continuous column:

sdata.pl.render_points("blobs_points", color="instance_id", norm=Normalize(), colorbar=True).pl.show()
../../_images/9209520828f05b7ea0b6db77e1c3515236beb897f04702af411bbaed942174a0.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/efd31ec5c05f03789957e714582e55ca2989a5910dac9198341539a571f9b37c.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.

sdata.pl.render_points("blobs_points", color="instance_id", method="datashader", norm=Normalize()).pl.show()
../../_images/2d5b3006d7013d43bb0a4e1689a7871fa2f5b26348395e87f7ceabe930fdcbfc.png

12. Edge cases the renderer handles for you#

A constant-value channel can’t be min/max scaled (it would divide by zero), so it warns and renders as mid-gray instead of going blank:

const = sd.datasets.blobs()
const.images["blobs_image"].values[0, :, :] = 0.3  # make channel 0 a single value (in place; keeps attrs)
const.pl.render_images("blobs_image", channel=0).pl.show()
../../_images/d7842378d80c9fd04935ff5921a2a6da6498ff2724c08ac7917c9596218140aa.png

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.