Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/spatialdata_plot/pl/_datashader.py
Original file line number Diff line number Diff line change
Expand Up @@ -730,9 +730,10 @@ def _ax_show_and_transform(
norm: Normalize | None = None,
interpolation: str | None = None,
) -> matplotlib.image.AxesImage:
# ``extent`` uses mpl's pixel-grid convention; world placement happens via
# ``set_transform(trans_data)`` afterwards.
image_extent = (-0.5, array.shape[1] - 0.5, array.shape[0] - 0.5, -0.5)
# Pixel-edge extent [0, W] x [0, H], matching get_extent (which sets the axis limits)
# and the affine's data box (placement is via set_transform below). mpl's default
# pixel-center extent (-0.5, W-0.5, ...) offsets by half a pixel, amplified by the affine.
image_extent = (0.0, array.shape[1], array.shape[0], 0.0)
# ``alpha`` is applied only when no cmap is set, so RGBA arrays already
# carrying per-pixel alpha (e.g. datashader output) are not double-attenuated.
imshow_kwargs: dict[str, Any] = {"zorder": zorder, "extent": image_extent, "norm": norm}
Expand Down
11 changes: 10 additions & 1 deletion src/spatialdata_plot/pl/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -2400,7 +2400,16 @@ def _draw_labels(
# non-linear norm (LogNorm/PowerNorm). Display the RGB without a norm and build the
# continuous colorbar mappable separately from the resolved norm (mirrors the outline path),
# so the colorbar reflects the real norm subclass.
img = ax.imshow(labels, rasterized=True, alpha=alpha, origin="lower", zorder=render_params.zorder)
# Pixel-edge extent (0, W, 0, H) matching get_extent/the affine box; mpl's default
# pixel-center extent would offset labels half a pixel. Order follows origin="lower".
img = ax.imshow(
labels,
rasterized=True,
alpha=alpha,
origin="lower",
extent=(0.0, labels.shape[1], 0.0, labels.shape[0]),
zorder=render_params.zorder,
)
img.set_transform(trans_data)
if color_spec.is_categorical:
return img
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/Points_datashader_can_transform_points.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/Points_datashader_can_use_any_as_reduction.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/Points_datashader_can_use_count_as_reduction.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/Points_datashader_can_use_max_as_reduction.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/Points_datashader_can_use_mean_as_reduction.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/Points_datashader_can_use_min_as_reduction.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/Points_datashader_can_use_norm_with_clip.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/Points_datashader_can_use_norm_without_clip.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/Points_datashader_can_use_std_as_reduction.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/Points_datashader_can_use_sum_as_reduction.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/Points_datashader_can_use_var_as_reduction.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/Points_datashader_colors_from_table_obs.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/Points_datashader_matplotlib_stack.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/Points_datashader_norm_vmin_eq_vmax_with_clip.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/Points_datashader_norm_vmin_eq_vmax_without_clip.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/Shapes_datashader_can_color_by_identical_value.png
Binary file modified tests/_images/Shapes_datashader_can_color_by_value.png
Binary file modified tests/_images/Shapes_datashader_can_render_colored_shapes.png
Binary file modified tests/_images/Shapes_datashader_can_transform_polygons.png
Binary file modified tests/_images/Shapes_datashader_shades_with_linear_cmap.png
Binary file modified tests/_images/Show_user_ax_dpi_preserved.png
Binary file modified tests/_images/Utils_can_set_zero_in_cmap_to_transparent.png
16 changes: 16 additions & 0 deletions tests/pl/test_render_labels.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,22 @@ def test_plot_labels_render_permutations(self, sdata_blobs: SpatialData):
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_label_centroids_sit_at_pixel_centers(self):
# Regression for #216: on a tiny grid each data-pixel spans many display pixels, so a
# half-pixel image/overlay shift is blatant. Centroids (spatialdata's pixel-edge
# convention) must sit dead-center on their label pixels, not on the pixel corners.
from spatialdata import get_centroids
from spatialdata.models import PointsModel
from spatialdata.transformations import Identity

arr = np.zeros((6, 6), dtype=np.int32)
for label, (row, col) in enumerate([(1, 1), (1, 4), (4, 1), (4, 4), (2, 3)], start=1):
arr[row, col] = label
sdata = SpatialData(labels={"lab": Labels2DModel.parse(arr, dims=("y", "x"))})
centroids = get_centroids(sdata["lab"], coordinate_system="global").compute()
sdata["centroids"] = PointsModel.parse(centroids[["x", "y"]], transformations={"global": Identity()})
sdata.pl.render_labels("lab").pl.render_points("centroids", color="red", size=100).pl.show()

def test_plot_can_render_labels(self, sdata_blobs: SpatialData):
sdata_blobs.pl.render_labels(element="blobs_labels").pl.show()

Expand Down
61 changes: 61 additions & 0 deletions tests/pl/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1286,3 +1286,64 @@ def test_first_color_per_category_skips_nan_and_handles_numeric_categories():
source = pd.Categorical([1, None, 2, 1], categories=[1, 2])
cv = ["#aaaaaa", "#ffffff", "#bbbbbb", "#aaaaaa"]
assert _first_color_per_category(source, cv) == {1: "#aaaaaa", 2: "#bbbbbb"}


def _rendered_image_world_box(im, ax) -> tuple[float, float, float, float]:
"""World-space (x0, x1, y0, y1) the AxesImage's pixel grid actually occupies."""
left, right, bottom, top = im.get_extent()
element_affine = im.get_transform() - ax.transData
(x0, y0), (x1, y1) = element_affine.transform([(left, bottom), (right, top)])
return float(x0), float(x1), float(y0), float(y1)


@pytest.mark.parametrize("transform_name", ["identity", "scale"])
@pytest.mark.parametrize("element", ["image", "labels"])
def test_rasterized_artist_aligns_with_get_extent(transform_name, element):
# Regression test for #216: a rasterized image/labels artist must occupy the
# same world box as get_extent (which sets the axis limits). The old pixel-center
# extent left it half a pixel off, amplified by the affine (Scale -> hundreds of px).
from spatialdata import get_extent
from spatialdata.models import Image2DModel
from spatialdata.transformations import Identity, Scale

transform = Identity() if transform_name == "identity" else Scale([1000.0, 1000.0], axes=("x", "y"))
if element == "image":
el = Image2DModel.parse(np.zeros((1, 4, 8)), dims=("c", "y", "x"), transformations={"global": transform})
sdata = SpatialData(images={"el": el})
ax = sdata.pl.render_images().pl.show(return_ax=True)
else:
el = Labels2DModel.parse(
np.zeros((4, 8), dtype=np.int32), dims=("y", "x"), transformations={"global": transform}
)
sdata = SpatialData(labels={"el": el})
ax = sdata.pl.render_labels().pl.show(return_ax=True)

ext = get_extent(sdata["el"], coordinate_system="global")
x0, x1, y0, y1 = _rendered_image_world_box(ax.get_images()[0], ax)
assert (min(x0, x1), max(x0, x1)) == pytest.approx(ext["x"])
assert (min(y0, y1), max(y0, y1)) == pytest.approx(ext["y"])
plt.close("all")


def test_datashader_points_image_aligns_with_points_extent():
# Regression test for #216 (Sonja's case): the rasterized datashader-points image
# must occupy the points' world extent, matching where matplotlib scatters them.
from spatialdata.models import Image2DModel
from spatialdata.transformations import Identity

sdata = SpatialData(
images={"img": Image2DModel.parse(np.full((10, 10, 3), 128, dtype=np.uint8), dims=("y", "x", "c"))},
points={
"pts": PointsModel.parse(
pd.DataFrame({"x": [0.1, 0.9, 0.9, 0.1], "y": [0.1, 0.1, 0.9, 0.9]}),
transformations={"global": Identity()},
)
},
)
ax = sdata.pl.render_images().pl.render_points("pts", method="datashader", size=40).pl.show(return_ax=True)

# second image on the axis is the rasterized points (first is the background image)
x0, x1, y0, y1 = _rendered_image_world_box(ax.get_images()[1], ax)
assert (min(x0, x1), max(x0, x1)) == pytest.approx((0.1, 0.9))
assert (min(y0, y1), max(y0, y1)) == pytest.approx((0.1, 0.9))
plt.close("all")
Loading