From e0c3368be84c50cf0c2046c05a17e5edd8f873f5 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Fri, 12 Dec 2025 16:52:36 +0100 Subject: [PATCH 1/3] Add all-in-one run_compare_analyzers function --- spikeinterface_gui/__init__.py | 2 +- spikeinterface_gui/compareunitlistview.py | 209 ++++++ spikeinterface_gui/controller.py | 24 +- spikeinterface_gui/controllercomparison.py | 798 +++++++++++++++++++++ spikeinterface_gui/main.py | 183 ++++- spikeinterface_gui/viewlist.py | 5 +- spikeinterface_gui/waveformheatmapview.py | 1 - spikeinterface_gui/waveformview.py | 6 +- 8 files changed, 1210 insertions(+), 18 deletions(-) create mode 100644 spikeinterface_gui/compareunitlistview.py create mode 100644 spikeinterface_gui/controllercomparison.py diff --git a/spikeinterface_gui/__init__.py b/spikeinterface_gui/__init__.py index 90764da3..cc7a9e9c 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/compareunitlistview.py b/spikeinterface_gui/compareunitlistview.py new file mode 100644 index 00000000..df9350ef --- /dev/null +++ b/spikeinterface_gui/compareunitlistview.py @@ -0,0 +1,209 @@ +import numpy as np + +import pyqtgraph as pg + +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. + """ + _supported_backend = ['qt'] + _depend_on = ['comparison'] + _gui_help_txt = "Display comparison table between two sorting outputs" + _settings = [ + {"name": "matching_mode", "type": "list", "value": "hungarian", "options": ["hungarian", "best_match"]}, + ] + + def __init__(self, controller=None, parent=None, backend="qt"): + ViewBase.__init__(self, controller=controller, parent=parent, backend=backend) + self.unit_dtype = self.controller.unit_ids.dtype + + + 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(5) + self.table.setHorizontalHeaderLabels([ + f'Unit ({self.controller.analyzer1_name})', + f'Unit ({self.controller.analyzer2_name})', + 'Agreement Score', + f'#Spikes ({self.controller.analyzer1_name})', + f'#Spikes ({self.controller.analyzer2_name})' + ]) + self.table.setSortingEnabled(True) + # Sort by Agreement Score column (index 2) by default + self.table.sortItems(2, QT.Qt.DescendingOrder) + self.table.setSelectionMode(QT.QAbstractItemView.SingleSelection) + + def _qt_refresh(self): + """Populate/refresh the comparison table with data""" + from .myqt import QT + + comp = self.controller.comp + + # Get comparison data + if self.settings['matching_mode'] == 'hungarian': + matching_12 = comp.hungarian_match_12 + else: + matching_12 = comp.best_match_12 + matching_12 = comp.hungarian_match_12 + agreement_scores = comp.agreement_scores + + # Get all units from both analyzers + all_units2 = set(self.controller.analyzer2.unit_ids) + + # Build rows: matched pairs + unmatched units + rows = [] + + # Add matched units + for unit1_orig in matching_12.index: + unit2_orig = matching_12[unit1_orig] + if unit2_orig != -1: + # Get combined unit_ids + unit1_idx = list(self.controller.analyzer1.unit_ids).index(unit1_orig) + unit2_idx = list(self.controller.analyzer2.unit_ids).index(unit2_orig) + unit1 = self.controller.unit_ids1[unit1_idx] + unit2 = self.controller.unit_ids2[unit2_idx] + + score = agreement_scores.at[unit1_orig, unit2_orig] + num_spikes1 = self.controller.analyzer1.sorting.get_unit_spike_train(unit1_orig).size + num_spikes2 = self.controller.analyzer2.sorting.get_unit_spike_train(unit2_orig).size + + rows.append( + { + 'unit1': str(unit1), + 'unit2': str(unit2), + 'unit1_orig': unit1_orig, + 'unit2_orig': unit2_orig, + 'agreement_score': f"{score:.3f}", + 'num_spikes1': num_spikes1, + 'num_spikes2': num_spikes2 + } + ) + all_units2.discard(unit2_orig) + else: + # Unmatched unit from analyzer1 + unit1_idx = list(self.controller.analyzer1.unit_ids).index(unit1_orig) + unit1 = self.controller.unit_ids1[unit1_idx] + num_spikes1 = self.controller.analyzer1.sorting.get_unit_spike_train(unit1_orig).size + + rows.append({ + 'unit1': str(unit1), + 'unit2': '', + 'unit1_orig': unit1_orig, + 'unit2_orig': '', + 'agreement_score': '0', + 'num_spikes1': num_spikes1, + 'num_spikes2': 0 + }) + + # Add unmatched units from analyzer2 + print("Remaining unmatched units in analyzer2:", len(all_units2)) + for unit2_orig in all_units2: + unit2_idx = list(self.controller.analyzer2.unit_ids).index(unit2_orig) + unit2 = self.controller.unit_ids2[unit2_idx] + num_spikes2 = self.controller.analyzer2.sorting.get_unit_spike_train(unit2_orig).size + + rows.append({ + 'unit1': '', + 'unit2': str(unit2), + 'unit1_orig': '', + 'unit2_orig': unit2_orig, + 'agreement_score': '', + 'num_spikes1': 0, + 'num_spikes2': num_spikes2 + }) + + # Populate rows + print(len(rows), "rows to display in comparison table") + # Disable sorting while populating + self.table.setSortingEnabled(False) + self.table.setRowCount(len(rows)) + + for i, row in enumerate(rows): + # Unit 1 column with color + if row['unit1'] != '': + item1 = QT.QTableWidgetItem(str(row['unit1'])) + unit1 = np.array([row['unit1']]).astype(self.unit_dtype)[0] + color1 = self.controller.get_unit_color(unit1) + item1.setBackground(QT.QColor(int(color1[0] * 255), int(color1[1] * 255), int(color1[2] * 255))) + # Set text color to white or black depending on background brightness + brightness1 = 0.299 * color1[0] + 0.587 * color1[1] + 0.114 * color1[2] + text_color1 = QT.QColor(255, 255, 255) if brightness1 < 0.5 else QT.QColor(0, 0, 0) + item1.setForeground(text_color1) + else: + item1 = QT.QTableWidgetItem('') + self.table.setItem(i, 0, item1) + + # Unit 2 column with color + if row['unit2'] != '': + item2 = QT.QTableWidgetItem(str(row['unit2'])) + unit2 = np.array([row['unit2']]).astype(self.unit_dtype)[0] + color2 = self.controller.get_unit_color(unit2) + item2.setBackground(QT.QColor(int(color2[0] * 255), int(color2[1] * 255), int(color2[2] * 255))) + # Set text color to white or black depending on background brightness + brightness2 = 0.299 * color2[0] + 0.587 * color2[1] + 0.114 * color2[2] + text_color2 = QT.QColor(255, 255, 255) if brightness2 < 0.5 else QT.QColor(0, 0, 0) + item2.setForeground(text_color2) + else: + item2 = QT.QTableWidgetItem('') + self.table.setItem(i, 1, item2) + + # Other columns + self.table.setItem(i, 2, QT.QTableWidgetItem(row['agreement_score'])) + self.table.setItem(i, 3, QT.QTableWidgetItem(str(row['num_spikes1']))) + self.table.setItem(i, 4, QT.QTableWidgetItem(str(row['num_spikes2']))) + + # Re-enable sorting after populating + self.table.setSortingEnabled(True) + # Resize columns + self.table.resizeColumnsToContents() + + + def _qt_on_selection_changed(self): + """Handle row selection and update unit visibility""" + selected_rows = [] + for item in self.table.selectedItems(): + if item.column() != 1: continue + selected_rows.append(item.row()) + + row_idx = selected_rows[0] + # Get unit values from table items + unit1_item = self.table.item(row_idx, 0) + unit2_item = self.table.item(row_idx, 1) + + # Collect units to make visible + visible_units = [] + + if unit1_item is not None and unit1_item.text() != '': + unit1 = np.array([unit1_item.text()]).astype(self.unit_dtype)[0] + visible_units.append(unit1) + + if unit2_item is not None and unit2_item.text() != '': + unit2 = np.array([unit2_item.text()]).astype(self.unit_dtype)[0] + visible_units.append(unit2) + + print("Selected units:", visible_units) + # Update visibility + if visible_units: + self.controller.set_visible_unit_ids(visible_units) + self.notify_unit_visibility_changed() + + def on_unit_visibility_changed(self): + """Handle external unit visibility changes - could highlight selected row""" + pass \ No newline at end of file diff --git a/spikeinterface_gui/controller.py b/spikeinterface_gui/controller.py index c6064497..4fb1a3aa 100644 --- a/spikeinterface_gui/controller.py +++ b/spikeinterface_gui/controller.py @@ -8,8 +8,6 @@ from spikeinterface.widgets.utils import get_unit_colors from spikeinterface import compute_sparsity from spikeinterface.core import get_template_extremum_channel -import spikeinterface.postprocessing -import spikeinterface.qualitymetrics from spikeinterface.core.sorting_tools import spike_vector_to_indices from spikeinterface.core.core_tools import check_json from spikeinterface.curation import validate_curation_dict @@ -30,6 +28,7 @@ ) from spikeinterface.widgets.sorting_summary import _default_displayed_unit_properties + class Controller(): @@ -269,18 +268,21 @@ def __init__(self, analyzer=None, backend="qt", parent=None, verbose=False, save 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) + 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, unit_ids, absolute_index=True) - spike_indices = spike_vector_to_indices(spike_vector2, unit_ids) + 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 unit_ids: + 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]))) @@ -684,11 +686,11 @@ 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.recording.get_num_channels(), dtype='int64') + chan_inds = np.arange(self.analyzer.get_num_channels(), dtype='int64') else: # sparse waveforms chan_inds = self.analyzer.sparsity.unit_id_to_channel_indices[unit_id] @@ -1026,3 +1028,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..9a4feda4 --- /dev/null +++ b/spikeinterface_gui/controllercomparison.py @@ -0,0 +1,798 @@ +import time + +import numpy as np +import pandas as pd + + +from spikeinterface.widgets.utils import get_some_colors +from spikeinterface import compute_sparsity +from spikeinterface.core import get_template_extremum_channel +from spikeinterface.core.sorting_tools import spike_vector_to_indices +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 + + +spike_dtype =[('sample_index', 'int64'), ('unit_index', 'int64'), + ('channel_index', 'int64'), ('segment_index', 'int64'), + ('visible', 'bool'), ('selected', 'bool'), ('rand_selected', 'bool')] + + +_default_main_settings = dict( + max_visible_units=10, + color_mode='color_by_unit', + use_times=False +) + +from spikeinterface.widgets.sorting_summary import _default_displayed_unit_properties + + + +class ControllerComparison(): + 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 + ): + self.views = [] + skip_extensions = skip_extensions if skip_extensions is not None else [] + + self.skip_extensions = skip_extensions + self.skip_extensions.extend(["principal_components", "correlograms", "isi_histograms", "template_similarity"]) + self.skip_extensions = list(set(self.skip_extensions)) + self.backend = backend + self.disable_save_settings_button = disable_save_settings_button + self.curation = False + # this is not to have a popup when closing + self.current_curation_saved = True + + 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 + 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() + + self.num_channels = self.analyzer1.get_num_channels() + # this now private and shoudl be acess 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) + + # spikes + t0 = time.perf_counter() + if verbose: + print('Gathering all spikes') + self._extremum_channel1 = get_template_extremum_channel(self.analyzer1, peak_sign='neg', outputs='index') + self._extremum_channel2 = get_template_extremum_channel(self.analyzer2, peak_sign='neg', outputs='index') + self._extremum_channel = {} + for unit_id in self.unit_ids: + if unit_id in self.unit_ids1: + 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() + + self.spikes = np.zeros(spike_vector1.size + spike_vector2.size, dtype=spike_dtype) + 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.analyzer1.get_num_units()]) + 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 + num_seg = self.analyzer1.get_num_segments() + self.spike_order = np.argsort(self.spikes['sample_index'], kind='stable') + self.spikes = self.spikes[self.spike_order] + + 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) + + 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']: + 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 are skipped + self.correlograms, self.correlograms_bins = None, None + self.isi_histograms, self.isi_bins = None, None + + self._similarity_by_method = {} + # if "template_similarity" in self.skip_extensions: + # if self.verbose: + # print('\tSkipping template_similarity') + # else: + # if verbose: + # print('\tLoading template_similarity') + # ts_ext = analyzer.get_extension('template_similarity') + # if ts_ext is not None: + # method = ts_ext.params["method"] + # self._similarity_by_method[method] = ts_ext.get_data() + # else: + # if len(self.unit_ids) <= 64 and len(self.channel_ids) <= 64: + # # precompute similarity when low channel/units count + # method = 'l1' + # ts_ext = analyzer.compute_one_extension('template_similarity', method=method, save=save_on_compute) + # self._similarity_by_method[method] = ts_ext.get_data() + + 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._pc_projections = None + if "principal_components" in self.skip_extensions: + if self.verbose: + print('\tSkipping principal_components') + self.pc_ext1, self.pc_ext2 = None, None + else: + if verbose: + print('\tLoading principal_components') + pc_ext1 = self.analyzer1.get_extension('principal_components') + pc_ext2 = self.analyzer2.get_extension('principal_components') + if pc_ext1 is not None and pc_ext2 is not None: + self.pc_ext1 = pc_ext1 + self.pc_ext2 = pc_ext2 + else: + self.pc_ext1, self.pc_ext2 = None, 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 + self.num_spikes =self.analyzer1.sorting.count_num_spikes_per_unit(outputs="dict") + + # spikeinterface handle colors in matplotlib style tuple values in range (0,1) + self.refresh_colors() + + + 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() + + def check_is_view_possible(self, view_name): + from .viewlist import possible_class_views + view_class = possible_class_views[view_name] + if view_class._depend_on is not None: + depencies_ok = all(self.has_extension(k) for k in view_class._depend_on) + if not depencies_ok: + if self.verbose: + print(view_name, 'does not have all dependencies', view_class._depend_on) + return False + return True + + def declare_a_view(self, new_view): + assert new_view not in self.views, 'view already declared {}'.format(self) + self.views.append(new_view) + self.signal_handler.connect_view(new_view) + + @property + def channel_ids(self): + return self.analyzer1.channel_ids + + @property + def unit_ids1(self): + return self.unit_ids[:self.analyzer1.get_num_units()] + + @property + def unit_ids2(self): + return self.unit_ids[self.analyzer1.get_num_units():] + + def get_original_unit_id(self, unit_id): + """Get original unit id from analyzer1 or analyzer2 given combined unit_id""" + unit_index = list(self.unit_ids).index(unit_id) + if unit_index < self.analyzer1.get_num_units(): + return self.analyzer1.unit_ids[unit_index] + else: + return self.analyzer2.unit_ids[unit_index - self.analyzer1.get_num_units()] + + @property + def unit_ids(self): + if isinstance(self.analyzer1.unit_ids[0], np.integer) and isinstance(self.analyzer2.unit_ids[0], np.integer): + return np.concatenate((self.analyzer1.unit_ids, self.analyzer2.unit_ids + max(self.analyzer1.unit_ids) + 1)) + else: + analyzer1_ids = [str(uid) + f"_{self.analyzer1_name}" for uid in self.analyzer1.unit_ids] + analyzer2_ids = [str(uid) + f"_{self.analyzer2_name}" for uid in self.analyzer2.unit_ids] + return np.array(analyzer1_ids + analyzer2_ids) + + def get_time(self): + """ + Returns selected time and segment index + """ + segment_index = self.time_info['segment_index'] + time_by_seg = self.time_info['time_by_seg'] + time = time_by_seg[segment_index] + return time, segment_index + + def set_time(self, time=None, segment_index=None): + """ + Set selected time and segment index. + If time is None, then the current time is used. + If segment_index is None, then the current segment index is used. + """ + if segment_index is not None: + self.time_info['segment_index'] = segment_index + else: + segment_index = self.time_info['segment_index'] + if time is not None: + self.time_info['time_by_seg'][segment_index] = time + + def update_time_info(self): + # set default time info + if self.main_settings["use_times"] and self.has_extension("recording"): + time_by_seg=np.array( + [ + self.analyzer1.recording.get_start_time(segment_index) for segment_index in range(self.num_segments) + ], + dtype="float64" + ) + else: + time_by_seg=np.array([0] * self.num_segments, dtype="float64") + if not hasattr(self, 'time_info'): + self.time_info = dict( + time_by_seg=time_by_seg, + segment_index=0 + ) + else: + self.time_info['time_by_seg'] = time_by_seg + + def get_t_start_t_stop(self): + segment_index = self.time_info["segment_index"] + if self.main_settings["use_times"] and self.has_extension("recording"): + t_start =self.analyzer1.recording.get_start_time(segment_index=segment_index) + t_stop =self.analyzer1.recording.get_end_time(segment_index=segment_index) + return t_start, t_stop + else: + return 0, self.get_num_samples(segment_index) / self.sampling_frequency + + def get_times_chunk(self, segment_index, t1, t2): + ind1, ind2 = self.get_chunk_indices(t1, t2, segment_index) + if self.main_settings["use_times"]: + recording =self.analyzer1.recording + times_chunk = recording.get_times(segment_index=segment_index)[ind1:ind2] + else: + times_chunk = np.arange(ind2 - ind1, dtype='float64') / self.sampling_frequency + max(t1, 0) + return times_chunk + + def get_chunk_indices(self, t1, t2, segment_index): + if self.main_settings["use_times"]: + recording =self.analyzer1.recording + ind1, ind2 = recording.time_to_sample_index([t1, t2], segment_index=segment_index) + else: + t_start = 0.0 + sr = self.sampling_frequency + ind1 = int((t1 - t_start) * sr) + ind2 = int((t2 - t_start) * sr) + + ind1 = max(0, ind1) + ind2 = min(self.get_num_samples(segment_index), ind2) + return ind1, ind2 + + def sample_index_to_time(self, sample_index): + segment_index = self.time_info["segment_index"] + if self.main_settings["use_times"] and self.has_extension("recording"): + time =self.analyzer1.recording.sample_index_to_time(sample_index, segment_index=segment_index) + return time + else: + return sample_index / self.sampling_frequency + + def time_to_sample_index(self, time): + segment_index = self.time_info["segment_index"] + if self.main_settings["use_times"] and self.has_extension("recording"): + time =self.analyzer1.recording.time_to_sample_index(time, segment_index=segment_index) + return time + else: + return int(time * self.sampling_frequency) + + def get_information_txt(self): + nseg =self.analyzer1.get_num_segments() + nchan =self.analyzer1.get_num_channels() + nunits = len(self.unit_ids) + txt = f"{nchan} channels - {nunits} units - {nseg} segments - {self.analyzer1.format}\n" + txt += f"Loaded {len(self.analyzer1.extensions)} extensions" + + return txt + + def refresh_colors(self): + if self.backend == "qt": + self._cached_qcolors = {} + elif self.backend == "panel": + pass + + if self.main_settings['color_mode'] == 'color_by_unit': + self.colors = get_some_colors(self.unit_ids, color_engine='matplotlib', map_name='gist_ncar', + shuffle=True, seed=42) + elif self.main_settings['color_mode'] == 'color_only_visible': + unit_colors = get_some_colors(self.unit_ids, color_engine='matplotlib', map_name='gist_ncar', + shuffle=True, seed=42) + self.colors = {unit_id: (0.3, 0.3, 0.3, 1.) for unit_id in self.unit_ids} + for unit_id in self.get_visible_unit_ids(): + self.colors[unit_id] = unit_colors[unit_id] + elif self.main_settings['color_mode'] == 'color_by_visibility': + self.colors = {unit_id: (0.3, 0.3, 0.3, 1.) for unit_id in self.unit_ids} + import matplotlib.pyplot as plt + cmap = plt.colormaps['tab10'] + for i, unit_id in enumerate(self.get_visible_unit_ids()): + self.colors[unit_id] = cmap(i) + + + def get_unit_color(self, unit_id): + # scalar unit_id -> color html or QtColor + return self.colors[unit_id] + + def get_spike_colors(self, unit_indices): + # array[unit_ind] -> array[color html or QtColor] + colors = np.zeros((unit_indices.size, 4), dtype="uint8") + unit_inds = np.unique(unit_indices) + for unit_ind in unit_inds: + unit_id = self.unit_ids[unit_ind] + mask = unit_indices == unit_ind + colors[mask] = np.array(self.get_unit_color(unit_id)) * 255 + return colors + + + def get_extremum_channel(self, unit_id): + chan_ind = self._extremum_channel[unit_id] + return chan_ind + + # unit visibility zone + def set_visible_unit_ids(self, visible_unit_ids): + """Make visible some units, all other off""" + lim = self.main_settings['max_visible_units'] + if len(visible_unit_ids) > lim: + visible_unit_ids = visible_unit_ids[:lim] + self._visible_unit_ids = list(visible_unit_ids) + + def get_visible_unit_ids(self): + """Get list of visible unit_ids""" + return self._visible_unit_ids + + def get_visible_unit_indices(self): + """Get list of indicies of visible units""" + unit_ids = list(self.unit_ids) + visible_unit_indices = [unit_ids.index(u) for u in self._visible_unit_ids] + return visible_unit_indices + + def set_all_unit_visibility_off(self): + """As in the name""" + self._visible_unit_ids = [] + + def iter_visible_units(self): + """For looping over unit_ind and unit_id""" + visible_unit_indices = self.get_visible_unit_indices() + visible_unit_ids = self._visible_unit_ids + return zip(visible_unit_indices, visible_unit_ids) + + def set_unit_visibility(self, unit_id, state): + """Change the visibility of on unit, other are unchanged""" + if state and not(unit_id in self._visible_unit_ids): + self._visible_unit_ids.append(unit_id) + elif not state and unit_id in self._visible_unit_ids: + self._visible_unit_ids.remove(unit_id) + + def get_unit_visibility(self, unit_id): + """Get thethe visibility of on unit""" + return unit_id in self._visible_unit_ids + + def get_units_visibility_mask(self): + """Get bool mask of visibility""" + mask = np.zeros(self.unit_ids.size, dtype='bool') + mask[self.get_visible_unit_indices()] = True + return mask + + def get_dict_unit_visible(self): + """Construct the visibility dict keys are unit_ids, previous behavior""" + dict_unit_visible = {u:False for u in self.unit_ids} + for u in self.get_visible_unit_ids(): + dict_unit_visible[u] = True + return dict_unit_visible + ## end unit visibility zone + + def update_visible_spikes(self): + inds = [] + for unit_index, unit_id in self.iter_visible_units(): + inds.append(self._spike_index_by_units[unit_id]) + + if len(inds) > 0: + inds = np.concatenate(inds) + inds = np.sort(inds) + else: + inds = np.array([], dtype='int64') + self._spike_visible_indices = inds + + self._spike_selected_indices = np.array([], dtype='int64') + + def get_indices_spike_visible(self): + return self._spike_visible_indices + + def get_indices_spike_selected(self): + return self._spike_selected_indices + + def set_indices_spike_selected(self, inds): + self._spike_selected_indices = np.array(inds) + # reset active split if needed + if len(self._spike_selected_indices) == 1: + # set time info + segment_index = self.spikes['segment_index'][self._spike_selected_indices[0]] + sample_index = self.spikes['sample_index'][self._spike_selected_indices[0]] + self.set_time(time=self.sample_index_to_time(sample_index), segment_index=segment_index) + + def get_spike_indices(self, unit_id, segment_index=None): + if segment_index is None: + # dict[unit_id] -> all indices for this unit across segments + return self._spike_index_by_units[unit_id] + else: + # dict[segment_index][unit_id] -> all indices for this unit for one segment + return self._spike_index_by_segment_and_units[segment_index][unit_id] + + def get_num_samples(self, segment_index): + return self.analyzer1.get_num_samples(segment_index=segment_index) + + def get_traces(self, trace_source='preprocessed', **kargs): + # assert trace_source in ['preprocessed', 'raw'] + assert trace_source in ['preprocessed'] + + cache_key = (kargs.get("segment_index", None), kargs.get("start_frame", None), kargs.get("end_frame", None)) + if cache_key in self._traces_cached: + return self._traces_cached[cache_key] + else: + # check if start_frame and end_frame are a subset interval of a cached one + for cached_key in self._traces_cached.keys(): + cached_seg = cached_key[0] + cached_start = cached_key[1] + cached_end = cached_key[2] + req_seg = kargs.get("segment_index", None) + req_start = kargs.get("start_frame", None) + req_end = kargs.get("end_frame", None) + if cached_seg is not None and req_seg is not None: + if cached_seg != req_seg: + continue + if cached_start is not None and cached_end is not None and req_start is not None and req_end is not None: + if req_start >= cached_start and req_end <= cached_end: + # subset found + traces = self._traces_cached[cached_key] + start_offset = req_start - cached_start + end_offset = req_end - cached_start + return traces[start_offset:end_offset, :] + + if len(self._traces_cached) > 4: + self._traces_cached.pop(list(self._traces_cached.keys())[0]) + + if trace_source == 'preprocessed': + rec = self.analyzer1.recording + elif trace_source == 'raw': + raise NotImplementedError("Raw traces not implemented yet") + # TODO get with parent recording the non process recording + kargs['return_in_uV'] = self.return_in_uV + traces = rec.get_traces(**kargs) + # put in cache for next call + self._traces_cached[cache_key] = traces + return traces + + def get_contact_location(self): + location = self.analyzer1.get_channel_locations() + return location + + def get_waveform_sweep(self): + return self.nbefore, self.nafter + + def get_waveforms_range(self): + return np.nanmin(self.templates_average), np.nanmax(self.templates_average) + + def get_waveforms(self, unit_id, force_dense=False): + if unit_id in self.unit_ids1: + self.waveforms_ext = self.waveforms_ext1 + analyzer = self.analyzer1 + original_unit_id = self.get_original_unit_id(unit_id) + wfs = self.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[unit_id] + return wfs, chan_inds + + def get_common_sparse_channels(self, unit_ids): + sparsity_mask = self.get_sparsity_mask() + unit_indexes = [list(self.unit_ids).index(u) for u in unit_ids] + chan_inds, = np.nonzero(sparsity_mask[unit_indexes, :].sum(axis=0)) + return chan_inds + + def get_intersect_sparse_channels(self, unit_ids): + sparsity_mask = self.get_sparsity_mask() + unit_indexes = [list(self.unit_ids).index(u) for u in unit_ids] + chan_inds, = np.nonzero(sparsity_mask[unit_indexes, :].sum(axis=0) == len(unit_ids)) + return chan_inds + + def get_probegroup(self): + return self.analyzer1.get_probegroup() + + def set_channel_visibility(self, visible_channel_inds): + self.visible_channel_inds = np.array(visible_channel_inds, copy=True) + + def has_extension(self, extension_name): + if extension_name == 'recording': + return self.use_recordings + elif extension_name == 'comparison': + return True + else: + # extension needs to be loaded + if extension_name in self.skip_extensions: + return False + else: + return extension_name in self.analyzer1.extensions + + def handle_metrics(self): + return self.metrics is not None + + def get_units_table(self): + return self.units_table + + # TODO + def get_all_pcs(self): + return None, None + # if self._pc_projections is None and (self.pc_ext1 is not None and self.pc_ext2 is not None): + # pc_indices = + # for analyzer, pc_ext in zip([self.analyzer1, self.analyzer2], [self.pc_ext1, self.pc_ext2]): + # # make sure pcs are computed + # if pc_ext.get_data() is None: + # analyzer.compute_one_extension('principal_components', save=self.save_on_compute) + # self._pc_projections, self._pc_indices = self.pc_ext1.get_some_projections( + # channel_ids=self.analyzer1.channel_ids, + # unit_ids=self.analyzer1.unit_ids + # ) + + # return self._pc_indices, self._pc_projections + # else: + # return None, None + + def get_sparsity_mask(self): + if self.external_sparsity1 is not None: + return np.vstack([self.external_sparsity1.mask, self.external_sparsity2.mask]) + else: + return np.vstack([self.analyzer_sparsity1.mask, self.analyzer_sparsity2.mask]) + + def get_similarity(self, method=None): + if method is None and len(self._similarity_by_method) == 1: + method = list(self._similarity_by_method.keys())[0] + similarity = self._similarity_by_method.get(method, None) + return similarity + + def compute_similarity(self, method='l1'): + # have internal cache + if method in self._similarity_by_method: + return self._similarity_by_method[method] + ext = self.analyzer.compute("template_similarity", method=method, save=self.save_on_compute) + self._similarity_by_method[method] = ext.get_data() + return self._similarity_by_method[method] + + def compute_unit_positions(self, method, method_kwargs): + unit_positions = np.zeros((len(self.unit_ids), 2), dtype='float32') + for analyzer in [self.analyzer1, self.analyzer2]: + ext = analyzer.get_extension('unit_locations') + ext = analyzer.compute_one_extension('unit_locations', save=self.save_on_compute, method=method, **method_kwargs) + unit_positions[:len(analyzer.unit_ids), :] = ext.get_data()[:, :2] + self.unit_positions = unit_positions + + # def get_correlograms(self): + # return self.correlograms, self.correlograms_bins + + # def compute_correlograms(self, window_ms, bin_ms): + # ext = self.analyzer.compute("correlograms", save=self.save_on_compute, window_ms=window_ms, bin_ms=bin_ms) + # self.correlograms, self.correlograms_bins = ext.get_data() + # return self.correlograms, self.correlograms_bins + + # def get_isi_histograms(self): + # return self.isi_histograms, self.isi_bins + + # def compute_isi_histograms(self, window_ms, bin_ms): + # ext = self.analyzer.compute("isi_histograms", save=self.save_on_compute, window_ms=window_ms, bin_ms=bin_ms) + # self.isi_histograms, self.isi_bins = ext.get_data() + # return self.isi_histograms, self.isi_bins + + def get_units_table(self): + return self.units_table + + def get_split_unit_ids(self): + return [] diff --git a/spikeinterface_gui/main.py b/spikeinterface_gui/main.py index 2f373e42..95bd0a6e 100644 --- a/spikeinterface_gui/main.py +++ b/spikeinterface_gui/main.py @@ -93,7 +93,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": @@ -231,6 +230,188 @@ def run_launcher(mode="desktop", analyzer_folders=None, root_folder=None, addres else: raise ValueError(f"spikeinterface-gui wrong mode {mode}") + +def run_mainwindow_comparison( + analyzer1, + analyzer2, + analyzer1_name=None, + analyzer2_name=None, + 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 | None, default: None + The name to display for the first analyzer + analyzer2_name: str | None, default: None + 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 + curation: bool, default: False + If True, the curation panel is displayed + curation_dict: dict | None, default: None + The curation dictionary to start from an existing curation + label_definitions: dict | None, default: None + The label definitions to provide to the curation panel + 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_qt_app: bool, default: True + If True, the QT app loop is started + layout_preset : str | None + The name of the layout preset. None is default. + 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": + raise NotImplementedError + 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 + 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() + + views_to_remove = ["merge", "correlograms", "isi"] + + layout_dict = get_layout_description(layout_preset, layout) + if skip_extensions is None: + skip_extensions = find_skippable_extensions(layout_dict) + + for zone in layout_dict: + views_in_zone = layout_dict[zone] + if 'unitlist' in views_in_zone: + # substitute unitlist with compareunitlist + layout_dict[zone] = ['compareunitlist' if view == 'unitlist' else view for view in layout_dict[zone]] + for view in views_to_remove: + if view in layout_dict[zone]: + layout_dict[zone].remove(view) + print(layout_dict) + + 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 + ) + 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/viewlist.py b/spikeinterface_gui/viewlist.py index c48e0ac5..e012dc85 100644 --- a/spikeinterface_gui/viewlist.py +++ b/spikeinterface_gui/viewlist.py @@ -17,6 +17,8 @@ from .metricsview import MetricsView from .spikerateview import SpikeRateView +from .compareunitlistview import CompareUnitListView + # probe and mainsettings view are first, since they affect other views (e.g., time info) possible_class_views = dict( probe = ProbeView, @@ -36,5 +38,6 @@ tracemap = TraceMapView, curation = CurationView, spikerate = SpikeRateView, - metrics = MetricsView, + metrics = MetricsView, + compareunitlist = CompareUnitListView ) diff --git a/spikeinterface_gui/waveformheatmapview.py b/spikeinterface_gui/waveformheatmapview.py index 3006adf2..09313182 100644 --- a/spikeinterface_gui/waveformheatmapview.py +++ b/spikeinterface_gui/waveformheatmapview.py @@ -53,7 +53,6 @@ def get_plotting_data(self): waveforms = [] for unit_id in visible_unit_ids: - wfs, channel_inds = self.controller.get_waveforms(unit_id) wfs, chan_inds = self.controller.get_waveforms(unit_id) keep = np.isin(chan_inds, intersect_sparse_indexes) waveforms.append(wfs[:, :, keep]) diff --git a/spikeinterface_gui/waveformview.py b/spikeinterface_gui/waveformview.py index ca172186..81c1b611 100644 --- a/spikeinterface_gui/waveformview.py +++ b/spikeinterface_gui/waveformview.py @@ -540,7 +540,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 @@ -548,7 +547,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 @@ -1270,7 +1269,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 @@ -1278,7 +1276,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 From a1e3c0f3d0fc36d4010ff2d99f0799b77a436b3f Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Fri, 12 Dec 2025 17:08:34 +0100 Subject: [PATCH 2/3] Cleanup --- spikeinterface_gui/compareunitlistview.py | 57 ++++++++++++----------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/spikeinterface_gui/compareunitlistview.py b/spikeinterface_gui/compareunitlistview.py index df9350ef..9f3566c1 100644 --- a/spikeinterface_gui/compareunitlistview.py +++ b/spikeinterface_gui/compareunitlistview.py @@ -37,13 +37,11 @@ def _qt_make_layout(self): self.table.itemSelectionChanged.connect(self._qt_on_selection_changed) # Setup table structure - self.table.setColumnCount(5) + self.table.setColumnCount(3) self.table.setHorizontalHeaderLabels([ f'Unit ({self.controller.analyzer1_name})', f'Unit ({self.controller.analyzer2_name})', 'Agreement Score', - f'#Spikes ({self.controller.analyzer1_name})', - f'#Spikes ({self.controller.analyzer2_name})' ]) self.table.setSortingEnabled(True) # Sort by Agreement Score column (index 2) by default @@ -113,7 +111,6 @@ def _qt_refresh(self): }) # Add unmatched units from analyzer2 - print("Remaining unmatched units in analyzer2:", len(all_units2)) for unit2_orig in all_units2: unit2_idx = list(self.controller.analyzer2.unit_ids).index(unit2_orig) unit2 = self.controller.unit_ids2[unit2_idx] @@ -138,36 +135,44 @@ def _qt_refresh(self): for i, row in enumerate(rows): # Unit 1 column with color if row['unit1'] != '': - item1 = QT.QTableWidgetItem(str(row['unit1'])) unit1 = np.array([row['unit1']]).astype(self.unit_dtype)[0] - color1 = self.controller.get_unit_color(unit1) - item1.setBackground(QT.QColor(int(color1[0] * 255), int(color1[1] * 255), int(color1[2] * 255))) - # Set text color to white or black depending on background brightness - brightness1 = 0.299 * color1[0] + 0.587 * color1[1] + 0.114 * color1[2] - text_color1 = QT.QColor(255, 255, 255) if brightness1 < 0.5 else QT.QColor(0, 0, 0) - item1.setForeground(text_color1) + n = row['num_spikes1'] + name = f'{unit1} n={n}' + color = self.get_unit_color(unit1) + pix = QT.QPixmap(16, 16) + pix.fill(color) + icon = QT.QIcon(pix) + item1 = QT.QTableWidgetItem(name) + item1.setData(QT.Qt.ItemDataRole.UserRole, unit1) + item1.setFlags(QT.Qt.ItemIsEnabled | QT.Qt.ItemIsSelectable) + item1.setIcon(icon) + item1.unit1 = unit1 else: item1 = QT.QTableWidgetItem('') + item1.unit1 = '' self.table.setItem(i, 0, item1) # Unit 2 column with color if row['unit2'] != '': - item2 = QT.QTableWidgetItem(str(row['unit2'])) unit2 = np.array([row['unit2']]).astype(self.unit_dtype)[0] - color2 = self.controller.get_unit_color(unit2) - item2.setBackground(QT.QColor(int(color2[0] * 255), int(color2[1] * 255), int(color2[2] * 255))) - # Set text color to white or black depending on background brightness - brightness2 = 0.299 * color2[0] + 0.587 * color2[1] + 0.114 * color2[2] - text_color2 = QT.QColor(255, 255, 255) if brightness2 < 0.5 else QT.QColor(0, 0, 0) - item2.setForeground(text_color2) + n = row['num_spikes2'] + name = f'{unit2} n={n}' + color = self.get_unit_color(unit2) + pix = QT.QPixmap(16, 16) + pix.fill(color) + icon = QT.QIcon(pix) + item2 = QT.QTableWidgetItem(name) + item2.setData(QT.Qt.ItemDataRole.UserRole, unit2) + item2.setFlags(QT.Qt.ItemIsEnabled | QT.Qt.ItemIsSelectable) + item2.setIcon(icon) + item2.unit2 = unit2 else: item2 = QT.QTableWidgetItem('') + item2.unit2 = '' self.table.setItem(i, 1, item2) # Other columns self.table.setItem(i, 2, QT.QTableWidgetItem(row['agreement_score'])) - self.table.setItem(i, 3, QT.QTableWidgetItem(str(row['num_spikes1']))) - self.table.setItem(i, 4, QT.QTableWidgetItem(str(row['num_spikes2']))) # Re-enable sorting after populating self.table.setSortingEnabled(True) @@ -186,19 +191,17 @@ def _qt_on_selection_changed(self): # Get unit values from table items unit1_item = self.table.item(row_idx, 0) unit2_item = self.table.item(row_idx, 1) - + unit1 = unit1_item.unit1 + unit2 = unit2_item.unit2 + # Collect units to make visible visible_units = [] - if unit1_item is not None and unit1_item.text() != '': - unit1 = np.array([unit1_item.text()]).astype(self.unit_dtype)[0] + if unit1 != '': visible_units.append(unit1) - - if unit2_item is not None and unit2_item.text() != '': - unit2 = np.array([unit2_item.text()]).astype(self.unit_dtype)[0] + if unit2 != '': visible_units.append(unit2) - print("Selected units:", visible_units) # Update visibility if visible_units: self.controller.set_visible_unit_ids(visible_units) From da9cb34636a7134166103b2dd6f4857bc8dfc3a2 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Fri, 4 Sep 2026 16:59:19 +0200 Subject: [PATCH 3/3] feat: add agreement_matrix and venn views and refactor controller --- .../images/views/desktop/agreementmatrix.png | Bin 0 -> 21420 bytes .../images/views/desktop/compareunitlist.png | Bin 0 -> 62872 bytes docs/source/images/views/desktop/venn.png | Bin 0 -> 11839 bytes .../images/views/web/agreementmatrix.png | Bin 0 -> 25642 bytes .../images/views/web/compareunitlist.png | Bin 0 -> 56013 bytes docs/source/images/views/web/venn.png | Bin 0 -> 37126 bytes docs/source/launch.rst | 51 ++ docs/source/views.rst | 126 ++- spikeinterface_gui/agreementmatrixview.py | 269 ++++++ spikeinterface_gui/backend_panel.py | 14 + spikeinterface_gui/backend_qt.py | 14 + spikeinterface_gui/compareunitlistview.py | 421 ++++++---- spikeinterface_gui/controller.py | 58 +- spikeinterface_gui/controllercomparison.py | 767 +++++++----------- spikeinterface_gui/layout_presets.py | 19 +- spikeinterface_gui/main.py | 74 +- spikeinterface_gui/similarityview.py | 20 +- spikeinterface_gui/tests/debug_views.py | 59 +- .../tests/test_mainwindow_comparison_panel.py | 60 ++ .../tests/test_mainwindow_comparison_qt.py | 55 ++ spikeinterface_gui/tests/testingtools.py | 94 +++ spikeinterface_gui/tracemapview.py | 5 + spikeinterface_gui/unitlistview.py | 7 - spikeinterface_gui/vennview.py | 412 ++++++++++ spikeinterface_gui/view_base.py | 24 + spikeinterface_gui/viewlist.py | 5 +- 26 files changed, 1811 insertions(+), 743 deletions(-) create mode 100644 docs/source/images/views/desktop/agreementmatrix.png create mode 100644 docs/source/images/views/desktop/compareunitlist.png create mode 100644 docs/source/images/views/desktop/venn.png create mode 100644 docs/source/images/views/web/agreementmatrix.png create mode 100644 docs/source/images/views/web/compareunitlist.png create mode 100644 docs/source/images/views/web/venn.png create mode 100644 spikeinterface_gui/agreementmatrixview.py create mode 100644 spikeinterface_gui/tests/test_mainwindow_comparison_panel.py create mode 100644 spikeinterface_gui/tests/test_mainwindow_comparison_qt.py create mode 100644 spikeinterface_gui/vennview.py diff --git a/docs/source/images/views/desktop/agreementmatrix.png b/docs/source/images/views/desktop/agreementmatrix.png new file mode 100644 index 0000000000000000000000000000000000000000..f02549e0aaa5fcd34de8f27d747b13edc29c3277 GIT binary patch literal 21420 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 eZBvlaJD}Wkz8SDY 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 literal 0 HcmV?d00001 diff --git a/docs/source/images/views/web/agreementmatrix.png b/docs/source/images/views/web/agreementmatrix.png new file mode 100644 index 0000000000000000000000000000000000000000..6b14a17310b4b07bacf0dbc2162b398efcac9256 GIT binary patch literal 25642 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 literal 0 HcmV?d00001 diff --git a/docs/source/images/views/web/compareunitlist.png b/docs/source/images/views/web/compareunitlist.png new file mode 100644 index 0000000000000000000000000000000000000000..5ab8d8b1067ec5d04d6210e617bdd53781e6cec5 GIT binary patch literal 56013 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 MRCQIVlx!pZ1Kp;h5dZ)H literal 0 HcmV?d00001 diff --git a/docs/source/images/views/web/venn.png b/docs/source/images/views/web/venn.png new file mode 100644 index 0000000000000000000000000000000000000000..27550fb9efb9ed5a43f72bdeda078a249f02e797 GIT binary patch literal 37126 zcmaI7Wl&sQv@MFeyURy#5AMN&ySux)yL*B=!QG{C4G=s8cMI+`u5ag@x>fJbygokdwgQA_5$65iUTlEgf9hqAzvk zB?*s+kkLk>lt6DJKZkPHl$9NyWeLR)i@s#@^YGvTUd!j6$;ruVD0|i!m1(c9uf+y1 zl;Id#CJPG-1i`moXGN}^D2z-|IXIf<_i6{Ap7qWb|+Wu&-2Pr&v1oyO2tvkJl1evPNj~p2P1he znL_N%Ydp+!^t61GZEJ_fzzqQk+2#>|or*`As2s*{baeEm^Nn1A?H)a86C%@WWATwh zUZfOU^1!8UQXr@~%VlZBXy6jqR>=G<9jN|TmDx|U;i}52n|W)oRO+g#v0c+sKW=Zi zAN+a@JE8$15l!tpst?Ef<%U!kXbF)e^$P3kCR_N=Qz1+B=A5lo}l3yx$-%6 zx5i>C=Fdn@u$ER;5!G@%yzVIFvRMi5(J1WQkn0cE8FcBbzbq_m8V~Gp5OA0v$<1^H zgSLEl+Aje|IUI<3A18LI-K}v;Z@g;Z@VVaCqN}ek9=6b8vmC_f1?@KBEeWm*5!XMJ zzkEz0x*c%XtjY)8rWB~?=)m?{Xz3&N2cW-aeZ2JrwLa@?yuFAtxh=75>bJ%k>H7sZ z`&nq<_I2;j&?prQuN)fuVyy2aTYuQH%HJ#Od(S&{>Wr{yRy5sjo`Gx#7*$L+@Mbro zRW#WQ9e&++`k=tDw*Bt?9+9CPc>F*p;DS`nq&t|`_hb-GX!BpQ)zr^@Bnu-= zZg@Ahn7FvTTk1GMK2zRi%gHZxQzxu_wh!52(Z$tzA%twuIO*vr2D!t%H?3zZ2I3|* zJ~`yW`)LDUhAKNcCT1ss+qoCVj&sk&TBDgXk@nEOk9|=VeNVC^q3chV-4DVLtbNZ! zeII)TnV)?=KYSdchlhtd&oP4hI%IR+&+s(p;0n0ByYW6JaoTJ zcj5%$wQK>A>)7?Sl+qck2mh;PW0iyQMr0&!#`4)@vX6XU8*w_uDAHipt8JNIgf?K-Q?P!-24l*VFH# zkG4}pbzS%Pue;?UJJnoUS4%;!tPmn^*GUlCz6XR%`VBu8W=^`@rfImiA}ZBrz;6?* zsE$ETX-m@4O55mtFU&(x==+_-PH2%IA<*#NH(N@LKvJ64b|o5vhWnP2Ga7+!BxYA3#>72aL>!V3%It=^dxocb${Tc9jEp&57mv7F20pOlLC^FV1}+vkGp+&WcRY{h zLP5qSsr2hXmzRi$h&I;;`_3(k0|hRss#p++*P7(!E-o%Phu(r@O>VBrvW?DTgiJ!0 zP-Rp6F+7{P3%lKrGBE>gyTo-m>ht$p5g1ZNploy0Aqp0Lta_6L9NgIUqhH)fcYN~w zM1Qd{@xJP}G)wkbI%V@)d=`11)sW8;L=09tnAvOP*)*LgbZ!Jj0)owh&66+brl zH^{LUvmtO~N#6^)%qBT6#ckOyw6%LDDBnA^p@9ve$<=ka!GsEe!%7~W!tAf~S5CYC z>^W)NIKN|l|Aa1$_TCL~FqJpAd)Vv)*5r0vowSL=hg3FD8w&b(;{_@^gg%fQ<#YJ6 zM7EsnhVy#O*()pQU!Uu`p7yy!ihumT$e9|^vC38aIwGwoWFc&0QyEVnEa*KG9gXmQ z|2vGA1WnPXDKj<}iQ8clrGHQ9Jtuh=%P`>j$yj9nuS^Q>N726j>sa|@0S{>S;qDB~ z>N;{F8e$`vG6ohA(iaWVu7PgK! zg4|ACpKx&Snw}@>ANTKg*iBw}Wo2tbou|mXF;=~&{!Awb2B(%l+8G|E#=*r0 z1KIz2%&GwX;Se8ZyY>ODPf<=`VDpND10}4Yq5@C!z%^p`;pO_tKdQ%Sj!P}f1QCN2 zH)3?orEhnw)y`0;3F6|+sM_Vut{e-xW3@*Ya2JVS=ENl793hV< zN;#`g276X<7O#n5Mx8dWD!=fyqK0N$u+;ti=Y&8GThlSL(~j&FR^xwVK2g&EsA5wy zq$A)3?O-Mc`&E~I__f3B(ewOSG2nwm%lZmDVrf#aMD&WisJJwkI+pVE80N)>5*A`( z1M#}Mdg`-RtI>V8xQ1f)805G)dFDW%jDxqxvwZB0=HkGh2 z({5Yd8flweZ?4R(o!Hdmu+Sp-IvRIF{;b`8a$@^6VH{yGoH*~Z6AL9JC4fK;ecuTe z>$mzjX8QV1oqC5kW_tQt`yO=~R@#gG;GisXLULS~pJbc3mS{mlp-M(P{tt zFZA0tjFZ#T%Bm_FK|#UCAVK5)VD1?e*;e0TcJC_+2sDyOi-So*2z3n})ZFT-lgEF> zrd}`PxM77bJ6&Gd16N&}Yr4>848ofPjT2)BiJFC(P|E?LA^Qj1&rvqcTBie(gG2nQ znING-k&kOFJZ9e3;rkJce+!+%;}l{Ez5`rwe#c`DzHA5l)b2ifW88<`kIdRLd8ATI zkeME-8oSbL>AaX_1@jQ}K5Y9MGOaLtd?Ms0t0dT9Y5e@<4}6)+Icp%^dYjjl@M7)S z7k_^eos|^;L*;{K1TyH=XxwI;E_XYL9W8-s4-4H)H57@qzprIP4S>Y0;J*h4D$2^g zetoCXB^U805b(UPZ*;iq4KR4lFH_8#1PZL_`SV?FR#SM7Uo!-+%YDr@yN1RBkQDUm zlns6NH7}0&XY{>=-NeRj`*#+6+>UnNj}UiX{h3f#*Sz@~lz))twRL*s%TcCeW~@sD z+Iq?{c02qBmdf-T_xcCIc=Hse1p^S#-|omsCZ}L)WX^!xNmamQS9Bgbw9(9A&rQso zlOW`G-y%@N`RP-5?i(Lqibr*>F)Eoa0JNTLr_hkvRmMba_v1hb{c?O}G<2_Z|4& zzWdGIJ%(+LYtvOAJ+x<^zm20l4{zwJANkWl#K9u#e&i3E4-iWA{*hN#&eH-jC&+IP zYD)j`oMX=$8pKHASjmqc-*`6lhd25@s6ZQmv`&3smd3_LeeZXl@W@El=j34+D9Avc zH-&Zg2?zU?rZm7O+6bS;qRqqs?lMo4w$M7?N&GU(34Bo3T*6|j)lFTxEN|*euUaj` zY;5(o1CH+66d%`=mzI2To4f1#yZJ(9zF8TU#SfMkpP1-43I(tH`Ay5~w`0tUm|^&ror4mTa6xhKFAz^$GpFCl&aIXNNwIIIo?1xh~ZQ4ev%Q9?c0{ zwkL>&{mJ48q8$2M^WI4vhwnO}z~g@EcRVR1U^ccZx$gH7rth|q;BX-VK)lV5ZK{L( zrV71%nD)maO7sfP3E%aX&Lz@E^Aja?xqvhTMa(C__rR|8G0XEC}xQyy?oq8 zDZZfqq&fpDVFt|&-_rTW9Zl%|ZHU<%KAh6`?n%CJ+T%z%Blwn~2 zzXbD;@H&5^C36tcYUHcsf=44OscMRyEtYCssn^uk7c&eLrjkN&Vkao?;&+=dii ze8}%9DJ@I>hOuSaH*;tjLGOn!nCWz}_HgCn zJqkb_ZI{bqcJ+N@s7nTJ%cpSgp6(5Fa;BzGF)@f6%)X!tpBe;YWV7Ay>+=gQUJ~Z7 zgs+C3>41FDyF6bREC!0<#U)*@#||BhkJI!S#mDL994%&>=Ww(j3P6v zOfeF80MugGlSNM9*D1@kB8?v$B2w~uuFnkN?0S1zCp2E$)p}HG|nQJ zOZl_$@VBJ~XiY&{8ewN=X8^-teQWm6vn#Kng6=g?#IPQ;;1Pk5J`!OSKcb;&YipZm z?O2LY^uXm5>7Z`p#0+Cf02gTj1OLGJ(ODZ{EW|~NfdG`2P*LBbyNJg+w+?+kaWYPd zkN@ci+Vkw9XJB}q?p8MU?m0@zP6~G*bDkP%c(Ps=mzv}jsegJuc+2>cLH}I6`PxNB zNN96s(y7m)f6nwNfXQ0lqELW=fq{;db@bdb$Z8WMdQT19Z%5{c78M3iHO&y8Bl3hq z+eCPDGL$wp8Ryu&7}Zz+L7SSH#l*z$71@z4f!UCekSeOHhxT1tpBk(wpL5!vX7;XI z2-6J^yV~1%^;xvy+&(c4U-xq=00BWwO$~65p=dO)*eU?g{*gZ|X zzBL2H$q}#vH?e0IY0ealD19QMu3fGoZDC;{4jvvB5z*>-_JIp==KlWv|LAD3dtzo_ zYw*FJHatq}O)sDJZM*kX>oaHvLD034O*-x}+aAhMY0k__?PXM-8td3BPxF zZJPfoe_M~mP4=Y#r+H4n9B;N}^!v1B^Nwo=9Sh6FJy+tIR@gjXcUjMDt--9}RP&R^ z-&U$btprA0k0rnaF_L=zDtgcPPiwQm`3J3{T?Fb;T=&zPe{} z#vskkt!Brq5HI12waga-tXhHW&6~70SjJKp3V)JdEhXa;%V#!jPe+f^=Tl1CekJv8 z*S0i#BdJP?J%#bj_$uim)MB`mnjSgCf`@|LTxXB4p-~_5+*udCnYMN&iZ;e*?GUrk z((GQ(YIWLpw6;sPTi3>bhe=oQ8khXsyx{8?e=LLD7-PNV*w620OiZJggo&ipHlBBN zZ$HDkygkkp{X-z$gU9&hzRs<&%G7cJg%WsT6Vf^^>$a?%=mv-tLQ#?B;ibI!S!FUh zI)nhR+z51AKJQnT-2v;(Y%o?8xxGL`a~~ZDNkqS&Mwv&rsU=_*^R~SjTBzSwk~Ft#mpVsgWHE9Ou_YW zv9Y?{m1i-S5n?3BL`%s)4Dxo`jg5pyB!452>X<2Ms|c^CaQ=#-RNPsXxY31fY@1+9 zceNc(AVwMhQWV=GPXHudAk@)bYWfb=_R%AF@kpNqYFcS95R`RwSF0A6XMg-?v&|YC z8v~-S#T$j-mz^j{7eAP%tGN8$T?&L{?DBwPEv!5go)Km+Z2n~4GF05Uy*Y(f)LP8pG}~II81!AG|Mo6L3)2 z#oN{XvzgO5a!@utE-OFxNl=iaYh&|dz3uTWkb#l0aqXOWny-ab9ZH=fZq-kS^P{^Q zzJ=6<*e1XN6(YaWSAMSKy7l<_srD`KWIjQgf;iE3>H2=F+E5=(=mYw!ho<9f!g8l)k=p zZ|HncTH8qci}1}gemRkc zWvW#RZcM~dzq#Qo_goS}Y?@beQLb!WwcuW&_Y{=gS;&ECR%y)%9Y5J#;vVYXcTd*P zmyezrDZ0iEed%4vm>4UD!^GR=P1d`oJMO}H8!LqNUL{)pP2ispvZCE(PZmBEcJn)_ z?Sl4aV-0JNRN955ER6hASat8u^E;2{+z=NP|Dfql@hpL10B$HLDRFO&79)8e3TwOu zACOOhKzEZ9BUkq8oBn+mMXioYY^dVjaYz+CU0}tr%y`XI;W{kRx;q{5b?pYlNOA={ zI13fwm4X^ElUVy3N|p_c@RXu2lSh8roqjhWy_0Fvs7`IbDKgurGYM_YKjV41$>e8( z?K0U$&Rl;P!H1DgxqY;*%j7@|_6-V7;DePXPhYLYFPUeHu&%Ae+&X9WTQXF0)GMS9 zw_d}%Ga-sJJ%}y~5uDN*j&qHG0Yxo4NO|-pVT|S^9$1idDvN?=TM|Y|e5ZHn7{hr7 zi7fSq4vkh(p}T6W$m*$qo&*2l;&WqEP81E8DdH%b%xJw8v>12iEvT-caW~!?aWu1P zn|*G*X{k3VZH+YBTT=4r2R>9Y=$+;=eI60NQ8slW3MzNq(WLC#w|B3kOv6m+(F>h@3OYZvsIsG-8l@#(y8mR_a)I26KK_tgqQ`K z{J1qi0vaxP6o9!HV(y_ZWk8o_77;*52$T}E(#HS)lVYHCObRr{|&J#q^ypY!3%WdqS;3ljqEvqw%@<}0D2UF zJ<75s%`4GL(x13l60=VjVv&#mnTA>HHya8dO%sZk{D)1WK*`0NkeyBB>9{t|V^m*A zA(He3|E6M%@r~Dj^6{icT}vGfncx^{rz(O&l-}^gm(lM=uG7y4m_ZS4DbDmajDx(U zU>gog3^FoN0|Qb&&-1@`T83q9Z8YtIM)vw7`2J3OVQ}aJmtYk`&9B}PFdUWz{ma+b zkJ~+Lsnf`&&NXA*)-QEC$s5lcWSV7_nq#J=fq$3!Nt4^m)XLF|p| z;NVaeo6YA&7nd6U6A|^)3`|VIF-bu0ecm28+i1=K83v^_{ZTzbgXgfEvb^Y4({8Xv zg!Oqcx3cm$+1x;r_flX!psdta)|sm7Hnop51;2-080rE(WUV%GBtH^QD&^pST5TAV za8?uWllD{7?YaeuJo(R>722zdwQ7RT<^(p!$Eg`D~154RVqmUoE;dppj-)lbf{I8YS z;5Fm8X>dmq5?t`T_^VknA&RujSNh>H)YEHk!OSv5ZKbc*FK_;D3$`thjeE52&znO0 z=yR4FyiSYW(*;5yC7IFr-7SEd3|xID%v?3UvnnuMwx7%kVldgkJX`HZ>EuU;PEKJ* zfsWdM44yf(1M@@nEr=13k!>FPJ**DGo?Jx%)5kCXVEOgaD{rpiw1FpNTzc;GLwY?0 zHw#)lh^%V^O;$xsFHAL-eQ#vPnO{$T$g-K;<;FZ!_3z=n=kw<16qpU55dJ(zkv^$< z+)k!z=~LDX2O8X*T}51cIyyi-{R3Cl{Q76Y=lIF0va$}CVZ2Urmd~IJgnzT8Lir`* zzi2Aja^cVYL``kt#mlCE?d9MQ_Xhjq32xu{4C@Lm~dX$kt_ zkT*X2wa$*Yhtz%?;PKYel{znV$jB4*|E-++H8#GxR=@NkzRd7z{mkggWv{q|#HTsS z$xojdVxxZ{w6xh4>-arf{Xw1qJah5*MfP9p9%%v2BV3=cWaN`I7z)Mhs2$bShGzxt zm93pEi%~_YD}m^wt0VVkKd8Zp1z*@nUUgE9zgk+5wnbmYzhBwzakO zpY7JQ0C!o!(DzyHbpWt-&7Ehb@Ll85*RgS?f2~|0hs*p8 zf=JW*7+Mn!6gzgPQiAW_MJpIniw2=4gJVnx;&a%(UqvG@FfssqA5g$JnL0P`B9;ns zxF)kZ`Nc@4HP_xCQ${yGh*FDIH#{{!y}jp>EB_kFLo4lpLPL>B#s_EE^h|WdL5kds zjV?^OuKe6Nc|u)YX4#Or(-<@gS&;zeJl~MBkQ~O`>e36*(KQ_3eBE{2Xeg^o^ugxn z@~HdQK5z3%%!2$sdjSjt+8X^1(MIBlXCk)r6lG)(8(kU!Mw48SF`CWV2XGOrlw@i0 z)I^(X|K~BOfwaIx2@n)Ggxt{oCl4MF?VSKw0FTSDDr;zrJ;tO+E6S8@s?D@9=jhxW zoTjF0D~%Cx`@?v4h2_>T$;!!gF2MNF5#Cb$bDQ6~YH9{kYfilbJa)bR@zbQQW)sE6 zApsFIU_3c9VFAoDhr90bdjkyO|1`_v7qg$AeQ#Xd#wHy6Y`TD%H+s!nY+nnB{wpy* zUCL>nna~&P=462kAH5gz|4Q-ym;8^6A8nQ#D=)?;#x{XQa$bZmTaEJ#tB#PMlD>}P zS0$8Me$ukdWzU0QMxs%>vkB;uI)t8IGGe$t)uQ1T)m_8na&v+ZF0M{o>+cXe`rS!S z_>r`0=9mHPAbFqWS+%-Q;M-hO=DJ6bsqrW6*~-eAD3O3-ANL8bdPwIfNp!ZWj$D>J zw@;AE8e2Udn6pAD)1v4(E2kip*)iwm+M}y>!@dlRC~&n-Z0QiL5P2G#(~pDMITTy} zhF8?7UsAaEeULBJI~OT98J732e;jvzNgKzSMH2BtEB2udnkIUxgdceITQhR1%ue3 zV@PoQF+n-bl1jcv1cuaiOnrCdPmLK`ah(i6AHHw8e894rnm(Xpo)cT7CZlS^KHiD?e z-{MU_2m>ubLCvXdm^>FOhV7^-G52|hmW`OkQ;ypu#vCZmWt7{$V79iNBtFZK)~VBw zVEpq~s4oSN&{yh1ZcBRm(WKG&U~zZ%&yihN=N`po$tnxfS1VLU$xNsGDq}|xeG=!7 z2%isrpysr`o%WTjwq9Q(x25vMgXIwC6RsmZk#Ma*SW60Thdg$8suw-?XWgT&%~@$Gzgc&W-N+9U zzi{?FDytl7g)eiWPe;XZ;OeAZEuktbDY_4|K*s8c%&9MkE4J>gi4AWl&N58|r)Umt zMv97v7wH(@O0RJI*KlifUUVIf%KWr~QA64HaKN%5rMZ5}5YJ!S4Dqb9xaSxrS3UgYD;j*WMpCb_cy zOW{E{PP-$KV7Ja5qV^#Hu6pl6dMO zwk%-S<7y5)erScc z#LfG_D>mG;@HPPzKD*f|D9i@i0C=8asi}}79J;kFcf)H(erzj#%O7_#8odjLE4fYY zS4ZuIU(}3MTeJpM-Q^^EOr0VueLr{mX#G*~0F4(Yuhz?o9j zS3Vl|X}nBEM<`G$y!8G$)&F8K!q^h&nq;}zpZ>BQcuJ%O--0sfgv?cT@%TLPHB-<% z_CF9@dY=tUm?4S4C$<;rguoBc`CRhCl*rt~HPGt3;^B>uubs2n|E!{?calw1Hlis_Tx?HL*XW#qJ>eq3PKp)YvL})Hy6#TGh!h~+GgcT(yw*o;> z1$R|*_rJMWG1nc*ZZzBGxW1h7?lniv=C_3@`=R^n%blx|guLnwAG6Yzp|fS>mJkqD z>u_pcgX!5AHJWd6B}!6q>FCMwg$O{ z9#kP-DL~eE%c-27>+?yBMkWvp^a}Z*1sUW2%L=lE|6$e6R!jQTS}K0255o00sIMto ztuS}D2=VZDmt9+mQD}azWwG)5*ojrq2#0H#iJRWqbS<9>!Q}d}qy>w$MH(48prU-Z z;0M`hx(XgDqK2S@*Thipz^lH29wTNd6oYp?YLqOKj-O*_t+9=AaO-eiJ zB;@9n@L7Bt7_ikv!Lm)_G2UQo(OKAi$|J&gwO2h&^Cru#f7&(W_4vJ$a+y4~T z*Kvo8IFWD88DD7q3M!XIZ5ClzjjLk%l9U~Wc~#`bqZfyWl4~BZ9g=W#^55xl>r7$t zJ3O9#-0Cx0DrsHpRPag`ux~kgPQW=BmF2;bho(>9AG$Yml!@hZ0SMS9Cp%WgowE}aC=io zO~nD9GLAAl2J;tUCDB{8$J{@_z*HPEu_I)Kh z+G!?|EoX1){Tc+WX@&ZZA@l`0qr24mm2)Z$8%IkKLV4bMr*DnZ%YOw&bxn5XuhsSg zZO*HGQ^-a`m6Oq5+dy!ssT!?q1_Yzmsy3&3vvGszfH9DS%ZdzLp5vV7+^3Xi=!?W^ zT)khNKX|f5>Uq!v#{CB<6uiZaP99-SU)!|z(~qf1fmWX7;7=LnC)|PGw=V(A?|>Mm z?>U?nKR#(b)Y-bxoAKv)nLOfqK|#{!-cC5SqIK)#-ICE^ijwe7@5_Y%61Bi8j%~lR z9Us3ESLdtoXcRGbv_e$X;CAp2mMwM~Tqt3850}$CO3aE1NKFKC{shTrvZ7#YH7-x+ z*i#%mIRW8K8|x98{!OHc8&6jM&wHF6?`tV#c)2@-uA7OS`0 zblQDeqmg%~p)Y5i0<<$ZGv(w#68&4x#|j$&rU%%G5h1rJ3_f#T$#7e4?wK*iS;ZuK zuIF58B{~Swc zT(N(B&ERpqS$_f=|2OA6cHY}bedXQVH9Zq;GXJxs`Bra&^+lMl_-nJnDc)w#;raG` z)wrx{Q!~M5cLHR*ys!RZPy%gEdv_T|MI|K(5nBABw*LgS=i23EZBNIoX*DImMO&3n z1>#!E6*WAT!9(!%dQicMr*yjiG#>{ovQlSb~xF9;BGa5%@@ko%u; z66J9B_y3w(?cDi3-4>(eKW@mV0)0$p6k-M@JW{+cq_~g<0)!YSDTHNl<$H`<$^u3| zgfbI&j^L25IgobDMzx~wzbzre?GPT>gcd5EiPQs_SMA>90vv|!b=?i9y>n>vY1=u+ zOui98LJxA%?_lhxhYN7B2!snzOhWE>BDm2F5)1Qi!6-i}By5ETx8Wf_UEJ0$s=Rds>;>X|~1r2Udii!&T z5nyhdWR#@1wfU8cf`ibi6zJLq>~JpBU-aa;ah5BsF=M?)LCBDVk|tH9BHP4b7R*39 zTrpgh{V-SIDeR=Aw-v(wcPp_TmyNPZHpQTHtTH^;R#K7LTtie5cOL$e@^E8uRa0`+ z_&8ZdI!xK4q77NRM7fwd13wMyxqWuhLgnE`7R~r=v4RN9E8QG=895ZB`;{GbDNg6k zP=CMrkJJ!NxSjFQ<=>{Cb4$&asePoSHwsFUjQ|1Ijx$Hr*samhEyh$05ocaD zI`&-&N{wU-EAj_cl=vWh+heB&i?@M<$MyY*AKkiAkb zI;r~Yp|M>FQ&7H8xZyMYToTQ|NK(w0iKQXwYaP%vYFkTp&qsH|Pj?fktbbYZ>#*eK zsr}xOt}~2sn{P`G8VMy`)Wq|5{nuDwm-o z>KFLxC`n$<#zM_+BOMg8u$4QDhY4`uLxax4g98d{Z5i4A-;t}qI0c9FRXwD5QqjeL z5`SyQK!f^cMC^puecbv-o*{odysfM<7qzt)pLdlmjF}hIwX(`RMEOrV4w1HSwcUwm zueKBj-NruQaOiwxw8aQdjCj&3pWjibMqykSW0-J?_~V3*Z)*faW=LkU6Wt z+j07gU#9XOl`xCoWsGCrCF#{qc%@09ZT4nWWE{v3>m(LZzbGKir;7F5Ep?ZFz$GEp zAEYju9*ktTn#!+1BillI-uu-A3o7G}Lnc*^PGJ761_`h4D?Btr{SY6d;Kahk3ULkR zw)ZmqSt_p257F6+H|{pO-*I$up>*N+_j;&Tc3Ep>{+7rzEY*4HH^K04x~XqRWdU=^ zxO2%n_-Ygwzq^AVG|%U1#x>P_N}n`I0u*pabkBBP4<95nuS;2V zPN}d%NQCHldRQqoNXZACmJ7Mb@BSp}?nBmD)i64)N*;X1AnA$gA3o@49Vt2RTYI>H zuKE(Y-!g1iye9Cs2=wJ)W_KQJbrY@y8KRPl`#p$lEUf- z#X>?H_T55M`mMQn+Wk5*5$55bKm>=Zr2!UD+(zrpStau2LK6u)VAg!Goor1(1+=iRag!~1cV01_tDJXi@4pygz7}VTlova&)V@4~fYBDU=U)=(7 z_4dwijt0Z-!A;SW%!(S9jj^n1;XS~E#j|T@*?{)7HwDb&>Ssb~HGtOer`FIcO9yHN zCkV!H&FMtWqX4&}3Mt+K_551%SQl4CtxMt}=2Wj@9C;g3iXY3I* z;93>zrtn2l`6z_6dO^84Dru6+ZR8qleM@%95Q-MAHsg@#;Sm^7N{Mk82^>Ygr;p-->LhOb`jMCz1^Dq;wwNW)o!cv-SwNCDi{I8#mr}A z8-}`oX|~(Lnz6fF?m?jq{z-_oj}4zVVpyaB`t@!}#A>=Bpz-Xd}Ncj3K4n7Mp=iFN<@xlN zaj^2RU*YHanb?0x43{2Wm#Zt?htyw>bQqMzKA)p1ZWDLE7z)BOaA~0A-P2p*uZ{x% z=&S`ix?3xi8hJ36&*JD0=?#p0IBszcHreY_?ClISt|a?K!6r!+7ApH;%y0cTJrf6C z5)2T`@Ic!2xE#R@tMJ2qHIgvTzih50_4D$l;*ZiF*7Xi|*J7>P7QWu<=sXM67M9KH zy3QPnN&PgF_!I(#n-v`^-;#dFxEjGSeQ~#YRK3&1HXgGV4O4c6JKj({!XlBR6OLxXJylePRBTP&X4c>u8XP5+!7`kAxI#!Z0` z=PdXJx@VJhWu4w-SYL=OD#esBbnUfl;4p2rhCXT7Vcb1DzXUq4ViJ_TZdYN!(1neQ zl}N-%n3o(gy%Ec@x<foYie`EL|^M(h!yCN>>Y_M#K>y z%u0mf<^nYeGnVKxZXPE|9;ipFyo3|frQg`KesY+b1+|A=n_Y^Vo84Ex^y-r*nozG4 zOZ7|->{0qT-;l+2ZSQU_HrKZ5sy?9TuA^9xZlSJ?%7d~oM2JW|;6OBtzpvu2f*B>? z{5q8+wnYI-%{@n!04XKCNNtqc!Ck8e$@7biX90K5YTCibK-bgL_5qj9Fh1^V z4NFq#*U+1zYG%o$^ddkKElM7+9F0~CJ^D2H=j|Ja)L^Mgj)a@RoiTq4aqD1c-F>Qo zK++sx-6eGQPa{Av7X(3ap=vFkLg+iEcf=IM+4_HXPO`wY3 zvp1}OX_ho4Z&Y*y7XqJfMGn+)O*AvaZXx22%pu|*MKMGW3>vV({bh#a1oL#&NKI`F zBh=OhsOq00^x`N0HO+LGU~0o?fCu(7o3!@d`j{guF5ljTSE4_hSw{Pp;oZyu{3p1^ zy6UhzC@Vv`me9UUr(#bPOo{e7_S?&4(8}Mf6l7wEf^Ds*wZ@9sO#l3b=Wf+TY6Nki zp=E!E0>g{7V_SY2vODbmaQwaA^EH2l9&i%cN=QPsCqef=4P_3vPZjG^i_lpw-_)|! z`TXIxltrdzao)(p&mSo&9*IHf@J7n(y$u0>e+cZG zL36V_1nTb4XPUe)zMb1L)O@1&b>^d{T;cO_7(3zpJSxvtZ?yfBSZiS>h5qu=@WJE-ZoB7Hbqb|HsoPWLF8f?X%mUeuGg}uG`0R3z{m{>Sl?mdsbpbw#6ElvLV zqud}mi><^)??Us}+Mh#F8*#U#E7DhEV@KyT7B61%)R6!Xo`lHe0#5NM0|gB#E~XVK z3J^FPo)pjhG)F^=@!dq;rq!mFNK9lXp2SSV7PV@A4@4=WAXx{nHBnmDSjV9eLjU9n zZE|+{R|N$wqo611hzQun0O3d!;*sBlLt`WYi9k2H*#k^t=>M)`YuEW;&5tNQOT%@| zbo;Y8ySI)d;_bBGVDZo*i(GFKcv>8vYj)`W(`~HcOsr=x6%barRe~h%y&_*GiYVho2%Ah-<*(J~&C*f~C-AeFZHsPmF8LaS!v zccB2kI*RnL^VwNRd-PXGu>sj{!hA6`#macwmK{kf2;R8ZZjAa^sr4Jz`!pLWOAy=m zH$B}Xs|(k&J4k)-X4*u++jnrpSo^{y>Us(K7%P1X_5#U$Q`6mIE=4I`G)0+82|{YC zjX~W9m-Q^v%FCL8s8d~u7>Ya>-3l#b_KQ@?l%?6>{kh=FR=IAth1>A3s&gPOK> zf5oJHnS9P>tsek^Da>B6ZjV30vaC_hRJbe%);2+3`|leGRrdXP9k@i~orvh46lDZI z#^g$Wo}PhkqKl=p{uUv778ApL5Hv!wk1&57F1z#5X-j7y(}h1J`YgB!i_RL_WhrM( zrcnHuyOwQ97jt!Vkgr-Xc>xXVasy+u>VR z$IJ@@WZ<{Xf-Wg;uCDWHWD5+%0_TBPHcQp!#o9m)ZJn%+YJ!KNq&q zfg&wq2bgU;aVn+MbBRK{hCiH+jk+4ohUvv|HJvzAE$N{ETp(ZF_>8pdw6CSdSNWb zYpro|g0Z8Z{>reN1BcCkWK;jpu~u?9>-(~X2In&Qg6Sorx{=d6&*yHZCI1L0*QR@q zr+lo;EtQcPId%?3w7^<4dkOqAUIq)D^wd%4V$W?hc?i#?$ZOeI2%Qg&7P%Ke<#uAy zl+~8*&4#R_ZwQm?bqhqkgYbL}eX`B&ng8+Qun^S}VAVc7Ma4oJ*xh00ak?+FP4g$m zFHruUy#O!zlfh#jR+qg)do|PDNkKClR0$<2m&q1#7aIg>^7V+{lvRz@@RFJAxrHe6 z?2{Fm5S@Iv$I4R7=a4b5#H0&hQQ%P8gA=OlJ*t;fa=w3^@%i8_CL~1B%;PZIC^+4{ z0mpOwNid&sQVcRZElvqEq{9K;^~&zJsM!t)(?Af@zKo8urfwpiQCgM6e=Nuf73jEr z^J0!Cy}s2}VnO%B?VaQZ&-?)*^fE`#P>ygVnGbEHGotA{sBF(r-6(#1Z+1SH)&35j zGqJt7WV`vHGcs6DKAb2GzdF;2#K znySj5S^tXsNWFQc>ck=Bx!mMapk7h}xaeso+sem71s|fgZfJv4QgExlBh(7%8cMw9 z=t`8XLaelG4@YyiOj3-4w+HrQj!O`(Zxg;=7sV<=ypi0ok4U(8cwg(KG|rRz?%`qX zkAEsIqn3)cx`fpz@Vo5g1{bP0hU!Eg`{Snlv-S7M)ZVgJm_x+YTc%q^$HWK=a9~B? zysUP5DO`u$9>;8hJwn;_P#F@U9D4xO(9vSpk?5SM^JnC%8Xs7OKL*53LG=-Riz63( zzJV=(ilFsH(a%BG{G;g{O?!FnaDKhz$u9ccOZYupz$gZ&3N5t@xd9=km#Ou%o zO51Nh+hr;s<0ua}@`$yE-(-dt8FQ{kJ?pdF+#4!+O3EJEGyoIrVmBoh=kIUhWrj<%F`8G&vF&HX7D-3_2hK2WDKRB=X*Y z$47PFkZe?n1dipbjJaMm@6q|CQ;3^T$d{Ix zAnRyyF_1%V_|ZKkS|^}vsA}j&B|WMI@+N^~Uz$&U$;w~vZp;Ud0xWru0AcH+n#vE< z^5Z7~J#l6&b>NRp{AAF{`v|jXI{U`3Wv9ZaUJeRgs&Z@I3+#L3u(chHkjA`(>~;l; z(hvX)sH>*_^UohHyo(F>`hiPE{HLL!E^BMW7yPfdR0VznPQN~mqds2_AhFAl*}ry{ zL-|uP+~Z$TiqFMs=Ijj0_f-&JBs|HNkCgeZmKv76qeN)A(*AVyQyRzTGCbgDX<99a&qFjlrz<8*aMkocZZCuecJEAU{RMU&B$= z9Od&?jdeSI)PMeKXg->P6V`rT>Vs-j2prqVUM%D8;M2oWZCpDH{ZtA?D-S(Z z)Ttsxv+nkKVsWSNsOaCysDxBQ{?oRz~&6b$Cv9shWB{7cY7sbL8STS|5Q4 zeMtHnV0}^g9^6L}WrzzGiD7eHog%!mLjKnG5Jb^4FE0{T7d3&`gzxPfNE*+c35%ko z;Vo&eqSmd)8a^@>8;%vbeqk;D4I@EzgPZy1mSde{*GW?TaG>FXnQHavxgq~#uDLt~+ zl+4K)2Sr7W!|q2re@2hRpGfHp?m36i#}&mF)=9%@MF4;WOC|e3z}>02*=ZS13=cS7 zBUhZ`I0vP1s&m(m_CNknq!|FJSqVO6E$0xlrO~?orS{$G$GTstZJ1ZDcLXb^gLh>-xg0k<6e>Hp+Y4$fJfo1IkuDnzgb7ZIY;!gO~~U zUlCy`Q39(;(r9xt0-9uJ?>>r!$%hOg77+gS zPzOg1QQ~wVKjUyfHNLm$&%^+uY3Rwij4qiIOpD_=&FhQ(t@G!kth+_^E*DG{d(fM; zd(M#K{xiu?9!OsKaK&xPPa*P#`nkaBI1$ArDm+AapRStP~^EvFH zZ5HpSommBm>G1Jxe5&il^+8hed7%#z?>Cv;6>%);*AGfrk7Fet;+qNKGB&uqLmV(WOixU*r~ew0ELqs zMy6LdyzB5C12(JG_%5Ya+x9MVM))!f?e_Clu&gmoiI?qKJJt2mPv`))PYM;@RIXXN%_fZrF(@rvNU~a0citN< z1i$UE6O)=R8jy2oWrVkg8dop#-=#7}#|{=f%@xHNWl20iG&6;y(@pO=OYWq3d{ybG z+cm9%>5q=?A(0>Imfl;84@k7)l+Q58NRj{kiC2%lzLm70E4pB*)FT#Y0fKXyLgRu(Q9}GD_FwMStp0=pD9|Hn0TH(-+1DiVLlL&T=mXs(N9`$t*WVW zGz@tvdVLPw%g9zFN^9`Z{Bz*x_(^cSjBjN06D`eMYnY+8kH zy06|BuMjycq7qUEwf7uVFx5w9oiMql=#~DOmWaW>FeuyJXj55*jkgg_;}@UU#I?&V zO1W;9SF^~vJ z`(vwnANI+qZ>AZa+wOVy*Y!Ft8V5L?D3$Gbj!uE)>;YALSN-6M3qQ&*ULnPSXJ4R; zIv$)C(S!)deo`N9xi3PmB6CH>Pxc|7-D^aA8O0II7p|J~1n%~l&pH#M=(XL-_BRu= z%FN3G`H1qF+Bcv<`7%3H2=)+hyITj8Ffl*!Zu}t|nrz?xC0PG7@KF z7UE!VB1X}Z-$`?M)K$$+ix#kym^0z#%3dmk_Ew3+-}DQ4l2I(KJ-e%5NOneSooU64 z+fkJ^ZW!VMWX&pi$|_;MWk7BX7%09s&B&rM)Z2`T*|UjK#(1{Z6^isl_Ibo&4&bJ; zTJ3LOXEe3Ja7+qiwz^&dLFXhLq(gky7he9=7dRz_V+Q3ItH#D-KNgUw_qi)R=U<9+ zE99fTlj?!d@`h)1*)cp}EU{0OYy$uAMHb+ak7eNzrMZ6BF%C1R?=_@d!6BV9MEdmj zJJh7_PKhOSr#R_BSbCLq)g*kYF;R+bYG$Z|QD|&=f{MKP1mQf1dh{XWs`gb}K~FLB z7M%7^S6=X2`i#hgW43;0(}~jK&QE^7VCUz7g6T(s**bF3>8w_cNF2{%!9u)MFXNQ8 z&d|x4s$H`KJz*(qgXH_%>4{|ZufFpeAi;9wnJDaO3c*LoYID`v8dzwaj!E=orl|(VbIy>oDE3b!Rs~#I`VxEwOgDLC%9YOFmgd;{%zQ7`tzh_TNabF`!XcIrJMrnIYemmKq!fGjO zdsVQq@uPe#;B{EoeYG{>nT|Xvhv~yxS?vl9)`#Yygpl%1Mu8N)HiR8Z$n*JCaNt#y zeu1tL9dU4xG#Uh7$lf?4GafzI4z|>SxatS@(~y5i1^o$2vp>>x=yy5o)7MZ|YAAy1i3qeW4X0}Gy&&h@SXABX-)beCG>i&vsDHQP4$pOu!rq{sL=gj0yVrKvq>U=)Pmltc{lz)4v~8)Mg}O>DIeG%wXe9W)9VgI-R)YM_o_ua(|>(ALGske&gIif zP&0~`T|DE?ZAM4#5^j&x1)k4A4r_)a<`$@Rlv#<=>XbOe$WUHTgyr-T`=2lSS`4ZC z$1Bfzom=Tu$VT0$;CT*Ig1q2Qh8BrFLSi41E+df(mP6SDC~DWk$=Fcd?* z<)@M+Z^O~u!cqx!_G+Fl=Zix$)vREo!8;5qwR|wYR_+-a2^?azlS>@ml?4iqnef3uxvNkNW{W}msL3w; z!52OW`p6jeaz{=-T}6fE`k*5{pi<29;dYxSjhP`tNHI&c=1AA~@F}wN3#!V<5$1Oh zov1M0mj-r4Zg6%|G)L*^LL~IlN3G!u#J?$CxA06-FRjJm7dA!?5XF+BGWz5 z1C9+%)WoJ~rEH-&h=l`4q zKf2oD?q(d4C$^Kr{wzdkyx(i1$2ZZh0xCRXP)Emj|DO8EwY$eIioJOE>}0Y(HAe`G zFb%WSe6FfIsq4QRv|D&QscroB;$lKcph6O3_e3-_DbfYu3YNZY2D6(maf1c{l%7H` zyJm}(Nd^WI_Xp1uHQeS|M2pE|=+69&Pxjkk2Ul(0Hz6!CQk3{IoZ7J~T^Mf((?Na< z44CpdX;i7eO?wKa!YKx+C_6j-Xst|d_Bq29YQRDVX|-W$-FmIxlPj z8;h#lhI<%OzeQwzKZz^DhcWdmcE~`6o+uk1A0>~+?Weuh=(_;A%M4d=dRrh>90pQJ ziK>&zCg|T9+)SeQ)4@VR@n#Fys!^7CQs_1N&lBO6fks}x6P@a<=Z{Ybd_rG^5 z8M{3aC)@Insj|A)h}z;h@sPG!JWi>IJ12DJNVIuNb5n_sF?)fc(z!DGx{DG2!l5 zE>aI1ywt&!y9}4;dHj$X21qYu;WdmR3Y^gGo&rJeQx_^;yf&X#BX%IEC>HE~rpLDc zLl}_RS~+nfr`P^RjPHH@dA@OLI0|L0dB}<=J8(uCu00cCB_48>+va(;UG9A;*ZFUy zTdbH901*zQ3yN=62|k*~i^q~+w7{pX4Js!bVkSys6&rA)9~a1v^w!R47bc3wK)RD? z^*`MqHtim}bs$cYmw&|`t{~^z%O0#{n?B3@=#JahV*YXKJr0Bc3t{WhZ;%>XyWFkh zYB(#;bk7(0?{WOjccLo^uRwG>e-=J+G3;LkeMJRaJ1&HLOCv@q7m3Dvz1s{EWtWrp}WKgoL^u&J71W8QtGx{P& zzGv~PV=~VW4Q85OzL?P{qCtcULTtT6M1xDQLKZ;4R!mOD>lCR3f0m$*Pfd29O}eq7 zf@A^XeW^cbAmnlz4VHC`(Vqna|y4b>zJBneB9_~J>%e%?b@ zIf5%>f%iAe@H3&g90N#c`z#Y`PwIi&C*}6}0h*5mXwyv@<$)5j?c)7p3 zW?!q78UG@#bT`fJGgTl7px0#WHfFW4xFc-Q>X_N>xxy8|b`E@=iRM9{b%QKb^{@~x zu@FBpya&s+0qW}MVtJg=ts=?^-#a+ z7Oe4ZDsEWPdsDghrYhR6xIE?qO_9*v)%d5c0or_pCx@r8#$Su9b|c=S=gOy~1Y;nl zZ|)_{F~G5cq-j01Pc@^q1jRK4P+KhTnA>`jZ>^|+^m4I7o8n~}xnqh9x+a@c<$WkA z!=CJF6dN4=VST=fUB&D0MS>)7nT;qHR^$OG+E!7TS7DES;1{VZ-1=CIM2jFJ4;S`J zre#hDHYyf^MV-a=&47DAsorjak(yyw*{Dc%!SbGCgXn5EZtPIpiDh1r^t(N&l!`T_ zp>C_UIYS~bvE1wdJlZ=7Bhc6TdqvtP`5)#SBj%>8Q14CaK&ifA<@?h_=dC}N zAWh%E`I%s>54o?N(1i%S9^Q$oXv;FgIkONn9_ZIrv_2Q=H%OD7>{hZXQ)vyJ>pYus z7nnP{a}J8WMIWoR*=8}kzs+nqP(B7bup=6RmLCgtk~xisC|85n7JOvgVSM@}hKLqD zT!iB4r7NqBQ^w_)JTtCAk&pQ=q1++1Lz3Oko7hM5ib3mGS+Ky@#{p;B=NRAyO|?>s zN7+c&y|fB=$0PuK(WChxNMhvi(BLJ-?q*l4{LRIjc(;N}+DMi6N=y#q_I1^EziL1n zBFi7=MT`(J7EtK-i-2jBp%>Fm=g9-^la}HKp>ATGglkJ8ak>z4uLbfw|9#?P^B`r$ zAfGLkvpja!j7F-s$a;4P>JbljzB%eMuZMP{bI*ovCK|2?Z)DlpEq!IjN#S`t?KYW7 zi_j=A4V1aSjtdQ?)=|&BX=3lNgZ{Kb{8tKb1`SjuJ9`E&1$6VNi$;&tWy;|GZ!#rXD zG4dKiqrJz8Z{Jam)%(^OKji$C2OD#D-c(V(JMxy2Bk1LWNO5?H(j>M2BcR^R}yD$vod$2hUgkm#opQ9~9lhcop z;C>6IevURnoyCGeY0_@8jVA(Y@-?L(^ofuEjr8AsO+Wn8m9;g$4dy5x$IY86WEisHjVJTeYa%DaOqyTqf4f>PKl?@sUcNsL z>cEK`=)vr`Mqh$PVKnY-*f-re($bHwj5>GTU`h2{jv2i;;h}$ZG2LQO^>FRwA_|l> z9=-!ZiRAkt)TJ#ZTQIN?P2R_pRU2)}_BZPvoeFCzraXlhLX7-@BB8smzj7b#89VQX zBXF@EoS^2x%Z5mW{m>|=p z%*!=r=<))k>Tdq;X-nM0am?dmg$dujx3k~h7kt$Io*KDR`@Q|3&|j&4=WnFYAlT=< zf&SK~J)K?>^#W?bCewf6B}o^80Q*-k)b`su9qDpuI|-%?nJw)Z&4l9Hz!taiPDBx8>^EPEB!=JUgM;m(&mahewknMf3U#C zJMXLGGYjQi?XF+@uIOq`PmT+(B?1P?h|iyg38JDwdZ)iJfe_ze=9|selZc{6XiQ4` z)__mjX_+u>$w_h`@4PRhL20>6vY!j@29$+eq0acu-Hu{7QFt@?eeU(_*mZAXpupkI zRctz~NsGnbR7It+DT38L;L8vEdgXbt(3m9{^8%}kAu+ICL%i9~7V6;Y?CEkm)aGzDfOMmS#1C@15J2=~3}Z#xnj8&2c+a#mWHIzjnNn zo>=&M35b#RXVrzySk2vQBDc#jM01Ck|bh7*3sM-%T?Jr?t&VXseVWZ#dGx?fL#x3*h+3M+c z+FVQxhP`nni!^nB*6)~14#rt2Rsdr}m=UKvnd~NEX!c>07X79EvQDH|BaNojiQ*e6 z@-1L#P~i0B32DpR9C!|)@yYaa^R`>m`h*a^SCU1_?3&&AJtb4#6&0Dc0CpWPPVrd( z*#m@W9zW*|ezqk&|7V4PrJyo_sl;?#F^o_fJWRj;ElU`%g{KbU%mi=;&@{or*zw;* zS=PDjxjeXB(aub-l47?-_W+9{l)?s6viN(xhW|fHP+O1+=g>U29t+QQ< z;R6FF8>ZhY&_C<->53k0tIAlTE>?Y9(d0|Me4`w@ATL7t-s!#R`3$Eei!^b9Ety5s z$d-zf1Q)=-c<0rJM%)QFEAvy%{TgHnIGR!4zqb{I|B#n9^-0a$z{*S;y56nJTyTcOzde;3E>-Sy&1e4?3sBGXkNV0Sg+>QEVI`{SMpLgK zmGc(Sk;k8PQc**i;u3F&m3;HyZv1NLRzO}yRz=d69t*J-UC)I&SiJ;VqE^WlsBM$c zPm_9;`gyi>OA(NJPN-YArt9 zMhqo*lp{L!-TbMy$-8r3+lYt4kT62#B8{n^#6p;m?)M4}T2`47UNHNJt$?c-!1a}Z z2c8wX(X~$7v5Z~rjPsl0R~sa709H89IPvH-Y^D30Raw@u`+*n4BXarALt~ii#-J1NPsi4b=AXj|lWVc!b))Cs+?Mc+bv$H=Hs(B0 zY%C}ItaQWkGt=DZ_CWQ$(F-g@wqwSq85ZK-6hBgQ8_S0qmWCa$Z{`I}rTl*U<3$@` zF%i!ddqFOoDgM{-zos$wO&%?Fc(D-VlZTP&oA$-*s`se#ixw?bWKhia5KAhlD4Ff= znM~P7$Mqe*ZlkgMs+LVSE8wYs+``eS+e`5PHFBZS?0*Jv>K)Nm>wYHOfR-e=zm%9P zYg};R?iwCkW#ezJZd61&gZ>LBtej^xE1bMLLMwxbPHXl`&X@1&XWIUy)2Hu^Q9g+E z0AUubmFZSu;gb+5K#0w_!l4n;fbJ($C90r>Vvj(S7hB~JRkJ-Y3uc42sfVanB&l_ z(6rWBXo^(}^ZeQg5#IYM{2U9RSFrEZtzcPX&;bO3bQ=$9ztHoDS)`Obm-?a^IE7gn z`e5oK(xw?bGMa-DR0h-kpaCc<*;4VzVMLXrk4E{do3ph;f2BJdJDyI1S53T5N8f9a zmTUQ?kj)Qn6aqd4s;nsgNU&zl=sawDNJ8~;TT{ZJsG@2l$w`l%ND6##V7THa$OXTz z=Hk)2A^<#9Pac37Ns18ibXsXiKov2 zSr-9Bd!}X#EaslT*>%wTFzdXVmfC=~2g+#U5}e5B9msPJq`fg#W=vrz9^o0dyzx1$ z_{00%mH?QBhuWsk)_c~F3?%opv#@@v5mc0%zR{GGGGz?|(u-`{7x@gWce|s)t$irP zBO!rQZa%NY(w+@`rABs7 zxW(%r81ak_Iew>221;jI}657NaeAE(a@`-f(rOJVN2nt9Viq2bBB9Q!q2$dO&FN-+mZcGa+6fO>SxL7e@<1? zEF|o{9E!U;88`NQ2us7{aZ~>Rj%aae6!FAz(Xl*mGf72q@&iH`Sn0s|>Yc@$m8U^^ z+!gXnG8OA^L?;Kbsw$dt~RtIiRfyvVF>o=%)EYgL8%p4jC z6a%N5-uQwll<1KPavZE6oUSnFzCLK@{z;MS0uM^3I34HR)a$k8x8rwA^*o9n^~y1F zcG=b+Rfrd@krEF7(pi|pOQ~ORE z?tFYD$;K;?;-iv-NPA(fV?`>pg;316dN;qCIjKcp_|n$s=y>ve&Ddus7whJz58i=U zklJ97uKM(}xe89D?E>};?oN6R#Iap@)Hi{Rq;rC!mcb0wP;KMu4*q%-E1jqR%jQgk z2u%M3A%#29tZst$K3k-xlDGqbpr}LzPZrkKwMVr+xicDW0U0<9oV;+C;g*b_3=Qu*#7H2(Fd1W25Q<5E0wbxF}q{`rFu)HnDQ0f{7 zebKABXUQ}R-}5f$aOdx+PyP5)IeAv9DnkE+7jL}|6d3ZFN?Ez;tpqjl{GHH7v{QrA zhl&mYNsHn1Q&LLN%ukZ7^;3o41}`-&*$JhBbSA7`nRpXLKznR--ka(dpdXef%$1L_ z2iNlJ?BDpVobFzfVT*ut7-UY>nsLmD11P*g8yXWwOZ`WFfL|s9dH6?fU8Y-v0Fc2w zhg=o3P>y)?`IZ%@Q&F6r&FDkMJw7{!!C&!(d;5_4lqf+)E0**kk8Er8N4K?^=;Q}T z8l4hTFPrJsw3l+4#e9FTsls%h`Inhtsa}H7U7Vaa;XL2%b)q&ku&vwJ1VruhQ%T!v zc^XU(Dwg=ZKIp)OYyql^mhgWa+>|wsvZKH$Df};892|Gca75x&sOgQBwsnF9YYSDU zAY2snI*Qs2$b0Ow%-de|5l6qRM$bwGQzjIzxm@)_d6aRSSt)Q0Oh1Pfv4JC>f~NLO zxVtQRvEpCS{d6b4c0@1L$%L(?yff$&lf#r?zbB>L=c!nJ>Ni(vquUA!4JLf*= z{=;#gx!LE)9o~qbEat|Q3is%Pq$?u{Xuljvowp?5pSBgw{(jD|>i&}IFL8(`>G{HVkk|rih17QFn!PSozoL(HdKYHFDDrZ z0>GX;|Jc0!Hjvk1`LI;Jk-9-=xSPGn$;Ux3HxAxpwG0eV%N!I!!M7RFa;i00?^QZf zWk7mb$5dB0v~f;Xm2#23VVSBI1tf%mX?Eq}a=9!Ele9)U%Tc)MX4dY$6}8B>m~Scr z2%WuOD559ud343m;AwaJ3;*APb~(HJiQfcvFy4Y1#{g^#;&pP$S4~3lM%=jdS%g#=vXc_dc&0d;1JM&*0Kvh1oM8 zANe8~d8@$Lc13Rwjd})P%nE4RK@G%eCmOod0OH-NfpdL!P(?C*m3vipQ7VIRY3K8+ zl+HH-j18^N8(rgI8ahbZM=PkUUW8GfAMK}{ye|fe09?lMys$CfH*>_}hN29v+_6@l zM$5;Jfyke7eq-OD6GO1a>+5->Q``fcOd1 zJVD$lX?9p1o2u;gM}4*(92i@3;fzwdNrpHAR=ZQKARuJ*RtQW6@v0^>dT4@?*Stt~ zd7vm*S`FAW8xiAS6fgtMhkj-0i|@JogrC=*-ZulYiW~wO!vSfd8x)5@iuDZhg?Y(W zpAQn|X=;83x?kwL#M5JezLNfzGq6C&-%f&i({*TmL93PyD znX}P=;%YWifhJKv5(Y6bLzuu3G zT@J$`1@XBGL{lCjE)9>bJUb7l?DK`Te4EV1Y!7hLHsb`WuFe*T7Vs2al^~?DJ`Em! z@J_0cHZXf@S`6^;MOpT4KZZ$=+*yF0efqbp;)Jc`jAB=sJJ<8ou%c`TpI^6eRfCGz z1;9e2{bJR9iyY!HwS0dYbmA!&C~hOi?8I-H+NhX?Z?Lju+*k0YQ)uOmSD@DU(J6XA zpb`%Ke7V1QGhw+2Gh>+_GF_7#oaopGE22gssPQ&vmOtUo`w%yZszNq}W#ueCjm_mw z++PX!u&6ve)f67uz0EF>4}2O-e&c+o7iXjVR@4WoUXLR>_Ie%*Q!R~PyB*{?+RzO zp!DECOk~%$>9U!(xv}SKR zU8DR=yg~M?Q0$Z_OKGdvBViJB_p75S-xuNO;WVM48;JTg=XwsU&0?*87j?RMRo$u7 z1`G}2I)X*ryz>btN!YGwH@g#2(v6)L(%adP{j+FQQ|*6+{%8N;^fc39i`kQCs(obl zt~0`J)%gelS1aQPpH^b~PsVj<)M21hx!ugwlB&StrAzt_G&fk&@5*` zj$b{PA7ziLSbnb;+&i(sq7v1>^rh}X!}j<>a}|-}kq!C_lmZgUXR&xLAGSUoN|G7g zFnd?OGxkhOzTZM(dF`vZUak@Zt0)f`L9j|~nv?Yd*Ytnl`Kg$3IkZG_jvgcNprGq5 zZ!T_hR&C(3EYT3@;}aT~mtY`$GK67K55bMg5c^B{T?)tJgg$>>+kO zV1`L+hFr()#@g$7RnU<;K0X+wS^)CROPfzE9c<018sD1~%4+y&n1%c5*YjoRxcnu2 z0~TwD5ODSkVzmxim}A{v|El2*lkdM*MT6u_+|kI0T{~)7vT-%qV3qs4W(6@H!4Aj= zH?&0r&x!?jwi%n*$v$5>o7 zI!yQnX4bQ+k`kqzeREpH3Azg^>0d#v=Ek)i@l;pys*PX#Sw*M3-iUuaF<@*o%~G5l zMGWV7ms}|c%{X*h9`!wcj4ZJ3>(`VeZL?;~(gSzb1Q4!XlU+j*VPZc9RUtW(J*ekB zYkdn3f@i^Q<$|Th9b=GqcD=D$od3OuwO$^83!e46&kO@<1nTwI5FxYiLxoc=1G7sq9;18sP=zdhwLUTgLAo~c7q zLVQUbB5OtlV0<*cvDPsmVQtu11+NM9cs4wz*Tw%5xYIore=Q%KYjZ9%SKbP+Qm)$j z^pKl_9op>}6!U8lat5G>c&$_V=8VEak&c)FJMKy(vk6s%zAJC~~lx|;xAWjSRV zU+CMt_?V}U-#xLCx6(9o8dquFa8B}PO_9J&+K1P8+s#8AbD!t##`+^F-p})<83yDx zz@uqJNlvE?7(BF>6+DW_zxEo))vj!A)A;48gyTks(I^hR=@Z8qzEKGLy9pn17ou-rpLp0QZ}J7T#ZD*QB!Or4tdiQLqM_RVuma zYXp=AXLI&YlGdN^$gG);*vYWi=VD`iA=!&&I?GRlDCI1zo~!SBlgMMR-Taec!>=4B zp(@JKoZ{%(x>(9%J(Ble)<;-f*j;OM6SOqriro%l{R>bNju+FrggKYl&uvpDCQT?g z1zqTMN&P&Fu#&jgXW%e zbh2)O(j@e9r?4)1WRIDezocko*Tr9e;^ho}<80+t<)xdrjz<_9w#a)|!^$_iTSp1`n`P6Tl!*>@mY~fSERiRF zNX`JzY#PtiV4~U`pIy()E?(7BNitB#efYTVGySVj%G{%ghfuxcdN0P0qzxxErHti& z2-J}{uF`rqcTah|3k2ij$k&wK4Da(UEn2+BJ4R>u8#TfXNNDP}6_Z3>=ls z_}iy$EO^>#N58~U;ubr4dArc6?x9+!()_g1jd(E;4N!<5QU*Tpst|`hF6wogh$uGs znBmbH8Ai%Cmk&{yDL^bPHvbH4LYnzL8g<0jZ|uvPP5ZE>NliWd}t%UVONWV5HpS{;;GYrI-<`)@1g>7uLI^7~O3JBAuGw8A1p zNyJZ|u5<|4EX3j055RIX75S`Ff9x{tp@e+H8m2u+$s=KA3Sj{!SFT}Qh`5sGqS|xx zfJ(whXpags)u3$oo6p>5y9SqONdmm*$K>%VHV2fo+v*Bp1?4_Lq>(6;Jecz$>(=rn z`0^3F$hMRl8E8@FYligj0hw=0YgmpmyGQ*^GaD$GaAh@QQC}%JRYKHX|k((=B&a^5;8OyQlj6m;oy|J`xnK>zv# zFV6DKMIy*7()*($WPAX?XL_Cwxh18mbtt*(JRc4^SnY8#!KtT?-OnH0%Q?w&S5W;d zwkrHgVx{nA=nVg7H3l?|0$`(J_roc$++jkU8XHF!$oOfnf$o!V?UG0Q-I9>X7NIJu zHB-`KiRv6$LG9Cq(+Hc7jz0QP_LEX=Je`r&XnGFEa408xCq-g`==zwv6|&I((ZV3f z2yeH+?&WT@fsmq`sRfn;J4ID5E-KY;fp@m}?!Z=0F;-zSaJY{M0G~H{v32)o%day> zHuYq`X=A+DSEuG5*CSv=hv>tPLL9{1Y^Y62jeZ&oXEb#jyxF#;pA@q&{5A)F;?sU` zG`6)M+2B6cfw2w$f?@TOdo$5AcBKF&?3Czp&yu03xIQfu)-J@x%5tXqx$!X~s*PD>Tt*WpOz^&XY- zb|#+UHMobU66A!8I4yLDb=d8ow-3hCLqGrOyTH4?bMxq+kGy$B4;8fu5?2AEUdIM7 z=7*vKp=ft&$8)aJ<_7kU5IgR2PG48T$33oPSIIMlM0SGbb@FJMy7`!`6RnT9t&d;o z?nTdQ&n9GJM%JuSB|WixPi)6$!hSmiuNDba>;UrW$d1z*;z=-Dj(mr%zKm=Be+=tUaX~R=c%37?YQHW!k9T72eQ_Vzs zIu!>32c;rMLtO^wWk0IvCz)RYXs!L_-xX@@FR)o%OGhO?W`|tvpkiZ_7T}O}+dP|@ zdbT2;-{~=G=fnDKKr=*C9OBJi1+qhCZ0L**D!Ti{P|Fv8`0vJNHR1WVqY_!K**>efkWz<{QnbEB|3RN$tHcyXqTid(zHQVL zPG*om_P1`8$%ZP*Xd26CJONA6lv*nXKW2 z7wTn1`+b|$a|vg?-rDnb#+uW^GkuQbe=@hZ;JgvKIoues)O_LoS_D=u-s7s8TtZ&N z8Z?~3Ai>E5gpc}?Ql$JuyYl!<)KO-uAp2&@R#z#SZYF_V2U#OYBdlx9Zv8Tp@MU}+ z$q9W33sHjjtteFhO~V7Ww#QSg?2t-b0vY4wvwx2*GB6M4R$r~XVmE&wH{J)37J?Kh zr+}KwN?+-*kJfK4YRdkk#oM9(3rGDIj;pIN0lFDMWW>D|0ocX+P3ZINJWk~BJE&Fu zRIQ7D`a0a(6%*`@(O8V-8#{WF@Qp`;eRP6-RGob&Bx&>9TVnQma~1xhPMHmP(vi;d zAfhyenq~{Ir{jo#!bsN-T}-Qe7qaruL02{5Iusrp#P|FOLR_HOlCau?5tF2~t3gLG zx{oOJ;HIX$4#d+_kZi}xP5m9&J`X19;CXBfi%3_I!_?O$lJkj|N`>@kDi&NLc2^ar zRs~bFYPNb4h4seM2~>C)!3W~e_y{A#TDq06%?&Eu3_S8517%NXY^U$rpFXNr+0cF% z7o;^`1uEPOSy(=6MB0}4EH0CA3S#1O>P8w=4~nL2gtoc48~a$Ag~(la^_ViBdu2#i z+b$mtNs0r(^c!<(ts*>#t1%|o3wOwSV*n`@<}Rxr6+wBa?6yl;xq|JpVwo-%V4d=aS4AixcsG!3H{|Mau+M>1xQ2?1Nzko9=aJd{6ic z;<`6S{-}i(Fu$T_A&SH(QZPQ8*}b%b*nI#zDStc?PI)pA_r3_K+HN`8AZzesGBSUh zkaeD*Cx8$)FXDDQA03%c z;!VRnpR%iPk`>GFeRWsRJ@=sNN>4RR@2ppS-dD@@yDFN-D-OW&)fh+5-j$B)&X*rS z2Lj6*uhr^tu~TJI%Td9{1%*)H zz05nU(#I-0~kIi4^0&ty9FZ;XS zh(+i`Ki~cnW-v+|AldbfrEmD$cy!Rl+QXNVZSqd{*FJQdcXi2RT5AD zb-Tvusn8jj13L(ZWY9Db_D*?tXpZC6`}3U&BV5H!-**b`n|IF*2f41U&kzZ?&J>k` zPqq>MbF}=VMUY;oSlZC+IGWZQGO3vHpQ!%%G{fB>l2+;@q4uPrICbR5B(U9?^;65D zPitig7o(#-g#q-t_)=bKQ^c=Ma|@U;JOY?r?W2uTQV_^s$+@2X>*nJv7lF988k^W` zASv9;L5U^P@PAW9&?fF4O|Y$uK48%=W<*voptdO6$}vohZpgd9BpOXaE*Te5kgExC z*ynpTE%(Tq_1Wa%B@J{?ftxbH+WW*xQ6KfN8vxHJO-0{piaX@4=&-b5X00AwS^fAvHy^~F=r)u@C%76Eg5kjVQa!n!ij2oWt zh+@lqClyLB)zBGH{K@`tKhltQzylz{Qn@l*Xtw9lBrs>}@A-S*eshAj>HB2o`zyg~ z`;liTp_s==MOuGKpbID7T>D|W|D&MlBq<-QNYgQX$9b~JSyygn&wz7FtbJv3Y-7M5 z#pU*5=F_3Y`O{F&lWYlzt}5pMPUpZCe#0~5-}R|R$9(R$nSbE}+*&Z(_P2cS zH5agY#Tr(hdlm2g_5(ByMgFqmQND5WfAN!lKEU@rd@Y+dZszKnzs)1{o$US9&0Kc+ z8kTIljGMmk0F9x^ea^s+AMwEtf15jgHD1@LY<|tn{A5QbjCFF~mp;vppY5mduK(hu z5C4|E9ZlTxg`4^89glL)H*V&#&8M>Y4Ik$x+dApk_9Je3<*EO_y}OT!vdRNE{>6cL zSVl(i%z!U53punzfGE(Gdg(jKB~ON~AfNZO7|7uH)AJEBNaAl&r0UZOh}Czv23M z+RL+98K1!FTxbIn~3$8UL&PT|TMHmoMY)?%u_tWlG;n zoeKRF7A`RJpj5=RwD%~kQVun<1UyZ$!m@Q>lYnpnabAeBV6*GLfnkea3YIsJ99WxY{O?pJn`cPP`YP32kS=> zJKh`lXev)G&!+K#I1(1jB__fLPUf)vvn#i+!?T{xGH&TVV*nTTzQG~qal}mtMfdLl zwr$r!J-B=51QTsg&Bh@@Q|L%r&1_9Q%%DJFI2tV3jjF4+eFfaFwD~eP%D6zNIcKx zv#hLwOIJo?dH(>VgJXFoH3g#w&@u@>#beCRx;c_`-+W$AUqqlAP?N{XoyYKu-_9F} z!KeYEBe*X)jukn1Sf)&+bVnwZQO~kBEe<~kAety$0yDSd@`=@i*%?40(45Q*i-VDf zCQvS8X1av=rHw2OkihQC#iB(Vs;EVl;loa1P5@A7n4Yqkm#1g|FuGMzxS<@2O(G;< zG=6F&g~LW*4A!BRE8EU(bOJA|YKu8}SPdr@9Kd(}MqV@Pprszqv&UHWO$8UPL@+=V zz>MULJTl9KpIcksj8>NM$%b+)^|z^`vHZvVCs(rVhQ8HLOJ?n{IF@Z%$ND2yvc3x@ zX-6C(>fXO11VIpPr|b7HJ%TWe>Cc}FPg4D`o? z0eH5LcI@ZMD83+5m6O8kkFO7b>=$a0r3$Rol@xzdkG*~ePn)xUcwe?#vD>v+&O1O4 zD3oY*8m>i<)M_M+7C%jUiL->O1T@OX_ReEY08r@|>2rMq?y8cIup_s0G`Xq7bC`|+ z?N^JWa!0Mw;`hq|?fn6gq=Xg+atmmKV_7`Bmg2*iY%M9__{lPgODeHTp>U<+Z{o)O z`w@kPM-o#opGaU=t~*g{HZx~rS7#9fK@k3XT~C+_6#)@O`v0krRSySSek^4OBEp10gw`-3AZu)I^R)U*!{n#c4AmJAjhH==9M}{E{3dYw>=7Sbc z8-g*8)!r03O41OnSGK>BkR;bzDwnu@UXf|)EM^&?kdV4NjVayI4+^;9i@u5d>r9p{ zO{Lg50AoZXljp`V_t)E5vE>f&Z|%Ph$Srq%Ul0W0Zt2!4S(PCQW&htU zwJj!*2H-bbi~Mm3R(mvh4*;2}ugh*)XmcxRkOs9%i8^8)&&BH6OJ%B#m&w?7iiX~AcdwFxrWz|38vwbM(wrTKl6NGNN5QA*o5?!emig=@nXFEJo~`*- zWVJ67CK|BkrnBYaww0dpg=1`4`7En<9;K$^&>icgn35}8vX)^vSKqS{-4&4SwODO3 zAX9TZn=P3o$g+$q_oNP)s@zmw`bdd+RWgg0B(r2x1zXm>L$R#~?Gpq+=z(tjae)%u zgOT{Ug4dWxf;_I3tME4`v2>D(PhVNswyk9TS`K1j=`&{h@6KwS-r*6FONX0a3^G8DzBI&_AQ>&wgn_2o?VJ*yz<9{9xi6-g_zPXLMDtr z&QE3LqbVG!?q(Ad8jK4QF{^S}W(p&CY#5%v#&=5FWchkKz`CK`rHg^I?>VCL)Se2c~%ry8zRvOa>cM`hBily3t`j-au zw@X^~S}f;kz{7yw;9lAzaW`Uii%d;rB~^9_eSiV2*!m#|!vCpjIi>d#=Kp{mCCm=V zSTYltxyjDLj0_f!aYsIv&#Oxl+2@(U-n3Y;4^R*U;U0D^r-;AC>y(6FbRy4{y~z5< zLV2~lR2vr0+EvjaSqg$6{7i@uW_L8!V)@q2fMEu-V#B;32*S^T7-9B=n+CrCO<%V{ z5ClQ!1LE;sf*=Tj&;=r43W6XALKldHDF}if2wfl&rXUD{AasF9n1Ub(g3ty1T3cKD zx(&UmwY3!|C#Sw{h9C%naF1|ua-y}hm40<~bu=|K^>urCMN?A~b#-;PxODK`DM1hf zLFgtI7Z>X4>TqsuZpLQ2w;uU0aNxkcZh{~Pf^d&;b93YS@4xTsW(a~H2!bF8f*=U@ ajQ;?lM=bcnq3bXJ0000e literal 0 HcmV?d00001 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/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 index 9f3566c1..700dbc27 100644 --- a/spikeinterface_gui/compareunitlistview.py +++ b/spikeinterface_gui/compareunitlistview.py @@ -1,7 +1,5 @@ import numpy as np -import pyqtgraph as pg - from .view_base import ViewBase @@ -10,21 +8,69 @@ class CompareUnitListView(ViewBase): View for displaying unit comparison between two analyzers. Shows matched units, their agreement scores, and spike counts. """ - _supported_backend = ['qt'] + id = "compareunitlist" + _supported_backend = ['qt', 'panel'] _depend_on = ['comparison'] - _gui_help_txt = "Display comparison table between two sorting outputs" _settings = [ - {"name": "matching_mode", "type": "list", "value": "hungarian", "options": ["hungarian", "best_match"]}, + {"name": "matching_mode", "type": "list", "limits": ["hungarian", "best_match"]}, ] - def __init__(self, controller=None, parent=None, backend="qt"): - ViewBase.__init__(self, controller=controller, parent=parent, backend=backend) - self.unit_dtype = self.controller.unit_ids.dtype + 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 @@ -46,167 +92,220 @@ def _qt_make_layout(self): self.table.setSortingEnabled(True) # Sort by Agreement Score column (index 2) by default self.table.sortItems(2, QT.Qt.DescendingOrder) - self.table.setSelectionMode(QT.QAbstractItemView.SingleSelection) + + 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): - """Populate/refresh the comparison table with data""" from .myqt import QT - comp = self.controller.comp - - # Get comparison data - if self.settings['matching_mode'] == 'hungarian': - matching_12 = comp.hungarian_match_12 - else: - matching_12 = comp.best_match_12 - matching_12 = comp.hungarian_match_12 - agreement_scores = comp.agreement_scores - - # Get all units from both analyzers - all_units2 = set(self.controller.analyzer2.unit_ids) - - # Build rows: matched pairs + unmatched units - rows = [] - - # Add matched units - for unit1_orig in matching_12.index: - unit2_orig = matching_12[unit1_orig] - if unit2_orig != -1: - # Get combined unit_ids - unit1_idx = list(self.controller.analyzer1.unit_ids).index(unit1_orig) - unit2_idx = list(self.controller.analyzer2.unit_ids).index(unit2_orig) - unit1 = self.controller.unit_ids1[unit1_idx] - unit2 = self.controller.unit_ids2[unit2_idx] - - score = agreement_scores.at[unit1_orig, unit2_orig] - num_spikes1 = self.controller.analyzer1.sorting.get_unit_spike_train(unit1_orig).size - num_spikes2 = self.controller.analyzer2.sorting.get_unit_spike_train(unit2_orig).size - - rows.append( - { - 'unit1': str(unit1), - 'unit2': str(unit2), - 'unit1_orig': unit1_orig, - 'unit2_orig': unit2_orig, - 'agreement_score': f"{score:.3f}", - 'num_spikes1': num_spikes1, - 'num_spikes2': num_spikes2 - } - ) - all_units2.discard(unit2_orig) - else: - # Unmatched unit from analyzer1 - unit1_idx = list(self.controller.analyzer1.unit_ids).index(unit1_orig) - unit1 = self.controller.unit_ids1[unit1_idx] - num_spikes1 = self.controller.analyzer1.sorting.get_unit_spike_train(unit1_orig).size - - rows.append({ - 'unit1': str(unit1), - 'unit2': '', - 'unit1_orig': unit1_orig, - 'unit2_orig': '', - 'agreement_score': '0', - 'num_spikes1': num_spikes1, - 'num_spikes2': 0 - }) - - # Add unmatched units from analyzer2 - for unit2_orig in all_units2: - unit2_idx = list(self.controller.analyzer2.unit_ids).index(unit2_orig) - unit2 = self.controller.unit_ids2[unit2_idx] - num_spikes2 = self.controller.analyzer2.sorting.get_unit_spike_train(unit2_orig).size - - rows.append({ - 'unit1': '', - 'unit2': str(unit2), - 'unit1_orig': '', - 'unit2_orig': unit2_orig, - 'agreement_score': '', - 'num_spikes1': 0, - 'num_spikes2': num_spikes2 - }) - - # Populate rows - print(len(rows), "rows to display in comparison table") - # Disable sorting while populating + 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): - # Unit 1 column with color - if row['unit1'] != '': - unit1 = np.array([row['unit1']]).astype(self.unit_dtype)[0] - n = row['num_spikes1'] - name = f'{unit1} n={n}' - color = self.get_unit_color(unit1) - pix = QT.QPixmap(16, 16) - pix.fill(color) - icon = QT.QIcon(pix) - item1 = QT.QTableWidgetItem(name) - item1.setData(QT.Qt.ItemDataRole.UserRole, unit1) - item1.setFlags(QT.Qt.ItemIsEnabled | QT.Qt.ItemIsSelectable) - item1.setIcon(icon) - item1.unit1 = unit1 - else: - item1 = QT.QTableWidgetItem('') - item1.unit1 = '' - self.table.setItem(i, 0, item1) - - # Unit 2 column with color - if row['unit2'] != '': - unit2 = np.array([row['unit2']]).astype(self.unit_dtype)[0] - n = row['num_spikes2'] - name = f'{unit2} n={n}' - color = self.get_unit_color(unit2) - pix = QT.QPixmap(16, 16) - pix.fill(color) - icon = QT.QIcon(pix) - item2 = QT.QTableWidgetItem(name) - item2.setData(QT.Qt.ItemDataRole.UserRole, unit2) - item2.setFlags(QT.Qt.ItemIsEnabled | QT.Qt.ItemIsSelectable) - item2.setIcon(icon) - item2.unit2 = unit2 - else: - item2 = QT.QTableWidgetItem('') - item2.unit2 = '' - self.table.setItem(i, 1, item2) - - # Other columns - self.table.setItem(i, 2, QT.QTableWidgetItem(row['agreement_score'])) - - # Re-enable sorting after populating + 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) - # Resize columns self.table.resizeColumnsToContents() - + self._qt_select_visible_row() def _qt_on_selection_changed(self): """Handle row selection and update unit visibility""" - selected_rows = [] - for item in self.table.selectedItems(): - if item.column() != 1: continue - selected_rows.append(item.row()) - - row_idx = selected_rows[0] - # Get unit values from table items - unit1_item = self.table.item(row_idx, 0) - unit2_item = self.table.item(row_idx, 1) - unit1 = unit1_item.unit1 - unit2 = unit2_item.unit2 - - # Collect units to make visible - visible_units = [] - - if unit1 != '': - visible_units.append(unit1) - if unit2 != '': - visible_units.append(unit2) - - # Update visibility - if visible_units: - self.controller.set_visible_unit_ids(visible_units) - self.notify_unit_visibility_changed() - - def on_unit_visibility_changed(self): - """Handle external unit visibility changes - could highlight selected row""" - pass \ No newline at end of file + 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 848a3491..54888b92 100644 --- a/spikeinterface_gui/controller.py +++ b/spikeinterface_gui/controller.py @@ -314,28 +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 = [] - 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) + self._build_spike_indices(num_seg) t1 = time.perf_counter() if verbose: @@ -416,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() @@ -560,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])) diff --git a/spikeinterface_gui/controllercomparison.py b/spikeinterface_gui/controllercomparison.py index 9a4feda4..977add76 100644 --- a/spikeinterface_gui/controllercomparison.py +++ b/spikeinterface_gui/controllercomparison.py @@ -4,49 +4,58 @@ import pandas as pd -from spikeinterface.widgets.utils import get_some_colors from spikeinterface import compute_sparsity from spikeinterface.core import get_template_extremum_channel -from spikeinterface.core.sorting_tools import spike_vector_to_indices 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 -spike_dtype =[('sample_index', 'int64'), ('unit_index', 'int64'), - ('channel_index', 'int64'), ('segment_index', 'int64'), - ('visible', 'bool'), ('selected', 'bool'), ('rand_selected', 'bool')] +# extensions that cannot be shared/concatenated between the two analyzers +_comparison_skip_extensions = ["principal_components", "correlograms", "isi_histograms", "template_similarity"] -_default_main_settings = dict( - max_visible_units=10, - color_mode='color_by_unit', - use_times=False -) -from spikeinterface.widgets.sorting_summary import _default_displayed_unit_properties - +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). + """ -class ControllerComparison(): def __init__( - self, analyzer1=None, analyzer2=None, + 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 + extra_unit_properties=None, skip_extensions=None, disable_save_settings_button=False, + user_main_settings=None, ): self.views = [] - skip_extensions = skip_extensions if skip_extensions is not None else [] + 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.skip_extensions = skip_extensions - self.skip_extensions.extend(["principal_components", "correlograms", "isi_histograms", "template_similarity"]) - self.skip_extensions = list(set(self.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 @@ -62,6 +71,8 @@ def __init__( 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 @@ -72,7 +83,7 @@ def __init__( recording1 = None recording2 = None self.use_recordings = False - + try: recording1 = self.analyzer1.recording except: @@ -92,27 +103,33 @@ def __init__( 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() - # this now private and shoudl be acess using function + + # 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.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.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() @@ -120,16 +137,22 @@ def __init__( 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, peak_sign='neg', outputs='index') - self._extremum_channel2 = get_template_extremum_channel(self.analyzer2, peak_sign='neg', outputs='index') + 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 unit_id in self.unit_ids1: + if self.get_analyzer_index(unit_id) == 1: extremum_channels = self._extremum_channel1 else: extremum_channels = self._extremum_channel2 @@ -141,42 +164,22 @@ def __init__( random_spikes_indices1 = self.analyzer1.get_extension("random_spikes").get_data() random_spikes_indices2 = self.analyzer2.get_extension("random_spikes").get_data() - self.spikes = np.zeros(spike_vector1.size + spike_vector2.size, dtype=spike_dtype) + # 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.analyzer1.get_num_units()]) + 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 + # sort spikes by segment then sample, so that the base class bookkeeping applies num_seg = self.analyzer1.get_num_segments() - self.spike_order = np.argsort(self.spikes['sample_index'], kind='stable') + self.spike_order = np.lexsort((self.spikes['sample_index'], self.spikes['segment_index'])) self.spikes = self.spikes[self.spike_order] - 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) + self._build_spike_indices(num_seg) t1 = time.perf_counter() if verbose: @@ -194,14 +197,14 @@ def __init__( self.templates_average = np.vstack([temp_ext1.get_templates(operator='average'), temp_ext2.get_templates(operator='average')]) - if 'std' in temp_ext1.params['operators']: + 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') + 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]]) @@ -258,27 +261,10 @@ def __init__( else: self.spike_depths = None - # Correlograms, ISIs are skipped + # 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 "template_similarity" in self.skip_extensions: - # if self.verbose: - # print('\tSkipping template_similarity') - # else: - # if verbose: - # print('\tLoading template_similarity') - # ts_ext = analyzer.get_extension('template_similarity') - # if ts_ext is not None: - # method = ts_ext.params["method"] - # self._similarity_by_method[method] = ts_ext.get_data() - # else: - # if len(self.unit_ids) <= 64 and len(self.channel_ids) <= 64: - # # precompute similarity when low channel/units count - # method = 'l1' - # ts_ext = analyzer.compute_one_extension('template_similarity', method=method, save=save_on_compute) - # self._similarity_by_method[method] = ts_ext.get_data() if "waveforms" in self.skip_extensions: if self.verbose: @@ -294,22 +280,24 @@ def __init__( 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 - if "principal_components" in self.skip_extensions: - if self.verbose: - print('\tSkipping principal_components') - self.pc_ext1, self.pc_ext2 = None, None - else: - if verbose: - print('\tLoading principal_components') - pc_ext1 = self.analyzer1.get_extension('principal_components') - pc_ext2 = self.analyzer2.get_extension('principal_components') - if pc_ext1 is not None and pc_ext2 is not None: - self.pc_ext1 = pc_ext1 - self.pc_ext2 = pc_ext2 - else: - self.pc_ext1, self.pc_ext2 = None, None + self._pc_indices = None + self.pc_ext = None self._potential_merges = None @@ -320,13 +308,20 @@ def __init__( t0 = time.perf_counter() # some direct attribute - self.num_segments =self.analyzer1.get_num_segments() - self.sampling_frequency =self.analyzer1.sampling_frequency - self.num_spikes =self.analyzer1.sorting.count_num_spikes_per_unit(outputs="dict") + 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') @@ -350,449 +345,233 @@ def __init__( # set default time info self.update_time_info() - def check_is_view_possible(self, view_name): - from .viewlist import possible_class_views - view_class = possible_class_views[view_name] - if view_class._depend_on is not None: - depencies_ok = all(self.has_extension(k) for k in view_class._depend_on) - if not depencies_ok: - if self.verbose: - print(view_name, 'does not have all dependencies', view_class._depend_on) - return False - return True - - def declare_a_view(self, new_view): - assert new_view not in self.views, 'view already declared {}'.format(self) - self.views.append(new_view) - self.signal_handler.connect_view(new_view) - + ## 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 channel_ids(self): - return self.analyzer1.channel_ids + def unit_ids(self): + return self._unit_ids @property def unit_ids1(self): - return self.unit_ids[:self.analyzer1.get_num_units()] + return self._unit_ids[:self._num_units1] @property def unit_ids2(self): - return self.unit_ids[self.analyzer1.get_num_units():] + return self._unit_ids[self._num_units1:] def get_original_unit_id(self, unit_id): - """Get original unit id from analyzer1 or analyzer2 given combined unit_id""" - unit_index = list(self.unit_ids).index(unit_id) - if unit_index < self.analyzer1.get_num_units(): - return self.analyzer1.unit_ids[unit_index] - else: - return self.analyzer2.unit_ids[unit_index - self.analyzer1.get_num_units()] + """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 unit_ids(self): - if isinstance(self.analyzer1.unit_ids[0], np.integer) and isinstance(self.analyzer2.unit_ids[0], np.integer): - return np.concatenate((self.analyzer1.unit_ids, self.analyzer2.unit_ids + max(self.analyzer1.unit_ids) + 1)) - else: - analyzer1_ids = [str(uid) + f"_{self.analyzer1_name}" for uid in self.analyzer1.unit_ids] - analyzer2_ids = [str(uid) + f"_{self.analyzer2_name}" for uid in self.analyzer2.unit_ids] - return np.array(analyzer1_ids + analyzer2_ids) + 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_time(self): + def get_agreement_scores(self, ordered=False): """ - Returns selected time and segment index + 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. """ - segment_index = self.time_info['segment_index'] - time_by_seg = self.time_info['time_by_seg'] - time = time_by_seg[segment_index] - return time, segment_index + 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 set_time(self, time=None, segment_index=None): + def get_matching(self, threshold=None): """ - Set selected time and segment index. - If time is None, then the current time is used. - If segment_index is None, then the current segment index is used. + 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. """ - if segment_index is not None: - self.time_info['segment_index'] = segment_index - else: - segment_index = self.time_info['segment_index'] - if time is not None: - self.time_info['time_by_seg'][segment_index] = time + from spikeinterface.comparison.comparisontools import make_hungarian_match - def update_time_info(self): - # set default time info - if self.main_settings["use_times"] and self.has_extension("recording"): - time_by_seg=np.array( - [ - self.analyzer1.recording.get_start_time(segment_index) for segment_index in range(self.num_segments) - ], - dtype="float64" - ) - else: - time_by_seg=np.array([0] * self.num_segments, dtype="float64") - if not hasattr(self, 'time_info'): - self.time_info = dict( - time_by_seg=time_by_seg, - segment_index=0 - ) - else: - self.time_info['time_by_seg'] = time_by_seg - - def get_t_start_t_stop(self): - segment_index = self.time_info["segment_index"] - if self.main_settings["use_times"] and self.has_extension("recording"): - t_start =self.analyzer1.recording.get_start_time(segment_index=segment_index) - t_stop =self.analyzer1.recording.get_end_time(segment_index=segment_index) - return t_start, t_stop - else: - return 0, self.get_num_samples(segment_index) / self.sampling_frequency + 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_times_chunk(self, segment_index, t1, t2): - ind1, ind2 = self.get_chunk_indices(t1, t2, segment_index) - if self.main_settings["use_times"]: - recording =self.analyzer1.recording - times_chunk = recording.get_times(segment_index=segment_index)[ind1:ind2] - else: - times_chunk = np.arange(ind2 - ind1, dtype='float64') / self.sampling_frequency + max(t1, 0) - return times_chunk + def get_best_matching(self, threshold=None): + """ + Best (not necessarily one-to-one) matching at the given agreement threshold. - def get_chunk_indices(self, t1, t2, segment_index): - if self.main_settings["use_times"]: - recording =self.analyzer1.recording - ind1, ind2 = recording.time_to_sample_index([t1, t2], segment_index=segment_index) - else: - t_start = 0.0 - sr = self.sampling_frequency - ind1 = int((t1 - t_start) * sr) - ind2 = int((t2 - t_start) * sr) - - ind1 = max(0, ind1) - ind2 = min(self.get_num_samples(segment_index), ind2) - return ind1, ind2 - - def sample_index_to_time(self, sample_index): - segment_index = self.time_info["segment_index"] - if self.main_settings["use_times"] and self.has_extension("recording"): - time =self.analyzer1.recording.sample_index_to_time(sample_index, segment_index=segment_index) - return time - else: - return sample_index / self.sampling_frequency + Returns (best_match_12, best_match_21), same convention as `get_matching`. + """ + from spikeinterface.comparison.comparisontools import make_best_match - def time_to_sample_index(self, time): - segment_index = self.time_info["segment_index"] - if self.main_settings["use_times"] and self.has_extension("recording"): - time =self.analyzer1.recording.time_to_sample_index(time, segment_index=segment_index) - return time - else: - return int(time * self.sampling_frequency) + if threshold is None: + threshold = self._agreement_threshold + return make_best_match(self.comp.agreement_scores, float(threshold)) - def get_information_txt(self): - nseg =self.analyzer1.get_num_segments() - nchan =self.analyzer1.get_num_channels() - nunits = len(self.unit_ids) - txt = f"{nchan} channels - {nunits} units - {nseg} segments - {self.analyzer1.format}\n" - txt += f"Loaded {len(self.analyzer1.extensions)} extensions" + @staticmethod + def is_unmatched(original_unit_id): + """ + Test the unmatched sentinel of a match Series. - return txt + 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 refresh_colors(self): - if self.backend == "qt": - self._cached_qcolors = {} - elif self.backend == "panel": - pass + def get_venn_unit_ids(self, threshold=None): + """ + Split the units in the 3 regions of the comparison Venn diagram. - if self.main_settings['color_mode'] == 'color_by_unit': - self.colors = get_some_colors(self.unit_ids, color_engine='matplotlib', map_name='gist_ncar', - shuffle=True, seed=42) - elif self.main_settings['color_mode'] == 'color_only_visible': - unit_colors = get_some_colors(self.unit_ids, color_engine='matplotlib', map_name='gist_ncar', - shuffle=True, seed=42) - self.colors = {unit_id: (0.3, 0.3, 0.3, 1.) for unit_id in self.unit_ids} - for unit_id in self.get_visible_unit_ids(): - self.colors[unit_id] = unit_colors[unit_id] - elif self.main_settings['color_mode'] == 'color_by_visibility': - self.colors = {unit_id: (0.3, 0.3, 0.3, 1.) for unit_id in self.unit_ids} - import matplotlib.pyplot as plt - cmap = plt.colormaps['tab10'] - for i, unit_id in enumerate(self.get_visible_unit_ids()): - self.colors[unit_id] = cmap(i) - - - def get_unit_color(self, unit_id): - # scalar unit_id -> color html or QtColor - return self.colors[unit_id] - - def get_spike_colors(self, unit_indices): - # array[unit_ind] -> array[color html or QtColor] - colors = np.zeros((unit_indices.size, 4), dtype="uint8") - unit_inds = np.unique(unit_indices) - for unit_ind in unit_inds: - unit_id = self.unit_ids[unit_ind] - mask = unit_indices == unit_ind - colors[mask] = np.array(self.get_unit_color(unit_id)) * 255 - return colors - - - def get_extremum_channel(self, unit_id): - chan_ind = self._extremum_channel[unit_id] - return chan_ind - - # unit visibility zone - def set_visible_unit_ids(self, visible_unit_ids): - """Make visible some units, all other off""" - lim = self.main_settings['max_visible_units'] - if len(visible_unit_ids) > lim: - visible_unit_ids = visible_unit_ids[:lim] - self._visible_unit_ids = list(visible_unit_ids) - - def get_visible_unit_ids(self): - """Get list of visible unit_ids""" - return self._visible_unit_ids - - def get_visible_unit_indices(self): - """Get list of indicies of visible units""" - unit_ids = list(self.unit_ids) - visible_unit_indices = [unit_ids.index(u) for u in self._visible_unit_ids] - return visible_unit_indices - - def set_all_unit_visibility_off(self): - """As in the name""" - self._visible_unit_ids = [] - - def iter_visible_units(self): - """For looping over unit_ind and unit_id""" - visible_unit_indices = self.get_visible_unit_indices() - visible_unit_ids = self._visible_unit_ids - return zip(visible_unit_indices, visible_unit_ids) - - def set_unit_visibility(self, unit_id, state): - """Change the visibility of on unit, other are unchanged""" - if state and not(unit_id in self._visible_unit_ids): - self._visible_unit_ids.append(unit_id) - elif not state and unit_id in self._visible_unit_ids: - self._visible_unit_ids.remove(unit_id) - - def get_unit_visibility(self, unit_id): - """Get thethe visibility of on unit""" - return unit_id in self._visible_unit_ids - - def get_units_visibility_mask(self): - """Get bool mask of visibility""" - mask = np.zeros(self.unit_ids.size, dtype='bool') - mask[self.get_visible_unit_indices()] = True - return mask - - def get_dict_unit_visible(self): - """Construct the visibility dict keys are unit_ids, previous behavior""" - dict_unit_visible = {u:False for u in self.unit_ids} - for u in self.get_visible_unit_ids(): - dict_unit_visible[u] = True - return dict_unit_visible - ## end unit visibility zone - - def update_visible_spikes(self): - inds = [] - for unit_index, unit_id in self.iter_visible_units(): - inds.append(self._spike_index_by_units[unit_id]) - - if len(inds) > 0: - inds = np.concatenate(inds) - inds = np.sort(inds) - else: - inds = np.array([], dtype='int64') - self._spike_visible_indices = inds - - self._spike_selected_indices = np.array([], dtype='int64') - - def get_indices_spike_visible(self): - return self._spike_visible_indices - - def get_indices_spike_selected(self): - return self._spike_selected_indices - - def set_indices_spike_selected(self, inds): - self._spike_selected_indices = np.array(inds) - # reset active split if needed - if len(self._spike_selected_indices) == 1: - # set time info - segment_index = self.spikes['segment_index'][self._spike_selected_indices[0]] - sample_index = self.spikes['sample_index'][self._spike_selected_indices[0]] - self.set_time(time=self.sample_index_to_time(sample_index), segment_index=segment_index) - - def get_spike_indices(self, unit_id, segment_index=None): - if segment_index is None: - # dict[unit_id] -> all indices for this unit across segments - return self._spike_index_by_units[unit_id] - else: - # dict[segment_index][unit_id] -> all indices for this unit for one segment - return self._spike_index_by_segment_and_units[segment_index][unit_id] - - def get_num_samples(self, segment_index): - return self.analyzer1.get_num_samples(segment_index=segment_index) - - def get_traces(self, trace_source='preprocessed', **kargs): - # assert trace_source in ['preprocessed', 'raw'] - assert trace_source in ['preprocessed'] - - cache_key = (kargs.get("segment_index", None), kargs.get("start_frame", None), kargs.get("end_frame", None)) - if cache_key in self._traces_cached: - return self._traces_cached[cache_key] + 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: - # check if start_frame and end_frame are a subset interval of a cached one - for cached_key in self._traces_cached.keys(): - cached_seg = cached_key[0] - cached_start = cached_key[1] - cached_end = cached_key[2] - req_seg = kargs.get("segment_index", None) - req_start = kargs.get("start_frame", None) - req_end = kargs.get("end_frame", None) - if cached_seg is not None and req_seg is not None: - if cached_seg != req_seg: - continue - if cached_start is not None and cached_end is not None and req_start is not None and req_end is not None: - if req_start >= cached_start and req_end <= cached_end: - # subset found - traces = self._traces_cached[cached_key] - start_offset = req_start - cached_start - end_offset = req_end - cached_start - return traces[start_offset:end_offset, :] - - if len(self._traces_cached) > 4: - self._traces_cached.pop(list(self._traces_cached.keys())[0]) - - if trace_source == 'preprocessed': - rec = self.analyzer1.recording - elif trace_source == 'raw': - raise NotImplementedError("Raw traces not implemented yet") - # TODO get with parent recording the non process recording - kargs['return_in_uV'] = self.return_in_uV - traces = rec.get_traces(**kargs) - # put in cache for next call - self._traces_cached[cache_key] = traces - return traces - - def get_contact_location(self): - location = self.analyzer1.get_channel_locations() - return location - - def get_waveform_sweep(self): - return self.nbefore, self.nafter - - def get_waveforms_range(self): - return np.nanmin(self.templates_average), np.nanmax(self.templates_average) - + 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 unit_id in self.unit_ids1: - self.waveforms_ext = self.waveforms_ext1 - analyzer = self.analyzer1 + 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 = self.waveforms_ext.get_waveforms_one_unit(original_unit_id, force_dense=force_dense) + 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[unit_id] + chan_inds = analyzer.sparsity.unit_id_to_channel_indices[original_unit_id] return wfs, chan_inds - def get_common_sparse_channels(self, unit_ids): - sparsity_mask = self.get_sparsity_mask() - unit_indexes = [list(self.unit_ids).index(u) for u in unit_ids] - chan_inds, = np.nonzero(sparsity_mask[unit_indexes, :].sum(axis=0)) - return chan_inds - - def get_intersect_sparse_channels(self, unit_ids): - sparsity_mask = self.get_sparsity_mask() - unit_indexes = [list(self.unit_ids).index(u) for u in unit_ids] - chan_inds, = np.nonzero(sparsity_mask[unit_indexes, :].sum(axis=0) == len(unit_ids)) - return chan_inds - - def get_probegroup(self): - return self.analyzer1.get_probegroup() - - def set_channel_visibility(self, visible_channel_inds): - self.visible_channel_inds = np.array(visible_channel_inds, copy=True) + 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 has_extension(self, extension_name): - if extension_name == 'recording': - return self.use_recordings - elif extension_name == 'comparison': - return True - else: - # extension needs to be loaded - if extension_name in self.skip_extensions: - return False - else: - return extension_name in self.analyzer1.extensions + def get_all_pcs(self): + # the two analyzers have unrelated PC spaces, they cannot be concatenated + return None, None - def handle_metrics(self): - return self.metrics is not 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_units_table(self): - return self.units_table + 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 - # TODO - def get_all_pcs(self): - return None, None - # if self._pc_projections is None and (self.pc_ext1 is not None and self.pc_ext2 is not None): - # pc_indices = - # for analyzer, pc_ext in zip([self.analyzer1, self.analyzer2], [self.pc_ext1, self.pc_ext2]): - # # make sure pcs are computed - # if pc_ext.get_data() is None: - # analyzer.compute_one_extension('principal_components', save=self.save_on_compute) - # self._pc_projections, self._pc_indices = self.pc_ext1.get_some_projections( - # channel_ids=self.analyzer1.channel_ids, - # unit_ids=self.analyzer1.unit_ids - # ) - - # return self._pc_indices, self._pc_projections - # else: - # return 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 get_sparsity_mask(self): - if self.external_sparsity1 is not None: - return np.vstack([self.external_sparsity1.mask, self.external_sparsity2.mask]) - else: - return np.vstack([self.analyzer_sparsity1.mask, self.analyzer_sparsity2.mask]) - - def get_similarity(self, method=None): - if method is None and len(self._similarity_by_method) == 1: - method = list(self._similarity_by_method.keys())[0] - similarity = self._similarity_by_method.get(method, None) - return similarity - def compute_similarity(self, method='l1'): - # have internal cache - if method in self._similarity_by_method: - return self._similarity_by_method[method] - ext = self.analyzer.compute("template_similarity", method=method, save=self.save_on_compute) - self._similarity_by_method[method] = ext.get_data() - return self._similarity_by_method[method] + raise NotImplementedError("template_similarity cannot be computed in comparison mode") - def compute_unit_positions(self, method, method_kwargs): - unit_positions = np.zeros((len(self.unit_ids), 2), dtype='float32') - for analyzer in [self.analyzer1, self.analyzer2]: - ext = analyzer.get_extension('unit_locations') - ext = analyzer.compute_one_extension('unit_locations', save=self.save_on_compute, method=method, **method_kwargs) - unit_positions[:len(analyzer.unit_ids), :] = ext.get_data()[:, :2] - self.unit_positions = unit_positions - - # def get_correlograms(self): - # return self.correlograms, self.correlograms_bins - - # def compute_correlograms(self, window_ms, bin_ms): - # ext = self.analyzer.compute("correlograms", save=self.save_on_compute, window_ms=window_ms, bin_ms=bin_ms) - # self.correlograms, self.correlograms_bins = ext.get_data() - # return self.correlograms, self.correlograms_bins - - # def get_isi_histograms(self): - # return self.isi_histograms, self.isi_bins - - # def compute_isi_histograms(self, window_ms, bin_ms): - # ext = self.analyzer.compute("isi_histograms", save=self.save_on_compute, window_ms=window_ms, bin_ms=bin_ms) - # self.isi_histograms, self.isi_bins = ext.get_data() - # return self.isi_histograms, self.isi_bins - - def get_units_table(self): - return self.units_table - - def get_split_unit_ids(self): - return [] + 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 44304e22..f2db1407 100644 --- a/spikeinterface_gui/main.py +++ b/spikeinterface_gui/main.py @@ -260,11 +260,16 @@ def run_launcher(mode="desktop", analyzer_folders=None, root_folder=None, addres 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=None, - analyzer2_name=None, + analyzer1_name="1", + analyzer2_name="2", mode="desktop", with_traces=True, displayed_unit_properties=None, @@ -291,9 +296,9 @@ def run_mainwindow_comparison( The first sorting analyzer object analyzer2: SortingAnalyzer The second sorting analyzer object - analyzer1_name: str | None, default: None + analyzer1_name: str, default: "1" The name to display for the first analyzer - analyzer2_name: str | None, default: None + analyzer2_name: str, default: "2" The name to display for the second analyzer mode: 'desktop' | 'web' The GUI mode to use. @@ -301,12 +306,6 @@ def run_mainwindow_comparison( 'web' will run a Panel app. with_traces: bool, default: True If True, traces are displayed - curation: bool, default: False - If True, the curation panel is displayed - curation_dict: dict | None, default: None - The curation dictionary to start from an existing curation - label_definitions: dict | None, default: None - The label definitions to provide to the curation panel displayed_unit_properties: list | None, default: None The displayed unit properties in the unit table extra_unit_properties: list | None, default: None @@ -316,10 +315,10 @@ def run_mainwindow_comparison( recording: RecordingExtractor | None, default: None The recording object to display traces. This can be used when the SortingAnalyzer is recordingless. - start_qt_app: bool, default: True - If True, the QT app loop is started + start_app: bool, default: True + If True, the app loop is started layout_preset : str | None - The name of the layout preset. None is default. + 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" @@ -349,7 +348,7 @@ def run_mainwindow_comparison( if mode == "desktop": backend = "qt" elif mode == "web": - raise NotImplementedError + backend = "panel" else: raise ValueError(f"spikeinterface-gui wrong mode {mode}") @@ -357,6 +356,10 @@ def run_mainwindow_comparison( # 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 @@ -377,21 +380,25 @@ def run_mainwindow_comparison( import time t0 = time.perf_counter() - views_to_remove = ["merge", "correlograms", "isi"] + 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) - for zone in layout_dict: - views_in_zone = layout_dict[zone] - if 'unitlist' in views_in_zone: - # substitute unitlist with compareunitlist - layout_dict[zone] = ['compareunitlist' if view == 'unitlist' else view for view in layout_dict[zone]] - for view in views_to_remove: - if view in layout_dict[zone]: - layout_dict[zone].remove(view) - print(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, @@ -400,7 +407,8 @@ def run_mainwindow_comparison( displayed_unit_properties=displayed_unit_properties, extra_unit_properties=extra_unit_properties, skip_extensions=skip_extensions, - disable_save_settings_button=disable_save_settings_button + disable_save_settings_button=disable_save_settings_button, + user_main_settings=user_main_settings, ) if verbose: t1 = time.perf_counter() @@ -426,16 +434,16 @@ def run_mainwindow_comparison( 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) + 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 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) + 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 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 9263a519..7e4cf528 100644 --- a/spikeinterface_gui/viewlist.py +++ b/spikeinterface_gui/viewlist.py @@ -23,6 +23,8 @@ 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 = [ @@ -30,7 +32,8 @@ TraceView, TraceMapView, WaveformView, WaveformHeatMapView, ISIView, CorrelogramView, NDScatterView, SimilarityView, SpikeAmplitudeView, SpikeDepthView, SpikeRateView, CurationView, MetricsView, SpikeListView, - AmplitudeScalingsView, MainTemplateView, EventView, CompareUnitListView + AmplitudeScalingsView, MainTemplateView, EventView, + CompareUnitListView, AgreementMatrixView, VennView, ] def get_all_possible_views():