From 5d94937ef8096c54542f92d6b9e8626f17168a1b Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Fri, 5 Sep 2025 18:09:03 +0200 Subject: [PATCH 1/8] allow position update for fast image --- src/plopp/backends/matplotlib/fast_image.py | 55 ++++++++++++++++----- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/src/plopp/backends/matplotlib/fast_image.py b/src/plopp/backends/matplotlib/fast_image.py index 89eeff457..a200859e7 100644 --- a/src/plopp/backends/matplotlib/fast_image.py +++ b/src/plopp/backends/matplotlib/fast_image.py @@ -52,19 +52,26 @@ def __init__( self._ax = self._canvas.ax self._data = data - string_labels = {} - self._bin_edge_coords = {} + # string_labels = {} + # self._raw_coords = {} + # self._bin_edge_coords = {} + # for i, k in enumerate("yx"): + # self._raw_coords[k] = self._data.coords[self._data.dims[i]] + # 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._raw_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._raw_coords[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._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._update_coords() # Calling imshow sets the aspect ratio to 'equal', which might not be what the # user requested. We need to restore the original aspect ratio after making the @@ -90,7 +97,7 @@ def __init__( self._colormapper.add_artist(self.uid, self) self._update_colors() - for xy, var in string_labels.items(): + for xy, var in self._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) @@ -99,6 +106,23 @@ def __init__( # included in our custom format_coord. self._image.format_cursor_data = lambda _: "" + def _update_coords(self) -> None: + self._string_labels = {} + self._raw_coords = {} + self._bin_edge_coords = {} + for i, k in enumerate("yx"): + self._raw_coords[k] = self._data.coords[self._data.dims[i]] + 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: + self._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]) + @property def data(self): """ @@ -137,6 +161,13 @@ def update(self, new_values: sc.DataArray): """ check_ndim(new_values, ndim=2, origin="FastImage") self._data = new_values + if any( + not sc.identical(new_values.coords[self._data.dims[i]], self._raw_coords[k]) + for i, k in enumerate("yx") + ): + self._update_coords() + self._image.set_extent((self._xmin, self._xmax, self._ymin, self._ymax)) + self._update_colors() def format_coord( From 79637d4bdb87d030bcee40fd339526e26d178bc3 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Fri, 5 Sep 2025 21:07:32 +0200 Subject: [PATCH 2/8] begin implementing for mesh image --- src/plopp/backends/matplotlib/fast_image.py | 7 ++-- src/plopp/backends/matplotlib/mesh_image.py | 41 +++++++++++++-------- 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/src/plopp/backends/matplotlib/fast_image.py b/src/plopp/backends/matplotlib/fast_image.py index a200859e7..6ef34eadb 100644 --- a/src/plopp/backends/matplotlib/fast_image.py +++ b/src/plopp/backends/matplotlib/fast_image.py @@ -63,9 +63,10 @@ def __init__( # if self._data.coords[self._data.dims[i]].dtype == str: # string_labels[k] = self._data.coords[self._data.dims[i]] - self._raw_coords = {} - for i, k in enumerate("yx"): - self._raw_coords[k] = self._data.coords[self._data.dims[i]] + # self._raw_coords = {} + self._raw_coords = { + k: self._data.coords[self._data.dims[i]] for i, k in enumerate("yx") + } # self._xmin, self._xmax = self._bin_edge_coords["x"].values[[0, -1]] # self._ymin, self._ymax = self._bin_edge_coords["y"].values[[0, -1]] diff --git a/src/plopp/backends/matplotlib/mesh_image.py b/src/plopp/backends/matplotlib/mesh_image.py index 2a2f4b324..51e9c10ab 100644 --- a/src/plopp/backends/matplotlib/mesh_image.py +++ b/src/plopp/backends/matplotlib/mesh_image.py @@ -104,26 +104,25 @@ def __init__( # 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]] + # to_dim_search = {} + self.string_labels = {} + self.bin_edge_coords = {} + # self._data_with_bin_edges = sc.DataArray(data=self._data.data) + to_dim_search = { + k: {'dim': self._data.dims[i], 'var': self._data.coords[self._data.dims[i]]} + for i, k in enumerate('yx') + } + # 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, + coords=self.bin_edge_coords, dim_1d=self._dim_1d, dim_2d=self._dim_2d, ) @@ -140,7 +139,7 @@ def __init__( self._mesh.set_array(None) self._update_colors() - for xy, var in string_labels.items(): + for xy, var in self.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) @@ -149,6 +148,18 @@ def __init__( self._canvas.register_format_coord(self.format_coord) + def _update_coords(self) -> None: + # string_labels = {} + # bin_edge_coords = {} + self._data_with_bin_edges = sc.DataArray(data=self._data.data) + for i, k in enumerate('yx'): + self.bin_edge_coords[k] = coord_as_bin_edges(self._data, self._data.dims[i]) + self._data_with_bin_edges.coords[self._data.dims[i]] = self.bin_edge_coords[ + k + ] + if self._data.coords[self._data.dims[i]].dtype == str: + self.string_labels[k] = self._data.coords[self._data.dims[i]] + @property def data(self): """ From 61f4588901255d7f8578a38a111fcca9c672dd0a Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Fri, 5 Sep 2025 23:29:34 +0200 Subject: [PATCH 3/8] implement for mesh image --- src/plopp/backends/matplotlib/fast_image.py | 25 +----- src/plopp/backends/matplotlib/mesh_image.py | 92 +++++++++++++-------- 2 files changed, 63 insertions(+), 54 deletions(-) diff --git a/src/plopp/backends/matplotlib/fast_image.py b/src/plopp/backends/matplotlib/fast_image.py index 6ef34eadb..df36155f7 100644 --- a/src/plopp/backends/matplotlib/fast_image.py +++ b/src/plopp/backends/matplotlib/fast_image.py @@ -52,26 +52,9 @@ def __init__( self._ax = self._canvas.ax self._data = data - # string_labels = {} - # self._raw_coords = {} - # self._bin_edge_coords = {} - # for i, k in enumerate("yx"): - # self._raw_coords[k] = self._data.coords[self._data.dims[i]] - # 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._raw_coords = {} - self._raw_coords = { - k: self._data.coords[self._data.dims[i]] for i, k in enumerate("yx") - } - - # 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._string_labels = {} + self._bin_edge_coords = {} + self._raw_coords = {} self._update_coords() # Calling imshow sets the aspect ratio to 'equal', which might not be what the @@ -109,8 +92,8 @@ def __init__( def _update_coords(self) -> None: self._string_labels = {} - self._raw_coords = {} self._bin_edge_coords = {} + self._raw_coords = {} for i, k in enumerate("yx"): self._raw_coords[k] = self._data.coords[self._data.dims[i]] self._bin_edge_coords[k] = coord_as_bin_edges( diff --git a/src/plopp/backends/matplotlib/mesh_image.py b/src/plopp/backends/matplotlib/mesh_image.py index 51e9c10ab..57deb0e57 100644 --- a/src/plopp/backends/matplotlib/mesh_image.py +++ b/src/plopp/backends/matplotlib/mesh_image.py @@ -6,6 +6,7 @@ import numpy as np import scipp as sc +from matplotlib.collections import QuadMesh from ...core.utils import coord_as_bin_edges, merge_masks, repeat, scalar_to_string from ...graphics.bbox import BoundingBox, axis_bounds @@ -99,47 +100,30 @@ def __init__( self._colormapper = colormapper self._ax = self._canvas.ax self._data = data + self._shading = shading + self._rasterized = rasterized + self._kwargs = kwargs # 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 = {} - self.string_labels = {} - self.bin_edge_coords = {} - # self._data_with_bin_edges = sc.DataArray(data=self._data.data) + self._string_labels = {} + self._bin_edge_coords = {} + self._raw_coords = {} to_dim_search = { k: {'dim': self._data.dims[i], 'var': self._data.coords[self._data.dims[i]]} for i, k in enumerate('yx') } - # 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._update_coords() 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=self.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._mesh = self._make_mesh() self._colormapper.add_artist(self.uid, self) self._mesh.set_array(None) self._update_colors() - for xy, var in self.string_labels.items(): + for xy, var in self._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) @@ -149,17 +133,34 @@ def __init__( self._canvas.register_format_coord(self.format_coord) def _update_coords(self) -> None: - # string_labels = {} - # bin_edge_coords = {} self._data_with_bin_edges = sc.DataArray(data=self._data.data) for i, k in enumerate('yx'): - self.bin_edge_coords[k] = coord_as_bin_edges(self._data, self._data.dims[i]) - self._data_with_bin_edges.coords[self._data.dims[i]] = self.bin_edge_coords[ - k - ] + self._raw_coords[k] = self._data.coords[self._data.dims[i]] + self._bin_edge_coords[k] = coord_as_bin_edges( + self._data, self._data.dims[i] + ) + self._data_with_bin_edges.coords[self._data.dims[i]] = ( + self._bin_edge_coords[k] + ) if self._data.coords[self._data.dims[i]].dtype == str: self.string_labels[k] = self._data.coords[self._data.dims[i]] + def _make_mesh(self) -> QuadMesh: + x, y, z = _from_data_array_to_pcolormesh( + data=self._data.data, + coords=self._bin_edge_coords, + dim_1d=self._dim_1d, + dim_2d=self._dim_2d, + ) + return self._ax.pcolormesh( + x.values, + y.values, + z.values, + shading=self._shading, + rasterized=self._rasterized, + **self._kwargs, + ) + @property def data(self): """ @@ -210,8 +211,33 @@ def update(self, new_values: sc.DataArray): New data to update the mesh values from. """ check_ndim(new_values, ndim=2, origin='MeshImage') + old_shape = self._data.shape self._data = new_values - self._data_with_bin_edges.data = new_values.data + if self._data.shape != old_shape: + # Remove the old mesh and make a new one + self._mesh.remove() + self._update_coords() + self._mesh = self._make_mesh() + self._mesh.set_array(None) + elif any( + not sc.identical(new_values.coords[self._data.dims[i]], self._raw_coords[k]) + for i, k in enumerate('yx') + ): + # Update the coordinates of the existing mesh + self._update_coords() + x, y, _ = _from_data_array_to_pcolormesh( + data=self._data.data, + coords=self._bin_edge_coords, + dim_1d=self._dim_1d, + dim_2d=self._dim_2d, + ) + m = QuadMesh(np.stack(np.meshgrid(x.values, y.values), axis=-1)) + # TODO: There is no public API to update the coordinates of a QuadMesh, + # so we have to access the protected member here. + self._mesh._coordinates = m._coordinates + self._mesh.stale = True # mark it for re-draw + else: + self._data_with_bin_edges.data = new_values.data self._update_colors() def format_coord( From da5f01c4f3fe60418c130a175592a0e75a5be467 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Sat, 6 Sep 2025 11:30:35 +0200 Subject: [PATCH 4/8] if coords change from linspace to non-linspace during an update, we need to change mode. So we need to unify fast and mesh image into a single class --- src/plopp/backends/matplotlib/fast_image.py | 7 + src/plopp/backends/matplotlib/image.py | 419 +++++++++++++++++++- 2 files changed, 407 insertions(+), 19 deletions(-) diff --git a/src/plopp/backends/matplotlib/fast_image.py b/src/plopp/backends/matplotlib/fast_image.py index df36155f7..b27f01604 100644 --- a/src/plopp/backends/matplotlib/fast_image.py +++ b/src/plopp/backends/matplotlib/fast_image.py @@ -96,6 +96,13 @@ def _update_coords(self) -> None: self._raw_coords = {} for i, k in enumerate("yx"): self._raw_coords[k] = self._data.coords[self._data.dims[i]] + if (self._raw_coords[k].dtype != str) and ( + not sc.islinspace(self._raw_coords[k]) + ): + raise ValueError( + f"The coordinate '{self._data.dims[i]}' is not linspace, which is " + "incompatible with FastImage. " + ) self._bin_edge_coords[k] = coord_as_bin_edges( self._data, self._data.dims[i] ) diff --git a/src/plopp/backends/matplotlib/image.py b/src/plopp/backends/matplotlib/image.py index e1da9e96a..926a1c6f7 100644 --- a/src/plopp/backends/matplotlib/image.py +++ b/src/plopp/backends/matplotlib/image.py @@ -1,36 +1,417 @@ +# # SPDX-License-Identifier: BSD-3-Clause +# # Copyright (c) 2025 Scipp contributors (https://github.com/scipp) + +# import scipp as sc + +# from .canvas import Canvas +# from .fast_image import FastImage +# from .mesh_image import MeshImage + + +# def Image( +# canvas: Canvas, +# data: sc.DataArray, +# **kwargs, +# ): +# """ +# 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. +# data: +# The data to create the image from. +# """ +# 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 +# ): +# return FastImage(canvas=canvas, data=data, **kwargs) +# else: +# return MeshImage(canvas=canvas, data=data, **kwargs) + + # SPDX-License-Identifier: BSD-3-Clause # Copyright (c) 2025 Scipp contributors (https://github.com/scipp) +import uuid +import warnings +from typing import Literal + +import numpy as np import scipp as sc +from matplotlib.collections import QuadMesh +from matplotlib.image import AxesImage +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 Image( - canvas: Canvas, - data: sc.DataArray, - **kwargs, -): +def _suitable_for_fast_image(canvas: Canvas, data: sc.DataArray) -> bool: + return (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 + ) + + +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: """ - 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. + 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 data to create the image from. + 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``. """ - 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 + + def __init__( + self, + canvas: Canvas, + colormapper: ColorMapper, + data: sc.DataArray, + artist_number: int, + uid: str | None = None, + shading: str = 'auto', + rasterized: bool = True, + **kwargs, ): - return FastImage(canvas=canvas, data=data, **kwargs) - else: - return MeshImage(canvas=canvas, data=data, **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 + self._shading = shading + self._rasterized = rasterized + self._kwargs = kwargs + self._optimized_mode = _suitable_for_fast_image(self._canvas, self._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() + # # # Calling imshow sets the aspect ratio to 'equal', which might not be what the + # # # user requested. We need to restore the original aspect ratio after making the + # # # image. + # # original_aspect = self._ax.get_aspect() + + self._string_labels = {} + self._bin_edge_coords = {} + self._raw_coords = {} + to_dim_search = { + k: {'dim': self._data.dims[i], 'var': self._data.coords[self._data.dims[i]]} + for i, k in enumerate('yx') + } + + self._update_coords() + self._dim_1d, self._dim_2d = _get_dims_of_1d_and_2d_coords(to_dim_search) + + self._image = self._make_image() + + self._colormapper.add_artist(self.uid, self) + self._update_colors() + + for xy, var in self._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) + + def _update_coords(self) -> None: + self._data_with_bin_edges = sc.DataArray(data=self._data.data) + for i, k in enumerate('yx'): + self._raw_coords[k] = self._data.coords[self._data.dims[i]] + self._bin_edge_coords[k] = coord_as_bin_edges( + self._data, self._data.dims[i] + ) + self._data_with_bin_edges.coords[self._data.dims[i]] = ( + self._bin_edge_coords[k] + ) + if self._data.coords[self._data.dims[i]].dtype == str: + self.string_labels[k] = self._data.coords[self._data.dims[i]] + + if self._optimized_mode: + 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]) + + def _make_image(self) -> QuadMesh | AxesImage: + if self._optimized_mode: + # Calling imshow sets the aspect ratio to 'equal', which might not be what the + # user requested. We need to restore the original aspect ratio after making the + # image. + original_aspect = self._ax.get_aspect() + + # Because imshow sets the aspect, it may generate warnings when the axes scales + # are log. + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + category=UserWarning, + message="Attempt to set non-positive .* on a log-scaled axis", + ) + img = self._ax.imshow( + self._data.values, + origin="lower", + extent=(self._xmin, self._xmax, self._ymin, self._ymax), + **({"interpolation": "nearest"} | self._kwargs), + ) + + self._ax.set_aspect(original_aspect) + return img + + else: + # self._optimized_mode = False + # 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() + + x, y, z = _from_data_array_to_pcolormesh( + data=self._data.data, + coords=self._bin_edge_coords, + dim_1d=self._dim_1d, + dim_2d=self._dim_2d, + ) + mesh = self._ax.pcolormesh( + x.values, + y.values, + z.values, + shading=self._shading, + rasterized=self._rasterized, + **self._kwargs, + ) + mesh.set_array(None) + if need_grid: + self._ax.grid(True) + return mesh + + @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. + """ + if self._optimized_mode: + return self._data + 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) + if self._optimized_mode: + self._image.set_data(rgba) + else: + self._image.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') + old_shape = self._data.shape + old_mode = self._optimized_mode + self._data = new_values + self._optimized_mode = _suitable_for_fast_image(self._canvas, self._data) + print("self._optimized_mode:", self._optimized_mode) + if old_mode != self._optimized_mode: + self._image.remove() + self._image = self._make_image() + elif self._data.shape != old_shape: + self._update_coords() + if self._optimized_mode: + self._image.set_extent((self._xmin, self._xmax, self._ymin, self._ymax)) + else: + self._image.remove() + self._image = self._make_image() + elif any( + not sc.identical(new_values.coords[self._data.dims[i]], self._raw_coords[k]) + for i, k in enumerate('yx') + ): + # Update the coordinates of the existing mesh + self._update_coords() + if self._optimized_mode: + self._image.set_extent((self._xmin, self._xmax, self._ymin, self._ymax)) + else: + x, y, _ = _from_data_array_to_pcolormesh( + data=self._data.data, + coords=self._bin_edge_coords, + dim_1d=self._dim_1d, + dim_2d=self._dim_2d, + ) + m = QuadMesh(np.stack(np.meshgrid(x.values, y.values), axis=-1)) + # TODO: There is no public API to update the coordinates of a QuadMesh, + # so we have to access the protected member here. + self._image._coordinates = m._coordinates + self._image.stale = True # mark it for re-draw + else: + 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 + + @property + def visible(self) -> bool: + """ + The visibility of the image. + """ + return self._image.get_visible() + + @visible.setter + def visible(self, val: bool): + self._image.set_visible(val) + + @property + def opacity(self) -> float: + """ + The opacity of the image. + """ + return self._image.get_alpha() + + @opacity.setter + def opacity(self, val: float): + self._image.set_alpha(val) + + 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._image.remove() + self._colormapper.remove_artist(self.uid) From 56b981b74abeef2d891f158a83d6e296dce600ce Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Sat, 6 Sep 2025 16:29:08 +0200 Subject: [PATCH 5/8] fix missing coord update --- src/plopp/backends/matplotlib/image.py | 81 ++++++-------------------- 1 file changed, 19 insertions(+), 62 deletions(-) diff --git a/src/plopp/backends/matplotlib/image.py b/src/plopp/backends/matplotlib/image.py index 926a1c6f7..7af804c2a 100644 --- a/src/plopp/backends/matplotlib/image.py +++ b/src/plopp/backends/matplotlib/image.py @@ -1,41 +1,3 @@ -# # SPDX-License-Identifier: BSD-3-Clause -# # Copyright (c) 2025 Scipp contributors (https://github.com/scipp) - -# import scipp as sc - -# from .canvas import Canvas -# from .fast_image import FastImage -# from .mesh_image import MeshImage - - -# def Image( -# canvas: Canvas, -# data: sc.DataArray, -# **kwargs, -# ): -# """ -# 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. -# data: -# The data to create the image from. -# """ -# 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 -# ): -# return FastImage(canvas=canvas, data=data, **kwargs) -# else: -# return MeshImage(canvas=canvas, data=data, **kwargs) - - # SPDX-License-Identifier: BSD-3-Clause # Copyright (c) 2025 Scipp contributors (https://github.com/scipp) @@ -153,15 +115,6 @@ def __init__( self._kwargs = kwargs self._optimized_mode = _suitable_for_fast_image(self._canvas, self._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() - # # # Calling imshow sets the aspect ratio to 'equal', which might not be what the - # # # user requested. We need to restore the original aspect ratio after making the - # # # image. - # # original_aspect = self._ax.get_aspect() - self._string_labels = {} self._bin_edge_coords = {} self._raw_coords = {} @@ -182,9 +135,6 @@ def __init__( 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) def _update_coords(self) -> None: @@ -208,13 +158,13 @@ def _update_coords(self) -> None: def _make_image(self) -> QuadMesh | AxesImage: if self._optimized_mode: - # Calling imshow sets the aspect ratio to 'equal', which might not be what the - # user requested. We need to restore the original aspect ratio after making the - # image. + # Calling imshow sets the aspect ratio to 'equal', which might not be what + # the user requested. We need to restore the original aspect ratio after + # making the image. original_aspect = self._ax.get_aspect() - # Because imshow sets the aspect, it may generate warnings when the axes scales - # are log. + # Because imshow sets the aspect, it may generate warnings when the axes + # scales are log. with warnings.catch_warnings(): warnings.filterwarnings( "ignore", @@ -229,10 +179,11 @@ def _make_image(self) -> QuadMesh | AxesImage: ) self._ax.set_aspect(original_aspect) - return img + # We also hide the cursor hover values generated by the image, as values are + # included in our custom format_coord. + img.format_cursor_data = lambda _: "" else: - # self._optimized_mode = False # 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. @@ -244,7 +195,7 @@ def _make_image(self) -> QuadMesh | AxesImage: dim_1d=self._dim_1d, dim_2d=self._dim_2d, ) - mesh = self._ax.pcolormesh( + img = self._ax.pcolormesh( x.values, y.values, z.values, @@ -252,10 +203,11 @@ def _make_image(self) -> QuadMesh | AxesImage: rasterized=self._rasterized, **self._kwargs, ) - mesh.set_array(None) + img.set_array(None) if need_grid: self._ax.grid(True) - return mesh + + return img @property def data(self): @@ -316,8 +268,8 @@ def update(self, new_values: sc.DataArray): old_mode = self._optimized_mode self._data = new_values self._optimized_mode = _suitable_for_fast_image(self._canvas, self._data) - print("self._optimized_mode:", self._optimized_mode) if old_mode != self._optimized_mode: + self._update_coords() self._image.remove() self._image = self._make_image() elif self._data.shape != old_shape: @@ -366,7 +318,12 @@ def format_coord( Dimension and y coordinate of the mouse pointer, as slice parameters. """ try: - val = self._data_with_bin_edges[yslice][xslice] + if self._optimized_mode: + ind_x = int((xslice[1].value - self._xmin) / self._dx) + ind_y = int((yslice[1].value - self._ymin) / self._dy) + val = self._data[yslice[0], ind_y][xslice[0], ind_x] + else: + val = self._data_with_bin_edges[yslice][xslice] prefix = self._data.name if prefix: prefix += ': ' From 6420401f65dc18a3b4d89b331251e13338677bd5 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Sun, 7 Sep 2025 22:09:59 +0200 Subject: [PATCH 6/8] remove old files --- src/plopp/backends/matplotlib/fast_image.py | 225 -------------- src/plopp/backends/matplotlib/mesh_image.py | 306 -------------------- 2 files changed, 531 deletions(-) delete mode 100644 src/plopp/backends/matplotlib/fast_image.py delete mode 100644 src/plopp/backends/matplotlib/mesh_image.py diff --git a/src/plopp/backends/matplotlib/fast_image.py b/src/plopp/backends/matplotlib/fast_image.py deleted file mode 100644 index b27f01604..000000000 --- a/src/plopp/backends/matplotlib/fast_image.py +++ /dev/null @@ -1,225 +0,0 @@ -# SPDX-License-Identifier: BSD-3-Clause -# Copyright (c) 2025 Scipp contributors (https://github.com/scipp) - -import uuid -import warnings -from typing import Literal - -import numpy as np -import scipp as sc - -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 - - self._string_labels = {} - self._bin_edge_coords = {} - self._raw_coords = {} - self._update_coords() - - # Calling imshow sets the aspect ratio to 'equal', which might not be what the - # user requested. We need to restore the original aspect ratio after making the - # image. - original_aspect = self._ax.get_aspect() - - # Because imshow sets the aspect, it may generate warnings when the axes scales - # are log. - with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", - category=UserWarning, - message="Attempt to set non-positive .* on a log-scaled axis", - ) - self._image = self._ax.imshow( - self._data.values, - origin="lower", - extent=(self._xmin, self._xmax, self._ymin, self._ymax), - **({"interpolation": "nearest"} | kwargs), - ) - - self._ax.set_aspect(original_aspect) - self._colormapper.add_artist(self.uid, self) - self._update_colors() - - for xy, var in self._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 _: "" - - def _update_coords(self) -> None: - self._string_labels = {} - self._bin_edge_coords = {} - self._raw_coords = {} - for i, k in enumerate("yx"): - self._raw_coords[k] = self._data.coords[self._data.dims[i]] - if (self._raw_coords[k].dtype != str) and ( - not sc.islinspace(self._raw_coords[k]) - ): - raise ValueError( - f"The coordinate '{self._data.dims[i]}' is not linspace, which is " - "incompatible with FastImage. " - ) - 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: - self._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]) - - @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 - if any( - not sc.identical(new_values.coords[self._data.dims[i]], self._raw_coords[k]) - for i, k in enumerate("yx") - ): - self._update_coords() - self._image.set_extent((self._xmin, self._xmax, self._ymin, self._ymax)) - - 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 - - @property - def visible(self) -> bool: - """ - The visibility of the image. - """ - return self._image.get_visible() - - @visible.setter - def visible(self, val: bool): - self._image.set_visible(val) - - @property - def opacity(self) -> float: - """ - The opacity of the image. - """ - return self._image.get_alpha() - - @opacity.setter - def opacity(self, val: float): - self._image.set_alpha(val) - - 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/mesh_image.py b/src/plopp/backends/matplotlib/mesh_image.py deleted file mode 100644 index 57deb0e57..000000000 --- a/src/plopp/backends/matplotlib/mesh_image.py +++ /dev/null @@ -1,306 +0,0 @@ -# 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.collections import QuadMesh - -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 - self._shading = shading - self._rasterized = rasterized - self._kwargs = kwargs - # 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() - - self._string_labels = {} - self._bin_edge_coords = {} - self._raw_coords = {} - to_dim_search = { - k: {'dim': self._data.dims[i], 'var': self._data.coords[self._data.dims[i]]} - for i, k in enumerate('yx') - } - - self._update_coords() - self._dim_1d, self._dim_2d = _get_dims_of_1d_and_2d_coords(to_dim_search) - self._mesh = self._make_mesh() - self._colormapper.add_artist(self.uid, self) - self._mesh.set_array(None) - self._update_colors() - - for xy, var in self._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) - - def _update_coords(self) -> None: - self._data_with_bin_edges = sc.DataArray(data=self._data.data) - for i, k in enumerate('yx'): - self._raw_coords[k] = self._data.coords[self._data.dims[i]] - self._bin_edge_coords[k] = coord_as_bin_edges( - self._data, self._data.dims[i] - ) - self._data_with_bin_edges.coords[self._data.dims[i]] = ( - self._bin_edge_coords[k] - ) - if self._data.coords[self._data.dims[i]].dtype == str: - self.string_labels[k] = self._data.coords[self._data.dims[i]] - - def _make_mesh(self) -> QuadMesh: - x, y, z = _from_data_array_to_pcolormesh( - data=self._data.data, - coords=self._bin_edge_coords, - dim_1d=self._dim_1d, - dim_2d=self._dim_2d, - ) - return self._ax.pcolormesh( - x.values, - y.values, - z.values, - shading=self._shading, - rasterized=self._rasterized, - **self._kwargs, - ) - - @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') - old_shape = self._data.shape - self._data = new_values - if self._data.shape != old_shape: - # Remove the old mesh and make a new one - self._mesh.remove() - self._update_coords() - self._mesh = self._make_mesh() - self._mesh.set_array(None) - elif any( - not sc.identical(new_values.coords[self._data.dims[i]], self._raw_coords[k]) - for i, k in enumerate('yx') - ): - # Update the coordinates of the existing mesh - self._update_coords() - x, y, _ = _from_data_array_to_pcolormesh( - data=self._data.data, - coords=self._bin_edge_coords, - dim_1d=self._dim_1d, - dim_2d=self._dim_2d, - ) - m = QuadMesh(np.stack(np.meshgrid(x.values, y.values), axis=-1)) - # TODO: There is no public API to update the coordinates of a QuadMesh, - # so we have to access the protected member here. - self._mesh._coordinates = m._coordinates - self._mesh.stale = True # mark it for re-draw - else: - 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 - - @property - def visible(self) -> bool: - """ - The visibility of the image. - """ - return self._mesh.get_visible() - - @visible.setter - def visible(self, val: bool): - self._mesh.set_visible(val) - - @property - def opacity(self) -> float: - """ - The opacity of the image. - """ - return self._mesh.get_alpha() - - @opacity.setter - def opacity(self, val: float): - self._mesh.set_alpha(val) - - 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) From 7eb3838a56cbd4f45ae0131eb3dd9417260e500d Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Sun, 7 Sep 2025 22:20:42 +0200 Subject: [PATCH 7/8] update image tests --- tests/backends/matplotlib/mpl_image_test.py | 49 +++++++++------------ 1 file changed, 22 insertions(+), 27 deletions(-) diff --git a/tests/backends/matplotlib/mpl_image_test.py b/tests/backends/matplotlib/mpl_image_test.py index c0f7aa3b0..1edc4c0dd 100644 --- a/tests/backends/matplotlib/mpl_image_test.py +++ b/tests/backends/matplotlib/mpl_image_test.py @@ -12,52 +12,47 @@ pytestmark = pytest.mark.usefixtures("_parametrize_mpl_backends") -def test_update_on_one_mesh_changes_colors_on_second_mesh(): - 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) - f = imagefigure(a, b) - old_b_colors = f.artists[b.id]._mesh.get_facecolors() - 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]._mesh.get_facecolors()) - 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]._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) +@pytest.mark.parametrize('linspace', [True, False]) +def test_update_on_one_image_changes_colors_on_second_image(linspace): + da1 = data_array(ndim=2, linspace=linspace) + da2 = 3.0 * data_array(ndim=2, linspace=linspace) 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() + old_b_colors = getattr( + f.artists[b.id]._image, "get_array" if linspace else "get_facecolors" + )() 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()) + # colors = f.artists[b.id]._image.get_array() if linspace else + assert np.allclose( + old_b_colors, + getattr( + f.artists[b.id]._image, "get_array" if linspace else "get_facecolors" + )(), + ) 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()) + assert not np.allclose( + old_b_colors, + getattr( + f.artists[b.id]._image, "get_array" if linspace else "get_facecolors" + )(), + ) def test_kwargs_are_forwarded_to_artist(): da = data_array(ndim=2, linspace=False) fig = imagefigure(Node(da), rasterized=True) [artist] = fig.artists.values() - assert artist._mesh.get_rasterized() + assert artist._image.get_rasterized() fig = imagefigure(Node(da), rasterized=False) [artist] = fig.artists.values() - assert not artist._mesh.get_rasterized() + assert not artist._image.get_rasterized() @pytest.mark.parametrize('linspace', [True, False]) From b686fdfe81ccd0ef6c3808c0e044c636bd5239d5 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Mon, 8 Sep 2025 09:09:08 +0200 Subject: [PATCH 8/8] fix variable name --- src/plopp/backends/matplotlib/image.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plopp/backends/matplotlib/image.py b/src/plopp/backends/matplotlib/image.py index 7af804c2a..792cd3b75 100644 --- a/src/plopp/backends/matplotlib/image.py +++ b/src/plopp/backends/matplotlib/image.py @@ -148,7 +148,7 @@ def _update_coords(self) -> None: self._bin_edge_coords[k] ) if self._data.coords[self._data.dims[i]].dtype == str: - self.string_labels[k] = self._data.coords[self._data.dims[i]] + self._string_labels[k] = self._data.coords[self._data.dims[i]] if self._optimized_mode: self._xmin, self._xmax = self._bin_edge_coords["x"].values[[0, -1]]