Skip to content

Multi-channel image: uniform (constant) channels render silently black with no warning #598

@timtreis

Description

@timtreis

Multi-channel image: uniform (constant) channels render silently black

Unexpressed markers, background channels, and synthetic test images all hit this.


Environment

  • spatialdata-plot 0.3.4.dev (main, commit 5cfedc7)
  • spatialdata 0.5.0
  • Python 3.13

Problem

When render_images composites multiple channels, each channel is individually normalized via matplotlib.colors.Normalize(vmin=None, vmax=None). For a channel with a constant value (min == max), this normalization computes (v - min) / (max - min) = 0/0, which numpy masks to 0. The channel contributes no color to the composite and renders completely black. No warning is emitted.

# render.py ~line 1425
layers[ch] = ch_norm(layers[ch])   # ch_norm = Normalize(vmin=None, vmax=None)
# result: all-zero array when layer is constant, no warning

This is inconsistent with the single-channel path, which passes the image directly to ax.imshow() and allows matplotlib to handle the constant case gracefully.

Practical impact: In fluorescence spatial omics data, channels representing unexpressed markers or tissue autofluorescence are frequently constant (or near-constant) within a region of interest. These silently vanish in composite overlays. Users see a dark image and have no diagnostic pointing to the actual cause.


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
import warnings

# Two-channel image where both channels have a constant (non-zero) value
stacked = np.stack([
    np.full((20, 20), 200, dtype=np.uint8),  # "DAPI" channel, all 200
    np.full((20, 20), 150, dtype=np.uint8),  # "GFP" channel, all 150
])
image = Image2DModel.parse(stacked, dims=["c","y","x"], c_coords=["DAPI","GFP"])
sdata = sd.SpatialData(images={"img": image})

fig, ax = plt.subplots()
with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    sdata.pl.render_images("img").pl.show(ax=ax)
    user_warns = [str(w.message) for w in caught if issubclass(w.category, UserWarning)]

arr = np.array(ax.images[0].get_array())
print(f"Image max value: {arr.max()}")   # 0 — entirely black
print(f"Warnings emitted: {user_warns}") # []

Expected behaviour

  • Constant channel → emit a UserWarning such as "Channel 'DAPI' has a constant value (200); it will appear black in the composite. Pass explicit norm= or vmin/vmax to control its display."
  • Ideally: map the constant value to a mid-grey (0.5) so the channel contributes a visible baseline, matching single-channel behaviour.

Actual behaviour

Image max value: 0
Warnings emitted: []

The composite image is entirely black. No diagnostic is produced.


Fix sketch

After ch_norm(layers[ch]), check whether the channel had zero variance:

ch_data = layers[ch]
if ch_data.min() == ch_data.max():
    logger.warning(
        f"Channel '{ch}' has a constant value ({ch_data.min()!r}); "
        "it will appear black in the composite. "
        "Pass an explicit norm= or vmin/vmax to control its display."
    )
    layers[ch] = np.full_like(ch_data, 0.5)   # visible grey instead of black

The zero-variance check before normalization is the minimal guard. The grey fallback is optional but matches the single-channel path's behaviour more closely.


Related

  • finding_multichannel_uniform_channel_black_no_warning.md

Triage tier: Tier 1

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