Skip to content

GUI API

Main Window

body_eye_sync.gui.main_window

The main window: the File menu, and the tabs an experiment is worked through.

MainWindow

Bases: QMainWindow

Source code in src/body_eye_sync/gui/main_window.py
class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        with as_file(files(__package__) / "resources" / "icon.ico") as icon_path:
            self.setWindowIcon(QIcon(str(icon_path)))

        self.experiment = _new_experiment()
        self._update_title()
        self._busy = False
        self._busy_source: BaseTab | None = None
        self._progress_label: str | None = None
        self._progress_start = 0.0
        self._dirty = False

        self._build_menu_bar()

        # make status label a QLabel so text can be selectable
        self.status_label = QLabel()
        self.status_label.setTextInteractionFlags(
            Qt.TextInteractionFlag.TextSelectableByMouse
        )
        self.status_label.setSizePolicy(
            QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred
        )
        self.statusBar().addWidget(self.status_label, 1)

        self.tabs = QTabWidget()
        self.tab_widgets: list[BaseTab] = []
        for tab_type in TAB_TYPES:
            tab = tab_type(self.experiment)
            tab.status_message.connect(self._show_status)
            tab.experiment_changed.connect(
                lambda source=tab: self._on_experiment_changed(source)
            )
            tab.busy_changed.connect(
                lambda busy, source=tab: self._set_busy(busy, source)
            )
            tab.finished.connect(lambda source=tab: self._on_tab_finished(source))
            tab.progress_changed.connect(
                lambda value, maximum, label, source=tab: self._set_progress(
                    source, value, maximum, label
                )
            )
            self.tabs.addTab(tab, tab_type.title)
            self.tab_widgets.append(tab)
        self.tabs.currentChanged.connect(self._on_current_tab_changed)

        self.progress_bar = QProgressBar()
        self.progress_bar.setTextVisible(True)
        self.progress_bar.setVisible(False)

        self.central = QWidget()
        central_layout = QVBoxLayout(self.central)
        central_layout.setContentsMargins(0, 0, 0, 0)
        central_layout.addWidget(self.tabs, stretch=1)
        central_layout.addWidget(self.progress_bar)
        self.setCentralWidget(self.central)

    def tab(self, tab_type: type[BaseTab]) -> BaseTab:
        """The window's instance of ``tab_type``."""
        return next(tab for tab in self.tab_widgets if isinstance(tab, tab_type))

    def load_experiment(self, folder: str | Path) -> None:
        self._load_experiment(Path(folder))

    def _build_menu_bar(self) -> None:
        file_menu = self.menuBar().addMenu("&File")

        self.new_action = QAction("&New", self)
        self.new_action.setShortcut(QKeySequence.StandardKey.New)
        self.new_action.triggered.connect(self._new_experiment)
        file_menu.addAction(self.new_action)

        self.open_action = QAction("&Open…", self)
        self.open_action.setShortcut(QKeySequence.StandardKey.Open)
        self.open_action.triggered.connect(self._choose_experiment)
        file_menu.addAction(self.open_action)

        self.save_action = QAction("&Save", self)
        self.save_action.setShortcut(QKeySequence.StandardKey.Save)
        self.save_action.triggered.connect(self._save_experiment)
        file_menu.addAction(self.save_action)

        file_menu.addSeparator()

        self.exit_action = QAction("E&xit", self)
        self.exit_action.setShortcut(QKeySequence.StandardKey.Quit)
        self.exit_action.triggered.connect(self.close)
        file_menu.addAction(self.exit_action)

    def _new_experiment(self) -> None:
        """Start again with an empty experiment, discarding the current one."""
        if self._busy or not self._confirm_discarding_changes():
            return
        self._set_experiment(_new_experiment())

    def _choose_experiment(self) -> None:
        if self._busy or not self._confirm_discarding_changes():
            return
        folder = QFileDialog.getExistingDirectory(self, "Open experiment folder")
        if folder:
            self._load_experiment(Path(folder))

    def _load_experiment(self, folder: Path) -> None:
        try:
            experiment = Experiment.load(folder)
        except (OSError, ValueError, ValidationError) as exc:
            QMessageBox.critical(self, "Could not open experiment", str(exc))
            return
        self._set_experiment(experiment)
        self._show_status(f"Opened experiment {folder}")

    def _set_experiment(self, experiment: Experiment) -> None:
        """Hand ``experiment`` to every tab, in place of the current one."""
        self.experiment = experiment
        self._dirty = False
        for tab in self.tab_widgets:
            tab.set_experiment(experiment)
        self._update_title()

    def _on_experiment_changed(self, _source: BaseTab) -> None:
        """Mark experiment as having unsaved changes."""
        self._dirty = True

    def _on_current_tab_changed(self, index: int) -> None:
        """Refresh a tab when it is opened."""
        if 0 <= index < len(self.tab_widgets):
            self.tab_widgets[index].refresh()

    def _on_tab_finished(self, source: BaseTab) -> None:
        if self._dirty and not self._save_experiment():
            return
        index = self.tabs.indexOf(source)
        if 0 <= index < self.tabs.count() - 1:
            self.tabs.setCurrentIndex(index + 1)

    def _save_experiment(self) -> bool:
        """Write the experiment (config and any computed results) to its folder.

        Returns True if the save was successful.
        """
        if self._busy:
            self._show_status("Cannot save while a step is running")
            return False
        folder = None
        if self.experiment.folder is None:
            folder = self._ask_save_folder()
            if folder is None:
                return False
        try:
            self.experiment.save(folder)
        except (OSError, ValueError, ValidationError) as exc:
            QMessageBox.critical(self, "Could not save experiment", str(exc))
            return False
        finally:
            # A failed save can still have created the folder, so the title is
            # brought up to date either way.
            self._update_title()
        self._dirty = False
        self._show_status(f"Saved experiment to {self.experiment.folder}")
        return True

    def _confirm_discarding_changes(self) -> bool:
        """Offer to save unsaved changes, and say whether to carry on.

        Returns True if the user discarded the changes, otherwise saves them and returns False
        """
        if not self._dirty:
            return True
        answer = QMessageBox.question(
            self,
            "Unsaved changes",
            "This experiment has changes that have not been saved.",
            QMessageBox.StandardButton.Save
            | QMessageBox.StandardButton.Discard
            | QMessageBox.StandardButton.Cancel,
            QMessageBox.StandardButton.Save,
        )
        if answer == QMessageBox.StandardButton.Save:
            return self._save_experiment()
        return answer == QMessageBox.StandardButton.Discard

    def _ask_save_folder(self) -> Path | None:
        """Ask where to save an experiment that has no folder yet."""
        location = QFileDialog.getExistingDirectory(self, "Save experiment in…")
        if not location:
            return None
        name, chosen = QInputDialog.getText(
            self,
            "Save experiment",
            f"Name of the folder to create in {location}\n(leave empty to use it):",
            text=_DEFAULT_FOLDER_NAME,
        )
        if not chosen:
            return None
        name = name.strip()
        return Path(location) / name if name else Path(location)

    def _update_title(self) -> None:
        folder = self.experiment.folder
        name = folder.name if folder is not None else _UNSAVED_TITLE
        self.setWindowTitle(f"{_BASE_TITLE} :: [{name}]")

    def _show_status(self, message: str) -> None:
        """Report ``message`` in the status bar until something replaces it."""
        self.status_label.setText(message)
        self.status_label.setToolTip(message)

    def _set_busy(self, busy: bool, source: BaseTab | None = None) -> None:
        """Lock other tabs and actions while the source tab is busy."""
        if busy:
            self._busy_source = source
            self._progress_label = None
            self.progress_bar.setRange(0, 0)
            self.progress_bar.setFormat("Working…")
            self.progress_bar.setVisible(True)
        elif self._busy_source is None or source is self._busy_source:
            self._busy_source = None
            self.progress_bar.setVisible(False)
        self._busy = busy
        self.new_action.setEnabled(not busy)
        self.open_action.setEnabled(not busy)
        self.save_action.setEnabled(not busy)
        for index, tab in enumerate(self.tab_widgets):
            self.tabs.setTabEnabled(index, not busy or tab is source)

    def _set_progress(
        self,
        source: BaseTab,
        value: int,
        maximum: int,
        label: str,
    ) -> None:
        """Display progress reported by the tab that owns the active task."""
        if source is not self._busy_source:
            return
        maximum = max(0, maximum)
        self.progress_bar.setRange(0, maximum)
        if maximum:
            value = max(0, min(value, maximum))
            self.progress_bar.setValue(value)
            self.progress_bar.setFormat(
                f"{label} — %p%{self._eta(value, maximum, label)}"
            )
        else:
            self.progress_bar.setFormat(label)

    def _eta(self, value: int, maximum: int, label: str) -> str:
        """How much longer this operation has left, from the progress so far.

        Timing restarts if the label changes.
        """
        now = time.monotonic()
        if label != self._progress_label:
            self._progress_label = label
            self._progress_start = now
        elapsed = now - self._progress_start
        remaining = tqdm.format_meter(
            n=value,
            total=maximum,
            elapsed=elapsed,
            bar_format="{remaining}",
        )
        return f" — {remaining} left"

    def closeEvent(self, event) -> None:
        if not self._confirm_discarding_changes():
            event.ignore()
            return
        for tab in self.tab_widgets:
            tab.shutdown()
        super().closeEvent(event)

tab(tab_type)

The window's instance of tab_type.

Source code in src/body_eye_sync/gui/main_window.py
def tab(self, tab_type: type[BaseTab]) -> BaseTab:
    """The window's instance of ``tab_type``."""
    return next(tab for tab in self.tab_widgets if isinstance(tab, tab_type))

Tabs

body_eye_sync.gui.tabs

The tabs the main window is made of, in the order they are shown.

Each tab is one stage of working with an experiment, and each lives in its own module. Adding a stage means writing a :class:~body_eye_sync.gui.tabs.base.BaseTab subclass and listing it in :data:TAB_TYPES; the window needs no changes.

AlignmentTab

Bases: BaseTab

Let the user align all kind of inputs in time with each other via setting their time_offset properties.

Source code in src/body_eye_sync/gui/tabs/alignment.py
class AlignmentTab(BaseTab):
    """Let the user align all kind of inputs in time with each other via setting their time_offset properties."""

    title = "Alignment"

    def __init__(self, experiment: Experiment) -> None:
        super().__init__(experiment)
        self.video_cards: list[_VideoAlignmentCard] = []
        self._play_all_primary: _VideoAlignmentCard | None = None
        self.align_button = QPushButton("Automatic alignment")
        self.align_button.setToolTip(
            "Estimate initial offsets for all recordings before fine-tuning them"
        )
        self.align_button.clicked.connect(self._align)
        self.reset_timeline_button = QToolButton()
        self.reset_timeline_button.setIcon(
            self.style().standardIcon(QStyle.StandardPixmap.SP_MediaSkipBackward)
        )
        self.reset_timeline_button.setToolTip("Go to timeline zero")
        self.reset_timeline_button.clicked.connect(self._go_to_timeline_zero)
        self.play_all_button = QToolButton()
        self.play_all_button.setCheckable(True)
        self.play_all_button.setIconSize(QSize(24, 24))
        self.play_all_button.setText("All")
        self.play_all_button.setToolButtonStyle(
            Qt.ToolButtonStyle.ToolButtonTextBesideIcon
        )
        self.play_all_button.setIcon(
            self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)
        )
        self.play_all_button.setToolTip("Play all videos")
        self.play_all_button.toggled.connect(self._on_play_all_toggled)
        self.done_button = QPushButton("Finish alignment")
        self.done_button.setDefault(True)
        self.done_button.clicked.connect(self._finish_alignment)

        layout = QVBoxLayout(self)
        layout.addWidget(self.align_button)
        self.scroll_area = QScrollArea()
        self.scroll_area.setWidgetResizable(True)
        self.video_grid_widget = QWidget()
        self.grid = QGridLayout(self.video_grid_widget)
        self.grid.setAlignment(Qt.AlignmentFlag.AlignTop)
        self.scroll_area.setWidget(self.video_grid_widget)
        layout.addWidget(self.scroll_area, stretch=1)
        buttons = QHBoxLayout()
        buttons.addWidget(self.reset_timeline_button)
        buttons.addWidget(self.play_all_button)
        buttons.addStretch(1)
        buttons.addWidget(self.done_button)
        layout.addLayout(buttons)
        self.refresh()

    def refresh(self) -> None:
        """Render every video input, with at most three videos per row."""
        self._stop_play_all()
        videos = [*self.experiment.glasses_videos, *self.experiment.fixed_videos]
        self.align_button.setEnabled(len(self._inputs()) >= 2)
        if (
            self.video_cards
            and len(videos) == len(self.video_cards)
            and all(
                card.video is video
                and card.loaded_path == video.video_path
                and card.loaded
                for card, video in zip(self.video_cards, videos)
            )
        ):
            for card in self.video_cards:
                card.input_label.setText(card.video.id)
                card.controls.spin.blockSignals(True)
                card.controls.spin.setValue(card.video.timeline.offset)
                card.controls.spin.blockSignals(False)
            self._show_shared_timeline_time(0.0)
            return

        for card in self.video_cards:
            self.grid.removeWidget(card)
            card.shutdown()
            card.deleteLater()
        self.video_cards = []

        column_count = min(_VIDEOS_PER_ROW, max(1, len(videos)))
        for column in range(_VIDEOS_PER_ROW):
            self.grid.setColumnStretch(column, int(column < column_count))
        for index, video in enumerate(videos):
            card = _VideoAlignmentCard(video)
            if card.load_error is not None:
                self.status_message.emit(f"Could not open video: {card.load_error}")
            card.changed.connect(self.experiment_changed)
            card.set_requested.connect(self._set_offset_from_current_frame)

            self.video_cards.append(card)
            self.grid.addWidget(card, index // _VIDEOS_PER_ROW, index % _VIDEOS_PER_ROW)
        self.play_all_button.setEnabled(
            # Only enable Play-all when every card can be played to avoid possible half-playing or mid-play loading states
            bool(self.video_cards) and all(card.loaded for card in self.video_cards)
        )
        self.reset_timeline_button.setEnabled(
            any(card.loaded for card in self.video_cards)
        )

    def _align(self) -> None:
        """Estimate initial offsets and show them in the manual controls."""
        if len(self._inputs()) < 2:
            return
        self._stop_play_all()
        self.busy_changed.emit(True)
        self.setEnabled(False)
        self.progress_changed.emit(0, 100, "Aligning recordings…")
        try:
            result = align_experiment(self.experiment, progress=self._progress)
            if result.offsets:
                self.experiment_changed.emit()
                self.status_message.emit("Automatic alignment finished")
        finally:
            self.setEnabled(True)
            self.busy_changed.emit(False)
            self.refresh()
        if result.offsets:
            self._show_shared_timeline_time(self._first_common_experiment_time())

    def _first_common_experiment_time(self) -> float:
        """First experiment time represented by every video timeline."""
        videos = [card.video for card in self.video_cards]
        return max(
            (video.timeline.to_experiment_time(0.0) for video in videos),
            default=0.0,
        )

    def _progress(self, value: float) -> bool:
        self.progress_changed.emit(round(100 * value), 100, "Aligning recordings…")
        QApplication.processEvents(QEventLoop.ProcessEventsFlag.ExcludeUserInputEvents)
        return True

    def _set_offset_from_current_frame(self, source: _VideoAlignmentCard) -> None:
        offset = round(
            -source.viewer.current_time_seconds * source.video.timeline.rate, 3
        )
        loaded_cards = [card for card in self.video_cards if card.loaded]
        message = QMessageBox(self)
        message.setWindowTitle("Zero current frame")
        message.setText(f"Apply offset {offset:.3f} s to this video, or to all videos?")
        message.setIcon(QMessageBox.Icon.Question)
        this_video_button = message.addButton(
            "This video", QMessageBox.ButtonRole.AcceptRole
        )
        all_videos_button = message.addButton(
            "All videos", QMessageBox.ButtonRole.DestructiveRole
        )
        cancel_button = message.addButton(QMessageBox.StandardButton.Cancel)
        this_video_button.setStyleSheet(_THIS_VIDEO_BUTTON_STYLE)
        all_videos_button.setStyleSheet(_ALL_VIDEOS_BUTTON_STYLE)
        message.setDefaultButton(this_video_button)
        message.setEscapeButton(cancel_button)
        message.exec()
        clicked_button = message.clickedButton()
        if clicked_button not in (this_video_button, all_videos_button):
            return
        self._stop_play_all()
        if clicked_button is this_video_button:
            source.set_offset(offset)
            self._show_shared_timeline_time(0.0)
            return
        for card in loaded_cards:
            card.set_offset(offset)

    def _go_to_timeline_zero(self) -> None:
        self._stop_play_all()
        self._show_shared_timeline_time(0.0)

    def _show_shared_timeline_time(self, seconds: float) -> None:
        for card in self.video_cards:
            if card.loaded:
                card.viewer.set_time_seconds(
                    card.video.timeline.to_local_time(seconds),
                    allow_negative=True,
                    show_requested_time=True,
                )
                card.controls._show_timeline_state(seconds)

    def _finish_alignment(self) -> None:
        self._stop_play_all()
        self.finished.emit()

    def _on_play_all_toggled(self, play: bool) -> None:
        """
        Start or stop shared playback; all videos must be loaded.
        """
        user_desires_pause = not play
        if user_desires_pause:
            self._stop_play_all()  # Cleanly stop all
            return
        if not self.video_cards or not all(card.loaded for card in self.video_cards):
            # To prevent unusual situations with some playing
            return
        primary = self.video_cards[0]
        # This is separate because we only stop videos other than the "primary one"
        for card in self.video_cards[1:]:
            card.viewer.stop()
        self._play_all_primary = primary
        primary.viewer.frame_changed.connect(self._sync_play_all_viewers)
        self.play_all_button.setIcon(
            self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPause)
        )
        self.play_all_button.setToolTip("Pause all videos")
        self._sync_play_all_viewers()
        if self._play_all_primary is not None:
            primary.viewer._play_button.setChecked(True)

    def _sync_play_all_viewers(self, _frame: int = 0) -> None:
        """
        Use the primary videos current time seconds to icnrement the frames of the other videos.
        """
        primary = self._play_all_primary
        if primary is None:
            return
        shared_timeline_time = primary.video.timeline.to_experiment_time(
            primary.viewer.playback_time_seconds
        )
        for card in self.video_cards:
            if card is not primary:
                card.viewer.set_time_seconds(
                    card.video.timeline.to_local_time(shared_timeline_time),
                    allow_negative=True,
                    show_requested_time=True,
                    sync_audio=False,
                )
                card.controls._show_timeline_state(shared_timeline_time)
        if primary.viewer.current_frame + 1 >= primary.viewer.frame_count:
            self._stop_play_all()

    def _stop_play_all(self) -> None:
        if self._play_all_primary is not None:
            self._play_all_primary.viewer.frame_changed.disconnect(
                self._sync_play_all_viewers
            )
            self._play_all_primary = None
        for card in self.video_cards:
            if card.viewer._play_button.isChecked():
                card.viewer.stop()
        self.play_all_button.blockSignals(True)
        self.play_all_button.setChecked(False)
        self.play_all_button.blockSignals(False)
        self.play_all_button.setIcon(
            self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)
        )
        self.play_all_button.setToolTip("Play all videos")

refresh()

Render every video input, with at most three videos per row.

