Skip to content

Commit 78d9233

Browse files
authored
Maintainability quick wins: dedup, redundant copies, O(K^2) lookup (#701)
1 parent c6badd2 commit 78d9233

5 files changed

Lines changed: 34 additions & 96 deletions

File tree

src/spatialdata_plot/pl/basic.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
ChannelLegendEntry,
4747
CmapParams,
4848
ColorbarSpec,
49+
ColorLike,
4950
GraphRenderParams,
5051
ImageRenderParams,
5152
LabelsRenderParams,
@@ -80,11 +81,6 @@
8081
save_fig,
8182
)
8283

83-
# replace with
84-
# from spatialdata._types import ColorLike
85-
# once https://github.com/scverse/spatialdata/pull/689/ is in a release
86-
ColorLike = tuple[float, ...] | list[float] | str
87-
8884

8985
@register_spatial_data_accessor("pl")
9086
class PlotAccessor:

src/spatialdata_plot/pl/render.py

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,16 @@
8787

8888
_Normalize = Normalize | abc.Sequence[Normalize]
8989

90+
# Shared body of the "blending multiple cmaps" warning. Emitted both when the user
91+
# supplies several cmaps and when a single cmap is broadcast across channels.
92+
_MULTI_CMAP_BLENDING_WARNING = (
93+
"You're blending multiple cmaps. "
94+
"If the plot doesn't look like you expect, it might be because your "
95+
"cmaps go from a given color to 'white', and not to 'transparent'. "
96+
"Therefore, the 'white' of higher layers will overlay the lower layers. "
97+
"Consider using 'palette' instead."
98+
)
99+
90100

