Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
3dc5729
Test IBL extractors tests failing for PI update
alejoe91 Dec 29, 2025
d1a0532
Merge branch 'main' of github.com:SpikeInterface/spikeinterface
alejoe91 Jan 6, 2026
33c6769
Merge branch 'main' of github.com:SpikeInterface/spikeinterface
alejoe91 Jan 16, 2026
2c94bac
Merge branch 'main' of github.com:SpikeInterface/spikeinterface
alejoe91 Jan 20, 2026
a40d073
Merge branch 'main' of github.com:alejoe91/spikeinterface
alejoe91 Feb 24, 2026
ef40b73
Merge branch 'main' of github.com:SpikeInterface/spikeinterface
alejoe91 Mar 17, 2026
11c5812
Merge branch 'main' of github.com:SpikeInterface/spikeinterface
alejoe91 Mar 24, 2026
ada53f8
Merge branch 'main' of github.com:SpikeInterface/spikeinterface
alejoe91 Mar 24, 2026
22ff8fd
Merge branch 'main' of github.com:SpikeInterface/spikeinterface
alejoe91 Mar 25, 2026
cbc36de
Merge branch 'main' of github.com:SpikeInterface/spikeinterface
alejoe91 Mar 31, 2026
6b3e373
Merge branch 'main' of github.com:SpikeInterface/spikeinterface
alejoe91 Apr 9, 2026
7eb2251
Merge branch 'main' of github.com:SpikeInterface/spikeinterface
alejoe91 Apr 16, 2026
e1006bc
Add read_kilosort4_motion function
alejoe91 Apr 16, 2026
6d904db
Fix imports
alejoe91 Apr 16, 2026
bb0456a
fix imports 2
alejoe91 Apr 16, 2026
b26ab8f
fix logger closing
alejoe91 Apr 16, 2026
0ea163f
fix: read function displacement, add test for read_kilosort4_motion f…
alejoe91 Apr 17, 2026
6e6c21f
Merge branch 'main' into read_ks_motion
alejoe91 Apr 17, 2026
77019af
Merge branch 'main' into read_ks_motion
alejoe91 Jun 9, 2026
c1c902c
Merge branch 'main' into read_ks_motion
alejoe91 Jul 16, 2026
ba5f23a
Merge branch 'main' into read_ks_motion
alejoe91 Jul 16, 2026
4da9433
build: update minimal supported KS version to 4.1.0
alejoe91 Jul 16, 2026
e6ab01f
build: update minimal supported KS version to 4.1.1
alejoe91 Jul 16, 2026
0cfcf88
fix: read_kilosort_as_analyzer
alejoe91 Jul 16, 2026
41ea5f0
fix: extract main_channel_indices in read_kilosort_as_analyzer
alejoe91 Jul 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions .github/scripts/check_kilosort4_releases.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,8 @@ def get_pypi_versions(package_name):
response.raise_for_status()
data = response.json()
versions = list(sorted(data["releases"].keys()))
# Filter out versions that are less than 4.0.16 and different from 4.0.26 and 4.0.27
# (buggy - https://github.com/MouseLand/Kilosort/releases/tag/v4.0.26)
versions = [ver for ver in versions if parse(ver) >= parse("4.0.16") and
parse(ver) not in [parse("4.0.26"), parse("4.0.27")]]
# Filter out versions that are less than 4.1.1, since they don't support numpy 2.0
versions = [ver for ver in versions if parse(ver) >= parse("4.1.1")]
return versions


Expand Down
40 changes: 39 additions & 1 deletion .github/scripts/test_kilosort4_ci.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@

import spikeinterface.full as si
from spikeinterface.core.testing import check_sortings_equal
from spikeinterface.sorters.external.kilosort4 import Kilosort4Sorter
from spikeinterface.sorters.external.kilosort4 import Kilosort4Sorter, read_kilosort4_motion
from spikeinterface.core.motion import Motion
from probeinterface.io import write_prb
from spikeinterface.extractors import read_kilosort_as_analyzer

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

def test_read_kilosort4_motion(self, recording_and_paths, tmp_path):
"""
Test that read_kilosort4_motion returns a Motion object whose displacement
equals dshift (not dshift + yblk), and that temporal/spatial bins are correct.
"""
recording, _ = recording_and_paths
sorter_output_dir = tmp_path / "ks4_motion_output" / "sorter_output"

