From dbaca249b93587fd0ed89e4fc2dd678d745d75cf Mon Sep 17 00:00:00 2001 From: AddadyTom Date: Mon, 8 Jun 2026 13:43:27 +0300 Subject: [PATCH 1/6] Implement ROI filtering by range and class with keyboard skipping in GUI --- suite2p/gui/gui2p.py | 197 +++++++++++++++++++++++++++++++++++++------ suite2p/gui/io.py | 2 + suite2p/gui/masks.py | 16 +++- 3 files changed, 186 insertions(+), 29 deletions(-) diff --git a/suite2p/gui/gui2p.py b/suite2p/gui/gui2p.py index 57481565..89ba418c 100644 --- a/suite2p/gui/gui2p.py +++ b/suite2p/gui/gui2p.py @@ -6,7 +6,7 @@ import numpy as np import pyqtgraph as pg from qtpy import QtGui, QtCore -from qtpy.QtWidgets import QMainWindow, QApplication, QWidget, QGridLayout, QCheckBox, QLineEdit, QLabel +from qtpy.QtWidgets import QMainWindow, QApplication, QWidget, QGridLayout, QCheckBox, QLineEdit, QLabel, QHBoxLayout, QComboBox from . import menus, io, merge, views, buttons, classgui, traces, graphics, masks, utils, rungui from .. import run_s2p, default_settings @@ -157,6 +157,62 @@ def make_buttons(self): b0 = classgui.make_buttons(self, b0) b0 += 1 + # --- Human-in-the-Loop Filter Controls --- + self.filter_checkbox = QCheckBox("Filter by Range") + self.filter_checkbox.setStyleSheet("color: white; font-weight: bold;") + self.filter_checkbox.stateChanged.connect(self.filter_changed) + self.l0.addWidget(self.filter_checkbox, b0, 0, 1, 2) + b0 += 1 + + self.filter_label = QLabel("Prob Range:") + self.filter_label.setFont(QtGui.QFont("Arial", 8)) + self.l0.addWidget(self.filter_label, b0, 0, 1, 1) + + prob_widget = QWidget() + prob_layout = QHBoxLayout() + prob_layout.setContentsMargins(0, 0, 0, 0) + prob_layout.setSpacing(2) + prob_widget.setLayout(prob_layout) + + self.filter_min_prob = QLineEdit("0.3") + self.filter_min_prob.setFixedWidth(35) + self.filter_min_prob.setFont(QtGui.QFont("Arial", 8)) + self.filter_min_prob.setAlignment(QtCore.Qt.AlignRight) + self.filter_min_prob.textChanged.connect(self.filter_changed) + + dash_label = QLabel("-") + dash_label.setStyleSheet("color: white;") + dash_label.setFont(QtGui.QFont("Arial", 8)) + + self.filter_max_prob = QLineEdit("0.7") + self.filter_max_prob.setFixedWidth(35) + self.filter_max_prob.setFont(QtGui.QFont("Arial", 8)) + self.filter_max_prob.setAlignment(QtCore.Qt.AlignRight) + self.filter_max_prob.textChanged.connect(self.filter_changed) + + prob_layout.addWidget(self.filter_min_prob) + prob_layout.addWidget(dash_label) + prob_layout.addWidget(self.filter_max_prob) + self.l0.addWidget(prob_widget, b0, 1, 1, 1) + b0 += 1 + + self.filter_class_label = QLabel("Class:") + self.filter_class_label.setFont(QtGui.QFont("Arial", 8)) + self.l0.addWidget(self.filter_class_label, b0, 0, 1, 1) + + self.filter_class_combo = QComboBox() + self.filter_class_combo.addItems(["All", "Cells", "Non-Cells"]) + self.filter_class_combo.setCurrentIndex(0) + self.filter_class_combo.setFont(QtGui.QFont("Arial", 8)) + self.filter_class_combo.currentIndexChanged.connect(self.filter_changed) + self.l0.addWidget(self.filter_class_combo, b0, 1, 1, 1) + b0 += 1 + + self.filter_counter_label = QLabel("0 / 0 ROIs") + self.filter_counter_label.setFont(QtGui.QFont("Arial", 8, QtGui.QFont.Bold)) + self.l0.addWidget(self.filter_counter_label, b0, 0, 1, 2) + b0 += 2 # leave extra row spacing + # ------ CELL STATS / ROI SELECTION -------- # which stats self.stats_to_show = [ @@ -209,26 +265,19 @@ def make_buttons(self): def roi_text(self, state): if QtCore.Qt.CheckState(state) == QtCore.Qt.Checked: - for n in range(len(self.roi_text_labels)): - if self.iscell[n] == 1: - self.p1.addItem(self.roi_text_labels[n]) - else: - self.p2.addItem(self.roi_text_labels[n]) self.roitext = True else: - for n in range(len(self.roi_text_labels)): - if self.iscell[n] == 1: - try: - self.p1.removeItem(self.roi_text_labels[n]) - except: - pass - else: - try: - self.p2.removeItem(self.roi_text_labels[n]) - except: - pass - self.roitext = False + for n in range(len(self.roi_text_labels)): + try: + self.p1.removeItem(self.roi_text_labels[n]) + except: + pass + try: + self.p2.removeItem(self.roi_text_labels[n]) + except: + pass + self.update_plot() def zoom_cell(self, state): if self.loaded: @@ -378,22 +427,37 @@ def keyPressEvent(self, event): self.colorbtns.button(9).press(self, 9) elif event.key() == QtCore.Qt.Key_Left: ctype = self.iscell[self.ichosen] - while -1: - self.ichosen = (self.ichosen - 1) % len(self.stat) - if self.iscell[self.ichosen] is ctype: - break + matching = self.get_matching_rois() + # Only search matching ones of same type (cell vs non-cell) + matching_of_type = [i for i in range(len(self.stat)) if self.iscell[i] == ctype and matching[i]] + if len(matching_of_type) > 0: + idx = self.ichosen + while True: + idx = (idx - 1) % len(self.stat) + if self.iscell[idx] == ctype and matching[idx]: + self.ichosen = idx + break + if idx == self.ichosen: + break self.imerge = [self.ichosen] self.ROI_remove() self.update_plot() elif event.key() == QtCore.Qt.Key_Right: - ##Agus self.ROI_remove() ctype = self.iscell[self.ichosen] - while 1: - self.ichosen = (self.ichosen + 1) % len(self.stat) - if self.iscell[self.ichosen] is ctype: - break + matching = self.get_matching_rois() + # Only search matching ones of same type (cell vs non-cell) + matching_of_type = [i for i in range(len(self.stat)) if self.iscell[i] == ctype and matching[i]] + if len(matching_of_type) > 0: + idx = self.ichosen + while True: + idx = (idx + 1) % len(self.stat) + if self.iscell[idx] == ctype and matching[idx]: + self.ichosen = idx + break + if idx == self.ichosen: + break self.imerge = [self.ichosen] self.update_plot() self.show() @@ -403,6 +467,7 @@ def keyPressEvent(self, event): self.ROI_remove() def update_plot(self): + self.update_filter_ui() if self.ops_plot["color"] == 7: masks.corr_masks(self) masks.plot_colorbar(self) @@ -413,6 +478,33 @@ def update_plot(self): traces.plot_trace(self) if self.zoomtocell: self.zoom_to_cell() + + # Update text labels (ROI numbers) based on filter + if hasattr(self, 'roitext') and self.roitext: + matching = self.get_matching_rois() + for n in range(len(self.roi_text_labels)): + label = self.roi_text_labels[n] + if self.iscell[n] == 1: + if matching[n]: + if label.scene() is None: + self.p1.addItem(label) + else: + if label.scene() is not None: + try: + self.p1.removeItem(label) + except: + pass + else: + if matching[n]: + if label.scene() is None: + self.p2.addItem(label) + else: + if label.scene() is not None: + try: + self.p2.removeItem(label) + except: + pass + self.p1.show() self.p2.show() self.win.show() @@ -691,6 +783,57 @@ def zoom_to_cell(self): self.win.show() self.show() + def get_matching_rois(self): + if not hasattr(self, 'stat') or self.stat is None: + return np.ones(0, dtype=bool) + + # If the filter checkbox is off or the GUI is not fully loaded, + # the feature is disabled (all ROIs match) + if not self.loaded or not hasattr(self, 'filter_checkbox') or not self.filter_checkbox.isChecked(): + return np.ones(len(self.stat), dtype=bool) + + try: + p_min = float(self.filter_min_prob.text()) + except ValueError: + p_min = 0.0 + + try: + p_max = float(self.filter_max_prob.text()) + except ValueError: + p_max = 1.0 + + class_filter = self.filter_class_combo.currentText() + + probs = self.probcell + prob_match = (probs >= p_min) & (probs <= p_max) + + if class_filter == "Cells": + class_match = (self.iscell == 1) + elif class_filter == "Non-Cells": + class_match = (self.iscell == 0) + else: # "All" + class_match = np.ones(len(self.stat), dtype=bool) + + return prob_match & class_match + + def update_filter_ui(self): + if not self.loaded: + if hasattr(self, 'filter_counter_label'): + self.filter_counter_label.setText("0 / 0 ROIs") + return + + matching = self.get_matching_rois() + n_matching = matching.sum() + n_total = len(self.stat) + + if hasattr(self, 'filter_counter_label'): + self.filter_counter_label.setText(f"{n_matching} / {n_total} ROIs") + + def filter_changed(self): + self.update_filter_ui() + if self.loaded: + self.update_plot() + def run(statfile=None): # Always start by initializing Qt (only once per application) diff --git a/suite2p/gui/io.py b/suite2p/gui/io.py index 9cf17b63..e5bea4f3 100644 --- a/suite2p/gui/io.py +++ b/suite2p/gui/io.py @@ -100,6 +100,8 @@ def make_masks_and_enable_buttons(parent): #parent.p2.setXLink(parent.p1) #parent.p2.setYLink(parent.p1) parent.loaded = True + if hasattr(parent, 'update_filter_ui'): + parent.update_filter_ui() parent.mode_change(2) parent.show() # no classifier loaded diff --git a/suite2p/gui/masks.py b/suite2p/gui/masks.py index 3a7fb1f4..c9881ba1 100644 --- a/suite2p/gui/masks.py +++ b/suite2p/gui/masks.py @@ -314,11 +314,23 @@ def draw_masks(parent): #settings, stat, settings_plot, iscell, ichosen): opacity = parent.ops_plot["opacity"] wplot = int(1 - parent.iscell[parent.ichosen]) + + # Get matching ROIs filter + matching = parent.get_matching_rois() if hasattr(parent, 'get_matching_rois') else np.ones(ncells, dtype=bool) + matching_map = np.ones(ncells + 1, dtype=bool) + matching_map[:-1] = matching + matching_map[-1] = True + # reset transparency for i in range(2): + alpha = (opacity[view == 0] * parent.rois["Sroi"][i] * + parent.rois["LamNorm"][i]) + # Mask out non-matching cell pixels + cell_indices = parent.rois["iROI"][i, 0] + alpha[~matching_map[cell_indices]] = 0 + parent.colors["RGB"][i, color, :, :, - 3] = (opacity[view == 0] * parent.rois["Sroi"][i] * - parent.rois["LamNorm"][i]).astype(np.uint8) + 3] = alpha.astype(np.uint8) M = [ np.array(parent.colors["RGB"][0, color]), np.array(parent.colors["RGB"][1, color]) From 5e5c2c2af6347e9bc2f08d3ddfcf3d952e7d9e38 Mon Sep 17 00:00:00 2001 From: AddadyTom Date: Mon, 8 Jun 2026 13:50:08 +0300 Subject: [PATCH 2/6] Add pytest test case for GUI curation filters --- tests/test_gui_filter.py | 74 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 tests/test_gui_filter.py diff --git a/tests/test_gui_filter.py b/tests/test_gui_filter.py new file mode 100644 index 00000000..07096948 --- /dev/null +++ b/tests/test_gui_filter.py @@ -0,0 +1,74 @@ +import os +import numpy as np +import pytest +from qtpy import QtWidgets, QtCore, QtGui + +def test_gui_filter(): + app = QtWidgets.QApplication.instance() + if not app: + app = QtWidgets.QApplication([]) + + from suite2p.gui.gui2p import MainWindow + + statfile = '/mnt/other_ubunthu/mnt/data/1-ordered/Stav1/stat.npy' + # Check if the test file exists before running, otherwise skip + if not os.path.exists(statfile): + pytest.skip(f"Test dataset {statfile} not found") + + gui = MainWindow(statfile=statfile) + app.processEvents() + + # Assert initial counter matches total ROIs (3289) + assert "3289 / 3289 ROIs" in gui.filter_counter_label.text() + + # 1. Enable filter + gui.filter_checkbox.setChecked(True) + app.processEvents() + + # Check matching count + probs = gui.probcell + expected_matching_all = ((probs >= 0.3) & (probs <= 0.7)).sum() + assert f"{expected_matching_all} / 3289" in gui.filter_counter_label.text() + + # 2. Change Class to Cells + gui.filter_class_combo.setCurrentText("Cells") + app.processEvents() + expected_matching_cells = (((probs >= 0.3) & (probs <= 0.7)) & (gui.iscell == 1)).sum() + assert f"{expected_matching_cells} / 3289" in gui.filter_counter_label.text() + + # 3. Change Class to Non-Cells + gui.filter_class_combo.setCurrentText("Non-Cells") + app.processEvents() + expected_matching_noncells = (((probs >= 0.3) & (probs <= 0.7)) & (gui.iscell == 0)).sum() + assert f"{expected_matching_noncells} / 3289" in gui.filter_counter_label.text() + + # 4. Test Keyboard Navigation with active filter + gui.filter_class_combo.setCurrentText("Cells") + gui.filter_min_prob.setText("0.8") + gui.filter_max_prob.setText("0.95") + app.processEvents() + + matching = gui.get_matching_rois() + matching_cells = [i for i in range(len(gui.stat)) if gui.iscell[i] == 1 and matching[i]] + + # Select the first matching cell + gui.ichosen = matching_cells[0] + gui.imerge = [gui.ichosen] + gui.update_plot() + app.processEvents() + + # Simulate Right arrow key press (Key_Right) + event_right = QtGui.QKeyEvent(QtCore.QEvent.KeyPress, QtCore.Qt.Key_Right, QtCore.Qt.NoModifier) + gui.keyPressEvent(event_right) + app.processEvents() + + # Assert that the new chosen cell matches the filter and is cell + assert gui.iscell[gui.ichosen] == 1 + assert matching[gui.ichosen] + + # 5. Disable filter and assert reset + gui.filter_checkbox.setChecked(False) + app.processEvents() + assert "3289 / 3289" in gui.filter_counter_label.text() + + gui.close() From b04c5160fe48c5e5d5475de522a86f3b73f4b21b Mon Sep 17 00:00:00 2001 From: AddadyTom Date: Mon, 8 Jun 2026 13:56:45 +0300 Subject: [PATCH 3/6] Fix left panel width constraints and add classifier probability label --- suite2p/gui/gui2p.py | 15 +++++++++++++++ tests/test_gui_filter.py | 4 ++++ 2 files changed, 19 insertions(+) diff --git a/suite2p/gui/gui2p.py b/suite2p/gui/gui2p.py index 89ba418c..bd9a4761 100644 --- a/suite2p/gui/gui2p.py +++ b/suite2p/gui/gui2p.py @@ -92,6 +92,9 @@ def __init__(self, statfile=None): # --------- MAIN WIDGET LAYOUT --------------------- cwidget = QWidget() self.l0 = QGridLayout() + self.l0.setColumnStretch(0, 0) + self.l0.setColumnStretch(1, 0) + self.l0.setColumnStretch(2, 1) cwidget.setLayout(self.l0) self.setCentralWidget(cwidget) @@ -193,6 +196,7 @@ def make_buttons(self): prob_layout.addWidget(self.filter_min_prob) prob_layout.addWidget(dash_label) prob_layout.addWidget(self.filter_max_prob) + prob_widget.setFixedWidth(80) self.l0.addWidget(prob_widget, b0, 1, 1, 1) b0 += 1 @@ -204,6 +208,7 @@ def make_buttons(self): self.filter_class_combo.addItems(["All", "Cells", "Non-Cells"]) self.filter_class_combo.setCurrentIndex(0) self.filter_class_combo.setFont(QtGui.QFont("Arial", 8)) + self.filter_class_combo.setFixedWidth(75) self.filter_class_combo.currentIndexChanged.connect(self.filter_changed) self.l0.addWidget(self.filter_class_combo, b0, 1, 1, 1) b0 += 1 @@ -231,6 +236,12 @@ def make_buttons(self): self.ROIedit.returnPressed.connect(self.number_chosen) self.l0.addWidget(self.ROIedit, b0, 1, 1, 1) b0 += 1 + self.ROIprob = QLabel(self) + self.ROIprob.setFont(lilfont) + self.ROIprob.setStyleSheet("color: white;") + self.ROIprob.setText("classifier prob: 0.0000") + self.l0.addWidget(self.ROIprob, b0, 0, 1, 2) + b0 += 1 self.ROIstats = [] self.ROIstats.append(qlabel) for k in range(1, len(self.stats_to_show) + 1): @@ -746,6 +757,10 @@ def plot_clicked(self, event): def ichosen_stats(self): n = self.ichosen self.ROIedit.setText(str(self.ichosen)) + if hasattr(self, 'probcell') and self.probcell is not None and len(self.probcell) > n: + self.ROIprob.setText("classifier prob: %2.4f" % (self.probcell[n])) + else: + self.ROIprob.setText("classifier prob: 0.0000") for k in range(1, len(self.stats_to_show) + 1): key = self.stats_to_show[k - 1] ival = self.stat[n][key] if key in self.stat[n] else 0 diff --git a/tests/test_gui_filter.py b/tests/test_gui_filter.py index 07096948..c42977ed 100644 --- a/tests/test_gui_filter.py +++ b/tests/test_gui_filter.py @@ -65,6 +65,10 @@ def test_gui_filter(): # Assert that the new chosen cell matches the filter and is cell assert gui.iscell[gui.ichosen] == 1 assert matching[gui.ichosen] + + # Assert that the classifier prob label displays the correct value + expected_prob_str = "%2.4f" % (gui.probcell[gui.ichosen]) + assert expected_prob_str in gui.ROIprob.text() # 5. Disable filter and assert reset gui.filter_checkbox.setChecked(False) From 6ece64ed1a004c3634c42ed51867e2b4462f27a9 Mon Sep 17 00:00:00 2001 From: AddadyTom Date: Mon, 8 Jun 2026 14:25:00 +0300 Subject: [PATCH 4/6] Split pytest tests into multiple test cases with module-level fixture --- tests/test_gui_filter.py | 58 ++++++++++++++++++++++++++++------------ 1 file changed, 41 insertions(+), 17 deletions(-) diff --git a/tests/test_gui_filter.py b/tests/test_gui_filter.py index c42977ed..4c5dff55 100644 --- a/tests/test_gui_filter.py +++ b/tests/test_gui_filter.py @@ -3,7 +3,8 @@ import pytest from qtpy import QtWidgets, QtCore, QtGui -def test_gui_filter(): +@pytest.fixture(scope="module") +def gui(): app = QtWidgets.QApplication.instance() if not app: app = QtWidgets.QApplication([]) @@ -11,38 +12,61 @@ def test_gui_filter(): from suite2p.gui.gui2p import MainWindow statfile = '/mnt/other_ubunthu/mnt/data/1-ordered/Stav1/stat.npy' - # Check if the test file exists before running, otherwise skip if not os.path.exists(statfile): pytest.skip(f"Test dataset {statfile} not found") - gui = MainWindow(statfile=statfile) + gui_instance = MainWindow(statfile=statfile) app.processEvents() - - # Assert initial counter matches total ROIs (3289) + yield gui_instance + gui_instance.close() + +def test_gui_initial_state(gui): + # Verify the initial counter matches total ROIs (3289) assert "3289 / 3289 ROIs" in gui.filter_counter_label.text() + assert not gui.filter_checkbox.isChecked() + +def test_gui_filter_by_range_all(gui): + app = QtWidgets.QApplication.instance() + # Reset filter state first + gui.filter_class_combo.setCurrentText("All") + gui.filter_min_prob.setText("0.3") + gui.filter_max_prob.setText("0.7") - # 1. Enable filter + # Enable filter gui.filter_checkbox.setChecked(True) app.processEvents() - # Check matching count probs = gui.probcell expected_matching_all = ((probs >= 0.3) & (probs <= 0.7)).sum() assert f"{expected_matching_all} / 3289" in gui.filter_counter_label.text() - - # 2. Change Class to Cells + +def test_gui_filter_by_range_cells(gui): + app = QtWidgets.QApplication.instance() + gui.filter_checkbox.setChecked(True) + gui.filter_min_prob.setText("0.3") + gui.filter_max_prob.setText("0.7") gui.filter_class_combo.setCurrentText("Cells") app.processEvents() + + probs = gui.probcell expected_matching_cells = (((probs >= 0.3) & (probs <= 0.7)) & (gui.iscell == 1)).sum() assert f"{expected_matching_cells} / 3289" in gui.filter_counter_label.text() - - # 3. Change Class to Non-Cells + +def test_gui_filter_by_range_noncells(gui): + app = QtWidgets.QApplication.instance() + gui.filter_checkbox.setChecked(True) + gui.filter_min_prob.setText("0.3") + gui.filter_max_prob.setText("0.7") gui.filter_class_combo.setCurrentText("Non-Cells") app.processEvents() + + probs = gui.probcell expected_matching_noncells = (((probs >= 0.3) & (probs <= 0.7)) & (gui.iscell == 0)).sum() assert f"{expected_matching_noncells} / 3289" in gui.filter_counter_label.text() - - # 4. Test Keyboard Navigation with active filter + +def test_gui_keyboard_navigation(gui): + app = QtWidgets.QApplication.instance() + gui.filter_checkbox.setChecked(True) gui.filter_class_combo.setCurrentText("Cells") gui.filter_min_prob.setText("0.8") gui.filter_max_prob.setText("0.95") @@ -69,10 +93,10 @@ def test_gui_filter(): # Assert that the classifier prob label displays the correct value expected_prob_str = "%2.4f" % (gui.probcell[gui.ichosen]) assert expected_prob_str in gui.ROIprob.text() - - # 5. Disable filter and assert reset + +def test_gui_filter_disable(gui): + app = QtWidgets.QApplication.instance() + # Disable filter and assert reset gui.filter_checkbox.setChecked(False) app.processEvents() assert "3289 / 3289" in gui.filter_counter_label.text() - - gui.close() From 629984b7a039a051b51e3e51061d3eee3d0a303b Mon Sep 17 00:00:00 2001 From: AddadyTom Date: Mon, 15 Jun 2026 23:12:13 +0300 Subject: [PATCH 5/6] Add rich documentation and docstrings for curation filter and probability label --- docs/gui.md | 23 ++++++++++++++++++++--- suite2p/gui/gui2p.py | 30 +++++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/docs/gui.md b/docs/gui.md index f3ae9789..f83bf68a 100644 --- a/docs/gui.md +++ b/docs/gui.md @@ -99,7 +99,7 @@ vice versa this will overwrite the rastermap colors. - O = turn of ROIs in non-ROI view - Q-U = different views (can change saturation with slider) - A-M = different color maps -- Left and right keys = cycle between cells of same panel +- Left and right keys = cycle between cells of same panel (skips non-matching ROIs if the curation filter is active) - Up Key = flip selected cell to other panel - Alt+Enter = merge selected ROIs @@ -160,9 +160,9 @@ probabilities are shown as the colors in the *classifier* view. You can then further manually curate this data (flipping cells left and right depending on your criteria). -### Adding data to a classifier +When an ROI is selected, its exact classifier probability is displayed in the left panel as `classifier prob: X.XXXX`. -You can add this manually curated data to an already built classifier: +### Adding data to a classifier 1. Load a classifier by going to the “Classifier” menu and clicking “Load”. Choose the *default* classifier, or load another classifier @@ -192,6 +192,23 @@ If you want to apply this new classifier to the ROIs category and update the `iscell.npy` file, then click the classifier probability box, enter your threshold, and press enter. +### ROI Curation Filtering + +To streamline the manual curation of large datasets, the GUI provides an interactive, real-time ROI curation filter panel in the left-hand settings sidebar. + +- **Enable Filter**: Check the **filter** checkbox to activate filtering. +- **Probability Range**: Enter minimum and maximum probability thresholds in the two text boxes (e.g., `0.3` and `0.7`). Only ROIs whose classifier probabilities fall within this range will be displayed. This allows you to quickly isolate and review borderline or low-confidence cells (e.g. around `0.5`). +- **Class Filter**: Select from the class dropdown menu: + - **All**: Shows both cells and non-cells matching the probability range. + - **Cells**: Shows only ROIs currently categorized as cells (left-hand side). + - **Non-Cells**: Shows only ROIs currently categorized as non-cells (right-hand side). +- **Matching Counter**: The label below the dropdown updates dynamically to show how many ROIs match your filter criteria out of the total (e.g. `124 / 542 ROIs`). + +When the curation filter is active, all non-matching ROIs are visually hidden from the main view masks, and their number/text labels are removed. + +Furthermore, keyboard navigation respects this active filter: +- **Left and Right Arrow Keys**: Pressing the left or right arrow keys will automatically skip any non-matching ROIs, cycling only through the subset of ROIs that match your filter criteria. This facilitates extremely fast keyboard-driven curation of ambiguous cell classifications. + ## Visualizing activity Go to the “Visualizations” menu and click “Visualize selected cells”. If diff --git a/suite2p/gui/gui2p.py b/suite2p/gui/gui2p.py index bd9a4761..bb589b34 100644 --- a/suite2p/gui/gui2p.py +++ b/suite2p/gui/gui2p.py @@ -161,12 +161,14 @@ def make_buttons(self): b0 += 1 # --- Human-in-the-Loop Filter Controls --- + # A checkbox to toggle the range and class filter status on/off self.filter_checkbox = QCheckBox("Filter by Range") self.filter_checkbox.setStyleSheet("color: white; font-weight: bold;") self.filter_checkbox.stateChanged.connect(self.filter_changed) self.l0.addWidget(self.filter_checkbox, b0, 0, 1, 2) b0 += 1 + # Label for the probability bounds input fields self.filter_label = QLabel("Prob Range:") self.filter_label.setFont(QtGui.QFont("Arial", 8)) self.l0.addWidget(self.filter_label, b0, 0, 1, 1) @@ -236,6 +238,7 @@ def make_buttons(self): self.ROIedit.returnPressed.connect(self.number_chosen) self.l0.addWidget(self.ROIedit, b0, 1, 1, 1) b0 += 1 + # Dedicated label displaying the classifier probability for the selected ROI self.ROIprob = QLabel(self) self.ROIprob.setFont(lilfont) self.ROIprob.setStyleSheet("color: white;") @@ -437,6 +440,8 @@ def keyPressEvent(self, event): self.colorbtns.button(9).setChecked(True) self.colorbtns.button(9).press(self, 9) elif event.key() == QtCore.Qt.Key_Left: + # Navigation left: cycle to previous ROI of same category (cell vs non-cell). + # If the curation filter is active, skip any non-matching ROIs. ctype = self.iscell[self.ichosen] matching = self.get_matching_rois() # Only search matching ones of same type (cell vs non-cell) @@ -455,6 +460,8 @@ def keyPressEvent(self, event): self.update_plot() elif event.key() == QtCore.Qt.Key_Right: + # Navigation right: cycle to next ROI of same category (cell vs non-cell). + # If the curation filter is active, skip any non-matching ROIs. self.ROI_remove() ctype = self.iscell[self.ichosen] matching = self.get_matching_rois() @@ -490,7 +497,8 @@ def update_plot(self): if self.zoomtocell: self.zoom_to_cell() - # Update text labels (ROI numbers) based on filter + # Update text labels (ROI numbers) based on active curation filter. + # This dynamically shows/removes text labels on the plots when filter changes. if hasattr(self, 'roitext') and self.roitext: matching = self.get_matching_rois() for n in range(len(self.roi_text_labels)): @@ -799,6 +807,18 @@ def zoom_to_cell(self): self.show() def get_matching_rois(self): + """ + Computes a boolean mask indicating which ROIs match the current curation filter settings. + + The filter evaluates: + 1. Whether filtering is enabled (Filter by Range checkbox is checked). + 2. Whether the ROI's classifier probability falls within the [min, max] range. + 3. Whether the ROI's category (cell vs. non-cell) matches the selected class filter dropdown. + + Returns: + np.ndarray[bool]: Boolean mask of length `ncells` where True indicates the ROI matches. + Returns all True if filtering is disabled or dataset is not yet loaded. + """ if not hasattr(self, 'stat') or self.stat is None: return np.ones(0, dtype=bool) @@ -832,6 +852,10 @@ def get_matching_rois(self): return prob_match & class_match def update_filter_ui(self): + """ + Updates the label text of `filter_counter_label` with the count of currently matching ROIs + versus the total number of ROIs in the dataset (e.g., "124 / 542 ROIs"). + """ if not self.loaded: if hasattr(self, 'filter_counter_label'): self.filter_counter_label.setText("0 / 0 ROIs") @@ -845,6 +869,10 @@ def update_filter_ui(self): self.filter_counter_label.setText(f"{n_matching} / {n_total} ROIs") def filter_changed(self): + """ + Slot triggered when any filter control (checkbox, min/max prob text boxes, or class dropdown) + is modified. Updates the counter label and triggers a GUI replot to refresh masks and labels. + """ self.update_filter_ui() if self.loaded: self.update_plot() From 1b2a3f556a6de8fc1f096aedd78348524212b834 Mon Sep 17 00:00:00 2001 From: AddadyTom Date: Mon, 15 Jun 2026 23:21:24 +0300 Subject: [PATCH 6/6] Document roi_text function in gui2p.py --- suite2p/gui/gui2p.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/suite2p/gui/gui2p.py b/suite2p/gui/gui2p.py index bb589b34..99675adc 100644 --- a/suite2p/gui/gui2p.py +++ b/suite2p/gui/gui2p.py @@ -278,6 +278,17 @@ def make_buttons(self): return b0 def roi_text(self, state): + """ + Slot triggered when the ROI numbers checkbox is toggled. Toggles the visibility + of text labels (ROI numbers) on the plots. + + When checked: + Sets `self.roitext` to True. The subsequent call to `update_plot()` will + render text labels for matching ROIs under the active curation filter. + When unchecked: + Sets `self.roitext` to False and explicitly removes all text labels from + the plot scenes (p1 and p2). + """ if QtCore.Qt.CheckState(state) == QtCore.Qt.Checked: self.roitext = True else: