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,68 @@
|
|
|
1
|
+
"""Console logging helpers used across the pipeline (GUI-safe, no ANSI when not a TTY)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
_LOGGER = logging.getLogger("nucleuskit_toolkit")
|
|
9
|
+
_CORE_LOGGER = logging.getLogger("reak_core") # processing messages of re-ak-core
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _AnsiAwareHandler(logging.StreamHandler):
|
|
13
|
+
"""Write to the current ``sys.stdout``.
|
|
14
|
+
|
|
15
|
+
The stream is resolved on every record so a GUI worker can redirect
|
|
16
|
+
stdout into the on-screen log after this handler was created.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
20
|
+
try:
|
|
21
|
+
msg = self.format(record)
|
|
22
|
+
stream = sys.stdout
|
|
23
|
+
if stream is None:
|
|
24
|
+
return
|
|
25
|
+
self.stream = stream
|
|
26
|
+
if hasattr(stream, "isatty") and stream.isatty():
|
|
27
|
+
if record.levelno >= logging.ERROR:
|
|
28
|
+
msg = f"\033[91m{msg}\033[0m"
|
|
29
|
+
elif record.levelno >= logging.WARNING:
|
|
30
|
+
msg = f"\033[93m{msg}\033[0m"
|
|
31
|
+
stream.write(msg + self.terminator)
|
|
32
|
+
self.flush()
|
|
33
|
+
except Exception:
|
|
34
|
+
self.handleError(record)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def configure_logging(level: int = logging.INFO) -> None:
|
|
38
|
+
"""Attach a single stream handler to the toolkit and re-ak-core loggers if none exist (idempotent)."""
|
|
39
|
+
for logger in (_LOGGER, _CORE_LOGGER):
|
|
40
|
+
if logger.handlers:
|
|
41
|
+
logger.setLevel(level)
|
|
42
|
+
continue
|
|
43
|
+
handler = _AnsiAwareHandler(sys.stdout)
|
|
44
|
+
handler.setFormatter(logging.Formatter("%(message)s"))
|
|
45
|
+
logger.addHandler(handler)
|
|
46
|
+
logger.setLevel(level)
|
|
47
|
+
logger.propagate = False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def printInfo(message: object) -> None:
|
|
51
|
+
configure_logging()
|
|
52
|
+
_LOGGER.info("%s", message)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def printWarning(message: object) -> None:
|
|
56
|
+
configure_logging()
|
|
57
|
+
_LOGGER.warning("%s", message)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def printError(message: object) -> None:
|
|
61
|
+
configure_logging()
|
|
62
|
+
_LOGGER.error("%s", message)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class bcolors:
|
|
66
|
+
WARNING = "\033[93m"
|
|
67
|
+
FAIL = "\033[91m"
|
|
68
|
+
ENDC = "\033[0m"
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MQTT controller for Vizia Mobile Android devices.
|
|
3
|
+
|
|
4
|
+
Connects to a user-configured MQTT broker and drives recording sessions via
|
|
5
|
+
the Vizia Mobile MQTT interface (see doc/mqtt_interface.md for the full contract).
|
|
6
|
+
|
|
7
|
+
Thread-safety: paho-mqtt runs its own network loop in a background thread.
|
|
8
|
+
All public methods are safe to call from any thread. GUI consumers should
|
|
9
|
+
connect to the Qt signals defined in MqttBridge instead of calling these
|
|
10
|
+
methods directly from UI callbacks.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Callable
|
|
20
|
+
|
|
21
|
+
DEFAULT_BROKER_PORT = 1883
|
|
22
|
+
DEFAULT_STATUS_TOPIC = "vizia/status"
|
|
23
|
+
DEFAULT_COMMAND_TOPIC_SUFFIX = "control/"
|
|
24
|
+
|
|
25
|
+
TARGET_ALL = "__ALL__"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class DeviceStatus:
|
|
30
|
+
device_id: str
|
|
31
|
+
recording: bool = False
|
|
32
|
+
battery: int = -1
|
|
33
|
+
last_seen: float = field(default_factory=time.time)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class MqttController:
|
|
37
|
+
"""
|
|
38
|
+
Manages a single MQTT connection to a Vizia Mobile broker.
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
broker_host : str
|
|
43
|
+
IP address or hostname of the MQTT broker.
|
|
44
|
+
broker_port : int
|
|
45
|
+
TCP port of the broker (default 1883).
|
|
46
|
+
status_topic : str
|
|
47
|
+
Topic on which devices publish status packets (default "vizia/status").
|
|
48
|
+
command_topic_suffix : str
|
|
49
|
+
Suffix appended after ``{recorderId}/`` when publishing commands
|
|
50
|
+
(default "control/").
|
|
51
|
+
on_log : Callable[[str], None] | None
|
|
52
|
+
Called from the paho network thread with human-readable log lines.
|
|
53
|
+
on_device_update : Callable[[DeviceStatus], None] | None
|
|
54
|
+
Called whenever a device status packet is received or updated.
|
|
55
|
+
on_connected : Callable[[], None] | None
|
|
56
|
+
Called once the broker connection is established.
|
|
57
|
+
on_disconnected : Callable[[], None] | None
|
|
58
|
+
Called when the connection drops.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
broker_host: str,
|
|
64
|
+
broker_port: int = DEFAULT_BROKER_PORT,
|
|
65
|
+
status_topic: str = DEFAULT_STATUS_TOPIC,
|
|
66
|
+
command_topic_suffix: str = DEFAULT_COMMAND_TOPIC_SUFFIX,
|
|
67
|
+
on_log: Callable[[str], None] | None = None,
|
|
68
|
+
on_device_update: Callable[[DeviceStatus], None] | None = None,
|
|
69
|
+
on_connected: Callable[[], None] | None = None,
|
|
70
|
+
on_disconnected: Callable[[], None] | None = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
self._broker_host = broker_host.strip()
|
|
73
|
+
self._broker_port = broker_port
|
|
74
|
+
self._status_topic = status_topic
|
|
75
|
+
self._command_topic_suffix = command_topic_suffix
|
|
76
|
+
self._on_log = on_log
|
|
77
|
+
self._on_device_update = on_device_update
|
|
78
|
+
self._on_connected = on_connected
|
|
79
|
+
self._on_disconnected = on_disconnected
|
|
80
|
+
|
|
81
|
+
self._devices: dict[str, DeviceStatus] = {}
|
|
82
|
+
self._devices_lock = threading.Lock()
|
|
83
|
+
self._connected = False
|
|
84
|
+
self._client = None
|
|
85
|
+
|
|
86
|
+
# ── Public properties ────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def is_connected(self) -> bool:
|
|
90
|
+
return self._connected
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def discovered_devices(self) -> list[DeviceStatus]:
|
|
94
|
+
with self._devices_lock:
|
|
95
|
+
return list(self._devices.values())
|
|
96
|
+
|
|
97
|
+
# ── Connection management ────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
def connect(self) -> None:
|
|
100
|
+
"""Connect to the broker and start the background network loop."""
|
|
101
|
+
try:
|
|
102
|
+
import paho.mqtt.client as mqtt
|
|
103
|
+
except ImportError as exc:
|
|
104
|
+
raise RuntimeError(
|
|
105
|
+
"paho-mqtt is required for the MQTT controller. "
|
|
106
|
+
"Install with: pip install 'nucleuskit-toolkit[gui]'"
|
|
107
|
+
) from exc
|
|
108
|
+
|
|
109
|
+
self._log(f"Connecting to broker {self._broker_host}:{self._broker_port} …")
|
|
110
|
+
|
|
111
|
+
client = mqtt.Client(
|
|
112
|
+
client_id=f"nucleuskit-{int(time.time())}",
|
|
113
|
+
protocol=mqtt.MQTTv311,
|
|
114
|
+
clean_session=True,
|
|
115
|
+
)
|
|
116
|
+
client.on_connect = self._on_mqtt_connect
|
|
117
|
+
client.on_disconnect = self._on_mqtt_disconnect
|
|
118
|
+
client.on_message = self._on_mqtt_message
|
|
119
|
+
client.on_log = self._on_mqtt_log
|
|
120
|
+
|
|
121
|
+
client.connect_async(self._broker_host, self._broker_port, keepalive=60)
|
|
122
|
+
client.loop_start()
|
|
123
|
+
self._client = client
|
|
124
|
+
|
|
125
|
+
def disconnect(self) -> None:
|
|
126
|
+
"""Gracefully disconnect from the broker."""
|
|
127
|
+
if self._client is None:
|
|
128
|
+
return
|
|
129
|
+
self._log("Disconnecting from broker …")
|
|
130
|
+
try:
|
|
131
|
+
self._client.loop_stop()
|
|
132
|
+
self._client.disconnect()
|
|
133
|
+
except Exception as exc:
|
|
134
|
+
self._log(f"[WARN] Error during disconnect: {exc}")
|
|
135
|
+
self._client = None
|
|
136
|
+
self._connected = False
|
|
137
|
+
|
|
138
|
+
# ── Command publishing ───────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
def send_start_recording(self, device_id: str = TARGET_ALL) -> None:
|
|
141
|
+
"""Send START_RECORDING to one device or all discovered devices."""
|
|
142
|
+
payload = json.dumps({"Type": "COMMAND", "Content": {"Code": "START_RECORDING"}})
|
|
143
|
+
self._publish_to(device_id, payload, label="START_RECORDING")
|
|
144
|
+
|
|
145
|
+
def send_stop_recording(self, device_id: str = TARGET_ALL) -> None:
|
|
146
|
+
"""Send STOP_RECORDING to one device or all discovered devices."""
|
|
147
|
+
payload = json.dumps({"Type": "COMMAND", "Content": {"Code": "STOP_RECORDING"}})
|
|
148
|
+
self._publish_to(device_id, payload, label="STOP_RECORDING")
|
|
149
|
+
|
|
150
|
+
def send_tag(self, code: str, values: str = "", device_id: str = TARGET_ALL) -> None:
|
|
151
|
+
"""Inject an event marker into the active recording session."""
|
|
152
|
+
payload = json.dumps({"Type": "TAG", "Content": {"Code": code, "Values": values}})
|
|
153
|
+
self._publish_to(device_id, payload, label=f"TAG:{code}")
|
|
154
|
+
|
|
155
|
+
# ── Internal helpers ─────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
def _publish_to(self, device_id: str, payload: str, label: str) -> None:
|
|
158
|
+
if not self._connected or self._client is None:
|
|
159
|
+
self._log("[WARN] Not connected — command not sent.")
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
if device_id == TARGET_ALL:
|
|
163
|
+
with self._devices_lock:
|
|
164
|
+
targets = list(self._devices.keys())
|
|
165
|
+
if not targets:
|
|
166
|
+
self._log("[WARN] No devices discovered yet — command not sent.")
|
|
167
|
+
return
|
|
168
|
+
for did in targets:
|
|
169
|
+
topic = f"{did}/{self._command_topic_suffix}"
|
|
170
|
+
self._client.publish(topic, payload, qos=0)
|
|
171
|
+
self._log(f"→ [{label}] → {topic}")
|
|
172
|
+
else:
|
|
173
|
+
topic = f"{device_id}/{self._command_topic_suffix}"
|
|
174
|
+
self._client.publish(topic, payload, qos=0)
|
|
175
|
+
self._log(f"→ [{label}] → {topic}")
|
|
176
|
+
|
|
177
|
+
def _log(self, msg: str) -> None:
|
|
178
|
+
if self._on_log is not None:
|
|
179
|
+
self._on_log(msg)
|
|
180
|
+
|
|
181
|
+
# ── paho callbacks (run on the paho network thread) ──────────────────
|
|
182
|
+
|
|
183
|
+
def _on_mqtt_connect(self, client, userdata, flags, rc) -> None: # type: ignore[no-untyped-def]
|
|
184
|
+
import paho.mqtt.client as mqtt
|
|
185
|
+
|
|
186
|
+
if rc == mqtt.CONNACK_ACCEPTED:
|
|
187
|
+
self._connected = True
|
|
188
|
+
self._log(f"✓ Connected to {self._broker_host}:{self._broker_port}")
|
|
189
|
+
self._log(f" Subscribing to status topic: {self._status_topic}")
|
|
190
|
+
client.subscribe(self._status_topic, qos=0)
|
|
191
|
+
self._log(" Ready — waiting for device status packets …")
|
|
192
|
+
if self._on_connected:
|
|
193
|
+
self._on_connected()
|
|
194
|
+
else:
|
|
195
|
+
reason = {
|
|
196
|
+
1: "unacceptable protocol version",
|
|
197
|
+
2: "identifier rejected",
|
|
198
|
+
3: "server unavailable",
|
|
199
|
+
4: "bad username or password",
|
|
200
|
+
5: "not authorised",
|
|
201
|
+
}.get(rc, f"unknown reason code {rc}")
|
|
202
|
+
self._log(f"✗ Connection refused: {reason}")
|
|
203
|
+
|
|
204
|
+
def _on_mqtt_disconnect(self, client, userdata, rc) -> None: # type: ignore[no-untyped-def]
|
|
205
|
+
was_connected = self._connected
|
|
206
|
+
self._connected = False
|
|
207
|
+
if rc == 0:
|
|
208
|
+
self._log("Disconnected cleanly.")
|
|
209
|
+
else:
|
|
210
|
+
self._log(f"[WARN] Unexpected disconnect (rc={rc}) — paho will attempt reconnect …")
|
|
211
|
+
if was_connected and self._on_disconnected:
|
|
212
|
+
self._on_disconnected()
|
|
213
|
+
|
|
214
|
+
def _on_mqtt_message(self, client, userdata, msg) -> None: # type: ignore[no-untyped-def]
|
|
215
|
+
try:
|
|
216
|
+
payload = msg.payload.decode("utf-8")
|
|
217
|
+
data = json.loads(payload)
|
|
218
|
+
device_id = str(data.get("id", "device"))
|
|
219
|
+
recording = bool(data.get("recording", False))
|
|
220
|
+
battery = int(data.get("battery", -1))
|
|
221
|
+
|
|
222
|
+
with self._devices_lock:
|
|
223
|
+
existing = self._devices.get(device_id)
|
|
224
|
+
is_new = existing is None
|
|
225
|
+
status = DeviceStatus(
|
|
226
|
+
device_id=device_id,
|
|
227
|
+
recording=recording,
|
|
228
|
+
battery=battery,
|
|
229
|
+
last_seen=time.time(),
|
|
230
|
+
)
|
|
231
|
+
self._devices[device_id] = status
|
|
232
|
+
|
|
233
|
+
if is_new:
|
|
234
|
+
self._log(
|
|
235
|
+
f" ● New device discovered: '{device_id}' "
|
|
236
|
+
f"(recording={recording}, battery={battery}%)"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
if self._on_device_update:
|
|
240
|
+
self._on_device_update(status)
|
|
241
|
+
|
|
242
|
+
except (json.JSONDecodeError, KeyError, ValueError) as exc:
|
|
243
|
+
self._log(f"[WARN] Malformed status packet on {msg.topic}: {exc}")
|
|
244
|
+
|
|
245
|
+
def _on_mqtt_log(self, client, userdata, level, buf) -> None: # type: ignore[no-untyped-def]
|
|
246
|
+
import paho.mqtt.client as mqtt
|
|
247
|
+
|
|
248
|
+
if level in (mqtt.MQTT_LOG_DEBUG,):
|
|
249
|
+
return
|
|
250
|
+
prefix = {
|
|
251
|
+
mqtt.MQTT_LOG_INFO: "[paho:INFO]",
|
|
252
|
+
mqtt.MQTT_LOG_NOTICE: "[paho:NOTICE]",
|
|
253
|
+
mqtt.MQTT_LOG_WARNING: "[paho:WARN]",
|
|
254
|
+
mqtt.MQTT_LOG_ERR: "[paho:ERR]",
|
|
255
|
+
}.get(level, "[paho]")
|
|
256
|
+
self._log(f"{prefix} {buf}")
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Nucleus-Kit analytics toolkit (offline session processing).
|
|
3
|
+
|
|
4
|
+
The processing itself is re-ak-core's default pipeline (``reak_core.pipeline``): heart dynamics,
|
|
5
|
+
arousal, cognition, emotions, events and the emotions report. Nucleus-Kit adds its local steps
|
|
6
|
+
around it: POV movie conversion, session video rotation and GPS.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import threading
|
|
12
|
+
from abc import ABC, abstractmethod
|
|
13
|
+
|
|
14
|
+
from reak_core.io.session import Session
|
|
15
|
+
from reak_core.pipeline import FunctionStep, Pipeline, RunContext, StepResult
|
|
16
|
+
|
|
17
|
+
from nucleuskit_toolkit.camera import convertPOVMovieClip, ensure_session_video_rotated_180
|
|
18
|
+
from nucleuskit_toolkit.config import resolve_pov_settings
|
|
19
|
+
from nucleuskit_toolkit.logging_utils import printError, printInfo, printWarning
|
|
20
|
+
from nucleuskit_toolkit.position import processGPS
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BasePipeline(ABC):
|
|
24
|
+
"""Minimal base from analyticsEngine.pipelines.BasePipeline."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, configuration=None):
|
|
27
|
+
self.configured = False
|
|
28
|
+
self.pipeName = "Unknown"
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def processSession(self, jobDetails):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
def updateSession(self, jobDetails):
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
def configure(self, config_name=None):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
def get_pipeline_name(self):
|
|
41
|
+
return self.pipeName
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_STEP_LOG = {
|
|
45
|
+
"prepare": "[pipeline] Preparing session folders",
|
|
46
|
+
"video_rotation": "[pipeline] Checking session video orientation",
|
|
47
|
+
"metainfo": "[pipeline] Reading recording length",
|
|
48
|
+
"heart_dynamics": "[pipeline] Computing heart dynamics",
|
|
49
|
+
"arousal": "[pipeline] Computing arousal",
|
|
50
|
+
"cognition": "[pipeline] Computing cognitive indexes",
|
|
51
|
+
"emotions": "[pipeline] Computing emotions",
|
|
52
|
+
"feature_events": "[pipeline] Processing events",
|
|
53
|
+
"playback_annotations": "[pipeline] Seeding playback annotations",
|
|
54
|
+
"gps": "[pipeline] Processing GPS",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _local_step(name: str, fn) -> FunctionStep:
|
|
59
|
+
"""A Nucleus-Kit step backed by ``fn(session_path)``."""
|
|
60
|
+
|
|
61
|
+
def run(session: Session, context: RunContext) -> dict:
|
|
62
|
+
fn(session.root)
|
|
63
|
+
return {}
|
|
64
|
+
|
|
65
|
+
return FunctionStep(name, run)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _LoggedStep:
|
|
69
|
+
"""Prints the Nucleus-Kit progress line before running the wrapped step.
|
|
70
|
+
|
|
71
|
+
When ``cancel`` is set, the step is skipped instead: a cancelled run finishes the step in progress, then
|
|
72
|
+
skips the remaining ones (recorded as ``skipped`` / ``cancelled`` in ``results/processing.json``).
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, step, cancel: threading.Event | None = None):
|
|
76
|
+
self._step = step
|
|
77
|
+
self._cancel = cancel
|
|
78
|
+
self.name = step.name
|
|
79
|
+
|
|
80
|
+
def run(self, session: Session, context: RunContext):
|
|
81
|
+
if self._cancel is not None and self._cancel.is_set():
|
|
82
|
+
return StepResult(self.name, "skipped", detail={"reason": "cancelled"})
|
|
83
|
+
printInfo(_STEP_LOG.get(self.name, f"[pipeline] {self.name}"))
|
|
84
|
+
return self._step.run(session, context)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class _CancellableExporter:
|
|
88
|
+
"""Runs the wrapped exporter unless the run was cancelled (a cancelled run exports nothing)."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, exporter, cancel: threading.Event):
|
|
91
|
+
self._exporter = exporter
|
|
92
|
+
self._cancel = cancel
|
|
93
|
+
self.name = exporter.name
|
|
94
|
+
|
|
95
|
+
def export(self, session: Session, context: RunContext, results) -> None:
|
|
96
|
+
if not self._cancel.is_set():
|
|
97
|
+
self._exporter.export(session, context, results)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def build_pipeline(
|
|
101
|
+
*,
|
|
102
|
+
skip_video_rotation: bool = False,
|
|
103
|
+
apply_nlms: bool = True,
|
|
104
|
+
emotion_model: str | None = None,
|
|
105
|
+
cancel: threading.Event | None = None,
|
|
106
|
+
) -> Pipeline:
|
|
107
|
+
"""re-ak-core's default pipeline with the Nucleus-Kit local steps, in Nucleus-Kit's order."""
|
|
108
|
+
pipeline = Pipeline.default(emotion_model=emotion_model, apply_nlms=apply_nlms)
|
|
109
|
+
if not skip_video_rotation:
|
|
110
|
+
pipeline.with_step(_local_step("video_rotation", ensure_session_video_rotated_180), after="prepare")
|
|
111
|
+
pipeline.with_step(_local_step("gps", processGPS))
|
|
112
|
+
pipeline.steps = [_LoggedStep(step, cancel) for step in pipeline.steps]
|
|
113
|
+
if cancel is not None:
|
|
114
|
+
pipeline.exporters = [_CancellableExporter(exporter, cancel) for exporter in pipeline.exporters]
|
|
115
|
+
return pipeline
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class NucleusKitProcessingToolkit(BasePipeline):
|
|
119
|
+
"""
|
|
120
|
+
Offline analytics pipeline for processing session data from a local folder.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def __init__(
|
|
124
|
+
self,
|
|
125
|
+
configuration=None,
|
|
126
|
+
*,
|
|
127
|
+
skip_video_rotation: bool = False,
|
|
128
|
+
apply_nlms: bool = True,
|
|
129
|
+
emotion_model: str | None = None,
|
|
130
|
+
cancel: threading.Event | None = None,
|
|
131
|
+
):
|
|
132
|
+
super().__init__(configuration)
|
|
133
|
+
self.plot_data = False
|
|
134
|
+
self.pipeline: Pipeline | None = None
|
|
135
|
+
self._skip_video_rotation = skip_video_rotation
|
|
136
|
+
self._apply_nlms = apply_nlms
|
|
137
|
+
self._emotion_model = emotion_model
|
|
138
|
+
self._cancel = cancel
|
|
139
|
+
|
|
140
|
+
if configuration is None:
|
|
141
|
+
self.configureAsDefault()
|
|
142
|
+
self.configured = True
|
|
143
|
+
else:
|
|
144
|
+
printError("[NucleusKitProcessingToolkit] ERROR: Invalid configuration")
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def processingSteps(self) -> list[str]:
|
|
148
|
+
"""Names of the steps run, in order."""
|
|
149
|
+
return [step.name for step in self.pipeline.steps] if self.pipeline else []
|
|
150
|
+
|
|
151
|
+
def processSession(self, jobDetails):
|
|
152
|
+
if not self.configured:
|
|
153
|
+
printError("[NucleusKitProcessingToolkit] ERROR: Pipeline not configured")
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
cachepath = jobDetails.path
|
|
157
|
+
session = Session.open(cachepath)
|
|
158
|
+
|
|
159
|
+
pov = resolve_pov_settings(getattr(jobDetails, "pov_config_json", None))
|
|
160
|
+
screen_id = jobDetails.screenID or (pov.screen_id if pov else None)
|
|
161
|
+
|
|
162
|
+
printInfo(f"[pipeline] Starting {self.pipeName}")
|
|
163
|
+
printInfo("[pipeline] Converting POV movie")
|
|
164
|
+
try:
|
|
165
|
+
if screen_id and pov and pov.data_root and pov.ffmpeg_bin_dir:
|
|
166
|
+
convertPOVMovieClip(screen_id, pov.data_root, pov.ffmpeg_bin_dir)
|
|
167
|
+
elif screen_id:
|
|
168
|
+
printWarning(
|
|
169
|
+
"[NucleusKitProcessingToolkit] POV screen id set but data_root/ffmpeg not configured; "
|
|
170
|
+
"skipping conversion"
|
|
171
|
+
)
|
|
172
|
+
except Exception as e:
|
|
173
|
+
printWarning(f"[NucleusKitProcessingToolkit] WARNING: Converting movie failed: {e}")
|
|
174
|
+
|
|
175
|
+
result = self.pipeline.run(session)
|
|
176
|
+
if self._cancel is not None and self._cancel.is_set():
|
|
177
|
+
printWarning("[pipeline] Run cancelled: the remaining steps were skipped")
|
|
178
|
+
for step in result.steps:
|
|
179
|
+
if step.status == "error":
|
|
180
|
+
printError(f"[NucleusKitProcessingToolkit] ERROR in {step.name}: {step.error}")
|
|
181
|
+
else:
|
|
182
|
+
printInfo(f"[pipeline] {step.name}: {step.status} ({step.seconds:.1f} s)")
|
|
183
|
+
|
|
184
|
+
printInfo("[pipeline] Session processing finished")
|
|
185
|
+
return True
|
|
186
|
+
|
|
187
|
+
def configureAsDefault(self):
|
|
188
|
+
self.pipeName = "HermesDevPipe"
|
|
189
|
+
self.pipeline = build_pipeline(
|
|
190
|
+
skip_video_rotation=self._skip_video_rotation,
|
|
191
|
+
apply_nlms=self._apply_nlms,
|
|
192
|
+
emotion_model=self._emotion_model,
|
|
193
|
+
cancel=self._cancel,
|
|
194
|
+
)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GPS Processor
|
|
3
|
+
|
|
4
|
+
Processes GPS tracking data from sessions.
|
|
5
|
+
|
|
6
|
+
Author(s):
|
|
7
|
+
Fred Simard (fs@re-ak.com), ©RE-AK Technologies Inc.
|
|
8
|
+
Winter 2026
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import pandas as pd
|
|
15
|
+
from reak_core.io.csvfiles import has_text_header
|
|
16
|
+
|
|
17
|
+
from nucleuskit_toolkit.logging_utils import printInfo, printWarning
|
|
18
|
+
|
|
19
|
+
GPS_HDR = "[GPSProcessor] "
|
|
20
|
+
|
|
21
|
+
_GPS_FILENAMES = ["gps.csv", "gps.tmp"]
|
|
22
|
+
_GRID_STEP_S = 0.5 # shared 2 Hz / 0.5 s pipeline timebase
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def processGPS(recpath):
|
|
26
|
+
"""
|
|
27
|
+
Process GPS tracking data.
|
|
28
|
+
|
|
29
|
+
Loads the raw GPS CSV (no header: unix_timestamp_s, latitude, longitude),
|
|
30
|
+
normalises timestamps to session-relative seconds, resamples to the shared
|
|
31
|
+
2 Hz / 0.5 s grid via linear interpolation, and writes results/gpsDf.csv.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
recpath: Path to the recording directory
|
|
35
|
+
"""
|
|
36
|
+
printInfo(f"{GPS_HDR}Processing GPS data")
|
|
37
|
+
|
|
38
|
+
out_path = os.path.join(recpath, "results", "gpsDf.csv")
|
|
39
|
+
if os.path.exists(out_path):
|
|
40
|
+
printInfo(f"{GPS_HDR}GPS already processed, using cached results")
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
gps_file = None
|
|
44
|
+
for filename in _GPS_FILENAMES:
|
|
45
|
+
candidate = os.path.join(recpath, "rawData", filename)
|
|
46
|
+
if os.path.isfile(candidate):
|
|
47
|
+
gps_file = candidate
|
|
48
|
+
break
|
|
49
|
+
|
|
50
|
+
if gps_file is None:
|
|
51
|
+
printWarning(f"{GPS_HDR}No GPS file found, tested: {_GPS_FILENAMES}")
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
printInfo(f"{GPS_HDR}Loading {os.path.basename(gps_file)}")
|
|
55
|
+
try:
|
|
56
|
+
skiprows = 1 if has_text_header(gps_file) else 0
|
|
57
|
+
raw = pd.read_csv(
|
|
58
|
+
gps_file,
|
|
59
|
+
header=None,
|
|
60
|
+
names=["unix_ts", "Latitude", "Longitude"],
|
|
61
|
+
skiprows=skiprows,
|
|
62
|
+
)
|
|
63
|
+
except Exception as exc:
|
|
64
|
+
printWarning(f"{GPS_HDR}Failed to read GPS file: {exc}")
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
if len(raw) < 2:
|
|
68
|
+
printWarning(f"{GPS_HDR}GPS file contains fewer than 2 rows — skipping")
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
t_raw = raw["unix_ts"].to_numpy(dtype=float)
|
|
72
|
+
t_rel = t_raw - t_raw[0]
|
|
73
|
+
lat = raw["Latitude"].to_numpy(dtype=float)
|
|
74
|
+
lon = raw["Longitude"].to_numpy(dtype=float)
|
|
75
|
+
|
|
76
|
+
grid = np.arange(0.0, t_rel[-1] + _GRID_STEP_S, _GRID_STEP_S)
|
|
77
|
+
|
|
78
|
+
lat_grid = np.interp(grid, t_rel, lat, left=np.nan, right=np.nan)
|
|
79
|
+
lon_grid = np.interp(grid, t_rel, lon, left=np.nan, right=np.nan)
|
|
80
|
+
|
|
81
|
+
result = pd.DataFrame({
|
|
82
|
+
"Timestamp": grid,
|
|
83
|
+
"Latitude": lat_grid,
|
|
84
|
+
"Longitude": lon_grid,
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
result.to_csv(out_path, index=False, float_format="%.7f", na_rep="NULL")
|
|
88
|
+
printInfo(
|
|
89
|
+
f"{GPS_HDR}GPS processing completed — "
|
|
90
|
+
f"{len(result)} rows @ 2 Hz written to {out_path}"
|
|
91
|
+
)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Minimal session job DTO for local Nucleus-Kit pipeline processing (no REST).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class SessionJob:
|
|
14
|
+
sessionID: Optional[str] = None
|
|
15
|
+
path: Optional[str] = None
|
|
16
|
+
jobType: str = "PROCESS"
|
|
17
|
+
pipeline: str = "hermes"
|
|
18
|
+
screenID: Optional[str] = None
|
|
19
|
+
pov_config_json: Optional[str] = None # optional path merged after cwd default JSON
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def session_job_from_folder(folder: str, pov_config_json: Optional[str] = None) -> SessionJob:
|
|
23
|
+
folder = os.path.abspath(os.path.expanduser(folder))
|
|
24
|
+
norm = os.path.normpath(folder)
|
|
25
|
+
session_id = os.path.basename(norm)
|
|
26
|
+
sep = os.path.sep
|
|
27
|
+
path = norm if norm.endswith(sep) else norm + sep
|
|
28
|
+
return SessionJob(sessionID=session_id, path=path, pov_config_json=pov_config_json)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Constants for the Shimmer3 wristband hardware."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ShimmerConstants(object):
|
|
5
|
+
|
|
6
|
+
# tmp signal definition
|
|
7
|
+
SAMPLING_RATE = 51.2
|
|
8
|
+
GSR_INDEX = 4
|
|
9
|
+
NYQUIST_FREQ = SAMPLING_RATE / 2
|
|
10
|
+
|
|
11
|
+
# ppg conditioning
|
|
12
|
+
FILTER_ORDER = 4 # applied twice
|
|
13
|
+
AVG_FILTER_FC = 0.5
|
|
14
|
+
|
|
15
|
+
# HRV/BPM
|
|
16
|
+
THRESHOLD = 20
|
|
17
|
+
HRV_WINDOW_WIDTH = 11 # used for smoothing
|
|
18
|
+
BPM_WINDOW_WIDTH = 11 # used for smoothing used to be 101
|
|
19
|
+
MIN_BPM = 40
|
|
20
|
+
MAX_BPM = 150
|
|
21
|
+
|
|
22
|
+
# gsr conditioning
|
|
23
|
+
GSR_BANDWIDTH = [0.01, 5]
|
|
24
|
+
TONIC_FC = 5.0
|
|
25
|
+
GSR_THRESHOLD_TIGHT = 10
|
|
26
|
+
|
|
27
|
+
# arousal conditioning
|
|
28
|
+
FILTER_ORDER = 4
|
|
29
|
+
FIRST_STAGE_BANDWIDTH = [0.03, 3]
|
|
30
|
+
SMOOTHING_CUTOFF = 0.03
|
|
31
|
+
|
|
32
|
+
THRESHOLD = 2
|