Skip to content

Images with channel names ["r","g","b"] use global normalization, silently crushing low-range channels #610

@timtreis

Description

@timtreis

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions