Skip to content

Commit 6f005ea

Browse files
committed
feat(render_images): opt-in PCA multichannel_strategy for n>=4 channels (#450)
Adds multichannel_strategy={'stack','pca'}|None. Default None->'stack' (byte-identical to current additive blending); 'pca' is opt-in. PCA reduces the per-channel-normalized stack to 3 components, percentile-stretches each, blends with chosen colors (default RGB; palette of 3 or 3 cmap samples), and scales by total-signal brightness so background fades to black (no segmentation). svd_solver='full' keeps colors deterministic across runs and sklearn versions. scikit-learn already a runtime dep.
1 parent bfda2ed commit 6f005ea

5 files changed

Lines changed: 218 additions & 13 deletions

File tree

src/spatialdata_plot/pl/_validate.py

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1345,6 +1345,7 @@ def _validate_image_render_params(
13451345
scale: str | None,
13461346
colorbar: bool | str | None,
13471347
colorbar_params: dict[str, object] | None,
1348+
multichannel_strategy: str | None = None,
13481349
) -> dict[str, dict[str, Any]]:
13491350
param_dict: dict[str, Any] = {
13501351
"sdata": sdata,
@@ -1397,17 +1398,25 @@ def _validate_image_render_params(
13971398
assert isinstance(palette, list | type(None)) # if present, was converted to list, just to make sure
13981399

13991400
if isinstance(palette, list):
1400-
# case A: single palette for all channels
1401-
if len(palette) == 1:
1402-
palette_length = len(channel) if channel is not None else len(spatial_element_ch)
1403-
palette = palette * palette_length
1404-
# case B: one palette per channel (either given or derived from channel length)
1405-
channels_to_use = spatial_element_ch if element_params[el]["channel"] is None else channel
1406-
if channels_to_use is not None and len(palette) != len(channels_to_use):
1407-
raise ValueError(
1408-
f"Palette length ({len(palette)}) does not match channel length "
1409-
f"({', '.join(str(c) for c in channels_to_use)})."
1410-
)
1401+
if multichannel_strategy == "pca":
1402+
# palette colors the 3 principal components, not the channels
1403+
if len(palette) != 3:
1404+
raise ValueError(
1405+
"multichannel_strategy='pca' maps the 3 principal components to colors; "
1406+
f"'palette' must have exactly 3 entries, got {len(palette)}."
1407+
)
1408+
else:
1409+
# case A: single palette for all channels
1410+
if len(palette) == 1:
1411+
palette_length = len(channel) if channel is not None else len(spatial_element_ch)
1412+
palette = palette * palette_length
1413+
# case B: one palette per channel (either given or derived from channel length)
1414+
channels_to_use = spatial_element_ch if element_params[el]["channel"] is None else channel
1415+
if channels_to_use is not None and len(palette) != len(channels_to_use):
1416+
raise ValueError(
1417+
f"Palette length ({len(palette)}) does not match channel length "
1418+
f"({', '.join(str(c) for c in channels_to_use)})."
1419+
)
14111420
element_params[el]["palette"] = palette
14121421

14131422
expected_len = len(channel) if channel is not None else len(spatial_element_ch)

src/spatialdata_plot/pl/basic.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,7 @@ def render_images(
740740
colorbar: bool | str | None = "auto",
741741
colorbar_params: dict[str, object] | None = None,
742742
channels_as_legend: bool = False,
743+
multichannel_strategy: Literal["stack", "pca"] | None = None,
743744
method: Literal["matplotlib", "datashader"] | None = None,
744745
datashader_reduction: _ImageDsReduction | None = None,
745746
) -> sd.SpatialData:
@@ -822,6 +823,20 @@ def render_images(
822823
Ignored for single-channel and RGB(A) images. When multiple
823824
``render_images`` calls use this flag on the same axes, all
824825
channel entries are combined into a single legend.
826+
multichannel_strategy : {"stack", "pca"} | None, default None
827+
How to composite an image with more than three channels.
828+
``None`` (default) and ``"stack"`` both use additive blending of
829+
per-channel colors (today's behavior, unchanged). ``"pca"`` reduces
830+
the per-channel-normalized stack to three principal components mapped
831+
to RGB, with per-pixel brightness taken from the total signal so
832+
low-signal background fades to black (no segmentation) — useful for
833+
highly multiplexed assays (CODEX, IMC, Xenium morphology) where
834+
additive blending saturates to a muddy white. The three components
835+
map to red/green/blue by default; pass ``palette`` (exactly three
836+
colors) or ``cmap`` (three colors are sampled from it) to recolor
837+
them. It is an exploratory overview (colors are abstract, not
838+
per-channel), so ``channels_as_legend`` is ignored with a warning,
839+
and ``"pca"`` requires at least three channels.
825840
method : str | None, optional
826841
Whether to use ``'matplotlib'`` (default) or ``'datashader'`` for
827842
the downsampling step. When ``'datashader'`` is selected, the
@@ -882,6 +897,7 @@ def render_images(
882897
scale=scale,
883898
colorbar=colorbar,
884899
colorbar_params=colorbar_params,
900+
multichannel_strategy=multichannel_strategy,
885901
)
886902

887903
sdata = self._copy()
@@ -936,6 +952,7 @@ def render_images(
936952
transfunc=transfunc,
937953
grayscale=grayscale,
938954
channels_as_legend=channels_as_legend,
955+
multichannel_strategy=multichannel_strategy,
939956
method=method,
940957
ds_reduction=datashader_reduction,
941958
)

src/spatialdata_plot/pl/render.py

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1756,6 +1756,69 @@ def _draw_channel_legend(
17561756
)
17571757

17581758

1759+
# Explained-variance ratio below which a component is treated as noise (rank-deficient input).
1760+
_PCA_NOISE_FLOOR = 1e-6
1761+
# Percentile clip applied per component (and to the brightness) before rescaling to [0, 1].
1762+
_PCA_STRETCH_PERCENTILES = (1.0, 99.0)
1763+
_PCA_DEFAULT_COLORS = np.eye(3) # PC1/2/3 -> red/green/blue
1764+
1765+
1766+
def _stretch_to_unit(values: np.ndarray) -> np.ndarray:
1767+
"""Clip to ``_PCA_STRETCH_PERCENTILES`` and rescale to ``[0, 1]`` (flat input -> zeros)."""
1768+
lo, hi = np.percentile(values, _PCA_STRETCH_PERCENTILES)
1769+
if hi <= lo:
1770+
return np.zeros_like(values)
1771+
return np.clip((values - lo) / (hi - lo), 0.0, 1.0)
1772+
1773+
1774+
def _pca_compose(
1775+
layers: dict[Any, np.ndarray], channels: list[Any], colors: np.ndarray | None = None
1776+
) -> tuple[np.ndarray, np.ndarray]:
1777+
"""Reduce a per-channel-normalized multichannel stack to RGB via PCA.
1778+
1779+
Channels are treated as features and reduced to up to three principal components; each is
1780+
percentile-stretched to ``[0, 1]``, additively blended with its ``colors`` row (a ``(3, 3)``
1781+
RGB array, default red/green/blue), then scaled by per-pixel total signal so background fades
1782+
to black (no segmentation). Components below ``_PCA_NOISE_FLOOR`` explained variance are
1783+
dropped (no color) with a warning. ``svd_solver="full"`` plus a per-component max-abs sign
1784+
convention keep colors deterministic across runs and sklearn versions. Returns
1785+
``((y, x, 3) in [0, 1], explained_variance_ratio)``.
1786+
"""
1787+
from sklearn.decomposition import PCA
1788+
1789+
if colors is None:
1790+
colors = _PCA_DEFAULT_COLORS
1791+
1792+
# float32 halves peak memory on the (h*w, n_channels) matrix and everything derived from it;
1793+
# the percentile stretch and 8-bit display are insensitive to the lost precision.
1794+
h, w = np.asarray(layers[channels[0]]).shape
1795+
mat = np.stack([np.asarray(layers[ch], dtype=np.float32).reshape(-1) for ch in channels], axis=1)
1796+
1797+
pca = PCA(n_components=min(3, mat.shape[1]), svd_solver="full")
1798+
comps = pca.fit_transform(mat)
1799+
1800+
rgb = np.zeros((h * w, 3), dtype=np.float32)
1801+
dropped: list[int] = []
1802+
for k in range(comps.shape[1]):
1803+
if pca.explained_variance_ratio_[k] < _PCA_NOISE_FLOOR:
1804+
dropped.append(k)
1805+
continue
1806+
col = comps[:, k]
1807+
if col[np.argmax(np.abs(col))] < 0: # pin PCA's arbitrary sign
1808+
col = -col
1809+
rgb += np.outer(_stretch_to_unit(col), colors[k])
1810+
np.clip(rgb, 0.0, 1.0, out=rgb)
1811+
1812+
if dropped:
1813+
logger.warning(
1814+
f"PCA: component(s) {dropped} below the noise floor (explained variance < "
1815+
f"{_PCA_NOISE_FLOOR:g}); channels appear collinear, so they contribute no color."
1816+
)
1817+
1818+
rgb *= _stretch_to_unit(mat.sum(axis=1))[:, np.newaxis] # brightness from total signal
1819+
return rgb.reshape(h, w, 3), pca.explained_variance_ratio_
1820+
1821+
17591822
def _render_images(
17601823
sdata: sd.SpatialData,
17611824
render_params: ImageRenderParams,
@@ -2049,6 +2112,40 @@ def _render_images(
20492112
# Colors for the channel legend (set by each branch if applicable)
20502113
legend_colors: list[str] | None = None
20512114

2115+
# 2-PCA) Opt-in PCA reduction to RGB for highly multiplexed images, bypassing the
2116+
# additive seed-color blending that saturates at 4+ channels.
2117+
if render_params.multichannel_strategy == "pca":
2118+
if n_channels < 3:
2119+
raise ValueError(
2120+
f"multichannel_strategy='pca' requires at least 3 channels, got {n_channels}. "
2121+
"Select 3+ channels via 'channel' or use the default 'stack' strategy."
2122+
)
2123+
2124+
# Color the 3 components by `palette` (exactly 3, validated upstream), 3 cmap
2125+
# samples, or (default, left as None) red/green/blue inside _pca_compose.
2126+
component_colors = None
2127+
if palette is not None:
2128+
component_colors = np.array([matplotlib.colors.to_rgb(c) for c in palette])
2129+
elif has_explicit_cmap or user_supplied_multi_cmaps:
2130+
cmap_obj = (
2131+
render_params.cmap_params[0].cmap
2132+
if isinstance(render_params.cmap_params, list)
2133+
else render_params.cmap_params.cmap
2134+
)
2135+
component_colors = np.array([cmap_obj(x)[:3] for x in (0.0, 0.5, 1.0)])
2136+
2137+
colored, explained_variance = _pca_compose(layers, channels, component_colors)
2138+
logger.info(
2139+
"PCA explained-variance ratio per component: "
2140+
f"{', '.join(f'{v:.3f}' for v in explained_variance)}."
2141+
)
2142+
_ax_show_and_transform(
2143+
colored, trans_data, ax, render_params.alpha, zorder=render_params.zorder, interpolation=_interp
2144+
)
2145+
if render_params.channels_as_legend:
2146+
logger.warning("channels_as_legend is ignored with multichannel_strategy='pca'.")
2147+
return
2148+
20522149
# 2A) Image has 3 channels, no palette info, and no/only one cmap was given
20532150
if palette is None and n_channels == 3 and not isinstance(render_params.cmap_params, list):
20542151
if render_params.cmap_params.cmap_is_default: # -> use RGB
@@ -2129,8 +2226,9 @@ def _render_images(
21292226
colored = np.clip(comp_rgb, 0, 1)
21302227
logger.info(
21312228
f"Your image has {n_channels} channels. Sampling categorical colors and using "
2132-
f"multichannel strategy 'stack' to render."
2133-
) # TODO: update when pca is added as strategy
2229+
f"multichannel strategy 'stack' to render (pass multichannel_strategy='pca' for a "
2230+
f"PCA overview)."
2231+
)
21342232

21352233
legend_colors = seed_colors
21362234

src/spatialdata_plot/pl/render_params.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,7 @@ class ImageRenderParams(RenderParams):
308308
transfunc: Callable[[np.ndarray], np.ndarray] | list[Callable[[np.ndarray], np.ndarray]] | None = None
309309
grayscale: bool = False
310310
channels_as_legend: bool = False
311+
multichannel_strategy: Literal["stack", "pca"] | None = None
311312
method: Literal["matplotlib", "datashader"] | None = None
312313
ds_reduction: _ImageDsReduction | None = None
313314

tests/pl/test_render_images.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -927,3 +927,83 @@ def _render_and_grab(**kwargs):
927927
plt.close(fig)
928928

929929
np.testing.assert_array_equal(_render_and_grab(), _render_and_grab(method="matplotlib"))
930+
931+
932+
class TestMultichannelPCA(PlotTester, metaclass=PlotTesterMeta):
933+
"""PCA compositing strategy for n >= 4 channel images (#450)."""
934+
935+
@staticmethod
936+
def _render_array(sdata: SpatialData, **kwargs) -> np.ndarray:
937+
fig, ax = plt.subplots(figsize=(2, 2), dpi=50)
938+
try:
939+
sdata.pl.render_images("blobs_image", **kwargs).pl.show(ax=ax)
940+
return np.asarray(ax.get_images()[-1].get_array())
941+
finally:
942+
plt.close(fig)
943+
944+
def test_plot_pca_strategy_5_channels(self, sdata_blobs_str: SpatialData):
945+
sdata_blobs_str.pl.render_images("blobs_image", multichannel_strategy="pca").pl.show()
946+
947+
def test_pca_renders_rgb_in_unit_range(self, sdata_blobs_str: SpatialData):
948+
arr = self._render_array(sdata_blobs_str, multichannel_strategy="pca")
949+
assert arr.ndim == 3 and arr.shape[-1] == 3
950+
assert arr.min() >= 0.0 and arr.max() <= 1.0
951+
952+
def test_pca_is_deterministic(self, sdata_blobs_str: SpatialData):
953+
# PCA's SVD sign ambiguity is pinned by the max-abs sign convention -> stable colors.
954+
first = self._render_array(sdata_blobs_str, multichannel_strategy="pca")
955+
second = self._render_array(sdata_blobs_str, multichannel_strategy="pca")
956+
np.testing.assert_array_equal(first, second)
957+
958+
def test_pca_below_3_channels_raises(self, sdata_blobs_str: SpatialData):
959+
with pytest.raises(ValueError, match="at least 3 channels"):
960+
sdata_blobs_str.pl.render_images(
961+
"blobs_image", channel=["c1", "c2"], multichannel_strategy="pca"
962+
).pl.show()
963+
plt.close("all")
964+
965+
def test_pca_rank_deficient_drops_components_and_warns(self, caplog):
966+
# Three identical channels -> rank 1: the surviving component must still render (not
967+
# all-black), the two noise-floor components must be dropped (no color) and reported.
968+
rng = np.random.default_rng(0)
969+
base = rng.random((64, 64), dtype=np.float32)
970+
arr = np.stack([base, base, base], axis=0)
971+
sdata = SpatialData(images={"blobs_image": Image2DModel.parse(arr, c_coords=["c1", "c2", "c3"])})
972+
with logger_warns(caplog, logger, match="below the noise floor"):
973+
out = self._render_array(sdata, multichannel_strategy="pca")
974+
assert out.shape[-1] == 3
975+
assert out[..., 0].max() > 0 # dominant component rendered
976+
assert np.allclose(out[..., 1], 0.0) and np.allclose(out[..., 2], 0.0) # dropped -> no color
977+
978+
def test_pca_low_signal_background_renders_dark(self):
979+
# Signal in the centre, zeros elsewhere: the total-signal brightness gate must pull the
980+
# empty border toward black (the non-segmenting background fix).
981+
rng = np.random.default_rng(0)
982+
arr = np.zeros((4, 64, 64), dtype=np.float32)
983+
arr[:, 24:40, 24:40] = rng.random((4, 16, 16), dtype=np.float32) + 0.5
984+
sdata = SpatialData(images={"blobs_image": Image2DModel.parse(arr, c_coords=["c1", "c2", "c3", "c4"])})
985+
out = self._render_array(sdata, multichannel_strategy="pca")
986+
border = np.concatenate([out[:8].ravel(), out[-8:].ravel()]) # the empty margin
987+
assert border.mean() < 0.05
988+
assert out[24:40, 24:40].mean() > border.mean()
989+
990+
@pytest.mark.parametrize("recolor", [{"palette": ["cyan", "magenta", "yellow"]}, {"cmap": "viridis"}])
991+
def test_pca_recolors_components(self, sdata_blobs_str: SpatialData, recolor):
992+
# palette (3 colors) and cmap (3 samples) must change the output vs the default RGB mapping.
993+
default = self._render_array(sdata_blobs_str, multichannel_strategy="pca")
994+
recolored = self._render_array(sdata_blobs_str, multichannel_strategy="pca", **recolor)
995+
assert not np.array_equal(default, recolored)
996+
997+
def test_pca_palette_wrong_length_raises(self, sdata_blobs_str: SpatialData):
998+
with pytest.raises(ValueError, match="must have exactly 3 entries"):
999+
sdata_blobs_str.pl.render_images(
1000+
"blobs_image", multichannel_strategy="pca", palette=["red", "green"]
1001+
).pl.show()
1002+
plt.close("all")
1003+
1004+
def test_stack_default_is_backward_compatible(self, sdata_blobs_str: SpatialData):
1005+
# None (default) and explicit "stack" must be byte-identical to the pre-#450 output.
1006+
np.testing.assert_array_equal(
1007+
self._render_array(sdata_blobs_str, multichannel_strategy=None),
1008+
self._render_array(sdata_blobs_str, multichannel_strategy="stack"),
1009+
)

0 commit comments

Comments
 (0)