mxbiflow 0.1.1__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.
- mxbiflow/__init__.py +3 -0
- mxbiflow/assets/__init__.py +5 -0
- mxbiflow/assets/clicker.wav +0 -0
- mxbiflow/config_store.py +68 -0
- mxbiflow/data_logger.py +114 -0
- mxbiflow/default/__init__.py +4 -0
- mxbiflow/default/idle/assets/apple_v1.png +0 -0
- mxbiflow/default/idle/idle.py +57 -0
- mxbiflow/detector_bridge.py +87 -0
- mxbiflow/game.py +84 -0
- mxbiflow/infra/eventbus.py +31 -0
- mxbiflow/main.py +106 -0
- mxbiflow/models/animal.py +130 -0
- mxbiflow/models/reward.py +7 -0
- mxbiflow/models/session.py +145 -0
- mxbiflow/mxbiflow.py +43 -0
- mxbiflow/path.py +41 -0
- mxbiflow/scene/__init__.py +8 -0
- mxbiflow/scene/scene_manager.py +64 -0
- mxbiflow/scene/scene_protocol.py +22 -0
- mxbiflow/scheduler.py +90 -0
- mxbiflow/tasks/GNGSiD/models.py +70 -0
- mxbiflow/tasks/GNGSiD/stages/detect_stage/config.json +116 -0
- mxbiflow/tasks/GNGSiD/stages/detect_stage/detect_stage.py +161 -0
- mxbiflow/tasks/GNGSiD/stages/detect_stage/detect_stage_models.py +65 -0
- mxbiflow/tasks/GNGSiD/stages/discriminate_stage/config.json +70 -0
- mxbiflow/tasks/GNGSiD/stages/discriminate_stage/discriminate_stage.py +173 -0
- mxbiflow/tasks/GNGSiD/stages/discriminate_stage/discriminate_stage_models.py +80 -0
- mxbiflow/tasks/GNGSiD/stages/size_reduction_stage/config.json +83 -0
- mxbiflow/tasks/GNGSiD/stages/size_reduction_stage/size_reduction_models.py +58 -0
- mxbiflow/tasks/GNGSiD/stages/size_reduction_stage/size_reduction_stage.py +149 -0
- mxbiflow/tasks/GNGSiD/tasks/artifacts.py +13 -0
- mxbiflow/tasks/GNGSiD/tasks/detect/models.py +21 -0
- mxbiflow/tasks/GNGSiD/tasks/detect/scene.py +271 -0
- mxbiflow/tasks/GNGSiD/tasks/discriminate/discriminate_models.py +31 -0
- mxbiflow/tasks/GNGSiD/tasks/discriminate/discriminate_scene.py +336 -0
- mxbiflow/tasks/GNGSiD/tasks/touch/touch_models.py +17 -0
- mxbiflow/tasks/GNGSiD/tasks/touch/touch_scene.py +256 -0
- mxbiflow/tasks/GNGSiD/tasks/utils/targets.py +57 -0
- mxbiflow/tasks/cross_modal/bundle_dir.py +553 -0
- mxbiflow/tasks/cross_modal/config.py +41 -0
- mxbiflow/tasks/cross_modal/media.py +61 -0
- mxbiflow/tasks/cross_modal/models.py +57 -0
- mxbiflow/tasks/cross_modal/scene.py +252 -0
- mxbiflow/tasks/cross_modal/stage.py +218 -0
- mxbiflow/tasks/cross_modal/trial_io.py +23 -0
- mxbiflow/tasks/cross_modal/trial_schema.py +113 -0
- mxbiflow/tasks/default/error_task/error_scene.py +53 -0
- mxbiflow/tasks/default/idle_task/assets/apple_v1.png +0 -0
- mxbiflow/tasks/default/idle_task/idle_scene.py +85 -0
- mxbiflow/tasks/default/initial_habituation_training/README.md +188 -0
- mxbiflow/tasks/default/initial_habituation_training/stages/config.csv +7 -0
- mxbiflow/tasks/default/initial_habituation_training/stages/config.json +67 -0
- mxbiflow/tasks/default/initial_habituation_training/stages/initial_habituation_training_stage.py +172 -0
- mxbiflow/tasks/default/initial_habituation_training/stages/models.py +56 -0
- mxbiflow/tasks/default/initial_habituation_training/tasks/stay_to_reward/stay_to_reward.py +244 -0
- mxbiflow/tasks/default/initial_habituation_training/tasks/stay_to_reward/stay_to_reward_models.py +50 -0
- mxbiflow/tasks/task_protocol.py +26 -0
- mxbiflow/tasks/task_table.py +29 -0
- mxbiflow/tasks/two_alternative_choice/assets/starter.py +27 -0
- mxbiflow/tasks/two_alternative_choice/models.py +68 -0
- mxbiflow/tasks/two_alternative_choice/stages/size_reduction_stage/config.json +118 -0
- mxbiflow/tasks/two_alternative_choice/stages/size_reduction_stage/size_reduction_models.py +41 -0
- mxbiflow/tasks/two_alternative_choice/stages/size_reduction_stage/size_reduction_stage.py +122 -0
- mxbiflow/tasks/two_alternative_choice/tasks/touch/touch_models.py +19 -0
- mxbiflow/tasks/two_alternative_choice/tasks/touch/touch_scene.py +249 -0
- mxbiflow/timer/__init__.py +3 -0
- mxbiflow/timer/frame_timer.py +47 -0
- mxbiflow/timer/realtime_timer.py +0 -0
- mxbiflow/tmp_email.py +13 -0
- mxbiflow/ui/components/animal.py +87 -0
- mxbiflow/ui/components/baseconfig.py +68 -0
- mxbiflow/ui/components/card.py +18 -0
- mxbiflow/ui/components/device_card/__init__.py +17 -0
- mxbiflow/ui/components/device_card/detector/beambreak_detector_card.py +29 -0
- mxbiflow/ui/components/device_card/detector/fusion_detector.py +45 -0
- mxbiflow/ui/components/device_card/detector/mock_detector_card.py +20 -0
- mxbiflow/ui/components/device_card/detector/rfid_detector.py +40 -0
- mxbiflow/ui/components/device_card/device_card.py +67 -0
- mxbiflow/ui/components/device_card/rewarder/mock_rewarder_card.py +20 -0
- mxbiflow/ui/components/device_card/rewarder/rpi_gpio_rewarder.py +33 -0
- mxbiflow/ui/components/devices.py +183 -0
- mxbiflow/ui/components/dialog/__init__.py +3 -0
- mxbiflow/ui/components/dialog/add_devices_dialog.py +64 -0
- mxbiflow/ui/components/experiment_groups.py +122 -0
- mxbiflow/ui/experiment_panel.py +91 -0
- mxbiflow/ui/mxbi_panel.py +152 -0
- mxbiflow/utils/logger.py +19 -0
- mxbiflow/utils/serial.py +10 -0
- mxbiflow-0.1.1.dist-info/METADATA +168 -0
- mxbiflow-0.1.1.dist-info/RECORD +93 -0
- mxbiflow-0.1.1.dist-info/WHEEL +4 -0
- mxbiflow-0.1.1.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from mxbi.tasks.cross_modal.trial_schema import Trial
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BundleValidationError(RuntimeError):
|
|
10
|
+
def __init__(self, errors: list[str]) -> None:
|
|
11
|
+
self.errors = errors
|
|
12
|
+
super().__init__("\n".join(errors))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def assert_safe_internal_path(internal_path: str) -> None:
|
|
16
|
+
if not internal_path:
|
|
17
|
+
raise ValueError("Empty path is not allowed.")
|
|
18
|
+
if internal_path.startswith(("/", "\\")):
|
|
19
|
+
raise ValueError(f"Absolute paths are not allowed: '{internal_path}'")
|
|
20
|
+
if "\\" in internal_path:
|
|
21
|
+
raise ValueError(
|
|
22
|
+
f"Backslashes are not allowed in bundle paths: '{internal_path}'"
|
|
23
|
+
)
|
|
24
|
+
parts = internal_path.split("/")
|
|
25
|
+
for part in parts:
|
|
26
|
+
if not part:
|
|
27
|
+
raise ValueError(f"Invalid path segment in '{internal_path}'")
|
|
28
|
+
if part in {".", ".."}:
|
|
29
|
+
raise ValueError(f"Path traversal is not allowed: '{internal_path}'")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BundleCounts(BaseModel):
|
|
33
|
+
model_config = ConfigDict(extra="allow")
|
|
34
|
+
|
|
35
|
+
overallTrials: int = Field(alias="overallTrials")
|
|
36
|
+
perSubjectTrials: dict[str, int] = Field(alias="perSubjectTrials")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DatasetMeta(BaseModel):
|
|
40
|
+
model_config = ConfigDict(extra="allow")
|
|
41
|
+
|
|
42
|
+
dataset_id: str
|
|
43
|
+
created_at: str
|
|
44
|
+
source_data_dir_label: str
|
|
45
|
+
subjects: list[str]
|
|
46
|
+
seed_policy: dict[str, Any]
|
|
47
|
+
generator_config: dict[str, Any]
|
|
48
|
+
counts: dict[str, Any]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ManifestExemplar(BaseModel):
|
|
52
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
53
|
+
|
|
54
|
+
index: int
|
|
55
|
+
relative_path: str = Field(validation_alias="relativePath")
|
|
56
|
+
file_name: str = Field(validation_alias="fileName")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ManifestIdentity(BaseModel):
|
|
60
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
61
|
+
|
|
62
|
+
id: str
|
|
63
|
+
image_exemplars: list[ManifestExemplar] = Field(validation_alias="imageExemplars")
|
|
64
|
+
audio_exemplars: list[ManifestExemplar] = Field(validation_alias="audioExemplars")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Manifest(BaseModel):
|
|
68
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
69
|
+
|
|
70
|
+
meta: dict[str, Any]
|
|
71
|
+
identities: list[ManifestIdentity]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class CrossModalBundleDir:
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
root_dir: Path,
|
|
78
|
+
*,
|
|
79
|
+
dataset_meta: DatasetMeta,
|
|
80
|
+
manifest: Manifest,
|
|
81
|
+
file_index: dict[str, Path],
|
|
82
|
+
lower_to_actual: dict[str, str],
|
|
83
|
+
trials_by_subject: dict[str, list[Trial]],
|
|
84
|
+
) -> None:
|
|
85
|
+
self._root_dir = root_dir
|
|
86
|
+
self._dataset_meta = dataset_meta
|
|
87
|
+
self._manifest = manifest
|
|
88
|
+
self._file_index = file_index
|
|
89
|
+
self._lower_to_actual = lower_to_actual
|
|
90
|
+
self._trials_by_subject = trials_by_subject
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def root_dir(self) -> Path:
|
|
94
|
+
return self._root_dir
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def dataset_meta(self) -> DatasetMeta:
|
|
98
|
+
return self._dataset_meta
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def manifest(self) -> Manifest:
|
|
102
|
+
return self._manifest
|
|
103
|
+
|
|
104
|
+
def subject_ids(self) -> list[str]:
|
|
105
|
+
return list(self._dataset_meta.subjects)
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def from_dir_path(cls, root_dir: Path) -> "CrossModalBundleDir":
|
|
109
|
+
root_dir = root_dir.expanduser().resolve()
|
|
110
|
+
errors: list[str] = []
|
|
111
|
+
|
|
112
|
+
if not root_dir.exists():
|
|
113
|
+
raise BundleValidationError(
|
|
114
|
+
[f"Bundle directory does not exist: {root_dir}"]
|
|
115
|
+
)
|
|
116
|
+
if not root_dir.is_dir():
|
|
117
|
+
raise BundleValidationError([f"Bundle path is not a directory: {root_dir}"])
|
|
118
|
+
|
|
119
|
+
file_index, lower_to_actual, index_errors = cls._build_file_index(root_dir)
|
|
120
|
+
errors.extend(index_errors)
|
|
121
|
+
|
|
122
|
+
dataset_meta = cls._read_json_model(
|
|
123
|
+
root_dir,
|
|
124
|
+
file_index=file_index,
|
|
125
|
+
lower_to_actual=lower_to_actual,
|
|
126
|
+
internal_path="dataset_meta.json",
|
|
127
|
+
model=DatasetMeta,
|
|
128
|
+
errors=errors,
|
|
129
|
+
)
|
|
130
|
+
manifest = cls._read_json_model(
|
|
131
|
+
root_dir,
|
|
132
|
+
file_index=file_index,
|
|
133
|
+
lower_to_actual=lower_to_actual,
|
|
134
|
+
internal_path="manifest.json",
|
|
135
|
+
model=Manifest,
|
|
136
|
+
errors=errors,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
cls._require_directory(
|
|
140
|
+
root_dir / "media", errors, "Bundle is missing required directory: 'media/'"
|
|
141
|
+
)
|
|
142
|
+
cls._require_directory(
|
|
143
|
+
root_dir / "media" / "images",
|
|
144
|
+
errors,
|
|
145
|
+
"Bundle is missing required directory: 'media/images/'",
|
|
146
|
+
)
|
|
147
|
+
cls._require_directory(
|
|
148
|
+
root_dir / "media" / "audio",
|
|
149
|
+
errors,
|
|
150
|
+
"Bundle is missing required directory: 'media/audio/'",
|
|
151
|
+
)
|
|
152
|
+
cls._require_directory(
|
|
153
|
+
root_dir / "trial_sets",
|
|
154
|
+
errors,
|
|
155
|
+
"Bundle is missing required directory: 'trial_sets/'",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if dataset_meta is None or manifest is None:
|
|
159
|
+
raise BundleValidationError(errors)
|
|
160
|
+
|
|
161
|
+
expected_subjects = [
|
|
162
|
+
s for s in dataset_meta.subjects if isinstance(s, str) and s.strip()
|
|
163
|
+
]
|
|
164
|
+
expected_subjects = [s.strip() for s in expected_subjects]
|
|
165
|
+
if not expected_subjects:
|
|
166
|
+
errors.append(
|
|
167
|
+
"dataset_meta.json: 'subjects' must be a non-empty list of subject IDs."
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
trial_sets_dir = root_dir / "trial_sets"
|
|
171
|
+
actual_subject_dirs = (
|
|
172
|
+
sorted([p.name for p in trial_sets_dir.iterdir() if p.is_dir()])
|
|
173
|
+
if trial_sets_dir.exists()
|
|
174
|
+
else []
|
|
175
|
+
)
|
|
176
|
+
expected_sorted = sorted(expected_subjects)
|
|
177
|
+
if actual_subject_dirs != expected_sorted:
|
|
178
|
+
errors.append(
|
|
179
|
+
"Bundle subject directories mismatch under 'trial_sets/'. "
|
|
180
|
+
f"Expected: {expected_sorted} but found: {actual_subject_dirs}"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
trials_by_subject: dict[str, list[Trial]] = {}
|
|
184
|
+
for subject_id in expected_subjects:
|
|
185
|
+
subject_errors = cls._validate_subject(
|
|
186
|
+
root_dir,
|
|
187
|
+
file_index=file_index,
|
|
188
|
+
lower_to_actual=lower_to_actual,
|
|
189
|
+
manifest=manifest,
|
|
190
|
+
subject_id=subject_id,
|
|
191
|
+
trials_by_subject=trials_by_subject,
|
|
192
|
+
)
|
|
193
|
+
errors.extend(subject_errors)
|
|
194
|
+
|
|
195
|
+
manifest_errors = cls._validate_manifest_media(
|
|
196
|
+
root_dir,
|
|
197
|
+
file_index=file_index,
|
|
198
|
+
lower_to_actual=lower_to_actual,
|
|
199
|
+
manifest=manifest,
|
|
200
|
+
)
|
|
201
|
+
errors.extend(manifest_errors)
|
|
202
|
+
|
|
203
|
+
if errors:
|
|
204
|
+
raise BundleValidationError(errors)
|
|
205
|
+
|
|
206
|
+
return cls(
|
|
207
|
+
root_dir,
|
|
208
|
+
dataset_meta=dataset_meta,
|
|
209
|
+
manifest=manifest,
|
|
210
|
+
file_index=file_index,
|
|
211
|
+
lower_to_actual=lower_to_actual,
|
|
212
|
+
trials_by_subject=trials_by_subject,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def validate_selected_subjects(self, subject_ids: list[str]) -> None:
|
|
216
|
+
errors: list[str] = []
|
|
217
|
+
for subject_id in subject_ids:
|
|
218
|
+
if subject_id not in self._dataset_meta.subjects:
|
|
219
|
+
errors.append(
|
|
220
|
+
f"Selected subject '{subject_id}' is not listed in dataset_meta.json subjects."
|
|
221
|
+
)
|
|
222
|
+
continue
|
|
223
|
+
subject_errors = self._validate_subject(
|
|
224
|
+
self._root_dir,
|
|
225
|
+
file_index=self._file_index,
|
|
226
|
+
lower_to_actual=self._lower_to_actual,
|
|
227
|
+
manifest=self._manifest,
|
|
228
|
+
subject_id=subject_id,
|
|
229
|
+
trials_by_subject=self._trials_by_subject,
|
|
230
|
+
)
|
|
231
|
+
errors.extend(subject_errors)
|
|
232
|
+
if errors:
|
|
233
|
+
raise BundleValidationError(errors)
|
|
234
|
+
|
|
235
|
+
def load_trials(self, subject_id: str) -> list[Trial]:
|
|
236
|
+
try:
|
|
237
|
+
return list(self._trials_by_subject[subject_id])
|
|
238
|
+
except KeyError as e:
|
|
239
|
+
raise ValueError(f"No trial set loaded for subject '{subject_id}'.") from e
|
|
240
|
+
|
|
241
|
+
def resolve_media_path(self, internal_path: str) -> Path:
|
|
242
|
+
assert_safe_internal_path(internal_path)
|
|
243
|
+
if not internal_path.startswith("media/"):
|
|
244
|
+
raise ValueError(f"Not a bundle media path: '{internal_path}'")
|
|
245
|
+
|
|
246
|
+
exact = self._file_index.get(internal_path)
|
|
247
|
+
if exact is not None:
|
|
248
|
+
return exact.resolve()
|
|
249
|
+
|
|
250
|
+
lower = internal_path.lower()
|
|
251
|
+
actual = self._lower_to_actual.get(lower)
|
|
252
|
+
if actual is not None:
|
|
253
|
+
raise FileNotFoundError(
|
|
254
|
+
"Bundle media path case mismatch: "
|
|
255
|
+
f"trial references '{internal_path}' but bundle contains '{actual}'"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
raise FileNotFoundError(f"Bundle media missing: '{internal_path}'")
|
|
259
|
+
|
|
260
|
+
@staticmethod
|
|
261
|
+
def _require_directory(path: Path, errors: list[str], message: str) -> None:
|
|
262
|
+
if not path.exists() or not path.is_dir():
|
|
263
|
+
errors.append(message)
|
|
264
|
+
|
|
265
|
+
@staticmethod
|
|
266
|
+
def _build_file_index(
|
|
267
|
+
root_dir: Path,
|
|
268
|
+
) -> tuple[dict[str, Path], dict[str, str], list[str]]:
|
|
269
|
+
errors: list[str] = []
|
|
270
|
+
file_index: dict[str, Path] = {}
|
|
271
|
+
lower_to_actual: dict[str, str] = {}
|
|
272
|
+
|
|
273
|
+
for p in root_dir.rglob("*"):
|
|
274
|
+
if p.is_dir():
|
|
275
|
+
continue
|
|
276
|
+
try:
|
|
277
|
+
rel = p.relative_to(root_dir).as_posix()
|
|
278
|
+
except Exception:
|
|
279
|
+
continue
|
|
280
|
+
|
|
281
|
+
if not rel:
|
|
282
|
+
errors.append(f"Invalid file with empty relative path: {p}")
|
|
283
|
+
continue
|
|
284
|
+
|
|
285
|
+
if "\\" in rel:
|
|
286
|
+
errors.append(f"Invalid bundle file path contains backslash: '{rel}'")
|
|
287
|
+
continue
|
|
288
|
+
|
|
289
|
+
if rel in file_index:
|
|
290
|
+
errors.append(f"Duplicate bundle file path detected: '{rel}'")
|
|
291
|
+
continue
|
|
292
|
+
|
|
293
|
+
lower = rel.lower()
|
|
294
|
+
existing = lower_to_actual.get(lower)
|
|
295
|
+
if existing is not None and existing != rel:
|
|
296
|
+
errors.append(
|
|
297
|
+
"Bundle contains two files that differ only by case, which is not allowed: "
|
|
298
|
+
f"'{existing}' and '{rel}'"
|
|
299
|
+
)
|
|
300
|
+
continue
|
|
301
|
+
|
|
302
|
+
file_index[rel] = p
|
|
303
|
+
lower_to_actual[lower] = rel
|
|
304
|
+
|
|
305
|
+
return file_index, lower_to_actual, errors
|
|
306
|
+
|
|
307
|
+
@classmethod
|
|
308
|
+
def _read_json_model(
|
|
309
|
+
cls,
|
|
310
|
+
root_dir: Path,
|
|
311
|
+
*,
|
|
312
|
+
file_index: dict[str, Path],
|
|
313
|
+
lower_to_actual: dict[str, str],
|
|
314
|
+
internal_path: str,
|
|
315
|
+
model: type[BaseModel],
|
|
316
|
+
errors: list[str],
|
|
317
|
+
) -> BaseModel | None:
|
|
318
|
+
try:
|
|
319
|
+
assert_safe_internal_path(internal_path)
|
|
320
|
+
except Exception as e:
|
|
321
|
+
errors.append(str(e))
|
|
322
|
+
return None
|
|
323
|
+
|
|
324
|
+
file_path = file_index.get(internal_path)
|
|
325
|
+
if file_path is None:
|
|
326
|
+
actual = lower_to_actual.get(internal_path.lower())
|
|
327
|
+
if actual is not None:
|
|
328
|
+
errors.append(
|
|
329
|
+
"Bundle file path case mismatch: "
|
|
330
|
+
f"expected '{internal_path}' but bundle contains '{actual}'"
|
|
331
|
+
)
|
|
332
|
+
else:
|
|
333
|
+
errors.append(
|
|
334
|
+
f"Bundle is missing required file at root: '{internal_path}'"
|
|
335
|
+
)
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
try:
|
|
339
|
+
raw = json.loads(file_path.read_text(encoding="utf-8"))
|
|
340
|
+
except Exception as e:
|
|
341
|
+
errors.append(f"Failed to parse JSON '{internal_path}': {e}")
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
return model.model_validate(raw)
|
|
346
|
+
except ValidationError as e:
|
|
347
|
+
errors.append(f"Invalid '{internal_path}' schema: {e}")
|
|
348
|
+
return None
|
|
349
|
+
|
|
350
|
+
@classmethod
|
|
351
|
+
def _validate_manifest_media(
|
|
352
|
+
cls,
|
|
353
|
+
root_dir: Path,
|
|
354
|
+
*,
|
|
355
|
+
file_index: dict[str, Path],
|
|
356
|
+
lower_to_actual: dict[str, str],
|
|
357
|
+
manifest: Manifest,
|
|
358
|
+
) -> list[str]:
|
|
359
|
+
errors: list[str] = []
|
|
360
|
+
for identity in manifest.identities:
|
|
361
|
+
for exemplar in identity.image_exemplars:
|
|
362
|
+
errors.extend(
|
|
363
|
+
cls._validate_media_reference(
|
|
364
|
+
root_dir,
|
|
365
|
+
file_index=file_index,
|
|
366
|
+
lower_to_actual=lower_to_actual,
|
|
367
|
+
internal_path=exemplar.relative_path,
|
|
368
|
+
context=f"manifest.json identity '{identity.id}' image exemplar index={exemplar.index}",
|
|
369
|
+
expected_prefix="media/images/",
|
|
370
|
+
)
|
|
371
|
+
)
|
|
372
|
+
for exemplar in identity.audio_exemplars:
|
|
373
|
+
errors.extend(
|
|
374
|
+
cls._validate_media_reference(
|
|
375
|
+
root_dir,
|
|
376
|
+
file_index=file_index,
|
|
377
|
+
lower_to_actual=lower_to_actual,
|
|
378
|
+
internal_path=exemplar.relative_path,
|
|
379
|
+
context=f"manifest.json identity '{identity.id}' audio exemplar index={exemplar.index}",
|
|
380
|
+
expected_prefix="media/audio/",
|
|
381
|
+
)
|
|
382
|
+
)
|
|
383
|
+
return errors
|
|
384
|
+
|
|
385
|
+
@classmethod
|
|
386
|
+
def _validate_subject(
|
|
387
|
+
cls,
|
|
388
|
+
root_dir: Path,
|
|
389
|
+
*,
|
|
390
|
+
file_index: dict[str, Path],
|
|
391
|
+
lower_to_actual: dict[str, str],
|
|
392
|
+
manifest: Manifest,
|
|
393
|
+
subject_id: str,
|
|
394
|
+
trials_by_subject: dict[str, list[Trial]],
|
|
395
|
+
) -> list[str]:
|
|
396
|
+
errors: list[str] = []
|
|
397
|
+
|
|
398
|
+
trials_json_internal_path = f"trial_sets/{subject_id}/trials.json"
|
|
399
|
+
try:
|
|
400
|
+
assert_safe_internal_path(trials_json_internal_path)
|
|
401
|
+
except Exception as e:
|
|
402
|
+
errors.append(f"[{subject_id}] Invalid trials.json internal path: {e}")
|
|
403
|
+
return errors
|
|
404
|
+
|
|
405
|
+
trials_json_path = file_index.get(trials_json_internal_path)
|
|
406
|
+
if trials_json_path is None:
|
|
407
|
+
actual = lower_to_actual.get(trials_json_internal_path.lower())
|
|
408
|
+
if actual is not None:
|
|
409
|
+
errors.append(
|
|
410
|
+
f"[{subject_id}] trials.json path case mismatch: expected '{trials_json_internal_path}' "
|
|
411
|
+
f"but bundle contains '{actual}'"
|
|
412
|
+
)
|
|
413
|
+
else:
|
|
414
|
+
errors.append(
|
|
415
|
+
f"[{subject_id}] Missing required file: '{trials_json_internal_path}'"
|
|
416
|
+
)
|
|
417
|
+
return errors
|
|
418
|
+
|
|
419
|
+
try:
|
|
420
|
+
raw = json.loads(trials_json_path.read_text(encoding="utf-8"))
|
|
421
|
+
except Exception as e:
|
|
422
|
+
errors.append(f"[{subject_id}] Failed to parse trials.json: {e}")
|
|
423
|
+
return errors
|
|
424
|
+
|
|
425
|
+
if not isinstance(raw, dict):
|
|
426
|
+
errors.append(f"[{subject_id}] trials.json must be a JSON object.")
|
|
427
|
+
return errors
|
|
428
|
+
|
|
429
|
+
meta = raw.get("meta")
|
|
430
|
+
if not isinstance(meta, dict):
|
|
431
|
+
errors.append(f"[{subject_id}] trials.json: 'meta' must be a JSON object.")
|
|
432
|
+
return errors
|
|
433
|
+
|
|
434
|
+
meta_subject_id = str(meta.get("subjectId", "")).strip()
|
|
435
|
+
if meta_subject_id != subject_id:
|
|
436
|
+
errors.append(
|
|
437
|
+
f"[{subject_id}] Trial set subject mismatch: folder '{subject_id}' but trials.json meta.subjectId "
|
|
438
|
+
f"is '{meta_subject_id}'"
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
raw_trials = raw.get("trials")
|
|
442
|
+
if not isinstance(raw_trials, list) or not raw_trials:
|
|
443
|
+
errors.append(
|
|
444
|
+
f"[{subject_id}] trials.json: 'trials' must be a non-empty array."
|
|
445
|
+
)
|
|
446
|
+
return errors
|
|
447
|
+
|
|
448
|
+
parsed_trials: list[Trial] = []
|
|
449
|
+
for i, rec in enumerate(raw_trials, start=1):
|
|
450
|
+
try:
|
|
451
|
+
trial = Trial.model_validate(rec)
|
|
452
|
+
except ValidationError as e:
|
|
453
|
+
errors.append(
|
|
454
|
+
f"[{subject_id}] trials.json trial #{i} failed schema validation: {e}"
|
|
455
|
+
)
|
|
456
|
+
continue
|
|
457
|
+
|
|
458
|
+
if trial.subject_id != subject_id:
|
|
459
|
+
errors.append(
|
|
460
|
+
f"[{subject_id}] Trial subject mismatch in trial_id='{trial.trial_id}': "
|
|
461
|
+
f"trial.subject_id='{trial.subject_id}'"
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
errors.extend(
|
|
465
|
+
cls._validate_media_reference(
|
|
466
|
+
root_dir,
|
|
467
|
+
file_index=file_index,
|
|
468
|
+
lower_to_actual=lower_to_actual,
|
|
469
|
+
internal_path=trial.audio_path,
|
|
470
|
+
context=f"[{subject_id}] trial_id='{trial.trial_id}' audio",
|
|
471
|
+
expected_prefix="media/audio/",
|
|
472
|
+
)
|
|
473
|
+
)
|
|
474
|
+
errors.extend(
|
|
475
|
+
cls._validate_media_reference(
|
|
476
|
+
root_dir,
|
|
477
|
+
file_index=file_index,
|
|
478
|
+
lower_to_actual=lower_to_actual,
|
|
479
|
+
internal_path=trial.left_image_path,
|
|
480
|
+
context=f"[{subject_id}] trial_id='{trial.trial_id}' left_image",
|
|
481
|
+
expected_prefix="media/images/",
|
|
482
|
+
)
|
|
483
|
+
)
|
|
484
|
+
errors.extend(
|
|
485
|
+
cls._validate_media_reference(
|
|
486
|
+
root_dir,
|
|
487
|
+
file_index=file_index,
|
|
488
|
+
lower_to_actual=lower_to_actual,
|
|
489
|
+
internal_path=trial.right_image_path,
|
|
490
|
+
context=f"[{subject_id}] trial_id='{trial.trial_id}' right_image",
|
|
491
|
+
expected_prefix="media/images/",
|
|
492
|
+
)
|
|
493
|
+
)
|
|
494
|
+
parsed_trials.append(trial)
|
|
495
|
+
|
|
496
|
+
if errors:
|
|
497
|
+
return errors
|
|
498
|
+
|
|
499
|
+
trials_by_subject[subject_id] = sorted(
|
|
500
|
+
parsed_trials, key=lambda t: t.trial_number
|
|
501
|
+
)
|
|
502
|
+
return errors
|
|
503
|
+
|
|
504
|
+
@classmethod
|
|
505
|
+
def _validate_media_reference(
|
|
506
|
+
cls,
|
|
507
|
+
root_dir: Path,
|
|
508
|
+
*,
|
|
509
|
+
file_index: dict[str, Path],
|
|
510
|
+
lower_to_actual: dict[str, str],
|
|
511
|
+
internal_path: str,
|
|
512
|
+
context: str,
|
|
513
|
+
expected_prefix: str,
|
|
514
|
+
) -> list[str]:
|
|
515
|
+
errors: list[str] = []
|
|
516
|
+
try:
|
|
517
|
+
assert_safe_internal_path(internal_path)
|
|
518
|
+
except Exception as e:
|
|
519
|
+
errors.append(f"{context}: invalid path '{internal_path}': {e}")
|
|
520
|
+
return errors
|
|
521
|
+
|
|
522
|
+
if not internal_path.startswith(expected_prefix):
|
|
523
|
+
errors.append(
|
|
524
|
+
f"{context}: invalid media path prefix, expected '{expected_prefix}*' but got '{internal_path}'"
|
|
525
|
+
)
|
|
526
|
+
return errors
|
|
527
|
+
|
|
528
|
+
exact = file_index.get(internal_path)
|
|
529
|
+
if exact is None:
|
|
530
|
+
actual = lower_to_actual.get(internal_path.lower())
|
|
531
|
+
if actual is not None:
|
|
532
|
+
errors.append(
|
|
533
|
+
f"{context}: path case mismatch, trial references '{internal_path}' but bundle contains '{actual}'"
|
|
534
|
+
)
|
|
535
|
+
else:
|
|
536
|
+
errors.append(f"{context}: missing media file '{internal_path}'")
|
|
537
|
+
return errors
|
|
538
|
+
|
|
539
|
+
try:
|
|
540
|
+
resolved = exact.resolve()
|
|
541
|
+
root_resolved = root_dir.resolve()
|
|
542
|
+
if not resolved.is_file():
|
|
543
|
+
errors.append(f"{context}: media path is not a file: '{internal_path}'")
|
|
544
|
+
if not resolved.is_relative_to(root_resolved):
|
|
545
|
+
errors.append(
|
|
546
|
+
f"{context}: media path escapes bundle root: '{internal_path}'"
|
|
547
|
+
)
|
|
548
|
+
except Exception as e:
|
|
549
|
+
errors.append(
|
|
550
|
+
f"{context}: failed to resolve media path '{internal_path}': {e}"
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
return errors
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from mxbi.path import CROSS_MODAL_CONFIG_PATH
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CrossModalVisualConfig(BaseModel):
|
|
10
|
+
image_scale: float = Field(default=0.5, ge=0.1, le=0.9)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CrossModalAudioConfig(BaseModel):
|
|
14
|
+
master_volume: int = Field(default=70, ge=0, le=100)
|
|
15
|
+
digital_volume: int = Field(default=70, ge=0, le=100)
|
|
16
|
+
gain: float = Field(default=1.0, ge=0.0, le=4.0)
|
|
17
|
+
wav_rate_policy: Literal["resample", "error"] = "resample"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CrossModalTimingConfig(BaseModel):
|
|
21
|
+
fixation_ms: int = Field(default=300, ge=0, le=10_000)
|
|
22
|
+
trial_timeout_ms: int = Field(default=10_000, ge=1_000, le=120_000)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CrossModalConfig(BaseModel):
|
|
26
|
+
visual: CrossModalVisualConfig = Field(default_factory=CrossModalVisualConfig)
|
|
27
|
+
audio: CrossModalAudioConfig = Field(default_factory=CrossModalAudioConfig)
|
|
28
|
+
timing: CrossModalTimingConfig = Field(default_factory=CrossModalTimingConfig)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_cross_modal_config(path: Path = CROSS_MODAL_CONFIG_PATH) -> CrossModalConfig:
|
|
32
|
+
if not path.exists():
|
|
33
|
+
raise FileNotFoundError(f"Cross-modal config not found: {path}")
|
|
34
|
+
return CrossModalConfig.model_validate_json(path.read_text(encoding="utf-8"))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def save_cross_modal_config(
|
|
38
|
+
config: CrossModalConfig, path: Path = CROSS_MODAL_CONFIG_PATH
|
|
39
|
+
) -> None:
|
|
40
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
path.write_text(json.dumps(config.model_dump(), indent=4), encoding="utf-8")
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import wave
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from mxbi.utils.aplayer import SAMPLE_RATE
|
|
7
|
+
from numpy.typing import NDArray
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load_wav_as_int16(
|
|
11
|
+
path: Path,
|
|
12
|
+
*,
|
|
13
|
+
target_rate: int = SAMPLE_RATE,
|
|
14
|
+
rate_policy: Literal["resample", "error"] = "resample",
|
|
15
|
+
gain: float = 1.0,
|
|
16
|
+
) -> NDArray[np.int16]:
|
|
17
|
+
with wave.open(str(path), "rb") as wf:
|
|
18
|
+
frames = wf.readframes(wf.getnframes())
|
|
19
|
+
sample_width = wf.getsampwidth()
|
|
20
|
+
channel_count = wf.getnchannels()
|
|
21
|
+
source_rate = wf.getframerate()
|
|
22
|
+
|
|
23
|
+
if sample_width != 2:
|
|
24
|
+
raise ValueError(f"Only 16-bit WAVs supported, got sampwidth={sample_width}")
|
|
25
|
+
|
|
26
|
+
data = np.frombuffer(frames, dtype=np.int16)
|
|
27
|
+
|
|
28
|
+
if channel_count > 1:
|
|
29
|
+
data = data.reshape(-1, channel_count).mean(axis=1).astype(np.int16)
|
|
30
|
+
|
|
31
|
+
float_data = (data.astype(np.float32)) / float(np.iinfo(np.int16).max)
|
|
32
|
+
|
|
33
|
+
if source_rate != target_rate:
|
|
34
|
+
if rate_policy == "error":
|
|
35
|
+
raise ValueError(
|
|
36
|
+
f"WAV sample rate mismatch: wav={source_rate}Hz, expected={target_rate}Hz"
|
|
37
|
+
)
|
|
38
|
+
float_data = _resample_1d(
|
|
39
|
+
float_data, source_rate=source_rate, target_rate=target_rate
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if gain != 1.0:
|
|
43
|
+
float_data = np.clip(float_data * gain, -1.0, 1.0)
|
|
44
|
+
|
|
45
|
+
return (float_data * float(np.iinfo(np.int16).max)).astype(np.int16)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _resample_1d(
|
|
49
|
+
signal: NDArray[np.float32], *, source_rate: int, target_rate: int
|
|
50
|
+
) -> NDArray[np.float32]:
|
|
51
|
+
if signal.size == 0:
|
|
52
|
+
return signal
|
|
53
|
+
|
|
54
|
+
target_length = int(round(signal.size * (target_rate / source_rate)))
|
|
55
|
+
if target_length <= 0:
|
|
56
|
+
return np.zeros(0, dtype=np.float32)
|
|
57
|
+
|
|
58
|
+
old_positions = np.linspace(0.0, 1.0, num=signal.size, endpoint=False)
|
|
59
|
+
new_positions = np.linspace(0.0, 1.0, num=target_length, endpoint=False)
|
|
60
|
+
resampled = np.interp(new_positions, old_positions, signal).astype(np.float32)
|
|
61
|
+
return resampled
|