scverse / scverse/spatialdata-plot
Feature: PCA aggregation for n >= 4 channels in render_images
Nobody has claimed this yet.
- #451 by @timtreis — closed without merging
- Dominant language
- Python
- Stars
- 86
- Forks
- 21
- Avg merge
- 14h 50m
- Merged PRs (30d)
- 3
Description
Problem
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.
- For multiplexed assays (CODEX, IMC, MERFISH/Xenium morphology, CycIF) with 4–60 channels, the result is essentially unreadable — see #534 for a real user reproduction (Xenium Explorer-style multi-stain panel).
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.
API sketch
sdata.pl.render_images(
\"morphology_focus\",
channel=[\"DAPI\", \"ATP1A1/CD45/E-Cadherin\", \"18S\", \"AlphaSMA/Vimentin\"],
norm=[Normalize(0, p, clip=True) for p in (5000, 8000, 50000, 3000)],
multichannel_strategy=\"pca\", # explicit; default auto-switches at n≥4
).pl.show()
Edge cases
- Interaction with
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. - Channel legend:
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. - 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_imagesNaN policy). - Reproducibility:
random_state=0and 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. transfuncinteraction: 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.
-
3-component visualizations (channel grouping, small-multiples). Track separately.
- The render_shapes/render_labels analog (multi-column blending) — tracked in #677.
References
- TODO comment in code:
src/spatialdata_plot/pl/render.py:1630 - User report: #534 (Xenium morphology multi-stain panel; remaining muddiness after per-channel norm)
- Related (closed): #460 (per-channel norm documentation — landed)
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in src/spatialdata_plot/pl/render.py at the existing stack strategy and TODO around lines 1597–1630, then review tests/pl/test_render_images.py around line 474 for normalization behavior. Trace how rasterization, normalization, and compositing interact before deciding how the listed PCA edge cases fit the API. Done means the strategy, logging, edge cases, and regression coverage described in the issue are implemented and the render_images tests pass.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python, scikit-learn
- Domain
- data-visualization
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Stale
- Clarity
- Mostly clear
- Newbie friendliness
- 38/100