diff --git a/src/spatialdata_plot/pl/_datashader.py b/src/spatialdata_plot/pl/_datashader.py index 446c6de0..dd6fd15e 100644 --- a/src/spatialdata_plot/pl/_datashader.py +++ b/src/spatialdata_plot/pl/_datashader.py @@ -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} diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index 5ddc4c63..49f288a9 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -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 diff --git a/tests/_images/ColorbarControls_single_channel_default_channel_name_omits_label.png b/tests/_images/ColorbarControls_single_channel_default_channel_name_omits_label.png index 2fe14027..5817a42d 100644 Binary files a/tests/_images/ColorbarControls_single_channel_default_channel_name_omits_label.png and b/tests/_images/ColorbarControls_single_channel_default_channel_name_omits_label.png differ diff --git a/tests/_images/Extent_extent_of_img_is_correct_after_spatial_query.png b/tests/_images/Extent_extent_of_img_is_correct_after_spatial_query.png index aecc4ca0..778cd9da 100755 Binary files a/tests/_images/Extent_extent_of_img_is_correct_after_spatial_query.png and b/tests/_images/Extent_extent_of_img_is_correct_after_spatial_query.png differ diff --git a/tests/_images/Labels_label_centroids_sit_at_pixel_centers.png b/tests/_images/Labels_label_centroids_sit_at_pixel_centers.png new file mode 100644 index 00000000..bb1cc403 Binary files /dev/null and b/tests/_images/Labels_label_centroids_sit_at_pixel_centers.png differ diff --git a/tests/_images/Points_can_annotate_points_with_nan_in_df_categorical_datashader.png b/tests/_images/Points_can_annotate_points_with_nan_in_df_categorical_datashader.png index d539271e..d8171430 100644 Binary files a/tests/_images/Points_can_annotate_points_with_nan_in_df_categorical_datashader.png and b/tests/_images/Points_can_annotate_points_with_nan_in_df_categorical_datashader.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 d1d5d37d..a295c0cb 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 da647972..17c5ed29 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_categorical_datashader.png b/tests/_images/Points_can_annotate_points_with_nan_in_table_obs_categorical_datashader.png index 53b515b4..8f4d3879 100644 Binary files a/tests/_images/Points_can_annotate_points_with_nan_in_table_obs_categorical_datashader.png and b/tests/_images/Points_can_annotate_points_with_nan_in_table_obs_categorical_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 01488584..a34864f0 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_can_transform_points.png b/tests/_images/Points_datashader_can_transform_points.png index 51813e64..09a6bbb7 100644 Binary files a/tests/_images/Points_datashader_can_transform_points.png and b/tests/_images/Points_datashader_can_transform_points.png differ diff --git a/tests/_images/Points_datashader_can_use_any_as_reduction.png b/tests/_images/Points_datashader_can_use_any_as_reduction.png index d0712364..17291ba9 100644 Binary files a/tests/_images/Points_datashader_can_use_any_as_reduction.png and b/tests/_images/Points_datashader_can_use_any_as_reduction.png differ diff --git a/tests/_images/Points_datashader_can_use_count_as_reduction.png b/tests/_images/Points_datashader_can_use_count_as_reduction.png index 7c64b7c6..9d9c8312 100644 Binary files a/tests/_images/Points_datashader_can_use_count_as_reduction.png and b/tests/_images/Points_datashader_can_use_count_as_reduction.png differ diff --git a/tests/_images/Points_datashader_can_use_max_as_reduction.png b/tests/_images/Points_datashader_can_use_max_as_reduction.png index f04444c4..792cd919 100644 Binary files a/tests/_images/Points_datashader_can_use_max_as_reduction.png and b/tests/_images/Points_datashader_can_use_max_as_reduction.png differ diff --git a/tests/_images/Points_datashader_can_use_mean_as_reduction.png b/tests/_images/Points_datashader_can_use_mean_as_reduction.png index b2451e12..55329ea9 100644 Binary files a/tests/_images/Points_datashader_can_use_mean_as_reduction.png and b/tests/_images/Points_datashader_can_use_mean_as_reduction.png differ diff --git a/tests/_images/Points_datashader_can_use_min_as_reduction.png b/tests/_images/Points_datashader_can_use_min_as_reduction.png index fff0ff21..d881aff6 100644 Binary files a/tests/_images/Points_datashader_can_use_min_as_reduction.png and b/tests/_images/Points_datashader_can_use_min_as_reduction.png differ diff --git a/tests/_images/Points_datashader_can_use_norm_with_clip.png b/tests/_images/Points_datashader_can_use_norm_with_clip.png index 55901644..42db1cb9 100644 Binary files a/tests/_images/Points_datashader_can_use_norm_with_clip.png and b/tests/_images/Points_datashader_can_use_norm_with_clip.png differ diff --git a/tests/_images/Points_datashader_can_use_norm_without_clip.png b/tests/_images/Points_datashader_can_use_norm_without_clip.png index bea23ec7..a0708b9b 100644 Binary files a/tests/_images/Points_datashader_can_use_norm_without_clip.png and b/tests/_images/Points_datashader_can_use_norm_without_clip.png differ diff --git a/tests/_images/Points_datashader_can_use_std_as_reduction.png b/tests/_images/Points_datashader_can_use_std_as_reduction.png index 27479629..386f61ef 100644 Binary files a/tests/_images/Points_datashader_can_use_std_as_reduction.png and b/tests/_images/Points_datashader_can_use_std_as_reduction.png differ diff --git a/tests/_images/Points_datashader_can_use_std_as_reduction_not_all_zero.png b/tests/_images/Points_datashader_can_use_std_as_reduction_not_all_zero.png index 406b7a8c..599e7b57 100644 Binary files a/tests/_images/Points_datashader_can_use_std_as_reduction_not_all_zero.png and b/tests/_images/Points_datashader_can_use_std_as_reduction_not_all_zero.png differ diff --git a/tests/_images/Points_datashader_can_use_sum_as_reduction.png b/tests/_images/Points_datashader_can_use_sum_as_reduction.png index 8b0cac3d..8e2c30ea 100644 Binary files a/tests/_images/Points_datashader_can_use_sum_as_reduction.png and b/tests/_images/Points_datashader_can_use_sum_as_reduction.png differ diff --git a/tests/_images/Points_datashader_can_use_var_as_reduction.png b/tests/_images/Points_datashader_can_use_var_as_reduction.png index 27479629..386f61ef 100644 Binary files a/tests/_images/Points_datashader_can_use_var_as_reduction.png and b/tests/_images/Points_datashader_can_use_var_as_reduction.png differ diff --git a/tests/_images/Points_datashader_colors_from_table_obs.png b/tests/_images/Points_datashader_colors_from_table_obs.png index fe6ca8f6..8624c954 100644 Binary files a/tests/_images/Points_datashader_colors_from_table_obs.png and b/tests/_images/Points_datashader_colors_from_table_obs.png differ diff --git a/tests/_images/Points_datashader_matplotlib_stack.png b/tests/_images/Points_datashader_matplotlib_stack.png index a8ce1312..7c5a6c5d 100644 Binary files a/tests/_images/Points_datashader_matplotlib_stack.png and b/tests/_images/Points_datashader_matplotlib_stack.png differ diff --git a/tests/_images/Points_datashader_norm_vmin_eq_vmax_with_clip.png b/tests/_images/Points_datashader_norm_vmin_eq_vmax_with_clip.png index c4376b51..9ceddfca 100644 Binary files a/tests/_images/Points_datashader_norm_vmin_eq_vmax_with_clip.png and b/tests/_images/Points_datashader_norm_vmin_eq_vmax_with_clip.png differ diff --git a/tests/_images/Points_datashader_norm_vmin_eq_vmax_without_clip.png b/tests/_images/Points_datashader_norm_vmin_eq_vmax_without_clip.png index 2e19ff07..5ec89f6d 100644 Binary files a/tests/_images/Points_datashader_norm_vmin_eq_vmax_without_clip.png and b/tests/_images/Points_datashader_norm_vmin_eq_vmax_without_clip.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 0126b59b..afcb1908 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 d4800058..accae98c 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/Shapes_datashader_can_color_by_identical_value.png b/tests/_images/Shapes_datashader_can_color_by_identical_value.png index 97ef67a3..5f2fdbaa 100644 Binary files a/tests/_images/Shapes_datashader_can_color_by_identical_value.png and b/tests/_images/Shapes_datashader_can_color_by_identical_value.png differ diff --git a/tests/_images/Shapes_datashader_can_color_by_value.png b/tests/_images/Shapes_datashader_can_color_by_value.png index c279dbbc..d1644b3c 100644 Binary files a/tests/_images/Shapes_datashader_can_color_by_value.png and b/tests/_images/Shapes_datashader_can_color_by_value.png differ diff --git a/tests/_images/Shapes_datashader_can_render_colored_shapes.png b/tests/_images/Shapes_datashader_can_render_colored_shapes.png index d3aa8375..3d4ba4e6 100644 Binary files a/tests/_images/Shapes_datashader_can_render_colored_shapes.png and b/tests/_images/Shapes_datashader_can_render_colored_shapes.png differ diff --git a/tests/_images/Shapes_datashader_can_transform_polygons.png b/tests/_images/Shapes_datashader_can_transform_polygons.png index b351b932..f4c270d8 100644 Binary files a/tests/_images/Shapes_datashader_can_transform_polygons.png and b/tests/_images/Shapes_datashader_can_transform_polygons.png differ diff --git a/tests/_images/Shapes_datashader_shades_with_linear_cmap.png b/tests/_images/Shapes_datashader_shades_with_linear_cmap.png index f74465d7..b29f5d2c 100644 Binary files a/tests/_images/Shapes_datashader_shades_with_linear_cmap.png and b/tests/_images/Shapes_datashader_shades_with_linear_cmap.png differ diff --git a/tests/_images/Show_user_ax_dpi_preserved.png b/tests/_images/Show_user_ax_dpi_preserved.png index ca509852..5830a394 100644 Binary files a/tests/_images/Show_user_ax_dpi_preserved.png and b/tests/_images/Show_user_ax_dpi_preserved.png differ diff --git a/tests/_images/Utils_can_set_zero_in_cmap_to_transparent.png b/tests/_images/Utils_can_set_zero_in_cmap_to_transparent.png index f708ea55..57aae80a 100644 Binary files a/tests/_images/Utils_can_set_zero_in_cmap_to_transparent.png and b/tests/_images/Utils_can_set_zero_in_cmap_to_transparent.png differ diff --git a/tests/pl/test_render_labels.py b/tests/pl/test_render_labels.py index 44d62225..3e8160dc 100644 --- a/tests/pl/test_render_labels.py +++ b/tests/pl/test_render_labels.py @@ -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() diff --git a/tests/pl/test_utils.py b/tests/pl/test_utils.py index 6efc5452..edc4a2b9 100644 --- a/tests/pl/test_utils.py +++ b/tests/pl/test_utils.py @@ -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")