diff --git a/src/spatialdata_plot/pl/basic.py b/src/spatialdata_plot/pl/basic.py index f864482c..89b6292c 100644 --- a/src/spatialdata_plot/pl/basic.py +++ b/src/spatialdata_plot/pl/basic.py @@ -64,6 +64,7 @@ _expand_color_panels, _get_cs_contents, _get_elements_to_be_rendered, + _get_extent_fast, _get_valid_cs, _get_wanted_render_elements, _maybe_set_colors, @@ -71,6 +72,7 @@ _prepare_cmap_norm, _prepare_params_plot, _set_outline, + _validate_as_points_size, _validate_graph_render_params, _validate_image_render_params, _validate_label_render_params, @@ -332,6 +334,8 @@ def render_shapes( colorbar_params: dict[str, object] | None = None, datashader_reduction: _DsReduction | None = None, transfunc: Callable[[float], float] | None = None, + as_points: bool = False, + size: float | int = 1.0, ) -> sd.SpatialData: """ Render shapes elements in SpatialData. @@ -448,6 +452,8 @@ def render_shapes( sd.SpatialData A copy of the SpatialData object with the rendering parameters stored in its plotting tree. """ + if as_points: + _validate_as_points_size(size) panel_param_dicts = _expand_color_panels( self._sdata, color, @@ -515,6 +521,8 @@ def render_shapes( ds_reduction=param_values["ds_reduction"], colorbar=param_values["colorbar"], colorbar_params=param_values["colorbar_params"], + as_points=as_points, + size=size, panel_key=panel_key, ) n_steps += 1 @@ -953,6 +961,9 @@ def render_labels( table_layer: str | None = None, gene_symbols: str | None = None, transfunc: Callable[[float], float] | None = None, + as_points: bool = False, + size: float | int = 1.0, + method: str | None = None, ) -> sd.SpatialData: """ Render labels elements in SpatialData. @@ -1038,12 +1049,18 @@ def render_labels( in another column of ``var``. Mimics scanpy's ``gene_symbols`` parameter. transfunc : Callable[[float], float] | None, optional Optional transformation applied to the continuous color vector before normalization and colormap mapping. + method : str | None, optional + Backend for ``as_points`` centroids: ``'matplotlib'`` or ``'datashader'``. When ``None``, + matplotlib is used unless there are more than ~500k centroids. Datashader is skipped (with a + warning) when the colouring cannot be aggregated (e.g. labels with no color column). Returns ------- sd.SpatialData A copy of the SpatialData object with the rendering parameters stored in its plotting tree. """ + if as_points: + _validate_as_points_size(size) panel_param_dicts = _expand_color_panels( self._sdata, color, @@ -1101,6 +1118,9 @@ def render_labels( zorder=n_steps, colorbar=param_values["colorbar"], colorbar_params=param_values["colorbar_params"], + as_points=as_points, + size=size, + method=method, panel_key=panel_key, ) n_steps += 1 @@ -1812,7 +1832,7 @@ def _draw_colorbar( empty_shape_elements = [ name for name in wanted_elements - if name in sdata.shapes and not sdata.shapes[name]["geometry"].apply(lambda g: not g.is_empty).any() + if name in sdata.shapes and sdata.shapes[name]["geometry"].is_empty.all() ] if empty_shape_elements: raise ValueError( @@ -1820,7 +1840,8 @@ def _draw_colorbar( "all geometries are empty. Drop the element or restore at least one non-empty geometry." ) - extent = get_extent( + # fast path for axis-aligned transforms; identical result, falls back to get_extent otherwise + extent = _get_extent_fast( sdata, coordinate_system=cs, has_images=has_images and wants_images, diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index 17236af7..3e8dea53 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -21,7 +21,7 @@ from anndata import AnnData from matplotlib import patheffects from matplotlib.cm import ScalarMappable -from matplotlib.colors import ListedColormap, Normalize +from matplotlib.colors import Colormap, ListedColormap, Normalize from scanpy._settings import settings as sc_settings from scanpy.plotting._tools.scatterplots import _add_categorical_legend from spatialdata import get_extent, get_values @@ -77,11 +77,13 @@ _maybe_set_colors, _mpl_ax_contains_elements, _multiscale_to_spatial_image, + _pixel_to_coord, _prepare_cmap_norm, _prepare_transformation, _rasterize_if_necessary, _rasterize_if_necessary_datashader, _set_color_source_vec, + _stream_label_centroid_stats, _validate_polygons, ) @@ -750,6 +752,31 @@ def _render_shapes( color_source_vector = color_source_vector.remove_unused_categories() 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()])) + _render_centroids_as_points( + ax, + render_params, + x=xy[:, 0], + y=xy[:, 1], + color_vector=color_vector, + color_source_vector=color_source_vector, + norm=norm, + na_color=render_params.cmap_params.na_color, + adata=table, + col_for_color=col_for_color, + palette=palette, + fig_params=fig_params, + legend_params=legend_params, + colorbar_requests=colorbar_requests, + ) + return + # convert shapes if necessary if render_params.shape is not None: current_type = shapes["geometry"].type @@ -1051,6 +1078,268 @@ def _render_shapes( ) +def _scatter_points( + ax: matplotlib.axes.SubplotBase, + x: Any, + y: Any, + color_vector: Any, + *, + size: float, + cmap: Colormap, + norm: Normalize | None, + alpha: float, + trans_data: Any, + zorder: int, +) -> Any: + """Draw one marker per (x, y) colored by ``color_vector`` via ``ax.scatter``. + + Shared scatter primitive for points and the centroid "fast mode" of shapes/labels; + ``color_vector`` is per-point hex strings or numeric values mapped through ``cmap``/``norm``. + """ + return ax.scatter( + x, + y, + s=size, + c=color_vector, + rasterized=sc_settings._vector_friendly, + cmap=cmap, + norm=norm, + alpha=alpha, + transform=trans_data, + zorder=zorder, + plotnonfinite=True, # nan points should be rendered as well + ) + + +# Above this many centroids matplotlib's per-glyph draw (~18 µs/dot) dominates, so auto-switch to +# datashader. Conservative because datashader changes the look (density raster); only used when method=None. +AS_POINTS_DS_AUTO = 500_000 + + +def _resolve_as_points_method( + render_params: ShapesRenderParams | LabelsRenderParams, *, n: int, allow_datashader: bool +) -> str: + """Pick the as_points backend. matplotlib by default; datashader only when it can represent the colors.""" + method = render_params.method + if not allow_datashader or n == 0: + # no-color labels get one random colour per cell (`_map_color_seg` Case C), which datashader's + # aggregate-then-shade model cannot represent; an empty element just has nothing to draw. + if method == "datashader" and not allow_datashader: + logger.warning("`as_points` cannot use datashader for this colouring; falling back to matplotlib.") + return "matplotlib" + if method == "datashader": + return "datashader" + if method is None and n > AS_POINTS_DS_AUTO: + logger.info( + f"`as_points`: {n} centroids exceed {AS_POINTS_DS_AUTO}; using the datashader backend " + "(pass `method='matplotlib'` to override)." + ) + return "datashader" + return "matplotlib" + + +def _render_centroids_as_points( + ax: matplotlib.axes.SubplotBase, + render_params: ShapesRenderParams | LabelsRenderParams, + *, + x: Any, + y: Any, + color_vector: Any, + color_source_vector: pd.Series | None, + norm: Normalize | None, + na_color: Any, + adata: AnnData | None, + col_for_color: str | None, + palette: Any, + fig_params: FigParams, + legend_params: LegendParams, + colorbar_requests: list[ColorbarSpec] | None, + allow_datashader: bool = True, +) -> None: + """Render one dot per cell at ``(x, y)`` (coordinate-system coords), colored like the fill. + + Shared "fast mode" for shapes/labels. Backend is matplotlib unless ``render_params.method`` or the + size threshold selects datashader (and the colouring supports it). ``norm``/``na_color`` are explicit + because they differ between the shapes and labels paths. + """ + method = _resolve_as_points_method(render_params, n=len(x), allow_datashader=allow_datashader) + if method == "datashader": + df = pd.DataFrame({"x": x, "y": y}) + cax, color_vector, color_source_vector = _datashader_points( + ax, + df, + col_for_color=col_for_color, + color_vector=color_vector, + color_source_vector=color_source_vector, + norm=norm, + cmap_params=render_params.cmap_params, + alpha=render_params.fill_alpha, + size=render_params.size, + zorder=render_params.zorder, + # as_points has no user reduction knob: centroids rarely share a pixel, and "max" + # (the top cell) keeps the datashader output closest to the matplotlib backend. + ds_reduction=None, + default_reduction="max", + density=False, + density_how="linear", + fig_params=fig_params, + ) + else: + cax = _scatter_points( + ax, + x, + y, + color_vector, + size=render_params.size, + cmap=render_params.cmap_params.cmap, + norm=norm, + alpha=render_params.fill_alpha, + trans_data=ax.transData, # x/y are coordinate-system coords + zorder=render_params.zorder, + ) + _add_legend_and_colorbar( + ax=ax, + cax=cax, + fig_params=fig_params, + adata=adata, + col_for_color=col_for_color, + color_source_vector=color_source_vector, + color_vector=color_vector, + palette=palette, + alpha=render_params.fill_alpha, + na_color=na_color, + legend_params=legend_params, + colorbar=render_params.colorbar, + colorbar_params=render_params.colorbar_params, + colorbar_requests=colorbar_requests, + ) + + +def _datashader_points( + ax: matplotlib.axes.SubplotBase, + df: pd.DataFrame, + *, + col_for_color: str | None, + color_vector: Any, + color_source_vector: Any, + norm: Normalize | None, + cmap_params: CmapParams, + alpha: float, + size: float, + zorder: int, + ds_reduction: _DsReduction | None, + density: bool, + density_how: str, + fig_params: FigParams, + default_reduction: _DsReduction = "sum", +) -> 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 uses the same values. Primitives are explicit because shapes/labels params lack ``alpha``/density. + """ + # spread radius from marker size (matplotlib points**2, dpi-scaled); off under density to keep counts crisp + px: int | None = None if density else int(np.round(np.sqrt(size) * (fig_params.fig.dpi / 100))) + + plot_width, plot_height, x_ext, y_ext, factor = _datashader_canvas_from_dataframe(df, fig_params) + 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 + if col_for_color is not None and col_for_color not in df.columns: + series_index = df.index + if color_source_vector is not None: + if isinstance(color_source_vector, dd.Series): + color_source_vector = color_source_vector.compute() + source_series = ( + color_source_vector.reindex(series_index) + if isinstance(color_source_vector, pd.Series) + else pd.Series(color_source_vector, index=series_index) + ) + df[col_for_color] = source_series + else: + if isinstance(color_vector, dd.Series): + color_vector = color_vector.compute() + color_series = ( + color_vector.reindex(series_index) + if isinstance(color_vector, pd.Series) + else pd.Series(color_vector, index=series_index) + ) + df[col_for_color] = color_series + + color_dtype = df[col_for_color].dtype if col_for_color is not None else None + color_by_categorical = col_for_color is not None and ( + color_source_vector is not None + or isinstance(color_dtype, pd.CategoricalDtype) + or pd.api.types.is_object_dtype(color_dtype) + or pd.api.types.is_string_dtype(color_dtype) + ) + if color_by_categorical and not isinstance(color_dtype, pd.CategoricalDtype): + df[col_for_color] = df[col_for_color].astype("category") + + agg, reduction_bounds, nan_agg = _ds_aggregate( + cvs, + df, + col_for_color, + color_by_categorical, + ds_reduction, + default_reduction, + "points", + ) + + agg, color_span = _apply_ds_norm(agg, norm) + na_color_hex = _hex_no_alpha(cmap_params.na_color.get_hex()) + if cmap_params.na_color.is_fully_transparent(): + nan_agg = None + color_key = _build_color_key(df, col_for_color, color_by_categorical, color_vector, na_color_hex) + + if ( + color_vector is not None + and len(color_vector) > 0 + and isinstance(color_vector[0], str) + and color_vector[0].startswith("#") + ): + # color_vector usually holds only a few distinct hex strings (one per category), so strip + # alpha on the unique values and map back rather than parsing once per point. + unique_hex, inverse = np.unique(color_vector, return_inverse=True) + color_vector = np.asarray([_hex_no_alpha(c) for c in unique_hex])[inverse] + + shade_how = density_how if density else "linear" + # Plain density (no color column) uses the cmap as a sequential gradient over counts; the + # categorical path collapses to a single color and only modulates alpha. + plain_density = density and col_for_color is None + + nan_shaded = None + if not plain_density and (color_by_categorical or col_for_color is None): + shaded = _ds_shade_categorical( + agg, + color_key, + color_vector, + alpha, + spread_px=px, + how=shade_how, + density=density, + ) + else: + shaded, nan_shaded, reduction_bounds = _ds_shade_continuous( + agg, + color_span, + norm, + cmap_params.cmap, + alpha, + reduction_bounds, + nan_agg, + na_color_hex, + spread_px=px, + ds_reduction=ds_reduction, + how=shade_how, + ) + + _render_ds_image(ax, shaded, factor, zorder, x_min=x_ext[0], y_min=y_ext[0], nan_result=nan_shaded) + cax = _build_ds_colorbar(reduction_bounds, norm, cmap_params.cmap) + return cax, color_vector, color_source_vector + + def _render_points( sdata: sd.SpatialData, render_params: PointsRenderParams, @@ -1261,14 +1550,6 @@ def _render_points( if method == "datashader": _log_datashader_method(method, render_params.ds_reduction, _default_reduction) - # NOTE: s in matplotlib is in units of points**2 - # use dpi/100 as a factor for cases where dpi!=100 - # Under density, spreading would smear the count signal across pixels and - # distort apparent density at sparse edges, so disable it unconditionally. - px: int | None = ( - None if render_params.density else int(np.round(np.sqrt(render_params.size) * (fig_params.fig.dpi / 100))) - ) - # Apply transformations and materialize to pandas immediately so # datashader aggregates without dask scheduler overhead. See #379. transformed_element = PointsModel.parse( @@ -1283,138 +1564,38 @@ def _render_points( # any other elements on the axes. return - plot_width, plot_height, x_ext, y_ext, factor = _datashader_canvas_from_dataframe( - transformed_element, fig_params - ) - - # use datashader for the visualization of points - 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 transformed element with positional alignment - if col_for_color is not None and col_for_color not in transformed_element.columns: - series_index = transformed_element.index - if color_source_vector is not None: - if isinstance(color_source_vector, dd.Series): - color_source_vector = color_source_vector.compute() - source_series = ( - color_source_vector.reindex(series_index) - if isinstance(color_source_vector, pd.Series) - else pd.Series(color_source_vector, index=series_index) - ) - transformed_element[col_for_color] = source_series - else: - if isinstance(color_vector, dd.Series): - color_vector = color_vector.compute() - color_series = ( - color_vector.reindex(series_index) - if isinstance(color_vector, pd.Series) - else pd.Series(color_vector, index=series_index) - ) - transformed_element[col_for_color] = color_series - - color_dtype = transformed_element[col_for_color].dtype if col_for_color is not None else None - color_by_categorical = col_for_color is not None and ( - color_source_vector is not None - or isinstance(color_dtype, pd.CategoricalDtype) - or pd.api.types.is_object_dtype(color_dtype) - or pd.api.types.is_string_dtype(color_dtype) - ) - if color_by_categorical and not isinstance(color_dtype, pd.CategoricalDtype): - transformed_element[col_for_color] = transformed_element[col_for_color].astype("category") - - agg, reduction_bounds, nan_agg = _ds_aggregate( - cvs, - transformed_element, - col_for_color, - color_by_categorical, - render_params.ds_reduction, - _default_reduction, - "points", - ) - - agg, color_span = _apply_ds_norm(agg, norm) - na_color_hex = _hex_no_alpha(render_params.cmap_params.na_color.get_hex()) - if render_params.cmap_params.na_color.is_fully_transparent(): - nan_agg = None - color_key = _build_color_key( - transformed_element, - col_for_color, - color_by_categorical, - color_vector, - na_color_hex, - ) - - if ( - color_vector is not None - and len(color_vector) > 0 - and isinstance(color_vector[0], str) - and color_vector[0].startswith("#") - ): - # color_vector usually holds only a few distinct hex strings (one per - # category), so strip alpha on the unique values and map back rather than - # calling the per-string parser once per point. - unique_hex, inverse = np.unique(color_vector, return_inverse=True) - color_vector = np.asarray([_hex_no_alpha(c) for c in unique_hex])[inverse] - - shade_how = render_params.density_how if render_params.density else "linear" - # Plain density (no color column) must use the user-facing cmap as a sequential - # gradient over counts; the categorical path collapses to a single color and only - # modulates alpha, which renders as a flat hue regardless of density. - plain_density = render_params.density and col_for_color is None - - nan_shaded = None - if not plain_density and (color_by_categorical or col_for_color is None): - shaded = _ds_shade_categorical( - agg, - color_key, - color_vector, - render_params.alpha, - spread_px=px, - how=shade_how, - density=render_params.density, - ) - else: - shaded, nan_shaded, reduction_bounds = _ds_shade_continuous( - agg, - color_span, - norm, - render_params.cmap_params.cmap, - render_params.alpha, - reduction_bounds, - nan_agg, - na_color_hex, - spread_px=px, - ds_reduction=render_params.ds_reduction, - how=shade_how, - ) - - _render_ds_image( + cax, color_vector, color_source_vector = _datashader_points( ax, - shaded, - factor, - render_params.zorder, - x_min=x_ext[0], - y_min=y_ext[0], - nan_result=nan_shaded, + transformed_element, + col_for_color=col_for_color, + color_vector=color_vector, + color_source_vector=color_source_vector, + norm=norm, + cmap_params=render_params.cmap_params, + alpha=render_params.alpha, + size=render_params.size, + zorder=render_params.zorder, + ds_reduction=render_params.ds_reduction, + density=render_params.density, + density_how=render_params.density_how, + fig_params=fig_params, + default_reduction=_default_reduction, ) - cax = _build_ds_colorbar(reduction_bounds, norm, render_params.cmap_params.cmap) - elif method == "matplotlib": # update axis limits if plot was empty before (necessary if datashader comes after) update_parameters = not _mpl_ax_contains_elements(ax) - cax = ax.scatter( + cax = _scatter_points( + ax, adata[:, 0].X.flatten(), adata[:, 1].X.flatten(), - s=render_params.size, - c=color_vector, - rasterized=sc_settings._vector_friendly, + color_vector, + size=render_params.size, cmap=render_params.cmap_params.cmap, norm=norm, alpha=render_params.alpha, - transform=trans_data, + trans_data=trans_data, zorder=render_params.zorder, - plotnonfinite=True, # nan points should be rendered as well ) if update_parameters: # necessary if points are plotted with mpl first and then with datashader @@ -2134,7 +2315,7 @@ def _render_labels( # get instance id based on subsetted table instance_id = np.unique(table.obs[instance_key].values) - _, trans_data = _prepare_transformation(label, coordinate_system, ax) + trans, trans_data = _prepare_transformation(label, coordinate_system, ax) na_color = ( render_params.color @@ -2187,9 +2368,9 @@ def _render_labels( len(instance_id), ) - # rasterize could have removed labels from label - # only problematic if color is specified - if rasterize and (col_for_color is not None or col_for_outline_color is not None): + # rasterize/downsampling can drop labels from the raster; remove their (now-absent) instance ids + # so per-instance colors stay aligned and as_points does not emit dots for dropped cells. + if rasterize and (col_for_color is not None or col_for_outline_color is not None or render_params.as_points): mask = np.isin(instance_id, unique_labels) instance_id = instance_id[mask] if col_for_color is not None: @@ -2238,6 +2419,60 @@ def _render_labels( if color_source_vector is None and render_params.transfunc is not None: color_vector = render_params.transfunc(color_vector) + if render_params.as_points: + # Fast mode: one dot per label at its centroid. Compute on the *rendered* raster (already + # downsampled to ~display resolution above) and draw with its `trans_data`, so this is cheap + # and the dots land where the cells are. Centroid error is sub-pixel at display resolution. + logger.info("`as_points=True`: rendering label centroids; `contour_px` and `outline_*` are ignored.") + keep = instance_id != 0 # background label 0 has no centroid + point_ids = instance_id[keep] + labels, x_idx, y_idx, _area = _stream_label_centroid_stats(label.data) + centroids = pd.DataFrame( + { + "x": _pixel_to_coord(x_idx, label.coords["x"].values), + "y": _pixel_to_coord(y_idx, label.coords["y"].values), + }, + index=labels, + ) + # coerce so str/object table ids (e.g. Xenium) match the integer raster labels instead of NaN + centroids = centroids.reindex(point_ids.astype(labels.dtype, copy=False)) + # datashader cannot represent one distinct random colour per cell (the no-color path below) + allow_datashader = True + if col_for_color is None and not na_color.color_modified_by_user(): + # no color column: one distinct random colour per cell, matching the mask path + # (`_map_color_seg` Case C) instead of collapsing every dot to a single na_color. + point_color_vector = np.random.default_rng(42).random((len(point_ids), 3)) + point_color_source_vector = None + allow_datashader = False + elif len(color_vector) == len(instance_id): + # data-driven colour is per-instance + point_color_vector = np.asarray(color_vector)[keep] + point_color_source_vector = None if color_source_vector is None else color_source_vector[keep] + else: + # literal colour / user-set na_color -> one colour per centroid + point_color_vector = np.asarray([na_color.get_hex_with_alpha()] * len(point_ids)) + point_color_source_vector = None + # transform rendered-raster intrinsic centroids to coordinate-system coords + xy = trans.transform(np.column_stack([centroids["x"].to_numpy(), centroids["y"].to_numpy()])) + _render_centroids_as_points( + ax, + render_params, + x=xy[:, 0], + 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 + na_color=na_color, + adata=table if table_name is not None else None, + col_for_color=col_for_color, + palette=palette, + fig_params=fig_params, + legend_params=legend_params, + colorbar_requests=colorbar_requests, + allow_datashader=allow_datashader, + ) + return + def _draw_labels( seg_erosionpx: int | None, seg_boundaries: bool, diff --git a/src/spatialdata_plot/pl/render_params.py b/src/spatialdata_plot/pl/render_params.py index b021c305..28a110b9 100644 --- a/src/spatialdata_plot/pl/render_params.py +++ b/src/spatialdata_plot/pl/render_params.py @@ -253,6 +253,9 @@ class ShapesRenderParams: table_name: str | None = None table_layer: str | None = None shape: Literal["circle", "hex", "visium_hex", "square"] | None = None + # Fast mode: render each shape as a single dot at its centroid instead of its geometry. + as_points: bool = False + size: float = 1.0 # marker size for as_points (matplotlib scatter ``s``) ds_reduction: _DsReduction | None = None colorbar: bool | str | None = "auto" colorbar_params: dict[str, object] | None = None @@ -328,6 +331,11 @@ class LabelsRenderParams: zorder: int = 0 colorbar: bool | str | None = "auto" colorbar_params: dict[str, object] | None = None + # Fast mode: render each label as a single dot at its centroid instead of the mask. + as_points: bool = False + size: float = 1.0 # marker size for as_points (matplotlib scatter ``s``) + # Backend for the as_points centroids: None auto-selects (datashader above ~500k dots). + method: str | None = None # Multi-panel color: when set, this render entry belongs to the panel identified by this # color key. ``None`` means the entry is shared across every panel (e.g. a background layer). panel_key: str | None = None diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index 82b3e7a2..a0230850 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -72,6 +72,7 @@ from spatialdata.models import ( Image2DModel, Labels2DModel, + PointsModel, ShapesModel, SpatialElement, get_model, @@ -3133,6 +3134,14 @@ def _expand_color_panels( return panel_param_dicts +def _validate_as_points_size(size: float) -> None: + """Validate the centroid marker `size` used by ``render_shapes``/``render_labels`` with ``as_points=True``.""" + if isinstance(size, bool) or not isinstance(size, (int, float)): + raise TypeError("Parameter 'size' must be numeric.") + if size <= 0: + raise ValueError("Parameter 'size' must be a positive number.") + + def _validate_label_render_params( sdata: sd.SpatialData, element: str | None, @@ -3967,7 +3976,11 @@ def _get_extent_and_range_for_datashader_canvas( coordinate_system: str, fig_params: FigParams, ) -> tuple[Any, Any, list[Any], list[Any], Any]: - extent = get_extent(spatial_element, coordinate_system=coordinate_system) + # The corner-transform fast path avoids transforming every geometry just to size the canvas; + # it is identical for axis-aligned transforms and returns None (-> exact get_extent) otherwise. + extent = _element_extent_fast(spatial_element, coordinate_system) or get_extent( + spatial_element, coordinate_system=coordinate_system + ) x_ext = [float(extent["x"][0]), float(extent["x"][1])] y_ext = [float(extent["y"][0]), float(extent["y"][1])] return _compute_datashader_canvas_params(x_ext, y_ext, fig_params) @@ -4784,3 +4797,120 @@ def measure_obs( table = _resolve_measure_table(target, name, table_name) _measure_into_table(target, name, table, centroids=centroids, area=area, diameter=diameter) return None if inplace else target + + +# --- Fast extent for axis-aligned transforms ------------------------------------------------------ +# spatialdata's `get_extent(..., exact=True)` transforms every shapes/points geometry (O(N)) just to +# take a bounding box. For an axis-aligned transform (scale/flip/90deg-rotation/axis-swap + translation) +# the exact extent equals the bbox of the *transformed corners*, so we transform 4 corners instead; +# rotation/shear and other element types fall back to `get_extent`. Self-contained for upstreaming. + + +def _is_axis_aligned(linear2x2: ArrayLike, *, rtol: float = 1e-9) -> bool: + """Whether a 2x2 linear map sends axis-aligned boxes to axis-aligned boxes. + + True for a *monomial matrix* (at most one non-zero per row and per column): scale, axis flips, + 90/180/270-degree rotations and axis swaps. For such maps the exact extent equals the bounding box + of the transformed corners. A relative tolerance ignores floating-point noise in the affine matrix. + """ + m = np.abs(np.asarray(linear2x2, dtype=float)) + nz = m > rtol * (m.max() or 1.0) + return bool((nz.sum(0) <= 1).all() and (nz.sum(1) <= 1).all() and int(nz.sum()) == m.shape[0]) + + +def _element_extent_fast( + element: Any, coordinate_system: str, *, transformations: Mapping[str, Any] | None = None +) -> dict[str, tuple[float, float]] | None: + """Extent of one shapes/points element in ``coordinate_system`` via corner-transform. + + Returns ``None`` to fall back to ``get_extent`` for an unsupported type, a non-axis-aligned + transform, or an anisotropically-scaled circle (an ellipse, but spatialdata stores one radius, so + cheap and exact agree only under isotropic scale). ``transformations`` may pass a pre-fetched + ``get_transformation(element, get_all=True)`` to avoid re-reading it. + """ + model = get_model(element) + if model not in (ShapesModel, PointsModel): + return None + if transformations is None: + transformations = get_transformation(element, get_all=True) + matrix = transformations[coordinate_system].to_affine_matrix(("x", "y"), ("x", "y")) + affine = matrix[:2, :2] + if not _is_axis_aligned(affine): + return None + + if model is PointsModel: + x, y = element["x"], element["y"] + xmin, ymin, xmax, ymax = (float(v) for v in dask.compute(x.min(), y.min(), x.max(), y.max())) + else: # ShapesModel + geom = element.geometry + if (geom.geom_type == "Point").all(): # circles + a = np.abs(affine) + nz = a[a > 1e-9 * (a.max() or 1.0)] + if not np.allclose(nz, nz[0]): # anisotropic -> radius handling diverges from spatialdata + return None + x, y = geom.x.to_numpy(), geom.y.to_numpy() + r = np.asarray(element["radius"], dtype=float) + xmin = float(np.nanmin(x - r)) + ymin = float(np.nanmin(y - r)) + xmax = float(np.nanmax(x + r)) + ymax = float(np.nanmax(y + r)) + else: # polygons / multipolygons + xmin, ymin, xmax, ymax = (float(v) for v in geom.total_bounds) # C-level union; skips empties + + if not np.isfinite((xmin, ymin, xmax, ymax)).all(): # all-empty element: defer to get_extent's clear error + return None + corners = np.array([[xmin, ymin], [xmax, ymin], [xmin, ymax], [xmax, ymax]]) + tc = corners @ affine.T + matrix[:2, 2] + return {"x": (float(tc[:, 0].min()), float(tc[:, 0].max())), "y": (float(tc[:, 1].min()), float(tc[:, 1].max()))} + + +def _get_extent_fast( + sdata: SpatialData, + coordinate_system: str, + *, + has_images: bool = True, + has_labels: bool = True, + has_points: bool = True, + has_shapes: bool = True, + elements: list[str] | None = None, +) -> dict[str, tuple[float, float]]: + """Drop-in replacement for spatialdata ``get_extent(sdata, ...)`` with a fast path for shapes/points. + + Shapes/points with axis-aligned transforms get the corner-transform extent (identical result, no + per-geometry transform); everything else (rotation/shear, images, labels) delegates to spatialdata's + ``get_extent``. The union semantics match spatialdata's ``get_extent``. + """ + include = {"images": has_images, "labels": has_labels, "points": has_points, "shapes": has_shapes} + element_dicts = {"images": sdata.images, "labels": sdata.labels, "points": sdata.points, "shapes": sdata.shapes} + mins: dict[str, list[float]] = {"x": [], "y": []} + maxs: dict[str, list[float]] = {"x": [], "y": []} + for etype, edict in element_dicts.items(): + if not include[etype]: + continue + for name, element in edict.items(): + if elements is not None and name not in elements: + continue + transformations = get_transformation(element, get_all=True) + if coordinate_system not in transformations: + continue + ext = ( + _element_extent_fast(element, coordinate_system, transformations=transformations) + if etype in ("shapes", "points") + else None + ) + if ext is None: # rotation/shear, image/label (already cheap), or unsupported + ext = get_extent(element, coordinate_system=coordinate_system) + for ax in ("x", "y"): + mins[ax].append(ext[ax][0]) + maxs[ax].append(ext[ax][1]) + if not mins["x"]: # nothing matched -> defer to spatialdata (preserves its error behaviour) + return get_extent( + sdata, + coordinate_system=coordinate_system, + has_images=has_images, + has_labels=has_labels, + has_points=has_points, + has_shapes=has_shapes, + elements=elements, + ) + return {ax: (min(mins[ax]), max(maxs[ax])) for ax in ("x", "y")} diff --git a/tests/_images/Labels_can_render_labels_as_points.png b/tests/_images/Labels_can_render_labels_as_points.png new file mode 100644 index 00000000..405d744a Binary files /dev/null and b/tests/_images/Labels_can_render_labels_as_points.png differ diff --git a/tests/_images/Labels_labels_as_points_datashader.png b/tests/_images/Labels_labels_as_points_datashader.png new file mode 100644 index 00000000..96161dd8 Binary files /dev/null and b/tests/_images/Labels_labels_as_points_datashader.png differ diff --git a/tests/_images/Labels_labels_as_points_datashader_categorical.png b/tests/_images/Labels_labels_as_points_datashader_categorical.png new file mode 100644 index 00000000..bf716cb2 Binary files /dev/null and b/tests/_images/Labels_labels_as_points_datashader_categorical.png differ diff --git a/tests/_images/Labels_labels_as_points_respects_size.png b/tests/_images/Labels_labels_as_points_respects_size.png new file mode 100644 index 00000000..305c0d95 Binary files /dev/null and b/tests/_images/Labels_labels_as_points_respects_size.png differ diff --git a/tests/_images/Shapes_can_render_circles_as_points.png b/tests/_images/Shapes_can_render_circles_as_points.png new file mode 100644 index 00000000..d6b8d3fb Binary files /dev/null and b/tests/_images/Shapes_can_render_circles_as_points.png differ diff --git a/tests/_images/Shapes_shapes_as_points_datashader.png b/tests/_images/Shapes_shapes_as_points_datashader.png new file mode 100644 index 00000000..a09677a1 Binary files /dev/null and b/tests/_images/Shapes_shapes_as_points_datashader.png differ diff --git a/tests/_images/Shapes_shapes_as_points_respects_size.png b/tests/_images/Shapes_shapes_as_points_respects_size.png new file mode 100644 index 00000000..69451763 Binary files /dev/null and b/tests/_images/Shapes_shapes_as_points_respects_size.png differ diff --git a/tests/pl/test_render_labels.py b/tests/pl/test_render_labels.py index 1babf756..a46cf3b3 100644 --- a/tests/pl/test_render_labels.py +++ b/tests/pl/test_render_labels.py @@ -435,6 +435,30 @@ def test_plot_can_color_labels_by_gene_symbols(self, sdata_blobs: SpatialData): "blobs_labels", color="GeneA", table_name="table", gene_symbols="gene_symbol" ).pl.show() + def test_plot_can_render_labels_as_points(self, sdata_blobs: SpatialData): + """as_points draws one colored dot per label at its centroid instead of the mask.""" + sdata_blobs.pl.render_labels("blobs_labels", color="instance_id", as_points=True, size=100).pl.show() + + def test_plot_labels_as_points_respects_size(self, sdata_blobs: SpatialData): + """size sets the scatter marker area; larger size -> larger dots.""" + sdata_blobs.pl.render_labels("blobs_labels", color="instance_id", as_points=True, size=600).pl.show() + + def test_plot_labels_as_points_datashader(self, sdata_blobs: SpatialData): + """as_points with method='datashader' rasterizes the colored centroids instead of drawing markers.""" + sdata_blobs.pl.render_labels( + "blobs_labels", color="instance_id", as_points=True, method="datashader", size=600 + ).pl.show() + + def test_plot_labels_as_points_datashader_categorical(self, sdata_blobs: SpatialData): + """Categorical-coloured as_points centroids datashade with a legend (color_source_vector path).""" + max_col = sdata_blobs["table"].to_df().idxmax(axis=1) + sdata_blobs["table"].obs["which_max"] = pd.Categorical( + max_col, categories=sdata_blobs["table"].to_df().columns, ordered=True + ) + sdata_blobs.pl.render_labels( + "blobs_labels", color="which_max", as_points=True, method="datashader", size=600 + ).pl.show() + def test_raises_when_table_does_not_annotate_element(sdata_blobs: SpatialData): # Work on an independent copy since we mutate tables @@ -605,3 +629,100 @@ def test_render_labels_color_list_creates_one_panel_per_key(sdata_blobs: Spatial assert len(axs) == 2 assert [ax.get_title() for ax in axs] == ["channel_0_sum", "channel_1_sum"] plt.close("all") + + +def test_render_labels_as_points_renders_centroids(sdata_blobs: SpatialData): + """as_points draws one dot per label near its centroid. Centroids are computed on the rendered + (possibly downsampled) raster, so positions are checked in display space within a few-pixel + rasterization tolerance rather than against the exact full-resolution centroid.""" + import spatialdata as sd + + fig, ax = plt.subplots() + sdata_blobs.pl.render_labels("blobs_labels", color="instance_id", as_points=True, size=50).pl.show(ax=ax) + coll = ax.collections[0] + dots = coll.get_offset_transform().transform(np.asarray(coll.get_offsets())) # display px + ref_world = sd.get_centroids(sdata_blobs["blobs_labels"], coordinate_system="global").compute()[["x", "y"]] + ref = ax.transData.transform(ref_world.to_numpy()) + assert len(dots) == len(ref) + od, oe = np.lexsort((dots[:, 1], dots[:, 0])), np.lexsort((ref[:, 1], ref[:, 0])) + assert np.allclose(dots[od], ref[oe], atol=3.0) # within a few display px of the true centroid + plt.close(fig) + + +def test_render_labels_as_points_without_color(sdata_blobs: SpatialData): + """as_points must not crash without a color column; the background label (0) is excluded.""" + import spatialdata as sd + + fig, ax = plt.subplots() + sdata_blobs.pl.render_labels("blobs_labels", as_points=True).pl.show(ax=ax) + offsets = np.asarray(ax.collections[0].get_offsets()) + n_cells = len(sd.get_centroids(sdata_blobs["blobs_labels"], coordinate_system="global").compute()) + assert len(offsets) == n_cells # one dot per cell, no spurious background point + # without a color column, cells get distinct random colours (like the mask path), not one na_color + facecolors = ax.collections[0].get_facecolors() + assert len({tuple(np.round(c, 4)) for c in facecolors}) > 1 + plt.close(fig) + + +def test_render_labels_as_points_applies_non_identity_transform(sdata_blobs: SpatialData): + """Regression guard: under a non-identity element->CS transform the dots must land at the cells' + coordinate-system positions (in display space). A wrong transform would be off by the scale + factor (hundreds of px); the rendered-raster centroid is correct within a few px.""" + import spatialdata as sd + from spatialdata.transformations import Scale, set_transformation + + set_transformation(sdata_blobs["blobs_labels"], Scale([2.0, 3.0], axes=("x", "y")), "scaled") + fig, ax = plt.subplots() + sdata_blobs.pl.render_labels("blobs_labels", color="instance_id", as_points=True, size=50).pl.show( + ax=ax, coordinate_systems="scaled" + ) + coll = ax.collections[0] + dots_display = coll.get_offset_transform().transform(np.asarray(coll.get_offsets())) + cs = sd.get_centroids(sdata_blobs["blobs_labels"], coordinate_system="scaled").compute()[["x", "y"]].to_numpy() + expected_display = ax.transData.transform(cs) # where the cells truly are, in display pixels + order_d = np.lexsort((dots_display[:, 1], dots_display[:, 0])) + order_e = np.lexsort((expected_display[:, 1], expected_display[:, 0])) + assert np.allclose(dots_display[order_d], expected_display[order_e], atol=3.0) + plt.close(fig) + + +def test_render_labels_as_points_method_datashader_renders_image(sdata_blobs: SpatialData): + """method='datashader' under as_points (with a color column) draws a datashaded raster.""" + fig, ax = plt.subplots() + sdata_blobs.pl.render_labels( + "blobs_labels", color="instance_id", as_points=True, method="datashader", size=50 + ).pl.show(ax=ax) + assert len(ax.images) >= 1 + assert len(ax.collections) == 0 + plt.close(fig) + + +def test_render_labels_as_points_no_color_forces_matplotlib(sdata_blobs: SpatialData, caplog): + """No-color labels get one random colour per cell, which datashader cannot represent; even + method='datashader' must fall back to a matplotlib scatter (with a warning).""" + fig, ax = plt.subplots() + with logger_warns(caplog, logger, match="cannot use datashader"): + sdata_blobs.pl.render_labels("blobs_labels", as_points=True, method="datashader").pl.show(ax=ax) + assert len(ax.collections) == 1 # matplotlib scatter, not a datashader image + assert len(ax.images) == 0 + plt.close(fig) + + +def test_resolve_as_points_method_threshold_and_fallback(): + """Backend selection: explicit honored, no-color/empty force matplotlib, auto switches past the cap.""" + from types import SimpleNamespace + + from spatialdata_plot.pl.render import AS_POINTS_DS_AUTO, _resolve_as_points_method + + rp_auto = SimpleNamespace(method=None) + rp_ds = SimpleNamespace(method="datashader") + rp_mpl = SimpleNamespace(method="matplotlib") + # auto: matplotlib below the cap, datashader above + assert _resolve_as_points_method(rp_auto, n=1000, allow_datashader=True) == "matplotlib" + assert _resolve_as_points_method(rp_auto, n=AS_POINTS_DS_AUTO + 1, allow_datashader=True) == "datashader" + # explicit datashader honored only when the colouring allows it + assert _resolve_as_points_method(rp_ds, n=10, allow_datashader=True) == "datashader" + assert _resolve_as_points_method(rp_ds, n=10, allow_datashader=False) == "matplotlib" + # explicit matplotlib always matplotlib; empty always matplotlib + assert _resolve_as_points_method(rp_mpl, n=10**9, allow_datashader=True) == "matplotlib" + assert _resolve_as_points_method(rp_auto, n=0, allow_datashader=True) == "matplotlib" diff --git a/tests/pl/test_render_shapes.py b/tests/pl/test_render_shapes.py index 1a7803f1..1c5d100f 100644 --- a/tests/pl/test_render_shapes.py +++ b/tests/pl/test_render_shapes.py @@ -1108,6 +1108,18 @@ def test_plot_can_color_shapes_by_gene_symbols(self, sdata_blobs: SpatialData): "blobs_circles", color="GeneA", table_name="table", gene_symbols="gene_symbol" ).pl.show() + def test_plot_can_render_circles_as_points(self, sdata_blobs: SpatialData): + """as_points draws one dot per shape at its centroid instead of the geometry.""" + sdata_blobs.pl.render_shapes("blobs_circles", as_points=True, size=100).pl.show() + + def test_plot_shapes_as_points_respects_size(self, sdata_blobs: SpatialData): + """size sets the scatter marker area; larger size -> larger dots.""" + sdata_blobs.pl.render_shapes("blobs_circles", as_points=True, size=600).pl.show() + + def test_plot_shapes_as_points_datashader(self, sdata_blobs: SpatialData): + """as_points with method='datashader' rasterizes the centroids instead of drawing markers.""" + sdata_blobs.pl.render_shapes("blobs_circles", as_points=True, method="datashader", size=600).pl.show() + def test_gene_symbols_auto_detect_table(sdata_blobs: SpatialData): """gene_symbols resolves correctly without explicit table_name (#247).""" @@ -1705,3 +1717,64 @@ def test_render_shapes_color_list_branches_are_independent(sdata_blobs: SpatialD # branch1 is still usable and unaffected assert len(branch1.plotting_tree) == base_steps + 2 plt.close("all") + + +def test_render_shapes_as_points_renders_centroids(sdata_blobs: SpatialData): + """as_points draws one dot per shape at its centroid (fast mode).""" + import spatialdata as sd + + fig, ax = plt.subplots() + sdata_blobs.pl.render_shapes("blobs_circles", as_points=True, size=50).pl.show(ax=ax) + offsets = np.asarray(ax.collections[0].get_offsets()) + ref = sd.get_centroids(sdata_blobs["blobs_circles"]).compute()[["x", "y"]] + assert len(offsets) == len(ref) + assert np.allclose(np.sort(offsets[:, 0]), np.sort(ref["x"].to_numpy()), atol=1e-6) + assert np.allclose(np.sort(offsets[:, 1]), np.sort(ref["y"].to_numpy()), atol=1e-6) + plt.close(fig) + + +def test_render_shapes_as_points_ignores_outline_and_shape(sdata_blobs: SpatialData): + """outline_* and shape are ignored under as_points and must not error.""" + fig, ax = plt.subplots() + sdata_blobs.pl.render_shapes( + "blobs_polygons", as_points=True, outline_alpha=1.0, outline_color="red", shape="square" + ).pl.show(ax=ax) + assert len(ax.collections) >= 1 # a scatter, not the patch collections of the geometry path + plt.close(fig) + + +def test_render_shapes_as_points_applies_non_identity_transform(sdata_blobs: SpatialData): + """Regression: shapes as_points must place dots at coordinate-system positions, not intrinsic ones. + A wrong transform is off by the scale factor (hundreds of px).""" + import spatialdata as sd + from spatialdata.transformations import Scale, set_transformation + + set_transformation(sdata_blobs["blobs_circles"], Scale([3.0, 5.0], axes=("x", "y")), "scaled") + fig, ax = plt.subplots() + sdata_blobs.pl.render_shapes("blobs_circles", as_points=True, size=50).pl.show( + ax=ax, coordinate_systems="scaled" + ) + coll = ax.collections[0] + dots = coll.get_offset_transform().transform(np.asarray(coll.get_offsets())) + cs = sd.get_centroids(sdata_blobs["blobs_circles"], coordinate_system="scaled").compute()[["x", "y"]].to_numpy() + expected = ax.transData.transform(cs) + od, oe = np.lexsort((dots[:, 1], dots[:, 0])), np.lexsort((expected[:, 1], expected[:, 0])) + assert np.allclose(dots[od], expected[oe], atol=3.0) + plt.close(fig) + + +def test_render_shapes_as_points_method_datashader_renders_image(sdata_blobs: SpatialData): + """method='datashader' under as_points draws a datashaded raster, not a scatter collection.""" + fig, ax = plt.subplots() + sdata_blobs.pl.render_shapes("blobs_circles", as_points=True, method="datashader", size=50).pl.show(ax=ax) + assert len(ax.images) >= 1 # datashader image + assert len(ax.collections) == 0 # no matplotlib scatter + plt.close(fig) + + +def test_render_shapes_as_points_default_is_matplotlib(sdata_blobs: SpatialData): + """Small element with method=None uses matplotlib (crisp markers), not datashader.""" + fig, ax = plt.subplots() + sdata_blobs.pl.render_shapes("blobs_circles", as_points=True, size=50).pl.show(ax=ax) + assert len(ax.collections) == 1 and len(ax.images) == 0 + plt.close(fig) diff --git a/tests/pl/test_utils.py b/tests/pl/test_utils.py index 36db5729..74a929f4 100644 --- a/tests/pl/test_utils.py +++ b/tests/pl/test_utils.py @@ -681,6 +681,57 @@ def test_element_none_measures_single_table_elements(self, sdata_blobs: SpatialD assert "spatial" in sdata_blobs["table"].obsm +class TestGetExtentFast: + """`_get_extent_fast` matches spatialdata's `get_extent` while skipping the per-geometry transform.""" + + @pytest.mark.parametrize( + ("matrix", "expected"), + [ + ([[2, 0], [0, 3]], True), # anisotropic scale + ([[-1, 0], [0, 1]], True), # flip + ([[0, -1], [1, 0]], True), # 90-degree rotation + ([[0, 1], [1, 0]], True), # axis swap + ([[0.7071, -0.7071], [0.7071, 0.7071]], False), # 45-degree rotation + ([[1, 0.5], [0, 1]], False), # shear + ], + ) + def test_is_axis_aligned(self, matrix, expected): + from spatialdata_plot.pl.utils import _is_axis_aligned + + assert _is_axis_aligned(matrix) is expected + + @pytest.mark.parametrize("element", ["blobs_circles", "blobs_polygons"]) + @pytest.mark.parametrize("kind", ["scale_iso", "scale_aniso", "translate", "flip", "rot90", "rot45", "shear"]) + def test_matches_get_extent(self, sdata_blobs: SpatialData, element: str, kind: str): + import math + + from spatialdata import get_extent + from spatialdata.transformations import Affine, Scale, Translation, set_transformation + + from spatialdata_plot.pl.utils import _get_extent_fast + + def _rot(theta: float) -> Affine: + c, s = math.cos(theta), math.sin(theta) + return Affine([[c, -s, 0], [s, c, 0], [0, 0, 1]], input_axes=("x", "y"), output_axes=("x", "y")) + + transforms = { + "scale_iso": Scale([2.0, 2.0], axes=("x", "y")), + "scale_aniso": Scale([2.0, 3.0], axes=("x", "y")), # circles fall back here + "translate": Translation([10.0, 20.0], axes=("x", "y")), + "flip": Scale([-1.0, 1.0], axes=("x", "y")), + "rot90": _rot(math.pi / 2), + "rot45": _rot(math.pi / 4), # not axis-aligned -> fall back + "shear": Affine([[1, 0.5, 0], [0, 1, 0], [0, 0, 1]], input_axes=("x", "y"), output_axes=("x", "y")), + } + set_transformation(sdata_blobs[element], transforms[kind], "cs") + sub = SpatialData(shapes={element: sdata_blobs[element]}) + kw = dict(has_images=False, has_labels=False, has_points=False) + fast = _get_extent_fast(sub, "cs", **kw) + exact = get_extent(sub, "cs", exact=True, **kw) + for ax in ("x", "y"): + np.testing.assert_allclose(fast[ax], exact[ax], atol=1e-6) + + class TestExtractColorColumn: """`_extract_color_column` matches spatialdata's `get_values` bit-identically without copying the table."""