Source code in src/body_eye_sync/gui/tabs/alignment.py
def refresh(self) -> None:
    """Render every video input, with at most three videos per row."""
    self._stop_play_all()
    videos = [*self.experiment.glasses_videos, *self.experiment.fixed_videos]
    self.align_button.setEnabled(len(self._inputs()) >= 2)
    if (
        self.video_cards
        and len(videos) == len(self.video_cards)
        and all(
            card.video is video
            and card.loaded_path == video.video_path
            and card.loaded
            for card, video in zip(self.video_cards, videos)
        )
    ):
        for card in self.video_cards:
            card.input_label.setText(card.video.id)
            card.controls.spin.blockSignals(True)
            card.controls.spin.setValue(card.video.timeline.offset)
            card.controls.spin.blockSignals(False)
        self._show_shared_timeline_time(0.0)
        return

    for card in self.video_cards:
        self.grid.removeWidget(card)
        card.shutdown()
        card.deleteLater()
    self.video_cards = []

    column_count = min(_VIDEOS_PER_ROW, max(1, len(videos)))
    for column in range(_VIDEOS_PER_ROW):
        self.grid.setColumnStretch(column, int(column < column_count))
    for index, video in enumerate(videos):
        card = _VideoAlignmentCard(video)
        if card.load_error is not None:
            self.status_message.emit(f"Could not open video: {card.load_error}")
        card.changed.connect(self.experiment_changed)
        card.set_requested.connect(self._set_offset_from_current_frame)

        self.video_cards.append(card)
        self.grid.addWidget(card, index // _VIDEOS_PER_ROW, index % _VIDEOS_PER_ROW)
    self.play_all_button.setEnabled(
        # Only enable Play-all when every card can be played to avoid possible half-playing or mid-play loading states
        bool(self.video_cards) and all(card.loaded for card in self.video_cards)
    )
    self.reset_timeline_button.setEnabled(
        any(card.loaded for card in self.video_cards)
    )

AudioProcessingTab

Bases: BaseTab

Show one input's speech results and transcribe its audio.

Source code in src/body_eye_sync/gui/tabs/audio_processing.py
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
class AudioProcessingTab(BaseTab):
    """Show one input's speech results and transcribe its audio."""

    title = "Audio processing"

    def __init__(self, experiment: Experiment) -> None:
        super().__init__(experiment)

        self._thread: threading.Thread | None = None
        self._worker: TranscriptionWorker | None = None
        self._pending_inputs: list[Video | Audio] = []
        self._recordings: list[Video | Audio] = []
        self._live_word_count = 0

        self.input_selector = QComboBox()
        self.input_selector.currentIndexChanged.connect(self._on_input_selected)

        self.summary_label = QLabel()
        self.summary_label.setWordWrap(True)

        self.audio_player = AudioPlaybackWidget()
        self.audio_player.position_changed.connect(self._highlight_transcript_at)
        self._highlighted_row = -1
        self._marker_row = -1

        self.transcript_table = QTableWidget(0, len(_COLUMNS))
        self.transcript_table.setHorizontalHeaderLabels(_COLUMNS)
        row_header = self.transcript_table.verticalHeader()
        row_header.setSectionResizeMode(QHeaderView.ResizeMode.Fixed)
        row_header.setFixedWidth(24)
        self.transcript_table.setSelectionMode(
            QAbstractItemView.SelectionMode.NoSelection
        )
        self.transcript_table.setEditTriggers(
            QAbstractItemView.EditTrigger.NoEditTriggers
        )
        self.transcript_table.cellDoubleClicked.connect(self._play_transcript_row)
        header = self.transcript_table.horizontalHeader()
        header.setSectionResizeMode(_TEXT, QHeaderView.ResizeMode.Stretch)

        self.cancel_button = QPushButton("Cancel")
        self.cancel_button.setVisible(False)
        self.cancel_button.clicked.connect(self._cancel_run)

        top_bar = QHBoxLayout()
        top_bar.addWidget(QLabel("Recording:"))
        top_bar.addWidget(self.input_selector, stretch=1)

        bottom_bar = QHBoxLayout()
        bottom_bar.addWidget(self.summary_label, stretch=1)
        bottom_bar.addWidget(self.cancel_button)

        results_layout = QVBoxLayout()
        results_layout.setContentsMargins(0, 0, 0, 0)
        results_layout.addLayout(top_bar)
        results_layout.addWidget(self.audio_player)
        results_layout.addWidget(self.transcript_table, stretch=1)
        results_layout.addLayout(bottom_bar)
        results_side = QWidget()
        results_side.setLayout(results_layout)

        self.pipeline_editor = PipelineEditor(SPEECH_STEPS)
        self.pipeline_editor.changed.connect(self._on_pipeline_edited)
        self.pipeline_editor.run_requested.connect(
            lambda _step_type: self._start_transcription()
        )
        self.pipeline_editor.run_all_requested.connect(self._start_run_all)
        self.transcription_checkbox = QCheckBox("Transcribe this experiment's speech")
        self.transcription_checkbox.toggled.connect(self._on_pipeline_toggled)
        self.pipeline_group = QGroupBox("Speech pipeline")
        pipeline_group_layout = QVBoxLayout(self.pipeline_group)
        pipeline_group_layout.addWidget(self.transcription_checkbox)
        pipeline_group_layout.addWidget(self.pipeline_editor)

        pipeline_layout = QVBoxLayout()
        pipeline_layout.setContentsMargins(0, 0, 0, 0)
        pipeline_layout.addWidget(self.pipeline_group)
        pipeline_layout.addStretch(1)
        pipeline_side = QWidget()
        pipeline_side.setLayout(pipeline_layout)

        self.splitter = QSplitter(Qt.Orientation.Horizontal)
        self.splitter.addWidget(results_side)
        self.splitter.addWidget(pipeline_side)
        self.splitter.setStretchFactor(0, 1)
        self.splitter.setStretchFactor(1, 0)

        layout = QVBoxLayout(self)
        layout.addWidget(self.splitter)

        self.refresh()

    def _inputs(self) -> dict[str, Video | Audio]:
        """The inputs that carry sound, the only ones with speech to transcribe."""
        return {
            input_id: data
            for input_id, data in super()._inputs().items()
            if data.has_audio_track()
        }

    def refresh(self) -> None:
        """Re-list the inputs, keeping the shown one if it is still there."""
        if self._thread is not None:
            return
        shown = self.input()
        self._recordings = list(self._inputs().values())
        self.input_selector.blockSignals(True)
        self.input_selector.clear()
        for data in self._recordings:
            self.input_selector.addItem(f"{data.id} ({_kind(data)})")
        index = next(
            (i for i, data in enumerate(self._recordings) if data is shown),
            0 if self._recordings else -1,
        )
        self.input_selector.setCurrentIndex(index)
        self.input_selector.blockSignals(False)
        self.input_selector.setEnabled(bool(self._recordings))
        self._show_selected_input()

    def input(self) -> Video | Audio | None:
        """The input being shown, or ``None`` if the experiment has none."""
        index = self.input_selector.currentIndex()
        if 0 <= index < len(self._recordings):
            return self._recordings[index]
        return None

    def speech(self) -> Speech | None:
        """The shown input's speech results, whichever kind of input it is."""
        data = self.input()
        return None if data is None else data.speech

    def is_busy(self) -> bool:
        """Whether transcription is currently running."""
        return self._thread is not None

    def _on_input_selected(self, _index: int) -> None:
        self._show_selected_input()

    def _show_selected_input(self) -> None:
        """List the chosen input's speech results and bind the editor to it."""
        data = self.input()
        if data is None:
            self.audio_player.clear()
        else:
            self.audio_player.load(data.path, data.loudness.levels)
        self._refresh_results()
        self._bind_editor_to_pipeline()
        self._update_run_availability()

    def _refresh_results(self) -> None:
        """Fill the transcript table and its summary from the shown input."""
        speech = self.speech()
        data = None if speech is None else speech.data
        self.transcript_table.clearContents()
        self.transcript_table.setRowCount(0 if data is None else len(data))
        self._highlighted_row = -1
        self._marker_row = -1
        if data is None:
            self.summary_label.setText(self._nothing_to_show())
            return
        for row, segment in enumerate(data.itertuples(index=False)):
            self._set_transcript_row(row, segment.start, segment.end, segment.text)
        self.summary_label.setText(self._summary(speech))

    def _set_transcript_row(
        self, row: int, start: float, end: float, text: str
    ) -> None:
        """Populate one row shared by loaded and live transcript segments."""
        self.transcript_table.setVerticalHeaderItem(row, QTableWidgetItem(""))
        alignment = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
        start_item = QTableWidgetItem(_time_text(start))
        start_item.setTextAlignment(alignment)
        # Playback highlighting needs the exact bounds, not the rounded text.
        start_item.setData(Qt.ItemDataRole.UserRole, (float(start), float(end)))
        end_item = QTableWidgetItem(_time_text(end))
        end_item.setTextAlignment(alignment)

        self.transcript_table.setItem(row, _START, start_item)
        self.transcript_table.setItem(row, _END, end_item)

        text_item = QTableWidgetItem(str(text))
        text_item.setToolTip(textwrap.fill(str(text), 80))
        self.transcript_table.setItem(row, _TEXT, text_item)
        self._tint_row(row, _REST_ALPHA)

    @Slot(float)
    def _highlight_transcript_at(self, seconds: float) -> None:
        """Select the transcript segment containing the playback position."""
        row_at_position = -1
        marker_row = -1
        for row in range(self.transcript_table.rowCount()):
            item = self.transcript_table.item(row, _START)
            bounds = None if item is None else item.data(Qt.ItemDataRole.UserRole)
            if bounds is None:
                continue
            if bounds[0] <= seconds:
                marker_row = row
            if bounds[0] <= seconds <= bounds[1] and row_at_position < 0:
                row_at_position = row
        self._mark_row(marker_row)
        if row_at_position == self._highlighted_row:
            return
        if self._highlighted_row >= 0:
            self._tint_row(self._highlighted_row, _REST_ALPHA)
        self._highlighted_row = row_at_position
        if row_at_position >= 0:
            self._tint_row(row_at_position, _HIGHLIGHT_ALPHA)
            self.transcript_table.scrollToItem(
                self.transcript_table.item(row_at_position, _TEXT),
                QAbstractItemView.ScrollHint.PositionAtCenter,
            )

    def _tint_row(self, row: int, alpha: int) -> None:
        """Wash one row in the highlight colour, at ``alpha`` out of 255."""
        color = QColor(self.palette().highlight().color())
        color.setAlpha(alpha)
        for column in range(self.transcript_table.columnCount()):
            item = self.transcript_table.item(row, column)
            if item is not None:
                item.setBackground(QBrush(color))

    def _mark_row(self, row: int) -> None:
        """Point the gutter at the last segment playback has reached.

        The selection only lasts as long as the segment is being spoken, so this
        is what holds the place in the table through the silence in between.
        """
        if row == self._marker_row:
            return
        for at, text in ((self._marker_row, ""), (row, _MARKER)):
            item = self.transcript_table.verticalHeaderItem(at) if at >= 0 else None
            if item is not None:
                item.setText(text)
        self._marker_row = row

    @Slot(int, int)
    def _play_transcript_row(self, row: int, _column: int) -> None:
        """Seek to a double-clicked segment and start or continue playback."""
        if self._thread is not None:
            return
        item = self.transcript_table.item(row, _START)
        bounds = None if item is None else item.data(Qt.ItemDataRole.UserRole)
        if bounds is None:
            return
        self.audio_player.seek(bounds[0])
        self.audio_player.play()

    def _nothing_to_show(self) -> str:
        """Why the transcript table is empty."""
        if self.input() is not None:
            return "No transcript yet; run transcription."
        if any(data.path is not None for data in self.experiment.inputs):
            return "None of this experiment's recordings carry audio."
        return "This experiment has no inputs."

    def _summary(self, speech: Speech) -> str:
        words = 0 if speech.words is None else len(speech.words)
        return f"{len(speech.data)} segment(s), {words} word(s)"

    def _bind_editor_to_pipeline(self) -> None:
        """Bind the checkbox and editor to the experiment's speech pipeline."""
        pipeline = self.experiment.pipeline.speech
        self.transcription_checkbox.blockSignals(True)
        self.transcription_checkbox.setChecked(pipeline is not None)
        self.transcription_checkbox.blockSignals(False)
        if pipeline is not None:
            self.pipeline_editor.set_from(pipeline)
        self.pipeline_editor.setEnabled(
            pipeline is not None and self.input() is not None
        )

    @Slot(bool)
    def _on_pipeline_toggled(self, enabled: bool) -> None:
        """Enable or disable transcription for the experiment."""
        if enabled:
            pipeline = SpeechPipeline()
            self.experiment.pipeline.speech = pipeline
            self.pipeline_editor.set_from(pipeline)
        else:
            self.experiment.pipeline.speech = None
            self._pending_inputs = []
        self.pipeline_editor.setEnabled(enabled and self.input() is not None)
        self._update_run_availability()
        self.experiment_changed.emit()

    def _on_pipeline_edited(self) -> None:
        """Adopt the editor's pipeline as the experiment's, when it is valid."""
        pipeline = self.experiment.pipeline.speech
        if pipeline is None:
            return
        try:
            self.pipeline_editor.apply_to(pipeline)
        except (ValidationError, ValueError):
            self.status_message.emit("Pipeline has invalid settings; not applied")
            return
        self.experiment_changed.emit()

    def _update_run_availability(self) -> None:
        """Enable the "Run" buttons when there are recordings to transcribe."""
        enabled = self.experiment.pipeline.speech is not None
        self.pipeline_editor.set_run_enabled(
            TranscriptionStep, enabled and self.input() is not None
        )
        self.pipeline_editor.set_run_all_enabled(enabled and bool(self._recordings))

    def _transcription_config(self) -> TranscriptionStep | None:
        """The editor's validated transcription settings, or ``None``."""
        if self.experiment.pipeline.speech is None:
            return None
        try:
            return self.pipeline_editor.config_for(TranscriptionStep)
        except (ValidationError, ValueError) as exc:
            QMessageBox.critical(self, "Invalid settings", str(exc))
            return None

    @Slot()
    def _start_transcription(self) -> None:
        """Transcribe the selected recording with the editor's settings."""
        if self._thread is not None:
            return
        data = self.input()
        if data is None:
            self._pending_inputs = []
            return
        settings = self._transcription_config()
        if settings is None:
            self._pending_inputs = []
            return

        speech = data.speech
        speech.begin_transcription()
        self._begin_run()

        self._worker = TranscriptionWorker(speech, data.loudness, data.path, settings)
        self._worker.progress.connect(self._on_progress)
        self._worker.new_frame.connect(self._on_new_segment)
        self._worker.finished.connect(self._on_transcription_finished)
        self._worker.failed.connect(self._on_failed)
        self._worker.cancelled.connect(self._on_cancelled)

        self._thread = threading.Thread(target=self._worker.run, daemon=True)
        self._thread.start()

    def _start_run_all(self) -> None:
        """Transcribe every recording in chooser order."""
        if self._thread is not None:
            return
        if self._transcription_config() is None:
            self._pending_inputs = []
            return
        self._pending_inputs = list(self._recordings)
        self._continue_run_all()

    def _continue_run_all(self) -> None:
        """Select and transcribe the next recording queued by "Run all"."""
        while self._pending_inputs and self._thread is None:
            data = self._pending_inputs.pop(0)
            index = self._recordings.index(data)
            self.input_selector.setCurrentIndex(index)
            self._start_transcription()

    def _begin_run(self) -> None:
        """Prepare the tab for a transcription run."""
        self.audio_player.pause()
        self._set_running(True)
        self.transcript_table.clearContents()
        self.transcript_table.setRowCount(0)
        self._live_word_count = 0
        self.summary_label.setText("Waiting for transcript segments…")
        self.progress_changed.emit(0, 0, "Downloading weights…")

    @Slot(object)
    def _on_new_segment(self, segment) -> None:
        """Append one provisional Whisper segment while transcription runs."""
        scrollbar = self.transcript_table.verticalScrollBar()
        following = scrollbar.value() == scrollbar.maximum()
        row = self.transcript_table.rowCount()
        self.transcript_table.insertRow(row)
        self._set_transcript_row(row, segment.start, segment.end, segment.text)
        self._live_word_count += len(segment.words)
        self.summary_label.setText(
            f"{row + 1} segment(s), {self._live_word_count} word(s) — transcribing…"
        )
        if following:
            self.transcript_table.scrollToBottom()

    @Slot(float)
    def _on_progress(self, fraction: float) -> None:
        self.progress_changed.emit(round(100 * fraction), 100, "Transcription…")

    def _cancel_run(self) -> None:
        if self._worker is not None:
            self._worker.cancel()
        self.cancel_button.setEnabled(False)
        self.cancel_button.setText("Cancelling…")

    @Slot()
    def _on_transcription_finished(self) -> None:
        speech = self.speech()
        n_words = 0 if speech.words is None else len(speech.words)
        self.status_message.emit(
            f"Transcription finished: {n_words} words over {len(speech.data)} segments"
        )
        self._set_running(False)
        self._continue_run_all()

    @Slot(str, str)
    def _on_failed(self, message: str, details: str) -> None:
        self._pending_inputs = []
        dialog = QMessageBox(self)
        dialog.setIcon(QMessageBox.Icon.Critical)
        dialog.setWindowTitle("Transcription failed")
        dialog.setText(message)
        dialog.setDetailedText(details)
        dialog.exec()
        self._set_running(False)

    @Slot()
    def _on_cancelled(self) -> None:
        self._pending_inputs = []
        self.status_message.emit("Transcription cancelled")
        self._set_running(False)

    def _set_running(self, running: bool) -> None:
        if not running:
            self._thread = None
            self._worker = None
            self.refresh()
            self.experiment_changed.emit()
        self.input_selector.setEnabled(not running and bool(self._recordings))
        self.audio_player.setEnabled(not running)
        self.transcription_checkbox.setEnabled(not running)
        self.pipeline_editor.setEnabled(
            not running
            and self.input() is not None
            and self.experiment.pipeline.speech is not None
        )
        self.cancel_button.setVisible(running)
        self.cancel_button.setEnabled(True)
        self.cancel_button.setText("Cancel")
        self.busy_changed.emit(running)

input()

The input being shown, or None if the experiment has none.

Source code in src/body_eye_sync/gui/tabs/audio_processing.py
def input(self) -> Video | Audio | None:
    """The input being shown, or ``None`` if the experiment has none."""
    index = self.input_selector.currentIndex()
    if 0 <= index < len(self._recordings):
        return self._recordings[index]
    return None

is_busy()

Whether transcription is currently running.

Source code in src/body_eye_sync/gui/tabs/audio_processing.py
def is_busy(self) -> bool:
    """Whether transcription is currently running."""
    return self._thread is not None

refresh()

Re-list the inputs, keeping the shown one if it is still there.

Source code in src/body_eye_sync/gui/tabs/audio_processing.py
def refresh(self) -> None:
    """Re-list the inputs, keeping the shown one if it is still there."""
    if self._thread is not None:
        return
    shown = self.input()
    self._recordings = list(self._inputs().values())
    self.input_selector.blockSignals(True)
    self.input_selector.clear()
    for data in self._recordings:
        self.input_selector.addItem(f"{data.id} ({_kind(data)})")
    index = next(
        (i for i, data in enumerate(self._recordings) if data is shown),
        0 if self._recordings else -1,
    )
    self.input_selector.setCurrentIndex(index)
    self.input_selector.blockSignals(False)
    self.input_selector.setEnabled(bool(self._recordings))
    self._show_selected_input()

speech()

The shown input's speech results, whichever kind of input it is.

Source code in src/body_eye_sync/gui/tabs/audio_processing.py
def speech(self) -> Speech | None:
    """The shown input's speech results, whichever kind of input it is."""
    data = self.input()
    return None if data is None else data.speech

BaseTab

Bases: QWidget

One tab of the main window, acting on an :class:Experiment.

Source code in src/body_eye_sync/gui/tabs/base.py
class BaseTab(QWidget):
    """One tab of the main window, acting on an :class:`Experiment`."""

    title: ClassVar[str] = ""

    _worker: QObject | None = None
    _thread: threading.Thread | None = None

    # signals
    status_message = Signal(str)
    experiment_changed = Signal()
    busy_changed = Signal(bool)
    finished = Signal()
    # current value, maximum value (zero means indeterminate), operation label:
    progress_changed = Signal(int, int, str)

    def __init__(self, experiment: Experiment) -> None:
        super().__init__()
        self.experiment = experiment

    def set_experiment(self, experiment: Experiment) -> None:
        self.experiment = experiment
        self.refresh()

    def _inputs(self) -> dict[str, Video | Audio]:
        """The experiment's inputs that have a recording, keyed by id."""
        return {
            data.id: data for data in self.experiment.inputs if data.path is not None
        }

    def refresh(self) -> None:
        """Re-read the experiment, which may have been changed elsewhere."""

    def shutdown(self) -> None:
        """Stop any work in progress; called when the window is closing."""
        worker, thread = self._worker, self._thread
        self._worker = None
        self._thread = None
        if worker is None:
            return
        worker.cancel()
        QObject.disconnect(worker, None, None, None)
        if thread is not None:
            thread.join(timeout=SHUTDOWN_TIMEOUT)

refresh()

Re-read the experiment, which may have been changed elsewhere.

Source code in src/body_eye_sync/gui/tabs/base.py
def refresh(self) -> None:
    """Re-read the experiment, which may have been changed elsewhere."""

shutdown()

Stop any work in progress; called when the window is closing.

Source code in src/body_eye_sync/gui/tabs/base.py
def shutdown(self) -> None:
    """Stop any work in progress; called when the window is closing."""
    worker, thread = self._worker, self._thread
    self._worker = None
    self._thread = None
    if worker is None:
        return
    worker.cancel()
    QObject.disconnect(worker, None, None, None)
    if thread is not None:
        thread.join(timeout=SHUTDOWN_TIMEOUT)

ClockRateTab

Bases: BaseTab

Recalculate and apply each input's offset and clock rate.

Source code in src/body_eye_sync/gui/tabs/clock_rate.py
class ClockRateTab(BaseTab):
    """Recalculate and apply each input's offset and clock rate."""

    title = "Clock rate"

    def __init__(self, experiment: Experiment) -> None:
        super().__init__(experiment)
        self._thread: threading.Thread | None = None
        self._worker: _Worker | None = None
        self._analysis: ClockRateAnalysis | None = None
        self._analysis_signature: tuple | None = None

        self.table = AutoHeightTable(_COLUMNS)
        self.table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
        self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
        self.table.horizontalHeader().setSectionResizeMode(
            _DRIFT, QHeaderView.ResizeMode.Stretch
        )

        self.correct_button = QPushButton("Analyse and correct clock rates")
        self.correct_button.clicked.connect(self._start_correction)
        self.clear_button = QPushButton("Clear corrections")
        self.clear_button.setToolTip(
            "Reset every input's clock rate, leaving the offsets alone"
        )
        self.clear_button.clicked.connect(self._clear_corrections)
        self.window_spin = _setting_spin(2.0, 120.0, DEFAULT_WINDOW)
        self.window_spin.setToolTip("How long each measurement window is.")
        self.search_spin = _setting_spin(1.0, 120.0, DEFAULT_SEARCH)
        self.search_spin.setToolTip(
            "How far either side of the current offset each window looks for its lag."
        )
        self.min_quality_spin = _setting_spin(1.0, 30.0, SPECTRAL_MIN_QUALITY)
        self.min_quality_spin.setToolTip(
            "Quality gate: higher values require a stronger signal to measure a lag."
        )
        self.min_drift_spin = _setting_spin(0.5, 50.0, MIN_DRIFT_PPM)
        self.min_drift_spin.setDecimals(1)
        self.min_drift_spin.setSingleStep(0.5)
        self.min_drift_spin.setToolTip(
            "The smallest clock difference worth correcting, in parts per million."
        )
        settings_form = QFormLayout()
        settings_form.setContentsMargins(0, 0, 0, 0)
        settings_form.addRow("Window (s)", self.window_spin)
        settings_form.addRow("Search (s)", self.search_spin)
        settings_form.addRow("Min quality", self.min_quality_spin)
        settings_form.addRow("Min drift (ppm)", self.min_drift_spin)
        self.settings_widget = QWidget()
        self.settings_widget.setLayout(settings_form)

        self.figure = Figure(figsize=(9, 5), constrained_layout=True)
        self.canvas = FigureCanvasQTAgg(self.figure)
        self.canvas.setMinimumHeight(400)

        buttons = QHBoxLayout()
        buttons.addWidget(self.correct_button)
        buttons.addWidget(self.clear_button)
        buttons.addStretch(1)

        controls = QVBoxLayout()
        controls.addWidget(self.settings_widget)
        controls.addLayout(buttons)
        controls.addStretch(1)

        top = QHBoxLayout()
        top.addWidget(self.table, 1, Qt.AlignmentFlag.AlignTop)
        top.addLayout(controls)

        page_layout = QVBoxLayout()
        page_layout.addLayout(top)
        page_layout.addWidget(self.canvas)
        page_layout.addStretch(1)

        self.page = QWidget()
        self.page.setLayout(page_layout)
        self.scroll_area = QScrollArea()
        self.scroll_area.setWidgetResizable(True)
        self.scroll_area.setWidget(self.page)

        layout = QVBoxLayout(self)
        layout.addWidget(self.scroll_area)
        self.refresh()

    def _show_plot(self, visible: bool) -> None:
        self.canvas.setVisible(visible)

    def _timeline_signature(self) -> tuple:
        return tuple(
            (
                name,
                str(data.path),
                data.timeline.offset,
                data.timeline.rate,
            )
            for name, data in self._inputs().items()
        )

    def set_experiment(self, experiment: Experiment) -> None:
        self._analysis = None
        self._analysis_signature = None
        self._show_plot(False)
        super().set_experiment(experiment)

    def refresh(self) -> None:
        if self._thread is not None:
            return
        if (
            self._analysis is not None
            and self._analysis_signature != self._timeline_signature()
        ):
            self._analysis = None
            self._analysis_signature = None
        self._refresh_table()
        if self._analysis is None:
            self._draw_stored_corrections()
        self._update_buttons(False)

    def _refresh_table(self) -> None:
        inputs = list(self.experiment.inputs)
        unavailable = set(self._analysis.unavailable) if self._analysis else set()
        self.table.clearContents()
        self.table.setRowCount(len(inputs))
        for row, data in enumerate(inputs):
            values = [
                data.id,
                _offset_text(data.timeline.offset),
                _drift_text(data.timeline),
            ]
            if data.id in unavailable:
                values[2] = "Couldn't match"
            for column, value in enumerate(values):
                item = QTableWidgetItem(value)
                if column == _OFFSET:
                    item.setTextAlignment(
                        Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
                    )
                self.table.setItem(row, column, item)
        self.table.fit_to_rows()

    def is_busy(self) -> bool:
        return self._thread is not None

    def _clear_corrections(self) -> None:
        if self._thread is not None or not clear_clock_rates(self.experiment):
            return
        self._analysis = None
        self._analysis_signature = None
        self._refresh_table()
        self._draw_stored_corrections()
        self._update_buttons(False)
        self.experiment_changed.emit()
        self.status_message.emit("Clock-rate corrections cleared")

    def _start_correction(self) -> None:
        if self._thread is not None:
            return
        paths = {name: data.path for name, data in self._inputs().items()}
        if len(paths) < 2:
            return
        offsets = {name: data.timeline.offset for name, data in self._inputs().items()}
        self._analysis = None
        self._analysis_signature = None
        self._show_plot(False)
        self._set_running(True)
        self.progress_changed.emit(0, 100, _LABEL)
        self._worker = _Worker(paths, offsets, self._settings())
        self._worker.progress.connect(self._on_progress)
        self._worker.finished.connect(self._on_correction_finished)
        self._worker.failed.connect(self._on_failed)
        self._worker.cancelled.connect(self._on_cancelled)
        self._thread = threading.Thread(target=self._worker.run, daemon=True)
        self._thread.start()

    @Slot(int)
    def _on_progress(self, percent: int) -> None:
        self.progress_changed.emit(percent, 100, _LABEL)

    @Slot(object)
    def _on_correction_finished(self, analysis: ClockRateAnalysis) -> None:
        changed = apply_clock_rates(self.experiment, analysis)
        self._analysis = analysis
        self._analysis_signature = self._timeline_signature()
        self._refresh_table()

        self._draw_corrections(
            {name: data.timeline for name, data in self._inputs().items()}, analysis
        )
        self._show_plot(True)
        if changed:
            self.experiment_changed.emit()
            self.status_message.emit(
                f"Updated the clock rate of {len(changed)} input(s)"
            )
        else:
            self.status_message.emit("No clock-rate changes detected")
        self._set_running(False)

    def _draw_stored_corrections(self) -> None:
        timelines = {name: data.timeline for name, data in self._inputs().items()}
        if not any(timeline.corrects_drift for timeline in timelines.values()):
            self._show_plot(False)
            return
        self._draw_corrections(timelines)
        self._show_plot(True)

    def _draw_corrections(
        self,
        timelines: dict[str, Timeline],
        analysis: ClockRateAnalysis | None = None,
    ) -> None:
        self.figure.clear()
        axis = self.figure.subplots()
        inputs = self._inputs()
        for index, (name, timeline) in enumerate(timelines.items()):
            if name not in inputs:
                continue
            points = analysis.points.get(name, []) if analysis is not None else []
            if not points and not timeline.corrects_drift:
                continue
            if points:
                measured_experiment = np.asarray([point.time for point in points])
                measured_offset = np.asarray([point.offset for point in points])
                local = measured_experiment - measured_offset
            else:
                duration = media_duration(inputs[name].path) or 3600.0
                local = np.asarray([0.0, duration])
            fitted = timeline.to_experiment_times(local)
            experiment = measured_experiment if points else fitted
            fitted_offset = fitted - local
            colour = f"C{index}"
            if points:
                axis.scatter(
                    experiment / 60.0,
                    (measured_offset - timeline.offset) * 1000,
                    s=9,
                    alpha=0.45,
                    color=colour,
                )
            axis.plot(
                experiment / 60.0,
                (fitted_offset - timeline.offset) * 1000,
                color=colour,
                linewidth=1.5,
                label=name,
            )
        axis.axhline(
            0,
            color="black",
            linewidth=0.8,
            label=(
                f"{analysis.reference} (reference)"
                if analysis is not None
                else "No clock drift"
            ),
        )
        corrected = any(
            timeline.corrects_drift
            for name, timeline in timelines.items()
            if name in inputs
        )
        if analysis is None:
            title = "Applied clock-rate corrections"
        elif corrected:
            title = "Measured offsets and applied clock-rate corrections"
        else:
            title = "Measured offsets: no clock-rate correction needed"
        axis.set_title(title)
        axis.set_xlabel("Experiment time (minutes)")
        axis.set_ylabel("Offset change from recording start (ms)")
        axis.grid(alpha=0.25)
        axis.legend(fontsize=9)
        self.canvas.draw_idle()

    @Slot(str, str)
    def _on_failed(self, message: str, details: str) -> None:
        dialog = QMessageBox(self)
        dialog.setIcon(QMessageBox.Icon.Critical)
        dialog.setWindowTitle("Clock-rate analysis failed")
        dialog.setText(message)
        dialog.setDetailedText(details)
        dialog.exec()
        self.status_message.emit("Could not complete the clock-rate analysis")
        self._draw_stored_corrections()
        self._set_running(False)

    @Slot()
    def _on_cancelled(self) -> None:
        self.status_message.emit("Clock-rate analysis cancelled")
        self._draw_stored_corrections()
        self._set_running(False)

    def _set_running(self, running: bool) -> None:
        if not running:
            self._thread = None
            self._worker = None
        self._update_buttons(running)
        self.busy_changed.emit(running)

    def _settings(self) -> dict[str, float]:
        """The analysis settings as the form currently has them."""
        return {
            "window": self.window_spin.value(),
            "search": self.search_spin.value(),
            "min_quality": self.min_quality_spin.value(),
            "min_drift_ppm": self.min_drift_spin.value(),
        }

    def _update_buttons(self, running: bool) -> None:
        self.settings_widget.setEnabled(not running)
        self.correct_button.setEnabled(not running and len(self._inputs()) >= 2)
        self.clear_button.setEnabled(
            not running and has_corrected_clock_rates(self.experiment)
        )

DataExportTab

Bases: BaseTab

Choose experiment inputs and export their synchronized video grid.

Source code in src/body_eye_sync/gui/tabs/data_export.py
class DataExportTab(BaseTab):
    """Choose experiment inputs and export their synchronized video grid."""

    title = "Data export"

    def __init__(self, experiment: Experiment) -> None:
        super().__init__(experiment)
        self._thread: threading.Thread | None = None
        self._worker: _VideoExportWorker | None = None

        description = QLabel(
            "Select the inputs to include in the synchronized 25 fps video. "
            "Video inputs fill the slots of the chosen layout; audio-only inputs "
            "contribute audio tracks."
        )
        description.setWordWrap(True)

        self.input_list = QListWidget()
        self.input_list.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
        self.input_list.setAlternatingRowColors(True)
        self.input_list.itemChanged.connect(self._update_availability)

        self.layout_editor = VideoLayoutEditor()
        self.layout_editor.changed.connect(self._update_availability)

        self.merged_audio_checkbox = QCheckBox("Include merged audio track")
        self.merged_audio_checkbox.setToolTip(
            "Append one default playback track mixing the synchronized audio from "
            "all selected inputs, while retaining the individual tracks."
        )

        self.export_button = QPushButton("Export combined video with ELAN annotations…")
        self.export_button.clicked.connect(self._choose_output)
        self.cancel_button = QPushButton("Cancel")
        self.cancel_button.setVisible(False)
        self.cancel_button.clicked.connect(self._cancel_export)

        buttons = QHBoxLayout()
        buttons.addWidget(self.export_button)
        buttons.addWidget(self.cancel_button)
        buttons.addStretch(1)

        layout = QVBoxLayout(self)
        layout.addWidget(description)
        layout.addWidget(self.input_list, stretch=1)
        layout.addWidget(self.layout_editor, stretch=2)
        layout.addWidget(self.merged_audio_checkbox)
        layout.addLayout(buttons)
        self.refresh()

    def set_experiment(self, experiment: Experiment) -> None:
        self.input_list.clear()
        super().set_experiment(experiment)

    def refresh(self) -> None:
        if self._thread is not None:
            return
        checked = {
            self.input_list.item(index).data(_INPUT_ID_ROLE): self.input_list.item(
                index
            ).checkState()
            == Qt.CheckState.Checked
            for index in range(self.input_list.count())
        }
        self.input_list.blockSignals(True)
        self.input_list.clear()
        for data in self.experiment.inputs:
            item = QListWidgetItem(f"{data.id} ({_input_kind(data)})")
            item.setData(_INPUT_ID_ROLE, data.id)
            item.setData(_IS_VIDEO_ROLE, isinstance(data, Video))
            item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
            item.setCheckState(
                Qt.CheckState.Checked
                if checked.get(data.id, True)
                else Qt.CheckState.Unchecked
            )
            self.input_list.addItem(item)
        self.input_list.blockSignals(False)
        self._update_availability()

    def selected_input_ids(self) -> list[str]:
        return self._checked_ids()

    def selected_video_ids(self) -> list[str]:
        """The checked video inputs, in the order the layout offers them."""
        return self._checked_ids(videos_only=True)

    def _checked_ids(self, videos_only: bool = False) -> list[str]:
        return [
            item.data(_INPUT_ID_ROLE)
            for index in range(self.input_list.count())
            if (item := self.input_list.item(index)).checkState()
            == Qt.CheckState.Checked
            and (not videos_only or bool(item.data(_IS_VIDEO_ROLE)))
        ]

    def is_busy(self) -> bool:
        return self._thread is not None

    @Slot()
    def _update_availability(self) -> None:
        self.layout_editor.set_videos(self.selected_video_ids())
        # There is nothing to export until the layout shows at least one video.
        placed = any(self.layout_editor.slots())
        running = self._thread is not None
        self.input_list.setEnabled(not running)
        self.layout_editor.setEnabled(not running)
        self.merged_audio_checkbox.setEnabled(not running)
        self.export_button.setEnabled(not running and placed)
        self.cancel_button.setVisible(running)
        self.cancel_button.setEnabled(True)
        self.cancel_button.setText("Cancel")

    @Slot()
    def _choose_output(self) -> None:
        if self._thread is not None or not self.export_button.isEnabled():
            return
        folder = self.experiment.folder or Path.cwd()
        chosen, _selected_filter = QFileDialog.getSaveFileName(
            self,
            "Export combined video",
            str(folder / "combined_video.mp4"),
            "MP4 video (*.mp4)",
        )
        if not chosen:
            return
        output_path = Path(chosen)
        if output_path.suffix.lower() != ".mp4":
            output_path = output_path.with_suffix(".mp4")
        self._start_export(output_path)

    def _start_export(self, output_path: Path) -> None:
        input_ids = self.selected_input_ids()
        if self._thread is not None or not input_ids:
            return
        self._worker = _VideoExportWorker(
            self.experiment,
            output_path,
            input_ids,
            self.layout_editor.layout_kind(),
            self.layout_editor.slots(),
            self.merged_audio_checkbox.isChecked(),
        )
        self._worker.progress.connect(self._on_progress)
        self._worker.finished.connect(self._on_finished)
        self._worker.failed.connect(self._on_failed)
        self._worker.cancelled.connect(self._on_cancelled)
        self._thread = threading.Thread(target=self._worker.run, daemon=True)
        self.progress_changed.emit(0, 100, _LABEL)
        self.busy_changed.emit(True)
        self._update_availability()
        self._thread.start()

    @Slot(int)
    def _on_progress(self, percent: int) -> None:
        self.progress_changed.emit(percent, 100, _LABEL)

    @Slot()
    def _cancel_export(self) -> None:
        if self._worker is not None:
            self._worker.cancel()
            self.cancel_button.setEnabled(False)
            self.cancel_button.setText("Cancelling…")

    @Slot(object)
    def _on_finished(self, result: VideoGridResult) -> None:
        message = f"Exported combined video to {result.path}"
        message += self._write_annotations(result)
        self.status_message.emit(message)
        self._set_running(False)

    def _write_annotations(self, result: VideoGridResult) -> str:
        """Write the speech turns beside the video, reporting what happened."""
        if not self.experiment.speech_turns.has_data():
            return ""
        try:
            annotation_path = export_elan(self.experiment, result, overwrite=True)
        except (OSError, ValueError) as exc:
            return f"; could not write speech annotations: {exc}"
        return f"; wrote speech annotations to {annotation_path.name}"

    @Slot(str, str)
    def _on_failed(self, message: str, details: str) -> None:
        dialog = QMessageBox(self)
        dialog.setIcon(QMessageBox.Icon.Critical)
        dialog.setWindowTitle("Video export failed")
        dialog.setText(message)
        dialog.setDetailedText(details)
        dialog.exec()
        self.status_message.emit("Could not export combined video")
        self._set_running(False)

    @Slot()
    def _on_cancelled(self) -> None:
        self.status_message.emit("Combined video export cancelled")
        self._set_running(False)

    def _set_running(self, running: bool) -> None:
        if not running:
            self._thread = None
            self._worker = None
        self.busy_changed.emit(running)
        self._update_availability()

selected_video_ids()

The checked video inputs, in the order the layout offers them.

Source code in src/body_eye_sync/gui/tabs/data_export.py
def selected_video_ids(self) -> list[str]:
    """The checked video inputs, in the order the layout offers them."""
    return self._checked_ids(videos_only=True)

InputFilesTab

Bases: BaseTab

Name the experiment, and add, remove and edit its input files.

Source code in src/body_eye_sync/gui/tabs/input_files.py
class InputFilesTab(BaseTab):
    """Name the experiment, and add, remove and edit its input files."""

    title = "Input files"

    def __init__(self, experiment: Experiment) -> None:
        super().__init__(experiment)

        #: One section per input type, in :data:`INPUT_KINDS` order.
        self.sections = [_InputSection(kind, experiment) for kind in INPUT_KINDS]
        self.glasses_section, self.fixed_section, self.audio_section = self.sections

        sections_layout = QVBoxLayout()
        for section in self.sections:
            section.changed.connect(self._on_section_changed)
            section.status_message.connect(self.status_message)
            section.selected.connect(lambda source=section: self._select_only(source))
            sections_layout.addWidget(section)
        sections_layout.addStretch(1)

        sections = QWidget()
        sections.setLayout(sections_layout)
        area = QScrollArea()
        area.setWidgetResizable(True)
        area.setWidget(sections)

        layout = QVBoxLayout(self)
        layout.addWidget(area)

    def refresh(self) -> None:
        """Rebuild every section from the experiment."""
        for section in self.sections:
            section.refresh()

    def set_experiment(self, experiment: Experiment) -> None:
        for section in self.sections:
            section.experiment = experiment
        super().set_experiment(experiment)

    def selected_inputs(self) -> list[Video | Audio]:
        """The selected inputs; at most one section has a selection at a time."""
        return [data for section in self.sections for data in section.selected_inputs()]

    def add_glasses_videos(self, paths: list[Path]) -> None:
        """Add each path as a glasses video input, ids taken from the filenames."""
        self.glasses_section.add_files(paths)

    def add_fixed_videos(self, paths: list[Path]) -> None:
        """Add each path as a fixed video input, ids taken from the filenames."""
        self.fixed_section.add_files(paths)

    def add_audio(self, paths: list[Path]) -> None:
        """Add each path as an audio input, ids taken from the filenames."""
        self.audio_section.add_files(paths)

    def remove_inputs(self, inputs: list[Video | Audio]) -> None:
        """Remove ``inputs`` from the experiment, reporting any that cannot go."""
        if _remove_inputs(self, self.experiment, inputs):
            self._on_section_changed()

    def _on_section_changed(self) -> None:
        """A section edited the experiment: re-read it, and pass the news on.

        Every section is rebuilt, not just the one that changed: adding, renaming
        or removing a glasses video changes what the audio section offers.
        """
        self.refresh()
        self.experiment_changed.emit()

    def _select_only(self, source: _InputSection) -> None:
        """Keep the selection in one section, so Remove is never ambiguous."""
        for section in self.sections:
            if section is not source:
                section.clear_selection()

add_audio(paths)

Add each path as an audio input, ids taken from the filenames.

Source code in src/body_eye_sync/gui/tabs/input_files.py
def add_audio(self, paths: list[Path]) -> None:
    """Add each path as an audio input, ids taken from the filenames."""
    self.audio_section.add_files(paths)

add_fixed_videos(paths)

Add each path as a fixed video input, ids taken from the filenames.

Source code in src/body_eye_sync/gui/tabs/input_files.py
def add_fixed_videos(self, paths: list[Path]) -> None:
    """Add each path as a fixed video input, ids taken from the filenames."""
    self.fixed_section.add_files(paths)

add_glasses_videos(paths)

Add each path as a glasses video input, ids taken from the filenames.

Source code in src/body_eye_sync/gui/tabs/input_files.py
def add_glasses_videos(self, paths: list[Path]) -> None:
    """Add each path as a glasses video input, ids taken from the filenames."""
    self.glasses_section.add_files(paths)

refresh()

Rebuild every section from the experiment.

Source code in src/body_eye_sync/gui/tabs/input_files.py
def refresh(self) -> None:
    """Rebuild every section from the experiment."""
    for section in self.sections:
        section.refresh()

remove_inputs(inputs)

Remove inputs from the experiment, reporting any that cannot go.

Source code in src/body_eye_sync/gui/tabs/input_files.py
def remove_inputs(self, inputs: list[Video | Audio]) -> None:
    """Remove ``inputs`` from the experiment, reporting any that cannot go."""
    if _remove_inputs(self, self.experiment, inputs):
        self._on_section_changed()

selected_inputs()

The selected inputs; at most one section has a selection at a time.

Source code in src/body_eye_sync/gui/tabs/input_files.py
def selected_inputs(self) -> list[Video | Audio]:
    """The selected inputs; at most one section has a selection at a time."""
    return [data for section in self.sections for data in section.selected_inputs()]

PlaceholderTab

Bases: BaseTab

A temporary tab for yet to be implemented tabs.

Source code in src/body_eye_sync/gui/tabs/base.py
class PlaceholderTab(BaseTab):
    """A temporary tab for yet to be implemented tabs."""

    def __init__(self, experiment: Experiment) -> None:
        super().__init__(experiment)
        label = QLabel(f"{self.title} is not implemented yet")
        label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        layout = QVBoxLayout(self)
        layout.addWidget(label)

PostProcessingTab

Bases: PlaceholderTab

Combine the per-input results into experiment-level results.

Source code in src/body_eye_sync/gui/tabs/post_processing.py
class PostProcessingTab(PlaceholderTab):
    """Combine the per-input results into experiment-level results."""

    title = "Post processing"

SpeechPostProcessingTab

Bases: BaseTab

Work out the experiment's speech turns from its glasses recordings.

Source code in src/body_eye_sync/gui/tabs/speech_post_processing.py
class SpeechPostProcessingTab(BaseTab):
    """Work out the experiment's speech turns from its glasses recordings."""

    title = "Speech post processing"

    def __init__(self, experiment: Experiment) -> None:
        super().__init__(experiment)
        self._thread: threading.Thread | None = None
        self._worker: _Worker | None = None

        self.attribute_button = QPushButton("Attribute speech to speakers")
        self.attribute_button.clicked.connect(self._start)

        self.cancel_button = QPushButton("Cancel")
        self.cancel_button.setVisible(False)
        self.cancel_button.clicked.connect(self._cancel)

        self.summary_label = QLabel()
        self.summary_label.setWordWrap(True)

        self.blocked_label = QLabel()
        self.blocked_label.setWordWrap(True)
        blocked_font = self.blocked_label.font()
        blocked_font.setBold(True)
        self.blocked_label.setFont(blocked_font)
        self.blocked_label.setStyleSheet(f"color: {_BLOCKED_COLOR};")
        self.blocked_label.setVisible(False)

        self.audio_player = SynchronizedAudioPlaybackWidget()
        self.audio_player.position_changed.connect(self._highlight_turns_at)
        self._highlighted_rows: set[int] = set()
        self._marker_row = -1

        self.turns_table = QTableWidget(0, len(_COLUMNS))
        self.turns_table.setHorizontalHeaderLabels(_COLUMNS)
        # The row header is kept as a gutter for the playback marker, so it is
        # narrow and unlabelled rather than counting the rows off.
        header = self.turns_table.verticalHeader()
        header.setSectionResizeMode(QHeaderView.ResizeMode.Fixed)
        header.setFixedWidth(24)
        self.turns_table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
        self.turns_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
        self.turns_table.cellDoubleClicked.connect(self._play_turn)
        self.turns_table.horizontalHeader().setSectionResizeMode(
            _TEXT, QHeaderView.ResizeMode.Stretch
        )

        results_layout = QVBoxLayout()
        results_layout.setContentsMargins(0, 0, 0, 0)
        results_layout.addWidget(self.audio_player)
        results_layout.addWidget(self.turns_table, stretch=1)
        results_layout.addWidget(self.summary_label)
        results_side = QWidget()
        results_side.setLayout(results_layout)

        settings = self.experiment.pipeline.speech_post_processing
        self.splitting_form = PydanticForm(settings, fields=_SPLITTING_FIELDS)
        self.splitting_form.changed.connect(self._on_settings_changed)
        self.splitting_group = QGroupBox("Splitting")
        splitting_group_layout = QVBoxLayout(self.splitting_group)
        splitting_group_layout.addWidget(self.splitting_form)

        self.attribution_form = PydanticForm(settings, fields=_ATTRIBUTION_FIELDS)
        self.attribution_form.changed.connect(self._on_settings_changed)
        self.attribution_group = QGroupBox("Attribution")
        attribution_group_layout = QVBoxLayout(self.attribution_group)
        attribution_group_layout.addWidget(self.attribution_form)

        settings_layout = QVBoxLayout()
        settings_layout.setContentsMargins(0, 0, 0, 0)
        settings_layout.addWidget(self.splitting_group)
        settings_layout.addWidget(self.attribution_group)
        settings_layout.addWidget(self.blocked_label)
        settings_layout.addWidget(self.attribute_button)
        settings_layout.addWidget(self.cancel_button)
        settings_layout.addStretch(1)
        settings_side = QWidget()
        settings_side.setLayout(settings_layout)

        self.splitter = QSplitter(Qt.Orientation.Horizontal)
        self.splitter.addWidget(results_side)
        self.splitter.addWidget(settings_side)
        self.splitter.setStretchFactor(0, 1)
        self.splitter.setStretchFactor(1, 0)

        layout = QVBoxLayout(self)
        layout.addWidget(self.splitter)
        self.refresh()

    def refresh(self) -> None:
        if self._thread is not None:
            return
        for form in (self.splitting_form, self.attribution_form):
            form.blockSignals(True)
            form.from_model(self.experiment.pipeline.speech_post_processing)
            form.blockSignals(False)
        self._refresh_audio()
        self._refresh_table()
        blocked = self.blocked_reason()
        self.attribute_button.setEnabled(blocked is None)
        self.blocked_label.setText(blocked or "")
        self.blocked_label.setVisible(blocked is not None)
        self.summary_label.setText(self._summary())

    def blocked_reason(self) -> str | None:
        """Why attribution cannot run yet, or ``None`` when it can."""
        if self.experiment.pipeline.speech is None:
            return (
                "Transcription is switched off for this experiment; switch it "
                "on in the Audio processing tab. Speech turns are worked out "
                "from the transcripts, so there is nothing to attribute without "
                "them."
            )
        glasses = [v for v in self.experiment.glasses_videos if v.path is not None]
        if len(glasses) < 2:
            return (
                "Speaker attribution compares the glasses recordings against each "
                "other, so it needs at least two of them."
            )
        missing = sorted(v.id for v in glasses if v.speech.data is None)
        if missing:
            return (
                "Transcribe these recordings first, in the Audio processing tab: "
                + ", ".join(missing)
            )
        if not any(v.timeline.offset for v in glasses):
            return (
                "Align the recordings first, in the Alignment tab: attribution "
                "compares them moment by moment, so it needs them on one clock."
            )
        return None

    def is_busy(self) -> bool:
        return self._thread is not None

    @Slot()
    def _on_settings_changed(self) -> None:
        """Persist the visible settings as the experiment's post-processing config."""
        settings = self.experiment.pipeline.speech_post_processing
        settings = self.splitting_form.to_model(settings)
        settings = self.attribution_form.to_model(settings)
        if not isinstance(settings, SpeechPostProcessingSettings):
            return
        self.experiment.pipeline.speech_post_processing = settings
        self.experiment_changed.emit()

    def _refresh_table(self) -> None:
        """Show the experiment's speech turns, however they got there."""
        turns = self.experiment.speech_turns
        data = turns.data
        self.turns_table.clearContents()
        self.turns_table.setRowCount(0 if data is None else len(data))
        self._highlighted_rows.clear()
        self._marker_row = -1
        if data is None:
            return
        self.turns_table.setVerticalHeaderLabels([""] * len(data))
        for row, turn in enumerate(data.itertuples(index=False)):
            values = [
                _time_text(turn.start),
                _time_text(turn.end),
                str(turn.speaker),
                str(turn.text),
            ]
            for column, value in enumerate(values):
                item = QTableWidgetItem(value)
                if column in (_START, _END):
                    item.setTextAlignment(
                        Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
                    )
                if column == _TEXT:
                    item.setToolTip(textwrap.fill(value, 80))
                self.turns_table.setItem(row, column, item)
            self.turns_table.item(row, _START).setData(
                Qt.ItemDataRole.UserRole, (float(turn.start), float(turn.end))
            )
            self._tint_row(row, _TINT_ALPHA)

    def _tint_row(self, row: int, alpha: int) -> None:
        """Wash one row in its speaker's colour, at ``alpha`` out of 255."""
        speaker = self.turns_table.item(row, _SPEAKER)
        color = None if speaker is None else self.audio_player.color_for(speaker.text())
        if color is None:
            color = self.palette().highlight().color()
        color = QColor(color)
        color.setAlpha(alpha)
        for column in range(self.turns_table.columnCount()):
            item = self.turns_table.item(row, column)
            if item is not None:
                item.setBackground(QBrush(color))

    def _mark_row(self, row: int) -> None:
        """Point the gutter at the last turn playback has reached.

        The highlight only lasts as long as somebody is talking, so this is what
        holds the place in the table through the silence in between.
        """
        if row == self._marker_row:
            return
        for at, text in ((self._marker_row, ""), (row, _MARKER)):
            item = self.turns_table.verticalHeaderItem(at) if at >= 0 else None
            if item is not None:
                item.setText(text)
        self._marker_row = row

    def _refresh_audio(self) -> None:
        """Show every synchronized input that carries an audio stream."""
        recordings = [data for data in self.experiment.inputs if data.has_audio_track()]
        self.audio_player.load(recordings)
        self.audio_player.set_turns(self.experiment.speech_turns.data)

    @Slot(int, int)
    def _play_turn(self, row: int, _column: int) -> None:
        """Seek to a double-clicked accepted turn and begin shared playback."""
        item = self.turns_table.item(row, _START)
        bounds = None if item is None else item.data(Qt.ItemDataRole.UserRole)
        if bounds is None:
            return
        self.audio_player.seek(bounds[0])
        self.audio_player.play()

    @Slot(float)
    def _highlight_turns_at(self, seconds: float) -> None:
        """Deepen every accepted turn holding the shared playback position.

        Each turn keeps its speaker's colour, so which of them is talking is as
        plain in the table as it is on the player's tracks.
        """
        rows = set()
        marker_row = -1
        for row in range(self.turns_table.rowCount()):
            item = self.turns_table.item(row, _START)
            bounds = None if item is None else item.data(Qt.ItemDataRole.UserRole)
            if bounds is None:
                continue
            if bounds[0] <= seconds:
                marker_row = row
            if bounds[0] <= seconds < bounds[1]:
                rows.add(row)
        self._mark_row(marker_row)
        if rows == self._highlighted_rows:
            return

        for row in self._highlighted_rows - rows:
            self._tint_row(row, _TINT_ALPHA)
        for row in rows - self._highlighted_rows:
            self._tint_row(row, _HIGHLIGHT_ALPHA)
        self._highlighted_rows = rows
        if rows:
            first_row = min(rows)
            self.turns_table.scrollToItem(
                self.turns_table.item(first_row, _TEXT),
                QAbstractItemView.ScrollHint.PositionAtCenter,
            )

    def _summary(self) -> str:
        """What the table holds, or nothing at all when it holds nothing."""
        turns = self.experiment.speech_turns
        data = turns.data
        if data is None:
            return ""
        if data.empty:
            return "No speech was attributed to anyone."
        words = int(data["text"].str.split().str.len().sum())
        per_speaker = ", ".join(
            f"{speaker} {len(turns.for_speaker(speaker))}" for speaker in turns.speakers
        )
        return (
            f"{len(data)} turn(s), {words} word(s) across "
            f"{len(turns.speakers)} speaker(s) — turns each: {per_speaker}"
        )

    def _start(self) -> None:
        if self._thread is not None or self.blocked_reason() is not None:
            return
        self.summary_label.setText("Working out who spoke when…")
        self._set_running(True)
        self.progress_changed.emit(0, 100, _LABEL)
        self._worker = _Worker(self.experiment)
        self._worker.progress.connect(self._on_progress)
        self._worker.finished.connect(self._on_finished)
        self._worker.failed.connect(self._on_failed)
        self._worker.cancelled.connect(self._on_cancelled)
        self._thread = threading.Thread(target=self._worker.run, daemon=True)
        self._thread.start()

    def _cancel(self) -> None:
        if self._worker is not None:
            self._worker.cancel()
        self.cancel_button.setEnabled(False)
        self.cancel_button.setText("Cancelling…")

    @Slot(int)
    def _on_progress(self, percent: int) -> None:
        self.progress_changed.emit(percent, 100, _LABEL)

    @Slot()
    def _on_finished(self) -> None:
        turns = self.experiment.speech_turns
        count = 0 if turns.data is None else len(turns.data)
        self.status_message.emit(
            f"Attributed {count} speech turns across {len(turns.speakers)} speakers"
        )
        self._set_running(False)
        self.experiment_changed.emit()

    @Slot(str, str)
    def _on_failed(self, message: str, details: str) -> None:
        dialog = QMessageBox(self)
        dialog.setIcon(QMessageBox.Icon.Critical)
        dialog.setWindowTitle("Speaker attribution failed")
        dialog.setText(message)
        dialog.setDetailedText(details)
        dialog.exec()
        self._set_running(False)
        self.summary_label.setText("Could not attribute the speech.")

    @Slot()
    def _on_cancelled(self) -> None:
        self.status_message.emit("Speaker attribution cancelled")
        self._set_running(False)

    def _set_running(self, running: bool) -> None:
        if not running:
            self._thread = None
            self._worker = None
            self.refresh()
        self.attribute_button.setEnabled(not running and self.blocked_reason() is None)
        self.splitting_group.setEnabled(not running)
        self.attribution_group.setEnabled(not running)
        self.cancel_button.setVisible(running)
        self.cancel_button.setEnabled(True)
        self.cancel_button.setText("Cancel")
        self.busy_changed.emit(running)

blocked_reason()

Why attribution cannot run yet, or None when it can.

Source code in src/body_eye_sync/gui/tabs/speech_post_processing.py
def blocked_reason(self) -> str | None:
    """Why attribution cannot run yet, or ``None`` when it can."""
    if self.experiment.pipeline.speech is None:
        return (
            "Transcription is switched off for this experiment; switch it "
            "on in the Audio processing tab. Speech turns are worked out "
            "from the transcripts, so there is nothing to attribute without "
            "them."
        )
    glasses = [v for v in self.experiment.glasses_videos if v.path is not None]
    if len(glasses) < 2:
        return (
            "Speaker attribution compares the glasses recordings against each "
            "other, so it needs at least two of them."
        )
    missing = sorted(v.id for v in glasses if v.speech.data is None)
    if missing:
        return (
            "Transcribe these recordings first, in the Audio processing tab: "
            + ", ".join(missing)
        )
    if not any(v.timeline.offset for v in glasses):
        return (
            "Align the recordings first, in the Alignment tab: attribution "
            "compares them moment by moment, so it needs them on one clock."
        )
    return None

VideoProcessingTab

Bases: BaseTab

Show one of the experiment's videos and run its pipeline over it.

Source code in src/body_eye_sync/gui/tabs/video_processing.py
class VideoProcessingTab(BaseTab):
    """Show one of the experiment's videos and run its pipeline over it."""

    title = "Video processing"

    def __init__(self, experiment: Experiment) -> None:
        super().__init__(experiment)

        self._thread: threading.Thread | None = None
        self._worker: (
            ObjectTrackingWorker | FaceDetectionWorker | BodyPoseWorker | None
        ) = None
        #: Remaining step types queued by "Run all"; consumed one at a time as
        #: each step finishes, so later steps see earlier steps' results.
        self._pending_steps: list[type] = []
        #: The experiment's video inputs, in the order the chooser lists them.
        self._videos: list[Video] = []

        self.video_selector = QComboBox()
        self.video_selector.currentIndexChanged.connect(self._on_video_selected)

        self.video_viewer = VideoViewer()

        self.cancel_button = QPushButton("Cancel")
        self.cancel_button.setVisible(False)
        self.cancel_button.clicked.connect(self._cancel_run)

        top_bar = QHBoxLayout()
        top_bar.addWidget(QLabel("Video:"))
        top_bar.addWidget(self.video_selector, stretch=1)

        bottom_bar = QHBoxLayout()
        bottom_bar.addStretch(1)
        bottom_bar.addWidget(self.cancel_button)

        viewer_layout = QVBoxLayout()
        viewer_layout.setContentsMargins(0, 0, 0, 0)
        viewer_layout.addLayout(top_bar)
        viewer_layout.addWidget(self.video_viewer, stretch=1)
        viewer_layout.addLayout(bottom_bar)
        viewer_side = QWidget()
        viewer_side.setLayout(viewer_layout)

        self.pipeline_editor = PipelineEditor(VIDEO_STEPS)
        self.pipeline_editor.changed.connect(self._on_pipeline_edited)
        self.pipeline_editor.run_requested.connect(self._start_step)
        self.pipeline_editor.run_all_requested.connect(self._start_run_all)
        self.pipeline_scroll_area = QScrollArea()
        self.pipeline_scroll_area.setWidgetResizable(True)
        self.pipeline_scroll_area.setWidget(self.pipeline_editor)

        self.splitter = QSplitter(Qt.Orientation.Horizontal)
        self.splitter.addWidget(viewer_side)
        self.splitter.addWidget(self.pipeline_scroll_area)
        self.splitter.setStretchFactor(0, 1)
        self.splitter.setStretchFactor(1, 0)

        layout = QVBoxLayout(self)
        layout.addWidget(self.splitter)

        # How to run each step: its worker, readiness check and viewer plumbing.
        # Built last since it closes over ``self.video_viewer``.
        self._step_runners: dict[type, _StepRunner] = {
            ObjectTrackingStep: _StepRunner(
                worker_cls=ObjectTrackingWorker,
                ready=lambda video: video.video_path is not None,
                begin=lambda video, k: video.begin_object_tracking(k),
                live_frame_slot=self.video_viewer.show_live_frame,
                on_finished=self._on_finished,
            ),
            FaceDetectionStep: _StepRunner(
                worker_cls=FaceDetectionWorker,
                ready=lambda video: video.data is not None,
                begin=lambda video, k: video.begin_face_detection(k),
                live_frame_slot=self.video_viewer.show_live_face_frame,
                on_finished=self._on_face_finished,
            ),
            BodyPoseStep: _StepRunner(
                worker_cls=BodyPoseWorker,
                ready=lambda video: video.data is not None,
                begin=lambda video, k: video.begin_body_pose_detection(k),
                live_frame_slot=self.video_viewer.show_live_pose_frame,
                on_finished=self._on_pose_finished,
            ),
        }
        self.refresh()

    def refresh(self) -> None:
        """Re-list the experiment's videos, keeping the shown one if it is still there."""
        if self._thread is not None:
            # A run drives the viewer and the video it writes into; leave it be.
            return
        shown = self.video()
        self._videos = [*self.experiment.glasses_videos, *self.experiment.fixed_videos]
        self.video_selector.blockSignals(True)
        self.video_selector.clear()
        for video in self._videos:
            kind = "glasses" if isinstance(video, GlassesVideo) else "fixed"
            self.video_selector.addItem(f"{video.id} ({kind})")
        index = next(
            (i for i, video in enumerate(self._videos) if video is shown),
            0 if self._videos else -1,
        )
        self.video_selector.setCurrentIndex(index)
        self.video_selector.blockSignals(False)
        self.video_selector.setEnabled(bool(self._videos))
        self._show_selected_video()

    def video(self) -> Video | None:
        """The video input being shown, or ``None`` if the experiment has none."""
        index = self.video_selector.currentIndex()
        if 0 <= index < len(self._videos):
            return self._videos[index]
        return None

    def is_busy(self) -> bool:
        """Whether a pipeline step is currently running."""
        return self._thread is not None

    def shutdown(self) -> None:
        if self._worker is not None:
            self._worker.cancel()
        if self._thread is not None:
            self._thread.join(timeout=5.0)

    def _on_video_selected(self, _index: int) -> None:
        self._show_selected_video()

    def _show_selected_video(self) -> None:
        """Load the chosen video into the viewer and bind the editor to it."""
        video = self.video()
        # The viewer says what it holds, so a video it could not open is tried
        # again next time rather than being left blank for good.
        if video is not self.video_viewer.video:
            if video is None:
                self.video_viewer.clear()
            else:
                try:
                    self.video_viewer.load(video)
                except OSError as exc:
                    self.video_viewer.clear()
                    self.status_message.emit(f"Could not open video: {exc}")
        self.video_viewer.refresh_overlays()
        self._bind_editor_to_video()
        self._update_step_availability()

    def _pipeline(self) -> VideoPipeline | None:
        """The pipeline block for the shown video's input type, or ``None``.

        Each input type has its own block, so the editor edits the one belonging
        to the video being shown.
        """
        video = self.video()
        if video is None:
            return None
        if isinstance(video, GlassesVideo):
            return self.experiment.pipeline.glasses_video
        return self.experiment.pipeline.fixed_video

    def _bind_editor_to_video(self) -> None:
        """Populate the pipeline editor from the shown video (or disable it)."""
        pipeline = self._pipeline()
        if pipeline is None:
            self.pipeline_editor.reset()
            self.pipeline_editor.setEnabled(False)
            return
        self.pipeline_editor.setEnabled(True)
        self.pipeline_editor.set_from(pipeline)

    def _on_pipeline_edited(self) -> None:
        """Adopt the editor's pipeline as the experiment's, when it is valid."""
        pipeline = self._pipeline()
        if pipeline is None:
            return
        try:
            self.pipeline_editor.apply_to(pipeline)
        except (ValidationError, ValueError):
            self.status_message.emit("Pipeline has invalid settings; not applied")
            return
        self.experiment_changed.emit()

    def _update_step_availability(self) -> None:
        """Enable each step's "Run" button (and "Run all") once its inputs are ready.

        Object tracking needs a video; later passes run on tracked boxes, so
        they wait for object tracking's results. Whether a *run* is currently
        in progress is handled separately, by disabling the whole pipeline
        editor (see ``_set_running``).
        """
        video = self.video()
        has_video = video is not None and video.video_path is not None
        has_tracks = video is not None and video.data is not None
        self.pipeline_editor.set_run_enabled(ObjectTrackingStep, has_video)
        self.pipeline_editor.set_run_enabled(FaceDetectionStep, has_tracks)
        self.pipeline_editor.set_run_enabled(BodyPoseStep, has_tracks)
        self.pipeline_editor.set_run_all_enabled(has_video)

    def _step_config(self, step_type):
        """The editor's validated config for a step, or None (with an alert)."""
        try:
            return self.pipeline_editor.config_for(step_type)
        except (ValidationError, ValueError) as exc:
            QMessageBox.critical(self, "Invalid settings", str(exc))
            return None

    @Slot(object)
    def _start_step(self, step_type: type) -> None:
        """Run one pipeline step, using the editor's current arguments for it."""
        if self._thread is not None:
            return
        video = self.video()
        runner = self._step_runners[step_type]
        if video is None or not runner.ready(video):
            self._pending_steps = []
            return
        step = self._step_config(step_type)
        if step is None:
            self._pending_steps = []
            return

        # Discards that step's previous results; keeps everything else (e.g. a
        # face/pose pass keeps the tracked boxes it runs over). The embedding
        # budget comes from the step config, so the GUI and CLI behave identically.
        runner.begin(video, getattr(step, "embeddings_per_track", 0))
        self._begin_run()

        self._worker = runner.worker_cls(video, step)
        self._worker.new_frame.connect(self._on_new_frame)
        self._worker.new_frame.connect(runner.live_frame_slot)
        self._worker.finished.connect(runner.on_finished)
        self._worker.failed.connect(self._on_failed)
        self._worker.cancelled.connect(self._on_cancelled)

        self._thread = threading.Thread(target=self._worker.run, daemon=True)
        self._thread.start()

    def _start_run_all(self) -> None:
        """Run every enabled pipeline step in order, one after another."""
        if self._thread is not None:
            return
        try:
            steps = self.pipeline_editor.enabled_steps()
        except (ValidationError, ValueError) as exc:
            QMessageBox.critical(self, "Invalid settings", str(exc))
            return
        self._pending_steps = [type(step) for step in steps]
        self._continue_run_all()

    def _continue_run_all(self) -> None:
        """Start the next step queued by "Run all", if any are left."""
        if self._pending_steps:
            self._start_step(self._pending_steps.pop(0))

    def _begin_run(self) -> None:
        """Shared start-up for object tracking and later detection runs."""
        self._set_running(True)
        # Weights are built/downloaded before the first frame is processed, so
        # show a busy bar until the first frame arrives.
        self.progress_changed.emit(0, 0, "Downloading weights…")

    @Slot(object)
    def _on_new_frame(self, frame) -> None:
        # The first frame turns the busy "downloading" bar into a determinate
        # one, which reporting a total takes care of on its own.
        total = self.video_viewer.frame_count
        label = f"{self._worker.operation_name}…"
        self.progress_changed.emit(frame.frame_idx if total else 0, total, label)

    def _cancel_run(self) -> None:
        if self._worker is not None:
            self._worker.cancel()
        self.cancel_button.setEnabled(False)
        self.cancel_button.setText("Cancelling…")

    @Slot()
    def _on_finished(self) -> None:
        data = self.video().data
        self.status_message.emit(
            f"Object tracking finished: {data['track_id'].nunique()} tracklets, "
            f"{len(data)} detections"
        )
        self._set_running(False)
        self._continue_run_all()

    @Slot()
    def _on_face_finished(self) -> None:
        data = self.video().data
        n_faces = int(data["face_score"].notna().sum())
        self.status_message.emit(
            f"Face detection finished: {n_faces} faces over {len(data)} detections"
        )
        self._set_running(False)
        self._continue_run_all()

    @Slot()
    def _on_pose_finished(self) -> None:
        data = self.video().data
        n_poses = int(data["pose_score"].notna().sum())
        self.status_message.emit(
            f"Body pose detection finished: {n_poses} poses over {len(data)} detections"
        )
        self._set_running(False)
        self._continue_run_all()

    @Slot(str, str)
    def _on_failed(self, message: str, details: str) -> None:
        # A failure stops a "Run all" chain rather than pressing on regardless.
        self._pending_steps = []
        dialog = QMessageBox(self)
        dialog.setIcon(QMessageBox.Icon.Critical)
        dialog.setWindowTitle(f"{self._worker.operation_name} failed")
        dialog.setText(message)
        dialog.setDetailedText(details)
        dialog.exec()
        self._set_running(False)

    @Slot()
    def _on_cancelled(self) -> None:
        # Cancelling one step cancels the rest of a "Run all" chain too.
        self._pending_steps = []
        self.status_message.emit(f"{self._worker.operation_name} cancelled")
        self._set_running(False)

    def _set_running(self, running: bool) -> None:
        if not running:
            # background thread has reported back; drop our references to it.
            self._thread = None
            self._worker = None
            # Re-read the experiment: refresh() bows out while a run is on, so
            # this is where any change made to the inputs meanwhile is picked up.
            self.refresh()
            # However it ended, the run changed the video's results.
            self.experiment_changed.emit()
        self.video_selector.setEnabled(not running and bool(self._videos))
        self.pipeline_editor.setEnabled(not running and self.video() is not None)
        self.video_viewer.enable_controls(not running)
        self.cancel_button.setVisible(running)
        self.cancel_button.setEnabled(True)
        self.cancel_button.setText("Cancel")
        # The window locks the actions that would pull the experiment away.
        self.busy_changed.emit(running)

is_busy()

Whether a pipeline step is currently running.

Source code in src/body_eye_sync/gui/tabs/video_processing.py
def is_busy(self) -> bool:
    """Whether a pipeline step is currently running."""
    return self._thread is not None

refresh()

Re-list the experiment's videos, keeping the shown one if it is still there.

Source code in src/body_eye_sync/gui/tabs/video_processing.py
def refresh(self) -> None:
    """Re-list the experiment's videos, keeping the shown one if it is still there."""
    if self._thread is not None:
        # A run drives the viewer and the video it writes into; leave it be.
        return
    shown = self.video()
    self._videos = [*self.experiment.glasses_videos, *self.experiment.fixed_videos]
    self.video_selector.blockSignals(True)
    self.video_selector.clear()
    for video in self._videos:
        kind = "glasses" if isinstance(video, GlassesVideo) else "fixed"
        self.video_selector.addItem(f"{video.id} ({kind})")
    index = next(
        (i for i, video in enumerate(self._videos) if video is shown),
        0 if self._videos else -1,
    )
    self.video_selector.setCurrentIndex(index)
    self.video_selector.blockSignals(False)
    self.video_selector.setEnabled(bool(self._videos))
    self._show_selected_video()

video()

The video input being shown, or None if the experiment has none.

Source code in src/body_eye_sync/gui/tabs/video_processing.py
def video(self) -> Video | None:
    """The video input being shown, or ``None`` if the experiment has none."""
    index = self.video_selector.currentIndex()
    if 0 <= index < len(self._videos):
        return self._videos[index]
    return None

body_eye_sync.gui.tabs.base

Common base class for tabs.

The main window owns the experiment, and passes the experiment to a tab using :meth:BaseTab.set_experiment, and calls :meth:BaseTab.refresh when the experiment is changed elsewhere. Tabs report changes to the experiment via signals.

BaseTab

Bases: QWidget

One tab of the main window, acting on an :class:Experiment.

Source code in src/body_eye_sync/gui/tabs/base.py
class BaseTab(QWidget):
    """One tab of the main window, acting on an :class:`Experiment`."""

    title: ClassVar[str] = ""

    _worker: QObject | None = None
    _thread: threading.Thread | None = None

    # signals
    status_message = Signal(str)
    experiment_changed = Signal()
    busy_changed = Signal(bool)
    finished = Signal()
    # current value, maximum value (zero means indeterminate), operation label:
    progress_changed = Signal(int, int, str)

    def __init__(self, experiment: Experiment) -> None:
        super().__init__()
        self.experiment = experiment

    def set_experiment(self, experiment: Experiment) -> None:
        self.experiment = experiment
        self.refresh()

    def _inputs(self) -> dict[str, Video | Audio]:
        """The experiment's inputs that have a recording, keyed by id."""
        return {
            data.id: data for data in self.experiment.inputs if data.path is not None
        }

    def refresh(self) -> None:
        """Re-read the experiment, which may have been changed elsewhere."""

    def shutdown(self) -> None:
        """Stop any work in progress; called when the window is closing."""
        worker, thread = self._worker, self._thread
        self._worker = None
        self._thread = None
        if worker is None:
            return
        worker.cancel()
        QObject.disconnect(worker, None, None, None)
        if thread is not None:
            thread.join(timeout=SHUTDOWN_TIMEOUT)

refresh()

Re-read the experiment, which may have been changed elsewhere.

Source code in src/body_eye_sync/gui/tabs/base.py
def refresh(self) -> None:
    """Re-read the experiment, which may have been changed elsewhere."""

shutdown()

Stop any work in progress; called when the window is closing.

Source code in src/body_eye_sync/gui/tabs/base.py
def shutdown(self) -> None:
    """Stop any work in progress; called when the window is closing."""
    worker, thread = self._worker, self._thread
    self._worker = None
    self._thread = None
    if worker is None:
        return
    worker.cancel()
    QObject.disconnect(worker, None, None, None)
    if thread is not None:
        thread.join(timeout=SHUTDOWN_TIMEOUT)

PlaceholderTab

Bases: BaseTab

A temporary tab for yet to be implemented tabs.

Source code in src/body_eye_sync/gui/tabs/base.py
class PlaceholderTab(BaseTab):
    """A temporary tab for yet to be implemented tabs."""

    def __init__(self, experiment: Experiment) -> None:
        super().__init__(experiment)
        label = QLabel(f"{self.title} is not implemented yet")
        label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        layout = QVBoxLayout(self)
        layout.addWidget(label)

body_eye_sync.gui.tabs.input_files

Input files tab: the input video and audio files for the experiment.

InputFilesTab

Bases: BaseTab

Name the experiment, and add, remove and edit its input files.

Source code in src/body_eye_sync/gui/tabs/input_files.py
class InputFilesTab(BaseTab):
    """Name the experiment, and add, remove and edit its input files."""

    title = "Input files"

    def __init__(self, experiment: Experiment) -> None:
        super().__init__(experiment)

        #: One section per input type, in :data:`INPUT_KINDS` order.
        self.sections = [_InputSection(kind, experiment) for kind in INPUT_KINDS]
        self.glasses_section, self.fixed_section, self.audio_section = self.sections

        sections_layout = QVBoxLayout()
        for section in self.sections:
            section.changed.connect(self._on_section_changed)
            section.status_message.connect(self.status_message)
            section.selected.connect(lambda source=section: self._select_only(source))
            sections_layout.addWidget(section)
        sections_layout.addStretch(1)

        sections = QWidget()
        sections.setLayout(sections_layout)
        area = QScrollArea()
        area.setWidgetResizable(True)
        area.setWidget(sections)

        layout = QVBoxLayout(self)
        layout.addWidget(area)

    def refresh(self) -> None:
        """Rebuild every section from the experiment."""
        for section in self.sections:
            section.refresh()

    def set_experiment(self, experiment: Experiment) -> None:
        for section in self.sections:
            section.experiment = experiment
        super().set_experiment(experiment)

    def selected_inputs(self) -> list[Video | Audio]:
        """The selected inputs; at most one section has a selection at a time."""
        return [data for section in self.sections for data in section.selected_inputs()]

    def add_glasses_videos(self, paths: list[Path]) -> None:
        """Add each path as a glasses video input, ids taken from the filenames."""
        self.glasses_section.add_files(paths)

    def add_fixed_videos(self, paths: list[Path]) -> None:
        """Add each path as a fixed video input, ids taken from the filenames."""
        self.fixed_section.add_files(paths)

    def add_audio(self, paths: list[Path]) -> None:
        """Add each path as an audio input, ids taken from the filenames."""
        self.audio_section.add_files(paths)

    def remove_inputs(self, inputs: list[Video | Audio]) -> None:
        """Remove ``inputs`` from the experiment, reporting any that cannot go."""
        if _remove_inputs(self, self.experiment, inputs):
            self._on_section_changed()

    def _on_section_changed(self) -> None:
        """A section edited the experiment: re-read it, and pass the news on.

        Every section is rebuilt, not just the one that changed: adding, renaming
        or removing a glasses video changes what the audio section offers.
        """
        self.refresh()
        self.experiment_changed.emit()

    def _select_only(self, source: _InputSection) -> None:
        """Keep the selection in one section, so Remove is never ambiguous."""
        for section in self.sections:
            if section is not source:
                section.clear_selection()

add_audio(paths)

Add each path as an audio input, ids taken from the filenames.

Source code in src/body_eye_sync/gui/tabs/input_files.py
def add_audio(self, paths: list[Path]) -> None:
    """Add each path as an audio input, ids taken from the filenames."""
    self.audio_section.add_files(paths)

add_fixed_videos(paths)

Add each path as a fixed video input, ids taken from the filenames.

Source code in src/body_eye_sync/gui/tabs/input_files.py
def add_fixed_videos(self, paths: list[Path]) -> None:
    """Add each path as a fixed video input, ids taken from the filenames."""
    self.fixed_section.add_files(paths)

add_glasses_videos(paths)

Add each path as a glasses video input, ids taken from the filenames.

Source code in src/body_eye_sync/gui/tabs/input_files.py
def add_glasses_videos(self, paths: list[Path]) -> None:
    """Add each path as a glasses video input, ids taken from the filenames."""
    self.glasses_section.add_files(paths)

refresh()

Rebuild every section from the experiment.

Source code in src/body_eye_sync/gui/tabs/input_files.py
def refresh(self) -> None:
    """Rebuild every section from the experiment."""
    for section in self.sections:
        section.refresh()

remove_inputs(inputs)

Remove inputs from the experiment, reporting any that cannot go.

Source code in src/body_eye_sync/gui/tabs/input_files.py
def remove_inputs(self, inputs: list[Video | Audio]) -> None:
    """Remove ``inputs`` from the experiment, reporting any that cannot go."""
    if _remove_inputs(self, self.experiment, inputs):
        self._on_section_changed()

selected_inputs()

The selected inputs; at most one section has a selection at a time.

Source code in src/body_eye_sync/gui/tabs/input_files.py
def selected_inputs(self) -> list[Video | Audio]:
    """The selected inputs; at most one section has a selection at a time."""
    return [data for section in self.sections for data in section.selected_inputs()]

gaze_file_for(section, video)

The gaze file to record a glasses video with: found beside it, or chosen.

Devices export the gaze samples next to the video, so that is looked for first and the user is only asked when it is not obvious.

Source code in src/body_eye_sync/gui/tabs/input_files.py
def gaze_file_for(section: _InputSection, video: Path) -> dict[str, Path] | None:
    """The gaze file to record a glasses video with: found beside it, or chosen.

    Devices export the gaze samples next to the video, so that is looked for
    first and the user is only asked when it is not obvious.
    """
    beside = video.with_suffix(".tsv")
    if beside.exists():
        return {"gaze_path": beside}
    chosen, _ = QFileDialog.getOpenFileName(
        section, f"Gaze file for {video.name}", str(video.parent), GAZE_FILTER
    )
    if not chosen:
        section.status_message.emit(f"{video.name} not added: it needs a gaze file")
        return None
    return {"gaze_path": Path(chosen)}

body_eye_sync.gui.tabs.alignment

Alignment tab: placing each input on the shared experiment timeline.

AlignmentTab

Bases: BaseTab

Let the user align all kind of inputs in time with each other via setting their time_offset properties.

Source code in src/body_eye_sync/gui/tabs/alignment.py
class AlignmentTab(BaseTab):
    """Let the user align all kind of inputs in time with each other via setting their time_offset properties."""

    title = "Alignment"

    def __init__(self, experiment: Experiment) -> None:
        super().__init__(experiment)
        self.video_cards: list[_VideoAlignmentCard] = []
        self._play_all_primary: _VideoAlignmentCard | None = None
        self.align_button = QPushButton("Automatic alignment")
        self.align_button.setToolTip(
            "Estimate initial offsets for all recordings before fine-tuning them"
        )
        self.align_button.clicked.connect(self._align)
        self.reset_timeline_button = QToolButton()
        self.reset_timeline_button.setIcon(
            self.style().standardIcon(QStyle.StandardPixmap.SP_MediaSkipBackward)
        )
        self.reset_timeline_button.setToolTip("Go to timeline zero")
        self.reset_timeline_button.clicked.connect(self._go_to_timeline_zero)
        self.play_all_button = QToolButton()
        self.play_all_button.setCheckable(True)
        self.play_all_button.setIconSize(QSize(24, 24))
        self.play_all_button.setText("All")
        self.play_all_button.setToolButtonStyle(
            Qt.ToolButtonStyle.ToolButtonTextBesideIcon
        )
        self.play_all_button.setIcon(
            self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)
        )
        self.play_all_button.setToolTip("Play all videos")
        self.play_all_button.toggled.connect(self._on_play_all_toggled)
        self.done_button = QPushButton("Finish alignment")
        self.done_button.setDefault(True)
        self.done_button.clicked.connect(self._finish_alignment)

        layout = QVBoxLayout(self)
        layout.addWidget(self.align_button)
        self.scroll_area = QScrollArea()
        self.scroll_area.setWidgetResizable(True)
        self.video_grid_widget = QWidget()
        self.grid = QGridLayout(self.video_grid_widget)
        self.grid.setAlignment(Qt.AlignmentFlag.AlignTop)
        self.scroll_area.setWidget(self.video_grid_widget)
        layout.addWidget(self.scroll_area, stretch=1)
        buttons = QHBoxLayout()
        buttons.addWidget(self.reset_timeline_button)
        buttons.addWidget(self.play_all_button)
        buttons.addStretch(1)
        buttons.addWidget(self.done_button)
        layout.addLayout(buttons)
        self.refresh()

    def refresh(self) -> None:
        """Render every video input, with at most three videos per row."""
        self._stop_play_all()
        videos = [*self.experiment.glasses_videos, *self.experiment.fixed_videos]
        self.align_button.setEnabled(len(self._inputs()) >= 2)
        if (
            self.video_cards
            and len(videos) == len(self.video_cards)
            and all(
                card.video is video
                and card.loaded_path == video.video_path
                and card.loaded
                for card, video in zip(self.video_cards, videos)
            )
        ):
            for card in self.video_cards:
                card.input_label.setText(card.video.id)
                card.controls.spin.blockSignals(True)
                card.controls.spin.setValue(card.video.timeline.offset)
                card.controls.spin.blockSignals(False)
            self._show_shared_timeline_time(0.0)
            return

        for card in self.video_cards:
            self.grid.removeWidget(card)
            card.shutdown()
            card.deleteLater()
        self.video_cards = []

        column_count = min(_VIDEOS_PER_ROW, max(1, len(videos)))
        for column in range(_VIDEOS_PER_ROW):
            self.grid.setColumnStretch(column, int(column < column_count))
        for index, video in enumerate(videos):
            card = _VideoAlignmentCard(video)
            if card.load_error is not None:
                self.status_message.emit(f"Could not open video: {card.load_error}")
            card.changed.connect(self.experiment_changed)
            card.set_requested.connect(self._set_offset_from_current_frame)

            self.video_cards.append(card)
            self.grid.addWidget(card, index // _VIDEOS_PER_ROW, index % _VIDEOS_PER_ROW)
        self.play_all_button.setEnabled(
            # Only enable Play-all when every card can be played to avoid possible half-playing or mid-play loading states
            bool(self.video_cards) and all(card.loaded for card in self.video_cards)
        )
        self.reset_timeline_button.setEnabled(
            any(card.loaded for card in self.video_cards)
        )

    def _align(self) -> None:
        """Estimate initial offsets and show them in the manual controls."""
        if len(self._inputs()) < 2:
            return
        self._stop_play_all()
        self.busy_changed.emit(True)
        self.setEnabled(False)
        self.progress_changed.emit(0, 100, "Aligning recordings…")
        try:
            result = align_experiment(self.experiment, progress=self._progress)
            if result.offsets:
                self.experiment_changed.emit()
                self.status_message.emit("Automatic alignment finished")
        finally:
            self.setEnabled(True)
            self.busy_changed.emit(False)
            self.refresh()
        if result.offsets:
            self._show_shared_timeline_time(self._first_common_experiment_time())

    def _first_common_experiment_time(self) -> float:
        """First experiment time represented by every video timeline."""
        videos = [card.video for card in self.video_cards]
        return max(
            (video.timeline.to_experiment_time(0.0) for video in videos),
            default=0.0,
        )

    def _progress(self, value: float) -> bool:
        self.progress_changed.emit(round(100 * value), 100, "Aligning recordings…")
        QApplication.processEvents(QEventLoop.ProcessEventsFlag.ExcludeUserInputEvents)
        return True

    def _set_offset_from_current_frame(self, source: _VideoAlignmentCard) -> None:
        offset = round(
            -source.viewer.current_time_seconds * source.video.timeline.rate, 3
        )
        loaded_cards = [card for card in self.video_cards if card.loaded]
        message = QMessageBox(self)
        message.setWindowTitle("Zero current frame")
        message.setText(f"Apply offset {offset:.3f} s to this video, or to all videos?")
        message.setIcon(QMessageBox.Icon.Question)
        this_video_button = message.addButton(
            "This video", QMessageBox.ButtonRole.AcceptRole
        )
        all_videos_button = message.addButton(
            "All videos", QMessageBox.ButtonRole.DestructiveRole
        )
        cancel_button = message.addButton(QMessageBox.StandardButton.Cancel)
        this_video_button.setStyleSheet(_THIS_VIDEO_BUTTON_STYLE)
        all_videos_button.setStyleSheet(_ALL_VIDEOS_BUTTON_STYLE)
        message.setDefaultButton(this_video_button)
        message.setEscapeButton(cancel_button)
        message.exec()
        clicked_button = message.clickedButton()
        if clicked_button not in (this_video_button, all_videos_button):
            return
        self._stop_play_all()
        if clicked_button is this_video_button:
            source.set_offset(offset)
            self._show_shared_timeline_time(0.0)
            return
        for card in loaded_cards:
            card.set_offset(offset)

    def _go_to_timeline_zero(self) -> None:
        self._stop_play_all()
        self._show_shared_timeline_time(0.0)

    def _show_shared_timeline_time(self, seconds: float) -> None:
        for card in self.video_cards:
            if card.loaded:
                card.viewer.set_time_seconds(
                    card.video.timeline.to_local_time(seconds),
                    allow_negative=True,
                    show_requested_time=True,
                )
                card.controls._show_timeline_state(seconds)

    def _finish_alignment(self) -> None:
        self._stop_play_all()
        self.finished.emit()

    def _on_play_all_toggled(self, play: bool) -> None:
        """
        Start or stop shared playback; all videos must be loaded.
        """
        user_desires_pause = not play
        if user_desires_pause:
            self._stop_play_all()  # Cleanly stop all
            return
        if not self.video_cards or not all(card.loaded for card in self.video_cards):
            # To prevent unusual situations with some playing
            return
        primary = self.video_cards[0]
        # This is separate because we only stop videos other than the "primary one"
        for card in self.video_cards[1:]:
            card.viewer.stop()
        self._play_all_primary = primary
        primary.viewer.frame_changed.connect(self._sync_play_all_viewers)
        self.play_all_button.setIcon(
            self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPause)
        )
        self.play_all_button.setToolTip("Pause all videos")
        self._sync_play_all_viewers()
        if self._play_all_primary is not None:
            primary.viewer._play_button.setChecked(True)

    def _sync_play_all_viewers(self, _frame: int = 0) -> None:
        """
        Use the primary videos current time seconds to icnrement the frames of the other videos.
        """
        primary = self._play_all_primary
        if primary is None:
            return
        shared_timeline_time = primary.video.timeline.to_experiment_time(
            primary.viewer.playback_time_seconds
        )
        for card in self.video_cards:
            if card is not primary:
                card.viewer.set_time_seconds(
                    card.video.timeline.to_local_time(shared_timeline_time),
                    allow_negative=True,
                    show_requested_time=True,
                    sync_audio=False,
                )
                card.controls._show_timeline_state(shared_timeline_time)
        if primary.viewer.current_frame + 1 >= primary.viewer.frame_count:
            self._stop_play_all()

    def _stop_play_all(self) -> None:
        if self._play_all_primary is not None:
            self._play_all_primary.viewer.frame_changed.disconnect(
                self._sync_play_all_viewers
            )
            self._play_all_primary = None
        for card in self.video_cards:
            if card.viewer._play_button.isChecked():
                card.viewer.stop()
        self.play_all_button.blockSignals(True)
        self.play_all_button.setChecked(False)
        self.play_all_button.blockSignals(False)
        self.play_all_button.setIcon(
            self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)
        )
        self.play_all_button.setToolTip("Play all videos")

