You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
For images with n ≥ 4 channels, render_images falls back to an additive-RGB "stack" strategy (render.py:1597–1630): each channel gets a categorical seed color, channels are mapped through cmap → black linear maps, then summed and clipped to [0, 1].
This works for n ≤ 3 (it's the standard fluorescence-microscopy idiom) but degrades sharply at n ≥ 4:
Additive blending saturates fast — dense co-expression regions collapse to white, and individual-channel signal becomes hard to attribute.
Seed colors are picked by channel index, not by data variation — uninformative channels claim equal visual real estate alongside the structural ones.
The in-code TODO at render.py:1630 ("update when pca is added as strategy") points at the same gap.
What is not the gap
Per-channel norm support (a list of Normalize objects, one per channel) is already in main — see tests/pl/test_render_images.py:474. That fixes the dynamic-range half of #534 but does not address muddy composition: a recent verification on a synthetic 4-channel Xenium-morphology stand-in found that tuning per-channel norm to each peak reduced saturation but the additive overlap still produces blob-soup. The two problems are independent; this issue tracks only the compositing half.
Proposed solution: PCA reduction strategy
Add a multichannel_strategy: Literal[\"stack\", \"pca\"] | None = None kwarg to render_images. Default to \"stack\" (today's behavior) when n_channels ≤ 3; default to \"pca\" when n_channels ≥ 4. Log the chosen strategy (one line, like the existing stack log).
Algorithm for \"pca\":
Stack (c, y, x) → (c, h·w) after per-channel norm has been applied (so the reduction sees normalized intensities, not raw counts).
Run sklearn.decomposition.PCA(n_components=3, random_state=0) on the transposed matrix.
Reshape (h·w, 3) → (3, y, x) and rescale each component to [0, 1] independently.
Stack as RGB and render through the existing 3-channel path.
The 3-component cap is intentional: PCA → RGB is the standard multiplex-visualization recipe (used by napari plugins, MCMICRO, Steinbock). For interpretability, log the explained-variance ratio per component.
Interaction with cmap / palette: PCA produces 3 abstract components without per-channel identity, so per-channel cmap and palette lists do not apply. Either silently ignore with a warning, or error on the combination. Lean: warn + ignore.
Channel legend: channels_as_legend=True cannot map back to source channels under PCA. Either ignore with a warning or instead emit a small bar of explained-variance ratios.
Dask-backed sources: PCA needs a materialized matrix. Compute once after rasterization / multiscale-best-scale selection so we work on the canvas-size array, not the raw source.
NaN propagation: error early (consistent with current render_images NaN policy).
Reproducibility: random_state=0 and sign-normalize each component (deterministic sign by max-abs convention) so the rendered colors are stable across runs.
Fewer than 3 channels: \"pca\" is meaningless; raise.
transfunc interaction: applied before PCA, same as it runs before the existing rasterize/composite.
n_components < 3 (rank-deficient input): zero-pad missing components so the RGB stack still has 3 channels.
Out of scope
UMAP/t-SNE/ICA alternatives — defer to follow-up issues if requested.
Problem
For images with n ≥ 4 channels,
render_imagesfalls back to an additive-RGB "stack" strategy (render.py:1597–1630): each channel gets a categorical seed color, channels are mapped throughcmap → blacklinear maps, then summed and clipped to[0, 1].This works for n ≤ 3 (it's the standard fluorescence-microscopy idiom) but degrades sharply at n ≥ 4:
The in-code TODO at
render.py:1630("update when pca is added as strategy") points at the same gap.What is not the gap
Per-channel
normsupport (a list ofNormalizeobjects, one per channel) is already in main — seetests/pl/test_render_images.py:474. That fixes the dynamic-range half of #534 but does not address muddy composition: a recent verification on a synthetic 4-channel Xenium-morphology stand-in found that tuning per-channel norm to each peak reduced saturation but the additive overlap still produces blob-soup. The two problems are independent; this issue tracks only the compositing half.Proposed solution: PCA reduction strategy
Add a
multichannel_strategy: Literal[\"stack\", \"pca\"] | None = Nonekwarg torender_images. Default to\"stack\"(today's behavior) whenn_channels ≤ 3; default to\"pca\"whenn_channels ≥ 4. Log the chosen strategy (one line, like the existingstacklog).Algorithm for
\"pca\":(c, y, x)→(c, h·w)after per-channel norm has been applied (so the reduction sees normalized intensities, not raw counts).sklearn.decomposition.PCA(n_components=3, random_state=0)on the transposed matrix.(h·w, 3)→(3, y, x)and rescale each component to[0, 1]independently.The 3-component cap is intentional: PCA → RGB is the standard multiplex-visualization recipe (used by napari plugins, MCMICRO, Steinbock). For interpretability, log the explained-variance ratio per component.
API sketch
Edge cases
cmap/palette: PCA produces 3 abstract components without per-channel identity, so per-channelcmapandpalettelists do not apply. Either silently ignore with a warning, or error on the combination. Lean: warn + ignore.channels_as_legend=Truecannot map back to source channels under PCA. Either ignore with a warning or instead emit a small bar of explained-variance ratios.render_imagesNaN policy).random_state=0and sign-normalize each component (deterministic sign by max-abs convention) so the rendered colors are stable across runs.\"pca\"is meaningless; raise.transfuncinteraction: applied before PCA, same as it runs before the existing rasterize/composite.Out of scope
References
src/spatialdata_plot/pl/render.py:1630