From 6b9c1896f1815ed807672592dc8c481c73cd9249 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Wed, 25 Feb 2026 09:09:18 +0100 Subject: [PATCH 01/63] Upload classification smart annotation --- annotation_tool/config.yaml | 98 +++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 annotation_tool/config.yaml diff --git a/annotation_tool/config.yaml b/annotation_tool/config.yaml new file mode 100644 index 00000000..c118266e --- /dev/null +++ b/annotation_tool/config.yaml @@ -0,0 +1,98 @@ +TASK: classification + +DATA: + TASK: classification + dataset_name: mvfouls + data_dir: "" + data_modality: video + view_type: multi + num_classes: 8 + + # ⬅️ Added back the dummy train block + train: + type: annotations_train.json + video_path: "" + path: "" + dataloader: + batch_size: 1 + shuffle: true + num_workers: 0 + pin_memory: false + + # ⬅️ Added back the dummy valid block + valid: + type: annotations_valid.json + video_path: "" + path: "" + dataloader: + batch_size: 1 + num_workers: 0 + shuffle: false + + test: + type: annotations_test.json + video_path: "" + path: ./temp_workspace/temp_test.json + dataloader: + batch_size: 1 + num_workers: 0 + shuffle: false + + num_frames: 16 + input_fps: 25 + target_fps: 17 + start_frame: 63 + end_frame: 87 + frame_size: [224, 224] + + augmentations: + random_affine: false + random_perspective: false + random_rotation: false + color_jitter: false + random_horizontal_flip: false + random_crop: false +MODEL: + TASK: classification + type: custom + backbone: + type: mvit_v2_s + neck: + type: MV_Aggregate + agr_type: max + head: + type: MV_LinearLayer + pretrained_model: mvit_v2_s + +TRAIN: + monitor: loss + mode: min + enabled: false + use_weighted_sampler: false + use_weighted_loss: false + epochs: 1 + save_dir: ./temp_workspace/checkpoints + criterion: + type: CrossEntropyLoss + optimizer: + type: AdamW + lr: 0.0001 + backbone_lr: 0.00005 + head_lr: 0.001 + betas: [0.9, 0.999] # ⬅️ 刚才就是缺少了这个导致报错 + eps: 0.0000001 + weight_decay: 0.001 + amsgrad: false + scheduler: + type: StepLR + step_size: 3 + gamma: 0.1 + + +SYSTEM: + log_dir: ./logs + use_seed: false + seed: 42 + GPU: 0 # Mac 上没有多卡 Nvidia,设为 0 + device: cpu # 关键:由于你是 M1 芯片,为了防止 cuda 报错,先设为 cpu (或 auto 试试) + gpu_id: 0 \ No newline at end of file From 8709b8acd930b5e09609adfb12f6e74614a55e5e Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Wed, 25 Feb 2026 09:10:08 +0100 Subject: [PATCH 02/63] Update viewer.py Add smart annotation --- annotation_tool/viewer.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/annotation_tool/viewer.py b/annotation_tool/viewer.py index 087f4977..d0521550 100644 --- a/annotation_tool/viewer.py +++ b/annotation_tool/viewer.py @@ -7,6 +7,7 @@ from controllers.classification.class_annotation_manager import AnnotationManager from controllers.classification.class_navigation_manager import NavigationManager +from controllers.classification.inference_manager import InferenceManager from controllers.history_manager import HistoryManager from controllers.localization.localization_manager import LocalizationManager # Import Description Managers @@ -64,6 +65,7 @@ def __init__(self) -> None: # [NEW] Dense Description Controller self.dense_manager = DenseManager(self) + self.inference_manager = InferenceManager(self) # --- Local UI state (icons, etc.) --- bright_blue = QColor("#00BFFF") @@ -190,6 +192,9 @@ def connect_signals(self) -> None: cls_right.add_head_clicked.connect(self.annot_manager.handle_add_label_head) cls_right.remove_head_clicked.connect(self.annot_manager.handle_remove_label_head) + cls_right.smart_infer_requested.connect(self.inference_manager.start_inference) + cls_right.confirm_infer_requested.connect(lambda result_dict: self.annot_manager.save_manual_annotation()) + # Undo/redo for Class/Loc cls_right.undo_btn.clicked.connect(self.history_manager.perform_undo) cls_right.redo_btn.clicked.connect(self.history_manager.perform_redo) From 32bb1af0956e70c9361bf546264875ad2a31a25a Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Wed, 25 Feb 2026 09:11:26 +0100 Subject: [PATCH 03/63] Add inference control --- .../classification/inference_manager.py | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 annotation_tool/controllers/classification/inference_manager.py diff --git a/annotation_tool/controllers/classification/inference_manager.py b/annotation_tool/controllers/classification/inference_manager.py new file mode 100644 index 00000000..bcba119d --- /dev/null +++ b/annotation_tool/controllers/classification/inference_manager.py @@ -0,0 +1,189 @@ +import os +import json +import glob +import ssl +import copy +from PyQt6.QtCore import QThread, pyqtSignal, QObject +from PyQt6.QtWidgets import QMessageBox + +os.environ["WANDB_MODE"] = "disabled" +ssl._create_default_https_context = ssl._create_unverified_context + +from soccernetpro import model + +class InferenceWorker(QThread): + finished_signal = pyqtSignal(str, str, dict) + error_signal = pyqtSignal(str) + + def __init__(self, config_path, base_dir, action_id, json_path): + super().__init__() + self.config_path = config_path + self.base_dir = base_dir + self.action_id = str(action_id) + self.json_path = json_path + + self.label_map = { + '0': 'Challenge', '1': 'Dive', '2': 'Elbowing', '3': 'High leg', + '4': 'Holding', '5': 'Pushing', '6': 'Standing tackling', '7': 'Tackling' + } + + def run(self): + temp_json_path = "" + try: + with open(self.json_path, 'r', encoding='utf-8') as f: + original_data = json.load(f) + + target_item = None + for item in original_data.get("data", []): + if str(item.get("id")) == self.action_id: + target_item = copy.deepcopy(item) + break + + if not target_item: + raise ValueError(f"Action ID '{self.action_id}' not found in the original JSON file.") + + json_dir = os.path.dirname(os.path.abspath(self.json_path)) + for inp in target_item.get("inputs", []): + if "path" in inp: + video_rel_path = inp["path"] + if not os.path.isabs(video_rel_path): + abs_path = os.path.normpath(os.path.join(json_dir, video_rel_path)) + if not os.path.exists(abs_path): + raise FileNotFoundError(f"Video missing: {abs_path}\nPlease check if the video exists next to your JSON.") + inp["path"] = abs_path + + if "labels" not in target_item: + target_item["labels"] = {} + if "action" not in target_item["labels"]: + target_item["labels"]["action"] = {} + target_item["labels"]["action"]["label"] = "Tackling" + + temp_data = { + "version": original_data.get("version", "2.0"), + "task": original_data.get("task", "classification"), + "labels": original_data.get("labels", {}), + "data": [target_item] + } + + temp_dir = os.path.join(self.base_dir, "temp_workspace") + os.makedirs(temp_dir, exist_ok=True) + temp_json_path = os.path.join(temp_dir, f"temp_infer_{self.action_id}.json") + + with open(temp_json_path, 'w', encoding='utf-8') as f: + json.dump(temp_data, f, indent=4) + + myModel = model.classification(config=self.config_path) + metrics = myModel.infer( + test_set=temp_json_path, + pretrained="jeetv/snpro-classification-mvit" + ) + + checkpoint_dir = os.path.join(self.base_dir, "temp_workspace", "checkpoints") + search_pattern = os.path.join(checkpoint_dir, "**", "predictions_test_epoch_*.json") + pred_files = glob.glob(search_pattern, recursive=True) + + if not pred_files: + raise FileNotFoundError("Could not find the generated prediction JSON file.") + + latest_pred_file = max(pred_files, key=os.path.getctime) + with open(latest_pred_file, 'r', encoding='utf-8') as pf: + pred_data = json.load(pf) + + predicted_label_idx = None + confidence = 0.0 + raw_action_data = {} + + if "data" in pred_data and isinstance(pred_data["data"], list): + for item in pred_data["data"]: + if str(item.get("id")) == self.action_id: + try: + raw_action_data = item["labels"]["action"] + predicted_label_idx = str(raw_action_data["label"]).strip() + confidence = float(raw_action_data["confidence"]) + break + except KeyError: + pass + + if predicted_label_idx is None: + raise ValueError(f"Dataloader dropped the sample or prediction missing for ID '{self.action_id}'.") + + final_label = "Unknown" + valid_class_names = list(self.label_map.values()) + + if predicted_label_idx in valid_class_names: + final_label = predicted_label_idx + elif predicted_label_idx in self.label_map: + final_label = self.label_map[predicted_label_idx] + elif predicted_label_idx.endswith(".0"): + clean_idx = predicted_label_idx.replace(".0", "") + if clean_idx in self.label_map: + final_label = self.label_map[clean_idx] + else: + final_label = "Unknown" + + conf_dict = {} + if "confidences" in raw_action_data and isinstance(raw_action_data["confidences"], dict): + for k, v in raw_action_data["confidences"].items(): + key_name = self.label_map.get(str(k), str(k)) + conf_dict[key_name] = float(v) + else: + conf_dict[final_label] = confidence + remaining = max(0.0, 1.0 - confidence) + if remaining > 0.001: + conf_dict["Other Uncertainties"] = remaining + + self.finished_signal.emit("action", final_label, conf_dict) + + except Exception as e: + self.error_signal.emit(str(e)) + + finally: + if os.path.exists(temp_json_path): + try: + os.remove(temp_json_path) + except: + pass + + +class InferenceManager(QObject): + def __init__(self, main_window): + super().__init__() + self.main = main_window + self.ui = main_window.ui + self.base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) + self.config_path = os.path.join(self.base_dir, "config.yaml") + self.worker = None + + def start_inference(self): + if not os.path.exists(self.config_path): + QMessageBox.critical(self.main, "Error", f"config.yaml not found at:\n{self.config_path}") + return + + current_json_path = self.main.model.current_json_path + if not current_json_path or not os.path.exists(current_json_path): + QMessageBox.warning(self.main, "Warning", "Please import a valid JSON project first.") + return + + current_video_path = self.main.get_current_action_path() + if not current_video_path: + QMessageBox.warning(self.main, "Warning", "Please select an action from the list first.") + return + + action_id = self.main.model.action_path_to_name.get(current_video_path, "unknown") + + self.ui.classification_ui.right_panel.show_inference_loading(True) + + self.worker = InferenceWorker(self.config_path, self.base_dir, action_id, current_json_path) + self.worker.finished_signal.connect(self._on_inference_success) + self.worker.error_signal.connect(self._on_inference_error) + self.worker.start() + + def _on_inference_success(self, target_head, label, conf_dict): + self.ui.classification_ui.right_panel.display_inference_result(target_head, label, conf_dict) + self.worker = None + + def _on_inference_error(self, error_msg): + self.ui.classification_ui.right_panel.show_inference_loading(False) + QMessageBox.critical(self.main, "Inference Error", f"An error occurred during inference:\n\n{error_msg}") + print(f"[Smart Annotation Error] {error_msg}") + self.worker = None \ No newline at end of file From 1203c5d427ffc6168ea194601e7df5dca69a3c04 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Wed, 25 Feb 2026 09:12:08 +0100 Subject: [PATCH 04/63] Update editor.py Add smart annotation button and visualization --- .../ui/classification/event_editor/editor.py | 195 ++++++++++++++++-- 1 file changed, 180 insertions(+), 15 deletions(-) diff --git a/annotation_tool/ui/classification/event_editor/editor.py b/annotation_tool/ui/classification/event_editor/editor.py index 02c3c5d4..9b6df332 100644 --- a/annotation_tool/ui/classification/event_editor/editor.py +++ b/annotation_tool/ui/classification/event_editor/editor.py @@ -1,20 +1,137 @@ +import math from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, - QGroupBox, QLineEdit, QScrollArea, QFrame + QGroupBox, QLineEdit, QScrollArea, QFrame, QProgressBar, QToolTip ) -from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtCore import Qt, pyqtSignal, QRectF, QPointF +from PyQt6.QtGui import QPainter, QColor, QPen, QFont, QCursor from .dynamic_widgets import DynamicSingleLabelGroup, DynamicMultiLabelGroup +class NativeDonutChart(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.setMinimumSize(220, 220) + self.setMouseTracking(True) + + self.data_dict = {} + self.top_label = "" + self.slices_info = [] + self.setVisible(False) + + def update_chart(self, top_label, conf_dict): + self.top_label = top_label + + sorted_data = {top_label: conf_dict.get(top_label, 0.0)} + for k, v in conf_dict.items(): + if k != top_label: + sorted_data[k] = v + + self.data_dict = sorted_data + self.repaint() + self.setVisible(True) + + def paintEvent(self, event): + if not self.data_dict: + return + + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + margin = 30 + rect = QRectF(margin, margin, self.width() - margin * 2, self.height() - margin * 2) + pen_width = 35 + + start_angle_qt = 90 * 16 + self.slices_info.clear() + + color_top = QColor("#4CAF50") + colors_other = [QColor("#607D8B"), QColor("#78909C"), QColor("#546E7A"), QColor("#455A64")] + color_idx = 0 + + current_angle_deg = 0.0 + + for label, prob in self.data_dict.items(): + span_deg = prob * 360 + span_angle_qt = int(round(-span_deg * 16)) + + if span_angle_qt == 0: + continue + + color = color_top if label == self.top_label else colors_other[color_idx % len(colors_other)] + if label != self.top_label: + color_idx += 1 + + pen = QPen(color) + pen.setWidth(pen_width) + pen.setCapStyle(Qt.PenCapStyle.FlatCap) + painter.setPen(pen) + + painter.drawArc(rect, start_angle_qt, span_angle_qt) + + self.slices_info.append({ + "label": label, + "prob": prob, + "start_deg": current_angle_deg, + "end_deg": current_angle_deg + span_deg + }) + + start_angle_qt += span_angle_qt + current_angle_deg += span_deg + + painter.setPen(QColor("white")) + font = QFont("Arial", 12, QFont.Weight.Bold) + painter.setFont(font) + top_prob = self.data_dict.get(self.top_label, 0.0) + + text_rect = QRectF(0, 0, self.width(), self.height()) + painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, f"{self.top_label}\n{top_prob*100:.1f}%") + + def mouseMoveEvent(self, event): + if not self.data_dict: + return + + pos = event.position() + center_x = self.width() / 2 + center_y = self.height() / 2 + dx = pos.x() - center_x + dy = pos.y() - center_y + + distance = math.sqrt(dx**2 + dy**2) + radius = (self.width() - 60) / 2 + pen_width = 35 + + if distance < (radius - pen_width/2) or distance > (radius + pen_width/2): + QToolTip.hideText() + self.setCursor(Qt.CursorShape.ArrowCursor) + return + + angle_rad = math.atan2(dy, dx) + angle_deg = math.degrees(angle_rad) + 90 + if angle_deg < 0: + angle_deg += 360 + + hovered_text = None + for slice_info in self.slices_info: + if slice_info["start_deg"] <= angle_deg <= slice_info["end_deg"]: + hovered_text = f"{slice_info['label']}: {slice_info['prob']*100:.1f}%" + break + + if hovered_text: + self.setCursor(Qt.CursorShape.PointingHandCursor) + QToolTip.showText(event.globalPosition().toPoint(), hovered_text, self) + else: + self.setCursor(Qt.CursorShape.ArrowCursor) + QToolTip.hideText() + + class ClassificationEventEditor(QWidget): - """ - Right Panel for Classification Mode. - Renamed from ClassRightPanel to ClassificationEventEditor for consistency with folder name. - """ - add_head_clicked = pyqtSignal(str) remove_head_clicked = pyqtSignal(str) style_mode_changed = pyqtSignal(str) + + smart_infer_requested = pyqtSignal() + confirm_infer_requested = pyqtSignal(dict) def __init__(self, parent=None): super().__init__(parent) @@ -50,8 +167,8 @@ def __init__(self, parent=None): schema_layout.addWidget(self.add_head_btn) layout.addWidget(schema_box) - # 4. Dynamic Annotation Area - self.manual_box = QGroupBox("Annotations") + # --- 4. Hand Annotation --- + self.manual_box = QGroupBox("Hand Annotations") self.manual_box.setEnabled(False) manual_layout = QVBoxLayout(self.manual_box) @@ -65,22 +182,66 @@ def __init__(self, parent=None): scroll.setWidget(self.label_container) manual_layout.addWidget(scroll) + layout.addWidget(self.manual_box, 1) + + # --- 5. Smart Annotation --- + self.smart_box = QGroupBox("Smart Annotation") + smart_layout = QVBoxLayout(self.smart_box) + + self.btn_smart_infer = QPushButton("🚀 Run Smart Inference") + self.btn_smart_infer.setCursor(Qt.CursorShape.PointingHandCursor) + self.btn_smart_infer.clicked.connect(self.smart_infer_requested.emit) + + self.infer_progress = QProgressBar() + self.infer_progress.setRange(0, 0) + self.infer_progress.setVisible(False) + + self.chart_widget = NativeDonutChart() + smart_layout.addWidget(self.btn_smart_infer) + smart_layout.addWidget(self.infer_progress) + smart_layout.addWidget(self.chart_widget, alignment=Qt.AlignmentFlag.AlignCenter) # 居中 + layout.addWidget(self.smart_box) + btn_row = QHBoxLayout() - self.confirm_btn = QPushButton("Save Annotation") - self.clear_sel_btn = QPushButton("Clear Selection") + self.confirm_btn = QPushButton("✅ Confirm Annotation") + self.clear_sel_btn = QPushButton("🗑️ Clear Selection") self.confirm_btn.setProperty("class", "editor_save_btn") self.confirm_btn.setCursor(Qt.CursorShape.PointingHandCursor) self.clear_sel_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self.confirm_btn.clicked.connect(self.reset_smart_inference) + btn_row.addWidget(self.confirm_btn) btn_row.addWidget(self.clear_sel_btn) - manual_layout.addLayout(btn_row) - - layout.addWidget(self.manual_box, 1) + layout.addLayout(btn_row) self.label_groups = {} + def reset_smart_inference(self): + """重置智能推理区域的状态(清空扇形图、恢复按钮状态)""" + self.chart_widget.setVisible(False) + self.btn_smart_infer.setEnabled(True) + self.infer_progress.setVisible(False) + + def show_inference_loading(self, is_loading: bool): + self.btn_smart_infer.setEnabled(not is_loading) + self.infer_progress.setVisible(is_loading) + if is_loading: + self.chart_widget.setVisible(False) + + def display_inference_result(self, target_head: str, predicted_label: str, conf_dict: dict): + self.show_inference_loading(False) + self.chart_widget.update_chart(predicted_label, conf_dict) + + group = self.label_groups.get(target_head) + if group: + if hasattr(group, 'set_checked_label'): + group.set_checked_label(predicted_label) + elif hasattr(group, 'set_checked_labels'): + group.set_checked_labels([predicted_label]) + + def setup_dynamic_labels(self, label_definitions): while self.label_container_layout.count(): item = self.label_container_layout.takeAt(0) @@ -101,6 +262,8 @@ def setup_dynamic_labels(self, label_definitions): self.label_container_layout.addStretch() def set_annotation(self, data): + self.reset_smart_inference() + if not data: data = {} for head, group in self.label_groups.items(): val = data.get(head) @@ -121,8 +284,10 @@ def get_annotation(self): return result def clear_selection(self): + self.reset_smart_inference() + for group in self.label_groups.values(): if hasattr(group, 'set_checked_label'): group.set_checked_label(None) elif hasattr(group, 'set_checked_labels'): - group.set_checked_labels([]) \ No newline at end of file + group.set_checked_labels([]) From 96d3ece8564636ff1193a7a477f2a9475c851ea1 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Wed, 25 Feb 2026 09:16:37 +0100 Subject: [PATCH 05/63] Update release.yml Add yaml --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 69ad23a3..e1ae38f4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -75,6 +75,7 @@ jobs: --add-data "ui;ui" ` --add-data "controllers;controllers" ` --add-data "image;image" ` + --add-data "config.yaml;." ` "main.py" - name: Rename binary @@ -126,6 +127,7 @@ jobs: --add-data "ui:ui" --add-data "controllers:controllers" --add-data "image:image" + --add-data "config.yaml:." "main.py" - name: Zip macOS app @@ -178,6 +180,7 @@ jobs: --add-data "ui:ui" --add-data "controllers:controllers" --add-data "image:image" + --add-data "config.yaml:." "main.py" - name: Rename binary From 33db09a33f43c55de5e3b896411fc6c1666242cb Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Wed, 25 Feb 2026 09:16:55 +0100 Subject: [PATCH 06/63] Update ci.yml --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bee89977..bbfd4914 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,7 @@ jobs: --add-data "ui;ui" --add-data "controllers;controllers" --add-data "image;image" + --add-data "config.yaml;." "main.py" - name: Zip Windows binary (manual runs only) @@ -121,6 +122,7 @@ jobs: --add-data "ui:ui" --add-data "controllers:controllers" --add-data "image:image" + --add-data "config.yaml:." "main.py" - name: Zip macOS app (manual runs only) @@ -185,6 +187,7 @@ jobs: --add-data "ui:ui" --add-data "controllers:controllers" --add-data "image:image" + --add-data "config.yaml:." "main.py" - name: Zip Linux binary (manual runs only) From d3c5e1f93fc3ec2a2a5fa0a41619c35cddce0f50 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Wed, 25 Feb 2026 09:19:18 +0100 Subject: [PATCH 07/63] Update ci.yml --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbfd4914..2b299071 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: - main - dev-jintao + - smart-annotation workflow_dispatch: concurrency: From fed3c1974062360e88b7f57524abf7b67299d69c Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:07:11 +0100 Subject: [PATCH 08/63] Update requirements.txt Add soccernetPro Library --- annotation_tool/requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/annotation_tool/requirements.txt b/annotation_tool/requirements.txt index 69352a30..0c589895 100644 --- a/annotation_tool/requirements.txt +++ b/annotation_tool/requirements.txt @@ -1,2 +1,3 @@ PyQt6 -pyinstaller \ No newline at end of file +pyinstaller +soccernetpro From 39b98ccd5ebcde082b067eeee2b158d8baed5385 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:12:50 +0100 Subject: [PATCH 09/63] Update ci.yml Update Python version to 3.11 --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b299071..ce1f7647 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: "3.9" + python-version: "3.11" - name: Cache pip uses: actions/cache@v4 @@ -91,7 +91,7 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: "3.9" + python-version: "3.11" - name: Cache pip uses: actions/cache@v4 @@ -151,7 +151,7 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: "3.9" + python-version: "3.11" - name: Install system deps (Qt/OpenCV runtime) shell: bash From 905967ba3a95a59504e7eab383d9aa0b3b5b72d2 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:13:09 +0100 Subject: [PATCH 10/63] Update deploy_docs.yml Update Python version to 3.11 --- .github/workflows/deploy_docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index cf62697c..1a0a1195 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -22,7 +22,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.9" + python-version: "3.11" - name: Install dependencies run: | From 81e0a1e4c4d7f3989f5f9cf6bb802b6a54316f45 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:13:27 +0100 Subject: [PATCH 11/63] Update release.yml Update Python version to 3.11 --- .github/workflows/release.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e1ae38f4..06063fdf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,7 +52,7 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: "3.9" + python-version: "3.11" - name: Install requirements run: | @@ -105,7 +105,7 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: "3.9" + python-version: "3.11" - name: Install requirements run: | @@ -152,7 +152,7 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: "3.9" + python-version: "3.11" - name: Install system deps (Qt/OpenCV runtime) shell: bash From 9f06816c9a532093fb0150318cbc3689be292dd0 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:32:17 +0100 Subject: [PATCH 12/63] Update config.yaml Fix small bugs --- annotation_tool/config.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/annotation_tool/config.yaml b/annotation_tool/config.yaml index c118266e..cbdc0010 100644 --- a/annotation_tool/config.yaml +++ b/annotation_tool/config.yaml @@ -79,7 +79,7 @@ TRAIN: lr: 0.0001 backbone_lr: 0.00005 head_lr: 0.001 - betas: [0.9, 0.999] # ⬅️ 刚才就是缺少了这个导致报错 + betas: [0.9, 0.999] eps: 0.0000001 weight_decay: 0.001 amsgrad: false @@ -93,6 +93,6 @@ SYSTEM: log_dir: ./logs use_seed: false seed: 42 - GPU: 0 # Mac 上没有多卡 Nvidia,设为 0 - device: cpu # 关键:由于你是 M1 芯片,为了防止 cuda 报错,先设为 cpu (或 auto 试试) - gpu_id: 0 \ No newline at end of file + GPU: 0 + device: cpu + gpu_id: 0 From 74a67ad13e74885045dd9910feb723ac10ee8067 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:32:55 +0100 Subject: [PATCH 13/63] Update requirements.txt Update Soccernet Pro version --- annotation_tool/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/annotation_tool/requirements.txt b/annotation_tool/requirements.txt index 0c589895..a5d043d1 100644 --- a/annotation_tool/requirements.txt +++ b/annotation_tool/requirements.txt @@ -1,3 +1,3 @@ PyQt6 pyinstaller -soccernetpro +soccernetpro==0.0.1.dev11 From 5459df77aa690afecf8362735f70ede83c94f2c2 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:34:24 +0100 Subject: [PATCH 14/63] Update ci.yml --collect-all "soccernetpro" --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce1f7647..bec12c32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,7 @@ jobs: --add-data "controllers;controllers" --add-data "image;image" --add-data "config.yaml;." + --collect-all "soccernetpro" "main.py" - name: Zip Windows binary (manual runs only) @@ -124,6 +125,7 @@ jobs: --add-data "controllers:controllers" --add-data "image:image" --add-data "config.yaml:." + --collect-all "soccernetpro" "main.py" - name: Zip macOS app (manual runs only) @@ -189,6 +191,7 @@ jobs: --add-data "controllers:controllers" --add-data "image:image" --add-data "config.yaml:." + --collect-all "soccernetpro" "main.py" - name: Zip Linux binary (manual runs only) From a2a3a7376bba10b6052486ff09d1f779aaefbba8 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:35:55 +0100 Subject: [PATCH 15/63] Update release.yml Collect all of soccernetpro --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 06063fdf..2c840fff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -76,6 +76,7 @@ jobs: --add-data "controllers;controllers" ` --add-data "image;image" ` --add-data "config.yaml;." ` + --collect-all "soccernetpro" ` "main.py" - name: Rename binary @@ -128,6 +129,7 @@ jobs: --add-data "controllers:controllers" --add-data "image:image" --add-data "config.yaml:." + --collect-all "soccernetpro" "main.py" - name: Zip macOS app @@ -181,6 +183,7 @@ jobs: --add-data "controllers:controllers" --add-data "image:image" --add-data "config.yaml:." + --collect-all "soccernetpro" "main.py" - name: Rename binary From c1cad6fbbf23f67c57fb35486cb8289afedd8ffd Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:51:12 +0100 Subject: [PATCH 16/63] Update requirements.txt --- annotation_tool/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/annotation_tool/requirements.txt b/annotation_tool/requirements.txt index a5d043d1..54326883 100644 --- a/annotation_tool/requirements.txt +++ b/annotation_tool/requirements.txt @@ -1,3 +1,4 @@ PyQt6 pyinstaller +torch-geometric==2.7.0 soccernetpro==0.0.1.dev11 From 8c8f99ceb204dd7b5c04849d4b41091ed038ad88 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:54:42 +0100 Subject: [PATCH 17/63] Update ci.yml --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bec12c32..3f616649 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,7 @@ jobs: - name: Install requirements run: | python -m pip install --upgrade pip + pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu pip install -r requirements.txt - name: Cleanup before PyInstaller @@ -107,6 +108,7 @@ jobs: - name: Install requirements run: | python -m pip install --upgrade pip + pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu pip install -r requirements.txt - name: Cleanup before PyInstaller From 5e22aa4a14885fe815c18458a4345d52cc22680a Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:55:29 +0100 Subject: [PATCH 18/63] Update release.yml --- .github/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c840fff..ed677cc6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,6 +57,7 @@ jobs: - name: Install requirements run: | python -m pip install --upgrade pip + pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu pip install -r requirements.txt - name: Cleanup before PyInstaller @@ -165,6 +166,7 @@ jobs: - name: Install requirements run: | python -m pip install --upgrade pip + pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu pip install -r requirements.txt - name: Cleanup before PyInstaller From dda131795e9c01882b198b94fb52a4445cdeb11c Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:55:51 +0100 Subject: [PATCH 19/63] Update ci.yml --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f616649..546a85be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,7 +108,6 @@ jobs: - name: Install requirements run: | python -m pip install --upgrade pip - pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu pip install -r requirements.txt - name: Cleanup before PyInstaller @@ -175,6 +174,7 @@ jobs: - name: Install requirements run: | python -m pip install --upgrade pip + pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu pip install -r requirements.txt - name: Cleanup before PyInstaller From baf79daf7e401dfcec3be8c6f6551d11a0580267 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Wed, 25 Feb 2026 12:31:49 +0100 Subject: [PATCH 20/63] Update inference_manager.py --- .../classification/inference_manager.py | 43 ++++++++++++++----- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/annotation_tool/controllers/classification/inference_manager.py b/annotation_tool/controllers/classification/inference_manager.py index bcba119d..b31686be 100644 --- a/annotation_tool/controllers/classification/inference_manager.py +++ b/annotation_tool/controllers/classification/inference_manager.py @@ -3,6 +3,7 @@ import glob import ssl import copy +import uuid from PyQt6.QtCore import QThread, pyqtSignal, QObject from PyQt6.QtWidgets import QMessageBox @@ -29,7 +30,12 @@ def __init__(self, config_path, base_dir, action_id, json_path): def run(self): temp_json_path = "" + temp_config_path = "" try: + writable_dir = os.path.join(os.path.expanduser("~"), ".soccernet_workspace") + os.makedirs(writable_dir, exist_ok=True) + writable_dir_fwd = writable_dir.replace('\\', '/') + with open(self.json_path, 'r', encoding='utf-8') as f: original_data = json.load(f) @@ -65,20 +71,28 @@ def run(self): "data": [target_item] } - temp_dir = os.path.join(self.base_dir, "temp_workspace") - os.makedirs(temp_dir, exist_ok=True) - temp_json_path = os.path.join(temp_dir, f"temp_infer_{self.action_id}.json") + unique_id = uuid.uuid4().hex[:8] + temp_json_path = os.path.join(writable_dir, f"temp_infer_{self.action_id}_{unique_id}.json") with open(temp_json_path, 'w', encoding='utf-8') as f: json.dump(temp_data, f, indent=4) - myModel = model.classification(config=self.config_path) + with open(self.config_path, 'r', encoding='utf-8') as f: + config_text = f.read() + + config_text = config_text.replace('./temp_workspace', writable_dir_fwd) + + temp_config_path = os.path.join(writable_dir, f"temp_config_{unique_id}.yaml") + with open(temp_config_path, 'w', encoding='utf-8') as f: + f.write(config_text) + + myModel = model.classification(config=temp_config_path) metrics = myModel.infer( test_set=temp_json_path, pretrained="jeetv/snpro-classification-mvit" ) - checkpoint_dir = os.path.join(self.base_dir, "temp_workspace", "checkpoints") + checkpoint_dir = os.path.join(writable_dir, "checkpoints") search_pattern = os.path.join(checkpoint_dir, "**", "predictions_test_epoch_*.json") pred_files = glob.glob(search_pattern, recursive=True) @@ -139,10 +153,11 @@ def run(self): finally: if os.path.exists(temp_json_path): - try: - os.remove(temp_json_path) - except: - pass + try: os.remove(temp_json_path) + except: pass + if os.path.exists(temp_config_path): + try: os.remove(temp_config_path) + except: pass class InferenceManager(QObject): @@ -150,7 +165,13 @@ def __init__(self, main_window): super().__init__() self.main = main_window self.ui = main_window.ui - self.base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) + + import sys + if hasattr(sys, '_MEIPASS'): + self.base_dir = sys._MEIPASS + else: + self.base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) + self.config_path = os.path.join(self.base_dir, "config.yaml") self.worker = None @@ -186,4 +207,4 @@ def _on_inference_error(self, error_msg): self.ui.classification_ui.right_panel.show_inference_loading(False) QMessageBox.critical(self.main, "Inference Error", f"An error occurred during inference:\n\n{error_msg}") print(f"[Smart Annotation Error] {error_msg}") - self.worker = None \ No newline at end of file + self.worker = None From 2e9b057b5746f068b884b627b60e38220dfbea3e Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Wed, 25 Feb 2026 12:48:51 +0100 Subject: [PATCH 21/63] Update inference_manager.py --- .../controllers/classification/inference_manager.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/annotation_tool/controllers/classification/inference_manager.py b/annotation_tool/controllers/classification/inference_manager.py index b31686be..7db90476 100644 --- a/annotation_tool/controllers/classification/inference_manager.py +++ b/annotation_tool/controllers/classification/inference_manager.py @@ -80,9 +80,13 @@ def run(self): with open(self.config_path, 'r', encoding='utf-8') as f: config_text = f.read() + logs_dir_fwd = os.path.join(writable_dir, "logs").replace('\\', '/') + config_text = config_text.replace('./temp_workspace', writable_dir_fwd) + config_text = config_text.replace('./logs', logs_dir_fwd) temp_config_path = os.path.join(writable_dir, f"temp_config_{unique_id}.yaml") + with open(temp_config_path, 'w', encoding='utf-8') as f: f.write(config_text) From bf4c1e6baafae42041369d9f17702c0b2d0394d8 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:03:12 +0100 Subject: [PATCH 22/63] Update requirements.txt Add wandb --- annotation_tool/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/annotation_tool/requirements.txt b/annotation_tool/requirements.txt index 54326883..dd51e6eb 100644 --- a/annotation_tool/requirements.txt +++ b/annotation_tool/requirements.txt @@ -2,3 +2,4 @@ PyQt6 pyinstaller torch-geometric==2.7.0 soccernetpro==0.0.1.dev11 +wandb From 5376bb5d1d4cb035cd86db4587c97cc8cc216bca Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Wed, 25 Feb 2026 18:08:23 +0100 Subject: [PATCH 23/63] Update ci.yml --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 546a85be..2a8ede1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,6 +65,8 @@ jobs: --add-data "image;image" --add-data "config.yaml;." --collect-all "soccernetpro" + --collect-all "wandb" + --collect-all "torch_geometric" "main.py" - name: Zip Windows binary (manual runs only) @@ -127,6 +129,8 @@ jobs: --add-data "image:image" --add-data "config.yaml:." --collect-all "soccernetpro" + --collect-all "wandb" + --collect-all "torch_geometric" "main.py" - name: Zip macOS app (manual runs only) @@ -194,6 +198,8 @@ jobs: --add-data "image:image" --add-data "config.yaml:." --collect-all "soccernetpro" + --collect-all "wandb" + --collect-all "torch_geometric" "main.py" - name: Zip Linux binary (manual runs only) From e5f38a2f38cf68d81eaf05287f1d675f3d20cd24 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Wed, 25 Feb 2026 18:08:47 +0100 Subject: [PATCH 24/63] Update release.yml --- .github/workflows/release.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ed677cc6..2cb01f89 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,6 +78,8 @@ jobs: --add-data "image;image" ` --add-data "config.yaml;." ` --collect-all "soccernetpro" ` + --collect-all "wandb" ` + --collect-all "torch_geometric" ` "main.py" - name: Rename binary @@ -131,6 +133,8 @@ jobs: --add-data "image:image" --add-data "config.yaml:." --collect-all "soccernetpro" + --collect-all "wandb" + --collect-all "torch_geometric" "main.py" - name: Zip macOS app @@ -186,6 +190,8 @@ jobs: --add-data "image:image" --add-data "config.yaml:." --collect-all "soccernetpro" + --collect-all "wandb" + --collect-all "torch_geometric" "main.py" - name: Rename binary From 5d3fd6617e9c8fc9139af53ca2665a9b4076af77 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Wed, 25 Feb 2026 18:09:27 +0100 Subject: [PATCH 25/63] Update main.py freeze extra windows --- annotation_tool/main.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/annotation_tool/main.py b/annotation_tool/main.py index cdabad4c..c66d244c 100644 --- a/annotation_tool/main.py +++ b/annotation_tool/main.py @@ -1,9 +1,16 @@ +import os import sys +import multiprocessing + +os.environ["PYTORCH_JIT"] = "0" + from PyQt6.QtWidgets import QApplication from viewer import ActionClassifierApp if __name__ == '__main__': + multiprocessing.freeze_support() + app = QApplication(sys.argv) window = ActionClassifierApp() window.show() - sys.exit(app.exec()) \ No newline at end of file + sys.exit(app.exec()) From 7e05faea7192d053ed345277caa7809bbb4dd63d Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Wed, 25 Feb 2026 18:09:58 +0100 Subject: [PATCH 26/63] Update inference_manager.py --- .../controllers/classification/inference_manager.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/annotation_tool/controllers/classification/inference_manager.py b/annotation_tool/controllers/classification/inference_manager.py index 7db90476..8cacfa51 100644 --- a/annotation_tool/controllers/classification/inference_manager.py +++ b/annotation_tool/controllers/classification/inference_manager.py @@ -1,4 +1,5 @@ import os +import sys import json import glob import ssl @@ -34,7 +35,9 @@ def run(self): try: writable_dir = os.path.join(os.path.expanduser("~"), ".soccernet_workspace") os.makedirs(writable_dir, exist_ok=True) + writable_dir_fwd = writable_dir.replace('\\', '/') + logs_dir_fwd = os.path.join(writable_dir, "logs").replace('\\', '/') with open(self.json_path, 'r', encoding='utf-8') as f: original_data = json.load(f) @@ -80,13 +83,10 @@ def run(self): with open(self.config_path, 'r', encoding='utf-8') as f: config_text = f.read() - logs_dir_fwd = os.path.join(writable_dir, "logs").replace('\\', '/') - config_text = config_text.replace('./temp_workspace', writable_dir_fwd) - config_text = config_text.replace('./logs', logs_dir_fwd) + config_text = config_text.replace('./logs', logs_dir_fwd) temp_config_path = os.path.join(writable_dir, f"temp_config_{unique_id}.yaml") - with open(temp_config_path, 'w', encoding='utf-8') as f: f.write(config_text) @@ -96,6 +96,7 @@ def run(self): pretrained="jeetv/snpro-classification-mvit" ) + checkpoint_dir = os.path.join(writable_dir, "checkpoints") search_pattern = os.path.join(checkpoint_dir, "**", "predictions_test_epoch_*.json") pred_files = glob.glob(search_pattern, recursive=True) @@ -170,7 +171,6 @@ def __init__(self, main_window): self.main = main_window self.ui = main_window.ui - import sys if hasattr(sys, '_MEIPASS'): self.base_dir = sys._MEIPASS else: From cad4bf593febb41d08dfab0c46799e2f1e535b79 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:38:32 +0100 Subject: [PATCH 27/63] Update inference_manager.py Add smart batch annotation --- .../classification/inference_manager.py | 392 ++++++++++++++++-- 1 file changed, 346 insertions(+), 46 deletions(-) diff --git a/annotation_tool/controllers/classification/inference_manager.py b/annotation_tool/controllers/classification/inference_manager.py index 8cacfa51..ec4d5ae1 100644 --- a/annotation_tool/controllers/classification/inference_manager.py +++ b/annotation_tool/controllers/classification/inference_manager.py @@ -5,8 +5,10 @@ import ssl import copy import uuid +import re from PyQt6.QtCore import QThread, pyqtSignal, QObject from PyQt6.QtWidgets import QMessageBox +from utils import natural_sort_key os.environ["WANDB_MODE"] = "disabled" ssl._create_default_https_context = ssl._create_unverified_context @@ -17,12 +19,13 @@ class InferenceWorker(QThread): finished_signal = pyqtSignal(str, str, dict) error_signal = pyqtSignal(str) - def __init__(self, config_path, base_dir, action_id, json_path): + def __init__(self, config_path, base_dir, action_id, json_path, video_path): super().__init__() self.config_path = config_path self.base_dir = base_dir self.action_id = str(action_id) self.json_path = json_path + self.video_path = video_path self.label_map = { '0': 'Challenge', '1': 'Dive', '2': 'Elbowing', '3': 'High leg', @@ -39,43 +42,68 @@ def run(self): writable_dir_fwd = writable_dir.replace('\\', '/') logs_dir_fwd = os.path.join(writable_dir, "logs").replace('\\', '/') - with open(self.json_path, 'r', encoding='utf-8') as f: - original_data = json.load(f) + video_abs_path = self.video_path + if not os.path.isabs(video_abs_path): + if self.json_path and os.path.exists(self.json_path): + video_abs_path = os.path.join(os.path.dirname(self.json_path), self.video_path) + else: + video_abs_path = os.path.abspath(self.video_path) + + video_abs_path = os.path.normpath(video_abs_path).replace('\\', '/') + + if not os.path.exists(video_abs_path): + raise FileNotFoundError(f"Cannot find video file at absolute path:\n{video_abs_path}\nPlease ensure the file exists.") + original_data = {} target_item = None - for item in original_data.get("data", []): - if str(item.get("id")) == self.action_id: - target_item = copy.deepcopy(item) - break + + if self.json_path and os.path.exists(self.json_path): + with open(self.json_path, 'r', encoding='utf-8') as f: + original_data = json.load(f) + + for item in original_data.get("data", []): + if str(item.get("id")) == self.action_id: + target_item = copy.deepcopy(item) + break if not target_item: - raise ValueError(f"Action ID '{self.action_id}' not found in the original JSON file.") - - json_dir = os.path.dirname(os.path.abspath(self.json_path)) - for inp in target_item.get("inputs", []): - if "path" in inp: - video_rel_path = inp["path"] - if not os.path.isabs(video_rel_path): - abs_path = os.path.normpath(os.path.join(json_dir, video_rel_path)) - if not os.path.exists(abs_path): - raise FileNotFoundError(f"Video missing: {abs_path}\nPlease check if the video exists next to your JSON.") - inp["path"] = abs_path - - if "labels" not in target_item: - target_item["labels"] = {} - if "action" not in target_item["labels"]: - target_item["labels"]["action"] = {} - target_item["labels"]["action"]["label"] = "Tackling" + target_item = { + "id": self.action_id, + "inputs": [{"type": "video", "path": video_abs_path}], + "labels": { + "action": {"label": "Tackling", "confidence": 1.0} + } + } + else: + for inp in target_item.get("inputs", []): + inp["path"] = video_abs_path + if "type" not in inp: + inp["type"] = "video" + + if "labels" not in target_item: + target_item["labels"] = {} + if "action" not in target_item["labels"]: + target_item["labels"]["action"] = {"label": "Tackling"} + + global_labels = original_data.get("labels", {}) + if not isinstance(global_labels, dict): + global_labels = {} + + if "action" not in global_labels: + global_labels["action"] = { + "type": "single_label", + "labels": list(self.label_map.values()) + } temp_data = { "version": original_data.get("version", "2.0"), - "task": original_data.get("task", "classification"), - "labels": original_data.get("labels", {}), + "task": "classification", + "labels": global_labels, "data": [target_item] } unique_id = uuid.uuid4().hex[:8] - temp_json_path = os.path.join(writable_dir, f"temp_infer_{self.action_id}_{unique_id}.json") + temp_json_path = os.path.join(writable_dir, f"temp_infer_{unique_id}.json") with open(temp_json_path, 'w', encoding='utf-8') as f: json.dump(temp_data, f, indent=4) @@ -96,7 +124,6 @@ def run(self): pretrained="jeetv/snpro-classification-mvit" ) - checkpoint_dir = os.path.join(writable_dir, "checkpoints") search_pattern = os.path.join(checkpoint_dir, "**", "predictions_test_epoch_*.json") pred_files = glob.glob(search_pattern, recursive=True) @@ -112,16 +139,23 @@ def run(self): confidence = 0.0 raw_action_data = {} - if "data" in pred_data and isinstance(pred_data["data"], list): - for item in pred_data["data"]: - if str(item.get("id")) == self.action_id: - try: - raw_action_data = item["labels"]["action"] + pred_items = pred_data.get("data", []) + + if len(pred_items) == 1: + raw_action_data = pred_items[0].get("labels", {}).get("action", {}) + if "label" in raw_action_data: + predicted_label_idx = str(raw_action_data["label"]).strip() + confidence = float(raw_action_data.get("confidence", 0.0)) + else: + clean_action_id = re.sub(r'_view\d+', '', self.action_id) + for item in pred_items: + out_id = str(item.get("id")) + if out_id == self.action_id or out_id == clean_action_id: + raw_action_data = item.get("labels", {}).get("action", {}) + if "label" in raw_action_data: predicted_label_idx = str(raw_action_data["label"]).strip() - confidence = float(raw_action_data["confidence"]) - break - except KeyError: - pass + confidence = float(raw_action_data.get("confidence", 0.0)) + break if predicted_label_idx is None: raise ValueError(f"Dataloader dropped the sample or prediction missing for ID '{self.action_id}'.") @@ -165,6 +199,152 @@ def run(self): except: pass +class BatchInferenceWorker(QThread): + finished_signal = pyqtSignal(dict, list) + error_signal = pyqtSignal(str) + + def __init__(self, config_path, base_dir, json_path, target_clips): + super().__init__() + self.config_path = config_path + self.base_dir = base_dir + self.json_path = json_path + self.target_clips = target_clips + + self.label_map = { + '0': 'Challenge', '1': 'Dive', '2': 'Elbowing', '3': 'High leg', + '4': 'Holding', '5': 'Pushing', '6': 'Standing tackling', '7': 'Tackling' + } + + def _map_label(self, raw_label): + valid_class_names = list(self.label_map.values()) + if raw_label in valid_class_names: return raw_label + elif raw_label in self.label_map: return self.label_map[raw_label] + elif raw_label.endswith(".0"): + clean_idx = raw_label.replace(".0", "") + if clean_idx in self.label_map: return self.label_map[clean_idx] + return "Unknown" + + def run(self): + temp_json_path = "" + temp_config_path = "" + try: + writable_dir = os.path.join(os.path.expanduser("~"), ".soccernet_workspace") + os.makedirs(writable_dir, exist_ok=True) + + writable_dir_fwd = writable_dir.replace('\\', '/') + logs_dir_fwd = os.path.join(writable_dir, "logs").replace('\\', '/') + + data_items = [] + for clip in self.target_clips: + inputs = [] + for path in clip['paths']: + video_abs_path = path + if not os.path.isabs(video_abs_path): + if self.json_path and os.path.exists(self.json_path): + video_abs_path = os.path.join(os.path.dirname(self.json_path), video_abs_path) + else: + video_abs_path = os.path.abspath(video_abs_path) + video_abs_path = os.path.normpath(video_abs_path).replace('\\', '/') + inputs.append({"type": "video", "path": video_abs_path}) + + safe_gt = clip['gt'] if clip['gt'] else "Tackling" + + item = { + "id": clip['id'], + "inputs": inputs, + "labels": {"action": {"label": safe_gt, "confidence": 1.0}} + } + data_items.append(item) + + global_labels = { + "action": { + "type": "single_label", + "labels": list(self.label_map.values()) + } + } + + temp_data = { + "version": "2.0", + "task": "classification", + "labels": global_labels, + "data": data_items + } + + unique_id = uuid.uuid4().hex[:8] + temp_json_path = os.path.join(writable_dir, f"temp_batch_infer_{unique_id}.json") + + with open(temp_json_path, 'w', encoding='utf-8') as f: + json.dump(temp_data, f, indent=4) + + with open(self.config_path, 'r', encoding='utf-8') as f: + config_text = f.read() + + config_text = config_text.replace('./temp_workspace', writable_dir_fwd) + config_text = config_text.replace('./logs', logs_dir_fwd) + + temp_config_path = os.path.join(writable_dir, f"temp_batch_config_{unique_id}.yaml") + with open(temp_config_path, 'w', encoding='utf-8') as f: + f.write(config_text) + + myModel = model.classification(config=temp_config_path) + metrics = myModel.infer( + test_set=temp_json_path, + pretrained="jeetv/snpro-classification-mvit" + ) + if not metrics: metrics = {} + + checkpoint_dir = os.path.join(writable_dir, "checkpoints") + search_pattern = os.path.join(checkpoint_dir, "**", "predictions_test_epoch_*.json") + pred_files = glob.glob(search_pattern, recursive=True) + + if not pred_files: + raise FileNotFoundError("Could not find the generated prediction JSON file.") + + latest_pred_file = max(pred_files, key=os.path.getctime) + with open(latest_pred_file, 'r', encoding='utf-8') as pf: + pred_data = json.load(pf) + + pred_items = pred_data.get("data", []) + + out_dict = {} + for item in pred_items: + out_id = str(item.get("id")) + raw_action = item.get("labels", {}).get("action", {}) + raw_label = str(raw_action.get("label", "")).strip() + conf = float(raw_action.get("confidence", 0.0)) + out_dict[out_id] = (self._map_label(raw_label), conf) + + results = [] + for clip in self.target_clips: + aid = clip['id'] + clean_id = os.path.splitext(aid)[0] + + pred_label, conf = out_dict.get(aid, (None, 0.0)) + if pred_label is None: + pred_label, conf = out_dict.get(clean_id, ("Unknown", 0.0)) + + results.append({ + 'id': aid, + 'gt': clip['gt'], + 'pred': pred_label, + 'conf': conf, + 'original_items': clip['original_items'] + }) + + self.finished_signal.emit(metrics, results) + + except Exception as e: + self.error_signal.emit(str(e)) + + finally: + if os.path.exists(temp_json_path): + try: os.remove(temp_json_path) + except: pass + if os.path.exists(temp_config_path): + try: os.remove(temp_config_path) + except: pass + + class InferenceManager(QObject): def __init__(self, main_window): super().__init__() @@ -178,27 +358,27 @@ def __init__(self, main_window): self.config_path = os.path.join(self.base_dir, "config.yaml") self.worker = None + self.batch_worker = None + + self.ui.classification_ui.right_panel.batch_run_requested.connect(self.start_batch_inference) + self.ui.classification_ui.right_panel.batch_confirm_requested.connect(self.confirm_batch_inference) def start_inference(self): if not os.path.exists(self.config_path): QMessageBox.critical(self.main, "Error", f"config.yaml not found at:\n{self.config_path}") return - current_json_path = self.main.model.current_json_path - if not current_json_path or not os.path.exists(current_json_path): - QMessageBox.warning(self.main, "Warning", "Please import a valid JSON project first.") - return - + current_json_path = self.main.model.current_json_path current_video_path = self.main.get_current_action_path() if not current_video_path: - QMessageBox.warning(self.main, "Warning", "Please select an action from the list first.") + QMessageBox.warning(self.main, "Warning", "Please select an action/video from the list first.") return - action_id = self.main.model.action_path_to_name.get(current_video_path, "unknown") + action_id = self.main.model.action_path_to_name.get(current_video_path, os.path.basename(current_video_path)) self.ui.classification_ui.right_panel.show_inference_loading(True) - self.worker = InferenceWorker(self.config_path, self.base_dir, action_id, current_json_path) + self.worker = InferenceWorker(self.config_path, self.base_dir, action_id, current_json_path, current_video_path) self.worker.finished_signal.connect(self._on_inference_success) self.worker.error_signal.connect(self._on_inference_error) self.worker.start() @@ -210,5 +390,125 @@ def _on_inference_success(self, target_head, label, conf_dict): def _on_inference_error(self, error_msg): self.ui.classification_ui.right_panel.show_inference_loading(False) QMessageBox.critical(self.main, "Inference Error", f"An error occurred during inference:\n\n{error_msg}") - print(f"[Smart Annotation Error] {error_msg}") self.worker = None + + def start_batch_inference(self, start_idx: int, end_idx: int): + if not os.path.exists(self.config_path): + QMessageBox.critical(self.main, "Error", f"config.yaml not found at:\n{self.config_path}") + return + + sorted_items = sorted(self.main.model.action_item_data, key=lambda x: natural_sort_key(x.get('name', ''))) + + action_groups = {} + for item in sorted_items: + base_id = re.sub(r'_view\d+', '', item['name']) + if base_id not in action_groups: + action_groups[base_id] = [] + action_groups[base_id].append(item) + + sorted_base_ids = list(action_groups.keys()) + max_idx = len(sorted_base_ids) - 1 + + if start_idx < 0 or end_idx > max_idx or start_idx > end_idx: + QMessageBox.warning(self.main, "Invalid Range", f"Please enter a valid range between 0 and {max_idx}.") + return + + target_base_ids = sorted_base_ids[start_idx : end_idx + 1] + + target_clips = [] + for base_id in target_base_ids: + items = action_groups[base_id] + paths = [it['path'] for it in items] + + # Ground Truth + gt_label = "" + for it in items: + ann = self.main.model.manual_annotations.get(it['path'], {}) + if 'action' in ann: + gt_label = ann['action'] + break + + target_clips.append({'id': base_id, 'paths': paths, 'gt': gt_label, 'original_items': items}) + + self.ui.classification_ui.right_panel.show_inference_loading(True) + self.batch_worker = BatchInferenceWorker(self.config_path, self.base_dir, self.main.model.current_json_path, target_clips) + self.batch_worker.finished_signal.connect(self._on_batch_inference_success) + self.batch_worker.error_signal.connect(self._on_batch_inference_error) + self.batch_worker.start() + + def _on_batch_inference_success(self, metrics: dict, results_list: list): + text = "OVERALL ACCURACY METRICS:\n" + text += f"- Top_2_accuracy: {metrics.get('top_2_accuracy', 0.0):.4f}\n" + text += f"- Accuracy: {metrics.get('accuracy', 0.0):.4f}\n" + text += f"- Balanced accuracy: {metrics.get('balanced_accuracy', 0.0):.4f}\n" + text += f"- F1: {metrics.get('f1', 0.0):.4f}\n" + text += f"- Precision: {metrics.get('precision', 0.0):.4f}\n" + text += f"- Recall: {metrics.get('recall', 0.0):.4f}\n\n" + + batch_predictions = {} + for r in results_list: + gt_str = r['gt'] if r['gt'] else "None" + + if gt_str == "None": + match_str = "N/A" + elif gt_str == r['pred']: + match_str = "Match ✅" + else: + match_str = "Mismatch ❌" + + text += f"Video ID: {r['id']} - Ground Truth: {gt_str} -- Predicted: {r['pred']} (Confidence: {r['conf']*100:.1f}%) ({match_str})\n\n" + + for item in r['original_items']: + batch_predictions[item['path']] = r['pred'] + + self.ui.classification_ui.right_panel.display_batch_inference_result(text, batch_predictions) + self.batch_worker = None + + def _on_batch_inference_error(self, error_msg): + self.ui.classification_ui.right_panel.show_inference_loading(False) + QMessageBox.critical(self.main, "Batch Inference Error", f"An error occurred during batch inference:\n\n{error_msg}") + self.batch_worker = None + + def confirm_batch_inference(self, results: dict): + from models import CmdType + import copy + + batch_changes = {} + applied_count = 0 + + # 1. Collect old and new states for all affected videos into a single dictionary + for path, label in results.items(): + old_data = copy.deepcopy(self.main.model.manual_annotations.get(path)) + + new_data = copy.deepcopy(old_data) if old_data else {} + new_data['action'] = label + + # Only record if there is an actual change + if old_data != new_data: + batch_changes[path] = { + 'old_data': old_data, + 'new_data': new_data + } + + # If nothing actually changed, just return + if not batch_changes: + self.main.show_temp_msg("Batch Annotation", "No new labels to apply.") + return + + # 2. Push the ENTIRE batch as a SINGLE command to the undo stack + self.main.model.push_undo( + CmdType.BATCH_ANNOTATION_CONFIRM, + batch_changes=batch_changes + ) + + # 3. Actually apply the changes to the model + for path, changes in batch_changes.items(): + self.main.model.manual_annotations[path] = changes['new_data'] + self.main.update_action_item_status(path) + applied_count += 1 + + # 4. Update UI global states + if applied_count > 0: + self.main.model.is_data_dirty = True + self.main.update_save_export_button_state() + self.main.show_temp_msg("Batch Annotation", f"Applied labels to {applied_count} items. (1 Undo Step)") From d2362375c2776d4c5bfa8b7b7b4508b15cfb685d Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:40:08 +0100 Subject: [PATCH 28/63] Update editor.py Add smart batch annotation and statistic demonstration. --- .../ui/classification/event_editor/editor.py | 76 +++++++++++++++++-- 1 file changed, 69 insertions(+), 7 deletions(-) diff --git a/annotation_tool/ui/classification/event_editor/editor.py b/annotation_tool/ui/classification/event_editor/editor.py index 9b6df332..6e4a9e78 100644 --- a/annotation_tool/ui/classification/event_editor/editor.py +++ b/annotation_tool/ui/classification/event_editor/editor.py @@ -1,7 +1,7 @@ import math from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, - QGroupBox, QLineEdit, QScrollArea, QFrame, QProgressBar, QToolTip + QGroupBox, QLineEdit, QScrollArea, QFrame, QProgressBar, QToolTip, QTextEdit ) from PyQt6.QtCore import Qt, pyqtSignal, QRectF, QPointF from PyQt6.QtGui import QPainter, QColor, QPen, QFont, QCursor @@ -132,12 +132,18 @@ class ClassificationEventEditor(QWidget): smart_infer_requested = pyqtSignal() confirm_infer_requested = pyqtSignal(dict) + + batch_run_requested = pyqtSignal(int, int) + batch_confirm_requested = pyqtSignal(dict) def __init__(self, parent=None): super().__init__(parent) self.setFixedWidth(350) layout = QVBoxLayout(self) + self.is_batch_mode_active = False + self.pending_batch_results = {} + # 1. Undo/Redo Controls h_undo = QHBoxLayout() self.undo_btn = QPushButton("Undo") @@ -188,19 +194,51 @@ def __init__(self, parent=None): self.smart_box = QGroupBox("Smart Annotation") smart_layout = QVBoxLayout(self.smart_box) - self.btn_smart_infer = QPushButton("🚀 Run Smart Inference") + # Two Buttons + btn_h_layout = QHBoxLayout() + self.btn_smart_infer = QPushButton("🚀Single Inference") self.btn_smart_infer.setCursor(Qt.CursorShape.PointingHandCursor) self.btn_smart_infer.clicked.connect(self.smart_infer_requested.emit) + self.btn_batch_infer = QPushButton("🚀Batch Inference") + self.btn_batch_infer.setCursor(Qt.CursorShape.PointingHandCursor) + self.btn_batch_infer.clicked.connect(lambda: self.batch_input_widget.setVisible(not self.batch_input_widget.isVisible())) + + btn_h_layout.addWidget(self.btn_smart_infer) + btn_h_layout.addWidget(self.btn_batch_infer) + smart_layout.addLayout(btn_h_layout) + + # Input Box + self.batch_input_widget = QWidget() + h_batch = QHBoxLayout(self.batch_input_widget) + h_batch.setContentsMargins(0, 5, 0, 5) + self.spin_start = QLineEdit() + self.spin_start.setPlaceholderText("Start Idx") + self.spin_end = QLineEdit() + self.spin_end.setPlaceholderText("End Idx") + self.btn_run_batch = QPushButton("Run Batch") + self.btn_run_batch.setCursor(Qt.CursorShape.PointingHandCursor) + self.btn_run_batch.clicked.connect(self._on_run_batch_clicked) + h_batch.addWidget(self.spin_start) + h_batch.addWidget(self.spin_end) + h_batch.addWidget(self.btn_run_batch) + self.batch_input_widget.setVisible(False) + self.infer_progress = QProgressBar() self.infer_progress.setRange(0, 0) self.infer_progress.setVisible(False) self.chart_widget = NativeDonutChart() - smart_layout.addWidget(self.btn_smart_infer) + self.batch_result_text = QTextEdit() + self.batch_result_text.setReadOnly(True) + self.batch_result_text.setVisible(False) + self.batch_result_text.setMinimumHeight(200) + + smart_layout.addWidget(self.batch_input_widget) smart_layout.addWidget(self.infer_progress) - smart_layout.addWidget(self.chart_widget, alignment=Qt.AlignmentFlag.AlignCenter) # 居中 + smart_layout.addWidget(self.chart_widget, alignment=Qt.AlignmentFlag.AlignCenter) + smart_layout.addWidget(self.batch_result_text) layout.addWidget(self.smart_box) btn_row = QHBoxLayout() @@ -210,7 +248,7 @@ def __init__(self, parent=None): self.confirm_btn.setCursor(Qt.CursorShape.PointingHandCursor) self.clear_sel_btn.setCursor(Qt.CursorShape.PointingHandCursor) - self.confirm_btn.clicked.connect(self.reset_smart_inference) + self.confirm_btn.clicked.connect(self.on_confirm_clicked) btn_row.addWidget(self.confirm_btn) btn_row.addWidget(self.clear_sel_btn) @@ -218,20 +256,38 @@ def __init__(self, parent=None): self.label_groups = {} + def _on_run_batch_clicked(self): + try: + start_idx = int(self.spin_start.text().strip()) + end_idx = int(self.spin_end.text().strip()) + self.batch_run_requested.emit(start_idx, end_idx) + except ValueError: + pass + + def on_confirm_clicked(self): + if self.is_batch_mode_active: + self.batch_confirm_requested.emit(self.pending_batch_results) + self.reset_smart_inference() + def reset_smart_inference(self): - """重置智能推理区域的状态(清空扇形图、恢复按钮状态)""" + self.is_batch_mode_active = False self.chart_widget.setVisible(False) + self.batch_result_text.setVisible(False) self.btn_smart_infer.setEnabled(True) + self.btn_batch_infer.setEnabled(True) self.infer_progress.setVisible(False) def show_inference_loading(self, is_loading: bool): self.btn_smart_infer.setEnabled(not is_loading) + self.btn_batch_infer.setEnabled(not is_loading) self.infer_progress.setVisible(is_loading) if is_loading: self.chart_widget.setVisible(False) + self.batch_result_text.setVisible(False) def display_inference_result(self, target_head: str, predicted_label: str, conf_dict: dict): self.show_inference_loading(False) + self.is_batch_mode_active = False self.chart_widget.update_chart(predicted_label, conf_dict) group = self.label_groups.get(target_head) @@ -241,6 +297,13 @@ def display_inference_result(self, target_head: str, predicted_label: str, conf_ elif hasattr(group, 'set_checked_labels'): group.set_checked_labels([predicted_label]) + def display_batch_inference_result(self, result_text: str, batch_predictions: dict): + self.show_inference_loading(False) + self.is_batch_mode_active = True + self.pending_batch_results = batch_predictions + self.chart_widget.setVisible(False) + self.batch_result_text.setText(result_text) + self.batch_result_text.setVisible(True) def setup_dynamic_labels(self, label_definitions): while self.label_container_layout.count(): @@ -285,7 +348,6 @@ def get_annotation(self): def clear_selection(self): self.reset_smart_inference() - for group in self.label_groups.values(): if hasattr(group, 'set_checked_label'): group.set_checked_label(None) From 72d075a7421bdf602b5d23dbe9ff59b8ffd644d7 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:40:57 +0100 Subject: [PATCH 29/63] Update history_manager.py Add undo/redo for smart annotation --- .../controllers/history_manager.py | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/annotation_tool/controllers/history_manager.py b/annotation_tool/controllers/history_manager.py index 2a126d33..d5414599 100644 --- a/annotation_tool/controllers/history_manager.py +++ b/annotation_tool/controllers/history_manager.py @@ -90,6 +90,31 @@ def _apply_state_change(self, cmd, is_undo): if path in self.model.manual_annotations: del self.model.manual_annotations[path] else: self.model.manual_annotations[path] = copy.deepcopy(data) self.main.refresh_ui_after_undo_redo(path) + + # ========================================== + # [NEW] Handle batch annotation confirm + # ========================================== + elif ctype == CmdType.BATCH_ANNOTATION_CONFIRM: + batch_changes = cmd['batch_changes'] # Retrieve the packed dictionary + + # Loop through every video that was modified in this batch + for path, changes in batch_changes.items(): + data = changes['old_data'] if is_undo else changes['new_data'] + + # Apply the data + if data: + self.model.manual_annotations[path] = copy.deepcopy(data) + else: + if path in self.model.manual_annotations: + del self.model.manual_annotations[path] + + # Update the checkmark status in the Tree UI for this video + self.main.update_action_item_status(path) + + # Refresh the right panel if the currently selected item was affected + self._refresh_active_view() + # ========================================== + elif ctype == CmdType.UI_CHANGE: path = cmd['path'] @@ -378,4 +403,4 @@ def _apply_state_change(self, cmd, is_undo): if evt.get('head') == head and evt.get('label') == src: evt['label'] = dst - self._refresh_active_view() \ No newline at end of file + self._refresh_active_view() From 8c5b58e4ac7a9d3a93d114a71069524331e22e1a Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:41:34 +0100 Subject: [PATCH 30/63] Update app_state.py Add redo/undo status to smart annotation --- annotation_tool/models/app_state.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/annotation_tool/models/app_state.py b/annotation_tool/models/app_state.py index 284e79a3..83fb89ea 100644 --- a/annotation_tool/models/app_state.py +++ b/annotation_tool/models/app_state.py @@ -8,6 +8,7 @@ class CmdType(Enum): # --- Classification commands --- ANNOTATION_CONFIRM = auto() # Persist a user-confirmed annotation to the model + BATCH_ANNOTATION_CONFIRM = auto() # [NEW] Persist a batch of annotations as a single action UI_CHANGE = auto() # Fine-grained UI toggle (radio/checkbox changes) # --- Shared schema commands (used by both modes) --- @@ -792,4 +793,4 @@ def _fmt(title, lst): if warn_duplicates: warnings.append(_fmt("Duplicate dense captions found", warn_duplicates)) - return True, "", "\n\n".join(warnings) \ No newline at end of file + return True, "", "\n\n".join(warnings) From 50b864d68ff04de9b7da2d115aeab71b77f704f6 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Fri, 6 Mar 2026 23:41:07 +0100 Subject: [PATCH 31/63] Update README.md Update "SoccernetPro" to "VideoAnnotationTool" --- README.md | 39 ++++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 4c8b24ae..989fb3b4 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ -# SoccerNetPro Analyzer (UI) +# Video Annotation Tool (UI) [![Documentation Status](https://img.shields.io/badge/docs-online-brightgreen)](https://opensportslab.github.io/soccernetpro-ui/) -A **PyQt6-based GUI** for analyzing and annotating **SoccerNetPro / action spotting** datasets (OpenSportsLab). +A **PyQt6-based GUI** for analyzing and annotating **OSL format** datasets (OpenSportsLab). --- ## Features -- Open and visualize SoccerNetPro-style data and annotations. +- Open and visualize OSL-style data and annotations. - Annotate and edit events/actions with a user-friendly GUI. - Manage labels/categories and export results for downstream tasks. - Easy to extend with additional viewers, overlays, and tools. @@ -24,16 +24,16 @@ We recommend using [Anaconda](https://www.anaconda.com/) or [Miniconda](https:// ### Step 0 – Clone the repository ```bash -git clone https://github.com/OpenSportsLab/soccernetpro-ui.git -cd soccernetpro-ui +git clone https://github.com/OpenSportsLab/VideoAnnotationTool.git +cd VideoAnnotationTool ``` ### Step 1 – Create a new Conda environment ```bash -conda create -n soccernetpro-ui python=3.9 -y -conda activate soccernetpro-ui +conda create -n VideoAnnotationTool python=3.9 -y +conda activate VideoAnnotationTool ``` @@ -63,14 +63,14 @@ This project provides **test datasets** for multiple tasks, including: - **Description (Video Captioning)** - **Dense Description (Dense Video Captioning)** -More details are available at: [`/test_data`](https://github.com/OpenSportsLab/soccernetpro-ui/tree/main/test_data) +More details are available at: [`/test_data`](https://github.com/OpenSportsLab/VideoAnnotationTool/tree/main/test_data) > ⚠️ **Important** > For all tasks, the corresponding **JSON annotation file must be placed in the same directory** > as the referenced data folders (e.g., `test/`, `germany_bundesliga/`, etc.). > Otherwise, the GUI may not load the data correctly due to relative path mismatches. -Some Hugging Face datasets (including SoccerNetPro datasets) are **restricted / gated**. Therefore you must: +Some Hugging Face datasets (including OSL datasets) are **restricted / gated**. Therefore you must: 1. Have access to the dataset on Hugging Face 2. Be authenticated locally using your Hugging Face account (`hf auth login`) @@ -151,6 +151,7 @@ python test_data/download_osl_hf.py \ **Data location (HuggingFace):** - [Localization Dataset (Soccer)](https://huggingface.co/datasets/OpenSportsLab/soccernetpro-localization-snas) - [Localization Dataset (Tennis)](https://huggingface.co/datasets/OpenSportsLab/soccernetpro-localization-tennis) +- [Localization Dataset (gymnastics)](https://huggingface.co/datasets/OpenSportsLab/soccernetpro-localization-gymnastics) Each folder (e.g., `england efl/`) contains video clips for localization testing. @@ -199,7 +200,7 @@ Test_Data/Description/XFoul/ --- -## 🟧 Dense Description (Dense Video Captioning) – SoccerNetPro SNDVC +## 🟧 Dense Description (Dense Video Captioning) **Dataset (Hugging Face):** [Dense—Description Dataset](https://huggingface.co/datasets/OpenSportsLab/soccernetpro-densedescription-sndvc) @@ -266,7 +267,7 @@ The commands below assume you run them **from the repository root**. cd annotation_tool python -m PyInstaller --noconfirm --clean --windowed \ - --name "SoccerNetProAnalyzer" \ + --name "VideoAnnotationTool" \ --add-data "style:style" \ --add-data "ui:ui" \ --add-data "controllers:controllers" \ @@ -275,7 +276,7 @@ python -m PyInstaller --noconfirm --clean --windowed \ Output: -* `annotation_tool/dist/SoccerNetProAnalyzer.app` +* `annotation_tool/dist/VideoAnnotationTool.app` --- @@ -287,7 +288,7 @@ Output: cd annotation_tool python -m PyInstaller --noconfirm --clean --windowed --onefile \ - --name "SoccerNetProAnalyzer" \ + --name "VideoAnnotationTool" \ --add-data "style:style" \ --add-data "ui:ui" \ --add-data "controllers:controllers" \ @@ -296,7 +297,7 @@ python -m PyInstaller --noconfirm --clean --windowed --onefile \ Output: -* `annotation_tool/dist/SoccerNetProAnalyzer` +* `annotation_tool/dist/VideoAnnotationTool` #### Windows (PowerShell) @@ -307,7 +308,7 @@ On Windows, the `--add-data` separator is **`;`** (not `:`). cd annotation_tool python -m PyInstaller --noconfirm --clean --windowed --onefile ` - --name "SoccerNetProAnalyzer" ` + --name "VideoAnnotationTool" ` --add-data "style;style" ` --add-data "ui;ui" ` --add-data "controllers;controllers" ` @@ -316,7 +317,7 @@ python -m PyInstaller --noconfirm --clean --windowed --onefile ` Output: -* `annotation_tool\dist\SoccerNetProAnalyzer.exe` +* `annotation_tool\dist\VideoAnnotationTool.exe` --- @@ -356,10 +357,10 @@ There is also a standalone build workflow that can be triggered manually: ## 📜 License -This Soccernet Pro project offers two licensing options to suit different needs: +This Video Annotation Tool project offers two licensing options to suit different needs: -* **AGPL-3.0 License**: This open-source license is ideal for students, researchers, and the community. It supports open collaboration and sharing. See the [`LICENSE.txt`](https://github.com/OpenSportsLab/soccernetpro-ui/blob/main/LICENSE.txt) file for full details. -* **Commercial License**: Designed for [`commercial use`](https://github.com/OpenSportsLab/soccernetpro-ui/blob/main/COMMERCIAL_LICENSE.md +* **AGPL-3.0 License**: This open-source license is ideal for students, researchers, and the community. It supports open collaboration and sharing. See the [`LICENSE.txt`](https://github.com/OpenSportsLab/VideoAnnotationTool/blob/main/LICENSE.txt) file for full details. +* **Commercial License**: Designed for [`commercial use`](https://github.com/OpenSportsLab/VideoAnnotationTool/blob/main/COMMERCIAL_LICENSE.md ), this option allows you to integrate this software into proprietary products and services without the open-source obligations of GPL-3.0. If your use case involves commercial deployment, please contact the maintainers to obtain a commercial license. **Contact:** OpenSportsLab / project maintainers. From 19f99d147b031d9a76a602cb4699363d6e79b68c Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Sat, 7 Mar 2026 13:53:05 +0100 Subject: [PATCH 32/63] Update viewer.py Updat to Video Annotation Tool --- annotation_tool/viewer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/annotation_tool/viewer.py b/annotation_tool/viewer.py index d0521550..c93ec3c1 100644 --- a/annotation_tool/viewer.py +++ b/annotation_tool/viewer.py @@ -34,7 +34,7 @@ class ActionClassifierApp(QMainWindow): def __init__(self) -> None: super().__init__() - self.setWindowTitle("SoccerNet Pro Analysis Tool") + self.setWindowTitle("Video Annotation Tool") self.setGeometry(100, 100, 600, 400) # --- MVC wiring --- From cf823bdda438aa9ab38f34cb3af557290c49d3e3 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Sat, 7 Mar 2026 13:53:35 +0100 Subject: [PATCH 33/63] Update welcome_widget.py Update to Video Annotation Tool --- annotation_tool/ui/common/welcome_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/annotation_tool/ui/common/welcome_widget.py b/annotation_tool/ui/common/welcome_widget.py index 93eab208..d99804f1 100644 --- a/annotation_tool/ui/common/welcome_widget.py +++ b/annotation_tool/ui/common/welcome_widget.py @@ -24,7 +24,7 @@ def __init__(self, parent=None): title_layout.setAlignment(Qt.AlignmentFlag.AlignHCenter) title_layout.setSpacing(15) - title = QLabel("SoccerNetPro Annotation Tool") + title = QLabel("Video Annotation Tool") title.setObjectName("welcome_title_lbl") self.logo_lbl = QLabel() From 7f0f32ecb0d34d5dd4f065a1612ed5d639fe3a61 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Sun, 8 Mar 2026 11:33:55 +0100 Subject: [PATCH 34/63] Update README.md Add smart annotation description --- annotation_tool/README.md | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/annotation_tool/README.md b/annotation_tool/README.md index c577db3e..6798e82f 100644 --- a/annotation_tool/README.md +++ b/annotation_tool/README.md @@ -1,6 +1,8 @@ # SoccerNet Pro Annotation Tool -This project is a professional video annotation desktop application built with **PyQt6**. It features a comprehensive **quad-mode** architecture supporting **Whole-Video Classification**, **Action Spotting (Localization)**, **Video Captioning (Description)**, and the newly integrated **Dense Video Captioning (Dense Description)**. +This project is a professional video annotation desktop application built with **PyQt6**. It features a comprehensive **quad-mode** architecture supporting **Whole-Video Classification**, **Action Spotting (Localization)**, **Video Captioning (Description)**, and the newly integrated **Dense Video Captioning (Dense Description)**. + +With the latest update, the Classification mode now features **AI-Powered Smart Annotation**, allowing users to leverage state-of-the-art `soccernetpro` models (e.g., MViT) to automatically infer actions via single or batch processing. The project follows a modular **MVC (Model-View-Controller)** design pattern to ensure strict separation of concerns. It leverages **Qt's Model/View architecture** for resource management and a unified **Media Controller** to ensure stable, high-performance video playback across all modalities. @@ -13,6 +15,7 @@ annotation_tool/ ├── main.py # Application entry point ├── viewer.py # Main Window controller (Orchestrator) ├── utils.py # Helper functions and constants +├── config.yaml # [NEW] Inference configuration for soccernetpro models ├── __init__.py # Package initialization │ ├── models/ # [Model Layer] Data Structures & State @@ -21,14 +24,18 @@ annotation_tool/ │ ├── controllers/ # [Controller Layer] Business Logic │ ├── router.py # Mode detection & Project lifecycle management -│ ├── history_manager.py # Universal Undo/Redo system +│ ├── history_manager.py # Universal Undo/Redo system (Supports Batch Annotations) │ ├── media_controller.py # Unified playback logic (Anti-freeze/Visual clearing) │ ├── classification/ # Logic for Classification mode +│ │ ├── class_annotation_manager.py # Manual label state management +│ │ ├── class_file_manager.py # JSON I/O for Classification tasks +│ │ ├── class_navigation_manager.py # Action tree navigation +│ │ └── inference_manager.py # [NEW] AI Smart Annotation (Single/Batch Inference) │ ├── localization/ # Logic for Action Spotting (Localization) mode │ ├── description/ # Logic for Global Captioning (Description) mode -│ └── dense_description/ # [NEW] Logic for Dense Captioning (Text-at-Timestamp) +│ └── dense_description/ # Logic for Dense Captioning (Text-at-Timestamp) │ ├── dense_manager.py # Core logic for dense annotations & UI sync -│ └── dense_file_manager.py # JSON I/O specifically for Dense tasks +│ └── dense_file_manager.py # JSON I/O specifically for Dense tasks │ ├── ui/ # [View Layer] Interface Definitions │ ├── common/ # Shared widgets (Main Window, Sidebar, Video Surface) @@ -37,9 +44,13 @@ annotation_tool/ │ │ ├── workspace.py # Unified 3-column skeleton │ │ └── dialogs.py # Project wizards and mode selectors │ ├── classification/ # UI specific to Classification +│ │ └── event_editor/ # Dynamic Schema Editor & [NEW] Smart Annotation UI +│ │ ├── dynamic_widgets.py # Single/Multi label dynamic radio & checkbox groups +│ │ ├── editor.py # Includes NativeDonutChart & Batch Progress UI +│ │ └── controls.py # Playback control bar │ ├── localization/ # UI specific to Localization (Timeline + Tabbed Spotting) │ ├── description/ # UI specific to Global Captioning (Full-video text) -│ └── dense_description/ # [NEW] UI specific to Dense Description +│ └── dense_description/ # UI specific to Dense Description │ └── event_editor/ │ ├── __init__.py # Right panel assembler for Dense mode │ ├── desc_input_widget.py # Text input & timestamp submission @@ -47,9 +58,7 @@ annotation_tool/ │ └── style/ # Visual theme assets └── style.qss # Centralized Dark mode stylesheet - ``` - --- ## 📝 Detailed Module Descriptions From 69cc1067cf0d733d496fe55c506bded7d05467d3 Mon Sep 17 00:00:00 2001 From: Jintao Ma Date: Mon, 9 Mar 2026 12:48:01 +0100 Subject: [PATCH 35/63] Update annotation_tool: Integrated Smart Annotation, Batch Processing, and UI compaction --- .DS_Store | Bin 12292 -> 10244 bytes annotation_tool/.DS_Store | Bin 12292 -> 0 bytes annotation_tool/README.md | 23 +- annotation_tool/config.yaml | 18 +- annotation_tool/controllers/.DS_Store | Bin 14340 -> 0 bytes .../class_annotation_manager.py | 84 +++- .../classification/class_file_manager.py | 59 ++- .../class_navigation_manager.py | 113 +++-- .../classification/inference_manager.py | 387 ++++++++++-------- .../controllers/description/.DS_Store | Bin 6148 -> 0 bytes .../controllers/history_manager.py | 39 +- annotation_tool/main.py | 2 +- annotation_tool/models/app_state.py | 14 + annotation_tool/requirements.txt | 5 +- annotation_tool/style/style.qss | 58 ++- .../final/predictions_test_epoch_final.json | 19 + .../final/predictions_test_epoch_final.json | 19 + .../final/predictions_test_epoch_final.json | 19 + .../final/predictions_test_epoch_final.json | 19 + .../final/predictions_test_epoch_final.json | 19 + .../final/predictions_test_epoch_final.json | 19 + .../final/predictions_test_epoch_final.json | 19 + annotation_tool/ui/.DS_Store | Bin 14340 -> 0 bytes annotation_tool/ui/classification/.DS_Store | Bin 12292 -> 0 bytes .../event_editor/dynamic_widgets.py | 19 +- .../ui/classification/event_editor/editor.py | 173 ++++++-- annotation_tool/ui/common/.DS_Store | Bin 6148 -> 0 bytes annotation_tool/ui/common/clip_explorer.py | 2 +- annotation_tool/ui/common/dialogs.py | 42 ++ annotation_tool/ui/common/video_surface.py | 2 + annotation_tool/ui/common/welcome_widget.py | 4 +- annotation_tool/ui/common/workspace.py | 2 +- annotation_tool/ui/description/.DS_Store | Bin 12292 -> 0 bytes .../ui/description/event_editor/.DS_Store | Bin 6148 -> 0 bytes .../ui/description/media_player/.DS_Store | Bin 6148 -> 0 bytes annotation_tool/ui/localization/.DS_Store | Bin 12292 -> 0 bytes .../ui/localization/event_editor/.DS_Store | Bin 6148 -> 0 bytes .../ui/localization/media_player/.DS_Store | Bin 6148 -> 0 bytes annotation_tool/viewer.py | 60 ++- 39 files changed, 943 insertions(+), 296 deletions(-) delete mode 100644 annotation_tool/.DS_Store delete mode 100644 annotation_tool/controllers/.DS_Store delete mode 100644 annotation_tool/controllers/description/.DS_Store create mode 100644 annotation_tool/temp_workspace/checkpoints/mvit_v2_s/1syooo1y/final/predictions_test_epoch_final.json create mode 100644 annotation_tool/temp_workspace/checkpoints/mvit_v2_s/2dpeoahi/final/predictions_test_epoch_final.json create mode 100644 annotation_tool/temp_workspace/checkpoints/mvit_v2_s/33bn17dw/final/predictions_test_epoch_final.json create mode 100644 annotation_tool/temp_workspace/checkpoints/mvit_v2_s/fcoek7gr/final/predictions_test_epoch_final.json create mode 100644 annotation_tool/temp_workspace/checkpoints/mvit_v2_s/porl7yyp/final/predictions_test_epoch_final.json create mode 100644 annotation_tool/temp_workspace/checkpoints/mvit_v2_s/r1tx0fro/final/predictions_test_epoch_final.json create mode 100644 annotation_tool/temp_workspace/checkpoints/mvit_v2_s/yzzui5g5/final/predictions_test_epoch_final.json delete mode 100644 annotation_tool/ui/.DS_Store delete mode 100644 annotation_tool/ui/classification/.DS_Store delete mode 100644 annotation_tool/ui/common/.DS_Store delete mode 100644 annotation_tool/ui/description/.DS_Store delete mode 100644 annotation_tool/ui/description/event_editor/.DS_Store delete mode 100644 annotation_tool/ui/description/media_player/.DS_Store delete mode 100644 annotation_tool/ui/localization/.DS_Store delete mode 100644 annotation_tool/ui/localization/event_editor/.DS_Store delete mode 100644 annotation_tool/ui/localization/media_player/.DS_Store diff --git a/.DS_Store b/.DS_Store index 97faddd2312301843c37e486cf8e322297b297ac..dca8ddb45f0d67bdef33867d72711e8044bad019 100644 GIT binary patch delta 1369 zcmd^9-A`Oa6rbOKGB-@Sv+L5`y;$xpOBXcD!t!CaX{k^{TPQ80ECt#|-1c6|71)K{ zrIivwh}GDSXmHfTr&^;gJ~Se2YGT@0AJoJcB_VwQiIFl|kWzWJyAq zO;RMey!u4h((TEWGFHmKXv1hdgt0t;Lso+9qYF;q@cMLpXy%wbJ!5BQ zMUb(=yl(h4CaxYe6IpA2%Ctvq%goQ{x>3W}ksSV1+EVWV%=Nm*R6ru0QH2y!)JHT; zWa8s?vKY#nnwcidfr-R1YeFqraJqE9HKwqAgD0(Y#!e**Ie5hmrVSlWO-@?LY^IpE zy@N6F`O~?~J&8=VcgjjWsHwe^vBR0MMB3^-m9*0LBf1zH5so~b3+XiNY%0CxQrq5V z==!ib_pc7qw9MSLox-K}=?7(1Wel1esob;>A~DiV;$&3yagkgg7s(ZJo!lT_l3V0^ z@(cNutdl>o1gMXBn3VYri5aXA}m`43)K$v5P-;`Mj3 zj)zeW0Rwf2Af{N{jlJkX5BB2_hA@mHc{WdA49{X5S)5di&f+wl#|wB7FX1d+#cOyy z&*~+-jd$=aF5`VH;{!K7!gbuh$GC}4@CDZJ9e$7_wXetzYXf3xEZVP>HMs!u3!m&G4J@fbYrs=kb5dsr#Rt ID)JnE11_va3;+NC delta 527 zcmZn(Xh~3DU|?W$DortDV9)?EIe-{M3-ADmHUvp~ z&rrgU2*jBa3za7usIaht6tGPGD4;a?gSY_GHO7sF&g`4nIaoLtc|oFbWx+*xIr(|% zP_x(>iW!m_ioj+hGh|G5kvMLMCYFq)ULm5ed9%!1 zrg}7q+`w!N;yo8Ep-%(j7=t=k+7fqMIdIgreruXkfAVnrHDM(;geIvvy3v(0*n(F*r}j! zU8J}*D}-nyMrz3fncl50YQ0!MQN;S^<55VWB$(OX2#0hdgf%!U>A<7*HJ-4!qo7bK zM2Vej4cHp6HDGJN)_|>n|6T(u_F|H9;=PdCXIlfd2Hw^hVDASX9xV4;`K-wKs{>DL z3(xA=7M>@(W*c@U^I5sy%4bE6HJCtj#awg+3u1uij&h&X&Tr+jA{X5W@R@NOYi6(z z3h>!c?&Hk~_(g7?Z4KBOSgZl&E)HUn1eqo}QA1PagoV2=geG*&Ow`pq4>V6;Zb_Do z1jdc2Kq8n(njOLTVOAP63_X;(4u%eB>eQH0(rLsJK`o}nc?T_`Vw2HfEfz7RI*ep2 zV)DKLQ6V`c=a}N2nW=AStoGHnHl42a&9pYuRr@wKHl050lu9e_+`514Zao?^?h}gu z>qS6TU3fLVsVgqrN)enaaJsFLeV@h#2sE3n!Frl{VdO&-Jin1az z95-}*z%Vs7%)ya_dMd%{7FCTLGL3kmlMgngC`nV@ui{wRq}qienj;!c$vT$gEFPTa zR+f3!SJgB$Z}WHW?%OwCq!h2Z*;VQ~gqn^ewBwo@KNvK_YAm9~P7FCf#^BiV1opBY<5v#)n&nDNPiEEIQplDd@s&ftAjV9t$Od)A>ZSf50!H}vu zm57+9#dViwcv9%!sq_fpc2}uq1fOygmuNbLtliV;Im)K(4VsDm2{p!U>9VNXfpv$> z@nBr-pF$J0?r0B6`6ER+>(m|55(ckCZR}m=@f>8swMQa+4XfSdMV^3XNK&4Y@DXXT zNe$fsAFJ>VsWSCwD*YQRG$?wVE$Ugy~01DwIkf8)t!+NNOEzk@t&af!;j%b_%-|%uEI<3FL(uBr4Cv~ z-L#ypqt&#A*3xF$Lhqtm>0a7TM`)0qqz1K|il$ugh_xOoqtkei@p944N$cf8&n@k2 z6+3tB-s9?h%Q)MWmE65bN^dFi-L~=0%`My8(`G2_Hf5@r4#2HG%U8J7bF0X)*m>@U zuC?Bj<1M(NaLplf7xk3HyjCf+T34=@&|MUVRjk^8#U7=<@OzbAvg+Sa125afhfelfP2u=?}gLwPMC#vqn&>ME&W6AVfZ+F0v;y`gZ&8vYT1IGoEag4dEi{+S%rw`t z$lUTek@@{>6?n&~*FkP%W|5mOB`YT9Kk?JF+>XX}G`6Ghn;4CS``K}rZ$#}lyu^O< zMvueXf-@s9J$dqR=SO%1w(1sQZ*E;y)OB3MxFZ5mPP}FEw@G5OnM}gOz%Ta+1Rn;n z0LTO2m6@^Ocq?-O%d9SI58`-u&GLFc*plK1+EN>@OpZWa;Xc4fg2PJy_3OhBr0IYq zfs>PQ0u{G1eA3$1xvO`8qjFg;y=6I2k--%>5m^K{GMO(bR#v&o-D@Po{Av6z#&Uao z2pi_IFb%J9yS?irDHHry9jZ1k*umex*6q~PGO)pskmxEmG%_3`aFiu#Zeds^1*$CJ zw(T7Mq>+~pyW2Yup{tfT;){?IBCX;o4hq%g`(Sv#&zBXS;^yM z9IM^pt&tUwoP3~Gd8@4Cb6SqowaI>2A?(c{syn^iUStB4OK+OdKSQ1)FQC!?361{W zkcXyT20pkQ8G}FaZ0I1!JZzc!DtK7EcgIHcapY??Oh5@dS(!dk~p| zhmaTh9OntX%z1(f@J;wOd7K=ch8|NjH9Ez4B^ diff --git a/annotation_tool/README.md b/annotation_tool/README.md index 6798e82f..c577db3e 100644 --- a/annotation_tool/README.md +++ b/annotation_tool/README.md @@ -1,8 +1,6 @@ # SoccerNet Pro Annotation Tool -This project is a professional video annotation desktop application built with **PyQt6**. It features a comprehensive **quad-mode** architecture supporting **Whole-Video Classification**, **Action Spotting (Localization)**, **Video Captioning (Description)**, and the newly integrated **Dense Video Captioning (Dense Description)**. - -With the latest update, the Classification mode now features **AI-Powered Smart Annotation**, allowing users to leverage state-of-the-art `soccernetpro` models (e.g., MViT) to automatically infer actions via single or batch processing. +This project is a professional video annotation desktop application built with **PyQt6**. It features a comprehensive **quad-mode** architecture supporting **Whole-Video Classification**, **Action Spotting (Localization)**, **Video Captioning (Description)**, and the newly integrated **Dense Video Captioning (Dense Description)**. The project follows a modular **MVC (Model-View-Controller)** design pattern to ensure strict separation of concerns. It leverages **Qt's Model/View architecture** for resource management and a unified **Media Controller** to ensure stable, high-performance video playback across all modalities. @@ -15,7 +13,6 @@ annotation_tool/ ├── main.py # Application entry point ├── viewer.py # Main Window controller (Orchestrator) ├── utils.py # Helper functions and constants -├── config.yaml # [NEW] Inference configuration for soccernetpro models ├── __init__.py # Package initialization │ ├── models/ # [Model Layer] Data Structures & State @@ -24,18 +21,14 @@ annotation_tool/ │ ├── controllers/ # [Controller Layer] Business Logic │ ├── router.py # Mode detection & Project lifecycle management -│ ├── history_manager.py # Universal Undo/Redo system (Supports Batch Annotations) +│ ├── history_manager.py # Universal Undo/Redo system │ ├── media_controller.py # Unified playback logic (Anti-freeze/Visual clearing) │ ├── classification/ # Logic for Classification mode -│ │ ├── class_annotation_manager.py # Manual label state management -│ │ ├── class_file_manager.py # JSON I/O for Classification tasks -│ │ ├── class_navigation_manager.py # Action tree navigation -│ │ └── inference_manager.py # [NEW] AI Smart Annotation (Single/Batch Inference) │ ├── localization/ # Logic for Action Spotting (Localization) mode │ ├── description/ # Logic for Global Captioning (Description) mode -│ └── dense_description/ # Logic for Dense Captioning (Text-at-Timestamp) +│ └── dense_description/ # [NEW] Logic for Dense Captioning (Text-at-Timestamp) │ ├── dense_manager.py # Core logic for dense annotations & UI sync -│ └── dense_file_manager.py # JSON I/O specifically for Dense tasks +│ └── dense_file_manager.py # JSON I/O specifically for Dense tasks │ ├── ui/ # [View Layer] Interface Definitions │ ├── common/ # Shared widgets (Main Window, Sidebar, Video Surface) @@ -44,13 +37,9 @@ annotation_tool/ │ │ ├── workspace.py # Unified 3-column skeleton │ │ └── dialogs.py # Project wizards and mode selectors │ ├── classification/ # UI specific to Classification -│ │ └── event_editor/ # Dynamic Schema Editor & [NEW] Smart Annotation UI -│ │ ├── dynamic_widgets.py # Single/Multi label dynamic radio & checkbox groups -│ │ ├── editor.py # Includes NativeDonutChart & Batch Progress UI -│ │ └── controls.py # Playback control bar │ ├── localization/ # UI specific to Localization (Timeline + Tabbed Spotting) │ ├── description/ # UI specific to Global Captioning (Full-video text) -│ └── dense_description/ # UI specific to Dense Description +│ └── dense_description/ # [NEW] UI specific to Dense Description │ └── event_editor/ │ ├── __init__.py # Right panel assembler for Dense mode │ ├── desc_input_widget.py # Text input & timestamp submission @@ -58,7 +47,9 @@ annotation_tool/ │ └── style/ # Visual theme assets └── style.qss # Centralized Dark mode stylesheet + ``` + --- ## 📝 Detailed Module Descriptions diff --git a/annotation_tool/config.yaml b/annotation_tool/config.yaml index cbdc0010..d3e7fe2c 100644 --- a/annotation_tool/config.yaml +++ b/annotation_tool/config.yaml @@ -6,7 +6,17 @@ DATA: data_dir: "" data_modality: video view_type: multi - num_classes: 8 + num_classes: 8 + + classes: + - Challenge + - Dive + - Elbowing + - High leg + - Holding + - Pushing + - Standing tackling + - Tackling # ⬅️ Added back the dummy train block train: @@ -93,6 +103,6 @@ SYSTEM: log_dir: ./logs use_seed: false seed: 42 - GPU: 0 - device: cpu - gpu_id: 0 + GPU: 0 + device: cpu + gpu_id: 0 \ No newline at end of file diff --git a/annotation_tool/controllers/.DS_Store b/annotation_tool/controllers/.DS_Store deleted file mode 100644 index 57a9279c0fc11fb1ec3d19a0e2d543eb1b83a3a4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14340 zcmeHNU2GIp6uxKL(pfsR1GKhaN7fd?Uklq7X!(oVKjpv3w)BU>c6Vo>1G6(_XSb!4 zn#QP!AR6O?M#bQhKSUl>g!m$fBIu){3C8e%!3aKRqAvfS*yGas)U6 zHzPp5{S%zM)mxptGs^V!Rs|wfw9gq1yp&|*?+?UG!!mavA2n|YJpmT_142l3vVY7E z5FW7mTtC36aG#;uai8y75VEp!CgqB`VxG7?&}$6@;-PrLZV1J;(Ne2r8R29;6yB<9 z13|g4(K6#9-PB@^fUauD(%r6`sx{DHB}~)ZC1`c7G$o zA}f%@;=3dkp2>OA6s5hRvrFm@%G3IB(k*>)-7@X1TFgcolnZPGXKlnXH^(&E-X5`x zMAWo{^3+Jkh$M_qT&p*XfWBYDx&k{AvkYUiW$Uz?&W^aYFHYNTYU=2+tysL#=`1MA z30vEuVPAT`)`UIU9Xbw41*y|0HGt=Me(|i?^D3)rSNWUQZhZWFfjo8Ebg4+{!c{Qi z`c7Sob%yMSW~#cmt8GA6l(XDoWi1wg6{MA`JZPA5wp)D2k454XN=uuXv72+U zc&D`f0=YzfBEORBsP*|U9ZFy>_)ymyU=3_?v^@ZAumgh717T1h3MN>v7xn%C48s#J z0#BmWKZkn%0=x*X!fWt4>i%2sHoODJ;62p*lW+? zH;&h8_&t+Wr#jg${&npU*U6sN3ZmB7#^9gukKYs=rH_`j?;AD*l$QsN07rl$z!BgG za0Koc0@Q7mM}qk4BZJPFJ3@MiMr+2MMuj<7({rY79j-0h`tYQeod4(3^qabIn3#Lq zwn6Bj6VK>NNbwPzOm5EanUA1&XsEWX!dvaD$>1V*md+uAi-5WiGEQ;a!jQ{XCgkU? z0`4l{u7bPhD#&cE++E;!&$zqb7EZGNwY#7mzbt9b=&7G^e*QN*}!g(47AM|9vqR{*W92j==390NG7}rUsNv{u_Q{H}tNZ7h?}b%5xec4VZUC zFmbNqA$6|fVS)M;>l`LG!8Sk5A{}8Aa~SUVMZmjpRe+!W`E>zuTbE@1v-AJo&pSoC diff --git a/annotation_tool/controllers/classification/class_annotation_manager.py b/annotation_tool/controllers/classification/class_annotation_manager.py index 7d9822ae..867024e6 100644 --- a/annotation_tool/controllers/classification/class_annotation_manager.py +++ b/annotation_tool/controllers/classification/class_annotation_manager.py @@ -9,11 +9,51 @@ def __init__(self, main_window): self.model = main_window.model self.ui = main_window.ui - def save_manual_annotation(self): + def confirm_smart_annotation_as_manual(self): + """ + [MODIFIED] Mark current smart prediction as confirmed WITHOUT polluting Hand Annotation. + """ path = self.main.get_current_action_path() if not path: return - raw = self.ui.classification_ui.right_panel.get_annotation() + smart_data = self.model.smart_annotations.get(path) + if not smart_data: + self.main.show_temp_msg("Notice", "No smart annotation available to confirm.") + return + + # [NEW] Simply flag it as confirmed internally within the smart memory. + # We DO NOT call self.save_manual_annotation(new_data) anymore to prevent UI pollution! + self.model.smart_annotations[path]["_confirmed"] = True + self.model.is_data_dirty = True + + self.main.show_temp_msg("Saved", "Smart Annotation confirmed independently.", 1000) + + self.main.update_action_item_status(path) + self.main.update_save_export_button_state() + + # Auto-advance to the next video clip + tree = self.ui.classification_ui.left_panel.tree + curr_idx = tree.currentIndex() + if curr_idx.isValid(): + nxt_idx = tree.indexBelow(curr_idx) + if nxt_idx.isValid(): + QTimer.singleShot(500, lambda: [tree.setCurrentIndex(nxt_idx), tree.scrollTo(nxt_idx)]) + + def save_manual_annotation(self, override_data=None): + """ + [MODIFIED] Added 'override_data' parameter. + If provided (e.g., from Smart Annotation confirm), it uses the provided dict. + Otherwise, it falls back to reading the Hand Annotation UI state. + """ + path = self.main.get_current_action_path() + if not path: return + + # Use provided data if available, otherwise read from the UI + if override_data is not None: + raw = override_data + else: + raw = self.ui.classification_ui.right_panel.get_annotation() + cleaned = {k: v for k, v in raw.items() if v} if not cleaned: cleaned = None @@ -52,10 +92,50 @@ def clear_current_manual_annotation(self): self.main.show_temp_msg("Cleared", "Selection cleared.") self.ui.classification_ui.right_panel.clear_selection() + def clear_current_smart_annotation(self): + """[NEW] Clear the smart annotation for the current video, with Undo support.""" + path = self.main.get_current_action_path() + if not path: return + + old_smart = copy.deepcopy(self.model.smart_annotations.get(path)) + if old_smart: + # Push the clearing action to the Undo stack using the SMART_ANNOTATION_RUN cmd + self.model.push_undo( + CmdType.SMART_ANNOTATION_RUN, + path=path, + old_data=old_smart, + new_data=None + ) + + # Remove from model memory + if path in self.model.smart_annotations: + del self.model.smart_annotations[path] + + self.model.is_data_dirty = True + self.main.show_temp_msg("Cleared", "Smart Annotation cleared.", 1000) + self.main.update_save_export_button_state() + + # Visually hide the donut chart and text without affecting the Hand Annotation UI + self.ui.classification_ui.right_panel.chart_widget.setVisible(False) + self.ui.classification_ui.right_panel.batch_result_text.setVisible(False) + def display_manual_annotation(self, path): + # 1. Restore manual annotation (This will reset the UI and hide the chart by default) data = self.model.manual_annotations.get(path, {}) self.ui.classification_ui.right_panel.set_annotation(data) + # 2. [NEW] Re-display the Smart Annotation Donut Chart if data exists + smart_data = self.model.smart_annotations.get(path, {}) + if smart_data: + # We display the chart for the first available head (typically 'action') + for head, s_data in smart_data.items(): + self.ui.classification_ui.right_panel.chart_widget.update_chart( + s_data["label"], + s_data.get("conf_dict", {}) + ) + self.ui.classification_ui.right_panel.chart_widget.setVisible(True) + break + def handle_ui_selection_change(self, head, new_val): if self.main.history_manager._is_undoing_redoing: return diff --git a/annotation_tool/controllers/classification/class_file_manager.py b/annotation_tool/controllers/classification/class_file_manager.py index 2910ce04..3f7b0f49 100644 --- a/annotation_tool/controllers/classification/class_file_manager.py +++ b/annotation_tool/controllers/classification/class_file_manager.py @@ -67,6 +67,14 @@ def load_project(self, data, file_path): clean_k = k.strip().replace(' ', '_').lower() self.model.label_definitions[clean_k] = {'type': v['type'], 'labels': sorted(list(set(v.get('labels', []))))} self.main.setup_dynamic_ui() + + # Check if it is multi view + is_multi = False + for item in data.get('data', []): + if len(item.get('inputs', [])) > 1: + is_multi = True + break + self.model.is_multi_view = is_multi # Load Data for item in data.get('data', []): @@ -108,6 +116,22 @@ def load_project(self, data, file_path): if has_l: self.model.manual_annotations[path_key] = manual + # [NEW] Load Smart Annotations from JSON + smart_lbls = item.get('smart_labels', {}) + smart = {} + for h, content in smart_lbls.items(): + ck = h.strip().replace(' ', '_').lower() + if ck in self.model.label_definitions and isinstance(content, dict): + # Reconstruct the prediction and confidence dictionary + smart[ck] = { + "label": content.get("label"), + "conf_dict": content.get("conf_dict", {content.get("label"): content.get("confidence", 1.0)}) + } + if smart: + # [MODIFIED] Mark loaded smart annotations as confirmed so the Filter recognizes them + smart["_confirmed"] = True + self.model.smart_annotations[path_key] = smart + self.model.current_json_path = file_path self.model.json_loaded = True @@ -196,6 +220,23 @@ def _write_json(self, save_path): if entry_labels: data_entry["labels"] = entry_labels + # [NEW] Write smart_labels parallel to manual labels + if path_key in self.model.smart_annotations: + smart_annots = self.model.smart_annotations[path_key] + # [MODIFIED] Only export if they were actually confirmed, and skip the internal flag + if smart_annots.get("_confirmed", False): + entry_smart_labels = {} + for head, data_dict in smart_annots.items(): + if head == "_confirmed": + continue # Skip the internal boolean flag to prevent TypeError + + entry_smart_labels[head] = { + "label": data_dict["label"], + "confidence": data_dict.get("conf_dict", {}).get(data_dict["label"], 1.0), + "conf_dict": data_dict.get("conf_dict", {}) + } + if entry_smart_labels: + data_entry["smart_labels"] = entry_smart_labels out["data"].append(data_entry) try: @@ -214,16 +255,28 @@ def create_new_project(self): """ Creates a blank project immediately, allowing the user to build the schema in the right-hand panel. + Now asks for SV/MV type before proceeding. """ + # Ask Single-View or Multi-View + from ui.common.dialogs import ClassificationTypeDialog + dialog = ClassificationTypeDialog(self.main) + + if not dialog.exec(): + return + # 1. Clear existing data (Full Reset) self._clear_workspace(full_reset=True) # 2. Initialize default "Blank Project" state in the Model self.model.current_task_name = "Untitled Task" self.model.modalities = ["video"] - self.model.label_definitions = {} # Empty Category (Category Editor start blank) + self.model.label_definitions = {} # Empty Category self.model.project_description = "" + # 2. Initialize default "Blank Project" state in the Model + # [MODIFIED] Changed from "Untitled Task" to "action_classification". + self.model.current_task_name = "action_classification" + # 3. Set flags to allow interaction self.model.json_loaded = True self.model.is_data_dirty = True @@ -250,3 +303,7 @@ def _clear_workspace(self, full_reset=False): self.ui.classification_ui.center_panel.show_single_view(None) if full_reset: self.main.setup_dynamic_ui() + + # [NEW] Clear the Smart Annotation dropdowns when workspace is reset + if hasattr(self.main, 'sync_batch_inference_dropdowns'): + self.main.sync_batch_inference_dropdowns() \ No newline at end of file diff --git a/annotation_tool/controllers/classification/class_navigation_manager.py b/annotation_tool/controllers/classification/class_navigation_manager.py index 20021d05..00b44952 100644 --- a/annotation_tool/controllers/classification/class_navigation_manager.py +++ b/annotation_tool/controllers/classification/class_navigation_manager.py @@ -36,7 +36,12 @@ def __init__(self, main_window): def add_items_via_dialog(self): """ Allows user to add video/image files to the project. + Smartly handles SV vs MV based on the loaded JSON flag. """ + from PyQt6.QtWidgets import QMessageBox, QFileDialog + import os + from collections import defaultdict + if not self.model.json_loaded: QMessageBox.warning(self.main, "Warning", "Please create or load a project first.") return @@ -51,24 +56,54 @@ def add_items_via_dialog(self): self.model.current_working_directory = os.path.dirname(files[0]) added_count = 0 - for file_path in files: - # Duplicate check - if any(d['path'] == file_path for d in self.model.action_item_data): - continue - - name = os.path.basename(file_path) - self.model.action_item_data.append({'name': name, 'path': file_path, 'source_files': [file_path]}) + + is_mv = getattr(self.model, 'is_multi_view', False) + + if is_mv: + grouped_files = defaultdict(list) - # [MV Fix] Add to Model directly - item = self.main.tree_model.add_entry(name, file_path, [file_path]) - self.model.action_item_map[file_path] = item - added_count += 1 + for file_path in files: + dir_name = os.path.dirname(file_path) + grouped_files[dir_name].append(file_path) + + for dir_path, paths in grouped_files.items(): + paths.sort() + + if len(paths) > 1: + name = os.path.basename(dir_path) + else: + name = os.path.basename(paths[0]) + + if any(d['name'] == name for d in self.model.action_item_data): + continue + + main_path = paths[0] + self.model.action_item_data.append({'name': name, 'path': main_path, 'source_files': paths}) + + item = self.main.tree_model.add_entry(name, main_path, paths) + self.model.action_item_map[main_path] = item + added_count += 1 + + else: + for file_path in files: + if any(d['path'] == file_path for d in self.model.action_item_data): + continue + + name = os.path.basename(file_path) + self.model.action_item_data.append({'name': name, 'path': file_path, 'source_files': [file_path]}) + + item = self.main.tree_model.add_entry(name, file_path, [file_path]) + self.model.action_item_map[file_path] = item + added_count += 1 if added_count > 0: self.model.is_data_dirty = True self.apply_action_filter() self.main.show_temp_msg("Added", f"Added {added_count} items.") + # [NEW] Force Smart Annotation dropdowns to update with the new videos + self.main.sync_batch_inference_dropdowns() + def remove_single_action_item(self, index: QModelIndex): """ Removes an item given its QModelIndex. @@ -92,6 +127,9 @@ def remove_single_action_item(self, index: QModelIndex): self.main.show_temp_msg("Removed", "Item removed.") self.main.update_save_export_button_state() + # [NEW] Force Smart Annotation dropdowns to update after deletion + self.main.sync_batch_inference_dropdowns() + def on_item_selected(self, current, previous): """ Called when the user clicks a different item in the left tree. @@ -136,24 +174,51 @@ def show_all_views(self): self.ui.classification_ui.center_panel.show_all_views([p for p in paths if p.lower().endswith(SUPPORTED_EXTENSIONS[:3])]) - def apply_action_filter(self): - """Filters the tree items based on Done/Not Done status using setRowHidden.""" - idx = self.ui.classification_ui.left_panel.filter_combo.currentIndex() - tree_view = self.ui.classification_ui.left_panel.tree + def apply_action_filter(self, index=None): + """ + [MODIFIED] Filter the tree based on 4 custom states for Classification. + 0: Show All + 1: Hand Labelled (Confirmed, purely manual) + 2: Smart Labelled (Confirmed via smart annotation) + 3: No Labelled (Not confirmed at all) + """ + tree = self.ui.classification_ui.left_panel.tree + combo = self.ui.classification_ui.left_panel.filter_combo + + # Use the passed index from the signal, or the current combo box index + filter_idx = combo.currentIndex() if index is None else index + if filter_idx < 0: return + model = self.main.tree_model - root = model.invisibleRootItem() - for i in range(root.rowCount()): - item = root.child(i) - # We access data via the item (QStandardItem) or index + for row in range(model.rowCount()): + idx = model.index(row, 0) + item = model.itemFromIndex(idx) + if not item: continue + path = item.data(ProjectTreeModel.FilePathRole) - is_done = (path in self.model.manual_annotations and bool(self.model.manual_annotations[path])) - should_hide = False - if idx == self.main.FILTER_DONE and not is_done: should_hide = True - elif idx == self.main.FILTER_NOT_DONE and is_done: should_hide = True + # 1. Is it Hand Labelled? (Exists in manual_annotations) + is_hand_labelled = path in self.model.manual_annotations and bool(self.model.manual_annotations[path]) - tree_view.setRowHidden(i, QModelIndex(), should_hide) + # 2. Is it Smart Labelled? (Has _confirmed flag in smart_annotations) + smart_data = self.model.smart_annotations.get(path, {}) + # If manual annotation exists, we prioritize classifying it as Hand Labelled to avoid overlap + is_smart_labelled = smart_data.get("_confirmed", False) and not is_hand_labelled + + # 3. No Labelled (Neither hand nor smart confirmed) + is_no_labelled = not is_hand_labelled and not is_smart_labelled + + # 4. Apply hiding logic based on the selected filter index + hidden = False + if filter_idx == 1 and not is_hand_labelled: + hidden = True + elif filter_idx == 2 and not is_smart_labelled: + hidden = True + elif filter_idx == 3 and not is_no_labelled: + hidden = True + + tree.setRowHidden(row, QModelIndex(), hidden) def nav_prev_action(self): self._nav_tree(step=-1, level='top') def nav_next_action(self): self._nav_tree(step=1, level='top') diff --git a/annotation_tool/controllers/classification/inference_manager.py b/annotation_tool/controllers/classification/inference_manager.py index ec4d5ae1..af2e89e7 100644 --- a/annotation_tool/controllers/classification/inference_manager.py +++ b/annotation_tool/controllers/classification/inference_manager.py @@ -6,6 +6,8 @@ import copy import uuid import re +import yaml +from models import CmdType from PyQt6.QtCore import QThread, pyqtSignal, QObject from PyQt6.QtWidgets import QMessageBox from utils import natural_sort_key @@ -15,11 +17,74 @@ from soccernetpro import model + +def _run_soccernet_inference(base_config_path: str, temp_data: dict, prefix: str): + """ + [REFACTORED] A shared helper function to handle the repetitive setup, + execution, and cleanup of the soccernetpro inference process. + Used by both Single Inference and Batch Inference workers. + """ + writable_dir = os.path.join(os.path.expanduser("~"), ".soccernet_workspace") + os.makedirs(writable_dir, exist_ok=True) + + writable_dir_fwd = writable_dir.replace('\\', '/') + logs_dir_fwd = os.path.join(writable_dir, "logs").replace('\\', '/') + + unique_id = uuid.uuid4().hex[:8] + temp_json_path = os.path.join(writable_dir, f"temp_{prefix}_{unique_id}.json") + temp_config_path = os.path.join(writable_dir, f"temp_config_{prefix}_{unique_id}.yaml") + + try: + # 1. Write the temporary JSON data + with open(temp_json_path, 'w', encoding='utf-8') as f: + json.dump(temp_data, f, indent=4) + + # 2. Read and modify the YAML config dynamically + with open(base_config_path, 'r', encoding='utf-8') as f: + config_text = f.read() + + config_text = config_text.replace('./temp_workspace', writable_dir_fwd) + config_text = config_text.replace('./logs', logs_dir_fwd) + + with open(temp_config_path, 'w', encoding='utf-8') as f: + f.write(config_text) + + # 3. Initialize model and run inference + myModel = model.classification(config=temp_config_path) + metrics = myModel.infer( + test_set=temp_json_path, + pretrained="jeetv/snpro-classification-mvit" + ) + + # 4. Search for the generated prediction output + checkpoint_dir = os.path.join(writable_dir, "checkpoints") + search_pattern = os.path.join(checkpoint_dir, "**", "predictions_test_epoch_*.json") + pred_files = glob.glob(search_pattern, recursive=True) + + if not pred_files: + raise FileNotFoundError("Could not find the generated prediction JSON file.") + + latest_pred_file = max(pred_files, key=os.path.getctime) + with open(latest_pred_file, 'r', encoding='utf-8') as pf: + pred_data = json.load(pf) + + return metrics if metrics else {}, pred_data + + finally: + # 5. Guaranteed cleanup of temporary payload files + if os.path.exists(temp_json_path): + try: os.remove(temp_json_path) + except: pass + if os.path.exists(temp_config_path): + try: os.remove(temp_config_path) + except: pass + + class InferenceWorker(QThread): finished_signal = pyqtSignal(str, str, dict) error_signal = pyqtSignal(str) - def __init__(self, config_path, base_dir, action_id, json_path, video_path): + def __init__(self, config_path, base_dir, action_id, json_path, video_path, label_map): super().__init__() self.config_path = config_path self.base_dir = base_dir @@ -27,21 +92,11 @@ def __init__(self, config_path, base_dir, action_id, json_path, video_path): self.json_path = json_path self.video_path = video_path - self.label_map = { - '0': 'Challenge', '1': 'Dive', '2': 'Elbowing', '3': 'High leg', - '4': 'Holding', '5': 'Pushing', '6': 'Standing tackling', '7': 'Tackling' - } + # [DYNAMIC] Assigned from config.yaml, no more hardcoding! + self.label_map = label_map def run(self): - temp_json_path = "" - temp_config_path = "" try: - writable_dir = os.path.join(os.path.expanduser("~"), ".soccernet_workspace") - os.makedirs(writable_dir, exist_ok=True) - - writable_dir_fwd = writable_dir.replace('\\', '/') - logs_dir_fwd = os.path.join(writable_dir, "logs").replace('\\', '/') - video_abs_path = self.video_path if not os.path.isabs(video_abs_path): if self.json_path and os.path.exists(self.json_path): @@ -66,12 +121,15 @@ def run(self): target_item = copy.deepcopy(item) break + # Dynamic default fallback from the schema instead of hardcoded strings + default_label = list(self.label_map.values())[0] if self.label_map else "Unknown" + if not target_item: target_item = { "id": self.action_id, "inputs": [{"type": "video", "path": video_abs_path}], "labels": { - "action": {"label": "Tackling", "confidence": 1.0} + "action": {"label": default_label, "confidence": 1.0} } } else: @@ -83,7 +141,7 @@ def run(self): if "labels" not in target_item: target_item["labels"] = {} if "action" not in target_item["labels"]: - target_item["labels"]["action"] = {"label": "Tackling"} + target_item["labels"]["action"] = {"label": default_label} global_labels = original_data.get("labels", {}) if not isinstance(global_labels, dict): @@ -102,38 +160,8 @@ def run(self): "data": [target_item] } - unique_id = uuid.uuid4().hex[:8] - temp_json_path = os.path.join(writable_dir, f"temp_infer_{unique_id}.json") - - with open(temp_json_path, 'w', encoding='utf-8') as f: - json.dump(temp_data, f, indent=4) - - with open(self.config_path, 'r', encoding='utf-8') as f: - config_text = f.read() - - config_text = config_text.replace('./temp_workspace', writable_dir_fwd) - config_text = config_text.replace('./logs', logs_dir_fwd) - - temp_config_path = os.path.join(writable_dir, f"temp_config_{unique_id}.yaml") - with open(temp_config_path, 'w', encoding='utf-8') as f: - f.write(config_text) - - myModel = model.classification(config=temp_config_path) - metrics = myModel.infer( - test_set=temp_json_path, - pretrained="jeetv/snpro-classification-mvit" - ) - - checkpoint_dir = os.path.join(writable_dir, "checkpoints") - search_pattern = os.path.join(checkpoint_dir, "**", "predictions_test_epoch_*.json") - pred_files = glob.glob(search_pattern, recursive=True) - - if not pred_files: - raise FileNotFoundError("Could not find the generated prediction JSON file.") - - latest_pred_file = max(pred_files, key=os.path.getctime) - with open(latest_pred_file, 'r', encoding='utf-8') as pf: - pred_data = json.load(pf) + # Use the shared helper function to run inference + metrics, pred_data = _run_soccernet_inference(self.config_path, temp_data, "infer") predicted_label_idx = None confidence = 0.0 @@ -171,8 +199,6 @@ def run(self): clean_idx = predicted_label_idx.replace(".0", "") if clean_idx in self.label_map: final_label = self.label_map[clean_idx] - else: - final_label = "Unknown" conf_dict = {} if "confidences" in raw_action_data and isinstance(raw_action_data["confidences"], dict): @@ -189,31 +215,21 @@ def run(self): except Exception as e: self.error_signal.emit(str(e)) - - finally: - if os.path.exists(temp_json_path): - try: os.remove(temp_json_path) - except: pass - if os.path.exists(temp_config_path): - try: os.remove(temp_config_path) - except: pass class BatchInferenceWorker(QThread): finished_signal = pyqtSignal(dict, list) error_signal = pyqtSignal(str) - def __init__(self, config_path, base_dir, json_path, target_clips): + def __init__(self, config_path, base_dir, json_path, target_clips, label_map): super().__init__() self.config_path = config_path self.base_dir = base_dir self.json_path = json_path self.target_clips = target_clips - self.label_map = { - '0': 'Challenge', '1': 'Dive', '2': 'Elbowing', '3': 'High leg', - '4': 'Holding', '5': 'Pushing', '6': 'Standing tackling', '7': 'Tackling' - } + # [DYNAMIC] Load map from external source + self.label_map = label_map def _map_label(self, raw_label): valid_class_names = list(self.label_map.values()) @@ -225,16 +241,10 @@ def _map_label(self, raw_label): return "Unknown" def run(self): - temp_json_path = "" - temp_config_path = "" try: - writable_dir = os.path.join(os.path.expanduser("~"), ".soccernet_workspace") - os.makedirs(writable_dir, exist_ok=True) - - writable_dir_fwd = writable_dir.replace('\\', '/') - logs_dir_fwd = os.path.join(writable_dir, "logs").replace('\\', '/') - data_items = [] + default_label = list(self.label_map.values())[0] if self.label_map else "Unknown" + for clip in self.target_clips: inputs = [] for path in clip['paths']: @@ -247,7 +257,8 @@ def run(self): video_abs_path = os.path.normpath(video_abs_path).replace('\\', '/') inputs.append({"type": "video", "path": video_abs_path}) - safe_gt = clip['gt'] if clip['gt'] else "Tackling" + # Fallback to default label instead of hardcoded strings + safe_gt = clip['gt'] if clip['gt'] else default_label item = { "id": clip['id'], @@ -270,42 +281,10 @@ def run(self): "data": data_items } - unique_id = uuid.uuid4().hex[:8] - temp_json_path = os.path.join(writable_dir, f"temp_batch_infer_{unique_id}.json") - - with open(temp_json_path, 'w', encoding='utf-8') as f: - json.dump(temp_data, f, indent=4) - - with open(self.config_path, 'r', encoding='utf-8') as f: - config_text = f.read() - - config_text = config_text.replace('./temp_workspace', writable_dir_fwd) - config_text = config_text.replace('./logs', logs_dir_fwd) - - temp_config_path = os.path.join(writable_dir, f"temp_batch_config_{unique_id}.yaml") - with open(temp_config_path, 'w', encoding='utf-8') as f: - f.write(config_text) - - myModel = model.classification(config=temp_config_path) - metrics = myModel.infer( - test_set=temp_json_path, - pretrained="jeetv/snpro-classification-mvit" - ) - if not metrics: metrics = {} - - checkpoint_dir = os.path.join(writable_dir, "checkpoints") - search_pattern = os.path.join(checkpoint_dir, "**", "predictions_test_epoch_*.json") - pred_files = glob.glob(search_pattern, recursive=True) - - if not pred_files: - raise FileNotFoundError("Could not find the generated prediction JSON file.") - - latest_pred_file = max(pred_files, key=os.path.getctime) - with open(latest_pred_file, 'r', encoding='utf-8') as pf: - pred_data = json.load(pf) + # Use the shared helper function to run inference + metrics, pred_data = _run_soccernet_inference(self.config_path, temp_data, "batch_infer") pred_items = pred_data.get("data", []) - out_dict = {} for item in pred_items: out_id = str(item.get("id")) @@ -335,14 +314,6 @@ def run(self): except Exception as e: self.error_signal.emit(str(e)) - - finally: - if os.path.exists(temp_json_path): - try: os.remove(temp_json_path) - except: pass - if os.path.exists(temp_config_path): - try: os.remove(temp_config_path) - except: pass class InferenceManager(QObject): @@ -363,6 +334,33 @@ def __init__(self, main_window): self.ui.classification_ui.right_panel.batch_run_requested.connect(self.start_batch_inference) self.ui.classification_ui.right_panel.batch_confirm_requested.connect(self.confirm_batch_inference) + def _get_label_map_from_config(self) -> dict: + """ + [DYNAMIC PARSING] Reads the config.yaml on-the-fly to extract the classes list. + Prevents hardcoding so the framework scales effortlessly to new sports/models. + """ + label_map = {} + try: + with open(self.config_path, 'r', encoding='utf-8') as f: + config_data = yaml.safe_load(f) + + # Extract classes array safely from YAML structure + if config_data and 'DATA' in config_data and 'classes' in config_data['DATA']: + classes_list = config_data['DATA']['classes'] + for i, cls_name in enumerate(classes_list): + label_map[str(i)] = cls_name + except Exception as e: + print(f"Warning: Could not read classes from config.yaml dynamically: {e}") + + # Absolute failsafe if the user forgot to write `classes:` in their yaml + if not label_map: + label_map = { + '0': 'Challenge', '1': 'Dive', '2': 'Elbowing', '3': 'High leg', + '4': 'Holding', '5': 'Pushing', '6': 'Standing tackling', '7': 'Tackling' + } + + return label_map + def start_inference(self): if not os.path.exists(self.config_path): QMessageBox.critical(self.main, "Error", f"config.yaml not found at:\n{self.config_path}") @@ -378,14 +376,53 @@ def start_inference(self): self.ui.classification_ui.right_panel.show_inference_loading(True) - self.worker = InferenceWorker(self.config_path, self.base_dir, action_id, current_json_path, current_video_path) + # 1. Dynamically load labels from config + label_map = self._get_label_map_from_config() + + # 2. Pass labels to worker + self.worker = InferenceWorker(self.config_path, self.base_dir, action_id, current_json_path, current_video_path, label_map) self.worker.finished_signal.connect(self._on_inference_success) self.worker.error_signal.connect(self._on_inference_error) self.worker.start() def _on_inference_success(self, target_head, label, conf_dict): + # Auto-create the schema (Category) if it's a completely blank/new project + if target_head not in self.main.model.label_definitions: + if self.worker: + # Use dynamically generated labels + default_labels = list(self.worker.label_map.values()) + self.main.model.label_definitions[target_head] = { + "type": "single_label", + "labels": sorted(default_labels) + } + # Force UI regeneration to display radio buttons + self.main.setup_dynamic_ui() + + # [NEW] Save raw inference result to smart_annotations memory + current_video_path = self.main.get_current_action_path() + # [NEW] Capture old state before overwriting + old_data = self.main.model.smart_annotations.get(current_video_path, {}) + new_data = { + target_head: {"label": label, "conf_dict": conf_dict} + } + + # [NEW] Push to Undo History + import copy + self.main.model.push_undo( + CmdType.SMART_ANNOTATION_RUN, + path=current_video_path, + old_data=copy.deepcopy(old_data), + new_data=copy.deepcopy(new_data) + ) + + # Save new data + if current_video_path not in self.main.model.smart_annotations: + self.main.model.smart_annotations[current_video_path] = {} + self.main.model.smart_annotations[current_video_path] = new_data + + self.main.model.is_data_dirty = True self.ui.classification_ui.right_panel.display_inference_result(target_head, label, conf_dict) - self.worker = None + self.worker = None def _on_inference_error(self, error_msg): self.ui.classification_ui.right_panel.show_inference_loading(False) @@ -420,7 +457,7 @@ def start_batch_inference(self, start_idx: int, end_idx: int): items = action_groups[base_id] paths = [it['path'] for it in items] - # Ground Truth + # Extract current ground truth gt_label = "" for it in items: ann = self.main.model.manual_annotations.get(it['path'], {}) @@ -431,36 +468,67 @@ def start_batch_inference(self, start_idx: int, end_idx: int): target_clips.append({'id': base_id, 'paths': paths, 'gt': gt_label, 'original_items': items}) self.ui.classification_ui.right_panel.show_inference_loading(True) - self.batch_worker = BatchInferenceWorker(self.config_path, self.base_dir, self.main.model.current_json_path, target_clips) + + # 1. Dynamically load labels from config + label_map = self._get_label_map_from_config() + + # 2. Pass labels to batch worker + self.batch_worker = BatchInferenceWorker(self.config_path, self.base_dir, self.main.model.current_json_path, target_clips, label_map) self.batch_worker.finished_signal.connect(self._on_batch_inference_success) self.batch_worker.error_signal.connect(self._on_batch_inference_error) self.batch_worker.start() def _on_batch_inference_success(self, metrics: dict, results_list: list): - text = "OVERALL ACCURACY METRICS:\n" - text += f"- Top_2_accuracy: {metrics.get('top_2_accuracy', 0.0):.4f}\n" - text += f"- Accuracy: {metrics.get('accuracy', 0.0):.4f}\n" - text += f"- Balanced accuracy: {metrics.get('balanced_accuracy', 0.0):.4f}\n" - text += f"- F1: {metrics.get('f1', 0.0):.4f}\n" - text += f"- Precision: {metrics.get('precision', 0.0):.4f}\n" - text += f"- Recall: {metrics.get('recall', 0.0):.4f}\n\n" + # Auto-create the schema (Category) if it's a completely blank/new project + target_head = "action" + if target_head not in self.main.model.label_definitions: + if self.batch_worker: + default_labels = list(self.batch_worker.label_map.values()) + self.main.model.label_definitions[target_head] = { + "type": "single_label", + "labels": sorted(default_labels) + } + self.main.setup_dynamic_ui() + # Start building the output text without the accuracy metrics + text = "BATCH INFERENCE PREDICTIONS:\n\n" batch_predictions = {} + + old_batch_data = {} # [NEW] + new_batch_data = {} # [NEW] + import copy + for r in results_list: - gt_str = r['gt'] if r['gt'] else "None" + text += f"Video ID: {r['id']}\nPredicted Class: {r['pred']} (Confidence: {r['conf']*100:.1f}%)\n\n" - if gt_str == "None": - match_str = "N/A" - elif gt_str == r['pred']: - match_str = "Match ✅" - else: - match_str = "Mismatch ❌" + for item in r['original_items']: + path = item['path'] + batch_predictions[path] = r['pred'] + + # [NEW] Record old data + if path not in old_batch_data: + old_batch_data[path] = self.main.model.smart_annotations.get(path, {}) + + conf_dict = {r['pred']: r['conf']} + if r['conf'] < 1.0: conf_dict["Other Uncertainties"] = 1.0 - r['conf'] + + # [NEW] Prepare new data + new_batch_data[path] = { + target_head: {"label": r['pred'], "conf_dict": conf_dict} + } - text += f"Video ID: {r['id']} - Ground Truth: {gt_str} -- Predicted: {r['pred']} (Confidence: {r['conf']*100:.1f}%) ({match_str})\n\n" + # [NEW] Push Batch to Undo History + self.main.model.push_undo( + CmdType.BATCH_SMART_ANNOTATION_RUN, + old_batch=copy.deepcopy(old_batch_data), + new_batch=copy.deepcopy(new_batch_data) + ) + + # Apply new data to model + for path, data in new_batch_data.items(): + self.main.model.smart_annotations[path] = data - for item in r['original_items']: - batch_predictions[item['path']] = r['pred'] - + self.main.model.is_data_dirty = True self.ui.classification_ui.right_panel.display_batch_inference_result(text, batch_predictions) self.batch_worker = None @@ -470,45 +538,24 @@ def _on_batch_inference_error(self, error_msg): self.batch_worker = None def confirm_batch_inference(self, results: dict): - from models import CmdType - import copy - - batch_changes = {} + """ + [MODIFIED] Acknowledge batch inference without polluting Hand Annotations. + """ applied_count = 0 - # 1. Collect old and new states for all affected videos into a single dictionary + # Smart annotations were already pushed to memory and Undo stack + # during _on_batch_inference_success. Here we just mark them as confirmed. for path, label in results.items(): - old_data = copy.deepcopy(self.main.model.manual_annotations.get(path)) - - new_data = copy.deepcopy(old_data) if old_data else {} - new_data['action'] = label - - # Only record if there is an actual change - if old_data != new_data: - batch_changes[path] = { - 'old_data': old_data, - 'new_data': new_data - } - - # If nothing actually changed, just return - if not batch_changes: - self.main.show_temp_msg("Batch Annotation", "No new labels to apply.") - return - - # 2. Push the ENTIRE batch as a SINGLE command to the undo stack - self.main.model.push_undo( - CmdType.BATCH_ANNOTATION_CONFIRM, - batch_changes=batch_changes - ) - - # 3. Actually apply the changes to the model - for path, changes in batch_changes.items(): - self.main.model.manual_annotations[path] = changes['new_data'] - self.main.update_action_item_status(path) - applied_count += 1 + if path in self.main.model.smart_annotations: + # [NEW] Set a confirmed flag directly in smart memory + self.main.model.smart_annotations[path]["_confirmed"] = True + self.main.update_action_item_status(path) + applied_count += 1 - # 4. Update UI global states + # Update UI global states if applied_count > 0: self.main.model.is_data_dirty = True self.main.update_save_export_button_state() - self.main.show_temp_msg("Batch Annotation", f"Applied labels to {applied_count} items. (1 Undo Step)") + self.main.show_temp_msg("Batch Annotation", f"Confirmed {applied_count} smart annotations independently.") + else: + self.main.show_temp_msg("Batch Annotation", "No smart annotations to confirm.") \ No newline at end of file diff --git a/annotation_tool/controllers/description/.DS_Store b/annotation_tool/controllers/description/.DS_Store deleted file mode 100644 index 5008ddfcf53c02e82d7eee2e57c38e5672ef89f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeH~Jr2S!425mzP>H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0b(B74Fx%X4nA+fk2QQ?M4D4kc3y_63E6YY2^SG2w2Gy;t+d`(csw~d1rS8 zR!GUI%4OoDT&~>MCVz6d;+!~6ek86+h)pV$9}cF1E0s8Q#g)TVxt)sbO67I;>}prH zau7nMw|d@m_j^6tZ{K_0>)s)RKu0FtOh|wbB4K4zRq*m&Li~&t6gJN4lv08{fVCPc z-m8Q}h>ahE+=F%QLXm~ZmttK)V#F+xk6|l~^H?OA4X2W(l|0G#?Q|e;6)1bQTU-se z8gMn>YQWWitAT}UfMG8d$s?$Hbv58>z|}xr11vuHu`(KT=(wOfbl`=h@NAr=@VwwX z?gMzA-=RT=jtj~~QHF~m6{6s5F+hl8zn`%q4LWpOP$5o0h!b$049*S(_|s9&&)EqC z1$D2k23!qH)c|vsa@>1cB!M5?Yez@zk*H}!vH$7lh-u{Z+ofgY6;g%dk&cCjta{F0n!Arg?>6+&A$3Kkl}tyBq@LmeMqEdhqy0uQZjE+WnPlANvSBeG z$&x&zR*#J}x3)C;n|EwG+vp$L(GqC%Z`r)<>{(f=s=KxA(9peRB56G+mH|#EAd4q0 z>)hlunJ1gWF)J}5_}1!E#_epUSaV4@x8@h|jDfFNUgYsC*7^qy4<0!>q^cQPKct(H zw2{?Ak+g0PsY`8C!Je3vJdo0}xb*&*Wo8md99b5Nn6V5p>+Pl)HtrYPv6N++2Q1r2 z8&>l0Kw3YYW@BukqM?C7+e)Q7`D6nI&M&ws*)w`Zn6Q1fp0bh3@{>ze`PSAqwQRpF z*tKWh!7EGEWtARJ)yn>?VH;6XKOC`RdNOV#Pxg%(@$~Q@YCV}Y?lJU~EH8t`-ee@9 zU#YBKz2GEw8cjFCR0)=83hhE`iAOX|g=aa`m+E6!$&^ASy}T`x&T zSbt|q$E)b}Xbp`h5b3pX$4u7RGt)yP6 z(Hgp**3*r&f$pMr(B1SP9i&5aSge8O-0@g`P5g$QK~fZAOgrm@82aS&?&f>P?mc_& z>b_y!?b;INV9Qrjtz6|_xAE33t+%)5%}`iv&QJ>-fZKeLr*NC+b}`vrxhHyRe0}H? z`soQ*6t02%bfI-0fP)Z*UN{CrZ~~%e>n0?@g8Lu^V{jHNeH3hIggbp=6v8c4l9us%(I%Om|1fS4BKmn&6D0hrGeD0qxvu|mGGbip9+*J_ob zu!Ug+=fMcLPOH@lBb<{JY|t7M9_)C4VT_xUfWm_v4=b!^v$9nap^}Fz*43)))N*l? z2QJomyHcg)W2-E(j&`k6n-nlvNaA1VP8=(Mc;_4K#onn9&jwK&(}Dcbe+A^_Jj!!W zZy_PlO-_&$86yvokCP`5)Gv|mlNZR($gdI1{{{f%P=RpnK|o&vYoQUg!ggqd9nb+i z80rpTs5=V7V8AJ`;WPsI7`zkSgW>I*6W$(0Ain^g#=!PfC$xPBo`cKq1Nbq#46nd1 z;Z^t@ya8{*TeOU>qSbUYt)-2$iEg6XX)E1H+vtAULkDPto}w0Ya^+b;eP%bE1K^d# zUIy?A0{s7o>s=?nS89AYXrJuKA`y_7533a%w7p8*T9ziOWlpnx151(Ba?$iQZ9<^F zs8Zfkye*qq-mR86&8F?x1kq61Zetm^TIw`wg4iUy;q`{P7~Ui3&z0KF)9SC0m&vcl z@5x(e^-EwmRAc%YKwIyC-3aT<+QZNX#~oOYL&AY|8!cYI`n%EU8LWS}Ac_4fhxI4m ziwNr9KvO!zT|Ltpco*Ly~KMVf^@Xv?8CLGcdDb$=1?M*L^bO)vg$(u+Cv@}c~@0`uL=7p58WmBCcw9}YbG Ai2wiq diff --git a/annotation_tool/ui/classification/.DS_Store b/annotation_tool/ui/classification/.DS_Store deleted file mode 100644 index ffe21a5b6c64060cdb1d54f9b3665945a33a6f58..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12292 zcmeHNYitx%6uxI#V20r_)RvbFyRrfmO4t^tAPU>=Hu7kBZA%|KmffA9oiIC7c4oKK zrl!V3l&Xm_(Wr?3jQRsL2tj|5AVCx34}wJbBgVvE{PBf9OpNEwo$ZsYkcbkvH@WxR zd+s^+?)~PRxpVGaLI~97v}!^s2_Yh8N|k9?JV!`SMmi$VewxRn666481vA!>=)@u} zNg&n6Nsng;$fEkCn5Usmew4m}BiSUIk7q5@wmPY-}@qH&F-nUbC%|Kx^dPaN}`ld z=JxefudS&HR) zJFDYqJJUTvrzjStlaDeiO5z19uKH&YqBK>Or>m`P?HzmeCKM&-=(}_?=^A-GmUML| zq0Dp;n$2n3+L6`s2uW+&Hgg#ZduFARW;%z$dYx&;ji;D&I%}Ke4%;zY!?xPnT>Xek z`{+QUy4pG%JL^Wh!P*QQpGlR`IktCGWoNgZb&yKZ$r*D34_B_JdGzr}!{(-)muD)o z%4E4>c5B{njFhRjC!MrzX@=F=(qm|DR|h@~%QX%fdRCHVK~=Mr%;=ZP=FXe{-~v@; zGQ&EBQQC4T<8aP!dzFwVvN_a6AxTtD@6~XTO}0iy)Vv;5U9?mb_fUQ`t7FO7vbwwq z6_U*kF4_N?;c7`zoaRGlGE7xoQ!7e})?y}8y6LkXTaT=*t$DU$pINa{l-lqqXK*nN z`Spp8@j}F;uX(P-GdahR&r2yqbq`C=zDu zaz=(qx=E@eOzO!NvL79_mkg5E$UEc{a+;hW7s%J-3b{spB0rN~$gkuE`4dWEDwM+l zSPV;{5*~pXSOu%07NXDyO|T6*APpMm&0oPc-X zL-+_jhR@(Ud=3}jB3yy*;Trq^*Wq`#0e=XRP$n!8RAHg8RHzh|3Co2I!bV|}uv6#| z5<(YSgHUkOeM4*VDnc)kiCoWr4GAZ2?s|bIH*MaswehZXz3*mFXM3Q$V)mTi;$nt`4+AII$Hf-Py!;%fgmh_Fk1Nrh(I00paCs?2keA6 zG{ZhfzyV01-J4*64Tm8MebA3qKLF3bF?bPPf|ucScmr+!IJ}LP{~o*#AHXR%4QJpi zeCqkYSMUv7g&)UQb<=38hTkT#>Wm$W`J1cQcW}RY{?&_h6U%G2CEwk3>zmuiL-x^Y z;4O{-M}Q;15#R`L1nxZq=skMTyZ3u0{x9<1f}6(w#bkN>&*OhQ#^Le*os0jAyB?4K zy;CzD|BpNMy8Gk*B33;z{y%!+*;7ODe^L42`2SYq{~`XzeSZ@BoT;Su{|&|e|Hq_w z$r0cP+>;1EX)GSA$E6ic=f|#+-n9oYAHYmy&S9nr>z)c0-gP`u?>Zi-cO8$^zsc|f pBfic6qu7h-KmQ}Z|8e!k&;Re^^Z%mz`SAJwt?cI+`(NGv{|0}{_$UAX diff --git a/annotation_tool/ui/classification/event_editor/dynamic_widgets.py b/annotation_tool/ui/classification/event_editor/dynamic_widgets.py index 32a8561f..cd4db1ab 100644 --- a/annotation_tool/ui/classification/event_editor/dynamic_widgets.py +++ b/annotation_tool/ui/classification/event_editor/dynamic_widgets.py @@ -15,7 +15,8 @@ def __init__(self, head_name, definition, parent=None): self.definition = definition self.layout = QVBoxLayout(self) - self.layout.setContentsMargins(0, 5, 0, 15) + self.layout.setContentsMargins(0, 0, 0, 2) + self.layout.setSpacing(2) # Header header_layout = QHBoxLayout() @@ -37,7 +38,7 @@ def __init__(self, head_name, definition, parent=None): self.radio_group.setExclusive(True) self.radio_container = QWidget() self.radio_layout = QVBoxLayout(self.radio_container) - self.radio_layout.setContentsMargins(10, 0, 0, 0) + self.radio_layout.setContentsMargins(5, 0, 0, 0) self.layout.addWidget(self.radio_container) # Input for new label @@ -68,10 +69,11 @@ def update_radios(self, labels): for i, lbl_text in enumerate(labels): row_widget = QWidget() row_layout = QHBoxLayout(row_widget) - row_layout.setContentsMargins(0, 2, 0, 2) + row_layout.setContentsMargins(0, 0, 0, 0) rb = QRadioButton(lbl_text) self.radio_group.addButton(rb, i) + rb.setProperty("class", "label_item") del_label_btn = QPushButton("×") del_label_btn.setCursor(Qt.CursorShape.PointingHandCursor) @@ -113,11 +115,11 @@ def __init__(self, head_name, definition, parent=None): self.definition = definition self.layout = QVBoxLayout(self) - self.layout.setContentsMargins(0, 5, 0, 15) + self.layout.setContentsMargins(0, 2, 0, 5) # Header header_layout = QHBoxLayout() - self.lbl_head = QLabel(head_name + " (Multi)") + self.lbl_head = QLabel(head_name) self.lbl_head.setProperty("class", "group_head_lbl group_head_multi") self.btn_del_cat = QPushButton("×") @@ -131,7 +133,7 @@ def __init__(self, head_name, definition, parent=None): self.checkbox_container = QWidget() self.checkbox_layout = QVBoxLayout(self.checkbox_container) - self.checkbox_layout.setContentsMargins(10, 0, 0, 0) + self.checkbox_layout.setContentsMargins(5, 0, 0, 0) self.layout.addWidget(self.checkbox_container) # Input @@ -158,11 +160,12 @@ def update_checkboxes(self, new_types): for type_name in sorted(list(set(new_types))): row_widget = QWidget() row_layout = QHBoxLayout(row_widget) - row_layout.setContentsMargins(0, 2, 0, 2) + row_layout.setContentsMargins(0, 0, 0, 0) cb = QCheckBox(type_name) cb.clicked.connect(self._on_box_clicked) self.checkboxes[type_name] = cb + cb.setProperty("class", "label_item") del_label_btn = QPushButton("×") del_label_btn.setCursor(Qt.CursorShape.PointingHandCursor) @@ -185,4 +188,4 @@ def set_checked_labels(self, label_list): if not label_list: label_list = [] for text, cb in self.checkboxes.items(): cb.setChecked(text in label_list) - self.blockSignals(False) + self.blockSignals(False) \ No newline at end of file diff --git a/annotation_tool/ui/classification/event_editor/editor.py b/annotation_tool/ui/classification/event_editor/editor.py index 6e4a9e78..40e9f0b2 100644 --- a/annotation_tool/ui/classification/event_editor/editor.py +++ b/annotation_tool/ui/classification/event_editor/editor.py @@ -1,7 +1,7 @@ import math from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, - QGroupBox, QLineEdit, QScrollArea, QFrame, QProgressBar, QToolTip, QTextEdit + QGroupBox, QLineEdit, QScrollArea, QFrame, QProgressBar, QToolTip, QTextEdit, QTabWidget, QComboBox ) from PyQt6.QtCore import Qt, pyqtSignal, QRectF, QPointF from PyQt6.QtGui import QPainter, QColor, QPen, QFont, QCursor @@ -11,7 +11,7 @@ class NativeDonutChart(QWidget): def __init__(self, parent=None): super().__init__(parent) - self.setMinimumSize(220, 220) + self.setMinimumSize(160, 160) self.setMouseTracking(True) self.data_dict = {} @@ -133,12 +133,19 @@ class ClassificationEventEditor(QWidget): smart_infer_requested = pyqtSignal() confirm_infer_requested = pyqtSignal(dict) - batch_run_requested = pyqtSignal(int, int) batch_confirm_requested = pyqtSignal(dict) + annotation_saved = pyqtSignal(dict) + smart_confirm_requested = pyqtSignal() # [NEW] Signal emitted when confirming from the Smart Tab + batch_run_requested = pyqtSignal(int, int) + + # [NEW] Signals for tab-aware clearing + hand_clear_requested = pyqtSignal() + smart_clear_requested = pyqtSignal() + def __init__(self, parent=None): super().__init__(parent) - self.setFixedWidth(350) + self.setFixedWidth(320) layout = QVBoxLayout(self) self.is_batch_mode_active = False @@ -173,8 +180,14 @@ def __init__(self, parent=None): schema_layout.addWidget(self.add_head_btn) layout.addWidget(schema_box) - # --- 4. Hand Annotation --- - self.manual_box = QGroupBox("Hand Annotations") + # [NEW] Create QTabWidget to hold both annotation modes + self.tabs = QTabWidget() + self.tabs.setObjectName("annotation_tabs") + layout.addWidget(self.tabs, 1) # Add tabs to main layout with stretch factor 1 + + # --- 4. Hand Annotation Tab --- + # Changed from QGroupBox to QWidget to fit seamlessly inside the Tab + self.manual_box = QWidget() self.manual_box.setEnabled(False) manual_layout = QVBoxLayout(self.manual_box) @@ -188,19 +201,26 @@ def __init__(self, parent=None): scroll.setWidget(self.label_container) manual_layout.addWidget(scroll) - layout.addWidget(self.manual_box, 1) + + # Add the manual widget as the first tab + self.tabs.addTab(self.manual_box, "Hand Annotation") - # --- 5. Smart Annotation --- - self.smart_box = QGroupBox("Smart Annotation") + # --- 5. Smart Annotation Tab --- + # Changed from QGroupBox to QWidget to fit seamlessly inside the Tab + self.smart_box = QWidget() smart_layout = QVBoxLayout(self.smart_box) + + # [NEW] Force all items in the smart tab to align to the top + # This prevents the inference buttons from jumping around + smart_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - # Two Buttons + # Two Buttons for Inference btn_h_layout = QHBoxLayout() - self.btn_smart_infer = QPushButton("🚀Single Inference") + self.btn_smart_infer = QPushButton("Single Inference") self.btn_smart_infer.setCursor(Qt.CursorShape.PointingHandCursor) self.btn_smart_infer.clicked.connect(self.smart_infer_requested.emit) - self.btn_batch_infer = QPushButton("🚀Batch Inference") + self.btn_batch_infer = QPushButton("Batch Inference") self.btn_batch_infer.setCursor(Qt.CursorShape.PointingHandCursor) self.btn_batch_infer.clicked.connect(lambda: self.batch_input_widget.setVisible(not self.batch_input_widget.isVisible())) @@ -208,21 +228,24 @@ def __init__(self, parent=None): btn_h_layout.addWidget(self.btn_batch_infer) smart_layout.addLayout(btn_h_layout) - # Input Box + # Input Box for Batch Inference self.batch_input_widget = QWidget() h_batch = QHBoxLayout(self.batch_input_widget) h_batch.setContentsMargins(0, 5, 0, 5) - self.spin_start = QLineEdit() - self.spin_start.setPlaceholderText("Start Idx") - self.spin_end = QLineEdit() - self.spin_end.setPlaceholderText("End Idx") - self.btn_run_batch = QPushButton("Run Batch") + self.spin_start = QComboBox() + self.spin_end = QComboBox() + self.btn_run_batch = QPushButton("Run") self.btn_run_batch.setCursor(Qt.CursorShape.PointingHandCursor) self.btn_run_batch.clicked.connect(self._on_run_batch_clicked) h_batch.addWidget(self.spin_start) h_batch.addWidget(self.spin_end) h_batch.addWidget(self.btn_run_batch) self.batch_input_widget.setVisible(False) + + + # [NEW] Connect validation signals to enforce i <= j rule + self.spin_start.currentIndexChanged.connect(self._validate_batch_range) + #self.spin_end.currentIndexChanged.connect(self._validate_batch_range) self.infer_progress = QProgressBar() self.infer_progress.setRange(0, 0) @@ -233,28 +256,33 @@ def __init__(self, parent=None): self.batch_result_text = QTextEdit() self.batch_result_text.setReadOnly(True) self.batch_result_text.setVisible(False) - self.batch_result_text.setMinimumHeight(200) + self.batch_result_text.setMinimumHeight(120) smart_layout.addWidget(self.batch_input_widget) smart_layout.addWidget(self.infer_progress) smart_layout.addWidget(self.chart_widget, alignment=Qt.AlignmentFlag.AlignCenter) smart_layout.addWidget(self.batch_result_text) - layout.addWidget(self.smart_box) + + # Add the smart widget as the second tab + self.tabs.addTab(self.smart_box, "Smart Annotation") + # --- 6. Bottom Confirm Buttons (Fixed Outside Tabs) --- btn_row = QHBoxLayout() - self.confirm_btn = QPushButton("✅ Confirm Annotation") - self.clear_sel_btn = QPushButton("🗑️ Clear Selection") + self.confirm_btn = QPushButton("Confirm Annotation") + self.clear_sel_btn = QPushButton("Clear Selection") self.confirm_btn.setProperty("class", "editor_save_btn") self.confirm_btn.setCursor(Qt.CursorShape.PointingHandCursor) self.clear_sel_btn.setCursor(Qt.CursorShape.PointingHandCursor) self.confirm_btn.clicked.connect(self.on_confirm_clicked) + # [NEW] Route the clear button internally + self.clear_sel_btn.clicked.connect(self.on_clear_clicked) btn_row.addWidget(self.confirm_btn) btn_row.addWidget(self.clear_sel_btn) - layout.addLayout(btn_row) + layout.addLayout(btn_row) # Add strictly to the main vertical layout, remaining at the bottom - self.label_groups = {} + self.label_groups = {} def _on_run_batch_clicked(self): try: @@ -264,11 +292,36 @@ def _on_run_batch_clicked(self): except ValueError: pass - def on_confirm_clicked(self): - if self.is_batch_mode_active: - self.batch_confirm_requested.emit(self.pending_batch_results) - self.reset_smart_inference() + def on_confirm_clicked(self): + """[MODIFIED] Route confirm action based on the active tab.""" + active_tab_idx = self.tabs.currentIndex() + + if active_tab_idx == 0: + # --- Hand Annotation Confirmation --- + data = {} + for head, group in self.label_groups.items(): + if hasattr(group, 'get_checked_label'): + val = group.get_checked_label() + if val: data[head] = val + elif hasattr(group, 'get_checked_labels'): + val = group.get_checked_labels() + if val: data[head] = val + self.annotation_saved.emit(data) + + elif active_tab_idx == 1: + # --- Smart Annotation Confirmation --- + self.smart_confirm_requested.emit() + + def on_clear_clicked(self): + """[NEW] Route clear action based on the active tab.""" + active_tab_idx = self.tabs.currentIndex() + if active_tab_idx == 0: + self.hand_clear_requested.emit() + elif active_tab_idx == 1: + self.smart_clear_requested.emit() + + # [MODIFIED] Hide the batch input box upon confirmation or action switch def reset_smart_inference(self): self.is_batch_mode_active = False self.chart_widget.setVisible(False) @@ -276,6 +329,58 @@ def reset_smart_inference(self): self.btn_smart_infer.setEnabled(True) self.btn_batch_infer.setEnabled(True) self.infer_progress.setVisible(False) + + # Ensures Run Batch dropdowns disappear after Confirm or switching videos + self.batch_input_widget.setVisible(False) + + # [MODIFIED] Save the full list and initialize the dropdowns + def update_action_list(self, action_names: list): + self.full_action_names = action_names + + self.spin_start.blockSignals(True) + self.spin_end.blockSignals(True) + + self.spin_start.clear() + self.spin_end.clear() + + self.spin_start.addItems(self.full_action_names) + self.spin_end.addItems(self.full_action_names) + + self.spin_start.blockSignals(False) + self.spin_end.blockSignals(False) + + # [MODIFIED] Dynamically update the second dropdown to only show items from index i onwards + def _validate_batch_range(self): + start_idx = self.spin_start.currentIndex() + if start_idx < 0: return + + current_end_text = self.spin_end.currentText() + + self.spin_end.blockSignals(True) + self.spin_end.clear() + + # Only add items starting from the selected 'start_idx' + valid_end_items = self.full_action_names[start_idx:] + self.spin_end.addItems(valid_end_items) + + # Attempt to restore the previous selection if it's still in the valid range + if current_end_text in valid_end_items: + self.spin_end.setCurrentText(current_end_text) + else: + self.spin_end.setCurrentIndex(0) + + self.spin_end.blockSignals(False) + + # [MODIFIED] Calculate absolute end index based on dynamic relative index + def _on_run_batch_clicked(self): + start_idx = self.spin_start.currentIndex() + + # Since spin_end only contains items from start_idx onwards, + # its absolute index is its relative index + start_idx + end_idx = start_idx + self.spin_end.currentIndex() + + if start_idx >= 0 and end_idx >= start_idx: + self.batch_run_requested.emit(start_idx, end_idx) def show_inference_loading(self, is_loading: bool): self.btn_smart_infer.setEnabled(not is_loading) @@ -289,13 +394,6 @@ def display_inference_result(self, target_head: str, predicted_label: str, conf_ self.show_inference_loading(False) self.is_batch_mode_active = False self.chart_widget.update_chart(predicted_label, conf_dict) - - group = self.label_groups.get(target_head) - if group: - if hasattr(group, 'set_checked_label'): - group.set_checked_label(predicted_label) - elif hasattr(group, 'set_checked_labels'): - group.set_checked_labels([predicted_label]) def display_batch_inference_result(self, result_text: str, batch_predictions: dict): self.show_inference_loading(False) @@ -347,9 +445,10 @@ def get_annotation(self): return result def clear_selection(self): - self.reset_smart_inference() + # [MODIFIED] Keep the Donut Chart visible even if the user clears hand annotations. + # self.reset_smart_inference() for group in self.label_groups.values(): if hasattr(group, 'set_checked_label'): group.set_checked_label(None) elif hasattr(group, 'set_checked_labels'): - group.set_checked_labels([]) + group.set_checked_labels([]) \ No newline at end of file diff --git a/annotation_tool/ui/common/.DS_Store b/annotation_tool/ui/common/.DS_Store deleted file mode 100644 index 5008ddfcf53c02e82d7eee2e57c38e5672ef89f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeH~Jr2S!425mzP>H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0 None: + super().__init__(parent) + self.setWindowTitle("Classification Project Type") + self.resize(450, 180) + self.is_multi_view = False # Default to Single-View + + layout = QVBoxLayout(self) + layout.setSpacing(15) + layout.setContentsMargins(30, 30, 30, 30) + + lbl = QLabel("Is this a Single-View or Multi-View project?") + lbl.setProperty("class", "dialog_instruction_lbl") + lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(lbl) + + btn_layout = QHBoxLayout() + btn_layout.setSpacing(20) + + self.btn_sv = QPushButton("Single-View\n(Individual Videos)") + self.btn_sv.setMinimumSize(QSize(0, 70)) + self.btn_sv.setCursor(Qt.CursorShape.PointingHandCursor) + + self.btn_mv = QPushButton("Multi-View\n(Grouped by Folder)") + self.btn_mv.setMinimumSize(QSize(0, 70)) + self.btn_mv.setCursor(Qt.CursorShape.PointingHandCursor) + + btn_layout.addWidget(self.btn_sv) + btn_layout.addWidget(self.btn_mv) + layout.addLayout(btn_layout) + + # Connect signals + self.btn_sv.clicked.connect(lambda: self.finalize_selection(False)) + self.btn_mv.clicked.connect(lambda: self.finalize_selection(True)) + + def finalize_selection(self, is_multi: bool): + self.is_multi_view = is_multi + self.accept() class FolderPickerDialog(QDialog): """ diff --git a/annotation_tool/ui/common/video_surface.py b/annotation_tool/ui/common/video_surface.py index 4c160c71..9235cc4a 100644 --- a/annotation_tool/ui/common/video_surface.py +++ b/annotation_tool/ui/common/video_surface.py @@ -35,6 +35,8 @@ def __init__(self, parent=None): # 3. Add video widget to layout self.layout.addWidget(self.video_widget) + + def load_source(self, path): """ Loads the video source. diff --git a/annotation_tool/ui/common/welcome_widget.py b/annotation_tool/ui/common/welcome_widget.py index d99804f1..5805ebf4 100644 --- a/annotation_tool/ui/common/welcome_widget.py +++ b/annotation_tool/ui/common/welcome_widget.py @@ -70,7 +70,7 @@ def __init__(self, parent=None): self.tutorial_btn.setFixedSize(160, 40) self.tutorial_btn.setProperty("class", "welcome_secondary_btn") self.tutorial_btn.setCursor(Qt.CursorShape.PointingHandCursor) - self.tutorial_btn.clicked.connect(lambda: QDesktopServices.openUrl(QUrl("https://drive.google.com/file/d/1EgQXGMQya06vNMuX_7-OlAUjF_Je-ye_/view?usp=sharing"))) + self.tutorial_btn.clicked.connect(lambda: QDesktopServices.openUrl(QUrl("https://www.youtube.com/"))) self.github_btn = QPushButton("🐙 GitHub Repo") self.github_btn.setFixedSize(160, 40) @@ -81,4 +81,4 @@ def __init__(self, parent=None): links_layout.addWidget(self.tutorial_btn) links_layout.addWidget(self.github_btn) - layout.addLayout(links_layout) + layout.addLayout(links_layout) \ No newline at end of file diff --git a/annotation_tool/ui/common/workspace.py b/annotation_tool/ui/common/workspace.py index 8e58fea1..386d8f1f 100644 --- a/annotation_tool/ui/common/workspace.py +++ b/annotation_tool/ui/common/workspace.py @@ -32,7 +32,7 @@ def __init__(self, # 1. Setup Layout layout = QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(5) + layout.setSpacing(2) # 2. Instantiate Left Panel (Common) # Default to Localization-style naming if not provided diff --git a/annotation_tool/ui/description/.DS_Store b/annotation_tool/ui/description/.DS_Store deleted file mode 100644 index 4dcda3f1a25d424fcdb3612103ee6af696bed828..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12292 zcmeHNYitx%6uxI#V20r_)RvblyRrfmO4ye25`}Gd8+o+6wxtgq%kIwDPMDopc4oKK zrl!V3l=_G2UAVd5lg^TZM02yg^A z0vrL3z?~yN_g+lmsQsRCdC3vr2t3dT(Dy?CGo@jV_A~0fI}l0a)#|Rda;9p#x-r%vN}?23 zX7~24s9jqXs9s&&R~6`8v!oI+e8yW4mSRj&7OlZH{)x zp?!3q5nXK^wv}}v?qF>?j?bjZ=p5TJRN3C8Wo@LAbaL7(|D%=5YF0lHZrIecFU6zVLJNLx|Wrs8Bo=1CeqrKve|RyK0MFo zV=_ZJg;CmaN&R3>cY2hdD6%pBbu_B*kt%fF{FK<&~>MNl{yjL{c+6*5m7twWT%BR_rk=Hi%LiKIJqn z#%4TUBr1x^m;30%jS1V?meI_?pA^|BiXC=WBCBodMuRPAG>W487?Dp^8dTk}vMw51 z7x;Ybbf`L2)l^*c+}U%=d@)~(s9Z)JMK|+0x(Gf4<|$&ZV57nC5>^eKPxZSs7vo?! z#MtGG43>10^pOy$C!5JWbkrU)K#r1k$tUCtIZG~*Z^%`0o%~FGAvejd>wi>*N@xar=(HMtd`2T9S_kYVS&*N|}XpIy)Y)W%Jlw=~|fuJ`>k>TC~{SInFh zSh!?)?V2a+22GM#aKUJYJA-TgBfQ47f7eDv+pg=C@_c^_I*4{?$Ss+h7MxR=L>*bY zLZu&(gMMWKx)5tFk}LhnM06k4T#Z=lux1tlQDEC|3N2%(j)hcMJZ6dKUd zx5EyIK{M=yIP8Zc+PwiLSa1-s&?dsJFR_uT8!A`G#Yc>c*E>??^t=HGFeB zMSogR4;y~SP6c`52yg^A0vrL307u|{Mu6U<7rlGGZ{q(V-xiFF|J}!26blSOc>K@f ze|j#!PqKQbn`Oj1ej{~L;b|Hq_w$r0cP+?NPIX*3qC$E6ic=SQ!S-n9oX@5fAK&SRzt z>#hnG?sYs;_c|V_dmWF|yUB0`!=6q*qu7h-KmQQm|G0YN=l>7z`G3*H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T04LI~97v}!^s2_Y0Sqv|v)o+KosARUodKg;7%3333liW%!jbYc;g zB#>(3q{lNPWHJ3x%+pXOKT6-gk!+I9$Fr7cTU|`naU+onAeEF(nmh&DA_)Wp0s;Yn zfIvVXaN`KDy%!T5v)^;BCIA%*aU6eU@FNuPW=DR=6DJVmRFnb% zfxvhKnA=Pv882ctND^cB9%3TxLryl;>-3~dJB2bQQ$40JGXADhs6zL~yY1e%n{;zd zeKNb7wOVZ3OcmE5c9%yUt=-j<0p{hAY->I8P*U0O!q^mm# zb%uk`Y)aeK_N<;qNZQi2nafz%Gc%nu(>WB@>r69lJjSKdS=%(X+m7KHw$;(@>W5s` z#|9eJ)!ymYS+~I(tlhxzxl|dOV_OeXc6RAm2dOL{yK8pvzRKk_4?GlY+|<0|{0wzw znW9w8YRemrkuvp;q?6Vy&9J&!dkxL)?!>2IxyGZ0o|Wa9P}O23Gy3_mIdkXTGd~dE zGQ&EBQQC7U<6zEk`_wR{e2&1vuuRqCdo)~Rv#qfaHLoWSSh$4J-HhLm)v@GkC9tdt z6_PCuF4_N?k!o31ot6V=GF(+zT}x$EYc-Q8-Sk-xu0z(Awme_4&#c%$<#v3^8C;CR zd4Yx)rRo>^*u+gq$KBSWTSGsoVI!rTPIoe^Z|g;aEod}R>ODr}GnGcoaP6#z#@2;_ zKnEMDPSbP^7d>yz+_FGC&`Q;FsG}NI-ar?@XTUv$h70x_bQGBS*+ffm|Y&$CT%g$8JXX4ndykOmEO=mHaRkcVFAgU8`1cp9F8Bk%&e3a`OYcmv*o z_uzf_06u}w;BzCBEjXt-g*h@k&!q2F z!a;Qsx)5(JRw{$)WON_iTozm&R3-FU-i!om6tx6>QucIL2bU`6eS06pT2muZen@uhU9y?uYYqJdB{F` z4WcCw5C{ka1Ofs9fxzvD0J}#odiQ?Y#Q#P9TX4^ zzjtaT;{S1{UblYyU&N|M#{WN^Ilbm|A^tBaKOFyGkNiKx|G4i@;-52>?Eb&8`2YX7 zlqdxP0)g8S0Vs{dWA(VS!s-0jRkFMG5a#`unanB7EMeVK!NR+aN9tY2BlWK1k@`0o qo?z718RQguG5zO%1o%I$-o*L;9en;@bUz`~TlC|N3YE diff --git a/annotation_tool/ui/localization/event_editor/.DS_Store b/annotation_tool/ui/localization/event_editor/.DS_Store deleted file mode 100644 index 5008ddfcf53c02e82d7eee2e57c38e5672ef89f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeH~Jr2S!425mzP>H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0 None: self.ui.show_welcome_view() self._adjust_window_size(0) + # --------------------------------------------------------------------- # Global Media Control to Prevent Freezing/Ghost Frames # --------------------------------------------------------------------- @@ -136,9 +137,11 @@ def _adjust_window_size(self, index: int) -> None: self.resize(600, 400) else: - self.setMinimumSize(1000, 700) + #self.setMinimumSize(1000, 700) - self.resize(1400, 900) + #self.resize(1400, 900) + self.setMinimumSize(600, 400) + self.resize(1200, 800) def _safe_import_annotations(self): """Wrapper to ensure players are stopped before loading a new project.""" @@ -162,6 +165,12 @@ def connect_signals(self) -> None: # --- Classification - Left panel --- cls_left = self.ui.classification_ui.left_panel + # [NEW] Customize the filter combo box exclusively for Classification mode + # Blocking signals prevents triggering filter logic before UI is fully built + cls_left.filter_combo.blockSignals(True) + cls_left.filter_combo.clear() + cls_left.filter_combo.addItems(["Show All", "Hand Labelled", "Smart Labelled", "No Labelled"]) + cls_left.filter_combo.blockSignals(False) cls_controls = cls_left.project_controls cls_controls.createRequested.connect(self._safe_create_project) @@ -187,8 +196,18 @@ def connect_signals(self) -> None: # --- Classification - Right panel --- cls_right = self.ui.classification_ui.right_panel - cls_right.confirm_btn.clicked.connect(self.annot_manager.save_manual_annotation) - cls_right.clear_sel_btn.clicked.connect(self.annot_manager.clear_current_manual_annotation) + + # [MODIFIED] Disconnect the direct button click and use our new Tab-aware signals + # cls_right.confirm_btn.clicked.connect(self.annot_manager.save_manual_annotation) # <-- 删除或注释掉这行旧代码 + + # [NEW] Connect the tab-aware confirm signals to their respective manager functions + cls_right.annotation_saved.connect(lambda data: self.annot_manager.save_manual_annotation()) + cls_right.smart_confirm_requested.connect(self.annot_manager.confirm_smart_annotation_as_manual) + + # [MODIFIED] Connect tab-aware clear signals + cls_right.hand_clear_requested.connect(self.annot_manager.clear_current_manual_annotation) + cls_right.smart_clear_requested.connect(self.annot_manager.clear_current_smart_annotation) + cls_right.add_head_clicked.connect(self.annot_manager.handle_add_label_head) cls_right.remove_head_clicked.connect(self.annot_manager.handle_remove_label_head) @@ -581,12 +600,31 @@ def get_current_action_path(self): return idx.parent().data(ProjectTreeModel.FilePathRole) return idx.data(ProjectTreeModel.FilePathRole) + + def sync_batch_inference_dropdowns(self) -> None: + """[NEW] Sync the Action List names from the model to the Batch Inference dropdowns.""" + right_panel = self.ui.classification_ui.right_panel + # Ensure the UI component exists and supports updating + if not hasattr(right_panel, 'update_action_list'): + return + + # Sort the data using natural sort to exactly match the left tree + sorted_list = sorted(self.model.action_item_data, key=lambda d: natural_sort_key(d.get("name", ""))) + action_names = [d["name"] for d in sorted_list] + + # Push the updated list to the dropdowns + right_panel.update_action_list(action_names) + def populate_action_tree(self) -> None: """Rebuild the action tree from model data using the new ProjectTreeModel.""" self.tree_model.clear() self.model.action_item_map.clear() sorted_list = sorted(self.model.action_item_data, key=lambda d: natural_sort_key(d.get("name", ""))) + + # [NEW] Extract sorted names and sync them to the Batch Inference dropdowns + action_names = [d["name"] for d in sorted_list] + self.ui.classification_ui.right_panel.update_action_list(action_names) for data in sorted_list: item = self.tree_model.add_entry( @@ -595,9 +633,10 @@ def populate_action_tree(self) -> None: source_files=data.get("source_files") ) self.model.action_item_map[data["path"]] = item + self.update_action_item_status(data["path"]) - for path in self.model.action_item_map.keys(): - self.update_action_item_status(path) + # [MODIFIED] Use the centralized sync method to update Smart Annotation dropdowns + self.sync_batch_inference_dropdowns() # Decide which manager handles the navigation logic if self._is_loc_mode(): @@ -643,8 +682,11 @@ def update_action_item_status(self, action_path: str) -> None: elif self._is_dense_mode(): is_done = action_path in self.model.dense_description_events and bool(self.model.dense_description_events[action_path]) else: - # Classification mode logic - is_done = action_path in self.model.manual_annotations and bool(self.model.manual_annotations[action_path]) + #is_done = action_path in self.model.manual_annotations and bool(self.model.manual_annotations[action_path]) + # [MODIFIED] Classification mode logic: Done if manually annotated OR smart confirmed + is_manual_done = action_path in self.model.manual_annotations and bool(self.model.manual_annotations[action_path]) + is_smart_done = self.model.smart_annotations.get(action_path, {}).get("_confirmed", False) + is_done = is_manual_done or is_smart_done item.setIcon(self.done_icon if is_done else self.empty_icon) @@ -704,4 +746,4 @@ def refresh_ui_after_undo_redo(self, action_path: str) -> None: else: self.annot_manager.display_manual_annotation(action_path) - self.update_save_export_button_state() + self.update_save_export_button_state() \ No newline at end of file From 54219ada4ad5290b74076d0004eb5068263290f5 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Mon, 9 Mar 2026 12:56:31 +0100 Subject: [PATCH 36/63] Update ci.yml Update to VideoAnnotationTool --- .github/workflows/ci.yml | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a8ede1d..4b978cb9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,7 +58,7 @@ jobs: shell: pwsh run: > python -m PyInstaller --noconfirm --clean --windowed --onefile - --name "SoccerNetProAnalyzer" + --name "VideoAnnotationTool" --add-data "style;style" --add-data "ui;ui" --add-data "controllers;controllers" @@ -73,15 +73,15 @@ jobs: if: github.event_name == 'workflow_dispatch' shell: pwsh run: | - Move-Item -Force dist\SoccerNetProAnalyzer.exe dist\SoccerNetProAnalyzer-win.exe - Compress-Archive -Path dist\SoccerNetProAnalyzer-win.exe -DestinationPath dist\SoccerNetProAnalyzer-win.zip -Force + Move-Item -Force dist\VideoAnnotationTool.exe dist\VideoAnnotationTool-win.exe + Compress-Archive -Path dist\VideoAnnotationTool-win.exe -DestinationPath dist\VideoAnnotationTool-win.zip -Force - name: Upload artifact (manual runs only) if: github.event_name == 'workflow_dispatch' uses: actions/upload-artifact@v4 with: - name: SoccerNetProAnalyzer-Windows - path: annotation_tool/dist/SoccerNetProAnalyzer-win.zip + name: VideoAnnotationTool-Windows + path: annotation_tool/dist/VideoAnnotationTool-win.zip retention-days: 3 build-macos: @@ -122,7 +122,7 @@ jobs: shell: bash run: > python -m PyInstaller --noconfirm --clean --windowed - --name "SoccerNetProAnalyzer" + --name "VideoAnnotationTool" --add-data "style:style" --add-data "ui:ui" --add-data "controllers:controllers" @@ -137,14 +137,14 @@ jobs: if: github.event_name == 'workflow_dispatch' shell: bash run: | - ditto -c -k --sequesterRsrc --keepParent "dist/SoccerNetProAnalyzer.app" "dist/SoccerNetProAnalyzer-mac.zip" + ditto -c -k --sequesterRsrc --keepParent "dist/VideoAnnotationTool.app" "dist/VideoAnnotationTool-mac.zip" - name: Upload artifact (manual runs only) if: github.event_name == 'workflow_dispatch' uses: actions/upload-artifact@v4 with: - name: SoccerNetProAnalyzer-macOS - path: annotation_tool/dist/SoccerNetProAnalyzer-mac.zip + name: VideoAnnotationTool-macOS + path: annotation_tool/dist/VideoAnnotationTool-mac.zip retention-days: 3 build-linux: @@ -191,7 +191,7 @@ jobs: shell: bash run: > python -m PyInstaller --noconfirm --clean --windowed --onefile - --name "SoccerNetProAnalyzer" + --name "VideoAnnotationTool" --add-data "style:style" --add-data "ui:ui" --add-data "controllers:controllers" @@ -206,15 +206,15 @@ jobs: if: github.event_name == 'workflow_dispatch' shell: bash run: | - mv -f dist/SoccerNetProAnalyzer dist/SoccerNetProAnalyzer-linux + mv -f dist/VideoAnnotationTool dist/VideoAnnotationTool-linux cd dist - zip -r SoccerNetProAnalyzer-linux.zip SoccerNetProAnalyzer-linux + zip -r VideoAnnotationTool-linux.zip VideoAnnotationTool-linux cd .. - name: Upload artifact (manual runs only) if: github.event_name == 'workflow_dispatch' uses: actions/upload-artifact@v4 with: - name: SoccerNetProAnalyzer-Linux - path: annotation_tool/dist/SoccerNetProAnalyzer-linux.zip + name: VideoAnnotationTool-Linux + path: annotation_tool/dist/VideoAnnotationTool-linux.zip retention-days: 3 From 2214936bbe61e0b2a4b13c451f77e67b4735f482 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Mon, 9 Mar 2026 12:57:59 +0100 Subject: [PATCH 37/63] Update release.yml Update to VideoAnnotationTool --- .github/workflows/release.yml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2cb01f89..c59a8293 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -71,7 +71,7 @@ jobs: shell: pwsh run: | python -m PyInstaller --noconfirm --clean --windowed --onefile ` - --name "SoccerNetProAnalyzer" ` + --name "VideoAnnotationTool" ` --add-data "style;style" ` --add-data "ui;ui" ` --add-data "controllers;controllers" ` @@ -85,17 +85,17 @@ jobs: - name: Rename binary shell: pwsh run: | - Move-Item -Force dist\SoccerNetProAnalyzer.exe dist\SoccerNetProAnalyzer-win.exe + Move-Item -Force dist\VideoAnnotationTool.exe dist\VideoAnnotationTool-win.exe - name: Zip Windows binary shell: pwsh run: | - Compress-Archive -Path dist\SoccerNetProAnalyzer-win.exe -DestinationPath dist\SoccerNetProAnalyzer-win.zip -Force + Compress-Archive -Path dist\VideoAnnotationTool-win.exe -DestinationPath dist\VideoAnnotationTool-win.zip -Force - name: Upload Release Asset (Windows) uses: softprops/action-gh-release@v2 with: - files: annotation_tool/dist/SoccerNetProAnalyzer-win.zip + files: annotation_tool/dist/VideoAnnotationTool-win.zip tag_name: ${{ github.ref_name }} build-macos: @@ -126,7 +126,7 @@ jobs: shell: bash run: > python -m PyInstaller --noconfirm --clean --windowed - --name "SoccerNetProAnalyzer" + --name "VideoAnnotationTool" --add-data "style:style" --add-data "ui:ui" --add-data "controllers:controllers" @@ -140,12 +140,12 @@ jobs: - name: Zip macOS app shell: bash run: | - ditto -c -k --sequesterRsrc --keepParent "dist/SoccerNetProAnalyzer.app" "dist/SoccerNetProAnalyzer-mac.zip" + ditto -c -k --sequesterRsrc --keepParent "dist/VideoAnnotationTool.app" "dist/VideoAnnotationTool-mac.zip" - name: Upload Release Asset (macOS) uses: softprops/action-gh-release@v2 with: - files: annotation_tool/dist/SoccerNetProAnalyzer-mac.zip + files: annotation_tool/dist/VideoAnnotationTool-mac.zip tag_name: ${{ github.ref_name }} build-linux: @@ -183,7 +183,7 @@ jobs: shell: bash run: > python -m PyInstaller --noconfirm --clean --windowed --onefile - --name "SoccerNetProAnalyzer" + --name "VideoAnnotationTool" --add-data "style:style" --add-data "ui:ui" --add-data "controllers:controllers" @@ -197,17 +197,17 @@ jobs: - name: Rename binary shell: bash run: | - mv -f dist/SoccerNetProAnalyzer dist/SoccerNetProAnalyzer-linux + mv -f dist/VideoAnnotationTool dist/VideoAnnotationTool-linux - name: Zip Linux binary shell: bash run: | cd dist - zip -r SoccerNetProAnalyzer-linux.zip SoccerNetProAnalyzer-linux + zip -r VideoAnnotationTool-linux.zip VideoAnnotationTool-linux cd .. - name: Upload Release Asset (Linux) uses: softprops/action-gh-release@v2 with: - files: annotation_tool/dist/SoccerNetProAnalyzer-linux.zip + files: annotation_tool/dist/VideoAnnotationTool-linux.zip tag_name: ${{ github.ref_name }} From fed88f9af1ad81e64fae718e893425f088c5b8fb Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Mon, 9 Mar 2026 13:15:11 +0100 Subject: [PATCH 38/63] Update release.yml config.yaml --collect-all "soccernetpro" --collect-all "wandb" --collect-all "torch_geometric" --- .github/workflows/release.yml | 74 +++++++++++++++++++++++++++-------- 1 file changed, 58 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c59a8293..80942cda 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,8 +9,13 @@ on: permissions: contents: write +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: generate-release-notes: + name: Generate release notes runs-on: ubuntu-latest outputs: changelog: ${{ steps.notes.outputs.changelog }} @@ -31,10 +36,11 @@ jobs: } >> "$GITHUB_OUTPUT" create-release: + name: Create GitHub Release needs: generate-release-notes runs-on: ubuntu-latest steps: - - name: Create/Update GitHub Release (body only) + - name: Create/Update GitHub Release uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.ref_name }} @@ -42,6 +48,7 @@ jobs: body: ${{ needs.generate-release-notes.outputs.changelog }} build-windows: + name: Build on Windows needs: create-release runs-on: windows-latest defaults: @@ -54,7 +61,19 @@ jobs: with: python-version: "3.11" + - name: Cache pip + uses: actions/cache@v4 + with: + path: | + ~\AppData\Local\pip\Cache + ~\AppData\Local\pip\cache + ~\AppData\Roaming\pip\Cache + key: ${{ runner.os }}-pip-${{ hashFiles('annotation_tool/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + - name: Install requirements + shell: pwsh run: | python -m pip install --upgrade pip pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu @@ -69,18 +88,18 @@ jobs: - name: Build exe shell: pwsh - run: | - python -m PyInstaller --noconfirm --clean --windowed --onefile ` - --name "VideoAnnotationTool" ` - --add-data "style;style" ` - --add-data "ui;ui" ` - --add-data "controllers;controllers" ` - --add-data "image;image" ` - --add-data "config.yaml;." ` - --collect-all "soccernetpro" ` - --collect-all "wandb" ` - --collect-all "torch_geometric" ` - "main.py" + run: > + python -m PyInstaller --noconfirm --clean --windowed --onefile + --name "VideoAnnotationTool" + --add-data "style;style" + --add-data "ui;ui" + --add-data "controllers;controllers" + --add-data "image;image" + --add-data "config.yaml;." + --collect-all "soccernetpro" + --collect-all "wandb" + --collect-all "torch_geometric" + "main.py" - name: Rename binary shell: pwsh @@ -95,10 +114,11 @@ jobs: - name: Upload Release Asset (Windows) uses: softprops/action-gh-release@v2 with: - files: annotation_tool/dist/VideoAnnotationTool-win.zip tag_name: ${{ github.ref_name }} + files: annotation_tool/dist/VideoAnnotationTool-win.zip build-macos: + name: Build on macOS needs: create-release runs-on: macos-latest defaults: @@ -111,7 +131,18 @@ jobs: with: python-version: "3.11" + - name: Cache pip + uses: actions/cache@v4 + with: + path: | + ~/Library/Caches/pip + ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('annotation_tool/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + - name: Install requirements + shell: bash run: | python -m pip install --upgrade pip pip install -r requirements.txt @@ -145,10 +176,11 @@ jobs: - name: Upload Release Asset (macOS) uses: softprops/action-gh-release@v2 with: - files: annotation_tool/dist/VideoAnnotationTool-mac.zip tag_name: ${{ github.ref_name }} + files: annotation_tool/dist/VideoAnnotationTool-mac.zip build-linux: + name: Build on Linux needs: create-release runs-on: ubuntu-latest defaults: @@ -167,7 +199,17 @@ jobs: sudo apt-get update sudo apt-get install -y libgl1 libglib2.0-0 libxcb-cursor0 + - name: Cache pip + uses: actions/cache@v4 + with: + path: | + ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('annotation_tool/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + - name: Install requirements + shell: bash run: | python -m pip install --upgrade pip pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu @@ -209,5 +251,5 @@ jobs: - name: Upload Release Asset (Linux) uses: softprops/action-gh-release@v2 with: - files: annotation_tool/dist/VideoAnnotationTool-linux.zip tag_name: ${{ github.ref_name }} + files: annotation_tool/dist/VideoAnnotationTool-linux.zip From eefb3fb3c20feb74b4e7a93c40d29ac332a980ef Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Mon, 9 Mar 2026 13:22:00 +0100 Subject: [PATCH 39/63] Update requirements.txt pyinstaller torch-geometric==2.7.0 soccernetpro==0.0.1.dev11 wandb --- annotation_tool/requirements.txt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/annotation_tool/requirements.txt b/annotation_tool/requirements.txt index 69352a30..dd51e6eb 100644 --- a/annotation_tool/requirements.txt +++ b/annotation_tool/requirements.txt @@ -1,2 +1,5 @@ PyQt6 -pyinstaller \ No newline at end of file +pyinstaller +torch-geometric==2.7.0 +soccernetpro==0.0.1.dev11 +wandb From b3c52d2ca0da0e7329c355bd32fef7e0240d0932 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Tue, 10 Mar 2026 09:54:38 +0100 Subject: [PATCH 40/63] Update mkdocs.yml Rename to VideoAnnotationTool --- mkdocs.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mkdocs.yml b/mkdocs.yml index afc2e326..890be20e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,8 +1,8 @@ site_name: SoccerNetPro Analyzer -site_description: A PyQt6 GUI tool for analyzing and annotating SoccerNetPro datasets (OpenSportsLab) +site_description: A PyQt6 GUI tool for analyzing and annotating OSL datasets (OpenSportsLab) site_author: OpenSportsLab -repo_url: https://github.com/OpenSportsLab/soccernetpro-ui -repo_name: OpenSportsLab/soccernetpro-ui +repo_url: https://github.com/OpenSportsLab/VideoAnnotationTool +repo_name: OpenSportsLab/VideoAnnotationTool theme: name: material From b9922d3da97df840c10dd6b6ccca5525f249f12a Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Tue, 10 Mar 2026 09:57:32 +0100 Subject: [PATCH 41/63] Update about.md Update to VideoAnnotationTool --- docs/about.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/about.md b/docs/about.md index 502be001..ecf72374 100644 --- a/docs/about.md +++ b/docs/about.md @@ -1,10 +1,10 @@ # About -The Soccernet Pro Tool is developed by OpenSportsLab to help researchers and practitioners efficiently annotate sports video datasets. +The Video Annotation Tool is developed by OpenSportsLab to help researchers and practitioners efficiently annotate sports video datasets. - **Project Lead:** Silvio Giancola - **Front End Developer:** Jintao Ma -- **GitHub:** [OpenSportsLab/soccernetpro-ui](https://github.com/OpenSportsLab/soccernetpro-ui) +- **GitHub:** [OpenSportsLab/soccernetpro-ui](https://github.com/OpenSportsLab/VideoAnnotationTool) - **License:** Dual-licensed (GPL-3.0 / Commercial) We welcome feedback and contributions from the community. From 622331bd615ea0fb4d23ab8a2261b2fcaa64c565 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Tue, 10 Mar 2026 09:58:23 +0100 Subject: [PATCH 42/63] Update index.md Update to VideoAnnotationTool --- docs/index.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/index.md b/docs/index.md index 83a52afd..f35e2c1f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,6 @@ -# Soccernet Pro Tool +# Video AnnotationTool -Welcome to the Soccernet Pro Annotation Tool documentation! +Welcome to the Video Annotation Tool Annotation Tool documentation! This tool helps you annotate action spotting datasets in sports video. Use the navigation to find installation instructions, user guides, and more. @@ -11,7 +11,7 @@ This tool helps you annotate action spotting datasets in sports video. Use the n - Intuitive graphical interface for annotating actions in sports videos - Fast video navigation and frame-accurate annotation - Easily edit timestamps and action labels -- Supports OSL JSON annotation format for seamless integration with [OSL-ActionSpotting](https://github.com/OpenSportsLab/OSL-ActionSpotting) +- Supports OSL JSON annotation format for seamless integration with [OSL-ActionSpotting](https://github.com/VideoAnnotationTool/OSL-ActionSpotting) - Save and load annotation files - Keyboard shortcuts for power users @@ -40,7 +40,7 @@ This project offers two licensing options to suit different needs: - **GPL-3.0 License**: This open-source license is intended for students, researchers, and the community. It supports open collaboration and sharing under the terms of the GNU General Public License v3.0. - See the [`LICENSE.txt`](https://github.com/OpenSportsLab/soccernetpro-ui/blob/main/LICENSE.txt) file for full details. + See the [`LICENSE.txt`](https://github.com/OpenSportsLab/VideoAnnotationTool/blob/main/LICENSE.txt) file for full details. - **Commercial License**: Designed for commercial use, this option allows integration of the software into proprietary products and services without the open-source obligations of GPL-3.0. From d3823f3e7e99c3e3b7bc1106b8e827153ede3370 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:47:03 +0100 Subject: [PATCH 43/63] Update deploy_docs.yml Add smart annotation --- .github/workflows/deploy_docs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index 1a0a1195..8046adb2 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -5,6 +5,7 @@ on: branches: - main - dev-jintao + - smart-annotation workflow_dispatch: From e1840e81abd6d3a5cc725f143652c0479206d3ce Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:47:23 +0100 Subject: [PATCH 44/63] Update deploy_docs.yml Add smart annotation --- .github/workflows/deploy_docs.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index 8046adb2..c37aabab 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -4,7 +4,6 @@ on: push: branches: - main - - dev-jintao - smart-annotation workflow_dispatch: From 3b8eb8364685578f4cf0dd9436dca8d3b2b51b4f Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:51:09 +0100 Subject: [PATCH 45/63] Update README.md Update to VideoAnnotationTool --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 989fb3b4..2657c340 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Video Annotation Tool (UI) -[![Documentation Status](https://img.shields.io/badge/docs-online-brightgreen)](https://opensportslab.github.io/soccernetpro-ui/) +[![Documentation Status](https://img.shields.io/badge/docs-online-brightgreen)](https://opensportslab.github.io/VideoAnnotationTool/) A **PyQt6-based GUI** for analyzing and annotating **OSL format** datasets (OpenSportsLab). From 4eeecd99397c7f3826f06a45e0e2d9c2b2bca47b Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:24:26 +0100 Subject: [PATCH 46/63] Update mkdocs.yml Update to Video Annotation Tool --- mkdocs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mkdocs.yml b/mkdocs.yml index 890be20e..08775ac5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,4 +1,4 @@ -site_name: SoccerNetPro Analyzer +site_name: Video Annotation Tool site_description: A PyQt6 GUI tool for analyzing and annotating OSL datasets (OpenSportsLab) site_author: OpenSportsLab repo_url: https://github.com/OpenSportsLab/VideoAnnotationTool From ac5c1365986a16e7c59e8451487977de234e4c08 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:30:42 +0100 Subject: [PATCH 47/63] Delete annotation_tool/temp_workspace/checkpoints/mvit_v2_s directory --- .../final/predictions_test_epoch_final.json | 19 ------------------- .../final/predictions_test_epoch_final.json | 19 ------------------- .../final/predictions_test_epoch_final.json | 19 ------------------- .../final/predictions_test_epoch_final.json | 19 ------------------- .../final/predictions_test_epoch_final.json | 19 ------------------- .../final/predictions_test_epoch_final.json | 19 ------------------- .../final/predictions_test_epoch_final.json | 19 ------------------- 7 files changed, 133 deletions(-) delete mode 100644 annotation_tool/temp_workspace/checkpoints/mvit_v2_s/1syooo1y/final/predictions_test_epoch_final.json delete mode 100644 annotation_tool/temp_workspace/checkpoints/mvit_v2_s/2dpeoahi/final/predictions_test_epoch_final.json delete mode 100644 annotation_tool/temp_workspace/checkpoints/mvit_v2_s/33bn17dw/final/predictions_test_epoch_final.json delete mode 100644 annotation_tool/temp_workspace/checkpoints/mvit_v2_s/fcoek7gr/final/predictions_test_epoch_final.json delete mode 100644 annotation_tool/temp_workspace/checkpoints/mvit_v2_s/porl7yyp/final/predictions_test_epoch_final.json delete mode 100644 annotation_tool/temp_workspace/checkpoints/mvit_v2_s/r1tx0fro/final/predictions_test_epoch_final.json delete mode 100644 annotation_tool/temp_workspace/checkpoints/mvit_v2_s/yzzui5g5/final/predictions_test_epoch_final.json diff --git a/annotation_tool/temp_workspace/checkpoints/mvit_v2_s/1syooo1y/final/predictions_test_epoch_final.json b/annotation_tool/temp_workspace/checkpoints/mvit_v2_s/1syooo1y/final/predictions_test_epoch_final.json deleted file mode 100644 index ba2f6456..00000000 --- a/annotation_tool/temp_workspace/checkpoints/mvit_v2_s/1syooo1y/final/predictions_test_epoch_final.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "version": "2.0", - "task": "action_classification", - "date": "2026-02-25", - "metadata": { - "type": "predictions" - }, - "data": [ - { - "id": "test_action_1", - "labels": { - "action": { - "label": "Elbowing", - "confidence": 0.46563655138015747 - } - } - } - ] -} \ No newline at end of file diff --git a/annotation_tool/temp_workspace/checkpoints/mvit_v2_s/2dpeoahi/final/predictions_test_epoch_final.json b/annotation_tool/temp_workspace/checkpoints/mvit_v2_s/2dpeoahi/final/predictions_test_epoch_final.json deleted file mode 100644 index b42541a6..00000000 --- a/annotation_tool/temp_workspace/checkpoints/mvit_v2_s/2dpeoahi/final/predictions_test_epoch_final.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "version": "2.0", - "task": "action_classification", - "date": "2026-02-25", - "metadata": { - "type": "predictions" - }, - "data": [ - { - "id": "test_action_13", - "labels": { - "action": { - "label": "Standing tackling", - "confidence": 0.5359266996383667 - } - } - } - ] -} \ No newline at end of file diff --git a/annotation_tool/temp_workspace/checkpoints/mvit_v2_s/33bn17dw/final/predictions_test_epoch_final.json b/annotation_tool/temp_workspace/checkpoints/mvit_v2_s/33bn17dw/final/predictions_test_epoch_final.json deleted file mode 100644 index c4db408c..00000000 --- a/annotation_tool/temp_workspace/checkpoints/mvit_v2_s/33bn17dw/final/predictions_test_epoch_final.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "version": "2.0", - "task": "action_classification", - "date": "2026-02-25", - "metadata": { - "type": "predictions" - }, - "data": [ - { - "id": "test_action_0", - "labels": { - "action": { - "label": "Tackling", - "confidence": 0.8907220363616943 - } - } - } - ] -} \ No newline at end of file diff --git a/annotation_tool/temp_workspace/checkpoints/mvit_v2_s/fcoek7gr/final/predictions_test_epoch_final.json b/annotation_tool/temp_workspace/checkpoints/mvit_v2_s/fcoek7gr/final/predictions_test_epoch_final.json deleted file mode 100644 index 103c8dbf..00000000 --- a/annotation_tool/temp_workspace/checkpoints/mvit_v2_s/fcoek7gr/final/predictions_test_epoch_final.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "version": "2.0", - "task": "action_classification", - "date": "2026-02-25", - "metadata": { - "type": "predictions" - }, - "data": [ - { - "id": "test_action_10", - "labels": { - "action": { - "label": "Standing tackling", - "confidence": 0.6716931462287903 - } - } - } - ] -} \ No newline at end of file diff --git a/annotation_tool/temp_workspace/checkpoints/mvit_v2_s/porl7yyp/final/predictions_test_epoch_final.json b/annotation_tool/temp_workspace/checkpoints/mvit_v2_s/porl7yyp/final/predictions_test_epoch_final.json deleted file mode 100644 index ba2f6456..00000000 --- a/annotation_tool/temp_workspace/checkpoints/mvit_v2_s/porl7yyp/final/predictions_test_epoch_final.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "version": "2.0", - "task": "action_classification", - "date": "2026-02-25", - "metadata": { - "type": "predictions" - }, - "data": [ - { - "id": "test_action_1", - "labels": { - "action": { - "label": "Elbowing", - "confidence": 0.46563655138015747 - } - } - } - ] -} \ No newline at end of file diff --git a/annotation_tool/temp_workspace/checkpoints/mvit_v2_s/r1tx0fro/final/predictions_test_epoch_final.json b/annotation_tool/temp_workspace/checkpoints/mvit_v2_s/r1tx0fro/final/predictions_test_epoch_final.json deleted file mode 100644 index 3579a363..00000000 --- a/annotation_tool/temp_workspace/checkpoints/mvit_v2_s/r1tx0fro/final/predictions_test_epoch_final.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "version": "2.0", - "task": "action_classification", - "date": "2026-02-25", - "metadata": { - "type": "predictions" - }, - "data": [ - { - "id": "test_action_59", - "labels": { - "action": { - "label": "Standing tackling", - "confidence": 0.3105074465274811 - } - } - } - ] -} \ No newline at end of file diff --git a/annotation_tool/temp_workspace/checkpoints/mvit_v2_s/yzzui5g5/final/predictions_test_epoch_final.json b/annotation_tool/temp_workspace/checkpoints/mvit_v2_s/yzzui5g5/final/predictions_test_epoch_final.json deleted file mode 100644 index 103c8dbf..00000000 --- a/annotation_tool/temp_workspace/checkpoints/mvit_v2_s/yzzui5g5/final/predictions_test_epoch_final.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "version": "2.0", - "task": "action_classification", - "date": "2026-02-25", - "metadata": { - "type": "predictions" - }, - "data": [ - { - "id": "test_action_10", - "labels": { - "action": { - "label": "Standing tackling", - "confidence": 0.6716931462287903 - } - } - } - ] -} \ No newline at end of file From 8ee604855982beeb698dc79cc5332216938aa78d Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:31:22 +0100 Subject: [PATCH 48/63] Update editor.py Added descriptive "Start:" and "End:" labels before the batch inference selection comboboxes in the UI. --- .../ui/classification/event_editor/editor.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/annotation_tool/ui/classification/event_editor/editor.py b/annotation_tool/ui/classification/event_editor/editor.py index 40e9f0b2..921a8e1d 100644 --- a/annotation_tool/ui/classification/event_editor/editor.py +++ b/annotation_tool/ui/classification/event_editor/editor.py @@ -232,14 +232,24 @@ def __init__(self, parent=None): self.batch_input_widget = QWidget() h_batch = QHBoxLayout(self.batch_input_widget) h_batch.setContentsMargins(0, 5, 0, 5) + # [NEW] Add descriptive labels for the Start and End comboboxes + self.lbl_start = QLabel("Start:") self.spin_start = QComboBox() + + self.lbl_end = QLabel("End:") self.spin_end = QComboBox() + self.btn_run_batch = QPushButton("Run") self.btn_run_batch.setCursor(Qt.CursorShape.PointingHandCursor) self.btn_run_batch.clicked.connect(self._on_run_batch_clicked) + + # [MODIFIED] Add the labels and comboboxes to the horizontal layout in order + h_batch.addWidget(self.lbl_start) h_batch.addWidget(self.spin_start) + h_batch.addWidget(self.lbl_end) h_batch.addWidget(self.spin_end) h_batch.addWidget(self.btn_run_batch) + self.batch_input_widget.setVisible(False) @@ -451,4 +461,4 @@ def clear_selection(self): if hasattr(group, 'set_checked_label'): group.set_checked_label(None) elif hasattr(group, 'set_checked_labels'): - group.set_checked_labels([]) \ No newline at end of file + group.set_checked_labels([]) From 22f9386096dc1f99c24c00db005de74f18f0e2ac Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:31:59 +0100 Subject: [PATCH 49/63] Update viewer.py Inserted filter refresh calls in refresh_ui_after_undo_redo to ensure the left list syncs immediately after undo/redo. --- annotation_tool/viewer.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/annotation_tool/viewer.py b/annotation_tool/viewer.py index 652bac79..44d7fac6 100644 --- a/annotation_tool/viewer.py +++ b/annotation_tool/viewer.py @@ -712,12 +712,22 @@ def refresh_ui_after_undo_redo(self, action_path: str) -> None: Refreshes the UI after an Undo/Redo operation. Updates the tree icon, selection, and the active editor content. """ + # [MODIFIED] Batch operations might pass action_path as None. + # We must still refresh the filter and button states even if path is None. if not action_path: + if not self._is_loc_mode() and not self._is_desc_mode() and not self._is_dense_mode(): + self.nav_manager.apply_action_filter() + self.update_save_export_button_state() return # 1. Update the tree icon status self.update_action_item_status(action_path) + # [NEW] 1.5 Refresh the tree filter to immediately show/hide items! + # This fixes the bug where Undo/Redo doesn't visually update the list. + if not self._is_loc_mode() and not self._is_desc_mode() and not self._is_dense_mode(): + self.nav_manager.apply_action_filter() + # 2. Ensure the item is selected in the active tree active_tree = None if self._is_loc_mode(): @@ -741,9 +751,8 @@ def refresh_ui_after_undo_redo(self, action_path: str) -> None: elif self._is_desc_mode(): self.desc_nav_manager.on_item_selected(item.index(), None) elif self._is_dense_mode(): - # [NEW] Refresh Dense events display self.dense_manager._display_events_for_item(action_path) else: self.annot_manager.display_manual_annotation(action_path) - self.update_save_export_button_state() \ No newline at end of file + self.update_save_export_button_state() From daf11f1f0fb2706d5a460cfd0439c03b3b68d723 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:32:42 +0100 Subject: [PATCH 50/63] Update history_manager.py Fixed key mismatch in batch undo logic by changing old_batch to old_data to match the history stack. --- annotation_tool/controllers/history_manager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/annotation_tool/controllers/history_manager.py b/annotation_tool/controllers/history_manager.py index 7ec7992c..ab684d16 100644 --- a/annotation_tool/controllers/history_manager.py +++ b/annotation_tool/controllers/history_manager.py @@ -128,7 +128,7 @@ def _apply_state_change(self, cmd, is_undo): # [NEW] Handle batch smart annotation run elif ctype == CmdType.BATCH_SMART_ANNOTATION_RUN: - batch_data = cmd['old_batch'] if is_undo else cmd['new_batch'] + batch_data = cmd['old_data'] if is_undo else cmd['new_data'] for path, data in batch_data.items(): if data: @@ -430,4 +430,4 @@ def _apply_state_change(self, cmd, is_undo): if evt.get('head') == head and evt.get('label') == src: evt['label'] = dst - self._refresh_active_view() \ No newline at end of file + self._refresh_active_view() From c023f71dd1848978d64717f52894be0bb8c18501 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:33:47 +0100 Subject: [PATCH 51/63] Update class_navigation_manager.py Modified apply_action_filter to make Hand Labelled and Smart Labelled filters inclusive rather than mutually exclusive. --- .../classification/class_navigation_manager.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/annotation_tool/controllers/classification/class_navigation_manager.py b/annotation_tool/controllers/classification/class_navigation_manager.py index 00b44952..6189cc84 100644 --- a/annotation_tool/controllers/classification/class_navigation_manager.py +++ b/annotation_tool/controllers/classification/class_navigation_manager.py @@ -176,11 +176,11 @@ def show_all_views(self): def apply_action_filter(self, index=None): """ - [MODIFIED] Filter the tree based on 4 custom states for Classification. + Filter the tree based on 4 custom states for Classification. 0: Show All - 1: Hand Labelled (Confirmed, purely manual) - 2: Smart Labelled (Confirmed via smart annotation) - 3: No Labelled (Not confirmed at all) + 1: Hand Labelled (Has manual annotation) + 2: Smart Labelled (Has confirmed smart annotation) + 3: No Labelled (Neither hand nor smart confirmed) """ tree = self.ui.classification_ui.left_panel.tree combo = self.ui.classification_ui.left_panel.filter_combo @@ -203,8 +203,9 @@ def apply_action_filter(self, index=None): # 2. Is it Smart Labelled? (Has _confirmed flag in smart_annotations) smart_data = self.model.smart_annotations.get(path, {}) - # If manual annotation exists, we prioritize classifying it as Hand Labelled to avoid overlap - is_smart_labelled = smart_data.get("_confirmed", False) and not is_hand_labelled + # [MODIFIED] Removed the mutually exclusive condition "and not is_hand_labelled". + # Now an item can be treated as both Hand Labelled and Smart Labelled simultaneously. + is_smart_labelled = smart_data.get("_confirmed", False) # 3. No Labelled (Neither hand nor smart confirmed) is_no_labelled = not is_hand_labelled and not is_smart_labelled @@ -212,10 +213,13 @@ def apply_action_filter(self, index=None): # 4. Apply hiding logic based on the selected filter index hidden = False if filter_idx == 1 and not is_hand_labelled: + # Hide if "Hand Labelled" is selected but the item lacks hand labels hidden = True elif filter_idx == 2 and not is_smart_labelled: + # Hide if "Smart Labelled" is selected but the item lacks smart labels hidden = True elif filter_idx == 3 and not is_no_labelled: + # Hide if "No Labelled" is selected but the item has ANY label hidden = True tree.setRowHidden(row, QModelIndex(), hidden) @@ -263,4 +267,4 @@ def _nav_tree(self, step, level): new_row = curr.row() + step if 0 <= new_row < model.rowCount(parent): nxt = model.index(new_row, 0, parent) - tree.setCurrentIndex(nxt); tree.scrollTo(nxt) \ No newline at end of file + tree.setCurrentIndex(nxt); tree.scrollTo(nxt) From 843d7b9ea1adaa1f4f07a0d86d191f0154226449 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:34:36 +0100 Subject: [PATCH 52/63] Update class_file_manager.py Added logic to reset smart annotation UI components in the right panel within the _clear_workspace method. --- .../controllers/classification/class_file_manager.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/annotation_tool/controllers/classification/class_file_manager.py b/annotation_tool/controllers/classification/class_file_manager.py index 3f7b0f49..c18415dd 100644 --- a/annotation_tool/controllers/classification/class_file_manager.py +++ b/annotation_tool/controllers/classification/class_file_manager.py @@ -299,11 +299,18 @@ def _clear_workspace(self, full_reset=False): self.model.reset(full_reset) self.main.update_save_export_button_state() + + # --- UI Resets --- self.ui.classification_ui.right_panel.manual_box.setEnabled(False) self.ui.classification_ui.center_panel.show_single_view(None) + + # [NEW] Explicitly reset the Smart Annotation UI (hide donut chart & batch results) + if hasattr(self.ui.classification_ui.right_panel, 'reset_smart_inference'): + self.ui.classification_ui.right_panel.reset_smart_inference() + if full_reset: self.main.setup_dynamic_ui() # [NEW] Clear the Smart Annotation dropdowns when workspace is reset if hasattr(self.main, 'sync_batch_inference_dropdowns'): - self.main.sync_batch_inference_dropdowns() \ No newline at end of file + self.main.sync_batch_inference_dropdowns() From 24462160379a5c48f914b7354537111f298cb271 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:35:10 +0100 Subject: [PATCH 53/63] Update class_annotation_manager.py Added real-time filter refreshing and refactored logic for batch confirmation, undo/redo, and robust confidence parsing. --- .../class_annotation_manager.py | 114 +++++++++++++++--- 1 file changed, 97 insertions(+), 17 deletions(-) diff --git a/annotation_tool/controllers/classification/class_annotation_manager.py b/annotation_tool/controllers/classification/class_annotation_manager.py index 867024e6..db921cc0 100644 --- a/annotation_tool/controllers/classification/class_annotation_manager.py +++ b/annotation_tool/controllers/classification/class_annotation_manager.py @@ -11,34 +11,113 @@ def __init__(self, main_window): def confirm_smart_annotation_as_manual(self): """ - [MODIFIED] Mark current smart prediction as confirmed WITHOUT polluting Hand Annotation. + [MODIFIED] Mark current smart prediction(s) as confirmed. + Added Undo/Redo support for Smart Annotations to fix history bugs. """ - path = self.main.get_current_action_path() - if not path: return + import copy + from models.app_state import CmdType # Ensure CmdType is available + right_panel = self.ui.classification_ui.right_panel - smart_data = self.model.smart_annotations.get(path) - if not smart_data: - self.main.show_temp_msg("Notice", "No smart annotation available to confirm.") - return + # Check if we are confirming a batch or a single inference + if right_panel.is_batch_mode_active: + # --- BATCH CONFIRMATION LOGIC --- + batch_preds = right_panel.pending_batch_results + if not batch_preds: + self.main.show_temp_msg("Notice", "No batch predictions to confirm.") + return - # [NEW] Simply flag it as confirmed internally within the smart memory. - # We DO NOT call self.save_manual_annotation(new_data) anymore to prevent UI pollution! - self.model.smart_annotations[path]["_confirmed"] = True - self.model.is_data_dirty = True - - self.main.show_temp_msg("Saved", "Smart Annotation confirmed independently.", 1000) - - self.main.update_action_item_status(path) + old_batch_data = {} + new_batch_data = {} + confirmed_count = 0 + + # Loop through all items in the batch + for path, pred_data in batch_preds.items(): + # Store the old state for Undo + old_batch_data[path] = copy.deepcopy(self.model.smart_annotations.get(path)) + + # --- ROBUST DATA FORMATTING --- + if isinstance(pred_data, str): + head = next(iter(self.model.label_definitions.keys()), "action") + formatted_data = {head: {"label": pred_data, "conf_dict": {pred_data: 1.0}}} + elif isinstance(pred_data, dict) and "label" in pred_data: + head = next(iter(self.model.label_definitions.keys()), "action") + formatted_data = {head: copy.deepcopy(pred_data)} + else: + formatted_data = copy.deepcopy(pred_data) + + # [NEW FIX] Ensure 'conf_dict' exists for the Donut Chart rendering! + for h, h_data in formatted_data.items(): + if isinstance(h_data, dict) and "label" in h_data: + if "conf_dict" not in h_data: + # Safely extract 'confidence', fallback to 1.0 if not found + conf = h_data.get("confidence", 1.0) + h_data["conf_dict"] = {h_data["label"]: conf} + # Also calculate the remaining percentage for the pie chart + rem = 1.0 - conf + if rem > 0.001: + h_data["conf_dict"]["Other Uncertainties"] = rem + + # Mark as confirmed safely + formatted_data["_confirmed"] = True + + # Store the new state for Redo + new_batch_data[path] = copy.deepcopy(formatted_data) + + # Save to model memory + self.model.smart_annotations[path] = formatted_data + self.main.update_action_item_status(path) + confirmed_count += 1 + + # [NEW] Push the batch confirmation to the Undo stack + self.model.push_undo(CmdType.BATCH_SMART_ANNOTATION_RUN, old_data=old_batch_data, new_data=new_batch_data) + + self.model.is_data_dirty = True + self.main.show_temp_msg("Saved", f"Batch Smart Annotations confirmed for {confirmed_count} items.", 2000) + + # Reset the batch UI back to normal after confirmation + right_panel.reset_smart_inference() + + else: + # --- SINGLE CONFIRMATION LOGIC --- + path = self.main.get_current_action_path() + if not path: return + + smart_data = self.model.smart_annotations.get(path) + if not smart_data: + self.main.show_temp_msg("Notice", "No smart annotation available to confirm.") + return + + # Store the old state for Undo + old_data = copy.deepcopy(smart_data) + + # Flag it as confirmed internally within the smart memory + self.model.smart_annotations[path]["_confirmed"] = True + self.model.is_data_dirty = True + + # Store the new state for Redo + new_data = copy.deepcopy(self.model.smart_annotations[path]) + + # [NEW] Push the single confirmation to the Undo stack + self.model.push_undo(CmdType.SMART_ANNOTATION_RUN, path=path, old_data=old_data, new_data=new_data) + + self.main.update_action_item_status(path) + self.main.show_temp_msg("Saved", "Smart Annotation confirmed independently.", 1000) + + # --- COMMON UI UPDATES --- self.main.update_save_export_button_state() + # Apply filter immediately to reflect the new Smart Labelled status + self.main.nav_manager.apply_action_filter() + # Auto-advance to the next video clip tree = self.ui.classification_ui.left_panel.tree curr_idx = tree.currentIndex() if curr_idx.isValid(): nxt_idx = tree.indexBelow(curr_idx) if nxt_idx.isValid(): + from PyQt6.QtCore import QTimer QTimer.singleShot(500, lambda: [tree.setCurrentIndex(nxt_idx), tree.scrollTo(nxt_idx)]) - + def save_manual_annotation(self, override_data=None): """ [MODIFIED] Added 'override_data' parameter. @@ -69,6 +148,7 @@ def save_manual_annotation(self, override_data=None): self.main.update_action_item_status(path) self.main.update_save_export_button_state() + self.main.nav_manager.apply_action_filter() # [MV Fix] Auto-advance using QTreeView API tree = self.ui.classification_ui.left_panel.tree @@ -232,4 +312,4 @@ def remove_custom_type(self, head, lbl): group = self.ui.classification_ui.right_panel.label_groups.get(head) if isinstance(group, DynamicSingleLabelGroup): group.update_radios(defn['labels']) else: group.update_checkboxes(defn['labels']) - self.display_manual_annotation(self.main.get_current_action_path()) \ No newline at end of file + self.display_manual_annotation(self.main.get_current_action_path()) From 65644772c3969dc41a12fc98b38bb869c1a75e70 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:35:47 +0100 Subject: [PATCH 54/63] Update inference_manager.py Corrected undo key names and updated batch results to pass rich dictionaries with confidence instead of plain strings. --- .../classification/inference_manager.py | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/annotation_tool/controllers/classification/inference_manager.py b/annotation_tool/controllers/classification/inference_manager.py index af2e89e7..12eadacb 100644 --- a/annotation_tool/controllers/classification/inference_manager.py +++ b/annotation_tool/controllers/classification/inference_manager.py @@ -494,8 +494,8 @@ def _on_batch_inference_success(self, metrics: dict, results_list: list): text = "BATCH INFERENCE PREDICTIONS:\n\n" batch_predictions = {} - old_batch_data = {} # [NEW] - new_batch_data = {} # [NEW] + old_batch_data = {} + new_batch_data = {} import copy for r in results_list: @@ -503,25 +503,33 @@ def _on_batch_inference_success(self, metrics: dict, results_list: list): for item in r['original_items']: path = item['path'] - batch_predictions[path] = r['pred'] - # [NEW] Record old data + # [NEW FIX 2] Store a rich dictionary instead of just a string! + # This ensures the Confidence is passed to the UI for the Donut Chart. + conf_dict = {r['pred']: r['conf']} + if r['conf'] < 1.0: + conf_dict["Other Uncertainties"] = 1.0 - r['conf'] + + batch_predictions[path] = { + "label": r['pred'], + "confidence": r['conf'], + "conf_dict": conf_dict + } + + # Record old data for Undo if path not in old_batch_data: old_batch_data[path] = self.main.model.smart_annotations.get(path, {}) - conf_dict = {r['pred']: r['conf']} - if r['conf'] < 1.0: conf_dict["Other Uncertainties"] = 1.0 - r['conf'] - - # [NEW] Prepare new data + # Prepare new data for Redo new_batch_data[path] = { target_head: {"label": r['pred'], "conf_dict": conf_dict} } - # [NEW] Push Batch to Undo History + # [NEW FIX 1] Push Batch to Undo History using CORRECT keys 'old_data' and 'new_data' self.main.model.push_undo( CmdType.BATCH_SMART_ANNOTATION_RUN, - old_batch=copy.deepcopy(old_batch_data), - new_batch=copy.deepcopy(new_batch_data) + old_data=copy.deepcopy(old_batch_data), + new_data=copy.deepcopy(new_batch_data) ) # Apply new data to model @@ -558,4 +566,4 @@ def confirm_batch_inference(self, results: dict): self.main.update_save_export_button_state() self.main.show_temp_msg("Batch Annotation", f"Confirmed {applied_count} smart annotations independently.") else: - self.main.show_temp_msg("Batch Annotation", "No smart annotations to confirm.") \ No newline at end of file + self.main.show_temp_msg("Batch Annotation", "No smart annotations to confirm.") From b49a91c1160a8d442bc6463b3d69ba843a7087d7 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Wed, 11 Mar 2026 01:59:04 +0100 Subject: [PATCH 55/63] Update README.md Add the Description of Smart Annotation --- annotation_tool/README.md | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/annotation_tool/README.md b/annotation_tool/README.md index c577db3e..871fa9c3 100644 --- a/annotation_tool/README.md +++ b/annotation_tool/README.md @@ -1,6 +1,8 @@ -# SoccerNet Pro Annotation Tool +# Video Annotation Tool -This project is a professional video annotation desktop application built with **PyQt6**. It features a comprehensive **quad-mode** architecture supporting **Whole-Video Classification**, **Action Spotting (Localization)**, **Video Captioning (Description)**, and the newly integrated **Dense Video Captioning (Dense Description)**. +This project is a professional video annotation desktop application built with **PyQt6**. It features a comprehensive **quad-mode** architecture supporting **Whole-Video Classification**, **Action Spotting (Localization)**, **Video Captioning (Description)**, and the newly integrated **Dense Video Captioning (Dense Description)**. + +With the latest update, the Classification mode now features **AI-Powered Smart Annotation**, allowing users to leverage state-of-the-art `soccernetpro` models (e.g., MViT) to automatically infer actions via single or batch processing. The project follows a modular **MVC (Model-View-Controller)** design pattern to ensure strict separation of concerns. It leverages **Qt's Model/View architecture** for resource management and a unified **Media Controller** to ensure stable, high-performance video playback across all modalities. @@ -13,6 +15,7 @@ annotation_tool/ ├── main.py # Application entry point ├── viewer.py # Main Window controller (Orchestrator) ├── utils.py # Helper functions and constants +├── config.yaml # [NEW] Inference configuration for soccernetpro models ├── __init__.py # Package initialization │ ├── models/ # [Model Layer] Data Structures & State @@ -21,14 +24,18 @@ annotation_tool/ │ ├── controllers/ # [Controller Layer] Business Logic │ ├── router.py # Mode detection & Project lifecycle management -│ ├── history_manager.py # Universal Undo/Redo system +│ ├── history_manager.py # Universal Undo/Redo system (Supports Batch Annotations) │ ├── media_controller.py # Unified playback logic (Anti-freeze/Visual clearing) │ ├── classification/ # Logic for Classification mode +│ │ ├── class_annotation_manager.py # Manual label state management +│ │ ├── class_file_manager.py # JSON I/O for Classification tasks +│ │ ├── class_navigation_manager.py # Action tree navigation +│ │ └── inference_manager.py # [NEW] AI Smart Annotation (Single/Batch Inference) │ ├── localization/ # Logic for Action Spotting (Localization) mode │ ├── description/ # Logic for Global Captioning (Description) mode -│ └── dense_description/ # [NEW] Logic for Dense Captioning (Text-at-Timestamp) +│ └── dense_description/ # Logic for Dense Captioning (Text-at-Timestamp) │ ├── dense_manager.py # Core logic for dense annotations & UI sync -│ └── dense_file_manager.py # JSON I/O specifically for Dense tasks +│ └── dense_file_manager.py # JSON I/O specifically for Dense tasks │ ├── ui/ # [View Layer] Interface Definitions │ ├── common/ # Shared widgets (Main Window, Sidebar, Video Surface) @@ -37,9 +44,13 @@ annotation_tool/ │ │ ├── workspace.py # Unified 3-column skeleton │ │ └── dialogs.py # Project wizards and mode selectors │ ├── classification/ # UI specific to Classification +│ │ └── event_editor/ # Dynamic Schema Editor & [NEW] Smart Annotation UI +│ │ ├── dynamic_widgets.py # Single/Multi label dynamic radio & checkbox groups +│ │ ├── editor.py # Includes NativeDonutChart & Batch Progress UI +│ │ └── controls.py # Playback control bar │ ├── localization/ # UI specific to Localization (Timeline + Tabbed Spotting) │ ├── description/ # UI specific to Global Captioning (Full-video text) -│ └── dense_description/ # [NEW] UI specific to Dense Description +│ └── dense_description/ # UI specific to Dense Description │ └── event_editor/ │ ├── __init__.py # Right panel assembler for Dense mode │ ├── desc_input_widget.py # Text input & timestamp submission @@ -47,9 +58,7 @@ annotation_tool/ │ └── style/ # Visual theme assets └── style.qss # Centralized Dark mode stylesheet - ``` - --- ## 📝 Detailed Module Descriptions From bc1b7e04cdefc296205baefed7b16a8ebed8cacf Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Wed, 11 Mar 2026 02:11:17 +0100 Subject: [PATCH 56/63] Update viewer.py Fix Save/Export button state to properly recognize confirmed smart annotations in Classification mode. --- annotation_tool/viewer.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/annotation_tool/viewer.py b/annotation_tool/viewer.py index 44d7fac6..abf948cf 100644 --- a/annotation_tool/viewer.py +++ b/annotation_tool/viewer.py @@ -493,7 +493,12 @@ def closeEvent(self, event) -> None: # [NEW] Check dense data has_data = bool(self.model.dense_description_events) else: - has_data = bool(self.model.manual_annotations) + has_manual = bool(self.model.manual_annotations) + has_smart_confirmed = any( + data.get("_confirmed", False) + for data in self.model.smart_annotations.values() + ) + has_data = has_manual or has_smart_confirmed can_export = self.model.json_loaded and has_data @@ -552,7 +557,13 @@ def update_save_export_button_state(self) -> None: # [NEW] has_data = bool(self.model.dense_description_events) else: - has_data = bool(self.model.manual_annotations) + # [FIXED] Check the hand and smart annotation + has_manual = bool(self.model.manual_annotations) + has_smart_confirmed = any( + data.get("_confirmed", False) + for data in self.model.smart_annotations.values() + ) + has_data = has_manual or has_smart_confirmed can_export = self.model.json_loaded and has_data can_save = can_export and (self.model.current_json_path is not None) and self.model.is_data_dirty From 4cfb6476dd3c66c4d21913868ba7f8475a63327c Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Sun, 15 Mar 2026 10:02:32 +0100 Subject: [PATCH 57/63] Update index.md --- docs/index.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/index.md b/docs/index.md index f35e2c1f..d73741e3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -31,6 +31,7 @@ This tool helps you annotate action spotting datasets in sports video. Use the n - [Installation](installation.md) - [User Guide](gui_overview.md) - [FAQ](faq.md) +- [OSL JSON format](OSL.md) --- From 5d557f4ba1cb2351ea816b48d350e1509b7ca196 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Sun, 15 Mar 2026 10:05:47 +0100 Subject: [PATCH 58/63] Upload OSL JSON format file document --- docs/OSL.md | 215 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 docs/OSL.md diff --git a/docs/OSL.md b/docs/OSL.md new file mode 100644 index 00000000..6cb951d5 --- /dev/null +++ b/docs/OSL.md @@ -0,0 +1,215 @@ +# OSL JSON Format + +The OSL JSON format is a unified, extensible data structure designed to handle multi-task video understanding datasets (e.g., action classification, action spotting, and various forms of video captioning) within a single file. + +By unifying dataset annotations, the OSL format makes it easy to load complex, multi-modal, and multi-task datasets without writing custom parsers for every new task. + +Below is a detailed breakdown of the format, followed by a comprehensive example. + +--- + +## 1. Top-Level Structure + +The root of the OSL JSON document contains metadata about the dataset, the shared taxonomy for labels, and the actual data items. + +| Field | Type | Description | Required | +| :--- | :--- | :--- | :---: | +| `version` | String | The version of the OSL format used (e.g., `"1.0"`). | Yes | +| `date` | String | The ISO-8601 formatted date when this split/file was produced (e.g., `"2025-10-20"`). | Yes | +| `dataset_name` | String | The name of the dataset and the specific split (e.g., `"OSL-Football-UNIFIED (train)"`). | Yes | +| `metadata` | Object | Global, file-level metadata (e.g., `source`, `license`, `created_by`, `notes`). | No | +| `tasks` | Array[String] | An advisory list of task families included in this file (e.g., `["action_classification", "action_spotting", ...]`). | No | +| `labels` | Object | The shared global taxonomy defining the available classes and their properties. | Yes* | +| `data` | Array[Object] | The list of data items (video clips) and their associated annotations. | Yes | + +*\* Required if the dataset involves classification or spotting tasks.* + +--- + +## 2. Shared Taxonomy (`labels`) + +The top-level `labels` object defines the taxonomy used across all data items for tasks like action classification and action spotting. It supports multi-head outputs (e.g., predicting an "action" and "attributes" simultaneously). + +Each key in the `labels` object represents a specific "head" and defines: +* `type`: Either `"single_label"` (exactly one class per item/event) or `"multi_label"` (zero or more classes). +* `labels`: An array of strings representing the valid class names. + +**Example:** +```json +"labels": { + "action": { + "type": "single_label", + "labels": ["Pass", "Shot", "Header", "Foul"] + }, + "attributes": { + "type": "multi_label", + "labels": ["Aerial", "SetPiece"] + } +} + +``` + +--- + +## 3. Data Items (`data`) + +The `data` array contains individual objects, each representing a specific data instance (typically a video clip) and all its multi-task annotations. + +### Item Properties + +| Field | Type | Description | Required | +| --- | --- | --- | --- | +| `id` | String | A unique identifier for this data item. All task targets below apply to this ID. | Yes | +| `metadata` | Object | Item-level metadata (e.g., `competition`, `stage`, `home_team`). | No | +| `inputs` | Array[Object] | A list of typed inputs associated with this item (e.g., raw video, extracted features, poses). | Yes | + +### 3.1 Inputs + +The `inputs` array defines the multi-modal data sources for the item. Different input types require different fields. Time references in annotations (like spotting or dense captioning) are relative to the start of the primary video file specified here. + +**Common Input Types:** + +* **Video:** `{ "type": "video", "path": "path/to/vid.mp4", "fps": 25 }` +* **Features:** `{ "type": "features", "name": "I3D", "path": "...", "dim": 1024, "hop_ms": 160 }` +* **Poses:** `{ "type": "poses", "format": "COCO", "path": "..." }` + +*(Note: If referencing an untrimmed video, you can specify `start_ms` and `end_ms` within the video input object to define a specific segment.)* + +### 3.2 Task Annotations + +An item can contain annotations for multiple tasks simultaneously. Only the fields relevant to the tasks present in the dataset need to be included. + +#### Action Classification (`labels`) + +Assigns classes to the entire video clip based on the shared taxonomy defined at the top level. + +* For `"single_label"` heads, use the `"label"` key (String). +* For `"multi_label"` heads, use the `"labels"` key (Array of Strings). + +```json +"labels": { + "action": { "label": "Header" }, + "attributes": { "labels": ["Aerial"] } +} + +``` + +#### Action Spotting (`events`) + +Defines instantaneous events occurring at specific timestamps within the clip. + +* `head`: The taxonomy head to use (from top-level `labels`). +* `label`: The class name. +* `position_ms`: The timestamp of the event in milliseconds (relative to the start of the clip). + +```json +"events": [ + { "head": "action", "label": "Header", "position_ms": 2100 } +] + +``` + +#### Video Captioning (`captions`) + +Provides text descriptions for the entire video clip. Multiple languages are supported. + +* `lang`: Language code (e.g., `"en"`, `"fr"`). +* `text`: The caption string. + +```json +"captions": [ + { "lang": "en", "text": "A precise cross finds the striker..." } +] + +``` + +#### Dense Video Captioning (`dense_captions`) + +Provides text descriptions for specific temporal segments within the video clip. + +* `start_ms`: Start time of the segment in milliseconds. +* `end_ms`: End time of the segment in milliseconds. +* `lang`: Language code. +* `text`: The caption string for that segment. + +```json +"dense_captions": [ + { "start_ms": 1200, "end_ms": 2500, "lang": "en", "text": "The winger accelerates..." } +] + +``` + +--- + +## 4. Full Example + +Below is a complete example of an OSL JSON file demonstrating a single data item with multiple inputs and multi-task annotations. + +```json +{ + "version": "1.0", + "date": "2025-10-20", + "dataset_name": "OSL-Football-UNIFIED (train)", + + "metadata": { + "source": "World Cup Finals", + "license": "CC-BY-NC-4.0", + "created_by": "OSL", + "notes": "Single item demonstrates multi-task targets on the same ID." + }, + + "tasks": ["action_classification", "action_spotting", "video_captioning", "dense_video_captioning"], + + "labels": { + "action": { + "type": "single_label", + "labels": ["Pass", "Shot", "Header", "Foul"] + }, + "attributes": { + "type": "multi_label", + "labels": ["Aerial", "SetPiece"] + } + }, + + "data": [ + { + "id": "M64_multi_000", + + "metadata": { + "competition": "FIFA WC", + "stage": "Final", + "home_team": "Germany", + "away_team": "Argentina" + }, + + "inputs": [ + { "type": "video", "path": "FWC2014/224p/M64_multi_000.mp4", "fps": 25 }, + { "type": "features", "name": "I3D", "path": "features/I3D/M64_multi_000.npy", "dim": 1024, "hop_ms": 160 }, + { "type": "poses", "format": "COCO", "path": "poses/M64_multi_000.json" }, + { "type": "gamestate", "path": "gamestate/M64_multi_000.json" } + ], + + "labels": { + "action": { "label": "Header" }, + "attributes": { "labels": ["Aerial"] } + }, + + "events": [ + { "head": "action", "label": "Header", "position_ms": 2100 }, + { "head": "action", "label": "Pass", "position_ms": 3850 } + ], + + "captions": [ + { "lang": "en", "text": "A precise cross finds the striker, who directs a powerful header on target." }, + { "lang": "fr", "text": "Un centre précis trouve l’attaquant, qui place une tête puissante cadrée." } + ], + + "dense_captions": [ + { "start_ms": 1200, "end_ms": 2500, "lang": "en", "text": "The winger accelerates down the flank and delivers a looping cross." }, + { "start_ms": 2600, "end_ms": 4200, "lang": "en", "text": "The striker rises above the defense and heads the ball toward goal." } + ] + } + ] +} + +``` From fe5ad9706989d16b34200da6455f073fbd2c51ae Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Sun, 15 Mar 2026 10:11:21 +0100 Subject: [PATCH 59/63] Update README.md Add the OSL link --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 2657c340..1f9eb4f9 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,7 @@ [![Documentation Status](https://img.shields.io/badge/docs-online-brightgreen)](https://opensportslab.github.io/VideoAnnotationTool/) -A **PyQt6-based GUI** for analyzing and annotating **OSL format** datasets (OpenSportsLab). - +A **PyQt6-based GUI** for analyzing and annotating **[OSL format](https://opensportslab.github.io/VideoAnnotationTool/OSL/)** datasets (OpenSportsLab). --- ## Features From 4ace55bbaaaaab7abd8ff294f9bdb3e384450a29 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Sun, 15 Mar 2026 10:11:51 +0100 Subject: [PATCH 60/63] Update README.md Add the OSL link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1f9eb4f9..c8ec687b 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Documentation Status](https://img.shields.io/badge/docs-online-brightgreen)](https://opensportslab.github.io/VideoAnnotationTool/) A **PyQt6-based GUI** for analyzing and annotating **[OSL format](https://opensportslab.github.io/VideoAnnotationTool/OSL/)** datasets (OpenSportsLab). ---- + ## Features From af103a3a4f4cdabcb7e70ddc3d29d18c58e75a09 Mon Sep 17 00:00:00 2001 From: Jintao Ma Date: Mon, 30 Mar 2026 22:07:56 +0200 Subject: [PATCH 61/63] Update Classification Train Progress and Localization --- .../classification/class_file_manager.py | 4 +- .../classification/train_manager.py | 313 ++++++++++++++++++ .../localization/loc_file_manager.py | 24 ++ .../controllers/localization/loc_inference.py | 160 +++++++++ .../localization/localization_manager.py | 180 +++++++++- .../controllers/media_controller.py | 97 ++++-- annotation_tool/models/app_state.py | 5 + .../ui/classification/event_editor/editor.py | 157 ++++++++- annotation_tool/ui/common/dialogs.py | 33 +- .../ui/localization/event_editor/__init__.py | 50 ++- .../event_editor/annotation_table.py | 35 +- .../event_editor/smart_spotting.py | 217 ++++++++++++ .../event_editor/spotting_controls.py | 128 ++++--- annotation_tool/viewer.py | 5 +- 14 files changed, 1321 insertions(+), 87 deletions(-) create mode 100644 annotation_tool/controllers/classification/train_manager.py create mode 100644 annotation_tool/controllers/localization/loc_inference.py create mode 100644 annotation_tool/ui/localization/event_editor/smart_spotting.py diff --git a/annotation_tool/controllers/classification/class_file_manager.py b/annotation_tool/controllers/classification/class_file_manager.py index c18415dd..76841407 100644 --- a/annotation_tool/controllers/classification/class_file_manager.py +++ b/annotation_tool/controllers/classification/class_file_manager.py @@ -308,9 +308,11 @@ def _clear_workspace(self, full_reset=False): if hasattr(self.ui.classification_ui.right_panel, 'reset_smart_inference'): self.ui.classification_ui.right_panel.reset_smart_inference() + if hasattr(self.ui.classification_ui.right_panel, 'reset_train_ui'): + self.ui.classification_ui.right_panel.reset_train_ui() if full_reset: self.main.setup_dynamic_ui() # [NEW] Clear the Smart Annotation dropdowns when workspace is reset if hasattr(self.main, 'sync_batch_inference_dropdowns'): - self.main.sync_batch_inference_dropdowns() + self.main.sync_batch_inference_dropdowns() \ No newline at end of file diff --git a/annotation_tool/controllers/classification/train_manager.py b/annotation_tool/controllers/classification/train_manager.py new file mode 100644 index 00000000..b9e8e7a0 --- /dev/null +++ b/annotation_tool/controllers/classification/train_manager.py @@ -0,0 +1,313 @@ +import os +import sys +import json +import uuid +import yaml +import copy +import re +import io +import contextlib +from PyQt6.QtCore import QThread, pyqtSignal, QObject +from PyQt6.QtWidgets import QMessageBox + +# Assume the model invocation style is the same as inference +from soccernetpro import model + +class TrainWorker(QThread): + """ + Background training thread. + Supports printing checkpoint-save related information to the terminal + every fixed number of steps. + """ + # Signal for appending plain log text to the UI console + log_signal = pyqtSignal(str) + # Signal for updating the progress bar percentage + progress_signal = pyqtSignal(int) + # Signal for updating the short training status label + status_msg_signal = pyqtSignal(str) + # Signal emitted when training ends: (success_flag, message) + finished_signal = pyqtSignal(bool, str) + + def __init__(self, config_path, train_params): + super().__init__() + # Path to the base YAML config template + self.config_path = config_path + # Training parameters collected from the UI + self.params = train_params + # Regex used to capture progress-style outputs such as "12/100 [" + self.progress_re = re.compile(r'(\d+)/(\d+)\s+\[') + + def run(self): + # Create a hidden workspace under the user's home directory + # to store temporary runtime config files + temp_workspace = os.path.join(os.path.expanduser("~"), ".soccernet_workspace") + os.makedirs(temp_workspace, exist_ok=True) + + # Generate a unique temporary config filename for this training run + unique_id = uuid.uuid4().hex[:6] + temp_config_path = os.path.join(temp_workspace, f"temp_train_config_{unique_id}.yaml") + + try: + # Load the original YAML config template + with open(self.config_path, 'r', encoding='utf-8') as f: + config = yaml.safe_load(f) + + # 1. Path setup + # Infer dataset root from the directory of the training annotation JSON + dataset_root = os.path.dirname(self.params['train_json']) + config['DATA']['data_dir'] = str(dataset_root).replace('\\', '/') + + # Create a checkpoint directory inside the dataset root + checkpoint_dir = os.path.join(dataset_root, "checkpoints") + os.makedirs(checkpoint_dir, exist_ok=True) + config['TRAIN']['save_dir'] = str(checkpoint_dir).replace('\\', '/') + + # 2. Structure adjustment + # Inject annotation paths into a custom annotations block + config['DATA']['annotations'] = { + 'train': str(self.params['train_json']), + 'valid': str(self.params['valid_json']) + } + + # 3. Update training hyperparameters from UI inputs + config['TRAIN']['epochs'] = int(self.params['epochs']) + config['TRAIN']['optimizer']['lr'] = float(self.params['lr']) + config['TRAIN']['save_every'] = 1 # Save by epoch + + # [NEW] Try to inject step-based checkpoint save options + # These keys depend on whether soccernetpro supports them internally + config['TRAIN']['save_step'] = 500 + config['TRAIN']['checkpoint_interval'] = 500 # Fallback compatibility key + + # Explicitly enable training mode + config['TRAIN']['enabled'] = True + # Set the device selected from the UI, e.g. "cpu" or "cuda" + config['SYSTEM']['device'] = str(self.params['device']) + + # Ensure the train block exists before modifying dataloader settings + if 'train' not in config['DATA']: + config['DATA']['train'] = {} + + # Ensure the nested dataloader block exists + config['DATA']['train']['dataloader'] = config['DATA'].get('train', {}).get('dataloader', {}) + # Update batch size and number of workers from UI + config['DATA']['train']['dataloader']['batch_size'] = int(self.params['batch']) + config['DATA']['train']['dataloader']['num_workers'] = int(self.params['workers']) + + # Write the runtime YAML config to a temporary file + with open(temp_config_path, 'w', encoding='utf-8') as f: + yaml.dump(config, f) + + # Notify UI that training initialization has started + self.log_signal.emit(f"🚀 Initializing Training on {self.params['device']}...") + + # --- 4. Enhanced log interceptor --- + # This stream captures stdout/stderr from training, parses progress, + # forwards readable logs to the UI, and prints special checkpoint info + # directly to the VSCode terminal. + class UILogStream(io.TextIOBase): + def __init__(self, outer_instance, cp_dir): + # Reference back to the outer TrainWorker instance + self.outer = outer_instance + # Directory where checkpoints are stored + self.cp_dir = cp_dir + # Best-effort current epoch string for UI status display + self.current_epoch_str = "Epoch ?/?" + # Internal line buffer for partial writes + self.line_buffer = "" + + def write(self, s): + # Accumulate incoming text because stdout/stderr may write in chunks + self.line_buffer += s + + # Process buffered content whenever a newline or carriage return appears + while '\n' in self.line_buffer or '\r' in self.line_buffer: + idx_n = self.line_buffer.find('\n') + idx_r = self.line_buffer.find('\r') + + # Split on the earliest line boundary + if idx_n != -1 and (idx_r == -1 or idx_n < idx_r): + line, self.line_buffer = self.line_buffer.split('\n', 1) + else: + line, self.line_buffer = self.line_buffer.split('\r', 1) + + # Remove leading/trailing spaces + clean_line = line.strip() + if not clean_line: + continue + + # Detect epoch lines and forward them to the UI log console + if "Epoch" in clean_line: + epoch_match = re.search(r'Epoch\s+\d+/\d+', clean_line) + if epoch_match: + self.current_epoch_str = epoch_match.group(0) + self.outer.log_signal.emit(clean_line) + + # Detect progress lines like "123/500 [" + match = self.outer.progress_re.search(clean_line) + if match: + curr_step = int(match.group(1)) + total_step = int(match.group(2)) + + # Compute percentage for the progress bar + percent = int((curr_step / total_step) * 100) + self.outer.progress_signal.emit(percent) + + # Update short status text in the UI + self.outer.status_msg_signal.emit( + f"{self.current_epoch_str} | Step: {curr_step}/{total_step}" + ) + + # [NEW] Every 500 steps, print an explicit message to the real terminal + # using sys.__stdout__ so it bypasses the redirection + if curr_step > 0 and curr_step % 500 == 0: + msg = ( + f"\n[VSCODE INFO] Iteration {curr_step} reached. " + f"Checkpoint auto-save triggered to: {self.cp_dir}\n" + ) + sys.__stdout__.write(msg) + sys.__stdout__.flush() + else: + # Forward non-progress, non-noisy lines to the UI log panel + if "Training:" not in clean_line and "|" not in clean_line: + self.outer.log_signal.emit(clean_line) + + return len(s) + + # 5. Start training + # Build the classification model using the temporary runtime config + myModel = model.classification(config=temp_config_path) + + # Redirect stdout and stderr from the training process into the custom UI stream + log_stream = UILogStream(self, checkpoint_dir) + with contextlib.ExitStack() as stack: + stack.enter_context(contextlib.redirect_stdout(log_stream)) + stack.enter_context(contextlib.redirect_stderr(log_stream)) + # Launch training + myModel.train() + + # Notify UI that training completed successfully + self.finished_signal.emit(True, f"Training Completed Successfully.\nCheckpoints: {checkpoint_dir}") + + except Exception as e: + # Print traceback to the real console for debugging + import traceback + traceback.print_exc() + # Notify UI that training failed + self.finished_signal.emit(False, str(e)) + finally: + # Always try to remove the temporary config file after training ends + if os.path.exists(temp_config_path): + try: + os.remove(temp_config_path) + except: + pass + +class TrainManager(QObject): + def __init__(self, main_window): + super().__init__() + # Reference to the main window + self.main = main_window + # Shortcut to the classification UI panel on the right side + self.ui = main_window.ui.classification_ui.right_panel + # Background worker thread instance + self.worker = None + + # Resolve base directory differently for bundled app vs source execution + if hasattr(sys, '_MEIPASS'): + self.base_dir = sys._MEIPASS + else: + self.base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) + + # Path to the base classification config file + self.config_path = os.path.join(self.base_dir, "config.yaml") + + # Connect UI buttons to start/stop handlers + self.ui.btn_start_train.clicked.connect(self.start_training) + self.ui.btn_stop_train.clicked.connect(self.stop_training) + + def start_training(self): + # Prevent launching a second training job while one is already running + if self.worker and self.worker.isRunning(): + return + + # Get the currently loaded JSON annotation file from the main model + train_json = self.main.model.current_json_path + + # Require that the currently loaded file is the training annotation file + if not train_json or "annotations_train" not in train_json: + QMessageBox.critical(self.main, "Error", "Please load 'annotations_train.json' first.") + return + + # Collect training parameters from the UI controls + params = { + "epochs": self.ui.spin_epochs.currentText(), + "lr": self.ui.edit_lr.text(), + "batch": self.ui.spin_batch.currentText(), + # Extract only the raw device token from combo text, e.g. "cuda (GPU)" -> "cuda" + "device": self.ui.combo_device.currentText().split(" ")[0], + "workers": self.ui.spin_workers.currentText(), + "train_json": train_json, + # Infer the validation annotation path by filename replacement + "valid_json": train_json.replace("annotations_train.json", "annotations_valid.json") + } + + # Prepare UI state for an active training session + self.ui.btn_start_train.setEnabled(False) + self.ui.btn_stop_train.setEnabled(True) + self.ui.train_progress.setVisible(True) + self.ui.train_progress.setValue(0) + self.ui.lbl_train_status.setVisible(True) + self.ui.lbl_train_status.setText("🚀 Starting Training Loop...") + self.ui.train_console.clear() + + # Create and wire up the training worker thread + self.worker = TrainWorker(self.config_path, params) + self.worker.log_signal.connect(self._append_log) + self.worker.progress_signal.connect(self.ui.train_progress.setValue) + self.worker.status_msg_signal.connect(self.ui.lbl_train_status.setText) + self.worker.finished_signal.connect(self._on_train_finished) + + # Start background training + self.worker.start() + + def _append_log(self, text): + # Append a line of log text to the training console widget + self.ui.train_console.append(text) + + def stop_training(self): + """Force-stop the training thread.""" + if self.worker and self.worker.isRunning(): + # Ask for user confirmation before aborting training + reply = QMessageBox.question( + self.main, "Confirm Stop", + "Are you sure you want to abort training?\nUnsaved progress in the current epoch will be lost.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + + if reply == QMessageBox.StandardButton.Yes: + # Update UI to reflect aborting state + self.ui.btn_stop_train.setEnabled(False) + self.ui.lbl_train_status.setText("🛑 Aborting...") + + # Forcefully terminate the worker thread + self.worker.terminate() + self.worker.wait() + + # Reuse the finish handler with a manual-abort message + self._on_train_finished(False, "Training was manually aborted by user.") + + def _on_train_finished(self, success, message): + # Restore UI controls after training ends + self.ui.btn_start_train.setEnabled(True) + self.ui.btn_stop_train.setEnabled(False) + self.ui.train_progress.setVisible(False) + self.ui.lbl_train_status.setVisible(False) + + # Show result feedback and append a final log line + if success: + QMessageBox.information(self.main, "Success", message) + self._append_log(f"\n✅ [SUCCESS] {message}") + else: + QMessageBox.critical(self.main, "Train Error", message) + self._append_log(f"\n❌ [ERROR] {message}") \ No newline at end of file diff --git a/annotation_tool/controllers/localization/loc_file_manager.py b/annotation_tool/controllers/localization/loc_file_manager.py index 952c71f8..ff28441f 100644 --- a/annotation_tool/controllers/localization/loc_file_manager.py +++ b/annotation_tool/controllers/localization/loc_file_manager.py @@ -299,6 +299,30 @@ def _write_json(self, path): except Exception as e: QMessageBox.critical(self.main, "Error", f"Save failed: {e}") return False + + for video_path in sorted(self.model.localization_events.keys()): + # 获取该视频所属的原始 item 定义(包含 inputs 视频源信息) + base_item = next((item for item in self.model.action_item_data if item["path"] == video_path), None) + if not base_item: continue + + # 1. 获取手工(或已确认的)标注 + manual_events = self.model.localization_events.get(video_path, []) + + # 2. 获取未确认的智能标注 + smart_events = self.model.smart_localization_events.get(video_path, []) + + # 构建符合 OSL 标准规范的单条数据结构 + out_item = { + "id": base_item.get("id", ""), + "inputs": [{"path": f, "type": "video"} for f in base_item.get("source_files", [video_path])], + "events": manual_events + } + + # 遵循原始结构添加 smart_events 字段(如果有的话) + if smart_events: + out_item["smart_events"] = smart_events + + items.append(out_item) def _clear_workspace(self, full_reset=False): """ diff --git a/annotation_tool/controllers/localization/loc_inference.py b/annotation_tool/controllers/localization/loc_inference.py new file mode 100644 index 00000000..6ef4a1f8 --- /dev/null +++ b/annotation_tool/controllers/localization/loc_inference.py @@ -0,0 +1,160 @@ +import os +import json +import tempfile +import yaml +import glob +from PyQt6.QtCore import QObject, QThread, pyqtSignal + +class LocInferenceWorker(QThread): + """ + Background worker for running OpenSportsLib Localization inference. + Dynamically patches config for CPU usage (Mac M1/M2 compatibility). + """ + finished_signal = pyqtSignal(list) + error_signal = pyqtSignal(str) + + def __init__(self, video_path, start_ms, end_ms, config_path): + super().__init__() + self.video_path = os.path.abspath(video_path) + self.start_ms = start_ms + self.end_ms = end_ms + self.config_path = config_path + + def run(self): + try: + # Import library inside thread to avoid blocking main thread at startup + from opensportslib import model + + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_input_json = os.path.join(tmp_dir, "temp_test.json") + tmp_config_yaml = os.path.join(tmp_dir, "temp_config.yaml") + tmp_output_json = os.path.join(tmp_dir, "predictions.json") + + # --- 1. Load and dynamically patch the YAML config --- + with open(self.config_path, 'r', encoding='utf-8') as f: + config_dict = yaml.safe_load(f) + + classes = config_dict.get("DATA", {}).get("classes", []) + + # 🚀 [MAC CPU ADAPTATION & PATH FIXES] 🚀 + # Force CPU mode and disable Multi-GPU dynamically + if "SYSTEM" not in config_dict: config_dict["SYSTEM"] = {} + config_dict["SYSTEM"]["work_dir"] = tmp_dir + config_dict["SYSTEM"]["device"] = "cpu" + config_dict["SYSTEM"]["GPU"] = 0 + config_dict["SYSTEM"]["gpu_id"] = 0 + + if "MODEL" not in config_dict: config_dict["MODEL"] = {} + config_dict["MODEL"]["multi_gpu"] = False + + # Override dataloader paths for test + if "DATA" in config_dict and "test" in config_dict["DATA"]: + config_dict["DATA"]["test"]["video_path"] = os.path.dirname(self.video_path) + config_dict["DATA"]["test"]["path"] = tmp_input_json + config_dict["DATA"]["test"]["results"] = "predictions" + + with open(tmp_config_yaml, 'w', encoding='utf-8') as f: + yaml.dump(config_dict, f) + + # --- 2. Create temporary JSON for the single video --- + test_data = { + "version": "2.0", + "task": "action_spotting", + "labels": {"ball_action": {"type": "single_label", "labels": classes}}, + "data": [{ + "id": "inf_vid", + "inputs": [{"path": self.video_path, "type": "video", "fps": 25.0}], + # 必须放一个 Dummy event 骗过 DataLoader + "events": [{"head": "ball_action", "label": classes[0] if classes else "Unknown", "position_ms": 0}] + }] + } + with open(tmp_input_json, 'w', encoding='utf-8') as f: + json.dump(test_data, f) + + # --- 3. Execute model inference --- + loc_model = model.localization(config=tmp_config_yaml) + + try: + # 运行推理。这里一定会抛出 FileNotFoundError,因为框架底层的评估器找不到文件 + loc_model.infer( + test_set=tmp_input_json, + pretrained="jeetv/snpro-snbas-2024" + ) + except FileNotFoundError: + # [关键修复 4]:霸气忽略! + # 因为报错发生在推理完成之后的“评估阶段”,所以我们直接 catch 掉这个错误, + # 假装无事发生,直接进入下一步去深层文件夹里捞生成的 JSON。 + pass + + # --- 4. Parse result JSON --- + # 递归搜索临时文件夹下的所有 .json 文件(完美穿透 checkpoints/xxx 嵌套文件夹) + search_pattern = os.path.join(tmp_dir, "**", "*.json") + all_jsons = glob.glob(search_pattern, recursive=True) + + valid_preds = [] + for f in all_jsons: + filename = os.path.basename(f) + # 排除掉我们自己生成的输入数据和配置文件 + if "temp_test" not in filename and "temp_config" not in filename: + valid_preds.append(f) + + if valid_preds: + # 找到最新生成的那一个(防止有多个旧文件干扰) + actual_output_json = max(valid_preds, key=os.path.getctime) + else: + raise FileNotFoundError(f"Could not find any generated prediction JSON in {tmp_dir}/checkpoints/") + + predicted_events = [] + if os.path.exists(actual_output_json): + with open(actual_output_json, 'r', encoding='utf-8') as f: + output_data = json.load(f) + + raw_evts = output_data.get("data", [{}])[0].get("events", []) + for evt in raw_evts: + p_ms = int(evt.get("position_ms", 0)) + + if p_ms == 0 and evt.get("label") == (classes[0] if classes else "Unknown"): + continue + + if p_ms >= self.start_ms and (self.end_ms == 0 or p_ms <= self.end_ms): + predicted_events.append({ + "head": evt.get("head", "ball_action"), + "label": evt.get("label", "Unknown"), + "position_ms": p_ms + }) + + self.finished_signal.emit(predicted_events) + + except Exception as e: + import traceback + traceback.print_exc() + self.error_signal.emit(str(e)) + + +class LocalizationInferenceManager(QObject): + """ + High-level controller that manages the inference thread lifecycle. + """ + inference_finished = pyqtSignal(list) + inference_error = pyqtSignal(str) + + def __init__(self, main_window): + super().__init__(main_window) + self.main = main_window + self.worker = None + + def start_inference(self, video_path: str, start_ms: int, end_ms: int): + if self.worker and self.worker.isRunning(): return + config_path = os.path.join(os.getcwd(), "loc_config.yaml") + self.worker = LocInferenceWorker(video_path, start_ms, end_ms, config_path) + self.worker.finished_signal.connect(self._on_finished) + self.worker.error_signal.connect(self._on_error) + self.worker.start() + + def _on_finished(self, events): + self.inference_finished.emit(events) + self.worker = None + + def _on_error(self, err_msg): + self.inference_error.emit(err_msg) + self.worker = None \ No newline at end of file diff --git a/annotation_tool/controllers/localization/localization_manager.py b/annotation_tool/controllers/localization/localization_manager.py index 9ef7461f..ef15725c 100644 --- a/annotation_tool/controllers/localization/localization_manager.py +++ b/annotation_tool/controllers/localization/localization_manager.py @@ -9,6 +9,7 @@ from models import CmdType # [NEW] Import the unified MediaController from controllers.media_controller import MediaController +from .loc_inference import LocalizationInferenceManager class LocalizationManager: """ @@ -24,6 +25,10 @@ def __init__(self, main_window): self.left_panel = self.ui_root.left_panel self.center_panel = self.ui_root.center_panel self.right_panel = self.ui_root.right_panel + + self.inference_manager = LocalizationInferenceManager(self.main) + self.inference_manager.inference_finished.connect(self._on_inference_success) + self.inference_manager.inference_error.connect(self._on_inference_error) # [NEW] Initialize Media Controller # We access the underlying QMediaPlayer from the UI wrapper @@ -62,7 +67,7 @@ def setup_connections(self): media.durationChanged.connect(timeline.set_duration) timeline.seekRequested.connect(media.set_position) - # [CHANGED] Use MediaController for playback control + # Use MediaController for playback control pb.stopRequested.connect(self.media_controller.stop) pb.playPauseRequested.connect(self.media_controller.toggle_play_pause) @@ -73,6 +78,18 @@ def setup_connections(self): pb.nextPrevAnnotRequested.connect(self._navigate_annotation) # --- Right Panel --- + #Smart Annotation UI + if hasattr(self.right_panel, 'smart_widget'): + smart_ui = self.right_panel.smart_widget + smart_ui.setTimeRequested.connect(self._on_smart_set_time) + smart_ui.runInferenceRequested.connect(self._run_localization_inference) + smart_ui.confirmSmartRequested.connect(self._confirm_smart_events) + smart_ui.clearSmartRequested.connect(self._clear_smart_events) + + # Tab switch to toggle timeline markers + self.right_panel.tabs.currentChanged.connect(self._on_tab_switched) + + tabs = self.right_panel.annot_mgmt.tabs table = self.right_panel.table @@ -90,11 +107,32 @@ def setup_connections(self): table.annotationDeleted.connect(self._on_delete_single_annotation) table.annotationModified.connect(self._on_annotation_modified) + table.updateTimeForSelectedRequested.connect(self._on_update_time_for_selected) + def _on_media_position_changed(self, ms): self.center_panel.timeline.set_position(ms) time_str = self._fmt_ms_full(ms) self.right_panel.annot_mgmt.tabs.update_current_time(time_str) + def _on_update_time_for_selected(self, old_event): + """ + Handles the logic when the user clicks the + 'Set to Current Video Time' button. + """ + if not self.current_video_path: + return + + # 1. Get the current playback position in milliseconds + current_ms = self.center_panel.media_preview.player.position() + + # 2. Copy the old event and update its timestamp + new_event = old_event.copy() + new_event['position_ms'] = current_ms + + # 3. Reuse the existing modification logic + self._on_annotation_modified(old_event, new_event) + + # --- Video Loading Logic (Strict Classification Style via Controller) --- def on_clip_selected(self, current_idx, previous_idx): if not current_idx.isValid(): @@ -291,6 +329,8 @@ def _on_spotting_triggered(self, head, label): self.main.show_temp_msg("Event Created", f"{head}: {label}") self.main.update_save_export_button_state() + self._reselect_event(new_event) + # --- Table Modification --- def _on_annotation_modified(self, old_event, new_event): events = self.model.localization_events.get(self.current_video_path, []) @@ -318,6 +358,8 @@ def _on_annotation_modified(self, old_event, new_event): self.main.show_temp_msg("Event Updated", "Modified") self.main.update_save_export_button_state() + self._reselect_event(new_event) + def _on_delete_single_annotation(self, item_data): events = self.model.localization_events.get(self.current_video_path, []) if item_data not in events: return @@ -482,8 +524,144 @@ def _select_row_by_time(self, time_ms): self.right_panel.table.table.scrollTo(idx) break + def _reselect_event(self, target_event): + model = self.right_panel.table.model + table_view = self.right_panel.table.table + + table_view.selectionModel().blockSignals(True) + + for row in range(model.rowCount()): + item = model.get_annotation_at(row) + if not item: continue + + if (item.get('position_ms') == target_event.get('position_ms') and + item.get('head') == target_event.get('head') and + item.get('label') == target_event.get('label')): + + idx = model.index(row, 0) + + table_view.selectRow(row) + table_view.scrollTo(idx) + + if hasattr(self.right_panel.table, 'btn_set_time'): + self.right_panel.table.btn_set_time.setEnabled(True) + + break + + table_view.selectionModel().blockSignals(False) + def _fmt_ms_full(self, ms): s = ms // 1000 m = s // 60 h = m // 60 return f"{h:02}:{m%60:02}:{s%60:02}.{ms%1000:03}" + + + # ========================================== + # --- Smart Annotation Control Logic --- + # ========================================== + + def _on_smart_set_time(self, target: str): + """ + Triggered when 'Set to Current' is clicked in Smart Spotting UI. + Gets current player position and updates the smart UI. + """ + player = self.center_panel.media_preview.player + current_ms = player.position() + time_str = self._fmt_ms_full(current_ms) + + # Update the UI display and internal state in the Smart Widget + self.right_panel.smart_widget.update_time_display(target, time_str, current_ms) + + + def _run_localization_inference(self, start_ms: int, end_ms: int): + if not self.current_video_path: + return + if start_ms >= end_ms and end_ms != 0: + from PyQt6.QtWidgets import QMessageBox + QMessageBox.warning(self.main, "Invalid Range", "End time must be greater than Start time.") + return + + self.main.show_temp_msg("Smart Inference", "Running OpenSportsLib Localization Model...") + self.right_panel.smart_widget.btn_run_infer.setEnabled(False) + self.inference_manager.start_inference(self.current_video_path, start_ms, end_ms) + + + def _on_inference_success(self, predicted_events: list): + self.right_panel.smart_widget.btn_run_infer.setEnabled(True) + if not self.current_video_path: + return + + self.model.smart_localization_events[self.current_video_path] = predicted_events + self.main.show_temp_msg("Smart Inference", f"Success: Found {len(predicted_events)} events.") + + if self.right_panel.tabs.currentIndex() == 1: + self._display_smart_events(self.current_video_path) + + def _on_inference_error(self, error_msg: str): + self.right_panel.smart_widget.btn_run_infer.setEnabled(True) + from PyQt6.QtWidgets import QMessageBox + QMessageBox.critical(self.main, "Inference Error", f"Failed to run model:\n{error_msg}") + + def _confirm_smart_events(self): + """将智能预测合并到手工标注中""" + if not self.current_video_path: + return + + smart_events = self.model.smart_localization_events.get(self.current_video_path, []) + if not smart_events: + return + + # 初始化当前视频的手工事件列表(如果没有) + if self.current_video_path not in self.model.localization_events: + self.model.localization_events[self.current_video_path] = [] + + # 合并事件 (此处暂不处理 undo/redo) + self.model.localization_events[self.current_video_path].extend(smart_events) + + # 按照时间排序 + self.model.localization_events[self.current_video_path].sort(key=lambda x: x.get('position_ms', 0)) + + # 清空当前的 Smart Events + self.model.smart_localization_events[self.current_video_path] = [] + self._display_smart_events(self.current_video_path) # 刷新为空表 + + # 提示用户 + self.main.show_temp_msg("Smart Spotting", "Predictions confirmed and merged into Hand Annotations.") + self.model.is_data_dirty = True + self.main.update_save_export_button_state() + + def _clear_smart_events(self): + if not self.current_video_path: + return + self.model.smart_localization_events[self.current_video_path] = [] + self._display_smart_events(self.current_video_path) + self.main.show_temp_msg("Smart Spotting", "Cleared smart predictions.") + + def _display_smart_events(self, video_path: str): + """Dedicated method to display ONLY smart events in the smart table and timeline.""" + events = self.model.smart_localization_events.get(video_path, []) + # 更新 Smart Table + self.right_panel.smart_widget.smart_table.set_data(events) + # 更新 Timeline + markers = [] + for evt in events: + # Smart events 也可以使用不同的颜色,比如蓝色,用来和手工标注(红色)区分 + from PyQt6.QtGui import QColor + markers.append({ + 'start_ms': evt.get('position_ms', 0), + 'color': QColor('deepskyblue') + }) + self.center_panel.timeline.set_markers(markers) + + def _on_tab_switched(self, index: int): + """切换 Tab 时隔离视觉状态""" + if not self.current_video_path: + return + + if index == 0: + # 回到手工标注,加载原始的手工事件 + self._display_events_for_item(self.current_video_path) + elif index == 1: + # 去到智能标注,加载智能事件 + self._display_smart_events(self.current_video_path) diff --git a/annotation_tool/controllers/media_controller.py b/annotation_tool/controllers/media_controller.py index f3a3907c..131775a1 100644 --- a/annotation_tool/controllers/media_controller.py +++ b/annotation_tool/controllers/media_controller.py @@ -5,73 +5,122 @@ class MediaController(QObject): """ A unified controller for managing video playback logic across all modes. - Now handles: - 1. Robust Playback State (Stop -> Clear -> Load -> Delay -> Play) - 2. Timer Cancellation (Prevents race conditions on rapid switching) - 3. Visual Clearing (Forces VideoWidget to refresh) + Now includes a 'Watchdog' mechanism to catch silent hardware decoder failures + (e.g., AV1 video fails, but Audio keeps playing causing a zombie black screen). """ def __init__(self, player: QMediaPlayer, video_widget: QWidget = None): super().__init__() self.player = player self.video_widget = video_widget - # [CRITICAL FIX] Use an instance timer so we can cancel it! - # This prevents the "Ghost Timer" bug where a video starts playing - # *after* the user has closed the project or switched modes. + # 1. Connect standard error signals + self.player.errorOccurred.connect(self._handle_media_error) + + # 2. Setup Play Timer self.play_timer = QTimer() self.play_timer.setSingleShot(True) - self.play_timer.setInterval(150) # 150ms delay + self.play_timer.setInterval(150) self.play_timer.timeout.connect(self._execute_play) - def load_and_play(self, file_path: str, auto_play: bool = True): + # 3. [NEW] Setup Watchdog Timer to catch silent Black Screens + self.watchdog_timer = QTimer() + self.watchdog_timer.setSingleShot(True) + self.watchdog_timer.setInterval(1500) # Check 1.5 seconds after play starts + self.watchdog_timer.timeout.connect(self._check_for_black_screen) + + # 4. [NEW] Monitor actual frames being drawn to the screen + self._frame_received = False + if self.video_widget and hasattr(self.video_widget, 'videoSink'): + sink = self.video_widget.videoSink() + if sink: + # Every time a pixel frame is actually rendered, this triggers + sink.videoFrameChanged.connect(self._on_frame_rendered) + + def _on_frame_rendered(self, *args): + """Marks that the GPU successfully decoded and drew at least one frame.""" + self._frame_received = True + + def _trigger_error_dialog(self, error_details: str): + """Stops playback immediately and blocks the UI with an error dialog.""" + self.stop() # Force kill playback + + try: + from ui.common.dialogs import MediaErrorDialog + error_dialog = MediaErrorDialog(error_details, parent=self.video_widget) + error_dialog.exec() # Block UI thread + except ImportError as e: + print(f"Failed to import MediaErrorDialog: {e}") + + def _check_for_black_screen(self): + """ + The Ultimate Catch: Watchdog timer triggered. + """ + is_playing = self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState + is_loaded = self.player.mediaStatus() in [QMediaPlayer.MediaStatus.LoadedMedia, QMediaPlayer.MediaStatus.BufferedMedia] + + if is_playing and is_loaded and self.player.hasVideo() and not self._frame_received: + # Pass a concise technical reason instead of a long paragraph + self._trigger_error_dialog("Watchdog Timeout: The hardware video decoder crashed silently and failed to render any frames within 1.5 seconds.") + + def _handle_media_status(self, status: QMediaPlayer.MediaStatus): + """ + Catches silent failures from MediaStatus. + """ + if status == QMediaPlayer.MediaStatus.InvalidMedia: + self._trigger_error_dialog("Status Error: Invalid Media or completely unsupported file format.") + + elif status == QMediaPlayer.MediaStatus.LoadedMedia: + if not self.player.hasVideo(): + self._trigger_error_dialog("Status Error: The file has no decodable video stream (e.g., missing AV1 hardware decoder).") + + def _handle_media_error(self, error: QMediaPlayer.Error, error_string: str): """ - Standardized sequence to load and play a video. + Catches standard MediaFoundation/AVFoundation load errors. """ - # 1. Force Stop & Cancel any pending play requests + if error != QMediaPlayer.Error.NoError: + print(f"[Media Error] Code: {error}, Message: {error_string}") + self._trigger_error_dialog(f"Player Error Code {error}: {error_string}") + + def load_and_play(self, file_path: str, auto_play: bool = True): self.stop() if not file_path: return - # 2. Load Source self.player.setSource(QUrl.fromLocalFile(file_path)) - # 3. Auto-play with safety delay if auto_play: self.play_timer.start() def _execute_play(self): - """Actual slot called by timer to start playback.""" + """Starts playback and launches the Watchdog.""" + self._frame_received = False # Reset the frame flag self.player.play() + self.watchdog_timer.start() # Unleash the watchdog def toggle_play_pause(self): - """Toggle between Play and Pause.""" if self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState: self.player.pause() else: + self._frame_received = False self.player.play() + self.watchdog_timer.start() def stop(self): - """ - Stops playback, clears source, cancels timers, and forces UI refresh. - """ - # A. Cancel pending auto-play if user clicked away quickly + """Stops playback and cancels all timers.""" if self.play_timer.isActive(): self.play_timer.stop() + if self.watchdog_timer.isActive(): + self.watchdog_timer.stop() - # B. Stop Player logic self.player.stop() self.player.setSource(QUrl()) - # C. [Visual Fix] Force the video widget to repaint/update - # This helps clear the "stuck frame" from the GPU buffer if self.video_widget: self.video_widget.update() self.video_widget.repaint() - def set_looping(self, enable: bool): - """Helper to set looping.""" if enable: self.player.setLoops(QMediaPlayer.Loops.Infinite) else: diff --git a/annotation_tool/models/app_state.py b/annotation_tool/models/app_state.py index ff09ef08..4ad396e7 100644 --- a/annotation_tool/models/app_state.py +++ b/annotation_tool/models/app_state.py @@ -76,6 +76,10 @@ def __init__(self): # Format: { video_path: [ { "head": ..., "label": ..., "position_ms": ... }, ... ] } self.localization_events = {} + + # localization-smart annotation + self.smart_localization_events = {} + # --- Common clip list --- # Each item: { "name": "...", "path": "...", "source_files": [...] } # This is the shared source of truth for the Project Tree @@ -103,6 +107,7 @@ def reset(self, full_reset: bool = False): # [NEW] Clear smart annotations on reset self.smart_annotations = {} self.localization_events = {} + self.smart_localization_events = {} self.imported_input_metadata = {} self.imported_action_metadata = {} diff --git a/annotation_tool/ui/classification/event_editor/editor.py b/annotation_tool/ui/classification/event_editor/editor.py index 921a8e1d..08350fe2 100644 --- a/annotation_tool/ui/classification/event_editor/editor.py +++ b/annotation_tool/ui/classification/event_editor/editor.py @@ -5,6 +5,7 @@ ) from PyQt6.QtCore import Qt, pyqtSignal, QRectF, QPointF from PyQt6.QtGui import QPainter, QColor, QPen, QFont, QCursor +import sys from .dynamic_widgets import DynamicSingleLabelGroup, DynamicMultiLabelGroup @@ -182,6 +183,31 @@ def __init__(self, parent=None): # [NEW] Create QTabWidget to hold both annotation modes self.tabs = QTabWidget() + + # 1. 【核心】彻底禁用省略模式,防止文字变成 "..." + self.tabs.setElideMode(Qt.TextElideMode.ElideNone) + + # 2. 强制标签栏不自动扩展,使其仅占据文字所需的空间 + self.tabs.tabBar().setExpanding(False) + + # 3. 优化样式表:移除 min-width 限制,并设置极窄 Padding + self.tabs.setStyleSheet(""" + QTabBar::tab { + /* 设置较小的左右边距,确保文字紧凑且可见 */ + padding-left: 3px; + padding-right: 3px; + padding-top: 5px; + padding-bottom: 5px; + + /* 保持字体大小适中 */ + font-size: 13px; + + /* 确保没有最小宽度和最大宽度的硬性限制 */ + min-width: 0px; + max-width: 1000px; + } + """) + self.tabs.setObjectName("annotation_tabs") layout.addWidget(self.tabs, 1) # Add tabs to main layout with stretch factor 1 @@ -276,6 +302,124 @@ def __init__(self, parent=None): # Add the smart widget as the second tab self.tabs.addTab(self.smart_box, "Smart Annotation") + # --- 7. Train Tab [RE-DESIGNED] --- + self.train_box = QWidget() + train_main_layout = QVBoxLayout(self.train_box) + train_main_layout.setContentsMargins(5, 5, 5, 5) + train_main_layout.setSpacing(10) + + # 使用滚动区域,防止参数过多时显示不全 + train_scroll = QScrollArea() + train_scroll.setWidgetResizable(True) + train_scroll.setFrameShape(QFrame.Shape.NoFrame) + train_scroll_content = QWidget() + train_layout = QVBoxLayout(train_scroll_content) + train_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + # A. 训练超参数组 (Hyperparameters) + hyper_group = QGroupBox("Hyperparameters") + hyper_form = QVBoxLayout(hyper_group) # 使用垂直布局包装表单行 + + # 封装一个简单的表单行函数 + def add_form_row(label_text, widget): + row = QHBoxLayout() + lbl = QLabel(label_text) + lbl.setFixedWidth(80) + row.addWidget(lbl) + row.addWidget(widget) + return row + + self.spin_epochs = QComboBox() + self.spin_epochs.addItems(["1", "5", "10", "20", "50", "100"]) + self.spin_epochs.setEditable(True) + hyper_form.addLayout(add_form_row("Epochs:", self.spin_epochs)) + + self.edit_lr = QLineEdit("0.0001") + hyper_form.addLayout(add_form_row("LR:", self.edit_lr)) + + self.spin_batch = QComboBox() + self.spin_batch.addItems(["1", "2", "4", "8", "16"]) + self.spin_batch.setEditable(True) + hyper_form.addLayout(add_form_row("Batch:", self.spin_batch)) + + train_layout.addWidget(hyper_group) + + # B. 硬件设置组 (Hardware - 针对 Mac M1 优化) + device_group = QGroupBox("Execution") + device_form = QVBoxLayout(device_group) + + self.combo_device = QComboBox() + # 针对 M1 增加 mps 选项 + self.combo_device.addItems(["cpu", "mps (Metal)", "cuda"]) + if sys.platform == "darwin": + self.combo_device.setCurrentText("mps (Metal)") + device_form.addLayout(add_form_row("Device:", self.combo_device)) + + self.spin_workers = QComboBox() + self.spin_workers.addItems(["0", "2", "4"]) + device_form.addLayout(add_form_row("Workers:", self.spin_workers)) + + train_layout.addWidget(device_group) + + # C. 训练操作与监控 (Action & Monitor) + h_train_btns = QHBoxLayout() # 创建横向布局 + + # 1. Start Training 按钮 + self.btn_start_train = QPushButton("Start Training") + self.btn_start_train.setMinimumHeight(40) + self.btn_start_train.setCursor(Qt.CursorShape.PointingHandCursor) + self.btn_start_train.setStyleSheet(""" + QPushButton { + background-color: #007bff; + color: white; + font-weight: bold; + border-radius: 4px; + } + QPushButton:hover { background-color: #0069d9; } + QPushButton:disabled { background-color: #cccccc; color: #666666; } + """) + + # 2. Stop Training 按钮 [NEW] + self.btn_stop_train = QPushButton("Stop Training") + self.btn_stop_train.setMinimumHeight(40) + self.btn_stop_train.setCursor(Qt.CursorShape.PointingHandCursor) + self.btn_stop_train.setEnabled(False) # 初始不可点击 + # 样式与 Clear Selection 一致(标准按钮样式) + self.btn_stop_train.setProperty("class", "editor_control_btn") + + h_train_btns.addWidget(self.btn_start_train, 2) # Start 占更多空间 + h_train_btns.addWidget(self.btn_stop_train, 1) + + # 后面跟着状态标签和进度条 + self.lbl_train_status = QLabel("Ready to train") + + + self.lbl_train_status = QLabel("Ready to train") + self.lbl_train_status.setStyleSheet("color: #4A90E2; font-weight: bold; margin-top: 5px;") + self.lbl_train_status.setVisible(False) + + + self.train_progress = QProgressBar() + self.train_progress.setRange(0, 100) + self.train_progress.setValue(0) + self.train_progress.setVisible(False) + + self.train_console = QTextEdit() + self.train_console.setReadOnly(True) + self.train_console.setPlaceholderText("Training logs will appear here...") + self.train_console.setMinimumHeight(150) + self.train_console.setStyleSheet("background-color: #1e1e1e; color: #d4d4d4; font-family: 'Courier New'; font-size: 11px;") + + train_layout.addLayout(h_train_btns) + train_layout.addWidget(self.lbl_train_status) + train_layout.addWidget(self.train_progress) + train_layout.addWidget(self.train_console) + + train_scroll.setWidget(train_scroll_content) + train_main_layout.addWidget(train_scroll) + + self.tabs.addTab(self.train_box, "Train") + # --- 6. Bottom Confirm Buttons (Fixed Outside Tabs) --- btn_row = QHBoxLayout() self.confirm_btn = QPushButton("Confirm Annotation") @@ -343,6 +487,17 @@ def reset_smart_inference(self): # Ensures Run Batch dropdowns disappear after Confirm or switching videos self.batch_input_widget.setVisible(False) + def reset_train_ui(self): + self.train_progress.setValue(0) + self.train_progress.setVisible(False) + + self.lbl_train_status.setText("Ready to train") + self.lbl_train_status.setVisible(False) + + self.train_console.clear() + + self.btn_start_train.setEnabled(True) + # [MODIFIED] Save the full list and initialize the dropdowns def update_action_list(self, action_names: list): self.full_action_names = action_names @@ -461,4 +616,4 @@ def clear_selection(self): if hasattr(group, 'set_checked_label'): group.set_checked_label(None) elif hasattr(group, 'set_checked_labels'): - group.set_checked_labels([]) + group.set_checked_labels([]) \ No newline at end of file diff --git a/annotation_tool/ui/common/dialogs.py b/annotation_tool/ui/common/dialogs.py index 2dcb050b..b91964b9 100644 --- a/annotation_tool/ui/common/dialogs.py +++ b/annotation_tool/ui/common/dialogs.py @@ -162,4 +162,35 @@ def get_selected_folders(self) -> list[str]: """Returns a list of absolute paths for the selected folders.""" indexes = self.tree.selectionModel().selectedRows() paths = [self.model.filePath(idx) for idx in indexes] - return paths \ No newline at end of file + return paths + +class MediaErrorDialog(QMessageBox): + """ + [NEW] A standardized error dialog for media playback failures. + Provides a concise explanation and an FFmpeg command to fix the codec issue. + Technical logs are hidden in the details section to keep the UI clean. + """ + def __init__(self, error_string: str, parent=None) -> None: + super().__init__(parent) + + self.setIcon(QMessageBox.Icon.Critical) + + # Main short title + self.setWindowTitle("Video Decoding Error") + self.setText("Unsupported Video Codec Detected") + + # Concise explanation with the FFmpeg terminal command + info_text = ( + "Your system cannot decode this video's format (e.g., AV1, DivX, or Xvid). " + "The audio might play, but the video hardware decoder has failed.\n\n" + "To fix this, please transcode your file to a standard H.264 MP4 format. " + "Run the following command in your terminal:\n\n" + "ffmpeg -i input.mp4 -vcodec libx264 -acodec aac output.mp4" + ) + self.setInformativeText(info_text) + + # Hide the long, ugly technical error logs inside a collapsible "Show Details..." button + if error_string: + self.setDetailedText(f"System Diagnostic Logs:\n{error_string}") + + self.setStandardButtons(QMessageBox.StandardButton.Ok) \ No newline at end of file diff --git a/annotation_tool/ui/localization/event_editor/__init__.py b/annotation_tool/ui/localization/event_editor/__init__.py index d19042f7..4185890a 100644 --- a/annotation_tool/ui/localization/event_editor/__init__.py +++ b/annotation_tool/ui/localization/event_editor/__init__.py @@ -1,24 +1,30 @@ +# __init__.py (in ui/localization/) + from PyQt6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel + QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QTabWidget ) -from PyQt6.QtCore import Qt +from PyQt6.QtCore import Qt, pyqtSignal -# Import the separated components from the same package from .spotting_controls import AnnotationManagementWidget from .annotation_table import AnnotationTableWidget +from .smart_spotting import SmartSpottingWidget -# --- [Assembled] Localization Right Panel --- class LocRightPanel(QWidget): """ Right Panel for Localization Mode. - Contains: Undo/Redo Buttons, Annotation Tabs (Top), and Events Table (Bottom). + Contains: Undo/Redo Buttons (Global), and a TabWidget separating + Hand Annotation and Smart Annotation interfaces. """ + # Signal emitted when the user switches between Hand and Smart tabs + # The Controller should catch this to swap the Timeline markers + tabSwitched = pyqtSignal(int) + def __init__(self, parent=None): super().__init__(parent) self.setFixedWidth(400) layout = QVBoxLayout(self) - # --- Undo/Redo Button Header --- + # --- 1. Global Undo/Redo Button Header --- header_layout = QHBoxLayout() header_layout.setContentsMargins(0, 0, 0, 5) @@ -28,7 +34,6 @@ def __init__(self, parent=None): self.undo_btn = QPushButton("Undo") self.redo_btn = QPushButton("Redo") - # Button Styling btn_style = """ QPushButton { background-color: #444; color: #DDD; @@ -49,15 +54,32 @@ def __init__(self, parent=None): header_layout.addStretch() header_layout.addWidget(self.undo_btn) header_layout.addWidget(self.redo_btn) - layout.addLayout(header_layout) - # ----------------------------------- - # 1. Top: Multi Head Management (Tabs) - self.annot_mgmt = AnnotationManagementWidget() + # --- 2. Main Tabs --- + self.tabs = QTabWidget() + self.tabs.setObjectName("localization_tabs") + layout.addWidget(self.tabs) + + # ========== TAB 0: Hand Annotation ========== + self.hand_widget = QWidget() + hand_layout = QVBoxLayout(self.hand_widget) + hand_layout.setContentsMargins(0, 5, 0, 0) - # 2. Bottom: Labelled Event List (Table) + # Top: Multi Head Management (Tabs for categories) + self.annot_mgmt = AnnotationManagementWidget() + # Bottom: Labelled Event List (Table for hand annotations) self.table = AnnotationTableWidget() - layout.addWidget(self.annot_mgmt, 3) - layout.addWidget(self.table, 2) \ No newline at end of file + hand_layout.addWidget(self.annot_mgmt, 2) + hand_layout.addWidget(self.table, 3) + + self.tabs.addTab(self.hand_widget, "Hand Annotation") + + # ========== TAB 1: Smart Annotation ========== + # Loads the newly created SmartSpottingWidget + self.smart_widget = SmartSpottingWidget() + self.tabs.addTab(self.smart_widget, "Smart Annotation") + + # Connect tab change signal + self.tabs.currentChanged.connect(self.tabSwitched.emit) \ No newline at end of file diff --git a/annotation_tool/ui/localization/event_editor/annotation_table.py b/annotation_tool/ui/localization/event_editor/annotation_table.py index 3f1e4712..df345ab1 100644 --- a/annotation_tool/ui/localization/event_editor/annotation_table.py +++ b/annotation_tool/ui/localization/event_editor/annotation_table.py @@ -1,6 +1,6 @@ from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QLabel, QTableView, QHeaderView, QMenu, - QAbstractItemView + QAbstractItemView, QPushButton ) from PyQt6.QtCore import pyqtSignal, Qt, QAbstractTableModel @@ -131,14 +131,27 @@ class AnnotationTableWidget(QWidget): annotationSelected = pyqtSignal(int) annotationModified = pyqtSignal(dict, dict) # old_event, new_event annotationDeleted = pyqtSignal(dict) + updateTimeForSelectedRequested = pyqtSignal(dict) def __init__(self, parent=None): super().__init__(parent) layout = QVBoxLayout(self) + + # [NEW] 1. Edit Annotation + self.edit_lbl = QLabel("Edit Annotation") + self.edit_lbl.setProperty("class", "panel_header_lbl") + layout.addWidget(self.edit_lbl) - lbl = QLabel("Events List") - lbl.setProperty("class", "panel_header_lbl") - layout.addWidget(lbl) + self.btn_set_time = QPushButton("Set to Current Video Time") + self.btn_set_time.setCursor(Qt.CursorShape.PointingHandCursor) + self.btn_set_time.setEnabled(False) + self.btn_set_time.clicked.connect(self._on_set_time_clicked) + layout.addWidget(self.btn_set_time) + + # 2. Events List + self.list_lbl = QLabel("Events List") + self.list_lbl.setProperty("class", "panel_header_lbl") + layout.addWidget(self.list_lbl) self.table = QTableView() self.table.setProperty("class", "annotation_table") @@ -166,13 +179,25 @@ def set_data(self, annotations): def set_schema(self, schema): self.current_schema = schema + def _on_selection_changed(self, selected, deselected): indexes = selected.indexes() if indexes: + self.btn_set_time.setEnabled(True) row = indexes[0].row() item = self.model.get_annotation_at(row) if item: self.annotationSelected.emit(item.get('position_ms', 0)) + else: + self.btn_set_time.setEnabled(False) + + def _on_set_time_clicked(self): + indexes = self.table.selectionModel().selectedRows() + if indexes: + row = indexes[0].row() + item = self.model.get_annotation_at(row) + if item: + self.updateTimeForSelectedRequested.emit(item) def _show_context_menu(self, pos): index = self.table.indexAt(pos) @@ -187,4 +212,4 @@ def _show_context_menu(self, pos): selected_action = menu.exec(self.table.mapToGlobal(pos)) if selected_action == act_delete: - self.annotationDeleted.emit(item) \ No newline at end of file + self.annotationDeleted.emit(item) diff --git a/annotation_tool/ui/localization/event_editor/smart_spotting.py b/annotation_tool/ui/localization/event_editor/smart_spotting.py new file mode 100644 index 00000000..e461f843 --- /dev/null +++ b/annotation_tool/ui/localization/event_editor/smart_spotting.py @@ -0,0 +1,217 @@ +# smart_spotting.py + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, + QGroupBox, QLineEdit +) +from PyQt6.QtCore import pyqtSignal, Qt + +# Reuse the existing table widget for displaying smart predictions +from .annotation_table import AnnotationTableWidget + +class TimeLineEdit(QLineEdit): + """ + Custom QLineEdit tailored for time input in the format MM:SS.mmm. + Supports free typing and using Up/Down arrow keys to increment/decrement time. + """ + timeChanged = pyqtSignal(int) # Emits the new time in milliseconds + + def __init__(self, parent=None): + super().__init__(parent) + self._ms = 0 + self.setText("00:00.000") + self.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.setStyleSheet("font-family: monospace; font-weight: bold; font-size: 13px; padding: 2px;") + self.setFixedWidth(100) + + # When user finishes typing and loses focus or hits Enter + self.editingFinished.connect(self._on_edit_finished) + + def set_time_ms(self, ms: int): + """Programmatically set the time in milliseconds.""" + self._ms = max(0, ms) + self.setText(self._fmt_ms(self._ms)) + self.timeChanged.emit(self._ms) + + def get_time_ms(self) -> int: + """Get the current time in milliseconds.""" + return self._ms + + def _fmt_ms(self, ms: int) -> str: + """Format milliseconds to MM:SS.mmm""" + s = ms // 1000 + m = s // 60 + return f"{m:02}:{s%60:02}.{ms%1000:03}" + + def _parse_time(self, text: str) -> int: + """Parse MM:SS.mmm string back to milliseconds.""" + try: + parts = text.split(':') + if len(parts) >= 2: + m = int(parts[0]) + s_parts = parts[1].split('.') + s = int(s_parts[0]) + ms = int(s_parts[1]) if len(s_parts) > 1 else 0 + return (m * 60 + s) * 1000 + ms + except Exception: + pass + return self._ms # Return the last valid time if parsing fails + + def _on_edit_finished(self): + """Validate and apply manually typed time.""" + parsed_ms = self._parse_time(self.text()) + self.set_time_ms(parsed_ms) + + def keyPressEvent(self, event): + """Intercept Up/Down arrows to adjust time dynamically.""" + if event.key() == Qt.Key.Key_Up: + self._adjust_time(1) + event.accept() + elif event.key() == Qt.Key.Key_Down: + self._adjust_time(-1) + event.accept() + else: + super().keyPressEvent(event) + + def _adjust_time(self, direction: int): + """Adjust time based on cursor position.""" + cursor = self.cursorPosition() + ms = self._ms + + # Cursor positions for MM:SS.mmm: + # <= 2: Minutes + # 3 to 5: Seconds + # >= 6: Milliseconds + if cursor <= 2: + ms += direction * 60000 # +/- 1 minute + elif cursor <= 5: + ms += direction * 1000 # +/- 1 second + else: + ms += direction * 100 # +/- 100 milliseconds for smoother scrolling + + self.set_time_ms(max(0, ms)) + self.setCursorPosition(cursor) # Restore cursor position so user can keep pressing + + +class SmartSpottingWidget(QWidget): + """ + UI for Smart Annotation in Localization mode. + Allows users to select a time range, run inference, and review predicted events + in a separate table before confirming them. + """ + # Signals to be connected to the LocalizationManager + setTimeRequested = pyqtSignal(str) # 'start' or 'end' + runInferenceRequested = pyqtSignal(int, int) # start_ms, end_ms + confirmSmartRequested = pyqtSignal() # Merge smart events to hand events + clearSmartRequested = pyqtSignal() # Clear current smart predictions + + def __init__(self, parent=None): + super().__init__(parent) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + # Internal state + self.start_ms = 0 + self.end_ms = 0 + + # --- 1. Time Range Selection Box --- + self.time_box = QGroupBox("Smart Inference Range") + self.time_box.setProperty("class", "smart_inference_box") + time_layout = QVBoxLayout(self.time_box) + + # Start Time Row + start_row = QHBoxLayout() + self.lbl_start = QLabel("Start Time:") + self.val_start = TimeLineEdit() + self.btn_set_start = QPushButton("Set to Current") + self.btn_set_start.setCursor(Qt.CursorShape.PointingHandCursor) + self.btn_set_start.clicked.connect(lambda: self.setTimeRequested.emit("start")) + self.val_start.timeChanged.connect(self._on_start_changed) + + start_row.addWidget(self.lbl_start) + start_row.addWidget(self.val_start) + start_row.addStretch() + start_row.addWidget(self.btn_set_start) + + # End Time Row + end_row = QHBoxLayout() + self.lbl_end = QLabel("End Time:") + self.val_end = TimeLineEdit() + self.btn_set_end = QPushButton("Set to Current") + self.btn_set_end.setCursor(Qt.CursorShape.PointingHandCursor) + self.btn_set_end.clicked.connect(lambda: self.setTimeRequested.emit("end")) + self.val_end.timeChanged.connect(self._on_end_changed) + + end_row.addWidget(self.lbl_end) + end_row.addWidget(self.val_end) + end_row.addStretch() + end_row.addWidget(self.btn_set_end) + + time_layout.addLayout(start_row) + time_layout.addLayout(end_row) + + # Run Button + self.btn_run_infer = QPushButton("Run Smart Inference") + self.btn_run_infer.setCursor(Qt.CursorShape.PointingHandCursor) + self.btn_run_infer.setProperty("class", "run_inference_btn") + self.btn_run_infer.clicked.connect(self._on_run_clicked) + + time_layout.addWidget(self.btn_run_infer) + layout.addWidget(self.time_box, 0) # 0 stretch means it stays at top + + # --- 2. Smart Events List (Separated from Hand Annotations) --- + self.smart_table = AnnotationTableWidget() + self.smart_table.edit_lbl.hide() + self.smart_table.btn_set_time.hide() + self.smart_table.list_lbl.setText("Predicted Events List") + + layout.addWidget(self.smart_table, 1) # 1 stretch means it fills remaining space + + # --- 3. Bottom Controls --- + bottom_row = QHBoxLayout() + self.btn_confirm = QPushButton("Confirm Predictions") + self.btn_confirm.setProperty("class", "editor_save_btn") + self.btn_confirm.setCursor(Qt.CursorShape.PointingHandCursor) + self.btn_confirm.clicked.connect(self.confirmSmartRequested.emit) + + self.btn_clear = QPushButton("Clear Predictions") + self.btn_clear.setCursor(Qt.CursorShape.PointingHandCursor) + self.btn_clear.clicked.connect(self.clearSmartRequested.emit) + + bottom_row.addWidget(self.btn_confirm) + bottom_row.addWidget(self.btn_clear) + layout.addLayout(bottom_row) + + # ==================== Logic & Validation ==================== + + def _on_start_changed(self, ms: int): + """Ensure Start Time does not exceed End Time""" + self.start_ms = ms + # If End Time is set (not 0) and Start > End, push End Time forward + if self.end_ms > 0 and self.start_ms > self.end_ms: + self.val_end.blockSignals(True) + self.val_end.set_time_ms(self.start_ms) + self.end_ms = self.start_ms + self.val_end.blockSignals(False) + + def _on_end_changed(self, ms: int): + """Ensure End Time does not drop below Start Time""" + self.end_ms = ms + # If End Time drops below Start Time, push Start Time backward + if self.end_ms > 0 and self.end_ms < self.start_ms: + self.val_start.blockSignals(True) + self.val_start.set_time_ms(self.end_ms) + self.start_ms = self.end_ms + self.val_start.blockSignals(False) + + def update_time_display(self, target: str, time_str: str, time_ms: int): + """Called by controller to update the UI with the player's current time""" + # We ignore the time_str since TimeLineEdit formats it internally + if target == "start": + self.val_start.set_time_ms(time_ms) + elif target == "end": + self.val_end.set_time_ms(time_ms) + + def _on_run_clicked(self): + """Emit the run signal with validated boundaries""" + self.runInferenceRequested.emit(self.start_ms, self.end_ms) \ No newline at end of file diff --git a/annotation_tool/ui/localization/event_editor/spotting_controls.py b/annotation_tool/ui/localization/event_editor/spotting_controls.py index 380ab99f..be1a7611 100644 --- a/annotation_tool/ui/localization/event_editor/spotting_controls.py +++ b/annotation_tool/ui/localization/event_editor/spotting_controls.py @@ -5,7 +5,6 @@ from PyQt6.QtCore import pyqtSignal, Qt # ==================== Custom Widgets ==================== - class LabelButton(QPushButton): """ Custom Label Button that supports Right-Click signal. @@ -16,9 +15,10 @@ class LabelButton(QPushButton): def __init__(self, text, parent=None): super().__init__(text, parent) - self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) self.setCursor(Qt.CursorShape.PointingHandCursor) - self.setMinimumHeight(40) + self.setMinimumHeight(28) + self.setStyleSheet("padding: 2px 10px;") self.setProperty("class", "spotting_label_btn") def mousePressEvent(self, event): @@ -50,29 +50,22 @@ def __init__(self, head_name, labels, parent=None): self.labels = labels layout = QVBoxLayout(self) - layout.setContentsMargins(5, 5, 5, 5) - layout.setSpacing(10) + layout.setContentsMargins(2, 2, 2, 2) + layout.setSpacing(5) # Time display self.time_label = QLabel("Current Time: 00:00.000") self.time_label.setProperty("class", "spotting_time_lbl") self.time_label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(self.time_label) - - # Scroll area for buttons - scroll = QScrollArea() - scroll.setWidgetResizable(True) - scroll.setFrameShape(QScrollArea.Shape.NoFrame) - scroll.setProperty("class", "spotting_scroll_area") - self.grid_container = QWidget() - self.grid_layout = QGridLayout(self.grid_container) - self.grid_layout.setSpacing(8) - self.grid_layout.setContentsMargins(0,0,0,0) - self.grid_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + # Scroll area for buttons + self.scroll = QScrollArea() + self.scroll.setWidgetResizable(True) + self.scroll.setFrameShape(QScrollArea.Shape.NoFrame) + self.scroll.setProperty("class", "spotting_scroll_area") - scroll.setWidget(self.grid_container) - layout.addWidget(scroll) + layout.addWidget(self.scroll) self._populate_grid() @@ -84,39 +77,96 @@ def refresh_labels(self, new_labels): self._populate_grid() def _populate_grid(self): - # Clear existing items - while self.grid_layout.count(): - item = self.grid_layout.takeAt(0) - if item.widget(): - item.widget().deleteLater() - - cols = 2 - row, col = 0, 0 + old_widget = self.scroll.takeWidget() + if old_widget: + old_widget.deleteLater() + + self.grid_container = QWidget() + self.grid_layout = QVBoxLayout(self.grid_container) + self.grid_layout.setSpacing(6) + self.grid_layout.setContentsMargins(0, 0, 0, 0) + self.grid_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + max_width = 360 + + #(Bin Packing) - # Add label buttons + buttons_info = [] for lbl in self.labels: display_text = lbl.replace('_', ' ') btn = LabelButton(display_text) btn.clicked.connect(lambda _, l=lbl: self.labelClicked.emit(l)) btn.rightClicked.connect(lambda l=lbl: self._show_context_menu(l)) btn.doubleClicked.connect(lambda l=lbl: self.renameLabelRequested.emit(l)) - self.grid_layout.addWidget(btn, row, col) - col += 1 - if col >= cols: - col = 0 - row += 1 + + btn.adjustSize() + btn_w = btn.sizeHint().width() + buttons_info.append((btn, btn_w)) + + buttons_info.sort(key=lambda x: x[1], reverse=True) + + rows = [] + + for btn, btn_w in buttons_info: + placed = False + for row in rows: + if row['width'] + btn_w + 6 <= max_width: + row['layout'].addWidget(btn) + row['width'] += btn_w + 6 + placed = True + break + + if not placed: + new_layout = QHBoxLayout() + new_layout.setSpacing(6) + new_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.grid_layout.addLayout(new_layout) + + new_layout.addWidget(btn) + rows.append({'layout': new_layout, 'width': btn_w}) - # Add "Add Label" button at the bottom - add_btn = QPushButton("Add new label at current time") + add_btn = QPushButton("+ Add Label to Current Time") add_btn.setCursor(Qt.CursorShape.PointingHandCursor) - add_btn.setMinimumHeight(45) - add_btn.setProperty("class", "spotting_add_btn") + add_btn.setMinimumHeight(28) + add_btn.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) + add_btn.setStyleSheet(""" + QPushButton { + padding: 2px 10px; + color: #FFFFFF; + font-weight: bold; + background-color: #007BFF; + border: none; + border-radius: 4px; + } + QPushButton:hover { + background-color: #0056b3; + } + QPushButton:pressed { + background-color: #004085; + } + """) + add_btn.clicked.connect(self.addLabelRequested.emit) + add_btn.adjustSize() + add_btn_w = add_btn.sizeHint().width() - if col != 0: - row += 1 - self.grid_layout.addWidget(add_btn, row, 0, 1, 2) + placed_add = False + for row in rows: + if row['width'] + add_btn_w + 6 <= max_width: + row['layout'].addWidget(add_btn) + row['width'] += add_btn_w + 6 + placed_add = True + break + + if not placed_add: + new_layout = QHBoxLayout() + new_layout.setSpacing(6) + new_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.grid_layout.addLayout(new_layout) + new_layout.addWidget(add_btn) + + self.scroll.setWidget(self.grid_container) def _show_context_menu(self, label): display_label = label.replace('_', ' ') diff --git a/annotation_tool/viewer.py b/annotation_tool/viewer.py index abf948cf..68b2b4e6 100644 --- a/annotation_tool/viewer.py +++ b/annotation_tool/viewer.py @@ -8,6 +8,7 @@ from controllers.classification.class_annotation_manager import AnnotationManager from controllers.classification.class_navigation_manager import NavigationManager from controllers.classification.inference_manager import InferenceManager +from controllers.classification.train_manager import TrainManager # [NEW] Import TrainManager from controllers.history_manager import HistoryManager from controllers.localization.localization_manager import LocalizationManager # Import Description Managers @@ -24,6 +25,7 @@ from utils import create_checkmark_icon, natural_sort_key, resource_path + class ActionClassifierApp(QMainWindow): """Main application window for annotation + localization + description + dense workflows.""" @@ -66,6 +68,7 @@ def __init__(self) -> None: # [NEW] Dense Description Controller self.dense_manager = DenseManager(self) self.inference_manager = InferenceManager(self) + self.train_manager = TrainManager(self) # --- Local UI state (icons, etc.) --- bright_blue = QColor("#00BFFF") @@ -766,4 +769,4 @@ def refresh_ui_after_undo_redo(self, action_path: str) -> None: else: self.annot_manager.display_manual_annotation(action_path) - self.update_save_export_button_state() + self.update_save_export_button_state() \ No newline at end of file From e22b980e4bc2a10d14d9ec64bac19a208a30c6d5 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Tue, 31 Mar 2026 08:03:38 +0200 Subject: [PATCH 62/63] Update train_manager.py Fix the bug of ./logs root --- .../controllers/classification/train_manager.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/annotation_tool/controllers/classification/train_manager.py b/annotation_tool/controllers/classification/train_manager.py index b9e8e7a0..7b69248e 100644 --- a/annotation_tool/controllers/classification/train_manager.py +++ b/annotation_tool/controllers/classification/train_manager.py @@ -62,6 +62,13 @@ def run(self): os.makedirs(checkpoint_dir, exist_ok=True) config['TRAIN']['save_dir'] = str(checkpoint_dir).replace('\\', '/') + log_dir = os.path.join(dataset_root, "logs") + os.makedirs(log_dir, exist_ok=True) + + if 'SYSTEM' not in config: + config['SYSTEM'] = {} + config['SYSTEM']['log_dir'] = str(log_dir).replace('\\', '/') + # 2. Structure adjustment # Inject annotation paths into a custom annotations block config['DATA']['annotations'] = { @@ -310,4 +317,4 @@ def _on_train_finished(self, success, message): self._append_log(f"\n✅ [SUCCESS] {message}") else: QMessageBox.critical(self.main, "Train Error", message) - self._append_log(f"\n❌ [ERROR] {message}") \ No newline at end of file + self._append_log(f"\n❌ [ERROR] {message}") From 7a8f936a4d4808e8a5e95f25f7a99db077d74e75 Mon Sep 17 00:00:00 2001 From: Jintao <48515469+woshimajintao@users.noreply.github.com> Date: Tue, 31 Mar 2026 08:57:37 +0200 Subject: [PATCH 63/63] Delete .DS_Store --- .DS_Store | Bin 10244 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index dca8ddb45f0d67bdef33867d72711e8044bad019..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10244 zcmeHNYit}>6~5oKo6L5`@z}(*v(09=ahkY^llYN`O&(pp+SqXtCu_%#B;D?MC-#u> zj=QtA<65a)As%f460ahlP>KKnKSF_~EudEAQOX-yg$N=Z0Yu?Pg#?tMPziDFomt0g zZ*Urw$Uk;gbI;s!@40jD*>lc!?%idKp?NG(%UFmprqC^@u2GTW67AxicX5v+kk>$71B*cegxTU!RF~$_5o5R_ zPsc|s(@EFVTmUa9ylKf&WvQYn$0Eb_L?j(ck2%e;)DcnZwrwk(d5*;oo5n;`U*2LT z(=jt?q@;kEFsRGXUNf1nCz|cCWWtfM5w}6{DZZ#)J~g$qwmMY1z2;PPXsV&1t~#`( zuKv_1pHf!2dB>sX32P*2Kjf|pUTk1RbJqFr!i%(qY9oX8$f%Ii>VEjdHApz)Ev{M$ zwr13FF{2jW&8k-9@9sI=tM&JpgLX1v-fu+pVuy6oPSviIF-|(^9ki{nk)#vVO9o@s z;FuLl8%>raIuozh8BE!h)nz+o+O(7XeQD!lIx|pKQ{RANr_wEQu&Az&ImRJ_#xlo^ zRvM9pDiv=#Fe-*|4jUf6N&)k_j_8)H7iw(!&Gv1trtwannfod?l#vPR2%z^Q9$vam5P1zi%vV-Zy5Z zC-q82amVrpLq0`6cQiqh*=Hw&WJ0$1{lPVgazyZ3QU;YSTk}`lK?1REhZNyWXX7TH zPj|XckhQp?T3x;3(-S>bEN)mHYkLE+_V$juEA*IUI~89aeYYbtX~*UH!%d2!fBzow z;?9_pJ}_z|XQmWxQRFOpvW+B-#~4eu2anB5esXZKvd__Xhp`elb*2B4H%N zqD%Brf0zG|qQ6Wsx|tj|$va5ru9GO0uHqT146k<|E1w^nmQPZbzU7$?9u?>qU~5?e zpWPG0#SJHsAhPqJs(bL=PV0{bod3;Qd(%>K>ZVE;h@G?bwn6ALk?n`oa?d~SJsybhgHst-nVI$LH#CjA-B3#s|xB%$bH=EhTz7a&dF=t zYE7_C(+kKaeNuL7aHFO}aVw454oxp4?{urXwN;wVd~T&V*c#M@uPJY})xX3pvP)$3 zud&Nm0u?%fsKO>Rkd5DkJ!nTK_Te!4(2pb1)`u{Rci;rl7$YL3iS5%4;(p~6KQV*#Lo(MIe^_K06n;-YGd8@o#D3jeFr6isp^{sbQw|t0T=YI zM3hV{nGf`8V0C3~pbrFAugd}SYG6ZkHqfhTO>H*N2h^?G<^X*_efy4iKp#-c?wkws zfj~>^)qtMb_RI%*;&$c+deX-J)qy}3(EBS^lm;S!p6dnrnH(%P;McHb*1_&!qim9$ zW{*g~KgXV8FR-5yX1u)ndtvwyJH*&8Ut%~+0=xE<>#4Bmk{0{ku5h87tH_hSHq zND$x;!NQmq03X16WbiwU58&hY1Rlkw@OgY$2EK3LJNPbMz>Bzm-{BH|k5}v$Dk&o}Vh{7$}yALIi(%7>-Tu^HGu=GHSMpS-Hm{ZFV@izL+V?Oe1@ zcY`2aeNzyxW<$KX0K}`=5U*Z=cy)(58{*Yf>h(bUb=vz6*vssfWbdz%y^G{lq0}}+ zDQyke_ilvIgjTd+Ke}*GCb!2B#l47=#aoox+PDuXN^MW!0ZhvT_YB@g3GPSnF|ztc z@kz4zFW`$Z(LIN!@C?2VVf{bGFL3dipgsqP%mL7h`7bJ3;Lau_&i~u4{`>!;nsa_K zc@5+>@YZSog{_g+W*RfIRi8V&IBO5leJ|bO68)xKDn+OeT?Ahq$5TZd$5(kg@0>}t thdti3OQkr;NpYg?Y5xEH$ABAt#CC&{gZ%zKzyH7T54@QZ{9OD0{{lQWyF&l~