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.
- nucleuskit_toolkit/__init__.py +3 -0
- nucleuskit_toolkit/__main__.py +60 -0
- nucleuskit_toolkit/app.py +34 -0
- nucleuskit_toolkit/camera/__init__.py +9 -0
- nucleuskit_toolkit/camera/processing.py +374 -0
- nucleuskit_toolkit/config.py +86 -0
- nucleuskit_toolkit/hermes/__init__.py +0 -0
- nucleuskit_toolkit/hermes/constants.py +77 -0
- nucleuskit_toolkit/hermes/realtime/__init__.py +8 -0
- nucleuskit_toolkit/hermes/realtime/proxy.py +652 -0
- nucleuskit_toolkit/logging_utils.py +68 -0
- nucleuskit_toolkit/mqtt/__init__.py +5 -0
- nucleuskit_toolkit/mqtt/mqtt_controller.py +256 -0
- nucleuskit_toolkit/pipeline.py +194 -0
- nucleuskit_toolkit/position/__init__.py +5 -0
- nucleuskit_toolkit/position/gps_processor.py +91 -0
- nucleuskit_toolkit/session_job.py +28 -0
- nucleuskit_toolkit/shimmer/__init__.py +5 -0
- nucleuskit_toolkit/shimmer/constants.py +32 -0
- nucleuskit_toolkit/shimmer/realtime/__init__.py +5 -0
- nucleuskit_toolkit/shimmer/realtime/proxy.py +279 -0
- nucleuskit_toolkit/tools/__init__.py +1 -0
- nucleuskit_toolkit/tools/eeg_regression.py +91 -0
- nucleuskit_toolkit/tools/recording_merger.py +437 -0
- nucleuskit_toolkit/ui/__init__.py +1 -0
- nucleuskit_toolkit/ui/icons.py +165 -0
- nucleuskit_toolkit/ui/main_window.py +465 -0
- nucleuskit_toolkit/ui/offline_job.py +144 -0
- nucleuskit_toolkit/ui/pages/__init__.py +1 -0
- nucleuskit_toolkit/ui/pages/about_page.py +88 -0
- nucleuskit_toolkit/ui/pages/channel_fixer_page.py +205 -0
- nucleuskit_toolkit/ui/pages/channel_gain_page.py +362 -0
- nucleuskit_toolkit/ui/pages/eeg_regression_page.py +385 -0
- nucleuskit_toolkit/ui/pages/mqtt_controller_page.py +394 -0
- nucleuskit_toolkit/ui/pages/offline_page.py +754 -0
- nucleuskit_toolkit/ui/pages/playback_page.py +1524 -0
- nucleuskit_toolkit/ui/pages/ppg_fixer_page.py +199 -0
- nucleuskit_toolkit/ui/pages/realtime_viewer_page.py +954 -0
- nucleuskit_toolkit/ui/pages/recording_merge_page.py +395 -0
- nucleuskit_toolkit/ui/pages/revert_original_page.py +128 -0
- nucleuskit_toolkit/ui/pages/settings_page.py +69 -0
- nucleuskit_toolkit/ui/pages/tools_menu_page.py +42 -0
- nucleuskit_toolkit/ui/plot_style.py +155 -0
- nucleuskit_toolkit/ui/realtime_eeg_plot.py +244 -0
- nucleuskit_toolkit/ui/realtime_hermes_motion_plot.py +162 -0
- nucleuskit_toolkit/ui/realtime_nlms_config_dialog.py +101 -0
- nucleuskit_toolkit/ui/realtime_shimmer_plot.py +135 -0
- nucleuskit_toolkit/ui/resources/__init__.py +1 -0
- nucleuskit_toolkit/ui/resources/branding/__init__.py +1 -0
- nucleuskit_toolkit/ui/resources/branding/logo.png +0 -0
- nucleuskit_toolkit/ui/session_inspect.py +226 -0
- nucleuskit_toolkit/ui/theme.py +330 -0
- nucleuskit_toolkit/ui/widgets.py +190 -0
- nucleuskit_toolkit-2.2.0.dist-info/METADATA +351 -0
- nucleuskit_toolkit-2.2.0.dist-info/RECORD +59 -0
- nucleuskit_toolkit-2.2.0.dist-info/WHEEL +5 -0
- nucleuskit_toolkit-2.2.0.dist-info/entry_points.txt +2 -0
- nucleuskit_toolkit-2.2.0.dist-info/licenses/LICENSE +675 -0
- nucleuskit_toolkit-2.2.0.dist-info/top_level.txt +1 -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
|