diff --git a/src/spatialdata_plot/pl/_datashader.py b/src/spatialdata_plot/pl/_datashader.py index 90e5ffba..b56a52fd 100644 --- a/src/spatialdata_plot/pl/_datashader.py +++ b/src/spatialdata_plot/pl/_datashader.py @@ -412,7 +412,9 @@ def _shade_datashader_aggregate( nan_agg, na_color_hex, spread_px=spread_px, - ds_reduction=ds_reduction, + # spread must follow the *resolved* reduction so it doesn't undo it (e.g. a "max" aggregate + # summed back up when spread defaults to "add"); ds_reduction=None falls back to default_reduction. + ds_reduction=ds_reduction if ds_reduction is not None else default_reduction, how=shade_how, uniform_alpha=uniform_alpha, ) @@ -721,6 +723,46 @@ def _pad_degenerate_extent(ext: list[Any]) -> list[Any]: return [ext[0] - 0.5, ext[1] + 0.5] if ext[1] == ext[0] else ext +def _affine_major_scale(tm: np.ndarray) -> float: + """Largest singular value of the affine's linear part — a circle's major-axis scale under ``tm``.""" + return float(np.linalg.svd(tm[:2, :2], compute_uv=False).max()) + + +def _circle_quad_segs(max_radius_px: float) -> int: + """Segments-per-quadrant for buffering circles to polygons, by the largest disc's pixel radius. + + Coarsen (4 vs shapely's default 16) only for sub-pixel discs, where buffering dominates the render + and the loss is invisible (e.g. dense Visium HD spots); any visible disc keeps the round default. + ``NaN`` radius falls through to the default. + """ + return 4 if max_radius_px <= 2 else 16 + + +def _circle_buffer_quad_segs( + centroids_xy: np.ndarray, + max_radius: float, + tm: np.ndarray, + fig_params: FigParams, +) -> int: + """Pick the circle-buffer ``resolution`` from the largest circle's on-screen pixel radius. + + Estimates the same world-units-per-pixel ``factor`` the datashader canvas will use (mirrors + ``_compute_datashader_canvas_params``), computed *before* buffering from the transformed centroids + expanded by the (major-axis) radius. ``tm`` is the coordinate-system affine; an anisotropic/shear + transform turns the circle into an ellipse, so size to its largest stretch (major axis). + """ + linear = tm[:2, :2] + r_t = float(max_radius) * _affine_major_scale(tm) # circle -> ellipse major-axis scale + xy_t = centroids_xy @ linear.T + tm[:2, 2] + ext_w = (xy_t[:, 0].max() + r_t) - (xy_t[:, 0].min() - r_t) + ext_h = (xy_t[:, 1].max() + r_t) - (xy_t[:, 1].min() - r_t) + fig = fig_params.fig + fig_px_w = fig.get_size_inches()[0] * fig.dpi + fig_px_h = fig.get_size_inches()[1] * fig.dpi + factor = max(ext_w / fig_px_w, ext_h / fig_px_h) + return _circle_quad_segs(r_t / factor if factor > 0 else 0.0) + + def _compute_datashader_canvas_params( x_ext: list[Any], y_ext: list[Any], diff --git a/src/spatialdata_plot/pl/basic.py b/src/spatialdata_plot/pl/basic.py index 5e5b17de..00af0628 100644 --- a/src/spatialdata_plot/pl/basic.py +++ b/src/spatialdata_plot/pl/basic.py @@ -444,10 +444,16 @@ def render_shapes( Reduction method for datashader when coloring by continuous values. When ``None``, defaults to ``"max"``. transfunc : Callable[[float], float] | None, optional Optional transformation applied to the continuous color vector before normalization and colormap mapping. + as_points : bool + If ``True``, draw one ``size``-d dot per shape centroid instead of its full geometry + (faster for large sets; available on both the matplotlib and datashader backends). Notes ----- - Empty geometries will be removed at the time of plotting. + - On the datashader backend, a large (>50k) uniform-radius, outline-free circle element is + rendered as radius-faithful points for speed (visually equivalent at that scale); pass + ``method="matplotlib"`` for a pixel-exact rendering of every circle. - An `outline_width` of 0.0 leads to no border being plotted. - If ``color`` is a string that is both a matplotlib color name and a column name in the element or an annotating table, a ``ValueError`` is raised. Disambiguate by passing @@ -626,7 +632,8 @@ def render_points( ``var_names`` are e.g. ENSEMBL IDs but you want to refer to genes by their symbols stored in another column of ``var``. Mimics scanpy's ``gene_symbols`` parameter. datashader_reduction : Literal["sum", "mean", "any", "count", "std", "var", "max", "min"] | None, optional - Reduction method for datashader when coloring by continuous values. When ``None``, defaults to ``"sum"``. + Reduction method for datashader when coloring by continuous values. When ``None``, defaults to ``"max"``, + which keeps the per-pixel color close to the matplotlib backend (``"sum"`` inflates overlapping dots). density : bool, default False Render the points as a 2-D count density via datashader instead of plotting individual markers. When ``True``, ``method`` is forced to ``"datashader"`` (passing ``method="matplotlib"`` raises). diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index e19cb2b0..da27e257 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -44,8 +44,10 @@ resolve_color, ) from spatialdata_plot.pl._datashader import ( + _affine_major_scale, _ax_show_and_transform, _build_ds_colorbar, + _circle_buffer_quad_segs, _datashader_canvas_from_dataframe, _get_extent_and_range_for_datashader_canvas, _hex_no_alpha, @@ -572,6 +574,32 @@ def _check_instance_ids_overlap( ) +# Above this many circles, a uniform-radius outline-free element is a dot-field where buffering every +# circle to a polygon dominates the render; rasterizing centroids as spread discs is far cheaper and +# visually equivalent at that scale. +_CIRCLE_FAST_PATH_MIN = 50_000 + + +def _circles_render_as_points(shapes: gpd.GeoDataFrame, is_point: Any, render_params: ShapesRenderParams) -> bool: + """Gate for the datashader circle fast-path: a large, uniform-radius, outline-free, default-shape element. + + A datashader speed optimization (use ``method="matplotlib"`` for exact circles), restricted so the + point approximation never silently distorts: per-circle varying radii and outlines can't be + reproduced by a single uniform spread, and a custom ``shape`` is meaningless for points. + """ + if ( + render_params.shape is not None + or "radius" not in shapes.columns + or len(shapes) <= _CIRCLE_FAST_PATH_MIN + or render_params.outline_alpha[0] > 0 + or render_params.outline_alpha[1] > 0 + or not is_point.all() + ): + return False + radius = pd.to_numeric(shapes["radius"], errors="coerce").to_numpy() + return bool(np.isfinite(radius).all() and np.ptp(radius) == 0) + + def _render_shapes( sdata: sd.SpatialData, render_params: ShapesRenderParams, @@ -717,12 +745,8 @@ def _render_shapes( shapes = gpd.GeoDataFrame(shapes, geometry="geometry") - if render_params.as_points: - # Fast mode: draw one dot per shape at its centroid instead of its geometry. - logger.info("`as_points=True`: rendering shape centroids; `outline_*` and `shape` are ignored.") - centroids = shapes.geometry.centroid # intrinsic coords, positionally aligned to color_vector - # transform to coordinate-system coords so dots land correctly under non-identity transforms - xy = trans.transform(np.column_stack([centroids.x.to_numpy(), centroids.y.to_numpy()])) + def _draw_centroids(xy: np.ndarray, radius: float | None = None) -> None: + """Render the element's centroids (coordinate-system coords) as dots; ``radius`` sizes the disc.""" _render_centroids_as_points( ax, render_params, @@ -738,7 +762,13 @@ def _render_shapes( legend_params=legend_params, colorbar_requests=colorbar_requests, axes_extent=_fast_extent(sdata_filt.shapes[element], coordinate_system), + radius=radius, ) + + if render_params.as_points: + logger.info("`as_points=True`: rendering shape centroids; `outline_*` and `shape` are ignored.") + centroids = shapes.geometry.centroid # intrinsic; transform so dots land under non-identity transforms + _draw_centroids(trans.transform(np.column_stack([centroids.x.to_numpy(), centroids.y.to_numpy()]))) return # convert shapes if necessary @@ -770,14 +800,30 @@ def _render_shapes( if method == "datashader": _geometry = shapes["geometry"] is_point = _geometry.type == "Point" + tm = trans.get_matrix() # coordinate-system affine; reused for circle sizing and the transform below + + # Fast path: a large uniform-radius circle element with no outline rasterizes (to within a + # pixel) the same as spread points, skipping the per-circle buffer/polygon-aggregation cost. + if _circles_render_as_points(shapes, is_point, render_params): + logger.info(f"Rendering {len(shapes)} uniform circles as datashader points (fast path).") + # radius is gate-guaranteed uniform + finite, so coerce only the first value (avoids an O(n) pass). + radius_one = float(pd.to_numeric(shapes["radius"].iloc[:1], errors="coerce").iloc[0]) + radius_cs = radius_one * render_params.scale * _affine_major_scale(tm) + xy = trans.transform(np.column_stack([_geometry.x.to_numpy(), _geometry.y.to_numpy()])) + _draw_centroids(xy, radius=radius_cs) + return # Handle circles encoded as points with radius if is_point.any(): - radius_values = shapes[is_point]["radius"] # Convert to numeric, replacing non-numeric values with NaN - radius_numeric = pd.to_numeric(radius_values, errors="coerce") - scale = radius_numeric * render_params.scale - shapes.loc[is_point, "geometry"] = _geometry[is_point].buffer(scale.to_numpy()) + radius = (pd.to_numeric(shapes[is_point]["radius"], errors="coerce") * render_params.scale).to_numpy() + points = _geometry[is_point] + # Buffer at a vertex count matched to the largest disc's on-screen size: tiny discs don't + # need shapely's 65-vertex default, which otherwise dominates the render for large sets. + quad_segs = _circle_buffer_quad_segs( + np.column_stack([points.x.to_numpy(), points.y.to_numpy()]), float(np.nanmax(radius)), tm, fig_params + ) + shapes.loc[is_point, "geometry"] = points.buffer(radius, resolution=quad_segs) # Handle polygon/multipolygon scaling is_polygon = _geometry.type.isin(["Polygon", "MultiPolygon"]) @@ -789,7 +835,6 @@ def _render_shapes( ) # apply transformations to the individual points - tm = trans.get_matrix() transformed_geometry = shapes["geometry"].transform( lambda x: (np.hstack([x, np.ones((x.shape[0], 1))]) @ tm.T)[:, :2] ) @@ -1073,12 +1118,14 @@ def _render_centroids_as_points( colorbar_requests: list[ColorbarSpec] | None, axes_extent: dict[str, tuple[float, float]], allow_datashader: bool = True, + radius: float | None = None, ) -> None: """Render one dot per cell at ``(x, y)`` (coordinate-system coords), colored like the fill. Shared "fast mode" for shapes/labels; backend chosen by ``_resolve_as_points_method``. ``axes_extent`` (the element's extent, i.e. the frame the axes will use) is what the datashader backend rasterizes over - so its dots match the matplotlib markers. + so its dots match the matplotlib markers. ``radius`` (coordinate-system units), when set, sizes the + datashader spread to a faithful disc of that radius instead of the marker ``size``. """ method = _resolve_as_points_method(render_params, n=len(x), allow_datashader=allow_datashader) if method == "datashader": @@ -1103,6 +1150,7 @@ def _render_centroids_as_points( fig_params=fig_params, as_markers=True, axes_extent=axes_extent, + radius=radius, ) color_spec = color_spec.evolve(source_vector=csv, color_vector=cv) else: @@ -1135,6 +1183,15 @@ def _render_centroids_as_points( ) +def _marker_spread_px(size: float, dpi: float, factor: float, factor_axesbox: float) -> int: + """Spread radius (canvas px) matching a matplotlib marker of area ``size`` at any panel layout. + + The marker radius is ``sqrt(size)*dpi/144`` display px; one figure-resolution canvas px displays at + ``factor_axesbox/factor`` of a display px, so rescale by that ratio to keep the on-screen size constant. + """ + return max(int(round(np.sqrt(size) * dpi / 144 * factor_axesbox / factor)), 0) + + def _datashader_points( ax: matplotlib.axes.SubplotBase, df: pd.DataFrame, @@ -1151,26 +1208,23 @@ def _datashader_points( density: bool, density_how: str, fig_params: FigParams, - default_reduction: _DsReduction = "sum", + default_reduction: _DsReduction = "max", as_markers: bool = False, axes_extent: dict[str, tuple[float, float]] | None = None, + radius: float | None = None, ) -> tuple[Any, Any, Any]: """Datashade an x/y(+color) point frame onto ``ax``; return ``(cax, color_vector, color_source_vector)``. Shared by ``render_points`` and the centroid "fast mode" of shapes/labels; ``df`` holds ``x``/``y`` in coordinate-system coords. The (possibly recomputed) color vectors are returned so the caller's legend matches. ``as_markers`` mimics matplotlib markers: it rasterizes over ``axes_extent`` (the plot frame), - sizes the spread to the marker radius, and uses a uniform alpha. + sizes the spread to the marker radius, and uses a uniform alpha. ``radius`` (coordinate-system units) + overrides ``size`` to spread each dot to a faithful disc of that radius (circle fast-path). """ - # Spread radius = matplotlib marker radius: an 'o' marker has diameter sqrt(s)*dpi/72 px, so radius - # sqrt(s)*dpi/144. render_points keeps the looser sqrt(s)*dpi/100 it was calibrated with. - px_div = 144 if as_markers else 100 - px: int | None = None if density else int(np.round(np.sqrt(size) * (fig_params.fig.dpi / px_div))) - if as_markers and axes_extent is not None: - # Size the canvas to the AXES display box, not the figure: the datashader output is a - # data-coordinate image that scales with the (smaller) axes, so a figure-sized canvas shrinks the - # dots. With 1 canvas px == 1 axes-display px, the spread radius above matches the marker. + # Centroid markers (as_points): size the canvas to the AXES box so 1 canvas px == 1 display px + # and the spread radius below is directly in display pixels (centroids are sparse, so the lower + # resolution doesn't affect aggregation). x_ext = [float(axes_extent["x"][0]), float(axes_extent["x"][1])] y_ext = [float(axes_extent["y"][0]), float(axes_extent["y"][1])] bb = ax.get_window_extent() @@ -1179,6 +1233,22 @@ def _datashader_points( plot_width, plot_height = int(round(rx / factor)), int(round(ry / factor)) else: plot_width, plot_height, x_ext, y_ext, factor = _datashader_canvas_from_dataframe(df, fig_params) + + if density: + px: int | None = None + elif radius is not None: + # Faithful disc (circle fast-path): spread to the circle's on-screen pixel radius. ds.tf.spread's + # footprint radius is ~px+0.5, so subtract 0.5 to match a filled disc of radius r. + px = max(int(round(radius / factor - 0.5)), 0) + elif as_markers: + # Canvas is already the axes box (factor == factor_axesbox), so the spread is the marker radius. + px = _marker_spread_px(size, fig_params.fig.dpi, factor, factor) + else: + # Layout-invariant marker radius: the figure-resolution canvas shrinks dots in multi-panel + # subplots, so rescale the spread by the axes-box/canvas factor ratio to cancel that. + bb = ax.get_window_extent() + factor_axesbox = max((x_ext[1] - x_ext[0]) / bb.width, (y_ext[1] - y_ext[0]) / bb.height) + px = _marker_spread_px(size, fig_params.fig.dpi, factor, factor_axesbox) cvs = ds.Canvas(plot_width=plot_width, plot_height=plot_height, x_range=x_ext, y_range=y_ext) # ensure color column exists on the frame with positional alignment @@ -1425,7 +1495,9 @@ def _render_points( elif method is None: method = "datashader" if n_points > 10000 else "matplotlib" - _default_reduction: _DsReduction = "sum" + # "max" keeps the per-pixel aggregate close to the matplotlib backend (each dot shows its own value); + # "sum" would inflate the normalization range where dots overlap and push single points to the dark end. + _default_reduction: _DsReduction = "max" if method == "datashader": # datashader colors the per-pixel aggregate (count/sum/reduction), not the per-point vector, diff --git a/tests/_images/Labels_labels_render_permutations.png b/tests/_images/Labels_labels_render_permutations.png new file mode 100644 index 00000000..dfce3510 Binary files /dev/null and b/tests/_images/Labels_labels_render_permutations.png differ diff --git a/tests/_images/Points_can_annotate_points_with_nan_in_df_continuous_datashader.png b/tests/_images/Points_can_annotate_points_with_nan_in_df_continuous_datashader.png index c7d6a536..d1d5d37d 100644 Binary files a/tests/_images/Points_can_annotate_points_with_nan_in_df_continuous_datashader.png and b/tests/_images/Points_can_annotate_points_with_nan_in_df_continuous_datashader.png differ diff --git a/tests/_images/Points_can_annotate_points_with_nan_in_table_X_continuous_datashader.png b/tests/_images/Points_can_annotate_points_with_nan_in_table_X_continuous_datashader.png index bf0888c0..da647972 100644 Binary files a/tests/_images/Points_can_annotate_points_with_nan_in_table_X_continuous_datashader.png and b/tests/_images/Points_can_annotate_points_with_nan_in_table_X_continuous_datashader.png differ diff --git a/tests/_images/Points_can_annotate_points_with_nan_in_table_obs_continuous_datashader.png b/tests/_images/Points_can_annotate_points_with_nan_in_table_obs_continuous_datashader.png index 21bafaf2..01488584 100644 Binary files a/tests/_images/Points_can_annotate_points_with_nan_in_table_obs_continuous_datashader.png and b/tests/_images/Points_can_annotate_points_with_nan_in_table_obs_continuous_datashader.png differ diff --git a/tests/_images/Points_datashader_continuous_color.png b/tests/_images/Points_datashader_continuous_color.png index be92016a..486d01ef 100644 Binary files a/tests/_images/Points_datashader_continuous_color.png and b/tests/_images/Points_datashader_continuous_color.png differ diff --git a/tests/_images/Points_groups_na_color_none_filters_points_datashader.png b/tests/_images/Points_groups_na_color_none_filters_points_datashader.png index b2b920a5..0126b59b 100644 Binary files a/tests/_images/Points_groups_na_color_none_filters_points_datashader.png and b/tests/_images/Points_groups_na_color_none_filters_points_datashader.png differ diff --git a/tests/_images/Points_mpl_and_datashader_point_sizes_agree_after_altered_dpi.png b/tests/_images/Points_mpl_and_datashader_point_sizes_agree_after_altered_dpi.png index 3cfff9c7..d4800058 100644 Binary files a/tests/_images/Points_mpl_and_datashader_point_sizes_agree_after_altered_dpi.png and b/tests/_images/Points_mpl_and_datashader_point_sizes_agree_after_altered_dpi.png differ diff --git a/tests/_images/Points_points_continuous_color_column_datashader.png b/tests/_images/Points_points_continuous_color_column_datashader.png index 6610c100..7ade0b9c 100644 Binary files a/tests/_images/Points_points_continuous_color_column_datashader.png and b/tests/_images/Points_points_continuous_color_column_datashader.png differ diff --git a/tests/_images/Points_points_render_permutations.png b/tests/_images/Points_points_render_permutations.png new file mode 100644 index 00000000..71b9198e Binary files /dev/null and b/tests/_images/Points_points_render_permutations.png differ diff --git a/tests/_images/Shapes_circle_render_permutations.png b/tests/_images/Shapes_circle_render_permutations.png new file mode 100644 index 00000000..fee45c0b Binary files /dev/null and b/tests/_images/Shapes_circle_render_permutations.png differ diff --git a/tests/_images/Shapes_polygon_render_permutations.png b/tests/_images/Shapes_polygon_render_permutations.png new file mode 100644 index 00000000..094ed965 Binary files /dev/null and b/tests/_images/Shapes_polygon_render_permutations.png differ diff --git a/tests/pl/test_render_labels.py b/tests/pl/test_render_labels.py index 7a942a71..44d62225 100644 --- a/tests/pl/test_render_labels.py +++ b/tests/pl/test_render_labels.py @@ -14,7 +14,15 @@ import spatialdata_plot # noqa: F401 from spatialdata_plot._logging import logger, logger_warns -from tests.conftest import DPI, PlotTester, PlotTesterMeta, _viridis_with_under_over, get_standard_RNG +from tests.conftest import ( + CANVAS_HEIGHT, + CANVAS_WIDTH, + DPI, + PlotTester, + PlotTesterMeta, + _viridis_with_under_over, + get_standard_RNG, +) sc.pl.set_rcParams_defaults() sc.set_figure_params(dpi=DPI, color_map="viridis") @@ -40,6 +48,19 @@ def _annotate_labels_with_outline_columns(sdata: SpatialData) -> SpatialData: class TestLabels(PlotTester, metaclass=PlotTesterMeta): + def test_plot_labels_render_permutations(self, sdata_blobs: SpatialData): + """2x2 of (fill / as_points) x (matplotlib / datashader); fill is backend-invariant, as_points should match.""" + panels = [ + ("fill · matplotlib", {"method": "matplotlib"}), + ("fill · datashader", {"method": "datashader"}), + ("as_points · matplotlib", {"as_points": True, "size": 150, "method": "matplotlib"}), + ("as_points · datashader", {"as_points": True, "size": 150, "method": "datashader"}), + ] + _, axs = plt.subplots(2, 2, figsize=(CANVAS_WIDTH / DPI, CANVAS_HEIGHT / DPI), dpi=DPI) + for ax, (title, kw) in zip(axs.ravel(), panels, strict=True): + sdata_blobs.pl.render_labels("blobs_labels", color="channel_0_sum", colorbar=False, **kw).pl.show(ax=ax) + ax.set_title(title, fontsize=8) + def test_plot_can_render_labels(self, sdata_blobs: SpatialData): sdata_blobs.pl.render_labels(element="blobs_labels").pl.show() diff --git a/tests/pl/test_render_points.py b/tests/pl/test_render_points.py index 4fbbc0d8..4d2b4afd 100644 --- a/tests/pl/test_render_points.py +++ b/tests/pl/test_render_points.py @@ -31,8 +31,16 @@ _ds_shade_categorical, _pad_degenerate_extent, ) -from spatialdata_plot.pl.render import _warn_groups_ignored_continuous -from tests.conftest import DPI, PlotTester, PlotTesterMeta, _viridis_with_under_over, get_standard_RNG +from spatialdata_plot.pl.render import _marker_spread_px, _warn_groups_ignored_continuous +from tests.conftest import ( + CANVAS_HEIGHT, + CANVAS_WIDTH, + DPI, + PlotTester, + PlotTesterMeta, + _viridis_with_under_over, + get_standard_RNG, +) sc.pl.set_rcParams_defaults() sc.set_figure_params(dpi=DPI, color_map="viridis") @@ -48,6 +56,24 @@ class TestPoints(PlotTester, metaclass=PlotTesterMeta): + def test_plot_points_render_permutations(self, sdata_blobs: SpatialData): + """2x2 of (no color / continuous color) x (matplotlib / datashader). + + Marker sizes agree between the matplotlib and datashader backends in any panel layout: the + datashader spread radius is rescaled by the axes-box/canvas factor ratio so it stays at the + matplotlib marker radius (sqrt(size)*dpi/144 display px) even in multi-panel subplots. + """ + panels = [ + ("no color · matplotlib", {"method": "matplotlib"}), + ("no color · datashader", {"method": "datashader"}), + ("color · matplotlib", {"color": "instance_id", "method": "matplotlib", "colorbar": False}), + ("color · datashader", {"color": "instance_id", "method": "datashader", "colorbar": False}), + ] + _, axs = plt.subplots(2, 2, figsize=(CANVAS_WIDTH / DPI, CANVAS_HEIGHT / DPI), dpi=DPI) + for ax, (title, kw) in zip(axs.ravel(), panels, strict=True): + sdata_blobs.pl.render_points("blobs_points", size=30, **kw).pl.show(ax=ax) + ax.set_title(title, fontsize=8) + def test_plot_can_render_points(self, sdata_blobs: SpatialData): sdata_blobs.pl.render_points(element="blobs_points").pl.show() @@ -1263,6 +1289,31 @@ def test_density_defaults_silent_and_force_datashader(sdata_blobs: SpatialData, # --------------------------------------------------------------------------- +@pytest.mark.parametrize("size", [10.0, 30.0, 400.0]) +def test_marker_spread_px_layout_invariant(size): + """Datashader marker display radius stays ~constant across panel layouts (matches matplotlib). + + On-screen radius is P * factor / factor_axesbox; only factor_axesbox (axes-box size) changes between a + single panel and a 2x2 grid. The spread rescaling keeps the displayed radius at the matplotlib marker + radius sqrt(size)*dpi/144 to within spread-px rounding (<1px). The pre-fix /100 formula had no layout + term and halved in 2x2 (diff >> 1px). + """ + dpi = 80 + rx = ry = 100.0 + fig_px = 600.0 + factor = rx / fig_px # figure-resolution canvas, identical in both layouts + + def displayed_radius(axes_box_px): + factor_axesbox = max(rx / axes_box_px, ry / axes_box_px) + return _marker_spread_px(size, dpi, factor, factor_axesbox) * factor / factor_axesbox + + target = np.sqrt(size) * dpi / 144 + d_single = displayed_radius(480.0) # axes ~ full figure + d_grid = displayed_radius(240.0) # 2x2 subplot, half the window + assert abs(d_single - d_grid) < 1.0, f"layout-dependent marker size: {d_single} vs {d_grid}" + assert abs(d_single - target) < 1.0 and abs(d_grid - target) < 1.0 + + def test_pad_degenerate_extent(): # Regression test for #724: a zero-width extent expands to a unit window centered on the value, # and a non-degenerate extent is passed through unchanged (so normal data is unaffected). diff --git a/tests/pl/test_render_shapes.py b/tests/pl/test_render_shapes.py index af5ff369..6cc9beef 100644 --- a/tests/pl/test_render_shapes.py +++ b/tests/pl/test_render_shapes.py @@ -18,7 +18,15 @@ import spatialdata_plot # noqa: F401 from spatialdata_plot._logging import logger, logger_warns -from tests.conftest import DPI, PlotTester, PlotTesterMeta, _viridis_with_under_over, get_standard_RNG +from tests.conftest import ( + CANVAS_HEIGHT, + CANVAS_WIDTH, + DPI, + PlotTester, + PlotTesterMeta, + _viridis_with_under_over, + get_standard_RNG, +) sc.pl.set_rcParams_defaults() sc.set_figure_params(dpi=DPI, color_map="viridis") @@ -48,6 +56,43 @@ def _annotate_polygons_with_outline_columns(sdata: SpatialData) -> SpatialData: class TestShapes(PlotTester, metaclass=PlotTesterMeta): + def test_plot_circle_render_permutations(self, monkeypatch): + """2x2 of (geometry / as_points) x (matplotlib / datashader); each row should look similar across backends.""" + import spatialdata_plot.pl.render as render_mod + + # exercise the datashader circle fast-path (points) on this small uniform set + monkeypatch.setattr(render_mod, "_CIRCLE_FAST_PATH_MIN", 1) + + grid = np.arange(8) * 10.0 + cx, cy = (a.ravel() for a in np.meshgrid(grid, grid)) + gdf = gpd.GeoDataFrame({"radius": np.full(cx.size, 3.0)}, geometry=gpd.points_from_xy(cx, cy)) + sdata = SpatialData(shapes={"circ": ShapesModel.parse(gdf)}) + + _, axs = plt.subplots(2, 2, figsize=(CANVAS_WIDTH / DPI, CANVAS_HEIGHT / DPI), dpi=DPI) + panels = [ + (axs[0, 0], "geometry · matplotlib", {"method": "matplotlib"}), + (axs[0, 1], "geometry · datashader", {"method": "datashader"}), + (axs[1, 0], "as_points · matplotlib", {"method": "matplotlib", "as_points": True, "size": 25}), + (axs[1, 1], "as_points · datashader", {"method": "datashader", "as_points": True, "size": 25}), + ] + for ax, title, kw in panels: + sdata.pl.render_shapes("circ", **kw).pl.show(ax=ax) + ax.set_title(title, fontsize=8) + + def test_plot_polygon_render_permutations(self, sdata_blobs_shapes_annotated: SpatialData): + """2x2 of (geometry / as_points) x (matplotlib / datashader) for polygons; columns should look alike.""" + panels = [ + ("geometry · matplotlib", {"method": "matplotlib"}), + ("geometry · datashader", {"method": "datashader"}), + ("as_points · matplotlib", {"as_points": True, "size": 150, "method": "matplotlib"}), + ("as_points · datashader", {"as_points": True, "size": 150, "method": "datashader"}), + ] + annotated = sdata_blobs_shapes_annotated + _, axs = plt.subplots(2, 2, figsize=(CANVAS_WIDTH / DPI, CANVAS_HEIGHT / DPI), dpi=DPI) + for ax, (title, kw) in zip(axs.ravel(), panels, strict=True): + annotated.pl.render_shapes("blobs_polygons", color="value", colorbar=False, **kw).pl.show(ax=ax) + ax.set_title(title, fontsize=8) + def test_plot_can_render_circles(self, sdata_blobs: SpatialData): sdata_blobs.pl.render_shapes(element="blobs_circles").pl.show() @@ -1839,3 +1884,76 @@ def test_continuous_fill_colorbar_matches_pixel_range(sdata_blobs_shapes_annotat clims = [c.get_clim() for c in ax.collections if isinstance(c, PatchCollection)] plt.close(fig) assert clims == [(1.0, 5.0)] # fixture's value column is [1, 2, 3, 4, 5] + + +# --------------------------------------------------------------------------- +# Adaptive circle buffer resolution (datashader perf) +# --------------------------------------------------------------------------- + + +def test_circle_quad_segs_step_rule(): + """Coarse polygon only for sub-pixel (≤2px) discs; any visible disc keeps the faithful default (16).""" + from spatialdata_plot.pl._datashader import _circle_quad_segs + + assert [_circle_quad_segs(r) for r in (0.5, 2.0, 2.1, 8.0, 50.0)] == [4, 4, 16, 16, 16] + assert _circle_quad_segs(float("nan")) == 16 # all-NaN radius falls back to the faithful default + + +def test_circle_buffer_fidelity_to_default(): + """A reduced-vertex circle (quad_segs=4) still matches shapely's default (resolution=16) within 3%.""" + c = Point(5.0, 5.0) + reduced = c.buffer(1.0, quad_segs=4) + full = c.buffer(1.0, quad_segs=16) + iou = reduced.intersection(full).area / reduced.union(full).area + assert iou >= 0.97 + + +def _uniform_circle_gdf(n: int, radius) -> gpd.GeoDataFrame: + import shapely + + radii = radius if hasattr(radius, "__len__") else [radius] * n + geom = gpd.GeoSeries(shapely.points(np.column_stack([np.arange(n), np.zeros(n)]))) + return gpd.GeoDataFrame({"radius": radii}, geometry=geom) + + +def test_circles_render_as_points_gate(): + """Phase 2 gate fires only for a large, uniform-radius, outline-free, default-shape circle element.""" + from types import SimpleNamespace + + from spatialdata_plot.pl.render import _CIRCLE_FAST_PATH_MIN, _circles_render_as_points + + big = _CIRCLE_FAST_PATH_MIN + 1 + + def gate(gdf, **kw): + rp = SimpleNamespace(**{"shape": None, "outline_alpha": (0.0, 0.0), **kw}) + return _circles_render_as_points(gdf, gdf.geometry.type == "Point", rp) + + assert gate(_uniform_circle_gdf(big, 2.0)) is True + assert gate(_uniform_circle_gdf(10, 2.0)) is False # too few + assert gate(_uniform_circle_gdf(big, np.arange(big) + 1.0)) is False # varying radius + assert gate(_uniform_circle_gdf(big, 2.0), outline_alpha=(1.0, 0.0)) is False # outline requested + assert gate(_uniform_circle_gdf(big, 2.0), shape="square") is False # custom shape + assert gate(_uniform_circle_gdf(big, np.where(np.arange(big) == 0, np.nan, 2.0))) is False # NaN radius + + +def test_circle_fast_path_renders_without_error(monkeypatch): + """A uniform circle element above the (patched-low) threshold renders via the datashader point path.""" + import spatialdata_plot.pl.render as render_mod + + monkeypatch.setattr(render_mod, "_CIRCLE_FAST_PATH_MIN", 4) + # Spy on the centroid renderer: only the fast path routes circles through it (with a disc radius); + # the ordinary buffer path does not. This pins that the fast path actually fired, not just "an image". + seen = {} + real = render_mod._render_centroids_as_points + + def spy(*args, **kwargs): + seen["radius"] = kwargs.get("radius") + return real(*args, **kwargs) + + monkeypatch.setattr(render_mod, "_render_centroids_as_points", spy) + sdata = SpatialData(shapes={"circ": ShapesModel.parse(_uniform_circle_gdf(20, 2.0))}) + fig, ax = plt.subplots() + sdata.pl.render_shapes("circ", method="datashader").pl.show(ax=ax) + assert seen.get("radius") is not None # fast path ran and sized the dots to the disc radius + assert len(ax.images) >= 1 # datashader raster produced + plt.close(fig)