Repository: scipp/plopp

Issue:
Pcolormesh is slow: add an imshow optimized branch for linspace bins
We are using Matplotlib's `pcolormesh` to plot all images.
Updating colors on the mesh at a high rate is difficult.

We should add an optimized branch in the `Image` class that uses `imshow` if the bins of the data are linspace.

Discussion:

Patch:
diff --git a/src/plopp/backends/matplotlib/fast_image.py b/src/plopp/backends/matplotlib/fast_image.py
new file mode 100644
index 0000000..63ef681
--- /dev/null
+++ b/src/plopp/backends/matplotlib/fast_image.py
@@ -0,0 +1,169 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright (c) 2025 Scipp contributors (https://github.com/scipp)
+
+import uuid
+from typing import Literal
+
+import numpy as np
+import scipp as sc
+from matplotlib.image import AxesImage
+
+from ...core.utils import coord_as_bin_edges, scalar_to_string
+from ...graphics.bbox import BoundingBox, axis_bounds
+from ...graphics.colormapper import ColorMapper
+from ..common import check_ndim
+from .canvas import Canvas
+
+
+class FastImage:
+    """
+    Artist to represent two-dimensional data.
+
+    Parameters
+    ----------
+    canvas:
+        The canvas that will display the image.
+    colormapper:
+        The colormapper to use for the image.
+    data:
+        The initial data to create the image from.
+    artist_number:
+        The canvas keeps track of how many images have been added to it. This is unused
+        by the FastImage artist.
+    uid:
+        The unique identifier of the artist. If None, a random UUID is generated.
+    **kwargs:
+        Additional arguments are forwarded to Matplotlib's ``imshow``.
+    """
+
+    def __init__(
+        self,
+        canvas: Canvas,
+        colormapper: ColorMapper,
+        data: sc.DataArray,
+        artist_number: int,
+        uid: str | None = None,
+        **kwargs,
+    ):
+        check_ndim(data, ndim=2, origin="FastImage")
+        self.uid = uid if uid is not None else uuid.uuid4().hex
+        self._canvas = canvas
+        self._colormapper = colormapper
+        self._ax = self._canvas.ax
+        self._data = data
+
+        string_labels = {}
+        self._bin_edge_coords = {}
+        for i, k in enumerate("yx"):
+            self._bin_edge_coords[k] = coord_as_bin_edges(
+                self._data, self._data.dims[i]
+            )
+            if self._data.coords[self._data.dims[i]].dtype == str:
+                string_labels[k] = self._data.coords[self._data.dims[i]]
+
+        self._xmin, self._xmax = self._bin_edge_coords["x"].values[[0, -1]]
+        self._ymin, self._ymax = self._bin_edge_coords["y"].values[[0, -1]]
+        self._dx = np.diff(self._bin_edge_coords["x"].values[:2])
+        self._dy = np.diff(self._bin_edge_coords["y"].values[:2])
+
+        self._image = AxesImage(
+            self._ax,
+            origin="lower",
+            extent=(self._xmin, self._xmax, self._ymin, self._ymax),
+            **({"interpolation": "nearest"} | kwargs),
+        )
+        self._image.set_data(self._data.values)
+        self._ax.add_image(self._image)
+
+        self._colormapper.add_artist(self.uid, self)
+        self._update_colors()
+
+        for xy, var in string_labels.items():
+            getattr(self._ax, f"set_{xy}ticks")(np.arange(float(var.shape[0])))
+            getattr(self._ax, f"set_{xy}ticklabels")(var.values)
+
+        self._canvas.register_format_coord(self.format_coord)
+        # We also hide the cursor hover values generated by the image, as values are
+        # included in our custom format_coord.
+        self._image.format_cursor_data = lambda _: ""
+
+    @property
+    def data(self):
+        """
+        Get the image's data in a form that may have been tweaked, compared to the
+        original data, in the case of a two-dimensional coordinate.
+        """
+        return self._data
+
+    def notify_artist(self, message: str) -> None:
+        """
+        Receive notification from the colormapper that its state has changed.
+        We thus need to update the colors of the image.
+
+        Parameters
+        ----------
+        message:
+            The message from the colormapper.
+        """
+        self._update_colors()
+
+    def _update_colors(self):
+        """
+        Update the image colors.
+        """
+        rgba = self._colormapper.rgba(self.data)
+        self._image.set_data(rgba)
+
+    def update(self, new_values: sc.DataArray):
+        """
+        Update image array with new values.
+
+        Parameters
+        ----------
+        new_values:
+            New data to update the image values from.
+        """
+        check_ndim(new_values, ndim=2, origin="FastImage")
+        self._data = new_values
+        self._update_colors()
+
+    def format_coord(
+        self, xslice: tuple[str, sc.Variable], yslice: tuple[str, sc.Variable]
+    ) -> str:
+        """
+        Format the coordinates of the mouse pointer to show the value of the
+        data at that point.
+
+        Parameters
+        ----------
+        xslice:
+            Dimension and x coordinate of the mouse pointer, as slice parameters.
+        yslice:
+            Dimension and y coordinate of the mouse pointer, as slice parameters.
+        """
+        ind_x = int((xslice[1].value - self._xmin) / self._dx)
+        ind_y = int((yslice[1].value - self._ymin) / self._dy)
+        try:
+            val = self._data[yslice[0], ind_y][xslice[0], ind_x]
+            prefix = self._data.name
+            if prefix:
+                prefix += ": "
+            return prefix + scalar_to_string(val)
+        except IndexError:
+            return None
+
+    def bbox(self, xscale: Literal["linear", "log"], yscale: Literal["linear", "log"]):
+        """
+        The bounding box of the image.
+        """
+        return BoundingBox(
+            **{**axis_bounds(("xmin", "xmax"), self._bin_edge_coords["x"], xscale)},
+            **{**axis_bounds(("ymin", "ymax"), self._bin_edge_coords["y"], yscale)},
+        )
+
+    def remove(self):
+        """
+        Remove the image artist from the canvas.
+        """
+        self._image.remove()
+        self._colormapper.remove_artist(self.uid)
diff --git a/src/plopp/backends/matplotlib/image.py b/src/plopp/backends/matplotlib/image.py
index c56ff0d..e1da9e9 100644
--- a/src/plopp/backends/matplotlib/image.py
+++ b/src/plopp/backends/matplotlib/image.py
@@ -1,251 +1,36 @@
 # SPDX-License-Identifier: BSD-3-Clause
-# Copyright (c) 2023 Scipp contributors (https://github.com/scipp)
+# Copyright (c) 2025 Scipp contributors (https://github.com/scipp)
 
-import uuid
-from typing import Literal
-
-import numpy as np
 import scipp as sc
 
-from ...core.utils import coord_as_bin_edges, merge_masks, repeat, scalar_to_string
-from ...graphics.bbox import BoundingBox, axis_bounds
-from ...graphics.colormapper import ColorMapper
-from ..common import check_ndim
 from .canvas import Canvas
+from .fast_image import FastImage
+from .mesh_image import MeshImage
 
 
-def _find_dim_of_2d_coord(coords):
-    for xy, coord in coords.items():
-        if coord['var'].ndim == 2:
-            return (xy, coord['dim'])
-
-
-def _get_dims_of_1d_and_2d_coords(coords):
-    dim_2d = _find_dim_of_2d_coord(coords)
-    if dim_2d is None:
-        return None, None
-    axis_1d = 'xy'.replace(dim_2d[0], '')
-    dim_1d = (axis_1d, coords[axis_1d]['dim'])
-    return dim_1d, dim_2d
-
-
-def _maybe_repeat_values(data, dim_1d, dim_2d):
-    if dim_2d is None:
-        return data
-    return repeat(data, dim=dim_1d[1], n=2)[dim_1d[1], :-1]
-
-
-def _from_data_array_to_pcolormesh(data, coords, dim_1d, dim_2d):
-    z = _maybe_repeat_values(data=data, dim_1d=dim_1d, dim_2d=dim_2d)
-    if dim_2d is None:
-        return coords['x'], coords['y'], z
-
-    # Broadcast 1d coord to 2d and repeat along 1d dim
-    # TODO: It may be more efficient here to first repeat and then broadcast, but
-    # the current order is simpler in implementation.
-    broadcasted_coord = repeat(
-        sc.broadcast(
-            coords[dim_1d[0]],
-            sizes={**coords[dim_2d[0]].sizes, **coords[dim_1d[0]].sizes},
-        ).transpose(data.dims),
-        dim=dim_1d[1],
-        n=2,
-    )
-    # Repeat 2d coord along 1d dim
-    repeated_coord = repeat(coords[dim_2d[0]].transpose(data.dims), dim=dim_1d[1], n=2)
-    out = {dim_1d[0]: broadcasted_coord[dim_1d[1], 1:-1], dim_2d[0]: repeated_coord}
-    return out['x'], out['y'], z
-
-
-class Image:
+def Image(
+    canvas: Canvas,
+    data: sc.DataArray,
+    **kwargs,
+):
     """
-    Artist to represent two-dimensional data.
+    Factory function to create an image artist.
+    If all the coordinates of the data are 1D and linearly spaced,
+    a `FastImage` is created.
+    Otherwise, a `MeshImage` is created.
 
     Parameters
     ----------
     canvas:
         The canvas that will display the image.
-    colormapper:
-        The colormapper to use for the image.
     data:
-        The initial data to create the image from.
-    uid:
-        The unique identifier of the artist. If None, a random UUID is generated.
-    shading:
-        The shading to use for the ``pcolormesh``.
-    rasterized:
-        Rasterize the mesh/image if ``True``.
-    **kwargs:
-        Additional arguments are forwarded to Matplotlib's ``pcolormesh``.
+        The data to create the image from.
     """
-
-    def __init__(
-        self,
-        canvas: Canvas,
-        colormapper: ColorMapper,
-        data: sc.DataArray,
-        uid: str | None = None,
-        shading: str = 'auto',
-        rasterized: bool = True,
-        **kwargs,
+    if (canvas.ax.name != 'polar') and all(
+        (data.coords[dim].ndim < 2)
+        and ((data.coords[dim].dtype == str) or (sc.islinspace(data.coords[dim])))
+        for dim in data.dims
     ):
-        check_ndim(data, ndim=2, origin='Image')
-        self.uid = uid if uid is not None else uuid.uuid4().hex
-        self._canvas = canvas
-        self._colormapper = colormapper
-        self._ax = self._canvas.ax
-        self._data = data
-        # Because all keyword arguments from the figure are forwarded to both the canvas
-        # and the line, we need to remove the arguments that belong to the canvas.
-        kwargs.pop('ax', None)
-        kwargs.pop('cax', None)
-        # An artist number is passed to the artist, but is unused for the image.
-        kwargs.pop('artist_number', None)
-        # If the grid is visible on the axes, we need to set that on again after we
-        # call pcolormesh, because that turns the grid off automatically.
-        # See https://github.com/matplotlib/matplotlib/issues/15600.
-        need_grid = self._ax.xaxis.get_gridlines()[0].get_visible()
-
-        to_dim_search = {}
-        string_labels = {}
-        bin_edge_coords = {}
-        self._data_with_bin_edges = sc.DataArray(data=self._data.data)
-        for i, k in enumerate('yx'):
-            to_dim_search[k] = {
-                'dim': self._data.dims[i],
-                'var': self._data.coords[self._data.dims[i]],
-            }
-            bin_edge_coords[k] = coord_as_bin_edges(self._data, self._data.dims[i])
-            self._data_with_bin_edges.coords[self._data.dims[i]] = bin_edge_coords[k]
-            if self._data.coords[self._data.dims[i]].dtype == str:
-                string_labels[k] = self._data.coords[self._data.dims[i]]
-
-        self._dim_1d, self._dim_2d = _get_dims_of_1d_and_2d_coords(to_dim_search)
-        self._mesh = None
-
-        x, y, z = _from_data_array_to_pcolormesh(
-            data=self._data.data,
-            coords=bin_edge_coords,
-            dim_1d=self._dim_1d,
-            dim_2d=self._dim_2d,
-        )
-        self._mesh = self._ax.pcolormesh(
-            x.values,
-            y.values,
-            z.values,
-            shading=shading,
-            rasterized=rasterized,
-            **kwargs,
-        )
-
-        self._colormapper.add_artist(self.uid, self)
-        self._mesh.set_array(None)
-        self._update_colors()
-
-        for xy, var in string_labels.items():
-            getattr(self._ax, f'set_{xy}ticks')(np.arange(float(var.shape[0])))
-            getattr(self._ax, f'set_{xy}ticklabels')(var.values)
-
-        if need_grid:
-            self._ax.grid(True)
-
-        self._canvas.register_format_coord(self.format_coord)
-
-    @property
-    def data(self):
-        """
-        Get the Mesh's data in a form that may have been tweaked, compared to the
-        original data, in the case of a two-dimensional coordinate.
-        """
-        out = sc.DataArray(
-            data=_maybe_repeat_values(
-                data=self._data.data, dim_1d=self._dim_1d, dim_2d=self._dim_2d
-            )
-        )
-        if self._data.masks:
-            out.masks['one_mask'] = _maybe_repeat_values(
-                data=sc.broadcast(
-                    merge_masks(self._data.masks),
-                    dims=self._data.dims,
-                    shape=self._data.shape,
-                ),
-                dim_1d=self._dim_1d,
-                dim_2d=self._dim_2d,
-            )
-        return out
-
-    def notify_artist(self, message: str) -> None:
-        """
-        Receive notification from the colormapper that its state has changed.
-        We thus need to update the colors of the mesh.
-
-        Parameters
-        ----------
-        message:
-            The message from the colormapper.
-        """
-        self._update_colors()
-
-    def _update_colors(self):
-        """
-        Update the mesh colors.
-        """
-        rgba = self._colormapper.rgba(self.data)
-        self._mesh.set_facecolors(rgba.reshape(np.prod(rgba.shape[:-1]), 4))
-
-    def update(self, new_values: sc.DataArray):
-        """
-        Update image array with new values.
-
-        Parameters
-        ----------
-        new_values:
-            New data to update the mesh values from.
-        """
-        check_ndim(new_values, ndim=2, origin='Image')
-        self._data = new_values
-        self._data_with_bin_edges.data = new_values.data
-        self._update_colors()
-
-    def format_coord(
-        self, xslice: tuple[str, sc.Variable], yslice: tuple[str, sc.Variable]
-    ) -> str:
-        """
-        Format the coordinates of the mouse pointer to show the value of the
-        data at that point.
-
-        Parameters
-        ----------
-        xslice:
-            Dimension and x coordinate of the mouse pointer, as slice parameters.
-        yslice:
-            Dimension and y coordinate of the mouse pointer, as slice parameters.
-        """
-        try:
-            val = self._data_with_bin_edges[yslice][xslice]
-            prefix = self._data.name
-            if prefix:
-                prefix += ': '
-            return prefix + scalar_to_string(val)
-        except (IndexError, RuntimeError):
-            return None
-
-    def bbox(self, xscale: Literal['linear', 'log'], yscale: Literal['linear', 'log']):
-        """
-        The bounding box of the image.
-        """
-        ydim, xdim = self._data.dims
-        image_x = self._data_with_bin_edges.coords[xdim]
-        image_y = self._data_with_bin_edges.coords[ydim]
-
-        return BoundingBox(
-            **{**axis_bounds(('xmin', 'xmax'), image_x, xscale)},
-            **{**axis_bounds(('ymin', 'ymax'), image_y, yscale)},
-        )
-
-    def remove(self):
-        """
-        Remove the image artist from the canvas.
-        """
-        self._mesh.remove()
-        self._colormapper.remove_artist(self.uid)
+        return FastImage(canvas=canvas, data=data, **kwargs)
+    else:
+        return MeshImage(canvas=canvas, data=data, **kwargs)
diff --git a/src/plopp/backends/matplotlib/line.py b/src/plopp/backends/matplotlib/line.py
index e8b4581..55b3373 100644
--- a/src/plopp/backends/matplotlib/line.py
+++ b/src/plopp/backends/matplotlib/line.py
@@ -58,9 +58,6 @@ class Line:
         self._canvas = canvas
         self._ax = self._canvas.ax
         self._data = data
-        # Because all keyword arguments from the figure are forwarded to both the canvas
-        # and the line, we need to remove the arguments that belong to the canvas.
-        kwargs.pop('ax', None)
 
         line_args = parse_dicts_in_kwargs(kwargs, name=data.name)
 
diff --git a/src/plopp/backends/matplotlib/mesh_image.py b/src/plopp/backends/matplotlib/mesh_image.py
new file mode 100644
index 0000000..a099398
--- /dev/null
+++ b/src/plopp/backends/matplotlib/mesh_image.py
@@ -0,0 +1,247 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright (c) 2025 Scipp contributors (https://github.com/scipp)
+
+import uuid
+from typing import Literal
+
+import numpy as np
+import scipp as sc
+
+from ...core.utils import coord_as_bin_edges, merge_masks, repeat, scalar_to_string
+from ...graphics.bbox import BoundingBox, axis_bounds
+from ...graphics.colormapper import ColorMapper
+from ..common import check_ndim
+from .canvas import Canvas
+
+
+def _find_dim_of_2d_coord(coords):
+    for xy, coord in coords.items():
+        if coord['var'].ndim == 2:
+            return (xy, coord['dim'])
+
+
+def _get_dims_of_1d_and_2d_coords(coords):
+    dim_2d = _find_dim_of_2d_coord(coords)
+    if dim_2d is None:
+        return None, None
+    axis_1d = 'xy'.replace(dim_2d[0], '')
+    dim_1d = (axis_1d, coords[axis_1d]['dim'])
+    return dim_1d, dim_2d
+
+
+def _maybe_repeat_values(data, dim_1d, dim_2d):
+    if dim_2d is None:
+        return data
+    return repeat(data, dim=dim_1d[1], n=2)[dim_1d[1], :-1]
+
+
+def _from_data_array_to_pcolormesh(data, coords, dim_1d, dim_2d):
+    z = _maybe_repeat_values(data=data, dim_1d=dim_1d, dim_2d=dim_2d)
+    if dim_2d is None:
+        return coords['x'], coords['y'], z
+
+    # Broadcast 1d coord to 2d and repeat along 1d dim
+    # TODO: It may be more efficient here to first repeat and then broadcast, but
+    # the current order is simpler in implementation.
+    broadcasted_coord = repeat(
+        sc.broadcast(
+            coords[dim_1d[0]],
+            sizes={**coords[dim_2d[0]].sizes, **coords[dim_1d[0]].sizes},
+        ).transpose(data.dims),
+        dim=dim_1d[1],
+        n=2,
+    )
+    # Repeat 2d coord along 1d dim
+    repeated_coord = repeat(coords[dim_2d[0]].transpose(data.dims), dim=dim_1d[1], n=2)
+    out = {dim_1d[0]: broadcasted_coord[dim_1d[1], 1:-1], dim_2d[0]: repeated_coord}
+    return out['x'], out['y'], z
+
+
+class MeshImage:
+    """
+    Artist to represent two-dimensional data.
+
+    Parameters
+    ----------
+    canvas:
+        The canvas that will display the image.
+    colormapper:
+        The colormapper to use for the image.
+    data:
+        The initial data to create the image from.
+    artist_number:
+        The canvas keeps track of how many images have been added to it. This is unused
+        by the MeshImage artist.
+    uid:
+        The unique identifier of the artist. If None, a random UUID is generated.
+    shading:
+        The shading to use for the ``pcolormesh``.
+    rasterized:
+        Rasterize the mesh/image if ``True``.
+    **kwargs:
+        Additional arguments are forwarded to Matplotlib's ``pcolormesh``.
+    """
+
+    def __init__(
+        self,
+        canvas: Canvas,
+        colormapper: ColorMapper,
+        data: sc.DataArray,
+        artist_number: int,
+        uid: str | None = None,
+        shading: str = 'auto',
+        rasterized: bool = True,
+        **kwargs,
+    ):
+        check_ndim(data, ndim=2, origin='MeshImage')
+        self.uid = uid if uid is not None else uuid.uuid4().hex
+        self._canvas = canvas
+        self._colormapper = colormapper
+        self._ax = self._canvas.ax
+        self._data = data
+        # If the grid is visible on the axes, we need to set that on again after we
+        # call pcolormesh, because that turns the grid off automatically.
+        # See https://github.com/matplotlib/matplotlib/issues/15600.
+        need_grid = self._ax.xaxis.get_gridlines()[0].get_visible()
+
+        to_dim_search = {}
+        string_labels = {}
+        bin_edge_coords = {}
+        self._data_with_bin_edges = sc.DataArray(data=self._data.data)
+        for i, k in enumerate('yx'):
+            to_dim_search[k] = {
+                'dim': self._data.dims[i],
+                'var': self._data.coords[self._data.dims[i]],
+            }
+            bin_edge_coords[k] = coord_as_bin_edges(self._data, self._data.dims[i])
+            self._data_with_bin_edges.coords[self._data.dims[i]] = bin_edge_coords[k]
+            if self._data.coords[self._data.dims[i]].dtype == str:
+                string_labels[k] = self._data.coords[self._data.dims[i]]
+
+        self._dim_1d, self._dim_2d = _get_dims_of_1d_and_2d_coords(to_dim_search)
+        self._mesh = None
+
+        x, y, z = _from_data_array_to_pcolormesh(
+            data=self._data.data,
+            coords=bin_edge_coords,
+            dim_1d=self._dim_1d,
+            dim_2d=self._dim_2d,
+        )
+        self._mesh = self._ax.pcolormesh(
+            x.values,
+            y.values,
+            z.values,
+            shading=shading,
+            rasterized=rasterized,
+            **kwargs,
+        )
+
+        self._colormapper.add_artist(self.uid, self)
+        self._mesh.set_array(None)
+        self._update_colors()
+
+        for xy, var in string_labels.items():
+            getattr(self._ax, f'set_{xy}ticks')(np.arange(float(var.shape[0])))
+            getattr(self._ax, f'set_{xy}ticklabels')(var.values)
+
+        if need_grid:
+            self._ax.grid(True)
+
+        self._canvas.register_format_coord(self.format_coord)
+
+    @property
+    def data(self):
+        """
+        Get the Mesh's data in a form that may have been tweaked, compared to the
+        original data, in the case of a two-dimensional coordinate.
+        """
+        out = sc.DataArray(
+            data=_maybe_repeat_values(
+                data=self._data.data, dim_1d=self._dim_1d, dim_2d=self._dim_2d
+            )
+        )
+        if self._data.masks:
+            out.masks['one_mask'] = _maybe_repeat_values(
+                data=sc.broadcast(
+                    merge_masks(self._data.masks), sizes=self._data.sizes
+                ),
+                dim_1d=self._dim_1d,
+                dim_2d=self._dim_2d,
+            )
+        return out
+
+    def notify_artist(self, message: str) -> None:
+        """
+        Receive notification from the colormapper that its state has changed.
+        We thus need to update the colors of the mesh.
+
+        Parameters
+        ----------
+        message:
+            The message from the colormapper.
+        """
+        self._update_colors()
+
+    def _update_colors(self):
+        """
+        Update the mesh colors.
+        """
+        rgba = self._colormapper.rgba(self.data)
+        self._mesh.set_facecolors(rgba.reshape(np.prod(rgba.shape[:-1]), 4))
+
+    def update(self, new_values: sc.DataArray):
+        """
+        Update image array with new values.
+
+        Parameters
+        ----------
+        new_values:
+            New data to update the mesh values from.
+        """
+        check_ndim(new_values, ndim=2, origin='MeshImage')
+        self._data = new_values
+        self._data_with_bin_edges.data = new_values.data
+        self._update_colors()
+
+    def format_coord(
+        self, xslice: tuple[str, sc.Variable], yslice: tuple[str, sc.Variable]
+    ) -> str:
+        """
+        Format the coordinates of the mouse pointer to show the value of the
+        data at that point.
+
+        Parameters
+        ----------
+        xslice:
+            Dimension and x coordinate of the mouse pointer, as slice parameters.
+        yslice:
+            Dimension and y coordinate of the mouse pointer, as slice parameters.
+        """
+        try:
+            val = self._data_with_bin_edges[yslice][xslice]
+            prefix = self._data.name
+            if prefix:
+                prefix += ': '
+            return prefix + scalar_to_string(val)
+        except (IndexError, RuntimeError):
+            return None
+
+    def bbox(self, xscale: Literal['linear', 'log'], yscale: Literal['linear', 'log']):
+        """
+        The bounding box of the image.
+        """
+        ydim, xdim = self._data.dims
+        image_x = self._data_with_bin_edges.coords[xdim]
+        image_y = self._data_with_bin_edges.coords[ydim]
+
+        return BoundingBox(
+            **{**axis_bounds(('xmin', 'xmax'), image_x, xscale)},
+            **{**axis_bounds(('ymin', 'ymax'), image_y, yscale)},
+        )
+
+    def remove(self):
+        """
+        Remove the image artist from the canvas.
+        """
+        self._mesh.remove()
+        self._colormapper.remove_artist(self.uid)
diff --git a/src/plopp/backends/matplotlib/scatter.py b/src/plopp/backends/matplotlib/scatter.py
index 75e3663..7c373ee 100644
--- a/src/plopp/backends/matplotlib/scatter.py
+++ b/src/plopp/backends/matplotlib/scatter.py
@@ -69,9 +69,7 @@ class Scatter:
         self._y = y
         self._size = size
         self._colormapper = colormapper
-        # Because all keyword arguments from the figure are forwarded to both the canvas
-        # and the line, we need to remove the arguments that belong to the canvas.
-        kwargs.pop('ax', None)
+
         if 's' in kwargs:
             raise ValueError("Use 'size' instead of 's' for scatter plot.")
 
diff --git a/src/plopp/data/factory.py b/src/plopp/data/factory.py
index 1be944e..e0b8427 100644
--- a/src/plopp/data/factory.py
+++ b/src/plopp/data/factory.py
@@ -62,6 +62,7 @@ def data_array(
     unit: str = 'm/s',
     dims: list[str] | None = None,
     dim_list: list[str] = default_dim_list,
+    linspace: bool = True,
 ) -> sc.DataArray:
     """
     Generate a sample ``DataArray`` containing data based on a sine function, with
@@ -91,6 +92,8 @@ def data_array(
         List of dimension labels. If ``None``, they will be auto-generated.
     dim_list:
         List of dimension labels to use if no ``dims`` are provided.
+    linspace:
+        If ``True``, the coordinates will be generated using :func:`scipp.linspace`.
     """
 
     coord_units = dict(zip(dim_list, ['m', 'm', 'm', 's', 'K'], strict=True))
@@ -113,12 +116,16 @@ def data_array(
         )
         for i in range(ndim)
     }
-    mask_dict = {}
+    if not linspace:
+        for dim in coord_dict:
+            coord_dict[dim].values **= 1.01
 
     if labels:
         coord_dict["lab"] = sc.linspace(
             data.dims[0], 101.0, 105.0, data.shape[0], unit='s'
         )
+
+    mask_dict = {}
     if masks:
         mask_dict["mask"] = sc.array(
             dims=data.dims, values=np.where(data.values > 0, True, False)
diff --git a/src/plopp/graphics/colormapper.py b/src/plopp/graphics/colormapper.py
index a953a75..16a2323 100644
--- a/src/plopp/graphics/colormapper.py
+++ b/src/plopp/graphics/colormapper.py
@@ -162,7 +162,7 @@ class ColorMapper:
         """
         colors = self.cmap(self.normalizer(data.values))
         if data.masks:
-            one_mask = merge_masks(data.masks).values
+            one_mask = sc.broadcast(merge_masks(data.masks), sizes=data.sizes).values
             colors[one_mask] = self.mask_cmap(self.normalizer(data.values[one_mask]))
         return colors
 


Test patch:
diff --git a/tests/backends/matplotlib/mpl_image_test.py b/tests/backends/matplotlib/mpl_image_test.py
index fb3c882..c0f7aa3 100644
--- a/tests/backends/matplotlib/mpl_image_test.py
+++ b/tests/backends/matplotlib/mpl_image_test.py
@@ -13,8 +13,8 @@ pytestmark = pytest.mark.usefixtures("_parametrize_mpl_backends")
 
 
 def test_update_on_one_mesh_changes_colors_on_second_mesh():
-    da1 = data_array(ndim=2)
-    da2 = 3.0 * data_array(ndim=2)
+    da1 = data_array(ndim=2, linspace=False)
+    da2 = 3.0 * data_array(ndim=2, linspace=False)
     da2.coords['xx'] += sc.scalar(50.0, unit='m')
     a = Node(da1)
     b = Node(da2)
@@ -31,8 +31,27 @@ def test_update_on_one_mesh_changes_colors_on_second_mesh():
     assert not np.allclose(old_b_colors, f.artists[b.id]._mesh.get_facecolors())
 
 
+def test_update_on_one_mesh_changes_colors_on_second_image():
+    da1 = data_array(ndim=2, linspace=True)
+    da2 = 3.0 * data_array(ndim=2, linspace=True)
+    da2.coords['xx'] += sc.scalar(50.0, unit='m')
+    a = Node(da1)
+    b = Node(da2)
+    f = imagefigure(a, b)
+    old_b_colors = f.artists[b.id]._image.get_array()
+    a.func = lambda: da1 * 2.1
+    a.notify_children('updated a')
+    f.view.colormapper.autoscale()  # Autoscale the colorbar limits
+    # No change because the update did not change the colorbar limits
+    assert np.allclose(old_b_colors, f.artists[b.id]._image.get_array())
+    a.func = lambda: da1 * 5.0
+    a.notify_children('updated a')
+    f.view.colormapper.autoscale()  # Autoscale the colorbar limits
+    assert not np.allclose(old_b_colors, f.artists[b.id]._image.get_array())
+
+
 def test_kwargs_are_forwarded_to_artist():
-    da = data_array(ndim=2)
+    da = data_array(ndim=2, linspace=False)
     fig = imagefigure(Node(da), rasterized=True)
     [artist] = fig.artists.values()
     assert artist._mesh.get_rasterized()
@@ -41,8 +60,9 @@ def test_kwargs_are_forwarded_to_artist():
     assert not artist._mesh.get_rasterized()
 
 
-def test_bbox_midpoints():
-    da = data_array(ndim=2)
+@pytest.mark.parametrize('linspace', [True, False])
+def test_bbox_midpoints(linspace):
+    da = data_array(ndim=2, linspace=linspace)
     fig = imagefigure(Node(da))
     [artist] = fig.artists.values()
     bbox = artist.bbox(xscale='linear', yscale='linear')
@@ -52,8 +72,9 @@ def test_bbox_midpoints():
     assert bbox.ymax > da.coords['yy'].max().value
 
 
-def test_bbox_binedges():
-    da = data_array(ndim=2, binedges=True)
+@pytest.mark.parametrize('linspace', [True, False])
+def test_bbox_binedges(linspace):
+    da = data_array(ndim=2, binedges=True, linspace=linspace)
     fig = imagefigure(Node(da))
     [artist] = fig.artists.values()
     bbox = artist.bbox(xscale='linear', yscale='linear')
diff --git a/tests/backends/matplotlib/mpl_plot_test.py b/tests/backends/matplotlib/mpl_plot_test.py
index 4fbc829..4fb46d8 100644
--- a/tests/backends/matplotlib/mpl_plot_test.py
+++ b/tests/backends/matplotlib/mpl_plot_test.py
@@ -27,17 +27,22 @@ def test_grid():
 
 def test_ax():
     _, ax = plt.subplots()
+    assert len(ax.lines) == 0
     assert len(ax.collections) == 0
-    da = data_array(ndim=2)
-    _ = da.plot(ax=ax)
+    assert len(ax.images) == 0
+    data_array(ndim=1).plot(ax=ax)
+    assert len(ax.lines) > 0
+    data_array(ndim=2, linspace=False).plot(ax=ax)
     assert len(ax.collections) == 1
+    data_array(ndim=2, linspace=True).plot(ax=ax)
+    assert len(ax.images) == 1
 
 
 def test_cax():
     fig, ax = plt.subplots()
     cax = fig.add_axes([0.9, 0.02, 0.05, 0.98])
     assert len(ax.collections) == 0
-    da = data_array(ndim=2)
+    da = data_array(ndim=2, linspace=False)
     fig = da.plot(ax=ax, cax=cax)
     assert len(ax.collections) > 0
     assert fig.canvas.cax is cax
diff --git a/tests/plotting/plot_2d_test.py b/tests/plotting/plot_2d_test.py
index a4be2e8..0055689 100644
--- a/tests/plotting/plot_2d_test.py
+++ b/tests/plotting/plot_2d_test.py
@@ -26,17 +26,23 @@ def test_plot_variable():
     pp.plot(variable(ndim=2))
 
 
-def test_plot_data_array():
-    pp.plot(data_array(ndim=2))
+@pytest.mark.parametrize('linspace', [True, False])
+def test_plot_data_array(linspace):
+    pp.plot(data_array(ndim=2, linspace=linspace))
 
 
-def test_plot_from_node():
-    da = data_array(ndim=2)
+@pytest.mark.parametrize('linspace', [True, False])
+def test_plot_from_node(linspace):
+    da = data_array(ndim=2, linspace=linspace)
     pp.plot(pp.Node(da))
 
 
-def test_plot_data_array_2d_with_one_missing_coord_and_binedges():
-    da = sc.data.table_xyz(100).bin(x=10, y=12).bins.mean()
+@pytest.mark.parametrize('linspace', [True, False])
+def test_plot_data_array_2d_with_one_missing_coord_and_binedges(linspace):
+    xbins = sc.linspace('x', 0, 1, 10, unit='m')
+    if not linspace:
+        xbins[-1] += sc.scalar(0.1, unit='m')
+    da = sc.data.table_xyz(100).bin(x=xbins, y=12).bins.mean()
     del da.coords['x']
     pp.plot(da)
 
@@ -53,20 +59,23 @@ def test_plot_2d_coord_with_mask():
     pp.plot(da)
 
 
-def test_kwarg_norm():
-    da = data_array(ndim=2)
+@pytest.mark.parametrize('linspace', [True, False])
+def test_kwarg_norm(linspace):
+    da = data_array(ndim=2, linspace=linspace)
     p = pp.plot(da, norm='log')
     assert p.view.colormapper.norm == 'log'
 
 
-def test_kwarg_cmap():
-    da = data_array(ndim=2)
+@pytest.mark.parametrize('linspace', [True, False])
+def test_kwarg_cmap(linspace):
+    da = data_array(ndim=2, linspace=linspace)
     p = pp.plot(da, cmap='magma')
     assert p.view.colormapper.cmap.name == 'magma'
 
 
-def test_kwarg_scale_2d():
-    da = data_array(ndim=2)
+@pytest.mark.parametrize('linspace', [True, False])
+def test_kwarg_scale_2d(linspace):
+    da = data_array(ndim=2, linspace=linspace)
     p = pp.plot(da, scale={'xx': 'log', 'yy': 'log'})
     assert p.canvas.ax.get_xscale() == 'log'
     assert p.canvas.ax.get_yscale() == 'log'
@@ -78,8 +87,9 @@ def test_raises_ValueError_when_given_binned_data():
         pp.plot(da)
 
 
-def test_use_non_dimension_coords():
-    da = data_array(ndim=2, binedges=True)
+@pytest.mark.parametrize('linspace', [True, False])
+def test_use_non_dimension_coords(linspace):
+    da = data_array(ndim=2, binedges=True, linspace=linspace)
     da.coords['xx2'] = 7.5 * da.coords['xx']
     da.coords['yy2'] = 3.3 * da.coords['yy']
     p = pp.plot(da, coords=['xx2', 'yy2'])
@@ -100,8 +110,9 @@ def test_use_two_coords_for_same_underlying_dimension_raises():
 
 
 @pytest.mark.parametrize('ext', ['jpg', 'png', 'pdf', 'svg'])
-def test_save_to_disk_2d(ext):
-    da = data_array(ndim=2)
+@pytest.mark.parametrize('linspace', [True, False])
+def test_save_to_disk_2d(ext, linspace):
+    da = data_array(ndim=2, linspace=linspace)
     fig = pp.plot(da)
     with tempfile.TemporaryDirectory() as path:
         fname = os.path.join(path, f'plopp_fig2d.{ext}')
@@ -109,15 +120,17 @@ def test_save_to_disk_2d(ext):
         assert os.path.isfile(fname)
 
 
-def test_save_to_disk_with_bad_extension_raises():
-    da = data_array(ndim=2)
+@pytest.mark.parametrize('linspace', [True, False])
+def test_save_to_disk_with_bad_extension_raises(linspace):
+    da = data_array(ndim=2, linspace=linspace)
     fig = pp.plot(da)
     with pytest.raises(ValueError, match='txt'):
         fig.save(filename='plopp_fig2d.txt')
 
 
-def test_plot_raises_with_multiple_2d_inputs():
-    a = data_array(ndim=2)
+@pytest.mark.parametrize('linspace', [True, False])
+def test_plot_raises_with_multiple_2d_inputs(linspace):
+    a = data_array(ndim=2, linspace=linspace)
     b = 3.3 * a
     with pytest.raises(
         ValueError, match='The plot function can only plot a single 2d data entry'
@@ -143,16 +156,18 @@ def test_plot_xarray_data_array_2d():
     assert p.canvas.units['y'] == 'dimensionless'
 
 
-def test_plot_2d_ignores_masked_data_for_colorbar_range():
-    da = data_array(ndim=2)
+@pytest.mark.parametrize('linspace', [True, False])
+def test_plot_2d_ignores_masked_data_for_colorbar_range(linspace):
+    da = data_array(ndim=2, linspace=linspace)
     da['xx', 10]['yy', 10].values = 100
     da.masks['m'] = da.data > sc.scalar(5.0, unit='m/s')
     p = pp.plot(da)
     assert p.view.colormapper.vmax < 100
 
 
-def test_plot_2d_includes_masked_data_in_horizontal_range():
-    da = data_array(ndim=2)
+@pytest.mark.parametrize('linspace', [True, False])
+def test_plot_2d_includes_masked_data_in_horizontal_range(linspace):
+    da = data_array(ndim=2, linspace=linspace)
     da.masks['left'] = da.coords['xx'] < sc.scalar(5.0, unit='m')
     da.masks['right'] = da.coords['xx'] > sc.scalar(30.0, unit='m')
     p = pp.plot(da)
@@ -160,8 +175,9 @@ def test_plot_2d_includes_masked_data_in_horizontal_range():
     assert p.canvas.xmax > 40.0
 
 
-def test_plot_2d_includes_masked_data_in_vertical_range():
-    da = data_array(ndim=2)
+@pytest.mark.parametrize('linspace', [True, False])
+def test_plot_2d_includes_masked_data_in_vertical_range(linspace):
+    da = data_array(ndim=2, linspace=linspace)
     da.masks['bottom'] = da.coords['yy'] < sc.scalar(5.0, unit='m')
     da.masks['top'] = da.coords['yy'] > sc.scalar(20.0, unit='m')
     p = pp.plot(da)
@@ -169,10 +185,13 @@ def test_plot_2d_includes_masked_data_in_vertical_range():
     assert p.canvas.ymax > 30.0
 
 
-def test_plot_2d_datetime_coord():
+@pytest.mark.parametrize('linspace', [True, False])
+def test_plot_2d_datetime_coord(linspace):
     t = np.arange(
         np.datetime64('2017-03-16T20:58:17'), np.datetime64('2017-03-16T21:15:17'), 20
     )
+    if not linspace:
+        t[-1] += np.timedelta64(1, 's')
     time = sc.array(dims=['time'], values=t)
     z = sc.arange('z', 50.0, unit='m')
     v = np.random.random(z.shape + time.shape)
@@ -183,10 +202,13 @@ def test_plot_2d_datetime_coord():
     pp.plot(da)
 
 
-def test_plot_2d_datetime_coord_binedges():
+@pytest.mark.parametrize('linspace', [True, False])
+def test_plot_2d_datetime_coord_binedges(linspace):
     t = np.arange(
         np.datetime64('2017-03-16T20:58:17'), np.datetime64('2017-03-16T21:15:17'), 20
     )
+    if not linspace:
+        t[-1] += np.timedelta64(1, 's')
     time = sc.array(dims=['time'], values=t)
     z = sc.arange('z', 50.0, unit='m')
     v = np.random.random(z[:-1].shape + time[:-1].shape)
@@ -197,16 +219,18 @@ def test_plot_2d_datetime_coord_binedges():
     pp.plot(da)
 
 
-def test_create_with_bin_edges():
-    da = data_array(ndim=2, binedges=True)
+@pytest.mark.parametrize('linspace', [True, False])
+def test_create_with_bin_edges(linspace):
+    da = data_array(ndim=2, binedges=True, linspace=linspace)
     fig = da.plot()
     assert len(fig.artists) == 1
     [img] = fig.artists.values()
     assert sc.identical(img._data, da)
 
 
-def test_create_with_only_one_bin_edge_coord():
-    da = data_array(ndim=2, binedges=True)
+@pytest.mark.parametrize('linspace', [True, False])
+def test_create_with_only_one_bin_edge_coord(linspace):
+    da = data_array(ndim=2, binedges=True, linspace=linspace)
     da.coords['xx'] = sc.midpoints(da.coords['xx'])
     fig = da.plot()
     assert len(fig.artists) == 1
@@ -263,8 +287,9 @@ def test_plot_1d_data_over_2d_data_datetime():
     pp.plot(da1d, ax=fig2d.ax)
 
 
-def test_no_cbar():
-    da = data_array(ndim=2)
+@pytest.mark.parametrize('linspace', [True, False])
+def test_no_cbar(linspace):
+    da = data_array(ndim=2, linspace=linspace)
     fig = da.plot(cbar=True)
     assert fig.view.colormapper.colorbar is not None
     fig = da.plot(cbar=False)
@@ -293,8 +318,9 @@ def test_figure_has_data_name_on_colorbar_for_one_image():
     assert str(da.unit) in ylabel
 
 
-def test_figure_has_only_unit_on_colorbar_for_multiple_images():
-    a = data_array(ndim=2)
+@pytest.mark.parametrize('linspace', [True, False])
+def test_figure_has_only_unit_on_colorbar_for_multiple_images(linspace):
+    a = data_array(ndim=2, linspace=linspace)
     a.name = "Velocity"
     b = a * 1.67
     dim = b.dims[-1]