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
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Mesofield command-line interface.
|
|
2
|
+
|
|
3
|
+
The CLI is organized into a small set of day-to-day acquisition commands at
|
|
4
|
+
the top level, plus four command groups:
|
|
5
|
+
|
|
6
|
+
\b
|
|
7
|
+
mesofield launch | init | playback | viewer acquisition workflow
|
|
8
|
+
mesofield rig ... manage canonical hardware.yaml rigs
|
|
9
|
+
mesofield datakit ... build / explore / profile / inspect datasets
|
|
10
|
+
mesofield process ... batch-process & convert recorded data
|
|
11
|
+
mesofield tools ... setup, export, and diagnostic utilities
|
|
12
|
+
|
|
13
|
+
Run ``mesofield <cmd> --help`` (or ``mesofield <group> --help``) for details.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
|
|
20
|
+
import click
|
|
21
|
+
|
|
22
|
+
# Disable debugger warning about the use of frozen modules
|
|
23
|
+
os.environ.setdefault("PYDEVD_DISABLE_FILE_VALIDATION", "1")
|
|
24
|
+
|
|
25
|
+
from ._richhelp import RichGroup
|
|
26
|
+
from .acquire import init, launch, playback, viewer
|
|
27
|
+
from .datakit import datakit
|
|
28
|
+
from .process import process
|
|
29
|
+
from .rig import rig
|
|
30
|
+
from .tools import tools
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@click.group(cls=RichGroup)
|
|
34
|
+
def cli():
|
|
35
|
+
"""Mesofield Command Line Interface."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Set the free-standing acquisition commands (launch/init/playback/viewer)
|
|
39
|
+
# apart from the command groups in the help tree.
|
|
40
|
+
cli.loose_command_heading = "acquisition workflow"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# --- Top-level acquisition commands ---
|
|
44
|
+
cli.add_command(launch)
|
|
45
|
+
cli.add_command(init)
|
|
46
|
+
cli.add_command(playback)
|
|
47
|
+
cli.add_command(viewer)
|
|
48
|
+
|
|
49
|
+
# --- Command groups ---
|
|
50
|
+
cli.add_command(rig)
|
|
51
|
+
cli.add_command(datakit)
|
|
52
|
+
cli.add_command(process)
|
|
53
|
+
cli.add_command(tools)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
cli()
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Rich-rendered help for the Mesofield CLI.
|
|
2
|
+
|
|
3
|
+
:class:`RichGroup` is a drop-in :class:`click.Group` that replaces the plain
|
|
4
|
+
"Commands:" listing with a colored ``rich`` tree, so the command groupings
|
|
5
|
+
are visible at a glance. Everything else (usage line, description, options)
|
|
6
|
+
is left to Click's native formatter.
|
|
7
|
+
|
|
8
|
+
Subgroups render their own commands as a flat tree; the root group nests one
|
|
9
|
+
level deep so ``mesofield --help`` shows the whole map. Degrades gracefully
|
|
10
|
+
to Click's default rendering if ``rich`` is unavailable or output is being
|
|
11
|
+
captured oddly.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import io
|
|
17
|
+
import shutil
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
import click
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _short(cmd: click.Command) -> str:
|
|
24
|
+
return cmd.get_short_help_str(limit=70)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _add_leaves(node, items) -> None:
|
|
28
|
+
"""Add ``(name, command)`` leaves to ``node``, name-padded for alignment."""
|
|
29
|
+
width = max((len(name) for name, _ in items), default=0)
|
|
30
|
+
for name, cmd in items:
|
|
31
|
+
node.add(f"[green]{name.ljust(width)}[/green] [dim]{_short(cmd)}[/dim]")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RichGroup(click.Group):
|
|
35
|
+
"""Click group that lists its subcommands as a ``rich`` tree.
|
|
36
|
+
|
|
37
|
+
Set :attr:`loose_command_heading` on an instance to bucket non-group
|
|
38
|
+
commands under a labeled node (used by the root group to set the
|
|
39
|
+
free-standing acquisition commands apart from the command groups).
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
loose_command_heading: str | None = None
|
|
43
|
+
|
|
44
|
+
def format_commands(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
|
|
45
|
+
commands = []
|
|
46
|
+
for name in self.list_commands(ctx):
|
|
47
|
+
cmd = self.get_command(ctx, name)
|
|
48
|
+
if cmd is None or getattr(cmd, "hidden", False):
|
|
49
|
+
continue
|
|
50
|
+
commands.append((name, cmd))
|
|
51
|
+
if not commands:
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
rendered = self._render_tree(ctx, commands)
|
|
56
|
+
except Exception:
|
|
57
|
+
super().format_commands(ctx, formatter)
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
formatter.write("\n")
|
|
61
|
+
formatter.write(rendered)
|
|
62
|
+
formatter.write("\n")
|
|
63
|
+
|
|
64
|
+
def _render_tree(self, ctx: click.Context, commands) -> str:
|
|
65
|
+
from rich.console import Console
|
|
66
|
+
from rich.tree import Tree
|
|
67
|
+
|
|
68
|
+
groups = [(n, c) for n, c in commands if isinstance(c, click.Group)]
|
|
69
|
+
leaves = [(n, c) for n, c in commands if not isinstance(c, click.Group)]
|
|
70
|
+
|
|
71
|
+
tree = Tree(f"[bold]{ctx.command_path}[/bold]", guide_style="dim")
|
|
72
|
+
|
|
73
|
+
if leaves:
|
|
74
|
+
if groups and self.loose_command_heading:
|
|
75
|
+
bucket = tree.add(f"[bold blue]{self.loose_command_heading}[/bold blue]")
|
|
76
|
+
_add_leaves(bucket, leaves)
|
|
77
|
+
else:
|
|
78
|
+
_add_leaves(tree, leaves)
|
|
79
|
+
|
|
80
|
+
for name, group in groups:
|
|
81
|
+
branch = tree.add(f"[bold cyan]{name}[/bold cyan] [dim]{_short(group)}[/dim]")
|
|
82
|
+
sub = [
|
|
83
|
+
(sn, sc)
|
|
84
|
+
for sn in group.list_commands(ctx)
|
|
85
|
+
if (sc := group.get_command(ctx, sn)) and not getattr(sc, "hidden", False)
|
|
86
|
+
]
|
|
87
|
+
if sub:
|
|
88
|
+
_add_leaves(branch, sub)
|
|
89
|
+
|
|
90
|
+
is_tty = bool(getattr(sys.stdout, "isatty", lambda: False)())
|
|
91
|
+
width = shutil.get_terminal_size((100, 24)).columns
|
|
92
|
+
buf = io.StringIO()
|
|
93
|
+
console = Console(
|
|
94
|
+
file=buf,
|
|
95
|
+
force_terminal=is_tty,
|
|
96
|
+
no_color=not is_tty,
|
|
97
|
+
width=max(40, width),
|
|
98
|
+
)
|
|
99
|
+
console.print(tree)
|
|
100
|
+
return buf.getvalue().rstrip("\n")
|
mesofield/cli/acquire.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Acquisition-workflow commands: launch, init, playback, viewer.
|
|
2
|
+
|
|
3
|
+
These are the top-level day-to-day entry points and are attached directly to
|
|
4
|
+
the root ``mesofield`` group (no subgroup prefix).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# launch
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@click.command()
|
|
21
|
+
@click.argument('config', type=click.Path(), required=False, default=None)
|
|
22
|
+
def launch(config):
|
|
23
|
+
"""Launch the Mesofield acquisition interface.
|
|
24
|
+
|
|
25
|
+
CONFIG is an optional path to an experiment JSON config file.
|
|
26
|
+
When omitted, Mesofield opens in a default state and the
|
|
27
|
+
Configuration Wizard is shown for hot-loading configs.
|
|
28
|
+
"""
|
|
29
|
+
import time
|
|
30
|
+
|
|
31
|
+
from PyQt6.QtWidgets import QApplication, QSplashScreen
|
|
32
|
+
from PyQt6.QtGui import QPixmap, QPainter, QFont
|
|
33
|
+
from PyQt6.QtCore import Qt
|
|
34
|
+
from PyQt6.QtGui import QColor, QRadialGradient
|
|
35
|
+
from PyQt6.QtGui import QIcon
|
|
36
|
+
|
|
37
|
+
from mesofield.gui.maingui import MainWindow
|
|
38
|
+
from mesofield.gui import theme
|
|
39
|
+
from mesofield.base import Procedure, load_procedure_from_config
|
|
40
|
+
|
|
41
|
+
app = QApplication([])
|
|
42
|
+
theme.apply_theme(app)
|
|
43
|
+
window_icon = QIcon(os.path.join(os.path.dirname(os.path.dirname(__file__)), "gui", "Mesofield_icon.png"))
|
|
44
|
+
app.setWindowIcon(window_icon)
|
|
45
|
+
|
|
46
|
+
# ====================== Splash Screen with ASCII Art ========================= """
|
|
47
|
+
|
|
48
|
+
# Font: Sub-Zero; character width: Full, Character Height: Fitted
|
|
49
|
+
# https://patorjk.com/software/taag/#p=display&h=0&v=1&f=Sub-Zero&t=Mesofield
|
|
50
|
+
ascii = r"""
|
|
51
|
+
__ __ ______ ______ ______ ______ __ ____ __ _____
|
|
52
|
+
/\ "-./ \ /\ ___\ /\ ___\ /\ __ \ /\ ___\ /\ \ /\ ___\ /\ \ /\ __-.
|
|
53
|
+
\ \ \-./\ \ \ \ __\ \ \___ \ \ \ \/\ \ \ \ __\ \ \ \ \ \ __\ \ \ \____ \ \ \/\ \
|
|
54
|
+
\ \_\ \ \_\ \ \_____\ \/\_____\ \ \_____\ \ \_\ \ \_\ \ \_____\ \ \_____\ \ \____-
|
|
55
|
+
\/_/ \/_/ \/_____/ \/_____/ \/_____/ \/_/ \/_/ \/_____/ \/_____/ \/____/
|
|
56
|
+
|
|
57
|
+
------------------------- Mesofield Acquisition Interface ---------------------------------
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
# Create a transparent pixmap
|
|
61
|
+
pixmap = QPixmap(1100, 210)
|
|
62
|
+
pixmap.fill(Qt.GlobalColor.transparent)
|
|
63
|
+
|
|
64
|
+
# Build a radial gradient: dark center that fades out at the edges
|
|
65
|
+
center = pixmap.rect().center()
|
|
66
|
+
radius = max(pixmap.width(), pixmap.height()) / 2
|
|
67
|
+
gradient = QRadialGradient(center.x(), center.y(), radius)
|
|
68
|
+
gradient.setColorAt(0.0, QColor(1, 25, 5)) # solid dark center
|
|
69
|
+
gradient.setColorAt(0.7, QColor(10, 15, 0, 200)) # keep dark until 80%
|
|
70
|
+
gradient.setColorAt(1.0, QColor(0, 0, 0, 0)) # fully transparent edges
|
|
71
|
+
|
|
72
|
+
painter = QPainter(pixmap)
|
|
73
|
+
# Fill entire pixmap with the gradient block
|
|
74
|
+
painter.fillRect(pixmap.rect(), gradient)
|
|
75
|
+
|
|
76
|
+
# Draw the ASCII art on top
|
|
77
|
+
painter.setPen(Qt.GlobalColor.green)
|
|
78
|
+
painter.setFont(QFont("Courier", 12))
|
|
79
|
+
painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, ascii)
|
|
80
|
+
painter.end()
|
|
81
|
+
|
|
82
|
+
splash = QSplashScreen(pixmap)
|
|
83
|
+
|
|
84
|
+
splash.show()
|
|
85
|
+
app.processEvents() # ensure the splash appears
|
|
86
|
+
|
|
87
|
+
time.sleep(0.5) # give the splash screen a moment to show :)
|
|
88
|
+
procedure = load_procedure_from_config(config) if config else Procedure(config)
|
|
89
|
+
|
|
90
|
+
mesofield = MainWindow(procedure)
|
|
91
|
+
mesofield.show()
|
|
92
|
+
splash.finish(mesofield)
|
|
93
|
+
app.exec()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# init
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _resolve_init_hardware(rig, hardware):
|
|
102
|
+
"""Resolve the ``hardware`` argument for :func:`scaffold_experiment`.
|
|
103
|
+
|
|
104
|
+
Returns a ``Path`` (copy a canonical rig file) or ``"dev"`` / ``"blank"``.
|
|
105
|
+
``--hardware`` wins over ``--rig``; with neither, an interactive picker
|
|
106
|
+
over the rig store plus the ``dev`` / ``blank`` built-ins is shown.
|
|
107
|
+
"""
|
|
108
|
+
from mesofield.scaffold import rigs
|
|
109
|
+
|
|
110
|
+
if hardware:
|
|
111
|
+
return Path(hardware)
|
|
112
|
+
if rig:
|
|
113
|
+
if rig in ("dev", "blank"):
|
|
114
|
+
return rig
|
|
115
|
+
try:
|
|
116
|
+
return rigs._resolve_existing(rig)
|
|
117
|
+
except FileNotFoundError as exc:
|
|
118
|
+
click.secho(str(exc), fg="red")
|
|
119
|
+
raise SystemExit(1)
|
|
120
|
+
|
|
121
|
+
choices = rigs.list_rigs() + ["dev", "blank"]
|
|
122
|
+
click.echo("Select a hardware configuration for this experiment:")
|
|
123
|
+
for name in rigs.list_rigs():
|
|
124
|
+
click.echo(f" {name} (canonical rig)")
|
|
125
|
+
click.echo(" dev (mock devices -- runs without hardware)")
|
|
126
|
+
click.echo(" blank (fill-out template)")
|
|
127
|
+
picked = click.prompt(
|
|
128
|
+
"Rig", type=click.Choice(choices), default="blank", show_choices=False
|
|
129
|
+
)
|
|
130
|
+
if picked in ("dev", "blank"):
|
|
131
|
+
return picked
|
|
132
|
+
return rigs.rig_path(picked)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@click.command('init')
|
|
136
|
+
@click.argument('directory', type=click.Path())
|
|
137
|
+
@click.option('--name', default=None,
|
|
138
|
+
help='Experiment protocol name (default: directory basename uppercased).')
|
|
139
|
+
@click.option('--force', is_flag=True,
|
|
140
|
+
help='Overwrite an existing non-empty directory.')
|
|
141
|
+
@click.option('--rig', default=None,
|
|
142
|
+
help="Canonical rig to copy hardware.yaml from "
|
|
143
|
+
"(or 'dev'/'blank'). Skips the interactive picker.")
|
|
144
|
+
@click.option('--hardware', default=None, type=click.Path(exists=True, dir_okay=False),
|
|
145
|
+
help='Explicit hardware.yaml file to copy in (overrides --rig).')
|
|
146
|
+
def init(directory, name, force, rig, hardware):
|
|
147
|
+
"""Scaffold a new mesofield experiment in DIRECTORY.
|
|
148
|
+
|
|
149
|
+
Generates `experiment.json`, `hardware.yaml`, `procedure.py`, and a
|
|
150
|
+
`devices/` subdirectory with an annotated thermal-sensor example.
|
|
151
|
+
|
|
152
|
+
The `hardware.yaml` is chosen interactively: a canonical rig from this
|
|
153
|
+
machine's rig store (see `mesofield rig`), `dev` (mock devices, runs
|
|
154
|
+
without hardware), or `blank` (a fill-out template). Use --rig/--hardware
|
|
155
|
+
to skip the prompt.
|
|
156
|
+
"""
|
|
157
|
+
from mesofield.scaffold import scaffold_experiment
|
|
158
|
+
|
|
159
|
+
hardware_choice = _resolve_init_hardware(rig, hardware)
|
|
160
|
+
try:
|
|
161
|
+
out = scaffold_experiment(
|
|
162
|
+
Path(directory), name=name, force=force, hardware=hardware_choice,
|
|
163
|
+
)
|
|
164
|
+
except FileExistsError as exc:
|
|
165
|
+
click.secho(str(exc), fg="red")
|
|
166
|
+
raise SystemExit(1)
|
|
167
|
+
click.secho(f"Scaffolded experiment at {out}", fg="green")
|
|
168
|
+
click.echo("Next steps:")
|
|
169
|
+
click.echo(f" 1. cd {out}")
|
|
170
|
+
if hardware_choice == "dev":
|
|
171
|
+
click.echo(" 2. python procedure.py # runs the mock acquisition")
|
|
172
|
+
click.echo(f" 3. open data/sub-SUBJ01/ses-01/manifest.json")
|
|
173
|
+
else:
|
|
174
|
+
click.echo(" 2. review hardware.yaml # confirm it matches this rig")
|
|
175
|
+
click.echo(" 3. python procedure.py # runs the acquisition")
|
|
176
|
+
click.echo("Read the generated README.md for customization tips.")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
# playback
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@click.command()
|
|
185
|
+
@click.argument('experiment_dir')
|
|
186
|
+
@click.option('--speed', default=1.0, show_default=True, help='Playback speed multiplier')
|
|
187
|
+
@click.option('--loop/--no-loop', default=False, show_default=True, help='Loop playback when finished')
|
|
188
|
+
def playback(experiment_dir: str, speed: float, loop: bool):
|
|
189
|
+
"""Launch Mesofield in playback mode for a recorded experiment."""
|
|
190
|
+
|
|
191
|
+
from mesofield.playback import (
|
|
192
|
+
discover_playback_context,
|
|
193
|
+
discover_playback_sessions,
|
|
194
|
+
launch_playback_app,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
sessions = discover_playback_sessions(Path(experiment_dir))
|
|
198
|
+
context = discover_playback_context(Path(experiment_dir), speed=speed, loop=loop)
|
|
199
|
+
launch_playback_app(context, browser_sessions=sessions)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
# viewer
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@click.command()
|
|
208
|
+
@click.argument('config', type=click.Path(exists=True, dir_okay=False), required=False, default=None)
|
|
209
|
+
def viewer(config):
|
|
210
|
+
"""Launch the standalone TIFF ROI viewer.
|
|
211
|
+
|
|
212
|
+
CONFIG is an optional path to an ``experiment.json``. When provided, the
|
|
213
|
+
viewer's "Open TIFF…" dialog opens in that experiment's data directory
|
|
214
|
+
(``<experiment>/data`` if it exists, otherwise the JSON's parent dir).
|
|
215
|
+
Hardware is NOT initialized — this is a read-only inspection tool.
|
|
216
|
+
"""
|
|
217
|
+
import json
|
|
218
|
+
from PyQt6.QtWidgets import QApplication
|
|
219
|
+
from PyQt6.QtGui import QIcon
|
|
220
|
+
from mesofield.data.proc.analysis import TiffViewer
|
|
221
|
+
|
|
222
|
+
initial_dir = ""
|
|
223
|
+
if config:
|
|
224
|
+
cfg_path = Path(config).resolve()
|
|
225
|
+
try:
|
|
226
|
+
with open(cfg_path) as f:
|
|
227
|
+
data = json.load(f)
|
|
228
|
+
except Exception:
|
|
229
|
+
data = {}
|
|
230
|
+
# Prefer an explicit save_dir from the config; fall back to
|
|
231
|
+
# <experiment>/data, then the JSON's parent directory.
|
|
232
|
+
save_dir = data.get('save_dir') if isinstance(data, dict) else None
|
|
233
|
+
candidates = []
|
|
234
|
+
if save_dir:
|
|
235
|
+
candidates.append(Path(save_dir))
|
|
236
|
+
candidates.append(Path(save_dir) / 'data')
|
|
237
|
+
candidates.append(cfg_path.parent / 'data')
|
|
238
|
+
candidates.append(cfg_path.parent)
|
|
239
|
+
for c in candidates:
|
|
240
|
+
if c and c.exists():
|
|
241
|
+
initial_dir = str(c)
|
|
242
|
+
break
|
|
243
|
+
|
|
244
|
+
app = QApplication([])
|
|
245
|
+
from mesofield.gui import theme
|
|
246
|
+
theme.apply_theme(app)
|
|
247
|
+
icon_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "gui", "Mesofield_icon.png")
|
|
248
|
+
if os.path.exists(icon_path):
|
|
249
|
+
app.setWindowIcon(QIcon(icon_path))
|
|
250
|
+
|
|
251
|
+
win = TiffViewer(initial_dir=initial_dir or None)
|
|
252
|
+
win.resize(1100, 800)
|
|
253
|
+
win.show()
|
|
254
|
+
app.exec()
|
mesofield/cli/datakit.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""``mesofield datakit`` — build, explore, profile, and inspect datasets.
|
|
2
|
+
|
|
3
|
+
A thin Click surface over :mod:`mesofield.datakit`. Each command maps to a
|
|
4
|
+
public datakit API:
|
|
5
|
+
|
|
6
|
+
\b
|
|
7
|
+
build -> Dataset.from_directory(...).save(...)
|
|
8
|
+
explore -> datakit.explore(target)
|
|
9
|
+
profile -> datakit.profile_materialized(pkl)
|
|
10
|
+
inspect -> datakit.inspect_sources(Dataset)
|
|
11
|
+
shell -> datakit.open_shell(target)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import click
|
|
19
|
+
|
|
20
|
+
from ._richhelp import RichGroup
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@click.group('datakit', cls=RichGroup)
|
|
24
|
+
def datakit():
|
|
25
|
+
"""Build, explore, profile, and inspect materialized datasets."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# build
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@datakit.command('build')
|
|
34
|
+
@click.argument('input_path', type=click.Path(exists=True, file_okay=False, dir_okay=True))
|
|
35
|
+
@click.option('--output', '-o', 'output_path', type=click.Path(), default=None,
|
|
36
|
+
help='Output file path (default: <experiment>/processed/YYMMDD_dataset_mvp.<fmt>)')
|
|
37
|
+
@click.option('--tags', '-t', multiple=True, default=None,
|
|
38
|
+
help='Source tags to include (repeatable; default: all configured tags)')
|
|
39
|
+
@click.option('--format', '-f', 'fmt', type=click.Choice(['h5', 'parquet', 'csv', 'pickle']),
|
|
40
|
+
default='h5', show_default=True, help='Output format')
|
|
41
|
+
@click.option('--progress', is_flag=True, help='Show a progress bar during materialization')
|
|
42
|
+
@click.option('--shell', 'open_shell_after', is_flag=True,
|
|
43
|
+
help='Drop into an IPython session after building')
|
|
44
|
+
def build(input_path, output_path, tags, fmt, progress, open_shell_after):
|
|
45
|
+
"""Build a materialized dataset from an experiment directory.
|
|
46
|
+
|
|
47
|
+
Discovers the BIDS hierarchy under INPUT_PATH, loads all registered
|
|
48
|
+
data sources, and writes a single dataset file.
|
|
49
|
+
"""
|
|
50
|
+
from mesofield.datakit import Dataset
|
|
51
|
+
|
|
52
|
+
ds = Dataset.from_directory(
|
|
53
|
+
Path(input_path),
|
|
54
|
+
sources=list(tags) if tags else None,
|
|
55
|
+
)
|
|
56
|
+
if output_path is None:
|
|
57
|
+
from datetime import datetime
|
|
58
|
+
stem = datetime.now().strftime("%y%m%d") + "_dataset_mvp"
|
|
59
|
+
ext = {"h5": ".h5", "parquet": ".parquet", "csv": ".csv", "pickle": ".pkl"}[fmt]
|
|
60
|
+
output_path = Path(input_path) / "processed" / (stem + ext)
|
|
61
|
+
result_path = ds.save(
|
|
62
|
+
Path(output_path),
|
|
63
|
+
format={"h5": "hdf5", "parquet": "parquet", "csv": "csv", "pickle": "pickle"}[fmt],
|
|
64
|
+
strict=True,
|
|
65
|
+
progress=progress,
|
|
66
|
+
)
|
|
67
|
+
click.secho(f"Dataset saved to {result_path}", fg="green")
|
|
68
|
+
if open_shell_after:
|
|
69
|
+
from mesofield.datakit import open_shell
|
|
70
|
+
open_shell(result_path)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# explore
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@datakit.command('explore')
|
|
79
|
+
@click.argument('target', type=click.Path(exists=True))
|
|
80
|
+
@click.option('--hdf-key', default='dataset', show_default=True,
|
|
81
|
+
help='HDF5 key to read when TARGET is an .h5/.hdf5 file.')
|
|
82
|
+
def explore(target, hdf_key):
|
|
83
|
+
"""Print a structural report for a dataset.
|
|
84
|
+
|
|
85
|
+
TARGET may be an experiment directory (pre-load inventory report), or a
|
|
86
|
+
materialized ``.pkl`` / ``.h5`` file (post-load DataFrame report).
|
|
87
|
+
"""
|
|
88
|
+
from mesofield.datakit import explore as explore_fn
|
|
89
|
+
|
|
90
|
+
explore_fn(Path(target), print_output=True, hdf_key=hdf_key)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# profile
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@datakit.command('profile')
|
|
99
|
+
@click.argument('pickle_path', type=click.Path(exists=True, dir_okay=False))
|
|
100
|
+
@click.option('--json', 'json_path', type=click.Path(), default=None,
|
|
101
|
+
help='Write a JSON report to this path.')
|
|
102
|
+
@click.option('--verbose', is_flag=True,
|
|
103
|
+
help='Print the detailed per-column breakdown instead of just the summary.')
|
|
104
|
+
@click.option('--top-cells', default=20, show_default=True,
|
|
105
|
+
help='Number of largest individual cells to include.')
|
|
106
|
+
def profile(pickle_path, json_path, verbose, top_cells):
|
|
107
|
+
"""Profile the memory / storage footprint of a materialized dataset.
|
|
108
|
+
|
|
109
|
+
PICKLE_PATH is a materialized ``.pkl`` file produced by ``datakit build``.
|
|
110
|
+
"""
|
|
111
|
+
from mesofield.datakit import profile_materialized
|
|
112
|
+
|
|
113
|
+
report = profile_materialized(Path(pickle_path), top_n_cells=top_cells)
|
|
114
|
+
click.echo(report.verbose(top_n_cells=top_cells) if verbose else report.summary())
|
|
115
|
+
if json_path:
|
|
116
|
+
out = report.to_json(json_path)
|
|
117
|
+
click.secho(f"\nWrote JSON report to: {out}", fg="green")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# inspect
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@datakit.command('inspect')
|
|
126
|
+
@click.argument('input_path', type=click.Path(exists=True, file_okay=False, dir_okay=True))
|
|
127
|
+
@click.option('--tags', '-t', multiple=True, default=None,
|
|
128
|
+
help='Source tags to report (repeatable; default: all discovered tags)')
|
|
129
|
+
def inspect(input_path, tags):
|
|
130
|
+
"""Print per-source coverage for an experiment directory.
|
|
131
|
+
|
|
132
|
+
Discovers the BIDS hierarchy under INPUT_PATH and reports how many rows
|
|
133
|
+
carry each registered source (present / total / missing / coverage),
|
|
134
|
+
without loading any file payloads.
|
|
135
|
+
"""
|
|
136
|
+
from mesofield.datakit import Dataset, inspect_sources
|
|
137
|
+
|
|
138
|
+
ds = Dataset.from_directory(Path(input_path))
|
|
139
|
+
summary = inspect_sources(ds, sources=list(tags) if tags else None)
|
|
140
|
+
if summary.empty:
|
|
141
|
+
click.secho("No registered sources found in the inventory.", fg="yellow")
|
|
142
|
+
return
|
|
143
|
+
click.echo(summary.to_string())
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
# shell
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@datakit.command('shell')
|
|
152
|
+
@click.argument('target', type=click.Path(exists=True), required=False, default=None)
|
|
153
|
+
@click.option('--hdf-key', default='dataset', show_default=True,
|
|
154
|
+
help='HDF5 key to read when TARGET is an .h5/.hdf5 file.')
|
|
155
|
+
def shell(target, hdf_key):
|
|
156
|
+
"""Open an IPython shell pre-loaded with a datakit object.
|
|
157
|
+
|
|
158
|
+
TARGET may be an experiment directory (seeds ``dataset`` + ``inventory``)
|
|
159
|
+
or a materialized ``.pkl`` / ``.h5`` file (seeds ``df``). Omit TARGET for
|
|
160
|
+
a bare datakit shell. Each session also exposes ``datakit``, ``explore``,
|
|
161
|
+
and a pre-computed ``report``.
|
|
162
|
+
"""
|
|
163
|
+
from mesofield.datakit import open_shell
|
|
164
|
+
|
|
165
|
+
open_shell(Path(target) if target else None, hdf_key=hdf_key)
|