biosignal-device-interface 0.1.311__py3-none-any.whl → 0.2.1a2__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 (42) hide show
  1. biosignal_device_interface/constants/devices/__init__.py +3 -3
  2. biosignal_device_interface/constants/devices/core/base_device_constants.py +61 -51
  3. biosignal_device_interface/constants/devices/otb/otb_muovi_constants.py +129 -129
  4. biosignal_device_interface/constants/devices/otb/otb_quattrocento_constants.py +313 -0
  5. biosignal_device_interface/constants/devices/otb/otb_quattrocento_light_constants.py +59 -59
  6. biosignal_device_interface/constants/devices/otb/otb_syncstation_constants.py +233 -0
  7. biosignal_device_interface/constants/plots/color_palette.py +59 -59
  8. biosignal_device_interface/devices/__init__.py +17 -15
  9. biosignal_device_interface/devices/core/base_device.py +424 -410
  10. biosignal_device_interface/devices/otb/__init__.py +29 -21
  11. biosignal_device_interface/devices/otb/otb_muovi.py +290 -291
  12. biosignal_device_interface/devices/otb/otb_quattrocento.py +332 -0
  13. biosignal_device_interface/devices/otb/otb_quattrocento_light.py +210 -213
  14. biosignal_device_interface/devices/otb/otb_syncstation.py +407 -0
  15. biosignal_device_interface/gui/device_template_widgets/all_devices_widget.py +51 -43
  16. biosignal_device_interface/gui/device_template_widgets/core/base_device_widget.py +130 -121
  17. biosignal_device_interface/gui/device_template_widgets/core/base_multiple_devices_widget.py +108 -108
  18. biosignal_device_interface/gui/device_template_widgets/otb/otb_devices_widget.py +44 -36
  19. biosignal_device_interface/gui/device_template_widgets/otb/otb_muovi_plus_widget.py +158 -158
  20. biosignal_device_interface/gui/device_template_widgets/otb/otb_muovi_widget.py +158 -158
  21. biosignal_device_interface/gui/device_template_widgets/otb/otb_quattrocento_light_widget.py +174 -170
  22. biosignal_device_interface/gui/device_template_widgets/otb/otb_quattrocento_widget.py +260 -0
  23. biosignal_device_interface/gui/device_template_widgets/otb/otb_syncstation_widget.py +262 -0
  24. biosignal_device_interface/gui/plot_widgets/biosignal_plot_widget.py +501 -500
  25. biosignal_device_interface/gui/ui/devices_template_widget.ui +38 -38
  26. biosignal_device_interface/gui/ui/otb_muovi_plus_template_widget.ui +171 -171
  27. biosignal_device_interface/gui/ui/otb_muovi_template_widget.ui +171 -171
  28. biosignal_device_interface/gui/ui/otb_quattrocento_light_template_widget.ui +266 -266
  29. biosignal_device_interface/gui/ui/otb_quattrocento_template_widget.ui +415 -0
  30. biosignal_device_interface/gui/ui/otb_syncstation_template_widget.ui +732 -0
  31. biosignal_device_interface/gui/ui_compiled/devices_template_widget.py +56 -56
  32. biosignal_device_interface/gui/ui_compiled/otb_muovi_plus_template_widget.py +153 -153
  33. biosignal_device_interface/gui/ui_compiled/otb_muovi_template_widget.py +153 -153
  34. biosignal_device_interface/gui/ui_compiled/otb_quattrocento_light_template_widget.py +217 -217
  35. biosignal_device_interface/gui/ui_compiled/otb_quattrocento_template_widget.py +318 -0
  36. biosignal_device_interface/gui/ui_compiled/otb_syncstation_template_widget.py +495 -0
  37. biosignal_device_interface-0.2.1a2.dist-info/LICENSE +675 -0
  38. {biosignal_device_interface-0.1.311.dist-info → biosignal_device_interface-0.2.1a2.dist-info}/METADATA +6 -4
  39. biosignal_device_interface-0.2.1a2.dist-info/RECORD +46 -0
  40. {biosignal_device_interface-0.1.311.dist-info → biosignal_device_interface-0.2.1a2.dist-info}/WHEEL +1 -1
  41. biosignal_device_interface-0.1.311.dist-info/LICENSE +0 -395
  42. biosignal_device_interface-0.1.311.dist-info/RECORD +0 -36
@@ -1,500 +1,501 @@
1
- from __future__ import annotations
2
- from functools import partial
3
- from typing import TYPE_CHECKING
4
-
5
- from PySide6.QtGui import QResizeEvent, QWheelEvent
6
- from vispy import app, gloo
7
- from PySide6.QtWidgets import (
8
- QVBoxLayout,
9
- QWidget,
10
- QScrollArea,
11
- QCheckBox,
12
- QGridLayout,
13
- QSizePolicy,
14
- )
15
- from PySide6.QtCore import Qt, Signal, QPoint
16
- import matplotlib.colors as mcolors
17
- import numpy as np
18
- from scipy.signal import resample
19
-
20
- from biosignal_device_interface.constants.plots.color_palette import (
21
- COLOR_PALETTE_RGB_DARK,
22
- COLOR_PALETTE_RGB_LIGHT,
23
- )
24
-
25
-
26
- class BiosignalPlotWidget(QWidget):
27
- bad_channels_updated: Signal = Signal(np.ndarray)
28
-
29
- def __init__(self, parent=None):
30
- super().__init__(parent)
31
-
32
- self.main_window = parent
33
- self.number_of_lines: int | None = None
34
- self.number_of_vertices: int | None = None
35
- self.internal_sampling_frequency_threshold = 256
36
- self.external_sampling_frequency: int | None = None
37
- self.internal_sampling_frequency: int | None = None
38
- self.sampling_factor: int | None = None
39
- self.downsample_buffer: np.ndarray | None = None
40
-
41
- self.line_checkboxes: list[QCheckBox] = []
42
- self.lines_enabled: np.ndarray | None = None
43
-
44
- self.canvas_layout: QVBoxLayout | None = None
45
-
46
- self.color_dict = mcolors.CSS4_COLORS
47
-
48
- self._configure_widget()
49
-
50
- self.is_configured: bool = False
51
-
52
- def _configure_widget(self):
53
- # Create scroll_area
54
- self.scroll_area = QScrollArea(self)
55
- self.scroll_area.setHorizontalScrollBarPolicy(
56
- Qt.ScrollBarAlwaysOff
57
- ) # Disable horizontal scrollbar
58
- self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
59
- self.scroll_area.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
60
- self.scroll_area.setLayoutDirection(Qt.RightToLeft)
61
-
62
- # Create a layout for the VispyFastPlotWidget
63
- self.setLayout(QVBoxLayout())
64
- # Add the scroll_area to the layout
65
- self.layout().addWidget(self.scroll_area)
66
-
67
- # Create a layout for the Scroll Area
68
- self.scroll_area_layout = QVBoxLayout()
69
- # Add the layout to the scroll_area
70
- self.scroll_area.setLayout(self.scroll_area_layout)
71
-
72
- # Make the plot_widget a child of the scroll_area
73
- self.container_widget = QWidget()
74
- self.container_widget.setSizePolicy(
75
- QSizePolicy.Expanding, QSizePolicy.Expanding
76
- )
77
-
78
- self.scroll_area.setWidget(self.container_widget)
79
-
80
- self.container_widget_layout = QGridLayout()
81
- self.container_widget.setLayout(self.container_widget_layout)
82
- self.container_widget.setLayoutDirection(Qt.LeftToRight)
83
-
84
- self.container_widget_layout.setColumnStretch(0, 0)
85
- self.container_widget_layout.setColumnStretch(1, 1)
86
-
87
- self.canvas = VispyFastPlotCanvas(
88
- parent=self.main_window, scroll_area=self.scroll_area
89
- )
90
-
91
- self.max_texture_size = gloo.gl.glGetParameter(gloo.gl.GL_MAX_TEXTURE_SIZE)
92
- self.plot_widget = self.canvas.native
93
- self.container_widget_layout.addWidget(self.plot_widget, 0, 1, 0, 1)
94
-
95
- def configure(
96
- self,
97
- lines: int,
98
- sampling_frequency: int = 2000,
99
- plot_sampling_frequency: int | None = None,
100
- display_time: int = 10,
101
- line_height: int = 50,
102
- background_color: np.ndarray = np.array([18.0, 18.0, 18.0, 1]),
103
- ):
104
- self.number_of_lines = lines
105
- self.external_sampling_frequency = sampling_frequency
106
- if plot_sampling_frequency is not None:
107
- self.internal_sampling_frequency_threshold = plot_sampling_frequency
108
-
109
- if (
110
- self.external_sampling_frequency
111
- > self.internal_sampling_frequency_threshold
112
- ):
113
- self.sampling_factor = int(
114
- self.external_sampling_frequency
115
- / self.internal_sampling_frequency_threshold
116
- )
117
-
118
- self.internal_sampling_frequency = (
119
- self.external_sampling_frequency // self.sampling_factor
120
- )
121
-
122
- else:
123
- self.internal_sampling_frequency = self.external_sampling_frequency
124
-
125
- self.number_of_vertices = int(display_time * self.internal_sampling_frequency)
126
-
127
- background_color = self._check_background_color_for_format(background_color)
128
- self.setStyleSheet(
129
- f"background-color: rgba({background_color[0]}, {background_color[1]}, {background_color[2]}, {background_color[3]});"
130
- )
131
- self.container_widget.setStyleSheet(
132
- f"background-color: rgba({background_color[0]}, {background_color[1]}, {background_color[2]}, {background_color[3]});"
133
- )
134
-
135
- self.space_for_each_line = min(
136
- int(self.max_texture_size // self.number_of_lines // 1.5), line_height
137
- )
138
-
139
- self.canvas.configure(
140
- self.number_of_lines, self.number_of_vertices, background_color
141
- )
142
-
143
- # Clear the layout
144
- for i in reversed(range(self.container_widget_layout.rowCount())):
145
- # Remove the widget from the layout
146
- if self.container_widget_layout.itemAt(i) is not None:
147
- self.container_widget_layout.itemAt(i).widget().setParent(None)
148
-
149
- lines_space = self.space_for_each_line * self.number_of_lines
150
- self.container_widget.setFixedHeight((lines_space))
151
-
152
- self.line_checkboxes = []
153
- self.number_of_lines_enabled = []
154
- self.lines_enabled = np.ones((self.number_of_lines,)).astype(bool)
155
-
156
- for i in range(self.number_of_lines):
157
- checkbox = QCheckBox(f"Line {i + 1}")
158
- checkbox.setChecked(True)
159
- checkbox.stateChanged.connect(partial(self._toggle_line, i))
160
- checkbox.setStyleSheet("padding-left: 10px;")
161
- checkbox.setFixedHeight(self.space_for_each_line)
162
- self.line_checkboxes.append(checkbox)
163
- self.container_widget_layout.addWidget(checkbox, i, 0)
164
-
165
- self.container_widget_layout.removeWidget(self.plot_widget)
166
- self.container_widget_layout.addWidget(
167
- self.plot_widget, 0, 1, self.number_of_lines, 1
168
- )
169
-
170
- # Set the standardized height for each row
171
- for i in range(self.container_widget_layout.rowCount()):
172
- if i < self.number_of_lines:
173
- self.container_widget_layout.setRowMinimumHeight(
174
- i, self.space_for_each_line
175
- ) # Set the minimum height of each row to 100 pixels
176
- else:
177
- self.container_widget_layout.setRowMinimumHeight(i, 0)
178
-
179
- self.is_configured = True
180
-
181
- def update_plot(self, input_data: np.ndarray) -> None:
182
- # Downsample input_data with external sampling frequency to match internal sampling frequency
183
- if self.external_sampling_frequency != self.internal_sampling_frequency:
184
- if self.downsample_buffer is not None:
185
- input_data = np.hstack((self.downsample_buffer, input_data))
186
- self.downsample_buffer = None
187
-
188
- input_samples = input_data.shape[1]
189
- left_over_samples = input_samples % self.sampling_factor
190
- if left_over_samples > 0:
191
- self.downsample_buffer = input_data[:, -left_over_samples:]
192
- input_data = input_data[:, :-left_over_samples]
193
- output_samples = input_samples // self.sampling_factor
194
- input_data = resample(
195
- input_data,
196
- output_samples,
197
- axis=1,
198
- )
199
-
200
- self.canvas.on_update(input_data)
201
-
202
- def reset_data(self) -> None:
203
- self.canvas.on_reset()
204
-
205
- def _toggle_line(self, line_number: int, state: int) -> None:
206
- is_checked = state == 2
207
- self.lines_enabled[line_number] = is_checked
208
- self.canvas.on_update_color(line_number, not is_checked)
209
- self.bad_channels_updated.emit(self.lines_enabled)
210
-
211
- def set_lines_enabled(self, indices: list) -> None:
212
- self.lines_enabled = np.ones((self.number_of_lines,)).astype(bool)
213
- self.lines_enabled[indices] = False
214
-
215
- def resizeEvent(self, event: QResizeEvent) -> None:
216
- self.container_widget.setFixedWidth(self.scroll_area.width())
217
-
218
- return super().resizeEvent(event)
219
-
220
- def _check_background_color_for_format(
221
- self, input_color: str | list | np.ndarray
222
- ) -> np.ndarray:
223
- if isinstance(input_color, str):
224
- if input_color.startswith("#"):
225
- input_color = np.array(
226
- [int(input_color[i : i + 2], 16) / 255 for i in (1, 3, 5)] + [1]
227
- )
228
- elif input_color.startswith("rgb"):
229
- input_color = np.array(
230
- [int(i) for i in input_color[4:-1].split(",")] + [1]
231
- )
232
- elif input_color.startswith("rgba"):
233
- input_color = np.array([int(i) for i in input_color[5:-1].split(",")])
234
- # Check if color is given as "red" or "blue" etc.
235
- elif input_color in self.color_dict:
236
- input_color = np.array(
237
- [
238
- int(self.color_dict[input_color][i : i + 2], 16) / 255
239
- for i in (1, 3, 5)
240
- ]
241
- + [1]
242
- )
243
-
244
- elif isinstance(input_color, list):
245
- input_color = np.array(input_color)
246
-
247
- if np.max(input_color) > 1:
248
- input_color = input_color / 255
249
-
250
- if input_color.shape[0] == 3:
251
- input_color = np.append(input_color, 1)
252
-
253
- return input_color
254
-
255
-
256
- class VispyFastPlotCanvas(app.Canvas):
257
- def __init__(self, scroll_area: QScrollArea, parent=None):
258
- super().__init__(keys=None, parent=parent)
259
-
260
- self.main_widget = parent
261
- self.plot_scroll_area = scroll_area
262
- self.line_data = None
263
- self.line_colors = None
264
- self._init_shaders()
265
- self.program = gloo.Program(self.vert_shader, self.frag_shader)
266
-
267
- self.background_color: np.ndarray = None
268
-
269
- def configure(
270
- self,
271
- lines: int,
272
- vertices: int,
273
- background_color: np.ndarray = np.array([18.0, 18.0, 18.0]),
274
- line_color: np.ndarray | None = None,
275
- ):
276
- """_summary_
277
-
278
- Args:
279
- lines (int): _description_
280
- vertices (int): _description_
281
- background_color (str | np.ndarray, optional): _description_. Defaults to "black".
282
- line_color (np.ndarray | None, optional): Numpy array of rgb value(s). RGB values range between 0 and 255. Defaults to None.
283
- """
284
-
285
- self.lines = lines
286
- self.vertices = vertices
287
- self.background_color = background_color
288
-
289
- # Generate template signal
290
- self.line_data = np.zeros((self.lines, self.vertices)).astype(np.float32)
291
-
292
- colors = []
293
- if line_color is None:
294
- for line in range(self.lines):
295
- if self.is_light_color(self.background_color):
296
- colors.append(
297
- COLOR_PALETTE_RGB_LIGHT[line % len(COLOR_PALETTE_RGB_LIGHT)]
298
- / 255.0
299
- )
300
- else:
301
- colors.append(
302
- COLOR_PALETTE_RGB_DARK[line % len(COLOR_PALETTE_RGB_DARK)]
303
- / 255.0
304
- )
305
- else:
306
- for line in range(self.lines):
307
- colors.append(line_color[line % len(line_color)] / 255.0)
308
-
309
- self.line_colors = np.repeat(colors, self.vertices, axis=0)
310
-
311
- self.index = np.c_[
312
- np.repeat(np.repeat(np.arange(1), self.lines), self.vertices),
313
- np.repeat(np.tile(np.arange(self.lines), 1), self.vertices),
314
- np.tile(np.arange(self.vertices), self.lines),
315
- ].astype(np.float32)
316
-
317
- # Setup Program
318
- self.program["a_position"] = self.line_data.reshape(-1, 1)
319
- self.program["a_color"] = self.line_colors
320
- self.program["a_index"] = self.index
321
- self.program["u_scale"] = (1.0, 1.0)
322
- self.program["u_size"] = (self.lines, 1)
323
- self.program["u_n"] = self.vertices
324
-
325
- gloo.set_viewport(0, 0, *self.physical_size)
326
- gloo.set_state(
327
- clear_color=self.background_color,
328
- blend=True,
329
- blend_func=("src_alpha", "one_minus_src_alpha"),
330
- )
331
-
332
- def on_update(self, input_data: np.ndarray) -> None:
333
- input_lines = input_data.shape[0]
334
- input_vertices = input_data.shape[1]
335
-
336
- assert (
337
- input_lines == self.lines
338
- ), "Input data lines do not match the configured lines."
339
-
340
- # Flip the order of the input lines backwards
341
- input_data = np.flip(input_data, axis=0)
342
-
343
- # Check if the input data has more vertices than the configured vertices
344
- if input_vertices > self.vertices:
345
- self.line_data = input_data[:, -self.vertices :]
346
- else:
347
- self.line_data[:, :-input_vertices] = self.line_data[:, input_vertices:]
348
- self.line_data[:, -input_vertices:] = input_data
349
-
350
- self.program["a_position"].set_data(self.line_data.ravel().astype(np.float32))
351
- self.update()
352
- self.context.flush()
353
-
354
- def on_update_color(self, line_number: int, disable: bool = False) -> None:
355
- # Update alpha value of the line color
356
- disable_color = self.background_color
357
- fliped_line_number = self.lines - line_number - 1
358
- if disable:
359
- self.line_colors[
360
- fliped_line_number
361
- * self.vertices : (fliped_line_number + 1)
362
- * self.vertices
363
- ] = disable_color[:3]
364
- else:
365
-
366
- if self.is_light_color(self.background_color):
367
- self.line_colors[
368
- fliped_line_number
369
- * self.vertices : (fliped_line_number + 1)
370
- * self.vertices
371
- ] = (
372
- COLOR_PALETTE_RGB_LIGHT[
373
- fliped_line_number % len(COLOR_PALETTE_RGB_LIGHT)
374
- ]
375
- / 255.0
376
- )
377
- else:
378
- self.line_colors[
379
- fliped_line_number
380
- * self.vertices : (fliped_line_number + 1)
381
- * self.vertices
382
- ] = (
383
- COLOR_PALETTE_RGB_DARK[
384
- fliped_line_number % len(COLOR_PALETTE_RGB_DARK)
385
- ]
386
- / 255.0
387
- )
388
-
389
- self.program["a_color"].set_data(self.line_colors)
390
- self.update()
391
- self.context.flush()
392
-
393
- def on_reset(self):
394
- self.line_data = np.zeros((self.lines, self.vertices)).astype(np.float32)
395
- self.program["a_position"].set_data(self.line_data.ravel().astype(np.float32))
396
- self.update()
397
- self.context.flush()
398
-
399
- def on_resize(self, event):
400
- gloo.set_viewport(0, 0, *event.physical_size)
401
-
402
- def on_draw(self, event):
403
- if self.line_data is None:
404
- return
405
-
406
- gloo.clear()
407
- self.program.draw("line_strip")
408
-
409
- def on_mouse_wheel(self, event):
410
- # Get the delta from the mouse event
411
- dx, dy = event.delta
412
-
413
- scale_factor = 15
414
- dx *= scale_factor
415
- dy *= scale_factor
416
- # Create a QWheelEvent
417
- pos = QPoint(event.pos[0], event.pos[1])
418
- global_pos = QPoint(event.pos[0], event.pos[1])
419
- pixel_delta = QPoint(dx, dy)
420
- angle_delta = QPoint(dx * 8, dy * 8) # Convert to eighths of a degree
421
- buttons = Qt.NoButton
422
- modifiers = Qt.NoModifier
423
- phase = Qt.ScrollUpdate
424
- inverted = False
425
-
426
- wheel_event = QWheelEvent(
427
- pos,
428
- global_pos,
429
- pixel_delta,
430
- angle_delta,
431
- buttons,
432
- modifiers,
433
- phase,
434
- inverted,
435
- )
436
-
437
- self.plot_scroll_area.wheelEvent(wheel_event)
438
-
439
- def is_light_color(self, rgb):
440
- # Normalize to 1
441
- luminance = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]
442
- return luminance > 0.5
443
-
444
- def _init_shaders(self):
445
- self.vert_shader = """
446
- #version 120
447
- // y coordinate of the position.
448
- attribute float a_position;
449
- // row, col, and time index.
450
- attribute vec3 a_index;
451
- varying vec3 v_index;
452
- // 2D scaling factor (zooming).
453
- uniform vec2 u_scale;
454
- // Size of the table.
455
- uniform vec2 u_size;
456
- // Number of samples per signal.
457
- uniform float u_n;
458
- // Color.
459
- attribute vec3 a_color;
460
- varying vec4 v_color;
461
- // Varying variables used for clipping in the fragment shader.
462
- varying vec2 v_position;
463
- varying vec4 v_ab;
464
- void main() {
465
- float nrows = u_size.x;
466
- float ncols = u_size.y;
467
- // Compute the x coordinate from the time index.
468
- float x = -1 + 2*a_index.z / (u_n-1);
469
- vec2 position = vec2(x - (1 - 1 / u_scale.x), a_position);
470
- // Find the affine transformation for the subplots.
471
- vec2 a = vec2(1./ncols, 1./nrows)*.9;
472
- vec2 b = vec2(-1 + 2*(a_index.x+.5) / ncols,
473
- -1 + 2*(a_index.y+.5) / nrows);
474
- // Apply the static subplot transformation + scaling.
475
- gl_Position = vec4(a*u_scale*position+b, 0.0, 1.0);
476
- v_color = vec4(a_color, 1.);
477
- v_index = a_index;
478
- // For clipping test in the fragment shader.
479
- v_position = gl_Position.xy;
480
- v_ab = vec4(a, b);
481
- }
482
- """
483
-
484
- self.frag_shader = """
485
- #version 120
486
- varying vec4 v_color;
487
- varying vec3 v_index;
488
- varying vec2 v_position;
489
- varying vec4 v_ab;
490
- void main() {
491
- gl_FragColor = v_color;
492
- // Discard the fragments between the signals (emulate glMultiDrawArrays).
493
- if ((fract(v_index.x) > 0.) || (fract(v_index.y) > 0.))
494
- discard;
495
- // Clipping test.
496
- vec2 test = abs((v_position.xy-v_ab.zw)/v_ab.xy);
497
- if ((test.x > 1) || (test.y > 1))
498
- discard;
499
- }
500
- """
1
+ from __future__ import annotations
2
+ from functools import partial
3
+ from typing import TYPE_CHECKING
4
+
5
+ from PySide6.QtGui import QResizeEvent, QWheelEvent
6
+ from vispy import app, gloo
7
+ from PySide6.QtWidgets import (
8
+ QVBoxLayout,
9
+ QWidget,
10
+ QScrollArea,
11
+ QCheckBox,
12
+ QGridLayout,
13
+ QSizePolicy,
14
+ )
15
+ from PySide6.QtCore import Qt, Signal, QPoint
16
+ import matplotlib.colors as mcolors
17
+ import numpy as np
18
+ from scipy.signal import resample
19
+
20
+ from biosignal_device_interface.constants.plots.color_palette import (
21
+ COLOR_PALETTE_RGB_DARK,
22
+ COLOR_PALETTE_RGB_LIGHT,
23
+ )
24
+
25
+
26
+ class BiosignalPlotWidget(QWidget):
27
+ bad_channels_updated: Signal = Signal(np.ndarray)
28
+
29
+ def __init__(self, parent=None):
30
+ super().__init__(parent)
31
+
32
+ self.main_window = parent
33
+ self.number_of_lines: int | None = None
34
+ self.number_of_vertices: int | None = None
35
+ self.internal_sampling_frequency_threshold = 256
36
+ self.external_sampling_frequency: int | None = None
37
+ self.internal_sampling_frequency: int | None = None
38
+ self.sampling_factor: int | None = None
39
+ self.downsample_buffer: np.ndarray | None = None
40
+
41
+ self.line_checkboxes: list[QCheckBox] = []
42
+ self.lines_enabled: np.ndarray | None = None
43
+
44
+ self.canvas_layout: QVBoxLayout | None = None
45
+
46
+ self.color_dict = mcolors.CSS4_COLORS
47
+
48
+ self._configure_widget()
49
+
50
+ self.is_configured: bool = False
51
+
52
+ def _configure_widget(self):
53
+ # Create scroll_area
54
+ self.scroll_area = QScrollArea(self)
55
+ self.scroll_area.setHorizontalScrollBarPolicy(
56
+ Qt.ScrollBarAlwaysOff
57
+ ) # Disable horizontal scrollbar
58
+ self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
59
+ self.scroll_area.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
60
+ self.scroll_area.setLayoutDirection(Qt.RightToLeft)
61
+
62
+ # Create a layout for the VispyFastPlotWidget
63
+ self.setLayout(QVBoxLayout())
64
+ # Add the scroll_area to the layout
65
+ self.layout().addWidget(self.scroll_area)
66
+
67
+ # Create a layout for the Scroll Area
68
+ self.scroll_area_layout = QVBoxLayout()
69
+ # Add the layout to the scroll_area
70
+ self.scroll_area.setLayout(self.scroll_area_layout)
71
+
72
+ # Make the plot_widget a child of the scroll_area
73
+ self.container_widget = QWidget()
74
+ self.container_widget.setSizePolicy(
75
+ QSizePolicy.Expanding, QSizePolicy.Expanding
76
+ )
77
+
78
+ self.scroll_area.setWidget(self.container_widget)
79
+
80
+ self.container_widget_layout = QGridLayout()
81
+ self.container_widget.setLayout(self.container_widget_layout)
82
+ self.container_widget.setLayoutDirection(Qt.LeftToRight)
83
+
84
+ self.container_widget_layout.setColumnStretch(0, 0)
85
+ self.container_widget_layout.setColumnStretch(1, 1)
86
+
87
+ self.canvas = VispyFastPlotCanvas(
88
+ parent=self.main_window, scroll_area=self.scroll_area
89
+ )
90
+
91
+ self.max_texture_size = gloo.gl.glGetParameter(gloo.gl.GL_MAX_TEXTURE_SIZE)
92
+ self.plot_widget = self.canvas.native
93
+ self.container_widget_layout.addWidget(self.plot_widget, 0, 1, 0, 1)
94
+
95
+ def configure(
96
+ self,
97
+ lines: int,
98
+ sampling_frequency: int = 2000,
99
+ plot_sampling_frequency: int | None = None,
100
+ display_time: int = 10,
101
+ line_height: int = 50,
102
+ background_color: np.ndarray = np.array([18.0, 18.0, 18.0, 1]),
103
+ ):
104
+ self.number_of_lines = lines
105
+ self.external_sampling_frequency = sampling_frequency
106
+ if plot_sampling_frequency is not None:
107
+ self.internal_sampling_frequency_threshold = plot_sampling_frequency
108
+
109
+ if (
110
+ self.external_sampling_frequency
111
+ > self.internal_sampling_frequency_threshold
112
+ ):
113
+ self.sampling_factor = int(
114
+ self.external_sampling_frequency
115
+ / self.internal_sampling_frequency_threshold
116
+ )
117
+
118
+ self.internal_sampling_frequency = (
119
+ self.external_sampling_frequency // self.sampling_factor
120
+ )
121
+
122
+ else:
123
+ self.internal_sampling_frequency = self.external_sampling_frequency
124
+
125
+ self.number_of_vertices = int(display_time * self.internal_sampling_frequency)
126
+
127
+ background_color = self._check_background_color_for_format(background_color)
128
+ self.setStyleSheet(
129
+ f"background-color: rgba({background_color[0]}, {background_color[1]}, {background_color[2]}, {background_color[3]});"
130
+ )
131
+ self.container_widget.setStyleSheet(
132
+ f"background-color: rgba({background_color[0]}, {background_color[1]}, {background_color[2]}, {background_color[3]});"
133
+ )
134
+
135
+ self.space_for_each_line = min(
136
+ int(self.max_texture_size // self.number_of_lines // 1.5), line_height
137
+ )
138
+
139
+ self.canvas.configure(
140
+ self.number_of_lines, self.number_of_vertices, background_color
141
+ )
142
+
143
+ # Clear the layout
144
+ for i in reversed(range(self.container_widget_layout.rowCount())):
145
+ # Remove the widget from the layout
146
+ if self.container_widget_layout.itemAt(i) is not None:
147
+ self.container_widget_layout.itemAt(i).widget().setParent(None)
148
+
149
+ lines_space = self.space_for_each_line * self.number_of_lines
150
+ self.container_widget.setFixedHeight((lines_space))
151
+
152
+ self.line_checkboxes = []
153
+ self.number_of_lines_enabled = []
154
+ self.lines_enabled = np.ones((self.number_of_lines,)).astype(bool)
155
+
156
+ for i in range(self.number_of_lines):
157
+ checkbox = QCheckBox(f"Line {i + 1}")
158
+ checkbox.setChecked(True)
159
+ checkbox.stateChanged.connect(partial(self._toggle_line, i))
160
+ checkbox.setStyleSheet("padding-left: 10px;")
161
+ checkbox.setFixedHeight(self.space_for_each_line)
162
+ self.line_checkboxes.append(checkbox)
163
+ self.container_widget_layout.addWidget(checkbox, i, 0)
164
+
165
+ self.container_widget_layout.removeWidget(self.plot_widget)
166
+ self.container_widget_layout.addWidget(
167
+ self.plot_widget, 0, 1, self.number_of_lines, 1
168
+ )
169
+
170
+ # Set the standardized height for each row
171
+ for i in range(self.container_widget_layout.rowCount()):
172
+ if i < self.number_of_lines:
173
+ self.container_widget_layout.setRowMinimumHeight(
174
+ i, self.space_for_each_line
175
+ ) # Set the minimum height of each row to 100 pixels
176
+ else:
177
+ self.container_widget_layout.setRowMinimumHeight(i, 0)
178
+
179
+ self.is_configured = True
180
+
181
+ def update_plot(self, input_data: np.ndarray) -> None:
182
+ # Downsample input_data with external sampling frequency to match internal sampling frequency
183
+ if self.external_sampling_frequency != self.internal_sampling_frequency:
184
+ if self.downsample_buffer is not None:
185
+ input_data = np.hstack((self.downsample_buffer, input_data))
186
+ self.downsample_buffer = None
187
+
188
+ input_samples = input_data.shape[1]
189
+ left_over_samples = input_samples % self.sampling_factor
190
+ if left_over_samples > 0:
191
+ self.downsample_buffer = input_data[:, -left_over_samples:]
192
+ input_data = input_data[:, :-left_over_samples]
193
+ output_samples = input_samples // self.sampling_factor
194
+ input_data = resample(
195
+ input_data,
196
+ output_samples,
197
+ axis=1,
198
+ )
199
+
200
+ self.canvas.on_update(input_data)
201
+
202
+ def reset_data(self) -> None:
203
+ self.canvas.on_reset()
204
+
205
+ def _toggle_line(self, line_number: int, state: int) -> None:
206
+ is_checked = state == 2
207
+ self.lines_enabled[line_number] = is_checked
208
+ self.canvas.on_update_color(line_number, not is_checked)
209
+ self.bad_channels_updated.emit(self.lines_enabled)
210
+
211
+ def set_lines_enabled(self, indices: list) -> None:
212
+ self.lines_enabled = np.ones((self.number_of_lines,)).astype(bool)
213
+ self.lines_enabled[indices] = False
214
+
215
+ def resizeEvent(self, event: QResizeEvent) -> None:
216
+ self.container_widget.setFixedWidth(self.scroll_area.width())
217
+
218
+ return super().resizeEvent(event)
219
+
220
+ def _check_background_color_for_format(
221
+ self, input_color: str | list | np.ndarray
222
+ ) -> np.ndarray:
223
+ if isinstance(input_color, str):
224
+ if input_color.startswith("#"):
225
+ input_color = np.array(
226
+ [int(input_color[i : i + 2], 16) / 255 for i in (1, 3, 5)] + [1]
227
+ )
228
+ elif input_color.startswith("rgb"):
229
+ input_color = np.array(
230
+ [int(i) for i in input_color[4:-1].split(",")] + [1]
231
+ )
232
+ elif input_color.startswith("rgba"):
233
+ input_color = np.array([int(i) for i in input_color[5:-1].split(",")])
234
+ # Check if color is given as "red" or "blue" etc.
235
+ elif input_color in self.color_dict:
236
+ input_color = np.array(
237
+ [
238
+ int(self.color_dict[input_color][i : i + 2], 16) / 255
239
+ for i in (1, 3, 5)
240
+ ]
241
+ + [1]
242
+ )
243
+
244
+ elif isinstance(input_color, list):
245
+ input_color = np.array(input_color)
246
+
247
+ if np.max(input_color) > 1:
248
+ input_color = input_color / 255
249
+
250
+ if input_color.shape[0] == 3:
251
+ input_color = np.append(input_color, 1)
252
+
253
+ return input_color
254
+
255
+
256
+ class VispyFastPlotCanvas(app.Canvas):
257
+ def __init__(self, scroll_area: QScrollArea, parent=None):
258
+ super().__init__(keys=None, parent=parent)
259
+
260
+ self.main_widget = parent
261
+ self.plot_scroll_area = scroll_area
262
+ self.line_data = None
263
+ self.line_colors = None
264
+ self._init_shaders()
265
+ self.program = gloo.Program(self.vert_shader, self.frag_shader)
266
+
267
+ self.background_color: np.ndarray = None
268
+
269
+ def configure(
270
+ self,
271
+ lines: int,
272
+ vertices: int,
273
+ background_color: np.ndarray = np.array([18.0, 18.0, 18.0]),
274
+ line_color: np.ndarray | None = None,
275
+ ):
276
+ """_summary_
277
+
278
+ Args:
279
+ lines (int): _description_
280
+ vertices (int): _description_
281
+ background_color (str | np.ndarray, optional): _description_. Defaults to "black".
282
+ line_color (np.ndarray | None, optional): Numpy array of rgb value(s). RGB values range between 0 and 255. Defaults to None.
283
+ """
284
+
285
+ self.lines = lines
286
+ self.vertices = vertices
287
+ self.background_color = background_color
288
+
289
+ # Generate template signal
290
+ self.line_data = np.zeros((self.lines, self.vertices)).astype(np.float32)
291
+
292
+ colors = []
293
+ if line_color is None:
294
+ for line in range(self.lines):
295
+ if self.is_light_color(self.background_color):
296
+ colors.append(
297
+ COLOR_PALETTE_RGB_LIGHT[line % len(COLOR_PALETTE_RGB_LIGHT)]
298
+ / 255.0
299
+ )
300
+ else:
301
+ colors.append(
302
+ COLOR_PALETTE_RGB_DARK[line % len(COLOR_PALETTE_RGB_DARK)]
303
+ / 255.0
304
+ )
305
+ else:
306
+ for line in range(self.lines):
307
+ colors.append(line_color[line % len(line_color)] / 255.0)
308
+
309
+ self.line_colors = np.repeat(colors, self.vertices, axis=0)
310
+
311
+ self.index = np.c_[
312
+ np.repeat(np.repeat(np.arange(1), self.lines), self.vertices),
313
+ np.repeat(np.tile(np.arange(self.lines), 1), self.vertices),
314
+ np.tile(np.arange(self.vertices), self.lines),
315
+ ].astype(np.float32)
316
+
317
+ # Setup Program
318
+ self.program["a_position"] = self.line_data.reshape(-1, 1)
319
+ self.program["a_color"] = self.line_colors
320
+ self.program["a_index"] = self.index
321
+ self.program["u_scale"] = (1.0, 1.0)
322
+ self.program["u_size"] = (self.lines, 1)
323
+ self.program["u_n"] = self.vertices
324
+
325
+ gloo.set_viewport(0, 0, *self.physical_size)
326
+ gloo.set_state(
327
+ clear_color=self.background_color,
328
+ blend=True,
329
+ blend_func=("src_alpha", "one_minus_src_alpha"),
330
+ )
331
+
332
+ def on_update(self, input_data: np.ndarray) -> None:
333
+ input_lines = input_data.shape[0]
334
+ input_vertices = input_data.shape[1]
335
+
336
+ assert (
337
+ input_lines == self.lines
338
+ ), "Input data lines do not match the configured lines."
339
+
340
+ # Flip the order of the input lines backwards
341
+ input_data = np.flip(input_data, axis=0)
342
+
343
+ # Check if the input data has more vertices than the configured vertices
344
+ if input_vertices > self.vertices:
345
+ self.line_data = input_data[:, -self.vertices :]
346
+ else:
347
+ self.line_data[:, :-input_vertices] = self.line_data[:, input_vertices:]
348
+ self.line_data[:, -input_vertices:] = input_data
349
+
350
+ plot_data = self.line_data.ravel().astype(np.float32)
351
+ self.program["a_position"].set_data(plot_data)
352
+ self.update()
353
+ self.context.flush()
354
+
355
+ def on_update_color(self, line_number: int, disable: bool = False) -> None:
356
+ # Update alpha value of the line color
357
+ disable_color = self.background_color
358
+ fliped_line_number = self.lines - line_number - 1
359
+ if disable:
360
+ self.line_colors[
361
+ fliped_line_number
362
+ * self.vertices : (fliped_line_number + 1)
363
+ * self.vertices
364
+ ] = disable_color[:3]
365
+ else:
366
+
367
+ if self.is_light_color(self.background_color):
368
+ self.line_colors[
369
+ fliped_line_number
370
+ * self.vertices : (fliped_line_number + 1)
371
+ * self.vertices
372
+ ] = (
373
+ COLOR_PALETTE_RGB_LIGHT[
374
+ fliped_line_number % len(COLOR_PALETTE_RGB_LIGHT)
375
+ ]
376
+ / 255.0
377
+ )
378
+ else:
379
+ self.line_colors[
380
+ fliped_line_number
381
+ * self.vertices : (fliped_line_number + 1)
382
+ * self.vertices
383
+ ] = (
384
+ COLOR_PALETTE_RGB_DARK[
385
+ fliped_line_number % len(COLOR_PALETTE_RGB_DARK)
386
+ ]
387
+ / 255.0
388
+ )
389
+
390
+ self.program["a_color"].set_data(self.line_colors)
391
+ self.update()
392
+ self.context.flush()
393
+
394
+ def on_reset(self):
395
+ self.line_data = np.zeros((self.lines, self.vertices)).astype(np.float32)
396
+ self.program["a_position"].set_data(self.line_data.ravel().astype(np.float32))
397
+ self.update()
398
+ self.context.flush()
399
+
400
+ def on_resize(self, event):
401
+ gloo.set_viewport(0, 0, *event.physical_size)
402
+
403
+ def on_draw(self, event):
404
+ if self.line_data is None:
405
+ return
406
+
407
+ gloo.clear()
408
+ self.program.draw("line_strip")
409
+
410
+ def on_mouse_wheel(self, event):
411
+ # Get the delta from the mouse event
412
+ dx, dy = event.delta
413
+
414
+ scale_factor = 15
415
+ dx *= scale_factor
416
+ dy *= scale_factor
417
+ # Create a QWheelEvent
418
+ pos = QPoint(event.pos[0], event.pos[1])
419
+ global_pos = QPoint(event.pos[0], event.pos[1])
420
+ pixel_delta = QPoint(dx, dy)
421
+ angle_delta = QPoint(dx * 8, dy * 8) # Convert to eighths of a degree
422
+ buttons = Qt.NoButton
423
+ modifiers = Qt.NoModifier
424
+ phase = Qt.ScrollUpdate
425
+ inverted = False
426
+
427
+ wheel_event = QWheelEvent(
428
+ pos,
429
+ global_pos,
430
+ pixel_delta,
431
+ angle_delta,
432
+ buttons,
433
+ modifiers,
434
+ phase,
435
+ inverted,
436
+ )
437
+
438
+ self.plot_scroll_area.wheelEvent(wheel_event)
439
+
440
+ def is_light_color(self, rgb):
441
+ # Normalize to 1
442
+ luminance = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]
443
+ return luminance > 0.5
444
+
445
+ def _init_shaders(self):
446
+ self.vert_shader = """
447
+ #version 120
448
+ // y coordinate of the position.
449
+ attribute float a_position;
450
+ // row, col, and time index.
451
+ attribute vec3 a_index;
452
+ varying vec3 v_index;
453
+ // 2D scaling factor (zooming).
454
+ uniform vec2 u_scale;
455
+ // Size of the table.
456
+ uniform vec2 u_size;
457
+ // Number of samples per signal.
458
+ uniform float u_n;
459
+ // Color.
460
+ attribute vec3 a_color;
461
+ varying vec4 v_color;
462
+ // Varying variables used for clipping in the fragment shader.
463
+ varying vec2 v_position;
464
+ varying vec4 v_ab;
465
+ void main() {
466
+ float nrows = u_size.x;
467
+ float ncols = u_size.y;
468
+ // Compute the x coordinate from the time index.
469
+ float x = -1 + 2*a_index.z / (u_n-1);
470
+ vec2 position = vec2(x - (1 - 1 / u_scale.x), a_position);
471
+ // Find the affine transformation for the subplots.
472
+ vec2 a = vec2(1./ncols, 1./nrows)*.9;
473
+ vec2 b = vec2(-1 + 2*(a_index.x+.5) / ncols,
474
+ -1 + 2*(a_index.y+.5) / nrows);
475
+ // Apply the static subplot transformation + scaling.
476
+ gl_Position = vec4(a*u_scale*position+b, 0.0, 1.0);
477
+ v_color = vec4(a_color, 1.);
478
+ v_index = a_index;
479
+ // For clipping test in the fragment shader.
480
+ v_position = gl_Position.xy;
481
+ v_ab = vec4(a, b);
482
+ }
483
+ """
484
+
485
+ self.frag_shader = """
486
+ #version 120
487
+ varying vec4 v_color;
488
+ varying vec3 v_index;
489
+ varying vec2 v_position;
490
+ varying vec4 v_ab;
491
+ void main() {
492
+ gl_FragColor = v_color;
493
+ // Discard the fragments between the signals (emulate glMultiDrawArrays).
494
+ if ((fract(v_index.x) > 0.) || (fract(v_index.y) > 0.))
495
+ discard;
496
+ // Clipping test.
497
+ vec2 test = abs((v_position.xy-v_ab.zw)/v_ab.xy);
498
+ if ((test.x > 1) || (test.y > 1))
499
+ discard;
500
+ }
501
+ """