unitrefine 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- unitrefine/__init__.py +75 -0
- unitrefine/apply_models.py +111 -0
- unitrefine/cli.py +251 -0
- unitrefine/evaluation.py +457 -0
- unitrefine/figures.py +1458 -0
- unitrefine/import_labels.py +176 -0
- unitrefine/launch_sigui.py +138 -0
- unitrefine/launch_sigui_validate.py +243 -0
- unitrefine/launcher.py +239 -0
- unitrefine/main.py +2395 -0
- unitrefine/manage_models.py +193 -0
- unitrefine/metrics.py +146 -0
- unitrefine/model_selection.py +159 -0
- unitrefine/models_hub.py +118 -0
- unitrefine/phy_curation.py +306 -0
- unitrefine/phy_import.py +428 -0
- unitrefine/predictions.py +507 -0
- unitrefine/resources/curation_gui.jpg +0 -0
- unitrefine/resources/fonts/Inter-Bold.ttf +0 -0
- unitrefine/resources/fonts/Inter-LICENSE.txt +92 -0
- unitrefine/resources/fonts/Inter-Regular.ttf +0 -0
- unitrefine/resources/logo.png +0 -0
- unitrefine/resources/model_training_example.jpg +0 -0
- unitrefine/resources/predict-gui.JPG +0 -0
- unitrefine/resources/re-label.JPG +0 -0
- unitrefine/resources/si-gui.JPG +0 -0
- unitrefine/resources/train_and_load_gui.JPG +0 -0
- unitrefine/resources/unit_types_example.jpg +0 -0
- unitrefine/resources/unitrefine_gui.JPG +0 -0
- unitrefine/retraining_history.py +220 -0
- unitrefine/sigui_progress.py +157 -0
- unitrefine/theme.py +254 -0
- unitrefine/train.py +622 -0
- unitrefine/widgets.py +961 -0
- unitrefine-0.2.0.dist-info/METADATA +153 -0
- unitrefine-0.2.0.dist-info/RECORD +40 -0
- unitrefine-0.2.0.dist-info/WHEEL +5 -0
- unitrefine-0.2.0.dist-info/entry_points.txt +4 -0
- unitrefine-0.2.0.dist-info/licenses/LICENSE +21 -0
- unitrefine-0.2.0.dist-info/top_level.txt +1 -0
unitrefine/phy_import.py
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Using a Phy folder in UnitRefine: makes a SortingAnalyzer from it, and saves Phy's labels. There are two kinds:
|
|
3
|
+
|
|
4
|
+
- **Kilosort output**, curated in Phy. There is no analyzer yet, so one is made from Phy's clusters and the
|
|
5
|
+
recording Kilosort sorted (found from params.py), with all the metrics the models use.
|
|
6
|
+
- **A Phy folder exported from an analyzer** with SpikeInterface's export_to_phy (it has cluster_si_unit_ids.tsv).
|
|
7
|
+
Phy's merges and splits are applied to that analyzer (see phy_curation.py). If Phy only changed labels, the
|
|
8
|
+
analyzer is used as it is.
|
|
9
|
+
|
|
10
|
+
Either way, Phy's labels (good, mua, noise; unsorted is skipped) are saved as a csv with unit_id and quality
|
|
11
|
+
columns, which UnitRefine imports like any other labels.
|
|
12
|
+
|
|
13
|
+
Runs without the GUI too, e.g. on a computing cluster:
|
|
14
|
+
|
|
15
|
+
python -m unitrefine.phy_import <phy folder> [--analyzer <analyzer exported to Phy>] [--output <folder>]
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import ast
|
|
20
|
+
import json
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
import traceback
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
import pandas as pd
|
|
29
|
+
|
|
30
|
+
# lines printed for the GUI, which runs this module in its own process
|
|
31
|
+
PHY_STEP_MARKER = "UNITREFINE_PHY_STEP:"
|
|
32
|
+
PHY_RESULT_MARKER = "UNITREFINE_PHY_RESULT:"
|
|
33
|
+
|
|
34
|
+
# written next to the analyzer, so it is known where it came from
|
|
35
|
+
MADE_FROM_PHY_FILE = "made_from_phy.json"
|
|
36
|
+
|
|
37
|
+
KILOSORT_STEPS = [
|
|
38
|
+
("start", "Starting Python and SpikeInterface"),
|
|
39
|
+
("reading", "Reading the Phy folder and the recording"),
|
|
40
|
+
("waveforms", "Extracting waveforms (the longest step)"),
|
|
41
|
+
("extensions", "Computing templates, amplitudes, locations and correlograms"),
|
|
42
|
+
("pca", "Computing principal components"),
|
|
43
|
+
("metrics", "Computing quality and template metrics"),
|
|
44
|
+
("labels", "Saving Phy's labels"),
|
|
45
|
+
]
|
|
46
|
+
EXPORTED_STEPS = [
|
|
47
|
+
("start", "Starting Python and SpikeInterface"),
|
|
48
|
+
("reading", "Reading the Phy folder and the analyzer"),
|
|
49
|
+
("curation", "Applying Phy's merges and splits"),
|
|
50
|
+
("saving", "Saving the curated analyzer"),
|
|
51
|
+
("labels", "Saving Phy's labels"),
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
# the extensions computed for an analyzer made from Kilosort output: what the models, the figures and the
|
|
55
|
+
# SpikeInterface-GUI use, as for an analyzer made in SpikeInterface
|
|
56
|
+
EXTENSION_STEPS = [
|
|
57
|
+
("waveforms", ["random_spikes", "waveforms"]),
|
|
58
|
+
("extensions", ["templates", "noise_levels", "spike_amplitudes", "spike_locations", "unit_locations", "correlograms",
|
|
59
|
+
"template_similarity"]),
|
|
60
|
+
("pca", ["principal_components"]),
|
|
61
|
+
("metrics", ["quality_metrics", "template_metrics"]),
|
|
62
|
+
]
|
|
63
|
+
# the models on the Hugging Face Hub also use the multi-channel template metrics (e.g. spread) and the drift metrics
|
|
64
|
+
# (from spike_locations)
|
|
65
|
+
EXTENSION_PARAMS = {"template_metrics": dict(include_multi_channel_metrics=True)}
|
|
66
|
+
|
|
67
|
+
# from the SpikeGLX documentation: µV per bit of the AP band, for the usual probes
|
|
68
|
+
GAIN_HINT = "e.g. 2.34 for Neuropixels 1.0 (gain 500), 0.763 for Neuropixels 2.0"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class NeedsGain(ValueError):
|
|
72
|
+
"""The recording is a plain binary file, so its gain (µV per bit) must be given."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class RecordingNotFound(FileNotFoundError):
|
|
76
|
+
"""The recording Kilosort sorted is not where params.py says."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def report_step(step):
|
|
80
|
+
print(f"{PHY_STEP_MARKER} {step}", flush=True)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# --------------------------------------------------------------------------------------------------
|
|
84
|
+
# what is in a Phy folder
|
|
85
|
+
# --------------------------------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def read_params(phy_folder):
|
|
88
|
+
"""The settings in a Phy folder's params.py, as a dict (read without running the file)."""
|
|
89
|
+
params = {}
|
|
90
|
+
tree = ast.parse((Path(phy_folder) / "params.py").read_text())
|
|
91
|
+
for statement in tree.body:
|
|
92
|
+
if isinstance(statement, ast.Assign) and len(statement.targets) == 1 and isinstance(statement.targets[0], ast.Name):
|
|
93
|
+
try:
|
|
94
|
+
params[statement.targets[0].id] = ast.literal_eval(statement.value)
|
|
95
|
+
except ValueError:
|
|
96
|
+
pass
|
|
97
|
+
return params
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def phy_folder_kind(phy_folder):
|
|
101
|
+
"""
|
|
102
|
+
"exported" for a Phy folder made with SpikeInterface's export_to_phy, "kilosort" for Kilosort output.
|
|
103
|
+
Raises ValueError if the folder is not a Phy folder.
|
|
104
|
+
"""
|
|
105
|
+
phy_folder = Path(phy_folder)
|
|
106
|
+
missing = [name for name in ["params.py", "spike_times.npy", "spike_templates.npy"] if not (phy_folder / name).is_file()]
|
|
107
|
+
if len(missing) > 0:
|
|
108
|
+
raise ValueError(f"{phy_folder} is not a Phy folder: it has no {', '.join(missing)}. Choose the folder with "
|
|
109
|
+
"params.py, which Kilosort writes and Phy opens.")
|
|
110
|
+
return "exported" if (phy_folder / "cluster_si_unit_ids.tsv").is_file() else "kilosort"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def default_output_folder(phy_folder):
|
|
114
|
+
"""Where the analyzer made from a Phy folder is saved: next to it."""
|
|
115
|
+
phy_folder = Path(phy_folder)
|
|
116
|
+
return phy_folder.parent / f"{phy_folder.name}_analyzer"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def made_from_phy(analyzer_folder):
|
|
120
|
+
"""What an analyzer was made from (the contents of made_from_phy.json), or None."""
|
|
121
|
+
path = Path(analyzer_folder) / MADE_FROM_PHY_FILE
|
|
122
|
+
if not path.is_file():
|
|
123
|
+
return None
|
|
124
|
+
with open(path) as f:
|
|
125
|
+
return json.load(f)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def recording_file(phy_folder, params=None):
|
|
129
|
+
"""The recording file Kilosort sorted, from params.py, or None if it is not found."""
|
|
130
|
+
phy_folder = Path(phy_folder)
|
|
131
|
+
params = read_params(phy_folder) if params is None else params
|
|
132
|
+
dat_paths = params.get("dat_path")
|
|
133
|
+
if dat_paths is None:
|
|
134
|
+
return None
|
|
135
|
+
if isinstance(dat_paths, (list, tuple)):
|
|
136
|
+
if len(dat_paths) != 1:
|
|
137
|
+
return None
|
|
138
|
+
dat_paths = dat_paths[0]
|
|
139
|
+
path = Path(dat_paths)
|
|
140
|
+
# relative paths are relative to the Phy folder; the folder may also have been moved with its recording
|
|
141
|
+
for candidate in [path if path.is_absolute() else phy_folder / path, phy_folder / path.name, phy_folder.parent / path.name]:
|
|
142
|
+
if candidate.is_file():
|
|
143
|
+
return candidate
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def recording_needs_gain(path):
|
|
148
|
+
"""True for plain binary files, whose gain is not saved with them (SpikeGLX and IBL files have it)."""
|
|
149
|
+
path = Path(path)
|
|
150
|
+
return not (path.with_suffix(".meta").is_file() or path.suffix == ".cbin")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def matches_phy_folder(analyzer_or_sorting, phy_folder):
|
|
154
|
+
"""True if the Phy folder was exported from this analyzer (same units, same spike counts)."""
|
|
155
|
+
from unitrefine.phy_curation import phy_to_curation
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
phy_to_curation(phy_folder, analyzer_or_sorting)
|
|
159
|
+
return True
|
|
160
|
+
except ValueError:
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def phy_has_edits(phy_folder):
|
|
165
|
+
"""True if clusters were merged or split in Phy (spike_clusters differs from the exported units)."""
|
|
166
|
+
phy_folder = Path(phy_folder)
|
|
167
|
+
clusters_path = phy_folder / "spike_clusters.npy"
|
|
168
|
+
if not clusters_path.is_file():
|
|
169
|
+
return False
|
|
170
|
+
return not np.array_equal(np.load(clusters_path).ravel(), np.load(phy_folder / "spike_templates.npy").ravel())
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# --------------------------------------------------------------------------------------------------
|
|
174
|
+
# reading the recording of Kilosort output
|
|
175
|
+
# --------------------------------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
def load_kilosort_recording(phy_folder, path=None, gain_to_uV=None):
|
|
178
|
+
"""
|
|
179
|
+
The recording Kilosort sorted, with the channels Kilosort used, filtered as for spike sorting (unless
|
|
180
|
+
params.py says it already is): high-pass filtered and common median referenced, and phase shifted for
|
|
181
|
+
Neuropixels recordings from SpikeGLX. Returns (recording, description of how it was read).
|
|
182
|
+
"""
|
|
183
|
+
import spikeinterface.full as si
|
|
184
|
+
from probeinterface import Probe
|
|
185
|
+
|
|
186
|
+
phy_folder = Path(phy_folder)
|
|
187
|
+
params = read_params(phy_folder)
|
|
188
|
+
path = recording_file(phy_folder, params) if path is None else Path(path)
|
|
189
|
+
if path is None or not path.is_file():
|
|
190
|
+
raise RecordingNotFound(
|
|
191
|
+
f"The recording Kilosort sorted was not found (params.py: dat_path = {params.get('dat_path')!r}). "
|
|
192
|
+
"Choose the recording file (e.g. the .ap.bin file from SpikeGLX)."
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
steps = []
|
|
196
|
+
if path.with_suffix(".meta").is_file():
|
|
197
|
+
# SpikeGLX: e.g. run_g0_t0.imec0.ap.bin is the stream imec0.ap; the gains are in the .meta file
|
|
198
|
+
stream_name = ".".join(path.name.split(".")[-3:-1])
|
|
199
|
+
recording = si.read_spikeglx(path.parent, stream_name=stream_name)
|
|
200
|
+
steps.append(f"SpikeGLX stream {stream_name}")
|
|
201
|
+
elif path.suffix == ".cbin":
|
|
202
|
+
recording = si.read_cbin_ibl(cbin_file_path=path)
|
|
203
|
+
steps.append("IBL compressed binary (.cbin)")
|
|
204
|
+
else:
|
|
205
|
+
if gain_to_uV is None:
|
|
206
|
+
raise NeedsGain(f"{path.name} is a binary file which doesn't say how to convert its values to µV. "
|
|
207
|
+
f"Give its gain in µV per bit ({GAIN_HINT}).")
|
|
208
|
+
recording = si.read_binary(
|
|
209
|
+
path, sampling_frequency=float(params["sample_rate"]), dtype=params.get("dtype", "int16"),
|
|
210
|
+
num_channels=int(params["n_channels_dat"]), file_offset=int(params.get("offset", 0)), gain_to_uV=float(gain_to_uV),
|
|
211
|
+
offset_to_uV=0.0,
|
|
212
|
+
)
|
|
213
|
+
steps.append(f"binary file, gain {gain_to_uV} µV per bit")
|
|
214
|
+
|
|
215
|
+
# the channels Kilosort used (e.g. without the sync channel), in its order
|
|
216
|
+
channel_map = np.load(phy_folder / "channel_map.npy").ravel() if (phy_folder / "channel_map.npy").is_file() else np.arange(recording.get_num_channels())
|
|
217
|
+
recording = recording.select_channels(recording.channel_ids[channel_map])
|
|
218
|
+
if not recording.has_probe():
|
|
219
|
+
positions = np.load(phy_folder / "channel_positions.npy")
|
|
220
|
+
probe = Probe(ndim=2, si_units="um")
|
|
221
|
+
probe.set_contacts(positions=positions, shapes="square", shape_params={"width": 12})
|
|
222
|
+
probe.set_device_channel_indices(np.arange(len(positions)))
|
|
223
|
+
# in place (SpikeInterface 0.106 removes the in_place argument)
|
|
224
|
+
recording.set_probe(probe)
|
|
225
|
+
|
|
226
|
+
if not params.get("hp_filtered", False):
|
|
227
|
+
if "SpikeGLX" in steps[0]:
|
|
228
|
+
recording = si.phase_shift(recording)
|
|
229
|
+
steps.append("phase shift")
|
|
230
|
+
recording = si.highpass_filter(recording, freq_min=300.0)
|
|
231
|
+
recording = si.common_reference(recording, reference="global", operator="median")
|
|
232
|
+
steps += ["high-pass filter at 300 Hz", "common median reference"]
|
|
233
|
+
return recording, ", ".join(steps)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# --------------------------------------------------------------------------------------------------
|
|
237
|
+
# making the analyzer
|
|
238
|
+
# --------------------------------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
def phy_labels(sorting):
|
|
241
|
+
"""Phy's labels as a table with unit_id and quality columns: good, MUA, noise (other labels are left out)."""
|
|
242
|
+
from unitrefine.phy_curation import QUALITY_LABELS
|
|
243
|
+
|
|
244
|
+
if "quality" not in sorting.get_property_keys():
|
|
245
|
+
return pd.DataFrame(columns=["unit_id", "quality"])
|
|
246
|
+
labels = [QUALITY_LABELS.get(str(label).strip().lower()) for label in sorting.get_property("quality")]
|
|
247
|
+
table = pd.DataFrame({"unit_id": sorting.unit_ids, "quality": labels})
|
|
248
|
+
return table.dropna(subset=["quality"]).reset_index(drop=True)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def kilosort_sorting(phy_folder):
|
|
252
|
+
"""
|
|
253
|
+
Phy's clusters (after its merges and splits) as a sorting, with the cluster ids as unit ids. Made from the
|
|
254
|
+
spikes, not with read_phy, which leaves out clusters missing from the cluster_*.tsv files.
|
|
255
|
+
"""
|
|
256
|
+
import spikeinterface.full as si
|
|
257
|
+
|
|
258
|
+
phy_folder = Path(phy_folder)
|
|
259
|
+
spike_times = np.load(phy_folder / "spike_times.npy").ravel().astype("int64")
|
|
260
|
+
clusters_path = phy_folder / "spike_clusters.npy"
|
|
261
|
+
spike_clusters = np.load(clusters_path if clusters_path.is_file() else phy_folder / "spike_templates.npy").ravel().astype("int64")
|
|
262
|
+
order = np.argsort(spike_times, kind="stable")
|
|
263
|
+
return si.NumpySorting.from_samples_and_labels([spike_times[order]], [spike_clusters[order]],
|
|
264
|
+
float(read_params(phy_folder)["sample_rate"]))
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def kilosort_phy_labels(phy_folder, unit_ids):
|
|
268
|
+
"""
|
|
269
|
+
The labels set in Phy (cluster_group.tsv), as a table with unit_id and quality columns. Kilosort writes its
|
|
270
|
+
own automatic labels to cluster_group.tsv too: if they are all the same as cluster_KSLabel.tsv, the folder
|
|
271
|
+
was not labelled in Phy, so no labels are returned.
|
|
272
|
+
"""
|
|
273
|
+
from unitrefine.phy_curation import QUALITY_LABELS
|
|
274
|
+
|
|
275
|
+
phy_folder = Path(phy_folder)
|
|
276
|
+
group_file = phy_folder / "cluster_group.tsv"
|
|
277
|
+
if not group_file.is_file():
|
|
278
|
+
print("No cluster_group.tsv: no units were labelled in Phy.", flush=True)
|
|
279
|
+
return pd.DataFrame(columns=["unit_id", "quality"])
|
|
280
|
+
groups = pd.read_csv(group_file, sep=" ")
|
|
281
|
+
groups = dict(zip(groups["cluster_id"].astype("int64"), groups[[column for column in groups.columns if column != "cluster_id"][0]]))
|
|
282
|
+
ks_file = phy_folder / "cluster_KSLabel.tsv"
|
|
283
|
+
if ks_file.is_file():
|
|
284
|
+
ks_labels = pd.read_csv(ks_file, sep=" ")
|
|
285
|
+
ks_labels = dict(zip(ks_labels["cluster_id"].astype("int64"), ks_labels["KSLabel"]))
|
|
286
|
+
if all(ks_labels.get(cluster_id) == label for cluster_id, label in groups.items()):
|
|
287
|
+
print("The labels in cluster_group.tsv are Kilosort's automatic labels (the same as cluster_KSLabel.tsv), "
|
|
288
|
+
"not labels set in Phy, so they are not used.", flush=True)
|
|
289
|
+
return pd.DataFrame(columns=["unit_id", "quality"])
|
|
290
|
+
unit_ids = set(int(unit_id) for unit_id in unit_ids)
|
|
291
|
+
rows = [(cluster_id, QUALITY_LABELS.get(str(label).strip().lower())) for cluster_id, label in groups.items() if cluster_id in unit_ids]
|
|
292
|
+
return pd.DataFrame(rows, columns=["unit_id", "quality"]).dropna(subset=["quality"]).reset_index(drop=True)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def analyzer_from_kilosort(phy_folder, output_folder, recording_path=None, gain_to_uV=None, job_kwargs=None):
|
|
296
|
+
"""Makes an analyzer from Kilosort output curated in Phy, saved to `output_folder`. Returns (analyzer, labels, how the recording was read)."""
|
|
297
|
+
import spikeinterface.full as si
|
|
298
|
+
|
|
299
|
+
phy_folder = Path(phy_folder)
|
|
300
|
+
report_step("reading")
|
|
301
|
+
sorting = kilosort_sorting(phy_folder)
|
|
302
|
+
recording, how_read = load_kilosort_recording(phy_folder, recording_path, gain_to_uV)
|
|
303
|
+
print(f"Recording: {how_read}. {sorting.get_num_units()} units from Phy.", flush=True)
|
|
304
|
+
|
|
305
|
+
analyzer = si.create_sorting_analyzer(sorting, recording, format="binary_folder", folder=output_folder, sparse=True,
|
|
306
|
+
overwrite=True)
|
|
307
|
+
for step, extensions in EXTENSION_STEPS:
|
|
308
|
+
report_step(step)
|
|
309
|
+
params = {name: EXTENSION_PARAMS[name] for name in extensions if name in EXTENSION_PARAMS}
|
|
310
|
+
analyzer.compute(extensions, extension_params=params, **(job_kwargs or {}))
|
|
311
|
+
return analyzer, kilosort_phy_labels(phy_folder, sorting.unit_ids), how_read
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def analyzer_from_exported(phy_folder, analyzer_path, output_folder, allow_hard_merge=False, job_kwargs=None):
|
|
315
|
+
"""
|
|
316
|
+
Applies Phy's curation to the analyzer exported to Phy. Returns (analyzer path, labels): the analyzer itself
|
|
317
|
+
if Phy only changed labels, else the curated analyzer saved to `output_folder`.
|
|
318
|
+
"""
|
|
319
|
+
import spikeinterface.full as si
|
|
320
|
+
from unitrefine.phy_curation import apply_phy_curation
|
|
321
|
+
|
|
322
|
+
report_step("reading")
|
|
323
|
+
analyzer = si.load_sorting_analyzer(analyzer_path)
|
|
324
|
+
report_step("curation")
|
|
325
|
+
curated = apply_phy_curation(analyzer, phy_folder, allow_hard_merge=allow_hard_merge, **(job_kwargs or {}))
|
|
326
|
+
labels = phy_labels(curated.sorting)
|
|
327
|
+
if not phy_has_edits(phy_folder):
|
|
328
|
+
print("Phy only changed labels, so the analyzer is used as it is.", flush=True)
|
|
329
|
+
return Path(analyzer_path), labels, False
|
|
330
|
+
report_step("saving")
|
|
331
|
+
curated.save_as(format="binary_folder", folder=output_folder)
|
|
332
|
+
return Path(output_folder), labels, True
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def import_phy_folder(phy_folder, output_folder=None, labels_path=None, analyzer_path=None, recording_path=None,
|
|
336
|
+
gain_to_uV=None, allow_hard_merge=False, job_kwargs=None, reuse=False):
|
|
337
|
+
"""
|
|
338
|
+
Makes the analyzer for a Phy folder (see the module description) and saves Phy's labels to `labels_path`
|
|
339
|
+
(default: next to the analyzer, as <analyzer>_phy_labels.csv). Returns a dict with the `analyzer` path,
|
|
340
|
+
the `labels` path and their number, the `kind` of Phy folder, and whether a `new_analyzer` was saved.
|
|
341
|
+
With `reuse`, the analyzer saved earlier in `output_folder` is used, and only the labels are read again.
|
|
342
|
+
"""
|
|
343
|
+
phy_folder = Path(phy_folder)
|
|
344
|
+
kind = phy_folder_kind(phy_folder)
|
|
345
|
+
output_folder = default_output_folder(phy_folder) if output_folder is None else Path(output_folder)
|
|
346
|
+
start_time = time.time()
|
|
347
|
+
|
|
348
|
+
if reuse:
|
|
349
|
+
import spikeinterface.full as si
|
|
350
|
+
|
|
351
|
+
report_step("reading")
|
|
352
|
+
sorting = si.load_sorting_analyzer(output_folder, load_extensions=False).sorting
|
|
353
|
+
labels = kilosort_phy_labels(phy_folder, sorting.unit_ids) if kind == "kilosort" else phy_labels(sorting)
|
|
354
|
+
analyzer_folder, new_analyzer = output_folder, False
|
|
355
|
+
print(f"Using the analyzer made earlier: {output_folder}", flush=True)
|
|
356
|
+
elif kind == "kilosort":
|
|
357
|
+
_, labels, how_read = analyzer_from_kilosort(phy_folder, output_folder, recording_path, gain_to_uV, job_kwargs)
|
|
358
|
+
analyzer_folder, new_analyzer = output_folder, True
|
|
359
|
+
details = dict(recording=str(recording_path or recording_file(phy_folder)), recording_read_as=how_read)
|
|
360
|
+
else:
|
|
361
|
+
if analyzer_path is None:
|
|
362
|
+
raise ValueError(f"{phy_folder} was exported from a SpikeInterface analyzer: give that analyzer (--analyzer).")
|
|
363
|
+
analyzer_folder, labels, new_analyzer = analyzer_from_exported(phy_folder, analyzer_path, output_folder,
|
|
364
|
+
allow_hard_merge, job_kwargs)
|
|
365
|
+
details = dict(exported_from=str(analyzer_path))
|
|
366
|
+
|
|
367
|
+
if new_analyzer:
|
|
368
|
+
with open(output_folder / MADE_FROM_PHY_FILE, "w") as f:
|
|
369
|
+
json.dump(dict(phy_folder=str(phy_folder), kind=kind, date=datetime.now().isoformat(timespec="seconds"),
|
|
370
|
+
**details), f, indent=4)
|
|
371
|
+
|
|
372
|
+
report_step("labels")
|
|
373
|
+
labels_path = Path(labels_path) if labels_path is not None else output_folder.parent / f"{output_folder.name}_phy_labels.csv"
|
|
374
|
+
labels_path.parent.mkdir(parents=True, exist_ok=True)
|
|
375
|
+
labels.to_csv(labels_path, index=False)
|
|
376
|
+
counts = {label: int(count) for label, count in labels['quality'].value_counts().items()}
|
|
377
|
+
print(f"{len(labels)} units have a Phy label {counts}. Saved in {labels_path}.", flush=True)
|
|
378
|
+
print(f"Done in {time.time() - start_time:.0f} s. Analyzer: {analyzer_folder}", flush=True)
|
|
379
|
+
return dict(kind=kind, analyzer=str(analyzer_folder), new_analyzer=new_analyzer, labels=str(labels_path),
|
|
380
|
+
num_labels=len(labels))
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
# --------------------------------------------------------------------------------------------------
|
|
384
|
+
# command line
|
|
385
|
+
# --------------------------------------------------------------------------------------------------
|
|
386
|
+
|
|
387
|
+
def main(argv=None):
|
|
388
|
+
from unitrefine.phy_curation import HardMergeRequired
|
|
389
|
+
|
|
390
|
+
parser = argparse.ArgumentParser(prog="python -m unitrefine.phy_import", description=__doc__.split("\n\n")[0],
|
|
391
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
392
|
+
parser.add_argument("phy_folder", help="The Phy folder (with params.py)")
|
|
393
|
+
parser.add_argument("--output", default=None, help="Where to save the analyzer (default: next to the Phy folder)")
|
|
394
|
+
parser.add_argument("--labels", default=None, help="Where to save Phy's labels (csv)")
|
|
395
|
+
parser.add_argument("--analyzer", default=None, help="For a Phy folder exported from an analyzer: that analyzer")
|
|
396
|
+
parser.add_argument("--recording", default=None, help="For Kilosort output: the recording, if params.py doesn't find it")
|
|
397
|
+
parser.add_argument("--gain_to_uv", type=float, default=None, help=f"For a plain binary recording: µV per bit ({GAIN_HINT})")
|
|
398
|
+
parser.add_argument("--allow_hard_merge", action="store_true", help="Do merges which need the raw recording")
|
|
399
|
+
parser.add_argument("--reuse", action="store_true", help="Use the analyzer made earlier in --output; only read the labels again")
|
|
400
|
+
parser.add_argument("--n_jobs", type=float, default=-1, help="Processes to use (-1: all cores)")
|
|
401
|
+
args = parser.parse_args(argv)
|
|
402
|
+
|
|
403
|
+
report_step("start")
|
|
404
|
+
try:
|
|
405
|
+
result = import_phy_folder(
|
|
406
|
+
args.phy_folder, output_folder=args.output, labels_path=args.labels, analyzer_path=args.analyzer,
|
|
407
|
+
recording_path=args.recording, gain_to_uV=args.gain_to_uv, allow_hard_merge=args.allow_hard_merge,
|
|
408
|
+
job_kwargs=dict(n_jobs=int(args.n_jobs) if args.n_jobs >= 1 or args.n_jobs == -1 else args.n_jobs),
|
|
409
|
+
reuse=args.reuse,
|
|
410
|
+
)
|
|
411
|
+
result["status"] = "done"
|
|
412
|
+
except HardMergeRequired as e:
|
|
413
|
+
result = dict(status="hard_merge_required", message=str(e))
|
|
414
|
+
except NeedsGain as e:
|
|
415
|
+
result = dict(status="needs_gain", message=str(e))
|
|
416
|
+
except RecordingNotFound as e:
|
|
417
|
+
result = dict(status="needs_recording", message=str(e))
|
|
418
|
+
except Exception as e:
|
|
419
|
+
traceback.print_exc()
|
|
420
|
+
result = dict(status="error", message=f"{type(e).__name__}: {e}")
|
|
421
|
+
if result["status"] != "done":
|
|
422
|
+
print(result["message"], flush=True)
|
|
423
|
+
print(f"{PHY_RESULT_MARKER} {json.dumps(result)}", flush=True)
|
|
424
|
+
return 0 if result["status"] == "done" else 1
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
if __name__ == "__main__":
|
|
428
|
+
sys.exit(main())
|