Skip to content

Commit edf3f76

Browse files
authored
perf(render_images): stream multi-channel composite (avoid O(n_channels) RGBA cube) (#736)
1 parent eb0491d commit edf3f76

2 files changed

Lines changed: 54 additions & 34 deletions

File tree

src/spatialdata_plot/pl/render.py

Lines changed: 22 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -618,13 +618,10 @@ def _render_shapes(
618618
)
619619

620620
table_name = render_params.table_name
621-
if table_name is None:
622-
table = None
623-
else:
621+
if table_name is not None:
624622
# No join/copy: _set_color_source_vec resolves each shape's color from the table (region-masked
625623
# and reindexed to the element), so unannotated shapes keep their place and render with na_color.
626624
_check_instance_ids_overlap(sdata_filt, table_name, element, sdata_filt[element].index)
627-
table = sdata_filt[table_name]
628625

629626
shapes = sdata_filt[element]
630627

@@ -1709,6 +1706,22 @@ def _draw_channel_legend(
17091706
)
17101707

17111708

1709+
def _composite_channels(
1710+
channel_cmaps: list[Colormap],
1711+
layers: dict[Any, np.ndarray],
1712+
channels: list[Any],
1713+
) -> np.ndarray:
1714+
"""Sum per-channel RGB into one ``(H, W, 3)`` buffer.
1715+
1716+
Holds O(1) full-resolution buffers instead of the full ``(n_channels, H, W, 4)`` cube;
1717+
byte-identical to stacking then summing because the reduction is sequential.
1718+
"""
1719+
acc = channel_cmaps[0](layers[channels[0]])[:, :, :3].copy()
1720+
for cmap, ch in zip(channel_cmaps[1:], channels[1:], strict=True):
1721+
acc += cmap(layers[ch])[:, :, :3]
1722+
return acc
1723+
1724+
17121725
def _render_images(
17131726
sdata: sd.SpatialData,
17141727
render_params: ImageRenderParams,
@@ -2008,14 +2021,7 @@ def _render_images(
20082021
legend_colors = ["red", "green", "blue"]
20092022
else: # -> use given cmap for each channel
20102023
channel_cmaps = [render_params.cmap_params.cmap] * n_channels
2011-
stacked = (
2012-
np.stack(
2013-
[channel_cmaps[ind](layers[ch]) for ind, ch in enumerate(channels)],
2014-
0,
2015-
).sum(0)
2016-
/ n_channels
2017-
)
2018-
stacked = stacked[:, :, :3]
2024+
stacked = _composite_channels(channel_cmaps, layers, channels) / n_channels
20192025
logger.warning(
20202026
"One cmap was given for multiple channels and is now used for each channel. "
20212027
+ _MULTI_CMAP_BLENDING_WARNING
@@ -2036,19 +2042,11 @@ def _render_images(
20362042
if n_channels == 2:
20372043
seed_colors = ["#ff0000ff", "#00ff00ff"]
20382044
channel_cmaps = [_get_linear_colormap([c], "k")[0] for c in seed_colors]
2039-
colored = np.stack(
2040-
[channel_cmaps[ch_ind](layers[ch]) for ch_ind, ch in enumerate(channels)],
2041-
0,
2042-
).sum(0)
2043-
colored = np.clip(colored[:, :, :3], 0, 1)
2045+
colored = np.clip(_composite_channels(channel_cmaps, layers, channels), 0, 1)
20442046
elif n_channels == 3:
20452047
seed_colors = _get_colors_for_categorical_obs(list(range(n_channels)))
20462048
channel_cmaps = [_get_linear_colormap([c], "k")[0] for c in seed_colors]
2047-
colored = np.stack(
2048-
[channel_cmaps[ind](layers[ch]) for ind, ch in enumerate(channels)],
2049-
0,
2050-
).sum(0)
2051-
colored = np.clip(colored[:, :, :3], 0, 1)
2049+
colored = np.clip(_composite_channels(channel_cmaps, layers, channels), 0, 1)
20522050
else:
20532051
if isinstance(render_params.cmap_params, list):
20542052
cmap_is_default = render_params.cmap_params[0].cmap_is_default
@@ -2102,8 +2100,7 @@ def _render_images(
21022100
raise ValueError("If 'palette' is provided, its length must match the number of channels.")
21032101

21042102
channel_cmaps = [_get_linear_colormap([c], "k")[0] for c in palette if isinstance(c, str)]
2105-
colored = np.stack([channel_cmaps[i](layers[c]) for i, c in enumerate(channels)], 0).sum(0)
2106-
colored = np.clip(colored[:, :, :3], 0, 1)
2103+
colored = np.clip(_composite_channels(channel_cmaps, layers, channels), 0, 1)
21072104

21082105
legend_colors = list(palette)
21092106

@@ -2118,14 +2115,7 @@ def _render_images(
21182115

21192116
elif palette is None and got_multiple_cmaps:
21202117
channel_cmaps = [cp.cmap for cp in render_params.cmap_params] # type: ignore[union-attr]
2121-
colored = (
2122-
np.stack(
2123-
[channel_cmaps[ind](layers[ch]) for ind, ch in enumerate(channels)],
2124-
0,
2125-
).sum(0)
2126-
/ n_channels
2127-
)
2128-
colored = colored[:, :, :3]
2118+
colored = _composite_channels(channel_cmaps, layers, channels) / n_channels
21292119

21302120
legend_colors = [matplotlib.colors.to_hex(cm(0.75)) for cm in channel_cmaps]
21312121

tests/pl/test_render_images.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@
44
import numpy as np
55
import pytest
66
import scanpy as sc
7-
from matplotlib.colors import LogNorm, Normalize
7+
from matplotlib.colors import LinearSegmentedColormap, LogNorm, Normalize
88
from spatial_image import to_spatial_image
99
from spatialdata import SpatialData
1010
from spatialdata.models import Image2DModel, Image3DModel
1111

1212
import spatialdata_plot # noqa: F401
1313
from spatialdata_plot import PercentileNormalize
1414
from spatialdata_plot._logging import logger, logger_no_warns, logger_warns
15-
from spatialdata_plot.pl.render import _is_rgb_image
15+
from spatialdata_plot.pl.render import _composite_channels, _is_rgb_image
1616
from tests.conftest import DPI, PlotTester, PlotTesterMeta, _viridis_with_under_over
1717

1818
sc.pl.set_rcParams_defaults()
@@ -937,3 +937,33 @@ def _render_and_grab(**kwargs):
937937
plt.close(fig)
938938

939939
np.testing.assert_array_equal(_render_and_grab(), _render_and_grab(method="matplotlib"))
940+
941+
942+
class TestCompositeChannels:
943+
"""`_composite_channels` must be byte-identical to the materialized stack-and-sum it replaces."""
944+
945+
@staticmethod
946+
def _stack_sum(channel_cmaps, layers, channels):
947+
return np.stack([channel_cmaps[i](layers[ch]) for i, ch in enumerate(channels)], 0).sum(0)[:, :, :3]
948+
949+
@pytest.mark.parametrize("n", [2, 3, 129, 200]) # 129/200 cross numpy's 128-element pairwise threshold
950+
def test_streaming_matches_stack_sum(self, n: int):
951+
rng = np.random.default_rng(0)
952+
channels = list(range(n))
953+
layers = {ch: rng.random((32, 24)) for ch in channels}
954+
cmaps = [LinearSegmentedColormap.from_list("x", ["k", rng.random(3)], N=256) for _ in channels]
955+
956+
result = _composite_channels(cmaps, layers, channels)
957+
958+
np.testing.assert_array_equal(result, self._stack_sum(cmaps, layers, channels))
959+
960+
def test_returns_owned_rgb_buffer(self):
961+
rng = np.random.default_rng(1)
962+
channels = [0, 1]
963+
layers = {ch: rng.random((8, 8)) for ch in channels}
964+
cmaps = [LinearSegmentedColormap.from_list("x", ["k", "r"], N=256) for _ in channels]
965+
966+
result = _composite_channels(cmaps, layers, channels)
967+
assert result.shape == (8, 8, 3)
968+
assert result.dtype == np.float64
969+
assert result.flags["C_CONTIGUOUS"] # owns its buffer -> the (H,W,4) cmap temps are freed each step

0 commit comments

Comments
 (0)