Visitar URL original
Fix GUI freeze when showing trajectory plots in Analyze Videos by C-Achard · Pull Request #3466 · DeepLabCut/DeepLabCut · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Refactor: Each batch now saves its trajectory plots to disk as it fin…
…ishes
  • Loading branch information
C-Achard committed Nov 2, 2026
commit 97e9e45bfa1219e85101952d2eff69095455a33f
82 changes: 22 additions & 60 deletions deeplabcut/gui/tabs/analyze_videos.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,73 +53,40 @@ class AnalyzeVideos(DefaultTab):
def __init__(self, root, parent, h1_description):
super().__init__(root, parent, h1_description)

self._pending_plot_timer = QTimer(self)
self._pending_plot_timer.setSingleShot(True)
self._pending_plot_timer.setInterval(0)
self._pending_plot_timer.timeout.connect(self._show_pending_trajectory_plots)
self._pending_plot_options: AnalyzeVideosOptions | None = None
self._pending_plot_batches: list[tuple[str, list[Path]]] | None = None
self._analysis_failed = False
self._plot_options: AnalyzeVideosOptions | None = None
self._plotted_batches: list[tuple[str, list[Path]]] = []

self._show_plots_timer = QTimer(self)
self._show_plots_timer.setSingleShot(True)
self._show_plots_timer.setInterval(0)
self._show_plots_timer.timeout.connect(self._show_plotted_batches)

self._set_page()

@property
def files(self):
return self.video_selection_widget.files

@Slot(object)
def _handle_analysis_error(self, _error):
self._analysis_failed = True
@Slot()
def _show_plotted_batches(self):
options = self._plot_options
if options is None or not options.show_trajectory_plots or not self._plotted_batches:
return

Comment thread
C-Achard marked this conversation as resolved.
Resolved
def _show_trajectory_plots_safely(
self,
options: AnalyzeVideosOptions,
batches: list[tuple[str, list[Path]]],
):
try:
self._show_trajectory_plots(options, batches)
self._show_trajectory_plots(options, self._plotted_batches)
except Exception as error:
self.root.logger.error(
"Failed to display trajectory plots.",
exc_info=True,
)
self.root.show_task_error(error)

def _clear_pending_trajectory_plots(self) -> None:
self._pending_plot_options = None
self._pending_plot_batches = None

@Slot()
def _show_pending_trajectory_plots(self):
options = self._pending_plot_options
batches = self._pending_plot_batches

self._clear_pending_trajectory_plots()

if options is None or batches is None:
return

self._show_trajectory_plots_safely(options, batches)

@Slot()
def _handle_analysis_finished(self):
should_show_plots = (
not self._analysis_failed
and self._pending_plot_options is not None
and self._pending_plot_batches is not None
and self._pending_plot_options.plot_trajectories
and self._pending_plot_options.show_trajectory_plots
)

self._analysis_failed = False

if should_show_plots:
self._pending_plot_timer.start()
else:
self._clear_pending_trajectory_plots()

self.analyze_videos_btn.setEnabled(True)
self.root._progress_bar.hide()
self._show_plots_timer.start()

Comment thread
C-Achard marked this conversation as resolved.
Resolved
def _set_page(self):
self.main_layout.addWidget(_create_label_widget("Video Selection", "font:bold"))
Expand Down Expand Up @@ -454,7 +421,9 @@ def _run_postprocessing_for_group(
track_method=options.track_method,
)

