Skip to content

Commit ca7882b

Browse files
timtreisclaude
andcommitted
Use signal-based compositing for correct blending with any colormap
The naive additive blend (sum RGB + clip) only works with black-to-color LUTs. For white-to-color cmaps (e.g. Reds, Greens) it saturates to white; for diverging cmaps (e.g. seismic) it washes out. Signal-based blending subtracts each cmap's zero-value before summing, then composites onto a canvas colored by the mean zero-value. This reduces to standard additive for black-to-color LUTs (Napari/ImageJ) and to invert-add-invert for white-to-color LUTs (ImageJ Composite Invert), while producing reasonable results for arbitrary cmaps. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent becae8d commit ca7882b

6 files changed

Lines changed: 24 additions & 15 deletions

File tree

src/spatialdata_plot/pl/render.py

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1180,16 +1180,33 @@ def _additive_blend(
11801180
channels: list[Any],
11811181
channel_cmaps: list[Colormap],
11821182
) -> np.ndarray:
1183-
"""Additive blend of colormapped channels, matching Napari's additive mode.
1183+
"""Composite colormapped channels via signal-based additive blending.
11841184
1185-
Each channel is mapped through its colormap (which must return RGBA),
1186-
the RGB components are summed, and the result is clamped to [0, 1].
1185+
For each channel, the "signal" is the deviation of the mapped color from
1186+
that colormap's zero-value color (``cmap(0)``). Signals are summed and
1187+
placed on a canvas whose color is the mean zero-value across all cmaps.
1188+
1189+
This generalises two established compositing strategies:
1190+
1191+
* **Black-to-color LUTs** (Napari/ImageJ additive): ``cmap(0) == black``,
1192+
so ``signal == cmap(val)`` and the canvas is black — identical to a
1193+
naive RGB sum.
1194+
* **White-to-color LUTs** (ImageJ Composite Invert): ``cmap(0) ≈ white``,
1195+
so ``signal == cmap(val) - white`` (negative deviations) and the canvas
1196+
is white — equivalent to *invert → add → invert*.
1197+
1198+
For arbitrary colormaps (e.g. diverging) the same formula produces a
1199+
reasonable result by extracting each cmap's contribution above its own
1200+
background.
11871201
"""
11881202
height, width = next(iter(layers.values())).shape
1189-
composite = np.zeros((height, width, 3), dtype=float)
1203+
zero_colors = np.array([cm(0.0)[:3] for cm in channel_cmaps])
1204+
canvas = np.mean(zero_colors, axis=0)
1205+
composite = np.full((height, width, 3), canvas, dtype=float)
11901206
for ch, cmap in zip(channels, channel_cmaps, strict=True):
1207+
zero_rgb = np.array(cmap(0.0)[:3])
11911208
rgba = cmap(np.asarray(layers[ch]))
1192-
composite += rgba[..., :3]
1209+
composite += rgba[..., :3] - zero_rgb
11931210
return np.clip(composite, 0, 1, out=composite)
11941211

11951212

@@ -1245,11 +1262,7 @@ def _render_images(
12451262
got_multiple_cmaps = isinstance(render_params.cmap_params, list)
12461263
if got_multiple_cmaps:
12471264
logger.warning(
1248-
"You're blending multiple cmaps. "
1249-
"If the plot doesn't look like you expect, it might be because your "
1250-
"cmaps go from a given color to 'white', and not to 'transparent'. "
1251-
"Therefore, the 'white' of higher layers will overlay the lower layers. "
1252-
"Consider using 'palette' instead."
1265+
"You're blending multiple cmaps. Consider using 'palette' for black-to-color compositing instead."
12531266
)
12541267

12551268
# not using got_multiple_cmaps here because of ruff :(
@@ -1333,11 +1346,7 @@ def _render_images(
13331346
stacked = _additive_blend(layers, channels, channel_cmaps)
13341347
logger.warning(
13351348
"One cmap was given for multiple channels and is now used for each channel. "
1336-
"You're blending multiple cmaps. "
1337-
"If the plot doesn't look like you expect, it might be because your "
1338-
"cmaps go from a given color to 'white', and not to 'transparent'. "
1339-
"Therefore, the 'white' of higher layers will overlay the lower layers. "
1340-
"Consider using 'palette' instead."
1349+
"Consider using 'palette' for black-to-color compositing instead."
13411350
)
13421351

13431352
_ax_show_and_transform(
20.7 KB
Loading
71.3 KB
Loading
64.1 KB
Loading
20.7 KB
Loading
71.3 KB
Loading

0 commit comments

Comments
 (0)