Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 92 additions & 35 deletions src/plopp/plotting/inspector.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,42 @@ def _slice_xy(da: sc.DataArray, xy: dict[str, dict[str, int]]) -> sc.DataArray:
return sc.full_like(da[y['dim'], 0][x['dim'], 0], value=np.nan, dtype=float)


def _slice_rectangular_region(da: sc.DataArray, rect: dict, op: str) -> sc.DataArray:
x = rect['x']
y = rect['y']
xmin, xmax = x['value'].min(), x['value'].max()
ymin, ymax = y['value'].min(), y['value'].max()
Comment on lines +66 to +67
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I presume the exclusive bounds are intentional?

Copy link
Copy Markdown
Member Author

@nvaytet nvaytet Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean that if we have a max value that falls exactly on a bin edge, the bin to the right of that won't be included?
I think that's it what we want?
Screenshot_20260410_103524

If I draw a rectangle exactly from 15 to 20, then I want the cells from the left edge at 15 to the right edge at 20 to be included.
I don't want the cell with a right edge at 15 and the one with left edge at 20 to be included. Right?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know. We have made that decision for slicing, but I am not sure the same is valid for a UI and drawing a rectangle — a user might expect both bounds to be inclusive?

Copy link
Copy Markdown
Member Author

@nvaytet nvaytet Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm quite sure this is what the users will expect.

In any case, I decided to just document the behaviour in the docstring with

    In rectangle mode, if any part of a data pixel lies inside the rectangle, the whole
    pixel is included in the selected region (as opposed to computing the overlap area).
    This is because it is not always possible to know how the bin fraction should be
    included, depending on the reduction operation which is applied to the data (sum,
    mean, etc).
    If one of the edges of the rectangle lies exactly on a bin edge
    between two data points, the selected region does not include the data on the
    outside of the rectangle, even though the edge is touching the rectangle.

    In polygon mode, only data whose bin centers are inside the polygon are included in
    the selected region.

try:
# If there is a 2D coordinate in the data, we need to slice the other dimension
# first, as trying to slice a 2D coordinate using label-based indexing raises an
# error in Scipp. After slicing the other dimension, the 2D coordinate will be
# 1D and can be sliced normally using label-based indexing.
# We assume here that there would only be one multi-dimensional coordinate in a
# given DataArray (which is very likely the case).
if da.coords[y['dim']].ndim > 1:
out = da[x['dim'], xmin:xmax][y['dim'], ymin:ymax]
else:
out = da[y['dim'], ymin:ymax][x['dim'], xmin:xmax]
# If the operation is a mean, there is currently a bug in the implementation
# in scipp where doing a mean over a subset of the array's dimensions gives the
# wrong result: https://github.com/scipp/scipp/issues/3841
# Instead, we manually compute the mean
dims = (x['dim'], y['dim'])
if 'mean' not in op:
return getattr(out, op)(dims)
if 'nan' in op:
numerator = out.nansum(dims)
denominator = (~sc.isnan(out.data)).sum()
else:
numerator = out.sum(dims)
denominator = out.size
denominator.unit = ""
return numerator / denominator
except IndexError:
# If the index is out of bounds, return an empty DataArray
return sc.full_like(da[y['dim'], 0][x['dim'], 0], value=np.nan, dtype=float)


def _mask_outside_polygon(
da: sc.DataArray,
poly: dict,
Expand Down Expand Up @@ -166,6 +202,18 @@ def inspector(
Almost all the arguments for plot customization apply to the two-dimensional image
(unless specified).

In rectangle mode, if any part of a data pixel lies inside the rectangle, the whole
pixel is included in the selected region (as opposed to computing the overlap area).
This is because it is not always possible to know how the bin fraction should be
included, depending on the reduction operation which is applied to the data (sum,
mean, etc).
If one of the edges of the rectangle lies exactly on a bin edge
between two data points, the selected region does not include the data on the
outside of the rectangle, even though the edge is touching the rectangle.

In polygon mode, only data whose bin centers are inside the polygon are included in
the selected region.

Parameters
----------
obj:
Expand Down Expand Up @@ -304,41 +352,50 @@ def inspector(
ylabel=ylabel,
**kwargs,
)
if mode == 'point':
tool = PointsTool(
figure=f2d,
input_node=bin_edges_node,
func=_slice_xy,
destination=f1d,
tooltip="Activate inspector tool",
continuous_update=continuous_update,
)
else:
da = bin_centers_node()
xdim = f2d.canvas.dims['x']
ydim = f2d.canvas.dims['y']
x = da.coords[xdim]
y = da.coords[ydim]
sizes = {**x.sizes, **y.sizes}
xx = sc.broadcast(x, sizes=sizes)
yy = sc.broadcast(y, sizes=sizes)
points = np.column_stack([xx.values.ravel(), yy.values.ravel()])
non_nan = ~sc.isnan(da.data)
tools = {'polygon': PolygonTool, 'rectangle': RectangleTool}
tool = tools[mode](
figure=f2d,
input_node=bin_centers_node,
func=partial(
_mask_outside_polygon,
points=points,
sizes=sizes,
op=operation,
non_nan=non_nan,
),
destination=f1d,
tooltip=f"Activate {mode} inspector tool",
continuous_update=continuous_update,
)
match mode:
case 'point':
tool = PointsTool(
figure=f2d,
input_node=bin_edges_node,
func=_slice_xy,
destination=f1d,
tooltip="Activate inspector tool",
continuous_update=continuous_update,
)
case 'rectangle':
tool = RectangleTool(
figure=f2d,
input_node=bin_edges_node,
func=partial(_slice_rectangular_region, op=operation),
destination=f1d,
tooltip="Activate rectangle inspector tool",
continuous_update=continuous_update,
)
case 'polygon':
da = bin_centers_node()
xdim = f2d.canvas.dims['x']
ydim = f2d.canvas.dims['y']
x = da.coords[xdim]
y = da.coords[ydim]
sizes = {**x.sizes, **y.sizes}
xx = sc.broadcast(x, sizes=sizes)
yy = sc.broadcast(y, sizes=sizes)
points = np.column_stack([xx.values.ravel(), yy.values.ravel()])
non_nan = ~sc.isnan(da.data)
tool = PolygonTool(
figure=f2d,
input_node=bin_centers_node,
func=partial(
_mask_outside_polygon,
points=points,
sizes=sizes,
op=operation,
non_nan=non_nan,
),
destination=f1d,
tooltip="Activate polygon inspector tool",
continuous_update=continuous_update,
)

f2d.toolbar['inspect'] = tool
out = [f2d, f1d]
Expand Down
4 changes: 2 additions & 2 deletions tests/plotting/inspector_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -420,8 +420,8 @@ def test_rectangle_mode():

# This rectangle should select the bottom left corner of the data.
# Closing the rectangle by repeating the first point.
x = [-1, 21]
y = [-1, 310]
x = [-1, 19]
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This indicates a slight behaviour change:
before a cell was only included if its center was in the rectangle, now it's as soon as one edge is in the rectangle.
This makes little difference for data with a large resolution.

Which is the correct one is difficult to decide. When the operation we perform at the end is a sum, we would probably want to just rebin at the exact position of the rectangle edge, to include a fraction of an incomplete bin.
But if the operation is something else, it is not clear what should actually be done.

I think it's fine for now, it's probably what most users would expect.
If some fine tuning is needed for a specific application we can either revisit or make a custom selection function and inject that in the inspector (somehow).

y = [-1, 290]
for xi, yi in zip(x, y, strict=True):
tool.click(x=xi, y=yi)

Expand Down
Loading