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.
@@ -0,0 +1,5 @@
1
+ """TotalSync 2-photon data synchronization."""
2
+
3
+ from .sync import synchronize
4
+
5
+ __all__ = ['synchronize']
@@ -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,5 @@
1
+ """TotalSync data decoding utilities."""
2
+
3
+ from .decoder import decode_b64_files, decode_single_file, load_pin_mapping, apply_pin_mapping
4
+
5
+ __all__ = ['decode_b64_files', 'decode_single_file', 'load_pin_mapping', 'apply_pin_mapping']
@@ -0,0 +1,6 @@
1
+ """Allow running as python -m totalsync_utils."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == '__main__':
6
+ main()
@@ -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())