From 917ab88e6f9e6414fafa7a92e7f1ce691c8448d8 Mon Sep 17 00:00:00 2001 From: chrishalcrow Date: Fri, 17 Apr 2026 09:36:45 +0100 Subject: [PATCH 1/4] allow user to pass recording --- .../extractors/phykilosortextractors.py | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/spikeinterface/extractors/phykilosortextractors.py b/src/spikeinterface/extractors/phykilosortextractors.py index 0e5dd2694d..33eaaac3e5 100644 --- a/src/spikeinterface/extractors/phykilosortextractors.py +++ b/src/spikeinterface/extractors/phykilosortextractors.py @@ -314,7 +314,9 @@ def __init__(self, folder_path: Path | str, keep_good_only: bool = False, remove read_kilosort = define_function_from_class(source_class=KiloSortSortingExtractor, name="read_kilosort") -def read_kilosort_as_analyzer(folder_path, unwhiten=True, gain_to_uV=None, offset_to_uV=None) -> SortingAnalyzer: +def read_kilosort_as_analyzer( + folder_path, recording=None, unwhiten=True, gain_to_uV=None, offset_to_uV=None +) -> SortingAnalyzer: """ Load Kilosort output into a SortingAnalyzer. Output from Kilosort version 4.1 and above are supported. The function may work on older versions of Kilosort output, @@ -324,6 +326,8 @@ def read_kilosort_as_analyzer(folder_path, unwhiten=True, gain_to_uV=None, offse ---------- folder_path : str or Path Path to the output Phy folder (containing the params.py). + recording : BaseRecording + A spikeinterface Recording object which will be attached to the analyzer unwhiten : bool, default: True Unwhiten the templates computed by kilosort. gain_to_uV : float | None, default: None @@ -370,14 +374,15 @@ def read_kilosort_as_analyzer(folder_path, unwhiten=True, gain_to_uV=None, offse else: AssertionError(f"Cannot read probe layout from folder {phy_path}.") - # to make the initial analyzer, we'll use a fake recording and set it to None later - recording, _ = generate_ground_truth_recording( - probe=probe, - sampling_frequency=sampling_frequency, - durations=[duration], - num_units=1, - seed=1205, - ) + if recording is None: + # to make the initial analyzer, we'll use a fake recording and set it to None later + recording, _ = generate_ground_truth_recording( + probe=probe, + sampling_frequency=sampling_frequency, + durations=[duration], + num_units=1, + seed=1205, + ) sparsity = _make_sparsity_from_templates(sorting, recording, phy_path) From 6bae4d334e9f3ff59b082b24406e3436affcf475 Mon Sep 17 00:00:00 2001 From: chrishalcrow Date: Fri, 17 Apr 2026 10:14:26 +0100 Subject: [PATCH 2/4] add channel map and checks --- .../extractors/phykilosortextractors.py | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/spikeinterface/extractors/phykilosortextractors.py b/src/spikeinterface/extractors/phykilosortextractors.py index 33eaaac3e5..e6c64dccb3 100644 --- a/src/spikeinterface/extractors/phykilosortextractors.py +++ b/src/spikeinterface/extractors/phykilosortextractors.py @@ -370,10 +370,23 @@ def read_kilosort_as_analyzer( probe = Probe(si_units="um") channel_positions = np.load(phy_path / "channel_positions.npy") probe.set_contacts(channel_positions) - probe.set_device_channel_indices(range(probe.get_contact_count())) + channel_map = np.load(phy_path / "channel_map.npy") + probe.set_device_channel_indices(channel_map) else: AssertionError(f"Cannot read probe layout from folder {phy_path}.") + # Check that user-defined recording probe geometry is consistent with phy output + if recording is not None: + for recording_channel_location, probe_contact_position in zip( + recording.get_channel_locations(), probe.contact_positions + ): + if not np.all(recording_channel_location == probe_contact_position): + raise ValueError( + "Recording channel locations from `recording` do not match probe channel locations from `folder_path/probe.prb`." + "Hence there is an inconsistency between probe layout or wiring between the recording and sorting output." + "Please resolve this inconsistency." + ) + if recording is None: # to make the initial analyzer, we'll use a fake recording and set it to None later recording, _ = generate_ground_truth_recording( @@ -402,7 +415,9 @@ def read_kilosort_as_analyzer( ) _make_locations(sorting_analyzer, phy_path) - sorting_analyzer._recording = None + if recording is None: + sorting_analyzer._recording = None + return sorting_analyzer @@ -418,14 +433,9 @@ def _make_locations(sorting_analyzer, kilosort_output_path): else: return - # Check that the spike locations vector is the same size as the spike vector + # When recording is given, need to trim spike locations to match spikes in sorting num_spikes = len(sorting_analyzer.sorting.to_spike_vector()) - num_spike_locs = len(locs_np) - if num_spikes != num_spike_locs: - warnings.warn( - "The number of spikes does not match the number of spike locations in `spike_positions.npy`. Skipping spike locations." - ) - return + locs_np = locs_np[:num_spikes] num_dims = len(locs_np[0]) column_names = ["x", "y", "z"][:num_dims] From 8f3257421ee5ec14e8ee639ee3a4abdc2083f32a Mon Sep 17 00:00:00 2001 From: chrishalcrow Date: Mon, 20 Apr 2026 16:03:48 +0100 Subject: [PATCH 3/4] allow for probegroups --- .../extractors/phykilosortextractors.py | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/src/spikeinterface/extractors/phykilosortextractors.py b/src/spikeinterface/extractors/phykilosortextractors.py index e6c64dccb3..db5ebf6f15 100644 --- a/src/spikeinterface/extractors/phykilosortextractors.py +++ b/src/spikeinterface/extractors/phykilosortextractors.py @@ -12,11 +12,12 @@ ComputeTemplates, create_sorting_analyzer, SortingAnalyzer, + aggregate_channels, ) from spikeinterface.core.core_tools import define_function_from_class from spikeinterface.postprocessing import ComputeSpikeAmplitudes, ComputeSpikeLocations -from probeinterface import read_prb, Probe +from probeinterface import read_prb, Probe, ProbeGroup class BasePhyKilosortSortingExtractor(BaseSorting): @@ -363,22 +364,20 @@ def read_kilosort_as_analyzer( if (phy_path / "probe.prb").is_file(): probegroup = read_prb(phy_path / "probe.prb") - if len(probegroup.probes) > 0: - warnings.warn("Found more than one probe. Selecting the first probe in ProbeGroup.") - probe = probegroup.probes[0] elif (phy_path / "channel_positions.npy").is_file(): probe = Probe(si_units="um") channel_positions = np.load(phy_path / "channel_positions.npy") probe.set_contacts(channel_positions) channel_map = np.load(phy_path / "channel_map.npy") probe.set_device_channel_indices(channel_map) + probegroup = ProbeGroup().add_probe(probe) else: AssertionError(f"Cannot read probe layout from folder {phy_path}.") # Check that user-defined recording probe geometry is consistent with phy output if recording is not None: for recording_channel_location, probe_contact_position in zip( - recording.get_channel_locations(), probe.contact_positions + recording.get_channel_locations(), probe.get_global_contact_positions() ): if not np.all(recording_channel_location == probe_contact_position): raise ValueError( @@ -389,13 +388,17 @@ def read_kilosort_as_analyzer( if recording is None: # to make the initial analyzer, we'll use a fake recording and set it to None later - recording, _ = generate_ground_truth_recording( - probe=probe, - sampling_frequency=sampling_frequency, - durations=[duration], - num_units=1, - seed=1205, - ) + recordings = [] + for probe in probegroup.probes: + one_recording, _ = generate_ground_truth_recording( + probe=probe, + sampling_frequency=sampling_frequency, + durations=[duration], + num_units=1, + seed=1205, + ) + recordings.append(one_recording) + recording = aggregate_channels(recordings) sparsity = _make_sparsity_from_templates(sorting, recording, phy_path) From 485142a90a7f4146b04b922c9674daa14eb2ba3e Mon Sep 17 00:00:00 2001 From: chrishalcrow Date: Mon, 20 Apr 2026 16:19:58 +0100 Subject: [PATCH 4/4] fix bugs --- .../extractors/phykilosortextractors.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/spikeinterface/extractors/phykilosortextractors.py b/src/spikeinterface/extractors/phykilosortextractors.py index db5ebf6f15..5b53360ca5 100644 --- a/src/spikeinterface/extractors/phykilosortextractors.py +++ b/src/spikeinterface/extractors/phykilosortextractors.py @@ -370,14 +370,18 @@ def read_kilosort_as_analyzer( probe.set_contacts(channel_positions) channel_map = np.load(phy_path / "channel_map.npy") probe.set_device_channel_indices(channel_map) - probegroup = ProbeGroup().add_probe(probe) + + probegroup = ProbeGroup() + probegroup.add_probe(probe) else: AssertionError(f"Cannot read probe layout from folder {phy_path}.") # Check that user-defined recording probe geometry is consistent with phy output if recording is not None: + user_gave_recording = True + all_contact_positions = np.vstack([probe.contact_positions for probe in probegroup.probes]) for recording_channel_location, probe_contact_position in zip( - recording.get_channel_locations(), probe.get_global_contact_positions() + recording.get_channel_locations(), all_contact_positions ): if not np.all(recording_channel_location == probe_contact_position): raise ValueError( @@ -385,8 +389,8 @@ def read_kilosort_as_analyzer( "Hence there is an inconsistency between probe layout or wiring between the recording and sorting output." "Please resolve this inconsistency." ) - - if recording is None: + else: + user_gave_recording = False # to make the initial analyzer, we'll use a fake recording and set it to None later recordings = [] for probe in probegroup.probes: @@ -418,7 +422,7 @@ def read_kilosort_as_analyzer( ) _make_locations(sorting_analyzer, phy_path) - if recording is None: + if not user_gave_recording: sorting_analyzer._recording = None return sorting_analyzer