Skip to content

feat(show): crop kwarg for on-the-fly bounding-box rendering - #765

Merged
timtreis merged 9 commits into
mainfrom
feature/bbox-crop-show
Aug 12, 2026
Merged

feat(show): crop kwarg for on-the-fly bounding-box rendering#765
timtreis merged 9 commits into
mainfrom
feature/bbox-crop-show

Conversation

@timtreis

@timtreis timtreis commented Aug 11, 2026

Copy link
Copy Markdown
Member

Closes #764.

Adds show(crop_coord=(xmin, xmax, ymin, ymax)) to restrict a plot to a bounding box in the rendered coordinate system's units. Limits both the data drawn and the rasterized canvas, so large elements are not fully materialized before being clipped. Named crop_coord to match sc.pl.spatial / sq.pl.spatial_scatter (same 4-tuple order).

sdata.pl.render_images("he").pl.show(crop_coord=(x0, x1, y0, y1))

Per element type

  • points / shapes: predicate subset before drawing (dask predicate for points; radius-aware bounds for circles, bbox-intersect for polygons, per-row so mixed circle/polygon frames are correct). Colour domain is taken from the full element, so colours and colourbar match the uncropped plot (resolved only on the matplotlib path; transfunc-aware).
  • images (matplotlib and datashader): rasterize only the crop window via rasterize()'s target bbox. The full image is never materialized and the window keeps full figure resolution. Datashader images aggregate only the sliced source, preserving the configured reduction. Contrast auto-scales over the window (vmin/vmax/norm to override).
  • labels: rasterized to the window like images (multiscale picks the level for the window). Stable for numeric, pandas-Categorical, and explicit-palette columns; a plain-string column with the default palette falls back to full render + clip (windowing would otherwise reshuffle colours as categories drop out of the window). Empty-window guard.

Axis limits are set to the exact box (bypasses the extent-union merge, honours the inverted y-axis, ignores pad_extent). Restricted to a single coordinate system (validated).

Rotation/shear falls back to full render + clip (a box does not map to a box). Multi-coordinate-system crop with a per-CS box is not implemented.

Benchmarks

Prototype scripts, 3% window, image carrying a Scale+Translation. Two things measured: matplotlib image rasterization time vs image size, and datashader-image peak memory.

Matplotlib image, window rasterize vs full-render-then-clip (wall time):

image full + clip window speedup
6000² 57 ms 16 ms 3.6×
12000² 173 ms 16 ms 10.8×
20000² 436 ms 17 ms 25.7×

Window cost is flat (~16 ms) because only the window's source pixels are read; the full path grows with image size and would OOM at Visium HD scale.

Datashader image (8000² sparse): peak memory 258 MB → 19 MB, since the full image is no longer passed through image.compute().

Tests

tests/pl/test_show.py, tests/pl/test_utils.py: bbox helpers (negative-scale re-sort, rotation → None, radius-aware + mixed circle/polygon), points/shapes subset, full-element colour domain, transfunc match, datashader window autoscale, multiscale level selection, image and datashader-image window rasterization, labels placement / empty-window / no-colour-reshuffle. Full non-visual pl suite: 547 passed.

Add show(crop=(xmin, xmax, ymin, ymax)) to restrict a plot to a bounding
box in the rendered coordinate system, addressing #764.

- points/shapes: cheap per-element predicate subset before drawing
  (radius-aware for circles); auto-scaled color domain taken from the full
  element so colors match the uncropped plot.
- images: rasterize only the crop window via rasterize()'s target bbox, so
  the full image is never materialized (fast at Visium HD scale) and the
  zoom keeps full figure resolution. Placement is correct without a
  transform rewrite. Contrast auto-scales over the window unless vmin/vmax
  or a norm is given.
- labels: drawn in full and clipped to the box (windowed label rendering
  needs full-element palette handling; deferred).
- exact axis limits (bypass extent-union merge, honor inverted y-axis,
  ignore pad_extent); validated single-coordinate-system.

Unit tests cover the bbox helpers, points/shapes subset, full-element
color domain, image window-rasterization, and validation errors.
@codecov-commenter

codecov-commenter commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.88889% with 18 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.93%. Comparing base (fbb6872) to head (85a171a).

Files with missing lines Patch % Lines
src/spatialdata_plot/pl/render.py 82.14% 4 Missing and 6 partials ⚠️
src/spatialdata_plot/pl/utils.py 91.78% 1 Missing and 5 partials ⚠️
src/spatialdata_plot/pl/basic.py 90.00% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #765      +/-   ##
==========================================
+ Coverage   79.66%   79.93%   +0.26%     
==========================================
  Files          18       18              
  Lines        4672     4814     +142     
  Branches     1036     1069      +33     
