Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
3 changes: 2 additions & 1 deletion docs/user-guide/gui.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Right-click a video name to open a context menu:

- **Get Info:** Open a dialog showing details about the video and its associated pose file.
- **Copy Video Name:** Copy the video's filename to the clipboard.
- **Classify Video:** Run the trained classifier for the current behavior on just this video, rather than every video the way the **Classify** button does. This item is only available once a classifier has been trained for the current behavior (if none is ready, JABS prompts you to train one first). When classification finishes, JABS displays the new predictions, switching to the video if it is not already the active one.
- **Exclude from Training:** Toggle whether the video is held out of classifier training (unchecked by default). When a video is excluded:
- its labels are **not** used to train the classifier, and
- it does **not** count toward the labeling thresholds that enable the **Train** button, so excluding too many videos can disable training.
Expand All @@ -39,7 +40,7 @@ Right-click a video name to open a context menu:
<img src="imgs/classifier_controls.png" alt="JABS Classifier Controls" width=900 />

- **Train Button:** Train the classifier with the current parameters. This button is disabled until minimum number of frames have been labeled for a minimum number of mice (increasing the cross validation k parameter increases the minimum number of labeled mice). When training completes, a training report dialog will display performance metrics including cross-validation results and feature importance rankings.
- **Classify Button:** Infer class of unlabeled frames. Disabled until classifier is trained. Changing classifier parameters may require retraining before the Classify button becomes active again.
- **Classify Button:** Infer class of unlabeled frames for every video in the project. Disabled until classifier is trained. Changing classifier parameters may require retraining before the Classify button becomes active again. To classify a single video instead, use **Classify Video** in the video list's right-click menu.
- **Classifier Type Selection:** Users can select from a list of supported classifiers.
- **Window Size Selection:** Number of frames on each side of the current frame to include in window feature calculations for that frame. A "window size" of 5 means that 11 frames are included into the window feature calculations for each frame (5 previous frames, current frame, 5 following frames).
- **New Window Size:** Add a new window size to the project.
Expand Down
3 changes: 2 additions & 1 deletion src/jabs/resources/docs/user_guide/gui.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Right-click a video name to open a context menu:

- **Get Info:** Open a dialog showing details about the video and its associated pose file.
- **Copy Video Name:** Copy the video's filename to the clipboard.
- **Classify Video:** Run the trained classifier for the current behavior on just this video, rather than every video the way the **Classify** button does. This item is only available once a classifier has been trained for the current behavior (if none is ready, JABS prompts you to train one first). When classification finishes, JABS displays the new predictions, switching to the video if it is not already the active one.
- **Exclude from Training:** Toggle whether the video is held out of classifier training (unchecked by default). When a video is excluded:
- its labels are **not** used to train the classifier, and
- it does **not** count toward the labeling thresholds that enable the **Train** button, so excluding too many videos can disable training.
Expand All @@ -39,7 +40,7 @@ Right-click a video name to open a context menu:
<img src="imgs/classifier_controls.png" alt="JABS Classifier Controls" width=900 />

- **Train Button:** Train the classifier with the current parameters. This button is disabled until minimum number of frames have been labeled for a minimum number of mice (increasing the cross validation k parameter increases the minimum number of labeled mice). When training completes, a training report dialog will display performance metrics including cross-validation results and feature importance rankings.
- **Classify Button:** Infer class of unlabeled frames. Disabled until classifier is trained. Changing classifier parameters may require retraining before the Classify button becomes active again.
- **Classify Button:** Infer class of unlabeled frames for every video in the project. Disabled until classifier is trained. Changing classifier parameters may require retraining before the Classify button becomes active again. To classify a single video instead, use **Classify Video** in the video list's right-click menu.
- **Classifier Type Selection:** Users can select from a list of supported classifiers.
- **Window Size Selection:** Number of frames on each side of the current frame to include in window feature calculations for that frame. A "window size" of 5 means that 11 frames are included into the window feature calculations for each frame (5 previous frames, current frame, 5 following frames).
- **New Window Size:** Add a new window size to the project.
Expand Down
2 changes: 1 addition & 1 deletion src/jabs/scripts/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
from .update_pose import update_pose_command

# find out which classifiers are supported in this environment
CLASSIFIER_CHOICES: list[ClassifierType] = Classifier().classifier_choices()
CLASSIFIER_CHOICES = Classifier().classifier_choices()
DEFAULT_CLASSIFIER: str = (
ClassifierType.XGBOOST.value.lower()
if ClassifierType.XGBOOST in CLASSIFIER_CHOICES
Expand Down
10 changes: 9 additions & 1 deletion src/jabs/ui/classification_thread.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ class ClassifyThread(QThread):
project (Project): The project containing data and settings.
behavior (str): The behavior label to classify.
current_video (str): The video currently loaded in the video player.
videos (list[str] | None, optional): Restrict classification to these video
filenames. When ``None`` (the default) every video in the project is
classified, preserving the "classify all" behavior.
parent (QWidget or None, optional): Optional parent widget.

Note:
Expand All @@ -62,6 +65,7 @@ def __init__(
project: Project,
behavior: str,
current_video: str,
videos: list[str] | None = None,
parent: QWidget | None = None,
):
super().__init__(parent=parent)
Expand All @@ -70,6 +74,7 @@ def __init__(
self._behavior = behavior
self._tasks_complete = 0
self._current_video = current_video
self._videos = videos
self._should_terminate = False

def request_termination(self) -> None:
Expand Down Expand Up @@ -118,7 +123,10 @@ def check_termination_requested() -> None:
strategy = self._build_strategy()
project_settings = strategy.project_settings()

for video in self._project.video_manager.videos:
videos = (
self._videos if self._videos is not None else self._project.video_manager.videos
)
for video in videos:
check_termination_requested()

video_path = self._project.video_manager.video_path(video)
Expand Down
107 changes: 89 additions & 18 deletions src/jabs/ui/main_window/central_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ class CentralWidget(QtWidgets.QWidget):
search_hit_loaded = QtCore.Signal(SearchHit)
bbox_overlay_supported = QtCore.Signal(bool)

# Emitted whenever the classify button's enabled state changes, so the video
# list's "Classify Video" context-menu action can track classifier readiness.
classify_availability_changed = QtCore.Signal(bool)

# Requests that the video list select (and thus load) the given video, used to
# auto-switch to a single video after it is classified from the context menu.
request_video_selection = QtCore.Signal(str)

def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)

Expand Down Expand Up @@ -92,6 +100,9 @@ def __init__(self, *args, **kwargs) -> None:
self._classifier = Classifier(n_jobs=-1)
self._training_thread: TrainingThread | None = None
self._classify_thread: ClassifyThread | None = None
# videos targeted by the in-flight classification run (None == all videos),
# used to decide how the completion handler updates the display
self._classification_targets: list[str] | None = None
self._training_report_markdown: str | None = None
self._training_report_dialog: TrainingReportDialog | None = None

Expand Down Expand Up @@ -906,6 +917,19 @@ def _get_label_track(self) -> TrackLabels:
str(self._controls.current_identity_index), self._controls.current_behavior
)

def _set_classify_enabled(self, enabled: bool) -> None:
"""Set the classify button's enabled state and notify listeners.

Centralizes updates to the classify button so the video list's
"Classify Video" context-menu action can track classifier readiness via
the ``classify_availability_changed`` signal.

Args:
enabled: True to enable classification, False to disable it.
"""
self._controls.classify_button_enabled = enabled
self.classify_availability_changed.emit(enabled)

def _update_classifier_controls(self) -> None:
"""Called when settings related to a loaded classifier should be updated"""
self._controls.set_classifier_selection(self._classifier.classifier_type)
Expand All @@ -920,11 +944,11 @@ def _update_classifier_controls(self) -> None:
and classifier_settings.get("symmetric_behavior", None) == self._controls.use_symmetric
):
# if yes, we can enable the classify button
self._controls.classify_button_enabled = True
self._set_classify_enabled(True)
else:
# if not, the classify button needs to be disabled until the
# user retrains
self._controls.classify_button_enabled = False
self._set_classify_enabled(False)

def _train_button_clicked(self) -> None:
"""handle user click on "Train" button"""
Expand Down Expand Up @@ -1028,7 +1052,7 @@ def _training_thread_complete(self, elapsed_ms) -> None:
self.status_message.emit(
f"Training Complete. Elapsed time: {elapsed_ms / 1000:.1f}s", 20000
)
self._controls.classify_button_enabled = True
self._set_classify_enabled(True)

# Display training report if available
if self._training_report_markdown:
Expand Down Expand Up @@ -1100,7 +1124,7 @@ def _training_thread_error_callback(self, error: Exception) -> None:
message=f"An exception occurred during training:\n{error}",
details="".join(traceback.format_exception(error)),
)
self._controls.classify_button_enabled = False
self._set_classify_enabled(False)

def _classify_thread_error_callback(self, error: Exception) -> None:
"""handle an error in the classification thread"""
Expand Down Expand Up @@ -1158,26 +1182,62 @@ def _update_training_progress(self, step: int) -> None:
self._progress_dialog.setValue(step)

def _classify_button_clicked(self) -> None:
"""handle user click on "Classify" button"""
"""handle user click on "Classify" button (classifies every video)"""
self._start_classification(None)

def classify_single_video(self, video_name: str) -> None:
"""Classify a single video, e.g. from the video list context menu.

Runs the same classification pipeline as the "Classify" button, but
restricted to ``video_name``. If no trained classifier is ready for the
current behavior, a warning is shown and nothing runs.

Args:
video_name: The video filename to classify.
"""
if not self._controls.classify_button_enabled:
MessageDialog.warning(
self,
message="Train a classifier for this behavior before classifying a video.",
)
return
self._start_classification([video_name])

def _start_classification(self, videos: list[str] | None) -> None:
"""Launch the classification thread for the given videos.

Args:
videos: Video filenames to classify, or ``None`` to classify every
video in the project.
"""
# ignore the request if a classification run is already in progress
if self._classify_thread is not None:
return

# make sure video playback is stopped
self._player_widget.stop()
self._ensure_classifier_for_mode()

self._classification_targets = videos
current_video = self._loaded_video.name if self._loaded_video else ""
total_videos = (
len(videos) if videos is not None else self._project.video_manager.num_videos
)

# setup classification thread
self._classify_thread = ClassifyThread(
self._classifier,
self._project,
self._controls.current_behavior,
self._loaded_video.name,
current_video,
videos=videos,
parent=self,
)
self._classify_thread.classification_complete.connect(self._classify_thread_complete)
self._classify_thread.error_callback.connect(self._classify_thread_error_callback)
self._classify_thread.update_progress.connect(self._update_classify_progress)
self._classify_thread.current_status.connect(lambda m: self.status_message.emit(m, 0))
self._progress_dialog = create_cancelable_progress_dialog(
self, "Predicting", self._project.video_manager.num_videos
)
self._progress_dialog = create_cancelable_progress_dialog(self, "Predicting", total_videos)
self._progress_dialog.show()
self._progress_dialog.canceled.connect(self._classify_thread.request_termination)

Expand All @@ -1186,18 +1246,29 @@ def _classify_button_clicked(self) -> None:

def _classify_thread_complete(self, output: dict, elapsed_ms: int) -> None:
"""update the gui when the classification is complete"""
# display the new predictions
self._predictions = output["predictions"]
self._probabilities = output["probabilities"]
self._predictions_postprocessed = output["predictions_postprocessed"]
if self._project.settings_manager.classifier_mode == ClassifierMode.MULTICLASS:
self._multiclass_class_names = output.get("class_names")
targets = self._classification_targets
loaded_video = self._loaded_video.name if self._loaded_video else None

self._cleanup_progress_dialog()
self._cleanup_classify_thread()
self._classification_targets = None
self.status_message.emit(
f"Classification Complete. Elapsed time: {elapsed_ms / 1000:.1f}s", 20000
)
self._set_prediction_vis()

# if the classified set includes the currently loaded video (or we
# classified every video), refresh the display with the new predictions
if targets is None or (loaded_video is not None and loaded_video in targets):
self._predictions = output["predictions"]
self._probabilities = output["probabilities"]
self._predictions_postprocessed = output["predictions_postprocessed"]
if self._project.settings_manager.classifier_mode == ClassifierMode.MULTICLASS:
self._multiclass_class_names = output.get("class_names")
self._set_prediction_vis()
elif len(targets) == 1:
# a single, non-loaded video was classified from the context menu;
# switch to it so its freshly-saved predictions are displayed
self.request_video_selection.emit(targets[0])

def _update_classify_progress(self, step: int) -> None:
"""update progress bar with the number of completed tasks"""
Expand Down Expand Up @@ -1560,7 +1631,7 @@ def _classifier_changed(self) -> None:
"""handle classifier selection change"""
if self._classifier.classifier_type != self._controls.classifier_type:
# changing classifier type, disable until retrained
self._controls.classify_button_enabled = False
self._set_classify_enabled(False)
self._classifier.set_classifier(self._controls.classifier_type)

def _on_pixmap_clicked(self, event: dict[str, int]) -> None:
Expand Down Expand Up @@ -1664,7 +1735,7 @@ def _load_cached_classifier(self) -> None:
if classifier_loaded:
self._update_classifier_controls()
else:
self._controls.classify_button_enabled = False
self._set_classify_enabled(False)

def _on_search_hit_changed_later(self, _: SearchHit | None) -> None:
"""Update the search hit after a short delay to allow UI updates to complete."""
Expand Down
11 changes: 11 additions & 0 deletions src/jabs/ui/main_window/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,17 @@ def _setup_dock_widgets(self) -> None:
# Handle event where user selects a different video in the playlist
self.video_list.selectionChanged.connect(self._video_list_selection)

# Classify a single video from the video list context menu, and keep the
# menu action's availability in sync with the classifier's readiness
self.video_list.classify_video_requested.connect(
self._central_widget.classify_single_video
)
self._central_widget.classify_availability_changed.connect(
self.video_list.set_classify_available
)
# After a single video is classified, switch to it so its predictions show
self._central_widget.request_video_selection.connect(self.video_list.select_video)

def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:
"""override keyPressEvent so we can pass some key press events on to the centralWidget"""
key = event.key()
Expand Down
Loading
Loading