plopp 25.3.0__py3-none-any.whl → 25.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
plopp/backends/common.py CHANGED
@@ -1,6 +1,7 @@
1
1
  # SPDX-License-Identifier: BSD-3-Clause
2
2
  # Copyright (c) 2023 Scipp contributors (https://github.com/scipp)
3
3
 
4
+ import uuid
4
5
  from typing import Literal
5
6
 
6
7
  import numpy as np
@@ -98,15 +99,16 @@ def make_line_bbox(
98
99
  The scale of the y-axis.
99
100
  """
100
101
  line_x = data.coords[dim]
101
- sel = slice(None)
102
- if data.masks:
103
- sel = ~merge_masks(data.masks)
104
- if set(sel.dims) != set(data.data.dims):
105
- sel = sc.broadcast(sel, sizes=data.data.sizes).copy()
106
- line_y = data.data[sel]
107
102
  if errorbars:
108
- stddevs = sc.stddevs(data.data[sel])
109
- line_y = sc.concat([line_y - stddevs, line_y + stddevs], dim)
103
+ stddevs = sc.stddevs(data.data)
104
+ line_y = sc.DataArray(
105
+ data=sc.concat(
106
+ [data.data - stddevs, data.data + stddevs], dim=uuid.uuid4().hex
107
+ ),
108
+ masks=data.masks,
109
+ )
110
+ else:
111
+ line_y = data
110
112
 
111
113
  return BoundingBox(
112
114
  **{**axis_bounds(('xmin', 'xmax'), line_x, xscale, pad=True)},
@@ -0,0 +1,203 @@
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+ # Copyright (c) 2025 Scipp contributors (https://github.com/scipp)
3
+
4
+ import uuid
5
+ import warnings
6
+ from typing import Literal
7
+
8
+ import numpy as np
9
+ import scipp as sc
10
+
11
+ from ...core.utils import coord_as_bin_edges, scalar_to_string
12
+ from ...graphics.bbox import BoundingBox, axis_bounds
13
+ from ...graphics.colormapper import ColorMapper
14
+ from ..common import check_ndim
15
+ from .canvas import Canvas
16
+
17
+
18
+ class FastImage:
19
+ """
20
+ Artist to represent two-dimensional data.
21
+
22
+ Parameters
23
+ ----------
24
+ canvas:
25
+ The canvas that will display the image.
26
+ colormapper:
27
+ The colormapper to use for the image.
28
+ data:
29
+ The initial data to create the image from.
30
+ artist_number:
31
+ The canvas keeps track of how many images have been added to it. This is unused
32
+ by the FastImage artist.
33
+ uid:
34
+ The unique identifier of the artist. If None, a random UUID is generated.
35
+ **kwargs:
36
+ Additional arguments are forwarded to Matplotlib's ``imshow``.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ canvas: Canvas,
42
+ colormapper: ColorMapper,
43
+ data: sc.DataArray,
44
+ artist_number: int,
45
+ uid: str | None = None,
46
+ **kwargs,
47
+ ):
48
+ check_ndim(data, ndim=2, origin="FastImage")
49
+ self.uid = uid if uid is not None else uuid.uuid4().hex
50
+ self._canvas = canvas
51
+ self._colormapper = colormapper
52
+ self._ax = self._canvas.ax
53
+ self._data = data
54
+
55
+ string_labels = {}
56
+ self._bin_edge_coords = {}
57
+ for i, k in enumerate("yx"):
58
+ self._bin_edge_coords[k] = coord_as_bin_edges(
59
+ self._data, self._data.dims[i]
60
+ )
61
+ if self._data.coords[self._data.dims[i]].dtype == str:
62
+ string_labels[k] = self._data.coords[self._data.dims[i]]
63
+
64
+ self._xmin, self._xmax = self._bin_edge_coords["x"].values[[0, -1]]
65
+ self._ymin, self._ymax = self._bin_edge_coords["y"].values[[0, -1]]
66
+ self._dx = np.diff(self._bin_edge_coords["x"].values[:2])
67
+ self._dy = np.diff(self._bin_edge_coords["y"].values[:2])
68
+
69
+ # Calling imshow sets the aspect ratio to 'equal', which might not be what the
70
+ # user requested. We need to restore the original aspect ratio after making the
71
+ # image.
72
+ original_aspect = self._ax.get_aspect()
73
+
74
+ # Because imshow sets the aspect, it may generate warnings when the axes scales
75
+ # are log.
76
+ with warnings.catch_warnings():
77
+ warnings.filterwarnings(
78
+ "ignore",
79
+ category=UserWarning,
80
+ message="Attempt to set non-positive .* on a log-scaled axis",
81
+ )
82
+ self._image = self._ax.imshow(
83
+ self._data.values,
84
+ origin="lower",
85
+ extent=(self._xmin, self._xmax, self._ymin, self._ymax),
86
+ **({"interpolation": "nearest"} | kwargs),
87
+ )
88
+
89
+ self._ax.set_aspect(original_aspect)
90
+ self._colormapper.add_artist(self.uid, self)
91
+ self._update_colors()
92
+
93
+ for xy, var in string_labels.items():
94
+ getattr(self._ax, f"set_{xy}ticks")(np.arange(float(var.shape[0])))
95
+ getattr(self._ax, f"set_{xy}ticklabels")(var.values)
96
+
97
+ self._canvas.register_format_coord(self.format_coord)
98
+ # We also hide the cursor hover values generated by the image, as values are
99
+ # included in our custom format_coord.
100
+ self._image.format_cursor_data = lambda _: ""
101
+
102
+ @property
103
+ def data(self):
104
+ """
105
+ Get the image's data in a form that may have been tweaked, compared to the
106
+ original data, in the case of a two-dimensional coordinate.
107
+ """
108
+ return self._data
109
+
110
+ def notify_artist(self, message: str) -> None:
111
+ """
112
+ Receive notification from the colormapper that its state has changed.
113
+ We thus need to update the colors of the image.
114
+
115
+ Parameters
116
+ ----------
117
+ message:
118
+ The message from the colormapper.
119
+ """
120
+ self._update_colors()
121
+
122
+ def _update_colors(self):
123
+ """
124
+ Update the image colors.
125
+ """
126
+ rgba = self._colormapper.rgba(self.data)
127
+ self._image.set_data(rgba)
128
+
129
+ def update(self, new_values: sc.DataArray):
130
+ """
131
+ Update image array with new values.
132
+
133
+ Parameters
134
+ ----------
135
+ new_values:
136
+ New data to update the image values from.
137
+ """
138
+ check_ndim(new_values, ndim=2, origin="FastImage")
139
+ self._data = new_values
140
+ self._update_colors()
141
+
142
+ def format_coord(
143
+ self, xslice: tuple[str, sc.Variable], yslice: tuple[str, sc.Variable]
144
+ ) -> str:
145
+ """
146
+ Format the coordinates of the mouse pointer to show the value of the
147
+ data at that point.
148
+
149
+ Parameters
150
+ ----------
151
+ xslice:
152
+ Dimension and x coordinate of the mouse pointer, as slice parameters.
153
+ yslice:
154
+ Dimension and y coordinate of the mouse pointer, as slice parameters.
155
+ """
156
+ ind_x = int((xslice[1].value - self._xmin) / self._dx)
157
+ ind_y = int((yslice[1].value - self._ymin) / self._dy)
158
+ try:
159
+ val = self._data[yslice[0], ind_y][xslice[0], ind_x]
160
+ prefix = self._data.name
161
+ if prefix:
162
+ prefix += ": "
163
+ return prefix + scalar_to_string(val)
164
+ except IndexError:
165
+ return None
166
+
167
+ @property
168
+ def visible(self) -> bool:
169
+ """
170
+ The visibility of the image.
171
+ """
172
+ return self._image.get_visible()
173
+
174
+ @visible.setter
175
+ def visible(self, val: bool):
176
+ self._image.set_visible(val)
177
+
178
+ @property
179
+ def opacity(self) -> float:
180
+ """
181
+ The opacity of the image.
182
+ """
183
+ return self._image.get_alpha()
184
+
185
+ @opacity.setter
186
+ def opacity(self, val: float):
187
+ self._image.set_alpha(val)
188
+
189
+ def bbox(self, xscale: Literal["linear", "log"], yscale: Literal["linear", "log"]):
190
+ """
191
+ The bounding box of the image.
192
+ """
193
+ return BoundingBox(
194
+ **{**axis_bounds(("xmin", "xmax"), self._bin_edge_coords["x"], xscale)},
195
+ **{**axis_bounds(("ymin", "ymax"), self._bin_edge_coords["y"], yscale)},
196
+ )
197
+
198
+ def remove(self):
199
+ """
200
+ Remove the image artist from the canvas.
201
+ """
202
+ self._image.remove()
203
+ self._colormapper.remove_artist(self.uid)
@@ -1,251 +1,36 @@
1
1
  # SPDX-License-Identifier: BSD-3-Clause
