Skip to content

[essreduce] DetectorExplorer for viewing and masking detector banks - #834

Open
nvaytet wants to merge 63 commits into
mainfrom
masking-diagnostics
Open

nvaytet wants to merge 63 commits into
mainfrom
masking-diagnostics

Conversation

@nvaytet

@nvaytet nvaytet commented Oct 26, 2026

Copy link
Copy Markdown
Member

We turn the LokiBankViewer into a more generic tool that could also be used for other instruments.

  • You can choose what dimensions are displayed using the show_dims arg.
  • You can add horizontal or vertical masks by activating a masking tool
  • zooming and panning are now available on the single bank panels
Screenshot_20261026_234436 Screenshot_20261026_234359

da:
The DataArray to process.
"""
return da.assign_coords({dim: sc.arange(dim, sz) for dim, sz in da.sizes.items()})

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 replaces coordinates for every dimension, including existing calibrated coordinates and units, so the plotted axes can become misleading. Could this add coordinates only for dimensions that lack them? If existing coordinates are preserved, mask-click positions will also need converting to pixel indices.

@nvaytet
nvaytet requested a review from SimonHeybrock November 6, 2026 10:36
@nvaytet

nvaytet commented Nov 6, 2026

Copy link
Copy Markdown
Member Author

@SimonHeybrock in the latest update, the masks are added with only the dimensions they require.
So a mask for an entire tube will have dimensions (layer, tube) instead of all 4 dimensions as it was before.
Which dimensions to give the mask is determined by which dimensions are displayed and the state of the slider sum checkboxes.

dims = tuple(
dim for dim in self.data[key].dims if dim in tuple(sl[0] for sl in slices)
)
mask_name = str(dims)

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.

The mask name is the set of dimensions, so the number of stored masks depends on which dimensions a stroke covers, not on what the user wants to keep apart.

  • There is no way to keep, say, a beam-stop mask and a dead-tube mask separate and apply or drop them independently.
  • There is also no way of having a mask that does not mask anything. I think this is relevant since there is a difference between "we did not apply the dead-tubes mask" and "we applied the dead-tubes mask (but currently no tube is dead)".
  • We may want to open the tool with existing masks that should be edited.

I feel like giving an explicit dict with named masks (or just using the da.masks of the input) would avoid this and related problems. We should have a chat about this.

Comments on current solution from Claude below, but those may not be fully relevant, depending on what we conclude above.


Clearing and saving to a second file does separate them, but both files use the same names, so applying both loses the first:

da.assign_masks(file_a['bank_0']).assign_masks(file_b['bank_0'])  # file_a's mask is replaced

If separate masks are wanted, one option is a name field in MaskControl, used as a prefix so that strokes with different dims can still coexist under one name:

mask_name = f'{self.mask_control.mask_name}{dims}'

Two related questions:

  • The constructor cannot take existing masks, so a saved file cannot be reopened and edited. Is that intended?
  • What will read these files? The existing PixelMaskFilename paths read XML detector-ID lists (SANS) or an HDF5 variable of detector IDs (powder), and name each mask after its file. Neither reads this {bank: {dims: mask}} layout.

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.

The constructor cannot take existing masks, so a saved file cannot be reopened and edited. Is that intended?

This was planned for a follow-up, so as to not make this any larger than it already is. But I can add it here if deemed critical.

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.

If we don't do it now, wouldn't it require either keeping two parallel mechanism, or remove the current one?

m = self.masks[key][mask_name]
for s in slices:
m = m[s]
m.values[...] = False if m.values.all() else True

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.

The toggle only looks at the mask for the current dims. If a column was masked with layer summed (mask dims ('x',)), clicking the same column with the layer slider active does not unmask it. It adds a second mask ('layer', 'x'), and the column stays masked. Only Clear removes the first one. If this is intended, could the docstring say so?

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.

That is how it works currently, The behaviour was slightly more intuitive in the first iteration where the second click would unmask only the visible part of the mask that spans the full dimension.

When it adds the second mask, it feels as if nothing happened. Clicking again removes the second mask, once again feeling like nothing happened because the mask with dim x is still masking all layers.

I did not have a good solution to this, if we want to keep a dict of masks that can all have different dimensions.
We don't want to broadcast the existing 'x' mask to then unmask only the first layer of it.

The alternative would be to just remove any mask that is hit by the click. But this is again problematic if you made a horizontal mask, and then switch to vertical masks. When you click on a masked horizontal stripe, should it add a vertical mask or should it unmask the existing horizontal mask?

Or maybe we should not toggle on click? Instead, the user need to enable "Delete mode" with the toggle buttons and only then do clicks delete visible masks and don't add new ones. The toggling was just a shortcut to make it efficient to mask/unmask without having to go back to the toggle buttons constantly.
We could still have an additional "Clear" button that would clear all visible masks in one go.

Comment on lines +754 to +755
x = round(event.xdata)
y = round(event.ydata)

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.

event.xdata is in coordinate units but is used as an index below. _add_missing_coordinates keeps existing dimension coordinates, so a bank that has one gets the wrong pixel or an error:

# x = [0.1, 0.2, 0.3, 0.4] m, click on x=0.3
round(0.3) == 0  # masks column 0, no error
# x = [1000, ..., 1003], click on x=1003
# IndexError: The requested index 1003 is out of range

The click position needs converting to an index using the coordinate. A test with a non-arange coordinate would cover this.

allow continuous masking.
"""
# Ignore the event if it occurred outside any axes
if event.inaxes is None:

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.

event.inaxes is also set when the pointer is over the colorbar, so this check does not exclude it. With a masking mode active, a click low on the colorbar silently masks a pixel, because x and y come from the colorbar's data coordinates. A click higher up raises IndexError. Dragging from the image onto the colorbar in continuous mode does the same. Comparing with the image axes here and in add_mask fixes both:

if event.inaxes is not self.tab_figs[self.current_key].ax:
    return

)

self._link = ipw.jslink((self.sum, 'value'), (self.slider, 'disabled'))
self.value = (self.dim, self.slider.value)

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.

When dim is absent from a bank, the slider starts with sum=True but value is (dim, 0), because _on_subwidget_change has not run. add_mask then keeps it in slices, and m[('layer', 0)] raises DimensionError on every masking click in that bank. Reproduced with banks of dims (layer, y, x) and (y, x).

self.value = (self.dim, None if disabled else self.slider.value)

values in the color control widgets.
"""
bounds = {
key: float(item.value) if len(item.value) > 0 else None

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.

The Text fields update on every keystroke, so typing 1e3 or -5 runs this on the partial text and raises ValueError. continuous_update=False on vmin and vmax handles partial input. A final invalid value would still raise, so this needs handling too.

[project.optional-dependencies]
test = [
"ipywidgets>=8.1",
"ipympl>=0.9.4",

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.

DetectorExplorer fails with plopp 26.7 (AxesImage.set() got an unexpected keyword argument 'hide_log_buttons'), but only the docs extra has the >=26.10.0 bound. Could test have it too?

Comment on lines +768 to +769
xdim = self.tab_figs[key].view._dims['x']
ydim = self.tab_figs[key].view._dims['y']

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.

nit: ydim, xdim = self._visible_dims gives the same result without reading plopp's private view._dims, since the plotted data is transposed to that order.



class DimSlider(ipw.HBox, ipw.ValueWidget):
def __init__(self, sizes: dict[str, int], dim: str, visible_dims: list[str]):

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.

visible_dims is stored but never read in DimSlider, and _lock is unused here and in ColorControl. Can these be removed?

Comment on lines +334 to +335
data: sc.DataGroup,
figsize=(11, 8),

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.

data also accepts a single DataArray (see the hasattr branch below), which the annotation and docstring do not mention. figsize has no annotation.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

essreduce Issues for essreduce. esssans Issues for esssans.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants