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.
Files changed (111) hide show
  1. docs/_static/custom.css +40 -0
  2. docs/_static/favicon.png +0 -0
  3. docs/_static/logo.png +0 -0
  4. docs/api/index.md +70 -0
  5. docs/conf.py +200 -0
  6. docs/developer_guide.md +303 -0
  7. docs/index.md +25 -0
  8. docs/tutorial.md +4 -0
  9. docs/user_guide.md +172 -0
  10. examples/teensy_pulse_generator.py +320 -0
  11. experiments/pipeline_demo/experiment.json +24 -0
  12. experiments/pipeline_demo/hardware.yaml +23 -0
  13. experiments/pipeline_demo/procedure.py +50 -0
  14. experiments/two_cam_demo/experiment.json +24 -0
  15. experiments/two_cam_demo/hardware.yaml +58 -0
  16. experiments/two_cam_demo/load_dataset.py +213 -0
  17. experiments/two_cam_demo/procedure.py +87 -0
  18. external/video-codecs/openh264-1.8.0-win64.dll +0 -0
  19. mesofield/__init__.py +45 -0
  20. mesofield/__main__.py +11 -0
  21. mesofield/_version.py +24 -0
  22. mesofield/base.py +750 -0
  23. mesofield/cli/__init__.py +57 -0
  24. mesofield/cli/_richhelp.py +100 -0
  25. mesofield/cli/acquire.py +254 -0
  26. mesofield/cli/datakit.py +165 -0
  27. mesofield/cli/process.py +376 -0
  28. mesofield/cli/rig.py +108 -0
  29. mesofield/cli/tools.py +347 -0
  30. mesofield/config.py +751 -0
  31. mesofield/data/__init__.py +23 -0
  32. mesofield/data/batch.py +633 -0
  33. mesofield/data/manager.py +388 -0
  34. mesofield/data/writer.py +289 -0
  35. mesofield/datakit/__init__.py +44 -0
  36. mesofield/datakit/__main__.py +35 -0
  37. mesofield/datakit/_utils/_logger.py +5 -0
  38. mesofield/datakit/_version.py +141 -0
  39. mesofield/datakit/config.py +50 -0
  40. mesofield/datakit/core.py +783 -0
  41. mesofield/datakit/datamodel.py +200 -0
  42. mesofield/datakit/discover.py +124 -0
  43. mesofield/datakit/explore.py +651 -0
  44. mesofield/datakit/notebooks/pupil_dlc.ipynb +2445 -0
  45. mesofield/datakit/profile.py +535 -0
  46. mesofield/datakit/shell.py +83 -0
  47. mesofield/datakit/sources/__init__.py +65 -0
  48. mesofield/datakit/sources/analysis/mesomap.py +194 -0
  49. mesofield/datakit/sources/analysis/mesoscope.py +77 -0
  50. mesofield/datakit/sources/analysis/pupil.py +246 -0
  51. mesofield/datakit/sources/behavior/__init__.py +0 -0
  52. mesofield/datakit/sources/behavior/dataqueue.py +281 -0
  53. mesofield/datakit/sources/behavior/psychopy.py +364 -0
  54. mesofield/datakit/sources/behavior/treadmill.py +323 -0
  55. mesofield/datakit/sources/behavior/wheel.py +277 -0
  56. mesofield/datakit/sources/camera/mesoscope.py +32 -0
  57. mesofield/datakit/sources/camera/metadata_json.py +130 -0
  58. mesofield/datakit/sources/camera/pupil.py +28 -0
  59. mesofield/datakit/sources/camera/suite2p.py +547 -0
  60. mesofield/datakit/sources/register.py +204 -0
  61. mesofield/datakit/sources/session/config.py +130 -0
  62. mesofield/datakit/sources/session/notes.py +63 -0
  63. mesofield/datakit/sources/session/timestamps.py +58 -0
  64. mesofield/datakit/timeline.py +306 -0
  65. mesofield/devices/__init__.py +42 -0
  66. mesofield/devices/base.py +498 -0
  67. mesofield/devices/base_camera.py +295 -0
  68. mesofield/devices/cameras.py +740 -0
  69. mesofield/devices/daq.py +151 -0
  70. mesofield/devices/encoder.py +384 -0
  71. mesofield/devices/mocks.py +275 -0
  72. mesofield/devices/psychopy_device.py +455 -0
  73. mesofield/devices/subprocesses/__init__.py +0 -0
  74. mesofield/devices/subprocesses/psychopy.py +133 -0
  75. mesofield/devices/treadmill.py +318 -0
  76. mesofield/engines.py +380 -0
  77. mesofield/gui/Mesofield_icon.png +0 -0
  78. mesofield/gui/__init__.py +76 -0
  79. mesofield/gui/config_wizard.py +724 -0
  80. mesofield/gui/controller.py +535 -0
  81. mesofield/gui/dynamic_controller.py +78 -0
  82. mesofield/gui/maingui.py +427 -0
  83. mesofield/gui/mdagui.py +285 -0
  84. mesofield/gui/qt_device_adapter.py +109 -0
  85. mesofield/gui/speedplotter.py +152 -0
  86. mesofield/gui/theme.py +445 -0
  87. mesofield/gui/tiff_viewer.py +1050 -0
  88. mesofield/gui/viewer.py +691 -0
  89. mesofield/hardware.py +549 -0
  90. mesofield/playback.py +1298 -0
  91. mesofield/processing/__init__.py +12 -0
  92. mesofield/processing/runner.py +237 -0
  93. mesofield/processors/__init__.py +13 -0
  94. mesofield/processors/base.py +287 -0
  95. mesofield/processors/frame_mean.py +19 -0
  96. mesofield/protocols.py +378 -0
  97. mesofield/scaffold/__init__.py +34 -0
  98. mesofield/scaffold/experiment.py +400 -0
  99. mesofield/scaffold/rigs.py +121 -0
  100. mesofield/signals.py +85 -0
  101. mesofield/utils/__init__.py +0 -0
  102. mesofield/utils/_logger.py +156 -0
  103. mesofield/utils/retrofit.py +309 -0
  104. mesofield/utils/utils.py +217 -0
  105. mesofield-0.3.2b0.dist-info/METADATA +178 -0
  106. mesofield-0.3.2b0.dist-info/RECORD +111 -0
  107. mesofield-0.3.2b0.dist-info/WHEEL +5 -0
  108. mesofield-0.3.2b0.dist-info/entry_points.txt +2 -0
  109. mesofield-0.3.2b0.dist-info/licenses/LICENSE +21 -0
  110. mesofield-0.3.2b0.dist-info/top_level.txt +6 -0
  111. 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