si.run_sorter(
"kilosort4",
recording,
folder=tmp_path / "ks4_motion_output",
remove_existing_folder=True,
)

ops = np.load(sorter_output_dir / "ops.npy", allow_pickle=True).item()
yblk = ops["yblk"]
dshift = ops["dshift"]

# without recording: temporal bins estimated from batch count
motion = read_kilosort4_motion(sorter_output_dir)
assert isinstance(motion, Motion)
assert motion.displacement[0].shape == dshift.shape
np.testing.assert_array_equal(motion.displacement[0], dshift)
np.testing.assert_array_equal(motion.spatial_bins_um, yblk)
assert motion.temporal_bins_s[0].shape[0] == dshift.shape[0]
# displacement must be relative (not offset by spatial bin position)
assert not np.allclose(motion.displacement[0], dshift + yblk)

# with recording: temporal bins bounded by recording times
motion_rec = read_kilosort4_motion(sorter_output_dir, recording=recording)
assert isinstance(motion_rec, Motion)
np.testing.assert_array_equal(motion_rec.displacement[0], dshift)
assert motion_rec.temporal_bins_s[0].shape[0] == dshift.shape[0]
assert motion_rec.temporal_bins_s[0][0] >= recording.get_start_time()
assert motion_rec.temporal_bins_s[0][-1] <= recording.get_end_time()

##### Helpers ######
def _get_kilosort_native_settings(self, recording, paths, param_key, param_value):
"""
Expand Down
3 changes: 1 addition & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@ classifiers = [


dependencies = [
"numpy>=1.20;python_version<'3.13'",
"numpy>=2.0.0;python_version>='3.13'",
"numpy>=2.0.0",
"threadpoolctl>=3.0.0",
"tqdm",
"zarr>=2.18,<3",
Expand Down
4 changes: 3 additions & 1 deletion src/spikeinterface/core/motion.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ class Motion:
Parameters
----------
displacement : numpy array 2d or list of
Motion estimate in um.
Motion estimate in um, relative to the spatial_bins_um.
The first dimension is temporal bins, the second dimension is spatial bins.
List is the number of segment.
For each semgent :

Expand Down Expand Up @@ -93,6 +94,7 @@ def get_displacement_at_time_and_depth(self, times_s, locations_um, segment_inde
Parameters
----------
times_s: np.array
Times at which to evaluate the motion, in seconds. This should be a one-dimensional array.
locations_um: np.array
Either this is a one-dimensional array (a vector of positions along self.dimension), or
else a 2d array with the 2 or 3 spatial dimensions indexed along axis=1.
Expand Down
18 changes: 16 additions & 2 deletions src/spikeinterface/extractors/phykilosortextractors.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,7 @@ def read_kilosort_as_analyzer(folder_path, unwhiten=True, gain_to_uV=None, offse

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

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

sparsity = _make_sparsity_from_templates(sorting, recording, phy_path)
main_channel_indices = _make_main_channel_indices_from_templates(sorting, recording, phy_path)

sorting_analyzer = create_sorting_analyzer(sorting, recording, sparse=True, sparsity=sparsity)
sorting_analyzer = create_sorting_analyzer(
sorting, recording, sparse=True, sparsity=sparsity, main_channel_indices=main_channel_indices
)

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


def _make_main_channel_indices_from_templates(sorting, recording, kilosort_output_path):
"""Constructs the `ChannelSparsity` of from kilosort output, by seeing if the
templates output is zero or not on all channels."""

templates = np.load(kilosort_output_path / "templates.npy")
# main channel indices are the argmax of the ptp of the templates, which is the channel with
# the largest peak-to-peak amplitude
main_channel_indices = np.argmax(np.ptp(templates, axis=1), axis=1)
return main_channel_indices


def _make_templates(
sorting_analyzer, kilosort_output_path, mask, sampling_frequency, gain_to_uV, offset_to_uV, unwhiten=True
):
Expand Down
69 changes: 59 additions & 10 deletions src/spikeinterface/sorters/external/kilosort4.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import warnings
from pathlib import Path
from packaging import version

from spikeinterface.core import write_binary_recording
import numpy as np

from spikeinterface.core import write_binary_recording, Motion, BaseRecording
from spikeinterface.sorters.basesorter import BaseSorter, get_job_kwargs
from .kilosortbase import KilosortBase
from spikeinterface.sorters.basesorter import get_job_kwargs
Expand Down Expand Up @@ -77,7 +80,7 @@ def is_installed(cls):

@classmethod
def get_sorter_version(cls):
"""kilosort.__version__ <4.0.10 is always '4'"""
"""kilosort.__version__ < 4.0.10 is always '4'"""
return importlib_version("kilosort")

@classmethod
Expand Down Expand Up @@ -122,12 +125,11 @@ def initialize_folder(cls, recording, output_folder, verbose, remove_existing_fo
@classmethod
def check_sorter_version(cls):
kilosort_version = version.parse(cls.get_sorter_version())
if kilosort_version < version.parse("4.0.16"):
raise Exception(
f"""SpikeInterface only supports kilosort versions 4.0.16 and above. You are running version {kilosort_version}. To install the latest version, run:
>>> pip install kilosort --upgrade
"""
)
if kilosort_version < version.parse("4.1.1"):
raise Exception(f"""SpikeInterface only supports kilosort versions 4.1.1 and above (which support numpy>=2).
You are running version {kilosort_version}. To install the latest version, run:
>>> pip install kilosort --upgrade
""")

@classmethod
def _setup_recording(cls, recording, sorter_output_folder, params, verbose):
Expand Down Expand Up @@ -177,7 +179,6 @@ def _run_from_folder(cls, sorter_output_folder, params, verbose):

import time
import torch
import numpy as np
import logging

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

# close logger
for handler in logger.handlers.copy():
logger.removeHandler(handler)
handler.close()

@classmethod
def _get_result_from_folder(cls, sorter_output_folder):
return KilosortBase._get_result_from_folder(sorter_output_folder)
Expand All @@ -469,7 +475,6 @@ def _get_result_from_folder(cls, sorter_output_folder):
def _setup_json_probe_map(cls, recording, sorter_output_folder):
"""Create a JSON probe map file for Kilosort4."""
from kilosort.io import save_probe
import numpy as np

groups = recording.get_channel_groups()
positions = np.array(recording.get_channel_locations())
Expand All @@ -492,3 +497,47 @@ def _setup_json_probe_map(cls, recording, sorter_output_folder):
"n_chan": n_chan,
}
save_probe(probe, str(sorter_output_folder / "chanMap.json"))


def read_kilosort4_motion(sorter_output_folder: str | Path, recording: BaseRecording | None = None) -> Motion:
"""Reads the motion information from a Kilosort4 output folder and returns a Motion object.

