mesofield 0.3.2b0__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.
- docs/_static/custom.css +40 -0
- docs/_static/favicon.png +0 -0
- docs/_static/logo.png +0 -0
- docs/api/index.md +70 -0
- docs/conf.py +200 -0
- docs/developer_guide.md +303 -0
- docs/index.md +25 -0
- docs/tutorial.md +4 -0
- docs/user_guide.md +172 -0
- examples/teensy_pulse_generator.py +320 -0
- experiments/pipeline_demo/experiment.json +24 -0
- experiments/pipeline_demo/hardware.yaml +23 -0
- experiments/pipeline_demo/procedure.py +50 -0
- experiments/two_cam_demo/experiment.json +24 -0
- experiments/two_cam_demo/hardware.yaml +58 -0
- experiments/two_cam_demo/load_dataset.py +213 -0
- experiments/two_cam_demo/procedure.py +87 -0
- external/video-codecs/openh264-1.8.0-win64.dll +0 -0
- mesofield/__init__.py +45 -0
- mesofield/__main__.py +11 -0
- mesofield/_version.py +24 -0
- mesofield/base.py +750 -0
- mesofield/cli/__init__.py +57 -0
- mesofield/cli/_richhelp.py +100 -0
- mesofield/cli/acquire.py +254 -0
- mesofield/cli/datakit.py +165 -0
- mesofield/cli/process.py +376 -0
- mesofield/cli/rig.py +108 -0
- mesofield/cli/tools.py +347 -0
- mesofield/config.py +751 -0
- mesofield/data/__init__.py +23 -0
- mesofield/data/batch.py +633 -0
- mesofield/data/manager.py +388 -0
- mesofield/data/writer.py +289 -0
- mesofield/datakit/__init__.py +44 -0
- mesofield/datakit/__main__.py +35 -0
- mesofield/datakit/_utils/_logger.py +5 -0
- mesofield/datakit/_version.py +141 -0
- mesofield/datakit/config.py +50 -0
- mesofield/datakit/core.py +783 -0
- mesofield/datakit/datamodel.py +200 -0
- mesofield/datakit/discover.py +124 -0
- mesofield/datakit/explore.py +651 -0
- mesofield/datakit/notebooks/pupil_dlc.ipynb +2445 -0
- mesofield/datakit/profile.py +535 -0
- mesofield/datakit/shell.py +83 -0
- mesofield/datakit/sources/__init__.py +65 -0
- mesofield/datakit/sources/analysis/mesomap.py +194 -0
- mesofield/datakit/sources/analysis/mesoscope.py +77 -0
- mesofield/datakit/sources/analysis/pupil.py +246 -0
- mesofield/datakit/sources/behavior/__init__.py +0 -0
- mesofield/datakit/sources/behavior/dataqueue.py +281 -0
- mesofield/datakit/sources/behavior/psychopy.py +364 -0
- mesofield/datakit/sources/behavior/treadmill.py +323 -0
- mesofield/datakit/sources/behavior/wheel.py +277 -0
- mesofield/datakit/sources/camera/mesoscope.py +32 -0
- mesofield/datakit/sources/camera/metadata_json.py +130 -0
- mesofield/datakit/sources/camera/pupil.py +28 -0
- mesofield/datakit/sources/camera/suite2p.py +547 -0
- mesofield/datakit/sources/register.py +204 -0
- mesofield/datakit/sources/session/config.py +130 -0
- mesofield/datakit/sources/session/notes.py +63 -0
- mesofield/datakit/sources/session/timestamps.py +58 -0
- mesofield/datakit/timeline.py +306 -0
- mesofield/devices/__init__.py +42 -0
- mesofield/devices/base.py +498 -0
- mesofield/devices/base_camera.py +295 -0
- mesofield/devices/cameras.py +740 -0
- mesofield/devices/daq.py +151 -0
- mesofield/devices/encoder.py +384 -0
- mesofield/devices/mocks.py +275 -0
- mesofield/devices/psychopy_device.py +455 -0
- mesofield/devices/subprocesses/__init__.py +0 -0
- mesofield/devices/subprocesses/psychopy.py +133 -0
- mesofield/devices/treadmill.py +318 -0
- mesofield/engines.py +380 -0
- mesofield/gui/Mesofield_icon.png +0 -0
- mesofield/gui/__init__.py +76 -0
- mesofield/gui/config_wizard.py +724 -0
- mesofield/gui/controller.py +535 -0
- mesofield/gui/dynamic_controller.py +78 -0
- mesofield/gui/maingui.py +427 -0
- mesofield/gui/mdagui.py +285 -0
- mesofield/gui/qt_device_adapter.py +109 -0
- mesofield/gui/speedplotter.py +152 -0
- mesofield/gui/theme.py +445 -0
- mesofield/gui/tiff_viewer.py +1050 -0
- mesofield/gui/viewer.py +691 -0
- mesofield/hardware.py +549 -0
- mesofield/playback.py +1298 -0
- mesofield/processing/__init__.py +12 -0
- mesofield/processing/runner.py +237 -0
- mesofield/processors/__init__.py +13 -0
- mesofield/processors/base.py +287 -0
- mesofield/processors/frame_mean.py +19 -0
- mesofield/protocols.py +378 -0
- mesofield/scaffold/__init__.py +34 -0
- mesofield/scaffold/experiment.py +400 -0
- mesofield/scaffold/rigs.py +121 -0
- mesofield/signals.py +85 -0
- mesofield/utils/__init__.py +0 -0
- mesofield/utils/_logger.py +156 -0
- mesofield/utils/retrofit.py +309 -0
- mesofield/utils/utils.py +217 -0
- mesofield-0.3.2b0.dist-info/METADATA +178 -0
- mesofield-0.3.2b0.dist-info/RECORD +111 -0
- mesofield-0.3.2b0.dist-info/WHEEL +5 -0
- mesofield-0.3.2b0.dist-info/entry_points.txt +2 -0
- mesofield-0.3.2b0.dist-info/licenses/LICENSE +21 -0
- mesofield-0.3.2b0.dist-info/top_level.txt +6 -0
- scripts/bench_frame_processor.py +103 -0
mesofield/cli/process.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""``mesofield process`` — batch processing & conversion of recorded data.
|
|
2
|
+
|
|
3
|
+
Operates on BIDS-formatted experiment trees that have already been acquired:
|
|
4
|
+
fluorescence traces, frame montages, video conversion, and manifest
|
|
5
|
+
retrofitting.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
|
|
15
|
+
from ._richhelp import RichGroup
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@click.group('process', cls=RichGroup)
|
|
19
|
+
def process():
|
|
20
|
+
"""Batch-process and convert recorded experiment data."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Datakit-based meso tiff discovery helpers
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _discover_meso_entries(experiment_dir, subject_filter=None):
|
|
29
|
+
"""Return ManifestEntry objects for mesoscope tiff files.
|
|
30
|
+
|
|
31
|
+
Uses datakit's ``discover_manifest`` to scan the BIDS hierarchy,
|
|
32
|
+
filters for ``meso_metadata`` entries (the JSON sidecars), then
|
|
33
|
+
rewrites each entry's path to point at the tiff itself.
|
|
34
|
+
"""
|
|
35
|
+
from mesofield.datakit.discover import discover_manifest
|
|
36
|
+
from mesofield.datakit.datamodel import ManifestEntry
|
|
37
|
+
|
|
38
|
+
manifest = discover_manifest(Path(experiment_dir))
|
|
39
|
+
tiff_entries = []
|
|
40
|
+
for entry in manifest.entries:
|
|
41
|
+
if entry.tag != "meso_metadata":
|
|
42
|
+
continue
|
|
43
|
+
if subject_filter and entry.subject != subject_filter:
|
|
44
|
+
continue
|
|
45
|
+
# Derive the tiff path from the metadata sidecar path:
|
|
46
|
+
# ...mesoscope.ome.tiff_frame_metadata.json → ...mesoscope.ome.tiff
|
|
47
|
+
tiff_path = entry.path.replace("_frame_metadata.json", "")
|
|
48
|
+
tiff_entries.append(ManifestEntry(
|
|
49
|
+
tag="meso_tiff",
|
|
50
|
+
path=tiff_path,
|
|
51
|
+
origin=entry.origin,
|
|
52
|
+
subject=entry.subject,
|
|
53
|
+
session=entry.session,
|
|
54
|
+
task=entry.task,
|
|
55
|
+
))
|
|
56
|
+
return tiff_entries
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _discover_meso_tiffs(experiment_dir, subject_filter=None):
|
|
60
|
+
"""Return a list of absolute tiff paths and the experiment root."""
|
|
61
|
+
root = Path(experiment_dir).resolve()
|
|
62
|
+
entries = _discover_meso_entries(experiment_dir, subject_filter)
|
|
63
|
+
paths = [str(root / e.path) for e in entries]
|
|
64
|
+
return paths, root
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# trace-meso
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@process.command('trace-meso')
|
|
73
|
+
@click.option('--path', help='Path to a single tiff file (bypass discovery)')
|
|
74
|
+
@click.option('--dir', help='Experiment directory to discover mesoscope tiffs')
|
|
75
|
+
@click.option('--sub', default=None, help='Subject ID to filter (default: all)')
|
|
76
|
+
def trace_meso(path, dir, sub):
|
|
77
|
+
"""Compute mean fluorescence traces from mesoscope OME-TIFF stacks.
|
|
78
|
+
|
|
79
|
+
Uses datakit discovery to find mesoscope tiffs in a BIDS hierarchy,
|
|
80
|
+
or accepts a single --path for quick one-off analysis.
|
|
81
|
+
"""
|
|
82
|
+
import pandas as pd
|
|
83
|
+
import mesofield.data.batch as batch
|
|
84
|
+
|
|
85
|
+
if path:
|
|
86
|
+
tiff_paths = [path]
|
|
87
|
+
experiment_dir = Path(path).parents[4]
|
|
88
|
+
outdir = Path(experiment_dir) / "processed"
|
|
89
|
+
elif dir:
|
|
90
|
+
tiff_paths, experiment_dir = _discover_meso_tiffs(dir, sub)
|
|
91
|
+
outdir = Path(dir) / "processed" / sub if sub else Path(dir) / "processed"
|
|
92
|
+
else:
|
|
93
|
+
raise click.UsageError("Provide --path or --dir")
|
|
94
|
+
|
|
95
|
+
if not tiff_paths:
|
|
96
|
+
click.secho("No mesoscope tiffs found.", fg="yellow")
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
os.makedirs(outdir, exist_ok=True)
|
|
100
|
+
click.echo(f"Computing mean traces for {len(tiff_paths)} tiff(s)...")
|
|
101
|
+
results = batch.mean_trace_from_tiff(tiff_paths)
|
|
102
|
+
|
|
103
|
+
for tiff_path, trace in results.items():
|
|
104
|
+
df = pd.DataFrame({"Slice": range(len(trace)), "Mean": trace})
|
|
105
|
+
base_name = os.path.splitext(os.path.basename(tiff_path))[0]
|
|
106
|
+
filename = f"{base_name}_meso-mean-trace.csv"
|
|
107
|
+
save_path = os.path.join(outdir, filename)
|
|
108
|
+
df.to_csv(save_path, index=False)
|
|
109
|
+
click.echo(f" {filename} ({len(trace)} frames)")
|
|
110
|
+
|
|
111
|
+
click.secho(f"Saved {len(results)} trace(s) to {outdir}", fg="green")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# montage-meso
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@process.command('montage-meso')
|
|
120
|
+
@click.option('--dir', required=True, help='Experiment directory containing BIDS formatted /data hierarchy')
|
|
121
|
+
@click.option('--sub', default=None, help='Single subject ID to process (default: all subjects)')
|
|
122
|
+
@click.option('--frame', default=1, show_default=True, help='0-based frame index to extract from each tiff')
|
|
123
|
+
@click.option('--rotate', default=0, show_default=True, type=int, help='Rotate each frame by N degrees (positive=clockwise, negative=counter-clockwise)')
|
|
124
|
+
@click.option('--filter', 'ses_filter', default=None, help='Session range START:END (1-based, exclusive end). E.g. 1:10 keeps sessions 1-9.')
|
|
125
|
+
def montage_meso(dir, sub, frame, rotate, ses_filter):
|
|
126
|
+
"""Extract a single frame from each session's widefield tiff and save a per-subject montage.
|
|
127
|
+
|
|
128
|
+
Uses datakit to discover mesoscope tiffs in the BIDS hierarchy.
|
|
129
|
+
Each task within a session becomes its own row. Columns are sessions,
|
|
130
|
+
so the output image is a grid of (tasks x sessions) panels.
|
|
131
|
+
"""
|
|
132
|
+
import numpy as np
|
|
133
|
+
import tifffile
|
|
134
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
135
|
+
|
|
136
|
+
# --- Discover tiffs via datakit ---
|
|
137
|
+
entries = _discover_meso_entries(dir, sub)
|
|
138
|
+
if not entries:
|
|
139
|
+
click.secho("No mesoscope tiffs found.", fg="yellow")
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
# Build a nested dict: subject -> session -> task -> tiff_path
|
|
143
|
+
tree: dict[str, dict[str, dict[str, str]]] = {}
|
|
144
|
+
root = Path(dir).resolve()
|
|
145
|
+
for entry in entries:
|
|
146
|
+
s, ses, task = entry.subject, entry.session, entry.task or "default"
|
|
147
|
+
tree.setdefault(s, {}).setdefault(ses, {})[task] = str(root / entry.path)
|
|
148
|
+
|
|
149
|
+
subjects = [sub] if sub else sorted(tree.keys())
|
|
150
|
+
|
|
151
|
+
outdir = os.path.join(dir, "processed")
|
|
152
|
+
os.makedirs(outdir, exist_ok=True)
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
font = ImageFont.truetype("arial.ttf", 18)
|
|
156
|
+
except OSError:
|
|
157
|
+
font = ImageFont.load_default()
|
|
158
|
+
|
|
159
|
+
label_height = 30
|
|
160
|
+
|
|
161
|
+
for subject in subjects:
|
|
162
|
+
if subject not in tree:
|
|
163
|
+
click.echo(f"WARNING: sub-{subject} not found, skipping")
|
|
164
|
+
continue
|
|
165
|
+
sessions = tree[subject]
|
|
166
|
+
ses_keys = sorted(sessions.keys())
|
|
167
|
+
|
|
168
|
+
# Apply session range filter
|
|
169
|
+
if ses_filter:
|
|
170
|
+
parts = ses_filter.split(':')
|
|
171
|
+
start = int(parts[0]) if parts[0] else 1
|
|
172
|
+
end = int(parts[1]) if len(parts) > 1 and parts[1] else None
|
|
173
|
+
ses_keys = [k for k in ses_keys if int(k) >= start and (end is None or int(k) < end)]
|
|
174
|
+
|
|
175
|
+
# Collect the superset of task names across all sessions (preserving order)
|
|
176
|
+
all_tasks: list[str] = []
|
|
177
|
+
for sk in ses_keys:
|
|
178
|
+
for tk in sessions[sk]:
|
|
179
|
+
if tk not in all_tasks:
|
|
180
|
+
all_tasks.append(tk)
|
|
181
|
+
|
|
182
|
+
# grid[task][ses_key] = normalised 8-bit numpy image
|
|
183
|
+
grid: dict[str, dict[str, np.ndarray]] = {t: {} for t in all_tasks}
|
|
184
|
+
|
|
185
|
+
for ses_key in ses_keys:
|
|
186
|
+
session = sessions[ses_key]
|
|
187
|
+
for task in all_tasks:
|
|
188
|
+
if task not in session:
|
|
189
|
+
click.echo(f"WARNING: sub-{subject} ses-{ses_key} task-{task} missing, skipping")
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
tiff_path = session[task]
|
|
193
|
+
try:
|
|
194
|
+
tiff_array = tifffile.memmap(tiff_path)
|
|
195
|
+
if tiff_array.shape[0] <= frame:
|
|
196
|
+
click.echo(f"WARNING: {tiff_path} has only {tiff_array.shape[0]} frames, skipping")
|
|
197
|
+
continue
|
|
198
|
+
img = np.array(tiff_array[frame])
|
|
199
|
+
except Exception as e:
|
|
200
|
+
click.echo(f"ERROR reading {tiff_path}: {e}")
|
|
201
|
+
continue
|
|
202
|
+
|
|
203
|
+
img_min, img_max = img.min(), img.max()
|
|
204
|
+
if img_max > img_min:
|
|
205
|
+
img_norm = ((img - img_min) / (img_max - img_min) * 255).astype(np.uint8)
|
|
206
|
+
else:
|
|
207
|
+
img_norm = np.zeros_like(img, dtype=np.uint8)
|
|
208
|
+
|
|
209
|
+
if rotate:
|
|
210
|
+
img_norm = np.array(Image.fromarray(img_norm).rotate(-rotate, expand=True))
|
|
211
|
+
|
|
212
|
+
grid[task][ses_key] = img_norm
|
|
213
|
+
|
|
214
|
+
if not any(grid[t] for t in all_tasks):
|
|
215
|
+
click.echo(f"WARNING: No frames found for sub-{subject}, skipping")
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
all_imgs = [img for task_imgs in grid.values() for img in task_imgs.values()]
|
|
219
|
+
cell_h = max(img.shape[0] for img in all_imgs)
|
|
220
|
+
cell_w = max(img.shape[1] for img in all_imgs)
|
|
221
|
+
|
|
222
|
+
def _resize(img):
|
|
223
|
+
if img.shape[0] == cell_h and img.shape[1] == cell_w:
|
|
224
|
+
return img
|
|
225
|
+
return np.array(Image.fromarray(img).resize((cell_w, cell_h), Image.LANCZOS))
|
|
226
|
+
|
|
227
|
+
def _blank():
|
|
228
|
+
return np.zeros((cell_h, cell_w), dtype=np.uint8)
|
|
229
|
+
|
|
230
|
+
def _label_header(text, width):
|
|
231
|
+
header = Image.new('L', (width, label_height), color=0)
|
|
232
|
+
draw = ImageDraw.Draw(header)
|
|
233
|
+
bbox = draw.textbbox((0, 0), text, font=font)
|
|
234
|
+
text_w = bbox[2] - bbox[0]
|
|
235
|
+
draw.text(((width - text_w) // 2, 4), text, fill=255, font=font)
|
|
236
|
+
return np.array(header)
|
|
237
|
+
|
|
238
|
+
def _vertical_text(text, height, strip_w=40):
|
|
239
|
+
tmp = Image.new('L', (height, strip_w), color=0)
|
|
240
|
+
draw = ImageDraw.Draw(tmp)
|
|
241
|
+
bbox = draw.textbbox((0, 0), text, font=font)
|
|
242
|
+
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
|
243
|
+
draw.text(((height - tw) // 2, (strip_w - th) // 2), text, fill=255, font=font)
|
|
244
|
+
return tmp
|
|
245
|
+
|
|
246
|
+
side_strip_w = 40
|
|
247
|
+
grid_height = label_height + cell_h * len(all_tasks)
|
|
248
|
+
|
|
249
|
+
subject_label = f"sub-{subject}"
|
|
250
|
+
left_pil = _vertical_text(subject_label, grid_height, side_strip_w)
|
|
251
|
+
left_strip = np.array(left_pil.rotate(90, expand=True))
|
|
252
|
+
|
|
253
|
+
right_pieces = [np.zeros((label_height, side_strip_w), dtype=np.uint8)]
|
|
254
|
+
for task in all_tasks:
|
|
255
|
+
task_label = task if task else "default"
|
|
256
|
+
tmp = _vertical_text(task_label, cell_h, side_strip_w)
|
|
257
|
+
right_pieces.append(np.array(tmp.rotate(-90, expand=True)))
|
|
258
|
+
right_strip = np.vstack(right_pieces)
|
|
259
|
+
|
|
260
|
+
columns = []
|
|
261
|
+
for ses_key in ses_keys:
|
|
262
|
+
col_header = _label_header(f"ses-{ses_key}", cell_w)
|
|
263
|
+
panels = [col_header]
|
|
264
|
+
for task in all_tasks:
|
|
265
|
+
img = grid[task].get(ses_key)
|
|
266
|
+
panels.append(_resize(img) if img is not None else _blank())
|
|
267
|
+
columns.append(np.vstack(panels))
|
|
268
|
+
|
|
269
|
+
montage = np.hstack([left_strip] + columns + [right_strip])
|
|
270
|
+
montage_img = Image.fromarray(montage)
|
|
271
|
+
|
|
272
|
+
filename = f"sub-{subject}_frame-{frame}_montage.png"
|
|
273
|
+
save_path = os.path.join(outdir, filename)
|
|
274
|
+
montage_img.save(save_path)
|
|
275
|
+
n_cells = sum(len(v) for v in grid.values())
|
|
276
|
+
click.echo(f"Saved: {save_path} ({len(all_tasks)} tasks x {len(ses_keys)} sessions, {n_cells} images)")
|
|
277
|
+
|
|
278
|
+
click.secho("Done.", fg="green")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# ---------------------------------------------------------------------------
|
|
282
|
+
# pupil-mp4
|
|
283
|
+
# ---------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@process.command('pupil-mp4')
|
|
287
|
+
@click.option('--dir', help='Directory containing the BIDS formatted /data hierarchy')
|
|
288
|
+
def pupil_mp4(dir):
|
|
289
|
+
"""Convert the pupil videos to mp4 format."""
|
|
290
|
+
from mesofield.data.batch import tiff_to_mp4
|
|
291
|
+
|
|
292
|
+
tiff_to_mp4(
|
|
293
|
+
parent_directory=dir,
|
|
294
|
+
fps=30,
|
|
295
|
+
output_format="mp4",
|
|
296
|
+
use_color=False
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# ---------------------------------------------------------------------------
|
|
301
|
+
# convert-h264
|
|
302
|
+
# ---------------------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@process.command('convert-h264')
|
|
306
|
+
@click.option('--dir', required=True, help='Directory containing video files to convert')
|
|
307
|
+
@click.option('--pattern', default='*.mp4', help='Glob pattern to match files (e.g., "*.mp4", "pupil*.mp4")')
|
|
308
|
+
def convert_h264(dir, pattern):
|
|
309
|
+
"""Convert video files to H264 format for better compatibility."""
|
|
310
|
+
from mesofield.data.batch import batch_convert_to_h264
|
|
311
|
+
|
|
312
|
+
batch_convert_to_h264(
|
|
313
|
+
parent_directory=dir,
|
|
314
|
+
pattern=pattern
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# ---------------------------------------------------------------------------
|
|
319
|
+
# retrofit-manifest
|
|
320
|
+
# ---------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@process.command('retrofit-manifest')
|
|
324
|
+
@click.argument('path', type=click.Path(exists=True, file_okay=False, dir_okay=True))
|
|
325
|
+
@click.option('--force', is_flag=True,
|
|
326
|
+
help='Overwrite existing manifest.json files (default: skip).')
|
|
327
|
+
@click.option('--dry-run', is_flag=True,
|
|
328
|
+
help='Print what would be written without touching disk.')
|
|
329
|
+
def retrofit_manifest(path, force, dry_run):
|
|
330
|
+
"""Reconstruct mesokit-schema AcquisitionManifests for legacy sessions.
|
|
331
|
+
|
|
332
|
+
PATH is either a single session directory (``data/sub-X/ses-Y/``) or an
|
|
333
|
+
experiment root that contains many sessions under ``data/``. Hardware
|
|
334
|
+
calibration is not present in legacy acquisitions; producers get an
|
|
335
|
+
empty ``calibration`` dict. Frame-metadata sidecars (``*_frame_metadata.json``)
|
|
336
|
+
are attached to their tiffs. Multi-task sessions write one manifest
|
|
337
|
+
per task as ``manifest_task-<T>.json``.
|
|
338
|
+
"""
|
|
339
|
+
from mesofield.utils.retrofit import (
|
|
340
|
+
discover_sessions,
|
|
341
|
+
manifest_filename,
|
|
342
|
+
synthesize_manifests,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
sessions = list(discover_sessions(Path(path)))
|
|
346
|
+
if not sessions:
|
|
347
|
+
click.secho(f"No BIDS sessions found under {path}", fg="yellow")
|
|
348
|
+
raise SystemExit(1)
|
|
349
|
+
|
|
350
|
+
written = skipped = empty = 0
|
|
351
|
+
for session in sessions:
|
|
352
|
+
by_task = synthesize_manifests(session)
|
|
353
|
+
if not by_task:
|
|
354
|
+
click.echo(f"empty {session} (no producer files)")
|
|
355
|
+
empty += 1
|
|
356
|
+
continue
|
|
357
|
+
multi_task = len(by_task) > 1
|
|
358
|
+
for task, manifest in by_task.items():
|
|
359
|
+
out_path = session / manifest_filename(task, multi_task)
|
|
360
|
+
if out_path.exists() and not force:
|
|
361
|
+
click.echo(f"skip {out_path} (exists; use --force to overwrite)")
|
|
362
|
+
skipped += 1
|
|
363
|
+
continue
|
|
364
|
+
verb = "would write" if dry_run else "wrote"
|
|
365
|
+
click.echo(
|
|
366
|
+
f"{verb} {out_path} ({len(manifest.producers)} producers, task={task})"
|
|
367
|
+
)
|
|
368
|
+
if not dry_run:
|
|
369
|
+
manifest.write(out_path)
|
|
370
|
+
written += 1
|
|
371
|
+
|
|
372
|
+
summary = (
|
|
373
|
+
f"\n{'(dry-run) ' if dry_run else ''}"
|
|
374
|
+
f"sessions={len(sessions)} written={written} skipped={skipped} empty={empty}"
|
|
375
|
+
)
|
|
376
|
+
click.echo(summary)
|
mesofield/cli/rig.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""``mesofield rig`` — manage this machine's canonical hardware.yaml configs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from ._richhelp import RichGroup
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.group('rig', cls=RichGroup)
|
|
13
|
+
def rig():
|
|
14
|
+
"""Manage this machine's canonical hardware.yaml configurations.
|
|
15
|
+
|
|
16
|
+
A rig is a named hardware.yaml stored in this computer's OS config
|
|
17
|
+
directory. `mesofield init` copies a rig into each new experiment so
|
|
18
|
+
experiment folders stay self-contained.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@rig.command('list')
|
|
23
|
+
def rig_list():
|
|
24
|
+
"""List the canonical rigs registered on this machine."""
|
|
25
|
+
from mesofield.scaffold import rigs
|
|
26
|
+
|
|
27
|
+
names = rigs.list_rigs()
|
|
28
|
+
if not names:
|
|
29
|
+
click.echo(f"No rigs registered. Store: {rigs.rigs_dir()}")
|
|
30
|
+
click.echo("Add one with 'mesofield rig add' or 'mesofield rig new'.")
|
|
31
|
+
return
|
|
32
|
+
click.echo(f"Rigs in {rigs.rigs_dir()}:")
|
|
33
|
+
for name in names:
|
|
34
|
+
click.secho(f" {name}", fg="cyan")
|
|
35
|
+
try:
|
|
36
|
+
devices = rigs.rig_devices(name)
|
|
37
|
+
except Exception as exc:
|
|
38
|
+
click.secho(f" (could not read devices: {exc})", fg="red")
|
|
39
|
+
continue
|
|
40
|
+
if not devices:
|
|
41
|
+
click.echo(" (no devices declared)")
|
|
42
|
+
for dev_name, dev_type in devices:
|
|
43
|
+
click.echo(f" - {dev_name} (type: {dev_type})")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@rig.command('add')
|
|
47
|
+
@click.argument('name')
|
|
48
|
+
@click.argument('path', type=click.Path(exists=True, dir_okay=False))
|
|
49
|
+
@click.option('--force', is_flag=True, help='Overwrite an existing rig.')
|
|
50
|
+
def rig_add(name, path, force):
|
|
51
|
+
"""Copy an existing hardware.yaml at PATH into the store as NAME."""
|
|
52
|
+
from mesofield.scaffold import rigs
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
dst = rigs.add_rig(name, Path(path), force=force)
|
|
56
|
+
except FileExistsError as exc:
|
|
57
|
+
click.secho(str(exc), fg="red")
|
|
58
|
+
raise SystemExit(1)
|
|
59
|
+
except Exception as exc:
|
|
60
|
+
click.secho(f"Failed to add rig: {exc}", fg="red")
|
|
61
|
+
raise SystemExit(1)
|
|
62
|
+
click.secho(f"Registered rig {name!r} at {dst}", fg="green")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@rig.command('new')
|
|
66
|
+
@click.argument('name')
|
|
67
|
+
@click.option('--force', is_flag=True, help='Overwrite an existing rig.')
|
|
68
|
+
def rig_new(name, force):
|
|
69
|
+
"""Scaffold a blank fill-out hardware template in the store as NAME."""
|
|
70
|
+
from mesofield.scaffold import rigs
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
dst = rigs.new_rig(name, force=force)
|
|
74
|
+
except FileExistsError as exc:
|
|
75
|
+
click.secho(str(exc), fg="red")
|
|
76
|
+
raise SystemExit(1)
|
|
77
|
+
click.secho(f"Created rig template at {dst}", fg="green")
|
|
78
|
+
click.echo("Edit it to declare this machine's real devices, then use it")
|
|
79
|
+
click.echo(f"with 'mesofield init <dir> --rig {name}'.")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@rig.command('show')
|
|
83
|
+
@click.argument('name')
|
|
84
|
+
def rig_show(name):
|
|
85
|
+
"""Print the path and contents of rig NAME."""
|
|
86
|
+
from mesofield.scaffold import rigs
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
path = rigs._resolve_existing(name)
|
|
90
|
+
except FileNotFoundError as exc:
|
|
91
|
+
click.secho(str(exc), fg="red")
|
|
92
|
+
raise SystemExit(1)
|
|
93
|
+
click.echo(f"# {path}")
|
|
94
|
+
click.echo(path.read_text(encoding="utf-8"))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@rig.command('remove')
|
|
98
|
+
@click.argument('name')
|
|
99
|
+
def rig_remove(name):
|
|
100
|
+
"""Delete rig NAME from the store."""
|
|
101
|
+
from mesofield.scaffold import rigs
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
rigs.remove_rig(name)
|
|
105
|
+
except FileNotFoundError as exc:
|
|
106
|
+
click.secho(str(exc), fg="red")
|
|
107
|
+
raise SystemExit(1)
|
|
108
|
+
click.secho(f"Removed rig {name!r}", fg="green")
|