From d6393a8a4cd259e885ab916ee3ab1c9569e675e2 Mon Sep 17 00:00:00 2001 From: Dusten Hubbard Date: Fri, 17 Jul 2026 23:00:17 -0500 Subject: [PATCH 1/2] feat(field): add invert-selection action Proofreading a dense section (for example auto-segmentation output) often means working on one or a few traces while many others crowd the field. An invert-selection action lets the user pick out a working set quickly by selecting the few traces to exclude and then flipping the selection. Invert selection (Ctrl+Shift+I) flips which traces are selected on the current section. Hidden and group-hidden traces are skipped unless show-all traces (A) is active, and locked objects are never selected, matching the existing selection paths. Selected ztrace points and flags are left untouched. Like the other selection operations it is a view convenience and is not itself undoable. The action is wired into the field trace context menu next to Select all and Deselect, registered in the default shortcuts, and listed in the keyboard-shortcuts help dialog. Refs #71 --- .../modules/datatypes/default_settings.py | 1 + PyReconstruct/modules/datatypes/section.py | 32 ++++++++++++++++++- PyReconstruct/modules/gui/dialog/shortcuts.py | 1 + .../modules/gui/main/context_menu_list.py | 1 + .../modules/gui/main/field_widget_2_trace.py | 8 +++++ 5 files changed, 42 insertions(+), 1 deletion(-) diff --git a/PyReconstruct/modules/datatypes/default_settings.py b/PyReconstruct/modules/datatypes/default_settings.py index 3fee2ee7..c34f7486 100644 --- a/PyReconstruct/modules/datatypes/default_settings.py +++ b/PyReconstruct/modules/datatypes/default_settings.py @@ -88,6 +88,7 @@ def get_username() -> str: "homeview_act": "Home", "selectall_act": "Ctrl+A", "deselect_act": "Ctrl+D", + "invertselection_act": "Ctrl+Shift+I", "edittrace_act": "Ctrl+E", "mergetraces_act": "Ctrl+M", "mergeobjects_act": "Ctrl+Shift+M", diff --git a/PyReconstruct/modules/datatypes/section.py b/PyReconstruct/modules/datatypes/section.py index 9f9ee740..56b34127 100644 --- a/PyReconstruct/modules/datatypes/section.py +++ b/PyReconstruct/modules/datatypes/section.py @@ -667,7 +667,37 @@ def selectAllTraces(self): self.deselectAllTraces() for trace in self.tracesAsList(): self.addSelectedTrace(trace) - + + def invertTraceSelection(self, include_hidden=False): + """Invert the trace selection: deselect every selected trace and + select every unselected trace. + + Only traces visible in the field can become selected: hidden and + group-hidden traces are skipped unless include_hidden is True (the + show-all-traces mode). Locked objects are never selected + (addSelectedTrace refuses them). Selected ztrace points and flags are + left untouched. + + (Only meant for GUI use.) + + Params: + include_hidden (bool): True if hidden traces may be selected + """ + selected = set(self.selected_traces) + group_hidden = set(self.traces_group_hide) + + to_select = [] + for trace in self.tracesAsList(): + if trace in selected: + continue + if not include_hidden and (trace.hidden or trace in group_hidden): + continue + to_select.append(trace) + + self.selected_traces : list[Trace] = [] + for trace in to_select: + self.addSelectedTrace(trace) + def hideTraces(self, traces : list = None, hide=True, log_event=True): """Hide traces. diff --git a/PyReconstruct/modules/gui/dialog/shortcuts.py b/PyReconstruct/modules/gui/dialog/shortcuts.py index 8d735f17..72f6365d 100644 --- a/PyReconstruct/modules/gui/dialog/shortcuts.py +++ b/PyReconstruct/modules/gui/dialog/shortcuts.py @@ -160,6 +160,7 @@ def getStaticShortcuts(w : QWidget) -> list[QKeySequence]: "Field Interactions", ("selectall_act", "Select all traces on section"), ("deselect_act", "Deselect all traces on section"), + ("invertselection_act", "Invert selected traces on section"), ("edittrace_act", "Edit attributes of selected trace(s)"), ("mergetraces_act", "Merge selected traces"), ("mergeobjects_act", "Merge attributes of selected traces"), diff --git a/PyReconstruct/modules/gui/main/context_menu_list.py b/PyReconstruct/modules/gui/main/context_menu_list.py index 8fac77c9..50a350d3 100644 --- a/PyReconstruct/modules/gui/main/context_menu_list.py +++ b/PyReconstruct/modules/gui/main/context_menu_list.py @@ -48,6 +48,7 @@ def get_field_menu_list(self): None, ("selectall_act", "Select all traces", self.series, self.field.selectAllTraces), ("deselect_act", "Deselect traces", self.series, self.field.deselectAllTraces), + ("invertselection_act", "Invert selection", self.series, self.field.invertTraceSelection), None, ("delete_act", "Delete", "Del", self.backspace), ] diff --git a/PyReconstruct/modules/gui/main/field_widget_2_trace.py b/PyReconstruct/modules/gui/main/field_widget_2_trace.py index e2edf754..3eff1218 100644 --- a/PyReconstruct/modules/gui/main/field_widget_2_trace.py +++ b/PyReconstruct/modules/gui/main/field_widget_2_trace.py @@ -417,6 +417,14 @@ def selectAllTraces(self): self.section.selectAllTraces() self.generateView(generate_image=False) + def invertTraceSelection(self): + """Invert which traces are selected on the section.""" + # disable if trace layer is hidden + if self.hide_trace_layer: + return + self.section.invertTraceSelection(include_hidden=self.show_all_traces) + self.generateView(generate_image=False) + ############################################################################ ## Interactions only accessible through the field ########################## ############################################################################ From 83f84389c89bc069b7dce05c1291e985d15d4b9b Mon Sep 17 00:00:00 2001 From: Dusten Hubbard Date: Fri, 17 Jul 2026 23:00:36 -0500 Subject: [PATCH 2/2] feat(object list): invert selection and isolate objects volume-wide Proofreading a large auto-seg ROI is sluggish because every object stays live. An object spans many sections, so isolating a working set has to persist as the user pages through the volume, not just on one section. Object list: * Invert selection flips which objects are selected in the list. Object rows are freely selectable, so nothing is excluded; with no active filter this inverts against every object in the series. It lives in a new "Selection" menu and is prepended to the object right-click menu (it is a table selection operation, so it belongs with the list rather than in the shared field object menu). * Hide unselected objects hides every other object throughout the whole series via the existing series-wide Series.hideObjects path, so the isolation persists across sections. It is undoable series-wide (the same machinery as the existing object Hide), and Show all objects is the clear restore. Objects in the complement are hidden regardless of lock state, since locking guards edits and quantification rather than visibility. An empty selection is a no-op, so the series is never fully blanked. Hide unselected objects and Show all objects are field methods, so they are also reachable from the object right-click menu and act on the object(s) owning the field's selected traces. Refs #71 --- .../modules/gui/main/context_menu_list.py | 2 + .../modules/gui/main/field_widget_3_object.py | 45 +++++++++++++ PyReconstruct/modules/gui/table/object.py | 65 ++++++++++++++++++- 3 files changed, 109 insertions(+), 3 deletions(-) diff --git a/PyReconstruct/modules/gui/main/context_menu_list.py b/PyReconstruct/modules/gui/main/context_menu_list.py index 50a350d3..054fc2ff 100644 --- a/PyReconstruct/modules/gui/main/context_menu_list.py +++ b/PyReconstruct/modules/gui/main/context_menu_list.py @@ -95,6 +95,8 @@ def get_context_menu_list_obj(self): None, ("hideobj_act", "Hide", "", self.hideObj), ("unhideobj_act", "Unhide", "", lambda : self.hideObj(False)), + ("hideunselectedobj_act", "Hide unselected objects", "", self.hideUnselectedObjects), + ("showallobj_act", "Show all objects", "", self.unhideAllObjects), None, ("removealltags_act", "Remove all tags", "", self.removeAllTags), None, diff --git a/PyReconstruct/modules/gui/main/field_widget_3_object.py b/PyReconstruct/modules/gui/main/field_widget_3_object.py index f82721e2..8b672c97 100644 --- a/PyReconstruct/modules/gui/main/field_widget_3_object.py +++ b/PyReconstruct/modules/gui/main/field_widget_3_object.py @@ -364,6 +364,51 @@ def hideObj(self, obj_names : list, hide=True): return True + # update_objects=False so the decorator does NOT run its locked-check on the + # selection (the SELECTED objects are the ones being kept, not modified) and + # does not refresh the wrong rows -- both are handled against the complement + # below. + @object_function(update_objects=False, reload_field=True) + def hideUnselectedObjects(self, obj_names : list): + """Isolate the selected object(s): hide every OTHER object throughout the + whole series, so the isolation persists as sections change. + + Objects in the complement are hidden regardless of their locked state -- + locking guards edits and quantification, not visibility. An empty + selection is a no-op (the decorator returns before we get here), so this + can never blank the series. + + Params: + obj_names (list): the objects to keep visible (object-list + selection, or the objects owning the field's selected traces) + Returns: + (bool): True if any object was hidden + """ + keep = set(obj_names) + others = [name for name in self.series.data["objects"] if name not in keep] + if not others: # everything is already selected -> nothing to hide + return False + + self.series.hideObjects(others, True, self.series_states) + self.table_manager.updateObjects(others) + + return True + + def unhideAllObjects(self): + """Show all objects: unhide every object throughout the whole series. + + The clear restore for "Hide unselected objects"; undoable series-wide, + like the object hide itself. + """ + all_names = list(self.series.data["objects"].keys()) + if not all_names: + return + self.mainwindow.saveAllData() + self.series.hideObjects(all_names, False, self.series_states) + self.table_manager.updateObjects(all_names) + self.mainwindow.seriesModified(True) + self.reload() + @object_function(update_objects=False, reload_field=False) def addTo3D(self, obj_names : list): """Generate a 3D view of an object""" diff --git a/PyReconstruct/modules/gui/table/object.py b/PyReconstruct/modules/gui/table/object.py index 3eee2460..63325c75 100644 --- a/PyReconstruct/modules/gui/table/object.py +++ b/PyReconstruct/modules/gui/table/object.py @@ -12,7 +12,7 @@ QPalette, QColor ) -from PySide6.QtCore import Qt +from PySide6.QtCore import Qt, QItemSelection, QItemSelectionModel from .data_table import DataTable from .history import HistoryTableWidget @@ -36,6 +36,23 @@ TextWidget, ) + +def invert_object_rows(row_names : list, selected : set): + """Return the row indices to select when inverting an object selection. + + Every row that is not currently selected becomes selected. Object rows are + freely selectable in the list (no lock restriction), so nothing is excluded + here -- this matches the object list's existing selection behavior. + + Params: + row_names (list): object name at each row, in row order + selected (set): names of the currently selected objects + Returns: + (list): the row indices that should end up selected + """ + return [r for r, name in enumerate(row_names) if name not in selected] + + class ObjectTableWidget(DataTable): def __init__(self, series : Series, mainwindow : QWidget, manager, hidden=False): @@ -130,6 +147,17 @@ def getCall(col_name, opt_name): ("export_act", "Export...", "", self.export), ] }, + { + "attr_name": "selectionmenu", + "text": "Selection", + "opts": + [ + ("invertobjselection_act", "Invert selection", "", self.invertSelection), + None, + ("hideunselectedobj_act1", "Hide unselected objects", "", self.mainwindow.field.hideUnselectedObjects), + ("showallobj_act1", "Show all objects", "", self.mainwindow.field.unhideAllObjects), + ] + }, { "attr_name": "filtermenu", "text": "Filter", @@ -194,8 +222,13 @@ def getCall(col_name, opt_name): # fill in the menu bar object populateMenuBar(self, self.menubar, menubar_list) - # create the right-click menu - context_menu_list = self.mainwindow.field.getObjMenu() + # create the right-click menu -- prepend the object-list-only + # "Invert selection" (a table selection op, so it lives here rather + # than in the shared field object menu) + context_menu_list = [ + ("invertobjselection_act1", "Invert selection", "", self.invertSelection), + None, + ] + self.mainwindow.field.getObjMenu() self.context_menu = QMenu(self) populateMenu(self, self.context_menu, context_menu_list) @@ -575,6 +608,32 @@ def getSelected(self, single=False): else: return obj_names + def invertSelection(self): + """Invert which objects are selected in the list. + + Every object shown in the list that is not currently selected becomes + selected, and vice versa. Operates on the rows currently displayed, so + with no active filter this inverts against every object in the series. + """ + row_count = self.table.rowCount() + if not row_count: + return + + row_names = [self.table.item(r, 0).text() for r in range(row_count)] + selected = set(self.getSelected()) + to_select = invert_object_rows(row_names, selected) + + model = self.table.model() + last_col = self.table.columnCount() - 1 + new_selection = QItemSelection() + for r in to_select: + new_selection.select(model.index(r, 0), model.index(r, last_col)) + + self.table.selectionModel().select( + new_selection, + QItemSelectionModel.ClearAndSelect + ) + def itemChanged(self, item : QTableWidgetItem): """User checked a checkbox.""" # check for curation