nucleuskit-toolkit 2.2.0__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 (59) hide show
  1. nucleuskit_toolkit/__init__.py +3 -0
  2. nucleuskit_toolkit/__main__.py +60 -0
  3. nucleuskit_toolkit/app.py +34 -0
  4. nucleuskit_toolkit/camera/__init__.py +9 -0
  5. nucleuskit_toolkit/camera/processing.py +374 -0
  6. nucleuskit_toolkit/config.py +86 -0
  7. nucleuskit_toolkit/hermes/__init__.py +0 -0
  8. nucleuskit_toolkit/hermes/constants.py +77 -0
  9. nucleuskit_toolkit/hermes/realtime/__init__.py +8 -0
  10. nucleuskit_toolkit/hermes/realtime/proxy.py +652 -0
  11. nucleuskit_toolkit/logging_utils.py +68 -0
  12. nucleuskit_toolkit/mqtt/__init__.py +5 -0
  13. nucleuskit_toolkit/mqtt/mqtt_controller.py +256 -0
  14. nucleuskit_toolkit/pipeline.py +194 -0
  15. nucleuskit_toolkit/position/__init__.py +5 -0
  16. nucleuskit_toolkit/position/gps_processor.py +91 -0
  17. nucleuskit_toolkit/session_job.py +28 -0
  18. nucleuskit_toolkit/shimmer/__init__.py +5 -0
  19. nucleuskit_toolkit/shimmer/constants.py +32 -0
  20. nucleuskit_toolkit/shimmer/realtime/__init__.py +5 -0
  21. nucleuskit_toolkit/shimmer/realtime/proxy.py +279 -0
  22. nucleuskit_toolkit/tools/__init__.py +1 -0
  23. nucleuskit_toolkit/tools/eeg_regression.py +91 -0
  24. nucleuskit_toolkit/tools/recording_merger.py +437 -0
  25. nucleuskit_toolkit/ui/__init__.py +1 -0
  26. nucleuskit_toolkit/ui/icons.py +165 -0
  27. nucleuskit_toolkit/ui/main_window.py +465 -0
  28. nucleuskit_toolkit/ui/offline_job.py +144 -0
  29. nucleuskit_toolkit/ui/pages/__init__.py +1 -0
  30. nucleuskit_toolkit/ui/pages/about_page.py +88 -0
  31. nucleuskit_toolkit/ui/pages/channel_fixer_page.py +205 -0
  32. nucleuskit_toolkit/ui/pages/channel_gain_page.py +362 -0
  33. nucleuskit_toolkit/ui/pages/eeg_regression_page.py +385 -0
  34. nucleuskit_toolkit/ui/pages/mqtt_controller_page.py +394 -0
  35. nucleuskit_toolkit/ui/pages/offline_page.py +754 -0
  36. nucleuskit_toolkit/ui/pages/playback_page.py +1524 -0
  37. nucleuskit_toolkit/ui/pages/ppg_fixer_page.py +199 -0
  38. nucleuskit_toolkit/ui/pages/realtime_viewer_page.py +954 -0
  39. nucleuskit_toolkit/ui/pages/recording_merge_page.py +395 -0
  40. nucleuskit_toolkit/ui/pages/revert_original_page.py +128 -0
  41. nucleuskit_toolkit/ui/pages/settings_page.py +69 -0
  42. nucleuskit_toolkit/ui/pages/tools_menu_page.py +42 -0
  43. nucleuskit_toolkit/ui/plot_style.py +155 -0
  44. nucleuskit_toolkit/ui/realtime_eeg_plot.py +244 -0
  45. nucleuskit_toolkit/ui/realtime_hermes_motion_plot.py +162 -0
  46. nucleuskit_toolkit/ui/realtime_nlms_config_dialog.py +101 -0
  47. nucleuskit_toolkit/ui/realtime_shimmer_plot.py +135 -0
  48. nucleuskit_toolkit/ui/resources/__init__.py +1 -0
  49. nucleuskit_toolkit/ui/resources/branding/__init__.py +1 -0
  50. nucleuskit_toolkit/ui/resources/branding/logo.png +0 -0
  51. nucleuskit_toolkit/ui/session_inspect.py +226 -0
  52. nucleuskit_toolkit/ui/theme.py +330 -0
  53. nucleuskit_toolkit/ui/widgets.py +190 -0
  54. nucleuskit_toolkit-2.2.0.dist-info/METADATA +351 -0
  55. nucleuskit_toolkit-2.2.0.dist-info/RECORD +59 -0
  56. nucleuskit_toolkit-2.2.0.dist-info/WHEEL +5 -0
  57. nucleuskit_toolkit-2.2.0.dist-info/entry_points.txt +2 -0
  58. nucleuskit_toolkit-2.2.0.dist-info/licenses/LICENSE +675 -0
  59. nucleuskit_toolkit-2.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ """Nucleus-Kit offline processing toolkit (``python -m nucleuskit_toolkit``)."""
2
+
3
+ __version__ = "2.2.0"
@@ -0,0 +1,60 @@
1
+ """Entry point: ``python -m nucleuskit_toolkit`` or ``nucleuskit-toolkit`` CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ import matplotlib
8
+
9
+ matplotlib.use("Agg")
10
+
11
+
12
+ def main(argv: list[str] | None = None) -> int:
13
+ from reak_core.models import DEFAULT_MODEL, available_models
14
+
15
+ from nucleuskit_toolkit.logging_utils import configure_logging
16
+
17
+ configure_logging()
18
+
19
+ parser = argparse.ArgumentParser(
20
+ description="Nucleus-Kit offline processing toolkit (Hermes EEG/EMG, Shimmer PPG/EDA)."
21
+ )
22
+ parser.add_argument(
23
+ "--session",
24
+ metavar="DIR",
25
+ help="Process this session folder and exit (no GUI).",
26
+ )
27
+ parser.add_argument(
28
+ "--config",
29
+ metavar="JSON",
30
+ help="Optional POV / ffmpeg JSON (merged after cwd defaults).",
31
+ )
32
+
33
+ parser.add_argument(
34
+ "--emotion-model",
35
+ metavar="NAME",
36
+ default=DEFAULT_MODEL,
37
+ help=(
38
+ "Emotion model registry key "
39
+ f"(default: {DEFAULT_MODEL}). Available: {', '.join(available_models())}."
40
+ ),
41
+ )
42
+ ns = parser.parse_args(argv)
43
+
44
+ if ns.session:
45
+ from nucleuskit_toolkit.pipeline import NucleusKitProcessingToolkit
46
+ from nucleuskit_toolkit.session_job import session_job_from_folder
47
+
48
+ job = session_job_from_folder(ns.session, pov_config_json=ns.config)
49
+ pipe = NucleusKitProcessingToolkit(None, emotion_model=ns.emotion_model)
50
+ pipe.processSession(job)
51
+ return 0
52
+
53
+ from nucleuskit_toolkit.app import run_app
54
+
55
+ run_app()
56
+ return 0
57
+
58
+
59
+ if __name__ == "__main__":
60
+ raise SystemExit(main())
@@ -0,0 +1,34 @@
1
+ """
2
+ PySide6 GUI: sidebar navigation between processing, real-time viewer, playback, tools, MQTT, settings and about.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import sys
8
+
9
+
10
+ def run_app() -> None:
11
+ try:
12
+ from PySide6.QtCore import QTimer
13
+ from PySide6.QtWidgets import QApplication
14
+ except ImportError as e:
15
+ raise SystemExit(
16
+ "PySide6 is required for the graphical interface. "
17
+ "Install with: pip install 'nucleuskit-toolkit[gui]'"
18
+ ) from e
19
+
20
+ from nucleuskit_toolkit.ui.main_window import MainWindow
21
+ from nucleuskit_toolkit.ui.pages.settings_page import load_theme_setting
22
+ from nucleuskit_toolkit.ui.theme import apply_theme
23
+
24
+ app = QApplication(sys.argv)
25
+ app.setOrganizationName("REAK")
26
+ app.setApplicationName("NucleusKitToolkit")
27
+
28
+ apply_theme(app, load_theme_setting())
29
+
30
+ window = MainWindow()
31
+ window.show()
32
+ # Load the pages' libraries in the background once the window is on screen.
33
+ QTimer.singleShot(300, window.start_warmup)
34
+ sys.exit(app.exec())
@@ -0,0 +1,9 @@
1
+ """Camera and video processing: POV conversion, session video rotation, and faststart."""
2
+
3
+ from nucleuskit_toolkit.camera.processing import (
4
+ convertPOVMovieClip,
5
+ ensure_session_video_rotated_180,
6
+ ensure_video_faststart,
7
+ )
8
+
9
+ __all__ = ["convertPOVMovieClip", "ensure_session_video_rotated_180", "ensure_video_faststart"]
@@ -0,0 +1,374 @@
1
+ """
2
+ Camera and video processing utilities.
3
+
4
+ Covers three operations that run as part of offline session processing or
5
+ playback preparation:
6
+
7
+ 1. ``convertPOVMovieClip`` — rename UUID POV file to MKV then re-mux to MP4.
8
+ 2. ``ensure_session_video_rotated_180`` — rotate rawData/video.mp4 180° in place
9
+ using ffmpeg (idempotent, guarded by a marker file).
10
+ 3. ``ensure_video_faststart`` — remux rawData/video.mp4 so the MOOV atom sits at
11
+ the front of the file (idempotent, guarded by a marker file). Required for
12
+ smooth Qt ``QMediaPlayer`` playback of large recordings.
13
+
14
+ All functions are thin ffmpeg wrappers; the shared ``_ffmpeg_executable``
15
+ helper resolves the binary from either an explicit directory or the system PATH
16
+ (with an optional fallback through the pipeline config).
17
+
18
+ Author(s):
19
+ RE-AK Technologies Inc.
20
+ Winter – Spring 2026
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ import shutil
28
+ import subprocess
29
+ import sys
30
+ from datetime import datetime, timezone
31
+
32
+ from nucleuskit_toolkit.config import resolve_pov_settings
33
+ from nucleuskit_toolkit.logging_utils import printInfo, printWarning
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Shared ffmpeg helper
37
+ # ---------------------------------------------------------------------------
38
+
39
+ def _ffmpeg_executable(ffmpeg_bin_dir: str | None = None) -> str | None:
40
+ """
41
+ Resolve the ffmpeg binary path.
42
+
43
+ Resolution order:
44
+ 1. ``ffmpeg_bin_dir`` if provided explicitly.
45
+ 2. ``ffmpeg_bin_dir`` from the pipeline config (``nucleuskit_toolkit_config.json``).
46
+ 3. System PATH via ``shutil.which``.
47
+
48
+ Returns the full path to the executable, or *None* if not found.
49
+ """
50
+ name = "ffmpeg.exe" if sys.platform.startswith("win") else "ffmpeg"
51
+
52
+ if ffmpeg_bin_dir:
53
+ candidate = os.path.join(ffmpeg_bin_dir, name)
54
+ if os.path.isfile(candidate):
55
+ return candidate
56
+
57
+ pov = resolve_pov_settings(None)
58
+ if pov and pov.ffmpeg_bin_dir:
59
+ candidate = os.path.join(pov.ffmpeg_bin_dir, name)
60
+ if os.path.isfile(candidate):
61
+ return candidate
62
+
63
+ return shutil.which("ffmpeg")
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # POV clip conversion (MKV → MP4)
68
+ # ---------------------------------------------------------------------------
69
+
70
+ def convertPOVMovieClip(
71
+ screen_id: str | None,
72
+ data_root: str | None,
73
+ ffmpeg_bin_dir: str | None,
74
+ ) -> None:
75
+ """
76
+ Convert a UUID-named POV camera recording from MKV to MP4.
77
+
78
+ The source file lives at ``<data_root>/<screen_id>`` (no extension).
79
+ It is first renamed to ``<screen_id>.mkv``, then re-muxed to
80
+ ``<screen_id>.mp4`` via ``ffmpeg -c copy``. On failure the original
81
+ name is restored.
82
+
83
+ Args:
84
+ screen_id: File base name (UUID, no extension) inside ``data_root``.
85
+ data_root: Directory containing the UUID-named POV file.
86
+ ffmpeg_bin_dir: Directory containing the ffmpeg executable.
87
+ """
88
+ if not screen_id or not data_root or not ffmpeg_bin_dir:
89
+ printWarning("[camera] Missing screen_id, data_root, or ffmpeg_bin_dir; skipping POV conversion")
90
+ return
91
+
92
+ ffmpeg = _ffmpeg_executable(ffmpeg_bin_dir)
93
+ if not ffmpeg:
94
+ printWarning(f"[camera] ffmpeg not found in {ffmpeg_bin_dir}; skipping POV conversion")
95
+ return
96
+
97
+ if not os.path.isdir(data_root):
98
+ printWarning("[camera] data_root not found; skipping POV conversion")
99
+ return
100
+
101
+ uuid_full = os.path.join(data_root, screen_id)
102
+ if not os.path.exists(uuid_full):
103
+ printWarning(f"[camera] Source file not found: {uuid_full}; skipping POV conversion")
104
+ return
105
+
106
+ mkv_full = os.path.join(data_root, screen_id + ".mkv")
107
+ mp4_full = os.path.join(data_root, screen_id + ".mp4")
108
+
109
+ if os.path.exists(mkv_full):
110
+ printInfo("[camera] POV conversion already done; skipping")
111
+ return
112
+
113
+ printInfo(f"[camera] Converting POV clip: {screen_id}")
114
+ os.rename(uuid_full, mkv_full)
115
+ try:
116
+ rc = subprocess.run(
117
+ [ffmpeg, "-i", mkv_full, "-c", "copy", mp4_full],
118
+ check=False,
119
+ ).returncode
120
+ if rc != 0 or not os.path.isfile(mp4_full):
121
+ printWarning("[camera] ffmpeg failed or produced no mp4; restoring original name")
122
+ if os.path.isfile(mkv_full):
123
+ os.rename(mkv_full, uuid_full)
124
+ return
125
+ os.rename(mp4_full, uuid_full)
126
+ except OSError as e:
127
+ printWarning(f"[camera] POV conversion failed: {e}")
128
+ if os.path.isfile(mkv_full) and not os.path.exists(uuid_full):
129
+ os.rename(mkv_full, uuid_full)
130
+ return
131
+
132
+ if os.path.isfile(mkv_full):
133
+ os.remove(mkv_full)
134
+
135
+ printInfo("[camera] POV conversion completed")
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # Session video rotation (rawData/video.mp4 → 180°)
140
+ # ---------------------------------------------------------------------------
141
+
142
+ # Written under <session>/rawData/ after a successful rotation (idempotent flag).
143
+ MARKER_FILENAME = "nucleuskit_video_rotated_180.json"
144
+
145
+ # Session video expected by the playback UI.
146
+ VIDEO_BASENAME = "video.mp4"
147
+
148
+ _MARKER_SCHEMA = "nucleuskit.video_rotation.v1"
149
+
150
+
151
+ def ensure_session_video_rotated_180(recpath: str) -> None:
152
+ """
153
+ Rotate ``rawData/video.mp4`` 180° in place using ffmpeg (idempotent).
154
+
155
+ If ``rawData/nucleuskit_video_rotated_180.json`` already exists the
156
+ function returns immediately, preserving any previous rotation and
157
+ avoiding redundant re-encoding on repeated pipeline runs.
158
+
159
+ If the video file or ffmpeg are absent, a warning is logged and the
160
+ function returns without error.
161
+
162
+ Args:
163
+ recpath: Root directory of the recording session.
164
+ """
165
+ recpath = os.path.abspath(recpath)
166
+ raw_data = os.path.join(recpath, "rawData")
167
+ marker_path = os.path.join(raw_data, MARKER_FILENAME)
168
+ video_path = os.path.join(raw_data, VIDEO_BASENAME)
169
+
170
+ if os.path.isfile(marker_path):
171
+ printInfo(f"[camera] Marker present ({MARKER_FILENAME}); skipping 180° rotation")
172
+ return
173
+
174
+ if not os.path.isdir(raw_data):
175
+ printWarning(f"[camera] No rawData folder at {raw_data}; skipping")
176
+ return
177
+
178
+ if not os.path.isfile(video_path):
179
+ printInfo(f"[camera] No {VIDEO_BASENAME} under rawData; nothing to rotate")
180
+ return
181
+
182
+ ffmpeg = _ffmpeg_executable()
183
+ if not ffmpeg:
184
+ printWarning(
185
+ "[camera] ffmpeg not found (configure ffmpeg_dir in "
186
+ "nucleuskit_toolkit_config.json or add it to PATH); skipping rotation"
187
+ )
188
+ return
189
+
190
+ tmp_path = os.path.join(raw_data, ".video_rot180_tmp.mp4")
191
+ printInfo(f"[camera] Rotating 180° in place: {video_path}")
192
+
193
+ base_cmd = [
194
+ ffmpeg,
195
+ "-hide_banner", "-loglevel", "error",
196
+ "-y", "-i", video_path,
197
+ "-vf", "hflip,vflip",
198
+ "-c:v", "libx264",
199
+ "-preset", "medium",
200
+ "-crf", "23",
201
+ "-movflags", "+faststart",
202
+ ]
203
+
204
+ def _run_with_audio() -> subprocess.CompletedProcess[str]:
205
+ return subprocess.run(base_cmd + ["-c:a", "copy", tmp_path],
206
+ check=False, capture_output=True, text=True)
207
+
208
+ def _run_video_only() -> subprocess.CompletedProcess[str]:
209
+ return subprocess.run(base_cmd + ["-an", tmp_path],
210
+ check=False, capture_output=True, text=True)
211
+
212
+ try:
213
+ completed = _run_with_audio()
214
+ if completed.returncode != 0:
215
+ completed = _run_video_only()
216
+ if completed.returncode != 0:
217
+ err = (completed.stderr or completed.stdout or "").strip()
218
+ printWarning(f"[camera] ffmpeg failed (exit {completed.returncode}): {err}")
219
+ return
220
+ if not os.path.isfile(tmp_path) or os.path.getsize(tmp_path) == 0:
221
+ printWarning("[camera] ffmpeg produced no output; aborting")
222
+ return
223
+
224
+ os.replace(tmp_path, video_path)
225
+
226
+ payload = {
227
+ "schema": _MARKER_SCHEMA,
228
+ "rotation_degrees": 180,
229
+ "media_file": VIDEO_BASENAME,
230
+ "completed_at": datetime.now(timezone.utc).isoformat(),
231
+ }
232
+ with open(marker_path, "w", encoding="utf-8") as f:
233
+ json.dump(payload, f, indent=2)
234
+ f.write("\n")
235
+
236
+ printInfo(f"[camera] Rotation complete; wrote marker {marker_path}")
237
+ except OSError as e:
238
+ printWarning(f"[camera] I/O error during rotation: {e}")
239
+ finally:
240
+ if os.path.isfile(tmp_path):
241
+ try:
242
+ os.remove(tmp_path)
243
+ except OSError:
244
+ pass
245
+
246
+
247
+ # ---------------------------------------------------------------------------
248
+ # Session video faststart (rawData/video.mp4 → MOOV atom at front)
249
+ # ---------------------------------------------------------------------------
250
+
251
+ _FASTSTART_MARKER_FILENAME = "nucleuskit_video_faststart.json"
252
+ _FASTSTART_MARKER_SCHEMA = "nucleuskit.video_faststart.v2"
253
+
254
+
255
+ def ensure_video_faststart(recpath: str) -> None:
256
+ """
257
+ Remux ``rawData/video.mp4`` so the MOOV atom is at the front and the video
258
+ track timescale is normalised to 30 000 ticks/sec.
259
+
260
+ ``QMediaPlayer`` on Windows (Windows Media Foundation backend) requires the
261
+ MOOV atom at the beginning of the file to seek and buffer large recordings
262
+ efficiently. Without it the player must scan the entire file before it can
263
+ decode any frame — which manifests as audio continuing while the video
264
+ rendering stalls partway through.
265
+
266
+ Some cameras record with a 1 000 000 ticks/sec (microsecond) timescale.
267
+ Because MP4 stores track timestamps as uint32, a microsecond-timescale file
268
+ wraps at exactly 2^32 µs = 71 min 35 s, causing WMF to stall video decode
269
+ at that point while audio continues. Setting ``-video_track_timescale 30000``
270
+ rescales all timestamps to 30 000 ticks/sec (stream copy, no quality loss),
271
+ which pushes the uint32 wrap out to ~39.8 hours.
272
+
273
+ The remux uses ``-c copy`` (no re-encoding) and completes in seconds
274
+ regardless of file size. The function is idempotent: if
275
+ ``rawData/nucleuskit_video_faststart.json`` exists **and** carries the
276
+ current schema version the function returns immediately. An older v1 marker
277
+ (faststart without timescale fix) triggers a one-time reprocess.
278
+
279
+ The rotation marker (``nucleuskit_video_rotated_180.json``) implies both
280
+ faststart and a full re-encode, so FFmpeg already resets the timescale; the
281
+ function short-circuits in that case.
282
+
283
+ If ``video.mp4`` or ffmpeg are absent a warning is logged and the function
284
+ returns without error.
285
+
286
+ Args:
287
+ recpath: Root directory of the recording session.
288
+ """
289
+ recpath = os.path.abspath(recpath)
290
+ raw_data = os.path.join(recpath, "rawData")
291
+ faststart_marker = os.path.join(raw_data, _FASTSTART_MARKER_FILENAME)
292
+ rotation_marker = os.path.join(raw_data, MARKER_FILENAME)
293
+ video_path = os.path.join(raw_data, VIDEO_BASENAME)
294
+
295
+ if os.path.isfile(faststart_marker):
296
+ try:
297
+ with open(faststart_marker, encoding="utf-8") as _f:
298
+ _marker_data = json.load(_f)
299
+ if _marker_data.get("schema") == _FASTSTART_MARKER_SCHEMA:
300
+ printInfo(f"[camera] Faststart marker v2 present ({_FASTSTART_MARKER_FILENAME}); skipping")
301
+ return
302
+ printInfo("[camera] Faststart marker is v1 (no timescale fix); reprocessing")
303
+ except Exception:
304
+ printInfo("[camera] Faststart marker unreadable; reprocessing")
305
+
306
+ # Rotation already applies +faststart; no remux needed.
307
+ if os.path.isfile(rotation_marker):
308
+ printInfo("[camera] Rotation marker implies faststart already applied; skipping")
309
+ return
310
+
311
+ if not os.path.isdir(raw_data):
312
+ printWarning(f"[camera] No rawData folder at {raw_data}; skipping faststart")
313
+ return
314
+
315
+ if not os.path.isfile(video_path):
316
+ printInfo(f"[camera] No {VIDEO_BASENAME} under rawData; nothing to prepare")
317
+ return
318
+
319
+ ffmpeg = _ffmpeg_executable()
320
+ if not ffmpeg:
321
+ printWarning(
322
+ "[camera] ffmpeg not found (configure ffmpeg_dir in "
323
+ "nucleuskit_toolkit_config.json or add it to PATH); skipping faststart"
324
+ )
325
+ return
326
+
327
+ tmp_path = os.path.join(raw_data, ".video_faststart_tmp.mp4")
328
+ printInfo(f"[camera] Applying faststart remux + timescale normalisation (stream copy): {video_path}")
329
+
330
+ try:
331
+ completed = subprocess.run(
332
+ [
333
+ ffmpeg,
334
+ "-hide_banner", "-loglevel", "error",
335
+ "-y", "-i", video_path,
336
+ "-c", "copy",
337
+ "-video_track_timescale", "30000",
338
+ "-movflags", "+faststart",
339
+ tmp_path,
340
+ ],
341
+ check=False,
342
+ capture_output=True,
343
+ text=True,
344
+ )
345
+
346
+ if completed.returncode != 0:
347
+ err = (completed.stderr or completed.stdout or "").strip()
348
+ printWarning(f"[camera] ffmpeg faststart failed (exit {completed.returncode}): {err}")
349
+ return
350
+
351
+ if not os.path.isfile(tmp_path) or os.path.getsize(tmp_path) == 0:
352
+ printWarning("[camera] ffmpeg faststart produced no output; aborting")
353
+ return
354
+
355
+ os.replace(tmp_path, video_path)
356
+
357
+ payload = {
358
+ "schema": _FASTSTART_MARKER_SCHEMA,
359
+ "media_file": VIDEO_BASENAME,
360
+ "completed_at": datetime.now(timezone.utc).isoformat(),
361
+ }
362
+ with open(faststart_marker, "w", encoding="utf-8") as f:
363
+ json.dump(payload, f, indent=2)
364
+ f.write("\n")
365
+
366
+ printInfo(f"[camera] Faststart remux complete; wrote marker {faststart_marker}")
367
+ except OSError as e:
368
+ printWarning(f"[camera] I/O error during faststart remux: {e}")
369
+ finally:
370
+ if os.path.isfile(tmp_path):
371
+ try:
372
+ os.remove(tmp_path)
373
+ except OSError:
374
+ pass
@@ -0,0 +1,86 @@
1
+ """
2
+ Optional POV / ffmpeg settings for the Nucleus-Kit processing toolkit.
3
+
4
+ Precedence: merge JSON from the working directory (legacy filenames in order, then
5
+ canonical filename), then ``extra_json_path``, then environment variables override each field.
6
+
7
+ Environment variables:
8
+ HERMES_POV_DATA_ROOT — directory containing the UUID-named POV file
9
+ HERMES_FFMPEG_DIR — directory containing the ffmpeg executable
10
+ HERMES_POV_SCREEN_ID — optional default screen / file id (no extension)
11
+
12
+ JSON keys (same semantics): pov_data_root, ffmpeg_dir, screen_id
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ from dataclasses import dataclass
20
+ from typing import Any, Mapping, Optional
21
+
22
+ DEFAULT_JSON_NAME = "nucleuskit_toolkit_config.json"
23
+ LEGACY_JSON_NAME = "hermes_standalone_config.json"
24
+ PIPELINE_LEGACY_JSON_NAME = "nucleuskit_pipeline_config.json"
25
+
26
+
27
+ @dataclass
28
+ class PovSettings:
29
+ data_root: Optional[str] = None
30
+ ffmpeg_bin_dir: Optional[str] = None
31
+ screen_id: Optional[str] = None
32
+
33
+
34
+ def _strip_str(value: Any) -> Optional[str]:
35
+ if value is None:
36
+ return None
37
+ if not isinstance(value, str):
38
+ return None
39
+ value = value.strip()
40
+ return value or None
41
+
42
+
43
+ def _load_json(path: str) -> dict[str, Any]:
44
+ with open(path, encoding="utf-8") as f:
45
+ data = json.load(f)
46
+ return data if isinstance(data, dict) else {}
47
+
48
+
49
+ def _merge_pov_dict(base: Mapping[str, Any], overlay: Mapping[str, Any]) -> dict[str, Any]:
50
+ out = dict(base)
51
+ for k, v in overlay.items():
52
+ if v is not None:
53
+ out[k] = v
54
+ return out
55
+
56
+
57
+ def resolve_pov_settings(extra_json_path: Optional[str] = None) -> PovSettings:
58
+ """
59
+ Merge optional JSON config from cwd (legacy then canonical), then extra_json_path,
60
+ then apply env overrides.
61
+ """
62
+ merged: dict[str, Any] = {}
63
+ legacy = os.path.join(os.getcwd(), LEGACY_JSON_NAME)
64
+ if os.path.isfile(legacy):
65
+ merged = _merge_pov_dict(merged, _load_json(legacy))
66
+ pipeline_legacy = os.path.join(os.getcwd(), PIPELINE_LEGACY_JSON_NAME)
67
+ if os.path.isfile(pipeline_legacy):
68
+ merged = _merge_pov_dict(merged, _load_json(pipeline_legacy))
69
+ cwd_json = os.path.join(os.getcwd(), DEFAULT_JSON_NAME)
70
+ if os.path.isfile(cwd_json):
71
+ merged = _merge_pov_dict(merged, _load_json(cwd_json))
72
+ if extra_json_path and os.path.isfile(extra_json_path):
73
+ merged = _merge_pov_dict(merged, _load_json(extra_json_path))
74
+
75
+ data_root = _strip_str(merged.get("pov_data_root"))
76
+ ffmpeg_dir = _strip_str(merged.get("ffmpeg_dir"))
77
+ screen_id = _strip_str(merged.get("screen_id"))
78
+
79
+ if _strip_str(os.environ.get("HERMES_POV_DATA_ROOT")):
80
+ data_root = _strip_str(os.environ.get("HERMES_POV_DATA_ROOT"))
81
+ if _strip_str(os.environ.get("HERMES_FFMPEG_DIR")):
82
+ ffmpeg_dir = _strip_str(os.environ.get("HERMES_FFMPEG_DIR"))
83
+ if _strip_str(os.environ.get("HERMES_POV_SCREEN_ID")):
84
+ screen_id = _strip_str(os.environ.get("HERMES_POV_SCREEN_ID"))
85
+
86
+ return PovSettings(data_root=data_root, ffmpeg_bin_dir=ffmpeg_dir, screen_id=screen_id)
File without changes
@@ -0,0 +1,77 @@
1
+ """Constants for the Hermes EEG/EMG headset hardware."""
2
+
3
+ from __future__ import annotations
4
+
5
+ # ---------------------------------------------------------------------------
6
+ # Hardware acquisition
7
+ # ---------------------------------------------------------------------------
8
+ SAMPLING_RATE: int = 250
9
+ """EEG/EMG acquisition rate in Hz."""
10
+
11
+ DISCONNECT_VALUE: float = 187500.0
12
+ """Raw ADC saturation value indicating an electrode disconnect."""
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Channel layout (canonical electrode names, index 0..7)
16
+ # ---------------------------------------------------------------------------
17
+ CHANNEL_NAMES: tuple[str, ...] = (
18
+ "AF8",
19
+ "AF7",
20
+ "CHEEK_R",
21
+ "CHEEK_L",
22
+ "EAR_R",
23
+ "AFz",
24
+ "BROW_L",
25
+ "NOSE",
26
+ )
27
+
28
+ # Names written to emotion feature files. Same hardware order as CHANNEL_NAMES
29
+ # and as raw columns ch1..ch8.
30
+ OUTPUT_RMS_COLUMNS: tuple[str, ...] = (
31
+ "HEAD_R_RMS",
32
+ "HEAD_L_RMS",
33
+ "CHEEK_R_RMS",
34
+ "CHEEK_L_RMS",
35
+ "EAR_R_RMS",
36
+ "FOREHEAD_L_RMS",
37
+ "BROW_RMS",
38
+ "NOSE_RMS",
39
+ )
40
+
41
+ CHANNELS: dict[str, int] = {name: i for i, name in enumerate(CHANNEL_NAMES)}
42
+ N_CHANNELS: int = len(CHANNEL_NAMES)
43
+
44
+ # Accepted raw / legacy headers → hardware index (ch1 = AF8 = HEAD_R_RMS).
45
+ _RAW_COLUMN_INDEX: dict[str, int] = {}
46
+ for _i, _electrode in enumerate(CHANNEL_NAMES):
47
+ _RAW_COLUMN_INDEX[_electrode.lower()] = _i
48
+ _RAW_COLUMN_INDEX[f"ch{_i + 1}"] = _i
49
+ _RAW_COLUMN_INDEX[OUTPUT_RMS_COLUMNS[_i].lower()] = _i
50
+ _RAW_COLUMN_INDEX.update(
51
+ {
52
+ "head_r": 0,
53
+ "head_l": 1,
54
+ "forehead_l": 5,
55
+ "brow": 6,
56
+ }
57
+ )
58
+
59
+
60
+ def hardware_channel_index(column_name: str) -> int | None:
61
+ """Map a raw or legacy column header to hardware index 0..7.
62
+
63
+ Accepts ``ch1``..``ch8``, electrode names (``AF8`` …), legacy labels
64
+ (``HEAD_R`` …), and the output RMS names (``HEAD_R_RMS`` …).
65
+ """
66
+ return _RAW_COLUMN_INDEX.get(str(column_name).strip().lower())
67
+
68
+
69
+ class HermesConstants:
70
+ """Backward-compatible namespace for existing ``HermesConstants.*`` imports."""
71
+
72
+ SAMPLING_RATE = SAMPLING_RATE
73
+ CHANNEL_NAMES = list(CHANNEL_NAMES)
74
+ OUTPUT_RMS_COLUMNS = list(OUTPUT_RMS_COLUMNS)
75
+ CHANNELS = CHANNELS
76
+ N_CHANNELS = N_CHANNELS
77
+ DISCONNECT_VALUE = DISCONNECT_VALUE
@@ -0,0 +1,8 @@
1
+ """Hermes headset BLE streaming (real-time)."""
2
+
3
+ from nucleuskit_toolkit.hermes.realtime.proxy import (
4
+ HERMES_NAME_SUBSTRING,
5
+ HermesBleProxy,
6
+ )
7
+
8
+ __all__ = ["HERMES_NAME_SUBSTRING", "HermesBleProxy"]