==========================================
+ Hits         3722     3848     +126     
- Misses        599      604       +5     
- Partials      351      362      +11     
Files with missing lines Coverage Δ
src/spatialdata_plot/pl/_validate.py 71.00% <100.00%> (+0.22%) ⬆️
src/spatialdata_plot/pl/render_params.py 89.06% <100.00%> (+0.27%) ⬆️
src/spatialdata_plot/pl/basic.py 83.22% <90.00%> (+0.22%) ⬆️
src/spatialdata_plot/pl/utils.py 80.27% <91.78%> (+1.30%) ⬆️
src/spatialdata_plot/pl/render.py 88.98% <82.14%> (-0.43%) ⬇️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Follow-up to the max code review of the crop feature.

- Rename public kwarg crop -> crop_coord to match scanpy sc.pl.spatial /
  squidpy sq.pl.spatial_scatter (interop > novelty); 4-tuple unchanged.
- Norm handling is now non-mutating: capture the full-element range and
  apply it only on the matplotlib backend. Fixes the datashader leak (it
  now autoscales over the window, correct for sum/count) and the
  pre-transfunc pin (range is taken over the transfunc'd full vector).
- _bbox_mask_shapes is radius-aware per row, so a frame mixing circles
  and polygons no longer drops edge circles.
- Multiscale images pick the pyramid level for the crop WINDOW's required
  resolution, not the full image, so a Visium-HD crop no longer upsamples
  a coarse level.
- Document that labels and method='datashader' images are clip-only under
  crop (windowed labels need full-element palette handling; deferred).
- Simplify the crop axis-limit block to read crop_coord directly.

Tests: transfunc-matches-uncropped, datashader-autoscales-over-window,
multiscale-selects-finer-level, mixed circle+polygon mask.
@timtreis

Copy link
Copy Markdown
Member Author

Follow-up 5b66f8d addresses a max-effort review:

API naming — renamed the kwarg cropcrop_coord to match sc.pl.spatial / sq.pl.spatial_scatter (the 4-tuple order already matched; internals still map to bounding_box_query's min/max_coordinate at the adapter layer).

Correctness

  • Norm handling is now non-mutating: the full-element range is captured and applied only on the matplotlib backend. This fixes (a) the datashader leak — it now autoscales over the window (correct for sum/count, which can't match uncropped), and (b) the pre-transfunc pin — the range is taken over the transfunc'd full vector.
  • _bbox_mask_shapes is now radius-aware per row, so a frame mixing circles and polygons no longer drops edge circles.
  • Multiscale images select the pyramid level for the crop window's required resolution, not the full image — a Visium-HD crop no longer upsamples a coarse level.

Scope/docscrop_coord documents that labels and method='datashader' images are clip-only (windowed labels need full-element categorical-palette handling; deferred). Axis-limit block simplified to read crop_coord directly.

Tests added (the review's gap): transfunc-matches-uncropped, datashader-autoscales-over-window, multiscale-selects-finer-level, mixed circle+polygon mask. Full non-visual suite: 361 passed.

Remaining follow-ups: windowed labels, datashader-image window path (materializes the full image in the Canvas.raster step — larger change), multi-CS per-box crop, rotated/sheared fast path.

Previously method='datashader' images ignored crop_coord (full render +
axis-clip). Now the datashader aggregation is restricted to the window:
rasterize() supplies the correct window coords/transform and only the
sliced source is materialized, so the full image is never read — this
scales to Visium HD (prototype: peak memory 258 -> 19 MB on 8k^2, and
O(window) instead of O(image)). The datashader reduction (max/sum/...) is
preserved by aggregating the sliced source in pixel space and index-
assigning onto the rasterized grid. Falls back to full render for
rotation/shear or an empty window.

New _datashader_window_image helper in utils.py; regression test asserts
the window is rasterized at figure resolution, not the full source.
@timtreis

Copy link
Copy Markdown
Member Author

Follow-up 906b811datashader-image crop is now windowed too (was the last deferred item).

method='datashader' images previously ignored crop_coord (full render + axis-clip). Now the aggregation is restricted to the window: rasterize() supplies the correct window coords/transform, only the sliced source is materialized, and the configurable reduction (max/sum/…) is preserved by aggregating the sliced source in pixel space and index-assigning onto the rasterized grid. The full image is never read.

Prototype (profiling/bbox_ds_image_crop_prototype.py, 8k² sparse image, Scale+Translation, 5% window): peak memory 258 → 19 MB and O(window) instead of O(image) — so it scales to Visium HD where the old path would OOM on image.compute(). Placement verified against the window extent; the sparse peak survives the reduction; a value far outside the window is correctly excluded. Falls back to full render for rotation/shear or an empty window.

Regression test added; full non-visual suite still green (227 image/utils + 12 crop).

Only labels remain clip-only under crop now (windowing them needs full-element categorical-palette handling) — documented as a follow-up.

Labels were the last element still full-render + axis-clip under
crop_coord. Thread crop into _render_labels so the label raster is
windowed via the same crop-aware _rasterize_if_necessary /
_multiscale_to_spatial_image paths as images (full array never
materialized; multiscale picks the level for the window).

Colour stability: windowing drops off-window instances before colours
resolve. That is stable for numeric columns, pandas-Categorical columns
(fixed levels) and explicit palettes; a plain-string column with the
default palette would reshuffle colours as whole categories drop out of
the window, so that case falls back to full render + clip. Empty-window
guard returns before the instance-overlap/colour machinery when the box
covers no labels.

Tests: placement + empty window; no-colour-reshuffle for categorical
(windowed) and string (fallback). Full non-visual suite: 390 passed.
@timtreis

Copy link
Copy Markdown
Member Author

Follow-up c84f76alabels crop is now windowed too, so every element type honours crop_coord's fast path (the last deferred item).

_render_labels threads crop into the same crop-aware _rasterize_if_necessary / _multiscale_to_spatial_image used by images: the label raster is rasterized to the window (full array never materialized; multiscale picks the level for the window's resolution).

Colour stability (the reshuffle concern from the review): windowing drops off-window instances before colours resolve. That's stable for numeric columns, pandas-Categorical columns (fixed levels) and explicit palettes. A plain-string column with the default palette would reshuffle (as whole categories drop out of the window their sorted positions shift), so that specific case falls back to full render + clip — colours always match the uncropped plot; use a categorical dtype or an explicit palette to keep the windowed fast path. An empty-window guard returns cleanly when the box covers no labels.

Tests: placement + empty-window; no-colour-reshuffle for categorical (windowed) and string (fallback). Full non-visual suite: 390 passed.

Every element type now has the crop fast path: points/shapes (predicate subset), images + datashader-images (window rasterize), labels (window rasterize with the string-palette fallback). Only rotation/shear and multi-CS per-box remain as documented follow-ups.

Cleanup from a quality review of the crop feature:
- Extract _rasterize_to_bbox and use it in both _rasterize_if_necessary
  (replacing the raster_extent/do_rasterization branching) and
  _datashader_window_image, so 'rasterize a bbox to figure resolution'
  lives in one place.
- Drop _datashader_window_image's redundant has_c_dim/x_dims/y_dims
  params (recomputed from image at the top).
- Resolve the full-element crop norm range lazily on the matplotlib
  branch instead of eagerly in the crop block, so the datashader
  (large-data) path no longer pays a full-element min/max scan it never
  uses.
Add TestShow.test_plot_crop_{image,points,shapes,circles,labels,
layered_elements} exercising crop_coord on the blobs dataset. Reference
images are generated on CI and added in a follow-up commit (baselines
must match the CI matplotlib, not a local render).
Reference images for TestShow.test_plot_crop_* rendered by the
hatch-test CI matrix and downloaded from the visual_test_results
artifact.
Behavior-preserving cleanup of the crop_coord feature, plus one correctness
fix in the datashader window path.

- BBox NamedTuple (render_params): one (x0, y0, x1, y1) ordering travels
  through every crop helper instead of three names/orders (crop_coord at the
  API, crop_box in show(), crop downstream); show() converts the public
  (xmin, xmax, ymin, ymax) once at the boundary. Removes the per-helper
  order-restating comments.
- Thread crop via FigParams.crop instead of a kwarg on _render_panel and all
  four render fns; drops the kwargs['crop'] special-case.
- Dedup the axis-limit block (compute limits, set once).
- Extract _pin_norm_to_full_range (shared by _render_points/_render_shapes)
  and _crop_color_is_stable (labels windowing predicate).
- Harden _datashader_window_image: base and the datashader aggregate now
  cover the same integer pixel window (mapped back to world via the forward
  affine), fixing a sub-pixel misalignment and an edge-clamp case where the
  fractional canvas range overran the sliced source; materialize base before
  overwriting; drop the dead 2D branch (images always carry a c dim).

Tests: BBox is a tuple subclass so existing helper tests are unchanged; add
datashader window placement/clamp/empty-window tests. Full non-visual pl
suite: 550 passed.
@timtreis
timtreis merged commit ec21a32 into main Aug 12, 2026
8 checks passed
@timtreis
timtreis deleted the feature/bbox-crop-show branch August 12, 2026 22:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Crop rendering to a coordinate-system bounding box from show()

2 participants