91101
def _get_top_data_array(element: xr.DataArray | DataTree) -> xr.DataArray:
92102
if isinstance(element, DataTree):
@@ -1136,9 +1146,9 @@ def _render_points(
11361146
# if the points are colored by values in X (or a different layer), add the values to obs
11371147
if col_for_color in matched_table.var_names:
11381148
if table_layer is None:
1139-
adata_obs[col_for_color] = matched_table[:, col_for_color].X.flatten().copy()
1149+
adata_obs[col_for_color] = matched_table[:, col_for_color].X.flatten()
11401150
else:
1141-
adata_obs[col_for_color] = matched_table[:, col_for_color].layers[table_layer].flatten().copy()
1151+
adata_obs[col_for_color] = matched_table[:, col_for_color].layers[table_layer].flatten()
11421152
adata = AnnData(
11431153
X=points[["x", "y"]].values,
11441154
obs=adata_obs,
@@ -1742,13 +1752,7 @@ def _render_images(
17421752
user_supplied_multi_cmaps = False
17431753

17441754
if user_supplied_multi_cmaps:
1745-
logger.warning(
1746-
"You're blending multiple cmaps. "
1747-
"If the plot doesn't look like you expect, it might be because your "
1748-
"cmaps go from a given color to 'white', and not to 'transparent'. "
1749-
"Therefore, the 'white' of higher layers will overlay the lower layers. "
1750-
"Consider using 'palette' instead."
1751-
)
1755+
logger.warning(_MULTI_CMAP_BLENDING_WARNING)
17521756

17531757
# Force nearest-neighbor at display time when the datashader reduction picked
17541758
# a non-mean aggregation; otherwise imshow's default interpolation would smear it.
@@ -1864,7 +1868,9 @@ def _render_images(
18641868
)
18651869
layers = {}
18661870
for ch_idx, ch in enumerate(channels):
1867-
layers[ch] = img.sel(c=ch).copy(deep=True).squeeze()
1871+
# No copy needed: this entry is only read (min/max) and then replaced
1872+
# by a fresh array (np.full or ch_norm(...)) below; img is never mutated.
1873+
layers[ch] = img.sel(c=ch).squeeze()
18681874
if isinstance(render_params.cmap_params, list):
18691875
ch_norm = render_params.cmap_params[ch_idx].norm
18701876
else:
@@ -1904,11 +1910,7 @@ def _render_images(
19041910
stacked = stacked[:, :, :3]
19051911
logger.warning(
19061912
"One cmap was given for multiple channels and is now used for each channel. "
1907-
"You're blending multiple cmaps. "
1908-
"If the plot doesn't look like you expect, it might be because your "
1909-
"cmaps go from a given color to 'white', and not to 'transparent'. "
1910-
"Therefore, the 'white' of higher layers will overlay the lower layers. "
1911-
"Consider using 'palette' instead."
1913+
+ _MULTI_CMAP_BLENDING_WARNING
19121914
)
19131915

19141916
_ax_show_and_transform(

src/spatialdata_plot/pl/render_params.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@
1515
_DsReduction = Literal["sum", "mean", "any", "count", "std", "var", "max", "min"]
1616
_ImageDsReduction = Literal["max", "min", "mean", "mode", "first", "last", "var", "std"]
1717

18+
# Canonical definition for the package; imported by basic.py and utils.py.
1819
# replace with
1920
# from spatialdata._types import ColorLike
2021
# once https://github.com/scverse/spatialdata/pull/689/ is in a release
21-
ColorLike = tuple[float, ...] | str
22+
ColorLike = tuple[float, ...] | list[float] | str
2223

2324

2425
# NOTE: defined here instead of utils to avoid circular import

src/spatialdata_plot/pl/utils.py

Lines changed: 13 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import os
55
import warnings
66
from collections import Counter, OrderedDict
7-
from collections.abc import Callable, Iterable, Mapping, Sequence
7+
from collections.abc import Callable, Mapping, Sequence
88
from copy import copy
99
from functools import partial
1010
from pathlib import Path
@@ -78,6 +78,7 @@
7878
CmapParams,
7979
Color,
8080
ColorbarSpec,
81+
ColorLike,
8182
FigParams,
8283
GraphRenderParams,
8384
ImageRenderParams,
@@ -93,11 +94,6 @@
9394

9495
to_hex = partial(colors.to_hex, keep_alpha=True)
9596

96-
# replace with
97-
# from spatialdata._types import ColorLike
98-
# once https://github.com/scverse/spatialdata/pull/689/ is in a release
99-
ColorLike = tuple[float, ...] | list[float] | str
100-
10197
_GROUPS_IGNORED_WARNING = "Parameter 'groups' is ignored when 'color' is a literal color, not a column name."
10298

10399
_RENDER_CMD_TO_CS_FLAG: dict[str, str] = {
@@ -996,44 +992,6 @@ def _set_outline(
996992
)
997993

998994

999-
def _get_subplots(num_images: int, ncols: int = 4, width: int = 4, height: int = 3) -> plt.Figure | plt.Axes:
1000-
"""Set up the axs objects.
1001-
1002-
Parameters
1003-
----------
1004-
num_images
1005-
Number of images to plot. Must be greater than 1.
1006-
ncols
1007-
Number of columns in the subplot grid, by default 4
1008-
width
1009-
Width of each subplot, by default 4
1010-
1011-
Returns
1012-
-------
1013-
Union[plt.Figure, plt.Axes]
1014-
Matplotlib figure and axes object.
1015-
"""
1016-
if num_images < ncols:
1017-
nrows = 1
1018-
ncols = num_images
1019-
else:
1020-
nrows, reminder = divmod(num_images, ncols)
1021-
1022-
if nrows == 0:
1023-
nrows = 1
1024-
if reminder > 0:
1025-
nrows += 1
1026-
1027-
fig, axes = plt.subplots(nrows, ncols, figsize=(width * ncols, height * nrows))
1028-
1029-
if not isinstance(axes, Iterable):
1030-
axes = np.array([axes])
1031-
1032-
# get rid of the empty axes
1033-
_ = [ax.axis("off") for ax in axes.flatten()[num_images:]]
1034-
return fig, axes
1035-
1036-
1037995
def _get_colors_for_categorical_obs(
1038996
categories: Sequence[str | int],
1039997
palette: ListedColormap | str | list[str] | None = None,
@@ -1503,7 +1461,7 @@ def _map_color_seg(
15031461

15041462
if isinstance(color_vector.dtype, pd.CategoricalDtype):
15051463
# Case A: users wants to plot a categorical column
1506-
val_im: ArrayLike = map_array(seg.copy(), cell_id, color_vector.codes + 1)
1464+
val_im: ArrayLike = map_array(seg, cell_id, color_vector.codes + 1)
15071465
cols = colors.to_rgba_array(color_vector.categories)
15081466
elif pd.api.types.is_numeric_dtype(color_vector.dtype):
15091467
# Case B: user wants to plot a continous column
@@ -1515,7 +1473,7 @@ def _map_color_seg(
15151473
normed_color_vector[~np.isnan(normed_color_vector)]
15161474
)
15171475
cols = cmap_params.cmap(normed_color_vector)
1518-
val_im = map_array(seg.copy(), cell_id, cell_id)
1476+
val_im = map_array(seg, cell_id, cell_id)
15191477
else:
15201478
# Case C: User didn't specify any colors
15211479
if color_source_vector is not None and (
@@ -1524,12 +1482,12 @@ def _map_color_seg(
15241482
and set(color_vector) == {na_color.get_hex_with_alpha()}
15251483
and not na_color.color_modified_by_user()
15261484
):
1527-
val_im = map_array(seg.copy(), cell_id, cell_id)
1485+
val_im = map_array(seg, cell_id, cell_id)
15281486
RNG = default_rng(42)
15291487
cols = RNG.random((len(color_vector), 3))
15301488
else:
15311489
# Case D: User didn't specify a column to color by, but modified the na_color
1532-
val_im = map_array(seg.copy(), cell_id, cell_id)
1490+
val_im = map_array(seg, cell_id, cell_id)
15331491
first_value = color_vector.iloc[0] if isinstance(color_vector, pd.Series) else color_vector[0]
15341492
if _is_color_like(first_value):
15351493
# we have color-like values (e.g., hex or named colors)
@@ -1550,7 +1508,7 @@ def _map_color_seg(
15501508
if outline_color_source_vector is not None:
15511509
cat = pd.Categorical(outline_color_source_vector)
15521510
cat_codes = cat.codes
1553-
outline_val_im: ArrayLike = map_array(seg.copy(), cell_id, cat_codes + 1)
1511+
outline_val_im: ArrayLike = map_array(seg, cell_id, cat_codes + 1)
15541512
color_arr = np.asarray(outline_color_vector, dtype=object)
15551513
# Pick the first per-cell hex for each category in one vectorized pass
15561514
# (avoids `K × O(N)` Python loops on large label sets).
@@ -1572,7 +1530,7 @@ def _map_color_seg(
15721530
if finite.any():
15731531
normed[finite] = cmap_params.norm(normed[finite])
15741532
outline_cols = cmap_params.cmap(normed)
1575-
outline_val_im = map_array(seg.copy(), cell_id, cell_id)
1533+
outline_val_im = map_array(seg, cell_id, cell_id)
15761534
if seg_erosionpx is not None:
15771535
outline_val_im[
15781536
outline_val_im == erosion(outline_val_im, footprint_rectangle((seg_erosionpx, seg_erosionpx)))
@@ -1814,8 +1772,12 @@ def _to_hex_no_alpha(color_value: Any) -> str | None:
18141772
if col_to_colorby in adata.obs and hasattr(adata.obs[col_to_colorby], "cat")
18151773
else categories
18161774
)
1775+
# Map category -> index once (O(K)) instead of a per-category list scan
1776+
# (was O(K^2) via list.index). all_cats comes from pandas .categories,
1777+
# which is unique, so a plain dict comprehension is sufficient.
1778+
cat_to_idx: dict[Any, int] = {c: i for i, c in enumerate(all_cats)}
18171779
for category in categories:
1818-
idx = all_cats.index(category) if category in all_cats else None
1780+
idx = cat_to_idx.get(category)
18191781
if idx is not None and idx < len(hex_colors) and hex_colors[idx] is not None:
18201782
hex_color = hex_colors[idx]
18211783
assert hex_color is not None # type narrowing for mypy

tests/pl/test_utils.py

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,10 @@
1212
from spatialdata.models import PointsModel, ShapesModel, TableModel
1313

1414
import spatialdata_plot
15-
from spatialdata_plot.pl.render_params import Color
15+
from spatialdata_plot.pl.render_params import Color, ColorLike
1616
from spatialdata_plot.pl.utils import (
1717
_apply_cmap_alpha_to_datashader_result,
1818
_datashader_map_aggregate_to_color,
19-
_get_subplots,
2019
_set_outline,
2120
set_zero_in_cmap_to_transparent,
2221
)
@@ -34,11 +33,6 @@
3433
# the comp. function can be accessed as `self.compare(<your_filename>, tolerance=<your_tolerance>)`
3534
# ".png" is appended to <your_filename>, no need to set it
3635

37-
# replace with
38-
# from spatialdata._types import ColorLike
39-
# once https://github.com/scverse/spatialdata/pull/689/ is in a release
40-
ColorLike = tuple[float, ...] | str
41-
4236

4337
class TestUtils(PlotTester, metaclass=PlotTesterMeta):
4438
@pytest.mark.parametrize(
@@ -290,23 +284,6 @@ def test_plot_can_handle_rgba_color_specifications(sdata_blobs: SpatialData):
290284
sdata_blobs.pl.render_shapes(element="blobs_circles", color="blue").pl.show()
291285

292286

293-
@pytest.mark.parametrize(
294-
"input_output",
295-
[
296-
(1, 4, 1, [True]),
297-
(4, 4, 4, [True, True, True, True]),
298-
(6, 4, 8, [True, True, True, True, True, True, False, False]), # 2 rows with 4 columns
299-
],
300-
)
301-
def test_utils_get_subplots_produces_correct_axs_layout(input_output):
302-
num_images, ncols, len_axs, axs_visible = input_output
303-
304-
_, axs = _get_subplots(num_images=num_images, ncols=ncols)
305-
306-
assert len_axs == len(axs.flatten())
307-
assert axs_visible == [ax.axison for ax in axs.flatten()]
308-
309-
310287
class TestMultiscaleToSpatialImage:
311288
"""Regression tests for #589: multiscale resolution selection."""
312289

0 commit comments

Comments
 (0)