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,194 @@
|
|
|
1
|
+
"""Mesomap trace loader.
|
|
2
|
+
|
|
3
|
+
Parses wide-field mesomap traces exported as ``*_mesoscope.ome_traces.csv``
|
|
4
|
+
files. The accompanying ``.mask.npy`` and ``.regions.csv`` files are treated
|
|
5
|
+
as session-level metadata so they can be stored once per subject/session
|
|
6
|
+
instead of repeating for every task.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Iterable, Tuple
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pandas as pd
|
|
16
|
+
|
|
17
|
+
from mesofield.datakit.config import settings
|
|
18
|
+
from mesofield.datakit.sources.register import LoadContext, TimeseriesSource
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MesoMapSource(TimeseriesSource):
|
|
22
|
+
"""Load mesomap traces and attach optional mask/region metadata."""
|
|
23
|
+
|
|
24
|
+
tag = "mesomap"
|
|
25
|
+
patterns = ("**/*_mesoscope.ome_traces.csv",)
|
|
26
|
+
camera_tag = "meso_metadata"
|
|
27
|
+
flatten_payload = True
|
|
28
|
+
requires = ("dataqueue",)
|
|
29
|
+
|
|
30
|
+
mask_suffix = ".mask.npy"
|
|
31
|
+
regions_suffix = ".regions.csv"
|
|
32
|
+
|
|
33
|
+
frame_column = "frame"
|
|
34
|
+
time_basis = "mesomap_frame"
|
|
35
|
+
|
|
36
|
+
def build_timeseries(
|
|
37
|
+
self,
|
|
38
|
+
path: Path,
|
|
39
|
+
*,
|
|
40
|
+
context: LoadContext | None = None,
|
|
41
|
+
) -> tuple[np.ndarray, pd.DataFrame, dict]:
|
|
42
|
+
trace_df = pd.read_csv(path)
|
|
43
|
+
|
|
44
|
+
if self.frame_column not in trace_df.columns:
|
|
45
|
+
raise ValueError(f"Mesomap traces missing required column '{self.frame_column}' in {path}")
|
|
46
|
+
|
|
47
|
+
trace_df = trace_df.copy()
|
|
48
|
+
trace_df.sort_values(by=self.frame_column, inplace=True)
|
|
49
|
+
|
|
50
|
+
trace_columns = [col for col in trace_df.columns if col != self.frame_column]
|
|
51
|
+
timeline = trace_df[self.frame_column].to_numpy(dtype=np.float64)
|
|
52
|
+
timeline_meta = {"time_basis": self.time_basis}
|
|
53
|
+
aligned = self._aligned_timeline(context, len(trace_df))
|
|
54
|
+
if aligned is not None:
|
|
55
|
+
timeline, timeline_meta = aligned
|
|
56
|
+
|
|
57
|
+
payload_df = trace_df.loc[:, trace_columns].copy()
|
|
58
|
+
payload_df["time_elapsed_s"] = timeline
|
|
59
|
+
|
|
60
|
+
regions_df, roi_to_mask, missing_regions = self._load_regions(path, trace_columns)
|
|
61
|
+
mask_info = self._load_mask(path)
|
|
62
|
+
|
|
63
|
+
meta = {
|
|
64
|
+
"source_file": str(path),
|
|
65
|
+
"n_frames": len(trace_df),
|
|
66
|
+
"n_rois": len(trace_columns),
|
|
67
|
+
"trace_columns": trace_columns,
|
|
68
|
+
"frame_column": self.frame_column,
|
|
69
|
+
"frame_index": trace_df[self.frame_column].to_numpy(dtype=np.float64),
|
|
70
|
+
"roi_to_mask_label": roi_to_mask,
|
|
71
|
+
"roi_missing_region_metadata": missing_regions,
|
|
72
|
+
**timeline_meta,
|
|
73
|
+
"scope": settings.dataset.session_scope,
|
|
74
|
+
**mask_info,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if regions_df is not None:
|
|
78
|
+
meta.update(
|
|
79
|
+
{
|
|
80
|
+
"regions_path": str(self._regions_path(path)),
|
|
81
|
+
"region_count": len(regions_df),
|
|
82
|
+
"regions_table": regions_df,
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return timeline, payload_df, meta
|
|
87
|
+
|
|
88
|
+
def _aligned_timeline(
|
|
89
|
+
self,
|
|
90
|
+
context: LoadContext | None,
|
|
91
|
+
n_frames: int,
|
|
92
|
+
) -> tuple[np.ndarray, dict] | None:
|
|
93
|
+
if context is None:
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
if context.dataqueue_frame is not None:
|
|
97
|
+
aligned = self._aligned_from_frame(context.dataqueue_frame, n_frames)
|
|
98
|
+
if aligned is not None:
|
|
99
|
+
return aligned
|
|
100
|
+
|
|
101
|
+
if context.master_timeline is not None and context.master_timeline.shape[0] == n_frames:
|
|
102
|
+
return context.master_timeline, {
|
|
103
|
+
"time_basis": "master_timeline",
|
|
104
|
+
"dataqueue_alignment": "direct",
|
|
105
|
+
"dataqueue_anchors": int(n_frames),
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
def _aligned_from_frame(
|
|
111
|
+
self,
|
|
112
|
+
frame: pd.DataFrame,
|
|
113
|
+
n_frames: int,
|
|
114
|
+
) -> tuple[np.ndarray, dict] | None:
|
|
115
|
+
device_col = "device_id"
|
|
116
|
+
queue_col = settings.timeline.queue_column
|
|
117
|
+
if device_col not in frame.columns or queue_col not in frame.columns:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
device_series = frame[device_col].astype(str)
|
|
121
|
+
mask = pd.Series(False, index=frame.index)
|
|
122
|
+
for pattern in settings.timeline.window_device_patterns:
|
|
123
|
+
mask |= device_series.str.contains(pattern, case=False, na=False, regex=False)
|
|
124
|
+
|
|
125
|
+
if not mask.any():
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
times = pd.to_numeric(frame.loc[mask, queue_col], errors="coerce").dropna().to_numpy(dtype=np.float64)
|
|
129
|
+
if times.size == 0:
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
times = times - times[0]
|
|
133
|
+
n_anchors = int(times.size)
|
|
134
|
+
if n_anchors == n_frames:
|
|
135
|
+
return times, {
|
|
136
|
+
"time_basis": "dataqueue",
|
|
137
|
+
"dataqueue_alignment": "direct",
|
|
138
|
+
"dataqueue_anchors": n_anchors,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
anchor_index = np.arange(n_anchors, dtype=np.float64)
|
|
142
|
+
frame_index = np.linspace(0.0, float(n_anchors - 1), num=n_frames, dtype=np.float64)
|
|
143
|
+
aligned = np.interp(frame_index, anchor_index, times)
|
|
144
|
+
return aligned, {
|
|
145
|
+
"time_basis": "dataqueue",
|
|
146
|
+
"dataqueue_alignment": "resampled",
|
|
147
|
+
"dataqueue_anchors": n_anchors,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
def _mask_path(self, path: Path) -> Path:
|
|
151
|
+
return path.with_suffix(self.mask_suffix)
|
|
152
|
+
|
|
153
|
+
def _regions_path(self, path: Path) -> Path:
|
|
154
|
+
return path.with_suffix(self.regions_suffix)
|
|
155
|
+
|
|
156
|
+
def _load_mask(self, path: Path) -> dict:
|
|
157
|
+
mask_path = self._mask_path(path)
|
|
158
|
+
if not mask_path.exists():
|
|
159
|
+
return {
|
|
160
|
+
"mask_path": str(mask_path),
|
|
161
|
+
"mask_missing": True,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
mask_array = np.load(mask_path)
|
|
165
|
+
unique_labels = np.unique(mask_array)
|
|
166
|
+
return {
|
|
167
|
+
"mask_path": str(mask_path),
|
|
168
|
+
"mask_shape": tuple(int(dim) for dim in mask_array.shape),
|
|
169
|
+
"mask_dtype": str(mask_array.dtype),
|
|
170
|
+
"mask_labels": unique_labels.tolist(),
|
|
171
|
+
"mask_missing": False,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
def _load_regions(
|
|
175
|
+
self, path: Path, trace_columns: Iterable[str]
|
|
176
|
+
) -> Tuple[pd.DataFrame | None, dict[str, int], list[str]]:
|
|
177
|
+
regions_path = self._regions_path(path)
|
|
178
|
+
if not regions_path.exists():
|
|
179
|
+
return None, {}, list(trace_columns)
|
|
180
|
+
|
|
181
|
+
regions_df = pd.read_csv(regions_path)
|
|
182
|
+
|
|
183
|
+
required_cols = {"acronym", "mask_label"}
|
|
184
|
+
if not required_cols.issubset(regions_df.columns):
|
|
185
|
+
missing = ", ".join(sorted(required_cols - set(regions_df.columns)))
|
|
186
|
+
raise ValueError(f"Regions file {regions_path} missing required columns: {missing}")
|
|
187
|
+
|
|
188
|
+
region_lookup = regions_df.set_index("acronym")["mask_label"]
|
|
189
|
+
roi_to_mask = {
|
|
190
|
+
roi: int(region_lookup[roi]) for roi in trace_columns if roi in region_lookup
|
|
191
|
+
}
|
|
192
|
+
missing_regions = sorted(set(trace_columns) - set(region_lookup.index))
|
|
193
|
+
|
|
194
|
+
return regions_df, roi_to_mask, missing_regions
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Mesoscope mean fluorescence analysis data source.
|
|
2
|
+
|
|
3
|
+
This module translates CSV exports from the mesoscope processing pipeline into
|
|
4
|
+
time-indexed :class:`~datakit.datamodel.LoadedStream` objects. Each record in the
|
|
5
|
+
table represents one slice/ROI with derived dF/F traces, ready for alignment
|
|
6
|
+
against other experiment timelines.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import pandas as pd
|
|
10
|
+
import numpy as np
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from mesofield.datakit.sources.register import LoadContext, TimeseriesSource, document
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MesoMeanSource(TimeseriesSource):
|
|
17
|
+
"""Load mean fluorescence CSV.
|
|
18
|
+
|
|
19
|
+
The CSV is expected to contain at least ``Slice`` and ``Mean`` columns.
|
|
20
|
+
"""
|
|
21
|
+
tag = "meso"
|
|
22
|
+
patterns = ("**/*_meso-mean-trace.csv",)
|
|
23
|
+
camera_tag = "meso_metadata" # Bind to meso camera
|
|
24
|
+
required_columns = ("Mean")
|
|
25
|
+
assumed_frame_rate_hz = 50.0
|
|
26
|
+
normalization_baseline = "min"
|
|
27
|
+
|
|
28
|
+
def build_timeseries(
|
|
29
|
+
self,
|
|
30
|
+
path: Path,
|
|
31
|
+
*,
|
|
32
|
+
context: LoadContext | None = None,
|
|
33
|
+
) -> tuple[np.ndarray, pd.DataFrame, dict]:
|
|
34
|
+
"""Read a mesoscope CSV and return a normalized table."""
|
|
35
|
+
df = pd.read_csv(path)
|
|
36
|
+
#remove the "Slice" column
|
|
37
|
+
df = df.drop(columns=["Slice"])
|
|
38
|
+
|
|
39
|
+
n_frames = len(df)
|
|
40
|
+
t = np.arange(n_frames, dtype=np.float64) / float(self.assumed_frame_rate_hz)
|
|
41
|
+
df = df.copy()
|
|
42
|
+
|
|
43
|
+
return t, df, {"source_file": str(path), "n_slices": len(df)}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MesoDFFSource(TimeseriesSource):
|
|
47
|
+
"""Load DFF fluorescence CSV.
|
|
48
|
+
|
|
49
|
+
The CSV is expected to contain at least ``Slice`` and ``Mean`` columns.
|
|
50
|
+
"""
|
|
51
|
+
tag = "meso"
|
|
52
|
+
patterns = ("**/*_meso-collapse_first-trace.csv",)
|
|
53
|
+
camera_tag = "meso_metadata" # Bind to meso camera
|
|
54
|
+
required_columns = ("Mean")
|
|
55
|
+
assumed_frame_rate_hz = 50.0
|
|
56
|
+
normalization_baseline = "min"
|
|
57
|
+
|
|
58
|
+
def build_timeseries(
|
|
59
|
+
self,
|
|
60
|
+
path: Path,
|
|
61
|
+
*,
|
|
62
|
+
context: LoadContext | None = None,
|
|
63
|
+
) -> tuple[np.ndarray, pd.DataFrame, dict]:
|
|
64
|
+
"""Read a mesoscope CSV and return a normalized table."""
|
|
65
|
+
df = pd.read_csv(path)
|
|
66
|
+
#remove the "Slice" column
|
|
67
|
+
df = df.drop(columns=["Slice"])
|
|
68
|
+
#rename the "Mean" column to "dF_F"
|
|
69
|
+
df = df.rename(columns={"Mean": "dF_F"})
|
|
70
|
+
|
|
71
|
+
n_frames = len(df)
|
|
72
|
+
t = np.arange(n_frames, dtype=np.float64) / float(self.assumed_frame_rate_hz)
|
|
73
|
+
df = df.copy()
|
|
74
|
+
|
|
75
|
+
return t, df, {"source_file": str(path), "n_slices": len(df)}
|
|
76
|
+
|
|
77
|
+
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Pupil DeepLabCut analysis data source."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
from ..._utils._logger import get_logger
|
|
11
|
+
from mesofield.datakit.sources.camera.pupil import PupilMetadataSource
|
|
12
|
+
from mesofield.datakit.sources.register import LoadContext, TimeseriesSource
|
|
13
|
+
from mesofield.datakit.timeline import DataqueueIndex
|
|
14
|
+
|
|
15
|
+
logger = get_logger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PupilDLCSource(TimeseriesSource):
|
|
19
|
+
"""Load DeepLabCut HDF5 output and compute pupil diameters."""
|
|
20
|
+
|
|
21
|
+
tag = "pupil_dlc"
|
|
22
|
+
patterns = (
|
|
23
|
+
"**/*_pupilDLC_*.h5",
|
|
24
|
+
"**/*_pupilDLC_*.hdf5",
|
|
25
|
+
"**/*_pupil.omeDLC_*.h5"
|
|
26
|
+
)
|
|
27
|
+
camera_tag = "pupil_metadata"
|
|
28
|
+
flatten_payload = True
|
|
29
|
+
requires = ("dataqueue", "pupil_metadata")
|
|
30
|
+
default_frame_rate_hz = 20.0
|
|
31
|
+
confidence_threshold = 0.7
|
|
32
|
+
pixel_to_mm = 53.6
|
|
33
|
+
dpi = 300
|
|
34
|
+
landmark_pairs = ((0, 1), (2, 3), (4, 5), (6, 7))
|
|
35
|
+
warn_on_low_confidence = True
|
|
36
|
+
alignment_device_id = "thorcam"
|
|
37
|
+
|
|
38
|
+
def build_timeseries(
|
|
39
|
+
self,
|
|
40
|
+
path: Path,
|
|
41
|
+
*,
|
|
42
|
+
context: LoadContext | None = None,
|
|
43
|
+
) -> tuple[np.ndarray, pd.DataFrame, dict]:
|
|
44
|
+
"""Load DeepLabCut output and return a time-indexed table."""
|
|
45
|
+
context = self._require_context(context)
|
|
46
|
+
analyzed_df = self._analyze_pupil_h5(path)
|
|
47
|
+
|
|
48
|
+
n_frames = len(analyzed_df)
|
|
49
|
+
timing = self._aligned_timeline(context, n_frames)
|
|
50
|
+
if timing is None:
|
|
51
|
+
metadata_path = context.path_for("pupil_metadata")
|
|
52
|
+
if metadata_path is None:
|
|
53
|
+
logger.warning(
|
|
54
|
+
"PupilDLCSource: ThorCam alignment and pupil metadata missing; falling back to assumed frame-rate timeline."
|
|
55
|
+
)
|
|
56
|
+
t = np.arange(n_frames, dtype=np.float64) / float(self.default_frame_rate_hz)
|
|
57
|
+
timing_meta = {
|
|
58
|
+
"time_basis": "assumed_frame_rate",
|
|
59
|
+
"assumed_frame_rate_hz": float(self.default_frame_rate_hz),
|
|
60
|
+
"pupil_metadata_file": None,
|
|
61
|
+
"pupil_metadata_alignment": None,
|
|
62
|
+
"pupil_metadata_frames": None,
|
|
63
|
+
}
|
|
64
|
+
else:
|
|
65
|
+
logger.warning(
|
|
66
|
+
"PupilDLCSource: ThorCam alignment missing; falling back to metadata timeline."
|
|
67
|
+
)
|
|
68
|
+
metadata_stream = PupilMetadataSource().load(metadata_path, context=context)
|
|
69
|
+
metadata_t = metadata_stream.t
|
|
70
|
+
if metadata_t.size == 0:
|
|
71
|
+
raise ValueError("PupilDLCSource: pupil metadata contains no timeline samples")
|
|
72
|
+
metadata_t = metadata_t - metadata_t[0]
|
|
73
|
+
n_meta = int(metadata_t.size)
|
|
74
|
+
if n_meta == n_frames:
|
|
75
|
+
t = metadata_t
|
|
76
|
+
method = "metadata_direct"
|
|
77
|
+
else:
|
|
78
|
+
anchor_index = np.arange(n_meta, dtype=np.float64)
|
|
79
|
+
frame_index = np.linspace(0.0, float(n_meta - 1), num=n_frames, dtype=np.float64)
|
|
80
|
+
t = np.interp(frame_index, anchor_index, metadata_t)
|
|
81
|
+
method = "metadata_resampled"
|
|
82
|
+
|
|
83
|
+
timing_meta = {
|
|
84
|
+
"time_basis": "pupil_metadata",
|
|
85
|
+
"pupil_metadata_file": str(metadata_path),
|
|
86
|
+
"pupil_metadata_alignment": method,
|
|
87
|
+
"pupil_metadata_frames": n_meta,
|
|
88
|
+
}
|
|
89
|
+
else:
|
|
90
|
+
t, timing_meta = timing
|
|
91
|
+
|
|
92
|
+
analyzed_df = analyzed_df.copy()
|
|
93
|
+
analyzed_df["time_elapsed_s"] = t
|
|
94
|
+
|
|
95
|
+
meta = {
|
|
96
|
+
"source_file": str(path),
|
|
97
|
+
"n_frames": n_frames,
|
|
98
|
+
"class_name": self.__class__.__name__,
|
|
99
|
+
"confidence_threshold": self.confidence_threshold,
|
|
100
|
+
"pixel_to_mm": self.pixel_to_mm,
|
|
101
|
+
"landmark_pairs": self.landmark_pairs,
|
|
102
|
+
**timing_meta,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return t, analyzed_df, meta
|
|
106
|
+
|
|
107
|
+
def _aligned_timeline(self, context: LoadContext, n_frames: int) -> tuple[np.ndarray, dict] | None:
|
|
108
|
+
if context.dataqueue_frame is not None:
|
|
109
|
+
times = self._aligned_from_frame(context.dataqueue_frame)
|
|
110
|
+
dq_path = context.path_for("dataqueue")
|
|
111
|
+
else:
|
|
112
|
+
dq_path = context.path_for("dataqueue")
|
|
113
|
+
if dq_path is None:
|
|
114
|
+
# No dataqueue available for this session (e.g. older
|
|
115
|
+
# STREHAB02/03/05). Signal the caller to fall back to the
|
|
116
|
+
# pupil-metadata timeline instead of raising.
|
|
117
|
+
return None
|
|
118
|
+
timeline = DataqueueIndex.from_path(dq_path)
|
|
119
|
+
device_slice = timeline.slice(
|
|
120
|
+
lambda ids: ids.str.contains(self.alignment_device_id, case=False, na=False, regex=False)
|
|
121
|
+
)
|
|
122
|
+
times = device_slice.queue_elapsed().to_numpy(dtype=np.float64)
|
|
123
|
+
if times.size == 0:
|
|
124
|
+
return None
|
|
125
|
+
n_anchors = int(times.size)
|
|
126
|
+
if n_anchors == n_frames:
|
|
127
|
+
aligned = times
|
|
128
|
+
method = "direct"
|
|
129
|
+
else:
|
|
130
|
+
anchor_index = np.arange(n_anchors, dtype=np.float64)
|
|
131
|
+
frame_index = np.linspace(0.0, float(n_anchors - 1), num=n_frames, dtype=np.float64)
|
|
132
|
+
aligned = np.interp(frame_index, anchor_index, times)
|
|
133
|
+
method = "resampled"
|
|
134
|
+
|
|
135
|
+
aligned = aligned - aligned[0]
|
|
136
|
+
|
|
137
|
+
meta = {
|
|
138
|
+
"time_basis": "dataqueue",
|
|
139
|
+
"dataqueue_file": str(dq_path) if dq_path is not None else None,
|
|
140
|
+
"dataqueue_device": self.alignment_device_id,
|
|
141
|
+
"dataqueue_anchors": n_anchors,
|
|
142
|
+
"dataqueue_alignment": method,
|
|
143
|
+
}
|
|
144
|
+
return aligned, meta
|
|
145
|
+
|
|
146
|
+
def _aligned_from_frame(self, frame: pd.DataFrame) -> np.ndarray:
|
|
147
|
+
device_col = "device_id"
|
|
148
|
+
time_col = "queue_elapsed"
|
|
149
|
+
if device_col not in frame.columns or time_col not in frame.columns:
|
|
150
|
+
return np.array([], dtype=np.float64)
|
|
151
|
+
device_series = frame[device_col].astype(str)
|
|
152
|
+
mask = device_series.str.contains(self.alignment_device_id, case=False, na=False, regex=False)
|
|
153
|
+
times = pd.to_numeric(frame.loc[mask, time_col], errors="coerce").dropna()
|
|
154
|
+
return times.to_numpy(dtype=np.float64)
|
|
155
|
+
|
|
156
|
+
def _analyze_pupil_h5(
|
|
157
|
+
self,
|
|
158
|
+
filepath: Path,
|
|
159
|
+
*,
|
|
160
|
+
confidence_threshold: float | None = None,
|
|
161
|
+
pixel_to_mm: float | None = None,
|
|
162
|
+
dpi: int | None = None,
|
|
163
|
+
) -> pd.DataFrame:
|
|
164
|
+
"""Compute pupil diameters from DeepLabCut HDF5 output."""
|
|
165
|
+
threshold = self.confidence_threshold if confidence_threshold is None else confidence_threshold
|
|
166
|
+
px_to_mm = self.pixel_to_mm if pixel_to_mm is None else pixel_to_mm
|
|
167
|
+
dpi_value = self.dpi if dpi is None else dpi
|
|
168
|
+
|
|
169
|
+
with pd.HDFStore(filepath, mode="r") as store:
|
|
170
|
+
keys = store.keys()
|
|
171
|
+
if not keys:
|
|
172
|
+
raise ValueError(f"No keys found in HDF5 file: {filepath}")
|
|
173
|
+
key = "/df_with_missing" if "/df_with_missing" in keys else keys[0]
|
|
174
|
+
frame = store.get(key)
|
|
175
|
+
|
|
176
|
+
if not isinstance(frame.columns, pd.MultiIndex) or frame.columns.nlevels < 3:
|
|
177
|
+
raise ValueError(f"Unexpected HDF5 column format for {filepath}")
|
|
178
|
+
|
|
179
|
+
scorer = str(frame.columns.get_level_values(0)[0])
|
|
180
|
+
sub = frame[scorer]
|
|
181
|
+
|
|
182
|
+
bodyparts = sorted(set(sub.columns.get_level_values(0)))
|
|
183
|
+
coords_list: list[np.ndarray] = []
|
|
184
|
+
conf_list: list[np.ndarray] = []
|
|
185
|
+
used_bodyparts: list[str] = []
|
|
186
|
+
|
|
187
|
+
for bodypart in bodyparts:
|
|
188
|
+
try:
|
|
189
|
+
x = sub[(bodypart, "x")].to_numpy(dtype=np.float64)
|
|
190
|
+
y = sub[(bodypart, "y")].to_numpy(dtype=np.float64)
|
|
191
|
+
likelihood = sub[(bodypart, "likelihood")].to_numpy(dtype=np.float64)
|
|
192
|
+
except KeyError:
|
|
193
|
+
continue
|
|
194
|
+
coords_list.append(np.stack([x, y], axis=1))
|
|
195
|
+
conf_list.append(likelihood)
|
|
196
|
+
used_bodyparts.append(bodypart)
|
|
197
|
+
|
|
198
|
+
if not coords_list:
|
|
199
|
+
raise ValueError(f"No coordinate columns found in {filepath}")
|
|
200
|
+
|
|
201
|
+
coords = np.stack(coords_list, axis=1)
|
|
202
|
+
conf = np.stack(conf_list, axis=1)
|
|
203
|
+
|
|
204
|
+
any_confident = np.any(conf >= threshold)
|
|
205
|
+
if self.warn_on_low_confidence and not any_confident:
|
|
206
|
+
logger.warning(
|
|
207
|
+
"PupilDLCSource: no confidence values above threshold",
|
|
208
|
+
extra={
|
|
209
|
+
"phase": "pupil_dlc_analysis",
|
|
210
|
+
"threshold": threshold,
|
|
211
|
+
"dpi": dpi_value,
|
|
212
|
+
"bodyparts": used_bodyparts,
|
|
213
|
+
},
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
n_points = coords.shape[1]
|
|
217
|
+
pairs = [(a, b) for a, b in self.landmark_pairs if a < n_points and b < n_points]
|
|
218
|
+
if not pairs:
|
|
219
|
+
diameters = np.full(coords.shape[0], np.nan, dtype=np.float64)
|
|
220
|
+
else:
|
|
221
|
+
dists = []
|
|
222
|
+
for a, b in pairs:
|
|
223
|
+
diff = coords[:, a, :] - coords[:, b, :]
|
|
224
|
+
dist = np.linalg.norm(diff, axis=1)
|
|
225
|
+
valid = (conf[:, a] >= threshold) & (conf[:, b] >= threshold)
|
|
226
|
+
dist = np.where(valid, dist, np.nan)
|
|
227
|
+
dists.append(dist)
|
|
228
|
+
stacked = np.vstack(dists).T
|
|
229
|
+
valid_counts = np.sum(np.isfinite(stacked), axis=1)
|
|
230
|
+
diameters = np.full(stacked.shape[0], np.nan, dtype=np.float64)
|
|
231
|
+
valid_mask = valid_counts > 0
|
|
232
|
+
if not np.any(valid_mask) and self.warn_on_low_confidence:
|
|
233
|
+
logger.warning(
|
|
234
|
+
"PupilDLCSource: no valid landmark pairs after confidence filtering",
|
|
235
|
+
extra={
|
|
236
|
+
"phase": "pupil_dlc_analysis",
|
|
237
|
+
"threshold": threshold,
|
|
238
|
+
"bodyparts": used_bodyparts,
|
|
239
|
+
},
|
|
240
|
+
)
|
|
241
|
+
if np.any(valid_mask):
|
|
242
|
+
with np.errstate(all="ignore"):
|
|
243
|
+
diameters[valid_mask] = np.nanmean(stacked[valid_mask], axis=1)
|
|
244
|
+
|
|
245
|
+
pupil_series = pd.Series(diameters, index=frame.index).interpolate() / px_to_mm
|
|
246
|
+
return pd.DataFrame({"pupil_diameter_mm": pupil_series})
|
|
File without changes
|