totalsync 0.1.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.
- packages/totalsync_2p/totalsync_2p/__init__.py +5 -0
- packages/totalsync_2p/totalsync_2p/cli.py +76 -0
- packages/totalsync_2p/totalsync_2p/sync.py +299 -0
- packages/totalsync_utils/totalsync_utils/__init__.py +5 -0
- packages/totalsync_utils/totalsync_utils/__main__.py +6 -0
- packages/totalsync_utils/totalsync_utils/cli.py +235 -0
- packages/totalsync_utils/totalsync_utils/decoder.py +299 -0
- totalsync-0.1.0.dist-info/METADATA +6 -0
- totalsync-0.1.0.dist-info/RECORD +12 -0
- totalsync-0.1.0.dist-info/WHEEL +5 -0
- totalsync-0.1.0.dist-info/licenses/LICENSE +676 -0
- totalsync-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Command-line interface for totalsync_2p."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from .sync import synchronize
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main():
|
|
10
|
+
parser = argparse.ArgumentParser(
|
|
11
|
+
description='Synchronize ScanImage tif recordings with TotalSync behavioral telemetry.'
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
input_group = parser.add_argument_group('input files')
|
|
15
|
+
input_group.add_argument(
|
|
16
|
+
'--tif-files',
|
|
17
|
+
nargs='+',
|
|
18
|
+
metavar='TIF',
|
|
19
|
+
help='One or more ScanImage tif files.',
|
|
20
|
+
)
|
|
21
|
+
input_group.add_argument(
|
|
22
|
+
'--b64-files',
|
|
23
|
+
nargs='+',
|
|
24
|
+
metavar='B64',
|
|
25
|
+
help='One or more TotalSync .b64 files, in the same order as --tif-files.',
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
'--output-dir',
|
|
30
|
+
required=True,
|
|
31
|
+
metavar='DIR',
|
|
32
|
+
help='Directory where output files will be saved.',
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
'--pin-sheet',
|
|
36
|
+
required=True,
|
|
37
|
+
metavar='JSON',
|
|
38
|
+
help='Path to the pin mapping JSON file.',
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
args = parser.parse_args()
|
|
42
|
+
|
|
43
|
+
if not args.tif_files or not args.b64_files:
|
|
44
|
+
parser.error('--tif-files and --b64-files are required.')
|
|
45
|
+
|
|
46
|
+
if len(args.tif_files) != len(args.b64_files):
|
|
47
|
+
parser.error(
|
|
48
|
+
f'Number of tif files ({len(args.tif_files)}) must match '
|
|
49
|
+
f'number of b64 files ({len(args.b64_files)}).'
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
errors = []
|
|
53
|
+
for i, (tif, b64) in enumerate(zip(args.tif_files, args.b64_files)):
|
|
54
|
+
print(f'\n[{i + 1}/{len(args.tif_files)}] Processing {tif} + {b64}')
|
|
55
|
+
try:
|
|
56
|
+
stats = synchronize(
|
|
57
|
+
tif_file=tif,
|
|
58
|
+
b64_file=b64,
|
|
59
|
+
output_dir=args.output_dir,
|
|
60
|
+
pin_sheet_file=args.pin_sheet,
|
|
61
|
+
)
|
|
62
|
+
barcode_info = 'barcode' if stats['has_barcode'] else 'no barcode (clock-based)'
|
|
63
|
+
print(f' Done — session: {stats["session"]}, alignment: {barcode_info}')
|
|
64
|
+
except Exception as e:
|
|
65
|
+
print(f' ERROR: {e}', file=sys.stderr)
|
|
66
|
+
errors.append((tif, b64, e))
|
|
67
|
+
|
|
68
|
+
if errors:
|
|
69
|
+
print(f'\n{len(errors)} file(s) failed:', file=sys.stderr)
|
|
70
|
+
for tif, b64, e in errors:
|
|
71
|
+
print(f' {tif} / {b64}: {e}', file=sys.stderr)
|
|
72
|
+
sys.exit(1)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == '__main__':
|
|
76
|
+
main()
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Core synchronization logic for aligning tif frames to behavioral telemetry."""
|
|
2
|
+
|
|
3
|
+
import pickle
|
|
4
|
+
import re
|
|
5
|
+
import warnings
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Dict, Union
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pynapple as nap
|
|
10
|
+
import tifffile
|
|
11
|
+
from tqdm import tqdm
|
|
12
|
+
|
|
13
|
+
from totalsync_utils import decode_single_file
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def extract_barcode_from_tif(tif_file: str) -> dict:
|
|
17
|
+
tag_structure = {
|
|
18
|
+
'image_description': 5,
|
|
19
|
+
'frame_timestamp': 3,
|
|
20
|
+
'auxTrigger0': 10,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
with tifffile.TiffFile(tif_file) as tif:
|
|
24
|
+
n_pages = len(tif.pages)
|
|
25
|
+
ts = np.zeros(n_pages)
|
|
26
|
+
value = np.zeros(n_pages)
|
|
27
|
+
frame_n = np.zeros(n_pages)
|
|
28
|
+
for i, page in tqdm(enumerate(tif.pages), total=n_pages):
|
|
29
|
+
description = page.tags.values()[tag_structure['image_description']].value
|
|
30
|
+
timestamp = float(
|
|
31
|
+
description.split('\n')[tag_structure['frame_timestamp']].split('=')[-1]
|
|
32
|
+
)
|
|
33
|
+
aux_line = description.split('\n')[tag_structure['auxTrigger0']]
|
|
34
|
+
data = aux_line.split('=')[-1].strip(' [').strip(']').strip(' ]')
|
|
35
|
+
ts[i] = timestamp
|
|
36
|
+
frame_n[i] = i
|
|
37
|
+
if 0 < len(data) < 50:
|
|
38
|
+
try:
|
|
39
|
+
value[i] = float(data)
|
|
40
|
+
except ValueError:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
return {'ts': ts, 'value': value, 'frame_n': frame_n}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def closest_match_indices_sorted(A, B, max_tolerance):
|
|
47
|
+
"""Return one-to-one closest matches between sorted arrays A and B."""
|
|
48
|
+
A = np.asarray(A)
|
|
49
|
+
B = np.asarray(B)
|
|
50
|
+
|
|
51
|
+
i = 0
|
|
52
|
+
j = 0
|
|
53
|
+
|
|
54
|
+
match_A_indices = []
|
|
55
|
+
match_B_indices = []
|
|
56
|
+
match_A_values = []
|
|
57
|
+
match_B_values = []
|
|
58
|
+
match_abs_differences = []
|
|
59
|
+
match_differences = []
|
|
60
|
+
|
|
61
|
+
unmatched_A_indices = []
|
|
62
|
+
matched_B_indices = set()
|
|
63
|
+
|
|
64
|
+
import pandas as pd
|
|
65
|
+
|
|
66
|
+
while i < len(A):
|
|
67
|
+
a = A[i]
|
|
68
|
+
|
|
69
|
+
while j < len(B) and B[j] < a - max_tolerance:
|
|
70
|
+
j += 1
|
|
71
|
+
|
|
72
|
+
candidates = []
|
|
73
|
+
|
|
74
|
+
if j < len(B) and abs(B[j] - a) <= max_tolerance:
|
|
75
|
+
candidates.append(j)
|
|
76
|
+
|
|
77
|
+
if j > 0 and (j - 1) not in matched_B_indices:
|
|
78
|
+
if abs(B[j - 1] - a) <= max_tolerance:
|
|
79
|
+
candidates.append(j - 1)
|
|
80
|
+
|
|
81
|
+
candidates = [idx for idx in candidates if idx not in matched_B_indices]
|
|
82
|
+
|
|
83
|
+
if candidates:
|
|
84
|
+
best_j = min(candidates, key=lambda idx: abs(B[idx] - a))
|
|
85
|
+
|
|
86
|
+
match_A_indices.append(i)
|
|
87
|
+
match_B_indices.append(best_j)
|
|
88
|
+
match_A_values.append(A[i])
|
|
89
|
+
match_B_values.append(B[best_j])
|
|
90
|
+
match_abs_differences.append(abs(B[best_j] - A[i]))
|
|
91
|
+
match_differences.append(B[best_j] - A[i])
|
|
92
|
+
matched_B_indices.add(best_j)
|
|
93
|
+
|
|
94
|
+
if best_j == j:
|
|
95
|
+
j += 1
|
|
96
|
+
else:
|
|
97
|
+
unmatched_A_indices.append(i)
|
|
98
|
+
|
|
99
|
+
i += 1
|
|
100
|
+
|
|
101
|
+
unmatched_B_indices = np.array(
|
|
102
|
+
[idx for idx in range(len(B)) if idx not in matched_B_indices], dtype=int
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
matches = pd.DataFrame({
|
|
106
|
+
'A_index': np.array(match_A_indices, dtype=int),
|
|
107
|
+
'B_index': np.array(match_B_indices, dtype=int),
|
|
108
|
+
'A_value': np.array(match_A_values, dtype=A.dtype),
|
|
109
|
+
'B_value': np.array(match_B_values, dtype=B.dtype),
|
|
110
|
+
'abs_difference': np.array(match_abs_differences),
|
|
111
|
+
'difference': np.array(match_differences),
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
return matches, np.array(unmatched_A_indices, dtype=int), unmatched_B_indices
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def normalize_underscores(s: str) -> str:
|
|
118
|
+
return re.sub(r'_+', '_', s).rstrip('_')
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def fix_tsync_time(log_times: np.ndarray) -> np.ndarray:
|
|
122
|
+
skips = -np.where(
|
|
123
|
+
np.diff(log_times) < 0,
|
|
124
|
+
np.diff(log_times) - np.median(np.diff(log_times)),
|
|
125
|
+
0,
|
|
126
|
+
)
|
|
127
|
+
cs = np.cumsum(skips)
|
|
128
|
+
cs2 = np.hstack((0, cs))
|
|
129
|
+
return log_times + cs2
|
|
130
|
+
|
|
131
|
+
def fill_gaps_in_tframes(t_frames: np.ndarray) -> np.ndarray:
|
|
132
|
+
median_interval = np.median(np.diff(t_frames))
|
|
133
|
+
|
|
134
|
+
t_frames_filled = t_frames.copy()
|
|
135
|
+
gap_locations = np.where(np.diff(t_frames_filled) > median_interval * 1.5)[0]
|
|
136
|
+
|
|
137
|
+
while len(gap_locations) > 0:
|
|
138
|
+
loc = gap_locations[0]
|
|
139
|
+
gap_size = np.diff(t_frames_filled)[loc]
|
|
140
|
+
t_frame_before = t_frames_filled[loc]
|
|
141
|
+
t_frame_after = t_frames_filled[loc + 1]
|
|
142
|
+
n_frames_to_fill = int(gap_size / median_interval)
|
|
143
|
+
frames_to_fill = np.linspace(t_frame_before, t_frame_after, n_frames_to_fill + 1)
|
|
144
|
+
t_frames_filled = np.insert(t_frames_filled, loc + 1, frames_to_fill[1:-1])
|
|
145
|
+
gap_locations = np.where(np.diff(t_frames_filled) > median_interval * 1.5)[0]
|
|
146
|
+
|
|
147
|
+
return t_frames_filled
|
|
148
|
+
|
|
149
|
+
def synchronize(tif_file: str, b64_file: str, output_dir_str: str, pin_sheet_file: str, fill_gaps: bool=False, ignore_barcode: bool=False) -> Dict[str, Union[str, nap.Tsd, np.ndarray]]:
|
|
150
|
+
"""Synchronize a tif imaging file with a b64 behavioral telemetry file.
|
|
151
|
+
|
|
152
|
+
Parameters
|
|
153
|
+
----------
|
|
154
|
+
tif_file : str
|
|
155
|
+
Path to the ScanImage tif recording.
|
|
156
|
+
b64_file : str
|
|
157
|
+
Path to the TotalSync .b64 behavioral telemetry file.
|
|
158
|
+
output_dir_str: str
|
|
159
|
+
Directory where output files will be saved.
|
|
160
|
+
pin_sheet_file : str
|
|
161
|
+
Path to the pin mapping JSON file.
|
|
162
|
+
fill_gaps : bool, default=False
|
|
163
|
+
Whether to fill gaps in the timestamp sequence using linear interpolation
|
|
164
|
+
|
|
165
|
+
Returns
|
|
166
|
+
-------
|
|
167
|
+
dict
|
|
168
|
+
Statistics and synchronization results with keys:
|
|
169
|
+
- 'session': session name (stem of b64_file)
|
|
170
|
+
- 'has_barcode': whether barcode-based alignment was used
|
|
171
|
+
- 'max_ts_gap': maximum timestamp gap in the behavioral log
|
|
172
|
+
- 'gap_locations': timestamps where gaps occurred
|
|
173
|
+
- 'barcode_shift' (if has_barcode): time shift between barcode signals
|
|
174
|
+
- 'barcode_frame_matches' (if has_barcode): DataFrame of matched frames
|
|
175
|
+
- 'frames_time_idx': the resulting pynapple Tsd
|
|
176
|
+
|
|
177
|
+
Saved files (in output_dir)
|
|
178
|
+
---------------------------
|
|
179
|
+
{session}_barcode_data.npz
|
|
180
|
+
Raw decoded telemetry arrays.
|
|
181
|
+
{session}_frames_time_idx.npz
|
|
182
|
+
Pynapple Tsd mapping scanner time -> tif frame index.
|
|
183
|
+
{session}_behavior_sync_stats.pkl
|
|
184
|
+
Dictionary of synchronization statistics.
|
|
185
|
+
behavior/{key_name}.npz
|
|
186
|
+
One pynapple Tsd (1-D channels) or TsdFrame (2-D channels) per decoded
|
|
187
|
+
telemetry key, time-indexed by the corrected TotalSync clock (µs).
|
|
188
|
+
"""
|
|
189
|
+
output_dir = Path(output_dir_str)
|
|
190
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
|
|
192
|
+
session = Path(b64_file).stem
|
|
193
|
+
stats = {'session': session}
|
|
194
|
+
|
|
195
|
+
# --- Decode behavioral telemetry ---
|
|
196
|
+
tsync_data = decode_single_file(b64_file, pin_json_path=pin_sheet_file)
|
|
197
|
+
np.savez(output_dir / f"{session}_barcode_data.npz", **tsync_data)
|
|
198
|
+
|
|
199
|
+
# --- Build frame-clock timestamps ---
|
|
200
|
+
frame_clock = tsync_data['Scanner Frame Clock (Input)'].astype(int)
|
|
201
|
+
log_times = tsync_data['startTS'].astype(int)
|
|
202
|
+
|
|
203
|
+
onsets = np.nonzero(np.diff(frame_clock) == 1)[0] + 1
|
|
204
|
+
|
|
205
|
+
stats['max_ts_gap'] = int(np.max(np.diff(log_times)))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
tsync_time = fix_tsync_time(log_times)
|
|
209
|
+
stats['gap_locations'] = tsync_time[np.where(np.diff(log_times) > 30000)]
|
|
210
|
+
if len(stats['gap_locations']) > 0:
|
|
211
|
+
warnings.warn(f"There are gaps in the timestamps: {stats['gap_locations']}")
|
|
212
|
+
# --- Save behavioral telemetry as pynapple objects ---
|
|
213
|
+
behavior_dir = output_dir / "behavior"
|
|
214
|
+
behavior_dir.mkdir(parents=True, exist_ok=True)
|
|
215
|
+
for key in tsync_data:
|
|
216
|
+
key_name = normalize_underscores(
|
|
217
|
+
key.replace(" ", "_").replace("(", "_").replace(")", "_").replace("-", "_")
|
|
218
|
+
)
|
|
219
|
+
if tsync_data[key].ndim == 1:
|
|
220
|
+
tsd = nap.Tsd(t=tsync_time, d=tsync_data[key], time_units='us')
|
|
221
|
+
tsd.save(behavior_dir / f"{key_name}.npz")
|
|
222
|
+
elif tsync_data[key].ndim == 2:
|
|
223
|
+
tsd = nap.TsdFrame(t=tsync_time, d=tsync_data[key], time_units='us')
|
|
224
|
+
tsd.save(behavior_dir / f"{key_name}.npz")
|
|
225
|
+
|
|
226
|
+
t_frames = nap.Ts(tsync_time[onsets], time_units='us')
|
|
227
|
+
|
|
228
|
+
# --- Extract aux barcode from tif ---
|
|
229
|
+
aux_data = extract_barcode_from_tif(tif_file)
|
|
230
|
+
|
|
231
|
+
aux_high = np.nonzero(aux_data['value'])[0]
|
|
232
|
+
aux_barcode_ts = nap.Ts(aux_data['value'][aux_high], time_units='s')
|
|
233
|
+
|
|
234
|
+
has_barcode = 'Barcode (Scanner)' in tsync_data and len(aux_barcode_ts) > 0 and not ignore_barcode
|
|
235
|
+
stats['has_barcode'] = has_barcode
|
|
236
|
+
|
|
237
|
+
if has_barcode:
|
|
238
|
+
tsync_barcode = tsync_data['Barcode (Scanner)'].astype(int)
|
|
239
|
+
tsync_barcode_rising_edge = np.nonzero(np.diff(tsync_barcode) > 0)[0] + 1
|
|
240
|
+
tsync_barcode_ts = nap.Ts(tsync_time[tsync_barcode_rising_edge], time_units='us')
|
|
241
|
+
# shift_offset = (tsync_barcode_ts.t[0] - aux_barcode_ts.t[0])
|
|
242
|
+
shift_offset = t_frames.t[0]
|
|
243
|
+
aux_barcode_ts = nap.Ts(aux_barcode_ts.t + shift_offset, time_units='s')
|
|
244
|
+
|
|
245
|
+
barcode_group = nap.TsGroup({0: aux_barcode_ts, 1: tsync_barcode_ts})
|
|
246
|
+
crosscorrs = nap.compute_crosscorrelogram(
|
|
247
|
+
group=barcode_group, time_units='ms', windowsize=5000, binsize=1
|
|
248
|
+
)
|
|
249
|
+
shift = crosscorrs.idxmax().iloc[0] + shift_offset
|
|
250
|
+
stats['barcode_shift'] = float(shift)
|
|
251
|
+
|
|
252
|
+
matches, _, _ = closest_match_indices_sorted(
|
|
253
|
+
aux_data['ts'] + shift, t_frames.t, max_tolerance=0.025
|
|
254
|
+
)
|
|
255
|
+
stats['barcode_frame_matches'] = matches
|
|
256
|
+
|
|
257
|
+
frames_time_idx = nap.Tsd(
|
|
258
|
+
t=matches['B_value'].to_numpy(),
|
|
259
|
+
d=matches['A_index'].to_numpy(),
|
|
260
|
+
time_units='s',
|
|
261
|
+
)
|
|
262
|
+
else:
|
|
263
|
+
warnings.warn(
|
|
264
|
+
"No barcode detected in the tif file, frame/time alignment will be done "
|
|
265
|
+
"by assuming that the first Scanner frame clock pulse corresponds to the "
|
|
266
|
+
"first tif frame"
|
|
267
|
+
)
|
|
268
|
+
if len(stats['gap_locations']) > 0:
|
|
269
|
+
if fill_gaps:
|
|
270
|
+
t_frames = nap.Ts(fill_gaps_in_tframes(t_frames.t), time_units='s')
|
|
271
|
+
stats['orig_gap_locations'] = stats['gap_locations']
|
|
272
|
+
stats['orig_max_ts_gap'] = stats['max_ts_gap']
|
|
273
|
+
stats['gap_locations'] = np.empty(0)
|
|
274
|
+
stats['max_ts_gap'] = 0
|
|
275
|
+
|
|
276
|
+
else:
|
|
277
|
+
warnings.warn(
|
|
278
|
+
"With no barcode, and gaps in the timestamps, alignment will be done "
|
|
279
|
+
"up to the first gap"
|
|
280
|
+
)
|
|
281
|
+
# find the first gap location after the scanner was started
|
|
282
|
+
align_stop = stats['gap_locations'][np.where(stats['gap_locations'] > t_frames[0].t * 1e6)[0][0]]
|
|
283
|
+
ep = nap.IntervalSet(start=0, end=align_stop, time_units='us')
|
|
284
|
+
t_frames = t_frames.restrict(ep)
|
|
285
|
+
|
|
286
|
+
frames_time_idx = nap.Tsd(
|
|
287
|
+
t=t_frames.t,
|
|
288
|
+
d=np.arange(len(t_frames)),
|
|
289
|
+
time_units='s',
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
frames_time_idx.save(output_dir / f"{session}_frames_time_idx.npz")
|
|
293
|
+
|
|
294
|
+
stats['frames_time_idx'] = frames_time_idx
|
|
295
|
+
|
|
296
|
+
with open(output_dir / f"{session}_behavior_sync_stats.pkl", 'wb') as f:
|
|
297
|
+
pickle.dump({k: v for k, v in stats.items() if k != 'frames_time_idx'}, f)
|
|
298
|
+
|
|
299
|
+
return stats
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Command-line interface for TotalSync decoder."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
import warnings
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
from scipy.io import savemat
|
|
12
|
+
|
|
13
|
+
from .decoder import decode_b64_files
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _fix_tsync_time(log_times: np.ndarray) -> np.ndarray:
|
|
17
|
+
skips = -np.where(
|
|
18
|
+
np.diff(log_times) < 0,
|
|
19
|
+
np.diff(log_times) - np.median(np.diff(log_times)),
|
|
20
|
+
0,
|
|
21
|
+
)
|
|
22
|
+
cs = np.cumsum(skips)
|
|
23
|
+
return log_times + np.hstack((0, cs))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _normalize_underscores(s: str) -> str:
|
|
27
|
+
return re.sub(r'_+', '_', s).rstrip('_')
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def save_as_pynapple(data_dict, output_dir, base_name):
|
|
31
|
+
"""Save decoded b64 data as pynapple Tsd/TsdFrame files, time-indexed by corrected TotalSync clock."""
|
|
32
|
+
import pynapple as nap
|
|
33
|
+
|
|
34
|
+
log_times = data_dict['startTS'].astype(int)
|
|
35
|
+
tsync_time = _fix_tsync_time(log_times)
|
|
36
|
+
|
|
37
|
+
gap_locations = tsync_time[np.where(np.diff(log_times) > 30000)]
|
|
38
|
+
if len(gap_locations) > 0:
|
|
39
|
+
warnings.warn(f"{base_name}: gaps in timestamps at {gap_locations}")
|
|
40
|
+
|
|
41
|
+
behavior_dir = Path(output_dir) / base_name / "behavior"
|
|
42
|
+
behavior_dir.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
|
|
44
|
+
for key, arr in data_dict.items():
|
|
45
|
+
if not isinstance(arr, np.ndarray):
|
|
46
|
+
continue
|
|
47
|
+
key_name = _normalize_underscores(
|
|
48
|
+
key.replace(" ", "_").replace("(", "_").replace(")", "_").replace("-", "_")
|
|
49
|
+
)
|
|
50
|
+
if arr.ndim == 1 and len(arr) == len(tsync_time):
|
|
51
|
+
nap.Tsd(t=tsync_time, d=arr, time_units='us').save(behavior_dir / f"{key_name}.npz")
|
|
52
|
+
elif arr.ndim == 2 and arr.shape[0] == len(tsync_time):
|
|
53
|
+
nap.TsdFrame(t=tsync_time, d=arr, time_units='us').save(behavior_dir / f"{key_name}.npz")
|
|
54
|
+
|
|
55
|
+
print(f"Saved: {behavior_dir}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def save_as_pynapple_concatenated(results_dict, output_dir):
|
|
59
|
+
"""Concatenate multiple decoded b64 files into a single set of pynapple files.
|
|
60
|
+
|
|
61
|
+
Timestamps are shifted so each file's time axis starts at prev_end + 1 (in µs).
|
|
62
|
+
"""
|
|
63
|
+
import pynapple as nap
|
|
64
|
+
|
|
65
|
+
all_tsync_times = []
|
|
66
|
+
offset = None
|
|
67
|
+
for name, data_dict in results_dict.items():
|
|
68
|
+
log_times = data_dict['startTS'].astype(int)
|
|
69
|
+
tsync_time = _fix_tsync_time(log_times)
|
|
70
|
+
|
|
71
|
+
gap_locations = tsync_time[np.where(np.diff(log_times) > 30000)]
|
|
72
|
+
if len(gap_locations) > 0:
|
|
73
|
+
warnings.warn(f"{name}: gaps in timestamps at {gap_locations}")
|
|
74
|
+
|
|
75
|
+
if offset is None:
|
|
76
|
+
shifted = tsync_time
|
|
77
|
+
else:
|
|
78
|
+
shifted = tsync_time - tsync_time[0] + offset + 1
|
|
79
|
+
|
|
80
|
+
all_tsync_times.append(shifted)
|
|
81
|
+
offset = int(shifted[-1])
|
|
82
|
+
|
|
83
|
+
tsync_time_cat = np.concatenate(all_tsync_times)
|
|
84
|
+
|
|
85
|
+
behavior_dir = Path(output_dir) / "behavior"
|
|
86
|
+
behavior_dir.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
|
|
88
|
+
first_data = next(iter(results_dict.values()))
|
|
89
|
+
for key in first_data:
|
|
90
|
+
arrays = [d[key] for d in results_dict.values()
|
|
91
|
+
if key in d and isinstance(d[key], np.ndarray)]
|
|
92
|
+
if len(arrays) != len(results_dict):
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
arr = np.concatenate(arrays, axis=0)
|
|
96
|
+
key_name = _normalize_underscores(
|
|
97
|
+
key.replace(" ", "_").replace("(", "_").replace(")", "_").replace("-", "_")
|
|
98
|
+
)
|
|
99
|
+
if arr.ndim == 1 and len(arr) == len(tsync_time_cat):
|
|
100
|
+
nap.Tsd(t=tsync_time_cat, d=arr, time_units='us').save(behavior_dir / f"{key_name}.npz")
|
|
101
|
+
elif arr.ndim == 2 and arr.shape[0] == len(tsync_time_cat):
|
|
102
|
+
nap.TsdFrame(t=tsync_time_cat, d=arr, time_units='us').save(behavior_dir / f"{key_name}.npz")
|
|
103
|
+
|
|
104
|
+
print(f"Saved concatenated pynapple files to: {behavior_dir}")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def save_as_npy(data_dict, output_dir, base_name):
|
|
108
|
+
"""Save decoded data as .npy files."""
|
|
109
|
+
output_path = os.path.join(output_dir, f"{base_name}_decoded.npy")
|
|
110
|
+
np.save(output_path, data_dict)
|
|
111
|
+
print(f"Saved: {output_path}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def save_as_mat(data_dict, output_dir, base_name):
|
|
115
|
+
"""Save decoded data as .mat file."""
|
|
116
|
+
output_path = os.path.join(output_dir, f"{base_name}_decoded.mat")
|
|
117
|
+
savemat(output_path, data_dict)
|
|
118
|
+
print(f"Saved: {output_path}")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def main():
|
|
122
|
+
"""Main CLI entry point."""
|
|
123
|
+
parser = argparse.ArgumentParser(
|
|
124
|
+
description='Decode TotalSync .b64 files',
|
|
125
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
126
|
+
epilog="""
|
|
127
|
+
Examples:
|
|
128
|
+
# Decode files and print to console
|
|
129
|
+
totalsync-decode /path/to/data
|
|
130
|
+
|
|
131
|
+
# Save as .mat files
|
|
132
|
+
totalsync-decode /path/to/data --output /path/to/output --format mat
|
|
133
|
+
|
|
134
|
+
# Save as .npy files
|
|
135
|
+
totalsync-decode /path/to/data --output /path/to/output --format npy
|
|
136
|
+
|
|
137
|
+
# Use pin mapping to split channels by name
|
|
138
|
+
totalsync-decode /path/to/data --output /path/to/output --pin-json docs/pinSheet.json
|
|
139
|
+
|
|
140
|
+
# Save as pynapple Tsd/TsdFrame files (time-indexed by TotalSync clock)
|
|
141
|
+
totalsync-decode /path/to/data --output /path/to/output --format pynapple --pin-json docs/pinSheet.json
|
|
142
|
+
|
|
143
|
+
# Concatenate multiple b64 files into a single set of pynapple files
|
|
144
|
+
totalsync-decode /path/to/data --output /path/to/output --format pynapple --concatenate --pin-json docs/pinSheet.json
|
|
145
|
+
"""
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
parser.add_argument(
|
|
149
|
+
'directory',
|
|
150
|
+
help='Directory containing .b64 files to decode'
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
parser.add_argument(
|
|
154
|
+
'-o', '--output',
|
|
155
|
+
help='Output directory (if not specified, data is only returned, not saved)',
|
|
156
|
+
default=None
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
parser.add_argument(
|
|
160
|
+
'-f', '--format',
|
|
161
|
+
choices=['npy', 'mat', 'pynapple'],
|
|
162
|
+
default='mat',
|
|
163
|
+
help='Output format for saved files (default: mat)'
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
parser.add_argument(
|
|
167
|
+
'-p', '--pin-json',
|
|
168
|
+
help='Path to pin mapping JSON file (e.g., pinSheet.json)',
|
|
169
|
+
default=None
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
parser.add_argument(
|
|
173
|
+
'--concatenate',
|
|
174
|
+
action='store_true',
|
|
175
|
+
help='Concatenate all b64 files into a single set of pynapple files (only with --format pynapple)'
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
parser.add_argument(
|
|
179
|
+
'-q', '--quiet',
|
|
180
|
+
action='store_true',
|
|
181
|
+
help='Suppress progress output'
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
args = parser.parse_args()
|
|
185
|
+
|
|
186
|
+
if args.concatenate and args.format != 'pynapple':
|
|
187
|
+
print("Error: --concatenate is only valid with --format pynapple", file=sys.stderr)
|
|
188
|
+
return 1
|
|
189
|
+
|
|
190
|
+
# Check input directory exists
|
|
191
|
+
if not os.path.isdir(args.directory):
|
|
192
|
+
print(f"Error: Directory not found: {args.directory}", file=sys.stderr)
|
|
193
|
+
return 1
|
|
194
|
+
|
|
195
|
+
# Create output directory if specified
|
|
196
|
+
if args.output:
|
|
197
|
+
os.makedirs(args.output, exist_ok=True)
|
|
198
|
+
if not os.path.isdir(args.output):
|
|
199
|
+
print(f"Error: Could not create output directory: {args.output}", file=sys.stderr)
|
|
200
|
+
return 1
|
|
201
|
+
|
|
202
|
+
# Check pin JSON file if provided
|
|
203
|
+
if args.pin_json and not os.path.isfile(args.pin_json):
|
|
204
|
+
print(f"Error: Pin JSON file not found: {args.pin_json}", file=sys.stderr)
|
|
205
|
+
return 1
|
|
206
|
+
|
|
207
|
+
# Decode files
|
|
208
|
+
try:
|
|
209
|
+
results = decode_b64_files(args.directory, verbose=not args.quiet, pin_json_path=args.pin_json)
|
|
210
|
+
except Exception as e:
|
|
211
|
+
print(f"Error during decoding: {e}", file=sys.stderr)
|
|
212
|
+
return 1
|
|
213
|
+
|
|
214
|
+
# Save results if output directory specified
|
|
215
|
+
if args.output:
|
|
216
|
+
if args.format == 'pynapple' and args.concatenate:
|
|
217
|
+
save_as_pynapple_concatenated(results, args.output)
|
|
218
|
+
else:
|
|
219
|
+
for name, data in results.items():
|
|
220
|
+
if args.format == 'npy':
|
|
221
|
+
save_as_npy(data, args.output, name)
|
|
222
|
+
elif args.format == 'pynapple':
|
|
223
|
+
save_as_pynapple(data, args.output, name)
|
|
224
|
+
else: # mat
|
|
225
|
+
save_as_mat(data, args.output, name)
|
|
226
|
+
else:
|
|
227
|
+
if not args.quiet:
|
|
228
|
+
print(f"\nDecoded {len(results)} file(s)")
|
|
229
|
+
print("No output directory specified - data not saved")
|
|
230
|
+
|
|
231
|
+
return 0
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
if __name__ == '__main__':
|
|
235
|
+
sys.exit(main())
|