From 03d37456c2ec03477a8f6148f70201f3a5fffeaf Mon Sep 17 00:00:00 2001 From: Johannes Kasimir Date: Thu, 29 Jan 2026 12:37:32 +0100 Subject: [PATCH 01/12] feat: polygon inspector tool --- src/plopp/__init__.py | 1 + src/plopp/plotting/__init__.pyi | 3 +- src/plopp/plotting/inspector.py | 248 +++++++++++++++++++++++ src/plopp/widgets/__init__.pyi | 3 +- src/plopp/widgets/drawing.py | 159 +++++++++++++++ tests/plotting/inspector_polygon_test.py | 103 ++++++++++ 6 files changed, 515 insertions(+), 2 deletions(-) create mode 100644 tests/plotting/inspector_polygon_test.py diff --git a/src/plopp/__init__.py b/src/plopp/__init__.py index 337a0087e..a3a8f9769 100644 --- a/src/plopp/__init__.py +++ b/src/plopp/__init__.py @@ -27,6 +27,7 @@ ], 'plotting': [ 'inspector', + 'inspector_polygon', 'mesh3d', 'plot', 'scatter', diff --git a/src/plopp/plotting/__init__.pyi b/src/plopp/plotting/__init__.pyi index d25cda71b..07b19535c 100644 --- a/src/plopp/plotting/__init__.pyi +++ b/src/plopp/plotting/__init__.pyi @@ -1,7 +1,7 @@ # SPDX-License-Identifier: BSD-3-Clause # Copyright (c) 2025 Scipp contributors (https://github.com/scipp) -from .inspector import inspector +from .inspector import inspector, inspector_polygon from .mesh3d import mesh3d from .plot import plot from .scatter import scatter @@ -12,6 +12,7 @@ from .xyplot import xyplot __all__ = [ 'inspector', + 'inspector_polygon', 'mesh3d', 'plot', 'scatter', diff --git a/src/plopp/plotting/inspector.py b/src/plopp/plotting/inspector.py index 891047877..5f4f6789e 100644 --- a/src/plopp/plotting/inspector.py +++ b/src/plopp/plotting/inspector.py @@ -1,6 +1,8 @@ # SPDX-License-Identifier: BSD-3-Clause # Copyright (c) 2023 Scipp contributors (https://github.com/scipp) + +from functools import partial from typing import Literal import scipp as sc @@ -34,6 +36,60 @@ def _slice_xy(da: sc.DataArray, xy: dict[str, dict[str, int]]) -> sc.DataArray: return da[y['dim'], y['value']][x['dim'], x['value']] +def _coord_to_centers(da: sc.DataArray, dim: str) -> sc.Variable: + coord = da.coords[dim] + if da.coords.is_edges(dim, dim=dim) and coord.sizes[dim] == da.sizes[dim] + 1: + return sc.midpoints(coord, dim=dim) + return coord + + +def _polygon_mask( + da: sc.DataArray, poly: dict[str, dict[str, sc.Variable]] +) -> sc.Variable: + import numpy as np + from matplotlib.path import Path + + xdim = poly['x']['dim'] + ydim = poly['y']['dim'] + x = _coord_to_centers(da, xdim) + y = _coord_to_centers(da, ydim) + vx = poly['x']['value'].to(unit=x.unit).values + vy = poly['y']['value'].to(unit=y.unit).values + verts = np.column_stack([vx, vy]) + path = Path(verts) + xx, yy = np.meshgrid(x.values, y.values, indexing='xy') + points = np.column_stack([xx.ravel(), yy.ravel()]) + inside = path.contains_points(points).reshape(yy.shape) + return sc.array(dims=[ydim, xdim], values=inside) + + +def _slice_polygon( + da: sc.DataArray, poly: dict[str, dict[str, sc.Variable]], op: str +) -> sc.DataArray: + mask = _polygon_mask(da, poly) + masked = da.copy(deep=False) + masked.masks['polygon'] = sc.logical_not(mask) + has_points = bool(sc.any(mask).value) + reduce_op = { + 'sum': sc.nansum, + 'mean': sc.nanmean, + 'min': sc.nanmin, + 'max': sc.nanmax, + }[op] + out = reduce_op(masked, dim=[poly['x']['dim'], poly['y']['dim']]) + if (not has_points) and op in {'min', 'max'}: + out.data = sc.full( + dims=out.dims, + shape=out.shape, + value=float('nan'), + unit=out.unit, + dtype=sc.DType.float64, + ) + if out.name: + out.name = f'{op} of {out.name}' + return out + + def inspector( obj: Plottable, dim: str | None = None, @@ -222,3 +278,195 @@ def inspector( if orientation == 'horizontal': out = [out] return Box(out) + + +def inspector_polygon( + obj: Plottable, + dim: str | None = None, + *, + aspect: Literal['auto', 'equal', None] = None, + autoscale: bool = True, + cbar: bool = True, + clabel: str | None = None, + cmax: sc.Variable | float | None = None, + cmin: sc.Variable | float | None = None, + errorbars: bool = True, + figsize: tuple[float, float] | None = None, + grid: bool = False, + legend: bool | tuple[float, float] = True, + logc: bool | None = None, + mask_cmap: str = 'gray', + mask_color: str = 'black', + nan_color: str | None = None, + norm: Literal['linear', 'log', None] = None, + operation: Literal['sum', 'mean', 'min', 'max'] = 'sum', + orientation: Literal['horizontal', 'vertical'] = 'horizontal', + title: str | None = None, + vmax: sc.Variable | float | None = None, + vmin: sc.Variable | float | None = None, + xlabel: str | None = None, + xmax: sc.Variable | float | None = None, + xmin: sc.Variable | float | None = None, + ylabel: str | None = None, + ymax: sc.Variable | float | None = None, + ymin: sc.Variable | float | None = None, + **kwargs, +): + """ + Inspector takes in a three-dimensional input and applies a reduction operation + (``'sum'`` by default) along one of the dimensions specified by ``dim``. + It displays the result as a two-dimensional image. + In addition, an 'inspection' tool is available in the toolbar which allows drawing + polygon regions on the image. The resulting one-dimensional slice on the right hand + side figure is obtained by reducing over all points within the polygon. + + Controls: + - Click to add polygon vertices + - Click the first vertex to close the polygon + - Right-click a polygon edge to delete it (when the tool is inactive) + + Notes + ----- + + Almost all the arguments for plot customization apply to the two-dimensional image + (unless specified). + + Parameters + ---------- + obj: + The object to be plotted. + dim: + The dimension along which to apply the reduction operation. This will also be + the dimension that remains in the one-dimensional slices generated by adding + markers on the image. If no dim is provided, the last (inner) dim of the input + data will be used. + aspect: + Aspect ratio for the axes. + autoscale: + Automatically scale the axes/colormap on updates if ``True``. + cbar: + Show colorbar if ``True``. + clabel: + Label for colorscale. + cmax: + Upper limit for colorscale. + cmin: + Lower limit for colorscale. + errorbars: + Show errorbars if ``True`` (1d figure). + figsize: + The width and height of the figure, in inches. + grid: + Show grid if ``True``. + legend: + Show legend if ``True``. If ``legend`` is a tuple, it should contain the + ``(x, y)`` coordinates of the legend's anchor point in axes coordinates + (1d figure). + logc: + If ``True``, use logarithmic scale for colorscale. + mask_cmap: + Colormap to use for masks. + mask_color: + Color of masks (overrides ``mask_cmap``). + nan_color: + Color to use for NaN values. + norm: + Set to ``'log'`` for a logarithmic colorscale. Legacy, prefer ``logc`` instead. + operation: + The operation to apply along the third (undisplayed) dimension specified by + ``dim``. + orientation: + Display the two panels side-by-side ('horizontal') or one below the other + ('vertical'). + title: + The figure title. + vmax: + Upper limit for data colorscale to be displayed. + Legacy, prefer ``cmax`` instead. + vmin: + Lower limit for data colorscale to be displayed. + Legacy, prefer ``cmin`` instead. + xlabel: + Label for x-axis. + xmax: + Upper limit for x-axis (1d figure) + xmin: + Lower limit for x-axis (1d figure) + ylabel: + Label for y-axis. + ymax: + Upper limit for y-axis (1d figure). + ymin: + Lower limit for y-axis (1d figure). + **kwargs: + Additional arguments forwarded to the underlying plotting library. + + Returns + ------- + : + A :class:`Box` which will contain two :class:`Figure` and one slider widget. + """ + + f1d = linefigure( + autoscale=autoscale, + errorbars=errorbars, + grid=grid, + legend=legend, + mask_color=mask_color, + xmax=xmax, + xmin=xmin, + ymax=ymax, + ymin=ymin, + ) + require_interactive_figure(f1d, 'inspector_polygon') + + in_node = Node(preprocess, obj, ignore_size=True) + data = in_node() + if data.ndim != 3: + raise ValueError( + 'The inspector plot currently only works with ' + f'three-dimensional data, found {data.ndim} dims.' + ) + if dim is None: + dim = data.dims[-1] + bin_edges_node = Node(_to_bin_edges, in_node, dim=dim) + op_node = Node(_apply_op, da=bin_edges_node, op=operation, dim=dim) + f2d = imagefigure( + op_node, + aspect=aspect, + cbar=cbar, + clabel=clabel, + cmax=cmax, + cmin=cmin, + figsize=figsize, + grid=grid, + logc=logc, + mask_cmap=mask_cmap, + mask_color=mask_color, + nan_color=nan_color, + norm=norm, + title=title, + vmax=vmax, + vmin=vmin, + xlabel=xlabel, + ylabel=ylabel, + **kwargs, + ) + + from ..widgets import Box, PolygonTool + from ..widgets.drawing import _get_polygon_info + + polys = PolygonTool( + figure=f2d, + input_node=bin_edges_node, + func=partial(_slice_polygon, op=operation), + destination=f1d, + get_vertices_info=_get_polygon_info, + tooltip="Activate polygon inspector tool", + icon='draw-polygon', + ) + f2d.toolbar['inspect'] = polys + out = [f2d, f1d] + if orientation == 'horizontal': + out = [out] + return Box(out) diff --git a/src/plopp/widgets/__init__.pyi b/src/plopp/widgets/__init__.pyi index 225f8a653..b7516d4f8 100644 --- a/src/plopp/widgets/__init__.pyi +++ b/src/plopp/widgets/__init__.pyi @@ -4,7 +4,7 @@ from .box import Box, HBar, VBar from .checkboxes import Checkboxes from .clip3d import Clip3dTool, ClippingPlanes -from .drawing import DrawingTool, PointsTool +from .drawing import DrawingTool, PointsTool, PolygonTool from .linesave import LineSaveTool from .slice import RangeSliceWidget, SliceWidget, slice_dims from .toolbar import Toolbar, make_toolbar_canvas2d, make_toolbar_canvas3d @@ -21,6 +21,7 @@ __all__ = [ "HBar", "LineSaveTool", "PointsTool", + "PolygonTool", "RangeSliceWidget", "SliceWidget", "ToggleTool", diff --git a/src/plopp/widgets/drawing.py b/src/plopp/widgets/drawing.py index a65b9b456..3e49376d4 100644 --- a/src/plopp/widgets/drawing.py +++ b/src/plopp/widgets/drawing.py @@ -136,6 +136,139 @@ def start_stop(self): self._tool.stop() +class PolygonTool(ToggleTool): + """ + Tool to draw polygons on a figure and use them to generate derived data. + + Parameters + ---------- + figure: + The figure where the tool will draw polygons. + input_node: + The node that provides the raw data which is shown in ``figure``. + func: + The function to be used to make a node whose parents will be the + ``input_node`` and a node yielding the polygon vertices. + destination: + Where the output from the ``func`` node will be then sent on. This can + either be a figure, or another graph node. + get_vertices_info: + A function that converts polygon vertices to a dict usable by ``func``. + value: + Activate the tool upon creation if ``True``. + + **kwargs: + Additional arguments are forwarded to the ``ToggleTool`` constructor. + """ + + def __init__( + self, + figure: FigureLike, + input_node: Node, + func: Callable, + destination: FigureLike | Node, + get_vertices_info: Callable, + value: bool = False, + **kwargs, + ): + super().__init__(callback=self.start_stop, value=value, **kwargs) + + self._figure = figure + self._input_node = input_node + self._func = func + self._destination = destination + self._destination_is_fig = is_figure(self._destination) + self._get_vertices_info = get_vertices_info + self._selector = None + self._polygons = {} + self._patch_to_nodeid = {} + self._draw_nodes = {} + self._output_nodes = {} + self._pick_cid = None + + def _ensure_selector(self): + if self._selector is not None: + return + from matplotlib.widgets import PolygonSelector + + self._selector = PolygonSelector( + self._figure.ax, + onselect=self._on_select, + useblit=False, + props={"linewidth": 1.5, "linestyle": "-", "alpha": 0.8}, + ) + self._pick_cid = self._figure.canvas.fig.canvas.mpl_connect( + "pick_event", self._on_pick + ) + + def _on_select(self, verts): + if len(verts) < 3: + return + info = self._get_vertices_info(verts=verts, figure=self._figure) + draw_node = Node(info) + draw_node.pretty_name = f'Polygon draw node {len(self._draw_nodes)}' + nodeid = draw_node.id + self._draw_nodes[nodeid] = draw_node + output_node = node(self._func)(self._input_node, draw_node) + output_node.pretty_name = f'Polygon output node {len(self._output_nodes)}' + self._output_nodes[nodeid] = output_node + if self._destination_is_fig: + output_node.add_view(self._destination.view) + self._destination.update({output_node.id: output_node()}) + elif isinstance(self._destination, Node): + self._destination.add_parents(output_node) + self._destination.notify_children(verts) + + from matplotlib.patches import Polygon + + patch = Polygon( + verts, closed=True, fill=False, linewidth=1.5, zorder=4, picker=True + ) + if self._destination_is_fig: + patch.set_edgecolor(self._destination.artists[output_node.id].color) + self._figure.ax.add_patch(patch) + self._polygons[nodeid] = patch + self._patch_to_nodeid[patch] = nodeid + self._figure.canvas.draw() + + if self._selector is not None: + self._selector.clear() + + def _on_pick(self, event): + if event.mouseevent.button != 3: + return + if self.value: + return + nodeid = self._patch_to_nodeid.get(event.artist) + if nodeid is None: + return + self.remove(nodeid) + + def remove(self, nodeid): + patch = self._polygons.pop(nodeid) + self._patch_to_nodeid.pop(patch, None) + if self._destination_is_fig: + artist = self._destination.artists.pop(self._output_nodes[nodeid].id) + artist.remove() + self._destination.canvas.draw() + patch.remove() + output_node = self._output_nodes.pop(nodeid) + output_node.remove() + draw_node = self._draw_nodes.pop(nodeid) + draw_node.remove() + self._figure.canvas.draw() + + def start_stop(self): + """ + Toggle start or stop of the tool. + """ + if self.value: + self._ensure_selector() + self._selector.set_active(True) + elif self._selector is not None: + self._selector.set_active(False) + + def _get_points_info(artist, figure): """ Convert the raw (x, y) position of a point to a dict containing the dimensions of @@ -153,6 +286,32 @@ def _get_points_info(artist, figure): } +def _get_polygon_info(verts, figure): + """ + Convert the raw polygon vertices to a dict containing the dimensions of + each axis, and arrays with units. + """ + xs, ys = zip(*verts, strict=False) + return { + 'x': { + 'dim': figure.canvas.dims['x'], + 'value': sc.array( + dims=['vertex'], + values=xs, + unit=figure.canvas.units['x'], + ), + }, + 'y': { + 'dim': figure.canvas.dims['y'], + 'value': sc.array( + dims=['vertex'], + values=ys, + unit=figure.canvas.units['y'], + ), + }, + } + + def _make_points(**kwargs): """ Intermediate function needed for giving to `partial` to avoid making mpltoolbox a diff --git a/tests/plotting/inspector_polygon_test.py b/tests/plotting/inspector_polygon_test.py new file mode 100644 index 000000000..55d05428a --- /dev/null +++ b/tests/plotting/inspector_polygon_test.py @@ -0,0 +1,103 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2025 Scipp contributors (https://github.com/scipp) + +import numpy as np +import scipp as sc + + +def _make_da() -> sc.DataArray: + data = sc.array( + dims=['y', 'x', 'z'], + values=np.arange(2 * 3 * 4, dtype=float).reshape(2, 3, 4), + ) + coords = { + 'x': sc.arange('x', 3), + 'y': sc.arange('y', 2), + 'z': sc.arange('z', 4), + } + return sc.DataArray(data=data, coords=coords) + + +def test_slice_polygon_sum_full_selection(): + import plopp as pp + from plopp.plotting.inspector import _slice_polygon + + _ = pp.inspector + da = _make_da() + poly = { + 'x': { + 'dim': 'x', + 'value': sc.array(dims=['vertex'], values=[-1.0, 3.0, 3.0, -1.0]), + }, + 'y': { + 'dim': 'y', + 'value': sc.array(dims=['vertex'], values=[-1.0, -1.0, 2.0, 2.0]), + }, + } + + out = _slice_polygon(da, poly, 'sum') + expected = sc.nansum(da, dim=['y', 'x']) + assert sc.identical(out, expected) + + +def test_slice_polygon_min_empty_selection_is_nan(): + import plopp as pp + from plopp.plotting.inspector import _slice_polygon + + _ = pp.inspector + da = _make_da() + poly = { + 'x': { + 'dim': 'x', + 'value': sc.array(dims=['vertex'], values=[10.0, 11.0, 11.0, 10.0]), + }, + 'y': { + 'dim': 'y', + 'value': sc.array(dims=['vertex'], values=[10.0, 10.0, 11.0, 11.0]), + }, + } + + out = _slice_polygon(da, poly, 'min') + assert sc.all(sc.isnan(out.data)).value + + +def test_slice_polygon_max_empty_selection_is_nan(): + import plopp as pp + from plopp.plotting.inspector import _slice_polygon + + _ = pp.inspector + da = _make_da() + poly = { + 'x': { + 'dim': 'x', + 'value': sc.array(dims=['vertex'], values=[10.0, 11.0, 11.0, 10.0]), + }, + 'y': { + 'dim': 'y', + 'value': sc.array(dims=['vertex'], values=[10.0, 10.0, 11.0, 11.0]), + }, + } + + out = _slice_polygon(da, poly, 'max') + assert sc.all(sc.isnan(out.data)).value + + +def test_slice_polygon_mean_empty_selection_is_nan(): + import plopp as pp + from plopp.plotting.inspector import _slice_polygon + + _ = pp.inspector + da = _make_da() + poly = { + 'x': { + 'dim': 'x', + 'value': sc.array(dims=['vertex'], values=[10.0, 11.0, 11.0, 10.0]), + }, + 'y': { + 'dim': 'y', + 'value': sc.array(dims=['vertex'], values=[10.0, 10.0, 11.0, 11.0]), + }, + } + + out = _slice_polygon(da, poly, 'mean') + assert sc.all(sc.isnan(out.data)).value From 8c0a99674d46e2bb2614126e2e76357d4592c91f Mon Sep 17 00:00:00 2001 From: Johannes Kasimir Date: Thu, 29 Jan 2026 12:48:24 +0100 Subject: [PATCH 02/12] fix --- src/plopp/plotting/inspector.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plopp/plotting/inspector.py b/src/plopp/plotting/inspector.py index 5f4f6789e..e9d54059d 100644 --- a/src/plopp/plotting/inspector.py +++ b/src/plopp/plotting/inspector.py @@ -284,7 +284,7 @@ def inspector_polygon( obj: Plottable, dim: str | None = None, *, - aspect: Literal['auto', 'equal', None] = None, + aspect: Literal['auto', 'equal'] | None = None, autoscale: bool = True, cbar: bool = True, clabel: str | None = None, @@ -298,7 +298,7 @@ def inspector_polygon( mask_cmap: str = 'gray', mask_color: str = 'black', nan_color: str | None = None, - norm: Literal['linear', 'log', None] = None, + norm: Literal['linear', 'log'] | None = None, operation: Literal['sum', 'mean', 'min', 'max'] = 'sum', orientation: Literal['horizontal', 'vertical'] = 'horizontal', title: str | None = None, From 956c81e84eb08990ba4e565b906f6b7f369d388c Mon Sep 17 00:00:00 2001 From: Johannes Kasimir Date: Thu, 29 Jan 2026 13:11:36 +0100 Subject: [PATCH 03/12] use strict --- src/plopp/widgets/drawing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plopp/widgets/drawing.py b/src/plopp/widgets/drawing.py index 3e49376d4..5f944636d 100644 --- a/src/plopp/widgets/drawing.py +++ b/src/plopp/widgets/drawing.py @@ -291,7 +291,7 @@ def _get_polygon_info(verts, figure): Convert the raw polygon vertices to a dict containing the dimensions of each axis, and arrays with units. """ - xs, ys = zip(*verts, strict=False) + xs, ys = zip(*verts, strict=True) return { 'x': { 'dim': figure.canvas.dims['x'], From 65b98fcb317a964cf3936ad97c5a7816c2d06262 Mon Sep 17 00:00:00 2001 From: Johannes Kasimir Date: Thu, 29 Jan 2026 13:26:56 +0100 Subject: [PATCH 04/12] refactor: move to separate file to avoid import issue --- src/plopp/plotting/__init__.pyi | 3 +- src/plopp/plotting/inspector.py | 247 -------------------- src/plopp/plotting/inspector_polygon.py | 275 +++++++++++++++++++++++ tests/plotting/inspector_polygon_test.py | 8 +- 4 files changed, 281 insertions(+), 252 deletions(-) create mode 100644 src/plopp/plotting/inspector_polygon.py diff --git a/src/plopp/plotting/__init__.pyi b/src/plopp/plotting/__init__.pyi index 07b19535c..b046c3e8d 100644 --- a/src/plopp/plotting/__init__.pyi +++ b/src/plopp/plotting/__init__.pyi @@ -1,7 +1,8 @@ # SPDX-License-Identifier: BSD-3-Clause # Copyright (c) 2025 Scipp contributors (https://github.com/scipp) -from .inspector import inspector, inspector_polygon +from .inspector import inspector +from .inspector_polygon import inspector_polygon from .mesh3d import mesh3d from .plot import plot from .scatter import scatter diff --git a/src/plopp/plotting/inspector.py b/src/plopp/plotting/inspector.py index e9d54059d..ed965b55e 100644 --- a/src/plopp/plotting/inspector.py +++ b/src/plopp/plotting/inspector.py @@ -2,7 +2,6 @@ # Copyright (c) 2023 Scipp contributors (https://github.com/scipp) -from functools import partial from typing import Literal import scipp as sc @@ -36,60 +35,6 @@ def _slice_xy(da: sc.DataArray, xy: dict[str, dict[str, int]]) -> sc.DataArray: return da[y['dim'], y['value']][x['dim'], x['value']] -def _coord_to_centers(da: sc.DataArray, dim: str) -> sc.Variable: - coord = da.coords[dim] - if da.coords.is_edges(dim, dim=dim) and coord.sizes[dim] == da.sizes[dim] + 1: - return sc.midpoints(coord, dim=dim) - return coord - - -def _polygon_mask( - da: sc.DataArray, poly: dict[str, dict[str, sc.Variable]] -) -> sc.Variable: - import numpy as np - from matplotlib.path import Path - - xdim = poly['x']['dim'] - ydim = poly['y']['dim'] - x = _coord_to_centers(da, xdim) - y = _coord_to_centers(da, ydim) - vx = poly['x']['value'].to(unit=x.unit).values - vy = poly['y']['value'].to(unit=y.unit).values - verts = np.column_stack([vx, vy]) - path = Path(verts) - xx, yy = np.meshgrid(x.values, y.values, indexing='xy') - points = np.column_stack([xx.ravel(), yy.ravel()]) - inside = path.contains_points(points).reshape(yy.shape) - return sc.array(dims=[ydim, xdim], values=inside) - - -def _slice_polygon( - da: sc.DataArray, poly: dict[str, dict[str, sc.Variable]], op: str -) -> sc.DataArray: - mask = _polygon_mask(da, poly) - masked = da.copy(deep=False) - masked.masks['polygon'] = sc.logical_not(mask) - has_points = bool(sc.any(mask).value) - reduce_op = { - 'sum': sc.nansum, - 'mean': sc.nanmean, - 'min': sc.nanmin, - 'max': sc.nanmax, - }[op] - out = reduce_op(masked, dim=[poly['x']['dim'], poly['y']['dim']]) - if (not has_points) and op in {'min', 'max'}: - out.data = sc.full( - dims=out.dims, - shape=out.shape, - value=float('nan'), - unit=out.unit, - dtype=sc.DType.float64, - ) - if out.name: - out.name = f'{op} of {out.name}' - return out - - def inspector( obj: Plottable, dim: str | None = None, @@ -278,195 +223,3 @@ def inspector( if orientation == 'horizontal': out = [out] return Box(out) - - -def inspector_polygon( - obj: Plottable, - dim: str | None = None, - *, - aspect: Literal['auto', 'equal'] | None = None, - autoscale: bool = True, - cbar: bool = True, - clabel: str | None = None, - cmax: sc.Variable | float | None = None, - cmin: sc.Variable | float | None = None, - errorbars: bool = True, - figsize: tuple[float, float] | None = None, - grid: bool = False, - legend: bool | tuple[float, float] = True, - logc: bool | None = None, - mask_cmap: str = 'gray', - mask_color: str = 'black', - nan_color: str | None = None, - norm: Literal['linear', 'log'] | None = None, - operation: Literal['sum', 'mean', 'min', 'max'] = 'sum', - orientation: Literal['horizontal', 'vertical'] = 'horizontal', - title: str | None = None, - vmax: sc.Variable | float | None = None, - vmin: sc.Variable | float | None = None, - xlabel: str | None = None, - xmax: sc.Variable | float | None = None, - xmin: sc.Variable | float | None = None, - ylabel: str | None = None, - ymax: sc.Variable | float | None = None, - ymin: sc.Variable | float | None = None, - **kwargs, -): - """ - Inspector takes in a three-dimensional input and applies a reduction operation - (``'sum'`` by default) along one of the dimensions specified by ``dim``. - It displays the result as a two-dimensional image. - In addition, an 'inspection' tool is available in the toolbar which allows drawing - polygon regions on the image. The resulting one-dimensional slice on the right hand - side figure is obtained by reducing over all points within the polygon. - - Controls: - - Click to add polygon vertices - - Click the first vertex to close the polygon - - Right-click a polygon edge to delete it (when the tool is inactive) - - Notes - ----- - - Almost all the arguments for plot customization apply to the two-dimensional image - (unless specified). - - Parameters - ---------- - obj: - The object to be plotted. - dim: - The dimension along which to apply the reduction operation. This will also be - the dimension that remains in the one-dimensional slices generated by adding - markers on the image. If no dim is provided, the last (inner) dim of the input - data will be used. - aspect: - Aspect ratio for the axes. - autoscale: - Automatically scale the axes/colormap on updates if ``True``. - cbar: - Show colorbar if ``True``. - clabel: - Label for colorscale. - cmax: - Upper limit for colorscale. - cmin: - Lower limit for colorscale. - errorbars: - Show errorbars if ``True`` (1d figure). - figsize: - The width and height of the figure, in inches. - grid: - Show grid if ``True``. - legend: - Show legend if ``True``. If ``legend`` is a tuple, it should contain the - ``(x, y)`` coordinates of the legend's anchor point in axes coordinates - (1d figure). - logc: - If ``True``, use logarithmic scale for colorscale. - mask_cmap: - Colormap to use for masks. - mask_color: - Color of masks (overrides ``mask_cmap``). - nan_color: - Color to use for NaN values. - norm: - Set to ``'log'`` for a logarithmic colorscale. Legacy, prefer ``logc`` instead. - operation: - The operation to apply along the third (undisplayed) dimension specified by - ``dim``. - orientation: - Display the two panels side-by-side ('horizontal') or one below the other - ('vertical'). - title: - The figure title. - vmax: - Upper limit for data colorscale to be displayed. - Legacy, prefer ``cmax`` instead. - vmin: - Lower limit for data colorscale to be displayed. - Legacy, prefer ``cmin`` instead. - xlabel: - Label for x-axis. - xmax: - Upper limit for x-axis (1d figure) - xmin: - Lower limit for x-axis (1d figure) - ylabel: - Label for y-axis. - ymax: - Upper limit for y-axis (1d figure). - ymin: - Lower limit for y-axis (1d figure). - **kwargs: - Additional arguments forwarded to the underlying plotting library. - - Returns - ------- - : - A :class:`Box` which will contain two :class:`Figure` and one slider widget. - """ - - f1d = linefigure( - autoscale=autoscale, - errorbars=errorbars, - grid=grid, - legend=legend, - mask_color=mask_color, - xmax=xmax, - xmin=xmin, - ymax=ymax, - ymin=ymin, - ) - require_interactive_figure(f1d, 'inspector_polygon') - - in_node = Node(preprocess, obj, ignore_size=True) - data = in_node() - if data.ndim != 3: - raise ValueError( - 'The inspector plot currently only works with ' - f'three-dimensional data, found {data.ndim} dims.' - ) - if dim is None: - dim = data.dims[-1] - bin_edges_node = Node(_to_bin_edges, in_node, dim=dim) - op_node = Node(_apply_op, da=bin_edges_node, op=operation, dim=dim) - f2d = imagefigure( - op_node, - aspect=aspect, - cbar=cbar, - clabel=clabel, - cmax=cmax, - cmin=cmin, - figsize=figsize, - grid=grid, - logc=logc, - mask_cmap=mask_cmap, - mask_color=mask_color, - nan_color=nan_color, - norm=norm, - title=title, - vmax=vmax, - vmin=vmin, - xlabel=xlabel, - ylabel=ylabel, - **kwargs, - ) - - from ..widgets import Box, PolygonTool - from ..widgets.drawing import _get_polygon_info - - polys = PolygonTool( - figure=f2d, - input_node=bin_edges_node, - func=partial(_slice_polygon, op=operation), - destination=f1d, - get_vertices_info=_get_polygon_info, - tooltip="Activate polygon inspector tool", - icon='draw-polygon', - ) - f2d.toolbar['inspect'] = polys - out = [f2d, f1d] - if orientation == 'horizontal': - out = [out] - return Box(out) diff --git a/src/plopp/plotting/inspector_polygon.py b/src/plopp/plotting/inspector_polygon.py new file mode 100644 index 000000000..468ee16f0 --- /dev/null +++ b/src/plopp/plotting/inspector_polygon.py @@ -0,0 +1,275 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2025 Scipp contributors (https://github.com/scipp) + +from functools import partial +from typing import Literal + +import scipp as sc + +from ..core import Node +from ..core.typing import Plottable +from ..core.utils import coord_as_bin_edges +from ..graphics import imagefigure, linefigure +from .common import preprocess, require_interactive_figure + + +def _to_bin_edges(da: sc.DataArray, dim: str) -> sc.DataArray: + """ + Convert dimension coords to bin edges. + """ + for d in set(da.dims) - {dim}: + da.coords[d] = coord_as_bin_edges(da, d) + return da + + +def _apply_op(da: sc.DataArray, op: str, dim: str) -> sc.DataArray: + out = getattr(sc, op)(da, dim=dim) + if out.name: + out.name = f'{op} of {out.name}' + return out + + +def _coord_to_centers(da: sc.DataArray, dim: str) -> sc.Variable: + coord = da.coords[dim] + if da.coords.is_edges(dim, dim=dim) and coord.sizes[dim] == da.sizes[dim] + 1: + return sc.midpoints(coord, dim=dim) + return coord + + +def _polygon_mask( + da: sc.DataArray, poly: dict[str, dict[str, sc.Variable]] +) -> sc.Variable: + import numpy as np + from matplotlib.path import Path + + xdim = poly['x']['dim'] + ydim = poly['y']['dim'] + x = _coord_to_centers(da, xdim) + y = _coord_to_centers(da, ydim) + vx = poly['x']['value'].to(unit=x.unit).values + vy = poly['y']['value'].to(unit=y.unit).values + verts = np.column_stack([vx, vy]) + path = Path(verts) + xx, yy = np.meshgrid(x.values, y.values, indexing='xy') + points = np.column_stack([xx.ravel(), yy.ravel()]) + inside = path.contains_points(points).reshape(yy.shape) + return sc.array(dims=[ydim, xdim], values=inside) + + +def _slice_polygon( + da: sc.DataArray, poly: dict[str, dict[str, sc.Variable]], op: str +) -> sc.DataArray: + mask = _polygon_mask(da, poly) + masked = da.copy(deep=False) + masked.masks['polygon'] = sc.logical_not(mask) + has_points = bool(sc.any(mask).value) + reduce_op = { + 'sum': sc.nansum, + 'mean': sc.nanmean, + 'min': sc.nanmin, + 'max': sc.nanmax, + }[op] + out = reduce_op(masked, dim=[poly['x']['dim'], poly['y']['dim']]) + if (not has_points) and op in {'min', 'max'}: + out.data = sc.full( + dims=out.dims, + shape=out.shape, + value=float('nan'), + unit=out.unit, + dtype=sc.DType.float64, + ) + if out.name: + out.name = f'{op} of {out.name}' + return out + + +def inspector_polygon( + obj: Plottable, + dim: str | None = None, + *, + aspect: Literal['auto', 'equal'] | None = None, + autoscale: bool = True, + cbar: bool = True, + clabel: str | None = None, + cmax: sc.Variable | float | None = None, + cmin: sc.Variable | float | None = None, + errorbars: bool = True, + figsize: tuple[float, float] | None = None, + grid: bool = False, + legend: bool | tuple[float, float] = True, + logc: bool | None = None, + mask_cmap: str = 'gray', + mask_color: str = 'black', + nan_color: str | None = None, + norm: Literal['linear', 'log'] | None = None, + operation: Literal['sum', 'mean', 'min', 'max'] = 'sum', + orientation: Literal['horizontal', 'vertical'] = 'horizontal', + title: str | None = None, + vmax: sc.Variable | float | None = None, + vmin: sc.Variable | float | None = None, + xlabel: str | None = None, + xmax: sc.Variable | float | None = None, + xmin: sc.Variable | float | None = None, + ylabel: str | None = None, + ymax: sc.Variable | float | None = None, + ymin: sc.Variable | float | None = None, + **kwargs, +): + """ + Inspector takes in a three-dimensional input and applies a reduction operation + (``'sum'`` by default) along one of the dimensions specified by ``dim``. + It displays the result as a two-dimensional image. + In addition, an 'inspection' tool is available in the toolbar which allows drawing + polygon regions on the image. The resulting one-dimensional slice on the right hand + side figure is obtained by reducing over all points within the polygon. + + Controls: + - Click to add polygon vertices + - Click the first vertex to close the polygon + - Right-click a polygon edge to delete it (when the tool is inactive) + + Notes + ----- + + Almost all the arguments for plot customization apply to the two-dimensional image + (unless specified). + + Parameters + ---------- + obj: + The object to be plotted. + dim: + The dimension along which to apply the reduction operation. This will also be + the dimension that remains in the one-dimensional slices generated by adding + markers on the image. If no dim is provided, the last (inner) dim of the input + data will be used. + aspect: + Aspect ratio for the axes. + autoscale: + Automatically scale the axes/colormap on updates if ``True``. + cbar: + Show colorbar if ``True``. + clabel: + Label for colorscale. + cmax: + Upper limit for colorscale. + cmin: + Lower limit for colorscale. + errorbars: + Show errorbars if ``True`` (1d figure). + figsize: + The width and height of the figure, in inches. + grid: + Show grid if ``True``. + legend: + Show legend if ``True``. If ``legend`` is a tuple, it should contain the + ``(x, y)`` coordinates of the legend's anchor point in axes coordinates + (1d figure). + logc: + If ``True``, use logarithmic scale for colorscale. + mask_cmap: + Colormap to use for masks. + mask_color: + Color of masks (overrides ``mask_cmap``). + nan_color: + Color to use for NaN values. + norm: + Set to ``'log'`` for a logarithmic colorscale. Legacy, prefer ``logc`` instead. + operation: + The operation to apply along the third (undisplayed) dimension specified by + ``dim``. + orientation: + Display the two panels side-by-side ('horizontal') or one below the other + ('vertical'). + title: + The figure title. + vmax: + Upper limit for data colorscale to be displayed. + Legacy, prefer ``cmax`` instead. + vmin: + Lower limit for data colorscale to be displayed. + Legacy, prefer ``cmin`` instead. + xlabel: + Label for x-axis. + xmax: + Upper limit for x-axis (1d figure) + xmin: + Lower limit for x-axis (1d figure) + ylabel: + Label for y-axis. + ymax: + Upper limit for y-axis (1d figure). + ymin: + Lower limit for y-axis (1d figure). + **kwargs: + Additional arguments forwarded to the underlying plotting library. + + Returns + ------- + : + A :class:`Box` which will contain two :class:`Figure` and one slider widget. + """ + + f1d = linefigure( + autoscale=autoscale, + errorbars=errorbars, + grid=grid, + legend=legend, + mask_color=mask_color, + xmax=xmax, + xmin=xmin, + ymax=ymax, + ymin=ymin, + ) + require_interactive_figure(f1d, 'inspector_polygon') + + in_node = Node(preprocess, obj, ignore_size=True) + data = in_node() + if data.ndim != 3: + raise ValueError( + 'The inspector plot currently only works with ' + f'three-dimensional data, found {data.ndim} dims.' + ) + if dim is None: + dim = data.dims[-1] + bin_edges_node = Node(_to_bin_edges, in_node, dim=dim) + op_node = Node(_apply_op, da=bin_edges_node, op=operation, dim=dim) + f2d = imagefigure( + op_node, + aspect=aspect, + cbar=cbar, + clabel=clabel, + cmax=cmax, + cmin=cmin, + figsize=figsize, + grid=grid, + logc=logc, + mask_cmap=mask_cmap, + mask_color=mask_color, + nan_color=nan_color, + norm=norm, + title=title, + vmax=vmax, + vmin=vmin, + xlabel=xlabel, + ylabel=ylabel, + **kwargs, + ) + + from ..widgets import Box, PolygonTool + from ..widgets.drawing import _get_polygon_info + + polys = PolygonTool( + figure=f2d, + input_node=bin_edges_node, + func=partial(_slice_polygon, op=operation), + destination=f1d, + get_vertices_info=_get_polygon_info, + tooltip="Activate polygon inspector tool", + icon='draw-polygon', + ) + f2d.toolbar['inspect'] = polys + out = [f2d, f1d] + if orientation == 'horizontal': + out = [out] + return Box(out) diff --git a/tests/plotting/inspector_polygon_test.py b/tests/plotting/inspector_polygon_test.py index 55d05428a..cade5686b 100644 --- a/tests/plotting/inspector_polygon_test.py +++ b/tests/plotting/inspector_polygon_test.py @@ -20,7 +20,7 @@ def _make_da() -> sc.DataArray: def test_slice_polygon_sum_full_selection(): import plopp as pp - from plopp.plotting.inspector import _slice_polygon + from plopp.plotting.inspector_polygon import _slice_polygon _ = pp.inspector da = _make_da() @@ -42,7 +42,7 @@ def test_slice_polygon_sum_full_selection(): def test_slice_polygon_min_empty_selection_is_nan(): import plopp as pp - from plopp.plotting.inspector import _slice_polygon + from plopp.plotting.inspector_polygon import _slice_polygon _ = pp.inspector da = _make_da() @@ -63,7 +63,7 @@ def test_slice_polygon_min_empty_selection_is_nan(): def test_slice_polygon_max_empty_selection_is_nan(): import plopp as pp - from plopp.plotting.inspector import _slice_polygon + from plopp.plotting.inspector_polygon import _slice_polygon _ = pp.inspector da = _make_da() @@ -84,7 +84,7 @@ def test_slice_polygon_max_empty_selection_is_nan(): def test_slice_polygon_mean_empty_selection_is_nan(): import plopp as pp - from plopp.plotting.inspector import _slice_polygon + from plopp.plotting.inspector_polygon import _slice_polygon _ = pp.inspector da = _make_da() From 68e1b82ee5eb0f77f35ae8bf4f533be9581f00ca Mon Sep 17 00:00:00 2001 From: Johannes Kasimir Date: Thu, 29 Jan 2026 13:27:50 +0100 Subject: [PATCH 05/12] fix: remove unnecessary check --- src/plopp/plotting/inspector_polygon.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plopp/plotting/inspector_polygon.py b/src/plopp/plotting/inspector_polygon.py index 468ee16f0..3f5bf4047 100644 --- a/src/plopp/plotting/inspector_polygon.py +++ b/src/plopp/plotting/inspector_polygon.py @@ -31,7 +31,7 @@ def _apply_op(da: sc.DataArray, op: str, dim: str) -> sc.DataArray: def _coord_to_centers(da: sc.DataArray, dim: str) -> sc.Variable: coord = da.coords[dim] - if da.coords.is_edges(dim, dim=dim) and coord.sizes[dim] == da.sizes[dim] + 1: + if da.coords.is_edges(dim, dim=dim): return sc.midpoints(coord, dim=dim) return coord From fb2fbc2acafe369a8a888336d4f97577531898bf Mon Sep 17 00:00:00 2001 From: Johannes Kasimir Date: Fri, 30 Jan 2026 12:49:52 +0100 Subject: [PATCH 06/12] refactor: combine inspectors + use DrawTool --- src/plopp/__init__.py | 1 - src/plopp/plotting/__init__.pyi | 2 - src/plopp/plotting/inspector.py | 98 +++++++- src/plopp/plotting/inspector_polygon.py | 275 ----------------------- src/plopp/widgets/drawing.py | 189 ++++------------ tests/plotting/inspector_polygon_test.py | 103 --------- 6 files changed, 136 insertions(+), 532 deletions(-) delete mode 100644 src/plopp/plotting/inspector_polygon.py delete mode 100644 tests/plotting/inspector_polygon_test.py diff --git a/src/plopp/__init__.py b/src/plopp/__init__.py index a3a8f9769..337a0087e 100644 --- a/src/plopp/__init__.py +++ b/src/plopp/__init__.py @@ -27,7 +27,6 @@ ], 'plotting': [ 'inspector', - 'inspector_polygon', 'mesh3d', 'plot', 'scatter', diff --git a/src/plopp/plotting/__init__.pyi b/src/plopp/plotting/__init__.pyi index b046c3e8d..d25cda71b 100644 --- a/src/plopp/plotting/__init__.pyi +++ b/src/plopp/plotting/__init__.pyi @@ -2,7 +2,6 @@ # Copyright (c) 2025 Scipp contributors (https://github.com/scipp) from .inspector import inspector -from .inspector_polygon import inspector_polygon from .mesh3d import mesh3d from .plot import plot from .scatter import scatter @@ -13,7 +12,6 @@ from .xyplot import xyplot __all__ = [ 'inspector', - 'inspector_polygon', 'mesh3d', 'plot', 'scatter', diff --git a/src/plopp/plotting/inspector.py b/src/plopp/plotting/inspector.py index ed965b55e..5d92e41e0 100644 --- a/src/plopp/plotting/inspector.py +++ b/src/plopp/plotting/inspector.py @@ -4,6 +4,7 @@ from typing import Literal +import numpy as np import scipp as sc from ..core import Node @@ -35,6 +36,73 @@ def _slice_xy(da: sc.DataArray, xy: dict[str, dict[str, int]]) -> sc.DataArray: return da[y['dim'], y['value']][x['dim'], x['value']] +def _coord_to_centers(da: sc.DataArray, dim: str) -> sc.Variable: + coord = da.coords[dim] + if da.coords.is_edges(dim, dim=dim): + return sc.midpoints(coord, dim=dim) + return coord + + +def _slice_variable_by_mask(v, mask): + if set(mask.dims) - set(v.dims): + v = sc.broadcast(v, sizes=mask.sizes) + + v = v.transpose((*mask.dims, *(d for d in v.dims if d not in mask.dims))) + + values = v.values + mvalues = mask.values + + return sc.array( + dims=['points', *(d for d in v.dims if d not in mask.dims)], + values=values[mvalues].reshape( + -1, *(s for d, s in v.sizes.items() if d not in mask.dims) + ), + unit=v.unit, + ) + + +def _slice_dataarray_by_mask(da, mask): + return sc.DataArray( + data=_slice_variable_by_mask(da.data, mask), + coords={ + name: ( + _slice_variable_by_mask(coord, mask) + if set(coord.dims) & set(mask.dims) + else coord + ) + for name, coord in da.coords.items() + if not any(da.coords.is_edges(name, dim=dim) for dim in coord.dims) + }, + masks={ + name: ( + _slice_variable_by_mask(m, mask) if set(m.dims) & set(mask.dims) else m + ) + for name, m in da.masks.items() + }, + ) + + +def _slice_inside_polygon( + da: sc.DataArray, poly: dict[str, dict[str, sc.Variable]] +) -> sc.DataArray: + from matplotlib.path import Path + + xdim = poly['x']['dim'] + ydim = poly['y']['dim'] + x = _coord_to_centers(da, xdim) + y = _coord_to_centers(da, ydim) + vx = poly['x']['value'].to(unit=x.unit).values + vy = poly['y']['value'].to(unit=y.unit).values + verts = np.column_stack([vx, vy]) + path = Path(verts) + xx, yy = np.meshgrid(x.values, y.values, indexing='xy') + points = np.column_stack([xx.ravel(), yy.ravel()]) + inside = sc.array( + dims=[ydim, xdim], values=path.contains_points(points).reshape(yy.shape) + ) + return _slice_dataarray_by_mask(da, inside).sum('points') + + def inspector( obj: Plottable, dim: str | None = None, @@ -52,6 +120,7 @@ def inspector( logc: bool | None = None, mask_cmap: str = 'gray', mask_color: str = 'black', + mode: Literal['point', 'polygon'] = 'point', nan_color: str | None = None, norm: Literal['linear', 'log'] | None = None, operation: Literal['sum', 'mean', 'min', 'max'] = 'sum', @@ -208,17 +277,28 @@ def inspector( ylabel=ylabel, **kwargs, ) + from ..widgets import Box, PointsTool, PolygonTool - from ..widgets import Box, PointsTool + if mode == 'point': + tool = PointsTool( + figure=f2d, + input_node=bin_edges_node, + func=_slice_xy, + destination=f1d, + tooltip="Activate inspector tool", + ) + elif mode == 'polygon': + tool = PolygonTool( + figure=f2d, + input_node=bin_edges_node, + func=_slice_inside_polygon, + destination=f1d, + tooltip="Activate polygon inspector tool", + ) + else: + raise ValueError(f'Mode "{mode}" is unknown.') - pts = PointsTool( - figure=f2d, - input_node=bin_edges_node, - func=_slice_xy, - destination=f1d, - tooltip="Activate inspector tool", - ) - f2d.toolbar['inspect'] = pts + f2d.toolbar['inspect'] = tool out = [f2d, f1d] if orientation == 'horizontal': out = [out] diff --git a/src/plopp/plotting/inspector_polygon.py b/src/plopp/plotting/inspector_polygon.py deleted file mode 100644 index 3f5bf4047..000000000 --- a/src/plopp/plotting/inspector_polygon.py +++ /dev/null @@ -1,275 +0,0 @@ -# SPDX-License-Identifier: BSD-3-Clause -# Copyright (c) 2025 Scipp contributors (https://github.com/scipp) - -from functools import partial -from typing import Literal - -import scipp as sc - -from ..core import Node -from ..core.typing import Plottable -from ..core.utils import coord_as_bin_edges -from ..graphics import imagefigure, linefigure -from .common import preprocess, require_interactive_figure - - -def _to_bin_edges(da: sc.DataArray, dim: str) -> sc.DataArray: - """ - Convert dimension coords to bin edges. - """ - for d in set(da.dims) - {dim}: - da.coords[d] = coord_as_bin_edges(da, d) - return da - - -def _apply_op(da: sc.DataArray, op: str, dim: str) -> sc.DataArray: - out = getattr(sc, op)(da, dim=dim) - if out.name: - out.name = f'{op} of {out.name}' - return out - - -def _coord_to_centers(da: sc.DataArray, dim: str) -> sc.Variable: - coord = da.coords[dim] - if da.coords.is_edges(dim, dim=dim): - return sc.midpoints(coord, dim=dim) - return coord - - -def _polygon_mask( - da: sc.DataArray, poly: dict[str, dict[str, sc.Variable]] -) -> sc.Variable: - import numpy as np - from matplotlib.path import Path - - xdim = poly['x']['dim'] - ydim = poly['y']['dim'] - x = _coord_to_centers(da, xdim) - y = _coord_to_centers(da, ydim) - vx = poly['x']['value'].to(unit=x.unit).values - vy = poly['y']['value'].to(unit=y.unit).values - verts = np.column_stack([vx, vy]) - path = Path(verts) - xx, yy = np.meshgrid(x.values, y.values, indexing='xy') - points = np.column_stack([xx.ravel(), yy.ravel()]) - inside = path.contains_points(points).reshape(yy.shape) - return sc.array(dims=[ydim, xdim], values=inside) - - -def _slice_polygon( - da: sc.DataArray, poly: dict[str, dict[str, sc.Variable]], op: str -) -> sc.DataArray: - mask = _polygon_mask(da, poly) - masked = da.copy(deep=False) - masked.masks['polygon'] = sc.logical_not(mask) - has_points = bool(sc.any(mask).value) - reduce_op = { - 'sum': sc.nansum, - 'mean': sc.nanmean, - 'min': sc.nanmin, - 'max': sc.nanmax, - }[op] - out = reduce_op(masked, dim=[poly['x']['dim'], poly['y']['dim']]) - if (not has_points) and op in {'min', 'max'}: - out.data = sc.full( - dims=out.dims, - shape=out.shape, - value=float('nan'), - unit=out.unit, - dtype=sc.DType.float64, - ) - if out.name: - out.name = f'{op} of {out.name}' - return out - - -def inspector_polygon( - obj: Plottable, - dim: str | None = None, - *, - aspect: Literal['auto', 'equal'] | None = None, - autoscale: bool = True, - cbar: bool = True, - clabel: str | None = None, - cmax: sc.Variable | float | None = None, - cmin: sc.Variable | float | None = None, - errorbars: bool = True, - figsize: tuple[float, float] | None = None, - grid: bool = False, - legend: bool | tuple[float, float] = True, - logc: bool | None = None, - mask_cmap: str = 'gray', - mask_color: str = 'black', - nan_color: str | None = None, - norm: Literal['linear', 'log'] | None = None, - operation: Literal['sum', 'mean', 'min', 'max'] = 'sum', - orientation: Literal['horizontal', 'vertical'] = 'horizontal', - title: str | None = None, - vmax: sc.Variable | float | None = None, - vmin: sc.Variable | float | None = None, - xlabel: str | None = None, - xmax: sc.Variable | float | None = None, - xmin: sc.Variable | float | None = None, - ylabel: str | None = None, - ymax: sc.Variable | float | None = None, - ymin: sc.Variable | float | None = None, - **kwargs, -): - """ - Inspector takes in a three-dimensional input and applies a reduction operation - (``'sum'`` by default) along one of the dimensions specified by ``dim``. - It displays the result as a two-dimensional image. - In addition, an 'inspection' tool is available in the toolbar which allows drawing - polygon regions on the image. The resulting one-dimensional slice on the right hand - side figure is obtained by reducing over all points within the polygon. - - Controls: - - Click to add polygon vertices - - Click the first vertex to close the polygon - - Right-click a polygon edge to delete it (when the tool is inactive) - - Notes - ----- - - Almost all the arguments for plot customization apply to the two-dimensional image - (unless specified). - - Parameters - ---------- - obj: - The object to be plotted. - dim: - The dimension along which to apply the reduction operation. This will also be - the dimension that remains in the one-dimensional slices generated by adding - markers on the image. If no dim is provided, the last (inner) dim of the input - data will be used. - aspect: - Aspect ratio for the axes. - autoscale: - Automatically scale the axes/colormap on updates if ``True``. - cbar: - Show colorbar if ``True``. - clabel: - Label for colorscale. - cmax: - Upper limit for colorscale. - cmin: - Lower limit for colorscale. - errorbars: - Show errorbars if ``True`` (1d figure). - figsize: - The width and height of the figure, in inches. - grid: - Show grid if ``True``. - legend: - Show legend if ``True``. If ``legend`` is a tuple, it should contain the - ``(x, y)`` coordinates of the legend's anchor point in axes coordinates - (1d figure). - logc: - If ``True``, use logarithmic scale for colorscale. - mask_cmap: - Colormap to use for masks. - mask_color: - Color of masks (overrides ``mask_cmap``). - nan_color: - Color to use for NaN values. - norm: - Set to ``'log'`` for a logarithmic colorscale. Legacy, prefer ``logc`` instead. - operation: - The operation to apply along the third (undisplayed) dimension specified by - ``dim``. - orientation: - Display the two panels side-by-side ('horizontal') or one below the other - ('vertical'). - title: - The figure title. - vmax: - Upper limit for data colorscale to be displayed. - Legacy, prefer ``cmax`` instead. - vmin: - Lower limit for data colorscale to be displayed. - Legacy, prefer ``cmin`` instead. - xlabel: - Label for x-axis. - xmax: - Upper limit for x-axis (1d figure) - xmin: - Lower limit for x-axis (1d figure) - ylabel: - Label for y-axis. - ymax: - Upper limit for y-axis (1d figure). - ymin: - Lower limit for y-axis (1d figure). - **kwargs: - Additional arguments forwarded to the underlying plotting library. - - Returns - ------- - : - A :class:`Box` which will contain two :class:`Figure` and one slider widget. - """ - - f1d = linefigure( - autoscale=autoscale, - errorbars=errorbars, - grid=grid, - legend=legend, - mask_color=mask_color, - xmax=xmax, - xmin=xmin, - ymax=ymax, - ymin=ymin, - ) - require_interactive_figure(f1d, 'inspector_polygon') - - in_node = Node(preprocess, obj, ignore_size=True) - data = in_node() - if data.ndim != 3: - raise ValueError( - 'The inspector plot currently only works with ' - f'three-dimensional data, found {data.ndim} dims.' - ) - if dim is None: - dim = data.dims[-1] - bin_edges_node = Node(_to_bin_edges, in_node, dim=dim) - op_node = Node(_apply_op, da=bin_edges_node, op=operation, dim=dim) - f2d = imagefigure( - op_node, - aspect=aspect, - cbar=cbar, - clabel=clabel, - cmax=cmax, - cmin=cmin, - figsize=figsize, - grid=grid, - logc=logc, - mask_cmap=mask_cmap, - mask_color=mask_color, - nan_color=nan_color, - norm=norm, - title=title, - vmax=vmax, - vmin=vmin, - xlabel=xlabel, - ylabel=ylabel, - **kwargs, - ) - - from ..widgets import Box, PolygonTool - from ..widgets.drawing import _get_polygon_info - - polys = PolygonTool( - figure=f2d, - input_node=bin_edges_node, - func=partial(_slice_polygon, op=operation), - destination=f1d, - get_vertices_info=_get_polygon_info, - tooltip="Activate polygon inspector tool", - icon='draw-polygon', - ) - f2d.toolbar['inspect'] = polys - out = [f2d, f1d] - if orientation == 'horizontal': - out = [out] - return Box(out) diff --git a/src/plopp/widgets/drawing.py b/src/plopp/widgets/drawing.py index 5f944636d..f3006fdb2 100644 --- a/src/plopp/widgets/drawing.py +++ b/src/plopp/widgets/drawing.py @@ -136,139 +136,6 @@ def start_stop(self): self._tool.stop() -class PolygonTool(ToggleTool): - """ - Tool to draw polygons on a figure and use them to generate derived data. - - Parameters - ---------- - figure: - The figure where the tool will draw polygons. - input_node: - The node that provides the raw data which is shown in ``figure``. - func: - The function to be used to make a node whose parents will be the - ``input_node`` and a node yielding the polygon vertices. - destination: - Where the output from the ``func`` node will be then sent on. This can - either be a figure, or another graph node. - get_vertices_info: - A function that converts polygon vertices to a dict usable by ``func``. - value: - Activate the tool upon creation if ``True``. - - **kwargs: - Additional arguments are forwarded to the ``ToggleTool`` constructor. - """ - - def __init__( - self, - figure: FigureLike, - input_node: Node, - func: Callable, - destination: FigureLike | Node, - get_vertices_info: Callable, - value: bool = False, - **kwargs, - ): - super().__init__(callback=self.start_stop, value=value, **kwargs) - - self._figure = figure - self._input_node = input_node - self._func = func - self._destination = destination - self._destination_is_fig = is_figure(self._destination) - self._get_vertices_info = get_vertices_info - self._selector = None - self._polygons = {} - self._patch_to_nodeid = {} - self._draw_nodes = {} - self._output_nodes = {} - self._pick_cid = None - - def _ensure_selector(self): - if self._selector is not None: - return - from matplotlib.widgets import PolygonSelector - - self._selector = PolygonSelector( - self._figure.ax, - onselect=self._on_select, - useblit=False, - props={"linewidth": 1.5, "linestyle": "-", "alpha": 0.8}, - ) - self._pick_cid = self._figure.canvas.fig.canvas.mpl_connect( - "pick_event", self._on_pick - ) - - def _on_select(self, verts): - if len(verts) < 3: - return - info = self._get_vertices_info(verts=verts, figure=self._figure) - draw_node = Node(info) - draw_node.pretty_name = f'Polygon draw node {len(self._draw_nodes)}' - nodeid = draw_node.id - self._draw_nodes[nodeid] = draw_node - output_node = node(self._func)(self._input_node, draw_node) - output_node.pretty_name = f'Polygon output node {len(self._output_nodes)}' - self._output_nodes[nodeid] = output_node - if self._destination_is_fig: - output_node.add_view(self._destination.view) - self._destination.update({output_node.id: output_node()}) - elif isinstance(self._destination, Node): - self._destination.add_parents(output_node) - self._destination.notify_children(verts) - - from matplotlib.patches import Polygon - - patch = Polygon( - verts, closed=True, fill=False, linewidth=1.5, zorder=4, picker=True - ) - if self._destination_is_fig: - patch.set_edgecolor(self._destination.artists[output_node.id].color) - self._figure.ax.add_patch(patch) - self._polygons[nodeid] = patch - self._patch_to_nodeid[patch] = nodeid - self._figure.canvas.draw() - - if self._selector is not None: - self._selector.clear() - - def _on_pick(self, event): - if event.mouseevent.button != 3: - return - if self.value: - return - nodeid = self._patch_to_nodeid.get(event.artist) - if nodeid is None: - return - self.remove(nodeid) - - def remove(self, nodeid): - patch = self._polygons.pop(nodeid) - self._patch_to_nodeid.pop(patch, None) - if self._destination_is_fig: - artist = self._destination.artists.pop(self._output_nodes[nodeid].id) - artist.remove() - self._destination.canvas.draw() - patch.remove() - output_node = self._output_nodes.pop(nodeid) - output_node.remove() - draw_node = self._draw_nodes.pop(nodeid) - draw_node.remove() - self._figure.canvas.draw() - - def start_stop(self): - """ - Toggle start or stop of the tool. - """ - if self.value: - self._ensure_selector() - self._selector.set_active(True) - elif self._selector is not None: - self._selector.set_active(False) - - def _get_points_info(artist, figure): """ Convert the raw (x, y) position of a point to a dict containing the dimensions of @@ -286,12 +153,50 @@ def _get_points_info(artist, figure): } -def _get_polygon_info(verts, figure): +def _make_points(**kwargs): + """ + Intermediate function needed for giving to `partial` to avoid making mpltoolbox a + hard dependency. + """ + from mpltoolbox import Points + + return Points(**kwargs) + + +PointsTool = partial( + DrawingTool, + tool=partial(_make_points, mec='w'), + get_artist_info=_get_points_info, + icon='crosshairs', +) +""" +Tool to add point markers onto a figure. + +Parameters +---------- +figure: + The figure where the tool will draw things (points, lines, shapes...). +input_node: + The node that provides the raw data which is shown in ``figure``. +func: + The function to be used to make a node whose parents will be the ``input_node`` + and a node yielding the current state of the tool (current position, size). +destination: + Where the output from the ``func`` node will be then sent on. This can either + be a figure, or another graph node. +value: + Activate the tool upon creation if ``True``. +**kwargs: + Additional arguments are forwarded to the ``ToggleTool`` constructor. +""" + + +def _get_polygon_info(artist, figure): """ Convert the raw polygon vertices to a dict containing the dimensions of each axis, and arrays with units. """ - xs, ys = zip(*verts, strict=True) + xs, ys = artist.x, artist.y return { 'x': { 'dim': figure.canvas.dims['x'], @@ -312,21 +217,21 @@ def _get_polygon_info(verts, figure): } -def _make_points(**kwargs): +def _make_polygons(**kwargs): """ Intermediate function needed for giving to `partial` to avoid making mpltoolbox a hard dependency. """ - from mpltoolbox import Points + from mpltoolbox import Polygons - return Points(**kwargs) + return Polygons(**kwargs) -PointsTool = partial( +PolygonTool = partial( DrawingTool, - tool=partial(_make_points, mec='w'), - get_artist_info=_get_points_info, - icon='crosshairs', + tool=partial(_make_polygons, mec='w'), + get_artist_info=_get_polygon_info, + icon='draw-polygon', ) """ Tool to add point markers onto a figure. diff --git a/tests/plotting/inspector_polygon_test.py b/tests/plotting/inspector_polygon_test.py deleted file mode 100644 index cade5686b..000000000 --- a/tests/plotting/inspector_polygon_test.py +++ /dev/null @@ -1,103 +0,0 @@ -# SPDX-License-Identifier: BSD-3-Clause -# Copyright (c) 2025 Scipp contributors (https://github.com/scipp) - -import numpy as np -import scipp as sc - - -def _make_da() -> sc.DataArray: - data = sc.array( - dims=['y', 'x', 'z'], - values=np.arange(2 * 3 * 4, dtype=float).reshape(2, 3, 4), - ) - coords = { - 'x': sc.arange('x', 3), - 'y': sc.arange('y', 2), - 'z': sc.arange('z', 4), - } - return sc.DataArray(data=data, coords=coords) - - -def test_slice_polygon_sum_full_selection(): - import plopp as pp - from plopp.plotting.inspector_polygon import _slice_polygon - - _ = pp.inspector - da = _make_da() - poly = { - 'x': { - 'dim': 'x', - 'value': sc.array(dims=['vertex'], values=[-1.0, 3.0, 3.0, -1.0]), - }, - 'y': { - 'dim': 'y', - 'value': sc.array(dims=['vertex'], values=[-1.0, -1.0, 2.0, 2.0]), - }, - } - - out = _slice_polygon(da, poly, 'sum') - expected = sc.nansum(da, dim=['y', 'x']) - assert sc.identical(out, expected) - - -def test_slice_polygon_min_empty_selection_is_nan(): - import plopp as pp - from plopp.plotting.inspector_polygon import _slice_polygon - - _ = pp.inspector - da = _make_da() - poly = { - 'x': { - 'dim': 'x', - 'value': sc.array(dims=['vertex'], values=[10.0, 11.0, 11.0, 10.0]), - }, - 'y': { - 'dim': 'y', - 'value': sc.array(dims=['vertex'], values=[10.0, 10.0, 11.0, 11.0]), - }, - } - - out = _slice_polygon(da, poly, 'min') - assert sc.all(sc.isnan(out.data)).value - - -def test_slice_polygon_max_empty_selection_is_nan(): - import plopp as pp - from plopp.plotting.inspector_polygon import _slice_polygon - - _ = pp.inspector - da = _make_da() - poly = { - 'x': { - 'dim': 'x', - 'value': sc.array(dims=['vertex'], values=[10.0, 11.0, 11.0, 10.0]), - }, - 'y': { - 'dim': 'y', - 'value': sc.array(dims=['vertex'], values=[10.0, 10.0, 11.0, 11.0]), - }, - } - - out = _slice_polygon(da, poly, 'max') - assert sc.all(sc.isnan(out.data)).value - - -def test_slice_polygon_mean_empty_selection_is_nan(): - import plopp as pp - from plopp.plotting.inspector_polygon import _slice_polygon - - _ = pp.inspector - da = _make_da() - poly = { - 'x': { - 'dim': 'x', - 'value': sc.array(dims=['vertex'], values=[10.0, 11.0, 11.0, 10.0]), - }, - 'y': { - 'dim': 'y', - 'value': sc.array(dims=['vertex'], values=[10.0, 10.0, 11.0, 11.0]), - }, - } - - out = _slice_polygon(da, poly, 'mean') - assert sc.all(sc.isnan(out.data)).value From c8085b05a3a9a0b283cd5e39fe56db03554a3219 Mon Sep 17 00:00:00 2001 From: Johannes Kasimir Date: Fri, 30 Jan 2026 14:25:25 +0100 Subject: [PATCH 07/12] docs --- src/plopp/plotting/inspector.py | 45 ++++++++++++++++++++------------- src/plopp/widgets/drawing.py | 10 ++++++-- 2 files changed, 35 insertions(+), 20 deletions(-) diff --git a/src/plopp/plotting/inspector.py b/src/plopp/plotting/inspector.py index 5d92e41e0..9d254f5c3 100644 --- a/src/plopp/plotting/inspector.py +++ b/src/plopp/plotting/inspector.py @@ -46,15 +46,12 @@ def _coord_to_centers(da: sc.DataArray, dim: str) -> sc.Variable: def _slice_variable_by_mask(v, mask): if set(mask.dims) - set(v.dims): v = sc.broadcast(v, sizes=mask.sizes) - + # Order of dims before slicing: *mask dims, *all dims not sliced by the mask. v = v.transpose((*mask.dims, *(d for d in v.dims if d not in mask.dims))) - - values = v.values - mvalues = mask.values - return sc.array( + # Order of dims after slicing: points, *all dims not sliced by the mask. dims=['points', *(d for d in v.dims if d not in mask.dims)], - values=values[mvalues].reshape( + values=v.values[mask.values].reshape( -1, *(s for d, s in v.sizes.items() if d not in mask.dims) ), unit=v.unit, @@ -66,12 +63,14 @@ def _slice_dataarray_by_mask(da, mask): data=_slice_variable_by_mask(da.data, mask), coords={ name: ( - _slice_variable_by_mask(coord, mask) - if set(coord.dims) & set(mask.dims) - else coord + _slice_variable_by_mask(coord, mask) if shares_dim_with_mask else coord ) for name, coord in da.coords.items() - if not any(da.coords.is_edges(name, dim=dim) for dim in coord.dims) + # Drop bin edge coords in the "plot dimensions". + if ( + not (shares_dim_with_mask := (set(coord.dims) & set(mask.dims))) + or not any(da.coords.is_edges(name, dim=dim) for dim in coord.dims) + ) }, masks={ name: ( @@ -140,16 +139,23 @@ def inspector( Inspector takes in a three-dimensional input and applies a reduction operation (``'sum'`` by default) along one of the dimensions specified by ``dim``. It displays the result as a two-dimensional image. - In addition, an 'inspection' tool is available in the toolbar which allows to place - markers on the image which perform slicing at that position to retain only the third - dimension and displays the resulting one-dimensional slice on the right hand side - figure. - - Controls: - - Click to make new point - - Drag existing point to move it + In addition, an 'inspection' tool is available in the toolbar. In ``mode='point'`` + it allows placing point markers on the image to slice at that position, retaining + only the third dimension and displaying the resulting one-dimensional slice in the + right-hand side figure. In ``mode='polygon'`` it allows drawing a polygon to compute + the total intensity inside the polygon as a function of the third dimension. + + Controls (point mode): + - Left-click to make new points + - Left-click and hold on point to move point - Middle-click to delete point + Controls (polygon mode): + - Left-click to make new polygons + - Left-click and hold on polygon vertex to move vertex + - Right-click and hold to drag/move the entire polygon + - Middle-click to delete polygon + Notes ----- @@ -193,6 +199,9 @@ def inspector( Colormap to use for masks. mask_color: Color of masks (overrides ``mask_cmap``). + mode: + Select ``'point'`` for point inspection or ``'polygon'`` for polygon selection + with total intensity inside the polygon plotted as a function of ``dim``. nan_color: Color to use for NaN values. norm: diff --git a/src/plopp/widgets/drawing.py b/src/plopp/widgets/drawing.py index f3006fdb2..8bcc307dc 100644 --- a/src/plopp/widgets/drawing.py +++ b/src/plopp/widgets/drawing.py @@ -234,12 +234,18 @@ def _make_polygons(**kwargs): icon='draw-polygon', ) """ -Tool to add point markers onto a figure. +Tool to draw polygon selections on a figure. + +Controls: + - Left-click to make new polygons + - Left-click and hold on polygon vertex to move vertex + - Right-click and hold to drag/move the entire polygon + - Middle-click to delete polygon Parameters ---------- figure: - The figure where the tool will draw things (points, lines, shapes...). + The figure where the tool will draw the polygon. input_node: The node that provides the raw data which is shown in ``figure``. func: From 97f52c5bed6181f6bbc97bb997ed32471924bd72 Mon Sep 17 00:00:00 2001 From: Johannes Kasimir Date: Fri, 30 Jan 2026 15:10:40 +0100 Subject: [PATCH 08/12] tests --- tests/plotting/inspector_test.py | 212 +++++++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) diff --git a/tests/plotting/inspector_test.py b/tests/plotting/inspector_test.py index 5b776acd4..0ac62550c 100644 --- a/tests/plotting/inspector_test.py +++ b/tests/plotting/inspector_test.py @@ -63,6 +63,51 @@ def test_line_creation(): assert len(fig1d.artists) == 2 +def _finalize_polygon(tool, points): + """ + Click the polygon vertices and force creation without needing a close gesture. + """ + for x, y in points: + tool.click(x, y) + # Finalize to trigger the on_create callback without needing to close the polygon. + tool._finalize_owner() + + +def _coord_margin(values, index): + """ + Return a small margin around a coordinate to build a polygon around a bin center. + """ + if index == 0: + return (values[1] - values[0]) / 4.0 + if index == len(values) - 1: + return (values[-1] - values[-2]) / 4.0 + return ( + min(values[index] - values[index - 1], values[index + 1] - values[index]) / 4.0 + ) + + +def _polygon_vertices(da, xdim, ydim, x_index, y_indices): + """ + Build a rectangle polygon around the given x index and y index range. + """ + xvalues = da.coords[xdim].values + yvalues = da.coords[ydim].values + y_start = y_indices[0] + y_end = y_indices[-1] + x0 = xvalues[x_index] + y0 = yvalues[y_start] + y1 = yvalues[y_end] + x_margin = _coord_margin(xvalues, x_index) + y_margin_start = _coord_margin(yvalues, y_start) + y_margin_end = _coord_margin(yvalues, y_end) + return [ + (x0 - x_margin, y0 - y_margin_start), + (x0 + x_margin, y0 - y_margin_start), + (x0 + x_margin, y1 + y_margin_end), + (x0 - x_margin, y1 + y_margin_end), + ] + + @pytest.mark.usefixtures('_use_ipympl') def test_line_removal(): da = pp.data.data3d() @@ -83,6 +128,173 @@ def test_line_removal(): assert len(fig1d.artists) == 0 +def _polygon_case( + *, + dims, + values, + coords, + x_index, + y_indices, + keep_dim=None, +): + """ + Create a polygon test case with input data and the selection indices to compare. + """ + return ( + sc.DataArray( + data=sc.array(dims=dims, values=values), + coords={ + name: sc.array(dims=[name], values=vals) + for name, vals in coords.items() + }, + ), + x_index, + y_indices, + keep_dim, + ) + + +@pytest.mark.usefixtures('_use_ipympl') +@pytest.mark.parametrize( + ("da", "x_index", "y_indices", "keep_dim"), + [ + _polygon_case( + dims=["yy", "xx", "zz"], + values=[ + [[0, 1], [2, 3], [4, 5]], + [[6, 7], [8, 9], [10, 11]], + [[12, 13], [14, 15], [16, 17]], + ], + coords={ + "yy": [0.0, 10.0, 20.0], + "xx": [0.0, 5.0, 10.0], + "zz": [1.0, 2.0], + }, + x_index=1, + y_indices=[1], + keep_dim=None, + ), + _polygon_case( + dims=["row", "depth", "col"], + values=[ + [[0, 1, 2], [3, 4, 5], [6, 7, 8]], + [[9, 10, 11], [12, 13, 14], [15, 16, 17]], + [[18, 19, 20], [21, 22, 23], [24, 25, 26]], + [[27, 28, 29], [30, 31, 32], [33, 34, 35]], + ], + coords={ + "row": [0.0, 100.0, 200.0, 300.0], + "col": [0.0, 10.0, 20.0], + "depth": [0.0, 1.0, 2.0], + }, + x_index=1, + y_indices=[1, 2], + keep_dim="row", + ), + ], +) +def test_polygon_mode_data_values(da, x_index, y_indices, keep_dim): + """ + Polygon selection should produce the expected 1D curve over the keep dimension. + """ + if keep_dim is None: + keep_dim = da.dims[-1] + ip = pp.inspector(da, mode='polygon', dim=keep_dim) + fig2d = ip[0][0] + fig1d = ip[0][1] + fig2d.toolbar['inspect'].value = True + tool = fig2d.toolbar['inspect']._tool + xdim = fig2d.canvas.dims['x'] + ydim = fig2d.canvas.dims['y'] + keep_dim = keep_dim + points = _polygon_vertices(da, xdim, ydim, x_index=x_index, y_indices=y_indices) + _finalize_polygon(tool, points) + assert fig1d.canvas.dims == {'x': keep_dim} + assert len(fig1d.artists) == 1 + line = next(iter(fig1d.artists.values())) + if len(y_indices) == 1: + expected = da[ydim, y_indices[0]][xdim, x_index] + else: + expected = da[ydim, y_indices][xdim, x_index].sum(ydim) + expected = expected.drop_coords( + [name for name in expected.coords if name != keep_dim] + ) + assert sc.identical(line._data, expected) + + +@pytest.mark.usefixtures('_use_ipympl') +def test_polygon_mode_respects_masks_on_keep_dim(): + """ + Masks on the keep dimension should propagate to the 1D output. + """ + da = sc.DataArray( + data=sc.array( + dims=["yy", "xx", "zz"], + values=[ + [[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], + [[6.0, 7.0, 8.0], [9.0, 10.0, 11.0]], + ], + ), + coords={ + "yy": sc.array(dims=["yy"], values=[0.0, 1.0]), + "xx": sc.array(dims=["xx"], values=[0.0, 1.0]), + "zz": sc.array(dims=["zz"], values=[0.0, 1.0, 2.0]), + }, + masks={ + "masked": sc.array(dims=["zz"], values=[False, True, False]), + }, + ) + ip = pp.inspector(da, mode="polygon") + fig2d = ip[0][0] + fig1d = ip[0][1] + fig2d.toolbar["inspect"].value = True + tool = fig2d.toolbar["inspect"]._tool + xdim = fig2d.canvas.dims["x"] + ydim = fig2d.canvas.dims["y"] + points = _polygon_vertices(da, xdim, ydim, x_index=0, y_indices=[0, 1]) + _finalize_polygon(tool, points) + line = next(iter(fig1d.artists.values())) + expected = da[ydim, [0, 1]][xdim, 0].sum(ydim) + expected = expected.drop_coords( + [name for name in expected.coords if name != da.dims[-1]] + ) + assert sc.identical(line._data, expected) + assert "masked" in line._data.masks + + +@pytest.mark.usefixtures('_use_ipympl') +def test_polygon_mode_preserves_keep_dim_binedges(): + """ + Bin-edge coordinates on the keep dimension should be preserved in the 1D output. + """ + da = sc.DataArray( + data=sc.array( + dims=["yy", "xx", "zz"], + values=[ + [[0.0, 1.0], [2.0, 3.0]], + [[4.0, 5.0], [6.0, 7.0]], + ], + ), + coords={ + "yy": sc.array(dims=["yy"], values=[0.0, 1.0]), + "xx": sc.array(dims=["xx"], values=[0.0, 1.0]), + "zz": sc.array(dims=["zz"], values=[0.0, 1.0, 2.0]), + }, + ) + assert da.coords.is_edges("zz", dim="zz") + ip = pp.inspector(da, mode="polygon") + fig2d = ip[0][0] + fig1d = ip[0][1] + fig2d.toolbar["inspect"].value = True + tool = fig2d.toolbar["inspect"]._tool + xdim = fig2d.canvas.dims["x"] + ydim = fig2d.canvas.dims["y"] + points = _polygon_vertices(da, xdim, ydim, x_index=1, y_indices=[0, 1]) + _finalize_polygon(tool, points) + line = next(iter(fig1d.artists.values())) + assert line._data.coords.is_edges("zz", dim="zz") + + @pytest.mark.usefixtures('_use_ipympl') def test_operation(): da = pp.data.data3d() From d8c3f9a0a7e5bdb38d8d01ffb631f52009450834 Mon Sep 17 00:00:00 2001 From: Johannes Kasimir Date: Fri, 30 Jan 2026 15:21:04 +0100 Subject: [PATCH 09/12] fixup --- src/plopp/plotting/inspector.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/plopp/plotting/inspector.py b/src/plopp/plotting/inspector.py index 9d254f5c3..72e63f3b5 100644 --- a/src/plopp/plotting/inspector.py +++ b/src/plopp/plotting/inspector.py @@ -1,7 +1,6 @@ # SPDX-License-Identifier: BSD-3-Clause # Copyright (c) 2023 Scipp contributors (https://github.com/scipp) - from typing import Literal import numpy as np From 5a94009c622170feb319e60586eba7452b0de8dc Mon Sep 17 00:00:00 2001 From: Johannes Kasimir Date: Tue, 3 Feb 2026 12:06:33 +0100 Subject: [PATCH 10/12] fix: remove complex slicing --- src/plopp/plotting/inspector.py | 47 ++++----------------------------- 1 file changed, 5 insertions(+), 42 deletions(-) diff --git a/src/plopp/plotting/inspector.py b/src/plopp/plotting/inspector.py index 72e63f3b5..3d19ed014 100644 --- a/src/plopp/plotting/inspector.py +++ b/src/plopp/plotting/inspector.py @@ -42,44 +42,6 @@ def _coord_to_centers(da: sc.DataArray, dim: str) -> sc.Variable: return coord -def _slice_variable_by_mask(v, mask): - if set(mask.dims) - set(v.dims): - v = sc.broadcast(v, sizes=mask.sizes) - # Order of dims before slicing: *mask dims, *all dims not sliced by the mask. - v = v.transpose((*mask.dims, *(d for d in v.dims if d not in mask.dims))) - return sc.array( - # Order of dims after slicing: points, *all dims not sliced by the mask. - dims=['points', *(d for d in v.dims if d not in mask.dims)], - values=v.values[mask.values].reshape( - -1, *(s for d, s in v.sizes.items() if d not in mask.dims) - ), - unit=v.unit, - ) - - -def _slice_dataarray_by_mask(da, mask): - return sc.DataArray( - data=_slice_variable_by_mask(da.data, mask), - coords={ - name: ( - _slice_variable_by_mask(coord, mask) if shares_dim_with_mask else coord - ) - for name, coord in da.coords.items() - # Drop bin edge coords in the "plot dimensions". - if ( - not (shares_dim_with_mask := (set(coord.dims) & set(mask.dims))) - or not any(da.coords.is_edges(name, dim=dim) for dim in coord.dims) - ) - }, - masks={ - name: ( - _slice_variable_by_mask(m, mask) if set(m.dims) & set(mask.dims) else m - ) - for name, m in da.masks.items() - }, - ) - - def _slice_inside_polygon( da: sc.DataArray, poly: dict[str, dict[str, sc.Variable]] ) -> sc.DataArray: @@ -93,12 +55,13 @@ def _slice_inside_polygon( vy = poly['y']['value'].to(unit=y.unit).values verts = np.column_stack([vx, vy]) path = Path(verts) - xx, yy = np.meshgrid(x.values, y.values, indexing='xy') - points = np.column_stack([xx.ravel(), yy.ravel()]) + xx = sc.broadcast(x, sizes={**x.sizes, **y.sizes}) + yy = sc.broadcast(y, sizes={**x.sizes, **y.sizes}) + points = np.column_stack([xx.values.ravel(), yy.values.ravel()]) inside = sc.array( - dims=[ydim, xdim], values=path.contains_points(points).reshape(yy.shape) + dims=yy.dims, values=path.contains_points(points).reshape(yy.shape) ) - return _slice_dataarray_by_mask(da, inside).sum('points') + return da.assign_masks(__inside_polygon=~inside).sum({*x.dims, *y.dims}) def inspector( From dc756490613fa32743a5a126a74cbc23c9c2da52 Mon Sep 17 00:00:00 2001 From: jokasimr Date: Wed, 4 Feb 2026 11:49:52 +0100 Subject: [PATCH 11/12] Update src/plopp/plotting/inspector.py Co-authored-by: Neil Vaytet <39047984+nvaytet@users.noreply.github.com> --- src/plopp/plotting/inspector.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plopp/plotting/inspector.py b/src/plopp/plotting/inspector.py index 3d19ed014..ea63ca8c5 100644 --- a/src/plopp/plotting/inspector.py +++ b/src/plopp/plotting/inspector.py @@ -42,7 +42,7 @@ def _coord_to_centers(da: sc.DataArray, dim: str) -> sc.Variable: return coord -def _slice_inside_polygon( +def _mask_outside_polygon( da: sc.DataArray, poly: dict[str, dict[str, sc.Variable]] ) -> sc.DataArray: from matplotlib.path import Path From 5381464067793ac31f33e3ac79c8916622fe147f Mon Sep 17 00:00:00 2001 From: Johannes Kasimir Date: Wed, 4 Feb 2026 12:11:22 +0100 Subject: [PATCH 12/12] fix --- src/plopp/plotting/inspector.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plopp/plotting/inspector.py b/src/plopp/plotting/inspector.py index ea63ca8c5..311cb195c 100644 --- a/src/plopp/plotting/inspector.py +++ b/src/plopp/plotting/inspector.py @@ -262,7 +262,7 @@ def inspector( tool = PolygonTool( figure=f2d, input_node=bin_edges_node, - func=_slice_inside_polygon, + func=_mask_outside_polygon, destination=f1d, tooltip="Activate polygon inspector tool", )