2
- # Copyright (c) 2023 Scipp contributors (https://github.com/scipp)
2
+ # Copyright (c) 2025 Scipp contributors (https://github.com/scipp)
3
3
 
4
- import uuid
5
- from typing import Literal
6
-
7
- import numpy as np
8
4
  import scipp as sc
9
5
 
10
- from ...core.utils import coord_as_bin_edges, merge_masks, repeat, scalar_to_string
11
- from ...graphics.bbox import BoundingBox, axis_bounds
12
- from ...graphics.colormapper import ColorMapper
13
- from ..common import check_ndim
14
6
  from .canvas import Canvas
7
+ from .fast_image import FastImage
8
+ from .mesh_image import MeshImage
15
9
 
16
10
 
17
- def _find_dim_of_2d_coord(coords):
18
- for xy, coord in coords.items():
19
- if coord['var'].ndim == 2:
20
- return (xy, coord['dim'])
21
-
22
-
23
- def _get_dims_of_1d_and_2d_coords(coords):
24
- dim_2d = _find_dim_of_2d_coord(coords)
25
- if dim_2d is None:
26
- return None, None
27
- axis_1d = 'xy'.replace(dim_2d[0], '')
28
- dim_1d = (axis_1d, coords[axis_1d]['dim'])
29
- return dim_1d, dim_2d
30
-
31
-
32
- def _maybe_repeat_values(data, dim_1d, dim_2d):
33
- if dim_2d is None:
34
- return data
35
- return repeat(data, dim=dim_1d[1], n=2)[dim_1d[1], :-1]
36
-
37
-
38
- def _from_data_array_to_pcolormesh(data, coords, dim_1d, dim_2d):
39
- z = _maybe_repeat_values(data=data, dim_1d=dim_1d, dim_2d=dim_2d)
40
- if dim_2d is None:
41
- return coords['x'], coords['y'], z
42
-
43
- # Broadcast 1d coord to 2d and repeat along 1d dim
44
- # TODO: It may be more efficient here to first repeat and then broadcast, but
45
- # the current order is simpler in implementation.
46
- broadcasted_coord = repeat(
47
- sc.broadcast(
48
- coords[dim_1d[0]],
49
- sizes={**coords[dim_2d[0]].sizes, **coords[dim_1d[0]].sizes},
50
- ).transpose(data.dims),
51
- dim=dim_1d[1],
52
- n=2,
53
- )
54
- # Repeat 2d coord along 1d dim
55
- repeated_coord = repeat(coords[dim_2d[0]].transpose(data.dims), dim=dim_1d[1], n=2)
56
- out = {dim_1d[0]: broadcasted_coord[dim_1d[1], 1:-1], dim_2d[0]: repeated_coord}
57
- return out['x'], out['y'], z
58
-
59
-
60
- class Image:
11
+ def Image(
12
+ canvas: Canvas,
13
+ data: sc.DataArray,
14
+ **kwargs,
15
+ ):
61
16
  """
62
- Artist to represent two-dimensional data.
17
+ Factory function to create an image artist.
18
+ If all the coordinates of the data are 1D and linearly spaced,
19
+ a `FastImage` is created.
20
+ Otherwise, a `MeshImage` is created.
63
21
 
64
22
  Parameters
65
23
  ----------
66
24
  canvas:
67
25
  The canvas that will display the image.
68
- colormapper:
69
- The colormapper to use for the image.
70
26
  data:
71
- The initial data to create the image from.
72
- uid:
73
- The unique identifier of the artist. If None, a random UUID is generated.
74
- shading:
75
- The shading to use for the ``pcolormesh``.
76
- rasterized:
77
- Rasterize the mesh/image if ``True``.
78
- **kwargs:
79
- Additional arguments are forwarded to Matplotlib's ``pcolormesh``.
27
+ The data to create the image from.
80
28
  """