refresh()

Render every video input, with at most three videos per row.

Source code in src/body_eye_sync/gui/tabs/alignment.py
def refresh(self) -> None:
    """Render every video input, with at most three videos per row."""
    self._stop_play_all()
    videos = [*self.experiment.glasses_videos, *self.experiment.fixed_videos]
    self.align_button.setEnabled(len(self._inputs()) >= 2)
    if (
        self.video_cards
        and len(videos) == len(self.video_cards)
        and all(
            card.video is video
            and card.loaded_path == video.video_path
            and card.loaded
            for card, video in zip(self.video_cards, videos)
        )
    ):
        for card in self.video_cards:
            card.input_label.setText(card.video.id)
            card.controls.spin.blockSignals(True)
            card.controls.spin.setValue(card.video.timeline.offset)
            card.controls.spin.blockSignals(False)
        self._show_shared_timeline_time(0.0)
        return

    for card in self.video_cards:
        self.grid.removeWidget(card)
        card.shutdown()
        card.deleteLater()
    self.video_cards = []

    column_count = min(_VIDEOS_PER_ROW, max(1, len(videos)))
    for column in range(_VIDEOS_PER_ROW):
        self.grid.setColumnStretch(column, int(column < column_count))
    for index, video in enumerate(videos):
        card = _VideoAlignmentCard(video)
        if card.load_error is not None:
            self.status_message.emit(f"Could not open video: {card.load_error}")
        card.changed.connect(self.experiment_changed)
        card.set_requested.connect(self._set_offset_from_current_frame)

        self.video_cards.append(card)
        self.grid.addWidget(card, index // _VIDEOS_PER_ROW, index % _VIDEOS_PER_ROW)
    self.play_all_button.setEnabled(
        # Only enable Play-all when every card can be played to avoid possible half-playing or mid-play loading states
        bool(self.video_cards) and all(card.loaded for card in self.video_cards)
    )
    self.reset_timeline_button.setEnabled(
        any(card.loaded for card in self.video_cards)
    )

body_eye_sync.gui.tabs.clock_rate

Clock rate tab: measure each input's clock rate against the others.

ClockRateTab

Bases: BaseTab

Recalculate and apply each input's offset and clock rate.

Source code in src/body_eye_sync/gui/tabs/clock_rate.py
class ClockRateTab(BaseTab):
    """Recalculate and apply each input's offset and clock rate."""

    title = "Clock rate"

    def __init__(self, experiment: Experiment) -> None:
        super().__init__(experiment)
        self._thread: threading.Thread | None = None
        self._worker: _Worker | None = None
        self._analysis: ClockRateAnalysis | None = None
        self._analysis_signature: tuple | None = None

        self.table = AutoHeightTable(_COLUMNS)
        self.table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
        self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
        self.table.horizontalHeader().setSectionResizeMode(
            _DRIFT, QHeaderView.ResizeMode.Stretch
        )

        self.correct_button = QPushButton("Analyse and correct clock rates")
        self.correct_button.clicked.connect(self._start_correction)
        self.clear_button = QPushButton("Clear corrections")
        self.clear_button.setToolTip(
            "Reset every input's clock rate, leaving the offsets alone"
        )
        self.clear_button.clicked.connect(self._clear_corrections)
        self.window_spin = _setting_spin(2.0, 120.0, DEFAULT_WINDOW)
        self.window_spin.setToolTip("How long each measurement window is.")
        self.search_spin = _setting_spin(1.0, 120.0, DEFAULT_SEARCH)
        self.search_spin.setToolTip(
            "How far either side of the current offset each window looks for its lag."
        )
        self.min_quality_spin = _setting_spin(1.0, 30.0, SPECTRAL_MIN_QUALITY)
        self.min_quality_spin.setToolTip(
            "Quality gate: higher values require a stronger signal to measure a lag."
        )
        self.min_drift_spin = _setting_spin(0.5, 50.0, MIN_DRIFT_PPM)
        self.min_drift_spin.setDecimals(1)
        self.min_drift_spin.setSingleStep(0.5)
        self.min_drift_spin.setToolTip(
            "The smallest clock difference worth correcting, in parts per million."
        )
        settings_form = QFormLayout()
        settings_form.setContentsMargins(0, 0, 0, 0)
        settings_form.addRow("Window (s)", self.window_spin)
        settings_form.addRow("Search (s)", self.search_spin)
        settings_form.addRow("Min quality", self.min_quality_spin)
        settings_form.addRow("Min drift (ppm)", self.min_drift_spin)
        self.settings_widget = QWidget()
        self.settings_widget.setLayout(settings_form)

        self.figure = Figure(figsize=(9, 5), constrained_layout=True)
        self.canvas = FigureCanvasQTAgg(self.figure)
        self.canvas.setMinimumHeight(400)

        buttons = QHBoxLayout()
        buttons.addWidget(self.correct_button)
        buttons.addWidget(self.clear_button)
        buttons.addStretch(1)

        controls = QVBoxLayout()
        controls.addWidget(self.settings_widget)
        controls.addLayout(buttons)
        controls.addStretch(1)

        top = QHBoxLayout()
        top.addWidget(self.table, 1, Qt.AlignmentFlag.AlignTop)
        top.addLayout(controls)

        page_layout = QVBoxLayout()
        page_layout.addLayout(top)
        page_layout.addWidget(self.canvas)
        page_layout.addStretch(1)

        self.page = QWidget()
        self.page.setLayout(page_layout)
        self.scroll_area = QScrollArea()
        self.scroll_area.setWidgetResizable(True)
        self.scroll_area.setWidget(self.page)

        layout = QVBoxLayout(self)
        layout.addWidget(self.scroll_area)
        self.refresh()

    def _show_plot(self, visible: bool) -> None:
        self.canvas.setVisible(visible)

    def _timeline_signature(self) -> tuple:
        return tuple(
            (
                name,
                str(data.path),
                data.timeline.offset,
                data.timeline.rate,
            )
            for name, data in self._inputs().items()
        )

    def set_experiment(self, experiment: Experiment) -> None:
        self._analysis = None
        self._analysis_signature = None
        self._show_plot(False)
        super().set_experiment(experiment)

    def refresh(self) -> None:
        if self._thread is not None:
            return
        if (
            self._analysis is not None
            and self._analysis_signature != self._timeline_signature()
        ):
            self._analysis = None
            self._analysis_signature = None
        self._refresh_table()
        if self._analysis is None:
            self._draw_stored_corrections()
        self._update_buttons(False)

    def _refresh_table(self) -> None:
        inputs = list(self.experiment.inputs)
        unavailable = set(self._analysis.unavailable) if self._analysis else set()
        self.table.clearContents()
        self.table.setRowCount(len(inputs))
        for row, data in enumerate(inputs):
            values = [
                data.id,
                _offset_text(data.timeline.offset),
                _drift_text(data.timeline),
            ]
            if data.id in unavailable:
                values[2] = "Couldn't match"
            for column, value in enumerate(values):
                item = QTableWidgetItem(value)
                if column == _OFFSET:
                    item.setTextAlignment(
                        Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
                    )
                self.table.setItem(row, column, item)
        self.table.fit_to_rows()

    def is_busy(self) -> bool:
        return self._thread is not None

    def _clear_corrections(self) -> None:
        if self._thread is not None or not clear_clock_rates(self.experiment):
            return
        self._analysis = None
        self._analysis_signature = None
        self._refresh_table()
        self._draw_stored_corrections()
        self._update_buttons(False)
        self.experiment_changed.emit()
        self.status_message.emit("Clock-rate corrections cleared")

    def _start_correction(self) -> None:
        if self._thread is not None:
            return
        paths = {name: data.path for name, data in self._inputs().items()}
        if len(paths) < 2:
            return
        offsets = {name: data.timeline.offset for name, data in self._inputs().items()}
        self._analysis = None
        self._analysis_signature = None
        self._show_plot(False)
        self._set_running(True)
        self.progress_changed.emit(0, 100, _LABEL)
        self._worker = _Worker(paths, offsets, self._settings())
        self._worker.progress.connect(self._on_progress)
        self._worker.finished.connect(self._on_correction_finished)
        self._worker.failed.connect(self._on_failed)
        self._worker.cancelled.connect(self._on_cancelled)
        self._thread = threading.Thread(target=self._worker.run, daemon=True)
        self._thread.start()

    @Slot(int)
    def _on_progress(self, percent: int) -> None:
        self.progress_changed.emit(percent, 100, _LABEL)

    @Slot(object)
    def _on_correction_finished(self, analysis: ClockRateAnalysis) -> None:
        changed = apply_clock_rates(self.experiment, analysis)
        self._analysis = analysis
        self._analysis_signature = self._timeline_signature()
        self._refresh_table()

        self._draw_corrections(
            {name: data.timeline for name, data in self._inputs().items()}, analysis
        )
        self._show_plot(True)
        if changed:
            self.experiment_changed.emit()
            self.status_message.emit(
                f"Updated the clock rate of {len(changed)} input(s)"
            )
        else:
            self.status_message.emit("No clock-rate changes detected")
        self._set_running(False)

    def _draw_stored_corrections(self) -> None:
        timelines = {name: data.timeline for name, data in self._inputs().items()}
        if not any(timeline.corrects_drift for timeline in timelines.values()):
            self._show_plot(False)
            return
        self._draw_corrections(timelines)
        self._show_plot(True)

    def _draw_corrections(
        self,
        timelines: dict[str, Timeline],
        analysis: ClockRateAnalysis | None = None,
    ) -> None:
        self.figure.clear()
        axis = self.figure.subplots()
        inputs = self._inputs()
        for index, (name, timeline) in enumerate(timelines.items()):
            if name not in inputs:
                continue
            points = analysis.points.get(name, []) if analysis is not None else []
            if not points and not timeline.corrects_drift:
                continue
            if points:
                measured_experiment = np.asarray([point.time for point in points])
                measured_offset = np.asarray([point.offset for point in points])
                local = measured_experiment - measured_offset
            else:
                duration = media_duration(inputs[name].path) or 3600.0
                local = np.asarray([0.0, duration])
            fitted = timeline.to_experiment_times(local)
            experiment = measured_experiment if points else fitted
            fitted_offset = fitted - local
            colour = f"C{index}"
            if points:
                axis.scatter(
                    experiment / 60.0,
                    (measured_offset - timeline.offset) * 1000,
                    s=9,
                    alpha=0.45,
                    color=colour,
                )
            axis.plot(
                experiment / 60.0,
                (fitted_offset - timeline.offset) * 1000,
                color=colour,
                linewidth=1.5,
                label=name,
            )
        axis.axhline(
            0,
            color="black",
            linewidth=0.8,
            label=(
                f"{analysis.reference} (reference)"
                if analysis is not None
                else "No clock drift"
            ),
        )
        corrected = any(
            timeline.corrects_drift
            for name, timeline in timelines.items()
            if name in inputs
        )
        if analysis is None:
            title = "Applied clock-rate corrections"
        elif corrected:
            title = "Measured offsets and applied clock-rate corrections"
        else:
            title = "Measured offsets: no clock-rate correction needed"
        axis.set_title(title)
        axis.set_xlabel("Experiment time (minutes)")
        axis.set_ylabel("Offset change from recording start (ms)")
        axis.grid(alpha=0.25)
        axis.legend(fontsize=9)
        self.canvas.draw_idle()

    @Slot(str, str)
    def _on_failed(self, message: str, details: str) -> None:
        dialog = QMessageBox(self)
        dialog.setIcon(QMessageBox.Icon.Critical)
        dialog.setWindowTitle("Clock-rate analysis failed")
        dialog.setText(message)
        dialog.setDetailedText(details)
        dialog.exec()
        self.status_message.emit("Could not complete the clock-rate analysis")
        self._draw_stored_corrections()
        self._set_running(False)

    @Slot()
    def _on_cancelled(self) -> None:
        self.status_message.emit("Clock-rate analysis cancelled")
        self._draw_stored_corrections()
        self._set_running(False)

    def _set_running(self, running: bool) -> None:
        if not running:
            self._thread = None
            self._worker = None
        self._update_buttons(running)
        self.busy_changed.emit(running)

    def _settings(self) -> dict[str, float]:
        """The analysis settings as the form currently has them."""
        return {
            "window": self.window_spin.value(),
            "search": self.search_spin.value(),
            "min_quality": self.min_quality_spin.value(),
            "min_drift_ppm": self.min_drift_spin.value(),
        }

    def _update_buttons(self, running: bool) -> None:
        self.settings_widget.setEnabled(not running)
        self.correct_button.setEnabled(not running and len(self._inputs()) >= 2)
        self.clear_button.setEnabled(
            not running and has_corrected_clock_rates(self.experiment)
        )

body_eye_sync.gui.tabs.video_processing

Video processing tab: run the pipeline over one video input and watch it.

One video input is shown at a time, chosen from the experiment's video inputs. The pipeline editor beside the viewer edits the pipeline block belonging to that input's type, and its "Run" buttons run the steps in a background thread, with the results drawn over the video as they arrive.

VideoProcessingTab

Bases: BaseTab

Show one of the experiment's videos and run its pipeline over it.

Source code in src/body_eye_sync/gui/tabs/video_processing.py
class VideoProcessingTab(BaseTab):
    """Show one of the experiment's videos and run its pipeline over it."""

    title = "Video processing"

    def __init__(self, experiment: Experiment) -> None:
        super().__init__(experiment)

        self._thread: threading.Thread | None = None
        self._worker: (
            ObjectTrackingWorker | FaceDetectionWorker | BodyPoseWorker | None
        ) = None
        #: Remaining step types queued by "Run all"; consumed one at a time as
        #: each step finishes, so later steps see earlier steps' results.
        self._pending_steps: list[type] = []
        #: The experiment's video inputs, in the order the chooser lists them.
        self._videos: list[Video] = []

        self.video_selector = QComboBox()
        self.video_selector.currentIndexChanged.connect(self._on_video_selected)

        self.video_viewer = VideoViewer()

        self.cancel_button = QPushButton("Cancel")
        self.cancel_button.setVisible(False)
        self.cancel_button.clicked.connect(self._cancel_run)

        top_bar = QHBoxLayout()
        top_bar.addWidget(QLabel("Video:"))
        top_bar.addWidget(self.video_selector, stretch=1)

        bottom_bar = QHBoxLayout()
        bottom_bar.addStretch(1)
        bottom_bar.addWidget(self.cancel_button)

        viewer_layout = QVBoxLayout()
        viewer_layout.setContentsMargins(0, 0, 0, 0)
        viewer_layout.addLayout(top_bar)
        viewer_layout.addWidget(self.video_viewer, stretch=1)
        viewer_layout.addLayout(bottom_bar)
        viewer_side = QWidget()
        viewer_side.setLayout(viewer_layout)

        self.pipeline_editor = PipelineEditor(VIDEO_STEPS)
        self.pipeline_editor.changed.connect(self._on_pipeline_edited)
        self.pipeline_editor.run_requested.connect(self._start_step)
        self.pipeline_editor.run_all_requested.connect(self._start_run_all)
        self.pipeline_scroll_area = QScrollArea()
        self.pipeline_scroll_area.setWidgetResizable(True)
        self.pipeline_scroll_area.setWidget(self.pipeline_editor)

        self.splitter = QSplitter(Qt.Orientation.Horizontal)
        self.splitter.addWidget(viewer_side)
        self.splitter.addWidget(self.pipeline_scroll_area)
        self.splitter.setStretchFactor(0, 1)
        self.splitter.setStretchFactor(1, 0)

        layout = QVBoxLayout(self)
        layout.addWidget(self.splitter)

        # How to run each step: its worker, readiness check and viewer plumbing.
        # Built last since it closes over ``self.video_viewer``.
        self._step_runners: dict[type, _StepRunner] = {
            ObjectTrackingStep: _StepRunner(
                worker_cls=ObjectTrackingWorker,
                ready=lambda video: video.video_path is not None,
                begin=lambda video, k: video.begin_object_tracking(k),
                live_frame_slot=self.video_viewer.show_live_frame,
                on_finished=self._on_finished,
            ),
            FaceDetectionStep: _StepRunner(
                worker_cls=FaceDetectionWorker,
                ready=lambda video: video.data is not None,
                begin=lambda video, k: video.begin_face_detection(k),
                live_frame_slot=self.video_viewer.show_live_face_frame,
                on_finished=self._on_face_finished,
            ),
            BodyPoseStep: _StepRunner(
                worker_cls=BodyPoseWorker,
                ready=lambda video: video.data is not None,
                begin=lambda video, k: video.begin_body_pose_detection(k),
                live_frame_slot=self.video_viewer.show_live_pose_frame,
                on_finished=self._on_pose_finished,
            ),
        }
        self.refresh()

    def refresh(self) -> None:
        """Re-list the experiment's videos, keeping the shown one if it is still there."""
        if self._thread is not None:
            # A run drives the viewer and the video it writes into; leave it be.
            return
        shown = self.video()
        self._videos = [*self.experiment.glasses_videos, *self.experiment.fixed_videos]
        self.video_selector.blockSignals(True)
        self.video_selector.clear()
        for video in self._videos:
            kind = "glasses" if isinstance(video, GlassesVideo) else "fixed"
            self.video_selector.addItem(f"{video.id} ({kind})")
        index = next(
            (i for i, video in enumerate(self._videos) if video is shown),
            0 if self._videos else -1,
        )
        self.video_selector.setCurrentIndex(index)
        self.video_selector.blockSignals(False)
        self.video_selector.setEnabled(bool(self._videos))
        self._show_selected_video()

    def video(self) -> Video | None:
        """The video input being shown, or ``None`` if the experiment has none."""
        index = self.video_selector.currentIndex()
        if 0 <= index < len(self._videos):
            return self._videos[index]
        return None

    def is_busy(self) -> bool:
        """Whether a pipeline step is currently running."""
        return self._thread is not None

    def shutdown(self) -> None:
        if self._worker is not None:
            self._worker.cancel()
        if self._thread is not None:
            self._thread.join(timeout=5.0)

    def _on_video_selected(self, _index: int) -> None:
        self._show_selected_video()

    def _show_selected_video(self) -> None:
        """Load the chosen video into the viewer and bind the editor to it."""
        video = self.video()
        # The viewer says what it holds, so a video it could not open is tried
        # again next time rather than being left blank for good.
        if video is not self.video_viewer.video:
            if video is None:
                self.video_viewer.clear()
            else:
                try:
                    self.video_viewer.load(video)
                except OSError as exc:
                    self.video_viewer.clear()
                    self.status_message.emit(f"Could not open video: {exc}")
        self.video_viewer.refresh_overlays()
        self._bind_editor_to_video()
        self._update_step_availability()

    def _pipeline(self) -> VideoPipeline | None:
        """The pipeline block for the shown video's input type, or ``None``.

        Each input type has its own block, so the editor edits the one belonging
        to the video being shown.
        """
        video = self.video()
        if video is None:
            return None
        if isinstance(video, GlassesVideo):
            return self.experiment.pipeline.glasses_video
        return self.experiment.pipeline.fixed_video

    def _bind_editor_to_video(self) -> None:
        """Populate the pipeline editor from the shown video (or disable it)."""
        pipeline = self._pipeline()
        if pipeline is None:
            self.pipeline_editor.reset()
            self.pipeline_editor.setEnabled(False)
            return
        self.pipeline_editor.setEnabled(True)
        self.pipeline_editor.set_from(pipeline)

    def _on_pipeline_edited(self) -> None:
        """Adopt the editor's pipeline as the experiment's, when it is valid."""
        pipeline = self._pipeline()
        if pipeline is None:
            return
        try:
            self.pipeline_editor.apply_to(pipeline)
        except (ValidationError, ValueError):
            self.status_message.emit("Pipeline has invalid settings; not applied")
            return
        self.experiment_changed.emit()

    def _update_step_availability(self) -> None:
        """Enable each step's "Run" button (and "Run all") once its inputs are ready.

        Object tracking needs a video; later passes run on tracked boxes, so
        they wait for object tracking's results. Whether a *run* is currently
        in progress is handled separately, by disabling the whole pipeline
        editor (see ``_set_running``).
        """
        video = self.video()
        has_video = video is not None and video.video_path is not None
        has_tracks = video is not None and video.data is not None
        self.pipeline_editor.set_run_enabled(ObjectTrackingStep, has_video)
        self.pipeline_editor.set_run_enabled(FaceDetectionStep, has_tracks)
        self.pipeline_editor.set_run_enabled(BodyPoseStep, has_tracks)
        self.pipeline_editor.set_run_all_enabled(has_video)

    def _step_config(self, step_type):
        """The editor's validated config for a step, or None (with an alert)."""
        try:
            return self.pipeline_editor.config_for(step_type)
        except (ValidationError, ValueError) as exc:
            QMessageBox.critical(self, "Invalid settings", str(exc))
            return None

    @Slot(object)
    def _start_step(self, step_type: type) -> None:
        """Run one pipeline step, using the editor's current arguments for it."""
        if self._thread is not None:
            return
        video = self.video()
        runner = self._step_runners[step_type]
        if video is None or not runner.ready(video):
            self._pending_steps = []
            return
        step = self._step_config(step_type)
        if step is None:
            self._pending_steps = []
            return

        # Discards that step's previous results; keeps everything else (e.g. a
        # face/pose pass keeps the tracked boxes it runs over). The embedding
        # budget comes from the step config, so the GUI and CLI behave identically.
        runner.begin(video, getattr(step, "embeddings_per_track", 0))
        self._begin_run()

        self._worker = runner.worker_cls(video, step)
        self._worker.new_frame.connect(self._on_new_frame)
        self._worker.new_frame.connect(runner.live_frame_slot)
        self._worker.finished.connect(runner.on_finished)
        self._worker.failed.connect(self._on_failed)
        self._worker.cancelled.connect(self._on_cancelled)

        self._thread = threading.Thread(target=self._worker.run, daemon=True)
        self._thread.start()

    def _start_run_all(self) -> None:
        """Run every enabled pipeline step in order, one after another."""
        if self._thread is not None:
            return
        try:
            steps = self.pipeline_editor.enabled_steps()
        except (ValidationError, ValueError) as exc:
            QMessageBox.critical(self, "Invalid settings", str(exc))
            return
        self._pending_steps = [type(step) for step in steps]
        self._continue_run_all()

    def _continue_run_all(self) -> None:
        """Start the next step queued by "Run all", if any are left."""
        if self._pending_steps:
            self._start_step(self._pending_steps.pop(0))

    def _begin_run(self) -> None:
        """Shared start-up for object tracking and later detection runs."""
        self._set_running(True)
        # Weights are built/downloaded before the first frame is processed, so
        # show a busy bar until the first frame arrives.
        self.progress_changed.emit(0, 0, "Downloading weights…")

    @Slot(object)
    def _on_new_frame(self, frame) -> None:
        # The first frame turns the busy "downloading" bar into a determinate
        # one, which reporting a total takes care of on its own.
        total = self.video_viewer.frame_count
        label = f"{self._worker.operation_name}…"
        self.progress_changed.emit(frame.frame_idx if total else 0, total, label)

    def _cancel_run(self) -> None:
        if self._worker is not None:
            self._worker.cancel()
        self.cancel_button.setEnabled(False)
        self.cancel_button.setText("Cancelling…")

    @Slot()
    def _on_finished(self) -> None:
        data = self.video().data
        self.status_message.emit(
            f"Object tracking finished: {data['track_id'].nunique()} tracklets, "
            f"{len(data)} detections"
        )
        self._set_running(False)
        self._continue_run_all()

    @Slot()
    def _on_face_finished(self) -> None:
        data = self.video().data
        n_faces = int(data["face_score"].notna().sum())
        self.status_message.emit(
            f"Face detection finished: {n_faces} faces over {len(data)} detections"
        )
        self._set_running(False)
        self._continue_run_all()

    @Slot()
    def _on_pose_finished(self) -> None:
        data = self.video().data
        n_poses = int(data["pose_score"].notna().sum())
        self.status_message.emit(
            f"Body pose detection finished: {n_poses} poses over {len(data)} detections"
        )
        self._set_running(False)
        self._continue_run_all()

    @Slot(str, str)
    def _on_failed(self, message: str, details: str) -> None:
        # A failure stops a "Run all" chain rather than pressing on regardless.
        self._pending_steps = []
        dialog = QMessageBox(self)
        dialog.setIcon(QMessageBox.Icon.Critical)
        dialog.setWindowTitle(f"{self._worker.operation_name} failed")
        dialog.setText(message)
        dialog.setDetailedText(details)
        dialog.exec()
        self._set_running(False)

    @Slot()
    def _on_cancelled(self) -> None:
        # Cancelling one step cancels the rest of a "Run all" chain too.
        self._pending_steps = []
        self.status_message.emit(f"{self._worker.operation_name} cancelled")
        self._set_running(False)

    def _set_running(self, running: bool) -> None:
        if not running:
            # background thread has reported back; drop our references to it.
            self._thread = None
            self._worker = None
            # Re-read the experiment: refresh() bows out while a run is on, so
            # this is where any change made to the inputs meanwhile is picked up.
            self.refresh()
            # However it ended, the run changed the video's results.
            self.experiment_changed.emit()
        self.video_selector.setEnabled(not running and bool(self._videos))
        self.pipeline_editor.setEnabled(not running and self.video() is not None)
        self.video_viewer.enable_controls(not running)
        self.cancel_button.setVisible(running)
        self.cancel_button.setEnabled(True)
        self.cancel_button.setText("Cancel")
        # The window locks the actions that would pull the experiment away.
        self.busy_changed.emit(running)

is_busy()

Whether a pipeline step is currently running.

Source code in src/body_eye_sync/gui/tabs/video_processing.py
def is_busy(self) -> bool:
    """Whether a pipeline step is currently running."""
    return self._thread is not None

refresh()

Re-list the experiment's videos, keeping the shown one if it is still there.

Source code in src/body_eye_sync/gui/tabs/video_processing.py
def refresh(self) -> None:
    """Re-list the experiment's videos, keeping the shown one if it is still there."""
    if self._thread is not None:
        # A run drives the viewer and the video it writes into; leave it be.
        return
    shown = self.video()
    self._videos = [*self.experiment.glasses_videos, *self.experiment.fixed_videos]
    self.video_selector.blockSignals(True)
    self.video_selector.clear()
    for video in self._videos:
        kind = "glasses" if isinstance(video, GlassesVideo) else "fixed"
        self.video_selector.addItem(f"{video.id} ({kind})")
    index = next(
        (i for i, video in enumerate(self._videos) if video is shown),
        0 if self._videos else -1,
    )
    self.video_selector.setCurrentIndex(index)
    self.video_selector.blockSignals(False)
    self.video_selector.setEnabled(bool(self._videos))
    self._show_selected_video()

video()

The video input being shown, or None if the experiment has none.

Source code in src/body_eye_sync/gui/tabs/video_processing.py
def video(self) -> Video | None:
    """The video input being shown, or ``None`` if the experiment has none."""
    index = self.video_selector.currentIndex()
    if 0 <= index < len(self._videos):
        return self._videos[index]
    return None

body_eye_sync.gui.tabs.audio_processing

Audio processing tab: transcribe the speech from an input's audio.

AudioProcessingTab

Bases: BaseTab

Show one input's speech results and transcribe its audio.

Source code in src/body_eye_sync/gui/tabs/audio_processing.py
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
class AudioProcessingTab(BaseTab):
    """Show one input's speech results and transcribe its audio."""

    title = "Audio processing"

    def __init__(self, experiment: Experiment) -> None:
        super().__init__(experiment)

        self._thread: threading.Thread | None = None
        self._worker: TranscriptionWorker | None = None
        self._pending_inputs: list[Video | Audio] = []
        self._recordings: list[Video | Audio] = []
        self._live_word_count = 0

        self.input_selector = QComboBox()
        self.input_selector.currentIndexChanged.connect(self._on_input_selected)

        self.summary_label = QLabel()
        self.summary_label.setWordWrap(True)

        self.audio_player = AudioPlaybackWidget()
        self.audio_player.position_changed.connect(self._highlight_transcript_at)
        self._highlighted_row = -1
        self._marker_row = -1

        self.transcript_table = QTableWidget(0, len(_COLUMNS))
        self.transcript_table.setHorizontalHeaderLabels(_COLUMNS)
        row_header = self.transcript_table.verticalHeader()
        row_header.setSectionResizeMode(QHeaderView.ResizeMode.Fixed)
        row_header.setFixedWidth(24)
        self.transcript_table.setSelectionMode(
            QAbstractItemView.SelectionMode.NoSelection
        )
        self.transcript_table.setEditTriggers(
            QAbstractItemView.EditTrigger.NoEditTriggers
        )
        self.transcript_table.cellDoubleClicked.connect(self._play_transcript_row)
        header = self.transcript_table.horizontalHeader()
        header.setSectionResizeMode(_TEXT, QHeaderView.ResizeMode.Stretch)

        self.cancel_button = QPushButton("Cancel")
        self.cancel_button.setVisible(False)
        self.cancel_button.clicked.connect(self._cancel_run)

        top_bar = QHBoxLayout()
        top_bar.addWidget(QLabel("Recording:"))
        top_bar.addWidget(self.input_selector, stretch=1)

        bottom_bar = QHBoxLayout()
        bottom_bar.addWidget(self.summary_label, stretch=1)
        bottom_bar.addWidget(self.cancel_button)

        results_layout = QVBoxLayout()
        results_layout.setContentsMargins(0, 0, 0, 0)
        results_layout.addLayout(top_bar)
        results_layout.addWidget(self.audio_player)
        results_layout.addWidget(self.transcript_table, stretch=1)
        results_layout.addLayout(bottom_bar)
        results_side = QWidget()
        results_side.setLayout(results_layout)

        self.pipeline_editor = PipelineEditor(SPEECH_STEPS)
        self.pipeline_editor.changed.connect(self._on_pipeline_edited)
        self.pipeline_editor.run_requested.connect(
            lambda _step_type: self._start_transcription()
        )
        self.pipeline_editor.run_all_requested.connect(self._start_run_all)
        self.transcription_checkbox = QCheckBox("Transcribe this experiment's speech")
        self.transcription_checkbox.toggled.connect(self._on_pipeline_toggled)
        self.pipeline_group = QGroupBox("Speech pipeline")
        pipeline_group_layout = QVBoxLayout(self.pipeline_group)
        pipeline_group_layout.addWidget(self.transcription_checkbox)
        pipeline_group_layout.addWidget(self.pipeline_editor)

        pipeline_layout = QVBoxLayout()
        pipeline_layout.setContentsMargins(0, 0, 0, 0)
        pipeline_layout.addWidget(self.pipeline_group)
        pipeline_layout.addStretch(1)
        pipeline_side = QWidget()
        pipeline_side.setLayout(pipeline_layout)

        self.splitter = QSplitter(Qt.Orientation.Horizontal)
        self.splitter.addWidget(results_side)
        self.splitter.addWidget(pipeline_side)
        self.splitter.setStretchFactor(0, 1)
        self.splitter.setStretchFactor(1, 0)

        layout = QVBoxLayout(self)
        layout.addWidget(self.splitter)

        self.refresh()

    def _inputs(self) -> dict[str, Video | Audio]:
        """The inputs that carry sound, the only ones with speech to transcribe."""
        return {
            input_id: data
            for input_id, data in super()._inputs().items()
            if data.has_audio_track()
        }

    def refresh(self) -> None:
        """Re-list the inputs, keeping the shown one if it is still there."""
        if self._thread is not None:
            return
        shown = self.input()
        self._recordings = list(self._inputs().values())
        self.input_selector.blockSignals(True)
        self.input_selector.clear()
        for data in self._recordings:
            self.input_selector.addItem(f"{data.id} ({_kind(data)})")
        index = next(
            (i for i, data in enumerate(self._recordings) if data is shown),
            0 if self._recordings else -1,
        )
        self.input_selector.setCurrentIndex(index)
        self.input_selector.blockSignals(False)
        self.input_selector.setEnabled(bool(self._recordings))
        self._show_selected_input()

    def input(self) -> Video | Audio | None:
        """The input being shown, or ``None`` if the experiment has none."""
        index = self.input_selector.currentIndex()
        if 0 <= index < len(self._recordings):
            return self._recordings[index]
        return None

    def speech(self) -> Speech | None:
        """The shown input's speech results, whichever kind of input it is."""
        data = self.input()
        return None if data is None else data.speech

    def is_busy(self) -> bool:
        """Whether transcription is currently running."""
        return self._thread is not None

    def _on_input_selected(self, _index: int) -> None:
        self._show_selected_input()

    def _show_selected_input(self) -> None:
        """List the chosen input's speech results and bind the editor to it."""
        data = self.input()
        if data is None:
            self.audio_player.clear()
        else:
            self.audio_player.load(data.path, data.loudness.levels)
        self._refresh_results()
        self._bind_editor_to_pipeline()
        self._update_run_availability()

    def _refresh_results(self) -> None:
        """Fill the transcript table and its summary from the shown input."""
        speech = self.speech()
        data = None if speech is None else speech.data
        self.transcript_table.clearContents()
        self.transcript_table.setRowCount(0 if data is None else len(data))
        self._highlighted_row = -1
        self._marker_row = -1
        if data is None:
            self.summary_label.setText(self._nothing_to_show())
            return
        for row, segment in enumerate(data.itertuples(index=False)):
            self._set_transcript_row(row, segment.start, segment.end, segment.text)
        self.summary_label.setText(self._summary(speech))

    def _set_transcript_row(
        self, row: int, start: float, end: float, text: str
    ) -> None:
        """Populate one row shared by loaded and live transcript segments."""
        self.transcript_table.setVerticalHeaderItem(row, QTableWidgetItem(""))
        alignment = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
        start_item = QTableWidgetItem(_time_text(start))
        start_item.setTextAlignment(alignment)
        # Playback highlighting needs the exact bounds, not the rounded text.
        start_item.setData(Qt.ItemDataRole.UserRole, (float(start), float(end)))
        end_item = QTableWidgetItem(_time_text(end))
        end_item.setTextAlignment(alignment)

        self.transcript_table.setItem(row, _START, start_item)
        self.transcript_table.setItem(row, _END, end_item)

        text_item = QTableWidgetItem(str(text))
        text_item.setToolTip(textwrap.fill(str(text), 80))
        self.transcript_table.setItem(row, _TEXT, text_item)
        self._tint_row(row, _REST_ALPHA)

    @Slot(float)
    def _highlight_transcript_at(self, seconds: float) -> None:
        """Select the transcript segment containing the playback position."""
        row_at_position = -1
        marker_row = -1
        for row in range(self.transcript_table.rowCount()):
            item = self.transcript_table.item(row, _START)
            bounds = None if item is None else item.data(Qt.ItemDataRole.UserRole)
            if bounds is None:
                continue
            if bounds[0] <= seconds:
                marker_row = row
            if bounds[0] <= seconds <= bounds[1] and row_at_position < 0:
                row_at_position = row
        self._mark_row(marker_row)
        if row_at_position == self._highlighted_row:
            return
        if self._highlighted_row >= 0:
            self._tint_row(self._highlighted_row, _REST_ALPHA)
        self._highlighted_row = row_at_position
        if row_at_position >= 0:
            self._tint_row(row_at_position, _HIGHLIGHT_ALPHA)
            self.transcript_table.scrollToItem(
                self.transcript_table.item(row_at_position, _TEXT),
                QAbstractItemView.ScrollHint.PositionAtCenter,
            )

    def _tint_row(self, row: int, alpha: int) -> None:
        """Wash one row in the highlight colour, at ``alpha`` out of 255."""
        color = QColor(self.palette().highlight().color())
        color.setAlpha(alpha)
        for column in range(self.transcript_table.columnCount()):
            item = self.transcript_table.item(row, column)
            if item is not None:
                item.setBackground(QBrush(color))

    def _mark_row(self, row: int) -> None:
        """Point the gutter at the last segment playback has reached.

        The selection only lasts as long as the segment is being spoken, so this
        is what holds the place in the table through the silence in between.
        """
        if row == self._marker_row:
            return
        for at, text in ((self._marker_row, ""), (row, _MARKER)):
            item = self.transcript_table.verticalHeaderItem(at) if at >= 0 else None
            if item is not None:
                item.setText(text)
        self._marker_row = row

    @Slot(int, int)
    def _play_transcript_row(self, row: int, _column: int) -> None:
        """Seek to a double-clicked segment and start or continue playback."""
        if self._thread is not None:
            return
        item = self.transcript_table.item(row, _START)
        bounds = None if item is None else item.data(Qt.ItemDataRole.UserRole)
        if bounds is None:
            return
        self.audio_player.seek(bounds[0])
        self.audio_player.play()

    def _nothing_to_show(self) -> str:
        """Why the transcript table is empty."""
        if self.input() is not None:
            return "No transcript yet; run transcription."
        if any(data.path is not None for data in self.experiment.inputs):
            return "None of this experiment's recordings carry audio."
        return "This experiment has no inputs."

    def _summary(self, speech: Speech) -> str:
        words = 0 if speech.words is None else len(speech.words)
        return f"{len(speech.data)} segment(s), {words} word(s)"

    def _bind_editor_to_pipeline(self) -> None:
        """Bind the checkbox and editor to the experiment's speech pipeline."""
        pipeline = self.experiment.pipeline.speech
        self.transcription_checkbox.blockSignals(True)
        self.transcription_checkbox.setChecked(pipeline is not None)
        self.transcription_checkbox.blockSignals(False)
        if pipeline is not None:
            self.pipeline_editor.set_from(pipeline)
        self.pipeline_editor.setEnabled(
            pipeline is not None and self.input() is not None
        )

    @Slot(bool)
    def _on_pipeline_toggled(self, enabled: bool) -> None:
        """Enable or disable transcription for the experiment."""
        if enabled:
            pipeline = SpeechPipeline()
            self.experiment.pipeline.speech = pipeline
            self.pipeline_editor.set_from(pipeline)
        else:
            self.experiment.pipeline.speech = None
            self._pending_inputs = []
        self.pipeline_editor.setEnabled(enabled and self.input() is not None)
        self._update_run_availability()
        self.experiment_changed.emit()

    def _on_pipeline_edited(self) -> None:
        """Adopt the editor's pipeline as the experiment's, when it is valid."""
        pipeline = self.experiment.pipeline.speech
        if pipeline is None:
            return
        try:
            self.pipeline_editor.apply_to(pipeline)
        except (ValidationError, ValueError):
            self.status_message.emit("Pipeline has invalid settings; not applied")
            return
        self.experiment_changed.emit()

    def _update_run_availability(self) -> None:
        """Enable the "Run" buttons when there are recordings to transcribe."""
        enabled = self.experiment.pipeline.speech is not None
        self.pipeline_editor.set_run_enabled(
            TranscriptionStep, enabled and self.input() is not None
        )
        self.pipeline_editor.set_run_all_enabled(enabled and bool(self._recordings))

    def _transcription_config(self) -> TranscriptionStep | None:
        """The editor's validated transcription settings, or ``None``."""
        if self.experiment.pipeline.speech is None:
            return None
        try:
            return self.pipeline_editor.config_for(TranscriptionStep)
        except (ValidationError, ValueError) as exc:
            QMessageBox.critical(self, "Invalid settings", str(exc))
            return None

    @Slot()
    def _start_transcription(self) -> None:
        """Transcribe the selected recording with the editor's settings."""
        if self._thread is not None:
            return
        data = self.input()
        if data is None:
            self._pending_inputs = []
            return
        settings = self._transcription_config()
        if settings is None:
            self._pending_inputs = []
            return

        speech = data.speech
        speech.begin_transcription()
        self._begin_run()

        self._worker = TranscriptionWorker(speech, data.loudness, data.path, settings)
        self._worker.progress.connect(self._on_progress)
        self._worker.new_frame.connect(self._on_new_segment)
        self._worker.finished.connect(self._on_transcription_finished)
        self._worker.failed.connect(self._on_failed)
        self._worker.cancelled.connect(self._on_cancelled)

        self._thread = threading.Thread(target=self._worker.run, daemon=True)
        self._thread.start()

    def _start_run_all(self) -> None:
        """Transcribe every recording in chooser order."""
        if self._thread is not None:
            return
        if self._transcription_config() is None:
            self._pending_inputs = []
            return
        self._pending_inputs = list(self._recordings)
        self._continue_run_all()

    def _continue_run_all(self) -> None:
        """Select and transcribe the next recording queued by "Run all"."""
        while self._pending_inputs and self._thread is None:
            data = self._pending_inputs.pop(0)
            index = self._recordings.index(data)
            self.input_selector.setCurrentIndex(index)
            self._start_transcription()

    def _begin_run(self) -> None:
        """Prepare the tab for a transcription run."""
        self.audio_player.pause()
        self._set_running(True)
        self.transcript_table.clearContents()
        self.transcript_table.setRowCount(0)
        self._live_word_count = 0
        self.summary_label.setText("Waiting for transcript segments…")
        self.progress_changed.emit(0, 0, "Downloading weights…")

    @Slot(object)
    def _on_new_segment(self, segment) -> None:
        """Append one provisional Whisper segment while transcription runs."""
        scrollbar = self.transcript_table.verticalScrollBar()
        following = scrollbar.value() == scrollbar.maximum()
        row = self.transcript_table.rowCount()
        self.transcript_table.insertRow(row)
        self._set_transcript_row(row, segment.start, segment.end, segment.text)
        self._live_word_count += len(segment.words)
        self.summary_label.setText(
            f"{row + 1} segment(s), {self._live_word_count} word(s) — transcribing…"
        )
        if following:
            self.transcript_table.scrollToBottom()

    @Slot(float)
    def _on_progress(self, fraction: float) -> None:
        self.progress_changed.emit(round(100 * fraction), 100, "Transcription…")

    def _cancel_run(self) -> None:
        if self._worker is not None:
            self._worker.cancel()
        self.cancel_button.setEnabled(False)
        self.cancel_button.setText("Cancelling…")

    @Slot()
    def _on_transcription_finished(self) -> None:
        speech = self.speech()
        n_words = 0 if speech.words is None else len(speech.words)
        self.status_message.emit(
            f"Transcription finished: {n_words} words over {len(speech.data)} segments"
        )
        self._set_running(False)
        self._continue_run_all()

    @Slot(str, str)
    def _on_failed(self, message: str, details: str) -> None:
        self._pending_inputs = []
        dialog = QMessageBox(self)
        dialog.setIcon(QMessageBox.Icon.Critical)
        dialog.setWindowTitle("Transcription failed")
        dialog.setText(message)
        dialog.setDetailedText(details)
        dialog.exec()
        self._set_running(False)

    @Slot()
    def _on_cancelled(self) -> None:
        self._pending_inputs = []
        self.status_message.emit("Transcription cancelled")
        self._set_running(False)

    def _set_running(self, running: bool) -> None:
        if not running:
            self._thread = None
            self._worker = None
            self.refresh()
            self.experiment_changed.emit()
        self.input_selector.setEnabled(not running and bool(self._recordings))
        self.audio_player.setEnabled(not running)
        self.transcription_checkbox.setEnabled(not running)
        self.pipeline_editor.setEnabled(
            not running
            and self.input() is not None
            and self.experiment.pipeline.speech is not None
        )
        self.cancel_button.setVisible(running)
        self.cancel_button.setEnabled(True)
        self.cancel_button.setText("Cancel")
        self.busy_changed.emit(running)

input()

The input being shown, or None if the experiment has none.

Source code in src/body_eye_sync/gui/tabs/audio_processing.py
def input(self) -> Video | Audio | None:
    """The input being shown, or ``None`` if the experiment has none."""
    index = self.input_selector.currentIndex()
    if 0 <= index < len(self._recordings):
        return self._recordings[index]
    return None

is_busy()

Whether transcription is currently running.

Source code in src/body_eye_sync/gui/tabs/audio_processing.py
def is_busy(self) -> bool:
    """Whether transcription is currently running."""
    return self._thread is not None

refresh()

Re-list the inputs, keeping the shown one if it is still there.

Source code in src/body_eye_sync/gui/tabs/audio_processing.py
def refresh(self) -> None:
    """Re-list the inputs, keeping the shown one if it is still there."""
    if self._thread is not None:
        return
    shown = self.input()
    self._recordings = list(self._inputs().values())
    self.input_selector.blockSignals(True)
    self.input_selector.clear()
    for data in self._recordings:
        self.input_selector.addItem(f"{data.id} ({_kind(data)})")
    index = next(
        (i for i, data in enumerate(self._recordings) if data is shown),
        0 if self._recordings else -1,
    )
    self.input_selector.setCurrentIndex(index)
    self.input_selector.blockSignals(False)
    self.input_selector.setEnabled(bool(self._recordings))
    self._show_selected_input()

speech()

The shown input's speech results, whichever kind of input it is.

Source code in src/body_eye_sync/gui/tabs/audio_processing.py
def speech(self) -> Speech | None:
    """The shown input's speech results, whichever kind of input it is."""
    data = self.input()
    return None if data is None else data.speech

body_eye_sync.gui.tabs.speech_post_processing

Speech post processing tab: who spoke when, across the whole experiment.

SpeechPostProcessingTab

Bases: BaseTab

Work out the experiment's speech turns from its glasses recordings.

Source code in src/body_eye_sync/gui/tabs/speech_post_processing.py
class SpeechPostProcessingTab(BaseTab):
    """Work out the experiment's speech turns from its glasses recordings."""

    title = "Speech post processing"

    def __init__(self, experiment: Experiment) -> None:
        super().__init__(experiment)
        self._thread: threading.Thread | None = None
        self._worker: _Worker | None = None

        self.attribute_button = QPushButton("Attribute speech to speakers")
        self.attribute_button.clicked.connect(self._start)

        self.cancel_button = QPushButton("Cancel")
        self.cancel_button.setVisible(False)
        self.cancel_button.clicked.connect(self._cancel)

        self.summary_label = QLabel()
        self.summary_label.setWordWrap(True)

        self.blocked_label = QLabel()
        self.blocked_label.setWordWrap(True)
        blocked_font = self.blocked_label.font()
        blocked_font.setBold(True)
        self.blocked_label.setFont(blocked_font)
        self.blocked_label.setStyleSheet(f"color: {_BLOCKED_COLOR};")
        self.blocked_label.setVisible(False)

        self.audio_player = SynchronizedAudioPlaybackWidget()
        self.audio_player.position_changed.connect(self._highlight_turns_at)
        self._highlighted_rows: set[int] = set()
        self._marker_row = -1

        self.turns_table = QTableWidget(0, len(_COLUMNS))
        self.turns_table.setHorizontalHeaderLabels(_COLUMNS)
        # The row header is kept as a gutter for the playback marker, so it is
        # narrow and unlabelled rather than counting the rows off.
        header = self.turns_table.verticalHeader()
        header.setSectionResizeMode(QHeaderView.ResizeMode.Fixed)
        header.setFixedWidth(24)
        self.turns_table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
        self.turns_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
        self.turns_table.cellDoubleClicked.connect(self._play_turn)
        self.turns_table.horizontalHeader().setSectionResizeMode(
            _TEXT, QHeaderView.ResizeMode.Stretch
        )

        results_layout = QVBoxLayout()
        results_layout.setContentsMargins(0, 0, 0, 0)
        results_layout.addWidget(self.audio_player)
        results_layout.addWidget(self.turns_table, stretch=1)
        results_layout.addWidget(self.summary_label)
        results_side = QWidget()
        results_side.setLayout(results_layout)

        settings = self.experiment.pipeline.speech_post_processing
        self.splitting_form = PydanticForm(settings, fields=_SPLITTING_FIELDS)
        self.splitting_form.changed.connect(self._on_settings_changed)
        self.splitting_group = QGroupBox("Splitting")
        splitting_group_layout = QVBoxLayout(self.splitting_group)
        splitting_group_layout.addWidget(self.splitting_form)

        self.attribution_form = PydanticForm(settings, fields=_ATTRIBUTION_FIELDS)
        self.attribution_form.changed.connect(self._on_settings_changed)
        self.attribution_group = QGroupBox("Attribution")
        attribution_group_layout = QVBoxLayout(self.attribution_group)
        attribution_group_layout.addWidget(self.attribution_form)

        settings_layout = QVBoxLayout()
        settings_layout.setContentsMargins(0, 0, 0, 0)
        settings_layout.addWidget(self.splitting_group)
        settings_layout.addWidget(self.attribution_group)
        settings_layout.addWidget(self.blocked_label)
        settings_layout.addWidget(self.attribute_button)
        settings_layout.addWidget(self.cancel_button)
        settings_layout.addStretch(1)
        settings_side = QWidget()
        settings_side.setLayout(settings_layout)

        self.splitter = QSplitter(Qt.Orientation.Horizontal)
        self.splitter.addWidget(results_side)
        self.splitter.addWidget(settings_side)
        self.splitter.setStretchFactor(0, 1)
        self.splitter.setStretchFactor(1, 0)

        layout = QVBoxLayout(self)
        layout.addWidget(self.splitter)
        self.refresh()

    def refresh(self) -> None:
        if self._thread is not None:
            return
        for form in (self.splitting_form, self.attribution_form):
            form.blockSignals(True)
            form.from_model(self.experiment.pipeline.speech_post_processing)
            form.blockSignals(False)
        self._refresh_audio()
        self._refresh_table()
        blocked = self.blocked_reason()
        self.attribute_button.setEnabled(blocked is None)
        self.blocked_label.setText(blocked or "")
        self.blocked_label.setVisible(blocked is not None)
        self.summary_label.setText(self._summary())

    def blocked_reason(self) -> str | None:
        """Why attribution cannot run yet, or ``None`` when it can."""
        if self.experiment.pipeline.speech is None:
            return (
                "Transcription is switched off for this experiment; switch it "
                "on in the Audio processing tab. Speech turns are worked out "
                "from the transcripts, so there is nothing to attribute without "
                "them."
            )
        glasses = [v for v in self.experiment.glasses_videos if v.path is not None]
        if len(glasses) < 2:
            return (
                "Speaker attribution compares the glasses recordings against each "
                "other, so it needs at least two of them."
            )
        missing = sorted(v.id for v in glasses if v.speech.data is None)
        if missing:
            return (
                "Transcribe these recordings first, in the Audio processing tab: "
                + ", ".join(missing)
            )
        if not any(v.timeline.offset for v in glasses):
            return (
                "Align the recordings first, in the Alignment tab: attribution "
                "compares them moment by moment, so it needs them on one clock."
            )
        return None

    def is_busy(self) -> bool:
        return self._thread is not None

    @Slot()
    def _on_settings_changed(self) -> None:
        """Persist the visible settings as the experiment's post-processing config."""
        settings = self.experiment.pipeline.speech_post_processing
        settings = self.splitting_form.to_model(settings)
        settings = self.attribution_form.to_model(settings)
        if not isinstance(settings, SpeechPostProcessingSettings):
            return
        self.experiment.pipeline.speech_post_processing = settings
        self.experiment_changed.emit()

    def _refresh_table(self) -> None:
        """Show the experiment's speech turns, however they got there."""
        turns = self.experiment.speech_turns
        data = turns.data
        self.turns_table.clearContents()
        self.turns_table.setRowCount(0 if data is None else len(data))
        self._highlighted_rows.clear()
        self._marker_row = -1
        if data is None:
            return
        self.turns_table.setVerticalHeaderLabels([""] * len(data))
        for row, turn in enumerate(data.itertuples(index=False)):
            values = [
                _time_text(turn.start),
                _time_text(turn.end),
                str(turn.speaker),
                str(turn.text),
            ]
            for column, value in enumerate(values):
                item = QTableWidgetItem(value)
                if column in (_START, _END):
                    item.setTextAlignment(
                        Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
                    )
                if column == _TEXT:
                    item.setToolTip(textwrap.fill(value, 80))
                self.turns_table.setItem(row, column, item)
            self.turns_table.item(row, _START).setData(
                Qt.ItemDataRole.UserRole, (float(turn.start), float(turn.end))
            )
            self._tint_row(row, _TINT_ALPHA)

    def _tint_row(self, row: int, alpha: int) -> None:
        """Wash one row in its speaker's colour, at ``alpha`` out of 255."""
        speaker = self.turns_table.item(row, _SPEAKER)
        color = None if speaker is None else self.audio_player.color_for(speaker.text())
        if color is None:
            color = self.palette().highlight().color()
        color = QColor(color)
        color.setAlpha(alpha)
        for column in range(self.turns_table.columnCount()):
            item = self.turns_table.item(row, column)
            if item is not None:
                item.setBackground(QBrush(color))

    def _mark_row(self, row: int) -> None:
        """Point the gutter at the last turn playback has reached.

        The highlight only lasts as long as somebody is talking, so this is what
        holds the place in the table through the silence in between.
        """
        if row == self._marker_row:
            return
        for at, text in ((self._marker_row, ""), (row, _MARKER)):
            item = self.turns_table.verticalHeaderItem(at) if at >= 0 else None
            if item is not None:
                item.setText(text)
        self._marker_row = row

    def _refresh_audio(self) -> None:
        """Show every synchronized input that carries an audio stream."""
        recordings = [data for data in self.experiment.inputs if data.has_audio_track()]
        self.audio_player.load(recordings)
        self.audio_player.set_turns(self.experiment.speech_turns.data)

    @Slot(int, int)
    def _play_turn(self, row: int, _column: int) -> None:
        """Seek to a double-clicked accepted turn and begin shared playback."""
        item = self.turns_table.item(row, _START)
        bounds = None if item is None else item.data(Qt.ItemDataRole.UserRole)
        if bounds is None:
            return
        self.audio_player.seek(bounds[0])
        self.audio_player.play()

    @Slot(float)
    def _highlight_turns_at(self, seconds: float) -> None:
        """Deepen every accepted turn holding the shared playback position.

        Each turn keeps its speaker's colour, so which of them is talking is as
        plain in the table as it is on the player's tracks.
        """
        rows = set()
        marker_row = -1
        for row in range(self.turns_table.rowCount()):
            item = self.turns_table.item(row, _START)
            bounds = None if item is None else item.data(Qt.ItemDataRole.UserRole)
            if bounds is None:
                continue
            if bounds[0] <= seconds:
                marker_row = row
            if bounds[0] <= seconds < bounds[1]:
                rows.add(row)
        self._mark_row(marker_row)
        if rows == self._highlighted_rows:
            return

        for row in self._highlighted_rows - rows:
            self._tint_row(row, _TINT_ALPHA)
        for row in rows - self._highlighted_rows:
            self._tint_row(row, _HIGHLIGHT_ALPHA)
        self._highlighted_rows = rows
        if rows:
            first_row = min(rows)
            self.turns_table.scrollToItem(
                self.turns_table.item(first_row, _TEXT),
                QAbstractItemView.ScrollHint.PositionAtCenter,
            )

    def _summary(self) -> str:
        """What the table holds, or nothing at all when it holds nothing."""
        turns = self.experiment.speech_turns
        data = turns.data
        if data is None:
            return ""
        if data.empty:
            return "No speech was attributed to anyone."
        words = int(data["text"].str.split().str.len().sum())
        per_speaker = ", ".join(
            f"{speaker} {len(turns.for_speaker(speaker))}" for speaker in turns.speakers
        )
        return (
            f"{len(data)} turn(s), {words} word(s) across "
            f"{len(turns.speakers)} speaker(s) — turns each: {per_speaker}"
        )

    def _start(self) -> None:
        if self._thread is not None or self.blocked_reason() is not None:
            return
        self.summary_label.setText("Working out who spoke when…")
        self._set_running(True)
        self.progress_changed.emit(0, 100, _LABEL)
        self._worker = _Worker(self.experiment)
        self._worker.progress.connect(self._on_progress)
        self._worker.finished.connect(self._on_finished)
        self._worker.failed.connect(self._on_failed)
        self._worker.cancelled.connect(self._on_cancelled)
        self._thread = threading.Thread(target=self._worker.run, daemon=True)
        self._thread.start()

    def _cancel(self) -> None:
        if self._worker is not None:
            self._worker.cancel()
        self.cancel_button.setEnabled(False)
        self.cancel_button.setText("Cancelling…")

    @Slot(int)
    def _on_progress(self, percent: int) -> None:
        self.progress_changed.emit(percent, 100, _LABEL)

    @Slot()
    def _on_finished(self) -> None:
        turns = self.experiment.speech_turns
        count = 0 if turns.data is None else len(turns.data)
        self.status_message.emit(
            f"Attributed {count} speech turns across {len(turns.speakers)} speakers"
        )
        self._set_running(False)
        self.experiment_changed.emit()

    @Slot(str, str)
    def _on_failed(self, message: str, details: str) -> None:
        dialog = QMessageBox(self)
        dialog.setIcon(QMessageBox.Icon.Critical)
        dialog.setWindowTitle("Speaker attribution failed")
        dialog.setText(message)
        dialog.setDetailedText(details)
        dialog.exec()
        self._set_running(False)
        self.summary_label.setText("Could not attribute the speech.")

    @Slot()
    def _on_cancelled(self) -> None:
        self.status_message.emit("Speaker attribution cancelled")
        self._set_running(False)

    def _set_running(self, running: bool) -> None:
        if not running:
            self._thread = None
            self._worker = None
            self.refresh()
        self.attribute_button.setEnabled(not running and self.blocked_reason() is None)
        self.splitting_group.setEnabled(not running)
        self.attribution_group.setEnabled(not running)
        self.cancel_button.setVisible(running)
        self.cancel_button.setEnabled(True)
        self.cancel_button.setText("Cancel")
        self.busy_changed.emit(running)

blocked_reason()

Why attribution cannot run yet, or None when it can.

Source code in src/body_eye_sync/gui/tabs/speech_post_processing.py
def blocked_reason(self) -> str | None:
    """Why attribution cannot run yet, or ``None`` when it can."""
    if self.experiment.pipeline.speech is None:
        return (
            "Transcription is switched off for this experiment; switch it "
            "on in the Audio processing tab. Speech turns are worked out "
            "from the transcripts, so there is nothing to attribute without "
            "them."
        )
    glasses = [v for v in self.experiment.glasses_videos if v.path is not None]
    if len(glasses) < 2:
        return (
            "Speaker attribution compares the glasses recordings against each "
            "other, so it needs at least two of them."
        )
    missing = sorted(v.id for v in glasses if v.speech.data is None)
    if missing:
        return (
            "Transcribe these recordings first, in the Audio processing tab: "
            + ", ".join(missing)
        )
    if not any(v.timeline.offset for v in glasses):
        return (
            "Align the recordings first, in the Alignment tab: attribution "
            "compares them moment by moment, so it needs them on one clock."
        )
    return None

body_eye_sync.gui.tabs.post_processing

Post processing tab: what is derived from the per-input pipeline results.

PostProcessingTab

Bases: PlaceholderTab

Combine the per-input results into experiment-level results.

Source code in src/body_eye_sync/gui/tabs/post_processing.py
class PostProcessingTab(PlaceholderTab):
    """Combine the per-input results into experiment-level results."""

    title = "Post processing"

body_eye_sync.gui.tabs.data_export

Data export tab: write a synchronized combined video.

DataExportTab

Bases: BaseTab

Choose experiment inputs and export their synchronized video grid.

Source code in src/body_eye_sync/gui/tabs/data_export.py
class DataExportTab(BaseTab):
    """Choose experiment inputs and export their synchronized video grid."""

    title = "Data export"

    def __init__(self, experiment: Experiment) -> None:
        super().__init__(experiment)
        self._thread: threading.Thread | None = None
        self._worker: _VideoExportWorker | None = None

        description = QLabel(
            "Select the inputs to include in the synchronized 25 fps video. "
            "Video inputs fill the slots of the chosen layout; audio-only inputs "
            "contribute audio tracks."
        )
        description.setWordWrap(True)

        self.input_list = QListWidget()
        self.input_list.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
        self.input_list.setAlternatingRowColors(True)
        self.input_list.itemChanged.connect(self._update_availability)

        self.layout_editor = VideoLayoutEditor()
        self.layout_editor.changed.connect(self._update_availability)

        self.merged_audio_checkbox = QCheckBox("Include merged audio track")
        self.merged_audio_checkbox.setToolTip(
            "Append one default playback track mixing the synchronized audio from "
            "all selected inputs, while retaining the individual tracks."
        )

        self.export_button = QPushButton("Export combined video with ELAN annotations…")
        self.export_button.clicked.connect(self._choose_output)
        self.cancel_button = QPushButton("Cancel")
        self.cancel_button.setVisible(False)
        self.cancel_button.clicked.connect(self._cancel_export)

        buttons = QHBoxLayout()
        buttons.addWidget(self.export_button)
        buttons.addWidget(self.cancel_button)
        buttons.addStretch(1)

        layout = QVBoxLayout(self)
        layout.addWidget(description)
        layout.addWidget(self.input_list, stretch=1)
        layout.addWidget(self.layout_editor, stretch=2)
        layout.addWidget(self.merged_audio_checkbox)
        layout.addLayout(buttons)
        self.refresh()

    def set_experiment(self, experiment: Experiment) -> None:
        self.input_list.clear()
        super().set_experiment(experiment)

    def refresh(self) -> None:
        if self._thread is not None:
            return
        checked = {
            self.input_list.item(index).data(_INPUT_ID_ROLE): self.input_list.item(
                index
            ).checkState()
            == Qt.CheckState.Checked
            for index in range(self.input_list.count())
        }
        self.input_list.blockSignals(True)
        self.input_list.clear()
        for data in self.experiment.inputs:
            item = QListWidgetItem(f"{data.id} ({_input_kind(data)})")
            item.setData(_INPUT_ID_ROLE, data.id)
            item.setData(_IS_VIDEO_ROLE, isinstance(data, Video))
            item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
            item.setCheckState(
                Qt.CheckState.Checked
                if checked.get(data.id, True)
                else Qt.CheckState.Unchecked
            )
            self.input_list.addItem(item)
        self.input_list.blockSignals(False)
        self._update_availability()

    def selected_input_ids(self) -> list[str]:
        return self._checked_ids()

    def selected_video_ids(self) -> list[str]:
        """The checked video inputs, in the order the layout offers them."""
        return self._checked_ids(videos_only=True)

    def _checked_ids(self, videos_only: bool = False) -> list[str]:
        return [
            item.data(_INPUT_ID_ROLE)
            for index in range(self.input_list.count())
            if (item := self.input_list.item(index)).checkState()
            == Qt.CheckState.Checked
            and (not videos_only or bool(item.data(_IS_VIDEO_ROLE)))
        ]

    def is_busy(self) -> bool:
        return self._thread is not None

    @Slot()
    def _update_availability(self) -> None:
        self.layout_editor.set_videos(self.selected_video_ids())
        # There is nothing to export until the layout shows at least one video.
        placed = any(self.layout_editor.slots())
        running = self._thread is not None
        self.input_list.setEnabled(not running)
        self.layout_editor.setEnabled(not running)
        self.merged_audio_checkbox.setEnabled(not running)
        self.export_button.setEnabled(not running and placed)
        self.cancel_button.setVisible(running)
        self.cancel_button.setEnabled(True)
        self.cancel_button.setText("Cancel")

    @Slot()
    def _choose_output(self) -> None:
        if self._thread is not None or not self.export_button.isEnabled():
            return
        folder = self.experiment.folder or Path.cwd()
        chosen, _selected_filter = QFileDialog.getSaveFileName(
            self,
            "Export combined video",
            str(folder / "combined_video.mp4"),
            "MP4 video (*.mp4)",
        )
        if not chosen:
            return
        output_path = Path(chosen)
        if output_path.suffix.lower() != ".mp4":
            output_path = output_path.with_suffix(".mp4")
        self._start_export(output_path)

    def _start_export(self, output_path: Path) -> None:
        input_ids = self.selected_input_ids()
        if self._thread is not None or not input_ids:
            return
        self._worker = _VideoExportWorker(
            self.experiment,
            output_path,
            input_ids,
            self.layout_editor.layout_kind(),
            self.layout_editor.slots(),
            self.merged_audio_checkbox.isChecked(),
        )
        self._worker.progress.connect(self._on_progress)
        self._worker.finished.connect(self._on_finished)
        self._worker.failed.connect(self._on_failed)
        self._worker.cancelled.connect(self._on_cancelled)
        self._thread = threading.Thread(target=self._worker.run, daemon=True)
        self.progress_changed.emit(0, 100, _LABEL)
        self.busy_changed.emit(True)
        self._update_availability()
        self._thread.start()

    @Slot(int)
    def _on_progress(self, percent: int) -> None:
        self.progress_changed.emit(percent, 100, _LABEL)

    @Slot()
    def _cancel_export(self) -> None:
        if self._worker is not None:
            self._worker.cancel()
            self.cancel_button.setEnabled(False)
            self.cancel_button.setText("Cancelling…")

    @Slot(object)
    def _on_finished(self, result: VideoGridResult) -> None:
        message = f"Exported combined video to {result.path}"
        message += self._write_annotations(result)
        self.status_message.emit(message)
        self._set_running(False)

    def _write_annotations(self, result: VideoGridResult) -> str:
        """Write the speech turns beside the video, reporting what happened."""
        if not self.experiment.speech_turns.has_data():
            return ""
        try:
            annotation_path = export_elan(self.experiment, result, overwrite=True)
        except (OSError, ValueError) as exc:
            return f"; could not write speech annotations: {exc}"
        return f"; wrote speech annotations to {annotation_path.name}"

    @Slot(str, str)
    def _on_failed(self, message: str, details: str) -> None:
        dialog = QMessageBox(self)
        dialog.setIcon(QMessageBox.Icon.Critical)
        dialog.setWindowTitle("Video export failed")
        dialog.setText(message)
        dialog.setDetailedText(details)
        dialog.exec()
        self.status_message.emit("Could not export combined video")
        self._set_running(False)

    @Slot()
    def _on_cancelled(self) -> None:
        self.status_message.emit("Combined video export cancelled")
        self._set_running(False)

    def _set_running(self, running: bool) -> None:
        if not running:
            self._thread = None
            self._worker = None
        self.busy_changed.emit(running)
        self._update_availability()

selected_video_ids()

The checked video inputs, in the order the layout offers them.

Source code in src/body_eye_sync/gui/tabs/data_export.py
def selected_video_ids(self) -> list[str]:
    """The checked video inputs, in the order the layout offers them."""
    return self._checked_ids(videos_only=True)

Widgets

body_eye_sync.gui.widgets

AudioPlaybackWidget

Bases: QWidget

Play an audio-bearing file, seek it, and display its loudness.

Source code in src/body_eye_sync/gui/widgets/audio_playback.py
class AudioPlaybackWidget(QWidget):
    """Play an audio-bearing file, seek it, and display its loudness."""

    position_changed = Signal(float)
    _waveform_ready = Signal(int, object)

    def __init__(self, parent: QWidget | None = None) -> None:
        super().__init__(parent)
        self._path: Path | None = None
        self._levels = np.empty(0)
        self._duration = 0
        self._waveform_generation = 0
        self._waveform_started = False

        self._audio_output = QAudioOutput(self)
        self._player = QMediaPlayer(self)
        self._player.setAudioOutput(self._audio_output)
        self._player.durationChanged.connect(self._on_duration_changed)
        self._player.positionChanged.connect(self._on_position_changed)
        self._player.playbackStateChanged.connect(self._on_playback_state_changed)

        self._controls = PlaybackControls()
        self._controls.play_toggled.connect(self._on_play_toggled)
        self._controls.position_requested.connect(self._seek)

        self._graph = _LoudnessGraph()
        self._graph.seek_requested.connect(self._seek_fraction)

        layout = QVBoxLayout(self)
        layout.setContentsMargins(0, 0, 0, 0)
        layout.setSpacing(4)
        layout.addWidget(self._graph)
        layout.addWidget(self._controls)

        self._waveform_ready.connect(self._show_waveform)

    @property
    def path(self) -> Path | None:
        return self._path

    def load(self, path: str | Path, levels: np.ndarray | None = None) -> None:
        """Load an audio file or the audio track of a video file.

        ``levels`` are the recording's loudness in dB, if not provided will be measured in a background thread.
        """
        path = Path(path)
        levels = np.empty(0) if levels is None else np.asarray(levels, dtype=float)
        if path == self._path and levels.size == self._levels.size:
            return
        self.clear()
        self._path = path
        self._levels = levels
        self._waveform_generation += 1
        self._waveform_started = False
        self._graph.set_values(np.empty(0), "")
        self._controls.set_play_enabled(True)
        self._player.setSource(QUrl.fromLocalFile(str(path.resolve())))
        if self.isVisible():
            self._start_waveform()

    def clear(self) -> None:
        """Stop playback and forget the currently loaded recording."""
        self.pause()
        self._path = None
        self._duration = 0
        self._waveform_generation += 1
        self._waveform_started = False
        self._player.setSource(QUrl())
        self._controls.set_range(0, 0)
        self._controls.set_seek_enabled(False)
        self._controls.set_play_enabled(False)
        self._graph.set_values(np.empty(0), "")
        self._graph.set_position(0.0)
        self._update_time(0)

    def pause(self) -> None:
        self._player.pause()
        self._controls.set_playing(False)

    def play(self) -> None:
        """Start or resume playback of the loaded recording."""
        if self._path is not None:
            self._controls.play_button.setChecked(True)

    def seek(self, seconds: float) -> None:
        """Move playback to ``seconds`` on the recording clock."""
        self._player.setPosition(round(max(0.0, seconds) * 1000))

    @Slot(bool)
    def _on_play_toggled(self, playing: bool) -> None:
        if playing:
            if self._duration and self._player.position() >= self._duration:
                self._player.setPosition(0)
            self._player.play()
        else:
            self._player.pause()

    @Slot(object)
    def _on_playback_state_changed(self, state) -> None:
        self._controls.set_playing(state == QMediaPlayer.PlaybackState.PlayingState)

    @Slot(int)
    def _on_duration_changed(self, milliseconds: int) -> None:
        self._duration = max(0, milliseconds)
        self._controls.set_range(0, self._duration)
        self._controls.set_seek_enabled(self._path is not None and self._duration > 0)
        self._update_time(self._player.position())

    @Slot(int)
    def _on_position_changed(self, milliseconds: int) -> None:
        if not self._controls.is_seeking():
            self._controls.set_position(milliseconds)
        self._update_time(milliseconds)
        self._graph.set_position(
            milliseconds / self._duration if self._duration else 0.0
        )
        self.position_changed.emit(milliseconds / 1000)

    @Slot(int)
    def _seek(self, milliseconds: int) -> None:
        self._player.setPosition(milliseconds)
        self._update_time(milliseconds)
        self._graph.set_position(
            milliseconds / self._duration if self._duration else 0.0
        )
        self.position_changed.emit(milliseconds / 1000)

    @Slot(float)
    def _seek_fraction(self, fraction: float) -> None:
        self._controls.request_position(round(fraction * self._duration))

    def _update_time(self, position: int) -> None:
        self._controls.set_time_text(
            f"{_time_text(position)} / {_time_text(self._duration)}"
        )

    def _start_waveform(self) -> None:
        if self._path is None or self._waveform_started:
            return
        self._waveform_started = True
        generation = self._waveform_generation
        if self._levels.size:
            self._show_waveform(generation, loudness_overview(self._levels))
            return
        path = self._path
        threading.Thread(
            target=self._decode_waveform,
            args=(generation, path),
            daemon=True,
            name="audio-loudness",
        ).start()

    def _decode_waveform(self, generation: int, path: Path) -> None:
        try:
            values = _loudness_envelope(path)
        except Exception:
            values = None
        try:
            self._waveform_ready.emit(generation, values)
        except RuntimeError:
            pass

    @Slot(int, object)
    def _show_waveform(self, generation: int, values) -> None:
        if generation != self._waveform_generation:
            return
        if values is None:
            self._graph.set_values(np.empty(0), "Loudness unavailable")
        elif len(values) == 0:
            self._graph.set_values(np.empty(0), "No audio samples")
        else:
            self._graph.set_values(values)

    def showEvent(self, event) -> None:
        super().showEvent(event)
        self._start_waveform()

clear()

Stop playback and forget the currently loaded recording.

Source code in src/body_eye_sync/gui/widgets/audio_playback.py
def clear(self) -> None:
    """Stop playback and forget the currently loaded recording."""
    self.pause()
    self._path = None
    self._duration = 0
    self._waveform_generation += 1
    self._waveform_started = False
    self._player.setSource(QUrl())
    self._controls.set_range(0, 0)
    self._controls.set_seek_enabled(False)
    self._controls.set_play_enabled(False)
    self._graph.set_values(np.empty(0), "")
    self._graph.set_position(0.0)
    self._update_time(0)

load(path, levels=None)

Load an audio file or the audio track of a video file.

levels are the recording's loudness in dB, if not provided will be measured in a background thread.

Source code in src/body_eye_sync/gui/widgets/audio_playback.py
def load(self, path: str | Path, levels: np.ndarray | None = None) -> None:
    """Load an audio file or the audio track of a video file.

    ``levels`` are the recording's loudness in dB, if not provided will be measured in a background thread.
    """
    path = Path(path)
    levels = np.empty(0) if levels is None else np.asarray(levels, dtype=float)
    if path == self._path and levels.size == self._levels.size:
        return
    self.clear()
    self._path = path
    self._levels = levels
    self._waveform_generation += 1
    self._waveform_started = False
    self._graph.set_values(np.empty(0), "")
    self._controls.set_play_enabled(True)
    self._player.setSource(QUrl.fromLocalFile(str(path.resolve())))
    if self.isVisible():
        self._start_waveform()

play()

Start or resume playback of the loaded recording.

Source code in src/body_eye_sync/gui/widgets/audio_playback.py
def play(self) -> None:
    """Start or resume playback of the loaded recording."""
    if self._path is not None:
        self._controls.play_button.setChecked(True)

seek(seconds)

Move playback to seconds on the recording clock.

Source code in src/body_eye_sync/gui/widgets/audio_playback.py
def seek(self, seconds: float) -> None:
    """Move playback to ``seconds`` on the recording clock."""
    self._player.setPosition(round(max(0.0, seconds) * 1000))

AutoHeightTable

Bases: QTableWidget

A table sized to exactly fit its header and rows.

Source code in src/body_eye_sync/gui/widgets/auto_height_table.py
class AutoHeightTable(QTableWidget):
    """A table sized to exactly fit its header and rows."""

    def __init__(self, headers: Sequence[str]) -> None:
        super().__init__(0, len(headers))
        self.setHorizontalHeaderLabels(list(headers))
        self.verticalHeader().setVisible(False)
        self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)

    def fit_to_rows(self) -> None:
        """Make the table exactly as tall as its header and rows."""
        height = self.horizontalHeader().height() + 2 * self.frameWidth()
        for row in range(self.rowCount()):
            height += self.rowHeight(row)
        self.setFixedHeight(height)

fit_to_rows()

Make the table exactly as tall as its header and rows.

Source code in src/body_eye_sync/gui/widgets/auto_height_table.py
def fit_to_rows(self) -> None:
    """Make the table exactly as tall as its header and rows."""
    height = self.horizontalHeader().height() + 2 * self.frameWidth()
    for row in range(self.rowCount()):
        height += self.rowHeight(row)
    self.setFixedHeight(height)

PipelineEditor

Bases: QWidget

Edit one pipeline's steps and their arguments.

steps says which steps the editor shows, in run order -- :data:VIDEO_STEPS or :data:SPEECH_STEPS. Populate from a pipeline with :meth:set_from (or :meth:reset to defaults), and write the edited values back into one with :meth:apply_to. changed fires on any toggle or field edit. Each step has its own "Run" button (run_requested, with the step's type); run_all_requested fires from the button that runs every enabled step in order. Running is out of scope for this widget -- it only reports the requests, and its buttons' enabled state is driven from outside via :meth:set_run_enabled/:meth:set_run_all_enabled.

Source code in src/body_eye_sync/gui/widgets/pipeline_editor.py
class PipelineEditor(QWidget):
    """Edit one pipeline's steps and their arguments.

    ``steps`` says which steps the editor shows, in run order -- :data:`VIDEO_STEPS`
    or :data:`SPEECH_STEPS`. Populate from a pipeline with :meth:`set_from` (or
    :meth:`reset` to defaults), and write the edited values back into one with
    :meth:`apply_to`. ``changed`` fires on any toggle or field edit. Each step has
    its own "Run" button (``run_requested``, with the step's type);
    ``run_all_requested`` fires from the button that runs every enabled step in
    order. Running is out of scope for this widget -- it only reports the
    requests, and its buttons' enabled state is driven from outside via
    :meth:`set_run_enabled`/:meth:`set_run_all_enabled`.
    """

    changed = Signal()
    run_requested = Signal(object)
    run_all_requested = Signal()

    def __init__(
        self, steps: Sequence[StepEntry], parent: QWidget | None = None
    ) -> None:
        super().__init__(parent)
        self._sections: list[_StepSection] = []
        layout = QVBoxLayout(self)
        for attr_name, step_type, title, optional in steps:
            section = _StepSection(attr_name, step_type, title, optional)
            section.changed.connect(self.changed)
            section.run_requested.connect(
                lambda step_type=step_type: self.run_requested.emit(step_type)
            )
            self._sections.append(section)
            layout.addWidget(section)

        self.run_all_button = QPushButton("Run all")
        self.run_all_button.setEnabled(False)
        self.run_all_button.clicked.connect(self.run_all_requested)
        layout.addWidget(self.run_all_button)
        layout.addStretch(1)

    def set_from(self, pipeline: StepPipeline) -> None:
        """Populate the editor from ``pipeline``'s stages (no ``changed``)."""
        for section in self._sections:
            section.blockSignals(True)
            section.set_from(getattr(pipeline, section.attr_name))
            section.blockSignals(False)

    def reset(self) -> None:
        """Reset every step to its defaults, optional steps switched off."""
        for section in self._sections:
            section.blockSignals(True)
            section.reset()
            section.blockSignals(False)

    def apply_to(self, pipeline: StepPipeline) -> None:
        """Write the edited steps back onto ``pipeline``'s stage fields.

        Disabled optional steps become ``None``. All steps are validated before
        anything is assigned, so an invalid field leaves ``pipeline`` intact.
        Raises :class:`pydantic.ValidationError` / :class:`ValueError` if any
        step's arguments are invalid.
        """
        values = {
            s.attr_name: (s.to_step() if s.is_enabled() else None)
            for s in self._sections
        }
        for name, value in values.items():
            setattr(pipeline, name, value)

    def enabled_steps(self) -> list[StepSpec]:
        """The enabled steps, in order, built and validated from the widgets.

        Raises :class:`pydantic.ValidationError` / :class:`ValueError` if any
        enabled step's arguments are invalid.
        """
        return [s.to_step() for s in self._sections if s.is_enabled()]

    def _section(self, step_type: type) -> _StepSection:
        """The section editing ``step_type``, or ``KeyError`` if unknown."""
        for section in self._sections:
            if section.step_type is step_type:
                return section
        raise KeyError(step_type)

    def config_for(self, step_type: type) -> StepSpec:
        """The validated config for one step, whether or not it is enabled.

        Lets an interactive run of a single pass use the arguments the user has
        set, independent of whether the step is toggled into the saved pipeline.
        Raises :class:`pydantic.ValidationError` / :class:`ValueError` if invalid.
        """
        return self._section(step_type).to_step()

    def set_run_enabled(self, step_type: type, enabled: bool) -> None:
        """Enable/disable one step's "Run" button (e.g. while its inputs aren't ready)."""
        self._section(step_type).set_run_enabled(enabled)

    def set_run_all_enabled(self, enabled: bool) -> None:
        self.run_all_button.setEnabled(enabled)

apply_to(pipeline)

Write the edited steps back onto pipeline's stage fields.

Disabled optional steps become None. All steps are validated before anything is assigned, so an invalid field leaves pipeline intact. Raises :class:pydantic.ValidationError / :class:ValueError if any step's arguments are invalid.

Source code in src/body_eye_sync/gui/widgets/pipeline_editor.py
def apply_to(self, pipeline: StepPipeline) -> None:
    """Write the edited steps back onto ``pipeline``'s stage fields.

    Disabled optional steps become ``None``. All steps are validated before
    anything is assigned, so an invalid field leaves ``pipeline`` intact.
    Raises :class:`pydantic.ValidationError` / :class:`ValueError` if any
    step's arguments are invalid.
    """
    values = {
        s.attr_name: (s.to_step() if s.is_enabled() else None)
        for s in self._sections
    }
    for name, value in values.items():
        setattr(pipeline, name, value)

config_for(step_type)

The validated config for one step, whether or not it is enabled.

Lets an interactive run of a single pass use the arguments the user has set, independent of whether the step is toggled into the saved pipeline. Raises :class:pydantic.ValidationError / :class:ValueError if invalid.

Source code in src/body_eye_sync/gui/widgets/pipeline_editor.py
def config_for(self, step_type: type) -> StepSpec:
    """The validated config for one step, whether or not it is enabled.

    Lets an interactive run of a single pass use the arguments the user has
    set, independent of whether the step is toggled into the saved pipeline.
    Raises :class:`pydantic.ValidationError` / :class:`ValueError` if invalid.
    """
    return self._section(step_type).to_step()

enabled_steps()

The enabled steps, in order, built and validated from the widgets.

Raises :class:pydantic.ValidationError / :class:ValueError if any enabled step's arguments are invalid.

Source code in src/body_eye_sync/gui/widgets/pipeline_editor.py
def enabled_steps(self) -> list[StepSpec]:
    """The enabled steps, in order, built and validated from the widgets.

    Raises :class:`pydantic.ValidationError` / :class:`ValueError` if any
    enabled step's arguments are invalid.
    """
    return [s.to_step() for s in self._sections if s.is_enabled()]

reset()

Reset every step to its defaults, optional steps switched off.

Source code in src/body_eye_sync/gui/widgets/pipeline_editor.py
def reset(self) -> None:
    """Reset every step to its defaults, optional steps switched off."""
    for section in self._sections:
        section.blockSignals(True)
        section.reset()
        section.blockSignals(False)

set_from(pipeline)

Populate the editor from pipeline's stages (no changed).

Source code in src/body_eye_sync/gui/widgets/pipeline_editor.py
def set_from(self, pipeline: StepPipeline) -> None:
    """Populate the editor from ``pipeline``'s stages (no ``changed``)."""
    for section in self._sections:
        section.blockSignals(True)
        section.set_from(getattr(pipeline, section.attr_name))
        section.blockSignals(False)

set_run_enabled(step_type, enabled)

Enable/disable one step's "Run" button (e.g. while its inputs aren't ready).

Source code in src/body_eye_sync/gui/widgets/pipeline_editor.py
def set_run_enabled(self, step_type: type, enabled: bool) -> None:
    """Enable/disable one step's "Run" button (e.g. while its inputs aren't ready)."""
    self._section(step_type).set_run_enabled(enabled)

PydanticForm

Bases: QWidget

An editing form for one flat pydantic model instance.

Populate from a model with :meth:from_model, read the edited values back (validated) with :meth:to_model. changed fires on any edit.

Source code in src/body_eye_sync/gui/widgets/pydantic_form.py
class PydanticForm(QWidget):
    """An editing form for one flat pydantic model instance.

    Populate from a model with :meth:`from_model`, read the edited values back
    (validated) with :meth:`to_model`. ``changed`` fires on any edit.
    """

    changed = Signal()

    def __init__(
        self,
        model: BaseModel,
        parent: QWidget | None = None,
        fields: Iterable[str] | None = None,
    ) -> None:
        super().__init__(parent)
        self._model_type = type(model)
        self._widgets: dict[str, QWidget] = {}
        self._field_info: dict[str, FieldInfo] = {}
        selected = set(fields) if fields is not None else None
        unknown = (
            set()
            if selected is None
            else selected - self._model_type.model_fields.keys()
        )
        if unknown:
            raise ValueError(f"Unknown form field(s): {', '.join(sorted(unknown))}")

        layout = QFormLayout(self)
        for name, field in self._model_type.model_fields.items():
            if selected is not None and name not in selected:
                continue
            if get_origin(field.annotation) is Literal:
                continue  # discriminator tag: fixed, not user-editable
            widget = self._make_widget(field)
            self._widgets[name] = widget
            self._field_info[name] = field
            label = name.replace("_", " ").capitalize()
            if field.description:
                widget.setToolTip(field.description)
            layout.addRow(label, widget)

        self.from_model(model)

    def _make_widget(self, field: FieldInfo) -> QWidget:
        choices = _choices(field)
        if choices:
            combo = QComboBox()
            combo.setEditable(True)
            combo.addItems([str(c) for c in choices])
            combo.currentTextChanged.connect(self.changed)
            return combo

        annotation = field.annotation
        if annotation is bool:
            check = QCheckBox()
            check.toggled.connect(self.changed)
            return check
        if annotation is int:
            spin = QSpinBox()
            low, high = _bounds(field)
            spin.setMinimum(int(low) if low is not None else -_INT_LIMIT)
            spin.setMaximum(int(high) if high is not None else _INT_LIMIT)
            spin.valueChanged.connect(self.changed)
            return spin
        if annotation is float:
            spin = QDoubleSpinBox()
            spin.setDecimals(3)
            spin.setSingleStep(0.01)
            low, high = _bounds(field)
            spin.setMinimum(float(low) if low is not None else -_FLOAT_LIMIT)
            spin.setMaximum(float(high) if high is not None else _FLOAT_LIMIT)
            spin.valueChanged.connect(self.changed)
            return spin

        line = QLineEdit()
        line.textChanged.connect(self.changed)
        return line

    def from_model(self, model: BaseModel) -> None:
        """Populate the widgets from ``model``'s current values."""
        for name, widget in self._widgets.items():
            value = getattr(model, name)
            if isinstance(widget, QComboBox):
                widget.setCurrentText(str(value))
            elif isinstance(widget, QCheckBox):
                widget.setChecked(bool(value))
            elif isinstance(widget, QSpinBox):
                widget.setValue(int(value))
            elif isinstance(widget, QDoubleSpinBox):
                widget.setValue(float(value))
            elif isinstance(widget, QLineEdit):
                if isinstance(value, (list, tuple)):
                    widget.setText(", ".join(str(v) for v in value))
                elif value is None:
                    # An unset optional value shows as an empty box, not "None".
                    widget.setText("")
                else:
                    widget.setText(str(value))

    def to_model(self, base: BaseModel | None = None) -> BaseModel:
        """Build a validated model from the current widget values.

        When this form displays only selected fields, ``base`` preserves the
        other values instead of resetting them to their defaults.

        Raises :class:`pydantic.ValidationError` (or :class:`ValueError` from
        list parsing) if the edited values are invalid.
        """
        if base is not None and not isinstance(base, self._model_type):
            raise TypeError(f"Expected {self._model_type.__name__} as the base model")
        values = {} if base is None else base.model_dump()
        values.update(self._values())
        return self._model_type(**values)

    def _values(self) -> dict[str, Any]:
        values: dict[str, Any] = {}
        for name, widget in self._widgets.items():
            field = self._field_info[name]
            if isinstance(widget, QComboBox):
                values[name] = widget.currentText()
            elif isinstance(widget, QCheckBox):
                values[name] = widget.isChecked()
            elif isinstance(widget, (QSpinBox, QDoubleSpinBox)):
                values[name] = widget.value()
            elif isinstance(widget, QLineEdit):
                text = widget.text()
                if get_origin(field.annotation) is list:
                    values[name] = _parse_list(text, field)
                elif _optional(field) and not text:
                    values[name] = None
                else:
                    values[name] = text
        return values

from_model(model)

Populate the widgets from model's current values.

Source code in src/body_eye_sync/gui/widgets/pydantic_form.py
def from_model(self, model: BaseModel) -> None:
    """Populate the widgets from ``model``'s current values."""
    for name, widget in self._widgets.items():
        value = getattr(model, name)
        if isinstance(widget, QComboBox):
            widget.setCurrentText(str(value))
        elif isinstance(widget, QCheckBox):
            widget.setChecked(bool(value))
        elif isinstance(widget, QSpinBox):
            widget.setValue(int(value))
        elif isinstance(widget, QDoubleSpinBox):
            widget.setValue(float(value))
        elif isinstance(widget, QLineEdit):
            if isinstance(value, (list, tuple)):
                widget.setText(", ".join(str(v) for v in value))
            elif value is None:
                # An unset optional value shows as an empty box, not "None".
                widget.setText("")
            else:
                widget.setText(str(value))

to_model(base=None)

Build a validated model from the current widget values.

When this form displays only selected fields, base preserves the other values instead of resetting them to their defaults.

Raises :class:pydantic.ValidationError (or :class:ValueError from list parsing) if the edited values are invalid.

Source code in src/body_eye_sync/gui/widgets/pydantic_form.py
def to_model(self, base: BaseModel | None = None) -> BaseModel:
    """Build a validated model from the current widget values.

    When this form displays only selected fields, ``base`` preserves the
    other values instead of resetting them to their defaults.

    Raises :class:`pydantic.ValidationError` (or :class:`ValueError` from
    list parsing) if the edited values are invalid.
    """
    if base is not None and not isinstance(base, self._model_type):
        raise TypeError(f"Expected {self._model_type.__name__} as the base model")
    values = {} if base is None else base.model_dump()
    values.update(self._values())
    return self._model_type(**values)

SynchronizedAudioPlaybackWidget

Bases: QWidget

Stack aligned recordings and play accepted speech turns on one clock.

Source code in src/body_eye_sync/gui/widgets/synchronized_audio_playback.py
class SynchronizedAudioPlaybackWidget(QWidget):
    """Stack aligned recordings and play accepted speech turns on one clock."""

    position_changed = Signal(float)
    _waveform_ready = Signal(int, str, object, object)

    def __init__(self, parent: QWidget | None = None) -> None:
        super().__init__(parent)
        self._tracks: dict[str, _Track] = {}
        self._signature: tuple = ()
        self._turns: pd.DataFrame | None = None
        self._start = 0.0
        self._end = 0.0
        self._position = 0.0
        self._playing = False
        self._anchor_position = 0.0
        self._anchor_time = 0.0
        self._sync_counter = 0
        self._waveform_generation = 0
        self._waveforms_started = False

        self._rows_layout = QVBoxLayout()
        self._rows_layout.setContentsMargins(0, 0, 0, 0)
        self._rows_layout.setSpacing(2)

        self.mute_background_checkbox = QCheckBox("Mute background")
        self.mute_background_checkbox.setChecked(True)
        self.mute_background_checkbox.toggled.connect(self._on_mute_background_toggled)
        self._controls = PlaybackControls(
            extra_widget=self.mute_background_checkbox,
            time_label_width=115,
        )
        self._controls.play_toggled.connect(self._on_play_toggled)
        self._controls.position_requested.connect(self._seek_from_controls)

        layout = QVBoxLayout(self)
        layout.setContentsMargins(0, 0, 0, 0)
        layout.addLayout(self._rows_layout)
        layout.addWidget(self._controls)

        self._timer = QTimer(self)
        self._timer.setInterval(50)
        self._timer.timeout.connect(self._advance)
        self._waveform_ready.connect(self._show_waveform)

    @property
    def recording_ids(self) -> list[str]:
        return list(self._tracks)

    def color_for(self, recording_id: str) -> QColor | None:
        """Return the stable display color assigned to ``recording_id``."""
        track = self._tracks.get(recording_id)
        return None if track is None else QColor(track.color)

    def load(self, recordings: list[Video | Audio]) -> None:
        """Show ``recordings`` on their shared experiment clock."""
        signature = tuple(
            (
                data.id,
                str(data.path),
                data.timeline.offset,
                data.timeline.rate,
            )
            for data in recordings
        )
        if signature == self._signature:
            return
        self.clear()
        self._signature = signature

        measured = [
            (data, media_duration(data.path))
            for data in recordings
            if data.path is not None
        ]
        measured = [(data, duration) for data, duration in measured if duration]
        if not measured:
            return
        self._start = min(data.timeline.to_experiment_time(0.0) for data, _ in measured)
        self._end = max(
            data.timeline.to_experiment_time(float(duration))
            for data, duration in measured
        )
        self._position = self._start

        for index, (data, duration) in enumerate(measured):
            color = get_color(_RECORDING_COLOR_IDS[index % len(_RECORDING_COLOR_IDS)])
            row = _TrackRow(data.id, self._start, self._end, color)
            self._rows_layout.addWidget(row)
            self._tracks[data.id] = _Track(
                data, Path(data.path), float(duration), row, color
            )

        self._controls.set_range(round(self._start * 1000), round(self._end * 1000))
        self._controls.set_position(round(self._position * 1000))
        self._controls.set_seek_enabled(True)
        self._controls.set_play_enabled(True)
        self._waveforms_started = False
        if self.isVisible():
            self._start_waveforms()
        self._show_position()

    def set_turns(self, turns: pd.DataFrame | None) -> None:
        """Set the accepted turns identifying the contributing recordings."""
        self._turns = turns
        self._update_active_speakers()

    def clear(self) -> None:
        self.pause()
        self._waveform_generation += 1
        self._waveforms_started = False
        for track in self._tracks.values():
            if track.player is not None:
                track.player.stop()
                track.player.setSource(QUrl())
                track.player.deleteLater()
            if track.output is not None:
                track.output.deleteLater()
            track.row.deleteLater()
        self._tracks.clear()
        while self._rows_layout.count():
            item = self._rows_layout.takeAt(0)
            if item.widget() is not None:
                item.widget().setParent(None)
        self._signature = ()
        self._controls.set_range(0, 0)
        self._controls.set_seek_enabled(False)
        self._controls.set_play_enabled(False)

    def play(self) -> None:
        """Start or resume all recordings from the shared position."""
        if not self._tracks:
            return
        if self._position >= self._end:
            self.seek(self._start)
        self._ensure_players()
        self._playing = True
        self._anchor_position = self._position
        self._anchor_time = time.monotonic()
        self._controls.set_playing(True)
        self._sync_players(force=True)
        self._timer.start()

    def pause(self) -> None:
        self._timer.stop()
        self._playing = False
        for track in self._tracks.values():
            if track.player is not None:
                track.player.pause()
        self._controls.set_playing(False)

    def seek(self, seconds: float) -> None:
        """Move every recording to one experiment-clock position."""
        self._position = max(self._start, min(float(seconds), self._end))
        if self._playing:
            self._anchor_position = self._position
            self._anchor_time = time.monotonic()
        self._show_position()
        self._sync_players(force=True)

    def _ensure_players(self) -> None:
        for track in self._tracks.values():
            if track.player is not None:
                continue
            output = QAudioOutput(self)
            output.setMuted(True)
            player = QMediaPlayer(self)
            player.setAudioOutput(output)
            player.setSource(QUrl.fromLocalFile(str(track.path.resolve())))
            player.setPlaybackRate(1.0 / track.data.timeline.rate)
            track.output = output
            track.player = player

    @Slot(bool)
    def _on_play_toggled(self, playing: bool) -> None:
        if playing:
            self.play()
        else:
            self.pause()

    @Slot(bool)
    def _on_mute_background_toggled(self, _checked: bool) -> None:
        self._update_active_speakers()

    @Slot(int)
    def _seek_from_controls(self, milliseconds: int) -> None:
        self.seek(milliseconds / 1000)

    def _advance(self) -> None:
        position = self._anchor_position + (time.monotonic() - self._anchor_time)
        if position >= self._end:
            self._position = self._end
            self._show_position()
            self.pause()
            return
        self._position = position
        self._show_position()
        self._sync_counter += 1
        if self._sync_counter % 10 == 0:
            self._sync_players(force=False)

    def _show_position(self) -> None:
        self._controls.set_position(round(self._position * 1000))
        self._controls.set_time_text(
            f"{_time_text(self._position)} / {_time_text(self._end)}"
        )
        for track in self._tracks.values():
            track.row._graph.set_position(self._position)
        self._update_active_speakers()
        self.position_changed.emit(self._position)

    def _update_active_speakers(self) -> None:
        active = active_speakers(self._turns, self._position)
        for name, track in self._tracks.items():
            contributes = name in active
            track.row.set_active(contributes)
            if track.output is not None:
                local = track.data.timeline.to_local_time(self._position)
                covered = local is not None and 0 <= local <= track.duration
                track.output.setMuted(
                    not covered
                    or (self.mute_background_checkbox.isChecked() and not contributes)
                )

    def _sync_players(self, *, force: bool) -> None:
        if not self._playing:
            return
        active = active_speakers(self._turns, self._position)
        for name, track in self._tracks.items():
            if track.player is None or track.output is None:
                continue
            local = track.data.timeline.to_local_time(self._position)
            covered = local is not None and 0 <= local <= track.duration
            track.output.setMuted(
                not covered
                or (self.mute_background_checkbox.isChecked() and name not in active)
            )
            if not covered:
                track.player.pause()
                continue
            target = round(local * 1000)
            if force or abs(track.player.position() - target) > 80:
                track.player.setPosition(target)
            track.player.play()

    def _start_waveforms(self) -> None:
        if self._waveforms_started:
            return
        self._waveforms_started = True
        generation = self._waveform_generation
        for name, track in self._tracks.items():
            threading.Thread(
                target=self._decode_waveform,
                args=(generation, name, track),
                daemon=True,
                name=f"loudness-{name}",
            ).start()

    def _decode_waveform(self, generation: int, name: str, track: _Track) -> None:
        try:
            levels = track.data.loudness.levels
            values = (
                loudness_overview(levels)
                if levels.size
                else _loudness_envelope(track.path)
            )
            times = track.data.timeline.to_experiment_times(
                np.linspace(0.0, track.duration, len(values), endpoint=False)
            )
        except Exception:
            values, times = np.empty(0), np.empty(0)
        try:
            self._waveform_ready.emit(generation, name, values, times)
        except RuntimeError:
            pass

    @Slot(int, str, object, object)
    def _show_waveform(self, generation: int, name: str, values, times) -> None:
        if generation != self._waveform_generation or name not in self._tracks:
            return
        self._tracks[name].row._graph.set_values(values, times)

    def showEvent(self, event) -> None:
        super().showEvent(event)
        self._start_waveforms()

color_for(recording_id)

Return the stable display color assigned to recording_id.

Source code in src/body_eye_sync/gui/widgets/synchronized_audio_playback.py
def color_for(self, recording_id: str) -> QColor | None:
    """Return the stable display color assigned to ``recording_id``."""
    track = self._tracks.get(recording_id)
    return None if track is None else QColor(track.color)

load(recordings)

Show recordings on their shared experiment clock.

Source code in src/body_eye_sync/gui/widgets/synchronized_audio_playback.py
def load(self, recordings: list[Video | Audio]) -> None:
    """Show ``recordings`` on their shared experiment clock."""
    signature = tuple(
        (
            data.id,
            str(data.path),
            data.timeline.offset,
            data.timeline.rate,
        )
        for data in recordings
    )
    if signature == self._signature:
        return
    self.clear()
    self._signature = signature

    measured = [
        (data, media_duration(data.path))
        for data in recordings
        if data.path is not None
    ]
    measured = [(data, duration) for data, duration in measured if duration]
    if not measured:
        return
    self._start = min(data.timeline.to_experiment_time(0.0) for data, _ in measured)
    self._end = max(
        data.timeline.to_experiment_time(float(duration))
        for data, duration in measured
    )
    self._position = self._start

    for index, (data, duration) in enumerate(measured):
        color = get_color(_RECORDING_COLOR_IDS[index % len(_RECORDING_COLOR_IDS)])
        row = _TrackRow(data.id, self._start, self._end, color)
        self._rows_layout.addWidget(row)
        self._tracks[data.id] = _Track(
            data, Path(data.path), float(duration), row, color
        )

    self._controls.set_range(round(self._start * 1000), round(self._end * 1000))
    self._controls.set_position(round(self._position * 1000))
    self._controls.set_seek_enabled(True)
    self._controls.set_play_enabled(True)
    self._waveforms_started = False
    if self.isVisible():
        self._start_waveforms()
    self._show_position()

play()

Start or resume all recordings from the shared position.

Source code in src/body_eye_sync/gui/widgets/synchronized_audio_playback.py
def play(self) -> None:
    """Start or resume all recordings from the shared position."""
    if not self._tracks:
        return
    if self._position >= self._end:
        self.seek(self._start)
    self._ensure_players()
    self._playing = True
    self._anchor_position = self._position
    self._anchor_time = time.monotonic()
    self._controls.set_playing(True)
    self._sync_players(force=True)
    self._timer.start()

seek(seconds)

Move every recording to one experiment-clock position.

Source code in src/body_eye_sync/gui/widgets/synchronized_audio_playback.py
def seek(self, seconds: float) -> None:
    """Move every recording to one experiment-clock position."""
    self._position = max(self._start, min(float(seconds), self._end))
    if self._playing:
        self._anchor_position = self._position
        self._anchor_time = time.monotonic()
    self._show_position()
    self._sync_players(force=True)

set_turns(turns)

Set the accepted turns identifying the contributing recordings.

Source code in src/body_eye_sync/gui/widgets/synchronized_audio_playback.py
def set_turns(self, turns: pd.DataFrame | None) -> None:
    """Set the accepted turns identifying the contributing recordings."""
    self._turns = turns
    self._update_active_speakers()

VideoViewer

Bases: QWidget

Display a video with play/pause, a seek slider and a frame spinbox.

Source code in src/body_eye_sync/gui/widgets/video_viewer.py
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
class VideoViewer(QWidget):
    """Display a video with play/pause, a seek slider and a frame spinbox."""

    frame_changed = Signal(int)

    def __init__(self, parent: QWidget | None = None) -> None:
        super().__init__(parent)

        self._capture: cv2.VideoCapture | None = None
        self._frame_count = 0
        self._fps = 25.0
        self._current = 0
        self._preroll_seconds: float | None = None
        self._displayed_time_seconds: float | None = None
        self._video_aspect_ratio: float | None = None
        self._height_matches_video = False
        self._audio_output = QAudioOutput(self)
        self._media_player = QMediaPlayer(self)
        self._media_player.setAudioOutput(self._audio_output)

        # the video being displayed; supplies the boxes to draw per frame
        self._video: Video | None = None
        self.show_overlays = True
        self._overlay_items: list[QGraphicsItem] = []

        # video display
        self._scene = QGraphicsScene(self)
        self._pixmap_item = QGraphicsPixmapItem()
        self._scene.addItem(self._pixmap_item)
        self._view = _VideoGraphicsView(self._scene)
        self._view.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
        self._view.setAlignment(Qt.AlignmentFlag.AlignCenter)

        # controls
        self._play_button = QPushButton("Play")
        self._play_button.setCheckable(True)
        self._play_button.toggled.connect(self._on_play_toggled)

        self._mute_button = QToolButton()
        self._mute_button.setCheckable(True)
        self._mute_button.setIcon(
            self.style().standardIcon(QStyle.StandardPixmap.SP_MediaVolume)
        )
        self._mute_button.setToolTip(
            "Mute audio"
        )  # gets updated later as state changes.
        self._mute_button.toggled.connect(self._on_mute_toggled)

        self._slider = QSlider(Qt.Orientation.Horizontal)
        self._slider.setEnabled(False)
        self._slider.valueChanged.connect(self.set_frame)

        self._spinbox = QSpinBox()
        self._spinbox.setEnabled(False)
        self._spinbox.valueChanged.connect(self.set_frame)

        self._time_label = QLabel("0.000 s")
        self._total_label = QLabel("/ 0")

        controls = QHBoxLayout()
        controls.addWidget(self._play_button)
        controls.addWidget(self._mute_button)
        controls.addWidget(self._slider, stretch=1)
        controls.addWidget(self._time_label)
        controls.addWidget(self._spinbox)
        controls.addWidget(self._total_label)

        layout = QVBoxLayout(self)
        layout.addWidget(self._view, stretch=1)
        layout.addLayout(controls)

        # playback timer
        self._timer = QTimer(self)
        self._timer.setTimerType(Qt.TimerType.PreciseTimer)
        self._timer.timeout.connect(self._advance)

    def match_video_height(self) -> None:
        self._height_matches_video = True
        self._view.allow_parent_scroll = True
        self._view.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed)
        self._view.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        self._view.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        self.match_container_height_to_video_height()

    def load(self, video: Video) -> None:
        """Display ``video``, showing its first frame and its boxes (if any)."""
        self.stop()
        if self._capture is not None:
            self._capture.release()

        capture = cv2.VideoCapture(str(video.video_path))
        if not capture.isOpened():
            raise OSError(f"Could not open video: {video.video_path}")

        self._video = video
        self._capture = capture
        self._media_player.setSource(QUrl.fromLocalFile(str(video.video_path)))
        self._fps = capture.get(cv2.CAP_PROP_FPS) or 25.0
        self._timer.setInterval(max(1, round(1000 / self._fps)))

        count = int(capture.get(cv2.CAP_PROP_FRAME_COUNT)) or 0
        for control in (self._slider, self._spinbox):
            control.setEnabled(count > 0)
            control.setMinimum(0)
        self._set_frame_count(count)

        self._current = -1
        self._preroll_seconds = None
        self.set_frame(0)
        self.fit_image_at_aspect_ratio()

    def clear(self) -> None:
        """Show nothing at all: no video, no frame and no overlays."""
        self.stop()
        if self._capture is not None:
            self._capture.release()
            self._capture = None
        self._video = None
        self._current = -1
        self._preroll_seconds = None
        self._displayed_time_seconds = None
        self._video_aspect_ratio = None
        self._media_player.stop()
        self._media_player.setSource(QUrl())
        self._clear_overlays()
        self._pixmap_item.setPixmap(QPixmap())
        self._scene.setSceneRect(0, 0, 0, 0)
        self._set_frame_count(0)
        self.enable_controls(False)

    def set_frame(
        self,
        index: int,
        *,
        displayed_time_seconds: float | None = None,
        sync_audio: bool = True,
    ) -> None:
        """Display frame ``index`` and optionally seek embedded audio to it."""
        previous_time_seconds = self.current_time_seconds
        self._displayed_time_seconds = displayed_time_seconds
        if self._goto(index, sync_audio=sync_audio):
            self.refresh_overlays()
            return
        current_time_seconds = self.current_time_seconds
        if (
            displayed_time_seconds is not None
            or current_time_seconds != previous_time_seconds
        ):
            self._time_label.setText(f"{current_time_seconds:.3f} s")
        if sync_audio and current_time_seconds != previous_time_seconds:
            self._sync_audio_to_frame()
        if (
            current_time_seconds != previous_time_seconds
            and self._capture is not None
            and self._frame_count > 0
        ):
            self.frame_changed.emit(self._current)

    # Display the frame closest to ``seconds`` in the video.
    def set_time_seconds(
        self,
        seconds: float,
        *,
        allow_negative: bool = False,
        show_requested_time: bool = False,
        sync_audio: bool = True,
    ) -> None:
        """Display the frame selected by ``seconds`` in the source video."""
        if self._fps <= 0.0:
            self.set_frame(0, sync_audio=sync_audio)
            return
        if allow_negative and seconds < 0.0:
            self._show_preroll_frame(seconds)
            return
        frame = (
            int(seconds * self._fps)
            if show_requested_time
            else round(seconds * self._fps)
        )
        self.set_frame(
            max(0, frame),
            displayed_time_seconds=seconds if show_requested_time else None,
            sync_audio=sync_audio,
        )

    @Slot(object)
    def show_live_frame(self, frame) -> None:
        """Display a freshly tracked frame and draw its boxes directly.

        Connected to the object tracking worker's per-frame signal; ``frame`` is
        a BoxMOT per-frame result with 1-based indexing.
        """
        self._goto(frame.frame_idx - 1)
        self._draw_boxes(boxes_from_tracks(frame.tracks))

    @Slot(object)
    def show_live_face_frame(self, result) -> None:
        """Display a freshly face-detected frame, with person boxes and faces.

        Connected to the face-detection worker's per-frame signal; ``result`` is
        a :class:`FaceFrameResult` with 0-based indexing. The person boxes come
        from the already-tracked video, the faces straight from the result.
        """
        self._goto(result.frame_idx)
        self._clear_overlays()
        if not self.show_overlays:
            return
        if self._video is not None:
            for box in self._video.boxes_for_frame(self._current):
                self._add_box(box)
        for face in result.faces:
            self._add_face(face)

    @Slot(object)
    def show_live_pose_frame(self, result) -> None:
        """Display a freshly pose-detected frame, with person boxes and poses.

        Connected to the body-pose worker's per-frame signal; ``result`` is a
        :class:`PoseFrameResult` with 0-based indexing. The person boxes come
        from the already-tracked video, the poses straight from the result.
        """
        self._goto(result.frame_idx)
        self._clear_overlays()
        if not self.show_overlays:
            return
        if self._video is not None:
            for box in self._video.boxes_for_frame(self._current):
                self._add_box(box)
        for pose in result.poses:
            self._add_pose(pose)

    def enable_controls(self, enable: bool) -> None:
        """Enable or disable the playback, mute and seek controls."""
        if not enable:
            self.stop()
        has_frames = self._frame_count > 0
        self._play_button.setEnabled(enable and has_frames)
        self._mute_button.setEnabled(enable and has_frames)
        self._slider.setEnabled(enable and has_frames)
        self._spinbox.setEnabled(enable and has_frames)

    def refresh_overlays(self) -> None:
        """Redraw the current frame's person boxes and any detected faces."""
        self._clear_overlays()
        if not self.show_overlays or self._video is None or self._current < 0:
            return
        for box in self._video.boxes_for_frame(self._current):
            self._add_box(box)
        for pose in self._video.poses_for_frame(self._current):
            self._add_pose(pose)
        for face in self._video.faces_for_frame(self._current):
            self._add_face(face)

    @property
    def video(self) -> Video | None:
        """The video being displayed, or ``None`` if there is none."""
        return self._video

    @property
    def current_frame(self) -> int:
        return self._current

    @property
    def frame_count(self) -> int:
        return self._frame_count

    # The playback position represented by the current frame.
    @property
    def current_time_seconds(self) -> float:
        if self._preroll_seconds is not None:
            return self._preroll_seconds
        if self._displayed_time_seconds is not None:
            return self._displayed_time_seconds
        if self._frame_count == 0 or self._fps <= 0.0:
            return 0.0
        return self._current / self._fps

    @property
    def current_media_time_seconds(self) -> float:
        """Timestamp of the displayed video frame in the source media."""
        if self._frame_count == 0 or self._fps <= 0.0 or self._current < 0:
            return 0.0
        return self._current / self._fps

    @property
    def playback_time_seconds(self) -> float:
        """Exact source-media time represented by the playback clock."""
        if self._timer.isActive() and self._preroll_seconds is None:
            return self._media_position_seconds()
        return self.current_time_seconds

    def _goto(self, index: int, *, sync_audio: bool = True) -> bool:
        """Show the video image at ``index`` and sync controls.

        Returns ``True`` if the displayed frame actually changed, so callers can
        decide whether overlays need redrawing.
        """
        if self._capture is None or self._frame_count == 0:
            return False
        index = max(0, min(int(index), self._frame_count - 1))
        if index == self._current:
            return False

        index, frame = self._read(index)
        if frame is None or index == self._current:
            # Nothing decoded, or _read stepped back to the frame already shown.
            return False
        self._preroll_seconds = None
        self._current = index
        self._show(frame)

        # Keep slider/spinbox in sync without re-triggering set_frame.
        for control in (self._slider, self._spinbox):
            control.blockSignals(True)
            control.setValue(index)
            control.blockSignals(False)
        self._time_label.setText(f"{self.current_time_seconds:.3f} s")
        if sync_audio:
            self._sync_audio_to_frame()

        self.frame_changed.emit(index)
        return True

    def _draw_boxes(self, boxes: list[BoundingBox]) -> None:
        self._clear_overlays()
        if not self.show_overlays:
            return
        for box in boxes:
            self._add_box(box)

    def _set_frame_count(self, count: int) -> None:
        """Set the frame count and update the slider/spinbox range and label."""
        self._frame_count = max(0, count)
        last = max(0, self._frame_count - 1)
        for control in (self._slider, self._spinbox):
            control.blockSignals(True)
            control.setMaximum(last)
            control.blockSignals(False)
        self._total_label.setText(f"/ {self._frame_count}")

    def _read(self, index: int):
        """Read the frame at ``index``, stepping back to the last decodable one.

        Small forward jumps decode and discard the intervening frames because
        that is substantially cheaper than seeking in compressed video. Larger
        jumps and all backward moves seek directly.

        ``CAP_PROP_FRAME_COUNT`` over-estimates for many codecs, so the trailing
        frames it promises may not actually decode. When a read fails we treat
        everything from ``index`` on as non-existent, shrink the frame count to
        match, and retry the frame before it. Returns
        ``(actual_index, frame)``, or ``(-1, None)`` if nothing decodes.
        """
        forward_frames = index - self._current
        if self._current >= 0 and 1 <= forward_frames <= _MAX_SEQUENTIAL_FORWARD_FRAMES:
            last_index = -1
            last_frame = None
            for candidate in range(self._current + 1, index + 1):
                ok, frame = self._capture.read()
                if not ok:
                    self._set_frame_count(candidate)
                    break
                last_index = candidate
                last_frame = frame
            return last_index, last_frame

        while index >= 0:
            if self._capture.get(cv2.CAP_PROP_POS_FRAMES) != index:
                self._capture.set(cv2.CAP_PROP_POS_FRAMES, index)
            ok, frame = self._capture.read()
            if ok:
                return index, frame
            self._set_frame_count(index)
            index -= 1
        return -1, None

    def _show(self, frame) -> None:
        height, width = frame.shape[:2]
        if width <= 0 or height <= 0:
            raise ValueError("Video frame has no size")
        self._video_aspect_ratio = width / height
        self.match_container_height_to_video_height()
        image = QImage(
            frame.data, width, height, frame.strides[0], QImage.Format.Format_BGR888
        )
        self._pixmap_item.setPixmap(QPixmap.fromImage(image))
        self._scene.setSceneRect(0, 0, width, height)

    # Show the waiting period before a positively-offset video starts.
    def _show_preroll_frame(self, seconds: float) -> None:
        self._preroll_seconds = seconds
        self._current = min(-1, int(seconds * self._fps))
        self._media_player.pause()
        pixmap = QPixmap(self._pixmap_item.pixmap().size())
        pixmap.fill(Qt.GlobalColor.black)
        self._clear_overlays()
        self._pixmap_item.setPixmap(pixmap)
        self._scene.setSceneRect(0, 0, pixmap.width(), pixmap.height())
        text = QGraphicsSimpleTextItem(f"{self.current_time_seconds:.3f} s")
        text.setFont(QFont(self.font().family(), 50))
        text.setBrush(QBrush(Qt.GlobalColor.white))
        text.setPos(
            (pixmap.width() - text.boundingRect().width()) / 2,
            (pixmap.height() - text.boundingRect().height()) / 2,
        )
        self._scene.addItem(text)
        self._overlay_items.append(text)
        self._time_label.setText(f"{self.current_time_seconds:.3f} s")
        for control in (self._slider, self._spinbox):
            control.blockSignals(True)
            control.setValue(0)
            control.blockSignals(False)
        self.frame_changed.emit(self._current)

    def _clear_overlays(self) -> None:
        for item in self._overlay_items:
            self._scene.removeItem(item)
        self._overlay_items.clear()

    def _add_rect(self, box: BoundingBox, style: Qt.PenStyle) -> None:
        """Draw ``box`` as a rectangle coloured by its id, in the given pen style."""
        rect = QGraphicsRectItem(box.x1, box.y1, box.x2 - box.x1, box.y2 - box.y1)
        pen = QPen(get_color(box.track_id))
        pen.setStyle(style)
        # constant on-screen pen width regardless of zoom
        pen.setCosmetic(True)
        pen.setWidth(2)
        rect.setPen(pen)
        self._scene.addItem(rect)
        self._overlay_items.append(rect)

    def _add_box(self, box: BoundingBox) -> None:
        self._add_rect(box, Qt.PenStyle.SolidLine)

        label = QGraphicsSimpleTextItem(str(box.track_id))
        label.setBrush(QBrush(get_color(box.track_id)))
        # constant on-screen label size regardless of zoom
        label.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIgnoresTransformations)
        label.setPos(box.x1, box.y1)
        self._scene.addItem(label)
        self._overlay_items.append(label)

    def _add_face(self, face: FaceBox) -> None:
        # dashed, so the face box reads as distinct from its person box
        self._add_rect(face.box, Qt.PenStyle.DashLine)

        color = get_color(face.box.track_id)
        for px, py in face.landmarks:
            # a small constant-size dot regardless of zoom, centred on the point
            dot = QGraphicsEllipseItem(-2.0, -2.0, 4.0, 4.0)
            dot.setBrush(QBrush(color))
            dot.setPen(QPen(Qt.PenStyle.NoPen))
            dot.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIgnoresTransformations)
            dot.setPos(px, py)
            self._scene.addItem(dot)
            self._overlay_items.append(dot)

    def _add_pose(self, pose: BodyPose) -> None:
        color = get_color(pose.box.track_id)
        pen = QPen(color)
        pen.setCosmetic(True)
        pen.setWidth(2)

        visible = [
            score > 0.0 and isfinite(px) and isfinite(py)
            for px, py, score in pose.keypoints
        ]
        for start, end in SKELETON:
            if start >= len(pose.keypoints) or end >= len(pose.keypoints):
                continue
            if not (visible[start] and visible[end]):
                continue
            x1, y1, _ = pose.keypoints[start]
            x2, y2, _ = pose.keypoints[end]
            line = QGraphicsLineItem(x1, y1, x2, y2)
            line.setPen(pen)
            self._scene.addItem(line)
            self._overlay_items.append(line)

        for px, py, score in pose.keypoints:
            if not (score > 0.0 and isfinite(px) and isfinite(py)):
                continue
            dot = QGraphicsEllipseItem(-2.0, -2.0, 4.0, 4.0)
            dot.setBrush(QBrush(color))
            dot.setPen(QPen(Qt.PenStyle.NoPen))
            dot.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIgnoresTransformations)
            dot.setPos(px, py)
            self._scene.addItem(dot)
            self._overlay_items.append(dot)

    def _advance(self) -> None:
        if self._preroll_seconds is not None:
            next_seconds = self._preroll_seconds + 1 / self._fps
            if next_seconds < 0.0:
                self._show_preroll_frame(next_seconds)
                return
            self.set_frame(0)
            if self._play_button.isChecked():
                self._start_media_playback()
            return
        target_frame = self._media_frame_index()
        if target_frame >= self._frame_count:
            if self._current < self._frame_count - 1:
                self.set_frame(self._frame_count - 1, sync_audio=False)
            self._play_button.setChecked(False)
            return
        if target_frame <= self._current:
            return
        self.set_frame(target_frame, sync_audio=False)

    def _media_frame_index(self) -> int:
        """Return the frame containing the media player's current position."""
        return max(0, int(self._media_position_seconds() * self._fps))

    def _media_position_seconds(self) -> float:
        return self._media_player.position() / 1000

    def _start_media_playback(self) -> None:
        self._timer.setInterval(_PLAYBACK_POLL_INTERVAL_MS)
        self._sync_audio_to_frame()
        self._media_player.play()

    def _on_play_toggled(self, playing: bool) -> None:
        self._play_button.setText("Pause" if playing else "Play")
        if playing and self._capture is not None:
            if self._current >= 0:
                self._start_media_playback()
            else:
                self._timer.setInterval(max(1, round(1000 / self._fps)))
            self._timer.start()
        else:
            self._timer.stop()
            self._media_player.pause()

    def _on_mute_toggled(self, muted: bool) -> None:
        self._audio_output.setMuted(muted)
        icon = (
            QStyle.StandardPixmap.SP_MediaVolumeMuted
            if muted
            else QStyle.StandardPixmap.SP_MediaVolume  # just looks empty, noticeably not activated vs the other one.
        )
        label = "Unmute audio" if muted else "Mute audio"
        self._mute_button.setIcon(self.style().standardIcon(icon))
        self._mute_button.setToolTip(label)

    def stop(self) -> None:
        self._timer.stop()
        self._media_player.pause()
        self._play_button.setChecked(False)

    # Seek to an exact requested time when present, otherwise to the frame time.
    def _sync_audio_to_frame(self) -> None:
        self._media_player.setPosition(round(self.current_time_seconds * 1000))
        # see experiments.md for notes about when this audio could be out of sync with the same files video.

    def fit_image_at_aspect_ratio(self) -> None:
        if not self._pixmap_item.pixmap().isNull():
            self._view.fitInView(self._pixmap_item, Qt.AspectRatioMode.KeepAspectRatio)

    def match_container_height_to_video_height(self) -> None:
        # Make sure the container height of the wideget matches the actual videos hegiht.
        if not self._height_matches_video or self._video_aspect_ratio is None:
            return
        border_width = self._view.frameWidth()
        view_width = self._view.width()
        if view_width <= 0:
            view_width = self.width()
        video_width = max(1, view_width - 2 * border_width)
        video_height = max(
            _MINIMUM_VIDEO_VIEW_HEIGHT, round(video_width / self._video_aspect_ratio)
        )
        view_height = video_height + 2 * border_width
        if self._view.height() != view_height:
            self._view.setFixedHeight(view_height)

    def resizeEvent(self, event) -> None:
        super().resizeEvent(event)
        self.match_container_height_to_video_height()
        self.fit_image_at_aspect_ratio()

    def showEvent(self, event) -> None:
        super().showEvent(event)
        self.match_container_height_to_video_height()
        self.fit_image_at_aspect_ratio()

current_media_time_seconds property

Timestamp of the displayed video frame in the source media.

playback_time_seconds property

Exact source-media time represented by the playback clock.

video property

The video being displayed, or None if there is none.

clear()

Show nothing at all: no video, no frame and no overlays.

Source code in src/body_eye_sync/gui/widgets/video_viewer.py
def clear(self) -> None:
    """Show nothing at all: no video, no frame and no overlays."""
    self.stop()
    if self._capture is not None:
        self._capture.release()
        self._capture = None
    self._video = None
    self._current = -1
    self._preroll_seconds = None
    self._displayed_time_seconds = None
    self._video_aspect_ratio = None
    self._media_player.stop()
    self._media_player.setSource(QUrl())
    self._clear_overlays()
    self._pixmap_item.setPixmap(QPixmap())
    self._scene.setSceneRect(0, 0, 0, 0)
    self._set_frame_count(0)
    self.enable_controls(False)

enable_controls(enable)

Enable or disable the playback, mute and seek controls.

Source code in src/body_eye_sync/gui/widgets/video_viewer.py
def enable_controls(self, enable: bool) -> None:
    """Enable or disable the playback, mute and seek controls."""
    if not enable:
        self.stop()
    has_frames = self._frame_count > 0
    self._play_button.setEnabled(enable and has_frames)
    self._mute_button.setEnabled(enable and has_frames)
    self._slider.setEnabled(enable and has_frames)
    self._spinbox.setEnabled(enable and has_frames)

load(video)

Display video, showing its first frame and its boxes (if any).

Source code in src/body_eye_sync/gui/widgets/video_viewer.py
def load(self, video: Video) -> None:
    """Display ``video``, showing its first frame and its boxes (if any)."""
    self.stop()
    if self._capture is not None:
        self._capture.release()

    capture = cv2.VideoCapture(str(video.video_path))
    if not capture.isOpened():
        raise OSError(f"Could not open video: {video.video_path}")

    self._video = video
    self._capture = capture
    self._media_player.setSource(QUrl.fromLocalFile(str(video.video_path)))
    self._fps = capture.get(cv2.CAP_PROP_FPS) or 25.0
    self._timer.setInterval(max(1, round(1000 / self._fps)))

    count = int(capture.get(cv2.CAP_PROP_FRAME_COUNT)) or 0
    for control in (self._slider, self._spinbox):
        control.setEnabled(count > 0)
        control.setMinimum(0)
    self._set_frame_count(count)

    self._current = -1
    self._preroll_seconds = None
    self.set_frame(0)
    self.fit_image_at_aspect_ratio()

refresh_overlays()

Redraw the current frame's person boxes and any detected faces.

Source code in src/body_eye_sync/gui/widgets/video_viewer.py
def refresh_overlays(self) -> None:
    """Redraw the current frame's person boxes and any detected faces."""
    self._clear_overlays()
    if not self.show_overlays or self._video is None or self._current < 0:
        return
    for box in self._video.boxes_for_frame(self._current):
        self._add_box(box)
    for pose in self._video.poses_for_frame(self._current):
        self._add_pose(pose)
    for face in self._video.faces_for_frame(self._current):
        self._add_face(face)

set_frame(index, *, displayed_time_seconds=None, sync_audio=True)

Display frame index and optionally seek embedded audio to it.

Source code in src/body_eye_sync/gui/widgets/video_viewer.py
def set_frame(
    self,
    index: int,
    *,
    displayed_time_seconds: float | None = None,
    sync_audio: bool = True,
) -> None:
    """Display frame ``index`` and optionally seek embedded audio to it."""
    previous_time_seconds = self.current_time_seconds
    self._displayed_time_seconds = displayed_time_seconds
    if self._goto(index, sync_audio=sync_audio):
        self.refresh_overlays()
        return
    current_time_seconds = self.current_time_seconds
    if (
        displayed_time_seconds is not None
        or current_time_seconds != previous_time_seconds
    ):
        self._time_label.setText(f"{current_time_seconds:.3f} s")
    if sync_audio and current_time_seconds != previous_time_seconds:
        self._sync_audio_to_frame()
    if (
        current_time_seconds != previous_time_seconds
        and self._capture is not None
        and self._frame_count > 0
    ):
        self.frame_changed.emit(self._current)

set_time_seconds(seconds, *, allow_negative=False, show_requested_time=False, sync_audio=True)

Display the frame selected by seconds in the source video.

Source code in src/body_eye_sync/gui/widgets/video_viewer.py
def set_time_seconds(
    self,
    seconds: float,
    *,
    allow_negative: bool = False,
    show_requested_time: bool = False,
    sync_audio: bool = True,
) -> None:
    """Display the frame selected by ``seconds`` in the source video."""
    if self._fps <= 0.0:
        self.set_frame(0, sync_audio=sync_audio)
        return
    if allow_negative and seconds < 0.0:
        self._show_preroll_frame(seconds)
        return
    frame = (
        int(seconds * self._fps)
        if show_requested_time
        else round(seconds * self._fps)
    )
    self.set_frame(
        max(0, frame),
        displayed_time_seconds=seconds if show_requested_time else None,
        sync_audio=sync_audio,
    )

show_live_face_frame(result)

Display a freshly face-detected frame, with person boxes and faces.

Connected to the face-detection worker's per-frame signal; result is a :class:FaceFrameResult with 0-based indexing. The person boxes come from the already-tracked video, the faces straight from the result.

Source code in src/body_eye_sync/gui/widgets/video_viewer.py
@Slot(object)
def show_live_face_frame(self, result) -> None:
    """Display a freshly face-detected frame, with person boxes and faces.

    Connected to the face-detection worker's per-frame signal; ``result`` is
    a :class:`FaceFrameResult` with 0-based indexing. The person boxes come
    from the already-tracked video, the faces straight from the result.
    """
    self._goto(result.frame_idx)
    self._clear_overlays()
    if not self.show_overlays:
        return
    if self._video is not None:
        for box in self._video.boxes_for_frame(self._current):
            self._add_box(box)
    for face in result.faces:
        self._add_face(face)

show_live_frame(frame)

Display a freshly tracked frame and draw its boxes directly.

Connected to the object tracking worker's per-frame signal; frame is a BoxMOT per-frame result with 1-based indexing.

Source code in src/body_eye_sync/gui/widgets/video_viewer.py
@Slot(object)
def show_live_frame(self, frame) -> None:
    """Display a freshly tracked frame and draw its boxes directly.

    Connected to the object tracking worker's per-frame signal; ``frame`` is
    a BoxMOT per-frame result with 1-based indexing.
    """
    self._goto(frame.frame_idx - 1)
    self._draw_boxes(boxes_from_tracks(frame.tracks))

show_live_pose_frame(result)

Display a freshly pose-detected frame, with person boxes and poses.

Connected to the body-pose worker's per-frame signal; result is a :class:PoseFrameResult with 0-based indexing. The person boxes come from the already-tracked video, the poses straight from the result.

Source code in src/body_eye_sync/gui/widgets/video_viewer.py
@Slot(object)
def show_live_pose_frame(self, result) -> None:
    """Display a freshly pose-detected frame, with person boxes and poses.

    Connected to the body-pose worker's per-frame signal; ``result`` is a
    :class:`PoseFrameResult` with 0-based indexing. The person boxes come
    from the already-tracked video, the poses straight from the result.
    """
    self._goto(result.frame_idx)
    self._clear_overlays()
    if not self.show_overlays:
        return
    if self._video is not None:
        for box in self._video.boxes_for_frame(self._current):
            self._add_box(box)
    for pose in result.poses:
        self._add_pose(pose)

body_eye_sync.gui.widgets.pipeline_editor

Editor for an experiment's pipeline: which steps run, and their arguments.

The pipeline structure is hard-coded here -- the known steps and their order, one list per kind of pipeline -- while each step's arguments are edited by an auto-generated :class:PydanticForm. Every pipeline has a mandatory base pass that the rest build on; those later passes are optional and toggled on/off.

This widget concerns itself only with the pipeline; managing the experiment's inputs (videos, gaze data, ...) is a separate interface.

PipelineEditor

Bases: QWidget

Edit one pipeline's steps and their arguments.

steps says which steps the editor shows, in run order -- :data:VIDEO_STEPS or :data:SPEECH_STEPS. Populate from a pipeline with :meth:set_from (or :meth:reset to defaults), and write the edited values back into one with :meth:apply_to. changed fires on any toggle or field edit. Each step has its own "Run" button (run_requested, with the step's type); run_all_requested fires from the button that runs every enabled step in order. Running is out of scope for this widget -- it only reports the requests, and its buttons' enabled state is driven from outside via :meth:set_run_enabled/:meth:set_run_all_enabled.

Source code in src/body_eye_sync/gui/widgets/pipeline_editor.py
class PipelineEditor(QWidget):
    """Edit one pipeline's steps and their arguments.

    ``steps`` says which steps the editor shows, in run order -- :data:`VIDEO_STEPS`
    or :data:`SPEECH_STEPS`. Populate from a pipeline with :meth:`set_from` (or
    :meth:`reset` to defaults), and write the edited values back into one with
    :meth:`apply_to`. ``changed`` fires on any toggle or field edit. Each step has
    its own "Run" button (``run_requested``, with the step's type);
    ``run_all_requested`` fires from the button that runs every enabled step in
    order. Running is out of scope for this widget -- it only reports the
    requests, and its buttons' enabled state is driven from outside via
    :meth:`set_run_enabled`/:meth:`set_run_all_enabled`.
    """

    changed = Signal()
    run_requested = Signal(object)
    run_all_requested = Signal()

    def __init__(
        self, steps: Sequence[StepEntry], parent: QWidget | None = None
    ) -> None:
        super().__init__(parent)
        self._sections: list[_StepSection] = []
        layout = QVBoxLayout(self)
        for attr_name, step_type, title, optional in steps:
            section = _StepSection(attr_name, step_type, title, optional)
            section.changed.connect(self.changed)
            section.run_requested.connect(
                lambda step_type=step_type: self.run_requested.emit(step_type)
            )
            self._sections.append(section)
            layout.addWidget(section)

        self.run_all_button = QPushButton("Run all")
        self.run_all_button.setEnabled(False)
        self.run_all_button.clicked.connect(self.run_all_requested)
        layout.addWidget(self.run_all_button)
        layout.addStretch(1)

    def set_from(self, pipeline: StepPipeline) -> None:
        """Populate the editor from ``pipeline``'s stages (no ``changed``)."""
        for section in self._sections:
            section.blockSignals(True)
            section.set_from(getattr(pipeline, section.attr_name))
            section.blockSignals(False)

    def reset(self) -> None:
        """Reset every step to its defaults, optional steps switched off."""
        for section in self._sections:
            section.blockSignals(True)
            section.reset()
            section.blockSignals(False)

    def apply_to(self, pipeline: StepPipeline) -> None:
        """Write the edited steps back onto ``pipeline``'s stage fields.

        Disabled optional steps become ``None``. All steps are validated before
        anything is assigned, so an invalid field leaves ``pipeline`` intact.
        Raises :class:`pydantic.ValidationError` / :class:`ValueError` if any
        step's arguments are invalid.
        """
        values = {
            s.attr_name: (s.to_step() if s.is_enabled() else None)
            for s in self._sections
        }
        for name, value in values.items():
            setattr(pipeline, name, value)

    def enabled_steps(self) -> list[StepSpec]:
        """The enabled steps, in order, built and validated from the widgets.

        Raises :class:`pydantic.ValidationError` / :class:`ValueError` if any
        enabled step's arguments are invalid.
        """
        return [s.to_step() for s in self._sections if s.is_enabled()]

    def _section(self, step_type: type) -> _StepSection:
        """The section editing ``step_type``, or ``KeyError`` if unknown."""
        for section in self._sections:
            if section.step_type is step_type:
                return section
        raise KeyError(step_type)

    def config_for(self, step_type: type) -> StepSpec:
        """The validated config for one step, whether or not it is enabled.

        Lets an interactive run of a single pass use the arguments the user has
        set, independent of whether the step is toggled into the saved pipeline.
        Raises :class:`pydantic.ValidationError` / :class:`ValueError` if invalid.
        """
        return self._section(step_type).to_step()

    def set_run_enabled(self, step_type: type, enabled: bool) -> None:
        """Enable/disable one step's "Run" button (e.g. while its inputs aren't ready)."""
        self._section(step_type).set_run_enabled(enabled)

    def set_run_all_enabled(self, enabled: bool) -> None:
        self.run_all_button.setEnabled(enabled)

apply_to(pipeline)

Write the edited steps back onto pipeline's stage fields.

Disabled optional steps become None. All steps are validated before anything is assigned, so an invalid field leaves pipeline intact. Raises :class:pydantic.ValidationError / :class:ValueError if any step's arguments are invalid.

Source code in src/body_eye_sync/gui/widgets/pipeline_editor.py
def apply_to(self, pipeline: StepPipeline) -> None:
    """Write the edited steps back onto ``pipeline``'s stage fields.

    Disabled optional steps become ``None``. All steps are validated before
    anything is assigned, so an invalid field leaves ``pipeline`` intact.
    Raises :class:`pydantic.ValidationError` / :class:`ValueError` if any
    step's arguments are invalid.
    """
    values = {
        s.attr_name: (s.to_step() if s.is_enabled() else None)
        for s in self._sections
    }
    for name, value in values.items():
        setattr(pipeline, name, value)

config_for(step_type)

The validated config for one step, whether or not it is enabled.

Lets an interactive run of a single pass use the arguments the user has set, independent of whether the step is toggled into the saved pipeline. Raises :class:pydantic.ValidationError / :class:ValueError if invalid.

Source code in src/body_eye_sync/gui/widgets/pipeline_editor.py
def config_for(self, step_type: type) -> StepSpec:
    """The validated config for one step, whether or not it is enabled.

    Lets an interactive run of a single pass use the arguments the user has
    set, independent of whether the step is toggled into the saved pipeline.
    Raises :class:`pydantic.ValidationError` / :class:`ValueError` if invalid.
    """
    return self._section(step_type).to_step()

enabled_steps()

The enabled steps, in order, built and validated from the widgets.

Raises :class:pydantic.ValidationError / :class:ValueError if any enabled step's arguments are invalid.

Source code in src/body_eye_sync/gui/widgets/pipeline_editor.py
def enabled_steps(self) -> list[StepSpec]:
    """The enabled steps, in order, built and validated from the widgets.

    Raises :class:`pydantic.ValidationError` / :class:`ValueError` if any
    enabled step's arguments are invalid.
    """
    return [s.to_step() for s in self._sections if s.is_enabled()]

reset()

Reset every step to its defaults, optional steps switched off.

Source code in src/body_eye_sync/gui/widgets/pipeline_editor.py
def reset(self) -> None:
    """Reset every step to its defaults, optional steps switched off."""
    for section in self._sections:
        section.blockSignals(True)
        section.reset()
        section.blockSignals(False)

set_from(pipeline)

Populate the editor from pipeline's stages (no changed).

Source code in src/body_eye_sync/gui/widgets/pipeline_editor.py
def set_from(self, pipeline: StepPipeline) -> None:
    """Populate the editor from ``pipeline``'s stages (no ``changed``)."""
    for section in self._sections:
        section.blockSignals(True)
        section.set_from(getattr(pipeline, section.attr_name))
        section.blockSignals(False)

set_run_enabled(step_type, enabled)

Enable/disable one step's "Run" button (e.g. while its inputs aren't ready).

Source code in src/body_eye_sync/gui/widgets/pipeline_editor.py
def set_run_enabled(self, step_type: type, enabled: bool) -> None:
    """Enable/disable one step's "Run" button (e.g. while its inputs aren't ready)."""
    self._section(step_type).set_run_enabled(enabled)

body_eye_sync.gui.widgets.video_viewer

A Qt widget that plays a video with frame-accurate seeking, and displays boxes

VideoViewer

Bases: QWidget

Display a video with play/pause, a seek slider and a frame spinbox.

Source code in src/body_eye_sync/gui/widgets/video_viewer.py
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
class VideoViewer(QWidget):
    """Display a video with play/pause, a seek slider and a frame spinbox."""

    frame_changed = Signal(int)

    def __init__(self, parent: QWidget | None = None) -> None:
        super().__init__(parent)

        self._capture: cv2.VideoCapture | None = None
        self._frame_count = 0
        self._fps = 25.0
        self._current = 0
        self._preroll_seconds: float | None = None
        self._displayed_time_seconds: float | None = None
        self._video_aspect_ratio: float | None = None
        self._height_matches_video = False
        self._audio_output = QAudioOutput(self)
        self._media_player = QMediaPlayer(self)
        self._media_player.setAudioOutput(self._audio_output)

        # the video being displayed; supplies the boxes to draw per frame
        self._video: Video | None = None
        self.show_overlays = True
        self._overlay_items: list[QGraphicsItem] = []

        # video display
        self._scene = QGraphicsScene(self)
        self._pixmap_item = QGraphicsPixmapItem()
        self._scene.addItem(self._pixmap_item)
        self._view = _VideoGraphicsView(self._scene)
        self._view.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
        self._view.setAlignment(Qt.AlignmentFlag.AlignCenter)

        # controls
        self._play_button = QPushButton("Play")
        self._play_button.setCheckable(True)
        self._play_button.toggled.connect(self._on_play_toggled)

        self._mute_button = QToolButton()
        self._mute_button.setCheckable(True)
        self._mute_button.setIcon(
            self.style().standardIcon(QStyle.StandardPixmap.SP_MediaVolume)
        )
        self._mute_button.setToolTip(
            "Mute audio"
        )  # gets updated later as state changes.
        self._mute_button.toggled.connect(self._on_mute_toggled)

        self._slider = QSlider(Qt.Orientation.Horizontal)
        self._slider.setEnabled(False)
        self._slider.valueChanged.connect(self.set_frame)

        self._spinbox = QSpinBox()
        self._spinbox.setEnabled(False)
        self._spinbox.valueChanged.connect(self.set_frame)

        self._time_label = QLabel("0.000 s")
        self._total_label = QLabel("/ 0")

        controls = QHBoxLayout()
        controls.addWidget(self._play_button)
        controls.addWidget(self._mute_button)
        controls.addWidget(self._slider, stretch=1)
        controls.addWidget(self._time_label)
        controls.addWidget(self._spinbox)
        controls.addWidget(self._total_label)

        layout = QVBoxLayout(self)
        layout.addWidget(self._view, stretch=1)
        layout.addLayout(controls)

        # playback timer
        self._timer = QTimer(self)
        self._timer.setTimerType(Qt.TimerType.PreciseTimer)
        self._timer.timeout.connect(self._advance)

    def match_video_height(self) -> None:
        self._height_matches_video = True
        self._view.allow_parent_scroll = True
        self._view.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed)
        self._view.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        self._view.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        self.match_container_height_to_video_height()

    def load(self, video: Video) -> None:
        """Display ``video``, showing its first frame and its boxes (if any)."""
        self.stop()
        if self._capture is not None:
            self._capture.release()

        capture = cv2.VideoCapture(str(video.video_path))
        if not capture.isOpened():
            raise OSError(f"Could not open video: {video.video_path}")

        self._video = video
        self._capture = capture
        self._media_player.setSource(QUrl.fromLocalFile(str(video.video_path)))
        self._fps = capture.get(cv2.CAP_PROP_FPS) or 25.0
        self._timer.setInterval(max(1, round(1000 / self._fps)))

        count = int(capture.get(cv2.CAP_PROP_FRAME_COUNT)) or 0
        for control in (self._slider, self._spinbox):
            control.setEnabled(count > 0)
            control.setMinimum(0)
        self._set_frame_count(count)

        self._current = -1
        self._preroll_seconds = None
        self.set_frame(0)
        self.fit_image_at_aspect_ratio()

    def clear(self) -> None:
        """Show nothing at all: no video, no frame and no overlays."""
        self.stop()
        if self._capture is not None:
            self._capture.release()
            self._capture = None
        self._video = None
        self._current = -1
        self._preroll_seconds = None
        self._displayed_time_seconds = None
        self._video_aspect_ratio = None
        self._media_player.stop()
        self._media_player.setSource(QUrl())
        self._clear_overlays()
        self._pixmap_item.setPixmap(QPixmap())
        self._scene.setSceneRect(0, 0, 0, 0)
        self._set_frame_count(0)
        self.enable_controls(False)

    def set_frame(
        self,
        index: int,
        *,
        displayed_time_seconds: float | None = None,
        sync_audio: bool = True,
    ) -> None:
        """Display frame ``index`` and optionally seek embedded audio to it."""
        previous_time_seconds = self.current_time_seconds
        self._displayed_time_seconds = displayed_time_seconds
        if self._goto(index, sync_audio=sync_audio):
            self.refresh_overlays()
            return
        current_time_seconds = self.current_time_seconds
        if (
            displayed_time_seconds is not None
            or current_time_seconds != previous_time_seconds
        ):
            self._time_label.setText(f"{current_time_seconds:.3f} s")
        if sync_audio and current_time_seconds != previous_time_seconds:
            self._sync_audio_to_frame()
        if (
            current_time_seconds != previous_time_seconds
            and self._capture is not None
            and self._frame_count > 0
        ):
            self.frame_changed.emit(self._current)

    # Display the frame closest to ``seconds`` in the video.
    def set_time_seconds(
        self,
        seconds: float,
        *,
        allow_negative: bool = False,
        show_requested_time: bool = False,
        sync_audio: bool = True,
    ) -> None:
        """Display the frame selected by ``seconds`` in the source video."""
        if self._fps <= 0.0:
            self.set_frame(0, sync_audio=sync_audio)
            return
        if allow_negative and seconds < 0.0:
            self._show_preroll_frame(seconds)
            return
        frame = (
            int(seconds * self._fps)
            if show_requested_time
            else round(seconds * self._fps)
        )
        self.set_frame(
            max(0, frame),
            displayed_time_seconds=seconds if show_requested_time else None,
            sync_audio=sync_audio,
        )

    @Slot(object)
    def show_live_frame(self, frame) -> None:
        """Display a freshly tracked frame and draw its boxes directly.

        Connected to the object tracking worker's per-frame signal; ``frame`` is
        a BoxMOT per-frame result with 1-based indexing.
        """
        self._goto(frame.frame_idx - 1)
        self._draw_boxes(boxes_from_tracks(frame.tracks))

    @Slot(object)
    def show_live_face_frame(self, result) -> None:
        """Display a freshly face-detected frame, with person boxes and faces.

        Connected to the face-detection worker's per-frame signal; ``result`` is
        a :class:`FaceFrameResult` with 0-based indexing. The person boxes come
        from the already-tracked video, the faces straight from the result.
        """
        self._goto(result.frame_idx)
        self._clear_overlays()
        if not self.show_overlays:
            return
        if self._video is not None:
            for box in self._video.boxes_for_frame(self._current):
                self._add_box(box)
        for face in result.faces:
            self._add_face(face)

    @Slot(object)
    def show_live_pose_frame(self, result) -> None:
        """Display a freshly pose-detected frame, with person boxes and poses.

        Connected to the body-pose worker's per-frame signal; ``result`` is a
        :class:`PoseFrameResult` with 0-based indexing. The person boxes come
        from the already-tracked video, the poses straight from the result.
        """
        self._goto(result.frame_idx)
        self._clear_overlays()
        if not self.show_overlays:
            return
        if self._video is not None:
            for box in self._video.boxes_for_frame(self._current):
                self._add_box(box)
        for pose in result.poses:
            self._add_pose(pose)

    def enable_controls(self, enable: bool) -> None:
        """Enable or disable the playback, mute and seek controls."""
        if not enable:
            self.stop()
        has_frames = self._frame_count > 0
        self._play_button.setEnabled(enable and has_frames)
        self._mute_button.setEnabled(enable and has_frames)
        self._slider.setEnabled(enable and has_frames)
        self._spinbox.setEnabled(enable and has_frames)

    def refresh_overlays(self) -> None:
        """Redraw the current frame's person boxes and any detected faces."""
        self._clear_overlays()
        if not self.show_overlays or self._video is None or self._current < 0:
            return
        for box in self._video.boxes_for_frame(self._current):
            self._add_box(box)
        for pose in self._video.poses_for_frame(self._current):
            self._add_pose(pose)
        for face in self._video.faces_for_frame(self._current):
            self._add_face(face)

    @property
    def video(self) -> Video | None:
        """The video being displayed, or ``None`` if there is none."""
        return self._video

    @property
    def current_frame(self) -> int:
        return self._current

    @property
    def frame_count(self) -> int:
        return self._frame_count

    # The playback position represented by the current frame.
    @property
    def current_time_seconds(self) -> float:
        if self._preroll_seconds is not None:
            return self._preroll_seconds
        if self._displayed_time_seconds is not None:
            return self._displayed_time_seconds
        if self._frame_count == 0 or self._fps <= 0.0:
            return 0.0
        return self._current / self._fps

    @property
    def current_media_time_seconds(self) -> float:
        """Timestamp of the displayed video frame in the source media."""
        if self._frame_count == 0 or self._fps <= 0.0 or self._current < 0:
            return 0.0
        return self._current / self._fps

    @property
    def playback_time_seconds(self) -> float:
        """Exact source-media time represented by the playback clock."""
        if self._timer.isActive() and self._preroll_seconds is None:
            return self._media_position_seconds()
        return self.current_time_seconds

    def _goto(self, index: int, *, sync_audio: bool = True) -> bool:
        """Show the video image at ``index`` and sync controls.

        Returns ``True`` if the displayed frame actually changed, so callers can
        decide whether overlays need redrawing.
        """
        if self._capture is None or self._frame_count == 0:
            return False
        index = max(0, min(int(index), self._frame_count - 1))
        if index == self._current:
            return False

        index, frame = self._read(index)
        if frame is None or index == self._current:
            # Nothing decoded, or _read stepped back to the frame already shown.
            return False
        self._preroll_seconds = None
        self._current = index
        self._show(frame)

        # Keep slider/spinbox in sync without re-triggering set_frame.
        for control in (self._slider, self._spinbox):
            control.blockSignals(True)
            control.setValue(index)
            control.blockSignals(False)
        self._time_label.setText(f"{self.current_time_seconds:.3f} s")
        if sync_audio:
            self._sync_audio_to_frame()

        self.frame_changed.emit(index)
        return True

    def _draw_boxes(self, boxes: list[BoundingBox]) -> None:
        self._clear_overlays()
        if not self.show_overlays:
            return
        for box in boxes:
            self._add_box(box)

    def _set_frame_count(self, count: int) -> None:
        """Set the frame count and update the slider/spinbox range and label."""
        self._frame_count = max(0, count)
        last = max(0, self._frame_count - 1)
        for control in (self._slider, self._spinbox):
            control.blockSignals(True)
            control.setMaximum(last)
            control.blockSignals(False)
        self._total_label.setText(f"/ {self._frame_count}")

    def _read(self, index: int):
        """Read the frame at ``index``, stepping back to the last decodable one.

        Small forward jumps decode and discard the intervening frames because
        that is substantially cheaper than seeking in compressed video. Larger
        jumps and all backward moves seek directly.

        ``CAP_PROP_FRAME_COUNT`` over-estimates for many codecs, so the trailing
        frames it promises may not actually decode. When a read fails we treat
        everything from ``index`` on as non-existent, shrink the frame count to
        match, and retry the frame before it. Returns
        ``(actual_index, frame)``, or ``(-1, None)`` if nothing decodes.
        """
        forward_frames = index - self._current
        if self._current >= 0 and 1 <= forward_frames <= _MAX_SEQUENTIAL_FORWARD_FRAMES:
            last_index = -1
            last_frame = None
            for candidate in range(self._current + 1, index + 1):
                ok, frame = self._capture.read()
                if not ok:
                    self._set_frame_count(candidate)
                    break
                last_index = candidate
                last_frame = frame
            return last_index, last_frame

        while index >= 0:
            if self._capture.get(cv2.CAP_PROP_POS_FRAMES) != index:
                self._capture.set(cv2.CAP_PROP_POS_FRAMES, index)
            ok, frame = self._capture.read()
            if ok:
                return index, frame
            self._set_frame_count(index)
            index -= 1
        return -1, None

    def _show(self, frame) -> None:
        height, width = frame.shape[:2]
        if width <= 0 or height <= 0:
            raise ValueError("Video frame has no size")
        self._video_aspect_ratio = width / height
        self.match_container_height_to_video_height()
        image = QImage(
            frame.data, width, height, frame.strides[0], QImage.Format.Format_BGR888
        )
        self._pixmap_item.setPixmap(QPixmap.fromImage(image))
        self._scene.setSceneRect(0, 0, width, height)

    # Show the waiting period before a positively-offset video starts.
    def _show_preroll_frame(self, seconds: float) -> None:
        self._preroll_seconds = seconds
        self._current = min(-1, int(seconds * self._fps))
        self._media_player.pause()
        pixmap = QPixmap(self._pixmap_item.pixmap().size())
        pixmap.fill(Qt.GlobalColor.black)
        self._clear_overlays()
        self._pixmap_item.setPixmap(pixmap)
        self._scene.setSceneRect(0, 0, pixmap.width(), pixmap.height())
        text = QGraphicsSimpleTextItem(f"{self.current_time_seconds:.3f} s")
        text.setFont(QFont(self.font().family(), 50))
        text.setBrush(QBrush(Qt.GlobalColor.white))
        text.setPos(
            (pixmap.width() - text.boundingRect().width()) / 2,
            (pixmap.height() - text.boundingRect().height()) / 2,
        )
        self._scene.addItem(text)
        self._overlay_items.append(text)
        self._time_label.setText(f"{self.current_time_seconds:.3f} s")
        for control in (self._slider, self._spinbox):
            control.blockSignals(True)
            control.setValue(0)
            control.blockSignals(False)
        self.frame_changed.emit(self._current)

    def _clear_overlays(self) -> None:
        for item in self._overlay_items:
            self._scene.removeItem(item)
        self._overlay_items.clear()

    def _add_rect(self, box: BoundingBox, style: Qt.PenStyle) -> None:
        """Draw ``box`` as a rectangle coloured by its id, in the given pen style."""
        rect = QGraphicsRectItem(box.x1, box.y1, box.x2 - box.x1, box.y2 - box.y1)
        pen = QPen(get_color(box.track_id))
        pen.setStyle(style)
        # constant on-screen pen width regardless of zoom
        pen.setCosmetic(True)
        pen.setWidth(2)
        rect.setPen(pen)
        self._scene.addItem(rect)
        self._overlay_items.append(rect)

    def _add_box(self, box: BoundingBox) -> None:
        self._add_rect(box, Qt.PenStyle.SolidLine)

        label = QGraphicsSimpleTextItem(str(box.track_id))
        label.setBrush(QBrush(get_color(box.track_id)))
        # constant on-screen label size regardless of zoom
        label.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIgnoresTransformations)
        label.setPos(box.x1, box.y1)
        self._scene.addItem(label)
        self._overlay_items.append(label)

    def _add_face(self, face: FaceBox) -> None:
        # dashed, so the face box reads as distinct from its person box
        self._add_rect(face.box, Qt.PenStyle.DashLine)

        color = get_color(face.box.track_id)
        for px, py in face.landmarks:
            # a small constant-size dot regardless of zoom, centred on the point
            dot = QGraphicsEllipseItem(-2.0, -2.0, 4.0, 4.0)
            dot.setBrush(QBrush(color))
            dot.setPen(QPen(Qt.PenStyle.NoPen))
            dot.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIgnoresTransformations)
            dot.setPos(px, py)
            self._scene.addItem(dot)
            self._overlay_items.append(dot)

    def _add_pose(self, pose: BodyPose) -> None:
        color = get_color(pose.box.track_id)
        pen = QPen(color)
        pen.setCosmetic(True)
        pen.setWidth(2)

        visible = [
            score > 0.0 and isfinite(px) and isfinite(py)
            for px, py, score in pose.keypoints
        ]
        for start, end in SKELETON:
            if start >= len(pose.keypoints) or end >= len(pose.keypoints):
                continue
            if not (visible[start] and visible[end]):
                continue
            x1, y1, _ = pose.keypoints[start]
            x2, y2, _ = pose.keypoints[end]
            line = QGraphicsLineItem(x1, y1, x2, y2)
            line.setPen(pen)
            self._scene.addItem(line)
            self._overlay_items.append(line)

        for px, py, score in pose.keypoints:
            if not (score > 0.0 and isfinite(px) and isfinite(py)):
                continue
            dot = QGraphicsEllipseItem(-2.0, -2.0, 4.0, 4.0)
            dot.setBrush(QBrush(color))
            dot.setPen(QPen(Qt.PenStyle.NoPen))
            dot.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIgnoresTransformations)
            dot.setPos(px, py)
            self._scene.addItem(dot)
            self._overlay_items.append(dot)

    def _advance(self) -> None:
        if self._preroll_seconds is not None:
            next_seconds = self._preroll_seconds + 1 / self._fps
            if next_seconds < 0.0:
                self._show_preroll_frame(next_seconds)
                return
            self.set_frame(0)
            if self._play_button.isChecked():
                self._start_media_playback()
            return
        target_frame = self._media_frame_index()
        if target_frame >= self._frame_count:
            if self._current < self._frame_count - 1:
                self.set_frame(self._frame_count - 1, sync_audio=False)
            self._play_button.setChecked(False)
            return
        if target_frame <= self._current:
            return
        self.set_frame(target_frame, sync_audio=False)

    def _media_frame_index(self) -> int:
        """Return the frame containing the media player's current position."""
        return max(0, int(self._media_position_seconds() * self._fps))

    def _media_position_seconds(self) -> float:
        return self._media_player.position() / 1000

    def _start_media_playback(self) -> None:
        self._timer.setInterval(_PLAYBACK_POLL_INTERVAL_MS)
        self._sync_audio_to_frame()
        self._media_player.play()

    def _on_play_toggled(self, playing: bool) -> None:
        self._play_button.setText("Pause" if playing else "Play")
        if playing and self._capture is not None:
            if self._current >= 0:
                self._start_media_playback()
            else:
                self._timer.setInterval(max(1, round(1000 / self._fps)))
            self._timer.start()
        else:
            self._timer.stop()
            self._media_player.pause()

    def _on_mute_toggled(self, muted: bool) -> None:
        self._audio_output.setMuted(muted)
        icon = (
            QStyle.StandardPixmap.SP_MediaVolumeMuted
            if muted
            else QStyle.StandardPixmap.SP_MediaVolume  # just looks empty, noticeably not activated vs the other one.
        )
        label = "Unmute audio" if muted else "Mute audio"
        self._mute_button.setIcon(self.style().standardIcon(icon))
        self._mute_button.setToolTip(label)

    def stop(self) -> None:
        self._timer.stop()
        self._media_player.pause()
        self._play_button.setChecked(False)

    # Seek to an exact requested time when present, otherwise to the frame time.
    def _sync_audio_to_frame(self) -> None:
        self._media_player.setPosition(round(self.current_time_seconds * 1000))
        # see experiments.md for notes about when this audio could be out of sync with the same files video.

    def fit_image_at_aspect_ratio(self) -> None:
        if not self._pixmap_item.pixmap().isNull():
            self._view.fitInView(self._pixmap_item, Qt.AspectRatioMode.KeepAspectRatio)

    def match_container_height_to_video_height(self) -> None:
        # Make sure the container height of the wideget matches the actual videos hegiht.
        if not self._height_matches_video or self._video_aspect_ratio is None:
            return
        border_width = self._view.frameWidth()
        view_width = self._view.width()
        if view_width <= 0:
            view_width = self.width()
        video_width = max(1, view_width - 2 * border_width)
        video_height = max(
            _MINIMUM_VIDEO_VIEW_HEIGHT, round(video_width / self._video_aspect_ratio)
        )
        view_height = video_height + 2 * border_width
        if self._view.height() != view_height:
            self._view.setFixedHeight(view_height)

    def resizeEvent(self, event) -> None:
        super().resizeEvent(event)
        self.match_container_height_to_video_height()
        self.fit_image_at_aspect_ratio()

    def showEvent(self, event) -> None:
        super().showEvent(event)
        self.match_container_height_to_video_height()
        self.fit_image_at_aspect_ratio()

