Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 30 additions & 21 deletions src/spatialdata_plot/pl/_color.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -109,6 +109,29 @@ def _make_continuous_mappable(vmin: float, vmax: float, cmap: Any) -> ScalarMapp
return ScalarMappable(norm=Normalize(vmin=vmin, vmax=vmax), cmap=cmap)


def _resolve_continuous_norm(values: Any, cmap_params: CmapParams) -> Normalize:
"""Resolve a concrete ``Normalize`` for continuous coloring.

Honor explicit ``norm`` vmin/vmax, else the finite-value data range of ``values``, else
``[0, 1]``. Shared by the pixel and colorbar sites so both derive the same range. A degenerate
``vmin == vmax`` is left as-is (matplotlib expands it downstream), not reset to ``[0, 1]``.
"""
base = cmap_params.norm
vmin, vmax = base.vmin, base.vmax
if vmin is None or vmax is None:
arr = np.asarray(values)
if not np.issubdtype(arr.dtype, np.number):
arr = pd.to_numeric(arr.ravel(), errors="coerce")
finite = np.isfinite(arr)
data_min = float(np.nanmin(arr[finite])) if finite.any() else 0.0
data_max = float(np.nanmax(arr[finite])) if finite.any() else 1.0
if vmin is None:
vmin = data_min
if vmax is None:
vmax = data_max
return Normalize(vmin=vmin, vmax=vmax, clip=base.clip)