81
-
82
- def __init__(
83
- self,
84
- canvas: Canvas,
85
- colormapper: ColorMapper,
86
- data: sc.DataArray,
87
- uid: str | None = None,
88
- shading: str = 'auto',
89
- rasterized: bool = True,
90
- **kwargs,
29
+ if (canvas.ax.name != 'polar') and all(
30
+ (data.coords[dim].ndim < 2)
31
+ and ((data.coords[dim].dtype == str) or (sc.islinspace(data.coords[dim])))
32
+ for dim in data.dims
91
33
  ):
92
- check_ndim(data, ndim=2, origin='Image')
93
- self.uid = uid if uid is not None else uuid.uuid4().hex
94
- self._canvas = canvas
95
- self._colormapper = colormapper
96
- self._ax = self._canvas.ax
97
- self._data = data
98
- # Because all keyword arguments from the figure are forwarded to both the canvas
99
- # and the line, we need to remove the arguments that belong to the canvas.
100
- kwargs.pop('ax', None)
101
- kwargs.pop('cax', None)
102
- # An artist number is passed to the artist, but is unused for the image.
103
- kwargs.pop('artist_number', None)
104
- # If the grid is visible on the axes, we need to set that on again after we
105
- # call pcolormesh, because that turns the grid off automatically.
106
- # See https://github.com/matplotlib/matplotlib/issues/15600.
107
- need_grid = self._ax.xaxis.get_gridlines()[0].get_visible()
108
-
109
- to_dim_search = {}
110
- string_labels = {}
111
- bin_edge_coords = {}
112
- self._data_with_bin_edges = sc.DataArray(data=self._data.data)
113
- for i, k in enumerate('yx'):
114
- to_dim_search[k] = {
115
- 'dim': self._data.dims[i],
116
- 'var': self._data.coords[self._data.dims[i]],
117
- }
118
- bin_edge_coords[k] = coord_as_bin_edges(self._data, self._data.dims[i])
119
- self._data_with_bin_edges.coords[self._data.dims[i]] = bin_edge_coords[k]
120
- if self._data.coords[self._data.dims[i]].dtype == str:
121
- string_labels[k] = self._data.coords[self._data.dims[i]]
122
-
123
- self._dim_1d, self._dim_2d = _get_dims_of_1d_and_2d_coords(to_dim_search)
124
- self._mesh = None
125
-
126
- x, y, z = _from_data_array_to_pcolormesh(
127
- data=self._data.data,
128
- coords=bin_edge_coords,
129
- dim_1d=self._dim_1d,
130
- dim_2d=self._dim_2d,
131
- )
132
- self._mesh = self._ax.pcolormesh(
133
- x.values,
134
- y.values,
135
- z.values,
136
- shading=shading,
137
- rasterized=rasterized,
138
- **kwargs,
139
- )
140
-
141
- self._colormapper.add_artist(self.uid, self)
142
- self._mesh.set_array(None)
143
- self._update_colors()
144
-
145
- for xy, var in string_labels.items():
146
- getattr(self._ax, f'set_{xy}ticks')(np.arange(float(var.shape[0])))
147
- getattr(self._ax, f'set_{xy}ticklabels')(var.values)
148
-
149
- if need_grid:
150
- self._ax.grid(True)
151
-
152
- self._canvas.register_format_coord(self.format_coord)
153
-
154
- @property
155
- def data(self):
156
- """
157
- Get the Mesh's data in a form that may have been tweaked, compared to the
158
- original data, in the case of a two-dimensional coordinate.
159
- """
160
- out = sc.DataArray(
161
- data=_maybe_repeat_values(
162
- data=self._data.data, dim_1d=self._dim_1d, dim_2d=self._dim_2d
163
- )
164
- )
165
- if self._data.masks:
166
- out.masks['one_mask'] = _maybe_repeat_values(
167
- data=sc.broadcast(
168
- merge_masks(self._data.masks),
169
- dims=self._data.dims,
170
- shape=self._data.shape,
171
- ),
172
- dim_1d=self._dim_1d,
173
- dim_2d=self._dim_2d,
174
- )
175
- return out
176
-
177
- def notify_artist(self, message: str) -> None:
178
- """
179
- Receive notification from the colormapper that its state has changed.
180
- We thus need to update the colors of the mesh.
181
-
182
- Parameters
183
- ----------
184
- message:
185
- The message from the colormapper.
186
- """
187
- self._update_colors()
188
-
189
- def _update_colors(self):
190
- """
191
- Update the mesh colors.
192
- """
193
- rgba = self._colormapper.rgba(self.data)
194
- self._mesh.set_facecolors(rgba.reshape(np.prod(rgba.shape[:-1]), 4))
195
-
196
- def update(self, new_values: sc.DataArray):
197
- """
198
- Update image array with new values.
199
-
200
- Parameters
201
- ----------
202
- new_values:
203
- New data to update the mesh values from.
204
- """
205
- check_ndim(new_values, ndim=2, origin='Image')
206
- self._data = new_values
207
- self._data_with_bin_edges.data = new_values.data
208
- self._update_colors()
209
-
210
- def format_coord(
211
- self, xslice: tuple[str, sc.Variable], yslice: tuple[str, sc.Variable]
212
- ) -> str:
213
- """
214
- Format the coordinates of the mouse pointer to show the value of the
215
- data at that point.
216
-
217
- Parameters
218
- ----------
219
- xslice:
220
- Dimension and x coordinate of the mouse pointer, as slice parameters.
221
- yslice:
222
- Dimension and y coordinate of the mouse pointer, as slice parameters.
223
- """
224
- try:
225
- val = self._data_with_bin_edges[yslice][xslice]
226
- prefix = self._data.name
227
- if prefix:
228
- prefix += ': '
229
- return prefix + scalar_to_string(val)
230
- except (IndexError, RuntimeError):
231
- return None
232
-
233
- def bbox(self, xscale: Literal['linear', 'log'], yscale: Literal['linear', 'log']):
234
- """
235
- The bounding box of the image.
236
- """
237
- ydim, xdim = self._data.dims
238
- image_x = self._data_with_bin_edges.coords[xdim]
239
- image_y = self._data_with_bin_edges.coords[ydim]
240
-
241
- return BoundingBox(
242
- **{**axis_bounds(('xmin', 'xmax'), image_x, xscale)},
243
- **{**axis_bounds(('ymin', 'ymax'), image_y, yscale)},
244
- )
245
-
246
- def remove(self):
247
- """
248
- Remove the image artist from the canvas.
249
- """
250
- self._mesh.remove()
251
- self._colormapper.remove_artist(self.uid)
34
+ return FastImage(canvas=canvas, data=data, **kwargs)
35
+ else:
36
+ return MeshImage(canvas=canvas, data=data, **kwargs)
@@ -58,9 +58,6 @@ class Line:
58
58
  self._canvas = canvas
59
59
  self._ax = self._canvas.ax
60
60
  self._data = data
61
- # Because all keyword arguments from the figure are forwarded to both the canvas
62
- # and the line, we need to remove the arguments that belong to the canvas.
63
- kwargs.pop('ax', None)
64
61
 
65
62
  line_args = parse_dicts_in_kwargs(kwargs, name=data.name)
66
63
 
@@ -191,20 +188,89 @@ class Line:
191
188
  self._canvas.draw()
192
189
 
193
190
  @property
194
- def color(self):
191
+ def color(self) -> str:
195
192
  """
196
193
  The line color.
197
194
  """
198
195
  return self._line.get_color()
199
196
 
200
197
  @color.setter
201
- def color(self, val):
198
+ def color(self, val: str):
202
199
  self._line.set_color(val)
203
200
  if self._error is not None:
204
201
  for artist in self._error.get_children():
205
202
  artist.set_color(val)
206
203
  self._canvas.draw()
207
204
 
205
+ @property
206
+ def style(self) -> str:
207
+ """
208
+ The line style.
209
+ """
210
+ return self._line.get_linestyle()
211
+
212
+ @style.setter
213
+ def style(self, val: str):
214
+ self._line.set_linestyle(val)
215
+ self._canvas.draw()
216
+
217
+ @property
218
+ def width(self) -> float:
219
+ """
220
+ The line width.
221
+ """
222
+ return self._line.get_linewidth()
223
+
224
+ @width.setter
225
+ def width(self, val: float):
226
+ self._line.set_linewidth(val)
227
+ self._canvas.draw()
228
+
229
+ @property
230
+ def marker(self) -> str:
231
+ """
232
+ The line marker.
233
+ """
234
+ return self._line.get_marker()
235
+
236
+ @marker.setter
237
+ def marker(self, val: str):
238
+ self._line.set_marker(val)
239
+ self._mask.set_marker(val)
240
+ self._canvas.draw()
241
+
242
+ @property
243
+ def visible(self) -> bool:
244
+ """
245
+ Whether the line is visible.
246
+ """
247
+ return self._line.get_visible()
248
+
249
+ @visible.setter
250
+ def visible(self, val: bool):
251
+ self._line.set_visible(val)
252
+ self._mask.set_visible(val)
253
+ if self._error is not None:
254
+ for artist in self._error.get_children():
255
+ artist.set_visible(val)
256
+ self._canvas.draw()
257
+
258
+ @property
259
+ def opacity(self) -> float:
260
+ """
261
+ The line opacity.
262
+ """
263
+ return self._line.get_alpha()
264
+
265
+ @opacity.setter
266
+ def opacity(self, val: float):
267
+ self._line.set_alpha(val)
268
+ self._mask.set_alpha(val)
269
+ if self._error is not None:
270
+ for artist in self._error.get_children():
271
+ artist.set_alpha(val)
272
+ self._canvas.draw()
273
+
208
274
  def bbox(
209
275
  self, xscale: Literal['linear', 'log'], yscale: Literal['linear', 'log']
210
276
  ) -> BoundingBox: