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.
Files changed (93) hide show
  1. mxbiflow/__init__.py +3 -0
  2. mxbiflow/assets/__init__.py +5 -0
  3. mxbiflow/assets/clicker.wav +0 -0
  4. mxbiflow/config_store.py +68 -0
  5. mxbiflow/data_logger.py +114 -0
  6. mxbiflow/default/__init__.py +4 -0
  7. mxbiflow/default/idle/assets/apple_v1.png +0 -0
  8. mxbiflow/default/idle/idle.py +57 -0
  9. mxbiflow/detector_bridge.py +87 -0
  10. mxbiflow/game.py +84 -0
  11. mxbiflow/infra/eventbus.py +31 -0
  12. mxbiflow/main.py +106 -0
  13. mxbiflow/models/animal.py +130 -0
  14. mxbiflow/models/reward.py +7 -0
  15. mxbiflow/models/session.py +145 -0
  16. mxbiflow/mxbiflow.py +43 -0
  17. mxbiflow/path.py +41 -0
  18. mxbiflow/scene/__init__.py +8 -0
  19. mxbiflow/scene/scene_manager.py +64 -0
  20. mxbiflow/scene/scene_protocol.py +22 -0
  21. mxbiflow/scheduler.py +90 -0
  22. mxbiflow/tasks/GNGSiD/models.py +70 -0
  23. mxbiflow/tasks/GNGSiD/stages/detect_stage/config.json +116 -0
  24. mxbiflow/tasks/GNGSiD/stages/detect_stage/detect_stage.py +161 -0
  25. mxbiflow/tasks/GNGSiD/stages/detect_stage/detect_stage_models.py +65 -0
  26. mxbiflow/tasks/GNGSiD/stages/discriminate_stage/config.json +70 -0
  27. mxbiflow/tasks/GNGSiD/stages/discriminate_stage/discriminate_stage.py +173 -0
  28. mxbiflow/tasks/GNGSiD/stages/discriminate_stage/discriminate_stage_models.py +80 -0
  29. mxbiflow/tasks/GNGSiD/stages/size_reduction_stage/config.json +83 -0
  30. mxbiflow/tasks/GNGSiD/stages/size_reduction_stage/size_reduction_models.py +58 -0
  31. mxbiflow/tasks/GNGSiD/stages/size_reduction_stage/size_reduction_stage.py +149 -0
  32. mxbiflow/tasks/GNGSiD/tasks/artifacts.py +13 -0
  33. mxbiflow/tasks/GNGSiD/tasks/detect/models.py +21 -0
  34. mxbiflow/tasks/GNGSiD/tasks/detect/scene.py +271 -0
  35. mxbiflow/tasks/GNGSiD/tasks/discriminate/discriminate_models.py +31 -0
  36. mxbiflow/tasks/GNGSiD/tasks/discriminate/discriminate_scene.py +336 -0
  37. mxbiflow/tasks/GNGSiD/tasks/touch/touch_models.py +17 -0
  38. mxbiflow/tasks/GNGSiD/tasks/touch/touch_scene.py +256 -0
  39. mxbiflow/tasks/GNGSiD/tasks/utils/targets.py +57 -0
  40. mxbiflow/tasks/cross_modal/bundle_dir.py +553 -0
  41. mxbiflow/tasks/cross_modal/config.py +41 -0
  42. mxbiflow/tasks/cross_modal/media.py +61 -0
  43. mxbiflow/tasks/cross_modal/models.py +57 -0
  44. mxbiflow/tasks/cross_modal/scene.py +252 -0
  45. mxbiflow/tasks/cross_modal/stage.py +218 -0
  46. mxbiflow/tasks/cross_modal/trial_io.py +23 -0
  47. mxbiflow/tasks/cross_modal/trial_schema.py +113 -0
  48. mxbiflow/tasks/default/error_task/error_scene.py +53 -0
  49. mxbiflow/tasks/default/idle_task/assets/apple_v1.png +0 -0
  50. mxbiflow/tasks/default/idle_task/idle_scene.py +85 -0
  51. mxbiflow/tasks/default/initial_habituation_training/README.md +188 -0
  52. mxbiflow/tasks/default/initial_habituation_training/stages/config.csv +7 -0
  53. mxbiflow/tasks/default/initial_habituation_training/stages/config.json +67 -0
  54. mxbiflow/tasks/default/initial_habituation_training/stages/initial_habituation_training_stage.py +172 -0
  55. mxbiflow/tasks/default/initial_habituation_training/stages/models.py +56 -0
  56. mxbiflow/tasks/default/initial_habituation_training/tasks/stay_to_reward/stay_to_reward.py +244 -0
  57. mxbiflow/tasks/default/initial_habituation_training/tasks/stay_to_reward/stay_to_reward_models.py +50 -0
  58. mxbiflow/tasks/task_protocol.py +26 -0
  59. mxbiflow/tasks/task_table.py +29 -0
  60. mxbiflow/tasks/two_alternative_choice/assets/starter.py +27 -0
  61. mxbiflow/tasks/two_alternative_choice/models.py +68 -0
  62. mxbiflow/tasks/two_alternative_choice/stages/size_reduction_stage/config.json +118 -0
  63. mxbiflow/tasks/two_alternative_choice/stages/size_reduction_stage/size_reduction_models.py +41 -0
  64. mxbiflow/tasks/two_alternative_choice/stages/size_reduction_stage/size_reduction_stage.py +122 -0
  65. mxbiflow/tasks/two_alternative_choice/tasks/touch/touch_models.py +19 -0
  66. mxbiflow/tasks/two_alternative_choice/tasks/touch/touch_scene.py +249 -0
  67. mxbiflow/timer/__init__.py +3 -0
  68. mxbiflow/timer/frame_timer.py +47 -0
  69. mxbiflow/timer/realtime_timer.py +0 -0
  70. mxbiflow/tmp_email.py +13 -0
  71. mxbiflow/ui/components/animal.py +87 -0
  72. mxbiflow/ui/components/baseconfig.py +68 -0
  73. mxbiflow/ui/components/card.py +18 -0
  74. mxbiflow/ui/components/device_card/__init__.py +17 -0
  75. mxbiflow/ui/components/device_card/detector/beambreak_detector_card.py +29 -0
  76. mxbiflow/ui/components/device_card/detector/fusion_detector.py +45 -0
  77. mxbiflow/ui/components/device_card/detector/mock_detector_card.py +20 -0
  78. mxbiflow/ui/components/device_card/detector/rfid_detector.py +40 -0
  79. mxbiflow/ui/components/device_card/device_card.py +67 -0
  80. mxbiflow/ui/components/device_card/rewarder/mock_rewarder_card.py +20 -0
  81. mxbiflow/ui/components/device_card/rewarder/rpi_gpio_rewarder.py +33 -0
  82. mxbiflow/ui/components/devices.py +183 -0
  83. mxbiflow/ui/components/dialog/__init__.py +3 -0
  84. mxbiflow/ui/components/dialog/add_devices_dialog.py +64 -0
  85. mxbiflow/ui/components/experiment_groups.py +122 -0
  86. mxbiflow/ui/experiment_panel.py +91 -0
  87. mxbiflow/ui/mxbi_panel.py +152 -0
  88. mxbiflow/utils/logger.py +19 -0
  89. mxbiflow/utils/serial.py +10 -0
  90. mxbiflow-0.1.1.dist-info/METADATA +168 -0
  91. mxbiflow-0.1.1.dist-info/RECORD +93 -0
  92. mxbiflow-0.1.1.dist-info/WHEEL +4 -0
  93. 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