if options.plot_trajectories and not options.show_trajectory_plots:
if options.plot_trajectories:
# Matplotlib windows cannot open from this worker thread; they are
# shown from the GUI thread once analysis finishes.
deeplabcut.plot_trajectories(
options.config_path,
videos=videos,
Expand All @@ -465,6 +434,7 @@ def _run_postprocessing_for_group(
showfigures=False,
Comment thread
C-Achard marked this conversation as resolved.
Resolved
track_method=options.track_method,
)
self._plotted_batches.append((videotype, videos))

def _convert_outputs_to_csv_once_per_folder(self, batches: list[tuple[str, list[Path]]]):
folders = self._get_unique_video_parent_folders(batches)
Expand Down Expand Up @@ -494,7 +464,7 @@ def _show_trajectory_plots(
)

Comment on lines +455 to +465

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The worker thread had already run plot_trajectories by this stage. Also the GUI still hangs while redrawing the figures here on the GUI thread.

This can be fixed in a follow-up PR (see #3578)

def analyze_videos(self):
self._pending_plot_timer.stop()
self._show_plots_timer.stop()

options = self._collect_options()
batches = self._get_video_batches()
Expand All @@ -512,22 +482,14 @@ def analyze_videos(self):
cfg.default_track_method = options.track_method
cfg.to_yaml(self.root.config_path, overwrite=True, log_changes=True, mark_clean=True)

self._pending_plot_options = options
self._pending_plot_batches = batches
self._analysis_failed = False
self._plot_options = options
self._plotted_batches = []

func = partial(self._run_pipeline, options, batches)

self.worker, self.thread = move_to_separate_thread(func)

self.worker.error.connect(self._handle_analysis_error)
self.worker.error.connect(self.root.show_task_error)

self.worker.finished.connect(
self._handle_analysis_finished,
Qt.ConnectionType.QueuedConnection,
)

self.worker.finished.connect(self._handle_analysis_finished)
self.thread.start()
self.analyze_videos_btn.setEnabled(False)
self.root._progress_bar.show()
114 changes: 114 additions & 0 deletions tests/gui/test_analyze_videos_plotting.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
#
# DeepLabCut Toolbox (deeplabcut.org)
# © A. & M.W. Mathis Labs
# https://github.com/DeepLabCut/DeepLabCut
#
# Licensed under GNU Lesser General Public License v3.0
#
"""Tests for trajectory plotting in the Analyze Videos tab."""

import logging
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock

import pytest

pytest.importorskip("PySide6")
pytest.importorskip("pytestqt")

from PySide6 import QtCore, QtWidgets

import deeplabcut.gui.tabs.analyze_videos as analyze_videos_tab
from deeplabcut.gui.components import DefaultTab
from deeplabcut.gui.tabs.analyze_videos import AnalyzeVideos, AnalyzeVideosOptions

BATCHES = [(".avi", [Path("a.avi")]), (".mp4", [Path("b.mp4")])]


class FakeRoot(QtCore.QObject):
"""Minimal stand-in for MainWindow as seen by AnalyzeVideos."""

def __init__(self):
super().__init__()
self.logger = logging.getLogger(__name__)
self._progress_bar = MagicMock()
self.task_errors = []

def reload_project_config(self):
pass

def show_task_error(self, error):
self.task_errors.append(error)


def _options(show_trajectory_plots: bool) -> AnalyzeVideosOptions:
return AnalyzeVideosOptions(
config_path="config.yaml",
shuffle=1,
save_as_csv=False,
filter_data=False,
plot_trajectories=True,
show_trajectory_plots=show_trajectory_plots,
displayed_bodyparts=(),
create_video_all_detections=False,
auto_track=False,
calibrate_assembly=False,
assemble_with_ID_only=False,
num_animals_in_videos=None,
cropping=None,
dynamic_cropping_params=(False, 0.5, 10),
track_method=None,
)


@pytest.fixture
def plot_calls(monkeypatch):
"""Fake DeepLabCut API where analysing the .mp4 batch fails; records plot calls."""
calls = []

def analyze_videos(*args, video_extensions, **kwargs):
if video_extensions == ".mp4":
raise ValueError("synthetic analysis failure")

def plot_trajectories(*args, video_extensions, showfigures, **kwargs):
calls.append((video_extensions, showfigures))

fake_api = SimpleNamespace(analyze_videos=analyze_videos, plot_trajectories=plot_trajectories)
monkeypatch.setattr(analyze_videos_tab, "deeplabcut", fake_api)
return calls


@pytest.fixture
def tab(qtbot, monkeypatch):
"""An AnalyzeVideos tab without its widgets, which need a loaded project."""
monkeypatch.setattr(DefaultTab, "_init_default_layout", lambda self: None)
monkeypatch.setattr(AnalyzeVideos, "_set_page", lambda self: None)

tab = AnalyzeVideos(FakeRoot(), parent=None, h1_description="")
tab.analyze_videos_btn = QtWidgets.QPushButton()
tab.analyze_videos_btn.setEnabled(False)
qtbot.addWidget(tab)
return tab


def _run_failing_pipeline(tab, options):
tab._plot_options = options
with pytest.raises(RuntimeError, match="synthetic analysis failure"):
tab._run_pipeline(options, BATCHES)
tab._handle_analysis_finished()


def test_plots_of_successful_batches_are_saved_and_shown_after_later_failure(tab, plot_calls, qtbot):
_run_failing_pipeline(tab, _options(show_trajectory_plots=True))

assert tab.analyze_videos_btn.isEnabled()
qtbot.waitUntil(lambda: len(plot_calls) == 2, timeout=1000)
assert plot_calls == [(".avi", False), (".avi", True)]


def test_plots_are_saved_but_not_shown_when_display_is_off(tab, plot_calls, qtbot):
_run_failing_pipeline(tab, _options(show_trajectory_plots=False))

qtbot.wait(50)
assert plot_calls == [(".avi", False)]
Loading