From 60715187ebcf0c1665df0f55ca4ae23979cff1d5 Mon Sep 17 00:00:00 2001 From: FrancescoNegri Date: Thu, 4 Jul 2024 15:36:33 +0200 Subject: [PATCH 01/13] Add UnitSpatialDistributionsWidget class --- src/spikeinterface/widgets/unit_spatial.py | 135 +++++++++++++++++++++ src/spikeinterface/widgets/widget_list.py | 14 +-- 2 files changed, 140 insertions(+), 9 deletions(-) create mode 100644 src/spikeinterface/widgets/unit_spatial.py diff --git a/src/spikeinterface/widgets/unit_spatial.py b/src/spikeinterface/widgets/unit_spatial.py new file mode 100644 index 0000000000..936e77339b --- /dev/null +++ b/src/spikeinterface/widgets/unit_spatial.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import numpy as np +from probeinterface import Probe +from probeinterface.plotting import get_auto_lims +from seaborn import color_palette +from warnings import warn +from .base import BaseWidget, to_attr + +class UnitSpatialDistributionsWidget(BaseWidget): + """ + Placeholder documentation to be changed. + + Parameters + ---------- + sorting_analyzer : SortingAnalyzer + The SortingAnalyzer object + depth_axis : int, default: 1 + The dimension of unit_locations that is depth + """ + + def __init__( + self, + sorting_analyzer, probe=None, + depth_axis=1, bins=None, + cmap="viridis", kde=False, + depth_hist=True, groups=None, + backend=None, **backend_kwargs + ): + sorting_analyzer = self.ensure_sorting_analyzer(sorting_analyzer) + + self.check_extensions(sorting_analyzer, "unit_locations") + ulc = sorting_analyzer.get_extension("unit_locations") + unit_locations = ulc.get_data(outputs="numpy") + x, y = unit_locations[:, 0], unit_locations[:, 1] + + if type(probe) is Probe: + if sorting_analyzer.recording.has_probe(): + # TODO: throw warning saying that sorting_analyzer has a probe and it will be overwritten + pass + elif sorting_analyzer.recording.has_probe(): + probe = sorting_analyzer.get_probe() + else: + # TODO: throw error or warning, no probe available + pass + + xrange, yrange, _ = get_auto_lims(probe, margin=0) + if bins is None: + bins = ( + np.round(np.diff(xrange).squeeze() / 75).astype(int), + np.round(np.diff(yrange).squeeze() / 75).astype(int) + ) + + if type(cmap) is str: + cmap = color_palette(cmap, as_cmap=True) + + plot_data = dict( + probe=probe, + x=x, + y=y, + depth_axis=depth_axis, + xrange=xrange, + yrange=yrange, + bins=bins, + kde=kde, + cmap=cmap, + depth_hist=depth_hist, + groups=groups + ) + + BaseWidget.__init__(self, plot_data, backend=backend, **backend_kwargs) + + def plot_matplotlib(self, data_plot, **backend_kwargs): + import matplotlib.patches as patches + import matplotlib.path as path + from seaborn import kdeplot, histplot + from .utils_matplotlib import make_mpl_figure + + dp = to_attr(data_plot) + + self.figure, self.axes, self.ax = make_mpl_figure(**backend_kwargs) + + ax = self.ax + + custom_shape = path.Path(dp.probe.probe_planar_contour) + patch = patches.PathPatch(custom_shape, facecolor="none", edgecolor="none") + ax.add_patch(patch) + + if dp.kde is not True: + hist, xedges, yedges = np.histogram2d(dp.x, dp.y, bins=dp.bins, range=[dp.xrange, dp.yrange]) + pcm = ax.pcolormesh(xedges, yedges, hist.T, cmap=dp.cmap) + else: + data = dict(x=dp.x, y=dp.y) + bg = ax.add_patch( + patches.Rectangle( + [dp.xrange[0], dp.yrange[0]], + np.diff(dp.xrange).squeeze(), + np.diff(dp.yrange).squeeze(), + facecolor=dp.cmap.colors[0], + fill=True + ) + ) + bg.set_clip_path(patch) + kdeplot( + data, x='x', y='y', + cmap=dp.cmap, levels=100, thresh=0, fill=True, + ax=ax, bw_adjust=0.1, clip=[dp.xrange, dp.yrange] + ) + pcm = ax.collections[0] + ax.set_xlabel(None) + ax.set_ylabel(None) + + pcm.set_clip_path(patch) + + xlim, ylim, _ = get_auto_lims(dp.probe, margin=10) + ax.set_xlim(*xlim) + ax.set_ylim(*ylim) + ax.spines['top'].set_visible(False) + ax.spines['bottom'].set_visible(False) + ax.spines['right'].set_visible(False) + ax.set_xticks([]) + ax.set_xlabel('') + ax.set_ylabel('Depth (um)') + + if dp.depth_hist is True: + bbox = ax.get_window_extent() + hist_height = 1.5 * bbox.width + + ax_hist = ax.inset_axes([1, 0, hist_height / bbox.width, 1]) + data = dict(y=dp.y) + data['group'] = np.ones(dp.y.size) if dp.groups is None else dp.groups + palette = color_palette('bright', n_colors=1 if dp.groups is None else np.unique(dp.groups).size) + histplot(data=data, y='y', hue='group', bins=dp.bins[1], binrange=dp.yrange, palette=palette, ax=ax_hist, legend=False) + ax_hist.axis('off') + ax_hist.set_ylim(*ylim) \ No newline at end of file diff --git a/src/spikeinterface/widgets/widget_list.py b/src/spikeinterface/widgets/widget_list.py index 8163271ec4..8aebe31dd2 100644 --- a/src/spikeinterface/widgets/widget_list.py +++ b/src/spikeinterface/widgets/widget_list.py @@ -10,10 +10,9 @@ from .autocorrelograms import AutoCorrelogramsWidget from .crosscorrelograms import CrossCorrelogramsWidget from .isi_distribution import ISIDistributionWidget -from .motion import DriftRasterMapWidget, MotionWidget, MotionInfoWidget +from .motion import MotionWidget from .multicomparison import MultiCompGraphWidget, MultiCompGlobalAgreementWidget, MultiCompAgreementBySorterWidget from .peak_activity import PeakActivityMapWidget -from .peaks_on_probe import PeaksOnProbeWidget from .potential_merges import PotentialMergesWidget from .probe_map import ProbeMapWidget from .quality_metrics import QualityMetricsWidget @@ -28,6 +27,7 @@ from .unit_locations import UnitLocationsWidget from .unit_presence import UnitPresenceWidget from .unit_probe_map import UnitProbeMapWidget +from .unit_spatial import UnitSpatialDistributionsWidget from .unit_summary import UnitSummaryWidget from .unit_templates import UnitTemplatesWidget from .unit_waveforms_density_map import UnitWaveformDensityMapWidget @@ -44,15 +44,12 @@ ConfusionMatrixWidget, ComparisonCollisionBySimilarityWidget, CrossCorrelogramsWidget, - DriftRasterMapWidget, ISIDistributionWidget, MotionWidget, - MotionInfoWidget, MultiCompGlobalAgreementWidget, MultiCompAgreementBySorterWidget, MultiCompGraphWidget, PeakActivityMapWidget, - PeaksOnProbeWidget, PotentialMergesWidget, ProbeMapWidget, QualityMetricsWidget, @@ -67,6 +64,7 @@ UnitLocationsWidget, UnitPresenceWidget, UnitProbeMapWidget, + UnitSpatialDistributionsWidget, UnitSummaryWidget, UnitTemplatesWidget, UnitWaveformDensityMapWidget, @@ -119,15 +117,12 @@ plot_confusion_matrix = ConfusionMatrixWidget plot_comparison_collision_by_similarity = ComparisonCollisionBySimilarityWidget plot_crosscorrelograms = CrossCorrelogramsWidget -plot_drift_raster_map = DriftRasterMapWidget plot_isi_distribution = ISIDistributionWidget plot_motion = MotionWidget -plot_motion_info = MotionInfoWidget plot_multicomparison_agreement = MultiCompGlobalAgreementWidget plot_multicomparison_agreement_by_sorter = MultiCompAgreementBySorterWidget plot_multicomparison_graph = MultiCompGraphWidget plot_peak_activity = PeakActivityMapWidget -plot_peaks_on_probe = PeaksOnProbeWidget plot_potential_merges = PotentialMergesWidget plot_probe_map = ProbeMapWidget plot_quality_metrics = QualityMetricsWidget @@ -142,6 +137,7 @@ plot_unit_locations = UnitLocationsWidget plot_unit_presence = UnitPresenceWidget plot_unit_probe_map = UnitProbeMapWidget +plot_unit_spatial_distribution = UnitSpatialDistributionsWidget plot_unit_summary = UnitSummaryWidget plot_unit_templates = UnitTemplatesWidget plot_unit_waveforms_density_map = UnitWaveformDensityMapWidget @@ -156,4 +152,4 @@ def plot_timeseries(*args, **kwargs): warnings.warn("plot_timeseries() is now plot_traces()") - return plot_traces(*args, **kwargs) + return plot_traces(*args, **kwargs) \ No newline at end of file From cfda64986d62d8253ec0e551dbff3be78650415b Mon Sep 17 00:00:00 2001 From: FrancescoNegri Date: Thu, 4 Jul 2024 15:54:25 +0200 Subject: [PATCH 02/13] Add KDE keyword arguments --- src/spikeinterface/widgets/unit_spatial.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/spikeinterface/widgets/unit_spatial.py b/src/spikeinterface/widgets/unit_spatial.py index 936e77339b..746aaf0a8e 100644 --- a/src/spikeinterface/widgets/unit_spatial.py +++ b/src/spikeinterface/widgets/unit_spatial.py @@ -25,6 +25,7 @@ def __init__( depth_axis=1, bins=None, cmap="viridis", kde=False, depth_hist=True, groups=None, + kde_kws=None, backend=None, **backend_kwargs ): sorting_analyzer = self.ensure_sorting_analyzer(sorting_analyzer) @@ -50,6 +51,7 @@ def __init__( np.round(np.diff(xrange).squeeze() / 75).astype(int), np.round(np.diff(yrange).squeeze() / 75).astype(int) ) + # TODO: change behaviour, if bins is not defined, bin only along the depth axis if type(cmap) is str: cmap = color_palette(cmap, as_cmap=True) @@ -65,7 +67,8 @@ def __init__( kde=kde, cmap=cmap, depth_hist=depth_hist, - groups=groups + groups=groups, + kde_kws=kde_kws ) BaseWidget.__init__(self, plot_data, backend=backend, **backend_kwargs) @@ -90,6 +93,9 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): hist, xedges, yedges = np.histogram2d(dp.x, dp.y, bins=dp.bins, range=[dp.xrange, dp.yrange]) pcm = ax.pcolormesh(xedges, yedges, hist.T, cmap=dp.cmap) else: + kde_kws = dict(levels=100, thresh=0, fill=True, bw_adjust=0.1) + if dp.kde_kws is not None: + kde_kws.update(dp.kde_kws) data = dict(x=dp.x, y=dp.y) bg = ax.add_patch( patches.Rectangle( @@ -103,8 +109,9 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): bg.set_clip_path(patch) kdeplot( data, x='x', y='y', - cmap=dp.cmap, levels=100, thresh=0, fill=True, - ax=ax, bw_adjust=0.1, clip=[dp.xrange, dp.yrange] + clip=[dp.xrange, dp.yrange], + cmap=dp.cmap, ax=ax, + **kde_kws ) pcm = ax.collections[0] ax.set_xlabel(None) From 64f978812c01af3dcdf6c414f159660f1742d1a0 Mon Sep 17 00:00:00 2001 From: FrancescoNegri Date: Tue, 9 Jul 2024 16:43:29 +0200 Subject: [PATCH 03/13] Revert wrong changes in the widgets list --- src/spikeinterface/widgets/widget_list.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/spikeinterface/widgets/widget_list.py b/src/spikeinterface/widgets/widget_list.py index 8aebe31dd2..abd9b700e5 100644 --- a/src/spikeinterface/widgets/widget_list.py +++ b/src/spikeinterface/widgets/widget_list.py @@ -10,9 +10,10 @@ from .autocorrelograms import AutoCorrelogramsWidget from .crosscorrelograms import CrossCorrelogramsWidget from .isi_distribution import ISIDistributionWidget -from .motion import MotionWidget +from .motion import DriftRasterMapWidget, MotionWidget, MotionInfoWidget from .multicomparison import MultiCompGraphWidget, MultiCompGlobalAgreementWidget, MultiCompAgreementBySorterWidget from .peak_activity import PeakActivityMapWidget +from .peaks_on_probe import PeaksOnProbeWidget from .potential_merges import PotentialMergesWidget from .probe_map import ProbeMapWidget from .quality_metrics import QualityMetricsWidget @@ -44,12 +45,15 @@ ConfusionMatrixWidget, ComparisonCollisionBySimilarityWidget, CrossCorrelogramsWidget, + DriftRasterMapWidget, ISIDistributionWidget, MotionWidget, + MotionInfoWidget, MultiCompGlobalAgreementWidget, MultiCompAgreementBySorterWidget, MultiCompGraphWidget, PeakActivityMapWidget, + PeaksOnProbeWidget, PotentialMergesWidget, ProbeMapWidget, QualityMetricsWidget, @@ -117,12 +121,15 @@ plot_confusion_matrix = ConfusionMatrixWidget plot_comparison_collision_by_similarity = ComparisonCollisionBySimilarityWidget plot_crosscorrelograms = CrossCorrelogramsWidget +plot_drift_raster_map = DriftRasterMapWidget plot_isi_distribution = ISIDistributionWidget plot_motion = MotionWidget +plot_motion_info = MotionInfoWidget plot_multicomparison_agreement = MultiCompGlobalAgreementWidget plot_multicomparison_agreement_by_sorter = MultiCompAgreementBySorterWidget plot_multicomparison_graph = MultiCompGraphWidget plot_peak_activity = PeakActivityMapWidget +plot_peaks_on_probe = PeaksOnProbeWidget plot_potential_merges = PotentialMergesWidget plot_probe_map = ProbeMapWidget plot_quality_metrics = QualityMetricsWidget From 574955adaf638f89a36f254cd526ecae2253f7be Mon Sep 17 00:00:00 2001 From: FrancescoNegri Date: Tue, 9 Jul 2024 16:53:23 +0200 Subject: [PATCH 04/13] Run pre-commit locally --- src/spikeinterface/widgets/unit_spatial.py | 66 +++++++++++++--------- src/spikeinterface/widgets/widget_list.py | 2 +- 2 files changed, 39 insertions(+), 29 deletions(-) diff --git a/src/spikeinterface/widgets/unit_spatial.py b/src/spikeinterface/widgets/unit_spatial.py index 746aaf0a8e..06f253f07f 100644 --- a/src/spikeinterface/widgets/unit_spatial.py +++ b/src/spikeinterface/widgets/unit_spatial.py @@ -7,6 +7,7 @@ from warnings import warn from .base import BaseWidget, to_attr + class UnitSpatialDistributionsWidget(BaseWidget): """ Placeholder documentation to be changed. @@ -18,15 +19,20 @@ class UnitSpatialDistributionsWidget(BaseWidget): depth_axis : int, default: 1 The dimension of unit_locations that is depth """ - + def __init__( - self, - sorting_analyzer, probe=None, - depth_axis=1, bins=None, - cmap="viridis", kde=False, - depth_hist=True, groups=None, - kde_kws=None, - backend=None, **backend_kwargs + self, + sorting_analyzer, + probe=None, + depth_axis=1, + bins=None, + cmap="viridis", + kde=False, + depth_hist=True, + groups=None, + kde_kws=None, + backend=None, + **backend_kwargs, ): sorting_analyzer = self.ensure_sorting_analyzer(sorting_analyzer) @@ -44,12 +50,12 @@ def __init__( else: # TODO: throw error or warning, no probe available pass - + xrange, yrange, _ = get_auto_lims(probe, margin=0) if bins is None: bins = ( np.round(np.diff(xrange).squeeze() / 75).astype(int), - np.round(np.diff(yrange).squeeze() / 75).astype(int) + np.round(np.diff(yrange).squeeze() / 75).astype(int), ) # TODO: change behaviour, if bins is not defined, bin only along the depth axis @@ -68,7 +74,7 @@ def __init__( cmap=cmap, depth_hist=depth_hist, groups=groups, - kde_kws=kde_kws + kde_kws=kde_kws, ) BaseWidget.__init__(self, plot_data, backend=backend, **backend_kwargs) @@ -103,16 +109,11 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): np.diff(dp.xrange).squeeze(), np.diff(dp.yrange).squeeze(), facecolor=dp.cmap.colors[0], - fill=True + fill=True, ) ) bg.set_clip_path(patch) - kdeplot( - data, x='x', y='y', - clip=[dp.xrange, dp.yrange], - cmap=dp.cmap, ax=ax, - **kde_kws - ) + kdeplot(data, x="x", y="y", clip=[dp.xrange, dp.yrange], cmap=dp.cmap, ax=ax, **kde_kws) pcm = ax.collections[0] ax.set_xlabel(None) ax.set_ylabel(None) @@ -122,12 +123,12 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): xlim, ylim, _ = get_auto_lims(dp.probe, margin=10) ax.set_xlim(*xlim) ax.set_ylim(*ylim) - ax.spines['top'].set_visible(False) - ax.spines['bottom'].set_visible(False) - ax.spines['right'].set_visible(False) + ax.spines["top"].set_visible(False) + ax.spines["bottom"].set_visible(False) + ax.spines["right"].set_visible(False) ax.set_xticks([]) - ax.set_xlabel('') - ax.set_ylabel('Depth (um)') + ax.set_xlabel("") + ax.set_ylabel("Depth (um)") if dp.depth_hist is True: bbox = ax.get_window_extent() @@ -135,8 +136,17 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): ax_hist = ax.inset_axes([1, 0, hist_height / bbox.width, 1]) data = dict(y=dp.y) - data['group'] = np.ones(dp.y.size) if dp.groups is None else dp.groups - palette = color_palette('bright', n_colors=1 if dp.groups is None else np.unique(dp.groups).size) - histplot(data=data, y='y', hue='group', bins=dp.bins[1], binrange=dp.yrange, palette=palette, ax=ax_hist, legend=False) - ax_hist.axis('off') - ax_hist.set_ylim(*ylim) \ No newline at end of file + data["group"] = np.ones(dp.y.size) if dp.groups is None else dp.groups + palette = color_palette("bright", n_colors=1 if dp.groups is None else np.unique(dp.groups).size) + histplot( + data=data, + y="y", + hue="group", + bins=dp.bins[1], + binrange=dp.yrange, + palette=palette, + ax=ax_hist, + legend=False, + ) + ax_hist.axis("off") + ax_hist.set_ylim(*ylim) diff --git a/src/spikeinterface/widgets/widget_list.py b/src/spikeinterface/widgets/widget_list.py index abd9b700e5..ca4159cabb 100644 --- a/src/spikeinterface/widgets/widget_list.py +++ b/src/spikeinterface/widgets/widget_list.py @@ -159,4 +159,4 @@ def plot_timeseries(*args, **kwargs): warnings.warn("plot_timeseries() is now plot_traces()") - return plot_traces(*args, **kwargs) \ No newline at end of file + return plot_traces(*args, **kwargs) From 9e5330a42fd95b93ab799227e576328d6655bceb Mon Sep 17 00:00:00 2001 From: FrancescoNegri Date: Wed, 10 Jul 2024 10:23:37 +0200 Subject: [PATCH 05/13] Add seaborn to widgets dependencies --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 2ba53328e7..4b2fe2232c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -108,6 +108,7 @@ widgets = [ "matplotlib", "ipympl", "ipywidgets", + "seaborn>=0.13.0", "sortingview>=0.12.0", ] From ee76d37450f9fef902d469cbb65cc3de749b561f Mon Sep 17 00:00:00 2001 From: FrancescoNegri Date: Wed, 10 Jul 2024 10:25:51 +0200 Subject: [PATCH 06/13] Move plotting dependencies inside plot_matplotlib function --- src/spikeinterface/widgets/unit_spatial.py | 42 ++++++++++------------ 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/src/spikeinterface/widgets/unit_spatial.py b/src/spikeinterface/widgets/unit_spatial.py index 06f253f07f..09f4f1afba 100644 --- a/src/spikeinterface/widgets/unit_spatial.py +++ b/src/spikeinterface/widgets/unit_spatial.py @@ -2,8 +2,6 @@ import numpy as np from probeinterface import Probe -from probeinterface.plotting import get_auto_lims -from seaborn import color_palette from warnings import warn from .base import BaseWidget, to_attr @@ -51,24 +49,19 @@ def __init__( # TODO: throw error or warning, no probe available pass - xrange, yrange, _ = get_auto_lims(probe, margin=0) - if bins is None: - bins = ( - np.round(np.diff(xrange).squeeze() / 75).astype(int), - np.round(np.diff(yrange).squeeze() / 75).astype(int), - ) - # TODO: change behaviour, if bins is not defined, bin only along the depth axis - - if type(cmap) is str: - cmap = color_palette(cmap, as_cmap=True) + # xrange, yrange, _ = get_auto_lims(probe, margin=0) + # if bins is None: + # bins = ( + # np.round(np.diff(xrange).squeeze() / 75).astype(int), + # np.round(np.diff(yrange).squeeze() / 75).astype(int), + # ) + # # TODO: change behaviour, if bins is not defined, bin only along the depth axis plot_data = dict( probe=probe, x=x, y=y, depth_axis=depth_axis, - xrange=xrange, - yrange=yrange, bins=bins, kde=kde, cmap=cmap, @@ -82,10 +75,13 @@ def __init__( def plot_matplotlib(self, data_plot, **backend_kwargs): import matplotlib.patches as patches import matplotlib.path as path - from seaborn import kdeplot, histplot + from probeinterface.plotting import get_auto_lims + from seaborn import color_palette, kdeplot, histplot from .utils_matplotlib import make_mpl_figure dp = to_attr(data_plot) + xrange, yrange, _ = get_auto_lims(dp.probe, margin=0) + cmap = color_palette(dp.cmap, as_cmap=True) if type(dp.cmap) is str else dp.cmap self.figure, self.axes, self.ax = make_mpl_figure(**backend_kwargs) @@ -96,8 +92,8 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): ax.add_patch(patch) if dp.kde is not True: - hist, xedges, yedges = np.histogram2d(dp.x, dp.y, bins=dp.bins, range=[dp.xrange, dp.yrange]) - pcm = ax.pcolormesh(xedges, yedges, hist.T, cmap=dp.cmap) + hist, xedges, yedges = np.histogram2d(dp.x, dp.y, bins=dp.bins, range=[xrange, yrange]) + pcm = ax.pcolormesh(xedges, yedges, hist.T, cmap=cmap) else: kde_kws = dict(levels=100, thresh=0, fill=True, bw_adjust=0.1) if dp.kde_kws is not None: @@ -105,15 +101,15 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): data = dict(x=dp.x, y=dp.y) bg = ax.add_patch( patches.Rectangle( - [dp.xrange[0], dp.yrange[0]], - np.diff(dp.xrange).squeeze(), - np.diff(dp.yrange).squeeze(), - facecolor=dp.cmap.colors[0], + [xrange[0], yrange[0]], + np.diff(xrange).squeeze(), + np.diff(yrange).squeeze(), + facecolor=cmap.colors[0], fill=True, ) ) bg.set_clip_path(patch) - kdeplot(data, x="x", y="y", clip=[dp.xrange, dp.yrange], cmap=dp.cmap, ax=ax, **kde_kws) + kdeplot(data, x="x", y="y", clip=[xrange, yrange], cmap=cmap, ax=ax, **kde_kws) pcm = ax.collections[0] ax.set_xlabel(None) ax.set_ylabel(None) @@ -143,7 +139,7 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): y="y", hue="group", bins=dp.bins[1], - binrange=dp.yrange, + binrange=yrange, palette=palette, ax=ax_hist, legend=False, From e679568e68436ac9c6eddb08b0273c71bee83b02 Mon Sep 17 00:00:00 2001 From: FrancescoNegri Date: Wed, 10 Jul 2024 10:26:54 +0200 Subject: [PATCH 07/13] Add warning and error messages on Probe object --- src/spikeinterface/widgets/unit_spatial.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/spikeinterface/widgets/unit_spatial.py b/src/spikeinterface/widgets/unit_spatial.py index 09f4f1afba..a7c7e4f1c1 100644 --- a/src/spikeinterface/widgets/unit_spatial.py +++ b/src/spikeinterface/widgets/unit_spatial.py @@ -41,13 +41,15 @@ def __init__( if type(probe) is Probe: if sorting_analyzer.recording.has_probe(): - # TODO: throw warning saying that sorting_analyzer has a probe and it will be overwritten - pass + warn( + "There is a Probe attached to this recording, but the probe argument is not None: the attached Probe will be ignored." + ) elif sorting_analyzer.recording.has_probe(): probe = sorting_analyzer.get_probe() else: - # TODO: throw error or warning, no probe available - pass + raise ValueError( + "There is no Probe attached to this recording. Use set_probe(...) to attach one or pass it to the function via the probe argument." + ) # xrange, yrange, _ = get_auto_lims(probe, margin=0) # if bins is None: From da2042c6396c7f5a3399db1b4a6a4f377540a790 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 11 Nov 2026 13:46:30 +0000 Subject: [PATCH 08/13] Minor refactorings. --- src/spikeinterface/widgets/unit_spatial.py | 214 +++++++++++++++------ 1 file changed, 151 insertions(+), 63 deletions(-) diff --git a/src/spikeinterface/widgets/unit_spatial.py b/src/spikeinterface/widgets/unit_spatial.py index a7c7e4f1c1..7ba7d55023 100644 --- a/src/spikeinterface/widgets/unit_spatial.py +++ b/src/spikeinterface/widgets/unit_spatial.py @@ -1,63 +1,83 @@ from __future__ import annotations +from typing import TYPE_CHECKING, Any, Literal + import numpy as np from probeinterface import Probe -from warnings import warn from .base import BaseWidget, to_attr +if TYPE_CHECKING: + from matplotlib.colors import Colormap + from numpy.typing import ArrayLike + from spikeinterface.core import SortingAnalyzer + from spikeinterface.core.waveforms_extractor_backwards_compatibility import MockWaveformExtractor + class UnitSpatialDistributionsWidget(BaseWidget): """ - Placeholder documentation to be changed. + Plot unit spatial distributions as a histogram or kernel density estimate. Parameters ---------- sorting_analyzer : SortingAnalyzer - The SortingAnalyzer object + Analyzer with the unit_locations extension already computed. + probe : Probe or None, default: None + Probe geometry to display. If None, use the analyzer's stored probe. depth_axis : int, default: 1 - The dimension of unit_locations that is depth + Coordinate along the probe representing depth: 0 for x or 1 for y. + bins : int, tuple or None, default: None + Bin count for both axes, or a pair of counts or bin-edge arrays for x + and y. If None, use 75 um per depth bin. + cmap : str or Colormap, default: "viridis" + Colormap name or object for the spatial histogram or KDE. + kde : bool, default: False + If True, show a two-dimensional kernel density estimate instead of + the spatial histogram. + depth_hist : bool, default: True + Show a depth histogram to the right for y depth or below for x depth. + groups : array-like or None, default: None + Group label for each unit, in analyzer unit order. Colors the depth + histogram by group, e.g. ["good", "good", "bad", "bad"] for four units. + If None, use one group for all units. + histogram by group. If None, use one group for all units. + kde_kwargs : dict or None, default: None + Keyword arguments passed to seaborn.kdeplot, overriding the defaults. + Only used for KDE. + show_depth_hist_axis : bool, default: True + Show the count-axis on the depth histogram. + Only used when depth_hist is True. """ def __init__( self, - sorting_analyzer, - probe=None, - depth_axis=1, - bins=None, - cmap="viridis", - kde=False, - depth_hist=True, - groups=None, - kde_kws=None, - backend=None, - **backend_kwargs, - ): + sorting_analyzer: SortingAnalyzer, + probe: Probe | None = None, + depth_axis: Literal[0, 1] = 1, + bins: int | tuple | None = None, + cmap: str | Colormap = "viridis", + kde: bool = False, + depth_hist: bool = True, + groups: ArrayLike | None = None, + kde_kwargs: dict[str, Any] | None = None, + backend: str | None = None, + show_depth_hist_axis: bool = True, + **backend_kwargs: Any, + ) -> None: sorting_analyzer = self.ensure_sorting_analyzer(sorting_analyzer) self.check_extensions(sorting_analyzer, "unit_locations") ulc = sorting_analyzer.get_extension("unit_locations") unit_locations = ulc.get_data(outputs="numpy") + + if depth_axis not in (0, 1): + raise ValueError("depth_axis must be 0 (x) or 1 (y)") + x, y = unit_locations[:, 0], unit_locations[:, 1] - if type(probe) is Probe: - if sorting_analyzer.recording.has_probe(): - warn( - "There is a Probe attached to this recording, but the probe argument is not None: the attached Probe will be ignored." - ) - elif sorting_analyzer.recording.has_probe(): + if probe is None: probe = sorting_analyzer.get_probe() - else: - raise ValueError( - "There is no Probe attached to this recording. Use set_probe(...) to attach one or pass it to the function via the probe argument." - ) - - # xrange, yrange, _ = get_auto_lims(probe, margin=0) - # if bins is None: - # bins = ( - # np.round(np.diff(xrange).squeeze() / 75).astype(int), - # np.round(np.diff(yrange).squeeze() / 75).astype(int), - # ) - # # TODO: change behaviour, if bins is not defined, bin only along the depth axis + elif not isinstance(probe, Probe): + raise TypeError("probe must be a Probe or None") plot_data = dict( probe=probe, @@ -68,83 +88,151 @@ def __init__( kde=kde, cmap=cmap, depth_hist=depth_hist, + show_depth_hist_axis=show_depth_hist_axis, groups=groups, - kde_kws=kde_kws, + kde_kwargs=kde_kwargs, ) BaseWidget.__init__(self, plot_data, backend=backend, **backend_kwargs) - def plot_matplotlib(self, data_plot, **backend_kwargs): + def plot_matplotlib(self, data_plot: dict[str, Any], **backend_kwargs: Any) -> None: import matplotlib.patches as patches - import matplotlib.path as path + import matplotlib.path as mpl_paths from probeinterface.plotting import get_auto_lims from seaborn import color_palette, kdeplot, histplot from .utils_matplotlib import make_mpl_figure dp = to_attr(data_plot) xrange, yrange, _ = get_auto_lims(dp.probe, margin=0) + bins = dp.bins + + if bins is None: + # if bins not supplied, divive the probe into 75 um bins + depth_range = (xrange, yrange)[dp.depth_axis] + bins = [1, 1] + bins[dp.depth_axis] = max(1, int(np.ceil(np.ptp(depth_range) / 75.0))) + + # Explicitly create the depth bin edges so they can be + # shared between the spatial and marginal histograms. + bin_specs = (bins, bins) if np.isscalar(bins) else bins + bins = ( + np.histogram_bin_edges(dp.x, bins=bin_specs[0], range=xrange), + np.histogram_bin_edges(dp.y, bins=bin_specs[1], range=yrange), + ) cmap = color_palette(dp.cmap, as_cmap=True) if type(dp.cmap) is str else dp.cmap self.figure, self.axes, self.ax = make_mpl_figure(**backend_kwargs) ax = self.ax - - custom_shape = path.Path(dp.probe.probe_planar_contour) - patch = patches.PathPatch(custom_shape, facecolor="none", edgecolor="none") - ax.add_patch(patch) - + if dp.depth_hist and backend_kwargs.get("ax") is None and backend_kwargs.get("axes") is None: + # Keep the marginal histogram inside the default figure bounds. + # Fixed layout proportions reserve space for the depth histogram. + position = ax.get_position() + if dp.depth_axis == 1: + ax.set_position([position.x0, position.y0, position.width / 2.5, position.height]) + else: + height = position.height / 1.6 + ax.set_position([position.x0, position.y0 + 0.6 * height, position.width, height]) + + # Set the clipping region for the spatial plot based on the probe dimensions + probe_outline = None + if dp.probe.probe_planar_contour is not None: + probe_outline_path = mpl_paths.Path(dp.probe.probe_planar_contour) + probe_outline = patches.PathPatch(probe_outline_path, facecolor="none", edgecolor="none") + ax.add_patch(probe_outline) + + # Create the spatial plot if dp.kde is not True: - hist, xedges, yedges = np.histogram2d(dp.x, dp.y, bins=dp.bins, range=[xrange, yrange]) - pcm = ax.pcolormesh(xedges, yedges, hist.T, cmap=cmap) + hist, xedges, yedges = np.histogram2d(dp.x, dp.y, bins=bins, range=[xrange, yrange]) + spatial_plot = ax.pcolormesh(xedges, yedges, hist.T, cmap=cmap) else: - kde_kws = dict(levels=100, thresh=0, fill=True, bw_adjust=0.1) - if dp.kde_kws is not None: - kde_kws.update(dp.kde_kws) + kde_kwargs = dict(levels=100, thresh=0, fill=True, bw_adjust=0.1) + if dp.kde_kwargs is not None: + kde_kwargs.update(dp.kde_kwargs) + data = dict(x=dp.x, y=dp.y) - bg = ax.add_patch( + kde_background = ax.add_patch( patches.Rectangle( [xrange[0], yrange[0]], np.diff(xrange).squeeze(), np.diff(yrange).squeeze(), - facecolor=cmap.colors[0], + facecolor=cmap(0.0), fill=True, ) ) - bg.set_clip_path(patch) - kdeplot(data, x="x", y="y", clip=[xrange, yrange], cmap=cmap, ax=ax, **kde_kws) - pcm = ax.collections[0] + if probe_outline is not None: + kde_background.set_clip_path(probe_outline) + + kdeplot(data, x="x", y="y", clip=[xrange, yrange], cmap=cmap, ax=ax, **kde_kwargs) + spatial_plot = ax.collections[0] ax.set_xlabel(None) ax.set_ylabel(None) - pcm.set_clip_path(patch) + if probe_outline is not None: + spatial_plot.set_clip_path(probe_outline) + # Create the histogram plot xlim, ylim, _ = get_auto_lims(dp.probe, margin=10) ax.set_xlim(*xlim) ax.set_ylim(*ylim) ax.spines["top"].set_visible(False) ax.spines["bottom"].set_visible(False) ax.spines["right"].set_visible(False) - ax.set_xticks([]) - ax.set_xlabel("") - ax.set_ylabel("Depth (um)") + + if dp.depth_axis == 1: + ax.set_xticks([]) + ax.set_xlabel("") + ax.set_ylabel("Depth (um)") + else: + ax.set_yticks([]) + ax.set_xlabel("Depth (um)") + ax.set_ylabel("") if dp.depth_hist is True: bbox = ax.get_window_extent() hist_height = 1.5 * bbox.width - ax_hist = ax.inset_axes([1, 0, hist_height / bbox.width, 1]) - data = dict(y=dp.y) + if dp.depth_axis == 1: + ax_hist = ax.inset_axes([1, 0, hist_height / bbox.width, 1]) + depth_key = "y" + depth_values = dp.y + else: + ax_hist = ax.inset_axes([0, -0.6, 1, 0.4]) + depth_key = "x" + depth_values = dp.x + + data = {depth_key: depth_values} data["group"] = np.ones(dp.y.size) if dp.groups is None else dp.groups palette = color_palette("bright", n_colors=1 if dp.groups is None else np.unique(dp.groups).size) + histplot( data=data, - y="y", + **{depth_key: depth_key}, hue="group", - bins=dp.bins[1], - binrange=yrange, + bins=bins[dp.depth_axis], + binrange=(xrange, yrange)[dp.depth_axis], palette=palette, ax=ax_hist, legend=False, ) - ax_hist.axis("off") - ax_hist.set_ylim(*ylim) + + if dp.show_depth_hist_axis: + # Show axis for counts on histogram + if dp.depth_axis == 1: + ax_hist.yaxis.set_visible(False) + ax_hist.set_xlabel("Count") + visible_spine = "bottom" + else: + ax_hist.xaxis.set_visible(False) + ax_hist.set_ylabel("Count") + visible_spine = "left" + + for name, spine in ax_hist.spines.items(): + spine.set_visible(name == visible_spine) + else: + ax_hist.axis("off") + + if dp.depth_axis == 1: + ax_hist.set_ylim(*ylim) + else: + ax_hist.set_xlim(*xlim) From b1d05215a6605570fbbdb4fd0c064b8fa6f612b6 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Thu, 12 Nov 2026 10:19:46 +0100 Subject: [PATCH 09/13] fix: drop future annotations --- src/spikeinterface/widgets/unit_spatial.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/spikeinterface/widgets/unit_spatial.py b/src/spikeinterface/widgets/unit_spatial.py index 7ba7d55023..cfa3057d4c 100644 --- a/src/spikeinterface/widgets/unit_spatial.py +++ b/src/spikeinterface/widgets/unit_spatial.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import TYPE_CHECKING, Any, Literal import numpy as np From db6aa35a2aa70d6620292343170bd2ec7f1ddcd7 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Thu, 12 Nov 2026 10:21:49 +0100 Subject: [PATCH 10/13] fix: ruff --- .../generation/session_displacement_generator.py | 9 +++------ src/spikeinterface/widgets/unit_spatial.py | 1 - 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/spikeinterface/generation/session_displacement_generator.py b/src/spikeinterface/generation/session_displacement_generator.py index ce81c2d4f9..d6421b9886 100644 --- a/src/spikeinterface/generation/session_displacement_generator.py +++ b/src/spikeinterface/generation/session_displacement_generator.py @@ -196,7 +196,6 @@ def generate_session_displacement_recordings( amplitude_scaling_order = None for rec_idx, (shift, duration) in enumerate(zip(recording_shifts, recording_durations)): - displacement_vector, displacement_unit_factor = _get_inter_session_displacements( shift, non_rigid_gradient[rec_idx] if isinstance(non_rigid_gradient, list) else non_rigid_gradient, @@ -238,7 +237,6 @@ def generate_session_displacement_recordings( ) if recording_amplitude_scalings is not None: - if rec_idx == 0: amplitude_scaling_order = _get_amplitude_scaling_order( templates_array_moved, @@ -399,13 +397,12 @@ def _check_generate_session_displacement_arguments( raise ValueError("Each record entry for `recording_shifts` must have two elements, the x and y shift.") if recording_amplitude_scalings is not None: - keys = recording_amplitude_scalings.keys() - if not "method" in keys or not "scalings" in keys: + if "method" not in keys or "scalings" not in keys: raise ValueError("`recording_amplitude_scalings` must be a dict with keys `method` and `scalings`.") allowed_methods = ["by_passed_order", "by_amplitude_and_firing_rate", "by_firing_rate"] - if not recording_amplitude_scalings["method"] in allowed_methods: + if recording_amplitude_scalings["method"] not in allowed_methods: raise ValueError(f"`recording_amplitude_scalings` must be one of {allowed_methods}") rec_scalings = recording_amplitude_scalings["scalings"] @@ -420,7 +417,7 @@ def _check_generate_session_displacement_arguments( if isinstance(non_rigid_gradient, list): if not len(non_rigid_gradient) == expected_num_recs: - raise ValueError("If `non_rigid_gradient` is a list, it must " "contain one value for each recording.") + raise ValueError("If `non_rigid_gradient` is a list, it must contain one value for each recording.") def _update_kwargs_for_extended_units( diff --git a/src/spikeinterface/widgets/unit_spatial.py b/src/spikeinterface/widgets/unit_spatial.py index cfa3057d4c..72087fcf9f 100644 --- a/src/spikeinterface/widgets/unit_spatial.py +++ b/src/spikeinterface/widgets/unit_spatial.py @@ -8,7 +8,6 @@ from matplotlib.colors import Colormap from numpy.typing import ArrayLike from spikeinterface.core import SortingAnalyzer - from spikeinterface.core.waveforms_extractor_backwards_compatibility import MockWaveformExtractor class UnitSpatialDistributionsWidget(BaseWidget): From 97d600b8632ec671b966bfc7456f37767e3072d5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 12 Nov 2026 09:22:12 +0000 Subject: [PATCH 11/13] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../generation/tests/test_session_displacement_generator.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/spikeinterface/generation/tests/test_session_displacement_generator.py b/src/spikeinterface/generation/tests/test_session_displacement_generator.py index 05d6db4c8e..b1d8cffbfa 100644 --- a/src/spikeinterface/generation/tests/test_session_displacement_generator.py +++ b/src/spikeinterface/generation/tests/test_session_displacement_generator.py @@ -76,7 +76,6 @@ def test_x_y_rigid_shifts_are_properly_set(self, options): locations_1 = extra_outputs["unit_locations"][0] for rec_idx in range(1, 4): - shifts = recording_shifts[rec_idx] assert np.array_equal( @@ -91,14 +90,12 @@ def test_x_y_rigid_shifts_are_properly_set(self, options): min_channel_loc = output_recordings[0].get_channel_locations()[0, 1] max_channel_loc = output_recordings[0].get_channel_locations()[-1, 1] for unit_idx in range(num_units): - start_pos = self._get_peak_chan_loc_in_um( extra_outputs["templates_array_moved"][0][unit_idx], options["y_bin_um"], ) for rec_idx in range(1, options["num_recs"]): - new_pos = self._get_peak_chan_loc_in_um( extra_outputs["templates_array_moved"][rec_idx][unit_idx], options["y_bin_um"] ) @@ -156,7 +153,6 @@ def test_spike_times_and_firing_rates_across_recordings(self, options): for unit_idx in range(options["kwargs"]["num_units"]): for rec_idx in range(1, options["num_recs"]): - # Exact spike times are not preserved when seed is None assert np.array_equal( output_sortings_same[0].get_unit_spike_train(str(unit_idx)), @@ -222,7 +218,6 @@ def test_x_y_shift_non_rigid(self, options, dim_idx): # w.r.t the first recording), check the rigid and nonrigid shifts # are as expected. for rec_idx in range(1, options["num_recs"]): - shift = options["kwargs"]["recording_shifts"][rec_idx][dim_idx] # Get the rigid shift between the first recording and this shifted recording @@ -437,7 +432,6 @@ def test_shift_units_outside_probe(self, options): num_extended_units = num_units * 3 for ses_idx in range(num_sessions): - # There are 3x the number of units when new units are created # (one new set above, and one new set below the probe). for key in ["unit_locations", "templates_array_moved", "firing_rates"]: From a7f5a7ebb70cc1791368b8f07dd331a00145ddf5 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Thu, 12 Nov 2026 10:28:28 +0100 Subject: [PATCH 12/13] fix: tests --- src/spikeinterface/core/generate.py | 2 +- src/spikeinterface/widgets/unit_spatial.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/spikeinterface/core/generate.py b/src/spikeinterface/core/generate.py index 724d92023d..af005a8031 100644 --- a/src/spikeinterface/core/generate.py +++ b/src/spikeinterface/core/generate.py @@ -8,7 +8,7 @@ from .basesorting import SpikeVectorSortingSegment from .numpyextractors import NumpySorting -from probeinterface import generate_linear_probe, generate_multi_columns_probe +from probeinterface import Probe, generate_linear_probe, generate_multi_columns_probe from spikeinterface.core import BaseRecording, BaseRecordingSegment, BaseSorting, BaseSortingSegment from .core_tools import define_function_from_class, ms_to_samples, _ensure_seed diff --git a/src/spikeinterface/widgets/unit_spatial.py b/src/spikeinterface/widgets/unit_spatial.py index 72087fcf9f..bd4a91c5dd 100644 --- a/src/spikeinterface/widgets/unit_spatial.py +++ b/src/spikeinterface/widgets/unit_spatial.py @@ -47,14 +47,14 @@ class UnitSpatialDistributionsWidget(BaseWidget): def __init__( self, - sorting_analyzer: SortingAnalyzer, + sorting_analyzer: "SortingAnalyzer", probe: Probe | None = None, depth_axis: Literal[0, 1] = 1, bins: int | tuple | None = None, - cmap: str | Colormap = "viridis", + cmap: "str | Colormap" = "viridis", kde: bool = False, depth_hist: bool = True, - groups: ArrayLike | None = None, + groups: "ArrayLike | None" = None, kde_kwargs: dict[str, Any] | None = None, backend: str | None = None, show_depth_hist_axis: bool = True, From e0bf8048f61cfd6ecdc2b8256919893b41acafdd Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Thu, 12 Nov 2026 10:32:45 +0100 Subject: [PATCH 13/13] fix: remove type checking --- src/spikeinterface/widgets/unit_spatial.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/spikeinterface/widgets/unit_spatial.py b/src/spikeinterface/widgets/unit_spatial.py index bd4a91c5dd..49521d5be2 100644 --- a/src/spikeinterface/widgets/unit_spatial.py +++ b/src/spikeinterface/widgets/unit_spatial.py @@ -1,14 +1,11 @@ -from typing import TYPE_CHECKING, Any, Literal +from typing import Any, Literal import numpy as np +from numpy.typing import ArrayLike from probeinterface import Probe +from spikeinterface.core import SortingAnalyzer from .base import BaseWidget, to_attr -if TYPE_CHECKING: - from matplotlib.colors import Colormap - from numpy.typing import ArrayLike - from spikeinterface.core import SortingAnalyzer - class UnitSpatialDistributionsWidget(BaseWidget): """ @@ -47,14 +44,14 @@ class UnitSpatialDistributionsWidget(BaseWidget): def __init__( self, - sorting_analyzer: "SortingAnalyzer", + sorting_analyzer: SortingAnalyzer, probe: Probe | None = None, depth_axis: Literal[0, 1] = 1, bins: int | tuple | None = None, - cmap: "str | Colormap" = "viridis", + cmap="viridis", kde: bool = False, depth_hist: bool = True, - groups: "ArrayLike | None" = None, + groups: ArrayLike | None = None, kde_kwargs: dict[str, Any] | None = None, backend: str | None = None, show_depth_hist_axis: bool = True,