Skip to content

Commit ecf5c6d

Browse files
Add read_kilosort4_motion function (#4518)
Co-authored-by: Chris Halcrow <57948917+chrishalcrow@users.noreply.github.com>
1 parent a76c75b commit ecf5c6d

7 files changed

Lines changed: 121 additions & 21 deletions

File tree

.github/scripts/check_kilosort4_releases.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,8 @@ def get_pypi_versions(package_name):
1515
response.raise_for_status()
1616
data = response.json()
1717
versions = list(sorted(data["releases"].keys()))
18-
# Filter out versions that are less than 4.0.16 and different from 4.0.26 and 4.0.27
19-
# (buggy - https://github.com/MouseLand/Kilosort/releases/tag/v4.0.26)
20-
versions = [ver for ver in versions if parse(ver) >= parse("4.0.16") and
21-
parse(ver) not in [parse("4.0.26"), parse("4.0.27")]]
18+
# Filter out versions that are less than 4.1.1, since they don't support numpy 2.0
19+
versions = [ver for ver in versions if parse(ver) >= parse("4.1.1")]
2220
return versions
2321

2422

.github/scripts/test_kilosort4_ci.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@
2828

2929
import spikeinterface.full as si
3030
from spikeinterface.core.testing import check_sortings_equal
31-
from spikeinterface.sorters.external.kilosort4 import Kilosort4Sorter
31+
from spikeinterface.sorters.external.kilosort4 import Kilosort4Sorter, read_kilosort4_motion
32+
from spikeinterface.core.motion import Motion
3233
from probeinterface.io import write_prb
3334
from spikeinterface.extractors import read_kilosort_as_analyzer
3435

@@ -669,6 +670,43 @@ def monkeypatch_filter_function(self, X, ops=None, ibatch=None):
669670
assert np.allclose(results["ks"]["st"], results["si"]["st"], rtol=0, atol=1)
670671
assert np.array_equal(results["ks"]["clus"], results["si"]["clus"])
671672

673+
def test_read_kilosort4_motion(self, recording_and_paths, tmp_path):
674+
"""
675+
Test that read_kilosort4_motion returns a Motion object whose displacement
676+
equals dshift (not dshift + yblk), and that temporal/spatial bins are correct.
677+
"""
678+
recording, _ = recording_and_paths
679+
sorter_output_dir = tmp_path / "ks4_motion_output" / "sorter_output"
680+
681+
si.run_sorter(
682+
"kilosort4",
683+
recording,
684+
folder=tmp_path / "ks4_motion_output",
685+
remove_existing_folder=True,
686+
)
687+
688+
ops = np.load(sorter_output_dir / "ops.npy", allow_pickle=True).item()
689+
yblk = ops["yblk"]
690+
dshift = ops["dshift"]
691+
692+
# without recording: temporal bins estimated from batch count
693+
motion = read_kilosort4_motion(sorter_output_dir)
694+
assert isinstance(motion, Motion)
695+
assert motion.displacement[0].shape == dshift.shape
696+
np.testing.assert_array_equal(motion.displacement[0], dshift)
697+
np.testing.assert_array_equal(motion.spatial_bins_um, yblk)
698+
assert motion.temporal_bins_s[0].shape[0] == dshift.shape[0]
699+
# displacement must be relative (not offset by spatial bin position)
700+
assert not np.allclose(motion.displacement[0], dshift + yblk)
701+
702+
# with recording: temporal bins bounded by recording times
703+
motion_rec = read_kilosort4_motion(sorter_output_dir, recording=recording)
704+
assert isinstance(motion_rec, Motion)
705+
np.testing.assert_array_equal(motion_rec.displacement[0], dshift)
706+
assert motion_rec.temporal_bins_s[0].shape[0] == dshift.shape[0]
707+
assert motion_rec.temporal_bins_s[0][0] >= recording.get_start_time()
708+
assert motion_rec.temporal_bins_s[0][-1] <= recording.get_end_time()
709+
672710
##### Helpers ######
673711
def _get_kilosort_native_settings(self, recording, paths, param_key, param_value):
674712
"""

pyproject.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,7 @@ classifiers = [
2020

2121

2222
dependencies = [
23-
"numpy>=1.20;python_version<'3.13'",
24-
"numpy>=2.0.0;python_version>='3.13'",
23+
"numpy>=2.0.0",
2524
"threadpoolctl>=3.0.0",
2625
"tqdm",
2726
"zarr>=2.18,<3",

src/spikeinterface/core/motion.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ class Motion:
1414
Parameters
1515
----------
1616
displacement : numpy array 2d or list of
17-
Motion estimate in um.
17+
Motion estimate in um, relative to the spatial_bins_um.
18+
The first dimension is temporal bins, the second dimension is spatial bins.
1819
List is the number of segment.
1920
For each semgent :
2021
@@ -93,6 +94,7 @@ def get_displacement_at_time_and_depth(self, times_s, locations_um, segment_inde
9394
Parameters
9495
----------
9596
times_s: np.array
97+
Times at which to evaluate the motion, in seconds. This should be a one-dimensional array.
9698
locations_um: np.array
9799
Either this is a one-dimensional array (a vector of positions along self.dimension), or
98100
else a 2d array with the 2 or 3 spatial dimensions indexed along axis=1.

src/spikeinterface/extractors/phykilosortextractors.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -390,7 +390,7 @@ def read_kilosort_as_analyzer(folder_path, unwhiten=True, gain_to_uV=None, offse
390390

391391
# kilosort occasionally contains a few spikes just beyond the recording end point, which can lead
392392
# to errors later. To avoid this, we pad the recording with an extra second of blank time.
393-
duration = sorting.segments[0]._all_spikes[-1] / sampling_frequency + 1
393+
duration = sorting.segments[0]._all_spike_times[-1] / sampling_frequency + 1
394394

395395
if (phy_path / "probe.prb").is_file():
396396
probegroup = read_prb(phy_path / "probe.prb")
@@ -415,8 +415,11 @@ def read_kilosort_as_analyzer(folder_path, unwhiten=True, gain_to_uV=None, offse
415415
)
416416

417417
sparsity = _make_sparsity_from_templates(sorting, recording, phy_path)
418+
main_channel_indices = _make_main_channel_indices_from_templates(sorting, recording, phy_path)
418419

419-
sorting_analyzer = create_sorting_analyzer(sorting, recording, sparse=True, sparsity=sparsity)
420+
sorting_analyzer = create_sorting_analyzer(
421+
sorting, recording, sparse=True, sparsity=sparsity, main_channel_indices=main_channel_indices
422+
)
420423

421424
# first compute random spikes. These do nothing, but are needed for si-gui to run
422425
sorting_analyzer.compute("random_spikes")
@@ -487,6 +490,17 @@ def _make_sparsity_from_templates(sorting, recording, kilosort_output_path):
487490
return ChannelSparsity(mask, unit_ids=unit_ids, channel_ids=channel_ids)
488491

489492

493+
def _make_main_channel_indices_from_templates(sorting, recording, kilosort_output_path):
494+
"""Constructs the `main_channel_indices` from kilosort output, by finding the
495+
channel containing the largest peak-to-peak value."""
496+
497+
templates = np.load(kilosort_output_path / "templates.npy")
498+
# main channel indices are the argmax of the ptp of the templates, which is the channel with
499+
# the largest peak-to-peak amplitude
500+
main_channel_indices = np.argmax(np.ptp(templates, axis=1), axis=1)
501+
return main_channel_indices
502+
503+
490504
def _make_templates(
491505
sorting_analyzer, kilosort_output_path, mask, sampling_frequency, gain_to_uV, offset_to_uV, unwhiten=True
492506
):

src/spikeinterface/sorters/external/kilosort4.py

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import warnings
2+
from pathlib import Path
23
from packaging import version
34

4-
from spikeinterface.core import write_binary_recording
5+
import numpy as np
6+
7+
from spikeinterface.core import write_binary_recording, Motion, BaseRecording
58
from spikeinterface.sorters.basesorter import BaseSorter, get_job_kwargs
69
from .kilosortbase import KilosortBase
710
from spikeinterface.sorters.basesorter import get_job_kwargs
@@ -77,7 +80,7 @@ def is_installed(cls):
7780

7881
@classmethod
7982
def get_sorter_version(cls):
80-
"""kilosort.__version__ <4.0.10 is always '4'"""
83+
"""kilosort.__version__ < 4.0.10 is always '4'"""
8184
return importlib_version("kilosort")
8285

8386
@classmethod
@@ -122,12 +125,11 @@ def initialize_folder(cls, recording, output_folder, verbose, remove_existing_fo
122125
@classmethod
123126
def check_sorter_version(cls):
124127
kilosort_version = version.parse(cls.get_sorter_version())
125-
if kilosort_version < version.parse("4.0.16"):
126-
raise Exception(
127-
f"""SpikeInterface only supports kilosort versions 4.0.16 and above. You are running version {kilosort_version}. To install the latest version, run:
128-
>>> pip install kilosort --upgrade
129-
"""
130-
)
128+
if kilosort_version < version.parse("4.1.1"):
129+
raise Exception(f"""SpikeInterface only supports kilosort versions 4.1.1 and above (which support numpy>=2).
130+
You are running version {kilosort_version}. To install the latest version, run:
131+
>>> pip install kilosort --upgrade
132+
""")
131133

132134
@classmethod
133135
def _setup_recording(cls, recording, sorter_output_folder, params, verbose):
@@ -177,7 +179,6 @@ def _run_from_folder(cls, sorter_output_folder, params, verbose):
177179

178180
import time
179181
import torch
180-
import numpy as np
181182
import logging
182183

183184
if version.parse(cls.get_sorter_version()) < version.parse("4.0.16"):
@@ -461,6 +462,11 @@ def _run_from_folder(cls, sorter_output_folder, params, verbose):
461462
if (sorter_output_folder / "recording.dat").is_file():
462463
(sorter_output_folder / "recording.dat").unlink()
463464

465+
# close logger
466+
for handler in logger.handlers.copy():
467+
logger.removeHandler(handler)
468+
handler.close()
469+
464470
@classmethod
465471
def _get_result_from_folder(cls, sorter_output_folder):
466472
return KilosortBase._get_result_from_folder(sorter_output_folder)
@@ -469,7 +475,6 @@ def _get_result_from_folder(cls, sorter_output_folder):
469475
def _setup_json_probe_map(cls, recording, sorter_output_folder):
470476
"""Create a JSON probe map file for Kilosort4."""
471477
from kilosort.io import save_probe
472-
import numpy as np
473478

474479
groups = recording.get_channel_groups()
475480
positions = np.array(recording.get_channel_locations())
@@ -492,3 +497,47 @@ def _setup_json_probe_map(cls, recording, sorter_output_folder):
492497
"n_chan": n_chan,
493498
}
494499
save_probe(probe, str(sorter_output_folder / "chanMap.json"))
500+
501+
502+
def read_kilosort4_motion(sorter_output_folder: str | Path, recording: BaseRecording | None = None) -> Motion:
503+
"""Reads the motion information from a Kilosort4 output folder and returns a Motion object.
504+
505+
Parameters
506+
----------
507+
sorter_output_folder: str or Path
508+
The path to the Kilosort4 output folder.
509+
recording: BaseRecording, optional
510+
The recording object. If provided, the temporal bins will be estimated based on the recording's
511+
start and end times. If not provided, the temporal bins will be estimated based on the number
512+
of batches in the ops file.
513+
514+
Returns
515+
-------
516+
Motion
517+
A Motion object containing the displacement, temporal bins, and spatial bins.
518+
519+
"""
520+
sorter_output_folder = Path(sorter_output_folder)
521+
ops_file = sorter_output_folder / "ops.npy"
522+
if not ops_file.is_file():
523+
raise FileNotFoundError("'ops.npy' file not found!")
524+
ops = np.load(ops_file, allow_pickle=True).item()
525+
yblk = ops.get("yblk")
526+
dshift = ops.get("dshift")
527+
if yblk is None or dshift is None:
528+
raise Exception("'yblk' and 'dshift' fields not found in ops file!")
529+
displacement = dshift
530+
spatial_bins_um = yblk
531+
# estimate temporal bins
532+
batch_size = ops["batch_size"]
533+
fs = ops["fs"]
534+
t_bin = batch_size / fs
535+
if recording is not None:
536+
t_start = recording.get_start_time()
537+
t_end = recording.get_end_time()
538+
temporal_bins_s = np.linspace(t_start + t_bin / 2, t_end - t_bin / 2, displacement.shape[0])
539+
else:
540+
temporal_bins_s = np.arange(displacement.shape[0]) * t_bin + t_bin / 2
541+
542+
motion = Motion(displacement=displacement, temporal_bins_s=temporal_bins_s, spatial_bins_um=spatial_bins_um)
543+
return motion

src/spikeinterface/sorters/sorterlist.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from .external.kilosort2 import Kilosort2Sorter
77
from .external.kilosort2_5 import Kilosort2_5Sorter
88
from .external.kilosort3 import Kilosort3Sorter
9-
from .external.kilosort4 import Kilosort4Sorter
9+
from .external.kilosort4 import Kilosort4Sorter, read_kilosort4_motion
1010
from .external.pykilosort import PyKilosortSorter
1111
from .external.klusta import KlustaSorter
1212
from .external.mountainsort4 import Mountainsort4Sorter

0 commit comments

Comments
 (0)