def _apply_mask_to_outline_vectors(
outline_color_vector: Any,
outline_color_source_vector: pd.Series | None,
Expand DownExpand Up@@ -189,15 +212,7 @@ def _color_vector_to_rgba(
if np.issubdtype(arr.dtype, np.number):
finite_mask = np.isfinite(arr)
if finite_mask.any():
norm = cmap_params.norm
if norm.vmin is None or norm.vmax is None:
vmin = float(np.nanmin(arr[finite_mask]))
vmax = float(np.nanmax(arr[finite_mask]))
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax:
vmin, vmax = 0.0, 1.0
used_norm = Normalize(vmin=vmin, vmax=vmax, clip=False)
else:
used_norm = norm
used_norm = _resolve_continuous_norm(arr, cmap_params)
rgba[finite_mask] = cmap_params.cmap(used_norm(arr[finite_mask]))
return rgba

Expand All@@ -206,15 +221,7 @@ def _color_vector_to_rgba(
num = pd.to_numeric(series, errors="coerce").to_numpy()
is_num = np.isfinite(num)
if is_num.any():
norm = cmap_params.norm
if norm.vmin is None or norm.vmax is None:
vmin = float(np.nanmin(num[is_num]))
vmax = float(np.nanmax(num[is_num]))
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax:
vmin, vmax = 0.0, 1.0
used_norm = Normalize(vmin=vmin, vmax=vmax, clip=False)
else:
used_norm = norm
used_norm = _resolve_continuous_norm(num, cmap_params)
rgba[is_num] = cmap_params.cmap(used_norm(num[is_num]))
color_mask = (~is_num) & series.notna().to_numpy()
if color_mask.any():
Expand DownExpand Up@@ -754,7 +761,8 @@ def _map_color_seg(
color_vector = color_vector.to_numpy()
# normalize only the not nan values, else the whole array would contain only nan values
normed_color_vector = color_vector.copy().astype(float)
normed_color_vector[~np.isnan(normed_color_vector)] = cmap_params.norm(
used_norm = _resolve_continuous_norm(normed_color_vector, cmap_params)
normed_color_vector[~np.isnan(normed_color_vector)] = used_norm(
normed_color_vector[~np.isnan(normed_color_vector)]
)
cols = cmap_params.cmap(normed_color_vector)
Expand All@@ -779,7 +787,8 @@ def _map_color_seg(
assert all(_is_color_like(c) for c in color_vector), "Not all values are color-like."
cols = colors.to_rgba_array(color_vector)
else:
cols = cmap_params.cmap(cmap_params.norm(color_vector))
used_norm = _resolve_continuous_norm(color_vector, cmap_params)
cols = cmap_params.cmap(used_norm(color_vector))

if seg_erosionpx is not None:
val_im[val_im == erosion(val_im, footprint_rectangle((seg_erosionpx, seg_erosionpx)))] = 0
Expand DownExpand Up@@ -813,7 +822,7 @@ def _map_color_seg(
normed = ov.copy().astype(float)
finite = ~np.isnan(normed)
if finite.any():
normed[finite] = cmap_params.norm(normed[finite])
normed[finite] = _resolve_continuous_norm(ov, cmap_params)(normed[finite])
outline_cols = cmap_params.cmap(normed)
outline_val_im = map_array(seg, cell_id, cell_id)
if seg_erosionpx is not None:
Expand Down
3 changes: 1 addition & 2 deletions src/spatialdata_plot/pl/_datashader.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,6 @@

from __future__ import annotations

from copy import copy
from typing import Any, Literal

import dask
Expand DownExpand Up@@ -588,7 +587,7 @@ def _render_ds_outline_by_column(
)
# Apply the user-provided norm (vmin/vmax) the same way the fill path does so
# an explicit Normalize takes effect for the outline cmap.
norm = copy(cmap_params.norm)
norm = cmap_params.fresh_norm()
agg_outline, color_span = _apply_ds_norm(agg_outline, norm)
shaded = ds.tf.shade(
agg_outline,
Expand Down
29 changes: 5 additions & 24 deletions src/spatialdata_plot/pl/_geometry.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,11 +13,12 @@
from geopandas import GeoDataFrame
from matplotlib import colors
from matplotlib.collections import PatchCollection
from matplotlib.colors import ColorConverter, Normalize
from matplotlib.colors import ColorConverter
from scipy.spatial import ConvexHull
from shapely.errors import GEOSException

from spatialdata_plot._logging import logger
from spatialdata_plot.pl._color import _resolve_continuous_norm
from spatialdata_plot.pl.render_params import ShapesRenderParams
from spatialdata_plot.pl.utils import _extract_scalar_value

Expand DownExpand Up@@ -167,7 +168,6 @@ def _get_collection_shape(
shapes: list[GeoDataFrame],
c: Any,
s: float,
norm: Any,
render_params: ShapesRenderParams,
fill_alpha: None | float = None,
outline_alpha: None | float = None,
Expand DownExpand Up@@ -215,23 +215,11 @@ def _as_rgba_array(x: Any) -> np.ndarray:
elif c_arr.ndim == 1 and len(c_arr) == len(shapes) and np.issubdtype(c_arr.dtype, np.number):
finite_mask = np.isfinite(c_arr)

# Select or build a normalization that ignores NaNs for scaling
if isinstance(norm, Normalize):
used_norm: Normalize = norm
else:
if finite_mask.any():
vmin = float(np.nanmin(c_arr[finite_mask]))
vmax = float(np.nanmax(c_arr[finite_mask]))
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax:
vmin, vmax = 0.0, 1.0
else:
vmin, vmax = 0.0, 1.0
used_norm = colors.Normalize(vmin=vmin, vmax=vmax, clip=False)

# Map finite values through cmap(norm(.)); NaNs get na_color
# Map finite values through cmap(norm(.)); NaNs get na_color.
fill_c = np.empty((len(c_arr), 4), dtype=float)
fill_c[:] = na_rgba
if finite_mask.any():
used_norm = _resolve_continuous_norm(c_arr, render_params.cmap_params)
fill_c[finite_mask] = cmap(used_norm(c_arr[finite_mask]))

elif c_arr.ndim == 1 and len(c_arr) == len(shapes) and c_arr.dtype == object:
Expand All@@ -246,14 +234,7 @@ def _as_rgba_array(x: Any) -> np.ndarray:

# numeric entries via cmap(norm)
if is_num.any():
if isinstance(norm, Normalize):
used_norm = norm
else:
vmin = float(np.nanmin(num[is_num])) if is_num.any() else 0.0
vmax = float(np.nanmax(num[is_num])) if is_num.any() else 1.0
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax:
vmin, vmax = 0.0, 1.0
used_norm = colors.Normalize(vmin=vmin, vmax=vmax, clip=False)
used_norm = _resolve_continuous_norm(num, render_params.cmap_params)
fill_c[is_num] = cmap(used_norm(num[is_num]))

# non-numeric, non-NaN entries as explicit colors
Expand Down
57 changes: 17 additions & 40 deletions src/spatialdata_plot/pl/render.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,10 +38,10 @@
_color_vector_to_rgba,
_get_colors_for_categorical_obs,
_get_linear_colormap,
_make_continuous_mappable,
_map_color_seg,
_maybe_set_colors,
_prepare_cmap_norm,
_resolve_continuous_norm,
_set_color_source_vec,
)
from spatialdata_plot.pl._datashader import (
Expand DownExpand Up@@ -77,6 +77,7 @@
PointsRenderParams,
ShapesRenderParams,
_DsReduction,
colormap_with_alpha,
)
from spatialdata_plot.pl.utils import (
_decorate_axs,
Expand DownExpand Up@@ -515,21 +516,17 @@ def _append_outline_colorbar(
) -> None:
"""Append a `ColorbarSpec` for a continuous outline column.

No-op when ``outline_color_vector`` has no finite values. Honors user-supplied
`vmin`/`vmax` on ``cmap_params.norm``; falls back to data range. Mirrors the
`vmin == vmax` ±0.5 expansion used by the fill colorbar.
No-op when ``outline_color_vector`` has no finite values; derives the bar from the same resolved
norm the outline pixels use.
"""
arr = pd.to_numeric(pd.Series(np.asarray(outline_color_vector)), errors="coerce").to_numpy()
finite = np.isfinite(arr)
if not finite.any():
if not np.isfinite(arr).any():
return
norm = cmap_params.norm
vmin = norm.vmin if norm.vmin is not None else float(np.nanmin(arr[finite]))
vmax = norm.vmax if norm.vmax is not None else float(np.nanmax(arr[finite]))
used_norm = _resolve_continuous_norm(outline_color_vector, cmap_params)
colorbar_requests.append(
ColorbarSpec(
ax=ax,
mappable=_make_continuous_mappable(vmin, vmax, cmap_params.cmap),
mappable=ScalarMappable(norm=used_norm, cmap=cmap_params.cmap),
params=colorbar_params,
label=outline_col,
alpha=alpha,
Expand DownExpand Up@@ -747,7 +744,7 @@ def _render_shapes(

color_vector = _maybe_apply_transfunc(color_source_vector, color_vector, render_params.transfunc)

norm = copy(render_params.cmap_params.norm)
norm = render_params.cmap_params.fresh_norm()

if len(color_vector) == 0:
color_vector = [render_params.cmap_params.na_color.get_hex_with_alpha()]
Expand DownExpand Up@@ -968,7 +965,6 @@ def _render_shapes(
render_params=render_params,
rasterized=sc_settings._vector_friendly,
cmap=None,
norm=None,
fill_alpha=0.0,
outline_alpha=render_params.outline_alpha[0],
outline_color=outline_rgba,
Expand All@@ -987,7 +983,6 @@ def _render_shapes(
render_params=render_params,
rasterized=sc_settings._vector_friendly,
cmap=None,
norm=None,
fill_alpha=0.0,
outline_alpha=render_params.outline_alpha[0],
outline_color=render_params.outline_params.outer_outline_color.get_hex(),
Expand All@@ -1008,7 +1003,6 @@ def _render_shapes(
render_params=render_params,
rasterized=sc_settings._vector_friendly,
cmap=None,
norm=None,
fill_alpha=0.0,
outline_alpha=render_params.outline_alpha[1],
outline_color=render_params.outline_params.inner_outline_color.get_hex(),
Expand All@@ -1030,7 +1024,6 @@ def _render_shapes(
render_params=render_params,
rasterized=sc_settings._vector_friendly,
cmap=render_params.cmap_params.cmap,
norm=norm,
fill_alpha=render_params.fill_alpha,
outline_alpha=0.0,
zorder=render_params.zorder,
Expand All@@ -1043,25 +1036,9 @@ def _render_shapes(
path.vertices = trans.transform(path.vertices)

if not values_are_categorical:
# Respect explicit vmin/vmax; otherwise derive from finite numeric values, falling back to [0, 1] if unavailable
vmin = render_params.cmap_params.norm.vmin
vmax = render_params.cmap_params.norm.vmax
if vmin is None or vmax is None:
numeric_values = pd.to_numeric(np.asarray(color_vector), errors="coerce")
finite_mask = np.isfinite(numeric_values)
if finite_mask.any():
data_min = float(np.nanmin(numeric_values[finite_mask]))
data_max = float(np.nanmax(numeric_values[finite_mask]))
if vmin is None:
vmin = data_min
if vmax is None:
vmax = data_max
else:
if vmin is None:
vmin = 0.0
if vmax is None:
vmax = 1.0
_cax.set_clim(vmin=vmin, vmax=vmax)
# Colorbar range from the same resolved norm the fill pixels use.
used_norm = _resolve_continuous_norm(color_vector, render_params.cmap_params)
_cax.set_clim(vmin=used_norm.vmin, vmax=used_norm.vmax)

_add_legend_and_colorbar(
ax=ax,
Expand DownExpand Up@@ -1511,7 +1488,7 @@ def _render_points(

trans, trans_data = _prepare_transformation(sdata.points[element], coordinate_system, ax)

norm = copy(render_params.cmap_params.norm)
norm = render_params.cmap_params.fresh_norm()

method = render_params.method

Expand DownExpand Up@@ -1972,9 +1949,8 @@ def _render_images(
else render_params.cmap_params.cmap
)

# Overwrite alpha in cmap: https://stackoverflow.com/a/10127675
cmap._init()
cmap._lut[:, -1] = render_params.alpha
# Bake a uniform alpha into a fresh cmap (no shared-cmap mutation).
cmap = colormap_with_alpha(cmap, render_params.alpha, render_params.cmap_params.na_color.get_hex_with_alpha())

# norm needs to be passed directly to ax.imshow(). If we normalize before, that method would always clip.
_ax_show_and_transform(
Expand DownExpand Up@@ -2432,7 +2408,7 @@ def _render_labels(
y=xy[:, 1],
color_vector=point_color_vector,
color_source_vector=point_color_source_vector,
norm=copy(render_params.cmap_params.norm), # ax.scatter autoscales in place; don't mutate the shared norm
norm=render_params.cmap_params.fresh_norm(), # ax.scatter autoscales in place; don't mutate the shared norm
na_color=na_color,
adata=table if table_name is not None else None,
col_for_color=col_for_color,
Expand DownExpand Up@@ -2465,11 +2441,12 @@ def _draw_labels(
outline_color_source_vector=outline_color_source_vector if seg_boundaries else None,
)

# labels is pre-baked RGB; cmap/norm only drive the colorbar, so feed the same resolved norm.
cax = ax.imshow(
labels,
rasterized=True,
cmap=None if categorical else render_params.cmap_params.cmap,
norm=None if categorical else render_params.cmap_params.norm,
norm=None if categorical else _resolve_continuous_norm(color_vector, render_params.cmap_params),
alpha=alpha,
origin="lower",
zorder=render_params.zorder,
Expand Down
28 changes: 27 additions & 1 deletion src/spatialdata_plot/pl/render_params.py
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
from __future__ import annotations

from collections.abc import Callable, Mapping, Sequence
from copy import copy
from dataclasses import dataclass, field
from typing import Any, Literal

import numpy as np
from matplotlib.axes import Axes
from matplotlib.cm import ScalarMappable
from matplotlib.colors import Colormap, ListedColormap, Normalize, rgb2hex, to_hex
from matplotlib.colors import Colormap, ListedColormap, Normalize, rgb2hex, to_hex, to_rgba
from matplotlib.figure import Figure

_FontWeight = Literal["light", "normal", "medium", "semibold", "bold", "heavy", "black"]
Expand DownExpand Up@@ -148,6 +149,23 @@ def is_fully_transparent(self) -> bool:
return self.alpha == "00"


def colormap_with_alpha(cmap: Colormap, alpha: float, na_color: str) -> Colormap:
"""Return ``cmap`` rebuilt with a uniform ``alpha`` and ``na_color`` as the bad/NaN color.

Resampling at ``linspace(0, 1, N)`` is lossless (matplotlib quantizes ``__call__`` into ``N`` bins).
"""
lut = cmap(np.linspace(0, 1, cmap.N))
lut[:, -1] = alpha
new = ListedColormap(lut, name=cmap.name)
# Apply alpha to under/over too, matching the old ``_lut[:, -1] = alpha`` (which hit every row).
new.set_extremes(
bad=[*to_rgba(na_color)[:3], alpha],
under=[*cmap.get_under()[:3], alpha],
over=[*cmap.get_over()[:3], alpha],
)
return new


@dataclass
class CmapParams:
"""Cmap params."""
Expand All@@ -157,6 +175,14 @@ class CmapParams:
na_color: Color
cmap_is_default: bool = True

def fresh_norm(self) -> Normalize:
"""Return a copy of ``norm`` safe to apply/autoscale without mutating the shared one.

``Normalize.__call__`` autoscales ``vmin``/``vmax`` in place when unset, which would leak one
element's data range into later elements that reuse the same ``CmapParams``.
"""
return copy(self.norm)


@dataclass
class FigParams:
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 30 additions & 21 deletions src/spatialdata_plot/pl/_color.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -109,6 +109,29 @@ def _make_continuous_mappable(vmin: float, vmax: float, cmap: Any) -> ScalarMapp
return ScalarMappable(norm=Normalize(vmin=vmin, vmax=vmax), cmap=cmap)


def _resolve_continuous_norm(values: Any, cmap_params: CmapParams) -> Normalize:
"""Resolve a concrete ``Normalize`` for continuous coloring.

Honor explicit ``norm`` vmin/vmax, else the finite-value data range of ``values``, else
``[0, 1]``. Shared by the pixel and colorbar sites so both derive the same range. A degenerate
``vmin == vmax`` is left as-is (matplotlib expands it downstream), not reset to ``[0, 1]``.
"""
base = cmap_params.norm
vmin, vmax = base.vmin, base.vmax
if vmin is None or vmax is None:
arr = np.asarray(values)
if not np.issubdtype(arr.dtype, np.number):
arr = pd.to_numeric(arr.ravel(), errors="coerce")
finite = np.isfinite(arr)
data_min = float(np.nanmin(arr[finite])) if finite.any() else 0.0
data_max = float(np.nanmax(arr[finite])) if finite.any() else 1.0
if vmin is None:
vmin = data_min
if vmax is None:
vmax = data_max
return Normalize(vmin=vmin, vmax=vmax, clip=base.clip)


def _apply_mask_to_outline_vectors(
outline_color_vector: Any,
outline_color_source_vector: pd.Series | None,
Expand DownExpand Up@@ -189,15 +212,7 @@ def _color_vector_to_rgba(
if np.issubdtype(arr.dtype, np.number):
finite_mask = np.isfinite(arr)
if finite_mask.any():
norm = cmap_params.norm
if norm.vmin is None or norm.vmax is None:
vmin = float(np.nanmin(arr[finite_mask]))
vmax = float(np.nanmax(arr[finite_mask]))
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax:
vmin, vmax = 0.0, 1.0
used_norm = Normalize(vmin=vmin, vmax=vmax, clip=False)
else:
used_norm = norm
used_norm = _resolve_continuous_norm(arr, cmap_params)
rgba[finite_mask] = cmap_params.cmap(used_norm(arr[finite_mask]))
return rgba

Expand All@@ -206,15 +221,7 @@ def _color_vector_to_rgba(
num = pd.to_numeric(series, errors="coerce").to_numpy()
is_num = np.isfinite(num)
if is_num.any():
norm = cmap_params.norm
if norm.vmin is None or norm.vmax is None:
vmin = float(np.nanmin(num[is_num]))
vmax = float(np.nanmax(num[is_num]))
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax:
vmin, vmax = 0.0, 1.0
used_norm = Normalize(vmin=vmin, vmax=vmax, clip=False)
else:
used_norm = norm
used_norm = _resolve_continuous_norm(num, cmap_params)
rgba[is_num] = cmap_params.cmap(used_norm(num[is_num]))
color_mask = (~is_num) & series.notna().to_numpy()
if color_mask.any():
Expand DownExpand Up@@ -754,7 +761,8 @@ def _map_color_seg(
color_vector = color_vector.to_numpy()
# normalize only the not nan values, else the whole array would contain only nan values
normed_color_vector = color_vector.copy().astype(float)
normed_color_vector[~np.isnan(normed_color_vector)] = cmap_params.norm(
used_norm = _resolve_continuous_norm(normed_color_vector, cmap_params)
normed_color_vector[~np.isnan(normed_color_vector)] = used_norm(
normed_color_vector[~np.isnan(normed_color_vector)]
)
cols = cmap_params.cmap(normed_color_vector)
Expand All@@ -779,7 +787,8 @@ def _map_color_seg(
assert all(_is_color_like(c) for c in color_vector), "Not all values are color-like."
cols = colors.to_rgba_array(color_vector)
else:
cols = cmap_params.cmap(cmap_params.norm(color_vector))
used_norm = _resolve_continuous_norm(color_vector, cmap_params)
cols = cmap_params.cmap(used_norm(color_vector))

if seg_erosionpx is not None:
val_im[val_im == erosion(val_im, footprint_rectangle((seg_erosionpx, seg_erosionpx)))] = 0
Expand DownExpand Up@@ -813,7 +822,7 @@ def _map_color_seg(
normed = ov.copy().astype(float)
finite = ~np.isnan(normed)
if finite.any():
normed[finite] = cmap_params.norm(normed[finite])
normed[finite] = _resolve_continuous_norm(ov, cmap_params)(normed[finite])
outline_cols = cmap_params.cmap(normed)
outline_val_im = map_array(seg, cell_id, cell_id)
if seg_erosionpx is not None:
Expand Down
3 changes: 1 addition & 2 deletions src/spatialdata_plot/pl/_datashader.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,6 @@

from __future__ import annotations

from copy import copy
from typing import Any, Literal

import dask
Expand DownExpand Up@@ -588,7 +587,7 @@ def _render_ds_outline_by_column(
)
# Apply the user-provided norm (vmin/vmax) the same way the fill path does so
# an explicit Normalize takes effect for the outline cmap.
norm = copy(cmap_params.norm)
norm = cmap_params.fresh_norm()
agg_outline, color_span = _apply_ds_norm(agg_outline, norm)
shaded = ds.tf.shade(
agg_outline,
Expand Down
29 changes: 5 additions & 24 deletions src/spatialdata_plot/pl/_geometry.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,11 +13,12 @@
from geopandas import GeoDataFrame
from matplotlib import colors
from matplotlib.collections import PatchCollection
from matplotlib.colors import ColorConverter, Normalize
from matplotlib.colors import ColorConverter
from scipy.spatial import ConvexHull
from shapely.errors import GEOSException

from spatialdata_plot._logging import logger
from spatialdata_plot.pl._color import _resolve_continuous_norm
from spatialdata_plot.pl.render_params import ShapesRenderParams
from spatialdata_plot.pl.utils import _extract_scalar_value

Expand DownExpand Up@@ -167,7 +168,6 @@ def _get_collection_shape(
shapes: list[GeoDataFrame],
c: Any,
s: float,
norm: Any,
render_params: ShapesRenderParams,
fill_alpha: None | float = None,
outline_alpha: None | float = None,
Expand DownExpand Up@@ -215,23 +215,11 @@ def _as_rgba_array(x: Any) -> np.ndarray:
elif c_arr.ndim == 1 and len(c_arr) == len(shapes) and np.issubdtype(c_arr.dtype, np.number):
finite_mask = np.isfinite(c_arr)

# Select or build a normalization that ignores NaNs for scaling
if isinstance(norm, Normalize):
used_norm: Normalize = norm
else:
if finite_mask.any():
vmin = float(np.nanmin(c_arr[finite_mask]))
vmax = float(np.nanmax(c_arr[finite_mask]))
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax:
vmin, vmax = 0.0, 1.0
else:
vmin, vmax = 0.0, 1.0
used_norm = colors.Normalize(vmin=vmin, vmax=vmax, clip=False)

# Map finite values through cmap(norm(.)); NaNs get na_color
# Map finite values through cmap(norm(.)); NaNs get na_color.
fill_c = np.empty((len(c_arr), 4), dtype=float)
fill_c[:] = na_rgba
if finite_mask.any():
used_norm = _resolve_continuous_norm(c_arr, render_params.cmap_params)
fill_c[finite_mask] = cmap(used_norm(c_arr[finite_mask]))

elif c_arr.ndim == 1 and len(c_arr) == len(shapes) and c_arr.dtype == object:
Expand All@@ -246,14 +234,7 @@ def _as_rgba_array(x: Any) -> np.ndarray:

# numeric entries via cmap(norm)
if is_num.any():
if isinstance(norm, Normalize):
used_norm = norm
else:
vmin = float(np.nanmin(num[is_num])) if is_num.any() else 0.0
vmax = float(np.nanmax(num[is_num])) if is_num.any() else 1.0
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax:
vmin, vmax = 0.0, 1.0
used_norm = colors.Normalize(vmin=vmin, vmax=vmax, clip=False)
used_norm = _resolve_continuous_norm(num, render_params.cmap_params)
fill_c[is_num] = cmap(used_norm(num[is_num]))

# non-numeric, non-NaN entries as explicit colors
Expand Down
57 changes: 17 additions & 40 deletions src/spatialdata_plot/pl/render.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,10 +38,10 @@
_color_vector_to_rgba,
_get_colors_for_categorical_obs,
_get_linear_colormap,
_make_continuous_mappable,
_map_color_seg,
_maybe_set_colors,
_prepare_cmap_norm,
_resolve_continuous_norm,
_set_color_source_vec,
)
from spatialdata_plot.pl._datashader import (
Expand DownExpand Up@@ -77,6 +77,7 @@
PointsRenderParams,
ShapesRenderParams,
_DsReduction,
colormap_with_alpha,
)
from spatialdata_plot.pl.utils import (
_decorate_axs,
Expand DownExpand Up@@ -515,21 +516,17 @@ def _append_outline_colorbar(
) -> None:
"""Append a `ColorbarSpec` for a continuous outline column.

No-op when ``outline_color_vector`` has no finite values. Honors user-supplied
`vmin`/`vmax` on ``cmap_params.norm``; falls back to data range. Mirrors the
`vmin == vmax` ±0.5 expansion used by the fill colorbar.
No-op when ``outline_color_vector`` has no finite values; derives the bar from the same resolved
norm the outline pixels use.
"""
arr = pd.to_numeric(pd.Series(np.asarray(outline_color_vector)), errors="coerce").to_numpy()
finite = np.isfinite(arr)
if not finite.any():
if not np.isfinite(arr).any():
return
norm = cmap_params.norm
vmin = norm.vmin if norm.vmin is not None else float(np.nanmin(arr[finite]))
vmax = norm.vmax if norm.vmax is not None else float(np.nanmax(arr[finite]))
used_norm = _resolve_continuous_norm(outline_color_vector, cmap_params)
colorbar_requests.append(
ColorbarSpec(
ax=ax,
mappable=_make_continuous_mappable(vmin, vmax, cmap_params.cmap),
mappable=ScalarMappable(norm=used_norm, cmap=cmap_params.cmap),
params=colorbar_params,
label=outline_col,
alpha=alpha,
Expand DownExpand Up@@ -747,7 +744,7 @@ def _render_shapes(

color_vector = _maybe_apply_transfunc(color_source_vector, color_vector, render_params.transfunc)

norm = copy(render_params.cmap_params.norm)
norm = render_params.cmap_params.fresh_norm()

if len(color_vector) == 0:
color_vector = [render_params.cmap_params.na_color.get_hex_with_alpha()]
Expand DownExpand Up@@ -968,7 +965,6 @@ def _render_shapes(
render_params=render_params,
rasterized=sc_settings._vector_friendly,
cmap=None,
norm=None,
fill_alpha=0.0,
outline_alpha=render_params.outline_alpha[0],
outline_color=outline_rgba,
Expand All@@ -987,7 +983,6 @@ def _render_shapes(
render_params=render_params,
rasterized=sc_settings._vector_friendly,
cmap=None,
norm=None,
fill_alpha=0.0,
outline_alpha=render_params.outline_alpha[0],
outline_color=render_params.outline_params.outer_outline_color.get_hex(),
Expand All@@ -1008,7 +1003,6 @@ def _render_shapes(
render_params=render_params,
rasterized=sc_settings._vector_friendly,
cmap=None,
norm=None,
fill_alpha=0.0,
outline_alpha=render_params.outline_alpha[1],
outline_color=render_params.outline_params.inner_outline_color.get_hex(),
Expand All@@ -1030,7 +1024,6 @@ def _render_shapes(
render_params=render_params,
rasterized=sc_settings._vector_friendly,
cmap=render_params.cmap_params.cmap,
norm=norm,
fill_alpha=render_params.fill_alpha,
outline_alpha=0.0,
zorder=render_params.zorder,
Expand All@@ -1043,25 +1036,9 @@ def _render_shapes(
path.vertices = trans.transform(path.vertices)

if not values_are_categorical:
# Respect explicit vmin/vmax; otherwise derive from finite numeric values, falling back to [0, 1] if unavailable
vmin = render_params.cmap_params.norm.vmin
vmax = render_params.cmap_params.norm.vmax
if vmin is None or vmax is None:
numeric_values = pd.to_numeric(np.asarray(color_vector), errors="coerce")
finite_mask = np.isfinite(numeric_values)
if finite_mask.any():
data_min = float(np.nanmin(numeric_values[finite_mask]))
data_max = float(np.nanmax(numeric_values[finite_mask]))
if vmin is None:
vmin = data_min
if vmax is None:
vmax = data_max
else:
if vmin is None:
vmin = 0.0
if vmax is None:
vmax = 1.0
_cax.set_clim(vmin=vmin, vmax=vmax)
# Colorbar range from the same resolved norm the fill pixels use.
used_norm = _resolve_continuous_norm(color_vector, render_params.cmap_params)
_cax.set_clim(vmin=used_norm.vmin, vmax=used_norm.vmax)

_add_legend_and_colorbar(
ax=ax,
Expand DownExpand Up@@ -1511,7 +1488,7 @@ def _render_points(

trans, trans_data = _prepare_transformation(sdata.points[element], coordinate_system, ax)

norm = copy(render_params.cmap_params.norm)
norm = render_params.cmap_params.fresh_norm()

method = render_params.method

Expand DownExpand Up@@ -1972,9 +1949,8 @@ def _render_images(
else render_params.cmap_params.cmap
)

# Overwrite alpha in cmap: https://stackoverflow.com/a/10127675
cmap._init()
cmap._lut[:, -1] = render_params.alpha
# Bake a uniform alpha into a fresh cmap (no shared-cmap mutation).
cmap = colormap_with_alpha(cmap, render_params.alpha, render_params.cmap_params.na_color.get_hex_with_alpha())

# norm needs to be passed directly to ax.imshow(). If we normalize before, that method would always clip.
_ax_show_and_transform(
Expand DownExpand Up@@ -2432,7 +2408,7 @@ def _render_labels(
y=xy[:, 1],
color_vector=point_color_vector,
color_source_vector=point_color_source_vector,
norm=copy(render_params.cmap_params.norm), # ax.scatter autoscales in place; don't mutate the shared norm
norm=render_params.cmap_params.fresh_norm(), # ax.scatter autoscales in place; don't mutate the shared norm
na_color=na_color,
adata=table if table_name is not None else None,
col_for_color=col_for_color,
Expand DownExpand Up@@ -2465,11 +2441,12 @@ def _draw_labels(
outline_color_source_vector=outline_color_source_vector if seg_boundaries else None,
)

# labels is pre-baked RGB; cmap/norm only drive the colorbar, so feed the same resolved norm.
cax = ax.imshow(
labels,
rasterized=True,
cmap=None if categorical else render_params.cmap_params.cmap,
norm=None if categorical else render_params.cmap_params.norm,
norm=None if categorical else _resolve_continuous_norm(color_vector, render_params.cmap_params),
alpha=alpha,
origin="lower",
zorder=render_params.zorder,
Expand Down
28 changes: 27 additions & 1 deletion src/spatialdata_plot/pl/render_params.py
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
from __future__ import annotations

from collections.abc import Callable, Mapping, Sequence
from copy import copy
from dataclasses import dataclass, field
from typing import Any, Literal

import numpy as np
from matplotlib.axes import Axes
from matplotlib.cm import ScalarMappable
from matplotlib.colors import Colormap, ListedColormap, Normalize, rgb2hex, to_hex
from matplotlib.colors import Colormap, ListedColormap, Normalize, rgb2hex, to_hex, to_rgba
from matplotlib.figure import Figure

_FontWeight = Literal["light", "normal", "medium", "semibold", "bold", "heavy", "black"]
Expand DownExpand Up@@ -148,6 +149,23 @@ def is_fully_transparent(self) -> bool:
return self.alpha == "00"


def colormap_with_alpha(cmap: Colormap, alpha: float, na_color: str) -> Colormap:
"""Return ``cmap`` rebuilt with a uniform ``alpha`` and ``na_color`` as the bad/NaN color.

Resampling at ``linspace(0, 1, N)`` is lossless (matplotlib quantizes ``__call__`` into ``N`` bins).
"""
lut = cmap(np.linspace(0, 1, cmap.N))
lut[:, -1] = alpha
new = ListedColormap(lut, name=cmap.name)
# Apply alpha to under/over too, matching the old ``_lut[:, -1] = alpha`` (which hit every row).
new.set_extremes(
bad=[*to_rgba(na_color)[:3], alpha],
under=[*cmap.get_under()[:3], alpha],
over=[*cmap.get_over()[:3], alpha],
)
return new


@dataclass
class CmapParams:
"""Cmap params."""
Expand All@@ -157,6 +175,14 @@ class CmapParams:
na_color: Color
cmap_is_default: bool = True

def fresh_norm(self) -> Normalize:
"""Return a copy of ``norm`` safe to apply/autoscale without mutating the shared one.

``Normalize.__call__`` autoscales ``vmin``/``vmax`` in place when unset, which would leak one
element's data range into later elements that reuse the same ``CmapParams``.
"""
return copy(self.norm)


@dataclass
class FigParams:
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 30 additions & 21 deletions src/spatialdata_plot/pl/_color.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -109,6 +109,29 @@ def _make_continuous_mappable(vmin: float, vmax: float, cmap: Any) -> ScalarMapp
return ScalarMappable(norm=Normalize(vmin=vmin, vmax=vmax), cmap=cmap)


def _resolve_continuous_norm(values: Any, cmap_params: CmapParams) -> Normalize:
"""Resolve a concrete ``Normalize`` for continuous coloring.

Honor explicit ``norm`` vmin/vmax, else the finite-value data range of ``values``, else
``[0, 1]``. Shared by the pixel and colorbar sites so both derive the same range. A degenerate
``vmin == vmax`` is left as-is (matplotlib expands it downstream), not reset to ``[0, 1]``.
"""
base = cmap_params.norm
vmin, vmax = base.vmin, base.vmax
if vmin is None or vmax is None:
arr = np.asarray(values)
if not np.issubdtype(arr.dtype, np.number):
arr = pd.to_numeric(arr.ravel(), errors="coerce")
finite = np.isfinite(arr)
data_min = float(np.nanmin(arr[finite])) if finite.any() else 0.0
data_max = float(np.nanmax(arr[finite])) if finite.any() else 1.0
if vmin is None:
vmin = data_min
if vmax is None:
vmax = data_max
return Normalize(vmin=vmin, vmax=vmax, clip=base.clip)


def _apply_mask_to_outline_vectors(
outline_color_vector: Any,
outline_color_source_vector: pd.Series | None,
Expand DownExpand Up@@ -189,15 +212,7 @@ def _color_vector_to_rgba(
if np.issubdtype(arr.dtype, np.number):
finite_mask = np.isfinite(arr)
if finite_mask.any():
norm = cmap_params.norm
if norm.vmin is None or norm.vmax is None:
vmin = float(np.nanmin(arr[finite_mask]))
vmax = float(np.nanmax(arr[finite_mask]))
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax:
vmin, vmax = 0.0, 1.0
used_norm = Normalize(vmin=vmin, vmax=vmax, clip=False)
else:
used_norm = norm
used_norm = _resolve_continuous_norm(arr, cmap_params)
rgba[finite_mask] = cmap_params.cmap(used_norm(arr[finite_mask]))
return rgba

Expand All@@ -206,15 +221,7 @@ def _color_vector_to_rgba(
num = pd.to_numeric(series, errors="coerce").to_numpy()
is_num = np.isfinite(num)
if is_num.any():
norm = cmap_params.norm
if norm.vmin is None or norm.vmax is None:
vmin = float(np.nanmin(num[is_num]))
vmax = float(np.nanmax(num[is_num]))
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax:
vmin, vmax = 0.0, 1.0
used_norm = Normalize(vmin=vmin, vmax=vmax, clip=False)
else:
used_norm = norm
used_norm = _resolve_continuous_norm(num, cmap_params)
rgba[is_num] = cmap_params.cmap(used_norm(num[is_num]))
color_mask = (~is_num) & series.notna().to_numpy()
if color_mask.any():
Expand DownExpand Up@@ -754,7 +761,8 @@ def _map_color_seg(
color_vector = color_vector.to_numpy()
# normalize only the not nan values, else the whole array would contain only nan values
normed_color_vector = color_vector.copy().astype(float)
normed_color_vector[~np.isnan(normed_color_vector)] = cmap_params.norm(
used_norm = _resolve_continuous_norm(normed_color_vector, cmap_params)
normed_color_vector[~np.isnan(normed_color_vector)] = used_norm(
normed_color_vector[~np.isnan(normed_color_vector)]
)
cols = cmap_params.cmap(normed_color_vector)
Expand All@@ -779,7 +787,8 @@ def _map_color_seg(
assert all(_is_color_like(c) for c in color_vector), "Not all values are color-like."
cols = colors.to_rgba_array(color_vector)
else:
cols = cmap_params.cmap(cmap_params.norm(color_vector))
used_norm = _resolve_continuous_norm(color_vector, cmap_params)
cols = cmap_params.cmap(used_norm(color_vector))

if seg_erosionpx is not None:
val_im[val_im == erosion(val_im, footprint_rectangle((seg_erosionpx, seg_erosionpx)))] = 0
Expand DownExpand Up@@ -813,7 +822,7 @@ def _map_color_seg(
normed = ov.copy().astype(float)
finite = ~np.isnan(normed)
if finite.any():
normed[finite] = cmap_params.norm(normed[finite])
normed[finite] = _resolve_continuous_norm(ov, cmap_params)(normed[finite])
outline_cols = cmap_params.cmap(normed)
outline_val_im = map_array(seg, cell_id, cell_id)
if seg_erosionpx is not None:
Expand Down
3 changes: 1 addition & 2 deletions src/spatialdata_plot/pl/_datashader.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,6 @@

from __future__ import annotations

from copy import copy
from typing import Any, Literal

import dask
Expand DownExpand Up@@ -588,7 +587,7 @@ def _render_ds_outline_by_column(
)
# Apply the user-provided norm (vmin/vmax) the same way the fill path does so
# an explicit Normalize takes effect for the outline cmap.
norm = copy(cmap_params.norm)
norm = cmap_params.fresh_norm()
agg_outline, color_span = _apply_ds_norm(agg_outline, norm)
shaded = ds.tf.shade(
agg_outline,
Expand Down
29 changes: 5 additions & 24 deletions src/spatialdata_plot/pl/_geometry.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,11 +13,12 @@
from geopandas import GeoDataFrame
from matplotlib import colors
from matplotlib.collections import PatchCollection
from matplotlib.colors import ColorConverter, Normalize
from matplotlib.colors import ColorConverter
from scipy.spatial import ConvexHull
from shapely.errors import GEOSException

from spatialdata_plot._logging import logger
from spatialdata_plot.pl._color import _resolve_continuous_norm
from spatialdata_plot.pl.render_params import ShapesRenderParams
from spatialdata_plot.pl.utils import _extract_scalar_value

Expand DownExpand Up@@ -167,7 +168,6 @@ def _get_collection_shape(
shapes: list[GeoDataFrame],
c: Any,
s: float,
norm: Any,
render_params: ShapesRenderParams,
fill_alpha: None | float = None,
outline_alpha: None | float = None,
Expand DownExpand Up@@ -215,23 +215,11 @@ def _as_rgba_array(x: Any) -> np.ndarray:
elif c_arr.ndim == 1 and len(c_arr) == len(shapes) and np.issubdtype(c_arr.dtype, np.number):
finite_mask = np.isfinite(c_arr)

# Select or build a normalization that ignores NaNs for scaling
if isinstance(norm, Normalize):
used_norm: Normalize = norm
else:
if finite_mask.any():
vmin = float(np.nanmin(c_arr[finite_mask]))
vmax = float(np.nanmax(c_arr[finite_mask]))
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax:
vmin, vmax = 0.0, 1.0
else:
vmin, vmax = 0.0, 1.0
used_norm = colors.Normalize(vmin=vmin, vmax=vmax, clip=False)

# Map finite values through cmap(norm(.)); NaNs get na_color
# Map finite values through cmap(norm(.)); NaNs get na_color.
fill_c = np.empty((len(c_arr), 4), dtype=float)
fill_c[:] = na_rgba
if finite_mask.any():
used_norm = _resolve_continuous_norm(c_arr, render_params.cmap_params)
fill_c[finite_mask] = cmap(used_norm(c_arr[finite_mask]))

elif c_arr.ndim == 1 and len(c_arr) == len(shapes) and c_arr.dtype == object:
Expand All@@ -246,14 +234,7 @@ def _as_rgba_array(x: Any) -> np.ndarray:

# numeric entries via cmap(norm)
if is_num.any():
if isinstance(norm, Normalize):
used_norm = norm
else:
vmin = float(np.nanmin(num[is_num])) if is_num.any() else 0.0
vmax = float(np.nanmax(num[is_num])) if is_num.any() else 1.0
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax:
vmin, vmax = 0.0, 1.0
used_norm = colors.Normalize(vmin=vmin, vmax=vmax, clip=False)
used_norm = _resolve_continuous_norm(num, render_params.cmap_params)
fill_c[is_num] = cmap(used_norm(num[is_num]))

# non-numeric, non-NaN entries as explicit colors
Expand Down
57 changes: 17 additions & 40 deletions src/spatialdata_plot/pl/render.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,10 +38,10 @@
_color_vector_to_rgba,
_get_colors_for_categorical_obs,
_get_linear_colormap,
_make_continuous_mappable,
_map_color_seg,
_maybe_set_colors,
_prepare_cmap_norm,
_resolve_continuous_norm,
_set_color_source_vec,
)
from spatialdata_plot.pl._datashader import (
Expand DownExpand Up@@ -77,6 +77,7 @@
PointsRenderParams,
ShapesRenderParams,
_DsReduction,
colormap_with_alpha,
)
from spatialdata_plot.pl.utils import (
_decorate_axs,
Expand DownExpand Up@@ -515,21 +516,17 @@ def _append_outline_colorbar(
) -> None:
"""Append a `ColorbarSpec` for a continuous outline column.

No-op when ``outline_color_vector`` has no finite values. Honors user-supplied
`vmin`/`vmax` on ``cmap_params.norm``; falls back to data range. Mirrors the
`vmin == vmax` ±0.5 expansion used by the fill colorbar.
No-op when ``outline_color_vector`` has no finite values; derives the bar from the same resolved
norm the outline pixels use.
"""
arr = pd.to_numeric(pd.Series(np.asarray(outline_color_vector)), errors="coerce").to_numpy()
finite = np.isfinite(arr)
if not finite.any():
if not np.isfinite(arr).any():
return
norm = cmap_params.norm
vmin = norm.vmin if norm.vmin is not None else float(np.nanmin(arr[finite]))
vmax = norm.vmax if norm.vmax is not None else float(np.nanmax(arr[finite]))
used_norm = _resolve_continuous_norm(outline_color_vector, cmap_params)
colorbar_requests.append(
ColorbarSpec(
ax=ax,
mappable=_make_continuous_mappable(vmin, vmax, cmap_params.cmap),
mappable=ScalarMappable(norm=used_norm, cmap=cmap_params.cmap),
params=colorbar_params,
label=outline_col,
alpha=alpha,
Expand DownExpand Up@@ -747,7 +744,7 @@ def _render_shapes(

color_vector = _maybe_apply_transfunc(color_source_vector, color_vector, render_params.transfunc)

norm = copy(render_params.cmap_params.norm)
norm = render_params.cmap_params.fresh_norm()

if len(color_vector) == 0:
color_vector = [render_params.cmap_params.na_color.get_hex_with_alpha()]
Expand DownExpand Up@@ -968,7 +965,6 @@ def _render_shapes(
render_params=render_params,
rasterized=sc_settings._vector_friendly,
cmap=None,
norm=None,
fill_alpha=0.0,
outline_alpha=render_params.outline_alpha[0],
outline_color=outline_rgba,
Expand All@@ -987,7 +983,6 @@ def _render_shapes(
render_params=render_params,
rasterized=sc_settings._vector_friendly,
cmap=None,
norm=None,
fill_alpha=0.0,
outline_alpha=render_params.outline_alpha[0],
outline_color=render_params.outline_params.outer_outline_color.get_hex(),
Expand All@@ -1008,7 +1003,6 @@ def _render_shapes(
render_params=render_params,
rasterized=sc_settings._vector_friendly,
cmap=None,
norm=None,
fill_alpha=0.0,
outline_alpha=render_params.outline_alpha[1],
outline_color=render_params.outline_params.inner_outline_color.get_hex(),
Expand All@@ -1030,7 +1024,6 @@ def _render_shapes(
render_params=render_params,
rasterized=sc_settings._vector_friendly,
cmap=render_params.cmap_params.cmap,
norm=norm,
fill_alpha=render_params.fill_alpha,
outline_alpha=0.0,
zorder=render_params.zorder,
Expand All@@ -1043,25 +1036,9 @@ def _render_shapes(
path.vertices = trans.transform(path.vertices)

if not values_are_categorical:
# Respect explicit vmin/vmax; otherwise derive from finite numeric values, falling back to [0, 1] if unavailable
vmin = render_params.cmap_params.norm.vmin
vmax = render_params.cmap_params.norm.vmax
if vmin is None or vmax is None:
numeric_values = pd.to_numeric(np.asarray(color_vector), errors="coerce")
finite_mask = np.isfinite(numeric_values)
if finite_mask.any():
data_min = float(np.nanmin(numeric_values[finite_mask]))
data_max = float(np.nanmax(numeric_values[finite_mask]))
if vmin is None:
vmin = data_min
if vmax is None:
vmax = data_max
else:
if vmin is None:
vmin = 0.0
if vmax is None:
vmax = 1.0
_cax.set_clim(vmin=vmin, vmax=vmax)
# Colorbar range from the same resolved norm the fill pixels use.
used_norm = _resolve_continuous_norm(color_vector, render_params.cmap_params)
_cax.set_clim(vmin=used_norm.vmin, vmax=used_norm.vmax)

_add_legend_and_colorbar(
ax=ax,
Expand DownExpand Up@@ -1511,7 +1488,7 @@ def _render_points(

trans, trans_data = _prepare_transformation(sdata.points[element], coordinate_system, ax)

norm = copy(render_params.cmap_params.norm)
norm = render_params.cmap_params.fresh_norm()

method = render_params.method

Expand DownExpand Up@@ -1972,9 +1949,8 @@ def _render_images(
else render_params.cmap_params.cmap
)

# Overwrite alpha in cmap: https://stackoverflow.com/a/10127675
cmap._init()
cmap._lut[:, -1] = render_params.alpha
# Bake a uniform alpha into a fresh cmap (no shared-cmap mutation).
cmap = colormap_with_alpha(cmap, render_params.alpha, render_params.cmap_params.na_color.get_hex_with_alpha())

# norm needs to be passed directly to ax.imshow(). If we normalize before, that method would always clip.
_ax_show_and_transform(
Expand DownExpand Up@@ -2432,7 +2408,7 @@ def _render_labels(
y=xy[:, 1],
color_vector=point_color_vector,
color_source_vector=point_color_source_vector,
norm=copy(render_params.cmap_params.norm), # ax.scatter autoscales in place; don't mutate the shared norm
norm=render_params.cmap_params.fresh_norm(), # ax.scatter autoscales in place; don't mutate the shared norm
na_color=na_color,
adata=table if table_name is not None else None,
col_for_color=col_for_color,
Expand DownExpand Up@@ -2465,11 +2441,12 @@ def _draw_labels(
outline_color_source_vector=outline_color_source_vector if seg_boundaries else None,
)

# labels is pre-baked RGB; cmap/norm only drive the colorbar, so feed the same resolved norm.
cax = ax.imshow(
labels,
rasterized=True,
cmap=None if categorical else render_params.cmap_params.cmap,
norm=None if categorical else render_params.cmap_params.norm,
norm=None if categorical else _resolve_continuous_norm(color_vector, render_params.cmap_params),
alpha=alpha,
origin="lower",
zorder=render_params.zorder,
Expand Down
28 changes: 27 additions & 1 deletion src/spatialdata_plot/pl/render_params.py
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
from __future__ import annotations

from collections.abc import Callable, Mapping, Sequence
from copy import copy
from dataclasses import dataclass, field
from typing import Any, Literal

import numpy as np
from matplotlib.axes import Axes
from matplotlib.cm import ScalarMappable
from matplotlib.colors import Colormap, ListedColormap, Normalize, rgb2hex, to_hex
from matplotlib.colors import Colormap, ListedColormap, Normalize, rgb2hex, to_hex, to_rgba
from matplotlib.figure import Figure

_FontWeight = Literal["light", "normal", "medium", "semibold", "bold", "heavy", "black"]
Expand DownExpand Up@@ -148,6 +149,23 @@ def is_fully_transparent(self) -> bool:
return self.alpha == "00"


def colormap_with_alpha(cmap: Colormap, alpha: float, na_color: str) -> Colormap:
"""Return ``cmap`` rebuilt with a uniform ``alpha`` and ``na_color`` as the bad/NaN color.

Resampling at ``linspace(0, 1, N)`` is lossless (matplotlib quantizes ``__call__`` into ``N`` bins).
"""
lut = cmap(np.linspace(0, 1, cmap.N))
lut[:, -1] = alpha
new = ListedColormap(lut, name=cmap.name)
# Apply alpha to under/over too, matching the old ``_lut[:, -1] = alpha`` (which hit every row).
new.set_extremes(
bad=[*to_rgba(na_color)[:3], alpha],
under=[*cmap.get_under()[:3], alpha],
over=[*cmap.get_over()[:3], alpha],
)
return new


@dataclass
class CmapParams:
"""Cmap params."""
Expand All@@ -157,6 +175,14 @@ class CmapParams:
na_color: Color
cmap_is_default: bool = True

def fresh_norm(self) -> Normalize:
"""Return a copy of ``norm`` safe to apply/autoscale without mutating the shared one.

``Normalize.__call__`` autoscales ``vmin``/``vmax`` in place when unset, which would leak one
element's data range into later elements that reuse the same ``CmapParams``.
"""
return copy(self.norm)


@dataclass
class FigParams:
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 30 additions & 21 deletions src/spatialdata_plot/pl/_color.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -109,6 +109,29 @@ def _make_continuous_mappable(vmin: float, vmax: float, cmap: Any) -> ScalarMapp
return ScalarMappable(norm=Normalize(vmin=vmin, vmax=vmax), cmap=cmap)


def _resolve_continuous_norm(values: Any, cmap_params: CmapParams) -> Normalize:
"""Resolve a concrete ``Normalize`` for continuous coloring.

Honor explicit ``norm`` vmin/vmax, else the finite-value data range of ``values``, else
``[0, 1]``. Shared by the pixel and colorbar sites so both derive the same range. A degenerate
``vmin == vmax`` is left as-is (matplotlib expands it downstream), not reset to ``[0, 1]``.
"""
base = cmap_params.norm
vmin, vmax = base.vmin, base.vmax
if vmin is None or vmax is None:
arr = np.asarray(values)
if not np.issubdtype(arr.dtype, np.number):
arr = pd.to_numeric(arr.ravel(), errors="coerce")
finite = np.isfinite(arr)
data_min = float(np.nanmin(arr[finite])) if finite.any() else 0.0
data_max = float(np.nanmax(arr[finite])) if finite.any() else 1.0
if vmin is None:
vmin = data_min
if vmax is None:
vmax = data_max
return Normalize(vmin=vmin, vmax=vmax, clip=base.clip)


def _apply_mask_to_outline_vectors(
outline_color_vector: Any,
outline_color_source_vector: pd.Series | None,
Expand DownExpand Up@@ -189,15 +212,7 @@ def _color_vector_to_rgba(
if np.issubdtype(arr.dtype, np.number):
finite_mask = np.isfinite(arr)
if finite_mask.any():
norm = cmap_params.norm
if norm.vmin is None or norm.vmax is None:
vmin = float(np.nanmin(arr[finite_mask]))
vmax = float(np.nanmax(arr[finite_mask]))
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax:
vmin, vmax = 0.0, 1.0
used_norm = Normalize(vmin=vmin, vmax=vmax, clip=False)
else:
used_norm = norm
used_norm = _resolve_continuous_norm(arr, cmap_params)
rgba[finite_mask] = cmap_params.cmap(used_norm(arr[finite_mask]))
return rgba

Expand All@@ -206,15 +221,7 @@ def _color_vector_to_rgba(
num = pd.to_numeric(series, errors="coerce").to_numpy()
is_num = np.isfinite(num)
if is_num.any():
norm = cmap_params.norm
if norm.vmin is None or norm.vmax is None:
vmin = float(np.nanmin(num[is_num]))
vmax = float(np.nanmax(num[is_num]))
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax:
vmin, vmax = 0.0, 1.0
used_norm = Normalize(vmin=vmin, vmax=vmax, clip=False)
else:
used_norm = norm
used_norm = _resolve_continuous_norm(num, cmap_params)
rgba[is_num] = cmap_params.cmap(used_norm(num[is_num]))
color_mask = (~is_num) & series.notna().to_numpy()
if color_mask.any():
Expand DownExpand Up@@ -754,7 +761,8 @@ def _map_color_seg(
color_vector = color_vector.to_numpy()
# normalize only the not nan values, else the whole array would contain only nan values
normed_color_vector = color_vector.copy().astype(float)
normed_color_vector[~np.isnan(normed_color_vector)] = cmap_params.norm(
used_norm = _resolve_continuous_norm(normed_color_vector, cmap_params)
normed_color_vector[~np.isnan(normed_color_vector)] = used_norm(
normed_color_vector[~np.isnan(normed_color_vector)]
)
cols = cmap_params.cmap(normed_color_vector)
Expand All@@ -779,7 +787,8 @@ def _map_color_seg(
assert all(_is_color_like(c) for c in color_vector), "Not all values are color-like."
cols = colors.to_rgba_array(color_vector)
else:
cols = cmap_params.cmap(cmap_params.norm(color_vector))
used_norm = _resolve_continuous_norm(color_vector, cmap_params)
cols = cmap_params.cmap(used_norm(color_vector))

if seg_erosionpx is not None:
val_im[val_im == erosion(val_im, footprint_rectangle((seg_erosionpx, seg_erosionpx)))] = 0
Expand DownExpand Up@@ -813,7 +822,7 @@ def _map_color_seg(
normed = ov.copy().astype(float)
finite = ~np.isnan(normed)
if finite.any():
normed[finite] = cmap_params.norm(normed[finite])
normed[finite] = _resolve_continuous_norm(ov, cmap_params)(normed[finite])
outline_cols = cmap_params.cmap(normed)
outline_val_im = map_array(seg, cell_id, cell_id)
if seg_erosionpx is not None:
Expand Down
3 changes: 1 addition & 2 deletions src/spatialdata_plot/pl/_datashader.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,6 @@

from __future__ import annotations

from copy import copy
from typing import Any, Literal

import dask
Expand DownExpand Up@@ -588,7 +587,7 @@ def _render_ds_outline_by_column(
)
# Apply the user-provided norm (vmin/vmax) the same way the fill path does so
# an explicit Normalize takes effect for the outline cmap.
norm = copy(cmap_params.norm)
norm = cmap_params.fresh_norm()
agg_outline, color_span = _apply_ds_norm(agg_outline, norm)
shaded = ds.tf.shade(
agg_outline,
Expand Down
29 changes: 5 additions & 24 deletions src/spatialdata_plot/pl/_geometry.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,11 +13,12 @@
from geopandas import GeoDataFrame
from matplotlib import colors
from matplotlib.collections import PatchCollection
from matplotlib.colors import ColorConverter, Normalize
from matplotlib.colors import ColorConverter
from scipy.spatial import ConvexHull
from shapely.errors import GEOSException

from spatialdata_plot._logging import logger
from spatialdata_plot.pl._color import _resolve_continuous_norm
from spatialdata_plot.pl.render_params import ShapesRenderParams
from spatialdata_plot.pl.utils import _extract_scalar_value

Expand DownExpand Up@@ -167,7 +168,6 @@ def _get_collection_shape(
shapes: list[GeoDataFrame],
c: Any,
s: float,
norm: Any,
render_params: ShapesRenderParams,
fill_alpha: None | float = None,
outline_alpha: None | float = None,
Expand DownExpand Up@@ -215,23 +215,11 @@ def _as_rgba_array(x: Any) -> np.ndarray:
elif c_arr.ndim == 1 and len(c_arr) == len(shapes) and np.issubdtype(c_arr.dtype, np.number):
finite_mask = np.isfinite(c_arr)

# Select or build a normalization that ignores NaNs for scaling
if isinstance(norm, Normalize):
used_norm: Normalize = norm
else:
if finite_mask.any():
vmin = float(np.nanmin(c_arr[finite_mask]))
vmax = float(np.nanmax(c_arr[finite_mask]))
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax:
vmin, vmax = 0.0, 1.0
else:
vmin, vmax = 0.0, 1.0
used_norm = colors.Normalize(vmin=vmin, vmax=vmax, clip=False)

# Map finite values through cmap(norm(.)); NaNs get na_color
# Map finite values through cmap(norm(.)); NaNs get na_color.
fill_c = np.empty((len(c_arr), 4), dtype=float)
fill_c[:] = na_rgba
if finite_mask.any():
used_norm = _resolve_continuous_norm(c_arr, render_params.cmap_params)
fill_c[finite_mask] = cmap(used_norm(c_arr[finite_mask]))

elif c_arr.ndim == 1 and len(c_arr) == len(shapes) and c_arr.dtype == object:
Expand All@@ -246,14 +234,7 @@ def _as_rgba_array(x: Any) -> np.ndarray:

# numeric entries via cmap(norm)
if is_num.any():
if isinstance(norm, Normalize):
used_norm = norm
else:
vmin = float(np.nanmin(num[is_num])) if is_num.any() else 0.0
vmax = float(np.nanmax(num[is_num])) if is_num.any() else 1.0
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax:
vmin, vmax = 0.0, 1.0
used_norm = colors.Normalize(vmin=vmin, vmax=vmax, clip=False)
used_norm = _resolve_continuous_norm(num, render_params.cmap_params)
fill_c[is_num] = cmap(used_norm(num[is_num]))

# non-numeric, non-NaN entries as explicit colors
Expand Down
57 changes: 17 additions & 40 deletions src/spatialdata_plot/pl/render.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,10 +38,10 @@
_color_vector_to_rgba,
_get_colors_for_categorical_obs,
_get_linear_colormap,
_make_continuous_mappable,
_map_color_seg,
_maybe_set_colors,
_prepare_cmap_norm,
_resolve_continuous_norm,
_set_color_source_vec,
)
from spatialdata_plot.pl._datashader import (
Expand DownExpand Up@@ -77,6 +77,7 @@
PointsRenderParams,
ShapesRenderParams,
_DsReduction,
colormap_with_alpha,
)
from spatialdata_plot.pl.utils import (
_decorate_axs,
Expand DownExpand Up@@ -515,21 +516,17 @@ def _append_outline_colorbar(
) -> None:
"""Append a `ColorbarSpec` for a continuous outline column.

No-op when ``outline_color_vector`` has no finite values. Honors user-supplied
`vmin`/`vmax` on ``cmap_params.norm``; falls back to data range. Mirrors the
`vmin == vmax` ±0.5 expansion used by the fill colorbar.
No-op when ``outline_color_vector`` has no finite values; derives the bar from the same resolved
norm the outline pixels use.
"""
arr = pd.to_numeric(pd.Series(np.asarray(outline_color_vector)), errors="coerce").to_numpy()
finite = np.isfinite(arr)
if not finite.any():
if not np.isfinite(arr).any():
return
norm = cmap_params.norm
vmin = norm.vmin if norm.vmin is not None else float(np.nanmin(arr[finite]))
vmax = norm.vmax if norm.vmax is not None else float(np.nanmax(arr[finite]))
used_norm = _resolve_continuous_norm(outline_color_vector, cmap_params)
colorbar_requests.append(
ColorbarSpec(
ax=ax,
mappable=_make_continuous_mappable(vmin, vmax, cmap_params.cmap),
mappable=ScalarMappable(norm=used_norm, cmap=cmap_params.cmap),
params=colorbar_params,
label=outline_col,
alpha=alpha,
Expand DownExpand Up@@ -747,7 +744,7 @@ def _render_shapes(

color_vector = _maybe_apply_transfunc(color_source_vector, color_vector, render_params.transfunc)

norm = copy(render_params.cmap_params.norm)
norm = render_params.cmap_params.fresh_norm()

if len(color_vector) == 0:
color_vector = [render_params.cmap_params.na_color.get_hex_with_alpha()]
Expand DownExpand Up@@ -968,7 +965,6 @@ def _render_shapes(
render_params=render_params,
rasterized=sc_settings._vector_friendly,
cmap=None,
norm=None,
fill_alpha=0.0,
outline_alpha=render_params.outline_alpha[0],
outline_color=outline_rgba,
Expand All@@ -987,7 +983,6 @@ def _render_shapes(
render_params=render_params,
rasterized=sc_settings._vector_friendly,
cmap=None,
norm=None,
fill_alpha=0.0,
outline_alpha=render_params.outline_alpha[0],
outline_color=render_params.outline_params.outer_outline_color.get_hex(),
Expand All@@ -1008,7 +1003,6 @@ def _render_shapes(
render_params=render_params,
rasterized=sc_settings._vector_friendly,
cmap=None,
norm=None,
fill_alpha=0.0,
outline_alpha=render_params.outline_alpha[1],
outline_color=render_params.outline_params.inner_outline_color.get_hex(),
Expand All@@ -1030,7 +1024,6 @@ def _render_shapes(
render_params=render_params,
rasterized=sc_settings._vector_friendly,
cmap=render_params.cmap_params.cmap,
norm=norm,
fill_alpha=render_params.fill_alpha,
outline_alpha=0.0,
zorder=render_params.zorder,
Expand All@@ -1043,25 +1036,9 @@ def _render_shapes(
path.vertices = trans.transform(path.vertices)

if not values_are_categorical:
# Respect explicit vmin/vmax; otherwise derive from finite numeric values, falling back to [0, 1] if unavailable
vmin = render_params.cmap_params.norm.vmin
vmax = render_params.cmap_params.norm.vmax
if vmin is None or vmax is None:
numeric_values = pd.to_numeric(np.asarray(color_vector), errors="coerce")
finite_mask = np.isfinite(numeric_values)
if finite_mask.any():
data_min = float(np.nanmin(numeric_values[finite_mask]))
data_max = float(np.nanmax(numeric_values[finite_mask]))
if vmin is None:
vmin = data_min
if vmax is None:
vmax = data_max
else:
if vmin is None:
vmin = 0.0
if vmax is None:
vmax = 1.0
_cax.set_clim(vmin=vmin, vmax=vmax)
# Colorbar range from the same resolved norm the fill pixels use.
used_norm = _resolve_continuous_norm(color_vector, render_params.cmap_params)
_cax.set_clim(vmin=used_norm.vmin, vmax=used_norm.vmax)

_add_legend_and_colorbar(
ax=ax,
Expand DownExpand Up@@ -1511,7 +1488,7 @@ def _render_points(

trans, trans_data = _prepare_transformation(sdata.points[element], coordinate_system, ax)

norm = copy(render_params.cmap_params.norm)
norm = render_params.cmap_params.fresh_norm()

method = render_params.method

Expand DownExpand Up@@ -1972,9 +1949,8 @@ def _render_images(
else render_params.cmap_params.cmap
)

# Overwrite alpha in cmap: https://stackoverflow.com/a/10127675
cmap._init()
cmap._lut[:, -1] = render_params.alpha
# Bake a uniform alpha into a fresh cmap (no shared-cmap mutation).
cmap = colormap_with_alpha(cmap, render_params.alpha, render_params.cmap_params.na_color.get_hex_with_alpha())

# norm needs to be passed directly to ax.imshow(). If we normalize before, that method would always clip.
_ax_show_and_transform(
Expand DownExpand Up@@ -2432,7 +2408,7 @@ def _render_labels(
y=xy[:, 1],
color_vector=point_color_vector,
color_source_vector=point_color_source_vector,
norm=copy(render_params.cmap_params.norm), # ax.scatter autoscales in place; don't mutate the shared norm
norm=render_params.cmap_params.fresh_norm(), # ax.scatter autoscales in place; don't mutate the shared norm
na_color=na_color,
adata=table if table_name is not None else None,
col_for_color=col_for_color,
Expand DownExpand Up@@ -2465,11 +2441,12 @@ def _draw_labels(
outline_color_source_vector=outline_color_source_vector if seg_boundaries else None,
)

# labels is pre-baked RGB; cmap/norm only drive the colorbar, so feed the same resolved norm.
cax = ax.imshow(
labels,
rasterized=True,
cmap=None if categorical else render_params.cmap_params.cmap,
norm=None if categorical else render_params.cmap_params.norm,
norm=None if categorical else _resolve_continuous_norm(color_vector, render_params.cmap_params),
alpha=alpha,
origin="lower",
zorder=render_params.zorder,
Expand Down
28 changes: 27 additions & 1 deletion src/spatialdata_plot/pl/render_params.py
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
from __future__ import annotations

from collections.abc import Callable, Mapping, Sequence
from copy import copy
from dataclasses import dataclass, field
from typing import Any, Literal

import numpy as np
from matplotlib.axes import Axes
from matplotlib.cm import ScalarMappable
from matplotlib.colors import Colormap, ListedColormap, Normalize, rgb2hex, to_hex
from matplotlib.colors import Colormap, ListedColormap, Normalize, rgb2hex, to_hex, to_rgba
from matplotlib.figure import Figure

_FontWeight = Literal["light", "normal", "medium", "semibold", "bold", "heavy", "black"]
Expand DownExpand Up@@ -148,6 +149,23 @@ def is_fully_transparent(self) -> bool:
return self.alpha == "00"


def colormap_with_alpha(cmap: Colormap, alpha: float, na_color: str) -> Colormap:
"""Return ``cmap`` rebuilt with a uniform ``alpha`` and ``na_color`` as the bad/NaN color.

Resampling at ``linspace(0, 1, N)`` is lossless (matplotlib quantizes ``__call__`` into ``N`` bins).
"""
lut = cmap(np.linspace(0, 1, cmap.N))
lut[:, -1] = alpha
new = ListedColormap(lut, name=cmap.name)
# Apply alpha to under/over too, matching the old ``_lut[:, -1] = alpha`` (which hit every row).
new.set_extremes(
bad=[*to_rgba(na_color)[:3], alpha],
under=[*cmap.get_under()[:3], alpha],
over=[*cmap.get_over()[:3], alpha],
)
return new


@dataclass
class CmapParams:
"""Cmap params."""
Expand All@@ -157,6 +175,14 @@ class CmapParams:
na_color: Color
cmap_is_default: bool = True

def fresh_norm(self) -> Normalize:
"""Return a copy of ``norm`` safe to apply/autoscale without mutating the shared one.

``Normalize.__call__`` autoscales ``vmin``/``vmax`` in place when unset, which would leak one
element's data range into later elements that reuse the same ``CmapParams``.
"""
return copy(self.norm)


@dataclass
class FigParams:
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 30 additions & 21 deletions src/spatialdata_plot/pl/_color.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -109,6 +109,29 @@ def _make_continuous_mappable(vmin: float, vmax: float, cmap: Any) -> ScalarMapp
return ScalarMappable(norm=Normalize(vmin=vmin, vmax=vmax), cmap=cmap)


def _resolve_continuous_norm(values: Any, cmap_params: CmapParams) -> Normalize:
"""Resolve a concrete ``Normalize`` for continuous coloring.

Honor explicit ``norm`` vmin/vmax, else the finite-value data range of ``values``, else
``[0, 1]``. Shared by the pixel and colorbar sites so both derive the same range. A degenerate
``vmin == vmax`` is left as-is (matplotlib expands it downstream), not reset to ``[0, 1]``.
"""
base = cmap_params.norm
vmin, vmax = base.vmin, base.vmax
if vmin is None or vmax is None:
arr = np.asarray(values)
if not np.issubdtype(arr.dtype, np.number):
arr = pd.to_numeric(arr.ravel(), errors="coerce")
finite = np.isfinite(arr)
data_min = float(np.nanmin(arr[finite])) if finite.any() else 0.0
data_max = float(np.nanmax(arr[finite])) if finite.any() else 1.0
if vmin is None:
vmin = data_min
if vmax is None:
vmax = data_max
return Normalize(vmin=vmin, vmax=vmax, clip=base.clip)


def _apply_mask_to_outline_vectors(
outline_color_vector: Any,
outline_color_source_vector: pd.Series | None,
Expand DownExpand Up@@ -189,15 +212,7 @@ def _color_vector_to_rgba(
if np.issubdtype(arr.dtype, np.number):
finite_mask = np.isfinite(arr)
if finite_mask.any():
norm = cmap_params.norm
if norm.vmin is None or norm.vmax is None:
vmin = float(np.nanmin(arr[finite_mask]))
vmax = float(np.nanmax(arr[finite_mask]))
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax:
vmin, vmax = 0.0, 1.0
used_norm = Normalize(vmin=vmin, vmax=vmax, clip=False)
else:
used_norm = norm
used_norm = _resolve_continuous_norm(arr, cmap_params)
rgba[finite_mask] = cmap_params.cmap(used_norm(arr[finite_mask]))
return rgba

Expand All@@ -206,15 +221,7 @@ def _color_vector_to_rgba(
num = pd.to_numeric(series, errors="coerce").to_numpy()
is_num = np.isfinite(num)
if is_num.any():
norm = cmap_params.norm
if norm.vmin is None or norm.vmax is None:
vmin = float(np.nanmin(num[is_num]))
vmax = float(np.nanmax(num[is_num]))
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax:
vmin, vmax = 0.0, 1.0
used_norm = Normalize(vmin=vmin, vmax=vmax, clip=False)
else:
used_norm = norm
used_norm = _resolve_continuous_norm(num, cmap_params)
rgba[is_num] = cmap_params.cmap(used_norm(num[is_num]))
color_mask = (~is_num) & series.notna().to_numpy()
if color_mask.any():
Expand DownExpand Up@@ -754,7 +761,8 @@ def _map_color_seg(
color_vector = color_vector.to_numpy()
# normalize only the not nan values, else the whole array would contain only nan values
normed_color_vector = color_vector.copy().astype(float)
normed_color_vector[~np.isnan(normed_color_vector)] = cmap_params.norm(
used_norm = _resolve_continuous_norm(normed_color_vector, cmap_params)
normed_color_vector[~np.isnan(normed_color_vector)] = used_norm(
normed_color_vector[~np.isnan(normed_color_vector)]
)
cols = cmap_params.cmap(normed_color_vector)
Expand All@@ -779,7 +787,8 @@ def _map_color_seg(
assert all(_is_color_like(c) for c in color_vector), "Not all values are color-like."
cols = colors.to_rgba_array(color_vector)
else:
cols = cmap_params.cmap(cmap_params.norm(color_vector))
used_norm = _resolve_continuous_norm(color_vector, cmap_params)
cols = cmap_params.cmap(used_norm(color_vector))

if seg_erosionpx is not None:
val_im[val_im == erosion(val_im, footprint_rectangle((seg_erosionpx, seg_erosionpx)))] = 0
Expand DownExpand Up@@ -813,7 +822,7 @@ def _map_color_seg(
normed = ov.copy().astype(float)
finite = ~np.isnan(normed)
if finite.any():
normed[finite] = cmap_params.norm(normed[finite])
normed[finite] = _resolve_continuous_norm(ov, cmap_params)(normed[finite])
outline_cols = cmap_params.cmap(normed)
outline_val_im = map_array(seg, cell_id, cell_id)
if seg_erosionpx is not None:
Expand Down
3 changes: 1 addition & 2 deletions src/spatialdata_plot/pl/_datashader.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,6 @@

from __future__ import annotations

from copy import copy
from typing import Any, Literal

import dask
Expand DownExpand Up@@ -588,7 +587,7 @@ def _render_ds_outline_by_column(
)
# Apply the user-provided norm (vmin/vmax) the same way the fill path does so
# an explicit Normalize takes effect for the outline cmap.
norm = copy(cmap_params.norm)
norm = cmap_params.fresh_norm()
agg_outline, color_span = _apply_ds_norm(agg_outline, norm)
shaded = ds.tf.shade(
agg_outline,
Expand Down
29 changes: 5 additions & 24 deletions src/spatialdata_plot/pl/_geometry.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,11 +13,12 @@
from geopandas import GeoDataFrame
from matplotlib import colors
from matplotlib.collections import PatchCollection
from matplotlib.colors import ColorConverter, Normalize
from matplotlib.colors import ColorConverter
from scipy.spatial import ConvexHull
from shapely.errors import GEOSException

from spatialdata_plot._logging import logger
from spatialdata_plot.pl._color import _resolve_continuous_norm
from spatialdata_plot.pl.render_params import ShapesRenderParams
from spatialdata_plot.pl.utils import _extract_scalar_value

Expand DownExpand Up@@ -167,7 +168,6 @@ def _get_collection_shape(
shapes: list[GeoDataFrame],
c: Any,
s: float,
norm: Any,
render_params: ShapesRenderParams,
fill_alpha: None | float = None,
outline_alpha: None | float = None,
Expand DownExpand Up@@ -215,23 +215,11 @@ def _as_rgba_array(x: Any) -> np.ndarray:
elif c_arr.ndim == 1 and len(c_arr) == len(shapes) and np.issubdtype(c_arr.dtype, np.number):
finite_mask = np.isfinite(c_arr)

# Select or build a normalization that ignores NaNs for scaling
if isinstance(norm, Normalize):
used_norm: Normalize = norm
else:
if finite_mask.any():
vmin = float(np.nanmin(c_arr[finite_mask]))
vmax = float(np.nanmax(c_arr[finite_mask]))
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax:
vmin, vmax = 0.0, 1.0
else:
vmin, vmax = 0.0, 1.0
used_norm = colors.Normalize(vmin=vmin, vmax=vmax, clip=False)

# Map finite values through cmap(norm(.)); NaNs get na_color
# Map finite values through cmap(norm(.)); NaNs get na_color.
fill_c = np.empty((len(c_arr), 4), dtype=float)
fill_c[:] = na_rgba
if finite_mask.any():
used_norm = _resolve_continuous_norm(c_arr, render_params.cmap_params)
fill_c[finite_mask] = cmap(used_norm(c_arr[finite_mask]))

elif c_arr.ndim == 1 and len(c_arr) == len(shapes) and c_arr.dtype == object:
Expand All@@ -246,14 +234,7 @@ def _as_rgba_array(x: Any) -> np.ndarray:

# numeric entries via cmap(norm)
if is_num.any():
if isinstance(norm, Normalize):
used_norm = norm
else:
vmin = float(np.nanmin(num[is_num])) if is_num.any() else 0.0
vmax = float(np.nanmax(num[is_num])) if is_num.any() else 1.0
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax:
vmin, vmax = 0.0, 1.0
used_norm = colors.Normalize(vmin=vmin, vmax=vmax, clip=False)
used_norm = _resolve_continuous_norm(num, render_params.cmap_params)
fill_c[is_num] = cmap(used_norm(num[is_num]))

# non-numeric, non-NaN entries as explicit colors
Expand Down
57 changes: 17 additions & 40 deletions src/spatialdata_plot/pl/render.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,10 +38,10 @@
_color_vector_to_rgba,
_get_colors_for_categorical_obs,
_get_linear_colormap,
_make_continuous_mappable,
_map_color_seg,
_maybe_set_colors,
_prepare_cmap_norm,
_resolve_continuous_norm,
_set_color_source_vec,
)
from spatialdata_plot.pl._datashader import (
Expand DownExpand Up@@ -77,6 +77,7 @@
PointsRenderParams,
ShapesRenderParams,
_DsReduction,
colormap_with_alpha,
)
from spatialdata_plot.pl.utils import (
_decorate_axs,
Expand DownExpand Up@@ -515,21 +516,17 @@ def _append_outline_colorbar(
) -> None:
"""Append a `ColorbarSpec` for a continuous outline column.

No-op when ``outline_color_vector`` has no finite values. Honors user-supplied
`vmin`/`vmax` on ``cmap_params.norm``; falls back to data range. Mirrors the
`vmin == vmax` ±0.5 expansion used by the fill colorbar.
No-op when ``outline_color_vector`` has no finite values; derives the bar from the same resolved
norm the outline pixels use.
"""
arr = pd.to_numeric(pd.Series(np.asarray(outline_color_vector)), errors="coerce").to_numpy()
finite = np.isfinite(arr)
if not finite.any():
if not np.isfinite(arr).any():
return
norm = cmap_params.norm
vmin = norm.vmin if norm.vmin is not None else float(np.nanmin(arr[finite]))
vmax = norm.vmax if norm.vmax is not None else float(np.nanmax(arr[finite]))
used_norm = _resolve_continuous_norm(outline_color_vector, cmap_params)
colorbar_requests.append(
ColorbarSpec(
ax=ax,
mappable=_make_continuous_mappable(vmin, vmax, cmap_params.cmap),
mappable=ScalarMappable(norm=used_norm, cmap=cmap_params.cmap),
params=colorbar_params,
label=outline_col,
alpha=alpha,
Expand DownExpand Up@@ -747,7 +744,7 @@ def _render_shapes(

color_vector = _maybe_apply_transfunc(color_source_vector, color_vector, render_params.transfunc)

norm = copy(render_params.cmap_params.norm)
norm = render_params.cmap_params.fresh_norm()

if len(color_vector) == 0:
color_vector = [render_params.cmap_params.na_color.get_hex_with_alpha()]
Expand DownExpand Up@@ -968,7 +965,6 @@ def _render_shapes(
render_params=render_params,
rasterized=sc_settings._vector_friendly,
cmap=None,
norm=None,
fill_alpha=0.0,
outline_alpha=render_params.outline_alpha[0],
outline_color=outline_rgba,
Expand All@@ -987,7 +983,6 @@ def _render_shapes(
render_params=render_params,
rasterized=sc_settings._vector_friendly,
cmap=None,
norm=None,
fill_alpha=0.0,
outline_alpha=render_params.outline_alpha[0],
outline_color=render_params.outline_params.outer_outline_color.get_hex(),
Expand All@@ -1008,7 +1003,6 @@ def _render_shapes(
render_params=render_params,
rasterized=sc_settings._vector_friendly,
cmap=None,
norm=None,
fill_alpha=0.0,
outline_alpha=render_params.outline_alpha[1],
outline_color=render_params.outline_params.inner_outline_color.get_hex(),
Expand All@@ -1030,7 +1024,6 @@ def _render_shapes(
render_params=render_params,
rasterized=sc_settings._vector_friendly,
cmap=render_params.cmap_params.cmap,
norm=norm,
fill_alpha=render_params.fill_alpha,
outline_alpha=0.0,
zorder=render_params.zorder,
Expand All@@ -1043,25 +1036,9 @@ def _render_shapes(
path.vertices = trans.transform(path.vertices)

if not values_are_categorical:
# Respect explicit vmin/vmax; otherwise derive from finite numeric values, falling back to [0, 1] if unavailable
vmin = render_params.cmap_params.norm.vmin
vmax = render_params.cmap_params.norm.vmax
if vmin is None or vmax is None:
numeric_values = pd.to_numeric(np.asarray(color_vector), errors="coerce")
finite_mask = np.isfinite(numeric_values)
if finite_mask.any():
data_min = float(np.nanmin(numeric_values[finite_mask]))
data_max = float(np.nanmax(numeric_values[finite_mask]))
if vmin is None:
vmin = data_min
if vmax is None:
vmax = data_max
else:
if vmin is None:
vmin = 0.0
if vmax is None:
vmax = 1.0
_cax.set_clim(vmin=vmin, vmax=vmax)
# Colorbar range from the same resolved norm the fill pixels use.
used_norm = _resolve_continuous_norm(color_vector, render_params.cmap_params)
_cax.set_clim(vmin=used_norm.vmin, vmax=used_norm.vmax)

_add_legend_and_colorbar(
ax=ax,
Expand DownExpand Up@@ -1511,7 +1488,7 @@ def _render_points(

trans, trans_data = _prepare_transformation(sdata.points[element], coordinate_system, ax)

norm = copy(render_params.cmap_params.norm)
norm = render_params.cmap_params.fresh_norm()

method = render_params.method

Expand DownExpand Up@@ -1972,9 +1949,8 @@ def _render_images(
else render_params.cmap_params.cmap
)

# Overwrite alpha in cmap: https://stackoverflow.com/a/10127675
cmap._init()
cmap._lut[:, -1] = render_params.alpha
# Bake a uniform alpha into a fresh cmap (no shared-cmap mutation).
cmap = colormap_with_alpha(cmap, render_params.alpha, render_params.cmap_params.na_color.get_hex_with_alpha())

# norm needs to be passed directly to ax.imshow(). If we normalize before, that method would always clip.
_ax_show_and_transform(
Expand DownExpand Up@@ -2432,7 +2408,7 @@ def _render_labels(
y=xy[:, 1],
color_vector=point_color_vector,
color_source_vector=point_color_source_vector,
norm=copy(render_params.cmap_params.norm), # ax.scatter autoscales in place; don't mutate the shared norm
norm=render_params.cmap_params.fresh_norm(), # ax.scatter autoscales in place; don't mutate the shared norm
na_color=na_color,
adata=table if table_name is not None else None,
col_for_color=col_for_color,
Expand DownExpand Up@@ -2465,11 +2441,12 @@ def _draw_labels(
outline_color_source_vector=outline_color_source_vector if seg_boundaries else None,
)

# labels is pre-baked RGB; cmap/norm only drive the colorbar, so feed the same resolved norm.
cax = ax.imshow(
labels,
rasterized=True,
cmap=None if categorical else render_params.cmap_params.cmap,
norm=None if categorical else render_params.cmap_params.norm,
norm=None if categorical else _resolve_continuous_norm(color_vector, render_params.cmap_params),
alpha=alpha,
origin="lower",
zorder=render_params.zorder,
Expand Down
28 changes: 27 additions & 1 deletion src/spatialdata_plot/pl/render_params.py
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
from __future__ import annotations

from collections.abc import Callable, Mapping, Sequence
from copy import copy
from dataclasses import dataclass, field
from typing import Any, Literal

import numpy as np
from matplotlib.axes import Axes
from matplotlib.cm import ScalarMappable
from matplotlib.colors import Colormap, ListedColormap, Normalize, rgb2hex, to_hex
from matplotlib.colors import Colormap, ListedColormap, Normalize, rgb2hex, to_hex, to_rgba
from matplotlib.figure import Figure

_FontWeight = Literal["light", "normal", "medium", "semibold", "bold", "heavy", "black"]
Expand DownExpand Up@@ -148,6 +149,23 @@ def is_fully_transparent(self) -> bool:
return self.alpha == "00"


def colormap_with_alpha(cmap: Colormap, alpha: float, na_color: str) -> Colormap:
"""Return ``cmap`` rebuilt with a uniform ``alpha`` and ``na_color`` as the bad/NaN color.

Resampling at ``linspace(0, 1, N)`` is lossless (matplotlib quantizes ``__call__`` into ``N`` bins).
"""
lut = cmap(np.linspace(0, 1, cmap.N))
lut[:, -1] = alpha
new = ListedColormap(lut, name=cmap.name)
# Apply alpha to under/over too, matching the old ``_lut[:, -1] = alpha`` (which hit every row).
new.set_extremes(
bad=[*to_rgba(na_color)[:3], alpha],
under=[*cmap.get_under()[:3], alpha],
over=[*cmap.get_over()[:3], alpha],
)
return new


@dataclass
class CmapParams:
"""Cmap params."""
Expand All@@ -157,6 +175,14 @@ class CmapParams:
na_color: Color
cmap_is_default: bool = True

def fresh_norm(self) -> Normalize:
"""Return a copy of ``norm`` safe to apply/autoscale without mutating the shared one.

``Normalize.__call__`` autoscales ``vmin``/``vmax`` in place when unset, which would leak one
element's data range into later elements that reuse the same ``CmapParams``.
"""
return copy(self.norm)


@dataclass
class FigParams:
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 30 additions & 21 deletions src/spatialdata_plot/pl/_color.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -109,6 +109,29 @@ def _make_continuous_mappable(vmin: float, vmax: float, cmap: Any) -> ScalarMapp
return ScalarMappable(norm=Normalize(vmin=vmin, vmax=vmax), cmap=cmap)


def _resolve_continuous_norm(values: Any, cmap_params: CmapParams) -> Normalize:
"""Resolve a concrete ``Normalize`` for continuous coloring.

Honor explicit ``norm`` vmin/vmax, else the finite-value data range of ``values``, else
``[0, 1]``. Shared by the pixel and colorbar sites so both derive the same range. A degenerate
``vmin == vmax`` is left as-is (matplotlib expands it downstream), not reset to ``[0, 1]``.
"""
base = cmap_params.norm
vmin, vmax = base.vmin, base.vmax
if vmin is None or vmax is None:
arr = np.asarray(values)
if not np.issubdtype(arr.dtype, np.number):
arr = pd.to_numeric(arr.ravel(), errors="coerce")
finite = np.isfinite(arr)
data_min = float(np.nanmin(arr[finite])) if finite.any() else 0.0
data_max = float(np.nanmax(arr[finite])) if finite.any() else 1.0
if vmin is None:
vmin = data_min
if vmax is None:
vmax = data_max
return Normalize(vmin=vmin, vmax=vmax, clip=base.clip)


def _apply_mask_to_outline_vectors(
outline_color_vector: Any,
outline_color_source_vector: pd.Series | None,
Expand DownExpand Up@@ -189,15 +212,7 @@ def _color_vector_to_rgba(
if np.issubdtype(arr.dtype, np.number):
finite_mask = np.isfinite(arr)
if finite_mask.any():
norm = cmap_params.norm
if norm.vmin is None or norm.vmax is None:
vmin = float(np.nanmin(arr[finite_mask]))
vmax = float(np.nanmax(arr[finite_mask]))
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax:
vmin, vmax = 0.0, 1.0
used_norm = Normalize(vmin=vmin, vmax=vmax, clip=False)
else:
used_norm = norm
used_norm = _resolve_continuous_norm(arr, cmap_params)
rgba[finite_mask] = cmap_params.cmap(used_norm(arr[finite_mask]))
return rgba

Expand All@@ -206,15 +221,7 @@ def _color_vector_to_rgba(
num = pd.to_numeric(series, errors="coerce").to_numpy()
is_num = np.isfinite(num)
if is_num.any():
norm = cmap_params.norm
if norm.vmin is None or norm.vmax is None:
vmin = float(np.nanmin(num[is_num]))
vmax = float(np.nanmax(num[is_num]))
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax:
vmin, vmax = 0.0, 1.0
used_norm = Normalize(vmin=vmin, vmax=vmax, clip=False)
else:
used_norm = norm
used_norm = _resolve_continuous_norm(num, cmap_params)
rgba[is_num] = cmap_params.cmap(used_norm(num[is_num]))
color_mask = (~is_num) & series.notna().to_numpy()
if color_mask.any():
Expand DownExpand Up@@ -754,7 +761,8 @@ def _map_color_seg(
color_vector = color_vector.to_numpy()
# normalize only the not nan values, else the whole array would contain only nan values
normed_color_vector = color_vector.copy().astype(float)
normed_color_vector[~np.isnan(normed_color_vector)] = cmap_params.norm(
used_norm = _resolve_continuous_norm(normed_color_vector, cmap_params)
normed_color_vector[~np.isnan(normed_color_vector)] = used_norm(
normed_color_vector[~np.isnan(normed_color_vector)]
)
cols = cmap_params.cmap(normed_color_vector)
Expand All@@ -779,7 +787,8 @@ def _map_color_seg(
assert all(_is_color_like(c) for c in color_vector), "Not all values are color-like."
cols = colors.to_rgba_array(color_vector)
else:
cols = cmap_params.cmap(cmap_params.norm(color_vector))
used_norm = _resolve_continuous_norm(color_vector, cmap_params)
cols = cmap_params.cmap(used_norm(color_vector))

if seg_erosionpx is not None:
val_im[val_im == erosion(val_im, footprint_rectangle((seg_erosionpx, seg_erosionpx)))] = 0
Expand DownExpand Up@@ -813,7 +822,7 @@ def _map_color_seg(
normed = ov.copy().astype(float)
finite = ~np.isnan(normed)
if finite.any():
normed[finite] = cmap_params.norm(normed[finite])
normed[finite] = _resolve_continuous_norm(ov, cmap_params)(normed[finite])
outline_cols = cmap_params.cmap(normed)
outline_val_im = map_array(seg, cell_id, cell_id)
if seg_erosionpx is not None:
Expand Down
3 changes: 1 addition & 2 deletions src/spatialdata_plot/pl/_datashader.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,6 @@

from __future__ import annotations

from copy import copy
from typing import Any, Literal

import dask
Expand DownExpand Up@@ -588,7 +587,7 @@ def _render_ds_outline_by_column(
)
# Apply the user-provided norm (vmin/vmax) the same way the fill path does so
# an explicit Normalize takes effect for the outline cmap.
norm = copy(cmap_params.norm)
norm = cmap_params.fresh_norm()
agg_outline, color_span = _apply_ds_norm(agg_outline, norm)
shaded = ds.tf.shade(
agg_outline,
Expand Down
29 changes: 5 additions & 24 deletions src/spatialdata_plot/pl/_geometry.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,11 +13,12 @@
from geopandas import GeoDataFrame
from matplotlib import colors
from matplotlib.collections import PatchCollection
from matplotlib.colors import ColorConverter, Normalize
from matplotlib.colors import ColorConverter
from scipy.spatial import ConvexHull
from shapely.errors import GEOSException

from spatialdata_plot._logging import logger
from spatialdata_plot.pl._color import _resolve_continuous_norm
from spatialdata_plot.pl.render_params import ShapesRenderParams
from spatialdata_plot.pl.utils import _extract_scalar_value

Expand DownExpand Up@@ -167,7 +168,6 @@ def _get_collection_shape(
shapes: list[GeoDataFrame],
c: Any,
s: float,
norm: Any,
render_params: ShapesRenderParams,
fill_alpha: None | float = None,
outline_alpha: None | float = None,
Expand DownExpand Up@@ -215,23 +215,11 @@ def _as_rgba_array(x: Any) -> np.ndarray:
elif c_arr.ndim == 1 and len(c_arr) == len(shapes) and np.issubdtype(c_arr.dtype, np.number):
finite_mask = np.isfinite(c_arr)

# Select or build a normalization that ignores NaNs for scaling
if isinstance(norm, Normalize):
used_norm: Normalize = norm
else:
if finite_mask.any():
vmin = float(np.nanmin(c_arr[finite_mask]))
vmax = float(np.nanmax(c_arr[finite_mask]))
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax:
vmin, vmax = 0.0, 1.0
else:
vmin, vmax = 0.0, 1.0
used_norm = colors.Normalize(vmin=vmin, vmax=vmax, clip=False)

# Map finite values through cmap(norm(.)); NaNs get na_color
# Map finite values through cmap(norm(.)); NaNs get na_color.
fill_c = np.empty((len(c_arr), 4), dtype=float)
fill_c[:] = na_rgba
if finite_mask.any():
used_norm = _resolve_continuous_norm(c_arr, render_params.cmap_params)
fill_c[finite_mask] = cmap(used_norm(c_arr[finite_mask]))

elif c_arr.ndim == 1 and len(c_arr) == len(shapes) and c_arr.dtype == object:
Expand All@@ -246,14 +234,7 @@ def _as_rgba_array(x: Any) -> np.ndarray:

# numeric entries via cmap(norm)
if is_num.any():
if isinstance(norm, Normalize):
used_norm = norm
else:
vmin = float(np.nanmin(num[is_num])) if is_num.any() else 0.0
vmax = float(np.nanmax(num[is_num])) if is_num.any() else 1.0
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax:
vmin, vmax = 0.0, 1.0
used_norm = colors.Normalize(vmin=vmin, vmax=vmax, clip=False)
used_norm = _resolve_continuous_norm(num, render_params.cmap_params)
fill_c[is_num] = cmap(used_norm(num[is_num]))

# non-numeric, non-NaN entries as explicit colors
Expand Down
57 changes: 17 additions & 40 deletions src/spatialdata_plot/pl/render.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,10 +38,10 @@
_color_vector_to_rgba,
_get_colors_for_categorical_obs,
_get_linear_colormap,
_make_continuous_mappable,
_map_color_seg,
_maybe_set_colors,
_prepare_cmap_norm,
_resolve_continuous_norm,
_set_color_source_vec,
)
from spatialdata_plot.pl._datashader import (
Expand DownExpand Up@@ -77,6 +77,7 @@
PointsRenderParams,
ShapesRenderParams,
_DsReduction,
colormap_with_alpha,
)
from spatialdata_plot.pl.utils import (
_decorate_axs,
Expand DownExpand Up@@ -515,21 +516,17 @@ def _append_outline_colorbar(
) -> None:
"""Append a `ColorbarSpec` for a continuous outline column.

No-op when ``outline_color_vector`` has no finite values. Honors user-supplied
`vmin`/`vmax` on ``cmap_params.norm``; falls back to data range. Mirrors the
`vmin == vmax` ±0.5 expansion used by the fill colorbar.
No-op when ``outline_color_vector`` has no finite values; derives the bar from the same resolved
norm the outline pixels use.
"""
arr = pd.to_numeric(pd.Series(np.asarray(outline_color_vector)), errors="coerce").to_numpy()
finite = np.isfinite(arr)
if not finite.any():
if not np.isfinite(arr).any():
return
norm = cmap_params.norm
vmin = norm.vmin if norm.vmin is not None else float(np.nanmin(arr[finite]))
vmax = norm.vmax if norm.vmax is not None else float(np.nanmax(arr[finite]))
used_norm = _resolve_continuous_norm(outline_color_vector, cmap_params)
colorbar_requests.append(
ColorbarSpec(
ax=ax,
mappable=_make_continuous_mappable(vmin, vmax, cmap_params.cmap),
mappable=ScalarMappable(norm=used_norm, cmap=cmap_params.cmap),
params=colorbar_params,
label=outline_col,
alpha=alpha,
Expand DownExpand Up@@ -747,7 +744,7 @@ def _render_shapes(

color_vector = _maybe_apply_transfunc(color_source_vector, color_vector, render_params.transfunc)

norm = copy(render_params.cmap_params.norm)
norm = render_params.cmap_params.fresh_norm()

if len(color_vector) == 0:
color_vector = [render_params.cmap_params.na_color.get_hex_with_alpha()]
Expand DownExpand Up@@ -968,7 +965,6 @@ def _render_shapes(
render_params=render_params,
rasterized=sc_settings._vector_friendly,
cmap=None,
norm=None,
fill_alpha=0.0,
outline_alpha=render_params.outline_alpha[0],
outline_color=outline_rgba,
Expand All@@ -987,7 +983,6 @@ def _render_shapes(
render_params=render_params,
rasterized=sc_settings._vector_friendly,
cmap=None,
norm=None,
fill_alpha=0.0,
outline_alpha=render_params.outline_alpha[0],
outline_color=render_params.outline_params.outer_outline_color.get_hex(),
Expand All@@ -1008,7 +1003,6 @@ def _render_shapes(
render_params=render_params,
rasterized=sc_settings._vector_friendly,
cmap=None,
norm=None,
fill_alpha=0.0,
outline_alpha=render_params.outline_alpha[1],
outline_color=render_params.outline_params.inner_outline_color.get_hex(),
Expand All@@ -1030,7 +1024,6 @@ def _render_shapes(
render_params=render_params,
rasterized=sc_settings._vector_friendly,
cmap=render_params.cmap_params.cmap,
norm=norm,
fill_alpha=render_params.fill_alpha,
outline_alpha=0.0,
zorder=render_params.zorder,
Expand All@@ -1043,25 +1036,9 @@ def _render_shapes(
path.vertices = trans.transform(path.vertices)

if not values_are_categorical:
# Respect explicit vmin/vmax; otherwise derive from finite numeric values, falling back to [0, 1] if unavailable
vmin = render_params.cmap_params.norm.vmin
vmax = render_params.cmap_params.norm.vmax
if vmin is None or vmax is None:
numeric_values = pd.to_numeric(np.asarray(color_vector), errors="coerce")
finite_mask = np.isfinite(numeric_values)
if finite_mask.any():
data_min = float(np.nanmin(numeric_values[finite_mask]))
data_max = float(np.nanmax(numeric_values[finite_mask]))
if vmin is None:
vmin = data_min
if vmax is None:
vmax = data_max
else:
if vmin is None:
vmin = 0.0
if vmax is None:
vmax = 1.0
_cax.set_clim(vmin=vmin, vmax=vmax)
# Colorbar range from the same resolved norm the fill pixels use.
used_norm = _resolve_continuous_norm(color_vector, render_params.cmap_params)
_cax.set_clim(vmin=used_norm.vmin, vmax=used_norm.vmax)

_add_legend_and_colorbar(
ax=ax,
Expand DownExpand Up@@ -1511,7 +1488,7 @@ def _render_points(

trans, trans_data = _prepare_transformation(sdata.points[element], coordinate_system, ax)

norm = copy(render_params.cmap_params.norm)
norm = render_params.cmap_params.fresh_norm()

method = render_params.method

Expand DownExpand Up@@ -1972,9 +1949,8 @@ def _render_images(
else render_params.cmap_params.cmap
)

# Overwrite alpha in cmap: https://stackoverflow.com/a/10127675
cmap._init()
cmap._lut[:, -1] = render_params.alpha
# Bake a uniform alpha into a fresh cmap (no shared-cmap mutation).
cmap = colormap_with_alpha(cmap, render_params.alpha, render_params.cmap_params.na_color.get_hex_with_alpha())

# norm needs to be passed directly to ax.imshow(). If we normalize before, that method would always clip.
_ax_show_and_transform(
Expand DownExpand Up@@ -2432,7 +2408,7 @@ def _render_labels(
y=xy[:, 1],
color_vector=point_color_vector,
color_source_vector=point_color_source_vector,
norm=copy(render_params.cmap_params.norm), # ax.scatter autoscales in place; don't mutate the shared norm
norm=render_params.cmap_params.fresh_norm(), # ax.scatter autoscales in place; don't mutate the shared norm
na_color=na_color,
adata=table if table_name is not None else None,
col_for_color=col_for_color,
Expand DownExpand Up@@ -2465,11 +2441,12 @@ def _draw_labels(
outline_color_source_vector=outline_color_source_vector if seg_boundaries else None,
)

# labels is pre-baked RGB; cmap/norm only drive the colorbar, so feed the same resolved norm.
cax = ax.imshow(
labels,
rasterized=True,
cmap=None if categorical else render_params.cmap_params.cmap,
norm=None if categorical else render_params.cmap_params.norm,
norm=None if categorical else _resolve_continuous_norm(color_vector, render_params.cmap_params),
alpha=alpha,
origin="lower",
zorder=render_params.zorder,
Expand Down
28 changes: 27 additions & 1 deletion src/spatialdata_plot/pl/render_params.py
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
from __future__ import annotations

from collections.abc import Callable, Mapping, Sequence
from copy import copy
from dataclasses import dataclass, field
from typing import Any, Literal

import numpy as np
from matplotlib.axes import Axes
from matplotlib.cm import ScalarMappable
from matplotlib.colors import Colormap, ListedColormap, Normalize, rgb2hex, to_hex
from matplotlib.colors import Colormap, ListedColormap, Normalize, rgb2hex, to_hex, to_rgba
from matplotlib.figure import Figure

_FontWeight = Literal["light", "normal", "medium", "semibold", "bold", "heavy", "black"]
Expand DownExpand Up@@ -148,6 +149,23 @@ def is_fully_transparent(self) -> bool:
return self.alpha == "00"


def colormap_with_alpha(cmap: Colormap, alpha: float, na_color: str) -> Colormap:
"""Return ``cmap`` rebuilt with a uniform ``alpha`` and ``na_color`` as the bad/NaN color.

Resampling at ``linspace(0, 1, N)`` is lossless (matplotlib quantizes ``__call__`` into ``N`` bins).
"""
lut = cmap(np.linspace(0, 1, cmap.N))
lut[:, -1] = alpha
new = ListedColormap(lut, name=cmap.name)
# Apply alpha to under/over too, matching the old ``_lut[:, -1] = alpha`` (which hit every row).
new.set_extremes(
bad=[*to_rgba(na_color)[:3], alpha],
under=[*cmap.get_under()[:3], alpha],
over=[*cmap.get_over()[:3], alpha],
)
return new


@dataclass
class CmapParams:
"""Cmap params."""
Expand All@@ -157,6 +175,14 @@ class CmapParams:
na_color: Color
cmap_is_default: bool = True

def fresh_norm(self) -> Normalize:
"""Return a copy of ``norm`` safe to apply/autoscale without mutating the shared one.

``Normalize.__call__`` autoscales ``vmin``/``vmax`` in place when unset, which would leak one
element's data range into later elements that reuse the same ``CmapParams``.
"""
return copy(self.norm)


@dataclass
class FigParams:
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 30 additions & 21 deletions src/spatialdata_plot/pl/_color.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -109,6 +109,29 @@ def _make_continuous_mappable(vmin: float, vmax: float, cmap: Any) -> ScalarMapp
return ScalarMappable(norm=Normalize(vmin=vmin, vmax=vmax), cmap=cmap)


def _resolve_continuous_norm(values: Any, cmap_params: CmapParams) -> Normalize:
"""Resolve a concrete ``Normalize`` for continuous coloring.

Honor explicit ``norm`` vmin/vmax, else the finite-value data range of ``values``, else
``[0, 1]``. Shared by the pixel and colorbar sites so both derive the same range. A degenerate
``vmin == vmax`` is left as-is (matplotlib expands it downstream), not reset to ``[0, 1]``.
"""
base = cmap_params.norm
vmin, vmax = base.vmin, base.vmax
if vmin is None or vmax is None:
arr = np.asarray(values)
if not np.issubdtype(arr.dtype, np.number):
arr = pd.to_numeric(arr.ravel(), errors="coerce")
finite = np.isfinite(arr)
data_min = float(np.nanmin(arr[finite])) if finite.any() else 0.0
data_max = float(np.nanmax(arr[finite])) if finite.any() else 1.0
if vmin is None:
vmin = data_min
if vmax is None:
vmax = data_max
return Normalize(vmin=vmin, vmax=vmax, clip=base.clip)


def _apply_mask_to_outline_vectors(
outline_color_vector: Any,
outline_color_source_vector: pd.Series | None,
Expand DownExpand Up@@ -189,15 +212,7 @@ def _color_vector_to_rgba(
if np.issubdtype(arr.dtype, np.number):
finite_mask = np.isfinite(arr)
if finite_mask.any():
norm = cmap_params.norm
if norm.vmin is None or norm.vmax is None:
vmin = float(np.nanmin(arr[finite_mask]))
vmax = float(np.nanmax(arr[finite_mask]))
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax:
vmin, vmax = 0.0, 1.0
used_norm = Normalize(vmin=vmin, vmax=vmax, clip=False)
else:
used_norm = norm
used_norm = _resolve_continuous_norm(arr, cmap_params)
rgba[finite_mask] = cmap_params.cmap(used_norm(arr[finite_mask]))
return rgba

Expand All@@ -206,15 +221,7 @@ def _color_vector_to_rgba(
num = pd.to_numeric(series, errors="coerce").to_numpy()
is_num = np.isfinite(num)
if is_num.any():
norm = cmap_params.norm
if norm.vmin is None or norm.vmax is None:
vmin = float(np.nanmin(num[is_num]))
vmax = float(np.nanmax(num[is_num]))
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax:
vmin, vmax = 0.0, 1.0
used_norm = Normalize(vmin=vmin, vmax=vmax, clip=False)
else:
used_norm = norm
used_norm = _resolve_continuous_norm(num, cmap_params)
rgba[is_num] = cmap_params.cmap(used_norm(num[is_num]))
color_mask = (~is_num) & series.notna().to_numpy()
if color_mask.any():
Expand DownExpand Up@@ -754,7 +761,8 @@ def _map_color_seg(
color_vector = color_vector.to_numpy()
# normalize only the not nan values, else the whole array would contain only nan values
normed_color_vector = color_vector.copy().astype(float)
normed_color_vector[~np.isnan(normed_color_vector)] = cmap_params.norm(
used_norm = _resolve_continuous_norm(normed_color_vector, cmap_params)
normed_color_vector[~np.isnan(normed_color_vector)] = used_norm(
normed_color_vector[~np.isnan(normed_color_vector)]
)
cols = cmap_params.cmap(normed_color_vector)
Expand All@@ -779,7 +787,8 @@ def _map_color_seg(
assert all(_is_color_like(c) for c in color_vector), "Not all values are color-like."
cols = colors.to_rgba_array(color_vector)
else:
cols = cmap_params.cmap(cmap_params.norm(color_vector))
used_norm = _resolve_continuous_norm(color_vector, cmap_params)
cols = cmap_params.cmap(used_norm(color_vector))

if seg_erosionpx is not None:
val_im[val_im == erosion(val_im, footprint_rectangle((seg_erosionpx, seg_erosionpx)))] = 0
Expand DownExpand Up@@ -813,7 +822,7 @@ def _map_color_seg(
normed = ov.copy().astype(float)
finite = ~np.isnan(normed)
if finite.any():
normed[finite] = cmap_params.norm(normed[finite])
normed[finite] = _resolve_continuous_norm(ov, cmap_params)(normed[finite])
outline_cols = cmap_params.cmap(normed)
outline_val_im = map_array(seg, cell_id, cell_id)
if seg_erosionpx is not None:
Expand Down
3 changes: 1 addition & 2 deletions src/spatialdata_plot/pl/_datashader.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,6 @@

from __future__ import annotations

from copy import copy
from typing import Any, Literal

import dask
Expand DownExpand Up@@ -588,7 +587,7 @@ def _render_ds_outline_by_column(
)
# Apply the user-provided norm (vmin/vmax) the same way the fill path does so
# an explicit Normalize takes effect for the outline cmap.
norm = copy(cmap_params.norm)
norm = cmap_params.fresh_norm()
agg_outline, color_span = _apply_ds_norm(agg_outline, norm)
shaded = ds.tf.shade(
agg_outline,
Expand Down
29 changes: 5 additions & 24 deletions src/spatialdata_plot/pl/_geometry.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,11 +13,12 @@
from geopandas import GeoDataFrame
from matplotlib import colors
from matplotlib.collections import PatchCollection
from matplotlib.colors import ColorConverter, Normalize
from matplotlib.colors import ColorConverter
from scipy.spatial import ConvexHull
from shapely.errors import GEOSException

from spatialdata_plot._logging import logger
from spatialdata_plot.pl._color import _resolve_continuous_norm
from spatialdata_plot.pl.render_params import ShapesRenderParams
from spatialdata_plot.pl.utils import _extract_scalar_value

Expand DownExpand Up@@ -167,7 +168,6 @@ def _get_collection_shape(
shapes: list[GeoDataFrame],
c: Any,
s: float,
norm: Any,
render_params: ShapesRenderParams,
fill_alpha: None | float = None,
outline_alpha: None | float = None,
Expand DownExpand Up@@ -215,23 +215,11 @@ def _as_rgba_array(x: Any) -> np.ndarray:
elif c_arr.ndim == 1 and len(c_arr) == len(shapes) and np.issubdtype(c_arr.dtype, np.number):
finite_mask = np.isfinite(c_arr)

# Select or build a normalization that ignores NaNs for scaling
if isinstance(norm, Normalize):
used_norm: Normalize = norm
else:
if finite_mask.any():
vmin = float(np.nanmin(c_arr[finite_mask]))
vmax = float(np.nanmax(c_arr[finite_mask]))
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax:
vmin, vmax = 0.0, 1.0
else:
vmin, vmax = 0.0, 1.0
used_norm = colors.Normalize(vmin=vmin, vmax=vmax, clip=False)

# Map finite values through cmap(norm(.)); NaNs get na_color
# Map finite values through cmap(norm(.)); NaNs get na_color.
fill_c = np.empty((len(c_arr), 4), dtype=float)
fill_c[:] = na_rgba
if finite_mask.any():
used_norm = _resolve_continuous_norm(c_arr, render_params.cmap_params)
fill_c[finite_mask] = cmap(used_norm(c_arr[finite_mask]))

elif c_arr.ndim == 1 and len(c_arr) == len(shapes) and c_arr.dtype == object:
Expand All@@ -246,14 +234,7 @@ def _as_rgba_array(x: Any) -> np.ndarray:

# numeric entries via cmap(norm)
if is_num.any():
if isinstance(norm, Normalize):
used_norm = norm
else:
vmin = float(np.nanmin(num[is_num])) if is_num.any() else 0.0
vmax = float(np.nanmax(num[is_num])) if is_num.any() else 1.0
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax:
vmin, vmax = 0.0, 1.0
used_norm = colors.Normalize(vmin=vmin, vmax=vmax, clip=False)
used_norm = _resolve_continuous_norm(num, render_params.cmap_params)
fill_c[is_num] = cmap(used_norm(num[is_num]))

# non-numeric, non-NaN entries as explicit colors
Expand Down
57 changes: 17 additions & 40 deletions src/spatialdata_plot/pl/render.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,10 +38,10 @@
_color_vector_to_rgba,
_get_colors_for_categorical_obs,
_get_linear_colormap,
_make_continuous_mappable,
_map_color_seg,
_maybe_set_colors,
_prepare_cmap_norm,
_resolve_continuous_norm,
_set_color_source_vec,
)
from spatialdata_plot.pl._datashader import (
Expand DownExpand Up@@ -77,6 +77,7 @@
PointsRenderParams,
ShapesRenderParams,
_DsReduction,
colormap_with_alpha,
)
from spatialdata_plot.pl.utils import (
_decorate_axs,
Expand DownExpand Up@@ -515,21 +516,17 @@ def _append_outline_colorbar(
) -> None:
"""Append a `ColorbarSpec` for a continuous outline column.

No-op when ``outline_color_vector`` has no finite values. Honors user-supplied
`vmin`/`vmax` on ``cmap_params.norm``; falls back to data range. Mirrors the
`vmin == vmax` ±0.5 expansion used by the fill colorbar.
No-op when ``outline_color_vector`` has no finite values; derives the bar from the same resolved
norm the outline pixels use.
"""
arr = pd.to_numeric(pd.Series(np.asarray(outline_color_vector)), errors="coerce").to_numpy()
finite = np.isfinite(arr)
if not finite.any():
if not np.isfinite(arr).any():
return
norm = cmap_params.norm
vmin = norm.vmin if norm.vmin is not None else float(np.nanmin(arr[finite]))
vmax = norm.vmax if norm.vmax is not None else float(np.nanmax(arr[finite]))
used_norm = _resolve_continuous_norm(outline_color_vector, cmap_params)
colorbar_requests.append(
ColorbarSpec(
ax=ax,
mappable=_make_continuous_mappable(vmin, vmax, cmap_params.cmap),
mappable=ScalarMappable(norm=used_norm, cmap=cmap_params.cmap),
params=colorbar_params,
label=outline_col,
alpha=alpha,
Expand DownExpand Up@@ -747,7 +744,7 @@ def _render_shapes(

color_vector = _maybe_apply_transfunc(color_source_vector, color_vector, render_params.transfunc)

norm = copy(render_params.cmap_params.norm)
norm = render_params.cmap_params.fresh_norm()

if len(color_vector) == 0:
color_vector = [render_params.cmap_params.na_color.get_hex_with_alpha()]
Expand DownExpand Up@@ -968,7 +965,6 @@ def _render_shapes(
render_params=render_params,
rasterized=sc_settings._vector_friendly,
cmap=None,
norm=None,
fill_alpha=0.0,
outline_alpha=render_params.outline_alpha[0],
outline_color=outline_rgba,
Expand All@@ -987,7 +983,6 @@ def _render_shapes(
render_params=render_params,
rasterized=sc_settings._vector_friendly,
cmap=None,
norm=None,
fill_alpha=0.0,
outline_alpha=render_params.outline_alpha[0],
outline_color=render_params.outline_params.outer_outline_color.get_hex(),
Expand All@@ -1008,7 +1003,6 @@ def _render_shapes(
render_params=render_params,
rasterized=sc_settings._vector_friendly,
cmap=None,
norm=None,
fill_alpha=0.0,
outline_alpha=render_params.outline_alpha[1],
outline_color=render_params.outline_params.inner_outline_color.get_hex(),
Expand All@@ -1030,7 +1024,6 @@ def _render_shapes(
render_params=render_params,
rasterized=sc_settings._vector_friendly,
cmap=render_params.cmap_params.cmap,
norm=norm,
fill_alpha=render_params.fill_alpha,
outline_alpha=0.0,
zorder=render_params.zorder,
Expand All@@ -1043,25 +1036,9 @@ def _render_shapes(
path.vertices = trans.transform(path.vertices)

if not values_are_categorical:
# Respect explicit vmin/vmax; otherwise derive from finite numeric values, falling back to [0, 1] if unavailable
vmin = render_params.cmap_params.norm.vmin
vmax = render_params.cmap_params.norm.vmax
if vmin is None or vmax is None:
numeric_values = pd.to_numeric(np.asarray(color_vector), errors="coerce")
finite_mask = np.isfinite(numeric_values)
if finite_mask.any():
data_min = float(np.nanmin(numeric_values[finite_mask]))
data_max = float(np.nanmax(numeric_values[finite_mask]))
if vmin is None:
vmin = data_min
if vmax is None:
vmax = data_max
else:
if vmin is None:
vmin = 0.0
if vmax is None:
vmax = 1.0
_cax.set_clim(vmin=vmin, vmax=vmax)
# Colorbar range from the same resolved norm the fill pixels use.
used_norm = _resolve_continuous_norm(color_vector, render_params.cmap_params)
_cax.set_clim(vmin=used_norm.vmin, vmax=used_norm.vmax)

_add_legend_and_colorbar(
ax=ax,
Expand DownExpand Up@@ -1511,7 +1488,7 @@ def _render_points(

trans, trans_data = _prepare_transformation(sdata.points[element], coordinate_system, ax)

norm = copy(render_params.cmap_params.norm)
norm = render_params.cmap_params.fresh_norm()

method = render_params.method

Expand DownExpand Up@@ -1972,9 +1949,8 @@ def _render_images(
else render_params.cmap_params.cmap
)

# Overwrite alpha in cmap: https://stackoverflow.com/a/10127675
cmap._init()
cmap._lut[:, -1] = render_params.alpha
# Bake a uniform alpha into a fresh cmap (no shared-cmap mutation).
cmap = colormap_with_alpha(cmap, render_params.alpha, render_params.cmap_params.na_color.get_hex_with_alpha())

# norm needs to be passed directly to ax.imshow(). If we normalize before, that method would always clip.
_ax_show_and_transform(
Expand DownExpand Up@@ -2432,7 +2408,7 @@ def _render_labels(
y=xy[:, 1],
color_vector=point_color_vector,
color_source_vector=point_color_source_vector,
norm=copy(render_params.cmap_params.norm), # ax.scatter autoscales in place; don't mutate the shared norm
norm=render_params.cmap_params.fresh_norm(), # ax.scatter autoscales in place; don't mutate the shared norm
na_color=na_color,
adata=table if table_name is not None else None,
col_for_color=col_for_color,
Expand DownExpand Up@@ -2465,11 +2441,12 @@ def _draw_labels(
outline_color_source_vector=outline_color_source_vector if seg_boundaries else None,
)

# labels is pre-baked RGB; cmap/norm only drive the colorbar, so feed the same resolved norm.
cax = ax.imshow(
labels,
rasterized=True,
cmap=None if categorical else render_params.cmap_params.cmap,
norm=None if categorical else render_params.cmap_params.norm,
norm=None if categorical else _resolve_continuous_norm(color_vector, render_params.cmap_params),
alpha=alpha,
origin="lower",
zorder=render_params.zorder,
Expand Down
28 changes: 27 additions & 1 deletion src/spatialdata_plot/pl/render_params.py
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
from __future__ import annotations

from collections.abc import Callable, Mapping, Sequence
from copy import copy
from dataclasses import dataclass, field
from typing import Any, Literal

import numpy as np
from matplotlib.axes import Axes
from matplotlib.cm import ScalarMappable
from matplotlib.colors import Colormap, ListedColormap, Normalize, rgb2hex, to_hex
from matplotlib.colors import Colormap, ListedColormap, Normalize, rgb2hex, to_hex, to_rgba
from matplotlib.figure import Figure

_FontWeight = Literal["light", "normal", "medium", "semibold", "bold", "heavy", "black"]
Expand DownExpand Up@@ -148,6 +149,23 @@ def is_fully_transparent(self) -> bool:
return self.alpha == "00"


def colormap_with_alpha(cmap: Colormap, alpha: float, na_color: str) -> Colormap:
"""Return ``cmap`` rebuilt with a uniform ``alpha`` and ``na_color`` as the bad/NaN color.

Resampling at ``linspace(0, 1, N)`` is lossless (matplotlib quantizes ``__call__`` into ``N`` bins).
"""
lut = cmap(np.linspace(0, 1, cmap.N))
lut[:, -1] = alpha
new = ListedColormap(lut, name=cmap.name)
# Apply alpha to under/over too, matching the old ``_lut[:, -1] = alpha`` (which hit every row).
new.set_extremes(
bad=[*to_rgba(na_color)[:3], alpha],
under=[*cmap.get_under()[:3], alpha],
over=[*cmap.get_over()[:3], alpha],
)
return new


@dataclass
class CmapParams:
"""Cmap params."""
Expand All@@ -157,6 +175,14 @@ class CmapParams:
na_color: Color
cmap_is_default: bool = True

def fresh_norm(self) -> Normalize:
"""Return a copy of ``norm`` safe to apply/autoscale without mutating the shared one.

``Normalize.__call__`` autoscales ``vmin``/``vmax`` in place when unset, which would leak one
element's data range into later elements that reuse the same ``CmapParams``.
"""
return copy(self.norm)


@dataclass
class FigParams:
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 30 additions & 21 deletions src/spatialdata_plot/pl/_color.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -109,6 +109,29 @@ def _make_continuous_mappable(vmin: float, vmax: float, cmap: Any) -> ScalarMapp
return ScalarMappable(norm=Normalize(vmin=vmin, vmax=vmax), cmap=cmap)


def _resolve_continuous_norm(values: Any, cmap_params: CmapParams) -> Normalize:
"""Resolve a concrete ``Normalize`` for continuous coloring.

Honor explicit ``norm`` vmin/vmax, else the finite-value data range of ``values``, else
``[0, 1]``. Shared by the pixel and colorbar sites so both derive the same range. A degenerate
``vmin == vmax`` is left as-is (matplotlib expands it downstream), not reset to ``[0, 1]``.
"""
base = cmap_params.norm
vmin, vmax = base.vmin, base.vmax
if vmin is None or vmax is None:
arr = np.asarray(values)
if not np.issubdtype(arr.dtype, np.number):
arr = pd.to_numeric(arr.ravel(), errors="coerce")
finite = np.isfinite(arr)
data_min = float(np.nanmin(arr[finite])) if finite.any() else 0.0
data_max = float(np.nanmax(arr[finite])) if finite.any() else 1.0
if vmin is None:
vmin = data_min
if vmax is None:
vmax = data_max
return Normalize(vmin=vmin, vmax=vmax, clip=base.clip)


def _apply_mask_to_outline_vectors(
outline_color_vector: Any,
outline_color_source_vector: pd.Series | None,
Expand DownExpand Up@@ -189,15 +212,7 @@ def _color_vector_to_rgba(
if np.issubdtype(arr.dtype, np.number):
finite_mask = np.isfinite(arr)
if finite_mask.any():
norm = cmap_params.norm
if norm.vmin is None or norm.vmax is None:
vmin = float(np.nanmin(arr[finite_mask]))
vmax = float(np.nanmax(arr[finite_mask]))
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax:
vmin, vmax = 0.0, 1.0
used_norm = Normalize(vmin=vmin, vmax=vmax, clip=False)
else:
used_norm = norm
used_norm = _resolve_continuous_norm(arr, cmap_params)
rgba[finite_mask] = cmap_params.cmap(used_norm(arr[finite_mask]))
return rgba

Expand All@@ -206,15 +221,7 @@ def _color_vector_to_rgba(
num = pd.to_numeric(series, errors="coerce").to_numpy()
is_num = np.isfinite(num)
if is_num.any():
norm = cmap_params.norm
if norm.vmin is None or norm.vmax is None:
vmin = float(np.nanmin(num[is_num]))
vmax = float(np.nanmax(num[is_num]))
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax:
vmin, vmax = 0.0, 1.0
used_norm = Normalize(vmin=vmin, vmax=vmax, clip=False)
else:
used_norm = norm
used_norm = _resolve_continuous_norm(num, cmap_params)
rgba[is_num] = cmap_params.cmap(used_norm(num[is_num]))
color_mask = (~is_num) & series.notna().to_numpy()
if color_mask.any():
Expand DownExpand Up@@ -754,7 +761,8 @@ def _map_color_seg(
color_vector = color_vector.to_numpy()
# normalize only the not nan values, else the whole array would contain only nan values
normed_color_vector = color_vector.copy().astype(float)
normed_color_vector[~np.isnan(normed_color_vector)] = cmap_params.norm(
used_norm = _resolve_continuous_norm(normed_color_vector, cmap_params)
normed_color_vector[~np.isnan(normed_color_vector)] = used_norm(
normed_color_vector[~np.isnan(normed_color_vector)]
)
cols = cmap_params.cmap(normed_color_vector)
Expand All@@ -779,7 +787,8 @@ def _map_color_seg(
assert all(_is_color_like(c) for c in color_vector), "Not all values are color-like."
cols = colors.to_rgba_array(color_vector)
else:
cols = cmap_params.cmap(cmap_params.norm(color_vector))
used_norm = _resolve_continuous_norm(color_vector, cmap_params)
cols = cmap_params.cmap(used_norm(color_vector))

if seg_erosionpx is not None:
val_im[val_im == erosion(val_im, footprint_rectangle((seg_erosionpx, seg_erosionpx)))] = 0
Expand DownExpand Up@@ -813,7 +822,7 @@ def _map_color_seg(
normed = ov.copy().astype(float)
finite = ~np.isnan(normed)
if finite.any():
normed[finite] = cmap_params.norm(normed[finite])
normed[finite] = _resolve_continuous_norm(ov, cmap_params)(normed[finite])
outline_cols = cmap_params.cmap(normed)
outline_val_im = map_array(seg, cell_id, cell_id)
if seg_erosionpx is not None:
Expand Down
3 changes: 1 addition & 2 deletions src/spatialdata_plot/pl/_datashader.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,6 @@

from __future__ import annotations

from copy import copy
from typing import Any, Literal

import dask
Expand DownExpand Up@@ -588,7 +587,7 @@ def _render_ds_outline_by_column(
)
# Apply the user-provided norm (vmin/vmax) the same way the fill path does so
# an explicit Normalize takes effect for the outline cmap.
norm = copy(cmap_params.norm)
norm = cmap_params.fresh_norm()
agg_outline, color_span = _apply_ds_norm(agg_outline, norm)
shaded = ds.tf.shade(
agg_outline,
Expand Down
29 changes: 5 additions & 24 deletions src/spatialdata_plot/pl/_geometry.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,11 +13,12 @@
from geopandas import GeoDataFrame
from matplotlib import colors
from matplotlib.collections import PatchCollection
from matplotlib.colors import ColorConverter, Normalize
from matplotlib.colors import ColorConverter
from scipy.spatial import ConvexHull
from shapely.errors import GEOSException

from spatialdata_plot._logging import logger
from spatialdata_plot.pl._color import _resolve_continuous_norm
from spatialdata_plot.pl.render_params import ShapesRenderParams
from spatialdata_plot.pl.utils import _extract_scalar_value

Expand DownExpand Up@@ -167,7 +168,6 @@ def _get_collection_shape(
shapes: list[GeoDataFrame],
c: Any,
s: float,
norm: Any,
render_params: ShapesRenderParams,
fill_alpha: None | float = None,
outline_alpha: None | float = None,
Expand DownExpand Up@@ -215,23 +215,11 @@ def _as_rgba_array(x: Any) -> np.ndarray:
elif c_arr.ndim == 1 and len(c_arr) == len(shapes) and np.issubdtype(c_arr.dtype, np.number):
finite_mask = np.isfinite(c_arr)

# Select or build a normalization that ignores NaNs for scaling
if isinstance(norm, Normalize):
used_norm: Normalize = norm
else:
if finite_mask.any():
vmin = float(np.nanmin(c_arr[finite_mask]))
vmax = float(np.nanmax(c_arr[finite_mask]))
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax:
vmin, vmax = 0.0, 1.0
else:
vmin, vmax = 0.0, 1.0
used_norm = colors.Normalize(vmin=vmin, vmax=vmax, clip=False)

# Map finite values through cmap(norm(.)); NaNs get na_color
# Map finite values through cmap(norm(.)); NaNs get na_color.
fill_c = np.empty((len(c_arr), 4), dtype=float)
fill_c[:] = na_rgba
if finite_mask.any():
used_norm = _resolve_continuous_norm(c_arr, render_params.cmap_params)
fill_c[finite_mask] = cmap(used_norm(c_arr[finite_mask]))

elif c_arr.ndim == 1 and len(c_arr) == len(shapes) and c_arr.dtype == object:
Expand All@@ -246,14 +234,7 @@ def _as_rgba_array(x: Any) -> np.ndarray:

# numeric entries via cmap(norm)
if is_num.any():
if isinstance(norm, Normalize):
used_norm = norm
else:
vmin = float(np.nanmin(num[is_num])) if is_num.any() else 0.0
vmax = float(np.nanmax(num[is_num])) if is_num.any() else 1.0
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax:
vmin, vmax = 0.0, 1.0
used_norm = colors.Normalize(vmin=vmin, vmax=vmax, clip=False)
used_norm = _resolve_continuous_norm(num, render_params.cmap_params)
fill_c[is_num] = cmap(used_norm(num[is_num]))

# non-numeric, non-NaN entries as explicit colors
Expand Down
57 changes: 17 additions & 40 deletions src/spatialdata_plot/pl/render.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,10 +38,10 @@
_color_vector_to_rgba,
_get_colors_for_categorical_obs,
_get_linear_colormap,
_make_continuous_mappable,
_map_color_seg,
_maybe_set_colors,
_prepare_cmap_norm,
_resolve_continuous_norm,
_set_color_source_vec,
)
from spatialdata_plot.pl._datashader import (
Expand DownExpand Up@@ -77,6 +77,7 @@
PointsRenderParams,
ShapesRenderParams,
_DsReduction,
colormap_with_alpha,
)
from spatialdata_plot.pl.utils import (
_decorate_axs,
Expand DownExpand Up@@ -515,21 +516,17 @@ def _append_outline_colorbar(
) -> None:
"""Append a `ColorbarSpec` for a continuous outline column.

No-op when ``outline_color_vector`` has no finite values. Honors user-supplied
`vmin`/`vmax` on ``cmap_params.norm``; falls back to data range. Mirrors the
`vmin == vmax` ±0.5 expansion used by the fill colorbar.
No-op when ``outline_color_vector`` has no finite values; derives the bar from the same resolved
norm the outline pixels use.
"""
arr = pd.to_numeric(pd.Series(np.asarray(outline_color_vector)), errors="coerce").to_numpy()
finite = np.isfinite(arr)
if not finite.any():
if not np.isfinite(arr).any():
return
norm = cmap_params.norm
vmin = norm.vmin if norm.vmin is not None else float(np.nanmin(arr[finite]))
vmax = norm.vmax if norm.vmax is not None else float(np.nanmax(arr[finite]))
used_norm = _resolve_continuous_norm(outline_color_vector, cmap_params)
colorbar_requests.append(
ColorbarSpec(
ax=ax,
mappable=_make_continuous_mappable(vmin, vmax, cmap_params.cmap),
mappable=ScalarMappable(norm=used_norm, cmap=cmap_params.cmap),
params=colorbar_params,
label=outline_col,
alpha=alpha,
Expand DownExpand Up@@ -747,7 +744,7 @@ def _render_shapes(

color_vector = _maybe_apply_transfunc(color_source_vector, color_vector, render_params.transfunc)

norm = copy(render_params.cmap_params.norm)
norm = render_params.cmap_params.fresh_norm()

if len(color_vector) == 0:
color_vector = [render_params.cmap_params.na_color.get_hex_with_alpha()]
Expand DownExpand Up@@ -968,7 +965,6 @@ def _render_shapes(
render_params=render_params,
rasterized=sc_settings._vector_friendly,
cmap=None,
norm=None,
fill_alpha=0.0,
outline_alpha=render_params.outline_alpha[0],
outline_color=outline_rgba,
Expand All@@ -987,7 +983,6 @@ def _render_shapes(
render_params=render_params,
rasterized=sc_settings._vector_friendly,
cmap=None,
norm=None,
fill_alpha=0.0,
outline_alpha=render_params.outline_alpha[0],
outline_color=render_params.outline_params.outer_outline_color.get_hex(),
Expand All@@ -1008,7 +1003,6 @@ def _render_shapes(
render_params=render_params,
rasterized=sc_settings._vector_friendly,
cmap=None,
norm=None,
fill_alpha=0.0,
outline_alpha=render_params.outline_alpha[1],
outline_color=render_params.outline_params.inner_outline_color.get_hex(),
Expand All@@ -1030,7 +1024,6 @@ def _render_shapes(
render_params=render_params,
rasterized=sc_settings._vector_friendly,
cmap=render_params.cmap_params.cmap,
norm=norm,
fill_alpha=render_params.fill_alpha,
outline_alpha=0.0,
zorder=render_params.zorder,
Expand All@@ -1043,25 +1036,9 @@ def _render_shapes(
path.vertices = trans.transform(path.vertices)

if not values_are_categorical:
# Respect explicit vmin/vmax; otherwise derive from finite numeric values, falling back to [0, 1] if unavailable
vmin = render_params.cmap_params.norm.vmin
vmax = render_params.cmap_params.norm.vmax
if vmin is None or vmax is None:
numeric_values = pd.to_numeric(np.asarray(color_vector), errors="coerce")
finite_mask = np.isfinite(numeric_values)
if finite_mask.any():
data_min = float(np.nanmin(numeric_values[finite_mask]))
data_max = float(np.nanmax(numeric_values[finite_mask]))
if vmin is None:
vmin = data_min
if vmax is None:
vmax = data_max
else:
if vmin is None:
vmin = 0.0
if vmax is None:
vmax = 1.0
_cax.set_clim(vmin=vmin, vmax=vmax)
# Colorbar range from the same resolved norm the fill pixels use.
used_norm = _resolve_continuous_norm(color_vector, render_params.cmap_params)
_cax.set_clim(vmin=used_norm.vmin, vmax=used_norm.vmax)

_add_legend_and_colorbar(
ax=ax,
Expand DownExpand Up@@ -1511,7 +1488,7 @@ def _render_points(

trans, trans_data = _prepare_transformation(sdata.points[element], coordinate_system, ax)

norm = copy(render_params.cmap_params.norm)
norm = render_params.cmap_params.fresh_norm()

method = render_params.method

Expand DownExpand Up@@ -1972,9 +1949,8 @@ def _render_images(
else render_params.cmap_params.cmap
)

# Overwrite alpha in cmap: https://stackoverflow.com/a/10127675
cmap._init()
cmap._lut[:, -1] = render_params.alpha
# Bake a uniform alpha into a fresh cmap (no shared-cmap mutation).
cmap = colormap_with_alpha(cmap, render_params.alpha, render_params.cmap_params.na_color.get_hex_with_alpha())

# norm needs to be passed directly to ax.imshow(). If we normalize before, that method would always clip.
_ax_show_and_transform(
Expand DownExpand Up@@ -2432,7 +2408,7 @@ def _render_labels(
y=xy[:, 1],
color_vector=point_color_vector,
color_source_vector=point_color_source_vector,
norm=copy(render_params.cmap_params.norm), # ax.scatter autoscales in place; don't mutate the shared norm
norm=render_params.cmap_params.fresh_norm(), # ax.scatter autoscales in place; don't mutate the shared norm
na_color=na_color,
adata=table if table_name is not None else None,
col_for_color=col_for_color,
Expand DownExpand Up@@ -2465,11 +2441,12 @@ def _draw_labels(
outline_color_source_vector=outline_color_source_vector if seg_boundaries else None,
)

# labels is pre-baked RGB; cmap/norm only drive the colorbar, so feed the same resolved norm.
cax = ax.imshow(
labels,
rasterized=True,
cmap=None if categorical else render_params.cmap_params.cmap,
norm=None if categorical else render_params.cmap_params.norm,
norm=None if categorical else _resolve_continuous_norm(color_vector, render_params.cmap_params),
alpha=alpha,
origin="lower",
zorder=render_params.zorder,
Expand Down
28 changes: 27 additions & 1 deletion src/spatialdata_plot/pl/render_params.py
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
from __future__ import annotations

from collections.abc import Callable, Mapping, Sequence
from copy import copy
from dataclasses import dataclass, field
from typing import Any, Literal

import numpy as np
from matplotlib.axes import Axes
from matplotlib.cm import ScalarMappable
from matplotlib.colors import Colormap, ListedColormap, Normalize, rgb2hex, to_hex
from matplotlib.colors import Colormap, ListedColormap, Normalize, rgb2hex, to_hex, to_rgba
from matplotlib.figure import Figure

_FontWeight = Literal["light", "normal", "medium", "semibold", "bold", "heavy", "black"]
Expand DownExpand Up@@ -148,6 +149,23 @@ def is_fully_transparent(self) -> bool:
return self.alpha == "00"


def colormap_with_alpha(cmap: Colormap, alpha: float, na_color: str) -> Colormap:
"""Return ``cmap`` rebuilt with a uniform ``alpha`` and ``na_color`` as the bad/NaN color.

Resampling at ``linspace(0, 1, N)`` is lossless (matplotlib quantizes ``__call__`` into ``N`` bins).
"""
lut = cmap(np.linspace(0, 1, cmap.N))
lut[:, -1] = alpha
new = ListedColormap(lut, name=cmap.name)
# Apply alpha to under/over too, matching the old ``_lut[:, -1] = alpha`` (which hit every row).
new.set_extremes(
bad=[*to_rgba(na_color)[:3], alpha],
under=[*cmap.get_under()[:3], alpha],
over=[*cmap.get_over()[:3], alpha],
)
return new


@dataclass
class CmapParams:
"""Cmap params."""
Expand All@@ -157,6 +175,14 @@ class CmapParams:
na_color: Color
cmap_is_default: bool = True

def fresh_norm(self) -> Normalize:
"""Return a copy of ``norm`` safe to apply/autoscale without mutating the shared one.

``Normalize.__call__`` autoscales ``vmin``/``vmax`` in place when unset, which would leak one
element's data range into later elements that reuse the same ``CmapParams``.
"""
return copy(self.norm)


@dataclass
class FigParams:
Expand Down
Loading
Loading