current_media_time_seconds property

Timestamp of the displayed video frame in the source media.

playback_time_seconds property

Exact source-media time represented by the playback clock.

video property

The video being displayed, or None if there is none.

clear()

Show nothing at all: no video, no frame and no overlays.

Source code in src/body_eye_sync/gui/widgets/video_viewer.py
def clear(self) -> None:
    """Show nothing at all: no video, no frame and no overlays."""
    self.stop()
    if self._capture is not None:
        self._capture.release()
        self._capture = None
    self._video = None
    self._current = -1
    self._preroll_seconds = None
    self._displayed_time_seconds = None
    self._video_aspect_ratio = None
    self._media_player.stop()
    self._media_player.setSource(QUrl())
    self._clear_overlays()
    self._pixmap_item.setPixmap(QPixmap())
    self._scene.setSceneRect(0, 0, 0, 0)
    self._set_frame_count(0)
    self.enable_controls(False)

enable_controls(enable)

Enable or disable the playback, mute and seek controls.

Source code in src/body_eye_sync/gui/widgets/video_viewer.py
def enable_controls(self, enable: bool) -> None:
    """Enable or disable the playback, mute and seek controls."""
    if not enable:
        self.stop()
    has_frames = self._frame_count > 0
    self._play_button.setEnabled(enable and has_frames)
    self._mute_button.setEnabled(enable and has_frames)
    self._slider.setEnabled(enable and has_frames)
    self._spinbox.setEnabled(enable and has_frames)

load(video)

Display video, showing its first frame and its boxes (if any).

Source code in src/body_eye_sync/gui/widgets/video_viewer.py
def load(self, video: Video) -> None:
    """Display ``video``, showing its first frame and its boxes (if any)."""
    self.stop()
    if self._capture is not None:
        self._capture.release()

    capture = cv2.VideoCapture(str(video.video_path))
    if not capture.isOpened():
        raise OSError(f"Could not open video: {video.video_path}")

    self._video = video
    self._capture = capture
    self._media_player.setSource(QUrl.fromLocalFile(str(video.video_path)))
    self._fps = capture.get(cv2.CAP_PROP_FPS) or 25.0
    self._timer.setInterval(max(1, round(1000 / self._fps)))

    count = int(capture.get(cv2.CAP_PROP_FRAME_COUNT)) or 0
    for control in (self._slider, self._spinbox):
        control.setEnabled(count > 0)
        control.setMinimum(0)
    self._set_frame_count(count)

    self._current = -1
    self._preroll_seconds = None
    self.set_frame(0)
    self.fit_image_at_aspect_ratio()

refresh_overlays()

Redraw the current frame's person boxes and any detected faces.

Source code in src/body_eye_sync/gui/widgets/video_viewer.py
def refresh_overlays(self) -> None:
    """Redraw the current frame's person boxes and any detected faces."""
    self._clear_overlays()
    if not self.show_overlays or self._video is None or self._current < 0:
        return
    for box in self._video.boxes_for_frame(self._current):
        self._add_box(box)
    for pose in self._video.poses_for_frame(self._current):
        self._add_pose(pose)
    for face in self._video.faces_for_frame(self._current):
        self._add_face(face)

set_frame(index, *, displayed_time_seconds=None, sync_audio=True)

Display frame index and optionally seek embedded audio to it.

Source code in src/body_eye_sync/gui/widgets/video_viewer.py
def set_frame(
    self,
    index: int,
    *,
    displayed_time_seconds: float | None = None,
    sync_audio: bool = True,
) -> None:
    """Display frame ``index`` and optionally seek embedded audio to it."""
    previous_time_seconds = self.current_time_seconds
    self._displayed_time_seconds = displayed_time_seconds
    if self._goto(index, sync_audio=sync_audio):
        self.refresh_overlays()
        return
    current_time_seconds = self.current_time_seconds
    if (
        displayed_time_seconds is not None
        or current_time_seconds != previous_time_seconds
    ):
        self._time_label.setText(f"{current_time_seconds:.3f} s")
    if sync_audio and current_time_seconds != previous_time_seconds:
        self._sync_audio_to_frame()
    if (
        current_time_seconds != previous_time_seconds
        and self._capture is not None
        and self._frame_count > 0
    ):
        self.frame_changed.emit(self._current)

set_time_seconds(seconds, *, allow_negative=False, show_requested_time=False, sync_audio=True)

Display the frame selected by seconds in the source video.

Source code in src/body_eye_sync/gui/widgets/video_viewer.py
def set_time_seconds(
    self,
    seconds: float,
    *,
    allow_negative: bool = False,
    show_requested_time: bool = False,
    sync_audio: bool = True,
) -> None:
    """Display the frame selected by ``seconds`` in the source video."""
    if self._fps <= 0.0:
        self.set_frame(0, sync_audio=sync_audio)
        return
    if allow_negative and seconds < 0.0:
        self._show_preroll_frame(seconds)
        return
    frame = (
        int(seconds * self._fps)
        if show_requested_time
        else round(seconds * self._fps)
    )
    self.set_frame(
        max(0, frame),
        displayed_time_seconds=seconds if show_requested_time else None,
        sync_audio=sync_audio,
    )

show_live_face_frame(result)

Display a freshly face-detected frame, with person boxes and faces.

Connected to the face-detection worker's per-frame signal; result is a :class:FaceFrameResult with 0-based indexing. The person boxes come from the already-tracked video, the faces straight from the result.

Source code in src/body_eye_sync/gui/widgets/video_viewer.py
@Slot(object)
def show_live_face_frame(self, result) -> None:
    """Display a freshly face-detected frame, with person boxes and faces.

    Connected to the face-detection worker's per-frame signal; ``result`` is
    a :class:`FaceFrameResult` with 0-based indexing. The person boxes come
    from the already-tracked video, the faces straight from the result.
    """
    self._goto(result.frame_idx)
    self._clear_overlays()
    if not self.show_overlays:
        return
    if self._video is not None:
        for box in self._video.boxes_for_frame(self._current):
            self._add_box(box)
    for face in result.faces:
        self._add_face(face)

show_live_frame(frame)

Display a freshly tracked frame and draw its boxes directly.

Connected to the object tracking worker's per-frame signal; frame is a BoxMOT per-frame result with 1-based indexing.

Source code in src/body_eye_sync/gui/widgets/video_viewer.py
@Slot(object)
def show_live_frame(self, frame) -> None:
    """Display a freshly tracked frame and draw its boxes directly.

    Connected to the object tracking worker's per-frame signal; ``frame`` is
    a BoxMOT per-frame result with 1-based indexing.
    """
    self._goto(frame.frame_idx - 1)
    self._draw_boxes(boxes_from_tracks(frame.tracks))

show_live_pose_frame(result)

Display a freshly pose-detected frame, with person boxes and poses.

Connected to the body-pose worker's per-frame signal; result is a :class:PoseFrameResult with 0-based indexing. The person boxes come from the already-tracked video, the poses straight from the result.

Source code in src/body_eye_sync/gui/widgets/video_viewer.py
@Slot(object)
def show_live_pose_frame(self, result) -> None:
    """Display a freshly pose-detected frame, with person boxes and poses.

    Connected to the body-pose worker's per-frame signal; ``result`` is a
    :class:`PoseFrameResult` with 0-based indexing. The person boxes come
    from the already-tracked video, the poses straight from the result.
    """
    self._goto(result.frame_idx)
    self._clear_overlays()
    if not self.show_overlays:
        return
    if self._video is not None:
        for box in self._video.boxes_for_frame(self._current):
            self._add_box(box)
    for pose in result.poses:
        self._add_pose(pose)

body_eye_sync.gui.widgets.pydantic_form

Auto-generate an editing form for a flat pydantic model.

:class:PydanticForm builds one input widget per model field from the field's type, constraints and metadata, so the pydantic schema stays the single source of truth for both serialisation and the GUI. It supports the scalar field types the pipeline step models use (str, int, float, bool, a list of scalars) plus choices metadata; it is not a general recursive form and does not descend into nested models.

Widget mapping:

  • choices in json_schema_extra -> editable :class:QComboBox
  • bool -> :class:QCheckBox
  • int (with ge/le bounds) -> :class:QSpinBox
  • float (with ge/le bounds)-> :class:QDoubleSpinBox
  • list[...] -> :class:QLineEdit (comma separated)
  • anything else / str -> :class:QLineEdit

Literal fields (the discriminator tags) are fixed and not shown. A field whose type allows None is shown as an empty line edit and reads back as None, so an optional setting can be left unset.

PydanticForm

Bases: QWidget

An editing form for one flat pydantic model instance.

Populate from a model with :meth:from_model, read the edited values back (validated) with :meth:to_model. changed fires on any edit.

Source code in src/body_eye_sync/gui/widgets/pydantic_form.py
class PydanticForm(QWidget):
    """An editing form for one flat pydantic model instance.

    Populate from a model with :meth:`from_model`, read the edited values back
    (validated) with :meth:`to_model`. ``changed`` fires on any edit.
    """

    changed = Signal()

    def __init__(
        self,
        model: BaseModel,
        parent: QWidget | None = None,
        fields: Iterable[str] | None = None,
    ) -> None:
        super().__init__(parent)
        self._model_type = type(model)
        self._widgets: dict[str, QWidget] = {}
        self._field_info: dict[str, FieldInfo] = {}
        selected = set(fields) if fields is not None else None
        unknown = (
            set()
            if selected is None
            else selected - self._model_type.model_fields.keys()
        )
        if unknown:
            raise ValueError(f"Unknown form field(s): {', '.join(sorted(unknown))}")

        layout = QFormLayout(self)
        for name, field in self._model_type.model_fields.items():
            if selected is not None and name not in selected:
                continue
            if get_origin(field.annotation) is Literal:
                continue  # discriminator tag: fixed, not user-editable
            widget = self._make_widget(field)
            self._widgets[name] = widget
            self._field_info[name] = field
            label = name.replace("_", " ").capitalize()
            if field.description:
                widget.setToolTip(field.description)
            layout.addRow(label, widget)

        self.from_model(model)

    def _make_widget(self, field: FieldInfo) -> QWidget:
        choices = _choices(field)
        if choices:
            combo = QComboBox()
            combo.setEditable(True)
            combo.addItems([str(c) for c in choices])
            combo.currentTextChanged.connect(self.changed)
            return combo

        annotation = field.annotation
        if annotation is bool:
            check = QCheckBox()
            check.toggled.connect(self.changed)
            return check
        if annotation is int:
            spin = QSpinBox()
            low, high = _bounds(field)
            spin.setMinimum(int(low) if low is not None else -_INT_LIMIT)
            spin.setMaximum(int(high) if high is not None else _INT_LIMIT)
            spin.valueChanged.connect(self.changed)
            return spin
        if annotation is float:
            spin = QDoubleSpinBox()
            spin.setDecimals(3)
            spin.setSingleStep(0.01)
            low, high = _bounds(field)
            spin.setMinimum(float(low) if low is not None else -_FLOAT_LIMIT)
            spin.setMaximum(float(high) if high is not None else _FLOAT_LIMIT)
            spin.valueChanged.connect(self.changed)
            return spin

        line = QLineEdit()
        line.textChanged.connect(self.changed)
        return line

    def from_model(self, model: BaseModel) -> None:
        """Populate the widgets from ``model``'s current values."""
        for name, widget in self._widgets.items():
            value = getattr(model, name)
            if isinstance(widget, QComboBox):
                widget.setCurrentText(str(value))
            elif isinstance(widget, QCheckBox):
                widget.setChecked(bool(value))
            elif isinstance(widget, QSpinBox):
                widget.setValue(int(value))
            elif isinstance(widget, QDoubleSpinBox):
                widget.setValue(float(value))
            elif isinstance(widget, QLineEdit):
                if isinstance(value, (list, tuple)):
                    widget.setText(", ".join(str(v) for v in value))
                elif value is None:
                    # An unset optional value shows as an empty box, not "None".
                    widget.setText("")
                else:
                    widget.setText(str(value))

    def to_model(self, base: BaseModel | None = None) -> BaseModel:
        """Build a validated model from the current widget values.

        When this form displays only selected fields, ``base`` preserves the
        other values instead of resetting them to their defaults.

        Raises :class:`pydantic.ValidationError` (or :class:`ValueError` from
        list parsing) if the edited values are invalid.
        """
        if base is not None and not isinstance(base, self._model_type):
            raise TypeError(f"Expected {self._model_type.__name__} as the base model")
        values = {} if base is None else base.model_dump()
        values.update(self._values())
        return self._model_type(**values)

    def _values(self) -> dict[str, Any]:
        values: dict[str, Any] = {}
        for name, widget in self._widgets.items():
            field = self._field_info[name]
            if isinstance(widget, QComboBox):
                values[name] = widget.currentText()
            elif isinstance(widget, QCheckBox):
                values[name] = widget.isChecked()
            elif isinstance(widget, (QSpinBox, QDoubleSpinBox)):
                values[name] = widget.value()
            elif isinstance(widget, QLineEdit):
                text = widget.text()
                if get_origin(field.annotation) is list:
                    values[name] = _parse_list(text, field)
                elif _optional(field) and not text:
                    values[name] = None
                else:
                    values[name] = text
        return values

from_model(model)

Populate the widgets from model's current values.

Source code in src/body_eye_sync/gui/widgets/pydantic_form.py
def from_model(self, model: BaseModel) -> None:
    """Populate the widgets from ``model``'s current values."""
    for name, widget in self._widgets.items():
        value = getattr(model, name)
        if isinstance(widget, QComboBox):
            widget.setCurrentText(str(value))
        elif isinstance(widget, QCheckBox):
            widget.setChecked(bool(value))
        elif isinstance(widget, QSpinBox):
            widget.setValue(int(value))
        elif isinstance(widget, QDoubleSpinBox):
            widget.setValue(float(value))
        elif isinstance(widget, QLineEdit):
            if isinstance(value, (list, tuple)):
                widget.setText(", ".join(str(v) for v in value))
            elif value is None:
                # An unset optional value shows as an empty box, not "None".
                widget.setText("")
            else:
                widget.setText(str(value))

to_model(base=None)

Build a validated model from the current widget values.

When this form displays only selected fields, base preserves the other values instead of resetting them to their defaults.

Raises :class:pydantic.ValidationError (or :class:ValueError from list parsing) if the edited values are invalid.

Source code in src/body_eye_sync/gui/widgets/pydantic_form.py
def to_model(self, base: BaseModel | None = None) -> BaseModel:
    """Build a validated model from the current widget values.

    When this form displays only selected fields, ``base`` preserves the
    other values instead of resetting them to their defaults.

    Raises :class:`pydantic.ValidationError` (or :class:`ValueError` from
    list parsing) if the edited values are invalid.
    """
    if base is not None and not isinstance(base, self._model_type):
        raise TypeError(f"Expected {self._model_type.__name__} as the base model")
    values = {} if base is None else base.model_dump()
    values.update(self._values())
    return self._model_type(**values)

body_eye_sync.gui.widgets.auto_height_table

A table that grows with its rows instead of scrolling inside itself.

AutoHeightTable

Bases: QTableWidget

A table sized to exactly fit its header and rows.

Source code in src/body_eye_sync/gui/widgets/auto_height_table.py
class AutoHeightTable(QTableWidget):
    """A table sized to exactly fit its header and rows."""

    def __init__(self, headers: Sequence[str]) -> None:
        super().__init__(0, len(headers))
        self.setHorizontalHeaderLabels(list(headers))
        self.verticalHeader().setVisible(False)
        self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)

    def fit_to_rows(self) -> None:
        """Make the table exactly as tall as its header and rows."""
        height = self.horizontalHeader().height() + 2 * self.frameWidth()
        for row in range(self.rowCount()):
            height += self.rowHeight(row)
        self.setFixedHeight(height)

fit_to_rows()

Make the table exactly as tall as its header and rows.

Source code in src/body_eye_sync/gui/widgets/auto_height_table.py
def fit_to_rows(self) -> None:
    """Make the table exactly as tall as its header and rows."""
    height = self.horizontalHeader().height() + 2 * self.frameWidth()
    for row in range(self.rowCount()):
        height += self.rowHeight(row)
    self.setFixedHeight(height)

Workers

body_eye_sync.gui.workers

The background workers that run a pipeline step off the GUI thread.

One worker per step, each a :class:~body_eye_sync.gui.workers.base.BaseWorker subclass that reports its progress with signals and writes its results into the :class:~body_eye_sync.experiment.video.Video or :class:~body_eye_sync.experiment.speech.Speech it was given.

BaseWorker

Bases: QObject

Runs one pipeline step off the GUI thread, into the results it computes.

target is what the step writes into: a :class:~body_eye_sync.experiment.video.Video for the video stages, a :class:~body_eye_sync.experiment.speech.Speech for the speech ones. Subclasses supply the per-run work: :meth:_items yields each computed frame/result, :meth:_accumulate stores one into the target, :meth:_finalise folds the accumulated results once the run completes, and :meth:_discard rolls the target back if the run is cancelled or fails. Each item is emitted via new_frame so the GUI can show it live, and a step that can say how far through it is reports that as a fraction via progress; finished (after :meth:_finalise) or cancelled (after :meth:_discard) fires once the run ends, and any exception is reported via failed with a traceback (also after :meth:_discard). operation_name labels the run for the GUI.

Source code in src/body_eye_sync/gui/workers/base.py
class BaseWorker(QObject):
    """Runs one pipeline step off the GUI thread, into the results it computes.

    ``target`` is what the step writes into: a
    :class:`~body_eye_sync.experiment.video.Video` for the video stages, a
    :class:`~body_eye_sync.experiment.speech.Speech` for the speech ones.
    Subclasses supply the per-run work: :meth:`_items` yields each computed
    frame/result, :meth:`_accumulate` stores one into the target, :meth:`_finalise`
    folds the accumulated results once the run completes, and :meth:`_discard`
    rolls the target back if the run is cancelled or fails. Each item is emitted
    via ``new_frame`` so the GUI can show it live, and a step that can say how far
    through it is reports that as a fraction via ``progress``; ``finished`` (after
    :meth:`_finalise`) or ``cancelled`` (after :meth:`_discard`) fires once the
    run ends, and any exception is reported via ``failed`` with a traceback (also
    after :meth:`_discard`). ``operation_name`` labels the run for the GUI.
    """

    #: Human-readable name of the operation, for the GUI's status/error messages.
    operation_name: str = ""

    new_frame = Signal(object)
    progress = Signal(float)
    finished = Signal()
    failed = Signal(str, str)
    cancelled = Signal()

    def __init__(self, target) -> None:
        super().__init__()
        self._target = target
        self._cancel = threading.Event()

    def cancel(self) -> None:
        self._cancel.set()

    @Slot()
    def run(self) -> None:
        try:
            for item in self._items():
                if self._cancel.is_set():
                    self._discard()
                    self.cancelled.emit()
                    return
                self._accumulate(item)
                self.new_frame.emit(item)
        except Exception as exc:
            self._discard()
            self.failed.emit(str(exc), traceback.format_exc())
            return
        if self._cancel.is_set():
            self._discard()
            self.cancelled.emit()
        else:
            self._finalise()
            self.finished.emit()

    def _items(self) -> Iterator:
        """Yield each computed frame/result. Lazy-import the pipeline here."""
        raise NotImplementedError

    def _accumulate(self, item) -> None:
        """Store one computed item into the target."""
        raise NotImplementedError

    def _finalise(self) -> None:
        """Fold the accumulated items into the target's stored results."""
        raise NotImplementedError

    def _discard(self) -> None:
        """Roll the target back when the run is cancelled or fails."""
        raise NotImplementedError

BodyPoseWorker

Bases: BaseWorker

Runs :func:detect_body_poses off the GUI thread, into a :class:Video.

Body poses are detected inside the person boxes already tracked into the :class:Video. Each frame's poses are accumulated and emitted via new_frame so the GUI can draw them live, then folded onto the matching rows once the run finishes; a cancelled/failed pass keeps the tracked boxes. The detection arguments come from step.

Source code in src/body_eye_sync/gui/workers/body_pose.py
class BodyPoseWorker(BaseWorker):
    """Runs :func:`detect_body_poses` off the GUI thread, into a :class:`Video`.

    Body poses are detected inside the person boxes already tracked into the
    :class:`Video`. Each frame's poses are accumulated and emitted via
    ``new_frame`` so the GUI can draw them live, then folded onto the matching
    rows once the run finishes; a cancelled/failed pass keeps the tracked boxes.
    The detection arguments come from ``step``.
    """

    operation_name = "Body pose detection"

    def __init__(self, video: Video, step: BodyPoseStep) -> None:
        super().__init__(video)
        self._step = step

    def _items(self) -> Iterator:
        # lazy import to avoid making GUI startup slow due to module loading
        from body_eye_sync.pipeline.body_pose import detect_body_poses

        return detect_body_poses(
            self._target.video_path,
            self._target.all_boxes_by_frame(),
            **self._step.model_dump(),
        )

    def _accumulate(self, result) -> None:
        self._target.add_body_pose_frame(result)

    def _finalise(self) -> None:
        self._target.finish_body_pose_detection()

    def _discard(self) -> None:
        self._target.discard_body_pose_detection()

FaceDetectionWorker

Bases: BaseWorker

Runs :func:detect_faces off the GUI thread, into a :class:Video.

Faces are detected inside the person boxes already tracked into the :class:Video. Each frame's faces are accumulated and emitted via new_frame so the GUI can draw them live, then folded onto the matching rows once the run finishes; a cancelled/failed pass keeps the tracked boxes. The detection arguments come from step.

Source code in src/body_eye_sync/gui/workers/face_detection.py
class FaceDetectionWorker(BaseWorker):
    """Runs :func:`detect_faces` off the GUI thread, into a :class:`Video`.

    Faces are detected inside the person boxes already tracked into the
    :class:`Video`. Each frame's faces are accumulated and emitted via
    ``new_frame`` so the GUI can draw them live, then folded onto the matching
    rows once the run finishes; a cancelled/failed pass keeps the tracked boxes.
    The detection arguments come from ``step``.
    """

    operation_name = "Face detection"

    def __init__(self, video: Video, step: FaceDetectionStep) -> None:
        super().__init__(video)
        self._step = step

    def _items(self) -> Iterator:
        # lazy import to avoid making GUI startup slow due to module loading
        from body_eye_sync.pipeline.face_detection import detect_faces

        # embeddings_per_track drives the post-pass reduction in Video, not the
        # detector call, so it is not forwarded to detect_faces.
        return detect_faces(
            self._target.video_path,
            self._target.all_boxes_by_frame(),
            **self._step.model_dump(exclude={"embeddings_per_track"}),
        )

    def _accumulate(self, result) -> None:
        self._target.add_face_detection_frame(result)

    def _finalise(self) -> None:
        self._target.finish_face_detection()

    def _discard(self) -> None:
        self._target.discard_face_detection()

ObjectTrackingWorker

Bases: BaseWorker

Runs :func:detect_tracklets off the GUI thread, into a :class:Video.

Each tracked frame is appended to the :class:Video as it is computed and emitted via new_frame so the GUI can draw it live; the results are folded into the video once the run finishes, or discarded if it is cancelled/fails. The tracking arguments come from step.

Source code in src/body_eye_sync/gui/workers/object_tracking.py
class ObjectTrackingWorker(BaseWorker):
    """Runs :func:`detect_tracklets` off the GUI thread, into a :class:`Video`.

    Each tracked frame is appended to the :class:`Video` as it is computed and
    emitted via ``new_frame`` so the GUI can draw it live; the results are folded
    into the video once the run finishes, or discarded if it is cancelled/fails.
    The tracking arguments come from ``step``.
    """

    operation_name = "Object tracking"

    def __init__(self, video: Video, step: ObjectTrackingStep) -> None:
        super().__init__(video)
        self._step = step

    def _items(self) -> Iterator:
        # lazy import to avoid making GUI startup slow due to module loading
        from body_eye_sync.pipeline.object_tracking import detect_tracklets

        # embeddings_per_track drives the post-pass reduction in Video, not the
        # detector call, so it is not forwarded to detect_tracklets.
        return detect_tracklets(
            self._target.video_path,
            **self._step.model_dump(exclude={"embeddings_per_track"}),
        )

    def _accumulate(self, frame) -> None:
        self._target.add_object_tracking_frame(frame)

    def _finalise(self) -> None:
        self._target.finish_object_tracking()

    def _discard(self) -> None:
        self._target.discard_object_tracking()

TranscriptionWorker

Bases: BaseWorker

Runs :func:transcribe off the GUI thread, into a :class:Speech.

loudness is measured from the same audio before transcribing it, so that speaker attribution has it without decoding the recording again.

Source code in src/body_eye_sync/gui/workers/transcription.py
class TranscriptionWorker(BaseWorker):
    """Runs :func:`transcribe` off the GUI thread, into a :class:`Speech`.

    ``loudness`` is measured from the same audio before transcribing it, so
    that speaker attribution has it without decoding the recording again.
    """

    operation_name = "Transcription"

    def __init__(
        self,
        speech: Speech,
        loudness: Loudness,
        media_path: Path,
        step: TranscriptionStep,
    ) -> None:
        super().__init__(speech)
        self._media_path = media_path
        self._step = step
        self._loudness = loudness

    def _items(self) -> Iterator:
        from body_eye_sync.media import media_duration
        from body_eye_sync.pipeline.transcription import transcribe

        self._loudness.measure(self._media_path)
        duration = media_duration(self._media_path) or 0.0
        for segment in transcribe(self._media_path, **self._step.model_dump()):
            if duration > 0:
                self.progress.emit(min(segment.end / duration, 1.0))
            yield segment

    def _accumulate(self, segment) -> None:
        self._target.add_transcription_segment(segment)

    def _finalise(self) -> None:
        self._target.finish_transcription()

    def _discard(self) -> None:
        self._target.begin_transcription()

body_eye_sync.gui.workers.base

BaseWorker

Bases: QObject

Runs one pipeline step off the GUI thread, into the results it computes.

target is what the step writes into: a :class:~body_eye_sync.experiment.video.Video for the video stages, a :class:~body_eye_sync.experiment.speech.Speech for the speech ones. Subclasses supply the per-run work: :meth:_items yields each computed frame/result, :meth:_accumulate stores one into the target, :meth:_finalise folds the accumulated results once the run completes, and :meth:_discard rolls the target back if the run is cancelled or fails. Each item is emitted via new_frame so the GUI can show it live, and a step that can say how far through it is reports that as a fraction via progress; finished (after :meth:_finalise) or cancelled (after :meth:_discard) fires once the run ends, and any exception is reported via failed with a traceback (also after :meth:_discard). operation_name labels the run for the GUI.

Source code in src/body_eye_sync/gui/workers/base.py
class BaseWorker(QObject):
    """Runs one pipeline step off the GUI thread, into the results it computes.

    ``target`` is what the step writes into: a
    :class:`~body_eye_sync.experiment.video.Video` for the video stages, a
    :class:`~body_eye_sync.experiment.speech.Speech` for the speech ones.
    Subclasses supply the per-run work: :meth:`_items` yields each computed
    frame/result, :meth:`_accumulate` stores one into the target, :meth:`_finalise`
    folds the accumulated results once the run completes, and :meth:`_discard`
    rolls the target back if the run is cancelled or fails. Each item is emitted
    via ``new_frame`` so the GUI can show it live, and a step that can say how far
    through it is reports that as a fraction via ``progress``; ``finished`` (after
    :meth:`_finalise`) or ``cancelled`` (after :meth:`_discard`) fires once the
    run ends, and any exception is reported via ``failed`` with a traceback (also
    after :meth:`_discard`). ``operation_name`` labels the run for the GUI.
    """

    #: Human-readable name of the operation, for the GUI's status/error messages.
    operation_name: str = ""

    new_frame = Signal(object)
    progress = Signal(float)
    finished = Signal()
    failed = Signal(str, str)
    cancelled = Signal()

    def __init__(self, target) -> None:
        super().__init__()
        self._target = target
        self._cancel = threading.Event()

    def cancel(self) -> None:
        self._cancel.set()

    @Slot()
    def run(self) -> None:
        try:
            for item in self._items():
                if self._cancel.is_set():
                    self._discard()
                    self.cancelled.emit()
                    return
                self._accumulate(item)
                self.new_frame.emit(item)
        except Exception as exc:
            self._discard()
            self.failed.emit(str(exc), traceback.format_exc())
            return
        if self._cancel.is_set():
            self._discard()
            self.cancelled.emit()
        else:
            self._finalise()
            self.finished.emit()

    def _items(self) -> Iterator:
        """Yield each computed frame/result. Lazy-import the pipeline here."""
        raise NotImplementedError

    def _accumulate(self, item) -> None:
        """Store one computed item into the target."""
        raise NotImplementedError

    def _finalise(self) -> None:
        """Fold the accumulated items into the target's stored results."""
        raise NotImplementedError

    def _discard(self) -> None:
        """Roll the target back when the run is cancelled or fails."""
        raise NotImplementedError

body_eye_sync.gui.workers.object_tracking

ObjectTrackingWorker

Bases: BaseWorker

Runs :func:detect_tracklets off the GUI thread, into a :class:Video.

Each tracked frame is appended to the :class:Video as it is computed and emitted via new_frame so the GUI can draw it live; the results are folded into the video once the run finishes, or discarded if it is cancelled/fails. The tracking arguments come from step.

Source code in src/body_eye_sync/gui/workers/object_tracking.py
class ObjectTrackingWorker(BaseWorker):
    """Runs :func:`detect_tracklets` off the GUI thread, into a :class:`Video`.

    Each tracked frame is appended to the :class:`Video` as it is computed and
    emitted via ``new_frame`` so the GUI can draw it live; the results are folded
    into the video once the run finishes, or discarded if it is cancelled/fails.
    The tracking arguments come from ``step``.
    """

    operation_name = "Object tracking"

    def __init__(self, video: Video, step: ObjectTrackingStep) -> None:
        super().__init__(video)
        self._step = step

    def _items(self) -> Iterator:
        # lazy import to avoid making GUI startup slow due to module loading
        from body_eye_sync.pipeline.object_tracking import detect_tracklets

        # embeddings_per_track drives the post-pass reduction in Video, not the
        # detector call, so it is not forwarded to detect_tracklets.
        return detect_tracklets(
            self._target.video_path,
            **self._step.model_dump(exclude={"embeddings_per_track"}),
        )

    def _accumulate(self, frame) -> None:
        self._target.add_object_tracking_frame(frame)

    def _finalise(self) -> None:
        self._target.finish_object_tracking()

    def _discard(self) -> None:
        self._target.discard_object_tracking()

body_eye_sync.gui.workers.face_detection

FaceDetectionWorker

Bases: BaseWorker

Runs :func:detect_faces off the GUI thread, into a :class:Video.

Faces are detected inside the person boxes already tracked into the :class:Video. Each frame's faces are accumulated and emitted via new_frame so the GUI can draw them live, then folded onto the matching rows once the run finishes; a cancelled/failed pass keeps the tracked boxes. The detection arguments come from step.

Source code in src/body_eye_sync/gui/workers/face_detection.py
class FaceDetectionWorker(BaseWorker):
    """Runs :func:`detect_faces` off the GUI thread, into a :class:`Video`.

    Faces are detected inside the person boxes already tracked into the
    :class:`Video`. Each frame's faces are accumulated and emitted via
    ``new_frame`` so the GUI can draw them live, then folded onto the matching
    rows once the run finishes; a cancelled/failed pass keeps the tracked boxes.
    The detection arguments come from ``step``.
    """

    operation_name = "Face detection"

    def __init__(self, video: Video, step: FaceDetectionStep) -> None:
        super().__init__(video)
        self._step = step

    def _items(self) -> Iterator:
        # lazy import to avoid making GUI startup slow due to module loading
        from body_eye_sync.pipeline.face_detection import detect_faces

        # embeddings_per_track drives the post-pass reduction in Video, not the
        # detector call, so it is not forwarded to detect_faces.
        return detect_faces(
            self._target.video_path,
            self._target.all_boxes_by_frame(),
            **self._step.model_dump(exclude={"embeddings_per_track"}),
        )

    def _accumulate(self, result) -> None:
        self._target.add_face_detection_frame(result)

    def _finalise(self) -> None:
        self._target.finish_face_detection()

    def _discard(self) -> None:
        self._target.discard_face_detection()

body_eye_sync.gui.workers.body_pose

BodyPoseWorker

Bases: BaseWorker

Runs :func:detect_body_poses off the GUI thread, into a :class:Video.

Body poses are detected inside the person boxes already tracked into the :class:Video. Each frame's poses are accumulated and emitted via new_frame so the GUI can draw them live, then folded onto the matching rows once the run finishes; a cancelled/failed pass keeps the tracked boxes. The detection arguments come from step.

Source code in src/body_eye_sync/gui/workers/body_pose.py
class BodyPoseWorker(BaseWorker):
    """Runs :func:`detect_body_poses` off the GUI thread, into a :class:`Video`.

    Body poses are detected inside the person boxes already tracked into the
    :class:`Video`. Each frame's poses are accumulated and emitted via
    ``new_frame`` so the GUI can draw them live, then folded onto the matching
    rows once the run finishes; a cancelled/failed pass keeps the tracked boxes.
    The detection arguments come from ``step``.
    """

    operation_name = "Body pose detection"

    def __init__(self, video: Video, step: BodyPoseStep) -> None:
        super().__init__(video)
        self._step = step

    def _items(self) -> Iterator:
        # lazy import to avoid making GUI startup slow due to module loading
        from body_eye_sync.pipeline.body_pose import detect_body_poses

        return detect_body_poses(
            self._target.video_path,
            self._target.all_boxes_by_frame(),
            **self._step.model_dump(),
        )

    def _accumulate(self, result) -> None:
        self._target.add_body_pose_frame(result)

    def _finalise(self) -> None:
        self._target.finish_body_pose_detection()

    def _discard(self) -> None:
        self._target.discard_body_pose_detection()

body_eye_sync.gui.workers.transcription

TranscriptionWorker

Bases: BaseWorker

Runs :func:transcribe off the GUI thread, into a :class:Speech.

loudness is measured from the same audio before transcribing it, so that speaker attribution has it without decoding the recording again.

Source code in src/body_eye_sync/gui/workers/transcription.py
class TranscriptionWorker(BaseWorker):
    """Runs :func:`transcribe` off the GUI thread, into a :class:`Speech`.

    ``loudness`` is measured from the same audio before transcribing it, so
    that speaker attribution has it without decoding the recording again.
    """

    operation_name = "Transcription"

    def __init__(
        self,
        speech: Speech,
        loudness: Loudness,
        media_path: Path,
        step: TranscriptionStep,
    ) -> None:
        super().__init__(speech)
        self._media_path = media_path
        self._step = step
        self._loudness = loudness

    def _items(self) -> Iterator:
        from body_eye_sync.media import media_duration
        from body_eye_sync.pipeline.transcription import transcribe

        self._loudness.measure(self._media_path)
        duration = media_duration(self._media_path) or 0.0
        for segment in transcribe(self._media_path, **self._step.model_dump()):
            if duration > 0:
                self.progress.emit(min(segment.end / duration, 1.0))
            yield segment

    def _accumulate(self, segment) -> None:
        self._target.add_transcription_segment(segment)

    def _finalise(self) -> None:
        self._target.finish_transcription()

    def _discard(self) -> None:
        self._target.begin_transcription()

Utilities

body_eye_sync.gui.utils

body_eye_sync.gui.autoupdate

Check PyPI for a newer version of body-eye-sync and offer to install it.

The latest published version and its dependencies come from the PyPI JSON API. Updates are installed from PyPI with pip.

If any deps are missing we instead point the user at the full installer.

UpdateInfo dataclass

A newer version that is available to install.

Source code in src/body_eye_sync/gui/autoupdate.py
@dataclass
class UpdateInfo:
    """A newer version that is available to install."""

    version: str
    missing_dependencies: list[str]

check_for_update()

Return info about a newer version if found, otherwise None.

Source code in src/body_eye_sync/gui/autoupdate.py
def check_for_update() -> UpdateInfo | None:
    """Return info about a newer version if found, otherwise None."""
    installed = _installed_version()
    if installed is None:
        return None
    if _is_editable_install():
        # A developer running from a source checkout: don't overwrite their tree.
        return None
    try:
        metadata = _fetch_package_metadata()
    except Exception:
        # offline, PyPI unreachable, malformed metadata, etc
        return None
    latest = (metadata.get("info") or {}).get("version")
    if latest is None:
        return None
    try:
        if Version(latest) <= Version(installed):
            return None
    except InvalidVersion:
        return None
    return UpdateInfo(latest, _missing_dependencies(metadata))