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,437 @@
|
|
|
1
|
+
"""Backend for the Recording Merger tool.
|
|
2
|
+
|
|
3
|
+
Merges up to 5 recordings into a single session folder containing
|
|
4
|
+
``results/`` (CSVs, time-shifted and null-padded) and ``rawData/``
|
|
5
|
+
(video concatenated with black-screen gaps).
|
|
6
|
+
|
|
7
|
+
FFmpeg and ffprobe must be available on PATH.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import queue
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import tempfile
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
import pandas as pd
|
|
20
|
+
|
|
21
|
+
_RESULT_STEP = 0.5 # seconds per CSV sample (2 Hz)
|
|
22
|
+
_VIDEO_CODEC = "libx264"
|
|
23
|
+
_AUDIO_CODEC = "aac"
|
|
24
|
+
_AUDIO_SR = 44100
|
|
25
|
+
_PIX_FMT = "yuv420p"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ── logging helper ─────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
def _log(q: queue.SimpleQueue[str], msg: str) -> None:
|
|
31
|
+
q.put(msg + "\n")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ── dependency check ───────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
def _require_ffmpeg() -> None:
|
|
37
|
+
for cmd in ("ffmpeg", "ffprobe"):
|
|
38
|
+
if shutil.which(cmd) is None:
|
|
39
|
+
raise FileNotFoundError(
|
|
40
|
+
f"'{cmd}' not found on PATH. "
|
|
41
|
+
"Install FFmpeg and make sure it is accessible from the command line "
|
|
42
|
+
"before using the Recording Merger."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ── ffprobe helpers ────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
def _ffprobe(*args: str) -> str:
|
|
49
|
+
result = subprocess.run(
|
|
50
|
+
["ffprobe", "-v", "error", *args],
|
|
51
|
+
capture_output=True,
|
|
52
|
+
text=True,
|
|
53
|
+
check=True,
|
|
54
|
+
)
|
|
55
|
+
return result.stdout
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _probe_duration(path: Path) -> float | None:
|
|
59
|
+
"""Return video duration in seconds, or None on failure."""
|
|
60
|
+
try:
|
|
61
|
+
out = _ffprobe(
|
|
62
|
+
"-show_entries", "format=duration",
|
|
63
|
+
"-of", "default=noprint_wrappers=1:nokey=1",
|
|
64
|
+
str(path),
|
|
65
|
+
)
|
|
66
|
+
return float(out.strip())
|
|
67
|
+
except (subprocess.CalledProcessError, ValueError):
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _probe_video_info(path: Path) -> dict:
|
|
72
|
+
"""Return {width, height, fps, has_audio} for the given video file."""
|
|
73
|
+
info: dict = {
|
|
74
|
+
"width": 1920, "height": 1080,
|
|
75
|
+
"fps": 30.0, "has_audio": False,
|
|
76
|
+
}
|
|
77
|
+
try:
|
|
78
|
+
out = _ffprobe(
|
|
79
|
+
"-show_streams",
|
|
80
|
+
"-show_entries",
|
|
81
|
+
"stream=width,height,r_frame_rate,codec_type",
|
|
82
|
+
"-of", "default=noprint_wrappers=1",
|
|
83
|
+
str(path),
|
|
84
|
+
)
|
|
85
|
+
for line in out.splitlines():
|
|
86
|
+
k, _, v = line.partition("=")
|
|
87
|
+
k, v = k.strip(), v.strip()
|
|
88
|
+
if k == "width" and v not in ("", "N/A"):
|
|
89
|
+
info["width"] = int(v)
|
|
90
|
+
elif k == "height" and v not in ("", "N/A"):
|
|
91
|
+
info["height"] = int(v)
|
|
92
|
+
elif k == "r_frame_rate" and v not in ("", "N/A"):
|
|
93
|
+
n, _, d = v.partition("/")
|
|
94
|
+
info["fps"] = float(n) / float(d) if d and d != "0" else float(n)
|
|
95
|
+
elif k == "codec_type" and v == "audio":
|
|
96
|
+
info["has_audio"] = True
|
|
97
|
+
except (subprocess.CalledProcessError, ValueError):
|
|
98
|
+
pass
|
|
99
|
+
return info
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ── duration resolution ────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
def _resolve_duration(folder: Path, log_q: queue.SimpleQueue[str]) -> float:
|
|
105
|
+
"""Return recording duration (s): ffprobe video first, fall back to CSV max timestamp."""
|
|
106
|
+
video = folder / "rawData" / "video.mp4"
|
|
107
|
+
if video.exists():
|
|
108
|
+
dur = _probe_duration(video)
|
|
109
|
+
if dur is not None:
|
|
110
|
+
return dur
|
|
111
|
+
_log(log_q, f" [warn] ffprobe failed for {video.name}; falling back to CSVs.")
|
|
112
|
+
|
|
113
|
+
results_dir = folder / "results"
|
|
114
|
+
max_ts = 0.0
|
|
115
|
+
found = False
|
|
116
|
+
if results_dir.is_dir():
|
|
117
|
+
for csv_path in results_dir.glob("*.csv"):
|
|
118
|
+
try:
|
|
119
|
+
df = pd.read_csv(csv_path, usecols=["Timestamp"])
|
|
120
|
+
if not df.empty:
|
|
121
|
+
max_ts = max(max_ts, float(df["Timestamp"].max()))
|
|
122
|
+
found = True
|
|
123
|
+
except Exception:
|
|
124
|
+
pass
|
|
125
|
+
if found:
|
|
126
|
+
return max_ts + _RESULT_STEP
|
|
127
|
+
|
|
128
|
+
_log(log_q, f" [warn] Cannot determine duration for {folder.name!r}; defaulting to 0 s.")
|
|
129
|
+
return 0.0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ── CSV merging ────────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
def _collect_csv_names(folders: list[Path]) -> list[str]:
|
|
135
|
+
"""Union of *.csv names found in any recording's results/ folder."""
|
|
136
|
+
names: set[str] = set()
|
|
137
|
+
for folder in folders:
|
|
138
|
+
rd = folder / "results"
|
|
139
|
+
if rd.is_dir():
|
|
140
|
+
for p in rd.glob("*.csv"):
|
|
141
|
+
names.add(p.name)
|
|
142
|
+
return sorted(names)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _merge_csvs(
|
|
146
|
+
folders: list[Path],
|
|
147
|
+
durations: list[float],
|
|
148
|
+
shifts: list[float],
|
|
149
|
+
offsets: list[float],
|
|
150
|
+
output_results: Path,
|
|
151
|
+
log_q: queue.SimpleQueue[str],
|
|
152
|
+
) -> None:
|
|
153
|
+
csv_names = _collect_csv_names(folders)
|
|
154
|
+
if not csv_names:
|
|
155
|
+
_log(log_q, "[csv] No result CSVs found in any recording — skipping.")
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
for name in csv_names:
|
|
159
|
+
_log(log_q, f"[csv] {name}")
|
|
160
|
+
|
|
161
|
+
# Determine the union of data columns from all recordings that have this file
|
|
162
|
+
data_cols: list[str] = []
|
|
163
|
+
seen: set[str] = set()
|
|
164
|
+
for folder in folders:
|
|
165
|
+
p = folder / "results" / name
|
|
166
|
+
if p.exists():
|
|
167
|
+
try:
|
|
168
|
+
header = pd.read_csv(p, nrows=0)
|
|
169
|
+
for col in header.columns:
|
|
170
|
+
if col != "Timestamp" and col not in seen:
|
|
171
|
+
data_cols.append(col)
|
|
172
|
+
seen.add(col)
|
|
173
|
+
except Exception:
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
if not data_cols:
|
|
177
|
+
_log(log_q, f" [warn] Could not determine columns — skipping {name}.")
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
segments: list[pd.DataFrame] = []
|
|
181
|
+
|
|
182
|
+
for i, folder in enumerate(folders):
|
|
183
|
+
shift = shifts[i]
|
|
184
|
+
dur = durations[i]
|
|
185
|
+
p = folder / "results" / name
|
|
186
|
+
|
|
187
|
+
loaded = False
|
|
188
|
+
if p.exists():
|
|
189
|
+
try:
|
|
190
|
+
df = pd.read_csv(p)
|
|
191
|
+
for col in data_cols:
|
|
192
|
+
if col not in df.columns:
|
|
193
|
+
df[col] = np.nan
|
|
194
|
+
df = df[["Timestamp"] + data_cols].copy()
|
|
195
|
+
df["Timestamp"] += shift
|
|
196
|
+
segments.append(df)
|
|
197
|
+
loaded = True
|
|
198
|
+
except Exception as exc:
|
|
199
|
+
_log(log_q, f" [warn] Read error in recording {i + 1}: {exc}")
|
|
200
|
+
|
|
201
|
+
if not loaded and dur > 0:
|
|
202
|
+
ts_arr = np.arange(0.0, dur, _RESULT_STEP) + shift
|
|
203
|
+
null_df = pd.DataFrame({"Timestamp": ts_arr})
|
|
204
|
+
for col in data_cols:
|
|
205
|
+
null_df[col] = np.nan
|
|
206
|
+
segments.append(null_df)
|
|
207
|
+
|
|
208
|
+
# Null-padded gap before the next recording
|
|
209
|
+
if i < len(folders) - 1 and offsets[i] > 0:
|
|
210
|
+
gap_start = shift + dur
|
|
211
|
+
gap_end = shifts[i + 1]
|
|
212
|
+
if gap_end > gap_start + _RESULT_STEP * 0.5:
|
|
213
|
+
ts_arr = np.arange(gap_start, gap_end, _RESULT_STEP)
|
|
214
|
+
null_df = pd.DataFrame({"Timestamp": ts_arr})
|
|
215
|
+
for col in data_cols:
|
|
216
|
+
null_df[col] = np.nan
|
|
217
|
+
segments.append(null_df)
|
|
218
|
+
|
|
219
|
+
if not segments:
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
merged = pd.concat(segments, ignore_index=True)
|
|
223
|
+
merged = merged.sort_values("Timestamp").reset_index(drop=True)
|
|
224
|
+
out_path = output_results / name
|
|
225
|
+
merged.to_csv(out_path, index=False)
|
|
226
|
+
_log(log_q, f" → {len(merged)} rows ({out_path.name})")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ── video merging ──────────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
def _ffmpeg(*args: str) -> None:
|
|
232
|
+
result = subprocess.run(
|
|
233
|
+
["ffmpeg", "-y", *args],
|
|
234
|
+
capture_output=True,
|
|
235
|
+
text=True,
|
|
236
|
+
)
|
|
237
|
+
if result.returncode != 0:
|
|
238
|
+
raise subprocess.CalledProcessError(
|
|
239
|
+
result.returncode, "ffmpeg", result.stdout, result.stderr
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _encode_to_ref(
|
|
244
|
+
src: Path,
|
|
245
|
+
dst: Path,
|
|
246
|
+
src_has_audio: bool,
|
|
247
|
+
width: int,
|
|
248
|
+
height: int,
|
|
249
|
+
fps: float,
|
|
250
|
+
ref_has_audio: bool,
|
|
251
|
+
log_q: queue.SimpleQueue[str],
|
|
252
|
+
) -> None:
|
|
253
|
+
"""Re-encode a source video clip to the reference format."""
|
|
254
|
+
_log(log_q, f" encode {src.name} → {dst.name}")
|
|
255
|
+
base_args = [
|
|
256
|
+
"-vf", f"scale={width}:{height}",
|
|
257
|
+
"-r", str(fps), "-vsync", "cfr",
|
|
258
|
+
"-c:v", _VIDEO_CODEC, "-pix_fmt", _PIX_FMT, "-preset", "fast",
|
|
259
|
+
]
|
|
260
|
+
if ref_has_audio and src_has_audio:
|
|
261
|
+
_ffmpeg("-i", str(src), *base_args,
|
|
262
|
+
"-c:a", _AUDIO_CODEC, "-ar", str(_AUDIO_SR), str(dst))
|
|
263
|
+
elif ref_has_audio and not src_has_audio:
|
|
264
|
+
# Add a silent audio track alongside the video
|
|
265
|
+
_ffmpeg(
|
|
266
|
+
"-i", str(src),
|
|
267
|
+
"-f", "lavfi", "-i", f"anullsrc=r={_AUDIO_SR}:cl=stereo",
|
|
268
|
+
"-map", "0:v", "-map", "1:a",
|
|
269
|
+
*base_args,
|
|
270
|
+
"-c:a", _AUDIO_CODEC, "-ar", str(_AUDIO_SR),
|
|
271
|
+
str(dst),
|
|
272
|
+
)
|
|
273
|
+
else:
|
|
274
|
+
_ffmpeg("-i", str(src), *base_args, "-an", str(dst))
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _make_black_clip(
|
|
278
|
+
dst: Path,
|
|
279
|
+
duration: float,
|
|
280
|
+
width: int,
|
|
281
|
+
height: int,
|
|
282
|
+
fps: float,
|
|
283
|
+
has_audio: bool,
|
|
284
|
+
log_q: queue.SimpleQueue[str],
|
|
285
|
+
) -> None:
|
|
286
|
+
"""Generate a black-screen clip of the given duration."""
|
|
287
|
+
_log(log_q, f" black {duration:.1f} s → {dst.name}")
|
|
288
|
+
lavfi_video = f"color=c=black:s={width}x{height}:r={fps}"
|
|
289
|
+
base_args = [
|
|
290
|
+
"-t", str(duration),
|
|
291
|
+
"-c:v", _VIDEO_CODEC, "-pix_fmt", _PIX_FMT, "-preset", "fast",
|
|
292
|
+
]
|
|
293
|
+
if has_audio:
|
|
294
|
+
_ffmpeg(
|
|
295
|
+
"-f", "lavfi", "-i", lavfi_video,
|
|
296
|
+
"-f", "lavfi", "-i", f"anullsrc=r={_AUDIO_SR}:cl=stereo",
|
|
297
|
+
"-map", "0:v", "-map", "1:a",
|
|
298
|
+
*base_args,
|
|
299
|
+
"-c:a", _AUDIO_CODEC, "-ar", str(_AUDIO_SR),
|
|
300
|
+
str(dst),
|
|
301
|
+
)
|
|
302
|
+
else:
|
|
303
|
+
_ffmpeg(
|
|
304
|
+
"-f", "lavfi", "-i", lavfi_video,
|
|
305
|
+
*base_args, "-an",
|
|
306
|
+
str(dst),
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _merge_videos(
|
|
311
|
+
folders: list[Path],
|
|
312
|
+
durations: list[float],
|
|
313
|
+
offsets: list[float],
|
|
314
|
+
output_rawdata: Path,
|
|
315
|
+
log_q: queue.SimpleQueue[str],
|
|
316
|
+
) -> None:
|
|
317
|
+
video_paths = [folder / "rawData" / "video.mp4" for folder in folders]
|
|
318
|
+
has_videos = [p.exists() for p in video_paths]
|
|
319
|
+
|
|
320
|
+
if not any(has_videos):
|
|
321
|
+
_log(log_q, "[video] No video files found in any recording — skipping.")
|
|
322
|
+
return
|
|
323
|
+
|
|
324
|
+
# Use recording 1's video as reference; fall back to first available if rec 1 has none
|
|
325
|
+
ref_idx = 0 if has_videos[0] else next(i for i, h in enumerate(has_videos) if h)
|
|
326
|
+
ref_info = _probe_video_info(video_paths[ref_idx])
|
|
327
|
+
W, H = ref_info["width"], ref_info["height"]
|
|
328
|
+
fps = ref_info["fps"]
|
|
329
|
+
has_audio = ref_info["has_audio"]
|
|
330
|
+
_log(log_q, f"[video] Reference: {W}×{H} @ {fps:.3f} fps audio={has_audio}")
|
|
331
|
+
|
|
332
|
+
with tempfile.TemporaryDirectory() as tmp_str:
|
|
333
|
+
tmp = Path(tmp_str)
|
|
334
|
+
clip_paths: list[Path] = []
|
|
335
|
+
|
|
336
|
+
for i in range(len(folders)):
|
|
337
|
+
clip_dst = tmp / f"clip_{i:02d}.mp4"
|
|
338
|
+
if has_videos[i]:
|
|
339
|
+
src_info = _probe_video_info(video_paths[i])
|
|
340
|
+
_encode_to_ref(
|
|
341
|
+
video_paths[i], clip_dst,
|
|
342
|
+
src_info["has_audio"],
|
|
343
|
+
W, H, fps, has_audio, log_q,
|
|
344
|
+
)
|
|
345
|
+
else:
|
|
346
|
+
_make_black_clip(clip_dst, durations[i], W, H, fps, has_audio, log_q)
|
|
347
|
+
clip_paths.append(clip_dst)
|
|
348
|
+
|
|
349
|
+
if i < len(folders) - 1 and offsets[i] > 0:
|
|
350
|
+
gap_dst = tmp / f"gap_{i:02d}.mp4"
|
|
351
|
+
_make_black_clip(gap_dst, offsets[i], W, H, fps, has_audio, log_q)
|
|
352
|
+
clip_paths.append(gap_dst)
|
|
353
|
+
|
|
354
|
+
concat_txt = tmp / "concat.txt"
|
|
355
|
+
concat_txt.write_text(
|
|
356
|
+
"".join(f"file '{p}'\n" for p in clip_paths),
|
|
357
|
+
encoding="utf-8",
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
out_video = output_rawdata / "video.mp4"
|
|
361
|
+
_log(log_q, f"[video] Concatenating {len(clip_paths)} clips → video.mp4")
|
|
362
|
+
_ffmpeg(
|
|
363
|
+
"-f", "concat", "-safe", "0",
|
|
364
|
+
"-i", str(concat_txt),
|
|
365
|
+
"-c", "copy",
|
|
366
|
+
str(out_video),
|
|
367
|
+
)
|
|
368
|
+
_log(log_q, f"[video] Done → {out_video}")
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
# ── public API ─────────────────────────────────────────────────────────────────
|
|
372
|
+
|
|
373
|
+
def merge_recordings(
|
|
374
|
+
folders: list[str],
|
|
375
|
+
offsets: list[float],
|
|
376
|
+
output_dir: str,
|
|
377
|
+
log_q: queue.SimpleQueue[str],
|
|
378
|
+
*,
|
|
379
|
+
skip_video: bool = False,
|
|
380
|
+
) -> None:
|
|
381
|
+
"""Merge N recordings into a single output folder.
|
|
382
|
+
|
|
383
|
+
Parameters
|
|
384
|
+
----------
|
|
385
|
+
folders:
|
|
386
|
+
Absolute paths to recording root folders (1–5 items).
|
|
387
|
+
offsets:
|
|
388
|
+
Gap in seconds between consecutive recordings.
|
|
389
|
+
Must satisfy ``len(offsets) == len(folders) - 1``.
|
|
390
|
+
output_dir:
|
|
391
|
+
Destination root; created automatically if absent.
|
|
392
|
+
log_q:
|
|
393
|
+
Thread-safe queue for log messages displayed in the UI.
|
|
394
|
+
skip_video:
|
|
395
|
+
When True, skip the video merge step entirely.
|
|
396
|
+
"""
|
|
397
|
+
if not skip_video:
|
|
398
|
+
_require_ffmpeg()
|
|
399
|
+
|
|
400
|
+
fpaths = [Path(f) for f in folders]
|
|
401
|
+
output = Path(output_dir)
|
|
402
|
+
out_results = output / "results"
|
|
403
|
+
out_rawdata = output / "rawData"
|
|
404
|
+
out_results.mkdir(parents=True, exist_ok=True)
|
|
405
|
+
out_rawdata.mkdir(parents=True, exist_ok=True)
|
|
406
|
+
|
|
407
|
+
_log(log_q, f"Output folder: {output}\n")
|
|
408
|
+
|
|
409
|
+
# ── durations ──────────────────────────────────────────────────────────────
|
|
410
|
+
_log(log_q, "=== Resolving durations ===")
|
|
411
|
+
durations: list[float] = []
|
|
412
|
+
for i, fp in enumerate(fpaths):
|
|
413
|
+
dur = _resolve_duration(fp, log_q)
|
|
414
|
+
durations.append(dur)
|
|
415
|
+
_log(log_q, f" [{i + 1}] {fp.name!r}: {dur:.2f} s")
|
|
416
|
+
_log(log_q, "")
|
|
417
|
+
|
|
418
|
+
# Cumulative start time of each recording in merged time
|
|
419
|
+
shifts: list[float] = [0.0]
|
|
420
|
+
for i in range(len(fpaths) - 1):
|
|
421
|
+
shifts.append(shifts[-1] + durations[i] + offsets[i])
|
|
422
|
+
|
|
423
|
+
# ── CSVs ───────────────────────────────────────────────────────────────────
|
|
424
|
+
_log(log_q, "=== Merging result CSVs ===")
|
|
425
|
+
_merge_csvs(fpaths, durations, shifts, offsets, out_results, log_q)
|
|
426
|
+
_log(log_q, "")
|
|
427
|
+
|
|
428
|
+
# ── video ──────────────────────────────────────────────────────────────────
|
|
429
|
+
_log(log_q, "=== Merging video ===")
|
|
430
|
+
if skip_video:
|
|
431
|
+
_log(log_q, "[video] Skipped by user.")
|
|
432
|
+
else:
|
|
433
|
+
_merge_videos(fpaths, durations, offsets, out_rawdata, log_q)
|
|
434
|
+
_log(log_q, "")
|
|
435
|
+
|
|
436
|
+
_log(log_q, "=== Complete ===")
|
|
437
|
+
_log(log_q, f"Merged recording saved to: {output}")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""PySide6 desktop UI for nucleuskit-pipeline."""
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Line icons (24 x 24, stroke-based) rendered in any colour, plus a small spinner widget.
|
|
2
|
+
|
|
3
|
+
The icons are drawn for Nucleus-Kit in the style of outline icon sets: 2 px strokes, round caps and joins.
|
|
4
|
+
``icon(name, color)`` returns a QIcon; ``icon_file(name, color)`` writes an SVG to a cache folder for Qt
|
|
5
|
+
style sheets (``image: url(...)``), which cannot load in-memory images.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import tempfile
|
|
12
|
+
|
|
13
|
+
from PySide6.QtCore import QByteArray, QRectF, QSize, Qt, QTimer
|
|
14
|
+
from PySide6.QtGui import QColor, QIcon, QPainter, QPen, QPixmap
|
|
15
|
+
from PySide6.QtSvg import QSvgRenderer
|
|
16
|
+
from PySide6.QtWidgets import QSizePolicy, QWidget
|
|
17
|
+
|
|
18
|
+
_PATHS: dict[str, str] = {
|
|
19
|
+
"activity": '<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>',
|
|
20
|
+
"play": '<polygon points="7 4 20 12 7 20 7 4"/>',
|
|
21
|
+
"square": '<rect x="6" y="6" width="12" height="12" rx="1.5"/>',
|
|
22
|
+
"settings": (
|
|
23
|
+
'<circle cx="12" cy="12" r="3"/>'
|
|
24
|
+
'<path d="M12 2.5v3M12 18.5v3M2.5 12h3M18.5 12h3M5.3 5.3l2.1 2.1M16.6 16.6l2.1 2.1'
|
|
25
|
+
'M5.3 18.7l2.1-2.1M16.6 7.4l2.1-2.1"/>'
|
|
26
|
+
'<circle cx="12" cy="12" r="6.5"/>'
|
|
27
|
+
),
|
|
28
|
+
"book": (
|
|
29
|
+
'<path d="M2 4.5h6a4 4 0 0 1 4 4V21a3 3 0 0 0-3-3H2z"/>'
|
|
30
|
+
'<path d="M22 4.5h-6a4 4 0 0 0-4 4V21a3 3 0 0 1 3-3h7z"/>'
|
|
31
|
+
),
|
|
32
|
+
"info": '<circle cx="12" cy="12" r="9.5"/><path d="M12 16.5v-5M12 7.5h.01"/>',
|
|
33
|
+
"help": (
|
|
34
|
+
'<circle cx="12" cy="12" r="9.5"/>'
|
|
35
|
+
'<path d="M9.2 9a3 3 0 0 1 5.8 1c0 2-3 2.5-3 4.5"/><path d="M12 17.5h.01"/>'
|
|
36
|
+
),
|
|
37
|
+
"folder": '<path d="M3 6.5a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V18a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
|
|
38
|
+
"database": (
|
|
39
|
+
'<ellipse cx="12" cy="5.5" rx="8" ry="3"/>'
|
|
40
|
+
'<path d="M4 5.5v13c0 1.7 3.6 3 8 3s8-1.3 8-3v-13"/><path d="M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3"/>'
|
|
41
|
+
),
|
|
42
|
+
"file-text": (
|
|
43
|
+
'<path d="M14 2.5H6a2 2 0 0 0-2 2v15a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8.5z"/>'
|
|
44
|
+
'<path d="M14 2.5v6h6M8 13h8M8 17h8M8 9h2"/>'
|
|
45
|
+
),
|
|
46
|
+
"trash": (
|
|
47
|
+
'<path d="M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"/>'
|
|
48
|
+
'<path d="M5.5 6l1 14a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2l1-14M10 11v6M14 11v6"/>'
|
|
49
|
+
),
|
|
50
|
+
"radio": (
|
|
51
|
+
'<circle cx="12" cy="12" r="2"/><path d="M16.2 7.8a6 6 0 0 1 0 8.4M7.8 16.2a6 6 0 0 1 0-8.4"/>'
|
|
52
|
+
'<path d="M19.1 4.9a10 10 0 0 1 0 14.2M4.9 19.1a10 10 0 0 1 0-14.2"/>'
|
|
53
|
+
),
|
|
54
|
+
"film": (
|
|
55
|
+
'<rect x="3" y="3" width="18" height="18" rx="2"/>'
|
|
56
|
+
'<path d="M7 3v18M17 3v18M3 7.5h4M3 12h18M3 16.5h4M17 7.5h4M17 16.5h4"/>'
|
|
57
|
+
),
|
|
58
|
+
"wrench": (
|
|
59
|
+
'<path d="M14.7 6.3a4 4 0 0 0 5.3 5.3l-8.8 8.8a2.1 2.1 0 0 1-3-3l8.8-8.8a4 4 0 0 1-5.3-5.3'
|
|
60
|
+
'l2.9 2.9 2.4-.4.4-2.4z"/>'
|
|
61
|
+
),
|
|
62
|
+
"network": (
|
|
63
|
+
'<rect x="9" y="2.5" width="6" height="5" rx="1"/><rect x="2.5" y="16.5" width="6" height="5" rx="1"/>'
|
|
64
|
+
'<rect x="15.5" y="16.5" width="6" height="5" rx="1"/><path d="M12 7.5v4.5M5.5 16.5V12h13v4.5"/>'
|
|
65
|
+
),
|
|
66
|
+
"chevron-down": '<polyline points="6 9 12 15 18 9"/>',
|
|
67
|
+
"chevron-left": '<polyline points="15 18 9 12 15 6"/>',
|
|
68
|
+
"chevron-up": '<polyline points="6 15 12 9 18 15"/>',
|
|
69
|
+
"check": '<polyline points="20 6 9 17 4 12"/>',
|
|
70
|
+
"dot": '<circle cx="12" cy="12" r="5" fill="currentColor"/>',
|
|
71
|
+
"radio-off": '<circle cx="12" cy="12" r="9"/>',
|
|
72
|
+
"radio-on": '<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="4.5" fill="currentColor"/>',
|
|
73
|
+
"alert": '<circle cx="12" cy="12" r="9.5"/><path d="M12 7.5v5.5M12 16.5h.01"/>',
|
|
74
|
+
"check-circle": '<circle cx="12" cy="12" r="9.5"/><polyline points="16.5 9 10.5 15 7.5 12"/>',
|
|
75
|
+
"sliders": (
|
|
76
|
+
'<path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3"/>'
|
|
77
|
+
'<path d="M1.5 14h5M9.5 8h5M17.5 16h5"/>'
|
|
78
|
+
),
|
|
79
|
+
"bluetooth": '<polyline points="7 7 17 17 12 22 12 2 17 7 7 17"/>',
|
|
80
|
+
"external": (
|
|
81
|
+
'<path d="M18 13.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h5.5"/>'
|
|
82
|
+
'<path d="M15 3h6v6M10 14L21 3"/>'
|
|
83
|
+
),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
ICON_NAMES = tuple(_PATHS)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def svg(name: str, color: str, stroke_width: float = 2.0) -> str:
|
|
90
|
+
"""The SVG document of ``name`` drawn in ``color``."""
|
|
91
|
+
body = _PATHS[name].replace("currentColor", color)
|
|
92
|
+
return (
|
|
93
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" '
|
|
94
|
+
f'stroke="{color}" stroke-width="{stroke_width}" stroke-linecap="round" stroke-linejoin="round">'
|
|
95
|
+
f"{body}</svg>"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def pixmap(name: str, color: str, size: int = 18, ratio: float = 2.0) -> QPixmap:
|
|
100
|
+
renderer = QSvgRenderer(QByteArray(svg(name, color).encode("utf-8")))
|
|
101
|
+
pm = QPixmap(int(size * ratio), int(size * ratio))
|
|
102
|
+
pm.fill(Qt.GlobalColor.transparent)
|
|
103
|
+
painter = QPainter(pm)
|
|
104
|
+
renderer.render(painter)
|
|
105
|
+
painter.end()
|
|
106
|
+
pm.setDevicePixelRatio(ratio)
|
|
107
|
+
return pm
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def icon(name: str, color: str, size: int = 18) -> QIcon:
|
|
111
|
+
return QIcon(pixmap(name, color, size))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def icon_file(name: str, color: str, stroke_width: float = 2.0) -> str:
|
|
115
|
+
"""Path of a cached SVG file of ``name`` in ``color``, with forward slashes for style sheets."""
|
|
116
|
+
folder = os.path.join(tempfile.gettempdir(), "nucleuskit_icons")
|
|
117
|
+
os.makedirs(folder, exist_ok=True)
|
|
118
|
+
path = os.path.join(folder, f"{name}_{color.lstrip('#')}_{stroke_width:g}.svg")
|
|
119
|
+
if not os.path.isfile(path):
|
|
120
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
121
|
+
fh.write(svg(name, color, stroke_width))
|
|
122
|
+
return path.replace("\\", "/")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class Spinner(QWidget):
|
|
126
|
+
"""An animated arc showing that work is in progress."""
|
|
127
|
+
|
|
128
|
+
def __init__(self, size: int = 22, color: str = "#4F85EE", parent: QWidget | None = None) -> None:
|
|
129
|
+
super().__init__(parent)
|
|
130
|
+
self._angle = 0
|
|
131
|
+
self._color = QColor(color)
|
|
132
|
+
self.setFixedSize(QSize(size, size))
|
|
133
|
+
self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
|
134
|
+
self._timer = QTimer(self)
|
|
135
|
+
self._timer.setInterval(16)
|
|
136
|
+
self._timer.timeout.connect(self._tick)
|
|
137
|
+
|
|
138
|
+
def set_color(self, color: str) -> None:
|
|
139
|
+
self._color = QColor(color)
|
|
140
|
+
self.update()
|
|
141
|
+
|
|
142
|
+
def start(self) -> None:
|
|
143
|
+
self._timer.start()
|
|
144
|
+
self.show()
|
|
145
|
+
|
|
146
|
+
def stop(self) -> None:
|
|
147
|
+
self._timer.stop()
|
|
148
|
+
self.hide()
|
|
149
|
+
|
|
150
|
+
def _tick(self) -> None:
|
|
151
|
+
self._angle = (self._angle + 6) % 360
|
|
152
|
+
self.update()
|
|
153
|
+
|
|
154
|
+
def paintEvent(self, _event) -> None: # type: ignore[no-untyped-def]
|
|
155
|
+
painter = QPainter(self)
|
|
156
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
157
|
+
width = max(2.0, self.width() / 9)
|
|
158
|
+
rect = QRectF(width, width, self.width() - 2 * width, self.height() - 2 * width)
|
|
159
|
+
track = QColor(self._color)
|
|
160
|
+
track.setAlpha(50)
|
|
161
|
+
painter.setPen(QPen(track, width))
|
|
162
|
+
painter.drawEllipse(rect)
|
|
163
|
+
painter.setPen(QPen(self._color, width, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
|
|
164
|
+
painter.drawArc(rect, -self._angle * 16, 100 * 16)
|
|
165
|
+
painter.end()
|