Skip to content

Commit 1bdf93c

Browse files
committed
fix(render): align rasterized artists with get_extent (pixel-edge extent)
Images, labels, and datashader output were drawn with matplotlib's default pixel-center imshow extent (-0.5, W-0.5, ...), placing them half a pixel off the world coordinates used for the axis limits (get_extent), the affine box, and matplotlib point/shape overlays. The affine's linear part amplifies the constant 0.5 offset, so a Scale(1000) image shifted by 500 world units (#216). Switch to the pixel-edge convention (0, W, H, 0) so each artist's data box is the same [0, shape] box get_extent transforms — they now coincide under any affine. Labels are drawn outside the shared _ax_show_and_transform helper via a direct imshow(origin="lower"), so they get an explicit extent=(0, W, 0, H) (origin-lower order) to match. This also removes the residual half-canvas-pixel offset of datashader points relative to the matplotlib backend. Adds non-visual regression tests asserting the rendered world box equals get_extent for images and labels (Identity + Scale), and that the datashader points image occupies the points' extent.
1 parent 50a6606 commit 1bdf93c

3 files changed

Lines changed: 78 additions & 4 deletions

File tree

src/spatialdata_plot/pl/_datashader.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -730,9 +730,12 @@ def _ax_show_and_transform(
730730
norm: Normalize | None = None,
731731
interpolation: str | None = None,
732732
) -> matplotlib.image.AxesImage:
733-
# ``extent`` uses mpl's pixel-grid convention; world placement happens via
734-
# ``set_transform(trans_data)`` afterwards.
735-
image_extent = (-0.5, array.shape[1] - 0.5, array.shape[0] - 0.5, -0.5)
733+
# Pixel-edge extent: the array spans [0, width] x [0, height], matching
734+
# spatialdata's ``get_extent`` (which sets the axis limits) and the affine's
735+
# data box. World placement happens via ``set_transform(trans_data)``. Using
736+
# mpl's default pixel-center extent (-0.5, W-0.5, ...) would offset the image
737+
# half a pixel from the axes/overlays, amplified by the affine.
738+
image_extent = (0.0, array.shape[1], array.shape[0], 0.0)
736739
# ``alpha`` is applied only when no cmap is set, so RGBA arrays already
737740
# carrying per-pixel alpha (e.g. datashader output) are not double-attenuated.
738741
imshow_kwargs: dict[str, Any] = {"zorder": zorder, "extent": image_extent, "norm": norm}

src/spatialdata_plot/pl/render.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2400,7 +2400,17 @@ def _draw_labels(
24002400
# non-linear norm (LogNorm/PowerNorm). Display the RGB without a norm and build the
24012401
# continuous colorbar mappable separately from the resolved norm (mirrors the outline path),
24022402
# so the colorbar reflects the real norm subclass.
2403-
img = ax.imshow(labels, rasterized=True, alpha=alpha, origin="lower", zorder=render_params.zorder)
2403+
# Pixel-edge extent matching get_extent/the affine box; without it, imshow's
2404+
# default (pixel-center) would offset labels half a pixel from images and
2405+
# overlays. Note origin="lower" → extent order is (0, W, 0, H).
2406+
img = ax.imshow(
2407+
labels,
2408+
rasterized=True,
2409+
alpha=alpha,
2410+
origin="lower",
2411+
extent=(0.0, labels.shape[1], 0.0, labels.shape[0]),
2412+
zorder=render_params.zorder,
2413+
)
24042414
img.set_transform(trans_data)
24052415
if color_spec.is_categorical:
24062416
return img

tests/pl/test_utils.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1286,3 +1286,64 @@ def test_first_color_per_category_skips_nan_and_handles_numeric_categories():
12861286
source = pd.Categorical([1, None, 2, 1], categories=[1, 2])
12871287
cv = ["#aaaaaa", "#ffffff", "#bbbbbb", "#aaaaaa"]
12881288
assert _first_color_per_category(source, cv) == {1: "#aaaaaa", 2: "#bbbbbb"}
1289+
1290+
1291+
def _rendered_image_world_box(im, ax) -> tuple[float, float, float, float]:
1292+
"""World-space (x0, x1, y0, y1) the AxesImage's pixel grid actually occupies."""
1293+
left, right, bottom, top = im.get_extent()
1294+
element_affine = im.get_transform() - ax.transData
1295+
(x0, y0), (x1, y1) = element_affine.transform([(left, bottom), (right, top)])
1296+
return float(x0), float(x1), float(y0), float(y1)
1297+
1298+
1299+
@pytest.mark.parametrize("transform_name", ["identity", "scale"])
1300+
@pytest.mark.parametrize("element", ["image", "labels"])
1301+
def test_rasterized_artist_aligns_with_get_extent(transform_name, element):
1302+
# Regression test for #216: a rasterized image/labels artist must occupy the
1303+
# same world box as get_extent (which sets the axis limits). The old pixel-center
1304+
# extent left it half a pixel off, amplified by the affine (Scale -> hundreds of px).
1305+
from spatialdata import get_extent
1306+
from spatialdata.models import Image2DModel
1307+
from spatialdata.transformations import Identity, Scale
1308+
1309+
transform = Identity() if transform_name == "identity" else Scale([1000.0, 1000.0], axes=("x", "y"))
1310+
if element == "image":
1311+
el = Image2DModel.parse(np.zeros((1, 4, 8)), dims=("c", "y", "x"), transformations={"global": transform})
1312+
sdata = SpatialData(images={"el": el})
1313+
ax = sdata.pl.render_images().pl.show(return_ax=True)
1314+
else:
1315+
el = Labels2DModel.parse(
1316+
np.zeros((4, 8), dtype=np.int32), dims=("y", "x"), transformations={"global": transform}
1317+
)
1318+
sdata = SpatialData(labels={"el": el})
1319+
ax = sdata.pl.render_labels().pl.show(return_ax=True)
1320+
1321+
ext = get_extent(sdata["el"], coordinate_system="global")
1322+
x0, x1, y0, y1 = _rendered_image_world_box(ax.get_images()[0], ax)
1323+
assert (min(x0, x1), max(x0, x1)) == pytest.approx(tuple(map(float, ext["x"])))
1324+
assert (min(y0, y1), max(y0, y1)) == pytest.approx(tuple(map(float, ext["y"])))
1325+
plt.close("all")
1326+
1327+
1328+
def test_datashader_points_image_aligns_with_points_extent():
1329+
# Regression test for #216 (Sonja's case): the rasterized datashader-points image
1330+
# must occupy the points' world extent, matching where matplotlib scatters them.
1331+
from spatialdata.models import Image2DModel
1332+
from spatialdata.transformations import Identity
1333+
1334+
sdata = SpatialData(
1335+
images={"img": Image2DModel.parse(np.full((10, 10, 3), 128, dtype=np.uint8), dims=("y", "x", "c"))},
1336+
points={
1337+
"pts": PointsModel.parse(
1338+
pd.DataFrame({"x": [0.1, 0.9, 0.9, 0.1], "y": [0.1, 0.1, 0.9, 0.9]}),
1339+
transformations={"global": Identity()},
1340+
)
1341+
},
1342+
)
1343+
ax = sdata.pl.render_images().pl.render_points("pts", method="datashader", size=40).pl.show(return_ax=True)
1344+
1345+
# second image on the axis is the rasterized points (first is the background image)
1346+
x0, x1, y0, y1 = _rendered_image_world_box(ax.get_images()[1], ax)
1347+
assert (min(x0, x1), max(x0, x1)) == pytest.approx((0.1, 0.9))
1348+
assert (min(y0, y1), max(y0, y1)) == pytest.approx((0.1, 0.9))
1349+
plt.close("all")

0 commit comments

Comments
 (0)