Skip to content
Open
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
23 changes: 20 additions & 3 deletions docs/gui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
251 changes: 224 additions & 27 deletions suite2p/gui/gui2p.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -157,6 +160,66 @@ def make_buttons(self):
b0 = classgui.make_buttons(self, b0)
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("<font color='white'>Prob Range:</font>")
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)
prob_widget.setFixedWidth(80)
self.l0.addWidget(prob_widget, b0, 1, 1, 1)
b0 += 1

self.filter_class_label = QLabel("<font color='white'>Class:</font>")
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.setFixedWidth(75)
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("<font color='#a0a0a0'>0 / 0 ROIs</font>")
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 = [
Expand All @@ -175,6 +238,13 @@ 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;")
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):
Expand Down Expand Up @@ -208,27 +278,31 @@ 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:
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:
Expand Down Expand Up @@ -377,23 +451,42 @@ 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]
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
# 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]
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()
Expand All @@ -403,6 +496,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)
Expand All @@ -413,6 +507,34 @@ def update_plot(self):
traces.plot_trace(self)
if self.zoomtocell:
self.zoom_to_cell()

# 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)):
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()
Expand Down Expand Up @@ -654,6 +776,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
Expand Down Expand Up @@ -691,6 +817,77 @@ def zoom_to_cell(self):
self.win.show()
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)

# 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):
"""
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")
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):
"""
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()


def run(statfile=None):
# Always start by initializing Qt (only once per application)
Expand Down
2 changes: 2 additions & 0 deletions suite2p/gui/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading