@@ -1756,6 +1756,67 @@ 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+ h , w = np .asarray (layers [channels [0 ]]).shape
1793+ mat = np .stack ([np .asarray (layers [ch ]).reshape (- 1 ) for ch in channels ], axis = 1 )
1794+
1795+ pca = PCA (n_components = min (3 , mat .shape [1 ]), svd_solver = "full" )
1796+ comps = pca .fit_transform (mat )
1797+
1798+ rgb = np .zeros ((h * w , 3 ), dtype = np .float64 )
1799+ dropped : list [int ] = []
1800+ for k in range (comps .shape [1 ]):
1801+ if pca .explained_variance_ratio_ [k ] < _PCA_NOISE_FLOOR :
1802+ dropped .append (k )
1803+ continue
1804+ col = comps [:, k ]
1805+ if col [np .argmax (np .abs (col ))] < 0 : # pin PCA's arbitrary sign
1806+ col = - col
1807+ rgb += np .outer (_stretch_to_unit (col ), colors [k ])
1808+ np .clip (rgb , 0.0 , 1.0 , out = rgb )
1809+
1810+ if dropped :
1811+ logger .warning (
1812+ f"PCA: component(s) { dropped } below the noise floor (explained variance < "
1813+ f"{ _PCA_NOISE_FLOOR :g} ); channels appear collinear, so they contribute no color."
1814+ )
1815+
1816+ rgb *= _stretch_to_unit (mat .sum (axis = 1 ))[:, np .newaxis ] # brightness from total signal
1817+ return rgb .reshape (h , w , 3 ), pca .explained_variance_ratio_
1818+
1819+
17591820def _render_images (
17601821 sdata : sd .SpatialData ,
17611822 render_params : ImageRenderParams ,
@@ -2049,6 +2110,45 @@ def _render_images(
20492110 # Colors for the channel legend (set by each branch if applicable)
20502111 legend_colors : list [str ] | None = None
20512112
2113+ # 2-PCA) Opt-in PCA reduction to RGB for highly multiplexed images, bypassing the
2114+ # additive seed-color blending that saturates at 4+ channels.
2115+ if render_params .multichannel_strategy == "pca" :
2116+ if n_channels < 3 :
2117+ raise ValueError (
2118+ f"multichannel_strategy='pca' requires at least 3 channels, got { n_channels } . "
2119+ "Select 3+ channels via 'channel' or use the default 'stack' strategy."
2120+ )
2121+
2122+ # Color the 3 components by a 3-entry `palette`, 3 cmap samples, or (default,
2123+ # left as None) red/green/blue inside _pca_compose.
2124+ component_colors = None
2125+ if palette is not None :
2126+ if len (palette ) != 3 :
2127+ raise ValueError (
2128+ "multichannel_strategy='pca' maps the 3 principal components to colors; "
2129+ f"'palette' must have exactly 3 entries, got { len (palette )} ."
2130+ )
2131+ component_colors = np .array ([matplotlib .colors .to_rgb (c ) for c in palette ])
2132+ elif has_explicit_cmap or user_supplied_multi_cmaps :
2133+ cmap_obj = (
2134+ render_params .cmap_params [0 ].cmap
2135+ if isinstance (render_params .cmap_params , list )
2136+ else render_params .cmap_params .cmap
2137+ )
2138+ component_colors = np .array ([cmap_obj (x )[:3 ] for x in (0.0 , 0.5 , 1.0 )])
2139+
2140+ colored , explained_variance = _pca_compose (layers , channels , component_colors )
2141+ logger .info (
2142+ "PCA explained-variance ratio per component: "
2143+ f"{ ', ' .join (f'{ v :.3f} ' for v in explained_variance )} ."
2144+ )
2145+ _ax_show_and_transform (
2146+ colored , trans_data , ax , render_params .alpha , zorder = render_params .zorder , interpolation = _interp
2147+ )
2148+ if render_params .channels_as_legend :
2149+ logger .warning ("channels_as_legend is ignored with multichannel_strategy='pca'." )
2150+ return
2151+
20522152 # 2A) Image has 3 channels, no palette info, and no/only one cmap was given
20532153 if palette is None and n_channels == 3 and not isinstance (render_params .cmap_params , list ):
20542154 if render_params .cmap_params .cmap_is_default : # -> use RGB
0 commit comments