Parameters
----------
sorter_output_folder: str or Path
The path to the Kilosort4 output folder.
recording: BaseRecording, optional
The recording object. If provided, the temporal bins will be estimated based on the recording's
start and end times. If not provided, the temporal bins will be estimated based on the number
of batches in the ops file.

Returns
-------
Motion
A Motion object containing the displacement, temporal bins, and spatial bins.

"""
sorter_output_folder = Path(sorter_output_folder)
ops_file = sorter_output_folder / "ops.npy"
if not ops_file.is_file():
raise FileNotFoundError("'ops.npy' file not found!")
ops = np.load(ops_file, allow_pickle=True).item()
yblk = ops.get("yblk")
dshift = ops.get("dshift")
if yblk is None or dshift is None:
raise Exception("'yblk' and 'dshift' fields not found in ops file!")
displacement = dshift
spatial_bins_um = yblk
# estimate temporal bins
batch_size = ops["batch_size"]
fs = ops["fs"]
t_bin = batch_size / fs
if recording is not None:
t_start = recording.get_start_time()
t_end = recording.get_end_time()
temporal_bins_s = np.linspace(t_start + t_bin / 2, t_end - t_bin / 2, displacement.shape[0])
else:
temporal_bins_s = np.arange(displacement.shape[0]) * t_bin + t_bin / 2

motion = Motion(displacement=displacement, temporal_bins_s=temporal_bins_s, spatial_bins_um=spatial_bins_um)
return motion
2 changes: 1 addition & 1 deletion src/spikeinterface/sorters/sorterlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from .external.kilosort2 import Kilosort2Sorter
from .external.kilosort2_5 import Kilosort2_5Sorter
from .external.kilosort3 import Kilosort3Sorter
from .external.kilosort4 import Kilosort4Sorter
from .external.kilosort4 import Kilosort4Sorter, read_kilosort4_motion
from .external.pykilosort import PyKilosortSorter
from .external.klusta import KlustaSorter
from .external.mountainsort4 import Mountainsort4Sorter
Expand Down
Loading