diff --git a/docs/source/images/views/desktop/agreementmatrix.png b/docs/source/images/views/desktop/agreementmatrix.png new file mode 100644 index 00000000..f02549e0 Binary files /dev/null and b/docs/source/images/views/desktop/agreementmatrix.png differ diff --git a/docs/source/images/views/desktop/compareunitlist.png b/docs/source/images/views/desktop/compareunitlist.png new file mode 100644 index 00000000..428437c0 Binary files /dev/null and b/docs/source/images/views/desktop/compareunitlist.png differ diff --git a/docs/source/images/views/desktop/venn.png b/docs/source/images/views/desktop/venn.png new file mode 100644 index 00000000..78fe6802 Binary files /dev/null and b/docs/source/images/views/desktop/venn.png differ diff --git a/docs/source/images/views/web/agreementmatrix.png b/docs/source/images/views/web/agreementmatrix.png new file mode 100644 index 00000000..6b14a173 Binary files /dev/null and b/docs/source/images/views/web/agreementmatrix.png differ diff --git a/docs/source/images/views/web/compareunitlist.png b/docs/source/images/views/web/compareunitlist.png new file mode 100644 index 00000000..5ab8d8b1 Binary files /dev/null and b/docs/source/images/views/web/compareunitlist.png differ diff --git a/docs/source/images/views/web/venn.png b/docs/source/images/views/web/venn.png new file mode 100644 index 00000000..27550fb9 Binary files /dev/null and b/docs/source/images/views/web/venn.png differ diff --git a/docs/source/launch.rst b/docs/source/launch.rst index 81c913fd..44a27019 100644 --- a/docs/source/launch.rst +++ b/docs/source/launch.rst @@ -106,6 +106,57 @@ The `curation_dict` can be saved inside the folder of the analyzer (for "binary_ Then it is auto-reloaded when the gui is re-opened. +Comparing two sorting outputs +----------------------------- + +To compare the output of two sorters on the same recording, use ``run_mainwindow_comparison`` +with two ``SortingAnalyzer`` computed on that recording. + +.. code-block:: python + + from spikeinterface_gui import run_mainwindow_comparison + + run_mainwindow_comparison( + analyzer1, + analyzer2, + analyzer1_name="kilosort4", + analyzer2_name="tridesclous2", + ) + +The two analyzers are virtually concatenated into a single unit list, so that all the usual +views (probe, waveforms, traces, amplitudes, ...) can display units of both sorters side by +side. Unit ids are made unique across the two: integer ids are offset, and other ids get the +analyzer name as a suffix (``5_kilosort4``). + +On top of that, ``spikeinterface.comparison.compare_two_sorters`` is run and three dedicated +views are available: + +* the **compare unit list** replaces the usual unit list, with one row per pair of matched + units and one row per unit found by only one of the two sorters, +* the **agreement matrix** shows the agreement score of every pair of units, and clicking a + cell selects that pair, +* the **venn** view summarizes how many units are matched and how many are specific to each + sorter, with a slider for the agreement threshold. + +That agreement threshold is shared: moving the slider of the venn view also re-orders the +agreement matrix and re-categorizes the rows of the compare unit list. + +Both modes are supported, so the comparison can also be served as a web app: + +.. code-block:: python + + run_mainwindow_comparison(analyzer1, analyzer2, mode="web") + +Some things are not available in comparison mode: + +* curation, and therefore merging and splitting, +* the views that rely on extensions that cannot be shared between two sortings: + ``template_similarity``, ``correlograms``, ``isi_histograms`` and ``principal_components``. + +Only the recording of the first analyzer is used to display traces, and the two analyzers must +have matching recording attributes for the trace views to be shown at all. + + Open the GUI launcher --------------------- diff --git a/docs/source/views.rst b/docs/source/views.rst index 6a9c26c1..1b849eae 100644 --- a/docs/source/views.rst +++ b/docs/source/views.rst @@ -63,6 +63,7 @@ Controls * **ctrl + arrow up/down** : select next/previous unit and make it visible alone * **press 'ctrl+d'** : delete selected units (if curation=True) * **press 'ctrl+m'** : merge selected units (if curation=True) +* **press 'c'** : clear label of selected units (if curation=True) * **press 'g'** : label selected units as good (if curation=True) * **press 'm'** : label selected units as mua (if curation=True) * **press 'n'** : label selected units as noise (if curation=True) @@ -393,11 +394,12 @@ revert, and export the curation data. Controls ~~~~~~~~ -- **save in analyzer**: Save the current curation state in the analyzer. +- **save in analyzer**/**save data**: Save the current curation state in the analyzer. +If a custom save callback is provided, it will be used instead. - **export/download JSON**: Export the current curation state to a JSON file. - **restore**: Restore the selected unit from the deleted units table. - **unmerge**: Unmerge the selected merges from the merged units table. -- **submit to parent**: Submit the current curation state to the parent window (for use in web applications). +- **unsplit**: Unsplit the selected split groups from the split units table. - **press 'ctrl+r'**: Restore the selected units from the deleted units table. - **press 'ctrl+u'**: Unmerge the selected merges from the merged units table. - **press 'ctrl+x'**: Unsplit the selected split groups from the split units table. @@ -485,6 +487,10 @@ positions and widths. - troughs are negative extrema and are displayed with a downward triangle symbol - peaks are positive extrema and are displayed with an upward triangle symbol +x-axis represents time and is in units of milliseconds. +y-axis represents the electrical signal. The units depend on your preprocessing +steps, but is usually in uV. + Screenshots ~~~~~~~~~~~ @@ -499,3 +505,119 @@ Screenshots - .. image:: images/views/web/maintemplate.png :width: 100% +No help text available. + +*Screenshots not available for this view.* + +Compare Unit List View +---------------------- + +The unit list in comparison mode. One row per pair of matched units, plus one row per unit +that only one of the two sorters found. + +The rows are sorted by decreasing agreement score, so the best matches come first and the +units that only one sorter found (score 0) come last. + +The matching is done at the agreement threshold shared with the other comparison views +(set it with the slider of the Venn view). The table is read only: there is no curation in +comparison mode. + +Settings +~~~~~~~~ +- **matching_mode** : "hungarian" gives a one to one matching, "best_match" simply takes +the best candidate for each unit of the first sorter, so a unit of the second sorter can +appear on several rows. + +Controls +~~~~~~~~ +- **left click on a row** : make the units of this row visible in the other views. + +Screenshots +~~~~~~~~~~~ + +.. list-table:: + :widths: 50 50 + :header-rows: 1 + + * - Desktop (Qt) + - Web (Panel) + * - .. image:: images/views/desktop/compareunitlist.png + :width: 100% + - .. image:: images/views/web/compareunitlist.png + :width: 100% + +Agreement Matrix View +--------------------- + +Agreement scores between the units of the two compared sorting outputs. + +Rows (horizontal axis) are the units of the first analyzer, columns (vertical axis) the +units of the second one. The score is the number of matched spikes divided by the total +number of spikes of both units, so it is 1 for two identical spike trains and 0 when no +spike matches. + +Settings +~~~~~~~~ +- **ordered** : reorder rows and columns so that the best matches are on the diagonal. +- **show_all** : when off, only the currently visible units are displayed. +- **max_labels** : hide the unit id labels when the matrix is bigger than this. + +Controls +~~~~~~~~ +- **left click** : select the pair of units of this cell, and make only those visible. +- **ctrl + left click** : add the pair of units to the visible ones. + +Screenshots +~~~~~~~~~~~ + +.. list-table:: + :widths: 50 50 + :header-rows: 1 + + * - Desktop (Qt) + - Web (Panel) + * - .. image:: images/views/desktop/agreementmatrix.png + :width: 100% + - .. image:: images/views/web/agreementmatrix.png + :width: 100% + +Venn View +--------- + +Venn diagram of the two compared sorting outputs. The area of each disc is proportional to +the number of units of that sorter, and the area of the intersection is proportional to the +number of units matched between the two, so the picture is quantitatively honest. + +The matching is one to one (hungarian) at the agreement threshold set with the slider. +That threshold is shared with the other comparison views: moving it also re-orders the +agreement matrix and re-categorizes the rows of the comparison unit table. + +Settings +~~~~~~~~ +- **num_units_to_select** : how many units of a region a click makes visible. In the +intersection this is a number of pairs, and both units of each pair are selected. + +Controls +~~~~~~~~ +- **slider** : the agreement threshold above which two units are considered matched. +- **left click on a region** : make a random sample of that region visible. Clicking again +draws another sample, which is a quick way to walk through a region. +- **ctrl + left click on a region** : add the sample to the units already visible. + +Note that `max_visible_units` (see the main settings) still caps how many units can be +visible at once. + +Screenshots +~~~~~~~~~~~ + +.. list-table:: + :widths: 50 50 + :header-rows: 1 + + * - Desktop (Qt) + - Web (Panel) + * - .. image:: images/views/desktop/venn.png + :width: 100% + - .. image:: images/views/web/venn.png + :width: 100% + diff --git a/spikeinterface_gui/__init__.py b/spikeinterface_gui/__init__.py index 367d75ca..260c4e1f 100644 --- a/spikeinterface_gui/__init__.py +++ b/spikeinterface_gui/__init__.py @@ -12,5 +12,5 @@ from .version import version as __version__ -from .main import run_mainwindow, run_launcher +from .main import run_mainwindow, run_launcher, run_mainwindow_comparison diff --git a/spikeinterface_gui/agreementmatrixview.py b/spikeinterface_gui/agreementmatrixview.py new file mode 100644 index 00000000..138c6fea --- /dev/null +++ b/spikeinterface_gui/agreementmatrixview.py @@ -0,0 +1,269 @@ +import numpy as np +import matplotlib.cm +import matplotlib.colors + +from .view_base import ViewBase + + +class AgreementMatrixView(ViewBase): + """ + Agreement matrix between the units of the two compared analyzers. + + Rows (x axis) are the units of analyzer1, columns (y axis) the units of analyzer2. + Clicking a cell makes the corresponding pair of units visible. + """ + id = "agreementmatrix" + _supported_backend = ['qt', 'panel'] + _depend_on = ['comparison'] + _settings = [ + {'name': 'colormap', 'type': 'list', 'limits': ['viridis', 'jet', 'gray', 'hot']}, + {'name': 'ordered', 'type': 'bool', 'value': True}, + {'name': 'show_all', 'type': 'bool', 'value': True}, + {'name': 'max_labels', 'type': 'int', 'value': 30}, + ] + + def get_agreement_data(self): + """ + Sub-matrix to display. + + Returns (values, row_unit_ids, col_unit_ids) where the unit ids are the *combined* + ids in the row/column order of the displayed matrix, or (None, None, None) when + there is nothing to show. + """ + controller = self.controller + scores = controller.get_agreement_scores(ordered=self.settings['ordered']) + + row_unit_ids = np.array([controller.get_combined_unit_id(1, u) for u in scores.index]) + col_unit_ids = np.array([controller.get_combined_unit_id(2, u) for u in scores.columns]) + # pandas hands out a read-only view, but both pyqtgraph and bokeh may write into + # the array they are given, so pass them a writable copy + values = np.array(scores.values, dtype='float64') + + if not self.settings['show_all']: + visible_unit_ids = set(controller.get_visible_unit_ids()) + row_mask = np.array([u in visible_unit_ids for u in row_unit_ids], dtype='bool') + col_mask = np.array([u in visible_unit_ids for u in col_unit_ids], dtype='bool') + if not np.any(row_mask) or not np.any(col_mask): + return None, None, None + values = values[row_mask, :][:, col_mask] + row_unit_ids = row_unit_ids[row_mask] + col_unit_ids = col_unit_ids[col_mask] + + if values.size == 0: + return None, None, None + + return values, row_unit_ids, col_unit_ids + + def select_unit_pair_on_click(self, x, y, reset=True): + values, row_unit_ids, col_unit_ids = self.get_agreement_data() + if values is None: + return + + num_rows, num_cols = values.shape + if not ((0 <= x <= num_rows) and (0 <= y <= num_cols)): + return + # clip so that a click exactly on the far edge still selects the last unit + row = min(int(np.floor(x)), num_rows - 1) + col = min(int(np.floor(y)), num_cols - 1) + + if reset: + self.controller.set_all_unit_visibility_off() + self.controller.set_unit_visibility(row_unit_ids[row], True) + self.controller.set_unit_visibility(col_unit_ids[col], True) + self.notify_unit_and_channel_visibility_changed() + self.refresh() + + def get_colormap(self, num_colors=512): + return matplotlib.colormaps[self.settings['colormap']].resampled(num_colors) + + def get_axis_ticks(self, row_unit_ids, col_unit_ids): + """ + Tick positions and labels for both axes, or (None, None) when the matrix is too + big for the labels to be readable. + + The ticks are the unit ids in each analyzer's own namespace: the combined ids + would be far too long here, the axis label says which analyzer it is. + """ + max_labels = self.settings['max_labels'] + if max_labels <= 0 or max(len(row_unit_ids), len(col_unit_ids)) > max_labels: + return None, None + + bottom_ticks = [ + (i + 0.5, f'{self.controller.get_original_unit_id(unit_id)}') + for i, unit_id in enumerate(row_unit_ids) + ] + left_ticks = [ + (i + 0.5, f'{self.controller.get_original_unit_id(unit_id)}') + for i, unit_id in enumerate(col_unit_ids) + ] + return bottom_ticks, left_ticks + + def _qt_on_settings_changed(self): + N = 512 + cmap = self.get_colormap(N) + lut = [] + for i in range(N): + r, g, b, _ = matplotlib.colors.ColorConverter().to_rgba(cmap(i)) + lut.append([r * 255, g * 255, b * 255]) + self.lut = np.array(lut, dtype='uint8') + + self.refresh() + + def _panel_on_settings_changed(self): + N = 512 + cmap = self.get_colormap(N) + self.color_mapper.palette = [matplotlib.colors.rgb2hex(cmap(i)[:3]) for i in range(N)] + + self.refresh() + + ## Qt ## + def _qt_make_layout(self): + from .myqt import QT + import pyqtgraph as pg + from .utils_qt import ViewBoxHandlingClickToPositionWithCtrl + + self.layout = QT.QVBoxLayout() + self.graphicsview = pg.GraphicsView() + self.layout.addWidget(self.graphicsview) + + self.viewBox = ViewBoxHandlingClickToPositionWithCtrl() + self.viewBox.clicked.connect(self._qt_select_pair) + self.viewBox.disableAutoRange() + + self.plot = pg.PlotItem(viewBox=self.viewBox) + self.graphicsview.setCentralItem(self.plot) + self.plot.hideButtons() + + self.image = pg.ImageItem() + self.plot.addItem(self.image) + + # real axes: the tick labels are the unit ids of that analyzer, and the axis + # label says which analyzer it is. The combined ids would be far too long here. + self.plot.showAxis('bottom') + self.plot.showAxis('left') + self.plot.setLabel('bottom', f'{self.controller.analyzer1_name} units') + self.plot.setLabel('left', f'{self.controller.analyzer2_name} units') + + # this builds the lut and refreshes + self.on_settings_changed() + + def _qt_refresh(self): + values, row_unit_ids, col_unit_ids = self.get_agreement_data() + if values is None: + self.image.hide() + return + + # agreement scores are already normalized in [0, 1], no rescaling + self.image.setImage(values, lut=self.lut, levels=[0., 1.]) + self.image.show() + num_rows, num_cols = values.shape + self.plot.setXRange(0, num_rows) + self.plot.setYRange(0, num_cols) + + # one tick per unit, at the middle of its row/column. pyqtgraph drops the ones + # that would overlap, so no manual placement is needed. None gives back the + # automatic numeric ticks. + bottom_ticks, left_ticks = self.get_axis_ticks(row_unit_ids, col_unit_ids) + self.plot.getAxis('bottom').setTicks(None if bottom_ticks is None else [bottom_ticks]) + self.plot.getAxis('left').setTicks(None if left_ticks is None else [left_ticks]) + + def _qt_select_pair(self, x, y, reset): + self.select_unit_pair_on_click(x, y, reset=reset) + + ## panel ## + def _panel_make_layout(self): + import panel as pn + import bokeh.plotting as bpl + from bokeh.models import ColumnDataSource, LinearColorMapper, FixedTicker + from bokeh.events import Tap + from .utils_panel import _bg_color + + self.figure = bpl.figure( + sizing_mode="stretch_both", + tools="reset,wheel_zoom,tap", + background_fill_color=_bg_color, + border_fill_color=_bg_color, + outline_line_color="white", + styles={"flex": "1"}, + ) + self.figure.toolbar.logo = None + self.figure.grid.visible = False + self.figure.xaxis.axis_label = f'{self.controller.analyzer1_name} units' + self.figure.yaxis.axis_label = f'{self.controller.analyzer2_name} units' + + N = 512 + cmap = self.get_colormap(N) + # agreement scores are already normalized, the range is fixed + self.color_mapper = LinearColorMapper( + palette=[matplotlib.colors.rgb2hex(cmap(i)[:3]) for i in range(N)], low=0., high=1. + ) + + self.image_source = ColumnDataSource({"image": [np.zeros((1, 1))], "dw": [1], "dh": [1]}) + self.figure.image( + image="image", x=0, y=0, dw="dw", dh="dh", + color_mapper=self.color_mapper, source=self.image_source, + ) + + self.figure.on_event(Tap, self._panel_on_tap) + + self.layout = pn.Column( + self.figure, + styles={"display": "flex", "flex-direction": "column"}, + sizing_mode="stretch_both", + ) + + def _panel_refresh(self): + from bokeh.models import FixedTicker + + values, row_unit_ids, col_unit_ids = self.get_agreement_data() + if values is None: + self.image_source.data.update({"image": [np.zeros((1, 1))], "dw": [0], "dh": [0]}) + return + + num_rows, num_cols = values.shape + # bokeh reads image[y][x] while pyqtgraph reads image[x][y], so transpose to keep + # analyzer1 on the x axis in both backends + self.image_source.data.update({"image": [values.T], "dw": [num_rows], "dh": [num_cols]}) + + bottom_ticks, left_ticks = self.get_axis_ticks(row_unit_ids, col_unit_ids) + if bottom_ticks is None: + self.figure.xaxis.ticker = FixedTicker(ticks=[]) + self.figure.yaxis.ticker = FixedTicker(ticks=[]) + self.figure.xaxis.major_label_overrides = {} + self.figure.yaxis.major_label_overrides = {} + else: + self.figure.xaxis.ticker = FixedTicker(ticks=[pos for pos, _ in bottom_ticks]) + self.figure.xaxis.major_label_overrides = {pos: label for pos, label in bottom_ticks} + self.figure.yaxis.ticker = FixedTicker(ticks=[pos for pos, _ in left_ticks]) + self.figure.yaxis.major_label_overrides = {pos: label for pos, label in left_ticks} + + self.figure.x_range.start = 0 + self.figure.x_range.end = num_rows + self.figure.y_range.start = 0 + self.figure.y_range.end = num_cols + + def _panel_on_tap(self, event): + if event.x is None or event.y is None: + return + self.select_unit_pair_on_click(event.x, event.y, reset=True) + + +AgreementMatrixView._gui_help_txt = """ +## Agreement Matrix View + +Agreement scores between the units of the two compared sorting outputs. + +Rows (horizontal axis) are the units of the first analyzer, columns (vertical axis) the +units of the second one. The score is the number of matched spikes divided by the total +number of spikes of both units, so it is 1 for two identical spike trains and 0 when no +spike matches. + +### Settings +- **ordered** : reorder rows and columns so that the best matches are on the diagonal. +- **show_all** : when off, only the currently visible units are displayed. +- **max_labels** : hide the unit id labels when the matrix is bigger than this. + +### Controls +- **left click** : select the pair of units of this cell, and make only those visible. +- **ctrl + left click** : add the pair of units to the visible ones. +""" diff --git a/spikeinterface_gui/backend_panel.py b/spikeinterface_gui/backend_panel.py index 9abeaa61..e2c0ece7 100644 --- a/spikeinterface_gui/backend_panel.py +++ b/spikeinterface_gui/backend_panel.py @@ -16,6 +16,7 @@ class SignalNotifier(param.Parameterized): use_times_updated = param.Event() active_view_updated = param.Event() unit_color_changed = param.Event() + agreement_threshold_changed = param.Event() def __init__(self, view=None): param.Parameterized.__init__(self) @@ -50,6 +51,9 @@ def notify_active_view_updated(self): def notify_unit_color_changed(self): self.param.trigger("unit_color_changed") + def notify_agreement_threshold_changed(self): + self.param.trigger("agreement_threshold_changed") + class SignalHandler(param.Parameterized): def __init__(self, controller, parent=None): @@ -72,6 +76,7 @@ def connect_view(self, view): view.notifier.param.watch(self.on_use_times_updated, "use_times_updated") view.notifier.param.watch(self.on_active_view_updated, "active_view_updated") view.notifier.param.watch(self.on_unit_color_changed, "unit_color_changed") + view.notifier.param.watch(self.on_agreement_threshold_changed, "agreement_threshold_changed") def on_spike_selection_changed(self, param): if not self._active: @@ -140,6 +145,15 @@ def on_unit_color_changed(self, param): continue view.on_unit_color_changed() + def on_agreement_threshold_changed(self, param): + if not self._active: + return + for view in self.controller.views: + if param.obj.view == view: + continue + view.on_agreement_threshold_changed() + + param_type_map = { "float": param.Number, "int": param.Integer, diff --git a/spikeinterface_gui/backend_qt.py b/spikeinterface_gui/backend_qt.py index 6e6a7d7c..c13f983d 100644 --- a/spikeinterface_gui/backend_qt.py +++ b/spikeinterface_gui/backend_qt.py @@ -21,6 +21,7 @@ class SignalNotifier(QT.QObject): time_info_updated = QT.pyqtSignal() use_times_updated = QT.pyqtSignal() unit_color_changed = QT.pyqtSignal() + agreement_threshold_changed = QT.pyqtSignal() def __init__(self, parent=None, view=None): QT.QObject.__init__(self, parent=parent) @@ -47,6 +48,9 @@ def notify_use_times_updated(self): def notify_unit_color_changed(self): self.unit_color_changed.emit() + def notify_agreement_threshold_changed(self): + self.agreement_threshold_changed.emit() + # Used by controller to handle/callback signals class SignalHandler(QT.QObject): @@ -69,6 +73,7 @@ def connect_view(self, view): view.notifier.time_info_updated.connect(self.on_time_info_updated) view.notifier.use_times_updated.connect(self.on_use_times_updated) view.notifier.unit_color_changed.connect(self.on_unit_color_changed) + view.notifier.agreement_threshold_changed.connect(self.on_agreement_threshold_changed) def on_spike_selection_changed(self): if not self._active: @@ -134,6 +139,15 @@ def on_unit_color_changed(self): continue view.on_unit_color_changed() + def on_agreement_threshold_changed(self): + if not self._active: + return + for view in self.controller.views: + if view.qt_widget == self.sender().parent(): + # do not refresh it self + continue + view.on_agreement_threshold_changed() + def create_settings(view, parent): view.settings = pg.parametertree.Parameter.create(name="settings", type='group', children=view._settings) diff --git a/spikeinterface_gui/compareunitlistview.py b/spikeinterface_gui/compareunitlistview.py new file mode 100644 index 00000000..700dbc27 --- /dev/null +++ b/spikeinterface_gui/compareunitlistview.py @@ -0,0 +1,311 @@ +import numpy as np + +from .view_base import ViewBase + + +class CompareUnitListView(ViewBase): + """ + View for displaying unit comparison between two analyzers. + Shows matched units, their agreement scores, and spike counts. + """ + id = "compareunitlist" + _supported_backend = ['qt', 'panel'] + _depend_on = ['comparison'] + _settings = [ + {"name": "matching_mode", "type": "list", "limits": ["hungarian", "best_match"]}, + ] + + def get_rows(self): + """ + One row per match pair, plus one row per unmatched unit of either analyzer, + sorted by decreasing agreement score. + + Each row is a dict with the *combined* unit ids (or None when there is no unit on + that side) and the numeric agreement score. + """ + controller = self.controller + + if self.settings['matching_mode'] == 'best_match': + matching_12, _ = controller.get_best_matching() + else: + matching_12, _ = controller.get_matching() + agreement_scores = controller.get_agreement_scores() + + rows = [] + matched_original2 = set() + for original_unit_id1 in matching_12.index: + original_unit_id2 = matching_12[original_unit_id1] + unit_id1 = controller.get_combined_unit_id(1, original_unit_id1) + if controller.is_unmatched(original_unit_id2): + # unmatched unit of analyzer1 + rows.append(dict(unit_id1=unit_id1, unit_id2=None, agreement_score=0.)) + else: + rows.append(dict( + unit_id1=unit_id1, + unit_id2=controller.get_combined_unit_id(2, original_unit_id2), + agreement_score=float(agreement_scores.at[original_unit_id1, original_unit_id2]), + )) + matched_original2.add(original_unit_id2) + + # unmatched units of analyzer2 + for original_unit_id2 in controller.analyzer2.unit_ids: + if original_unit_id2 in matched_original2: + continue + rows.append(dict( + unit_id1=None, + unit_id2=controller.get_combined_unit_id(2, original_unit_id2), + agreement_score=0., + )) + + # best matches first, so both backends show the same order. The sort is stable, + # so the unmatched units keep their own order at the bottom. + rows.sort(key=lambda row: row['agreement_score'], reverse=True) + + return rows + + def on_agreement_threshold_changed(self): + # the matching, and therefore the rows, depend on the threshold + self.refresh() + + ## Qt ## + def _qt_make_layout(self): + from .myqt import QT + + self.layout = QT.QVBoxLayout() + + # Create table widget + self.table = QT.QTableWidget() + self.layout.addWidget(self.table) + + # Setup table + self.table.setSelectionBehavior(QT.QAbstractItemView.SelectRows) + self.table.setSelectionMode(QT.QAbstractItemView.SingleSelection) + self.table.itemSelectionChanged.connect(self._qt_on_selection_changed) + + # Setup table structure + self.table.setColumnCount(3) + self.table.setHorizontalHeaderLabels([ + f'Unit ({self.controller.analyzer1_name})', + f'Unit ({self.controller.analyzer2_name})', + 'Agreement Score', + ]) + self.table.setSortingEnabled(True) + # Sort by Agreement Score column (index 2) by default + self.table.sortItems(2, QT.Qt.DescendingOrder) + + def _qt_make_unit_item(self, unit_id): + """A table item showing the unit id, its spike count and its color""" + from .myqt import QT + + if unit_id is None: + item = QT.QTableWidgetItem('') + item.setFlags(QT.Qt.ItemIsEnabled | QT.Qt.ItemIsSelectable) + item.unit_id = None + return item + + num_spikes = self.controller.num_spikes[unit_id] + item = QT.QTableWidgetItem(f'{unit_id} n={num_spikes}') + item.setData(QT.Qt.ItemDataRole.UserRole, unit_id) + item.setFlags(QT.Qt.ItemIsEnabled | QT.Qt.ItemIsSelectable) + pix = QT.QPixmap(16, 16) + pix.fill(self.get_unit_color(unit_id)) + item.setIcon(QT.QIcon(pix)) + item.unit_id = unit_id + return item + + def _qt_refresh(self): + from .myqt import QT + + rows = self.get_rows() + + # Disable sorting while populating, otherwise rows move under us + self.table.setSortingEnabled(False) + self.table.clearContents() + self.table.setRowCount(len(rows)) + + for i, row in enumerate(rows): + self.table.setItem(i, 0, self._qt_make_unit_item(row['unit_id1'])) + self.table.setItem(i, 1, self._qt_make_unit_item(row['unit_id2'])) + # scores are in [0, 1] and always formatted with 3 decimals, so every string + # has the same width and the lexicographic sort of the column is the numeric one + score_item = QT.QTableWidgetItem(f"{row['agreement_score']:.3f}") + score_item.setFlags(QT.Qt.ItemIsEnabled | QT.Qt.ItemIsSelectable) + self.table.setItem(i, 2, score_item) + + self.table.setSortingEnabled(True) + self.table.resizeColumnsToContents() + self._qt_select_visible_row() + + def _qt_on_selection_changed(self): + """Handle row selection and update unit visibility""" + selected_rows = {item.row() for item in self.table.selectedItems()} + if len(selected_rows) == 0: + return + row_idx = min(selected_rows) + + visible_unit_ids = [] + for col in (0, 1): + item = self.table.item(row_idx, col) + if item is not None and item.unit_id is not None: + visible_unit_ids.append(item.unit_id) + + if len(visible_unit_ids) == 0: + return + + current_visible_units = self.controller.get_visible_unit_ids() + self.controller.set_visible_unit_ids(visible_unit_ids) + if set(current_visible_units) != set(self.controller.get_visible_unit_ids()): + self.notify_unit_and_channel_visibility_changed() + + def _qt_select_visible_row(self): + """Highlight the row holding the currently visible units, without notifying back""" + visible_unit_ids = set(self.controller.get_visible_unit_ids()) + if len(visible_unit_ids) == 0: + return + + for row_idx in range(self.table.rowCount()): + row_unit_ids = set() + for col in (0, 1): + item = self.table.item(row_idx, col) + if item is not None and item.unit_id is not None: + row_unit_ids.add(item.unit_id) + if len(row_unit_ids) > 0 and row_unit_ids <= visible_unit_ids: + self.table.blockSignals(True) + self.table.selectRow(row_idx) + self.table.blockSignals(False) + self.table.scrollToItem(self.table.item(row_idx, 0)) + return + + def _qt_on_unit_visibility_changed(self): + self._qt_select_visible_row() + + ## panel ## + def _panel_make_layout(self): + import panel as pn + + pn.extension("tabulator") + + self._panel_create_table() + + self.layout = pn.Column( + self.table, + sizing_mode="stretch_both", + ) + + def _panel_create_table(self): + import pandas as pd + import matplotlib.colors as mcolors + from .utils_panel import unit_formatter, SelectableTabulator + + rows = self.get_rows() + + def cell(unit_id): + if unit_id is None: + return None + return { + "id": str(unit_id), + "color": mcolors.to_hex(self.controller.get_unit_color(unit_id)), + "n": self.controller.num_spikes[unit_id], + } + + unit1_col = f'Unit ({self.controller.analyzer1_name})' + unit2_col = f'Unit ({self.controller.analyzer2_name})' + df = pd.DataFrame( + data={ + unit1_col: [cell(row['unit_id1']) for row in rows], + unit2_col: [cell(row['unit_id2']) for row in rows], + 'Agreement Score': [row['agreement_score'] for row in rows], + }, + index=list(range(len(rows))), + ) + # keep the combined unit ids out of the view, the selection callback needs them. + # dtype=object is required: a column mixing unit ids and None would be coerced to + # float, turning the unit ids into floats and the None into NaN + df['_unit_id1'] = pd.Series([row['unit_id1'] for row in rows], dtype=object) + df['_unit_id2'] = pd.Series([row['unit_id2'] for row in rows], dtype=object) + + # the comparison table is read only: nothing here can be curated, and an edit + # from the browser would try to patch a read-only array + editors = {col: {'type': 'editable', 'value': False} for col in df.columns} + + self.table = SelectableTabulator( + df, + formatters={unit1_col: unit_formatter, unit2_col: unit_formatter}, + editors=editors, + hidden_columns=['_unit_id1', '_unit_id2'], + sizing_mode="stretch_both", + layout="fit_data", + show_index=False, + selectable=True, + pagination=None, + # SelectableTabulator functions + skip_sort_columns=[unit1_col, unit2_col], + parent_view=self, + conditional_shortcut=self.is_view_active, + on_selection_changed=self._panel_on_selection_changed, + on_only_function=self._panel_on_selection_changed, + ) + + def _panel_on_selection_changed(self): + selected_rows = self.table.selection + if len(selected_rows) == 0: + return + df = self.table.value + row = df.iloc[selected_rows[0]] + + visible_unit_ids = [ + unit_id for unit_id in (row['_unit_id1'], row['_unit_id2']) if unit_id is not None + ] + if len(visible_unit_ids) == 0: + return + + current_visible_units = self.controller.get_visible_unit_ids() + self.controller.set_visible_unit_ids(visible_unit_ids) + if set(current_visible_units) != set(self.controller.get_visible_unit_ids()): + self.notify_unit_and_channel_visibility_changed() + + def _panel_refresh(self): + # the rows depend on the matching, so the whole table is rebuilt + old_panel = self.table.__panel__() + table_index = next(i for i, obj in enumerate(self.layout.objects) if obj is old_panel) + self._panel_create_table() + self.layout[table_index] = self.table + + def _panel_on_unit_visibility_changed(self): + # the rows themselves do not change, only which one is highlighted + visible_unit_ids = set(self.controller.get_visible_unit_ids()) + if len(visible_unit_ids) == 0: + return + df = self.table.value + for row_index in range(len(df)): + row = df.iloc[row_index] + row_unit_ids = { + unit_id for unit_id in (row['_unit_id1'], row['_unit_id2']) if unit_id is not None + } + if len(row_unit_ids) > 0 and row_unit_ids <= visible_unit_ids: + if self.table.selection != [row_index]: + self.table.selection = [row_index] + return + + +CompareUnitListView._gui_help_txt = """ +## Compare Unit List View + +The unit list in comparison mode. One row per pair of matched units, plus one row per unit +that only one of the two sorters found. + +The rows are sorted by decreasing agreement score, so the best matches come first and the +units that only one sorter found (score 0) come last. + +The matching is done at the agreement threshold shared with the other comparison views +(set it with the slider of the Venn view). The table is read only: there is no curation in +comparison mode. + +### Settings +- **matching_mode** : "hungarian" gives a one to one matching, "best_match" simply takes + the best candidate for each unit of the first sorter, so a unit of the second sorter can + appear on several rows. + +### Controls +- **left click on a row** : make the units of this row visible in the other views. +""" diff --git a/spikeinterface_gui/controller.py b/spikeinterface_gui/controller.py index 1c5f1790..54888b92 100644 --- a/spikeinterface_gui/controller.py +++ b/spikeinterface_gui/controller.py @@ -7,7 +7,7 @@ from copy import deepcopy from spikeinterface import compute_sparsity -from spikeinterface.core import get_template_extremum_channel, BaseEvent +from spikeinterface.core import get_template_extremum_channel from spikeinterface.core.sorting_tools import spike_vector_to_indices from spikeinterface.curation import validate_curation_dict from spikeinterface.curation.curation_model import Curation @@ -29,6 +29,7 @@ ) from spikeinterface.widgets.sorting_summary import _default_displayed_unit_properties + class Controller(): @@ -313,25 +314,7 @@ def __init__( self.spikes['rand_selected'][self.random_spikes_indices] = True # self.num_spikes = self.analyzer.sorting.count_num_spikes_per_unit(outputs="dict") - seg_limits = np.searchsorted(self.spikes["segment_index"], np.arange(num_seg + 1)) - self.segment_slices = {segment_index: slice(seg_limits[segment_index], seg_limits[segment_index + 1]) for segment_index in range(num_seg)} - - spike_vector2 = self.analyzer.sorting.to_spike_vector(concatenated=False) - self.final_spike_samples = [segment_spike_vector[-1][0] for segment_spike_vector in spike_vector2] - # this is dict of list because per segment spike_indices[segment_index][unit_id] - spike_indices_abs = spike_vector_to_indices(spike_vector2, unit_ids, absolute_index=True) - spike_indices = spike_vector_to_indices(spike_vector2, unit_ids) - # this is flatten - spike_per_seg = [s.size for s in spike_vector2] - # dict[unit_id] -> all indices for this unit across segments - self._spike_index_by_units = {} - # dict[segment_index][unit_id] -> all indices for this unit for one segment - self._spike_index_by_segment_and_units = spike_indices_abs - for unit_id in unit_ids: - inds = [] - for seg_ind in range(num_seg): - inds.append(spike_indices[seg_ind][unit_id] + int(np.sum(spike_per_seg[:seg_ind]))) - self._spike_index_by_units[unit_id] = np.concatenate(inds) + self._build_spike_indices(num_seg) t1 = time.perf_counter() if verbose: @@ -412,6 +395,37 @@ def __init__( curation_data = Curation(**curation_data).model_dump() self.curation_data = curation_data + def _build_spike_indices(self, num_seg): + """ + Build the per-segment and per-unit spike index bookkeeping from `self.spikes`. + + `self.spikes` must already be filled and sorted by sample_index within each segment. + Sets `segment_slices`, `final_spike_samples`, `_spike_index_by_units` and + `_spike_index_by_segment_and_units`. Shared with ControllerComparison. + """ + seg_limits = np.searchsorted(self.spikes["segment_index"], np.arange(num_seg + 1)) + self.segment_slices = {segment_index: slice(seg_limits[segment_index], seg_limits[segment_index + 1]) for segment_index in range(num_seg)} + + spike_vector2 = [] + for segment_index in range(num_seg): + seg_slice = self.segment_slices[segment_index] + spike_vector2.append(self.spikes[seg_slice]) + self.final_spike_samples = [segment_spike_vector[-1][0] for segment_spike_vector in spike_vector2] + # this is dict of list because per segment spike_indices[segment_index][unit_id] + spike_indices_abs = spike_vector_to_indices(spike_vector2, self.unit_ids, absolute_index=True) + spike_indices = spike_vector_to_indices(spike_vector2, self.unit_ids) + # this is flatten + spike_per_seg = [s.size for s in spike_vector2] + # dict[unit_id] -> all indices for this unit across segments + self._spike_index_by_units = {} + # dict[segment_index][unit_id] -> all indices for this unit for one segment + self._spike_index_by_segment_and_units = spike_indices_abs + for unit_id in self.unit_ids: + inds = [] + for seg_ind in range(num_seg): + inds.append(spike_indices[seg_ind][unit_id] + int(np.sum(spike_per_seg[:seg_ind]))) + self._spike_index_by_units[unit_id] = np.concatenate(inds) + def check_is_view_possible(self, view_name): from .viewlist import get_all_possible_views possible_class_views = get_all_possible_views() @@ -556,7 +570,9 @@ def get_divergent_unit_colors(self, num_entries=20): import glasbey import matplotlib.colors as mcolors - unit_locations = self.analyzer.get_extension("unit_locations").get_data() + # self.unit_positions is the 2D unit locations, already stacked for both + # analyzers in comparison mode + unit_locations = self.unit_positions # lexsort by x and y sorted_inds = np.lexsort((unit_locations[:, 0], unit_locations[:, 1])) @@ -761,9 +777,9 @@ def get_waveform_sweep(self): def get_waveforms_range(self): return np.nanmin(self.templates_average), np.nanmax(self.templates_average) - def get_waveforms(self, unit_id): - wfs = self.waveforms_ext.get_waveforms_one_unit(unit_id, force_dense=False) - if self.analyzer.sparsity is None: + def get_waveforms(self, unit_id, force_dense=False): + wfs = self.waveforms_ext.get_waveforms_one_unit(unit_id, force_dense=force_dense) + if self.analyzer.sparsity is None or force_dense: # dense waveforms chan_inds = np.arange(self.analyzer.get_num_channels(), dtype='int64') else: @@ -1182,3 +1198,5 @@ def remove_category_from_unit(self, unit_id, category): elif lbl.get('labels') is not None and category in lbl.get('labels'): lbl['labels'].pop(category) self.curation_data["manual_labels"][ix] = lbl + + diff --git a/spikeinterface_gui/controllercomparison.py b/spikeinterface_gui/controllercomparison.py new file mode 100644 index 00000000..977add76 --- /dev/null +++ b/spikeinterface_gui/controllercomparison.py @@ -0,0 +1,577 @@ +import time + +import numpy as np +import pandas as pd + + +from spikeinterface import compute_sparsity +from spikeinterface.core import get_template_extremum_channel +from spikeinterface.core.recording_tools import get_rec_attributes, do_recording_attributes_match +from spikeinterface.comparison import compare_two_sorters +from spikeinterface.widgets.utils import make_units_table_from_analyzer +from spikeinterface.widgets.sorting_summary import _default_displayed_unit_properties + +from .controller import Controller, spike_dtype, _default_main_settings + + +# extensions that cannot be shared/concatenated between the two analyzers +_comparison_skip_extensions = ["principal_components", "correlograms", "isi_histograms", "template_similarity"] + + +class ControllerComparison(Controller): + """ + Controller for comparison mode. + + Two SortingAnalyzer are virtually concatenated into a single unit_id namespace so that + all the standard views can be reused. On top of that a `compare_two_sorters` comparison + is computed and exposed through `get_agreement_scores()` / `get_matching()` / + `get_venn_unit_ids()`. + + `Controller.__init__` is deliberately not called: this `__init__` establishes the same + attribute contract (`spikes`, `unit_ids`, `templates_average`, `unit_positions`, ...) from + the two analyzers, and `self.analyzer` is set to `analyzer1` so that every recording/probe + facing method of the base class keeps working (only analyzer1's recording is used, the + channels are shared). + """ + + def __init__( + self, analyzer1=None, analyzer2=None, + analyzer1_name="1", analyzer2_name="2", + backend="qt", parent=None, verbose=False, with_traces=True, + displayed_unit_properties=None, + extra_unit_properties=None, skip_extensions=None, disable_save_settings_button=False, + user_main_settings=None, + ): + self.views = [] + skip_extensions = list(skip_extensions) if skip_extensions is not None else [] + skip_extensions.extend(_comparison_skip_extensions) + self.skip_extensions = sorted(set(skip_extensions)) + + self.backend = backend + self.disable_save_settings_button = disable_save_settings_button + # curation is not possible in comparison mode + self.curation = False + # this is not to have a popup when closing + self.current_curation_saved = True + self.external_data = None + self.events = None + self.save_on_compute = False + + if self.backend == "qt": + from .backend_qt import SignalHandler + self.signal_handler = SignalHandler(self, parent=parent) + + elif self.backend == "panel": + from .backend_panel import SignalHandler + self.signal_handler = SignalHandler(self, parent=parent) + + self.with_traces = with_traces + + self.analyzer1 = analyzer1 + self.analyzer2 = analyzer2 + self.analyzer1_name = analyzer1_name + self.analyzer2_name = analyzer2_name + # the base class methods that touch the recording/probe/format use self.analyzer + self.analyzer = analyzer1 + assert self.analyzer1.get_extension("random_spikes") is not None + assert self.analyzer2.get_extension("random_spikes") is not None + + assert self.analyzer1.return_in_uV == self.analyzer2.return_in_uV + self.return_in_uV = self.analyzer1.return_in_uV + + # check recording attributes match + recording1 = None + recording2 = None + self.use_recordings = False + + try: + recording1 = self.analyzer1.recording + except: + pass + try: + recording2 = self.analyzer2.recording + except: + pass + if recording1 is not None and recording2 is not None: + match, diff = do_recording_attributes_match( + recording1, get_rec_attributes(recording2) + ) + if match: + self.use_recordings = True + + self.verbose = verbose + t0 = time.perf_counter() + + self.main_settings = _default_main_settings.copy() + if user_main_settings is not None: + self.main_settings.update(user_main_settings) + + self.num_channels = self.analyzer1.get_num_channels() + + # the combined unit_id namespace and the mappings back to the original ids. + # computed once: unit_ids is read inside loops all over the views. + self._make_unit_id_mappings() + + # this now private and should be access using function + self._visible_unit_ids = [self.unit_ids[0]] + + # sparsity1 + if self.analyzer1.sparsity is None: + self.external_sparsity1 = compute_sparsity(self.analyzer1, method="radius", radius_um=90.) + self.analyzer_sparsity1 = None + else: + self.external_sparsity1 = None + self.analyzer_sparsity1 = self.analyzer1.sparsity + # sparsity2 + if self.analyzer2.sparsity is None: + self.external_sparsity2 = compute_sparsity(self.analyzer2, method="radius", radius_um=90.) + self.analyzer_sparsity2 = None + else: + self.external_sparsity2 = None + self.analyzer_sparsity2 = self.analyzer2.sparsity + + if verbose: + print("Comparing spike sorting outputs") + t0 = time.perf_counter() + self.comp = compare_two_sorters(self.analyzer1.sorting, self.analyzer2.sorting, + sorting1_name=self.analyzer1_name, sorting2_name=self.analyzer2_name) + if verbose: + print("Comparing took", time.perf_counter() - t0) + # agreement threshold shared by all the comparison views + self._agreement_threshold = self.comp.match_score + self._matching_cache = {} + self._ordered_agreement_scores = None + + # spikes + t0 = time.perf_counter() + if verbose: + print('Gathering all spikes') + self._extremum_channel1 = get_template_extremum_channel( + self.analyzer1, mode="extremum", peak_sign='both', outputs='index') + self._extremum_channel2 = get_template_extremum_channel( + self.analyzer2, mode="extremum", peak_sign='both', outputs='index') + self._extremum_channel = {} + for unit_id in self.unit_ids: + if self.get_analyzer_index(unit_id) == 1: + extremum_channels = self._extremum_channel1 + else: + extremum_channels = self._extremum_channel2 + self._extremum_channel[unit_id] = extremum_channels[self.get_original_unit_id(unit_id)] + + spike_vector1 = self.analyzer1.sorting.to_spike_vector(concatenated=True, extremum_channel_inds=self._extremum_channel1) + spike_vector2 = self.analyzer2.sorting.to_spike_vector(concatenated=True, extremum_channel_inds=self._extremum_channel2) + + random_spikes_indices1 = self.analyzer1.get_extension("random_spikes").get_data() + random_spikes_indices2 = self.analyzer2.get_extension("random_spikes").get_data() + + # align=True is required for np.searchsorted (and therefore trace views) to be fast. + self.spikes = np.zeros(spike_vector1.size + spike_vector2.size, dtype=np.dtype(spike_dtype, align=True)) + self.spikes['sample_index'] = np.concatenate([spike_vector1['sample_index'], spike_vector2['sample_index']]) + self.spikes['unit_index'] = np.concatenate([spike_vector1['unit_index'], spike_vector2['unit_index'] + self._num_units1]) + self.spikes['segment_index'] = np.concatenate([spike_vector1['segment_index'], spike_vector2['segment_index']]) + self.spikes['channel_index'] = np.concatenate([spike_vector1['channel_index'], spike_vector2['channel_index']]) + self.spikes['rand_selected'][:] = False + self.spikes['rand_selected'][random_spikes_indices1] = True + self.spikes['rand_selected'][random_spikes_indices2 + spike_vector1.size] = True + + # sort spikes by segment then sample, so that the base class bookkeeping applies + num_seg = self.analyzer1.get_num_segments() + self.spike_order = np.lexsort((self.spikes['sample_index'], self.spikes['segment_index'])) + self.spikes = self.spikes[self.spike_order] + + self._build_spike_indices(num_seg) + + t1 = time.perf_counter() + if verbose: + print('Gathering all spikes took', t1 - t0) + + if verbose: + print('Loading extensions') + # Mandatory extensions: computation forced + if verbose: + print('\tLoading templates') + temp_ext1 = self.analyzer1.get_extension("templates") + temp_ext2 = self.analyzer2.get_extension("templates") + assert temp_ext1 is not None and temp_ext2 is not None, "Both analyzers should have 'templates' extension" + self.nbefore, self.nafter = temp_ext1.nbefore, temp_ext1.nafter + + self.templates_average = np.vstack([temp_ext1.get_templates(operator='average'), temp_ext2.get_templates(operator='average')]) + + if 'std' in temp_ext1.params['operators'] and 'std' in temp_ext2.params['operators']: + self.templates_std = np.vstack([temp_ext1.get_templates(operator='std'), temp_ext2.get_templates(operator='std')]) + else: + self.templates_std = None + + if verbose: + print('\tLoading unit_locations') + ext1 = self.analyzer1.get_extension('unit_locations') + ext2 = self.analyzer2.get_extension('unit_locations') + assert ext1 is not None and ext2 is not None, "Both analyzers should have 'unit_locations' extension" + self.unit_positions = np.vstack([ext1.get_data()[:, :2], ext2.get_data()[:, :2]]) + + # Optional extensions : can be None or skipped + if verbose: + print('\tLoading noise_levels') + ext1 = self.analyzer1.get_extension('noise_levels') + if ext1 is None and self.has_extension('recording'): + print('Force compute "noise_levels" is needed') + ext1 = self.analyzer1.compute_one_extension('noise_levels') + self.noise_levels = ext1.get_data() if ext1 is not None else None + + if "quality_metrics" in self.skip_extensions: + if self.verbose: + print('\tSkipping quality_metrics') + self.metrics = None + else: + if verbose: + print('\tLoading quality_metrics') + qm_ext1 = self.analyzer1.get_extension('quality_metrics') + qm_ext2 = self.analyzer2.get_extension('quality_metrics') + if qm_ext1 is not None and qm_ext2 is not None: + self.metrics = pd.concat([qm_ext1.get_data(), qm_ext2.get_data()]) + self.metrics.index = self.unit_ids + else: + self.metrics = None + + if "spike_amplitudes" in self.skip_extensions: + if self.verbose: + print('\tSkipping spike_amplitudes') + self.spike_amplitudes = None + else: + if verbose: + print('\tLoading spike_amplitudes') + sa_ext1 = self.analyzer1.get_extension('spike_amplitudes') + sa_ext2 = self.analyzer2.get_extension('spike_amplitudes') + if sa_ext1 is not None and sa_ext2 is not None: + self.spike_amplitudes = np.concatenate([sa_ext1.get_data(), sa_ext2.get_data()])[self.spike_order] + else: + self.spike_amplitudes = None + + if "spike_locations" in self.skip_extensions: + if self.verbose: + print('\tSkipping spike_locations') + self.spike_depths = None + else: + if verbose: + print('\tLoading spike_locations') + sl_ext1 = self.analyzer1.get_extension('spike_locations') + sl_ext2 = self.analyzer2.get_extension('spike_locations') + if sl_ext1 is not None and sl_ext2 is not None: + self.spike_depths = np.concatenate([sl_ext1.get_data()["y"], sl_ext2.get_data()["y"]])[self.spike_order] + else: + self.spike_depths = None + + # Correlograms, ISIs and template_similarity cannot be concatenated: always skipped + self.correlograms, self.correlograms_bins = None, None + self.isi_histograms, self.isi_bins = None, None + self._similarity_by_method = {} + + if "waveforms" in self.skip_extensions: + if self.verbose: + print('\tSkipping waveforms') + self.waveforms_ext1, self.waveforms_ext2 = None, None + else: + if verbose: + print('\tLoading waveforms') + wf_ext1 = self.analyzer1.get_extension('waveforms') + wf_ext2 = self.analyzer2.get_extension('waveforms') + if wf_ext1 is not None and wf_ext2 is not None: + self.waveforms_ext1 = wf_ext1 + self.waveforms_ext2 = wf_ext2 + else: + self.waveforms_ext1, self.waveforms_ext2 = None, None + self.waveforms_ext = self.waveforms_ext1 + + # valid_unit_periods, keyed by the combined unit ids. Only used when both + # analyzers have it, like the other optional per-unit extensions above. + if self.analyzer1.has_extension("valid_unit_periods") and self.analyzer2.has_extension("valid_unit_periods"): + valid_periods1 = self.analyzer1.get_extension("valid_unit_periods").get_data(outputs="by_unit") + valid_periods2 = self.analyzer2.get_extension("valid_unit_periods").get_data(outputs="by_unit") + self.valid_periods = {} + for unit_id in self.unit_ids: + valid_periods = valid_periods1 if self.get_analyzer_index(unit_id) == 1 else valid_periods2 + self.valid_periods[unit_id] = valid_periods[self.get_original_unit_id(unit_id)] + else: + self.valid_periods = None + + # principal_components is always skipped: the two analyzers have unrelated PC spaces + self._pc_projections = None + self._pc_indices = None + self.pc_ext = None + + self._potential_merges = None + + t1 = time.perf_counter() + if verbose: + print('Loading extensions took', t1 - t0) + + t0 = time.perf_counter() + + # some direct attribute + self.num_segments = self.analyzer1.get_num_segments() + self.sampling_frequency = self.analyzer1.sampling_frequency + num_spikes1 = self.analyzer1.sorting.count_num_spikes_per_unit(outputs="dict") + num_spikes2 = self.analyzer2.sorting.count_num_spikes_per_unit(outputs="dict") + self.num_spikes = {} + for unit_id in self.unit_ids: + num_spikes = num_spikes1 if self.get_analyzer_index(unit_id) == 1 else num_spikes2 + self.num_spikes[unit_id] = num_spikes[self.get_original_unit_id(unit_id)] + + # spikeinterface handle colors in matplotlib style tuple values in range (0,1) + self.refresh_colors() + + # at init, we set the visible channels as the sparsity of the first unit + self.visible_channel_inds = np.flatnonzero(self.get_sparsity_mask()[0]) + + self._spike_visible_indices = np.array([], dtype='int64') + self._spike_selected_indices = np.array([], dtype='int64') + self.update_visible_spikes() + + self._traces_cached = {} + + unit_tables = [] + for analyzer in [self.analyzer1, self.analyzer2]: + unit_table = make_units_table_from_analyzer(analyzer) + unit_tables.append(unit_table) + self.units_table = pd.concat(unit_tables, ignore_index=True) + self.units_table.index = self.unit_ids + if displayed_unit_properties is None: + displayed_unit_properties = list(_default_displayed_unit_properties) + if extra_unit_properties is not None: + displayed_unit_properties += list(extra_unit_properties.keys()) + displayed_unit_properties = [v for v in displayed_unit_properties if v in self.units_table.columns] + self.displayed_unit_properties = displayed_unit_properties + + # set default time info + self.update_time_info() + + ## combined unit_id namespace ## + + def _make_unit_id_mappings(self): + """ + Build the combined unit_ids and the mappings between combined and original ids. + + Integer unit ids are offset, other ids are suffixed with the analyzer name. + """ + unit_ids1 = np.asarray(self.analyzer1.unit_ids) + unit_ids2 = np.asarray(self.analyzer2.unit_ids) + self._num_units1 = unit_ids1.size + + if unit_ids1.dtype.kind == "i" and unit_ids2.dtype.kind == "i": + self._unit_ids = np.concatenate((unit_ids1, unit_ids2 + max(unit_ids1) + 1)) + else: + self._unit_ids = np.array( + [f"{unit_id}_{self.analyzer1_name}" for unit_id in unit_ids1] + + [f"{unit_id}_{self.analyzer2_name}" for unit_id in unit_ids2] + ) + + original_unit_ids = list(unit_ids1) + list(unit_ids2) + analyzer_indices = [1] * unit_ids1.size + [2] * unit_ids2.size + self._original_unit_id_by_id = dict(zip(self._unit_ids, original_unit_ids)) + self._analyzer_index_by_id = dict(zip(self._unit_ids, analyzer_indices)) + # (analyzer_index, original_unit_id) -> combined unit_id + self._combined_unit_id_by_original = { + (analyzer_index, original_unit_id): unit_id + for analyzer_index, original_unit_id, unit_id in zip(analyzer_indices, original_unit_ids, self._unit_ids) + } + + @property + def unit_ids(self): + return self._unit_ids + + @property + def unit_ids1(self): + return self._unit_ids[:self._num_units1] + + @property + def unit_ids2(self): + return self._unit_ids[self._num_units1:] + + def get_original_unit_id(self, unit_id): + """Get the unit id in its own analyzer, given a combined unit_id""" + return self._original_unit_id_by_id[unit_id] + + def get_analyzer_index(self, unit_id): + """Get 1 or 2, telling which analyzer a combined unit_id belongs to""" + return self._analyzer_index_by_id[unit_id] + + def get_combined_unit_id(self, analyzer_index, original_unit_id): + """Inverse of get_original_unit_id: (1 or 2, original unit id) -> combined unit_id""" + return self._combined_unit_id_by_original[(analyzer_index, original_unit_id)] + + def get_analyzer_name(self, analyzer_index): + return self.analyzer1_name if analyzer_index == 1 else self.analyzer2_name + + ## comparison zone ## + + @property + def agreement_threshold(self): + return self._agreement_threshold + + def set_agreement_threshold(self, threshold): + """Set the agreement threshold shared by all the comparison views""" + self._agreement_threshold = float(threshold) + + def get_agreement_scores(self, ordered=False): + """ + Agreement scores as a DataFrame indexed by the *original* unit ids of + analyzer1 (rows) and analyzer2 (columns). + + When `ordered` is True the diagonalized ordering of the comparison is returned. + """ + if not ordered: + return self.comp.agreement_scores + if self._ordered_agreement_scores is None: + self._ordered_agreement_scores = self.comp.get_ordered_agreement_scores() + return self._ordered_agreement_scores + + def get_matching(self, threshold=None): + """ + One-to-one (hungarian) matching at the given agreement threshold. + + Returns (match_12, match_21), two pandas Series indexed by the original unit ids. + Unmatched units hold a sentinel that depends on the unit id dtype, use + `is_unmatched()` to test it. + """ + from spikeinterface.comparison.comparisontools import make_hungarian_match + + if threshold is None: + threshold = self._agreement_threshold + threshold = float(threshold) + if threshold not in self._matching_cache: + self._matching_cache[threshold] = make_hungarian_match(self.comp.agreement_scores, threshold) + return self._matching_cache[threshold] + + def get_best_matching(self, threshold=None): + """ + Best (not necessarily one-to-one) matching at the given agreement threshold. + + Returns (best_match_12, best_match_21), same convention as `get_matching`. + """ + from spikeinterface.comparison.comparisontools import make_best_match + + if threshold is None: + threshold = self._agreement_threshold + return make_best_match(self.comp.agreement_scores, float(threshold)) + + @staticmethod + def is_unmatched(original_unit_id): + """ + Test the unmatched sentinel of a match Series. + + spikeinterface uses -1 for integer unit ids and "" for string/object unit ids. + """ + if isinstance(original_unit_id, str): + return original_unit_id == "" + return original_unit_id == -1 + + def get_venn_unit_ids(self, threshold=None): + """ + Split the units in the 3 regions of the comparison Venn diagram. + + Returns a dict with combined unit_ids: + * "matched" : list of (unit_id1, unit_id2) pairs agreeing above the threshold + * "only1" : units of analyzer1 with no match + * "only2" : units of analyzer2 with no match + """ + match_12, _ = self.get_matching(threshold=threshold) + + matched = [] + only1 = [] + matched_original2 = set() + for original_unit_id1 in match_12.index: + original_unit_id2 = match_12[original_unit_id1] + unit_id1 = self.get_combined_unit_id(1, original_unit_id1) + if self.is_unmatched(original_unit_id2): + only1.append(unit_id1) + else: + matched.append((unit_id1, self.get_combined_unit_id(2, original_unit_id2))) + matched_original2.add(original_unit_id2) + + only2 = [ + self.get_combined_unit_id(2, original_unit_id2) + for original_unit_id2 in self.analyzer2.unit_ids + if original_unit_id2 not in matched_original2 + ] + + return dict(matched=matched, only1=only1, only2=only2) + + ## overrides of the single analyzer behavior ## + + def has_extension(self, extension_name): + if extension_name == 'recording': + return self.use_recordings + elif extension_name == 'comparison': + return True + else: + return Controller.has_extension(self, extension_name) + + def get_information_txt(self): + nseg = self.analyzer1.get_num_segments() + nchan = self.analyzer1.get_num_channels() + txt = f"{nchan} channels - {nseg} segments\n" + txt += f"{self.analyzer1_name}: {len(self.unit_ids1)} units - " + txt += f"{self.analyzer2_name}: {len(self.unit_ids2)} units\n" + venn = self.get_venn_unit_ids() + txt += f"{len(venn['matched'])} matched pairs at agreement >= {self.agreement_threshold:.2f}" + return txt + + def get_waveforms(self, unit_id, force_dense=False): + if self.get_analyzer_index(unit_id) == 1: + analyzer, waveforms_ext = self.analyzer1, self.waveforms_ext1 + else: + analyzer, waveforms_ext = self.analyzer2, self.waveforms_ext2 + original_unit_id = self.get_original_unit_id(unit_id) + wfs = waveforms_ext.get_waveforms_one_unit(original_unit_id, force_dense=force_dense) + if analyzer.sparsity is None or force_dense: + # dense waveforms + chan_inds = np.arange(analyzer.get_num_channels(), dtype='int64') + else: + # sparse waveforms + chan_inds = analyzer.sparsity.unit_id_to_channel_indices[original_unit_id] + return wfs, chan_inds + + def get_sparsity_mask(self): + masks = [] + for external_sparsity, analyzer_sparsity in ( + (self.external_sparsity1, self.analyzer_sparsity1), + (self.external_sparsity2, self.analyzer_sparsity2), + ): + masks.append(external_sparsity.mask if external_sparsity is not None else analyzer_sparsity.mask) + return np.vstack(masks) + + def get_all_pcs(self): + # the two analyzers have unrelated PC spaces, they cannot be concatenated + return None, None + + def get_template_upsampling_factor(self): + # template_metrics of the two analyzers are not merged: no upsampling in comparison mode + return 1 + + def get_upsampled_templates(self, unit_id): + # same 3-tuple contract as the base class, without the upsampled part + unit_index = list(self.unit_ids).index(unit_id) + chan_ind = self.get_extremum_channel(unit_id) + template = self.templates_average[unit_index, :, chan_ind] + return template, None, None + + def compute_unit_positions(self, method, method_kwargs): + unit_positions = [] + for analyzer in (self.analyzer1, self.analyzer2): + ext = analyzer.compute_one_extension( + 'unit_locations', save=self.save_on_compute, method=method, **method_kwargs + ) + unit_positions.append(ext.get_data()[:, :2]) + self.unit_positions = np.vstack(unit_positions) + + def compute_similarity(self, method='l1'): + raise NotImplementedError("template_similarity cannot be computed in comparison mode") + + def compute_correlograms(self, window_ms, bin_ms): + raise NotImplementedError("correlograms cannot be computed in comparison mode") + + def compute_isi_histograms(self, window_ms, bin_ms): + raise NotImplementedError("isi_histograms cannot be computed in comparison mode") + + def compute_auto_merge(self, **params): + raise NotImplementedError("auto merge is not available in comparison mode") diff --git a/spikeinterface_gui/layout_presets.py b/spikeinterface_gui/layout_presets.py index 73d3097a..bf71f6c7 100644 --- a/spikeinterface_gui/layout_presets.py +++ b/spikeinterface_gui/layout_presets.py @@ -1,4 +1,5 @@ import json +from copy import deepcopy from spikeinterface_gui.viewlist import get_all_possible_views import numpy as np @@ -51,7 +52,8 @@ def get_layout_description(preset_name, layout=None): else: if preset_name is None: preset_name = 'default' - return _presets[preset_name] + # deepcopy so that a caller mutating the returned dict does not corrupt the preset + return deepcopy(_presets[preset_name]) default_layout = dict( zone1=['curation', 'spikelist'], @@ -104,3 +106,18 @@ def get_layout_description(preset_name, layout=None): _presets['merge_focus'] = merge_focus_layout + +# comparison mode: the unit list is replaced by the comparison table and the +# comparison specific views take the place of similarity/ndscatter. +# merge, curation, correlogram and isi are not available when comparing two analyzers. +comparison_layout = dict( + zone1=['spikelist'], + zone2=['compareunitlist'], + zone3=['trace', 'tracemap', 'spikeamplitude', 'amplitudescalings', 'spikedepth', 'spikerate', 'event'], + zone4=[], + zone5=['probe'], + zone6=['venn', 'agreementmatrix'], + zone7=['waveform', 'waveformheatmap'], + zone8=['metrics', 'maintemplate', 'mainsettings'], +) +_presets['comparison'] = comparison_layout diff --git a/spikeinterface_gui/main.py b/spikeinterface_gui/main.py index 8f28c24d..f2db1407 100644 --- a/spikeinterface_gui/main.py +++ b/spikeinterface_gui/main.py @@ -110,7 +110,6 @@ def run_mainwindow( disable_save_settings_button: bool, default: False If True, disables the "save default settings" button, so that user cannot do this. """ - if mode == "desktop": backend = "qt" elif mode == "web": @@ -260,6 +259,196 @@ def run_launcher(mode="desktop", analyzer_folders=None, root_folder=None, addres else: raise ValueError(f"spikeinterface-gui wrong mode {mode}") + +# curation is off in comparison mode, and these views need extensions that cannot be +# shared between two different sortings +_comparison_incompatible_views = ("merge", "curation", "similarity", "correlogram", "isi") + + +def run_mainwindow_comparison( + analyzer1, + analyzer2, + analyzer1_name="1", + analyzer2_name="2", + mode="desktop", + with_traces=True, + displayed_unit_properties=None, + extra_unit_properties=None, + skip_extensions=None, + recording=None, + start_app=True, + layout_preset=None, + layout=None, + address="localhost", + port=0, + panel_start_server_kwargs=None, + panel_window_servable=True, + verbose=False, + user_settings=None, + disable_save_settings_button=False, +): + """ + Create the main window and start the QT app loop. + + Parameters + ---------- + analyzer1: SortingAnalyzer + The first sorting analyzer object + analyzer2: SortingAnalyzer + The second sorting analyzer object + analyzer1_name: str, default: "1" + The name to display for the first analyzer + analyzer2_name: str, default: "2" + The name to display for the second analyzer + mode: 'desktop' | 'web' + The GUI mode to use. + 'desktop' will run a Qt app. + 'web' will run a Panel app. + with_traces: bool, default: True + If True, traces are displayed + displayed_unit_properties: list | None, default: None + The displayed unit properties in the unit table + extra_unit_properties: list | None, default: None + The extra unit properties in the unit table + skip_extensions: list | None, default: None + The list of extensions to skip when loading the sorting analyzer + recording: RecordingExtractor | None, default: None + The recording object to display traces. This can be used when the + SortingAnalyzer is recordingless. + start_app: bool, default: True + If True, the app loop is started + layout_preset : str | None + The name of the layout preset. None uses the 'comparison' preset. + layout : dict | None + The layout dictionary to use instead of the preset. + address: str, default : "localhost" + For "web" mode only. By default it is "localhost". + Use "auto-ip" to use the real IP address of the machine. + port: int, default: 0 + For "web" mode only. If 0 then the port is automatic. + panel_start_server_kwargs: dict, default: None + For "web" mode only. Additional arguments to pass to the Panel server + - `{'show': True}` to automatically open the browser (default is True). + - `{'dev': True}` to enable development mode (default is False). + - `{'autoreload': True}` to enable autoreload of the server when files change + (default is False). + panel_window_servable: bool, default: True + For "web" mode only. If True, the Panel app is made servable. + This is useful when embedding the GUI in another Panel app. In that case, + the `panel_window_servable` should be set to False. + verbose: bool, default: False + If True, print some information in the console + user_settings: dict, default: None + A dictionary of user settings for each view, which overwrite the default settings. + disable_save_settings_button: bool, default: False + If True, disables the "save default settings" button, so that user cannot do this. + """ + from .controllercomparison import ControllerComparison + + if mode == "desktop": + backend = "qt" + elif mode == "web": + backend = "panel" + else: + raise ValueError(f"spikeinterface-gui wrong mode {mode}") + + # Order of preference for settings is set here: + # 1) User specified settings + # 2) Settings in the config folder + # 3) Default settings of each view + user_main_settings = None + if user_settings is not None: + user_main_settings = user_settings.get('mainsettings') + + if user_settings is None: + sigui_version = spikeinterface_gui.__version__ + config_version_folder = get_config_folder() / sigui_version + settings_file = config_version_folder / "settings.json" + if settings_file.is_file(): + try: + with open(settings_file) as f: + user_settings = json.load(f) + except json.JSONDecodeError as e: + print(f"Config file at {settings_file} is not decodable. Error: {e}") + print("Using default settings.") + + if recording is not None: + analyzer1.set_temporary_recording(recording) + analyzer2.set_temporary_recording(recording) + + if verbose: + import time + t0 = time.perf_counter() + + if layout_preset is None and layout is None: + # the 'comparison' preset already swaps in the comparison views and drops the + # ones that make no sense when comparing two analyzers + layout_preset = "comparison" + + layout_dict = get_layout_description(layout_preset, layout) + if skip_extensions is None: + skip_extensions = find_skippable_extensions(layout_dict) + + # a user given preset/layout can still hold views that cannot work in comparison mode, + # so filter them out whatever the layout. get_layout_description returns a copy, so + # this does not touch the shared presets. + for zone, views_in_zone in layout_dict.items(): + views_in_zone = [ + 'compareunitlist' if view_name == 'unitlist' else view_name + for view_name in views_in_zone + if view_name not in _comparison_incompatible_views + ] + layout_dict[zone] = views_in_zone + + controller = ControllerComparison( + analyzer1, analyzer2, analyzer1_name=analyzer1_name, analyzer2_name=analyzer2_name, + backend=backend, verbose=verbose, + with_traces=with_traces, + displayed_unit_properties=displayed_unit_properties, + extra_unit_properties=extra_unit_properties, + skip_extensions=skip_extensions, + disable_save_settings_button=disable_save_settings_button, + user_main_settings=user_main_settings, + ) + if verbose: + t1 = time.perf_counter() + print('controller init time', t1 - t0) + + if backend == "qt": + from spikeinterface_gui.myqt import QT, mkQApp + from spikeinterface_gui.backend_qt import QtMainWindow + + # Suppress a known pyqtgraph warning + warnings.filterwarnings("ignore", category=RuntimeWarning, module="pyqtgraph") + warnings.filterwarnings('ignore', category=UserWarning, message=".*QObject::connect.*") + + app = mkQApp() + + win = QtMainWindow(controller, layout_dict=layout_dict, user_settings=user_settings) + win.setWindowTitle('SpikeInterface GUI') + # Set window icon + icon_file = Path(__file__).absolute().parent / 'img' / 'si.png' + if icon_file.exists(): + app.setWindowIcon(QT.QIcon(str(icon_file))) + win.show() + if start_app: + app.exec() + + elif backend == "panel": + from .backend_panel import PanelMainWindow, start_server + win = PanelMainWindow(controller, layout_dict=layout_dict, user_settings=user_settings) + + if start_app or panel_window_servable: + win.main_layout.servable(title='SpikeInterface GUI') + + if start_app: + panel_start_server_kwargs = panel_start_server_kwargs or {} + _ = start_server(win, address=address, port=port, **panel_start_server_kwargs) + + return win + + + def check_folder_is_analyzer(folder): """ Check if the given folder is a valid SortingAnalyzer folder. diff --git a/spikeinterface_gui/similarityview.py b/spikeinterface_gui/similarityview.py index 00a90c68..a4a81aad 100644 --- a/spikeinterface_gui/similarityview.py +++ b/spikeinterface_gui/similarityview.py @@ -44,19 +44,23 @@ def select_unit_pair_on_click(self, x, y, reset=True): unit_ids = self.controller.unit_ids if self.settings['show_all']: - visible_ids = unit_ids + displayed_ids = unit_ids else: - visible_ids = self.get_visible_unit_ids() - - n = len(visible_ids) - + # same mask, and therefore the same order, as the sub-matrix drawn by + # get_similarity_data(). get_visible_unit_ids() is in selection order, + # which would map the click to the wrong units. + displayed_ids = unit_ids[self.controller.get_units_visibility_mask()] + + n = len(displayed_ids) + inside = (0 <= x <= n) and (0 <= y <= n) if not inside: return - - unit_id0 = unit_ids[int(np.floor(x))] - unit_id1 = unit_ids[int(np.floor(y))] + + # clip so that a click exactly on the far edge still selects the last unit + unit_id0 = displayed_ids[min(int(np.floor(x)), n - 1)] + unit_id1 = displayed_ids[min(int(np.floor(y)), n - 1)] if reset: self.controller.set_all_unit_visibility_off() diff --git a/spikeinterface_gui/tests/debug_views.py b/spikeinterface_gui/tests/debug_views.py index 10483e44..e53ab147 100644 --- a/spikeinterface_gui/tests/debug_views.py +++ b/spikeinterface_gui/tests/debug_views.py @@ -1,7 +1,10 @@ import spikeinterface_gui as sigui -from spikeinterface_gui.tests.testingtools import clean_all, make_analyzer_folder, make_curation_dict +from spikeinterface_gui.tests.testingtools import ( + clean_all, make_analyzer_folder, make_comparison_analyzer_folders, make_curation_dict +) from spikeinterface_gui.controller import Controller +from spikeinterface_gui.controllercomparison import ControllerComparison from spikeinterface_gui.myqt import mkQApp from spikeinterface_gui.viewlist import get_all_possible_views from spikeinterface_gui.backend_qt import ViewWidget @@ -17,29 +20,51 @@ # test_folder = Path(__file__).parents[2] / 'my_dataset_big' # test_folder = Path(__file__).parents[2] / 'my_dataset_multiprobe' +# for the comparison views, see make_comparison_analyzer_folders() +comparison_test_folder = Path(__file__).parents[2] / 'my_dataset_comparison_small' -def debug_one_view(): - app = mkQApp() +def make_controller(): analyzer = si.load_sorting_analyzer(test_folder / "sorting_analyzer", load_extensions=False) - + curation_dict = make_curation_dict(analyzer) # curation_dict = None curation = curation_dict is not None - + controller = Controller(analyzer, verbose=True, curation=curation, curation_data=curation_dict, skip_extensions=['principal_components'], ) - + controller.set_visible_unit_ids(analyzer.unit_ids[:2]) - - # view_class = possible_class_views['unitlist'] - # view_class = possible_class_views['mainsettings'] - # view_class = possible_class_views['spikeamplitude'] + return controller + + +def make_comparison_controller(): + """A ControllerComparison, to debug the comparison views (compareunitlist, agreementmatrix, venn)""" + if not comparison_test_folder.is_dir(): + make_comparison_analyzer_folders(comparison_test_folder, case="small", unit_dtype="int") + + analyzer1 = si.load_sorting_analyzer(comparison_test_folder / "sorting_analyzer_1") + analyzer2 = si.load_sorting_analyzer(comparison_test_folder / "sorting_analyzer_2") + + controller = ControllerComparison(analyzer1, analyzer2, analyzer1_name="sorter1", analyzer2_name="sorter2", + verbose=True) + controller.set_visible_unit_ids(controller.unit_ids[:2]) + return controller + + +def debug_one_view(view_name, comparison=False): + + app = mkQApp() + + if comparison: + controller = make_comparison_controller() + else: + controller = make_controller() + possible_class_views = get_all_possible_views() - # view_class = possible_class_views['metrics'] - view_class = possible_class_views[''] + view_class = possible_class_views[view_name] widget = ViewWidget(view_class) view = view_class(controller=controller, parent=widget, backend='qt') widget.set_view(view) @@ -50,4 +75,12 @@ def debug_one_view(): if __name__ == '__main__': - debug_one_view() + # debug_one_view('unitlist') + # debug_one_view('mainsettings') + # debug_one_view('spikeamplitude') + # debug_one_view('metrics') + + # the comparison only views + debug_one_view('venn', comparison=True) + # debug_one_view('agreementmatrix', comparison=True) + # debug_one_view('compareunitlist', comparison=True) diff --git a/spikeinterface_gui/tests/test_mainwindow_comparison_panel.py b/spikeinterface_gui/tests/test_mainwindow_comparison_panel.py new file mode 100644 index 00000000..e8a85ded --- /dev/null +++ b/spikeinterface_gui/tests/test_mainwindow_comparison_panel.py @@ -0,0 +1,60 @@ +from argparse import ArgumentParser +from pathlib import Path + +from spikeinterface import load_sorting_analyzer + +from spikeinterface_gui import run_mainwindow_comparison + +from spikeinterface_gui.tests.testingtools import clean_all, make_comparison_analyzer_folders + + +test_folder = Path(__file__).parents[2] / "my_dataset_comparison_small" + + +def setup_module(): + case = test_folder.stem.split('_')[-1] + make_comparison_analyzer_folders(test_folder, case=case, unit_dtype="int") + + +def teardown_module(): + clean_all(test_folder) + + +def test_mainwindow_comparison(start_app=False, verbose=True, port=0): + + analyzer1 = load_sorting_analyzer(test_folder / "sorting_analyzer_1") + analyzer2 = load_sorting_analyzer(test_folder / "sorting_analyzer_2") + + print(analyzer1) + print(analyzer2) + + win = run_mainwindow_comparison( + analyzer1, + analyzer2, + analyzer1_name="sorter1", + analyzer2_name="sorter2", + mode="web", + start_app=start_app, + verbose=verbose, + port=port, + ) + + return win + + +parser = ArgumentParser() +parser.add_argument('--dataset', default="small", help='Path to the dataset folder') + +if __name__ == '__main__': + args = parser.parse_args() + if args.dataset is not None: + test_folder = Path(__file__).parents[2] / f"my_dataset_comparison_{args.dataset}" + + if not test_folder.is_dir(): + setup_module() + + win = test_mainwindow_comparison(start_app=True, verbose=True, port=0) + +# TO RUN with panel serve: +# win = test_mainwindow_comparison(start_app=False, verbose=True) +# >>> panel serve test_mainwindow_comparison_panel.py --autoreload diff --git a/spikeinterface_gui/tests/test_mainwindow_comparison_qt.py b/spikeinterface_gui/tests/test_mainwindow_comparison_qt.py new file mode 100644 index 00000000..279881b9 --- /dev/null +++ b/spikeinterface_gui/tests/test_mainwindow_comparison_qt.py @@ -0,0 +1,55 @@ +from argparse import ArgumentParser +from pathlib import Path + +from spikeinterface import load_sorting_analyzer + +from spikeinterface_gui import run_mainwindow_comparison + +from spikeinterface_gui.tests.testingtools import clean_all, make_comparison_analyzer_folders + + +test_folder = Path(__file__).parents[2] / "my_dataset_comparison_small" + + +def setup_module(): + case = test_folder.stem.split('_')[-1] + make_comparison_analyzer_folders(test_folder, case=case, unit_dtype="int") + + +def teardown_module(): + clean_all(test_folder) + + +def test_mainwindow_comparison(start_app=False, verbose=True): + + analyzer1 = load_sorting_analyzer(test_folder / "sorting_analyzer_1") + analyzer2 = load_sorting_analyzer(test_folder / "sorting_analyzer_2") + + print(analyzer1) + print(analyzer2) + + win = run_mainwindow_comparison( + analyzer1, + analyzer2, + analyzer1_name="sorter1", + analyzer2_name="sorter2", + mode="desktop", + start_app=start_app, + verbose=verbose, + ) + + return win + + +parser = ArgumentParser() +parser.add_argument('--dataset', default="small", help='Path to the dataset folder') + +if __name__ == '__main__': + args = parser.parse_args() + if args.dataset is not None: + test_folder = Path(__file__).parents[2] / f"my_dataset_comparison_{args.dataset}" + + if not test_folder.is_dir(): + setup_module() + + win = test_mainwindow_comparison(start_app=True, verbose=True) diff --git a/spikeinterface_gui/tests/testingtools.py b/spikeinterface_gui/tests/testingtools.py index ed0f0a58..b3b0a8d9 100644 --- a/spikeinterface_gui/tests/testingtools.py +++ b/spikeinterface_gui/tests/testingtools.py @@ -143,6 +143,100 @@ def make_analyzer_folder(test_folder, case="small", unit_dtype="str"): sorting_analyzer.compute(["spike_amplitudes", "spike_locations"], **job_kwargs) +def make_comparison_analyzer_folders(test_folder, case="small", unit_dtype="str", + fraction_shared=0.7, fraction_dropped_spikes=0.1): + """ + Two analyzer folders to compare, in test_folder / "sorting_analyzer_1" and "_2". + + One ground truth sorting is split in three: `fraction_shared` of the units go to both + sortings, and half of the rest goes to each one only. So the comparison has units in + the three regions of the Venn diagram. + + To add some variability, `fraction_dropped_spikes` of the spikes of the second sorting + are then dropped at random. The shared units therefore agree strongly but not + perfectly, which is closer to a real comparison than an agreement of exactly 1. + """ + clean_all(test_folder) + + if case == 'small': + durations = [300.0, 100.0] + num_channels = 32 + num_units = 30 + elif case == 'medium': + durations = [600.0,] + num_channels = 128 + num_units = 100 + else: + raise ValueError(f"Wrong dataset type {case}") + + job_kwargs = dict(n_jobs=-1, progress_bar=True, chunk_duration="1s") + + recording, sorting = si.generate_ground_truth_recording( + durations=durations, + num_channels=num_channels, + num_units=num_units, + + sampling_frequency=30000.0, + + generate_sorting_kwargs=dict(firing_rates=3.0, refractory_period_ms=4.0), + generate_unit_locations_kwargs=dict( + margin_um=5.0, + minimum_z=5.0, + maximum_z=20.0, + ), + generate_templates_kwargs=dict( + unit_params=dict( + alpha=(100.0, 500.0), + ) + ), + noise_kwargs=dict(noise_levels=10.0, strategy="tile_pregenerated"), + seed=2205, + ) + + rng = np.random.default_rng(seed=2205) + + # split the units: shared / only in sorting1 / only in sorting2 + shuffled_unit_ids = sorting.unit_ids.copy() + rng.shuffle(shuffled_unit_ids) + num_shared = int(fraction_shared * num_units) + num_only = (num_units - num_shared) // 2 + shared_unit_ids = set(shuffled_unit_ids[:num_shared]) + only1_unit_ids = set(shuffled_unit_ids[num_shared:num_shared + num_only]) + only2_unit_ids = set(shuffled_unit_ids[num_shared + num_only:num_shared + 2 * num_only]) + + # keep the original unit order, so that the unit ids of both analyzers stay sorted + sorting1 = sorting.select_units([u for u in sorting.unit_ids if u in shared_unit_ids | only1_unit_ids]) + sorting2 = sorting.select_units([u for u in sorting.unit_ids if u in shared_unit_ids | only2_unit_ids]) + + # drop some spikes of sorting2, so that the shared units do not agree perfectly + spikes2 = sorting2.to_spike_vector() + keep = rng.random(spikes2.size) >= fraction_dropped_spikes + sorting2 = si.NumpySorting(spikes2[keep], sorting2.sampling_frequency, sorting2.unit_ids) + + folders = [] + for i, sorting_i in enumerate([sorting1, sorting2]): + sorting_i = sorting_i.rename_units(sorting_i.unit_ids.astype(unit_dtype)) + sorting_i.set_property(key='my_own_property', + values=np.array([f"yep{i}" for i in range(sorting_i.unit_ids.size)])) + + folder = test_folder / f"sorting_analyzer_{i + 1}" + sorting_analyzer = si.create_sorting_analyzer(sorting_i, recording, + format="binary_folder", + folder=folder, + **job_kwargs) + sorting_analyzer.compute("random_spikes", method="uniform", max_spikes_per_unit=500) + sorting_analyzer.compute("waveforms", **job_kwargs) + sorting_analyzer.compute("templates", **job_kwargs) + sorting_analyzer.compute("noise_levels", **job_kwargs) + sorting_analyzer.compute("unit_locations") + sorting_analyzer.compute("quality_metrics", metric_names=["snr", "firing_rate"]) + sorting_analyzer.compute("template_metrics") + sorting_analyzer.compute(["spike_amplitudes", "spike_locations"], **job_kwargs) + folders.append(folder) + + return folders + + def make_curation_dict(analyzer): unit_ids = analyzer.unit_ids.tolist() curation_dict = { diff --git a/spikeinterface_gui/tracemapview.py b/spikeinterface_gui/tracemapview.py index 995c529f..a32b7af3 100644 --- a/spikeinterface_gui/tracemapview.py +++ b/spikeinterface_gui/tracemapview.py @@ -213,6 +213,11 @@ def _panel_make_layout(self): self.figure.on_event(MouseWheel, self._panel_gain_zoom) self.figure.on_event(DoubleTap, self._panel_on_double_tap) + # Placeholder for events, set by the bottom bar when the analyzer has some. + # MixinViewTrace._panel_add_event_lines() reads it on every refresh. + self.event_line = None + self.event_source = None + # Add selection line self.selection_line = self.figure.line( x=[], y=[], line_color="purple", line_width=2, line_dash="dashed", visible=False diff --git a/spikeinterface_gui/unitlistview.py b/spikeinterface_gui/unitlistview.py index d95e32a3..7ec551e0 100644 --- a/spikeinterface_gui/unitlistview.py +++ b/spikeinterface_gui/unitlistview.py @@ -32,13 +32,6 @@ def update_manual_labels(self): elif self.backend == 'panel': self._panel_update_labels() - def notify_unit_and_channel_visibility_changed(self): - selected_units = self.controller.get_visible_unit_ids() - visible_channel_inds = self.controller.get_common_sparse_channels(selected_units) - self.controller.set_channel_visibility(visible_channel_inds) - self.notify_channel_visibility_changed() - self.notify_unit_visibility_changed() - ## Qt ## def _qt_make_layout(self): diff --git a/spikeinterface_gui/vennview.py b/spikeinterface_gui/vennview.py new file mode 100644 index 00000000..f9be2f68 --- /dev/null +++ b/spikeinterface_gui/vennview.py @@ -0,0 +1,412 @@ +import numpy as np + +from .view_base import ViewBase + + +def solve_venn_geometry(num_only1, num_matched, num_only2): + """ + Two circle Venn geometry with areas proportional to the unit counts. + + Returns (radius1, radius2, center_x1, center_x2). The disc areas are `num1` and `num2` + and the area of the lens is `num_matched`, so the picture is quantitatively honest. + The two circles are centered on y=0 and symmetric around x=0. + """ + num1 = num_only1 + num_matched + num2 = num_matched + num_only2 + + if num1 == 0 and num2 == 0: + return 0., 0., 0., 0. + + # disc area == unit count + radius1 = np.sqrt(num1 / np.pi) + radius2 = np.sqrt(num2 / np.pi) + + if num1 == 0 or num2 == 0: + # one of the two is empty, put the other one in the middle + distance = radius1 + radius2 + elif num_matched == 0: + # disjoint, just touching + distance = radius1 + radius2 + elif num_matched >= min(num1, num2): + # fully nested + distance = abs(radius1 - radius2) + else: + distance = _solve_center_distance(radius1, radius2, num_matched) + + return radius1, radius2, -distance / 2., distance / 2. + + +def _lens_area(distance, radius1, radius2): + """Area of the intersection of two discs whose centers are `distance` apart""" + if distance >= radius1 + radius2: + return 0. + if distance <= abs(radius1 - radius2): + return np.pi * min(radius1, radius2) ** 2 + d, r1, r2 = distance, radius1, radius2 + part1 = r1 ** 2 * np.arccos((d ** 2 + r1 ** 2 - r2 ** 2) / (2 * d * r1)) + part2 = r2 ** 2 * np.arccos((d ** 2 + r2 ** 2 - r1 ** 2) / (2 * d * r2)) + part3 = 0.5 * np.sqrt(max((-d + r1 + r2) * (d + r1 - r2) * (d - r1 + r2) * (d + r1 + r2), 0.)) + return part1 + part2 - part3 + + +def _solve_center_distance(radius1, radius2, target_area): + """Center distance giving an intersection of `target_area`. The lens area decreases with d.""" + from scipy.optimize import brentq + + low = abs(radius1 - radius2) + high = radius1 + radius2 + return brentq(lambda d: _lens_area(d, radius1, radius2) - target_area, low, high) + + +class VennView(ViewBase): + """ + Venn diagram of the two compared sortings: units matched by both, and units found + by only one of the two. The agreement threshold is set with a slider and is shared + with the other comparison views. + """ + id = "venn" + _supported_backend = ['qt', 'panel'] + _depend_on = ['comparison'] + _settings = [ + {'name': 'num_units_to_select', 'type': 'int', 'value': 1, 'step': 1}, + ] + + _color1 = "#1f77b4" + _color2 = "#ff7f0e" + + def get_venn_data(self): + """Returns (venn_dict, geometry) where geometry is (r1, r2, cx1, cx2)""" + venn = self.controller.get_venn_unit_ids() + geometry = solve_venn_geometry(len(venn['only1']), len(venn['matched']), len(venn['only2'])) + return venn, geometry + + def select_units_on_click(self, x, y, reset=True): + """ + Make visible a random sample of the clicked region. + + A region can hold hundreds of units, and what one wants is to inspect a few of + them, so `num_units_to_select` of them are drawn at random. Clicking the same + region again draws another sample. + """ + venn, (radius1, radius2, center_x1, center_x2) = self.get_venn_data() + + inside1 = (x - center_x1) ** 2 + y ** 2 <= radius1 ** 2 + inside2 = (x - center_x2) ** 2 + y ** 2 <= radius2 ** 2 + + if inside1 and inside2: + # in the intersection an entry is a pair, and both of its units are selected + candidates = venn['matched'] + elif inside1: + candidates = venn['only1'] + elif inside2: + candidates = venn['only2'] + else: + return + + if len(candidates) == 0: + return + + num_to_select = max(int(self.settings['num_units_to_select']), 1) + num_to_select = min(num_to_select, len(candidates)) + rng = np.random.default_rng() + selected = rng.choice(len(candidates), size=num_to_select, replace=False) + + unit_ids = [] + for index in selected: + candidate = candidates[index] + if isinstance(candidate, tuple): + # a matched pair, keep both units so that they can be compared + unit_ids.extend(candidate) + else: + unit_ids.append(candidate) + + if not reset: + unit_ids = list(self.controller.get_visible_unit_ids()) + unit_ids + # set_visible_unit_ids still truncates to main_settings['max_visible_units'] + self.controller.set_visible_unit_ids(unit_ids) + self.notify_unit_and_channel_visibility_changed() + self.refresh() + + def set_agreement_threshold(self, threshold): + self.controller.set_agreement_threshold(threshold) + self.notify_agreement_threshold_changed() + self.refresh() + + def get_venn_labels(self, venn, geometry): + """ + The texts to draw, as a list of (x, y, text, color). + + Each count sits in the middle of its own region along y=0, and each analyzer name + on the outer side of its own circle, in its own color so that the two never + overlap when the circles are close together. + """ + radius1, radius2, center_x1, center_x2 = geometry + num_only1 = len(venn['only1']) + num_matched = len(venn['matched']) + num_only2 = len(venn['only2']) + + left1, right1 = center_x1 - radius1, center_x1 + radius1 + left2, right2 = center_x2 - radius2, center_x2 + radius2 + labels = [] + + if num_only1 > 0: + # the only1 region runs from the left of circle1 to the left of circle2, + # or spans the whole of circle1 when the two are disjoint + only1_right = min(right1, max(left2, left1)) + labels.append(((left1 + only1_right) / 2., 0., f'{num_only1}', '#FFFFFF')) + if num_only2 > 0: + only2_left = max(left2, min(right1, right2)) + labels.append(((only2_left + right2) / 2., 0., f'{num_only2}', '#FFFFFF')) + if num_matched > 0 and radius1 + radius2 > abs(center_x2 - center_x1): + # middle of the lens along x + labels.append(((right1 + left2) / 2., 0., f'{num_matched}', '#FFFFFF')) + + top = max(radius1, radius2) + labels.append((left1 + radius1 * 0.45, top * 1.15, f'{self.controller.analyzer1_name}', self._color1)) + labels.append((right2 - radius2 * 0.45, top * 1.15, f'{self.controller.analyzer2_name}', self._color2)) + + return labels + + def get_circle_polygon(self, radius, center_x, num_points=100): + """A closed circle as (xs, ys), for the backends that draw polygons""" + theta = np.linspace(0, 2 * np.pi, num_points) + return center_x + radius * np.cos(theta), radius * np.sin(theta) + + def get_view_ranges(self, geometry): + radius1, radius2, center_x1, center_x2 = geometry + top = max(radius1, radius2) + margin = 0.2 * top + x_range = (center_x1 - radius1 - margin, center_x2 + radius2 + margin) + y_range = (-top - margin, top * 1.3 + margin) + return x_range, y_range + + ## Qt ## + def _qt_make_layout(self): + from .myqt import QT + import pyqtgraph as pg + from .utils_qt import ViewBoxHandlingClickToPositionWithCtrl + + self.layout = QT.QVBoxLayout() + + # threshold slider, the controller holds the value + header = QT.QHBoxLayout() + header.addWidget(QT.QLabel('agreement threshold')) + self.slider = QT.QSlider(QT.Qt.Horizontal) + self.slider.setMinimum(0) + self.slider.setMaximum(100) + self.slider.setValue(int(round(self.controller.agreement_threshold * 100))) + self.slider.valueChanged.connect(self._qt_on_slider_changed) + header.addWidget(self.slider) + self.threshold_label = QT.QLabel(f'{self.controller.agreement_threshold:.2f}') + header.addWidget(self.threshold_label) + self.layout.addLayout(header) + + # dragging the slider emits one signal per step: debounce the re-matching so that + # a drag does not re-run the matching and rebuild the unit table on every step + self._commit_timer = QT.QTimer(self.qt_widget) + self._commit_timer.setSingleShot(True) + self._commit_timer.setInterval(150) + self._commit_timer.timeout.connect(self._qt_commit_threshold) + + self.graphicsview = pg.GraphicsView() + self.layout.addWidget(self.graphicsview) + + self.viewBox = ViewBoxHandlingClickToPositionWithCtrl() + self.viewBox.clicked.connect(self._qt_select_units) + self.viewBox.disableAutoRange() + self.viewBox.setAspectLocked(True) + + self.plot = pg.PlotItem(viewBox=self.viewBox) + self.graphicsview.setCentralItem(self.plot) + self.plot.hideButtons() + self.plot.hideAxis('bottom') + self.plot.hideAxis('left') + + self._items = [] + + def _qt_on_slider_changed(self, value): + # the label follows the slider immediately, the rest is debounced + self.threshold_label.setText(f'{value / 100.:.2f}') + self._commit_timer.start() + + def _qt_commit_threshold(self): + self.set_agreement_threshold(self.slider.value() / 100.) + + def _qt_select_units(self, x, y, reset): + self.select_units_on_click(x, y, reset=reset) + + def _qt_refresh(self): + from .myqt import QT + import pyqtgraph as pg + + # keep the slider in sync when the threshold is changed elsewhere, but never + # while an edit of our own is still pending, otherwise a refresh triggered by + # another view would snap the handle back under the user's cursor + slider_value = int(round(self.controller.agreement_threshold * 100)) + if self.slider.value() != slider_value and not self._commit_timer.isActive(): + self.slider.blockSignals(True) + self.slider.setValue(slider_value) + self.slider.blockSignals(False) + # the label always shows what the handle shows + self.threshold_label.setText(f'{self.slider.value() / 100.:.2f}') + + for item in self._items: + self.plot.removeItem(item) + self._items = [] + + venn, geometry = self.get_venn_data() + radius1, radius2, center_x1, center_x2 = geometry + + if radius1 == 0. and radius2 == 0.: + return + + for radius, center_x, color in ( + (radius1, center_x1, self._color1), + (radius2, center_x2, self._color2), + ): + if radius == 0.: + continue + circle = QT.QGraphicsEllipseItem(center_x - radius, -radius, 2 * radius, 2 * radius) + circle.setPen(pg.mkPen(color, width=2)) + # semi transparent so that the intersection reads as a third region + brush_color = QT.QColor(color) + brush_color.setAlpha(110) + circle.setBrush(pg.mkBrush(brush_color)) + self.plot.addItem(circle) + self._items.append(circle) + + for x, y, text, color in self.get_venn_labels(venn, geometry): + item = pg.TextItem(text=text, color=color, anchor=(0.5, 0.5), border=None) + item.setPos(x, y) + self.plot.addItem(item) + self._items.append(item) + + x_range, y_range = self.get_view_ranges(geometry) + self.plot.setXRange(*x_range) + self.plot.setYRange(*y_range) + + ## panel ## + def _panel_make_layout(self): + import panel as pn + import bokeh.plotting as bpl + from bokeh.models import ColumnDataSource + from bokeh.events import Tap + from .utils_panel import _bg_color + + # value_throttled only fires at the end of a drag, which is the debounce that + # the Qt side has to do with a timer + self.threshold_slider = pn.widgets.FloatSlider( + name='agreement threshold', + start=0., end=1., step=0.01, + value=float(self.controller.agreement_threshold), + sizing_mode="stretch_width", + ) + self.threshold_slider.param.watch(self._panel_on_slider_changed, 'value_throttled') + + self.figure = bpl.figure( + sizing_mode="stretch_both", + tools="reset,wheel_zoom,tap", + background_fill_color=_bg_color, + border_fill_color=_bg_color, + match_aspect=True, + outline_line_color="white", + styles={"flex": "1"}, + ) + self.figure.toolbar.logo = None + self.figure.axis.visible = False + self.figure.grid.visible = False + + # one entry per circle, updated in place on refresh + self.circle_source = ColumnDataSource({"xs": [], "ys": [], "color": []}) + self.figure.patches( + xs="xs", ys="ys", source=self.circle_source, + fill_color="color", line_color="color", fill_alpha=0.43, line_width=2, + ) + + self.text_source = ColumnDataSource({"x": [], "y": [], "text": [], "color": []}) + self.figure.text( + x="x", y="y", text="text", text_color="color", source=self.text_source, + text_align="center", text_baseline="middle", + ) + + self.figure.on_event(Tap, self._panel_on_tap) + + self.layout = pn.Column( + self.threshold_slider, + self.figure, + styles={"display": "flex", "flex-direction": "column"}, + sizing_mode="stretch_both", + ) + + def _panel_on_slider_changed(self, event): + self.set_agreement_threshold(event.new) + + def _panel_on_tap(self, event): + if event.x is None or event.y is None: + return + self.select_units_on_click(event.x, event.y, reset=True) + + def _panel_refresh(self): + # keep the slider in sync when the threshold is changed elsewhere + threshold = float(self.controller.agreement_threshold) + if self.threshold_slider.value != threshold: + self.threshold_slider.value = threshold + + venn, geometry = self.get_venn_data() + radius1, radius2, center_x1, center_x2 = geometry + + if radius1 == 0. and radius2 == 0.: + self.circle_source.data.update({"xs": [], "ys": [], "color": []}) + self.text_source.data.update({"x": [], "y": [], "text": [], "color": []}) + return + + all_xs, all_ys, colors = [], [], [] + for radius, center_x, color in ( + (radius1, center_x1, self._color1), + (radius2, center_x2, self._color2), + ): + if radius == 0.: + continue + xs, ys = self.get_circle_polygon(radius, center_x) + all_xs.append(xs.tolist()) + all_ys.append(ys.tolist()) + colors.append(color) + self.circle_source.data.update({"xs": all_xs, "ys": all_ys, "color": colors}) + + labels = self.get_venn_labels(venn, geometry) + self.text_source.data.update({ + "x": [label[0] for label in labels], + "y": [label[1] for label in labels], + "text": [label[2] for label in labels], + "color": [label[3] for label in labels], + }) + + x_range, y_range = self.get_view_ranges(geometry) + self.figure.x_range.start, self.figure.x_range.end = x_range + self.figure.y_range.start, self.figure.y_range.end = y_range + + +VennView._gui_help_txt = """ +## Venn View + +Venn diagram of the two compared sorting outputs. The area of each disc is proportional to +the number of units of that sorter, and the area of the intersection is proportional to the +number of units matched between the two, so the picture is quantitatively honest. + +The matching is one to one (hungarian) at the agreement threshold set with the slider. +That threshold is shared with the other comparison views: moving it also re-orders the +agreement matrix and re-categorizes the rows of the comparison unit table. + +### Settings +- **num_units_to_select** : how many units of a region a click makes visible. In the + intersection this is a number of pairs, and both units of each pair are selected. + +### Controls +- **slider** : the agreement threshold above which two units are considered matched. +- **left click on a region** : make a random sample of that region visible. Clicking again + draws another sample, which is a quick way to walk through a region. +- **ctrl + left click on a region** : add the sample to the units already visible. + +Note that `max_visible_units` (see the main settings) still caps how many units can be +visible at once. +""" diff --git a/spikeinterface_gui/view_base.py b/spikeinterface_gui/view_base.py index 175059e7..871ebdcd 100644 --- a/spikeinterface_gui/view_base.py +++ b/spikeinterface_gui/view_base.py @@ -61,6 +61,14 @@ def notify_unit_visibility_changed(self): def notify_channel_visibility_changed(self): self.notifier.notify_channel_visibility_changed() + def notify_unit_and_channel_visibility_changed(self): + """Set the visible channels to the sparse channels of the visible units, then notify both""" + selected_units = self.controller.get_visible_unit_ids() + visible_channel_inds = self.controller.get_common_sparse_channels(selected_units) + self.controller.set_channel_visibility(visible_channel_inds) + self.notify_channel_visibility_changed() + self.notify_unit_visibility_changed() + def notify_manual_curation_updated(self): self.controller.current_curation_saved = False self.notifier.notify_manual_curation_updated() @@ -79,6 +87,10 @@ def notify_active_view_updated(self): def notify_unit_color_changed(self): self.notifier.notify_unit_color_changed() + def notify_agreement_threshold_changed(self): + # comparison mode only: the agreement threshold is shared by the comparison views + self.notifier.notify_agreement_threshold_changed() + def on_settings_changed(self, *params): # what to do when one settings is changed # optionally views can implement custom method @@ -239,6 +251,12 @@ def on_unit_color_changed(self): elif self.backend == "panel": self._panel_on_unit_color_changed() + def on_agreement_threshold_changed(self): + if self.backend == "qt": + self._qt_on_agreement_threshold_changed() + elif self.backend == "panel": + self._panel_on_agreement_threshold_changed() + def busy_cursor(self): if self.backend == "qt": return self._qt_busy_cursor() @@ -288,6 +306,9 @@ def _qt_on_use_times_updated(self): def _qt_on_unit_color_changed(self): self.refresh() + def _qt_on_agreement_threshold_changed(self): + self.refresh() + def _qt_insert_warning(self, warning_msg): from .myqt import QT @@ -348,6 +369,9 @@ def _panel_on_use_times_updated(self): def _panel_on_unit_color_changed(self): self.refresh() + def _panel_on_agreement_threshold_changed(self): + self.refresh() + def _panel_insert_warning(self, warning_msg): import panel as pn diff --git a/spikeinterface_gui/viewlist.py b/spikeinterface_gui/viewlist.py index 39cc70d8..7e4cf528 100644 --- a/spikeinterface_gui/viewlist.py +++ b/spikeinterface_gui/viewlist.py @@ -22,13 +22,18 @@ from .maintemplateview import MainTemplateView from .eventview import EventView +from .compareunitlistview import CompareUnitListView +from .agreementmatrixview import AgreementMatrixView +from .vennview import VennView + # probe and mainsettings view are first, since they affect other views (e.g., time info) builtin_views = [ ProbeView, MainSettingsView, UnitListView, SpikeRateView, MergeView, TraceView, TraceMapView, WaveformView, WaveformHeatMapView, ISIView, CorrelogramView, NDScatterView, SimilarityView, SpikeAmplitudeView, SpikeDepthView, SpikeRateView, CurationView, MetricsView, SpikeListView, - AmplitudeScalingsView, MainTemplateView, EventView + AmplitudeScalingsView, MainTemplateView, EventView, + CompareUnitListView, AgreementMatrixView, VennView, ] def get_all_possible_views(): diff --git a/spikeinterface_gui/waveformview.py b/spikeinterface_gui/waveformview.py index dfc06f7c..562a6d1e 100644 --- a/spikeinterface_gui/waveformview.py +++ b/spikeinterface_gui/waveformview.py @@ -555,7 +555,6 @@ def _qt_refresh_with_spikes(self): if num_waveforms <= 0: self.curve_waveforms.setData([], []) return - wf_ext = self.controller.analyzer.get_extension("waveforms") visible_unit_ids = self.controller.get_visible_unit_ids() # Process waveforms per unit to maintain color association @@ -563,7 +562,7 @@ def _qt_refresh_with_spikes(self): width = None for unit_id in visible_unit_ids: - waveforms = wf_ext.get_waveforms_one_unit(unit_id, force_dense=True) + waveforms, _ = self.controller.get_waveforms(unit_id, force_dense=True) if waveforms is None or len(waveforms) == 0: continue @@ -1283,7 +1282,6 @@ def _panel_refresh_waveforms_samples(self): self.lines_data_source_wfs_geom.data = dict(xs=[], ys=[], colors=[]) return - wf_ext = self.controller.analyzer.get_extension("waveforms") visible_unit_ids = self.controller.get_visible_unit_ids() # Process waveforms per unit to maintain color association @@ -1291,7 +1289,7 @@ def _panel_refresh_waveforms_samples(self): width = None for unit_id in visible_unit_ids: - waveforms = wf_ext.get_waveforms_one_unit(unit_id, force_dense=True) + waveforms, _ = self.controller.get_waveforms(unit_id, force_dense=True) if waveforms is None or len(waveforms) == 0: continue