diff --git a/hexrdgui/calibration/polar_plot.py b/hexrdgui/calibration/polar_plot.py index 96bf5cae0..f62b51965 100644 --- a/hexrdgui/calibration/polar_plot.py +++ b/hexrdgui/calibration/polar_plot.py @@ -177,6 +177,10 @@ def write_image(self, filename='polar_image.npz'): data[f'border_mask_{name}'] = mask.get_masked_arrays( self.type, ) + elif mask.highlight: + data[f'highlight_mask_{name}'] = mask.get_masked_arrays( + self.type, + ) keep_detectors = HexrdConfig().azimuthal_lineout_detectors if ( diff --git a/hexrdgui/image_canvas.py b/hexrdgui/image_canvas.py index 8b8f40430..df3a9f38e 100644 --- a/hexrdgui/image_canvas.py +++ b/hexrdgui/image_canvas.py @@ -10,7 +10,7 @@ from matplotlib.backends.backend_qtagg import FigureCanvas from matplotlib.figure import Figure from matplotlib.lines import Line2D -from matplotlib.patches import Circle +from matplotlib.patches import Circle, Polygon from matplotlib.ticker import AutoLocator, AutoMinorLocator, FuncFormatter import matplotlib as mpl @@ -122,6 +122,10 @@ def setup_connections(self): HexrdConfig().oscillation_stage_changed.connect( self.oscillation_stage_changed) MaskManager().polar_masks_changed.connect(self.polar_masks_changed) + # Update mask highlights without re-running expensive mask logic + MaskManager().mask_highlights_changed.connect( + self.mask_highlights_changed + ) HexrdConfig().overlay_renamed.connect(self.overlay_renamed) HexrdConfig().azimuthal_options_modified.connect( self.update_azimuthal_integral_plot) @@ -302,8 +306,10 @@ def load_images(self, image_names): self.raw_view_images_dict = computed_images_dict self.clear_mask_boundaries() + self.clear_mask_highlights() for name, axis in self.raw_axes.items(): self.draw_mask_boundaries(axis, name) + self.highlight_masks(axis, name) # This will call self.draw_idle() self.show_saturation() @@ -394,6 +400,10 @@ def blit_artists(self): def overlay_artists(self): return self.blit_artists.setdefault('overlays', {}) + @property + def mask_highlight_artists(self): + return self.blit_artists.setdefault('mask_highlights', {}) + def remove_all_overlay_artists(self): self.blit_manager.remove_artists('overlays') self.blit_manager.artists['overlays'] = {} @@ -1238,6 +1248,7 @@ def render_polar(self): self.axes_images[0].set_data(img) self.update_mask_boundaries(self.axis) + self.update_mask_highlights(self.axis) # Get the "tth" vector angular_grid = self.iviewer.angular_grid @@ -1365,6 +1376,7 @@ def finish_show_stereo(self, iviewer): self.figure.tight_layout() self.update_mask_boundaries(self.axis) + self.update_mask_highlights(self.axis) self.draw_stereo_border() self.update_auto_picked_data() @@ -1566,6 +1578,19 @@ def is_stereo_from_polar(self): self.iviewer.project_from_polar ) + def mask_highlights_changed(self): + if not self.iviewer: + return + + if self.mode == ViewType.raw: + self.clear_mask_highlights() + for det_name, ax in self.raw_axes.items(): + self.highlight_masks(ax, det_name) + return + + if self.mode in (ViewType.polar, ViewType.stereo): + self.update_mask_highlights(self.axis) + def polar_masks_changed(self): skip = ( not self.iviewer or @@ -1576,6 +1601,7 @@ def polar_masks_changed(self): return self.update_mask_boundaries(self.axis) + self.update_mask_highlights(self.axis) self.iviewer.reapply_masks() img = self.scaled_display_images[0] self.axes_images[0].set_data(img) @@ -2182,11 +2208,23 @@ def clear_mask_boundaries(self): self._mask_boundary_artists.clear() - def draw_mask_boundaries(self, axis, det=None): + def update_mask_highlights(self, axis): + # Update is a clear followed by a draw + self.clear_mask_highlights() + self.highlight_masks(axis) + + def clear_mask_highlights(self): + self.remove_all_mask_highlight_artists() + + def get_mask_verts(self, visible_attr, det=None): # Create an instrument once that we will re-use instr = create_view_hedm_instrument() all_verts = [] - for name in MaskManager().visible_boundaries: + options = { + 'boundaries': MaskManager().visible_boundaries, + 'highlights': MaskManager().visible_highlights + } + for name in options[visible_attr]: mask = MaskManager().masks[name] verts = None if self.mode == ViewType.raw: @@ -2251,6 +2289,10 @@ def draw_mask_boundaries(self, axis, det=None): [np.vstack((x, (np.nan, np.nan))) for x in verts] )) + return all_verts + + def draw_mask_boundaries(self, axis, det=None): + all_verts = self.get_mask_verts('boundaries', det) if not all_verts: return @@ -2264,6 +2306,35 @@ def draw_mask_boundaries(self, axis, det=None): **kwargs, ) + def highlight_masks(self, axis, det=None): + all_verts = self.get_mask_verts('highlights', det) + if not all_verts: + return + + kwargs = { + 'facecolor': MaskManager().highlight_color, + 'alpha': MaskManager().highlight_opacity, + 'edgecolor': 'none', + 'fill': True, + } + + highlight_artists = self.mask_highlight_artists.setdefault(det or 'default', []) + + for vert in all_verts: + polygon = Polygon(vert, **kwargs) + polygon.set_animated(True) + axis.add_patch(polygon) + highlight_artists.append(polygon) + + self.blit_manager.update() + + def remove_all_mask_highlight_artists(self): + self.blit_manager.remove_artists('mask_highlights') + self.blit_manager.artists['mask_highlights'] = {} + + def remove_mask_highlight_artists(self, key): + self.blit_manager.remove_artists('mask_highlights', key) + class PolarXAxisTickLocator(AutoLocator): """Subclass the tick locator so we can modify its behavior diff --git a/hexrdgui/image_tab_widget.py b/hexrdgui/image_tab_widget.py index 10557c093..0c451551c 100644 --- a/hexrdgui/image_tab_widget.py +++ b/hexrdgui/image_tab_widget.py @@ -465,7 +465,7 @@ def on_motion_notify_event(self, event): for mask in MaskManager().masks.values(): if ( mask.type == MaskType.threshold or - (not mask.visible and not mask.show_border) + (not mask.visible and not mask.show_border and not mask.highlight) ): continue diff --git a/hexrdgui/masking/mask_border_style_picker.py b/hexrdgui/masking/mask_border_style_picker.py index a5a6becfa..3f7f2d2a7 100644 --- a/hexrdgui/masking/mask_border_style_picker.py +++ b/hexrdgui/masking/mask_border_style_picker.py @@ -7,7 +7,15 @@ class MaskBorderStylePicker(QObject): - def __init__(self, original_color, original_style, original_width, parent=None): + def __init__( + self, + original_color, + original_style, + original_width, + original_highlight, + original_highlight_opacity, + parent=None + ): super().__init__(parent) loader = UiLoader() @@ -15,6 +23,8 @@ def __init__(self, original_color, original_style, original_width, parent=None): self.original_color = original_color self.original_style = original_style self.original_width = original_width + self.original_highlight = original_highlight + self.original_highlight_opacity = original_highlight_opacity self.reset_ui() self.setup_connections() @@ -24,14 +34,18 @@ def reset_ui(self): self.ui.border_color.setStyleSheet('QPushButton {background-color: %s}' % self.original_color) self.ui.border_style.setCurrentText(self.original_style) self.ui.border_size.setValue(self.original_width) + self.ui.highlight_color.setText(self.original_highlight) + self.ui.highlight_color.setStyleSheet('QPushButton {background-color: %s}' % self.original_highlight) + self.ui.opacity.setValue(self.original_highlight_opacity) def exec(self): self.ui.adjustSize() return self.ui.exec() def setup_connections(self): - self.ui.border_color.clicked.connect(self.pick_color) + self.ui.border_color.clicked.connect(lambda: self.pick_color('border')) self.ui.button_box.rejected.connect(self.reject) + self.ui.highlight_color.clicked.connect(lambda: self.pick_color('highlight')) @property def color(self): @@ -45,15 +59,29 @@ def style(self): def width(self): return self.ui.border_size.value() - def pick_color(self): - dialog = QColorDialog(QColor(self.original_color), self.ui) + @property + def highlight(self): + return self.ui.highlight_color.text() + + @property + def opacity(self): + return self.ui.opacity.value() + + def pick_color(self, type): + options = { + 'border': self.original_color, + 'highlight': self.original_highlight, + 'border_ui': self.ui.border_color, + 'highlight_ui': self.ui.highlight_color + } + dialog = QColorDialog(QColor(options[type]), self.ui) if dialog.exec(): color = dialog.selectedColor().name() - self.ui.border_color.setText(color) - self.ui.border_color.setStyleSheet('QPushButton {background-color: %s}' % color) + options[f'{type}_ui'].setText(color) + options[f'{type}_ui'].setStyleSheet('QPushButton {background-color: %s}' % color) def reject(self): self.reset_ui() def result(self): - return self.color, self.style, self.width + return self.color, self.style, self.width, self.highlight, self.opacity diff --git a/hexrdgui/masking/mask_manager.py b/hexrdgui/masking/mask_manager.py index 8fe450713..8071fe9ee 100644 --- a/hexrdgui/masking/mask_manager.py +++ b/hexrdgui/masking/mask_manager.py @@ -30,11 +30,13 @@ def __init__( visible=True, show_border=False, mode=None, - xray_source=None + xray_source=None, + highlight=False, ): self.type = mtype self.visible = visible self.show_border = show_border + self._highlight = highlight self.masked_arrays = None self.masked_arrays_view_mode = ViewType.raw self.creation_view_mode = mode @@ -86,6 +88,16 @@ def update_masked_arrays(self): def serialize(self): pass + @property + @abstractmethod + def highlight(self): + pass + + @highlight.setter + @abstractmethod + def highlight(self, value): + pass + @classmethod def deserialize(cls, data): return cls( @@ -106,9 +118,10 @@ def __init__( visible=True, show_border=False, mode=None, - xray_source=None + xray_source=None, + highlight=False ): - super().__init__(name, mtype, visible, show_border, mode, xray_source) + super().__init__(name, mtype, visible, show_border, mode, xray_source, highlight) self._raw = None @property @@ -120,6 +133,16 @@ def data(self, values): self._raw = values self.invalidate_masked_arrays() + @property + def highlight(self): + return self._highlight + + @highlight.setter + def highlight(self, value): + if self.type == MaskType.powder: + return + self._highlight = value + def update_masked_arrays(self, view=ViewType.raw, instr=None): self.masked_arrays_view_mode = view if view == ViewType.raw: @@ -205,6 +228,15 @@ def data(self, values): self.max_val = values[1] self.invalidate_masked_arrays() + @property + def highlight(self): + return False + + @highlight.setter + def highlight(self, value): + # Threshold masks do not support highlight; ignore assignments + pass + def update_masked_arrays(self, view=ViewType.raw): self.masked_arrays = recompute_raw_threshold_mask() @@ -257,11 +289,16 @@ class MaskManager(QObject, metaclass=QSingleton): """ export_masks_to_file = Signal(dict) + """Emitted when mask highlight states change""" + mask_highlights_changed = Signal() + def __init__(self): super().__init__(None) self.masks = {} self.view_mode = None self.boundary_color = '#000' # Default to black + self.highlight_color = '#FF0' # Default to yellow + self.highlight_opacity = 0.5 self.boundary_style = 'dashed' self.boundary_width = 1 self.setup_connections() @@ -274,6 +311,10 @@ def visible_masks(self): def visible_boundaries(self): return [k for k, v in self.masks.items() if v.show_border] + @property + def visible_highlights(self): + return [k for k, v in self.masks.items() if v.highlight] + @property def threshold_mask(self): for mask in self.masks.values(): @@ -328,6 +369,9 @@ def view_mode_changed(self, mode): self.view_mode = mode self.update_masks_for_active_beam() + def highlights_changed(self): + self.mask_highlights_changed.emit() + def masks_changed(self): if self.view_mode in (ViewType.polar, ViewType.stereo): self.polar_masks_changed.emit() @@ -368,6 +412,8 @@ def write_single_mask(self, name): '__boundary_color': self.boundary_color, '__boundary_style': self.boundary_style, '__boundary_width': self.boundary_width, + '__highlight_color': self.highlight_color, + '__highlight_opacity': self.highlight_opacity, } self.export_masks_to_file.emit(d) @@ -376,6 +422,8 @@ def write_masks(self, h5py_group=None): '__boundary_color': self.boundary_color, '__boundary_style': self.boundary_style, '__boundary_width': self.boundary_width, + '__highlight_color': self.highlight_color, + '__highlight_opacity': self.highlight_opacity, } for name, mask_info in self.masks.items(): d[name] = mask_info.serialize() @@ -395,7 +443,7 @@ def load_masks(self, h5py_group): actual_view_mode = self.view_mode self.view_mode = ViewType.raw for key, data in items.items(): - if key.startswith('__boundary_'): + if key.startswith('__'): setattr(self, key.split('__', 1)[1], data) continue elif data['mtype'] == MaskType.threshold: diff --git a/hexrdgui/masking/mask_manager_dialog.py b/hexrdgui/masking/mask_manager_dialog.py index 6ed59bb7a..5ddb09678 100644 --- a/hexrdgui/masking/mask_manager_dialog.py +++ b/hexrdgui/masking/mask_manager_dialog.py @@ -6,12 +6,12 @@ from operator import attrgetter import re -from PySide6.QtCore import QObject, Qt +from PySide6.QtCore import QObject, Qt, QTimer from PySide6.QtWidgets import ( QComboBox, QDialog, QDialogButtonBox, QFileDialog, QMenu, - QMessageBox, QPushButton, QTreeWidgetItem, QVBoxLayout, QColorDialog + QMessageBox, QTreeWidgetItem, QVBoxLayout ) -from PySide6.QtGui import QCursor, QColor, QFont +from PySide6.QtGui import QCursor, QFont from hexrdgui.constants import ViewType from hexrdgui.utils import block_signals @@ -36,8 +36,8 @@ def __init__(self, parent=None): flags = self.ui.windowFlags() self.ui.setWindowFlags(flags | Qt.Tool) - self.changed_masks = {} self.mask_tree_items = {} + self.selected_masks = [] add_help_url(self.ui.button_box, 'configuration/masking/#managing-masks') @@ -64,13 +64,13 @@ def setup_connections(self): self.ui.show_all_boundaries.clicked.connect(self.show_all_boundaries) MaskManager().export_masks_to_file.connect(self.export_masks_to_file) self.ui.border_style.clicked.connect(self.edit_style) - self.ui.apply_changes.clicked.connect(self.apply_changes) HexrdConfig().active_beam_switched.connect(self.update_collapsed) self.ui.masks_tree.itemSelectionChanged.connect(self.selected_changed) self.ui.presentation_selector.currentTextChanged.connect( self.change_presentation_for_selected) self.ui.export_selected.clicked.connect(self.export_selected) self.ui.remove_selected.clicked.connect(self.remove_selected_masks) + self.ui.finished.connect(self.ui.masks_tree.clearSelection) def create_mode_source_string(self, mode, source): if mode is None: @@ -79,17 +79,19 @@ def create_mode_source_string(self, mode, source): source_str = f' - {source}' if source else '' return f'{mode_str}{source_str}' - def update_presentation_combo(self, item, mask): + def update_presentation_label(self, item, mask): mask_type = MaskManager().masks[mask.name].type - idx = MaskStatus.none + status = [] if mask.name in MaskManager().visible_masks: - idx = MaskStatus.visible + status.append('Visible') if (mask_type == MaskType.region or mask_type == MaskType.polygon or mask_type == MaskType.pinhole): if mask.name in MaskManager().visible_boundaries: - idx += MaskStatus.boundary - self.ui.masks_tree.itemWidget(item, 1).setCurrentIndex(idx) + status.append('Boundary') + status_str = ' + '.join(status) if status else 'None' + item.setText(1, status_str) + item.setTextAlignment(1, Qt.AlignCenter) def create_mode_item(self, mode, source): text = self.create_mode_source_string(mode, source) @@ -114,31 +116,13 @@ def create_mask_item(self, parent_item, mask): parent_item.addChild(mask_item) self.mask_tree_items[mask.name] = mask_item - # Add combo box to select mask presentation - presentation_combo = QComboBox() - presentation_combo.addItem('None') - presentation_combo.addItem('Visible') - mask_type = MaskManager().masks[mask.name].type - if (mask_type == MaskType.region or - mask_type == MaskType.polygon or - mask_type == MaskType.pinhole): - presentation_combo.addItem('Boundary Only') - presentation_combo.addItem('Visible + Boundary') - self.ui.masks_tree.setItemWidget(mask_item, 1, presentation_combo) - self.update_presentation_combo(mask_item, mask) - presentation_combo.currentIndexChanged.connect( - lambda i, k=mask: self.track_mask_presentation_change(i, k)) - - # Add push button to remove mask - pb = QPushButton('Remove Mask') - self.ui.masks_tree.setItemWidget(mask_item, 2, pb) - pb.clicked.connect( - lambda checked, k=mask.name: self.remove_mask_item(k)) + # Add label to indicate current mask presentation + self.update_presentation_label(mask_item, mask) def update_mask_item(self, mask): item = self.mask_tree_items[mask.name] item.setText(0, mask.name) - self.update_presentation_combo(item, mask) + self.update_presentation_label(item, mask) def remove_mask_item(self, name): if name not in MaskManager().mask_names: @@ -221,11 +205,10 @@ def create_tree(self): self.ui.masks_tree.expandAll() self.ui.masks_tree.resizeColumnToContents(0) self.ui.masks_tree.resizeColumnToContents(1) - - def track_mask_presentation_change(self, index, mask): - self.changed_masks[mask.name] = index - if not self.ui.apply_changes.isEnabled(): - self.ui.apply_changes.setEnabled(True) + size_hint = 200 + header = self.ui.masks_tree.header() + header.resizeSection(1, size_hint) + header.resizeSection(0, header.width() - size_hint) def change_mask_presentation(self, index, name): match index: @@ -263,9 +246,6 @@ def update_mask_name(self, item, column): # Store the new name before updating the manager item.setData(0, Qt.UserRole, new_name) MaskManager().update_name(old_name, new_name) - # Update our tracking dictionaries - if old_name in self.changed_masks: - self.changed_masks[new_name] = self.changed_masks.pop(old_name) self.mask_tree_items[new_name] = self.mask_tree_items.pop(old_name) def update_collapsed(self): @@ -380,14 +360,13 @@ def update_presentation_selector(self): for j in range(mode_item.childCount()): mask_item = mode_item.child(j) name = mask_item.text(0) - cb = self.ui.masks_tree.itemWidget(mask_item, 1) - idx = MaskStatus.none + status = [] if name in MaskManager().visible_masks: - idx += MaskStatus.visible + status.append('Visible') if name in MaskManager().visible_boundaries: - idx += MaskStatus.boundary - with block_signals(cb): - cb.setCurrentIndex(idx) + status.append('Boundary') + status_str = ' + '.join(status) if status else 'None' + mask_item.setText(1, status_str) def change_mask_visibility(self, mask_names, visible): for name in mask_names: @@ -421,32 +400,42 @@ def edit_style(self): dialog = MaskBorderStylePicker( MaskManager().boundary_color, MaskManager().boundary_style, - MaskManager().boundary_width + MaskManager().boundary_width, + MaskManager().highlight_color, + MaskManager().highlight_opacity ) if dialog.exec(): - color, style, width = dialog.result() + color, style, width, highlight, opacity = dialog.result() MaskManager().boundary_color = color MaskManager().boundary_style = style MaskManager().boundary_width = width + MaskManager().highlight_color = highlight + MaskManager().highlight_opacity = opacity MaskManager().masks_changed() - def apply_changes(self): - for name, index in self.changed_masks.items(): - self.change_mask_presentation(index, name) - self.changed_masks = {} - self.ui.apply_changes.setEnabled(False) - def selected_changed(self): with block_signals(self.ui.presentation_selector): selected = self.ui.masks_tree.selectedItems() - self.ui.presentation_selector.setEnabled(len(selected) > 1) - self.ui.export_selected.setEnabled(len(selected) > 1) - self.ui.remove_selected.setEnabled(len(selected) > 1) + self.ui.presentation_selector.setEnabled(len(selected) >= 1) + self.ui.export_selected.setEnabled(len(selected) >= 1) + self.ui.remove_selected.setEnabled(len(selected) >= 1) + + # Update highlight states for masks + masks_from_names = [MaskManager().get_mask_by_name(i.text(0)) for i in selected] + for mask in self.selected_masks: + mask.highlight = False + for mask in masks_from_names: + mask.highlight = True + if set(self.selected_masks) != set(masks_from_names): + # Only update the mask highlights if the selected masks have changed + # Debounce the update to avoid re-drawing too often + self.selected_masks = masks_from_names + MaskManager().highlights_changed() + if len(selected) == 0: return boundary_masks = [MaskType.region, MaskType.polygon, MaskType.pinhole] - masks_from_names = [MaskManager().get_mask_by_name(i.text(0)) for i in selected] vis_only = any(mask.type not in boundary_masks for mask in masks_from_names) self.ui.presentation_selector.clear() self.ui.presentation_selector.addItem('None') @@ -456,7 +445,7 @@ def selected_changed(self): self.ui.presentation_selector.addItem('Visible + Boundary') def change_presentation_for_selected(self, text): - if len(self.ui.masks_tree.selectedItems()) <= 1: + if len(self.ui.masks_tree.selectedItems()) < 1: return mask_names = [i.text(0) for i in self.ui.masks_tree.selectedItems()] diff --git a/hexrdgui/resources/ui/mask_border_style_picker.ui b/hexrdgui/resources/ui/mask_border_style_picker.ui index edbddc2fb..f82faa363 100644 --- a/hexrdgui/resources/ui/mask_border_style_picker.ui +++ b/hexrdgui/resources/ui/mask_border_style_picker.ui @@ -1,13 +1,13 @@ - border_style_2 - + border_style_dialog + 0 0 497 - 114 + 185 @@ -109,6 +109,80 @@ + + + + Selected Mask Highlight + + + + + + Color: + + + border_color + + + + + + + + + + false + + + + + + + Opacity + + + border_size + + + + + + + false + + + 2 + + + 0.000000000000000 + + + 1.000000000000000 + + + 0.100000000000000 + + + 0.500000000000000 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + @@ -129,16 +203,18 @@ - border_style + border_color border_style border_size + highlight_color + opacity button_box accepted() - border_style_2 + border_style_dialog accept() @@ -154,7 +230,7 @@ button_box rejected() - border_style_2 + border_style_dialog reject() diff --git a/hexrdgui/resources/ui/mask_manager_dialog.ui b/hexrdgui/resources/ui/mask_manager_dialog.ui index b0ee0f5ce..bde1bdd82 100644 --- a/hexrdgui/resources/ui/mask_manager_dialog.ui +++ b/hexrdgui/resources/ui/mask_manager_dialog.ui @@ -49,52 +49,6 @@ - - - - - 500 - 0 - - - - Qt::CustomContextMenu - - - QAbstractItemView::DoubleClicked - - - true - - - QAbstractItemView::ExtendedSelection - - - QAbstractItemView::SelectRows - - - false - - - - Name - - - - - Presentation - - - AlignCenter - - - - - Remove - - - - @@ -265,6 +219,59 @@ + + + + + 500 + 0 + + + + Qt::CustomContextMenu + + + QAbstractScrollArea::AdjustToContents + + + QAbstractItemView::NoEditTriggers + + + true + + + QAbstractItemView::ExtendedSelection + + + QAbstractItemView::SelectRows + + + false + + + true + + + true + + + + Name + + + AlignCenter + + + + + Presentation + + + AlignCenter + + + +