scverse / scverse/spatialdata-plot

Feature: PCA aggregation for n >= 4 channels in render_images

Open
#450 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

  • #451 by @timtreis — closed without merging
enhancement images :framed_picture:
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\":

  1. Stack (c, y, x)(c, h·w) after per-channel norm has been applied (so the reduction sees normalized intensities, not raw counts).
  2. Run sklearn.decomposition.PCA(n_components=3, random_state=0) on the transposed matrix.
  3. Reshape (h·w, 3)(3, y, x) and rescale each component to [0, 1] independently.
  4. 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-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.
  • 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.