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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:

- uses: actions/setup-python@v5
with:
python-version: "3.11"
python-version: "3.12"

- name: Cache pip
uses: actions/cache@v4
Expand Down Expand Up @@ -95,7 +95,7 @@ jobs:

- uses: actions/setup-python@v5
with:
python-version: "3.11"
python-version: "3.12"

- name: Cache pip
uses: actions/cache@v4
Expand Down Expand Up @@ -158,7 +158,7 @@ jobs:

- uses: actions/setup-python@v5
with:
python-version: "3.11"
python-version: "3.12"

- name: Install system deps (Qt/OpenCV runtime)
shell: bash
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/deploy_docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
python-version: "3.12"

- name: Install dependencies
run: |
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ jobs:

- uses: actions/setup-python@v5
with:
python-version: "3.11"
python-version: "3.12"

- name: Cache pip
uses: actions/cache@v4
Expand Down Expand Up @@ -129,7 +129,7 @@ jobs:

- uses: actions/setup-python@v5
with:
python-version: "3.11"
python-version: "3.12"

- name: Cache pip
uses: actions/cache@v4
Expand Down Expand Up @@ -191,7 +191,7 @@ jobs:

- uses: actions/setup-python@v5
with:
python-version: "3.11"
python-version: "3.12"

- name: Install system deps (Qt/OpenCV runtime)
shell: bash
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ cd VideoAnnotationTool
### Step 1 – Create a new Conda environment

```bash
conda create -n VideoAnnotationTool python=3.11 -y
conda create -n VideoAnnotationTool python=3.12 -y
conda activate VideoAnnotationTool
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -887,6 +887,7 @@ def start_batch_inference(self, start_idx: int, end_idx: int):
)

label_map = self._get_label_map_from_config()
self.panel.show_inference_loading(True)
worker = BatchInferenceWorker(
self.config_path,
self.base_dir,
Expand All @@ -902,6 +903,7 @@ def start_batch_inference(self, start_idx: int, end_idx: int):
worker.start()

def _on_batch_inference_success(self, _metrics: dict, results_list: list):
self.panel.show_inference_loading(False)
if self.model is None:
return

Expand Down Expand Up @@ -946,6 +948,7 @@ def _on_batch_inference_success(self, _metrics: dict, results_list: list):
self.controller.saveStateRefreshRequested.emit()

def _on_batch_inference_error(self, error_msg):
self.panel.show_inference_loading(False)
QMessageBox.critical(self.panel, "Batch Inference Error", f"An error occurred during batch inference:\n\n{error_msg}")

def clear_smart_annotations_for_path(self, path: str):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ def __init__(self, localization_panel):
def reset_ui(self):
self.localization_panel.annot_mgmt.update_schema({})
self.localization_panel.table.set_data([])
self.localization_panel.show_inference_loading(False)
self.localization_panel.setEnabled(False)
self.current_video_path = None
self.current_sample_id = ""
Expand Down Expand Up @@ -578,6 +579,9 @@ def _prompt_inference_range(self):
def _on_head_smart_inference_requested(self, head_name: str):
if not self.current_video_path or not self.current_sample_id:
return
if self.inference_manager.has_running_threads():
self.statusMessageRequested.emit("Inference", "Localization inference is already running.", 1200)
return

labels = self._head_labels(head_name)
if not labels:
Expand All @@ -597,6 +601,7 @@ def _on_head_smart_inference_requested(self, head_name: str):
return

self._pending_inference_head = str(head_name or "")
self.localization_panel.show_inference_loading(True)
self.statusMessageRequested.emit("Inference", "Running localization inference...", 1200)
self.inference_manager.start_inference(
self.current_video_path,
Expand Down Expand Up @@ -646,6 +651,7 @@ def _prediction_confidence(event: dict) -> float:
return 1.0

def _on_inference_success(self, predicted_events: list):
self.localization_panel.show_inference_loading(False)
if not self.current_video_path or not self.current_sample_id:
self._pending_inference_head = None
return
Expand Down Expand Up @@ -704,6 +710,7 @@ def _on_inference_success(self, predicted_events: list):
self._pending_inference_head = None

def _on_inference_error(self, error_msg: str):
self.localization_panel.show_inference_loading(False)
self._pending_inference_head = None
QMessageBox.critical(self.localization_panel, "Inference Error", f"Failed to run model:\n{error_msg}")

Expand Down
59 changes: 58 additions & 1 deletion annotation_tool/ui/classification/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
QLabel,
QLineEdit,
QMenu,
QProgressDialog,
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

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

QProgressDialog is imported but not used anywhere in this module. Please remove the unused import to avoid confusion and keep the dependency surface minimal.

Suggested change
QProgressDialog,

Copilot uses AI. Check for mistakes.
QPushButton,
QRadioButton,
QScrollArea,
Expand All @@ -22,6 +23,7 @@
QWidget,
)

from ui.dialogs import BusyStatusDialog
from utils import resource_path


Expand Down Expand Up @@ -303,6 +305,20 @@ def set_smart_state(self, predicted_label: str, confidence_score: float, is_smar
def get_row_smart_widgets(self, label_text: str):
return self._smart_controls_by_label.get(str(label_text or ""))

def set_inference_loading(self, is_loading: bool):
self.btn_smart_infer.setEnabled(not is_loading)
self.btn_smart_infer.setText("Loading..." if is_loading else "Smart Inference")
for _conf_btn, accept_btn, reject_btn in self._smart_controls_by_label.values():
accept_btn.setEnabled(not is_loading)
reject_btn.setEnabled(not is_loading)

def set_inference_loading(self, is_loading: bool):
self.btn_smart_infer.setEnabled(not is_loading)
self.btn_smart_infer.setText("Loading..." if is_loading else "Smart Inference")
for _conf_btn, accept_btn, reject_btn in self._smart_controls_by_label.values():
accept_btn.setEnabled(not is_loading)
reject_btn.setEnabled(not is_loading)


Comment on lines +315 to 322
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

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

DynamicSingleLabelGroup defines set_inference_loading twice; the second definition overwrites the first, making the code harder to maintain and easy to accidentally diverge later. Remove the duplicate method definition so there is a single source of truth.

Suggested change
def set_inference_loading(self, is_loading: bool):
self.btn_smart_infer.setEnabled(not is_loading)
self.btn_smart_infer.setText("Loading..." if is_loading else "Smart Inference")
for _conf_btn, accept_btn, reject_btn in self._smart_controls_by_label.values():
accept_btn.setEnabled(not is_loading)
reject_btn.setEnabled(not is_loading)

Copilot uses AI. Check for mistakes.
class DynamicMultiLabelGroup(QWidget):
value_changed = pyqtSignal(str, list)
Expand Down Expand Up @@ -449,6 +465,13 @@ def set_smart_state(self, predicted_label: str, confidence_score: float, is_smar
def get_row_smart_widgets(self, label_text: str):
return self._smart_controls_by_label.get(str(label_text or ""))

def set_inference_loading(self, is_loading: bool):
self.btn_smart_infer.setEnabled(not is_loading)
self.btn_smart_infer.setText("Loading..." if is_loading else "Smart Inference")
for _conf_btn, accept_btn, reject_btn in self._smart_controls_by_label.values():
accept_btn.setEnabled(not is_loading)
reject_btn.setEnabled(not is_loading)


class ClassificationAnnotationPanel(QWidget):
add_head_clicked = pyqtSignal(str)
Expand Down Expand Up @@ -539,6 +562,7 @@ def __init__(self, parent=None):
self.chart_widget.setVisible(False)

self._configure_train_defaults()
self._configure_inference_feedback()
self.clear_dynamic_labels()
self.manual_box.setEnabled(False)
self._update_confirm_button_state()
Expand Down Expand Up @@ -704,6 +728,26 @@ def _configure_train_defaults(self):

self.btn_stop_train.setEnabled(False)

def _configure_inference_feedback(self):
self._inference_loading_dialog = BusyStatusDialog(
"Inference",
"Loading model and running inference. Please wait...",
self,
)
self._inference_loading_dialog.hide()

def _set_inference_controls_loading(self, is_loading: bool):
self.head_tabs_widget.setEnabled(not is_loading)
self.clear_sel_btn.setEnabled(not is_loading)
self.btn_batch_infer.setEnabled(not is_loading)
self.btn_run_batch.setEnabled(not is_loading)
self.spin_start.setEnabled(not is_loading)
self.spin_end.setEnabled(not is_loading)

for group in self.label_groups.values():
if hasattr(group, "set_inference_loading"):
group.set_inference_loading(is_loading)

def _toggle_batch_widget(self):
self.batch_input_widget.setVisible(not self.batch_input_widget.isVisible())

Expand Down Expand Up @@ -747,6 +791,7 @@ def reset_smart_inference(self):
self.is_batch_mode_active = False
self.pending_batch_results = {}
self.chart_widget.setVisible(False)
self.show_inference_loading(False)

def reset_train_ui(self):
self.train_progress.setValue(0)
Expand All @@ -771,7 +816,19 @@ def update_action_list(self, action_names: list):
self._validate_batch_range()

def show_inference_loading(self, is_loading: bool):
_ = is_loading
is_loading = bool(is_loading)
self._set_inference_controls_loading(is_loading)

if is_loading:
self._inference_loading_dialog.set_message("Loading model and running inference. Please wait...")
self._inference_loading_dialog.show()
self._inference_loading_dialog.raise_()
self._inference_loading_dialog.activateWindow()
self.setCursor(Qt.CursorShape.WaitCursor)
return

self._inference_loading_dialog.hide()
self.unsetCursor()

def display_inference_result(self, target_head: str, predicted_label: str, conf_dict: dict):
score = 0.0
Expand Down
25 changes: 24 additions & 1 deletion annotation_tool/ui/dialogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QRadioButton, QTreeView, QDialogButtonBox,
QAbstractItemView, QGroupBox, QFormLayout, QLineEdit, QHBoxLayout,
QFrame, QListWidget, QComboBox, QPushButton, QLabel,
QFrame, QListWidget, QComboBox, QPushButton, QLabel, QProgressBar,
QMessageBox, QWidget, QListWidgetItem, QStyle, QButtonGroup, QScrollArea
)
from PyQt6.QtCore import QDir, Qt, QSize
Expand Down Expand Up @@ -132,3 +132,26 @@ def __init__(self, error_string: str, parent=None) -> None:
self.setDetailedText(f"System Diagnostic Logs:\n{error_string}")

self.setStandardButtons(QMessageBox.StandardButton.Ok)


class BusyStatusDialog(QDialog):
def __init__(self, title: str, message: str, parent=None) -> None:
super().__init__(parent)
self.setWindowTitle(title)
self.setModal(True)

layout = QVBoxLayout(self)

self._label = QLabel(message, self)
self._label.setWordWrap(True)
layout.addWidget(self._label)

self._progress = QProgressBar(self)
self._progress.setRange(0, 0)
self._progress.setTextVisible(False)
layout.addWidget(self._progress)

self.setMinimumWidth(320)

Comment on lines +137 to +155
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

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

BusyStatusDialog can be dismissed via the window close button / Esc, which would hide the only user-visible feedback while inference is still running and controls remain disabled. Consider disabling the close affordances (e.g., remove the close button via window flags and ignore close/escape events) so the dialog cannot be dismissed while in a busy state.

Copilot uses AI. Check for mistakes.
def set_message(self, message: str) -> None:
self._label.setText(message)
47 changes: 47 additions & 0 deletions annotation_tool/ui/localization/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
localization_label_text_hex,
normalize_hex_color,
)
from ui.dialogs import BusyStatusDialog
from utils import resource_path


Expand Down Expand Up @@ -400,6 +401,7 @@ def update_schema(self, label_definitions):
"scroll": scroll,
"labels": labels,
"label_colors": dict(definition.get("label_colors", {})),
"smart_infer_btn": smart_infer_btn,
}
smart_infer_btn.clicked.connect(lambda _, h=head: self.smartInferenceRequested.emit(h))
self._populate_head_buttons(head)
Expand Down Expand Up @@ -641,6 +643,15 @@ def _handle_add_head(self):
if ok and name.strip():
self.headAdded.emit(name.strip())

def set_inference_loading(self, is_loading: bool):
self._tabs.setEnabled(not is_loading)
for page_info in self._head_pages.values():
smart_infer_btn = page_info.get("smart_infer_btn")
if smart_infer_btn is None:
continue
smart_infer_btn.setEnabled(not is_loading)
smart_infer_btn.setText("Loading..." if is_loading else "Smart Inference")


class _AnnotationManagementAdapter(QObject):
def __init__(self, spotting_tabs: QTabWidget, parent=None):
Expand All @@ -650,6 +661,9 @@ def __init__(self, spotting_tabs: QTabWidget, parent=None):
def update_schema(self, label_definitions):
self.tabs.update_schema(label_definitions)

def set_inference_loading(self, is_loading: bool):
self.tabs.set_inference_loading(is_loading)


class _SmartWidgetAdapter(QObject):
"""
Expand Down Expand Up @@ -783,5 +797,38 @@ def __init__(self, parent=None):
self.btn_prev_event.clicked.connect(lambda: self.eventNavigateRequested.emit(-1))
self.btn_next_event.clicked.connect(lambda: self.eventNavigateRequested.emit(1))

self._inference_loading_dialog = BusyStatusDialog(
"Inference",
"Loading model and running inference. Please wait...",
self,
)
self._inference_loading_dialog.hide()

def show_inference_loading(self, is_loading: bool):
is_loading = bool(is_loading)
self.annot_mgmt.set_inference_loading(is_loading)
self.table.table.setEnabled(not is_loading)
self.btn_prev_event.setEnabled(not is_loading)
self.btn_next_event.setEnabled(not is_loading)

if self.table.btn_set_time is not None:
if is_loading:
self.table.btn_set_time.setEnabled(False)
else:
selection_model = self.table.table.selectionModel()
has_selection = bool(selection_model and selection_model.selectedRows())
self.table.btn_set_time.setEnabled(has_selection)

if is_loading:
self._inference_loading_dialog.set_message("Loading model and running inference. Please wait...")
self._inference_loading_dialog.show()
self._inference_loading_dialog.raise_()
self._inference_loading_dialog.activateWindow()
self.setCursor(Qt.CursorShape.WaitCursor)
return

self._inference_loading_dialog.hide()
self.unsetCursor()


__all__ = ["LocalizationAnnotationPanel"]
2 changes: 1 addition & 1 deletion docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Pre-built binaries for Windows, macOS, and Linux are available on the [GitHub Re

## Requirements

- Python **3.11** or later
- Python **3.12** or later
- PyQt6
- Other dependencies (see `requirements.txt`)

Expand Down
5 changes: 4 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
PyQt6
pyinstaller
torch-geometric==2.7.0
opensportslib
opensportslib==0.1.1
datasets==4.8.2
imageio-ffmpeg==0.6.0
lightning==2.6.1
tabulate==0.10.0
wandb
pytest
pytest-qt
Loading