Images with channel names ["r","g","b"] use global normalization, silently crushing low-range channels
Description
When channel coordinates are exactly ["r","g","b"], render_images takes an RGB early-return path and normalizes all channels together using a global min/max via _normalize_dtype_to_float. For non-RGB images, each channel is normalized independently. When one channel has a much larger dynamic range (e.g., "b": 0–65535) than another (e.g., "r": 0–1), the global normalization makes the low-range channels essentially invisible (rendered max value ~0.00002) with no warning emitted.
This affects any image exported from external tools with lowercase RGB channel names that actually represent fluorescence markers with different intensity ranges.
Environment
spatialdata-plot: 0.3.4.dev (main, 5cfedc7)
spatialdata: 0.5.0
Python: 3.13
Minimal Reproducible Example
import matplotlib; matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import dask; dask.config.set({"dataframe.query-planning": False})
import spatialdata as sd
from spatialdata.models import Image2DModel
import spatialdata_plot
rng = np.random.default_rng(42)
channels = np.stack([
rng.uniform(0, 1, (20, 20)).astype(np.float32), # r: 0–1 range
rng.uniform(0, 100, (20, 20)).astype(np.float32), # g: 0–100 range
rng.uniform(0, 65535, (20, 20)).astype(np.float32), # b: 0–65535 range
])
# Same data — different channel names → different normalization
img_rgb = Image2DModel.parse(channels.copy(), dims=["c", "y", "x"], c_coords=["r", "g", "b"])
img_nonrgb = Image2DModel.parse(channels.copy(), dims=["c", "y", "x"], c_coords=["DAPI", "GFP", "RFP"])
fig, (ax1, ax2) = plt.subplots(1, 2)
sd.SpatialData(images={"img": img_rgb}).pl.render_images("img").pl.show(ax=ax1)
sd.SpatialData(images={"img": img_nonrgb}).pl.render_images("img").pl.show(ax=ax2)
r1 = np.array(ax1.images[0].get_array())
r2 = np.array(ax2.images[0].get_array())
print(f"RGB r-channel max: {r1[:, :, 0].max():.5f}") # ~0.00002 (invisible)
print(f"NonRGB r-channel max: {r2[:, :, 0].max():.5f}") # 1.00000 (correct)
Expected vs. Actual
Expected: Both channel naming conventions produce the same per-channel normalization. Identical pixel data rendered with ["r","g","b"] and ["DAPI","GFP","RFP"] should look identical.
Actual: RGB r-channel max: ~0.00002 — the "r" and "g" channels are essentially invisible. No warning is emitted.
Fix Sketch
In the RGB path (render.py:~1341), normalize each channel independently before stacking to RGBA — the same approach used by the non-RGB compositing path (~line 1425). A lower-impact alternative: emit a UserWarning when the ratio between the maximum and minimum per-channel ranges exceeds a threshold (e.g., >100×), so users know their channels have been crushed by global normalization.
Labels: bug, images, priority: medium
Triage tier: Tier 2
Images with channel names
["r","g","b"]use global normalization, silently crushing low-range channelsDescription
When channel coordinates are exactly
["r","g","b"],render_imagestakes an RGB early-return path and normalizes all channels together using a global min/max via_normalize_dtype_to_float. For non-RGB images, each channel is normalized independently. When one channel has a much larger dynamic range (e.g.,"b": 0–65535) than another (e.g.,"r": 0–1), the global normalization makes the low-range channels essentially invisible (rendered max value ~0.00002) with no warning emitted.This affects any image exported from external tools with lowercase RGB channel names that actually represent fluorescence markers with different intensity ranges.
Environment
Minimal Reproducible Example
Expected vs. Actual
Expected: Both channel naming conventions produce the same per-channel normalization. Identical pixel data rendered with
["r","g","b"]and["DAPI","GFP","RFP"]should look identical.Actual:
RGB r-channel max: ~0.00002— the"r"and"g"channels are essentially invisible. No warning is emitted.Fix Sketch
In the RGB path (
render.py:~1341), normalize each channel independently before stacking to RGBA — the same approach used by the non-RGB compositing path (~line 1425). A lower-impact alternative: emit aUserWarningwhen the ratio between the maximum and minimum per-channel ranges exceeds a threshold (e.g., >100×), so users know their channels have been crushed by global normalization.Labels: bug, images, priority: medium
Triage tier: Tier 2