@@ -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+
17591822def _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
0 commit comments