diff --git a/src/spatialdata_plot/pl/_datashader.py b/src/spatialdata_plot/pl/_datashader.py index 83de7497..90e5ffba 100644 --- a/src/spatialdata_plot/pl/_datashader.py +++ b/src/spatialdata_plot/pl/_datashader.py @@ -716,6 +716,11 @@ def _ax_show_and_transform( return im +def _pad_degenerate_extent(ext: list[Any]) -> list[Any]: + """Pad a zero-width extent to a unit window centered on its value; pass others through.""" + return [ext[0] - 0.5, ext[1] + 0.5] if ext[1] == ext[0] else ext + + def _compute_datashader_canvas_params( x_ext: list[Any], y_ext: list[Any], @@ -725,6 +730,10 @@ def _compute_datashader_canvas_params( Shared logic used by both the dask-based and pandas-based entry points. """ + # A zero-width extent (single point, coincident points, axis-aligned line) has no scale to + # build a canvas from; pad it so the factor below doesn't divide by zero. + x_ext, y_ext = _pad_degenerate_extent(x_ext), _pad_degenerate_extent(y_ext) + # Compute canvas size in pixels, capped at the figure's display resolution. # Using np.max ensures the canvas never exceeds display pixels on either axis, # preventing pixel-based operations (spread, line_width) from being downscaled diff --git a/tests/pl/test_render_points.py b/tests/pl/test_render_points.py index 52a3f114..4fbbc0d8 100644 --- a/tests/pl/test_render_points.py +++ b/tests/pl/test_render_points.py @@ -29,6 +29,7 @@ _build_datashader_color_key, _ds_aggregate, _ds_shade_categorical, + _pad_degenerate_extent, ) from spatialdata_plot.pl.render import _warn_groups_ignored_continuous from tests.conftest import DPI, PlotTester, PlotTesterMeta, _viridis_with_under_over, get_standard_RNG @@ -1255,3 +1256,34 @@ def test_density_defaults_silent_and_force_datashader(sdata_blobs: SpatialData, last = list(out.plotting_tree.values())[-1] assert (last.density, last.density_how, last.method) == (True, "linear", "datashader") assert not any("ignored when density=True" in str(w.message) for w in recwarn.list) + + +# --------------------------------------------------------------------------- +# Zero-extent datashader canvas (#724) +# --------------------------------------------------------------------------- + + +def test_pad_degenerate_extent(): + # Regression test for #724: a zero-width extent expands to a unit window centered on the value, + # and a non-degenerate extent is passed through unchanged (so normal data is unaffected). + assert _pad_degenerate_extent([5.0, 5.0]) == [4.5, 5.5] + assert _pad_degenerate_extent([-3.0, -3.0]) == [-3.5, -2.5] + assert _pad_degenerate_extent([0.0, 10.0]) == [0.0, 10.0] + + +@pytest.mark.parametrize( + "coords", + [ + [[5.0, 5.0]], + [[5.0, 5.0], [5.0, 5.0], [5.0, 5.0]], + [[5.0, 0.0], [5.0, 1.0], [5.0, 2.0]], + ], + ids=["single_point", "coincident_points", "axis_aligned_line"], +) +def test_datashader_zero_extent_renders(coords): + # Regression test for #724: zero-extent point sets crashed the datashader backend with + # "cannot convert float NaN to integer". They must now render without raising. + df = pd.DataFrame(np.asarray(coords, dtype=float), columns=["x", "y"]) + sdata = SpatialData(points={"points": PointsModel.parse(df)}) + sdata.pl.render_points("points", method="datashader").pl.show() + plt.close("all")