diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..97faddd Binary files /dev/null and b/.DS_Store differ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3461e1b..c60ebc3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,8 +4,13 @@ on: push: branches: - main + - dev-jintao workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + permissions: contents: read actions: write @@ -16,13 +21,24 @@ jobs: runs-on: windows-latest defaults: run: - working-directory: Tool + working-directory: annotation_tool steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: "3.9" + 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 run: | @@ -37,21 +53,29 @@ jobs: try { pip cache purge } catch { Write-Host "pip cache purge failed (ignored)" } - name: Build exe + shell: pwsh + run: > + python -m PyInstaller --noconfirm --clean --windowed --onefile + --name "SoccerNetProAnalyzer" + --add-data "style;style" + --add-data "ui;ui" + --add-data "controllers;controllers" + --add-data "image;image" + "main.py" + + - name: Zip Windows binary (manual runs only) + if: github.event_name == 'workflow_dispatch' + shell: pwsh run: | - pyinstaller --noconfirm --clean --windowed --onefile ` - --name "SoccerNetProAnalyzer" ` - --add-data "style;style" ` - --add-data "ui;ui" ` - --add-data "ui2;ui2" ` - "main.py" - - # 为了避免你再次 hit artifact quota:只在网页端手动 Run workflow 时上传 + Move-Item -Force dist\SoccerNetProAnalyzer.exe dist\SoccerNetProAnalyzer-win.exe + Compress-Archive -Path dist\SoccerNetProAnalyzer-win.exe -DestinationPath dist\SoccerNetProAnalyzer-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: Tool/dist/SoccerNetProAnalyzer.exe + path: annotation_tool/dist/SoccerNetProAnalyzer-win.zip retention-days: 3 build-macos: @@ -59,13 +83,23 @@ jobs: runs-on: macos-latest defaults: run: - working-directory: Tool + working-directory: annotation_tool steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: "3.9" + 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 run: | @@ -73,25 +107,34 @@ jobs: pip install -r requirements.txt - name: Cleanup before PyInstaller + shell: bash run: | rm -rf build dist *.spec pip cache purge || true - - name: Build binary + - name: Build app + shell: bash + run: > + python -m PyInstaller --noconfirm --clean --windowed + --name "SoccerNetProAnalyzer" + --add-data "style:style" + --add-data "ui:ui" + --add-data "controllers:controllers" + --add-data "image:image" + "main.py" + + - name: Zip macOS app (manual runs only) + if: github.event_name == 'workflow_dispatch' + shell: bash run: | - pyinstaller --noconfirm --clean --windowed --onefile \ - --name "SoccerNetProAnalyzer" \ - --add-data "style:style" \ - --add-data "ui:ui" \ - --add-data "ui2:ui2" \ - "main.py" + ditto -c -k --sequesterRsrc --keepParent "dist/SoccerNetProAnalyzer.app" "dist/SoccerNetProAnalyzer-mac.zip" - name: Upload artifact (manual runs only) if: github.event_name == 'workflow_dispatch' uses: actions/upload-artifact@v4 with: name: SoccerNetProAnalyzer-macOS - path: Tool/dist/SoccerNetProAnalyzer + path: annotation_tool/dist/SoccerNetProAnalyzer-mac.zip retention-days: 3 build-linux: @@ -99,18 +142,28 @@ jobs: runs-on: ubuntu-latest defaults: run: - working-directory: Tool + working-directory: annotation_tool steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: "3.9" + python-version: "3.11" - name: Install system deps (Qt/OpenCV runtime) + shell: bash run: | sudo apt-get update - sudo apt-get install -y libgl1 libglib2.0-0 + 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 run: | @@ -118,23 +171,35 @@ jobs: pip install -r requirements.txt - name: Cleanup before PyInstaller + shell: bash run: | rm -rf build dist *.spec pip cache purge || true - name: Build binary + shell: bash + run: > + python -m PyInstaller --noconfirm --clean --windowed --onefile + --name "SoccerNetProAnalyzer" + --add-data "style:style" + --add-data "ui:ui" + --add-data "controllers:controllers" + --add-data "image:image" + "main.py" + + - name: Zip Linux binary (manual runs only) + if: github.event_name == 'workflow_dispatch' + shell: bash run: | - pyinstaller --noconfirm --clean --windowed --onefile \ - --name "SoccerNetProAnalyzer" \ - --add-data "style:style" \ - --add-data "ui:ui" \ - --add-data "ui2:ui2" \ - "main.py" + mv -f dist/SoccerNetProAnalyzer dist/SoccerNetProAnalyzer-linux + cd dist + zip -r SoccerNetProAnalyzer-linux.zip SoccerNetProAnalyzer-linux + cd .. - name: Upload artifact (manual runs only) if: github.event_name == 'workflow_dispatch' uses: actions/upload-artifact@v4 with: name: SoccerNetProAnalyzer-Linux - path: Tool/dist/SoccerNetProAnalyzer + path: annotation_tool/dist/SoccerNetProAnalyzer-linux.zip retention-days: 3 diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index fc07741..1a0a119 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -4,8 +4,10 @@ on: push: branches: - main + - dev-jintao workflow_dispatch: + permissions: contents: write @@ -20,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: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8188340..850b511 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,22 +16,43 @@ jobs: changelog: ${{ steps.notes.outputs.changelog }} steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - id: notes + shell: bash run: | - echo "changelog=$(git log -20 --pretty=format:'* %s')" >> $GITHUB_OUTPUT + set -euo pipefail + delim="__CHANGELOG__" + { + printf 'changelog<<%s\n' "$delim" + git log -20 --pretty=format:'* %s' || true + printf '\n%s\n' "$delim" + } >> "$GITHUB_OUTPUT" - build-windows: + create-release: needs: generate-release-notes + runs-on: ubuntu-latest + steps: + - name: Create/Update GitHub Release (body only) + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: ${{ github.ref_name }} + body: ${{ needs.generate-release-notes.outputs.changelog }} + + build-windows: + needs: create-release runs-on: windows-latest defaults: run: - working-directory: Tool + working-directory: annotation_tool steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: "3.9" + python-version: "3.11" - name: Install requirements run: | @@ -46,12 +67,14 @@ jobs: try { pip cache purge } catch { Write-Host "pip cache purge failed (ignored)" } - name: Build exe + shell: pwsh run: | - pyinstaller --noconfirm --clean --windowed --onefile ` + python -m PyInstaller --noconfirm --clean --windowed --onefile ` --name "SoccerNetProAnalyzer" ` --add-data "style;style" ` --add-data "ui;ui" ` - --add-data "ui2;ui2" ` + --add-data "controllers;controllers" ` + --add-data "image;image" ` "main.py" - name: Rename binary @@ -67,22 +90,21 @@ jobs: - name: Upload Release Asset (Windows) uses: softprops/action-gh-release@v2 with: - files: Tool/dist/SoccerNetProAnalyzer-win.zip + files: annotation_tool/dist/SoccerNetProAnalyzer-win.zip tag_name: ${{ github.ref_name }} - body: ${{ needs.generate-release-notes.outputs.changelog }} build-macos: - needs: generate-release-notes + needs: create-release runs-on: macos-latest defaults: run: - working-directory: Tool + working-directory: annotation_tool steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: "3.9" + python-version: "3.11" - name: Install requirements run: | @@ -90,53 +112,51 @@ jobs: pip install -r requirements.txt - name: Cleanup before PyInstaller + shell: bash run: | rm -rf build dist *.spec pip cache purge || true - - name: Build binary - run: | - pyinstaller --noconfirm --clean --windowed --onefile \ - --name "SoccerNetProAnalyzer" \ - --add-data "style:style" \ - --add-data "ui:ui" \ - --add-data "ui2:ui2" \ - "main.py" + - name: Build app + shell: bash + run: > + python -m PyInstaller --noconfirm --clean --windowed + --name "SoccerNetProAnalyzer" + --add-data "style:style" + --add-data "ui:ui" + --add-data "controllers:controllers" + --add-data "image:image" + "main.py" - - name: Rename binary + - name: Zip macOS app + shell: bash run: | - mv -f dist/SoccerNetProAnalyzer dist/SoccerNetProAnalyzer-mac - - - name: Zip macOS binary - run: | - cd dist - zip -r SoccerNetProAnalyzer-mac.zip SoccerNetProAnalyzer-mac - cd .. + ditto -c -k --sequesterRsrc --keepParent "dist/SoccerNetProAnalyzer.app" "dist/SoccerNetProAnalyzer-mac.zip" - name: Upload Release Asset (macOS) uses: softprops/action-gh-release@v2 with: - files: Tool/dist/SoccerNetProAnalyzer-mac.zip + files: annotation_tool/dist/SoccerNetProAnalyzer-mac.zip tag_name: ${{ github.ref_name }} - body: ${{ needs.generate-release-notes.outputs.changelog }} build-linux: - needs: generate-release-notes + needs: create-release runs-on: ubuntu-latest defaults: run: - working-directory: Tool + working-directory: annotation_tool steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: "3.9" + python-version: "3.11" - name: Install system deps (Qt/OpenCV runtime) + shell: bash run: | sudo apt-get update - sudo apt-get install -y libgl1 libglib2.0-0 + sudo apt-get install -y libgl1 libglib2.0-0 libxcb-cursor0 - name: Install requirements run: | @@ -144,24 +164,29 @@ jobs: pip install -r requirements.txt - name: Cleanup before PyInstaller + shell: bash run: | rm -rf build dist *.spec pip cache purge || true - name: Build binary - run: | - pyinstaller --noconfirm --clean --windowed --onefile \ - --name "SoccerNetProAnalyzer" \ - --add-data "style:style" \ - --add-data "ui:ui" \ - --add-data "ui2:ui2" \ - "main.py" + shell: bash + run: > + python -m PyInstaller --noconfirm --clean --windowed --onefile + --name "SoccerNetProAnalyzer" + --add-data "style:style" + --add-data "ui:ui" + --add-data "controllers:controllers" + --add-data "image:image" + "main.py" - name: Rename binary + shell: bash run: | mv -f dist/SoccerNetProAnalyzer dist/SoccerNetProAnalyzer-linux - name: Zip Linux binary + shell: bash run: | cd dist zip -r SoccerNetProAnalyzer-linux.zip SoccerNetProAnalyzer-linux @@ -170,6 +195,5 @@ jobs: - name: Upload Release Asset (Linux) uses: softprops/action-gh-release@v2 with: - files: Tool/dist/SoccerNetProAnalyzer-linux.zip + files: annotation_tool/dist/SoccerNetProAnalyzer-linux.zip tag_name: ${{ github.ref_name }} - body: ${{ needs.generate-release-notes.outputs.changelog }} diff --git a/README.md b/README.md new file mode 100644 index 0000000..4c8b24a --- /dev/null +++ b/README.md @@ -0,0 +1,369 @@ +# SoccerNetPro Analyzer (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). + +--- + +## Features + +- Open and visualize SoccerNetPro-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. + +--- + +## 🔧 Environment Setup + +We recommend using [Anaconda](https://www.anaconda.com/) or [Miniconda](https://docs.conda.io/en/latest/miniconda.html) for managing your Python environment. + +> **Note:** The GUI project lives in the `annotation_tool/` subdirectory of this repository, and dependencies are defined in `annotation_tool/requirements.txt`. + +### Step 0 – Clone the repository + +```bash +git clone https://github.com/OpenSportsLab/soccernetpro-ui.git +cd soccernetpro-ui +``` + + +### Step 1 – Create a new Conda environment + +```bash +conda create -n soccernetpro-ui python=3.9 -y +conda activate soccernetpro-ui +``` + + +### Step 2 – Install dependencies +```bash +pip install -r annotation_tool/requirements.txt +``` +--- + +## 🚀 Run the GUI +From the repository root, launch the app with: +```bash +python annotation_tool/main.py +``` +A window will open where you can load your data and start working. + + +--- + + +## 📦 Download Test Datasets + +This project provides **test datasets** for multiple tasks, including: + +- **Classification** +- **Localization** +- **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) + +> ⚠️ **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: + +1. Have access to the dataset on Hugging Face +2. Be authenticated locally using your Hugging Face account (`hf auth login`) + +--- + +### ✅ Requirements + +- Python 3.x +- `huggingface_hub` (install via `pip install huggingface_hub`) + +--- + +### 🧩 Universal Downloader (recommended) + +We provide a single script that downloads **only the files referenced by a given JSON annotation file**: + +- Downloads the JSON +- Parses `data[].inputs[].path` (and legacy `videos[].path`) +- Downloads the referenced files while preserving the repo folder structure + +Script: + +- `test_data/download_osl_hf.py` + +Common usage: + +```bash +python test_data/download_osl_hf.py \ + --url \ + --output-dir \ + --types video +```` + +`--types` controls what input types to download from `item.inputs`: + +* `video` (default) +* `video,captions` +* `video,captions,features` +* `all` (download all inputs that contain a `path`) + +Use `--dry-run` to preview and estimate total size: + +```bash +python test_data/download_osl_hf.py \ + --url \ + --output-dir \ + --types video,captions,features \ + --dry-run +``` + +### 🟦 Classification – Test Data + +**Data location (HuggingFace):** +[Classification Dataset](https://huggingface.co/datasets/OpenSportsLab/soccernetpro-classification-vars) + +This folder contains multiple action-category subfolders (e.g. `action_0`, `action_1`, …). + +#### 📥 Download via command line + +**Classification – svfouls** + +```bash +python test_data/download_osl_hf.py \ + --url https://huggingface.co/datasets/OpenSportsLab/soccernetpro-classification-vars/blob/svfouls/annotations_test.json \ + --output-dir Test_Data/Classification/svfouls +``` + +**Classification – mvfouls** + +```bash +python test_data/download_osl_hf.py \ + --url https://huggingface.co/datasets/OpenSportsLab/soccernetpro-classification-vars/blob/mvfouls/annotations_test.json \ + --output-dir Test_Data/Classification/mvfouls +``` + +### 🟩 Localization – Test Data +**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) + +Each folder (e.g., `england efl/`) contains video clips for localization testing. + +#### 📥 Download via command line + +From the repository root: + +```bash +python test_data/download_osl_hf.py \ + --url https://huggingface.co/datasets/OpenSportsLab/soccernetpro-localization-snbas/blob/224p/annotations-test.json \ + --output-dir Test_Data/Localization +``` + + +## 🟪 Description (Video Captioning) – SoccerNet-XFoul +**Dataset (Hugging Face):** +[Description Dataset](https://huggingface.co/datasets/OpenSportsLab/soccernetpro-description-xfoul) + +This dataset provides **video captioning** samples in OSL JSON format. +Each split JSON references clips under its corresponding folder: + +* `annotations_train.json` → `train/` +* `annotations_valid.json` → `valid/` +* `annotations_test.json` → `test/` + +### 📥 Download Test Split (videos only) + +```bash +python test_data/download_osl_hf.py \ + --url https://huggingface.co/datasets/OpenSportsLab/soccernetpro-description-xfoul/blob/main/annotations_test.json \ + --output-dir Test_Data/Description/XFoul \ + --types video +``` + +After download, you should have a structure like: + +``` +Test_Data/Description/XFoul/ + annotations_test.json + test/ + action_0/ + clip_0.mp4 + clip_1.mp4 + ... +``` + +--- + +## 🟧 Dense Description (Dense Video Captioning) – SoccerNetPro SNDVC + +**Dataset (Hugging Face):** +[Dense—Description Dataset](https://huggingface.co/datasets/OpenSportsLab/soccernetpro-densedescription-sndvc) + + +This dataset provides **dense captions aligned with timestamps** (half-relative), in a unified multimodal JSON format. +Each item typically references: + +* half video (`.../1_224p.mp4` or `.../2_224p.mp4`) +* raw caption file (`.../Labels-caption.json`) +* optional visual features (e.g., `features/I3D/.../*.npy`) + +### 📥 Download Test Split (videos only — recommended for GUI) + +```bash +python test_data/download_osl_hf.py \ + --url https://huggingface.co/datasets/OpenSportsLab/soccernetpro-densedescription-sndvc/blob/main/annotations-test.json \ + --output-dir Test_Data/DenseDescription/SNDVC \ + --types video +``` + +### 📥 Download Test Split (videos + raw captions + features) + +```bash +python test_data/download_osl_hf.py \ + --url https://huggingface.co/datasets/OpenSportsLab/soccernetpro-densedescription-sndvc/blob/main/annotations-test.json \ + --output-dir Test_Data/DenseDescription/SNDVC \ + --types video,captions,features +``` + +Expected structure (example): + +``` +Test_Data/DenseDescription/SNDVC/ + annotations-test.json + germany_bundesliga/ + 2014-2015/ + / + 1_224p.mp4 + 2_224p.mp4 + Labels-caption.json + features/ + I3D/ + germany_bundesliga/... + 1_224p.npy + 2_224p.npy +``` + + +--- +## 🧰 Build a standalone app (PyInstaller) + +This project can be packaged into a standalone desktop app using **PyInstaller**. +The commands below assume you run them **from the repository root**. + +> **Note:** The app bundles runtime assets from `style/`, `ui/`, and `controllers/`. +> This matches the GitHub Actions build configuration. + +--- + +### **macOS (.app)** + +```bash +cd annotation_tool + +python -m PyInstaller --noconfirm --clean --windowed \ + --name "SoccerNetProAnalyzer" \ + --add-data "style:style" \ + --add-data "ui:ui" \ + --add-data "controllers:controllers" \ + main.py +``` + +Output: + +* `annotation_tool/dist/SoccerNetProAnalyzer.app` + +--- + +### **Windows / Linux (one-file binary)** + +#### Linux + +```bash +cd annotation_tool + +python -m PyInstaller --noconfirm --clean --windowed --onefile \ + --name "SoccerNetProAnalyzer" \ + --add-data "style:style" \ + --add-data "ui:ui" \ + --add-data "controllers:controllers" \ + main.py +``` + +Output: + +* `annotation_tool/dist/SoccerNetProAnalyzer` + + +#### Windows (PowerShell) + +On Windows, the `--add-data` separator is **`;`** (not `:`). + +```powershell +cd annotation_tool + +python -m PyInstaller --noconfirm --clean --windowed --onefile ` + --name "SoccerNetProAnalyzer" ` + --add-data "style;style" ` + --add-data "ui;ui" ` + --add-data "controllers;controllers" ` + main.py +``` + +Output: + +* `annotation_tool\dist\SoccerNetProAnalyzer.exe` + +--- + +## 🤖 How executables are built (CI / GitHub Releases) + +In addition to manual PyInstaller builds, standalone executables are automatically built using GitHub Actions. + +### Release builds (GitHub Releases) + +When a version tag matching `v*` or `V*` (e.g., `v1.0.7`) is pushed, the release workflow runs: + +* Workflow: `.github/workflows/release.yml` +* Builds for: **Windows**, **macOS**, **Linux** +* Packages outputs into ZIP archives +* Uploads ZIP files as **GitHub Release assets** +* Generates release notes from recent commit messages + +The build commands in CI mirror the manual PyInstaller commands above (including bundling `style/`, `ui/`, and `controllers/`). + +### Manual build artifacts (workflow dispatch) + +There is also a standalone build workflow that can be triggered manually: + +* Workflow: `.github/workflows/CL.yml` +* Builds for: **Windows**, **macOS**, **Linux** +* On **manual run** (`workflow_dispatch`), it zips the binaries and uploads them as **Actions artifacts** (short retention) + +### CI workflows overview + +* `CL.yml`: Multi-platform build (manual artifacts on `workflow_dispatch`; also runs on pushes to selected branches) +* `release.yml`: Multi-platform build + GitHub Release publishing (triggered by version tags) +* `deploy_docs.yml`: Documentation build and deployment (MkDocs) + + +--- + + +## 📜 License + +This Soccernet Pro 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 +), 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. + + + + diff --git a/Tool/Readme.md b/Tool/Readme.md deleted file mode 100644 index af9c2e0..0000000 --- a/Tool/Readme.md +++ /dev/null @@ -1,3 +0,0 @@ -## Code of Tool - -Usually I updated it every day. diff --git a/Tool/controllers/__init__.py b/Tool/controllers/__init__.py deleted file mode 100644 index c4be28b..0000000 --- a/Tool/controllers/__init__.py +++ /dev/null @@ -1,848 +0,0 @@ -import sys -import os -import copy -import json -import datetime -import re -from PyQt6.QtWidgets import ( - QMainWindow, QFileDialog, QMessageBox, QProgressDialog, QApplication -) -from PyQt6.QtCore import Qt, QTimer, QDir -from PyQt6.QtGui import QKeySequence, QShortcut, QColor, QIcon - -from models import AppStateModel, CmdType -from ui.panels import MainWindowUI -from ui.widgets import DynamicSingleLabelGroup, DynamicMultiLabelGroup -from dialogs import FolderPickerDialog, CreateProjectDialog -from utils import resource_path, create_checkmark_icon, SINGLE_VIDEO_PREFIX, SUPPORTED_EXTENSIONS, natural_sort_key - -class ActionClassifierApp(QMainWindow): - - FILTER_ALL = 0 - FILTER_DONE = 1 - FILTER_NOT_DONE = 2 - - def __init__(self): - super().__init__() - self.setWindowTitle("SoccerNet Pro Analysis Tool") - self.setGeometry(100, 100, 1400, 900) - - # 1. Init MVC - self.ui = MainWindowUI() - self.setCentralWidget(self.ui) - self.model = AppStateModel() - - # 2. Local State - self._is_undoing_redoing = False - bright_blue = QColor("#00BFFF") - self.done_icon = create_checkmark_icon(bright_blue) - self.empty_icon = QIcon() # [修复] 初始化为空图标对象,防止闪退 - - # 3. Setup - self.connect_signals() - self._setup_shortcuts() - self.apply_stylesheet("Night") - self.ui.right_panel.manual_box.setEnabled(False) - self._setup_dynamic_ui() - - # Ensure we start at welcome screen (default behavior of UI class, but good to be explicit) - self.ui.show_welcome_view() - - # --- Setup & Signals --- - def connect_signals(self): - # Welcome Screen Connections (Connect to same logic as Left Panel) - self.ui.welcome_widget.import_btn.clicked.connect(self.import_annotations) - self.ui.welcome_widget.create_btn.clicked.connect(self.create_new_project) - - # Left Panel - self.ui.left_panel.clear_btn.clicked.connect(self.on_clear_list_clicked) - self.ui.left_panel.import_btn.clicked.connect(self.import_annotations) - self.ui.left_panel.create_btn.clicked.connect(self.create_new_project) - self.ui.left_panel.add_data_btn.clicked.connect(self._dynamic_data_import) - self.ui.left_panel.request_remove_item.connect(self.remove_single_action_item) - self.ui.left_panel.action_tree.currentItemChanged.connect(self.on_item_selected) - self.ui.left_panel.filter_combo.currentIndexChanged.connect(self.apply_action_filter) - self.ui.left_panel.undo_btn.clicked.connect(self.perform_undo) - self.ui.left_panel.redo_btn.clicked.connect(self.perform_redo) - - # Center Panel - self.ui.center_panel.play_btn.clicked.connect(self.play_video) - self.ui.center_panel.multi_view_btn.clicked.connect(self.show_all_views) - self.ui.center_panel.prev_action.clicked.connect(self.nav_prev_action) - self.ui.center_panel.prev_clip.clicked.connect(self.nav_prev_clip) - self.ui.center_panel.next_clip.clicked.connect(self.nav_next_clip) - self.ui.center_panel.next_action.clicked.connect(self.nav_next_action) - - # Right Panel - self.ui.right_panel.save_btn.clicked.connect(self.save_results_to_json) - self.ui.right_panel.export_btn.clicked.connect(self.export_results_to_json) - self.ui.right_panel.confirm_btn.clicked.connect(self.save_manual_annotation) - self.ui.right_panel.clear_sel_btn.clicked.connect(self.clear_current_manual_annotation) - self.ui.right_panel.add_head_clicked.connect(self._handle_add_label_head) - self.ui.right_panel.remove_head_clicked.connect(self._handle_remove_label_head) - self.ui.right_panel.style_mode_changed.connect(self.apply_stylesheet) - - def _setup_shortcuts(self): - self.undo_shortcut = QShortcut(QKeySequence.StandardKey.Undo, self) - self.undo_shortcut.activated.connect(self.perform_undo) - self.redo_shortcut = QShortcut(QKeySequence.StandardKey.Redo, self) - self.redo_shortcut.activated.connect(self.perform_redo) - - def apply_stylesheet(self, mode): - qss = "style.qss" if mode == "Night" else "style_day.qss" - try: - with open(resource_path(os.path.join("style", qss)), "r", encoding="utf-8") as f: - self.setStyleSheet(f.read()) - except Exception as e: - print(f"Style error: {e}") - - # --- Close Event & Project Check (Warning Mechanisms) --- - def closeEvent(self, event): - """[新增] 退出应用时的未保存警告""" - can_export = self.model.json_loaded and bool(self.model.manual_annotations) - - # 如果数据未修改,或者没有可导出的数据,直接退出 - if not self.model.is_data_dirty or not can_export: - event.accept() - return - - msg = QMessageBox(self) - msg.setWindowTitle("Unsaved Annotations") - msg.setText("Do you want to save your annotations before quitting?") - msg.setIcon(QMessageBox.Icon.Question) - - save_btn = msg.addButton("Save & Exit", QMessageBox.ButtonRole.AcceptRole) - discard_btn = msg.addButton("Discard & Exit", QMessageBox.ButtonRole.DestructiveRole) - cancel_btn = msg.addButton("Cancel", QMessageBox.ButtonRole.RejectRole) - - msg.setDefaultButton(save_btn) - msg.exec() - - clicked_button = msg.clickedButton() - - if clicked_button == save_btn: - if self.save_results_to_json(): - event.accept() - else: - event.ignore() # 保存失败或取消,阻止退出 - elif clicked_button == discard_btn: - event.accept() - else: - event.ignore() - - def check_and_close_current_project(self): - """[新增] 在打开新项目前检查当前工作区""" - if self.model.json_loaded: - msg_box = QMessageBox(self) - msg_box.setWindowTitle("Open New Project") - msg_box.setText("Opening a new project will clear the current workspace. Continue?") - msg_box.setIcon(QMessageBox.Icon.Warning) - - if self.model.is_data_dirty: - msg_box.setInformativeText("You have unsaved changes in the current project.") - - btn_yes = msg_box.addButton("Yes", QMessageBox.ButtonRole.AcceptRole) - btn_no = msg_box.addButton("No", QMessageBox.ButtonRole.RejectRole) - msg_box.setDefaultButton(btn_no) - msg_box.exec() - - if msg_box.clickedButton() == btn_yes: - return True - else: - return False - return True - - # --- UI Logic --- - def _setup_dynamic_ui(self): - self.ui.right_panel.setup_dynamic_labels(self.model.label_definitions) - self.ui.right_panel.task_label.setText(f"Task: {self.model.current_task_name}") - self._connect_dynamic_type_buttons() - - def _connect_dynamic_type_buttons(self): - for head, group in self.ui.right_panel.label_groups.items(): - try: group.add_btn.clicked.disconnect() - except: pass - try: group.remove_label_signal.disconnect() - except: pass - try: group.value_changed.disconnect() - except: pass - - group.add_btn.clicked.connect(lambda _, h=head: self.add_custom_type(h)) - group.remove_label_signal.connect(lambda lbl, h=head: self.remove_custom_type(h, lbl)) - group.value_changed.connect(self._handle_ui_selection_change) - - def update_action_item_status(self, action_path): - item = self.model.action_item_map.get(action_path) - if not item: return - is_done = (action_path in self.model.manual_annotations and bool(self.model.manual_annotations[action_path])) - item.setIcon(0, self.done_icon if is_done else self.empty_icon) - self.apply_action_filter() - - def apply_action_filter(self): - curr = self.ui.left_panel.filter_combo.currentIndex() - for path, item in self.model.action_item_map.items(): - is_done = (path in self.model.manual_annotations and bool(self.model.manual_annotations[path])) - if curr == self.FILTER_ALL: item.setHidden(False) - elif curr == self.FILTER_DONE: item.setHidden(not is_done) - elif curr == self.FILTER_NOT_DONE: item.setHidden(is_done) - - def _refresh_ui_after_undo_redo(self, action_path): - if not action_path: return - self.update_action_item_status(action_path) - item = self.model.action_item_map.get(action_path) - if item and self.ui.left_panel.action_tree.currentItem() != item: - self.ui.left_panel.action_tree.setCurrentItem(item) - - current = self._get_current_action_path() - if current == action_path: self.display_manual_annotation(action_path) - self.update_save_export_button_state() - - def update_save_export_button_state(self): - can_export = self.model.json_loaded and bool(self.model.manual_annotations) - can_save = can_export and (self.model.current_json_path is not None) and self.model.is_data_dirty - self.ui.right_panel.export_btn.setEnabled(can_export) - self.ui.right_panel.save_btn.setEnabled(can_save) - self.ui.left_panel.undo_btn.setEnabled(len(self.model.undo_stack) > 0) - self.ui.left_panel.redo_btn.setEnabled(len(self.model.redo_stack) > 0) - - # --- Action Tree Management --- - def _populate_action_tree(self): - self.ui.left_panel.action_tree.clear() - self.model.action_item_map.clear() - - sorted_list = sorted(self.model.action_item_data, key=lambda d: natural_sort_key(d.get('name', ''))) - for data in sorted_list: - item = self.ui.left_panel.add_action_item(data['name'], data['path'], data.get('source_files')) - self.model.action_item_map[data['path']] = item - - for path in self.model.action_item_map.keys(): - self.update_action_item_status(path) - self.apply_action_filter() - - def remove_single_action_item(self, item): - if not item: return - target = item if item.parent() is None else item.parent() - path = target.data(0, Qt.ItemDataRole.UserRole) - name = target.text(0) - - reply = QMessageBox.question(self, 'Remove Item', f"Remove '{name}'? Annotations will be discarded.", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) - if reply == QMessageBox.StandardButton.Yes: - if path in self.model.manual_annotations: del self.model.manual_annotations[path] - if path in self.model.action_item_map: del self.model.action_item_map[path] - if path in self.model.action_path_to_name: del self.model.action_path_to_name[path] - if path in self.model.imported_action_metadata: del self.model.imported_action_metadata[path] - self.model.action_item_data = [d for d in self.model.action_item_data if d['path'] != path] - - root = self.ui.left_panel.action_tree.invisibleRootItem() - root.removeChild(target) - self.model.is_data_dirty = True - self.update_save_export_button_state() - if self.ui.left_panel.action_tree.topLevelItemCount() == 0: - self.ui.center_panel.show_single_view(None) - self.ui.right_panel.manual_box.setEnabled(False) - - def on_clear_list_clicked(self): - if not self.model.json_loaded and not self.model.action_item_data: return - msg = QMessageBox(self) - msg.setWindowTitle("Clear Workspace") - msg.setText("Clear workspace? Unsaved changes will be lost.") - msg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel) - if msg.exec() == QMessageBox.StandardButton.Yes: - self.clear_action_list(clear_working_dir=True, full_reset=True) - - def clear_action_list(self, clear_working_dir=True, full_reset=False): - self.ui.left_panel.action_tree.clear() - self.model.reset(full_reset) - self.update_save_export_button_state() - self.ui.right_panel.manual_box.setEnabled(False) - self.ui.center_panel.show_single_view(None) - if full_reset: - self._setup_dynamic_ui() - # If we fully reset (close project), go back to Welcome Screen - self.ui.show_welcome_view() - - # --- Data Operations (Import/Export) --- - def import_annotations(self): - # [修改] 使用新的项目检查逻辑 - if not self.check_and_close_current_project(): return - - file_path, _ = QFileDialog.getOpenFileName(self, "Select JSON", "", "JSON Files (*.json)") - if not file_path: return - - try: - with open(file_path, 'r', encoding='utf-8') as f: data = json.load(f) - except Exception as e: - QMessageBox.critical(self, "Error", f"Invalid JSON: {e}"); return - - valid, err, warn = self.model.validate_gac_json(data) - if not valid: - QMessageBox.critical(self, "JSON Error", err); return - if warn: - QMessageBox.warning(self, "Warnings", warn) - - self.clear_action_list(clear_working_dir=False, full_reset=True) - self.model.current_working_directory = os.path.dirname(file_path) - self.model.current_task_name = data.get('task', "N/A") - self.model.modalities = data.get('modalities', []) - - # Parse Labels - self.model.label_definitions = {} - if 'labels' in data: - for k, v in data['labels'].items(): - clean_k = k.strip().replace(' ', '_').lower() - self.model.label_definitions[clean_k] = {'type': v['type'], 'labels': sorted(list(set(v.get('labels', []))))} - self._setup_dynamic_ui() - - # Parse Data - count = 0 - for item in data.get('data', []): - aid = item.get('id') - if not aid: continue - - src_files = [] - for inp in item.get('inputs', []): - p = inp.get('path', '') - fp = p if os.path.isabs(p) else os.path.normpath(os.path.join(self.model.current_working_directory, p)) - src_files.append(fp) - self.model.imported_input_metadata[(aid, os.path.basename(fp))] = inp.get('metadata', {}) - - self.model.action_item_data.append({'name': aid, 'path': aid, 'source_files': src_files}) - self.model.action_path_to_name[aid] = aid - self.model.imported_action_metadata[aid] = item.get('metadata', {}) - - # Parse Manual Annotations - lbls = item.get('labels', {}) - manual = {} - has_l = False - for h, content in lbls.items(): - ck = h.strip().replace(' ', '_').lower() - if ck in self.model.label_definitions: - defn = self.model.label_definitions[ck] - if isinstance(content, dict): - if defn['type'] == 'single_label' and content.get('label') in defn['labels']: - manual[ck] = content.get('label'); has_l = True - elif defn['type'] == 'multi_label': - vals = [x for x in content.get('labels', []) if x in defn['labels']] - if vals: manual[ck] = vals; has_l = True - if has_l: - self.model.manual_annotations[aid] = manual - count += 1 - - self.model.current_json_path = file_path - self.model.json_loaded = True - self._populate_action_tree() - self.update_save_export_button_state() - self._show_temp_msg("Imported", f"Loaded {len(self.model.action_item_data)} items.") - - # Switch to Main View on success - self.ui.show_main_view() - - def create_new_project(self): - # [修改] 使用新的项目检查逻辑 - if not self.check_and_close_current_project(): return - - dlg = CreateProjectDialog(self) - if dlg.exec(): - self.clear_action_list(clear_working_dir=False, full_reset=True) - data = dlg.get_data() - self.model.current_task_name = data['task'] - self.model.modalities = data['modalities'] - self.model.label_definitions = data['labels'] - self.model.project_description = data['description'] - self.model.json_loaded = True - self.model.is_data_dirty = True - self._setup_dynamic_ui() - self.update_save_export_button_state() - - # Switch to Main View on success - self.ui.show_main_view() - - def _dynamic_data_import(self): - if not self.model.json_loaded: - QMessageBox.warning(self, "Warning", "Please import/create project first."); return - - has_vid = 'video' in self.model.modalities - has_others = any(x in ['image', 'audio'] for x in self.model.modalities) - - if has_vid and not has_others: - self._import_video_files_only() - elif has_vid and has_others: - self._import_multi_modal() - else: - QMessageBox.warning(self, "Warning", "Unsupported modality combination.") - - def _import_video_files_only(self): - start = self.model.current_working_directory or "" - files, _ = QFileDialog.getOpenFileNames(self, "Select Video", start, "Video (*.mp4 *.avi *.mov)") - if not files: return - - # Calc counter - ctr = 1 - for name in self.model.action_path_to_name.values(): - if name.startswith(SINGLE_VIDEO_PREFIX): - try: ctr = max(ctr, int(name.split('_')[-1]) + 1) - except: pass - - added = 0 - for fp in files: - aid = f"{SINGLE_VIDEO_PREFIX}{ctr:03d}" - self.model.action_item_data.append({'name': aid, 'path': aid, 'source_files': [fp]}) - self.model.action_path_to_name[aid] = aid - added += 1; ctr += 1 - - if added: - self._populate_action_tree() - self.model.is_data_dirty = True - self.update_save_export_button_state() - - def _import_multi_modal(self): - dlg = FolderPickerDialog(self.model.current_working_directory or "", self) - if dlg.exec(): - dirs = dlg.get_selected_paths() - if dirs: self._process_dirs(dirs) - - def _process_dirs(self, dirs): - if not self.model.current_working_directory and dirs: - self.model.current_working_directory = os.path.dirname(dirs[0]) - - prog = QProgressDialog("Importing...", "Cancel", 0, len(dirs), self) - prog.setWindowModality(Qt.WindowModality.WindowModal) - prog.show() - - added = False - for i, d in enumerate(dirs): - if prog.wasCanceled(): break - prog.setValue(i) - - srcs = [] - try: - for e in os.scandir(d): - if e.is_file() and e.name.lower().endswith(SUPPORTED_EXTENSIONS): - srcs.append(e.path) - except: continue - - if not srcs: continue - srcs.sort() - - try: rel = os.path.relpath(d, self.model.current_working_directory).replace(os.sep, '/') - except: rel = os.path.basename(d) - - self.model.action_item_data.append({'name': rel, 'path': rel, 'source_files': srcs}) - self.model.action_path_to_name[rel] = rel - added = True - - prog.close() - if added: - self._populate_action_tree() - self.model.is_data_dirty = True - self.update_save_export_button_state() - - # --- Save & Export Methods (Updated for boolean return) --- - def save_results_to_json(self): - if self.model.current_json_path: - return self._write_gac_json(self.model.current_json_path) - else: - return self.export_results_to_json() - - def export_results_to_json(self): - path, _ = QFileDialog.getSaveFileName(self, "Save JSON", "", "JSON (*.json)") - if path: - result = self._write_gac_json(path) - if result: - self.model.current_json_path = path - self.update_save_export_button_state() - return result - return False - - def _write_gac_json(self, path): - out = { - "version": "2.0", - "date": datetime.datetime.now().isoformat().split('T')[0], - "task": self.model.current_task_name, - "description": self.model.project_description, - "modalities": self.model.modalities, - "labels": self.model.label_definitions, - "data": [] - } - - root = self.ui.left_panel.action_tree.invisibleRootItem() - path_map = {} - for i in range(root.childCount()): - it = root.child(i) - path_map[it.data(0, Qt.ItemDataRole.UserRole)] = it - - sorted_keys = sorted(self.model.action_path_to_name.keys(), - key=lambda k: natural_sort_key(self.model.action_path_to_name.get(k, ""))) - - json_dir = os.path.dirname(path) - - for k in sorted_keys: - name = self.model.action_path_to_name.get(k) - if not name: continue - - man = self.model.manual_annotations.get(k, {}) - labels_out = {} - for head, dfn in self.model.label_definitions.items(): - if dfn['type'] == 'single_label': - if man.get(head): labels_out[head] = {"label": man[head]} - elif dfn['type'] == 'multi_label': - if man.get(head): labels_out[head] = {"labels": man[head]} - - inps = [] - item = path_map.get(k) - if item: - for j in range(item.childCount()): - clip = item.child(j) - abs_p = clip.data(0, Qt.ItemDataRole.UserRole) - bn = os.path.basename(abs_p) - ext = os.path.splitext(bn)[1].lower() - - mtype = "unknown" - if ext in ('.mp4', '.avi', '.mov'): mtype = "video" - elif ext in ('.jpg', '.png'): mtype = "image" - elif ext in ('.wav', '.mp3'): mtype = "audio" - - try: rel = os.path.relpath(abs_p, json_dir).replace(os.sep, '/') - except: rel = abs_p - - iobj = {"type": mtype, "path": rel} - meta = self.model.imported_input_metadata.get((k, bn)) - if meta: iobj["metadata"] = meta - inps.append(iobj) - - entry = { - "id": name, - "inputs": inps, - "labels": labels_out, - "metadata": self.model.imported_action_metadata.get(k, {}) - } - out["data"].append(entry) - - try: - with open(path, 'w', encoding='utf-8') as f: json.dump(out, f, indent=2, ensure_ascii=False) - self.model.is_data_dirty = False - self.update_save_export_button_state() - self._show_temp_msg("Saved", f"Saved to {os.path.basename(path)}") - return True # [修复] 返回 True - except Exception as e: - QMessageBox.critical(self, "Error", f"Save failed: {e}") - return False # [修复] 返回 False - - # --- Interaction Logic --- - def on_item_selected(self, current, _): - if not current: - self.ui.right_panel.manual_box.setEnabled(False) - return - - is_action = (current.childCount() > 0 or current.parent() is None) - path = None - - if is_action: - path = current.data(0, Qt.ItemDataRole.UserRole) - media = None - if current.childCount() > 0: - media = current.child(0).data(0, Qt.ItemDataRole.UserRole) - self.ui.center_panel.show_single_view(media) - self.ui.center_panel.multi_view_btn.setEnabled(True) - else: - media = current.data(0, Qt.ItemDataRole.UserRole) - self.ui.center_panel.show_single_view(media) - if current.parent(): - path = current.parent().data(0, Qt.ItemDataRole.UserRole) - self.ui.center_panel.multi_view_btn.setEnabled(False) - - can_annotate = (path is not None) and self.model.json_loaded - self.ui.right_panel.manual_box.setEnabled(can_annotate) - if path: self.display_manual_annotation(path) - - def display_manual_annotation(self, path): - self._is_undoing_redoing = True - data = self.model.manual_annotations.get(path, {}) - self.ui.right_panel.set_annotation(data) - self._is_undoing_redoing = False - - def save_manual_annotation(self): - path = self._get_current_action_path() - if not path: return - - raw = self.ui.right_panel.get_annotation() - cleaned = {k: v for k, v in raw.items() if v} - if not cleaned: cleaned = None - - old = copy.deepcopy(self.model.manual_annotations.get(path)) - self.model.push_undo(CmdType.ANNOTATION_CONFIRM, path=path, old_data=old, new_data=cleaned) - - if cleaned: - self.model.manual_annotations[path] = cleaned - self._show_temp_msg("Saved", "Annotation saved.", 1000) - else: - if path in self.model.manual_annotations: del self.model.manual_annotations[path] - self._show_temp_msg("Cleared", "Annotation cleared.", 1000) - - self.update_action_item_status(path) - self.update_save_export_button_state() - - # Auto Jump - tree = self.ui.left_panel.action_tree - curr = tree.currentItem() - nxt = tree.itemBelow(curr) - if nxt: - QTimer.singleShot(500, lambda: [tree.setCurrentItem(nxt), tree.scrollToItem(nxt)]) - - def clear_current_manual_annotation(self): - path = self._get_current_action_path() - if not path: return - - old = copy.deepcopy(self.model.manual_annotations.get(path)) - if old: - self.model.push_undo(CmdType.ANNOTATION_CONFIRM, path=path, old_data=old, new_data=None) - if path in self.model.manual_annotations: del self.model.manual_annotations[path] - self.update_action_item_status(path) - self.update_save_export_button_state() - self._show_temp_msg("Cleared", "Selection cleared.") - self.ui.right_panel.clear_selection() - - def _get_current_action_path(self): - curr = self.ui.left_panel.action_tree.currentItem() - if not curr: return None - if curr.parent() is None: return curr.data(0, Qt.ItemDataRole.UserRole) - return curr.parent().data(0, Qt.ItemDataRole.UserRole) - - def play_video(self): self.ui.center_panel.toggle_play_pause() - def show_all_views(self): - curr = self.ui.left_panel.action_tree.currentItem() - if not curr: return - if curr.parent(): curr = curr.parent() - paths = [curr.child(i).data(0, Qt.ItemDataRole.UserRole) for i in range(curr.childCount())] - self.ui.center_panel.show_all_views([p for p in paths if p.lower().endswith(SUPPORTED_EXTENSIONS[:3])]) - - # --- Navigation --- - def nav_prev_action(self): self._nav_tree(step=-1, level='top') - def nav_next_action(self): self._nav_tree(step=1, level='top') - def nav_prev_clip(self): self._nav_tree(step=-1, level='child') - def nav_next_clip(self): self._nav_tree(step=1, level='child') - - def _nav_tree(self, step, level): - tree = self.ui.left_panel.action_tree - curr = tree.currentItem() - if not curr: return - - if level == 'top': - item = curr if curr.parent() is None else curr.parent() - idx = tree.indexOfTopLevelItem(item) - new_idx = idx + step - if 0 <= new_idx < tree.topLevelItemCount(): - nxt = tree.topLevelItem(new_idx) - tree.setCurrentItem(nxt); tree.scrollToItem(nxt) - else: - parent = curr.parent() - if not parent: - if step == 1 and curr.childCount() > 0: - nxt = curr.child(0) - tree.setCurrentItem(nxt); tree.scrollToItem(nxt) - else: - idx = parent.indexOfChild(curr) - new_idx = idx + step - if 0 <= new_idx < parent.childCount(): - nxt = parent.child(new_idx) - tree.setCurrentItem(nxt); tree.scrollToItem(nxt) - - # --- Dynamic Schema Editing --- - def _handle_add_label_head(self, name): - clean = name.strip().replace(' ', '_').lower() - if not clean or clean in self.model.label_definitions: return - - msg = QMessageBox(self); msg.setText(f"Type for '{name}'?") - b1 = msg.addButton("Single Label", QMessageBox.ButtonRole.ActionRole) - b2 = msg.addButton("Multi Label", QMessageBox.ButtonRole.ActionRole) - msg.addButton("Cancel", QMessageBox.ButtonRole.RejectRole) - msg.exec() - - type_str = "single_label" if msg.clickedButton() == b1 else "multi_label" if msg.clickedButton() == b2 else None - if not type_str: return - - defn = {"type": type_str, "labels": []} - self.model.push_undo(CmdType.SCHEMA_ADD_CAT, head=clean, definition=defn) - self.model.label_definitions[clean] = defn - self.ui.right_panel.new_head_edit.clear() - self._setup_dynamic_ui() - - def _handle_remove_label_head(self, head): - if head not in self.model.label_definitions: return - if QMessageBox.question(self, "Remove", f"Remove '{head}'?") == QMessageBox.StandardButton.No: return - - affected = {} - for k, v in self.model.manual_annotations.items(): - if head in v: affected[k] = copy.deepcopy(v[head]) - - self.model.push_undo(CmdType.SCHEMA_DEL_CAT, head=head, definition=copy.deepcopy(self.model.label_definitions[head]), affected_data=affected) - - del self.model.label_definitions[head] - for k in affected: - del self.model.manual_annotations[k][head] - if not self.model.manual_annotations[k]: del self.model.manual_annotations[k] - self.update_action_item_status(k) - - self._setup_dynamic_ui() - self.display_manual_annotation(self._get_current_action_path()) - - def add_custom_type(self, head): - group = self.ui.right_panel.label_groups.get(head) - txt = group.input_field.text().strip() - if not txt: return - - labels = self.model.label_definitions[head]['labels'] - if any(l.lower() == txt.lower() for l in labels): - self._show_temp_msg("Duplicate", "Label exists.", icon=QMessageBox.Icon.Warning) - return - - self.model.push_undo(CmdType.SCHEMA_ADD_LBL, head=head, label=txt) - labels.append(txt); labels.sort() - if isinstance(group, DynamicSingleLabelGroup): group.update_radios(labels) - else: group.update_checkboxes(labels) - group.input_field.clear() - - def remove_custom_type(self, head, lbl): - defn = self.model.label_definitions[head] - if len(defn['labels']) <= 1: return - - affected = {} - for k, v in self.model.manual_annotations.items(): - if defn['type'] == 'single_label' and v.get(head) == lbl: affected[k] = lbl - elif defn['type'] == 'multi_label' and lbl in v.get(head, []): affected[k] = copy.deepcopy(v[head]) - - self.model.push_undo(CmdType.SCHEMA_DEL_LBL, head=head, label=lbl, affected_data=affected) - - if lbl in defn['labels']: defn['labels'].remove(lbl) - - for k, val in self.model.manual_annotations.items(): - if defn['type'] == 'single_label' and val.get(head) == lbl: val[head] = None - elif defn['type'] == 'multi_label' and lbl in val.get(head, []): val[head].remove(lbl) - - # UI Refresh - group = self.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._get_current_action_path()) - - def _handle_ui_selection_change(self, head, new_val): - if self._is_undoing_redoing: return - path = self._get_current_action_path() - if not path: return - - # Find old val from undo stack or current data - old_val = self.model.manual_annotations.get(path, {}).get(head) - for cmd in reversed(self.model.undo_stack): - if cmd['type'] == CmdType.UI_CHANGE and cmd['path'] == path and cmd['head'] == head: - old_val = cmd['new_val']; break - - self.model.push_undo(CmdType.UI_CHANGE, path=path, head=head, old_val=old_val, new_val=new_val) - - # --- Undo/Redo Exec --- - def perform_undo(self): - if not self.model.undo_stack: return - self._is_undoing_redoing = True - cmd = self.model.undo_stack.pop() - self.model.redo_stack.append(cmd) - self._apply_state_change(cmd, is_undo=True) - self.update_save_export_button_state() - self._is_undoing_redoing = False - - def perform_redo(self): - if not self.model.redo_stack: return - self._is_undoing_redoing = True - cmd = self.model.redo_stack.pop() - self.model.undo_stack.append(cmd) - self._apply_state_change(cmd, is_undo=False) - self.update_save_export_button_state() - self._is_undoing_redoing = False - - def _apply_state_change(self, cmd, is_undo): - ctype = cmd['type'] - - if ctype == CmdType.ANNOTATION_CONFIRM: - path = cmd['path'] - data = cmd['old_data'] if is_undo else cmd['new_data'] - if data is None: - if path in self.model.manual_annotations: del self.model.manual_annotations[path] - else: self.model.manual_annotations[path] = copy.deepcopy(data) - self._refresh_ui_after_undo_redo(path) - - elif ctype == CmdType.UI_CHANGE: - path = cmd['path'] - if self._get_current_action_path() == path: - val = cmd['old_val'] if is_undo else cmd['new_val'] - grp = self.ui.right_panel.label_groups.get(cmd['head']) - if isinstance(grp, DynamicSingleLabelGroup): grp.set_checked_label(val) - else: grp.set_checked_labels(val) - - elif ctype == CmdType.SCHEMA_ADD_CAT: - head = cmd['head'] - if is_undo: - del self.model.label_definitions[head] - else: - self.model.label_definitions[head] = cmd['definition'] - self._setup_dynamic_ui() - self._refresh_ui_after_undo_redo(self._get_current_action_path()) - - elif ctype == CmdType.SCHEMA_DEL_CAT: - head = cmd['head'] - if is_undo: - self.model.label_definitions[head] = cmd['definition'] - for k, v in cmd['affected_data'].items(): - if k not in self.model.manual_annotations: self.model.manual_annotations[k] = {} - self.model.manual_annotations[k][head] = v - else: - del self.model.label_definitions[head] - for k in cmd['affected_data']: - if head in self.model.manual_annotations.get(k, {}): del self.model.manual_annotations[k][head] - self._setup_dynamic_ui() - self._refresh_ui_after_undo_redo(self._get_current_action_path()) - - elif ctype == CmdType.SCHEMA_ADD_LBL: - head = cmd['head']; lbl = cmd['label'] - lst = self.model.label_definitions[head]['labels'] - if is_undo: - if lbl in lst: lst.remove(lbl) - else: - if lbl not in lst: lst.append(lbl); lst.sort() - - grp = self.ui.right_panel.label_groups.get(head) - if isinstance(grp, DynamicSingleLabelGroup): grp.update_radios(lst) - else: grp.update_checkboxes(lst) - - elif ctype == CmdType.SCHEMA_DEL_LBL: - head = cmd['head']; lbl = cmd['label'] - lst = self.model.label_definitions[head]['labels'] - affected = cmd['affected_data'] - - if is_undo: - if lbl not in lst: lst.append(lbl); lst.sort() - for k, v in affected.items(): - if k not in self.model.manual_annotations: self.model.manual_annotations[k] = {} - if self.model.label_definitions[head]['type'] == 'single_label': - self.model.manual_annotations[k][head] = v - else: - cur = self.model.manual_annotations[k].get(head, []) - if lbl not in cur: cur.append(lbl) - self.model.manual_annotations[k][head] = cur - else: - if lbl in lst: lst.remove(lbl) - for k in affected: - anno = self.model.manual_annotations.get(k, {}) - if self.model.label_definitions[head]['type'] == 'single_label': - if anno.get(head) == lbl: anno[head] = None - else: - if lbl in anno.get(head, []): anno[head].remove(lbl) - - grp = self.ui.right_panel.label_groups.get(head) - if isinstance(grp, DynamicSingleLabelGroup): grp.update_radios(lst) - else: grp.update_checkboxes(lst) - self._refresh_ui_after_undo_redo(self._get_current_action_path()) - - def _show_temp_msg(self, title, msg, duration=1500, icon=QMessageBox.Icon.Information): - m = QMessageBox(self); m.setWindowTitle(title); m.setText(msg); m.setIcon(icon) - m.setStandardButtons(QMessageBox.StandardButton.NoButton) - QTimer.singleShot(duration, m.accept) - m.exec() \ No newline at end of file diff --git a/Tool/controllers/classification/navigation_manager.py b/Tool/controllers/classification/navigation_manager.py deleted file mode 100644 index a8a7fcf..0000000 --- a/Tool/controllers/classification/navigation_manager.py +++ /dev/null @@ -1,161 +0,0 @@ -import os -from PyQt6.QtWidgets import QMessageBox, QFileDialog -from PyQt6.QtCore import Qt -from utils import SUPPORTED_EXTENSIONS - -class NavigationManager: - def __init__(self, main_window): - self.main = main_window - self.model = main_window.model - self.ui = main_window.ui - - def add_items_via_dialog(self): - """ - [新增] 允许用户在 Classification 模式下手动添加视频/图片数据。 - """ - if not self.model.json_loaded: - QMessageBox.warning(self.main, "Warning", "Please create or load a project first.") - return - - # 1. 准备文件过滤器 - filters = "Media Files (*.mp4 *.avi *.mov *.mkv *.jpg *.jpeg *.png *.bmp);;All Files (*)" - - # 2. 确定起始路径 - start_dir = self.model.current_working_directory or "" - - # 3. 弹出选择框 - files, _ = QFileDialog.getOpenFileNames(self.main, "Select Data to Add", start_dir, filters) - if not files: return - - # 如果是新建项目且还没有设置工作目录,以第一个文件的目录为准 - if not self.model.current_working_directory: - self.model.current_working_directory = os.path.dirname(files[0]) - - added_count = 0 - 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) - - # 构建 Classification 需要的数据结构 - new_item = { - 'name': name, - 'path': file_path, - 'source_files': [file_path] - } - - self.model.action_item_data.append(new_item) - self.model.action_path_to_name[file_path] = name - - # 初始化元数据占位 - if file_path not in self.model.imported_action_metadata: - self.model.imported_action_metadata[file_path] = {} - - added_count += 1 - - # 4. 刷新界面 - if added_count > 0: - self.model.is_data_dirty = True - self.main.populate_action_tree() - self.main.update_save_export_button_state() - self.main.show_temp_msg("Added", f"Added {added_count} items.") - - def on_item_selected(self, current, _): - if not current: - self.ui.right_panel.manual_box.setEnabled(False) - return - - is_action = (current.childCount() > 0 or current.parent() is None) - path = None - - if is_action: - path = current.data(0, Qt.ItemDataRole.UserRole) - media = None - if current.childCount() > 0: - media = current.child(0).data(0, Qt.ItemDataRole.UserRole) - self.ui.center_panel.show_single_view(media) - self.ui.center_panel.multi_view_btn.setEnabled(True) - else: - media = current.data(0, Qt.ItemDataRole.UserRole) - self.ui.center_panel.show_single_view(media) - if current.parent(): - path = current.parent().data(0, Qt.ItemDataRole.UserRole) - self.ui.center_panel.multi_view_btn.setEnabled(False) - - can_annotate = (path is not None) and self.model.json_loaded - self.ui.right_panel.manual_box.setEnabled(can_annotate) - if path: - self.main.annot_manager.display_manual_annotation(path) - - def remove_single_action_item(self, item): - if not item: return - target = item if item.parent() is None else item.parent() - path = target.data(0, Qt.ItemDataRole.UserRole) - name = target.text(0) - - reply = QMessageBox.question(self.main, 'Remove Item', f"Remove '{name}'? Annotations will be discarded.", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) - if reply == QMessageBox.StandardButton.Yes: - if path in self.model.manual_annotations: del self.model.manual_annotations[path] - if path in self.model.action_item_map: del self.model.action_item_map[path] - if path in self.model.action_path_to_name: del self.model.action_path_to_name[path] - if path in self.model.imported_action_metadata: del self.model.imported_action_metadata[path] - self.model.action_item_data = [d for d in self.model.action_item_data if d['path'] != path] - - root = self.ui.left_panel.action_tree.invisibleRootItem() - root.removeChild(target) - self.model.is_data_dirty = True - self.main.update_save_export_button_state() - if self.ui.left_panel.action_tree.topLevelItemCount() == 0: - self.ui.center_panel.show_single_view(None) - self.ui.right_panel.manual_box.setEnabled(False) - - def apply_action_filter(self): - curr = self.ui.left_panel.filter_combo.currentIndex() - for path, item in self.model.action_item_map.items(): - is_done = (path in self.model.manual_annotations and bool(self.model.manual_annotations[path])) - if curr == self.main.FILTER_ALL: item.setHidden(False) - elif curr == self.main.FILTER_DONE: item.setHidden(not is_done) - elif curr == self.main.FILTER_NOT_DONE: item.setHidden(is_done) - - def play_video(self): - self.ui.center_panel.toggle_play_pause() - - def show_all_views(self): - curr = self.ui.left_panel.action_tree.currentItem() - if not curr: return - if curr.parent(): curr = curr.parent() - paths = [curr.child(i).data(0, Qt.ItemDataRole.UserRole) for i in range(curr.childCount())] - self.ui.center_panel.show_all_views([p for p in paths if p.lower().endswith(SUPPORTED_EXTENSIONS[:3])]) - - def nav_prev_action(self): self._nav_tree(step=-1, level='top') - def nav_next_action(self): self._nav_tree(step=1, level='top') - def nav_prev_clip(self): self._nav_tree(step=-1, level='child') - def nav_next_clip(self): self._nav_tree(step=1, level='child') - - def _nav_tree(self, step, level): - tree = self.ui.left_panel.action_tree - curr = tree.currentItem() - if not curr: return - - if level == 'top': - item = curr if curr.parent() is None else curr.parent() - idx = tree.indexOfTopLevelItem(item) - new_idx = idx + step - if 0 <= new_idx < tree.topLevelItemCount(): - nxt = tree.topLevelItem(new_idx) - tree.setCurrentItem(nxt); tree.scrollToItem(nxt) - else: - parent = curr.parent() - if not parent: - if step == 1 and curr.childCount() > 0: - nxt = curr.child(0) - tree.setCurrentItem(nxt); tree.scrollToItem(nxt) - else: - idx = parent.indexOfChild(curr) - new_idx = idx + step - if 0 <= new_idx < parent.childCount(): - nxt = parent.child(new_idx) - tree.setCurrentItem(nxt); tree.scrollToItem(nxt) diff --git a/Tool/controllers/classification/readme.md b/Tool/controllers/classification/readme.md deleted file mode 100644 index 4fc425e..0000000 --- a/Tool/controllers/classification/readme.md +++ /dev/null @@ -1 +0,0 @@ -The controller function for Classification part diff --git a/Tool/controllers/localization/loc_file_manager.py b/Tool/controllers/localization/loc_file_manager.py deleted file mode 100644 index 946a6f8..0000000 --- a/Tool/controllers/localization/loc_file_manager.py +++ /dev/null @@ -1,271 +0,0 @@ -import os -import json -from PyQt6.QtWidgets import QFileDialog, QMessageBox -from PyQt6.QtCore import QUrl -from utils import natural_sort_key -from dialogs import CreateProjectDialog # [导入] - -class LocFileManager: - def __init__(self, main_window): - self.main = main_window - self.model = main_window.model - self.ui = main_window.ui - - def create_new_project(self): - """ - [新增] 创建新的 Localization 项目 - """ - # 1. 检查当前项目是否需要保存 - if not self.main.check_and_close_current_project(): - return - - # 2. 弹出创建对话框,指定类型为 localization - dlg = CreateProjectDialog(self.main, project_type="localization") - - if dlg.exec(): - # 3. 清空现有工作区 - self._clear_workspace(full_reset=True) - - # 4. 获取用户配置 - data = dlg.get_data() - - # 5. 初始化 Model - self.model.current_task_name = data['task'] - self.model.project_description = data['description'] - self.model.modalities = data['modalities'] - self.model.label_definitions = data['labels'] - - # 设置 Localization 模式下的状态 - self.model.current_working_directory = None - self.model.current_json_path = None - self.model.json_loaded = True - self.model.is_data_dirty = True - - # 6. 刷新 Localization UI - # 更新右侧 Schema - self.main.loc_manager.right_panel.annot_mgmt.update_schema(self.model.label_definitions) - - # 自动选择第一个 Head - if self.model.label_definitions: - first_head = list(self.model.label_definitions.keys())[0] - self.main.loc_manager.current_head = first_head - self.main.loc_manager.right_panel.annot_mgmt.tabs.set_current_head(first_head) - - # 刷新左侧树 (空) - self.main.loc_manager.populate_tree() - - # 7. 切换视图 - self.main.ui.show_localization_view() - self.main.update_save_export_button_state() - - self.main.show_temp_msg("Project Created", f"Task: {self.model.current_task_name}") - - def load_project(self, data, file_path): - """ - 加载 Localization 项目。 - 返回: Boolean (True 表示加载成功,False 表示失败或取消) - """ - if hasattr(self.model, 'validate_loc_json'): - is_valid, error_msg, warning_msg = self.model.validate_loc_json(data) - - if not is_valid: - if len(error_msg) > 800: error_msg = error_msg[:800] + "\n... (truncated)" - QMessageBox.critical(self.main, "Validation Error", f"Critical errors found in JSON. Load aborted.\n\n{error_msg}") - return False - - if warning_msg: - if len(warning_msg) > 800: warning_msg = warning_msg[:800] + "\n... (truncated)" - res = QMessageBox.warning( - self.main, - "Validation Warnings", - f"The file contains warnings:\n\n{warning_msg}\n\nDo you want to continue loading?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No - ) - if res != QMessageBox.StandardButton.Yes: - return False - - self._clear_workspace(full_reset=True) - - project_root = os.path.dirname(os.path.abspath(file_path)) - self.model.current_working_directory = project_root - - self.model.current_task_name = data.get('dataset_name', data.get('task', "Localization Task")) - self.model.modalities = data.get('modalities', ['video']) - - if 'labels' in data: - self.model.label_definitions = data['labels'] - self.main.loc_manager.right_panel.annot_mgmt.update_schema(self.model.label_definitions) - - default_head = None - if "ball_action" in self.model.label_definitions: - default_head = "ball_action" - elif "action" in self.model.label_definitions: - default_head = "action" - elif list(self.model.label_definitions.keys()): - default_head = list(self.model.label_definitions.keys())[0] - - if default_head: - self.main.loc_manager.current_head = default_head - self.main.loc_manager.right_panel.annot_mgmt.tabs.set_current_head(default_head) - - missing_files = [] - loaded_count = 0 - - for item in data.get('data', []): - inputs = item.get('inputs', []) - if not inputs or not isinstance(inputs, list): continue - - raw_path = inputs[0].get('path', '') - aid = item.get('id') - if not aid: - aid = os.path.splitext(os.path.basename(raw_path))[0] - - final_path = raw_path - - if os.path.isabs(raw_path) and os.path.exists(raw_path): - final_path = raw_path - else: - norm_raw = raw_path.replace('\\', '/') - abs_path_strict = os.path.normpath(os.path.join(project_root, norm_raw)) - - if os.path.exists(abs_path_strict): - final_path = abs_path_strict - else: - filename = os.path.basename(norm_raw) - abs_path_flat = os.path.join(project_root, filename) - - if os.path.exists(abs_path_flat): - final_path = abs_path_flat - else: - final_path = abs_path_strict - missing_files.append(f"{aid}: {filename}") - - self.model.action_item_data.append({ - 'name': aid, 'path': final_path, 'source_files': [final_path] - }) - self.model.action_path_to_name[final_path] = aid - - raw_events = item.get('events', []) - processed_events = [] - - if isinstance(raw_events, list): - for evt in raw_events: - if not isinstance(evt, dict): continue - try: - pos_ms = int(evt.get('position_ms', 0)) - except ValueError: - pos_ms = 0 - - new_evt = { - "head": evt.get('head', 'action'), - "label": evt.get('label', '?'), - "position_ms": pos_ms - } - processed_events.append(new_evt) - - if processed_events: - self.model.localization_events[final_path] = processed_events - - loaded_count += 1 - - self.model.current_json_path = file_path - self.model.json_loaded = True - - self.main.loc_manager.populate_tree() - - if missing_files: - shown_missing = missing_files[:5] - msg = f"Loaded {loaded_count} clips.\n\nWARNING: {len(missing_files)} videos not found locally:\n" + "\n".join(shown_missing) - if len(missing_files) > 5: msg += "\n..." - QMessageBox.warning(self.main, "Load Warning", msg) - else: - # [修改] 使用 show_temp_msg 并设置 5000ms (5秒) 延迟 - # 这个消息框会自动显示5秒,期间可以不点击,5秒后自动关闭并进入下一界面 - self.main.show_temp_msg( - "Mode Switched", - f"Successfully loaded {loaded_count} clips.\n\nCurrent Mode: LOCALIZATION", - duration=5000, - icon=QMessageBox.Icon.Information - ) - - return True - - def overwrite_json(self): - if self.model.current_json_path: - return self._write_json(self.model.current_json_path) - return self.export_json() - - def export_json(self): - path, _ = QFileDialog.getSaveFileName(self.main, "Export Localization JSON", "", "JSON (*.json)") - if path: - if self._write_json(path): - self.model.current_json_path = path - self.model.is_data_dirty = False - return True - return False - - def _write_json(self, path): - output = { - "version": "2.0", - "date": "2025-12-16", - "task": "action_spotting", - "dataset_name": self.model.current_task_name, - "metadata": { - "source": "Annotation Tool Export", - "created_by": "User" - }, - "labels": self.model.label_definitions, - "data": [] - } - - base_dir = os.path.dirname(path) - sorted_items = sorted(self.model.action_item_data, key=lambda d: natural_sort_key(d.get('name', ''))) - - for data in sorted_items: - abs_path = data['path'] - events = self.model.localization_events.get(abs_path, []) - - try: - rel_path = os.path.relpath(abs_path, base_dir).replace(os.sep, '/') - except: - rel_path = abs_path - - export_events = [] - for e in events: - export_events.append({ - "head": e.get('head'), - "label": e.get('label'), - "position_ms": str(e.get('position_ms')) - }) - - entry = { - "inputs": [{ - "type": "video", - "path": rel_path, - "fps": 25.0 - }], - "events": export_events - } - output["data"].append(entry) - - try: - with open(path, 'w', encoding='utf-8') as f: - json.dump(output, f, indent=4, ensure_ascii=False) - - self.model.is_data_dirty = False - self.main.show_temp_msg("Saved", f"Saved to {os.path.basename(path)}") - return True - except Exception as e: - QMessageBox.critical(self.main, "Error", f"Save failed: {e}") - return False - - def _clear_workspace(self, full_reset=False): - if hasattr(self.main, 'loc_manager'): - self.main.loc_manager.left_panel.clip_tree.clear() - self.main.loc_manager.center_panel.media_preview.stop() - self.main.loc_manager.center_panel.media_preview.player.setSource(QUrl()) - self.main.loc_manager.right_panel.table.set_data([]) - - self.model.reset(full_reset) - if full_reset: - self.main.ui.show_welcome_view() diff --git a/Tool/controllers/localization/readme.md b/Tool/controllers/localization/readme.md deleted file mode 100644 index 4fc425e..0000000 --- a/Tool/controllers/localization/readme.md +++ /dev/null @@ -1 +0,0 @@ -The controller function for Classification part diff --git a/Tool/controllers/readme.md b/Tool/controllers/readme.md deleted file mode 100644 index df79e28..0000000 --- a/Tool/controllers/readme.md +++ /dev/null @@ -1 +0,0 @@ -Make Classification and Localization Parts Separately. diff --git a/Tool/controllers/router.py b/Tool/controllers/router.py deleted file mode 100644 index 3dd534f..0000000 --- a/Tool/controllers/router.py +++ /dev/null @@ -1,82 +0,0 @@ -import json -from PyQt6.QtWidgets import QFileDialog, QMessageBox -# 导入两个专属的文件管理器 -from controllers.classification.class_file_manager import ClassFileManager -from controllers.localization.loc_file_manager import LocFileManager -from dialogs import ProjectTypeDialog # [导入] - -class AppRouter: - """ - 负责应用的入口路由: - 1. 打开 JSON 文件 / 创建新项目 - 2. 判断是 Classification 还是 Localization - 3. 将控制权移交给对应的专用管理器 - """ - def __init__(self, main_window): - self.main = main_window - # 初始化两个专用的文件管理器 - self.class_fm = ClassFileManager(main_window) - self.loc_fm = LocFileManager(main_window) - - def create_new_project_flow(self): - """ - [新增] 创建新项目的统一入口流程 - """ - # 1. 弹出类型选择框 - dlg = ProjectTypeDialog(self.main) - if dlg.exec(): - mode = dlg.selected_mode - - # 2. 根据选择分发到对应的 Manager - if mode == "classification": - # 调用 Classification 的创建流程 (它内部会处理 check_and_close) - self.class_fm.create_new_project() - - elif mode == "localization": - # 调用 Localization 的创建流程 - self.loc_fm.create_new_project() - - def import_annotations(self): - # 全局入口 - if not self.main.check_and_close_current_project(): return - - file_path, _ = QFileDialog.getOpenFileName(self.main, "Select Project JSON", "", "JSON Files (*.json)") - if not file_path: return - - try: - with open(file_path, 'r', encoding='utf-8') as f: - data = json.load(f) - except Exception as e: - QMessageBox.critical(self.main, "Error", f"Invalid JSON: {e}"); return - - json_type = self._detect_json_type(data) - - if json_type == "classification": - self.class_fm.load_project(data, file_path) - self.main.ui.show_classification_view() - - elif json_type == "localization": - # 检查返回值 - if self.loc_fm.load_project(data, file_path): - self.main.ui.show_localization_view() - - else: - QMessageBox.critical(self.main, "Error", "Unknown JSON format.") - - def _detect_json_type(self, data): - items = data.get("data", []) - first = items[0] if items else {} - - # 1) 先判定“样本级 labels” => classification - if isinstance(first, dict) and "labels" in first: - return "classification" - - # 2) 再判定“事件 events” => localization - if isinstance(first, dict) and "events" in first: - return "localization" - - # 3) 兜底:根据顶层 labels 结构判断(可选) - if "labels" in data: - pass - - return "unknown" diff --git a/Tool/dialogs.py b/Tool/dialogs.py deleted file mode 100644 index 5450405..0000000 --- a/Tool/dialogs.py +++ /dev/null @@ -1,382 +0,0 @@ -import os -from PyQt6.QtWidgets import ( - QDialog, QVBoxLayout, QRadioButton, QTreeView, QDialogButtonBox, - QAbstractItemView, QGroupBox, QFormLayout, QLineEdit, QHBoxLayout, - QCheckBox, QFrame, QListWidget, QComboBox, QPushButton, QLabel, - QMessageBox, QWidget, QListWidgetItem, QStyle, QButtonGroup, QScrollArea -) -from PyQt6.QtCore import QDir, Qt, QSize -from PyQt6.QtGui import QFileSystemModel, QIcon -from utils import get_square_remove_btn_style - -class ProjectTypeDialog(QDialog): - """ - Choose the event Type (Classification vs Localization) - """ - def __init__(self, parent=None): - super().__init__(parent) - self.setWindowTitle("Select Project Type") - self.resize(400, 250) - self.selected_mode = None - - layout = QVBoxLayout(self) - layout.setSpacing(15) - layout.setContentsMargins(30, 30, 30, 30) - - lbl = QLabel("Please select the type of project you want to create:") - lbl.setStyleSheet("font-size: 14px; font-weight: bold; color: #ccc;") - layout.addWidget(lbl) - - # 按钮容器 - btn_layout = QHBoxLayout() - btn_layout.setSpacing(20) - - # Classification 按钮 - self.btn_cls = QPushButton("Classification") - self.btn_cls.setMinimumSize(QSize(0, 80)) - self.btn_cls.setStyleSheet(""" - QPushButton { - font-size: 16px; background-color: #2A2A2A; border: 2px solid #444; border-radius: 8px; - } - QPushButton:hover { background-color: #3A3A3A; border-color: #00BFFF; } - """) - self.btn_cls.clicked.connect(lambda: self._finish("classification")) - - # Localization 按钮 - self.btn_loc = QPushButton("Localization\n(Action Spotting)") - self.btn_loc.setMinimumSize(QSize(0, 80)) - self.btn_loc.setStyleSheet(""" - QPushButton { - font-size: 16px; background-color: #2A2A2A; border: 2px solid #444; border-radius: 8px; - } - QPushButton:hover { background-color: #3A3A3A; border-color: #00BFFF; } - """) - self.btn_loc.clicked.connect(lambda: self._finish("localization")) - - btn_layout.addWidget(self.btn_cls) - btn_layout.addWidget(self.btn_loc) - - layout.addLayout(btn_layout) - layout.addStretch() - - cancel_btn = QPushButton("Cancel") - cancel_btn.clicked.connect(self.reject) - layout.addWidget(cancel_btn, alignment=Qt.AlignmentFlag.AlignRight) - - def _finish(self, mode): - self.selected_mode = mode - self.accept() - -class CreateProjectDialog(QDialog): - """ - Dialog for creating projects. - Layout: Top-Down flow. - 1. Project Info - 2. Head Definition (Name -> Labels) -> Add to List - 3. Final List of Heads - """ - def __init__(self, parent=None, project_type="classification"): - super().__init__(parent) - self.project_type = project_type - self.setWindowTitle(f"Create New {project_type.capitalize()} Project") - self.resize(600, 750) - - # Final result storage: { "HeadName": { "type": "...", "labels": [...] } } - self.final_categories = {} - - # Temporary storage for the head currently being created - self.current_head_labels = [] - - main_layout = QVBoxLayout(self) - main_layout.setSpacing(15) - - # ========================================== - # 1. Project Info Section - # ========================================== - info_group = QGroupBox("1. Project Information") - form = QFormLayout(info_group) - self.task_name_edit = QLineEdit("My Task") - form.addRow("Task Name:", self.task_name_edit) - - self.desc_edit = QLineEdit() - form.addRow("Description:", self.desc_edit) - - # Modalities - mod_layout = QHBoxLayout() - self.mod_video = QCheckBox("Video"); self.mod_video.setChecked(True) - self.mod_image = QCheckBox("Image") - self.mod_audio = QCheckBox("Audio") - - mod_layout.addWidget(self.mod_video) - mod_layout.addWidget(self.mod_image) - mod_layout.addWidget(self.mod_audio) - - # Localization restriction: Only Video usually needed, hide others for simplicity - if self.project_type == "localization": - self.mod_image.setVisible(False) - self.mod_audio.setVisible(False) - self.mod_video.setEnabled(False) # Force check - - form.addRow("Modalities:", mod_layout) - main_layout.addWidget(info_group) - - # ========================================== - # 2. Head Creator Section (Staging Area) - # ========================================== - creator_group = QGroupBox("2. Define a New Head (Category)") - creator_layout = QVBoxLayout(creator_group) - - # 2.1 Head Name - h_name_layout = QHBoxLayout() - h_name_layout.addWidget(QLabel("Head Name:")) - self.head_name_edit = QLineEdit() - self.head_name_edit.setPlaceholderText("e.g. Action, Team, Player...") - h_name_layout.addWidget(self.head_name_edit) - creator_layout.addLayout(h_name_layout) - - # 2.2 Label Type - type_layout = QHBoxLayout() - type_layout.addWidget(QLabel("Label Type:")) - self.type_group = QButtonGroup(self) - self.rb_single = QRadioButton("Single Label (Mutually Exclusive)") - self.rb_single.setChecked(True) - self.rb_multi = QRadioButton("Multi Label") - - type_layout.addWidget(self.rb_single) - type_layout.addWidget(self.rb_multi) - type_layout.addStretch() - self.type_group.addButton(self.rb_single) - self.type_group.addButton(self.rb_multi) - - # Localization restriction: Force Single Label - if self.project_type == "localization": - self.rb_multi.setVisible(False) - self.rb_single.setText("Single Label (Fixed for Action Spotting)") - self.rb_single.setEnabled(False) - - creator_layout.addLayout(type_layout) - - # 2.3 Labels Definition - lbl_def_group = QGroupBox("Labels for this Head") - lbl_def_group.setStyleSheet("QGroupBox { border: 1px solid #555; margin-top: 5px; } QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 3px; }") - lbl_def_layout = QVBoxLayout(lbl_def_group) - - # Input row - inp_row = QHBoxLayout() - self.label_input = QLineEdit() - self.label_input.setPlaceholderText("Type label name and press Enter (e.g. Pass, Shot)") - self.label_input.returnPressed.connect(self.add_label_to_staging) - - btn_add_lbl = QPushButton("Add Label") - btn_add_lbl.clicked.connect(self.add_label_to_staging) - - inp_row.addWidget(self.label_input) - inp_row.addWidget(btn_add_lbl) - lbl_def_layout.addLayout(inp_row) - - # List of staged labels - self.staged_labels_list = QListWidget() - self.staged_labels_list.setFixedHeight(100) # Keep it compact - lbl_def_layout.addWidget(self.staged_labels_list) - - creator_layout.addWidget(lbl_def_group) - - # 2.4 Add Head Button - self.btn_add_head_to_project = QPushButton("Add Head Categories to Project ↓") - self.btn_add_head_to_project.setStyleSheet("font-weight: bold; padding: 8px; font-size: 14px;") - self.btn_add_head_to_project.clicked.connect(self.commit_head_to_project) - creator_layout.addWidget(self.btn_add_head_to_project) - - main_layout.addWidget(creator_group) - - # ========================================== - # 3. Project Schema List (Result) - # ========================================== - result_group = QGroupBox("3. Project Structure (Heads)") - result_layout = QVBoxLayout(result_group) - - self.project_heads_list = QListWidget() - result_layout.addWidget(self.project_heads_list) - - main_layout.addWidget(result_group) - - # ========================================== - # Bottom Buttons - # ========================================== - bbox = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) - bbox.accepted.connect(self.validate_and_accept) - bbox.rejected.connect(self.reject) - main_layout.addWidget(bbox) - - # --- Logic Methods --- - - def add_label_to_staging(self): - """Adds a label to the temporary list for the head being created.""" - txt = self.label_input.text().strip() - if not txt: return - - # Case-insensitive check - if any(l.lower() == txt.lower() for l in self.current_head_labels): - QMessageBox.warning(self, "Duplicate Label", f"Label '{txt}' already exists (case-insensitive)!") - self.label_input.selectAll() # 全选文本方便用户修改 - return - - self.current_head_labels.append(txt) - - # Add to UI - item = QListWidgetItem(self.staged_labels_list) - widget = QWidget() - h = QHBoxLayout(widget) - h.setContentsMargins(5, 2, 5, 2) - h.addWidget(QLabel(txt)) - h.addStretch() - - rem_btn = QPushButton("×") - rem_btn.setFixedSize(20, 20) - rem_btn.setStyleSheet(get_square_remove_btn_style()) - rem_btn.clicked.connect(lambda: self.remove_label_from_staging(txt, item)) - h.addWidget(rem_btn) - - item.setSizeHint(widget.sizeHint()) - self.staged_labels_list.setItemWidget(item, widget) - - self.label_input.clear() - self.label_input.setFocus() - - def remove_label_from_staging(self, txt, item): - if txt in self.current_head_labels: - self.current_head_labels.remove(txt) - row = self.staged_labels_list.row(item) - self.staged_labels_list.takeItem(row) - - def commit_head_to_project(self): - """Moves the staged head definition into the final project structure.""" - head_name = self.head_name_edit.text().strip() - if not head_name: - QMessageBox.warning(self, "Warning", "Head Name cannot be empty.") - return - - # Check if head already exists (Case-Insensitive) - if any(k.lower() == head_name.lower() for k in self.final_categories): - QMessageBox.warning(self, "Error", f"Head '{head_name}' already exists in project.") - return - - # Determine type - ltype = "single_label" - if self.project_type == "classification" and self.rb_multi.isChecked(): - ltype = "multi_label" - - # Save to final dict - # Copy labels list to avoid reference issues - self.final_categories[head_name] = { - "type": ltype, - "labels": list(self.current_head_labels) - } - - # Update UI List - self.add_head_to_project_list_ui(head_name, ltype, self.current_head_labels) - - # Clear Staging Area - self.head_name_edit.clear() - self.label_input.clear() - self.staged_labels_list.clear() - self.current_head_labels = [] - - # Reset Focus - self.head_name_edit.setFocus() - - def add_head_to_project_list_ui(self, name, ltype, labels): - item = QListWidgetItem(self.project_heads_list) - widget = QWidget() - h = QHBoxLayout(widget) - h.setContentsMargins(5, 5, 5, 5) - - type_str = "[S]" if ltype == 'single_label' else "[M]" - label_summary = ", ".join(labels) - if len(label_summary) > 30: label_summary = label_summary[:30] + "..." - - info_label = QLabel(f"{name} {type_str} : {label_summary}") - h.addWidget(info_label) - h.addStretch() - - # [修改] 使用垃圾桶图标样式,与主界面右侧栏保持一致 - rem_btn = QPushButton() - rem_btn.setFixedSize(24, 24) - rem_btn.setFlat(True) - rem_btn.setCursor(Qt.CursorShape.PointingHandCursor) - rem_btn.setToolTip("Remove Head") - rem_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_TrashIcon)) - - rem_btn.clicked.connect(lambda: self.remove_head_from_project(name, item)) - h.addWidget(rem_btn) - - item.setSizeHint(widget.sizeHint()) - self.project_heads_list.setItemWidget(item, widget) - - def remove_head_from_project(self, name, item): - if name in self.final_categories: - del self.final_categories[name] - row = self.project_heads_list.row(item) - self.project_heads_list.takeItem(row) - - def validate_and_accept(self): - if not self.task_name_edit.text().strip(): - self.task_name_edit.setPlaceholderText("NAME REQUIRED!") - self.task_name_edit.setFocus() - return - - if not self.final_categories: - QMessageBox.warning(self, "Warning", "Please define at least one Head and add it to the project.") - return - - self.accept() - - def get_data(self): - modalities = [] - if self.mod_video.isChecked(): modalities.append("video") - if self.mod_image.isChecked(): modalities.append("image") - if self.mod_audio.isChecked(): modalities.append("audio") - - return { - "task": self.task_name_edit.text().strip(), - "description": self.desc_edit.text().strip(), - "modalities": modalities, - "labels": self.final_categories - } - -# --- FolderPickerDialog 保持不变 --- -class FolderPickerDialog(QDialog): - """Custom Folder Picker (Multi-Select without Ctrl).""" - def __init__(self, initial_dir="", parent=None): - super().__init__(parent) - self.setWindowTitle("Select Scene Folders (Click to Toggle Multiple)") - self.resize(900, 600) - - self.layout = QVBoxLayout(self) - self.layout.addWidget(QRadioButton("Tip: Click multiple folders to select them. No need to hold Ctrl.")) - - self.model = QFileSystemModel() - self.model.setRootPath(QDir.rootPath()) - self.model.setFilter(QDir.Filter.AllDirs | QDir.Filter.NoDotAndDotDot) - - self.tree = QTreeView() - self.tree.setModel(self.model) - self.tree.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection) - - self.tree.setColumnWidth(0, 400) - for i in range(1, 4): - self.tree.hideColumn(i) - - start_path = initial_dir if initial_dir and os.path.exists(initial_dir) else QDir.rootPath() - self.tree.setRootIndex(self.model.index(start_path)) - - self.layout.addWidget(self.tree) - - self.bbox = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) - self.bbox.accepted.connect(self.accept) - self.bbox.rejected.connect(self.reject) - self.layout.addWidget(self.bbox) - - def get_selected_folders(self): - indexes = self.tree.selectionModel().selectedRows() - return [self.model.filePath(idx) for idx in indexes] diff --git a/Tool/models.py b/Tool/models.py deleted file mode 100644 index 370f746..0000000 --- a/Tool/models.py +++ /dev/null @@ -1,311 +0,0 @@ -import os -import copy -from enum import Enum, auto - -class CmdType(Enum): - # --- Classification Commands --- - ANNOTATION_CONFIRM = auto() # Confirming data to storage - UI_CHANGE = auto() # Fine-grained UI toggle (radio/checkbox) - - # --- Shared / Schema Commands (Used by both) --- - SCHEMA_ADD_CAT = auto() # Add Category (Head) - SCHEMA_DEL_CAT = auto() # Delete Category (Head) - SCHEMA_REN_CAT = auto() # [NEW] Rename Category (Head) - - SCHEMA_ADD_LBL = auto() # Add Label option - SCHEMA_DEL_LBL = auto() # Delete Label option - SCHEMA_REN_LBL = auto() # [NEW] Rename Label option - - # --- Localization Commands --- - LOC_EVENT_ADD = auto() - LOC_EVENT_DEL = auto() - LOC_EVENT_MOD = auto() - -class AppStateModel: - """ - Manages the application state, data storage, and undo/redo stacks. - Does NOT interact with UI widgets directly. - """ - def __init__(self): - # --- Project Metadata --- - self.current_working_directory = None - self.current_json_path = None - self.json_loaded = False - self.is_data_dirty = False - self.project_description = "" # 防止 Classification 加载报错 - self.current_task_name = "Untitled Task" - self.modalities = ["video"] - - # --- Schema / Labels --- - # Structure: { "head_name": { "type": "single/multi", "labels": ["label1", ...] } } - self.label_definitions = {} - - # --- Classification Data --- - # { "video_path": { "Head": "Label", "Head2": ["L1", "L2"] } } - self.manual_annotations = {} - - # Classification Import Metadata (防止报错) - self.imported_input_metadata = {} # Key: (action_id, filename) - self.imported_action_metadata = {} # Key: action_id - - # --- Localization / Action Spotting Data --- - # { "video_path": [ { "head": "action", "label": "kick", "position_ms": 1500 }, ... ] } - self.localization_events = {} - - # --- Common Video/Clip List --- - # List of dicts: { "name": "...", "path": "...", "source_files": [...] } - self.action_item_data = [] - self.action_item_map = {} # path -> QTreeWidgetItem - self.action_path_to_name = {} # path -> name - - # --- Undo/Redo Stacks --- - self.undo_stack = [] - self.redo_stack = [] - - def reset(self, full_reset=False): - self.current_json_path = None - self.json_loaded = False - self.is_data_dirty = False - - self.manual_annotations = {} - self.localization_events = {} - - # 重置元数据 - self.imported_input_metadata = {} - self.imported_action_metadata = {} - - self.action_item_data = [] - self.action_item_map = {} - self.action_path_to_name = {} - self.undo_stack = [] - self.redo_stack = [] - - if full_reset: - self.label_definitions = {} - self.current_working_directory = None - self.current_task_name = "Untitled Task" - self.project_description = "" - - def push_undo(self, cmd_type, **kwargs): - """Pushes a command to the undo stack and clears redo stack.""" - command = {'type': cmd_type, **kwargs} - self.undo_stack.append(command) - self.redo_stack.clear() - self.is_data_dirty = True - - def validate_gac_json(self, data): - """ - Placeholder for existing Classification JSON validation logic. - Retained to match existing codebase. - """ - return True, "", "" - - def validate_loc_json(self, data): - """ - Strict Validation for Localization / Action Spotting JSON. - Covers 19 specific error cases. - Returns: (is_valid, error_msg, warning_msg) - """ - errors = [] - warnings = [] - - # --- Top Level Checks (Cases 1-4) --- - if not isinstance(data, dict): - return False, "Root JSON must be a dictionary.", "" - - # Case 1: Missing 'data' - if "data" not in data: - return False, "Critical: Missing top-level key 'data'.", "" - - # Case 2: 'data' is not a list - if not isinstance(data["data"], list): - return False, "Critical: Top-level 'data' must be a list.", "" - - # Case 3: Missing 'labels' - if "labels" not in data: - return False, "Critical: Missing top-level key 'labels'.", "" - - # Case 4: 'labels' is not a dict - labels_def = data["labels"] - if not isinstance(labels_def, dict): - return False, "Critical: Top-level 'labels' must be a dictionary.", "" - - # --- Schema Validity (Case 5) --- - valid_heads = set() - head_label_map = {} - - for head, content in labels_def.items(): - if not isinstance(content, dict): - errors.append(f"Label definition for '{head}' must be a dict.") - continue - - # Case 5: labels[head]['labels'] is not a list - if "labels" not in content or not isinstance(content["labels"], list): - errors.append(f"Critical: 'labels' field for head '{head}' must be a list.") - continue - - valid_heads.add(head) - head_label_map[head] = set(content["labels"]) - - if errors: - return False, "\n".join(errors), "" - - # --- Data Items Iteration --- - err_inputs_missing = [] # Case 6 - err_inputs_not_list = [] # Case 7 - err_inputs_empty = [] # Case 8 - err_input_type = [] # Case 9 - err_input_path = [] # Case 10 - err_input_fps = [] # Case 11 - - err_events_missing = [] # Case 12 - err_events_not_list = [] # Case 13 - - err_evt_missing_fields = [] # Case 14 (head/label/pos) - err_evt_unknown_head = [] # Case 15 - err_evt_unknown_label = [] # Case 16 (includes Case 19: empty labels list) - err_evt_pos_format = [] # Case 17 (not int) - err_evt_pos_neg = [] # Case 18 (< 0) - - warn_duplicates = [] - - items_list = data["data"] - - for i, item in enumerate(items_list): - if not isinstance(item, dict): - errors.append(f"Item #{i} is not a dictionary.") - continue - - # --- Inputs Checks (Cases 6-11) --- - # Case 6: Missing inputs - if "inputs" not in item: - err_inputs_missing.append(f"Item #{i}") - # Cannot proceed with input checks, but check events? Usually stop here for this item. - continue - - inputs = item["inputs"] - - # Case 7: inputs not list - if not isinstance(inputs, list): - err_inputs_not_list.append(f"Item #{i}") - continue - - # Case 8: inputs empty - if len(inputs) == 0: - err_inputs_empty.append(f"Item #{i}") - continue # Skip inner checks - - # Input[0] checks - first_inp = inputs[0] - if isinstance(first_inp, dict): - # Case 9: type != video - if first_inp.get("type") != "video": - err_input_type.append(f"Item #{i} type='{first_inp.get('type')}'") - - # Case 10: missing path - if "path" not in first_inp: - err_input_path.append(f"Item #{i}") - - # Case 11: fps invalid - fps = first_inp.get("fps") - if fps is None or not isinstance(fps, (int, float)) or fps <= 0: - err_input_fps.append(f"Item #{i} fps={fps}") - else: - err_inputs_not_list.append(f"Item #{i} (inputs[0] not dict)") - - # --- Events Checks (Cases 12-19) --- - # Case 12: Missing events - if "events" not in item: - err_events_missing.append(f"Item #{i}") - continue - - events = item["events"] - - # Case 13: Events not list - if not isinstance(events, list): - err_events_not_list.append(f"Item #{i}") - continue - - # Individual Event Checks - seen_events = set() - for j, evt in enumerate(events): - if not isinstance(evt, dict): - continue - - # Case 14: Missing keys - missing_fields = [] - if "head" not in evt: missing_fields.append("head") - if "label" not in evt: missing_fields.append("label") - if "position_ms" not in evt: missing_fields.append("position_ms") - - if missing_fields: - err_evt_missing_fields.append(f"Item #{i} Evt #{j} missing {missing_fields}") - continue # Cannot proceed logic checks if fields missing - - head = evt["head"] - label = evt["label"] - pos_val = evt["position_ms"] - - # Case 15: Unknown Head - if head not in valid_heads: - err_evt_unknown_head.append(f"Item #{i} Evt #{j} head='{head}'") - continue - - # Case 16 & 19: Label not in head's label list - # If head's labels list is empty (Case 19), this check will fail (Correct) - allowed_labels = head_label_map[head] - if label not in allowed_labels: - err_evt_unknown_label.append(f"Item #{i} Evt #{j} label='{label}' not in head '{head}'") - continue - - # Case 17: position_ms not convertible to int - try: - pos_int = int(pos_val) - # Case 18: position_ms < 0 - if pos_int < 0: - err_evt_pos_neg.append(f"Item #{i} Evt #{j} pos={pos_int}") - except (ValueError, TypeError): - err_evt_pos_format.append(f"Item #{i} Evt #{j} pos='{pos_val}'") - continue - - # Duplicate check (Warning) - sig = (head, label, int(pos_val) if isinstance(pos_val, (int, float, str)) and str(pos_val).isdigit() else pos_val) - if sig in seen_events: - warn_duplicates.append(f"Item #{i} ({head}, {label}, {pos_val})") - else: - seen_events.add(sig) - - # --- Aggregate Errors --- - def _fmt(title, lst): - if not lst: return None - return f"{title} ({len(lst)}):\n " + "\n ".join(lst[:5]) + ("\n ..." if len(lst)>5 else "") - - # Critical Errors (Return False) - crit_errors = [ - _fmt("Data items missing 'inputs'", err_inputs_missing), - _fmt("Data items 'inputs' not a list", err_inputs_not_list), - _fmt("Data items 'inputs' is empty", err_inputs_empty), - _fmt("Inputs type is not 'video'", err_input_type), - _fmt("Inputs missing 'path'", err_input_path), - _fmt("Inputs FPS invalid (<=0)", err_input_fps), - - _fmt("Data items missing 'events'", err_events_missing), - _fmt("Data items 'events' not a list", err_events_not_list), - - _fmt("Events missing keys (head/label/pos)", err_evt_missing_fields), - _fmt("Unknown Event Head (not in labels)", err_evt_unknown_head), - _fmt("Unknown Event Label (not in schema)", err_evt_unknown_label), - _fmt("Position invalid format (not int)", err_evt_pos_format), - _fmt("Position negative", err_evt_pos_neg), - ] - - final_errors = [e for e in crit_errors if e] + errors # Prepend schema errors - - if final_errors: - return False, "\n\n".join(final_errors), "" - - # Warnings (Return True) - if warn_duplicates: - warnings.append(_fmt("Duplicate Events found", warn_duplicates)) - - return True, "", "\n\n".join(warnings) diff --git a/Tool/style/readme.md b/Tool/style/readme.md deleted file mode 100644 index b893eb5..0000000 --- a/Tool/style/readme.md +++ /dev/null @@ -1 +0,0 @@ -Style desgin of the tool diff --git a/Tool/style/style.qss b/Tool/style/style.qss deleted file mode 100644 index 6691e23..0000000 --- a/Tool/style/style.qss +++ /dev/null @@ -1,151 +0,0 @@ -QWidget { - background-color: #2E2E2E; color: #F0F0F0; - font-family: Arial, sans-serif; font-size: 14px; -} -QLabel#titleLabel { - font-size: 18px; font-weight: bold; padding-bottom: 10px; - border-bottom: 2px solid #555; -} -QLabel#subtitleLabel { - font-size: 16px; font-weight: bold; color: #00AACC; - padding-top: 10px; -} -QPushButton { - background-color: #555; -border: 1px solid #666; - padding: 8px; border-radius: 4px; -} -QPushButton:hover { background-color: #666; } -QPushButton:pressed { background-color: #444; -} -QPushButton#startButton { - background-color: #007ACC; font-size: 16px; font-weight: bold; -} -QPushButton#startButton:hover { background-color: #008AE6; -} -QPushButton:disabled { - background-color: #4A5864; -} -QTreeWidget { - background-color: #3C3C3C; border: 1px solid #555; -} -QHeaderView::section { - background-color: #555; padding: 4px; - border: 1px solid #666; font-weight: bold; -} -QProgressBar { - border-radius: 5px; text-align: center; color: white; -} -QProgressBar::chunk { - background-color: #007ACC; border-radius: 5px; -} - -/* --- 新增的样式 --- */ - -QGroupBox { - font-size: 16px; - font-weight: bold; - color: #F0F0F0; - background-color: #3C3C3C; - border: 1px solid #555; - border-radius: 4px; - margin-top: 10px; /* 为标题留出空间 */ -} - -/* QGroupBox 的标题和复选框 */ -QGroupBox::title { - subcontrol-origin: margin; - subcontrol-position: top left; - padding: 5px 25px; /* 增加左边距以容纳指示器 */ - background-color: #4A4A4A; - border-top-left-radius: 4px; - border-top-right-radius: 4px; - border-bottom: 1px solid #555; -} - -/* QGroupBox 的复选框指示器 (折叠/展开) */ -QGroupBox::indicator { - width: 18px; - height: 18px; - /* 放置在标题的左侧 */ - position: absolute; - left: 5px; - top: 5px; -} -QGroupBox::indicator:unchecked { - /* "折叠" 状态 (指向右侧) */ - image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAABvSURBVDhPzZAxCgAgDAQhR/f+V80hgkWsUBsLs3/cqiDgBYH4xBcBvjFpFiQbka0cDDc720BKKcmyLFMvSZIyIfO9Evf1k9Tf8zDVB80wzPM8WlW1lQJwni8AJZJk7QAVVTWtqhYAz/NJkiRbAWoOQMbDDB3iAAAAAElFTkSuQmCC); -} -QGroupBox::indicator:checked { - /* "展开" 状态 (指向下方) */ - image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAABqSURBVDhPzYxBCgAwCAOj+/+vbZCKIpA5NFAuTcsxZJk/YgkB8YkmAHzjeZ4lSZIsqyopZYrleZ7d+wP3rNcANE0biqL8m0EQFIVRWKu1xBABvO8/SlJERAQhBL/vH0EQEiL+N4FmBt0L2dOkAAAAAElFTkSuQmCC); -} - -QRadioButton { - padding: 4px; - color: #F0F0F0; -} - -QRadioButton::indicator { - width: 16px; - height: 16px; -} - -/* --- 这部分是您缺失的关键功能 --- */ - -/* * 当 QGroupBox 可勾选 且 未被选中 (折叠状态) 时 - * 选择其内部的内容容器 (QWidget) - */ -QGroupBox::checkable:!checked > QWidget { - /* 将其最大高度设为0, 从而隐藏它 */ - max-height: 0px; - - /* 同样移除边距、内边距和边框,使其完全消失 */ - margin: 0; - padding: 0; - border: none; -} - -/* * 当 QGroupBox 可勾选 且 被选中 (展开状态) 时 - * 恢复其内部内容容器 (QWidget) - */ -QGroupBox::checkable:checked > QWidget { - /* 恢复其最大高度 (设一个足够大的值) */ - max-height: 9999px; - - /* (可选) 您可以在这里恢复展开时想要的内边距 */ - /* padding: 10px; */ -} - - - -/* --- QSlider 样式调整 --- */ - -QSlider::groove:horizontal { - /* 轨道(Groove)的样式 */ - border: 1px solid #555; - height: 6px; /* 轨道高度 (粗细) */ - background: #444; - margin: 2px 0; - border-radius: 3px; -} - -QSlider::handle:horizontal { - /* 节点(Handle)的样式 */ - background: #f2f6f8ff; /* 节点颜色 */ - border: 1px solid #005699; - width: 12px; /* 节点宽度 (大小) */ - height: 12px; /* 节点高度 (大小) */ - margin: -4px 0; /* 垂直居中 */ - border-radius: 6px; /* 使节点呈圆形 */ -} - -QSlider::handle:horizontal:hover { - background: #00AACC; /* 悬停颜色 */ -} - -QSlider::sub-page:horizontal { - /* 已经走过的轨道部分 */ - background: #007ACC; - border-radius: 3px; -} diff --git a/Tool/style/style_day.qss b/Tool/style/style_day.qss deleted file mode 100644 index 9fd2dfc..0000000 --- a/Tool/style/style_day.qss +++ /dev/null @@ -1,164 +0,0 @@ -/* style_day.qss */ - -/* 1. 全局和基础控件样式 */ -QWidget { - background-color: #E0E0E0; /* 浅灰色背景 */ - color: #1E1E1E; /* 深色字体 */ - font-family: Arial, sans-serif; - font-size: 14px; -} -QLabel#titleLabel { - font-size: 18px; - font-weight: bold; - padding-bottom: 10px; - border-bottom: 2px solid #A0A0A0; /* 灰色分割线 */ -} -QLabel#subtitleLabel { - font-size: 16px; - font-weight: bold; - color: #00AACC; /* 亮蓝色强调 */ - padding-top: 10px; -} -QPushButton { - background-color: #F0F0F0; - border: 1px solid #CCCCCC; - color: #1E1E1E; /* 按钮文字为深色 */ - padding: 8px; - border-radius: 4px; -} -QPushButton:hover { background-color: #DCDCDC; } -QPushButton:pressed { background-color: #C0C0C0; } -QPushButton#startButton { - background-color: #0088CC; /* 蓝色强调 */ - color: white; /* 启动按钮文字为白色 */ - font-size: 16px; - font-weight: bold; -} -QPushButton#startButton:hover { background-color: #0099DD; } -QPushButton:disabled { - background-color: #B0B0B0; - color: #777777; -} - -/* 2. 左侧列表样式 */ -QTreeWidget { - background-color: #FFFFFF; /* 白色背景 */ - border: 1px solid #CCCCCC; - color: #1E1E1E; - alternate-background-color: #FAFAFA; /* 增加交替行颜色 */ -} -QHeaderView::section { - background-color: #D0D0D0; - padding: 4px; - border: 1px solid #CCCCCC; - font-weight: bold; - color: #1E1E1E; -} - -/* 3. 进度条样式 */ -QProgressBar { - border-radius: 5px; - text-align: center; - color: #1E1E1E; /* 深色文字 */ - background-color: #F0F0F0; /* 进度条背景 */ -} -QProgressBar::chunk { - background-color: #0088CC; - border-radius: 5px; -} - -/* 4. 分组框 (Manual/Auto Annotation) 修正 */ -QGroupBox { - font-size: 16px; - font-weight: bold; - color: #1E1E1E; /* 标题文字为深色 */ - background-color: #FFFFFF; /* 组框内容区域背景为白色 */ - border: 1px solid #A0A0A0; - border-radius: 4px; - margin-top: 10px; -} - -/* QGroupBox 的标题 (修正:需要使用亮色背景和深色文字) */ -QGroupBox::title { - subcontrol-origin: margin; - subcontrol-position: top left; - padding: 5px 25px; - background-color: #D0D0D0; /* 浅灰色背景 */ - color: #1E1E1E; /* 标题文字为深色 */ - border-top-left-radius: 4px; - border-top-right-radius: 4px; - border-bottom: 1px solid #A0A0A0; -} - -/* QGroupBox 的折叠指示器 (不需要修改 image,因为使用的是 Base64 编码的图标) */ -QGroupBox::indicator { - width: 18px; - height: 18px; - position: absolute; - left: 5px; - top: 5px; -} -QGroupBox::indicator:unchecked { - /* "折叠" 状态 */ - image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAABvSURBVDhPzZAxCgAgDAQhR/f+V80hgkWsUBsLs3/cqiDgBYH4xBcBvjFpFiQbka0cDDc720BKKcmyLFMvSZIyIfO9Evf1k9Tf8zDVB80wzPM8WlW1lQJwni8AJZJk7QAVVTWtqhYAz/NJkiRbAWoOQMbDDB3iAAAAAElFTkSuQmCC); -} -QGroupBox::indicator:checked { - /* "展开" 状态 */ - image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAABqSURBVDhPzYxBCgAwCAOj+/+vbZCKIpA5NFAuTcsxZJk/YgkB8YkmAHzjeZ4lSZIsqyopZYrleZ7d+wP3rNcANE0biqL8m0EQFIVRWKu1xBABvO8/SlJERAQhBL/vH0EQEiL+N4FmBt0L2dOkAAAAAElFTkSuQmCC); -} - -/* 5. 折叠/展开功能 (不变) */ -QGroupBox::checkable:!checked > QWidget { - max-height: 0px; - margin: 0; - padding: 0; - border: none; -} -QGroupBox::checkable:checked > QWidget { - max-height: 9999px; - /* padding: 10px; */ -} - -/* 6. 单选按钮和复选框 (修正:文字颜色需要为深色) */ -QRadioButton { - padding: 4px; - color: #1E1E1E; /* 深色文字 */ -} -QRadioButton::indicator { - width: 16px; - height: 16px; -} -QCheckBox { - padding: 4px; - color: #1E1E1E; /* 深色文字 */ -} - -/* 7. QSlider 样式 (修正:将夜间模式的颜色调亮) */ -QSlider::groove:horizontal { - /* 轨道(Groove)的样式 */ - border: 1px solid #A0A0A0; /* 边框为灰色 */ - height: 6px; - background: #CCCCCC; /* 轨道背景为浅灰色 */ - margin: 2px 0; - border-radius: 3px; -} - -QSlider::handle:horizontal { - /* 节点(Handle)的样式 */ - background: #FFFFFF; /* 节点为白色 */ - border: 1px solid #0088CC; /* 边框为蓝色 */ - width: 12px; - height: 12px; - margin: -4px 0; - border-radius: 6px; -} - -QSlider::handle:horizontal:hover { - background: #00AACC; /* 悬停颜色 (不变) */ -} - -QSlider::sub-page:horizontal { - /* 已经走过的轨道部分 */ - background: #0088CC; /* 蓝色强调 (不变) */ - border-radius: 3px; -} \ No newline at end of file diff --git a/Tool/ui/panels.py b/Tool/ui/panels.py deleted file mode 100644 index 7339e90..0000000 --- a/Tool/ui/panels.py +++ /dev/null @@ -1,487 +0,0 @@ -import os -from PyQt6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QTreeWidget, QTreeWidgetItem, - QLabel, QComboBox, QScrollArea, QGroupBox, QLineEdit, QMenu, QStyle, QGridLayout, - QFrame, QStackedLayout -) -from PyQt6.QtCore import Qt, pyqtSignal, QSize, QUrl, QTime -from PyQt6.QtGui import QAction, QColor, QPixmap -from PyQt6.QtMultimedia import QMediaPlayer - -# 引入基础组件 -from .widgets import VideoViewAndControl, DynamicSingleLabelGroup, DynamicMultiLabelGroup -from utils import SUPPORTED_EXTENSIONS - -# [关键新增] 引入新的 Localization UI -# 请确保 ui2/panels.py 文件已存在 -from ui2.panels import LocalizationUI - -# ============================================================================= -# 1. Left Panel (Classification Mode) - 保持原样 -# ============================================================================= -class LeftPanel(QWidget): - request_remove_item = pyqtSignal(object) - - def __init__(self, parent=None): - super().__init__(parent) - self.setFixedWidth(300) - layout = QVBoxLayout(self) - layout.setSpacing(10) - - # Header - header_widget = QWidget() - header_layout = QHBoxLayout(header_widget) - header_layout.setContentsMargins(0, 0, 0, 0) - title = QLabel("Scenes / Clips") - title.setObjectName("titleLabel") - self.undo_btn = QPushButton(); self.undo_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowBack)) - self.undo_btn.setToolTip("Undo"); self.undo_btn.setFixedSize(28, 28); self.undo_btn.setEnabled(False) - self.redo_btn = QPushButton(); self.redo_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowForward)) - self.redo_btn.setToolTip("Redo"); self.redo_btn.setFixedSize(28, 28); self.redo_btn.setEnabled(False) - - header_layout.addWidget(title) - header_layout.addStretch() - header_layout.addWidget(self.undo_btn) - header_layout.addWidget(self.redo_btn) - layout.addWidget(header_widget) - - # Buttons - top_button_layout = QVBoxLayout() - row1 = QHBoxLayout() - self.import_btn = QPushButton("Import JSON") - self.create_btn = QPushButton("Create JSON") - row1.addWidget(self.import_btn); row1.addWidget(self.create_btn) - self.add_data_btn = QPushButton("Add Data") - top_button_layout.addLayout(row1) - top_button_layout.addWidget(self.add_data_btn) - layout.addLayout(top_button_layout) - - # Tree - self.action_tree = QTreeWidget() - self.action_tree.setHeaderLabels(["Actions"]) - self.action_tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) - self.action_tree.customContextMenuRequested.connect(self._show_context_menu) - layout.addWidget(self.action_tree) - - # Filter & Clear - bot = QHBoxLayout() - self.filter_label = QLabel("Filter:") - self.filter_combo = QComboBox() - self.filter_combo.addItems(["Show All", "Show Done", "Show Not Done"]) - self.filter_combo.setFixedWidth(120) - self.clear_btn = QPushButton("Clear List") - bot.addWidget(self.filter_label); bot.addWidget(self.filter_combo) - bot.addWidget(self.clear_btn) - bot.addStretch() - layout.addLayout(bot) - - def _show_context_menu(self, pos): - item = self.action_tree.itemAt(pos) - if item: - menu = QMenu() - act = QAction("Remove", self) - act.triggered.connect(lambda: self.request_remove_item.emit(item)) - menu.addAction(act) - menu.exec(self.action_tree.viewport().mapToGlobal(pos)) - - def add_action_item(self, name, path, explicit_files=None): - action_item = QTreeWidgetItem(self.action_tree, [name]) - action_item.setData(0, Qt.ItemDataRole.UserRole, path) - - if explicit_files: - for file_path in explicit_files: - clip_name = os.path.basename(file_path) - clip_item = QTreeWidgetItem(action_item, [clip_name]) - clip_item.setData(0, Qt.ItemDataRole.UserRole, file_path) - if not os.path.exists(file_path): - clip_item.setForeground(0, QColor("red")) - clip_item.setToolTip(0, f"File not found: {file_path}") - elif path and os.path.isdir(path): - try: - for sub_entry in sorted(os.scandir(path), key=lambda e: e.name): - if sub_entry.is_file() and sub_entry.name.lower().endswith(SUPPORTED_EXTENSIONS): - clip_item = QTreeWidgetItem(action_item, [os.path.basename(sub_entry.path)]) - clip_item.setData(0, Qt.ItemDataRole.UserRole, sub_entry.path) - except Exception as e: - print(f"Error scanning directory {path}: {e}") - return action_item - - -# ============================================================================= -# 2. Center Panel (Classification Mode) - 保持原样 -# ============================================================================= -class CenterPanel(QWidget): - - class ImageScrollArea(QScrollArea): - def __init__(self, pixmap, parent=None): - super().__init__(parent) - self.pixmap = pixmap - self.image_label = QLabel() - self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.setWidget(self.image_label) - self.setWidgetResizable(True) - self.update_pixmap() - def update_pixmap(self): - if self.pixmap.isNull(): - self.image_label.setText("Image not loaded") - return - self.image_label.setPixmap(self.pixmap.scaled( - self.viewport().size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation - )) - def resizeEvent(self, event): - super().resizeEvent(event) - self.update_pixmap() - - def __init__(self, parent=None): - super().__init__(parent) - self.view_controls = [] - layout = QVBoxLayout(self) - - title = QLabel("Media Preview") - title.setObjectName("titleLabel") - layout.addWidget(title) - - self.video_container = QWidget() - self.video_layout = QVBoxLayout(self.video_container) - self.video_layout.setContentsMargins(0,0,0,0) - layout.addWidget(self.video_container, 1) - - # Play Controls - control_layout = QHBoxLayout() - self.play_btn = QPushButton() - self.play_btn.setEnabled(False) - self.play_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)) - self.multi_view_btn = QPushButton("Sync-Play All Views") - self.multi_view_btn.setEnabled(False) - control_layout.addWidget(self.play_btn) - control_layout.addWidget(self.multi_view_btn) - layout.addLayout(control_layout) - - # Navigation - nav_layout = QHBoxLayout() - self.prev_action = QPushButton("Prev Action") - self.prev_clip = QPushButton("Prev Clip") - self.next_clip = QPushButton("Next Clip") - self.next_action = QPushButton("Next Action") - nav_layout.addWidget(self.prev_action) - nav_layout.addWidget(self.prev_clip) - nav_layout.addWidget(self.next_clip) - nav_layout.addWidget(self.next_action) - layout.addLayout(nav_layout) - - def _clear_video_layout(self): - for vc in self.view_controls: - vc.player.stop(); vc.player.setVideoOutput(None); vc.player.deleteLater(); vc.deleteLater() - self.view_controls.clear() - while self.video_layout.count(): - item = self.video_layout.takeAt(0) - if item.widget(): item.widget().deleteLater() - elif item.layout(): - while item.layout().count(): - c = item.layout().takeAt(0) - if c.widget(): c.widget().deleteLater() - item.layout().deleteLater() - - def _media_state_changed(self, state): - icon = QStyle.StandardPixmap.SP_MediaPause if state == QMediaPlayer.PlaybackState.PlayingState else QStyle.StandardPixmap.SP_MediaPlay - self.play_btn.setIcon(self.style().standardIcon(icon)) - - def _setup_controls(self, view_control: VideoViewAndControl): - # Helper to connect slider updates - player = view_control.player - slider = view_control.slider - def update_dur(d): - view_control.total_duration = d - slider.setEnabled(d > 0) - view_control.time_label.setText(f"{self._fmt(player.position())} / {self._fmt(d)}") - def update_pos(p): - if view_control.total_duration > 0 and not slider.isSliderDown(): - slider.setValue(int((p/view_control.total_duration)*1000)) - view_control.time_label.setText(f"{self._fmt(p)} / {self._fmt(view_control.total_duration)}") - def seek(v): - if view_control.total_duration > 0: - player.setPosition(int((v/1000)*view_control.total_duration)) - player.durationChanged.connect(update_dur) - player.positionChanged.connect(update_pos) - slider.sliderMoved.connect(seek) - self.view_controls.append(view_control) - - def _fmt(self, ms): - t = QTime(0, 0); t = t.addMSecs(ms) - return t.toString('mm:ss') - - def show_single_view(self, clip_path): - self._clear_video_layout() - if not clip_path or not os.path.exists(clip_path): - self.play_btn.setEnabled(False) - msg = f"No media selected or file not found.\nPath: {clip_path}" if clip_path else "No media selected" - lbl = QLabel(msg); lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.video_layout.addWidget(lbl) - return - - ext = os.path.splitext(clip_path)[1].lower() - if ext in ('.mp4', '.avi', '.mov', '.wav', '.mp3', '.aac'): - vc = VideoViewAndControl(clip_path) - vc.player.setSource(QUrl.fromLocalFile(clip_path)) - vc.player.playbackStateChanged.connect(self._media_state_changed) - self._setup_controls(vc) - self.video_layout.addWidget(vc) - self.play_btn.setEnabled(True) - if ext in ('.wav', '.mp3', '.aac'): vc.video_widget.setStyleSheet("background-color: black;") - vc.player.play() - elif ext in ('.jpg', '.jpeg', '.png', '.bmp'): - pix = QPixmap(clip_path) - if pix.isNull(): - lbl = QLabel(f"Failed to load image:\n{os.path.basename(clip_path)}"); lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.video_layout.addWidget(lbl) - else: - self.video_layout.addWidget(self.ImageScrollArea(pix)) - self.play_btn.setEnabled(False) - self._media_state_changed(QMediaPlayer.PlaybackState.StoppedState) - else: - lbl = QLabel(f"Unsupported file type:\n{os.path.basename(clip_path)}"); lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.video_layout.addWidget(lbl) - self.play_btn.setEnabled(False) - - def show_all_views(self, clip_paths): - self._clear_video_layout() - grid_widget = QWidget(); grid_layout = QGridLayout(grid_widget) - num = len(clip_paths) - if num == 0: return - cols = 2 if num > 1 else 1 - - for i, path in enumerate(clip_paths): - vc = VideoViewAndControl(path) - vc.player.setSource(QUrl.fromLocalFile(path)) - self._setup_controls(vc) - grid_layout.addWidget(vc, i // cols, i % cols) - if i == 0: vc.player.playbackStateChanged.connect(self._media_state_changed) - - scroll = QScrollArea(); scroll.setWidgetResizable(True); scroll.setWidget(grid_widget) - self.video_layout.addWidget(scroll) - if self.view_controls: - self.play_btn.setEnabled(True) - self.toggle_play_pause() - - def toggle_play_pause(self): - if not self.view_controls: return - is_playing = self.view_controls[0].player.playbackState() == QMediaPlayer.PlaybackState.PlayingState - for vc in self.view_controls: - if is_playing: vc.player.pause() - else: vc.player.play() - -# ============================================================================= -# 3. Right Panel (Classification Mode) - 保持原样 -# ============================================================================= -class RightPanel(QWidget): - style_mode_changed = pyqtSignal(str) - add_head_clicked = pyqtSignal(str) - remove_head_clicked = pyqtSignal(str) - - def __init__(self, parent=None): - super().__init__(parent) - self.setFixedWidth(400) - self.label_groups = {} - - main_l = QVBoxLayout(self) - main_l.setContentsMargins(0, 0, 0, 0) - - # Header - h_widget = QWidget() - h = QHBoxLayout(h_widget); h.setContentsMargins(0, 0, 0, 0) - title = QLabel("Annotation"); title.setObjectName("titleLabel") - self.mode_btn = QPushButton("Day Mode"); self.mode_btn.setObjectName("modeToggleButton") - self.mode_btn.setFixedSize(QSize(100, 30)) - self.mode_btn.clicked.connect(self._toggle_mode) - h.addWidget(title); h.addStretch(); h.addWidget(self.mode_btn) - main_l.addWidget(h_widget) - - # Scroll - scroll = QScrollArea(); scroll.setWidgetResizable(True); scroll.setFrameShape(QScrollArea.Shape.NoFrame) - content = QWidget(); scroll.setWidget(content) - self.c_layout = QVBoxLayout(content) - - self.task_label = QLabel("Task: N/A"); self.task_label.setObjectName("subtitleLabel") - self.c_layout.addWidget(self.task_label) - - # Annotation Content - self.content_widget = QWidget() - self.annot_layout = QVBoxLayout(self.content_widget); self.annot_layout.setContentsMargins(0, 0, 0, 0) - - self.manual_box = QGroupBox("Manual Annotation"); self.manual_box.setEnabled(False) - mbox_l = QVBoxLayout(self.manual_box) - - self.dyn_container = QWidget(); self.dyn_layout = QVBoxLayout(self.dyn_container); self.dyn_layout.setContentsMargins(0,0,0,0) - mbox_l.addWidget(self.dyn_container) - - # Add Cat - cat_w = QWidget(); cat_l = QHBoxLayout(cat_w); cat_l.setContentsMargins(0, 20, 0, 10) - self.new_head_edit = QLineEdit(); self.new_head_edit.setPlaceholderText("New Category Name") - self.add_head_btn = QPushButton("Add Category") - self.add_head_btn.clicked.connect(lambda: self.add_head_clicked.emit(self.new_head_edit.text())) - cat_l.addWidget(self.new_head_edit, 1); cat_l.addWidget(self.add_head_btn) - mbox_l.addWidget(cat_w) - - mbox_l.addStretch() - - btns = QHBoxLayout() - self.confirm_btn = QPushButton("Confirm Annotation") - self.clear_sel_btn = QPushButton("Clear Selection") - btns.addWidget(self.confirm_btn); btns.addWidget(self.clear_sel_btn) - mbox_l.addLayout(btns) - - self.annot_layout.addWidget(self.manual_box) - self.c_layout.addWidget(self.content_widget) - self.c_layout.addStretch() - main_l.addWidget(scroll, 1) - - # Bottom Save - bot = QHBoxLayout() - self.save_btn = QPushButton("Save"); self.save_btn.setEnabled(False) - self.export_btn = QPushButton("Export"); self.export_btn.setEnabled(False) - bot.addWidget(self.save_btn); bot.addWidget(self.export_btn) - main_l.addLayout(bot) - - def _toggle_mode(self): - txt = self.mode_btn.text() - self.mode_btn.setText("Night Mode" if txt == "Day Mode" else "Day Mode") - self.style_mode_changed.emit("Day" if txt == "Day Mode" else "Night") - - def setup_dynamic_labels(self, definitions): - while self.dyn_layout.count(): - item = self.dyn_layout.takeAt(0) - if item.widget(): item.widget().deleteLater() - - self.label_groups.clear() - for head, dfn in definitions.items(): - if dfn.get("type") == "single_label": - g = DynamicSingleLabelGroup(head, dfn, self.dyn_container) - g.remove_category_signal.connect(self.remove_head_clicked.emit) - self.label_groups[head] = g - self.dyn_layout.addWidget(g) - elif dfn.get("type") == "multi_label": - g = DynamicMultiLabelGroup(head, dfn, self.dyn_container) - g.remove_category_signal.connect(self.remove_head_clicked.emit) - self.label_groups[head] = g - self.dyn_layout.addWidget(g) - self.dyn_layout.addStretch() - - def get_annotation(self): - res = {} - for head, g in self.label_groups.items(): - if isinstance(g, DynamicSingleLabelGroup): res[head] = g.get_checked_label() - elif isinstance(g, DynamicMultiLabelGroup): res[head] = g.get_checked_labels() - return res - - def clear_selection(self): - for g in self.label_groups.values(): - if isinstance(g, DynamicSingleLabelGroup): g.set_checked_label(None) - elif isinstance(g, DynamicMultiLabelGroup): g.set_checked_labels([]) - - def set_annotation(self, data): - self.clear_selection() - for head, val in data.items(): - if head in self.label_groups: - g = self.label_groups[head] - if isinstance(g, DynamicSingleLabelGroup) and isinstance(val, str): g.set_checked_label(val) - elif isinstance(g, DynamicMultiLabelGroup) and isinstance(val, list): g.set_checked_labels(val) - -# ============================================================================= -# 4. Welcome Widget (Added) -# ============================================================================= -class WelcomeWidget(QWidget): - def __init__(self, parent=None): - super().__init__(parent) - layout = QVBoxLayout(self) - layout.setAlignment(Qt.AlignmentFlag.AlignCenter) - layout.setSpacing(30) - - title = QLabel("SoccerNet Pro Analysis Tool") - title.setStyleSheet("font-size: 32px; font-weight: bold; color: #00AACC;") - title.setAlignment(Qt.AlignmentFlag.AlignCenter) - layout.addWidget(title) - - subtitle = QLabel("Select an option to start") - subtitle.setStyleSheet("font-size: 18px; color: #888;") - subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter) - layout.addWidget(subtitle) - - btn_layout = QHBoxLayout() - btn_layout.setSpacing(40) - btn_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) - - # Style for big buttons - btn_style = """ - QPushButton { - font-size: 16px; - font-weight: bold; - padding: 15px 30px; - border-radius: 8px; - } - """ - - self.import_btn = QPushButton("Import JSON Project") - self.import_btn.setCursor(Qt.CursorShape.PointingHandCursor) - self.import_btn.setStyleSheet(btn_style) - - self.create_btn = QPushButton("Create New Project") - self.create_btn.setCursor(Qt.CursorShape.PointingHandCursor) - self.create_btn.setStyleSheet(btn_style) - - btn_layout.addWidget(self.import_btn) - btn_layout.addWidget(self.create_btn) - - layout.addLayout(btn_layout) - -# ============================================================================= -# 5. Main Window UI (Refactored to StackedLayout) -# ============================================================================= -class MainWindowUI(QWidget): - def __init__(self, parent=None): - super().__init__(parent) - - # Stack to hold [WelcomeScreen, ClassificationInterface, LocalizationInterface] - self.stack_layout = QStackedLayout(self) - - # --- Index 0: Welcome Screen --- - self.welcome_widget = WelcomeWidget() - self.stack_layout.addWidget(self.welcome_widget) - - # --- Index 1: Classification Interface (Original UI) --- - self.classification_container = QWidget() - self.class_h_layout = QHBoxLayout(self.classification_container) - self.class_h_layout.setContentsMargins(0, 0, 0, 0) - self.class_h_layout.setSpacing(0) - - self.left_panel = LeftPanel() - self.center_panel = CenterPanel() - self.right_panel = RightPanel() - - self.class_h_layout.addWidget(self.left_panel) - self.class_h_layout.addWidget(self.center_panel, 1) - self.class_h_layout.addWidget(self.right_panel) - - self.stack_layout.addWidget(self.classification_container) - - # --- Index 2: Localization Interface (NEW UI) --- - self.localization_ui = LocalizationUI() - self.stack_layout.addWidget(self.localization_ui) - - # Start at Welcome Screen - self.stack_layout.setCurrentIndex(0) - - def show_classification_view(self): - """Switches to the standard classification view (Left/Center/Right panels).""" - self.stack_layout.setCurrentWidget(self.classification_container) - - def show_main_view(self): - """Alias for show_classification_view (for compatibility).""" - self.show_classification_view() - - def show_localization_view(self): - """Switches to the new localization/spotting view.""" - self.stack_layout.setCurrentWidget(self.localization_ui) - - def show_welcome_view(self): - """Switches back to the welcome screen.""" - self.stack_layout.setCurrentWidget(self.welcome_widget) \ No newline at end of file diff --git a/Tool/ui/readme.md b/Tool/ui/readme.md deleted file mode 100644 index d70cf9a..0000000 --- a/Tool/ui/readme.md +++ /dev/null @@ -1 +0,0 @@ -UI components for Classification diff --git a/Tool/ui/widgets.py b/Tool/ui/widgets.py deleted file mode 100644 index 406ff8e..0000000 --- a/Tool/ui/widgets.py +++ /dev/null @@ -1,248 +0,0 @@ -import os -from PyQt6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QSlider, QLabel, QButtonGroup, - QRadioButton, QCheckBox, QGroupBox, QLineEdit, QPushButton, QStyle, - QScrollArea -) -from PyQt6.QtMultimedia import QMediaPlayer -from PyQt6.QtMultimediaWidgets import QVideoWidget -from PyQt6.QtCore import Qt, QUrl, QTime, pyqtSignal, QSize -from PyQt6.QtGui import QPixmap -from utils import get_square_remove_btn_style - -class VideoViewAndControl(QWidget): - """Wraps a QVideoWidget and its controls (Slider/Label).""" - def __init__(self, clip_path, parent=None): - super().__init__(parent) - self.clip_path = clip_path - self.player = QMediaPlayer() - self.player.setLoops(QMediaPlayer.Loops.Infinite) - self.video_widget = QVideoWidget() - self.player.setVideoOutput(self.video_widget) - - self.slider = QSlider(Qt.Orientation.Horizontal) - self.slider.setRange(0, 1000) - self.slider.setEnabled(False) - self.clip_name = os.path.basename(clip_path) if clip_path else "No Clip" - self.time_label = QLabel(f"00:00 / 00:00") - self.time_label.setFixedWidth(100) - - self.v_layout = QVBoxLayout(self) - self.v_layout.setContentsMargins(0, 0, 0, 0) - self.v_layout.addWidget(self.video_widget, 1) - - h_control_layout = QHBoxLayout() - h_control_layout.addWidget(self.time_label) - h_control_layout.addWidget(self.slider) - self.v_layout.addLayout(h_control_layout) - - self.total_duration = 0 - -class DynamicSingleLabelGroup(QWidget): - - remove_category_signal = pyqtSignal(str) - remove_label_signal = pyqtSignal(str) - value_changed = pyqtSignal(str, object) - - def __init__(self, label_head_name, label_type_definition, parent=None): - super().__init__(parent) - self.head_name = label_head_name - self.definition = label_type_definition - self.radio_buttons = {} - self.button_group = QButtonGroup(self) - self.button_group.setExclusive(True) - - self.v_layout = QVBoxLayout(self) - self.v_layout.setContentsMargins(0, 5, 0, 5) - - header_widget = QWidget() - header_layout = QHBoxLayout(header_widget) - header_layout.setContentsMargins(0, 0, 0, 0) - - self.label_title = QLabel(f"{self.head_name.replace('_', ' ').title()}:") - self.label_title.setObjectName("subtitleLabel") - - self.trash_btn = QPushButton() - self.trash_btn.setFixedSize(24, 24) - self.trash_btn.setFlat(True) - self.trash_btn.setCursor(Qt.CursorShape.PointingHandCursor) - self.trash_btn.setToolTip("Remove this category") - self.trash_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_TrashIcon)) - self.trash_btn.clicked.connect(self._on_remove_category_clicked) - - header_layout.addWidget(self.label_title) - header_layout.addStretch() - header_layout.addWidget(self.trash_btn) - - self.v_layout.addWidget(header_widget) - - self.radio_container = QWidget() - self.radio_layout = QVBoxLayout(self.radio_container) - self.radio_layout.setContentsMargins(0, 0, 0, 0) - self.v_layout.addWidget(self.radio_container) - - self.manager_group = QGroupBox() - self.manager_group.setFlat(True) - v_manager_layout = QVBoxLayout(self.manager_group) - v_manager_layout.setContentsMargins(0, 10, 0, 5) - - h_add_layout = QHBoxLayout() - self.input_field = QLineEdit() - self.input_field.setPlaceholderText(f"New {self.head_name} type...") - self.add_btn = QPushButton("Add") - h_add_layout.addWidget(self.input_field, 1) - h_add_layout.addWidget(self.add_btn) - - v_manager_layout.addLayout(h_add_layout) - self.v_layout.addWidget(self.manager_group) - - self.update_radios(self.definition.get("labels", [])) - - def _on_remove_category_clicked(self): - self.remove_category_signal.emit(self.head_name) - - def update_radios(self, new_types): - self.button_group.setExclusive(False) - while self.radio_layout.count(): - item = self.radio_layout.takeAt(0) - if item.widget(): item.widget().deleteLater() - - self.radio_buttons.clear() - sorted_types = sorted(list(set(new_types))) - - for type_name in sorted_types: - row_widget = QWidget() - row_layout = QHBoxLayout(row_widget) - row_layout.setContentsMargins(0, 2, 0, 2) - - rb = QRadioButton(type_name) - rb.clicked.connect(self._on_radio_clicked) - self.radio_buttons[type_name] = rb - self.button_group.addButton(rb) - - del_label_btn = QPushButton("×") - del_label_btn.setFixedSize(20, 20) - del_label_btn.setCursor(Qt.CursorShape.PointingHandCursor) - del_label_btn.setStyleSheet(get_square_remove_btn_style()) - del_label_btn.clicked.connect(lambda _, n=type_name: self.remove_label_signal.emit(n)) - - row_layout.addWidget(rb) - row_layout.addStretch() - row_layout.addWidget(del_label_btn) - self.radio_layout.addWidget(row_widget) - - self.button_group.setExclusive(True) - - def _on_radio_clicked(self): - self.value_changed.emit(self.head_name, self.get_checked_label()) - - def get_checked_label(self): - checked_btn = self.button_group.checkedButton() - return checked_btn.text() if checked_btn else None - - def set_checked_label(self, label_name): - self.blockSignals(True) - self.button_group.setExclusive(False) - for rb in self.radio_buttons.values(): rb.setChecked(False) - self.button_group.setExclusive(True) - if label_name in self.radio_buttons: - self.radio_buttons[label_name].setChecked(True) - self.blockSignals(False) - -class DynamicMultiLabelGroup(QWidget): - - remove_category_signal = pyqtSignal(str) - remove_label_signal = pyqtSignal(str) - value_changed = pyqtSignal(str, object) - - def __init__(self, label_head_name, label_type_definition, parent=None): - super().__init__(parent) - self.head_name = label_head_name - self.definition = label_type_definition - self.checkboxes = {} - - self.v_layout = QVBoxLayout(self) - self.v_layout.setContentsMargins(0, 5, 0, 5) - - header_widget = QWidget() - header_layout = QHBoxLayout(header_widget) - header_layout.setContentsMargins(0, 0, 0, 0) - - self.label_title = QLabel(f"{self.head_name.replace('_', ' ').title()}:") - self.label_title.setObjectName("subtitleLabel") - - self.trash_btn = QPushButton() - self.trash_btn.setFixedSize(24, 24) - self.trash_btn.setFlat(True) - self.trash_btn.setCursor(Qt.CursorShape.PointingHandCursor) - self.trash_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_TrashIcon)) - self.trash_btn.clicked.connect(self._on_remove_category_clicked) - - header_layout.addWidget(self.label_title) - header_layout.addStretch() - header_layout.addWidget(self.trash_btn) - - self.v_layout.addWidget(header_widget) - - self.checkbox_container = QWidget() - self.checkbox_layout = QVBoxLayout(self.checkbox_container) - self.checkbox_layout.setContentsMargins(0, 0, 0, 0) - self.v_layout.addWidget(self.checkbox_container) - - self.manager_group = QGroupBox() - self.manager_group.setFlat(True) - h_layout = QHBoxLayout(self.manager_group) - h_layout.setContentsMargins(0, 10, 0, 5) - - self.input_field = QLineEdit() - self.input_field.setPlaceholderText(f"New {self.head_name} type...") - self.add_btn = QPushButton("Add") - - h_layout.addWidget(self.input_field, 1) - h_layout.addWidget(self.add_btn) - self.v_layout.addWidget(self.manager_group) - self.update_checkboxes(self.definition.get("labels", [])) - - def _on_remove_category_clicked(self): - self.remove_category_signal.emit(self.head_name) - - def update_checkboxes(self, new_types): - while self.checkbox_layout.count(): - item = self.checkbox_layout.takeAt(0) - if item.widget(): item.widget().deleteLater() - - self.checkboxes.clear() - - for type_name in sorted(list(set(new_types))): - row_widget = QWidget() - row_layout = QHBoxLayout(row_widget) - row_layout.setContentsMargins(0, 2, 0, 2) - - cb = QCheckBox(type_name) - cb.clicked.connect(self._on_box_clicked) - self.checkboxes[type_name] = cb - - del_label_btn = QPushButton("×") - del_label_btn.setFixedSize(20, 20) - del_label_btn.setCursor(Qt.CursorShape.PointingHandCursor) - del_label_btn.setStyleSheet(get_square_remove_btn_style()) - del_label_btn.clicked.connect(lambda _, n=type_name: self.remove_label_signal.emit(n)) - - row_layout.addWidget(cb) - row_layout.addStretch() - row_layout.addWidget(del_label_btn) - self.checkbox_layout.addWidget(row_widget) - - def _on_box_clicked(self): - self.value_changed.emit(self.head_name, self.get_checked_labels()) - - def get_checked_labels(self): - return [cb.text() for cb in self.checkboxes.values() if cb.isChecked()] - - def set_checked_labels(self, label_list): - self.blockSignals(True) - if label_list is None: label_list = [] - checked_set = set(label_list) - for cb_name, cb in self.checkboxes.items(): - cb.setChecked(cb_name in checked_set) - self.blockSignals(False) \ No newline at end of file diff --git a/Tool/ui2/panels.py b/Tool/ui2/panels.py deleted file mode 100644 index 2126d85..0000000 --- a/Tool/ui2/panels.py +++ /dev/null @@ -1,130 +0,0 @@ -import os -from PyQt6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QTreeWidget, QTreeWidgetItem, - QLabel, QComboBox, QScrollArea, QGroupBox, QLineEdit, QMenu, QStyle, QGridLayout, - QFrame, QStackedLayout -) -from PyQt6.QtCore import Qt, pyqtSignal, QSize, QUrl, QTime -from PyQt6.QtGui import QAction, QColor, QPixmap -from PyQt6.QtMultimedia import QMediaPlayer - -# 引入基础组件 -# 注意:请根据您的实际目录结构调整引用。如果 ui2 文件夹下有 widgets 文件夹: -from .widgets.left_widgets import ProjectControlsWidget -from .widgets.center_widgets import MediaPreviewWidget, TimelineWidget, PlaybackControlBar -from .widgets.right_widgets import AnnotationManagementWidget, AnnotationTableWidget - -class LocLeftPanel(QWidget): - def __init__(self, parent=None): - super().__init__(parent) - self.setFixedWidth(300) - layout = QVBoxLayout(self) - - # 1. Project Controls - self.project_controls = ProjectControlsWidget() - - # 2. Clip List Tree - self.clip_tree_label = QLabel("Clips / Sequences") - self.clip_tree_label.setStyleSheet("font-weight: bold; color: #888; margin-top: 10px;") - self.clip_tree = QTreeWidget() - self.clip_tree.setHeaderHidden(True) - - # 3. Filter & Clear All - self.filter_label = QLabel("Filter:") - self.filter_combo = QComboBox() - self.filter_combo.addItems(["Show All", "Show Labelled", "No Labelled"]) - - # Clear All 按钮 - [修改] 移除红色样式,使其与 Export JSON 保持一致 - self.btn_clear_all = QPushButton("Clear All") - self.btn_clear_all.setCursor(Qt.CursorShape.PointingHandCursor) - self.btn_clear_all.setFixedWidth(80) - # 这里不再设置特殊的 setStyleSheet,让它使用全局样式 - - filter_row = QHBoxLayout() - filter_row.addWidget(self.filter_label) - filter_row.addWidget(self.filter_combo, 1) # Stretch combo - filter_row.addWidget(self.btn_clear_all) # Add button at the end - - layout.addWidget(self.project_controls) - layout.addWidget(self.clip_tree_label) - layout.addWidget(self.clip_tree, 1) - layout.addLayout(filter_row) - - -class LocCenterPanel(QWidget): - def __init__(self, parent=None): - super().__init__(parent) - layout = QVBoxLayout(self) - - self.media_preview = MediaPreviewWidget() - self.timeline = TimelineWidget() - self.playback = PlaybackControlBar() - - layout.addWidget(self.media_preview, 1) - layout.addWidget(self.timeline) - layout.addWidget(self.playback) - -class LocRightPanel(QWidget): - def __init__(self, parent=None): - super().__init__(parent) - self.setFixedWidth(400) - layout = QVBoxLayout(self) - - # --- Undo/Redo 按钮区域 --- - header_layout = QHBoxLayout() - header_layout.setContentsMargins(0, 0, 0, 5) - - lbl = QLabel("Annotation Controls") - lbl.setStyleSheet("font-weight: bold; color: #BBB; font-size: 13px;") - - self.undo_btn = QPushButton("Undo") - self.redo_btn = QPushButton("Redo") - - # 按钮样式 - btn_style = """ - QPushButton { - background-color: #444; color: #DDD; - border: 1px solid #555; border-radius: 4px; padding: 4px 10px; - font-weight: bold; - } - QPushButton:hover { background-color: #555; border-color: #777; } - QPushButton:pressed { background-color: #333; } - QPushButton:disabled { color: #777; background-color: #333; border-color: #444; } - """ - for btn in [self.undo_btn, self.redo_btn]: - btn.setCursor(Qt.CursorShape.PointingHandCursor) - btn.setStyleSheet(btn_style) - btn.setFixedWidth(60) - btn.setEnabled(False) # 初始禁用 - - header_layout.addWidget(lbl) - header_layout.addStretch() - header_layout.addWidget(self.undo_btn) - header_layout.addWidget(self.redo_btn) - - layout.addLayout(header_layout) - # ----------------------------------- - - # 1. 顶部:多 Head 管理 + 标签打点区域 - self.annot_mgmt = AnnotationManagementWidget() - - # 2. 底部:已标注事件列表 - self.table = AnnotationTableWidget() - - layout.addWidget(self.annot_mgmt, 3) - layout.addWidget(self.table, 2) - -class LocalizationUI(QWidget): - def __init__(self, parent=None): - super().__init__(parent) - layout = QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(5) - - self.left_panel = LocLeftPanel() - self.center_panel = LocCenterPanel() - self.right_panel = LocRightPanel() - - layout.addWidget(self.left_panel) - layout.addWidget(self.center_panel, 1) - layout.addWidget(self.right_panel) diff --git a/Tool/ui2/readme.md b/Tool/ui2/readme.md deleted file mode 100644 index 49b24dd..0000000 --- a/Tool/ui2/readme.md +++ /dev/null @@ -1 +0,0 @@ -UI components of Localization part diff --git a/Tool/ui2/widgets/left_widgets.py b/Tool/ui2/widgets/left_widgets.py deleted file mode 100644 index ce436a1..0000000 --- a/Tool/ui2/widgets/left_widgets.py +++ /dev/null @@ -1,143 +0,0 @@ -from PyQt6.QtWidgets import ( - QWidget, QVBoxLayout, QGroupBox, QPushButton, - QTreeWidget, QTreeWidgetItem, QComboBox, QHBoxLayout, - QLabel, QGridLayout, QFileDialog, QMessageBox -) -from PyQt6.QtCore import pyqtSignal, Qt - -class ProjectControlsWidget(QWidget): - loadRequested = pyqtSignal() - addVideoRequested = pyqtSignal() - saveRequested = pyqtSignal() - exportRequested = pyqtSignal() - - def __init__(self, parent=None): - super().__init__(parent) - layout = QVBoxLayout(self) - - group = QGroupBox("Project Controls") - - # [修改] 优化 Grid 布局参数 - grid_layout = QGridLayout(group) - - # 1. 增加按钮之间的间距 (水平和垂直) - grid_layout.setSpacing(10) - - # 2. 增加 GroupBox 内部的边距 (左, 上, 右, 下) - grid_layout.setContentsMargins(10, 20, 10, 10) - - self.btn_load = QPushButton("Load JSON") - self.btn_add = QPushButton("Add Video") - self.btn_save = QPushButton("Save JSON") - self.btn_export = QPushButton("Export JSON") - - self.btn_save.setEnabled(False) - self.btn_export.setEnabled(False) - - # [修改] 设置按钮的最小高度和样式,使其不那么紧缩 - btns = [self.btn_load, self.btn_add, self.btn_save, self.btn_export] - for btn in btns: - btn.setMinimumHeight(35) # 增加高度 - btn.setCursor(Qt.CursorShape.PointingHandCursor) - # 可选:增加一点圆角让界面更柔和 - btn.setStyleSheet(""" - QPushButton { - border-radius: 6px; - padding: 5px; - background-color: #444; - color: #EEE; - border: 1px solid #555; - } - QPushButton:hover { background-color: #555; border-color: #777; } - QPushButton:pressed { background-color: #0078D7; border-color: #0078D7; } - QPushButton:disabled { background-color: #333; color: #777; border-color: #333; } - """) - - # 添加到网格位置 (行, 列) - grid_layout.addWidget(self.btn_load, 0, 0) - grid_layout.addWidget(self.btn_add, 0, 1) - grid_layout.addWidget(self.btn_save, 1, 0) - grid_layout.addWidget(self.btn_export, 1, 1) - - layout.addWidget(group) - - # Connect signals - self.btn_load.clicked.connect(self.loadRequested.emit) - self.btn_add.clicked.connect(self.addVideoRequested.emit) - self.btn_save.clicked.connect(self.saveRequested.emit) - self.btn_export.clicked.connect(self.exportRequested.emit) - - def set_project_loaded_state(self, loaded: bool): - self.btn_save.setEnabled(loaded) - self.btn_export.setEnabled(loaded) - -# --- 保留 ActionListWidget 定义以防引用报错 (虽然 UI 中已移除) --- -class ActionListWidget(QWidget): - labelSelected = pyqtSignal(str, str) - listCleared = pyqtSignal() - - def __init__(self, parent=None): - super().__init__(parent) - layout = QVBoxLayout(self) - - group = QGroupBox("Action List") - v_layout = QVBoxLayout(group) - - self.tree = QTreeWidget() - self.tree.setHeaderHidden(True) - self.tree.itemClicked.connect(self._on_item_clicked) - v_layout.addWidget(self.tree) - - h_layout = QHBoxLayout() - self.filter_combo = QComboBox() - self.filter_combo.addItem("All") - self.filter_combo.currentTextChanged.connect(self._apply_filter) - - self.btn_clear = QPushButton("Clear Selection") - self.btn_clear.clicked.connect(self._on_clear_clicked) - - h_layout.addWidget(self.filter_combo) - h_layout.addWidget(self.btn_clear) - v_layout.addLayout(h_layout) - - layout.addWidget(group) - - def set_labels(self, labels: dict): - self.tree.clear() - self.filter_combo.blockSignals(True) - self.filter_combo.clear() - self.filter_combo.addItem("All") - - for head, defn in labels.items(): - self.filter_combo.addItem(head) - head_item = QTreeWidgetItem(self.tree, [head]) - head_item.setFlags(head_item.flags() & ~Qt.ItemFlag.ItemIsSelectable) - head_item.setExpanded(True) - font = head_item.font(0) - font.setBold(True) - head_item.setFont(0, font) - - items_list = defn.get('labels', []) - for lbl in items_list: - item = QTreeWidgetItem(head_item, [lbl]) - item.setData(0, Qt.ItemDataRole.UserRole, head) - - self.filter_combo.blockSignals(False) - - def _on_item_clicked(self, item, col): - head = item.data(0, Qt.ItemDataRole.UserRole) - if head: - label = item.text(0) - self.labelSelected.emit(head, label) - - def _apply_filter(self, text): - root = self.tree.invisibleRootItem() - for i in range(root.childCount()): - head_item = root.child(i) - head_name = head_item.text(0) - hidden = (text != "All" and text != head_name) - head_item.setHidden(hidden) - - def _on_clear_clicked(self): - self.tree.clearSelection() - self.listCleared.emit() diff --git a/Tool/ui2/widgets/readme.md b/Tool/ui2/widgets/readme.md deleted file mode 100644 index 685d4bc..0000000 --- a/Tool/ui2/widgets/readme.md +++ /dev/null @@ -1 +0,0 @@ -Widgets: Modular components diff --git a/Tool/ui2/widgets/right_widgets.py b/Tool/ui2/widgets/right_widgets.py deleted file mode 100644 index b714d57..0000000 --- a/Tool/ui2/widgets/right_widgets.py +++ /dev/null @@ -1,552 +0,0 @@ -from PyQt6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QTabWidget, - QGridLayout, QLabel, QScrollArea, QMenu, QInputDialog, QMessageBox, - QSizePolicy, QFrame, QTableView, QHeaderView, QDialog, QComboBox, - QDialogButtonBox, QFormLayout, QTimeEdit, QLineEdit -) -from PyQt6.QtCore import pyqtSignal, Qt, QPoint, QTime, QAbstractTableModel - -# ==================== Table Model (嵌入以确保完整性) ==================== -class AnnotationTableModel(QAbstractTableModel): - def __init__(self, annotations=None): - super().__init__() - self._data = annotations or [] - # [修改] 移除了 "Del" 列,只保留数据列 - self._headers = ["Time", "Head", "Label"] - - def rowCount(self, parent=None): - return len(self._data) - - def columnCount(self, parent=None): - return len(self._headers) - - def data(self, index, role=Qt.ItemDataRole.DisplayRole): - if not index.isValid(): - return None - - row = index.row() - item = self._data[row] - - if role == Qt.ItemDataRole.DisplayRole: - col = index.column() - if col == 0: - return self._fmt_ms(item.get('position_ms', 0)) - elif col == 1: - # 显示时去下划线 - return item.get('head', '').replace('_', ' ') - elif col == 2: - # 显示时去下划线 - return item.get('label', '').replace('_', ' ') - - # [新增] 存储原始数据 UserRole,方便逻辑获取 - elif role == Qt.ItemDataRole.UserRole: - return item - - return None - - def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole): - if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole: - return self._headers[section] - return None - - def set_annotations(self, annotations): - self.beginResetModel() - self._data = annotations - self.endResetModel() - - def get_annotation_at(self, row): - if 0 <= row < len(self._data): - return self._data[row] - return None - - def _fmt_ms(self, ms): - s = ms // 1000 - m = s // 60 - return f"{m:02}:{s%60:02}.{ms%1000:03}" - -# ==================== Widgets ==================== - -class LabelButton(QPushButton): - """自定义标签按钮""" - rightClicked = pyqtSignal() - doubleClicked = pyqtSignal() - - def __init__(self, text, parent=None): - super().__init__(text, parent) - self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) - self.setCursor(Qt.CursorShape.PointingHandCursor) - self.setMinimumHeight(40) - self.setStyleSheet(""" - QPushButton { - background-color: #444; - color: white; - border: 1px solid #555; - border-radius: 6px; - font-weight: bold; - font-size: 13px; - text-align: center; - padding: 4px; - } - QPushButton:hover { background-color: #555; border-color: #777; } - QPushButton:pressed { background-color: #0078D7; border-color: #0078D7; } - """) - - def mousePressEvent(self, event): - if event.button() == Qt.MouseButton.RightButton: - self.rightClicked.emit() - else: - super().mousePressEvent(event) - - def mouseDoubleClickEvent(self, event): - if event.button() == Qt.MouseButton.LeftButton: - self.doubleClicked.emit() - else: - super().mouseDoubleClickEvent(event) - -class HeadSpottingPage(QWidget): - labelClicked = pyqtSignal(str) - addLabelRequested = pyqtSignal() - renameLabelRequested = pyqtSignal(str) - deleteLabelRequested = pyqtSignal(str) - - def __init__(self, head_name, labels, parent=None): - super().__init__(parent) - self.head_name = head_name - self.labels = labels - - layout = QVBoxLayout(self) - layout.setContentsMargins(5, 5, 5, 5) - layout.setSpacing(10) - - self.time_label = QLabel("Current Time: 00:00.000") - self.time_label.setStyleSheet("color: #00BFFF; font-weight: bold; font-family: monospace; font-size: 14px;") - self.time_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - layout.addWidget(self.time_label) - - scroll = QScrollArea() - scroll.setWidgetResizable(True) - scroll.setFrameShape(QScrollArea.Shape.NoFrame) - scroll.setStyleSheet("background: transparent;") - - 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.setWidget(self.grid_container) - layout.addWidget(scroll) - - self._populate_grid() - - def update_time_display(self, text): - self.time_label.setText(f"Current Time: {text}") - - def refresh_labels(self, new_labels): - self.labels = new_labels - self._populate_grid() - - def _populate_grid(self): - while self.grid_layout.count(): - item = self.grid_layout.takeAt(0) - if item.widget(): - item.widget().deleteLater() - - cols = 2 - row, col = 0, 0 - - 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 - - add_btn = QPushButton("Add new label at current time") - add_btn.setCursor(Qt.CursorShape.PointingHandCursor) - add_btn.setMinimumHeight(45) - add_btn.setStyleSheet(""" - QPushButton { - background-color: #0078D7; - color: white; - border: 1px solid #005A9E; - border-radius: 6px; - font-weight: bold; - font-size: 13px; - } - QPushButton:hover { background-color: #1084E3; border-color: #2094F3; } - QPushButton:pressed { background-color: #005A9E; } - """) - add_btn.clicked.connect(self.addLabelRequested.emit) - - if col != 0: - row += 1 - self.grid_layout.addWidget(add_btn, row, 0, 1, 2) - - def _show_context_menu(self, label): - display_label = label.replace('_', ' ') - menu = QMenu(self) - rename_action = menu.addAction(f"Rename '{display_label}'") - delete_action = menu.addAction(f"Delete '{display_label}'") - - action = menu.exec(self.cursor().pos()) - if action == rename_action: - self.renameLabelRequested.emit(label) - elif action == delete_action: - self.deleteLabelRequested.emit(label) - - -class SpottingTabWidget(QTabWidget): - headAdded = pyqtSignal(str) - headRenamed = pyqtSignal(str, str) - headDeleted = pyqtSignal(str) - headSelected = pyqtSignal(str) - spottingTriggered = pyqtSignal(str, str) - labelAddReq = pyqtSignal(str) - labelRenameReq = pyqtSignal(str, str) - labelDeleteReq = pyqtSignal(str, str) - - def __init__(self, parent=None): - super().__init__(parent) - self.setTabBarAutoHide(False) - self.setMovable(False) - self.setTabsClosable(False) - self.setStyleSheet(""" - QTabWidget::pane { border: 1px solid #444; border-radius: 4px; background: #2E2E2E; } - QTabBar::tab { - background: #3A3A3A; color: #BBB; padding: 8px 12px; - border-top-left-radius: 4px; border-top-right-radius: 4px; - margin-right: 2px; - } - QTabBar::tab:selected { background: #2E2E2E; color: white; font-weight: bold; border-bottom: 2px solid #00BFFF; } - QTabBar::tab:hover { background: #444; color: white; } - """) - self.currentChanged.connect(self._on_tab_changed) - self.tabBar().setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) - self.tabBar().customContextMenuRequested.connect(self._show_tab_context_menu) - self._ignore_change = False - self._plus_tab_index = -1 - self._head_keys_map = [] - - def update_schema(self, label_definitions): - self._ignore_change = True - self.clear() - self._head_keys_map = [] - heads = sorted(label_definitions.keys()) - for head in heads: - labels = label_definitions[head].get('labels', []) - page = HeadSpottingPage(head, labels) - page.labelClicked.connect(lambda l, h=head: self.spottingTriggered.emit(h, l)) - page.addLabelRequested.connect(lambda h=head: self.labelAddReq.emit(h)) - page.renameLabelRequested.connect(lambda l, h=head: self.labelRenameReq.emit(h, l)) - page.deleteLabelRequested.connect(lambda l, h=head: self.labelDeleteReq.emit(h, l)) - display_head = head.replace('_', ' ') - self.addTab(page, display_head) - self._head_keys_map.append(head) - self._plus_tab_index = self.addTab(QWidget(), "+") - self._ignore_change = False - - def update_current_time(self, time_str): - current_widget = self.currentWidget() - if isinstance(current_widget, HeadSpottingPage): - current_widget.update_time_display(time_str) - - def set_current_head(self, head_name): - if head_name in self._head_keys_map: - idx = self._head_keys_map.index(head_name) - self.setCurrentIndex(idx) - - def _on_tab_changed(self, index): - if self._ignore_change: return - if index == self._plus_tab_index and index != -1: - self.setCurrentIndex(max(0, index - 1)) - self._handle_add_head() - else: - if 0 <= index < len(self._head_keys_map): - real_head = self._head_keys_map[index] - self.headSelected.emit(real_head) - - def _handle_add_head(self): - name, ok = QInputDialog.getText(self, "New Task Head", "Enter head name (e.g. 'player_action'):") - if ok and name.strip(): - self.headAdded.emit(name.strip()) - - def _show_tab_context_menu(self, pos): - index = self.tabBar().tabAt(pos) - if index == -1 or index == self._plus_tab_index: return - if 0 <= index < len(self._head_keys_map): - real_head_name = self._head_keys_map[index] - display_head_name = self.tabText(index) - menu = QMenu(self) - rename_act = menu.addAction(f"Rename '{display_head_name}'") - delete_act = menu.addAction(f"Delete '{display_head_name}'") - action = menu.exec(self.mapToGlobal(pos)) - if action == rename_act: - new_name, ok = QInputDialog.getText(self, "Rename Head", f"Rename '{real_head_name}' to:", text=real_head_name) - if ok and new_name.strip() and new_name != real_head_name: - self.headRenamed.emit(real_head_name, new_name.strip()) - elif action == delete_act: - self.headDeleted.emit(real_head_name) - -class AnnotationManagementWidget(QWidget): - def __init__(self, parent=None): - super().__init__(parent) - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(5) - title_label = QLabel("Create Annotation") - title_label.setStyleSheet("font-weight: bold; color: #888; margin-bottom: 2px;") - layout.addWidget(title_label) - self.tabs = SpottingTabWidget() - layout.addWidget(self.tabs) - - def update_schema(self, label_definitions): - self.tabs.update_schema(label_definitions) - -# ==================== [New] Edit Dialog & Table Widget ==================== - -class EditEventDialog(QDialog): - """ - 通用编辑对话框: - - 支持下拉选择现有的 (Head/Label) - - 包含 选项,选择后显示输入框添加新值 - """ - def __init__(self, current_value, existing_options, item_type="Head", parent=None): - super().__init__(parent) - self.setWindowTitle(f"Edit {item_type}") - self.resize(300, 150) - self.is_new = False - - layout = QVBoxLayout(self) - - # 1. ComboBox - layout.addWidget(QLabel(f"Select {item_type}:")) - self.combo = QComboBox() - - # 映射 Display (无下划线) -> Real Key (有下划线) - self.options_map = {} - - sorted_options = sorted(existing_options) - current_display = current_value.replace('_', ' ') - - for key in sorted_options: - display = key.replace('_', ' ') - self.options_map[display] = key - self.combo.addItem(display) - - self.combo.addItem("-- Create New --") - - # 选中当前值 - idx = self.combo.findText(current_display) - if idx >= 0: - self.combo.setCurrentIndex(idx) - else: - # 如果当前值不在列表中(比如是 "???" 占位符),不选中任何现有项,或者保持默认 - pass - - layout.addWidget(self.combo) - - # 2. Input Field (默认隐藏) - self.new_input_container = QWidget() - h_layout = QHBoxLayout(self.new_input_container) - h_layout.setContentsMargins(0, 5, 0, 5) - h_layout.addWidget(QLabel(f"New {item_type}:")) - self.line_edit = QLineEdit() - self.line_edit.setPlaceholderText(f"Enter new {item_type} name") - h_layout.addWidget(self.line_edit) - - self.new_input_container.setVisible(False) - layout.addWidget(self.new_input_container) - - # 逻辑连接 - self.combo.currentIndexChanged.connect(self._on_combo_change) - - # 按钮 - btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) - btns.accepted.connect(self.accept) - btns.rejected.connect(self.reject) - layout.addWidget(btns) - - def _on_combo_change(self): - txt = self.combo.currentText() - if txt == "-- Create New --": - self.new_input_container.setVisible(True) - self.line_edit.setFocus() - self.is_new = True - else: - self.new_input_container.setVisible(False) - self.is_new = False - - def get_value(self): - """返回 (value, is_created_new)""" - if self.is_new: - val = self.line_edit.text().strip() - return val - else: - display = self.combo.currentText() - return self.options_map.get(display, display) - - -class AnnotationTableWidget(QWidget): - annotationSelected = pyqtSignal(int) - annotationModified = pyqtSignal(dict, dict) # old_event, new_event - annotationDeleted = pyqtSignal(dict) - - def __init__(self, parent=None): - super().__init__(parent) - layout = QVBoxLayout(self) - - lbl = QLabel("Events List") - lbl.setStyleSheet("font-weight: bold; color: #888; margin-top: 10px;") - layout.addWidget(lbl) - - self.table = QTableView() - self.table.setStyleSheet(""" - QTableView { - background-color: #2E2E2E; - gridline-color: #555; - color: #DDD; - selection-background-color: #0078D7; - selection-color: white; - alternate-background-color: #3A3A3A; - } - QHeaderView::section { - background-color: #444; color: white; border: 1px solid #555; padding: 4px; - } - """) - - self.model = AnnotationTableModel() - self.table.setModel(self.model) - self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) - self.table.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows) - self.table.setSelectionMode(QTableView.SelectionMode.SingleSelection) - self.table.setAlternatingRowColors(True) - self.table.selectionModel().selectionChanged.connect(self._on_selection_changed) - - # [修改] 启用右键菜单 - self.table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) - self.table.customContextMenuRequested.connect(self._show_context_menu) - - layout.addWidget(self.table) - - self.current_schema = {} - - def set_data(self, annotations): - self.model.set_annotations(annotations) - - def set_schema(self, schema): - self.current_schema = schema - - def _on_selection_changed(self, selected, deselected): - indexes = selected.indexes() - if indexes: - row = indexes[0].row() - item = self.model.get_annotation_at(row) - if item: - self.annotationSelected.emit(item.get('position_ms', 0)) - - def _show_context_menu(self, pos): - # [核心修改] 根据点击的列展示不同的菜单 - index = self.table.indexAt(pos) - if not index.isValid(): return - - col = index.column() # 0:Time, 1:Head, 2:Label - row = index.row() - item = self.model.get_annotation_at(row) - if not item: return - - menu = QMenu(self) - - action_map = {} - - if col == 0: - act = menu.addAction("Edit Time") - action_map[act] = "edit_time" - elif col == 1: - act = menu.addAction("Edit Head") - action_map[act] = "edit_head" - elif col == 2: - act = menu.addAction("Edit Label") - action_map[act] = "edit_label" - - menu.addSeparator() - act_delete = menu.addAction("Delete Event") - action_map[act_delete] = "delete" - - selected_action = menu.exec(self.table.mapToGlobal(pos)) - - if selected_action: - mode = action_map.get(selected_action) - if mode == "delete": - self.annotationDeleted.emit(item) - elif mode == "edit_time": - self._edit_time(item) - elif mode == "edit_head": - self._edit_head(item) - elif mode == "edit_label": - self._edit_label(item) - - def _edit_time(self, item): - ms = item.get('position_ms', 0) - total_seconds = ms // 1000 - h = total_seconds // 3600 - m = (total_seconds % 3600) // 60 - s = total_seconds % 60 - ms_part = ms % 1000 - cur_time = QTime(h, m, s, ms_part) - - dlg = QDialog(self) - dlg.setWindowTitle("Edit Time") - l = QVBoxLayout(dlg) - - te = QTimeEdit() - te.setDisplayFormat("HH:mm:ss.zzz") - te.setTime(cur_time) - l.addWidget(QLabel("New Time:")) - l.addWidget(te) - - bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) - bb.accepted.connect(dlg.accept) - bb.rejected.connect(dlg.reject) - l.addWidget(bb) - - if dlg.exec(): - t = te.time() - new_ms = (t.hour()*3600 + t.minute()*60 + t.second())*1000 + t.msec() - - new_item = item.copy() - new_item['position_ms'] = new_ms - self.annotationModified.emit(item, new_item) - - def _edit_head(self, item): - heads = list(self.current_schema.keys()) - dlg = EditEventDialog(item.get('head', ''), heads, "Head", self) - - if dlg.exec(): - new_head = dlg.get_value() - if not new_head: return - - new_item = item.copy() - new_item['head'] = new_head - # 这里不处理 Label 置空,交给 Manager 处理 - self.annotationModified.emit(item, new_item) - - def _edit_label(self, item): - head = item.get('head', '') - labels = [] - if head in self.current_schema: - labels = self.current_schema[head].get('labels', []) - - dlg = EditEventDialog(item.get('label', ''), labels, "Label", self) - - if dlg.exec(): - new_label = dlg.get_value() - # 允许输入空字符串,如果用户想置空 - new_item = item.copy() - new_item['label'] = new_label - self.annotationModified.emit(item, new_item) diff --git a/Tool/ui2/widgets/table_model.py b/Tool/ui2/widgets/table_model.py deleted file mode 100644 index 9908a9c..0000000 --- a/Tool/ui2/widgets/table_model.py +++ /dev/null @@ -1,59 +0,0 @@ -from PyQt6.QtCore import QAbstractTableModel, Qt - -class AnnotationTableModel(QAbstractTableModel): - def __init__(self, annotations=None): - super().__init__() - self._data = annotations or [] - # [修改] 移除了 "Del" 列,只保留数据列 - self._headers = ["Time", "Head", "Label"] - - def rowCount(self, parent=None): - return len(self._data) - - def columnCount(self, parent=None): - return len(self._headers) - - def data(self, index, role=Qt.ItemDataRole.DisplayRole): - if not index.isValid(): - return None - - if role == Qt.ItemDataRole.DisplayRole: - row = index.row() - col = index.column() - item = self._data[row] - - if col == 0: - return self._fmt_ms(item.get('position_ms', 0)) - elif col == 1: - # 显示时去下划线 - return item.get('head', '').replace('_', ' ') - elif col == 2: - # 显示时去下划线 - return item.get('label', '').replace('_', ' ') - - # [新增] 存储原始数据 UserRole,方便逻辑处理 - elif role == Qt.ItemDataRole.UserRole: - return self._data[index.row()] - - return None - - def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole): - if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole: - return self._headers[section] - return None - - def set_annotations(self, annotations): - self.beginResetModel() - self._data = annotations - self.endResetModel() - - def get_annotation_at(self, row): - if 0 <= row < len(self._data): - return self._data[row] - return None - - def _fmt_ms(self, ms): - # 格式化时间为 mm:ss.mmm - s = ms // 1000 - m = s // 60 - return f"{m:02}:{s%60:02}.{ms%1000:03}" diff --git a/Tool/viewer.py b/Tool/viewer.py deleted file mode 100644 index 94dac56..0000000 --- a/Tool/viewer.py +++ /dev/null @@ -1,369 +0,0 @@ -import os -from PyQt6.QtWidgets import QMainWindow, QMessageBox -from PyQt6.QtCore import Qt, QDir, QTimer -from PyQt6.QtGui import QKeySequence, QShortcut, QColor, QIcon -from PyQt6.QtMultimedia import QMediaPlayer - -from models import AppStateModel -from ui.panels import MainWindowUI - -# 导入路径调整 -from controllers.router import AppRouter -from controllers.history_manager import HistoryManager -from controllers.classification.annotation_manager import AnnotationManager -from controllers.classification.navigation_manager import NavigationManager -from controllers.localization.localization_manager import LocalizationManager - -from utils import resource_path, create_checkmark_icon, natural_sort_key - -class ActionClassifierApp(QMainWindow): - - FILTER_ALL = 0 - FILTER_DONE = 1 - FILTER_NOT_DONE = 2 - - def __init__(self): - super().__init__() - self.setWindowTitle("SoccerNet Pro Analysis Tool") - self.setGeometry(100, 100, 1400, 900) - - # 1. Init MVC - self.ui = MainWindowUI() - self.setCentralWidget(self.ui) - self.model = AppStateModel() - - # 2. Init Controllers - self.router = AppRouter(self) - - self.history_manager = HistoryManager(self) - self.annot_manager = AnnotationManager(self) - self.nav_manager = NavigationManager(self) - self.loc_manager = LocalizationManager(self) - - # 3. Local State - bright_blue = QColor("#00BFFF") - self.done_icon = create_checkmark_icon(bright_blue) - self.empty_icon = QIcon() - - # 4. Setup - self.connect_signals() - self.apply_stylesheet("Night") - self.ui.right_panel.manual_box.setEnabled(False) - self.setup_dynamic_ui() - - # Setup Shortcuts (Last step to ensure controllers are ready) - self._setup_shortcuts() - - # Start at welcome screen - self.ui.show_welcome_view() - - def connect_signals(self): - # --- Welcome Screen --- - self.ui.welcome_widget.import_btn.clicked.connect(self.router.import_annotations) - self.ui.welcome_widget.create_btn.clicked.connect(self.router.create_new_project_flow) - - # --- Left Panel (Classification) --- - self.ui.left_panel.import_btn.clicked.connect(self.router.import_annotations) - self.ui.left_panel.create_btn.clicked.connect(self.router.create_new_project_flow) - self.ui.left_panel.add_data_btn.clicked.connect(self.nav_manager.add_items_via_dialog) - self.ui.left_panel.clear_btn.clicked.connect(self._on_class_clear_clicked) - - self.ui.left_panel.request_remove_item.connect(self.nav_manager.remove_single_action_item) - self.ui.left_panel.action_tree.currentItemChanged.connect(self.nav_manager.on_item_selected) - self.ui.left_panel.filter_combo.currentIndexChanged.connect(self.nav_manager.apply_action_filter) - - # --- Undo/Redo Connections --- - self.ui.left_panel.undo_btn.clicked.connect(self.history_manager.perform_undo) - self.ui.left_panel.redo_btn.clicked.connect(self.history_manager.perform_redo) - - self.ui.localization_ui.right_panel.undo_btn.clicked.connect(self.history_manager.perform_undo) - self.ui.localization_ui.right_panel.redo_btn.clicked.connect(self.history_manager.perform_redo) - - # --- Center Panel (Classification) --- - self.ui.center_panel.play_btn.clicked.connect(self.nav_manager.play_video) - self.ui.center_panel.multi_view_btn.clicked.connect(self.nav_manager.show_all_views) - self.ui.center_panel.prev_action.clicked.connect(self.nav_manager.nav_prev_action) - self.ui.center_panel.prev_clip.clicked.connect(self.nav_manager.nav_prev_clip) - self.ui.center_panel.next_clip.clicked.connect(self.nav_manager.nav_next_clip) - self.ui.center_panel.next_action.clicked.connect(self.nav_manager.nav_next_action) - - # --- Right Panel (Classification) --- - self.ui.right_panel.save_btn.clicked.connect(self.router.class_fm.save_json) - self.ui.right_panel.export_btn.clicked.connect(self.router.class_fm.export_json) - - self.ui.right_panel.confirm_btn.clicked.connect(self.annot_manager.save_manual_annotation) - self.ui.right_panel.clear_sel_btn.clicked.connect(self.annot_manager.clear_current_manual_annotation) - self.ui.right_panel.add_head_clicked.connect(self.annot_manager.handle_add_label_head) - self.ui.right_panel.remove_head_clicked.connect(self.annot_manager.handle_remove_label_head) - self.ui.right_panel.style_mode_changed.connect(self.apply_stylesheet) - - # --- Localization Connections --- - self.loc_manager.setup_connections() - - def _setup_shortcuts(self): - """ - Initialize all application-wide keyboard shortcuts. - """ - # --- 1. File Operations --- - # Ctrl+O: Open - QShortcut(QKeySequence("Ctrl+O"), self).activated.connect(self.router.import_annotations) - - # Ctrl+S: Save (Context Aware) - QShortcut(QKeySequence("Ctrl+S"), self).activated.connect(self._dispatch_save) - - # Ctrl+Shift+S: Save As / Export (Context Aware) - QShortcut(QKeySequence("Ctrl+Shift+S"), self).activated.connect(self._dispatch_export) - - # Ctrl+E: Settings (Placeholder) - QShortcut(QKeySequence("Ctrl+E"), self).activated.connect(lambda: self.show_temp_msg("Settings", "Settings dialog not implemented yet.")) - - # Ctrl+D: Dataset Downloader (Placeholder) - QShortcut(QKeySequence("Ctrl+D"), self).activated.connect(lambda: self.show_temp_msg("Downloader", "Dataset Downloader not implemented yet.")) - - # --- 2. Edit Operations --- - # Undo / Redo - QShortcut(QKeySequence.StandardKey.Undo, self).activated.connect(self.history_manager.perform_undo) - QShortcut(QKeySequence.StandardKey.Redo, self).activated.connect(self.history_manager.perform_redo) - - # --- 3. Playback Controls --- - # Space: Play/Pause - QShortcut(QKeySequence(Qt.Key.Key_Space), self).activated.connect(self._dispatch_play_pause) - - # Arrow Keys (Frame Step: approx 40ms) - QShortcut(QKeySequence(Qt.Key.Key_Left), self).activated.connect(lambda: self._dispatch_seek(-40)) - QShortcut(QKeySequence(Qt.Key.Key_Right), self).activated.connect(lambda: self._dispatch_seek(40)) - - # Ctrl + Arrows (1s Step) - QShortcut(QKeySequence("Ctrl+Left"), self).activated.connect(lambda: self._dispatch_seek(-1000)) - QShortcut(QKeySequence("Ctrl+Right"), self).activated.connect(lambda: self._dispatch_seek(1000)) - - # Ctrl + Shift + Arrows (5s Step) - QShortcut(QKeySequence("Ctrl+Shift+Left"), self).activated.connect(lambda: self._dispatch_seek(-5000)) - QShortcut(QKeySequence("Ctrl+Shift+Right"), self).activated.connect(lambda: self._dispatch_seek(5000)) - - # --- 4. Annotation Actions --- - # A: Add Annotation (Context Aware) - QShortcut(QKeySequence("A"), self).activated.connect(self._dispatch_add_annotation) - - # S: Set Time (Edit Context) - QShortcut(QKeySequence("S"), self).activated.connect(lambda: self.show_temp_msg("Info", "Select an event and Edit Time via Right-click.")) - - # ========================================================= - # Context Dispatchers (Handle Logic based on Active Mode) - # ========================================================= - - def _is_loc_mode(self): - return self.ui.stack_layout.currentWidget() == self.ui.localization_ui - - def _dispatch_save(self): - if self._is_loc_mode(): - self.router.loc_fm.overwrite_json() - else: - self.router.class_fm.save_json() - - def _dispatch_export(self): - if self._is_loc_mode(): - self.router.loc_fm.export_json() - else: - self.router.class_fm.export_json() - - def _dispatch_play_pause(self): - # 获取当前应该控制的播放器 - if self._is_loc_mode(): - # Localization Mode Player - player = self.loc_manager.center_panel.media_preview.player - if player.playbackState() == QMediaPlayer.PlaybackState.PlayingState: - player.pause() - else: - player.play() - else: - # Classification Mode Player (managed via NavigationManager for safety) - self.nav_manager.play_video() - - def _dispatch_seek(self, delta_ms): - # 获取当前播放器 - player = None - if self._is_loc_mode(): - player = self.loc_manager.center_panel.media_preview.player - else: - # In Classification, center panel uses single_view_widget - player = self.ui.center_panel.single_view_widget.player - - if player: - new_pos = max(0, player.position() + delta_ms) - player.setPosition(new_pos) - - def _dispatch_add_annotation(self): - if self._is_loc_mode(): - # Localization: Trigger "Add New Label" on the currently selected tab - # 这对应于我们刚刚修改的逻辑:暂停 -> 弹窗添加 -> 恢复 - current_head = self.loc_manager.current_head - if current_head: - self.loc_manager._on_label_add_req(current_head) - else: - self.show_temp_msg("Warning", "No Head/Category selected.", icon=QMessageBox.Icon.Warning) - else: - # Classification: Equivalent to clicking "Confirm" - self.annot_manager.save_manual_annotation() - - # ========================================================= - # Existing Methods (Unchanged) - # ========================================================= - - def _on_class_clear_clicked(self): - if not self.model.json_loaded and not self.model.action_item_data: return - msg = QMessageBox(self) - msg.setWindowTitle("Clear Workspace") - msg.setText("Clear workspace? Unsaved changes will be lost.") - msg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel) - if msg.exec() == QMessageBox.StandardButton.Yes: - self.router.class_fm._clear_workspace(full_reset=True) - - def apply_stylesheet(self, mode): - qss = "style.qss" if mode == "Night" else "style_day.qss" - try: - with open(resource_path(os.path.join("style", qss)), "r", encoding="utf-8") as f: - self.setStyleSheet(f.read()) - except Exception as e: - print(f"Style error: {e}") - - def check_and_close_current_project(self): - if self.model.json_loaded: - msg_box = QMessageBox(self) - msg_box.setWindowTitle("Open New Project") - msg_box.setText("Opening a new project will clear the current workspace. Continue?") - msg_box.setIcon(QMessageBox.Icon.Warning) - if self.model.is_data_dirty: - msg_box.setInformativeText("You have unsaved changes in the current project.") - btn_yes = msg_box.addButton("Yes", QMessageBox.ButtonRole.AcceptRole) - btn_no = msg_box.addButton("No", QMessageBox.ButtonRole.RejectRole) - msg_box.setDefaultButton(btn_no) - msg_box.exec() - if msg_box.clickedButton() == btn_yes: return True - else: return False - return True - - def closeEvent(self, event): - is_loc_mode = (self.ui.stack_layout.currentWidget() == self.ui.localization_ui) - if is_loc_mode: - has_data = bool(self.model.localization_events) - else: - has_data = bool(self.model.manual_annotations) - - can_export = self.model.json_loaded and has_data - - if not self.model.is_data_dirty or not can_export: - event.accept() - return - - msg = QMessageBox(self) - msg.setWindowTitle("Unsaved Annotations") - msg.setText("Do you want to save your annotations before quitting?") - msg.setIcon(QMessageBox.Icon.Question) - - save_btn = msg.addButton("Save & Exit", QMessageBox.ButtonRole.AcceptRole) - discard_btn = msg.addButton("Discard & Exit", QMessageBox.ButtonRole.DestructiveRole) - msg.addButton("Cancel", QMessageBox.ButtonRole.RejectRole) - - msg.setDefaultButton(save_btn) - msg.exec() - - if msg.clickedButton() == save_btn: - if is_loc_mode: - if self.router.loc_fm.overwrite_json(): event.accept() - else: event.ignore() - else: - if self.router.class_fm.save_json(): event.accept() - else: event.ignore() - elif msg.clickedButton() == discard_btn: - event.accept() - else: - event.ignore() - - def update_save_export_button_state(self): - is_loc_mode = (self.ui.stack_layout.currentWidget() == self.ui.localization_ui) - - if is_loc_mode: - has_data = bool(self.model.localization_events) - else: - has_data = bool(self.model.manual_annotations) - - 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 - - self.ui.right_panel.export_btn.setEnabled(can_export) - self.ui.right_panel.save_btn.setEnabled(can_save) - - can_undo = len(self.model.undo_stack) > 0 - can_redo = len(self.model.redo_stack) > 0 - - self.ui.left_panel.undo_btn.setEnabled(can_undo) - self.ui.left_panel.redo_btn.setEnabled(can_redo) - self.ui.localization_ui.right_panel.undo_btn.setEnabled(can_undo) - self.ui.localization_ui.right_panel.redo_btn.setEnabled(can_redo) - - def show_temp_msg(self, title, msg, duration=1500, icon=QMessageBox.Icon.Information): - m = QMessageBox(self); m.setWindowTitle(title); m.setText(msg); m.setIcon(icon) - m.setStandardButtons(QMessageBox.StandardButton.NoButton) - QTimer.singleShot(duration, m.accept) - m.exec() - - def get_current_action_path(self): - curr = self.ui.left_panel.action_tree.currentItem() - if not curr: return None - if curr.parent() is None: return curr.data(0, Qt.ItemDataRole.UserRole) - return curr.parent().data(0, Qt.ItemDataRole.UserRole) - - def populate_action_tree(self): - self.ui.left_panel.action_tree.clear() - self.model.action_item_map.clear() - - sorted_list = sorted(self.model.action_item_data, key=lambda d: natural_sort_key(d.get('name', ''))) - for data in sorted_list: - item = self.ui.left_panel.add_action_item(data['name'], data['path'], data.get('source_files')) - self.model.action_item_map[data['path']] = item - - for path in self.model.action_item_map.keys(): - self.update_action_item_status(path) - self.nav_manager.apply_action_filter() - - if self.ui.left_panel.action_tree.topLevelItemCount() > 0: - first_item = self.ui.left_panel.action_tree.topLevelItem(0) - self.ui.left_panel.action_tree.setCurrentItem(first_item) - QTimer.singleShot(200, self.nav_manager.play_video) - - def update_action_item_status(self, action_path): - item = self.model.action_item_map.get(action_path) - if not item: return - is_done = (action_path in self.model.manual_annotations and bool(self.model.manual_annotations[action_path])) - item.setIcon(0, self.done_icon if is_done else self.empty_icon) - self.nav_manager.apply_action_filter() - - def setup_dynamic_ui(self): - self.ui.right_panel.setup_dynamic_labels(self.model.label_definitions) - self.ui.right_panel.task_label.setText(f"Task: {self.model.current_task_name}") - self._connect_dynamic_type_buttons() - - def _connect_dynamic_type_buttons(self): - for head, group in self.ui.right_panel.label_groups.items(): - try: group.add_btn.clicked.disconnect() - except: pass - try: group.remove_label_signal.disconnect() - except: pass - try: group.value_changed.disconnect() - except: pass - - group.add_btn.clicked.connect(lambda _, h=head: self.annot_manager.add_custom_type(h)) - group.remove_label_signal.connect(lambda lbl, h=head: self.annot_manager.remove_custom_type(h, lbl)) - group.value_changed.connect(lambda h, v: self.annot_manager.handle_ui_selection_change(h, v)) - - def refresh_ui_after_undo_redo(self, action_path): - if not action_path: return - self.update_action_item_status(action_path) - item = self.model.action_item_map.get(action_path) - if item and self.ui.left_panel.action_tree.currentItem() != item: - self.ui.left_panel.action_tree.setCurrentItem(item) - - current = self.get_current_action_path() - if current == action_path: self.annot_manager.display_manual_annotation(action_path) - self.update_save_export_button_state() diff --git a/annotation_tool/.DS_Store b/annotation_tool/.DS_Store new file mode 100644 index 0000000..096b0f9 Binary files /dev/null and b/annotation_tool/.DS_Store differ diff --git a/annotation_tool/README.md b/annotation_tool/README.md new file mode 100644 index 0000000..c577db3 --- /dev/null +++ b/annotation_tool/README.md @@ -0,0 +1,114 @@ +# 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)**. + +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. + +--- + +## 📂 Project Structure Overview + +```text +annotation_tool/ +├── main.py # Application entry point +├── viewer.py # Main Window controller (Orchestrator) +├── utils.py # Helper functions and constants +├── __init__.py # Package initialization +│ +├── models/ # [Model Layer] Data Structures & State +│ ├── app_state.py # Global State, Undo/Redo Stacks, & JSON Validation +│ └── project_tree.py # Shared QStandardItemModel for the sidebar tree +│ +├── controllers/ # [Controller Layer] Business Logic +│ ├── router.py # Mode detection & Project lifecycle management +│ ├── history_manager.py # Universal Undo/Redo system +│ ├── media_controller.py # Unified playback logic (Anti-freeze/Visual clearing) +│ ├── classification/ # Logic for Classification mode +│ ├── 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_manager.py # Core logic for dense annotations & UI sync +│ └── dense_file_manager.py # JSON I/O specifically for Dense tasks +│ +├── ui/ # [View Layer] Interface Definitions +│ ├── common/ # Shared widgets (Main Window, Sidebar, Video Surface) +│ │ ├── main_window.py # Top-level UI (Stacked layout management) +│ │ ├── video_surface.py # Shared Pure QVideoWidget + QMediaPlayer +│ │ ├── workspace.py # Unified 3-column skeleton +│ │ └── dialogs.py # Project wizards and mode selectors +│ ├── classification/ # UI specific to Classification +│ ├── 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 +│ └── event_editor/ +│ ├── __init__.py # Right panel assembler for Dense mode +│ ├── desc_input_widget.py # Text input & timestamp submission +│ └── dense_table.py # Specialized Table Model for Lang/Text columns +│ +└── style/ # Visual theme assets + └── style.qss # Centralized Dark mode stylesheet + +``` + +--- + +## 📝 Detailed Module Descriptions + +### 1. Core Infrastructure & Routing + +* **`main.py`**: Initializes the `QApplication` and the high-level event loop. +* **`viewer.py`**: The heart of the application. It instantiates all Managers, connects signals between UI components and Logic Controllers, and implements `stop_all_players()` to prevent media resource leaks during mode switching. +* **`router.py`**: Features a heuristic detection engine that identifies project types from JSON keys (e.g., detecting `"dense"` tasks to trigger the Dense Description mode). +* **`media_controller.py`**: Manages the "Stop -> Load -> Delay -> Play" sequence to eliminate black screens and GPU buffer artifacts. + +### 2. The Model Layer (`/models`) + +* **`app_state.py`**: Maintains the "Source of Truth" for the application. It stores `manual_annotations` (Class), `localization_events` (Loc), and `dense_description_events` (Dense). It also contains strict JSON Schema validators for each task. +* **`project_tree.py`**: A `QStandardItemModel` used by all modes to display clips in the sidebar. + +### 3. Modality Logic (`/controllers`) + +* **`localization_manager.py`**: Logic for "Spotting" (mapping a label to a timestamp). +* **`dense_manager.py`**: **[NEW]** Logic for mapping free-text descriptions to timestamps. It handles the submission from the `DenseDescriptionInputWidget` and updates the timeline markers. +* **`dense_file_manager.py`**: Handles JSON persistence for dense tasks, ensuring the `text` and `position_ms` fields are properly serialized. + +### 4. The View Layer (`/ui`) + +* **`video_surface.py`**: A shared rendering component used by **every** mode to ensure consistent video performance. +* **`dense_table.py`**: A specialized view inheriting from the Localization table model. It replaces the "Label/Head" columns with "Lang/Description" while maintaining the same timestamp-jump functionality. +* **`desc_input_widget.py`**: Provides a `QTextEdit` for long-form text and an "Add" button that captures the exact current playback frame. + +--- + +## 🔄 Reusability & Modality Comparison + +The application is built on a "Composite Design" strategy. While each mode serves a different task, they share significant architectural DNA. + +### Is Dense Description a reuse of Localization? + +**Yes.** The Dense Description modality is essentially a **specialized evolution** of the Localization mode. + +* **Shared Center Panel**: Both use the `LocCenterPanel`, which includes the zoomable `TimelineWidget` and `VideoSurface`. +* **Shared Data Logic**: Both are "Event-based" (data is tied to a `position_ms`) rather than "Clip-based". +* **Shared Table Interface**: The `DenseTableModel` is a direct subclass of `AnnotationTableModel`, inheriting all natural sorting and timestamp-parsing logic. + +### Modality Feature Matrix + +| Feature | Classification | Localization | Global Description | Dense Description | +| --- | --- | --- | --- | --- | +| **Primary Data** | Multi-choice Labels | Timestamped Labels | Global Video Text | Timestamped Text | +| **Center UI** | Multi-view Player | Timeline + Player | Slider + Player | Timeline + Player | +| **Right UI** | Schema Editor | Tabbed Spotting | Text Editor | Text Input + Table | +| **Code Base** | Unique | Shared with Dense | Unique | Shared with Loc | + +--- + +## 🚀 Getting Started + +1. **Select Mode**: Launch the app and use the "New Project" wizard to select one of the four modes. +2. **Import**: The `AppRouter` will automatically detect the correct modality if you import an existing JSON. +3. **Annotate**: +* In **Dense mode**, navigate to a point in the video, type your description in the right panel, and click "Add Description". +* Use the **Timeline** to jump between existing text annotations. + + diff --git a/annotation_tool/controllers/.DS_Store b/annotation_tool/controllers/.DS_Store new file mode 100644 index 0000000..57a9279 Binary files /dev/null and b/annotation_tool/controllers/.DS_Store differ diff --git a/annotation_tool/controllers/README.md b/annotation_tool/controllers/README.md new file mode 100644 index 0000000..3270089 --- /dev/null +++ b/annotation_tool/controllers/README.md @@ -0,0 +1,106 @@ +# ⚙️ Controllers Module (Logic Layer) + +This directory contains the business logic of the **SoccerNet Pro Annotation Tool**. following the **MVC (Model-View-Controller)** architecture, these scripts act as the bridge between the data (`models.py`) and the interface (`ui/`). They handle user input, data processing, file I/O, and application state management. + +## 📂 Directory Structure + +```text +controllers/ +├── media_controller.py # [NEW] Unified Video Playback Manager +├── history_manager.py # Universal Undo/Redo logic +├── router.py # Application routing and mode switching +├── classification/ # Logic specific to Whole-Video Classification +├── localization/ # Logic specific to Action Spotting (Timestamps) +├── description/ # Logic specific to Global Captioning (Text) +└── dense_description/ # Logic specific to Dense Captioning (Timestamped Text) + +``` + +--- + +## 📦 Module Details + +### 1. Core Controllers (Root) + +These controllers provide foundational functionality used across the entire application to ensure stability and consistency. + +* **`media_controller.py`** +* **Role**: The centralized video playback engine used by all modes. +* **Responsibilities**: +* **Robust State Management**: Implements a strict `Stop -> Clear -> Load -> Delay -> Play` sequence to prevent black screens and buffer artifacts. +* **Race Condition Prevention**: Uses an internal `QTimer` that is explicitly cancelled upon stop, preventing videos from starting in the background after a user has closed a project. +* **Visual Clearing**: Forces the `QVideoWidget` to repaint/update on stop, ensuring no "stuck frames" remain visible. + + + + +* **`router.py`** +* **Role**: The "Traffic Cop" of the application. +* **Responsibilities**: +* Handles the "Create Project" and "Load Project" flows. +* Analyzes input JSON files (keys like `events`, `captions`, `labels`) to automatically detect the project mode. +* Initializes the appropriate specific managers and switches the UI view. + + + + +* **`history_manager.py`** +* **Role**: The "Time Machine" (Undo/Redo System). +* **Responsibilities**: +* Implements the **Command Pattern** to manage the Undo/Redo stacks in `AppStateModel`. +* Executes operations and triggers the necessary UI refreshes (`_refresh_active_view`) for all four modes. + + + + + +### 2. Classification Controllers (`controllers/classification/`) + +Logic dedicated to the **Whole-Video Classification** task (assigning attributes to an entire video clip). + +* **`class_file_manager.py`**: Handles JSON I/O and relative path calculations. +* **`navigation_manager.py`**: Manages the "Action List" (Left Panel), auto-play logic, and filtering. +* **`annotation_manager.py`**: Manages dynamic schema logic (Radio/Checkbox generation) and saves class selections. + +### 3. Localization Controllers (`controllers/localization/`) + +Logic dedicated to the **Action Spotting** task (pinpointing specific timestamps). + +* **`loc_file_manager.py`**: Handles JSON I/O with path fallback logic (checking local directories if absolute paths fail). +* **`localization_manager.py`**: +* Synchronizes the **Video Player**, **Timeline Widget**, and **Event Table**. +* Captures timestamps for spotting events and manages the multi-tab interface. + + + +### 4. Description Controllers (`controllers/description/`) [NEW] + +Logic dedicated to the **Global Captioning** task (one text description per video action). + +* **`desc_navigation_manager.py`**: +* Manages **Multi-Clip Actions** (navigating logical "Actions" that may contain multiple video files). +* Wraps the `MediaController` to ensure smooth loading of large video files. + + +* **`desc_annotation_manager.py`**: +* Handles **Q&A Formatting**: Parses JSON "questions" into a readable Q&A format in the editor. +* **Flattening**: Consolidates text into a single description block upon save. + + +* **`desc_file_manager.py`**: +* Manages JSON I/O specific to the captioning schema, ensuring `inputs` and `captions` fields are preserved correctly. + + + +### 5. Dense Description Controllers (`controllers/dense_description/`) [NEW] + +Logic dedicated to the **Dense Captioning** task (text descriptions anchored to specific timestamps). + +* **`dense_manager.py`**: +* **Editor-Timeline Sync**: Continuously synchronizes the text input field with the video playback position. If the video hits an event, the text loads automatically. +* **CRUD**: Handles creating, updating, and deleting timestamped text events. + + +* **`dense_file_manager.py`**: +* **Metadata Preservation**: Ensures global and item-level metadata is retained during Load/Save cycles. +* **Data Mapping**: Maps the flat `dense_captions` JSON list to the internal application model. diff --git a/annotation_tool/controllers/__init__.py b/annotation_tool/controllers/__init__.py new file mode 100644 index 0000000..ed848bc --- /dev/null +++ b/annotation_tool/controllers/__init__.py @@ -0,0 +1,3 @@ +# controllers/__init__.py +# This file should ideally be empty or only import controller logic. +# DO NOT put UI classes (QWidget subclasses) here. \ No newline at end of file diff --git a/annotation_tool/controllers/classification/README.md b/annotation_tool/controllers/classification/README.md new file mode 100644 index 0000000..25b7ad8 --- /dev/null +++ b/annotation_tool/controllers/classification/README.md @@ -0,0 +1,46 @@ +# 🏷️ Classification Controllers + +This module contains the business logic specifically designed for the **Whole-Video Classification** task. + +In this mode, the goal is to assign global attributes (labels) to an entire video clip (e.g., "Weather: Sunny", "Action: Goal"). These controllers bridge the gap between the Classification UI (`ui/classification/`) and the central data model (`models.py`). + +## 📂 Module Contents + +```text +controllers/classification/ +├── annotation_manager.py # Labeling logic & Dynamic Schema handling +├── class_file_manager.py # I/O operations (Save/Load/Create) +└── navigation_manager.py # Video list navigation & Playback flow + +``` + +--- + +## 📝 File Descriptions + +### 1. `annotation_manager.py` + +**Responsibility:** Manages the labeling process and schema modifications. + +* **Dynamic Schema Management**: Handles adding, renaming, or deleting Categories ("Heads") and Labels directly from the UI. +* **Annotation Saving**: Captures the user's selection from the UI (Right Panel), validates it, and commits it to the `AppStateModel`. +* **Undo/Redo Integration**: Pushes commands to the `HistoryManager` when annotations are changed or when the schema structure is modified. + +### 2. `class_file_manager.py` + +**Responsibility:** Handles file input/output and project lifecycle for Classification projects. + +* **Project Creation**: Orchestrates the "New Project" wizard dialog and initializes the model with the chosen schema. +* **JSON Loading**: Parses existing Classification JSON files, validating that they match the expected format. +* **JSON Saving**: Writes the current state to disk. It handles the critical logic of converting absolute file paths to **relative paths** to ensure portability between different computers. +* **Workspace Management**: Handles clearing the current data when closing a project. + +### 3. `navigation_manager.py` + +**Responsibility:** Manages the list of video clips and the user's movement through them. + +* **Tree Interaction**: Connects the `QTreeWidget` (Left Panel) to the media player, ensuring the correct video loads when clicked. +* **Smart Navigation**: Implements logic for "Next Action" (jump to next parent item) and "Next Clip" (jump to next file), skipping items based on filters. +* **Filtering**: Applies logic to show only "Done", "Not Done", or "All" videos in the list. +* **Media Control**: Triggers video playback and synchronizes the player state with the selected item. + diff --git a/Tool/controllers/classification/annotation_manager.py b/annotation_tool/controllers/classification/class_annotation_manager.py similarity index 83% rename from Tool/controllers/classification/annotation_manager.py rename to annotation_tool/controllers/classification/class_annotation_manager.py index b54542e..7d9822a 100644 --- a/Tool/controllers/classification/annotation_manager.py +++ b/annotation_tool/controllers/classification/class_annotation_manager.py @@ -13,7 +13,7 @@ def save_manual_annotation(self): path = self.main.get_current_action_path() if not path: return - raw = self.ui.right_panel.get_annotation() + 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 @@ -30,11 +30,14 @@ def save_manual_annotation(self): self.main.update_action_item_status(path) self.main.update_save_export_button_state() - tree = self.ui.left_panel.action_tree - curr = tree.currentItem() - nxt = tree.itemBelow(curr) - if nxt: - QTimer.singleShot(500, lambda: [tree.setCurrentItem(nxt), tree.scrollToItem(nxt)]) + # [MV Fix] Auto-advance using QTreeView API + tree = self.ui.classification_ui.left_panel.tree + curr_idx = tree.currentIndex() + if curr_idx.isValid(): + # Try to get index below + nxt_idx = tree.indexBelow(curr_idx) + if nxt_idx.isValid(): + QTimer.singleShot(500, lambda: [tree.setCurrentIndex(nxt_idx), tree.scrollTo(nxt_idx)]) def clear_current_manual_annotation(self): path = self.main.get_current_action_path() @@ -47,11 +50,11 @@ def clear_current_manual_annotation(self): self.main.update_action_item_status(path) self.main.update_save_export_button_state() self.main.show_temp_msg("Cleared", "Selection cleared.") - self.ui.right_panel.clear_selection() + self.ui.classification_ui.right_panel.clear_selection() def display_manual_annotation(self, path): data = self.model.manual_annotations.get(path, {}) - self.ui.right_panel.set_annotation(data) + self.ui.classification_ui.right_panel.set_annotation(data) def handle_ui_selection_change(self, head, new_val): if self.main.history_manager._is_undoing_redoing: @@ -67,6 +70,10 @@ def handle_ui_selection_change(self, head, new_val): self.model.push_undo(CmdType.UI_CHANGE, path=path, head=head, old_val=old_val, new_val=new_val) + # ... handle_add_label_head, handle_remove_label_head ... + # These methods below generally don't touch the TreeView, so they are fine as is, + # but I'll include them to ensure the file is complete. + def handle_add_label_head(self, name): clean = name.strip().replace(' ', '_').lower() if not clean or clean in self.model.label_definitions: return @@ -83,7 +90,7 @@ def handle_add_label_head(self, name): defn = {"type": type_str, "labels": []} self.model.push_undo(CmdType.SCHEMA_ADD_CAT, head=clean, definition=defn) self.model.label_definitions[clean] = defn - self.ui.right_panel.new_head_edit.clear() + self.ui.classification_ui.right_panel.new_head_edit.clear() self.main.setup_dynamic_ui() def handle_remove_label_head(self, head): @@ -106,7 +113,7 @@ def handle_remove_label_head(self, head): self.display_manual_annotation(self.main.get_current_action_path()) def add_custom_type(self, head): - group = self.ui.right_panel.label_groups.get(head) + group = self.ui.classification_ui.right_panel.label_groups.get(head) txt = group.input_field.text().strip() if not txt: return @@ -119,7 +126,7 @@ def add_custom_type(self, head): labels.append(txt); labels.sort() # Update UI directly - from ui.widgets import DynamicSingleLabelGroup + from ui.classification.event_editor import DynamicSingleLabelGroup if isinstance(group, DynamicSingleLabelGroup): group.update_radios(labels) else: group.update_checkboxes(labels) group.input_field.clear() @@ -141,8 +148,8 @@ def remove_custom_type(self, head, lbl): if defn['type'] == 'single_label' and val.get(head) == lbl: val[head] = None elif defn['type'] == 'multi_label' and lbl in val.get(head, []): val[head].remove(lbl) - from ui.widgets import DynamicSingleLabelGroup - group = self.ui.right_panel.label_groups.get(head) + from ui.classification.event_editor import DynamicSingleLabelGroup + 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()) + self.display_manual_annotation(self.main.get_current_action_path()) \ No newline at end of file diff --git a/Tool/controllers/classification/class_file_manager.py b/annotation_tool/controllers/classification/class_file_manager.py similarity index 69% rename from Tool/controllers/classification/class_file_manager.py rename to annotation_tool/controllers/classification/class_file_manager.py index b93239d..2910ce0 100644 --- a/Tool/controllers/classification/class_file_manager.py +++ b/annotation_tool/controllers/classification/class_file_manager.py @@ -2,7 +2,6 @@ import json import datetime from PyQt6.QtWidgets import QFileDialog, QMessageBox -from dialogs import CreateProjectDialog from utils import natural_sort_key class ClassFileManager: @@ -12,16 +11,50 @@ def __init__(self, main_window): self.ui = main_window.ui def load_project(self, data, file_path): - """专门加载 Classification 项目""" + """ + Load Classification Project. + Returns: + bool: True if loaded successfully, False if validation failed or cancelled. + """ + + # 1. Strict Validation valid, err, warn = self.model.validate_gac_json(data) + if not valid: - QMessageBox.critical(self.main, "JSON Error", err); return + # Truncate extremely long error messages for display + if len(err) > 1000: + err = err[:1000] + "\n... (truncated)" + + error_text = ( + "The imported JSON contains critical errors and cannot be loaded.\n\n" + f"{err}\n\n" + "--------------------------------------------------\n" + "💡 Please download the correct Classification JSON format from:\n" + "https://huggingface.co/datasets/OpenSportsLab/soccernetpro-classification-vars" + ) + + QMessageBox.critical( + self.main, + "Validation Error (Classification)", + error_text + ) + return False # [FIX] Return False to signal failure + if warn: - QMessageBox.warning(self.main, "Warnings", warn) + if len(warn) > 1000: + warn = warn[:1000] + "\n... (truncated)" + + res = QMessageBox.warning( + self.main, "Validation Warnings", + "The file contains warnings:\n\n" + warn + "\n\nContinue loading?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + if res != QMessageBox.StandardButton.Yes: + return False # [FIX] Return False on user cancel + # 2. Clear Workspace (Only if validation passed) self._clear_workspace(full_reset=True) - # [关键] 设置当前工作目录为 JSON 文件所在的目录 self.model.current_working_directory = os.path.dirname(file_path) self.model.current_task_name = data.get('task', "N/A") @@ -44,7 +77,6 @@ def load_project(self, data, file_path): for inp in item.get('inputs', []): p = inp.get('path', '') - # [关键] 路径恢复逻辑 if os.path.isabs(p): fp = p else: @@ -78,17 +110,19 @@ def load_project(self, data, file_path): self.model.current_json_path = file_path self.model.json_loaded = True + + # [MV Note] populate_action_tree now uses self.main.tree_model internally self.main.populate_action_tree() self.main.update_save_export_button_state() - # [修改] 使用 show_temp_msg 并设置 5000ms (5秒) 延迟 - # 这个方法会阻塞 5 秒,然后 router 会切换界面 self.main.show_temp_msg( "Mode Switched", f"Project loaded with {len(self.model.action_item_data)} items.\n\nCurrent Mode: CLASSIFICATION", - duration=5000, + duration=1500, icon=QMessageBox.Icon.Information ) + + return True # [FIX] Explicitly return True on success def save_json(self): if self.model.current_json_path: @@ -177,31 +211,42 @@ def _write_json(self, save_path): return False def create_new_project(self): - if not self.main.check_and_close_current_project(): return - dlg = CreateProjectDialog(self.main) - if dlg.exec(): - self._clear_workspace(full_reset=True) - data = dlg.get_data() - self.model.current_task_name = data['task'] - self.model.modalities = data['modalities'] - self.model.label_definitions = data['labels'] - self.model.project_description = data['description'] - - self.model.json_loaded = True - self.model.is_data_dirty = True - - self.model.current_json_path = None - self.model.current_working_directory = None - - self.main.setup_dynamic_ui() - self.main.update_save_export_button_state() - self.ui.show_classification_view() + """ + Creates a blank project immediately, allowing the user to + build the schema in the right-hand panel. + """ + # 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.project_description = "" + + # 3. Set flags to allow interaction + self.model.json_loaded = True + self.model.is_data_dirty = True + + # No file: None + self.model.current_json_path = None + self.model.current_working_directory = None + + # 4. Refresh UI and switch view + self.main.setup_dynamic_ui() + self.main.update_save_export_button_state() + self.ui.show_classification_view() + + # 5. [IMPORTANT] Explicitly unlock the UI for editing + self.main.prepare_new_project_ui() def _clear_workspace(self, full_reset=False): - self.ui.left_panel.action_tree.clear() + # [MV Fix] Clear the Model, not the View + self.main.tree_model.clear() + self.model.reset(full_reset) self.main.update_save_export_button_state() - self.ui.right_panel.manual_box.setEnabled(False) - self.ui.center_panel.show_single_view(None) + self.ui.classification_ui.right_panel.manual_box.setEnabled(False) + self.ui.classification_ui.center_panel.show_single_view(None) if full_reset: self.main.setup_dynamic_ui() diff --git a/annotation_tool/controllers/classification/class_navigation_manager.py b/annotation_tool/controllers/classification/class_navigation_manager.py new file mode 100644 index 0000000..20021d0 --- /dev/null +++ b/annotation_tool/controllers/classification/class_navigation_manager.py @@ -0,0 +1,201 @@ +import os +from PyQt6.QtWidgets import QMessageBox, QFileDialog, QWidget +from PyQt6.QtCore import Qt, QModelIndex, QTimer +# [Ref] Import from the correct models location +from models.project_tree import ProjectTreeModel +from utils import SUPPORTED_EXTENSIONS +from controllers.media_controller import MediaController + +class NavigationManager: + """ + Handles file navigation (action tree), adding videos, and playback flow + for the Classification mode. + Refactored for QTreeView (MV) and MediaController. + """ + def __init__(self, main_window): + self.main = main_window + self.model = main_window.model + self.ui = main_window.ui + + # [UPDATED] Initialize Media Controller with Player AND VideoWidget + # We need the widget to force repaint() during stop() to prevent ghost frames. + + # Path: center_panel -> single_view_widget (PlayerPanel) -> video_surface -> video_widget + panel = self.ui.classification_ui.center_panel.single_view_widget + player = panel.player + + # Access the underlying QVideoWidget + if hasattr(panel, 'video_surface'): + widget = panel.video_surface.video_widget + else: + # Fallback safety check + widget = panel.findChild(QWidget, "video_preview_widget") + + self.media_controller = MediaController(player, widget) + + def add_items_via_dialog(self): + """ + Allows user to add video/image files to the project. + """ + if not self.model.json_loaded: + QMessageBox.warning(self.main, "Warning", "Please create or load a project first.") + return + + filters = "Media Files (*.mp4 *.avi *.mov *.mkv *.jpg *.jpeg *.png *.bmp);;All Files (*)" + start_dir = self.model.current_working_directory or "" + + files, _ = QFileDialog.getOpenFileNames(self.main, "Select Data to Add", start_dir, filters) + if not files: return + + if not self.model.current_working_directory: + 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]}) + + # [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 + + 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.") + + def remove_single_action_item(self, index: QModelIndex): + """ + Removes an item given its QModelIndex. + """ + path = index.data(ProjectTreeModel.FilePathRole) + + # 1. Remove from Data + self.model.action_item_data = [d for d in self.model.action_item_data if d['path'] != path] + + if path in self.model.action_item_map: + del self.model.action_item_map[path] + + # 2. Remove Annotation if exists + if path in self.model.manual_annotations: + del self.model.manual_annotations[path] + + # 3. Remove from UI (Model) + self.main.tree_model.removeRow(index.row(), index.parent()) + + self.model.is_data_dirty = True + self.main.show_temp_msg("Removed", "Item removed.") + self.main.update_save_export_button_state() + + def on_item_selected(self, current, previous): + """ + Called when the user clicks a different item in the left tree. + Loads the video and forces playback using MediaController. + """ + if not current.isValid(): return + + path = current.data(ProjectTreeModel.FilePathRole) + + # Update Right Panel (Annotations) + self.main.annot_manager.display_manual_annotation(path) + self.ui.classification_ui.right_panel.manual_box.setEnabled(True) + + # [CHANGED] Use MediaController for robust loading logic + # This replaces the manual stop/load/timer sequence. + self.media_controller.load_and_play(path) + + # [UI FIX] Ensure we are in Single View mode (in case we were in Multi-View) + center_panel = self.ui.classification_ui.center_panel + if hasattr(center_panel, 'view_layout'): + center_panel.view_layout.setCurrentWidget(center_panel.single_view_widget) + + def play_video(self): + """Toggle Play/Pause""" + # [CHANGED] Use MediaController + self.media_controller.toggle_play_pause() + + def show_all_views(self): + # [MV] Handle Multi-View + tree_view = self.ui.classification_ui.left_panel.tree + curr_idx = tree_view.currentIndex() + if not curr_idx.isValid(): return + + # Check if item has children rows + model = self.main.tree_model + if model.rowCount(curr_idx) == 0: return + + paths = [] + for i in range(model.rowCount(curr_idx)): + child_idx = model.index(i, 0, curr_idx) + paths.append(child_idx.data(ProjectTreeModel.FilePathRole)) + + 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 + 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 + 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 + + tree_view.setRowHidden(i, QModelIndex(), should_hide) + + def nav_prev_action(self): self._nav_tree(step=-1, level='top') + def nav_next_action(self): self._nav_tree(step=1, level='top') + def nav_prev_clip(self): self._nav_tree(step=-1, level='child') + def nav_next_clip(self): self._nav_tree(step=1, level='child') + + def _nav_tree(self, step, level): + tree = self.ui.classification_ui.left_panel.tree + curr = tree.currentIndex() + if not curr.isValid(): return + + model = self.main.tree_model + + if level == 'top': + # Navigate Top Level Items (Siblings) + # If current is a child, get parent first + if curr.parent().isValid(): + curr = curr.parent() + + new_row = curr.row() + step + + # Simple bounds check, logic can be improved to skip hidden items + if 0 <= new_row < model.rowCount(QModelIndex()): + # Check visibility (filter) + while 0 <= new_row < model.rowCount(QModelIndex()): + if not tree.isRowHidden(new_row, QModelIndex()): + new_idx = model.index(new_row, 0, QModelIndex()) + tree.setCurrentIndex(new_idx) + tree.scrollTo(new_idx) + break + new_row += step + else: + # Navigate Children + parent = curr.parent() + if not parent.isValid(): + # Currently on top, go to child 0 if step 1 + if step == 1 and model.rowCount(curr) > 0: + nxt = model.index(0, 0, curr) + tree.setCurrentIndex(nxt); tree.scrollTo(nxt) + else: + # Currently on child + 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 diff --git a/annotation_tool/controllers/dense_description/README.md b/annotation_tool/controllers/dense_description/README.md new file mode 100644 index 0000000..5f8f0ef --- /dev/null +++ b/annotation_tool/controllers/dense_description/README.md @@ -0,0 +1,37 @@ +# 🧠 Controllers: Dense Description + +This directory contains the business logic for the **Dense Description** mode (Timestamped Captioning). + +This mode is a hybrid of **Localization** (time-based events) and **Description** (free-text generation). The controllers here manage the synchronization between the video playback, the scrolling timeline, and the text input field. + +## 📂 Files + +### `dense_manager.py` + +**The Primary Orchestrator.** +This class connects the UI components (`DenseRightPanel`, `Timeline`, `MediaPlayer`) to the Data Model (`AppState`). + +* **Key Responsibilities:** +* **Editor-Timeline Sync:** Uses a `QTimer` (`_sync_editor_to_timeline`) to continuously check the playback position. If the video hits an existing event, the text editor is automatically populated with that event's text. +* **CRUD Operations:** Handles creating, updating, and deleting events via `CmdType` for full **Undo/Redo** support. +* **Tree Management:** Populates the sidebar tree and handles filtering ("Show Annotated" vs "Not Annotated"). +* **Navigation:** Implements logic to jump between text events (`_navigate_annotation`). + + + +### `dense_file_manager.py` + +**The I/O Handler.** +Manages the serialization and deserialization of the Dense JSON format. + +* **Key Responsibilities:** +* **Strict Validation:** calls `AppState.validate_dense_json` before loading to prevent corruption. +* **Metadata Preservation:** Ensures Global Metadata (Dataset info) and Item-level Metadata are preserved during a Load -> Save cycle. +* **Path Resolution:** Converts absolute paths to relative paths upon Export for portability. +* **Data Structure:** Maps the flat JSON `dense_captions` list to the internal dictionary format: +```json +"dense_captions": [ + { "position_ms": 12500, "lang": "en", "text": "A player kicks the ball." } +] + +``` diff --git a/annotation_tool/controllers/dense_description/dense_file_manager.py b/annotation_tool/controllers/dense_description/dense_file_manager.py new file mode 100644 index 0000000..eaacaea --- /dev/null +++ b/annotation_tool/controllers/dense_description/dense_file_manager.py @@ -0,0 +1,273 @@ +import os +import json +import datetime +from PyQt6.QtWidgets import QFileDialog, QMessageBox +from PyQt6.QtCore import QUrl +from utils import natural_sort_key + +class DenseFileManager: + """ + Handles JSON I/O for Dense Video Captioning projects. + Includes validation and metadata preservation. + """ + def __init__(self, main_window): + self.main = main_window + self.model = main_window.model + + def create_new_project(self): + """ + Create a new Dense Description project (Blank). + Initializes default metadata and workspace. + """ + # 2. Clear Workspace + self._clear_workspace(full_reset=True) + + # 3. Initialize Model State + self.model.current_task_name = "Untitled Dense Task" + self.model.project_description = "" + self.model.modalities = ["video"] + self.model.dense_description_events = {} + + # [NEW] Initialize default Global Metadata for new projects + self.model.dense_global_metadata = { + "version": "1.0", + "date": datetime.date.today().isoformat(), + "metadata": { + "source": "SoccerNet Annotation Tool", + "created_by": "User", + "license": "CC-BY-NC 4.0" + } + } + + # Reset paths + self.model.current_working_directory = None + self.model.current_json_path = None + + # Mark as loaded + self.model.json_loaded = True + self.model.is_data_dirty = True + + # 4. Refresh UI + self.main.dense_manager.populate_tree() + self.main.ui.show_dense_description_view() + self.main.update_save_export_button_state() + + if hasattr(self.main, "prepare_new_dense_ui"): + self.main.prepare_new_dense_ui() + + self.main.statusBar().showMessage("Project Created — Dense Description Workspace Ready", 5000) + + def load_project(self, data, file_path): + """ + Loads dense description project from JSON data. + Performs strict validation and preserves metadata. + """ + # --- [STEP 1] VALIDATION --- + # Call the strict validator defined in AppStateModel + if hasattr(self.model, "validate_dense_json"): + is_valid, error_msg, warning_msg = self.model.validate_dense_json(data) + + if not is_valid: + # Truncate extremely long error messages for display + if len(error_msg) > 1000: + error_msg = error_msg[:1000] + "\n... (truncated)" + + error_text = ( + "The imported JSON contains critical errors and cannot be loaded.\n\n" + f"{error_msg}\n\n" + "--------------------------------------------------\n" + "💡 Please download the correct Dense Description JSON format from:\n" + "https://huggingface.co/datasets/OpenSportsLab/soccernetpro-densedescription-sndvc" + ) + + QMessageBox.critical( + self.main, + "Validation Error (Dense Description)", + error_text, + ) + return False + + if warning_msg: + # Show warnings but allow loading to proceed + if len(warning_msg) > 1000: + warning_msg = warning_msg[:1000] + "\n... (truncated)" + res = QMessageBox.warning( + self.main, + "Validation Warnings", + "The file contains warnings:\n\n" + + warning_msg + + "\n\nDo you want to continue loading?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if res != QMessageBox.StandardButton.Yes: + return False + + # --- [STEP 2] CLEAR & SETUP --- + self._clear_workspace(full_reset=True) + + project_root = os.path.dirname(os.path.abspath(file_path)) + self.model.current_working_directory = project_root + self.model.current_task_name = data.get("dataset_name", data.get("task", "Dense Captioning")) + + # [NEW] Preserve Global Metadata + self.model.dense_global_metadata = { + "version": data.get("version", "1.0"), + "date": data.get("date", datetime.date.today().isoformat()), + "metadata": data.get("metadata", {}) + } + + missing_files = [] + loaded_count = 0 + + # --- [STEP 3] LOAD ITEMS --- + for item in data.get("data", []): + inputs = item.get("inputs", []) + if not inputs: continue + + raw_path = inputs[0].get("path", "") + # ID Priority: explicit 'id' -> filename without extension + aid = item.get("id") or os.path.splitext(os.path.basename(raw_path))[0] + + # Resolve Path + final_path = os.path.normpath(os.path.join(project_root, raw_path)) + if not os.path.exists(final_path): + missing_files.append(aid) + + # [NEW] Preserve Item-level Metadata (using AppState's imported_action_metadata) + if "metadata" in item: + self.model.imported_action_metadata[aid] = item["metadata"] + + # Register Clip + self.model.action_item_data.append({"name": aid, "path": final_path, "source_files": [final_path]}) + self.model.action_path_to_name[final_path] = aid + + # Load Events (dense_captions) + events = item.get("dense_captions", item.get("events", [])) + if events: + self.model.dense_description_events[final_path] = [] + for e in events: + self.model.dense_description_events[final_path].append({ + "position_ms": int(e.get("position_ms", 0)), + "lang": e.get("lang", "en"), + "text": e.get("text", "") + }) + loaded_count += 1 + + self.model.current_json_path = file_path + self.model.json_loaded = True + + # --- [STEP 4] FINALIZE --- + self.main.dense_manager.populate_tree() + + if missing_files: + QMessageBox.warning(self.main, "Load Warning", f"Could not find {len(missing_files)} video files locally.") + else: + self.main.statusBar().showMessage(f"Dense Mode: Loaded {loaded_count} clips.", 2000) + + return True + + def overwrite_json(self): + if self.model.current_json_path: + return self._write_json(self.model.current_json_path) + return self.export_json() + + def export_json(self): + path, _ = QFileDialog.getSaveFileName(self.main, "Export Dense JSON", "", "JSON (*.json)") + if path: return self._write_json(path) + return False + + def _write_json(self, path): + """Serializes current dense description state to JSON, preserving all metadata.""" + + # [NEW] Retrieve Global Metadata from Model (or defaults) + global_meta = getattr(self.model, "dense_global_metadata", {}) + + output = { + "version": global_meta.get("version", "1.0"), + "date": global_meta.get("date", datetime.date.today().isoformat()), + "task": "dense_video_captioning", + "dataset_name": self.model.current_task_name, + "metadata": global_meta.get("metadata", { + "source": "SoccerNet Annotation Tool", + "created_by": "User" + }), + "data": [] + } + + base_dir = os.path.dirname(path) + + sorted_items = sorted( + self.model.action_item_data, key=lambda d: natural_sort_key(d.get("name", "")) + ) + + for data in sorted_items: + abs_path = data["path"] + aid = data["name"] + events = self.model.dense_description_events.get(abs_path, []) + + try: + rel_path = os.path.relpath(abs_path, base_dir).replace(os.sep, "/") + except: + rel_path = abs_path + + # Format Events + export_events = [] + sorted_events = sorted(events, key=lambda x: x.get("position_ms", 0)) + + for e in sorted_events: + export_events.append({ + "position_ms": e["position_ms"], + "lang": e["lang"], + "text": e["text"] + }) + + # Build Item Entry + entry = { + "id": aid, + "inputs": [{"type": "video", "path": rel_path, "fps": 25}], # FPS is hardcoded or needs retrieval + "dense_captions": export_events + } + + # [NEW] Inject Item-level Metadata if available + item_meta = self.model.imported_action_metadata.get(aid) + if item_meta: + entry["metadata"] = item_meta + + output["data"].append(entry) + + try: + with open(path, "w", encoding="utf-8") as f: + json.dump(output, f, indent=4, ensure_ascii=False) + + self.model.current_json_path = path + self.model.is_data_dirty = False + self.main.statusBar().showMessage(f"Saved — {os.path.basename(path)}", 1500) + return True + except Exception as e: + QMessageBox.critical(self.main, "Error", f"Save failed: {e}") + return False + + def _clear_workspace(self, full_reset=False): + """Resets the workspace for Dense Description mode.""" + self.model.reset(full_reset) + + # [NEW] Clear global metadata explicitly if full reset + if full_reset: + self.model.dense_global_metadata = {} + + if hasattr(self.main, "dense_manager"): + self.main.dense_manager.media_controller.stop() + # ✅ [FIX] Clear media source so duration resets deterministically + self.main.dense_manager.center_panel.media_preview.player.setSource(QUrl()) + + # ✅ [FIX] Reset timeline UI + tl = self.main.dense_manager.center_panel.timeline + tl.set_markers([]) + tl.set_duration(0) + tl.set_position(0) + + # Clear right panel + self.main.dense_manager.right_panel.table.set_data([]) + self.main.dense_manager.right_panel.input_widget.set_text("") + + self.main.dense_manager.current_video_path = None diff --git a/annotation_tool/controllers/dense_description/dense_manager.py b/annotation_tool/controllers/dense_description/dense_manager.py new file mode 100644 index 0000000..4f4e454 --- /dev/null +++ b/annotation_tool/controllers/dense_description/dense_manager.py @@ -0,0 +1,462 @@ +import os +import copy +from PyQt6.QtWidgets import QMessageBox, QFileDialog +from PyQt6.QtCore import Qt, QTimer, QModelIndex +from PyQt6.QtGui import QColor +from PyQt6.QtMultimedia import QMediaPlayer + +from utils import natural_sort_key +from models import CmdType +from controllers.media_controller import MediaController + +class DenseManager: + """ + Controller for Dense Description mode. + Handles free-text annotation at specific timestamps, synchronized with video and timeline. + """ + def __init__(self, main_window): + self.main = main_window + self.model = main_window.model + self.tree_model = main_window.tree_model + + # Access UI components from the Dense Description view + self.ui_root = main_window.ui.dense_description_ui + self.left_panel = self.ui_root.left_panel + self.center_panel = self.ui_root.center_panel + self.right_panel = self.ui_root.right_panel + + # Media Controller setup + preview_widget = self.center_panel.media_preview + self.media_controller = MediaController(preview_widget.player, preview_widget.video_widget) + + self.current_video_path = None + + # Timer to throttle text updates during playback/scrubbing + self.sync_timer = QTimer() + self.sync_timer.setSingleShot(True) + self.sync_timer.setInterval(100) + self.sync_timer.timeout.connect(self._sync_editor_to_timeline) + + def setup_connections(self): + """Link UI signals to logic handlers.""" + # --- Left Panel (Clip Tree) --- + pc = self.left_panel.project_controls + + self.left_panel.tree.selectionModel().currentChanged.connect(self._on_clip_selected) + self.left_panel.filter_combo.currentIndexChanged.connect(self._apply_clip_filter) + + # --- Center Panel (Playback & Timeline) --- + media = self.center_panel.media_preview + timeline = self.center_panel.timeline + pb = self.center_panel.playback + + media.positionChanged.connect(self._on_media_position_changed) + media.durationChanged.connect(timeline.set_duration) + timeline.seekRequested.connect(media.set_position) + + # Playback Controls + pb.playPauseRequested.connect(self.media_controller.toggle_play_pause) + pb.seekRelativeRequested.connect(lambda d: media.set_position(media.player.position() + d)) + # Connect playback rate signal + pb.playbackRateRequested.connect(media.set_playback_rate) + + # Navigation + pb.nextPrevClipRequested.connect(self._navigate_clip) + pb.nextPrevAnnotRequested.connect(self._navigate_annotation) + + # --- Right Panel (Text Input & Table) --- + input_w = self.right_panel.input_widget + table = self.right_panel.table + + # Handle "Confirm Description" button + input_w.descriptionSubmitted.connect(self._on_description_submitted) + + # Table interactions + table.annotationSelected.connect(self._on_event_selected_from_table) + table.annotationDeleted.connect(self._on_delete_single_annotation) + table.annotationModified.connect(self._on_annotation_modified) + + def _on_media_position_changed(self, ms): + """Update timeline and the time label in the input widget.""" + self.center_panel.timeline.set_position(ms) + time_str = self._fmt_ms_full(ms) + self.right_panel.input_widget.update_time(time_str) + + # Try to sync editor text if playback stops or during scrub + if not self.sync_timer.isActive(): + self.sync_timer.start() + + def _on_clip_selected(self, current_idx, previous_idx): + """Load video and refresh annotations when a tree item is clicked.""" + if not current_idx.isValid(): + self.current_video_path = None + return + + path = current_idx.data(Qt.ItemDataRole.UserRole) + if path == self.current_video_path: return + + if path and os.path.exists(path): + self.current_video_path = path + + # Clear editor first to avoid ghost text from previous video + self.right_panel.input_widget.set_text("") + + self.media_controller.load_and_play(path) + self._display_events_for_item(path) + else: + if path: QMessageBox.warning(self.main, "Error", f"File not found: {path}") + + def _on_event_selected_from_table(self, ms): + """ + Called when user clicks a row in the table. + 1. Seek video to timestamp. + 2. Populate input widget with the text of that event. + """ + # 1. Seek + self.center_panel.media_preview.set_position(ms) + + # 2. Sync Editor (Force immediate sync) + self._sync_editor_to_timeline() + + def _on_description_submitted(self, text): + """ + Logic for adding OR updating an annotation. + If an event exists at the current time (within tolerance), update it. + Otherwise, add a new one. + """ + if not self.current_video_path: + QMessageBox.warning(self.main, "Warning", "Please select a video first.") + return + + pos_ms = self.center_panel.media_preview.player.position() + events = self.model.dense_description_events.get(self.current_video_path, []) + + tolerance = 50 + existing_event = None + existing_index = -1 + + for i, e in enumerate(events): + if abs(e['position_ms'] - pos_ms) <= tolerance: + existing_event = e + existing_index = i + break + + if existing_event: + # --- MODIFY EXISTING EVENT --- + if existing_event['text'] == text: + return # No change + + new_event = copy.deepcopy(existing_event) + new_event['text'] = text + + self.model.push_undo(CmdType.DENSE_EVENT_MOD, + video_path=self.current_video_path, + old_event=copy.deepcopy(existing_event), + new_event=new_event) + + events[existing_index] = new_event + self.main.show_temp_msg("Updated", "Description updated.") + # [FIX] Do NOT clear input widget here, user wants to see the updated text. + + else: + # --- ADD NEW EVENT --- + new_event = { + "position_ms": pos_ms, + "lang": "en", + "text": text + } + + self.model.push_undo(CmdType.DENSE_EVENT_ADD, video_path=self.current_video_path, event=new_event) + + if self.current_video_path not in self.model.dense_description_events: + self.model.dense_description_events[self.current_video_path] = [] + + self.model.dense_description_events[self.current_video_path].append(new_event) + self.main.show_temp_msg("Added", "Dense description created.") + + # [FIX] Clear input widget ONLY on Add + self.right_panel.input_widget.set_text("") + + # Common Update Logic + self.model.is_data_dirty = True + self._display_events_for_item(self.current_video_path) + self.main.update_action_item_status(self.current_video_path) + self.main.update_save_export_button_state() + + def _display_events_for_item(self, path): + """Refresh the table and timeline markers for the current video.""" + # [FIX] 1. Capture current selection (if any) before resetting model + current_selection_ms = None + indexes = self.right_panel.table.table.selectionModel().selectedRows() + if indexes: + # Get the object from the model before it changes + row = indexes[0].row() + item = self.right_panel.table.model.get_annotation_at(row) + if item: + current_selection_ms = item.get('position_ms') + + # 2. Update Data + events = self.model.dense_description_events.get(path, []) + sorted_events = sorted(events, key=lambda x: x.get('position_ms', 0)) + + self.right_panel.table.set_data(sorted_events) + + markers = [{'start_ms': e.get('position_ms', 0), 'color': QColor("#FFD700")} for e in sorted_events] + self.center_panel.timeline.set_markers(markers) + + # [FIX] 3. Restore Selection + if current_selection_ms is not None: + self._select_row_by_time(current_selection_ms) + + # 4. Sync Editor + if path == self.current_video_path: + self._sync_editor_to_timeline() + + def _sync_editor_to_timeline(self): + """ + Checks if there is an event at the current playback position. + If yes, populates the editor with its text. + """ + if not self.current_video_path: return + + current_ms = self.center_panel.media_preview.player.position() + events = self.model.dense_description_events.get(self.current_video_path, []) + + # Same tolerance as submission + tolerance = 50 + target_text = "" + found = False + + for e in events: + if abs(e['position_ms'] - current_ms) <= tolerance: + target_text = e['text'] + found = True + break + + # Update UI only if changed to avoid cursor jumping + # Only update if we found an event. If we didn't find one, we keep the text + # (user might be typing a new one, or we just undid an Add). + if found: + current_ui_text = self.right_panel.input_widget.text_editor.toPlainText() + if current_ui_text != target_text: + self.right_panel.input_widget.set_text(target_text) + + def _on_annotation_modified(self, old_event, new_event): + """Handles direct edits within the table cells.""" + events = self.model.dense_description_events.get(self.current_video_path, []) + try: + index = events.index(old_event) + except ValueError: + return + + self.model.push_undo(CmdType.DENSE_EVENT_MOD, + video_path=self.current_video_path, + old_event=copy.deepcopy(old_event), + new_event=new_event) + + events[index] = new_event + self.model.is_data_dirty = True + + # Defer display refresh to fix QAbstractItemView error + QTimer.singleShot(0, lambda: self._display_events_for_item(self.current_video_path)) + + self.main.show_temp_msg("Updated", "Description modified.") + self.main.update_save_export_button_state() + + def _on_delete_single_annotation(self, item_data): + """Handles the deletion of a specific description point.""" + events = self.model.dense_description_events.get(self.current_video_path, []) + if item_data not in events: return + + self.model.push_undo(CmdType.DENSE_EVENT_DEL, video_path=self.current_video_path, event=copy.deepcopy(item_data)) + events.remove(item_data) + self.model.is_data_dirty = True + self._display_events_for_item(self.current_video_path) + self.main.update_action_item_status(self.current_video_path) + self.main.update_save_export_button_state() + self.right_panel.input_widget.set_text("") # Clear editor on delete + + def populate_tree(self): + """Rebuilds the left project tree for Dense Description mode.""" + self.left_panel.tree.blockSignals(True) + 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', ''))) + + first_idx = None + for i, data in enumerate(sorted_list): + name = data['name'] + path = data['path'] + item = self.tree_model.add_entry(name, path, data.get('source_files')) + self.model.action_item_map[path] = item + + events = self.model.dense_description_events.get(path, []) + item.setIcon(self.main.done_icon if events else self.main.empty_icon) + + if i == 0: + first_idx = item.index() + + self.left_panel.project_controls.set_project_loaded_state(True) + self._apply_clip_filter(self.left_panel.filter_combo.currentIndex()) + + if first_idx and first_idx.isValid(): + self.left_panel.tree.setCurrentIndex(first_idx) + self._on_clip_selected(first_idx, None) + + self.left_panel.tree.blockSignals(False) + + def _apply_clip_filter(self, index): + """Filter the tree based on 'Show Annotated' vs 'Not Annotated'.""" + root = self.tree_model.invisibleRootItem() + for i in range(root.rowCount()): + item = root.child(i) + path = item.data(Qt.ItemDataRole.UserRole) + has_anno = len(self.model.dense_description_events.get(path, [])) > 0 + hide = (index == 1 and not has_anno) or (index == 2 and has_anno) + self.left_panel.tree.setRowHidden(i, QModelIndex(), hide) + + + def remove_single_item(self, index: QModelIndex): + """ + [NEW] Handles the removal of a single video clip from the project. + """ + if not index.isValid(): + return + + # 1. Get the file path + path = index.data(Qt.ItemDataRole.UserRole) + + # 2. Confirm deletion + reply = QMessageBox.question( + self.main, "Remove Video", + f"Are you sure you want to remove this video and its annotations?\n\n{os.path.basename(path)}", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + if reply != QMessageBox.StandardButton.Yes: + return + + # 3. If removing the currently playing video, stop and clear + if path == self.current_video_path: + self.media_controller.stop() + self.current_video_path = None + self.right_panel.table.set_data([]) + self.center_panel.timeline.set_markers([]) + self.right_panel.input_widget.set_text("") + + # 4. Remove from Data Model (AppState) + # Remove from action_item_data list + self.model.action_item_data = [ + item for item in self.model.action_item_data + if item['path'] != path + ] + + # Remove from path mapping + if path in self.model.action_item_map: + del self.model.action_item_map[path] + + # Remove associated dense events + if path in self.model.dense_description_events: + del self.model.dense_description_events[path] + + # 5. Remove from Tree View Model + self.tree_model.removeRow(index.row()) + + # 6. Mark project as dirty + self.model.is_data_dirty = True + self.main.show_temp_msg("Removed", "Video removed from project.") + + def _navigate_clip(self, step): + tree = self.left_panel.tree + curr = tree.currentIndex() + if not curr.isValid(): return + nxt = tree.indexBelow(curr) if step > 0 else tree.indexAbove(curr) + if nxt.isValid(): tree.setCurrentIndex(nxt) + + def _navigate_annotation(self, step): + events = self.model.dense_description_events.get(self.current_video_path, []) + if not events: return + sorted_evts = sorted(events, key=lambda x: x.get('position_ms', 0)) + cur_pos = self.center_panel.media_preview.player.position() + target = None + if step > 0: + for e in sorted_evts: + if e['position_ms'] > cur_pos + 100: target = e; break + else: + for e in reversed(sorted_evts): + if e['position_ms'] < cur_pos - 100: target = e; break + + if target is not None: + self.center_panel.media_preview.set_position(target['position_ms']) + # Ensure navigation also populates the text box + self._select_row_by_time(target['position_ms']) + self.right_panel.input_widget.set_text(target['text']) + + def _select_row_by_time(self, time_ms): + """Selects the row in the table that matches the given timestamp.""" + model = self.right_panel.table.model + for row in range(model.rowCount()): + item = model.get_annotation_at(row) + if item and abs(item.get('position_ms', 0) - time_ms) < 20: + self.right_panel.table.table.selectRow(row) + break + + def _on_add_video_clicked(self): + """Handles adding videos to the current project.""" + start_dir = self.model.current_working_directory or "" + files, _ = QFileDialog.getOpenFileNames(self.main, "Select Video(s)", start_dir, "Video (*.mp4 *.avi *.mov *.mkv)") + if not files: return + + if not self.model.current_working_directory: + self.model.current_working_directory = os.path.dirname(files[0]) + + added_count = 0 + first_new_item_idx = None + + 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]}) + self.model.action_path_to_name[file_path] = name + + item = self.tree_model.add_entry(name=name, path=file_path, source_files=[file_path]) + self.model.action_item_map[file_path] = item + + if added_count == 0: + first_new_item_idx = item.index() + added_count += 1 + + if added_count > 0: + self.model.is_data_dirty = True + self.main.show_temp_msg("Videos Added", f"Added {added_count} clips.") + + if first_new_item_idx and first_new_item_idx.isValid(): + self.left_panel.tree.setCurrentIndex(first_new_item_idx) + self._on_clip_selected(first_new_item_idx, None) + + def _on_clear_all_clicked(self): + """Resets the workspace.""" + if not self.model.action_item_data: return + res = QMessageBox.question(self.main, "Clear All", "Are you sure you want to clear the workspace? Unsaved changes will be lost.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) + if res != QMessageBox.StandardButton.Yes: return + + self.media_controller.stop() + self.model.reset(full_reset=True) + + self.current_video_path = None + self.tree_model.clear() + self.right_panel.table.set_data([]) + self.center_panel.timeline.set_markers([]) + self.right_panel.input_widget.set_text("") + + self.main.ui.show_welcome_view() + self.main.show_temp_msg("Cleared", "Workspace reset.") + self.main.update_save_export_button_state() + + 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}" diff --git a/annotation_tool/controllers/description/.DS_Store b/annotation_tool/controllers/description/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/annotation_tool/controllers/description/.DS_Store differ diff --git a/annotation_tool/controllers/description/README.md b/annotation_tool/controllers/description/README.md new file mode 100644 index 0000000..c2a922f --- /dev/null +++ b/annotation_tool/controllers/description/README.md @@ -0,0 +1,42 @@ +# 🧠 Controllers: Description + +This directory contains the business logic for the **Description** mode (Global Video Captioning). + +Unlike other modes, this controller set must handle **Multi-Clip Actions** (where one logical "Action" ID might consist of multiple video files/camera angles) and legacy **Q&A formatting**. + +## 📂 Files + +### `desc_navigation_manager.py` + +**Playback & Navigation Logic.** +Manages the Center Panel (Video Player) and the Left Sidebar (Tree View). + +* **Key Responsibilities:** +* **Media Control:** Wraps the shared `MediaController`. It implements the "Stop -> Clear -> Load -> Play" sequence to ensure smooth video transitions without artifacts. +* **Tree Navigation:** Handles logic for moving between **Actions** (logical items) vs. **Clips** (physical files within an action). +* **Dynamic Loading:** Resolves video paths based on the project's root directory. + + + +### `desc_annotation_manager.py` + +**Editor & Data Logic.** +Manages the Right Panel (Text Input). + +* **Key Responsibilities:** +* **Q&A Formatting:** If the loaded JSON contains `questions`, it formats them into a readable "Q: ... A: ..." block in the text editor. +* **Flattening:** Upon saving, it consolidates the text into a single description block (unless strict schema enforcement is added later). +* **Undo/Redo:** Pushes `CmdType.DESC_EDIT` commands to the global history stack. +* **State Tracking:** Updates the tree icon (Checkmark/Empty) immediately after a save. + + + +### `desc_file_manager.py` + +**The I/O Handler.** +Manages the specific JSON schema for Global Captioning tasks. + +* **Key Responsibilities:** +* **Multi-Clip Support:** Can parse JSON where one `id` has multiple entries in the `inputs` list. It ensures all related files are registered in the `ProjectTreeModel`. +* **Legacy Support:** Handles older JSON formats where captions might be split by questions. +* **Export:** Reconstructs the `inputs` array during export to ensure the output JSON matches the input structure (preserving `name`, `fps`, etc.). diff --git a/Tool/ui/__init__.py b/annotation_tool/controllers/description/__init__.py similarity index 100% rename from Tool/ui/__init__.py rename to annotation_tool/controllers/description/__init__.py diff --git a/annotation_tool/controllers/description/desc_annotation_manager.py b/annotation_tool/controllers/description/desc_annotation_manager.py new file mode 100644 index 0000000..6251a56 --- /dev/null +++ b/annotation_tool/controllers/description/desc_annotation_manager.py @@ -0,0 +1,171 @@ +import copy +from PyQt6.QtCore import QModelIndex +from PyQt6.QtWidgets import QMessageBox +from models.project_tree import ProjectTreeModel +# [NEW] Import CmdType for Undo/Redo +from models.app_state import CmdType + +class DescAnnotationManager: + """ + Manages data loading and saving for Description Mode (Right Panel). + Handles the formatting of Q&A from JSON and flattening it upon save. + Supports Undo/Redo operations. + """ + def __init__(self, main_window): + self.main = main_window + self.model = main_window.model + self.ui = main_window.ui.description_ui.right_panel + self.current_action_path = None + + def setup_connections(self): + """Connect UI signals to controller methods.""" + # Listen to Tree Selection from the Description Panel + tree = self.main.ui.description_ui.left_panel.tree + tree.selectionModel().currentChanged.connect(self.on_item_selected) + + # Connect Editor Buttons + self.ui.confirm_clicked.connect(self.save_current_annotation) + self.ui.clear_clicked.connect(self.clear_current_text) + + def on_item_selected(self, current: QModelIndex, previous: QModelIndex): + """ + Triggered when a tree item is selected. + Loads the corresponding data into the text editor. + """ + if not current.isValid(): + self.ui.caption_edit.clear() + self.current_action_path = None + self.ui.caption_edit.setEnabled(False) + return + + # 1. Identify the Action Path + path = current.data(ProjectTreeModel.FilePathRole) + model = self.main.tree_model + + # If user clicked a child (video), find the parent (action) to show shared annotations + if not model.hasChildren(current) and current.parent().isValid(): + parent_idx = current.parent() + path = parent_idx.data(ProjectTreeModel.FilePathRole) + + self.current_action_path = path + self.ui.caption_edit.setEnabled(True) + + # 2. Find Data Object in Model + # We search by path first, fallback to ID if needed + action_data = next((item for item in self.model.action_item_data if item.get("metadata", {}).get("path") == path), None) + if not action_data: + #action_data = next((item for item in self.model.action_item_data if item.get("id") == current.text()), None) + action_data = next((item for item in self.model.action_item_data if item.get("id") == current.data()), None) + + if not action_data: + self.ui.caption_edit.setPlaceholderText("No metadata found for this item.") + return + + # 3. Format and Display Text + self._load_and_format_text(action_data) + + def _load_and_format_text(self, data): + """ + Formats the display text. + - If 'captions' contains 'question' fields, formats as Q&A blocks. + - If 'captions' is plain text (already saved), displays it directly. + """ + captions = data.get("captions", []) + formatted_blocks = [] + + if captions: + # Iterate through existing captions (which might be raw Q&A or edited text) + for cap in captions: + text = cap.get("text", "") + question = cap.get("question", "") # Check for the 'question' key + + if question: + # Format as Q & A if the question key exists + formatted_blocks.append(f'Q: "{question}"\nA: "{text}"') + else: + # Otherwise, just append the text (e.g. for already edited/flattened descriptions) + formatted_blocks.append(text) + + full_text = "\n\n".join(formatted_blocks) + + else: + # Fallback: If no captions exist yet, try to generate template from metadata questions + metadata = data.get("metadata", {}) + questions = metadata.get("questions", []) + for i, q in enumerate(questions): + formatted_blocks.append(f'Q: "{q}"\nA: ""') + + full_text = "\n\n".join(formatted_blocks) + + self.ui.caption_edit.setPlainText(full_text) + + def save_current_annotation(self): + """ + Saves the current text content back to the JSON model. + Flattens the structure: removes 'question' keys and saves everything as one text block. + Now supports UNDO/REDO. + """ + if not self.current_action_path: + return + + text_content = self.ui.caption_edit.toPlainText() + + # Find the target data item in the model + target_item = None + for item in self.model.action_item_data: + if item.get("metadata", {}).get("path") == self.current_action_path: + target_item = item + break + + if target_item: + # --- [NEW] Undo/Redo Logic Start --- + + # 1. Capture Old State (Deep copy to ensure isolation) + old_captions = copy.deepcopy(target_item.get("captions", [])) + + # 2. Define New State + new_captions = [ + { + "lang": "en", + "text": text_content + } + ] + + # 3. Push Command to History Stack + self.model.push_undo( + CmdType.DESC_EDIT, + path=self.current_action_path, + old_data=old_captions, + new_data=new_captions + ) + # --- Undo/Redo Logic End --- + + # Apply the Change + target_item["captions"] = new_captions + + # Mark state as dirty so Save button becomes active + self.model.is_data_dirty = True + self.main.update_save_export_button_state() + + # Update Tree Icon (Done/Empty) + is_done = bool(text_content.strip()) + self._update_tree_icon(self.current_action_path, is_done) + + self.main.show_temp_msg("Saved", "Description updated.") + + # Auto-advance to next item + self._auto_advance() + + def clear_current_text(self): + """Clears the editor text.""" + self.ui.caption_edit.clear() + + def _update_tree_icon(self, path, is_done): + """Updates the checkmark icon in the tree view.""" + item = self.model.action_item_map.get(path) + if item: + item.setIcon(self.main.done_icon if is_done else self.main.empty_icon) + + def _auto_advance(self): + """Moves selection to the next Action in the tree automatically.""" + self.main.desc_nav_manager.nav_next_action() \ No newline at end of file diff --git a/annotation_tool/controllers/description/desc_file_manager.py b/annotation_tool/controllers/description/desc_file_manager.py new file mode 100644 index 0000000..f910763 --- /dev/null +++ b/annotation_tool/controllers/description/desc_file_manager.py @@ -0,0 +1,274 @@ +import os +import json +import datetime +from PyQt6.QtWidgets import QFileDialog, QMessageBox +from utils import natural_sort_key + +class DescFileManager: + """ + Handles JSON I/O for Description (Video Captioning) projects. + Includes strict validation and metadata preservation. + Fixed to support Multi-Clip Actions and Q&A Caption structures. + """ + def __init__(self, main_window): + self.main = main_window + self.model = main_window.model + self.ui = main_window.ui + + def create_new_project(self): + """Create a blank Description project.""" + self._clear_workspace(full_reset=True) + + # Initialize Default State + self.model.current_task_name = "Untitled Description Task" + self.model.project_description = "" + self.model.modalities = ["video"] + + # Initialize Global Metadata + self.model.desc_global_metadata = { + "version": "1.0", + "date": datetime.date.today().isoformat(), + "metadata": { + "source": "SoccerNet Annotation Tool", + "created_by": "User", + } + } + + self.model.json_loaded = True + self.model.is_data_dirty = True + self.model.current_json_path = None + self.model.current_working_directory = None + + self.main.setup_dynamic_ui() + self.main.ui.show_description_view() + self.main.update_save_export_button_state() + + if hasattr(self.main, "prepare_new_desc_ui"): + self.main.prepare_new_desc_ui() + + self.main.statusBar().showMessage("Project Created — Description Workspace Ready", 5000) + + def load_project(self, data, file_path): + """ + Load Description project from JSON. + Returns True if successful, False otherwise. + """ + # --- [STEP 1] VALIDATION --- + if hasattr(self.model, "validate_desc_json"): + is_valid, error_msg, warning_msg = self.model.validate_desc_json(data) + + if not is_valid: + if len(error_msg) > 1000: + error_msg = error_msg[:1000] + "\n... (truncated)" + error_text = ( + "The imported JSON contains critical errors and cannot be loaded.\n\n" + f"{error_msg}\n\n" + "--------------------------------------------------\n" + "💡 Please download the correct Description JSON format from:\n" + "https://huggingface.co/datasets/OpenSportsLab/soccernetpro-description-xfoul" + ) + + QMessageBox.critical( + self.main, + "Validation Error (Description)", + error_text, + ) + return False + + if warning_msg: + if len(warning_msg) > 1000: + warning_msg = warning_msg[:1000] + "\n... (truncated)" + res = QMessageBox.warning( + self.main, + "Validation Warnings", + "The file contains warnings:\n\n" + warning_msg + "\n\nContinue loading?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if res != QMessageBox.StandardButton.Yes: + return False + + # --- [STEP 2] CLEAR & LOAD --- + self._clear_workspace(full_reset=True) + + self.model.current_working_directory = os.path.dirname(os.path.abspath(file_path)) + self.model.current_task_name = data.get("dataset_name", data.get("task", "Description Task")) + + # Preserve Global Metadata + self.model.desc_global_metadata = { + "version": data.get("version", "1.0"), + "date": data.get("date", datetime.date.today().isoformat()), + "metadata": data.get("metadata", {}) + } + + loaded_count = 0 + missing_files = [] + + # --- [STEP 3] PROCESS ITEMS (Multi-Clip Support) --- + for item in data.get("data", []): + inputs = item.get("inputs", []) + if not inputs: continue + + aid = item.get("id", "Unknown ID") + + # 1. Resolve ALL Source Files (for Tree Structure) + source_files = [] + for inp in inputs: + raw_path = inp.get("path", "") + if not raw_path: continue + + # Path Resolution + if os.path.isabs(raw_path): + final_path = raw_path + else: + final_path = os.path.normpath(os.path.join(self.model.current_working_directory, raw_path)) + + if os.path.exists(final_path): + source_files.append(final_path) + else: + # Keep valid structure even if file missing locally, but warn + source_files.append(final_path) + + if not source_files: + missing_files.append(aid) + continue + + # Check if any files are missing locally for warning + if not any(os.path.exists(p) for p in source_files): + missing_files.append(aid) + + # 2. Determine Action Key/Path + # Priority: item.metadata.path -> item.id -> first video path + # This 'action_path' is what keys the Tree Item to the Data. + meta = item.get("metadata", {}) + action_path = meta.get("path") + if not action_path: + action_path = aid # Fallback to ID if no path in metadata + + # 3. Store COMPLETE Data Structure + # We store the raw 'inputs' to preserve 'name': 'video1' etc. + # We store 'captions' as-is so AnnotationManager sees the Q&A list. + entry = { + "name": aid, + "path": action_path, # Key for Tree + "source_files": source_files, # List of absolute paths for playback/tree children + "inputs": inputs, # Original input metadata + "captions": item.get("captions", []), + "metadata": meta, + "id": aid + } + + # 4. Register + self.model.action_item_data.append(entry) + self.model.action_path_to_name[action_path] = aid + + # Store item metadata in the separate lookup if needed (legacy compatibility) + if meta: + self.model.imported_action_metadata[aid] = meta + + loaded_count += 1 + + self.model.current_json_path = file_path + self.model.json_loaded = True + + # Populate UI Tree + # The viewer will call tree_model.add_entry using 'name', 'path', and 'source_files' from our entry + self.main.populate_action_tree() + self.main.update_save_export_button_state() + + if missing_files: + QMessageBox.warning(self.main, "Load Warning", f"Could not find video files for {len(missing_files)} actions locally.") + else: + self.main.statusBar().showMessage(f"Loaded {loaded_count} actions into Description Mode.", 2000) + + return True + + def save_json(self): + if self.model.current_json_path: + return self._write_json(self.model.current_json_path) + return self.export_json() + + def export_json(self): + path, _ = QFileDialog.getSaveFileName(self.main, "Export Description JSON", "", "JSON (*.json)") + if path: return self._write_json(path) + return False + + def _write_json(self, path): + """Writes current state to JSON, preserving full structure.""" + global_meta = getattr(self.model, "desc_global_metadata", {}) + + output = { + "version": global_meta.get("version", "1.0"), + "date": global_meta.get("date", datetime.date.today().isoformat()), + "task": "video_captioning", + "dataset_name": self.model.current_task_name, + "metadata": global_meta.get("metadata", {}), + "data": [] + } + + base_dir = os.path.dirname(path) + sorted_items = sorted(self.model.action_item_data, key=lambda d: natural_sort_key(d.get("name", ""))) + + for data in sorted_items: + # We reconstruct the item from our internal model data + # which is kept in sync by DescAnnotationManager + + # 1. Reconstruct Inputs + # Try to use original 'inputs' structure, just updating paths to relative + export_inputs = [] + original_inputs = data.get("inputs", []) + source_files = data.get("source_files", []) + + if len(original_inputs) == len(source_files): + # We can map 1-to-1 + for i, inp in enumerate(original_inputs): + new_inp = inp.copy() + abs_p = source_files[i] + try: + rel_p = os.path.relpath(abs_p, base_dir).replace(os.sep, "/") + except: + rel_p = abs_p + new_inp["path"] = rel_p + export_inputs.append(new_inp) + else: + # Fallback: create new input structs from source_files + for i, abs_p in enumerate(source_files): + try: + rel_p = os.path.relpath(abs_p, base_dir).replace(os.sep, "/") + except: + rel_p = abs_p + export_inputs.append({ + "type": "video", + "name": f"video{i+1}", + "path": rel_p + }) + + # 2. Build Entry + entry = { + "id": data.get("name") or data.get("id"), + "metadata": data.get("metadata", {}), + "inputs": export_inputs, + "captions": data.get("captions", []) # This contains the edited/loaded captions + } + + output["data"].append(entry) + + try: + with open(path, "w", encoding="utf-8") as f: + json.dump(output, f, indent=4, ensure_ascii=False) + self.model.is_data_dirty = False + self.main.statusBar().showMessage(f"Saved to {os.path.basename(path)}", 2000) + return True + except Exception as e: + QMessageBox.critical(self.main, "Save Error", str(e)) + return False + + def _clear_workspace(self, full_reset=False): + self.main.tree_model.clear() + self.model.reset(full_reset) + if full_reset: + self.model.desc_global_metadata = {} + + # Clear Description UI elements + if hasattr(self.main.ui, "description_ui"): + self.main.ui.description_ui.right_panel.caption_edit.clear() + self.main.ui.description_ui.right_panel.caption_edit.setEnabled(False) diff --git a/annotation_tool/controllers/description/desc_navigation_manager.py b/annotation_tool/controllers/description/desc_navigation_manager.py new file mode 100644 index 0000000..20a084b --- /dev/null +++ b/annotation_tool/controllers/description/desc_navigation_manager.py @@ -0,0 +1,241 @@ +import os +from PyQt6.QtCore import QModelIndex +from PyQt6.QtWidgets import QMessageBox, QFileDialog, QWidget + +# [Ref] Import Model Roles +from models.project_tree import ProjectTreeModel + +# [Ref] Import the Unified MediaController +from controllers.media_controller import MediaController + +class DescNavigationManager: + """ + Handles file navigation, video playback, data addition, and filtering for Description Mode. + Refactored to STRICTLY follow Classification playback logic (No Looping) and pass video_widget for proper clearing. + """ + def __init__(self, main_window): + self.main = main_window + self.ui = main_window.ui + self.model = main_window.model + + # [UPDATED] Initialize Media Controller with Player AND VideoWidget + # 1. Access the UI components + center_panel = self.ui.description_ui.center_panel + preview_panel = center_panel.preview # This is DescriptionMediaPreview + + player = center_panel.player # Exposed in DescriptionMediaPlayer + + # 2. Locate the QVideoWidget for forced repainting + # This is critical for the MediaController.stop() to work correctly (clearing the black screen) + video_widget = None + + # Check standard paths based on recent refactoring + if hasattr(preview_panel, 'surface') and hasattr(preview_panel.surface, 'video_widget'): + # If using the shared VideoSurface wrapper + video_widget = preview_panel.surface.video_widget + elif hasattr(preview_panel, 'video_widget'): + # If directly attached + video_widget = preview_panel.video_widget + else: + # Fallback search by object name/type + video_widget = preview_panel.findChild(QWidget, "video_preview_widget") + + # 3. Instantiate the controller + self.media_controller = MediaController(player, video_widget) + + # [CRITICAL] Disable looping to match Classification stability + # self.media_controller.set_looping(True) + + def setup_connections(self): + """Called by viewer.py to wire up signals.""" + # Tree Selection + tree = self.ui.description_ui.left_panel.tree + tree.selectionModel().currentChanged.connect(self.on_item_selected) + + # Center Panel Controls + center = self.ui.description_ui.center_panel + + # Connect Play button via Controller + center.play_btn.clicked.connect(self.toggle_play_pause) + + # Navigation Buttons + center.prev_action.clicked.connect(self.nav_prev_action) + center.prev_clip.clicked.connect(self.nav_prev_clip) + center.next_clip.clicked.connect(self.nav_next_clip) + center.next_action.clicked.connect(self.nav_next_action) + + def toggle_play_pause(self): + """Delegate play/pause to the controller.""" + self.media_controller.toggle_play_pause() + + def on_item_selected(self, current: QModelIndex, previous: QModelIndex): + """ + Triggered when user clicks an item in the tree. + Uses MediaController to load video smoothly. + """ + if not current.isValid(): return + + path = current.data(ProjectTreeModel.FilePathRole) + model = self.main.tree_model + + # Handle folder selection: try to play first child + if model.hasChildren(current): + first_child_idx = model.index(0, 0, current) + if first_child_idx.isValid(): + path = first_child_idx.data(ProjectTreeModel.FilePathRole) + else: + return + + # Resolve absolute path + cwd = self.model.current_working_directory + if path and cwd and not os.path.isabs(path): + full_path = os.path.normpath(os.path.join(cwd, path)) + else: + full_path = path + + if not full_path or not os.path.exists(full_path): + return + + # [CRITICAL] EXACT Classification Logic + # Stop -> Clear -> Load -> Delay 150ms -> Play + self.media_controller.load_and_play(full_path) + + # ------------------------------------------------------------------------- + # Data Management (Adding items, Filtering) + # ------------------------------------------------------------------------- + + def add_items_via_dialog(self): + """Allows user to add video files to the Description project.""" + if not self.model.json_loaded: + QMessageBox.warning(self.main, "Warning", "Please create or load a project first.") + return + + filters = "Media Files (*.mp4 *.avi *.mov *.mkv *.jpg *.jpeg *.png *.bmp);;All Files (*)" + start_dir = self.model.current_working_directory or "" + + # [FIXED] Call QFileDialog FIRST before accessing 'files' + files, _ = QFileDialog.getOpenFileNames(self.main, "Select Videos to Add", start_dir, filters) + if not files: return + + if not self.model.current_working_directory: + self.model.current_working_directory = os.path.dirname(files[0]) + + added_count = 0 + first_new_idx = None # [NEW] Track the first new item index + + for file_path in files: + if any(d.get('metadata', {}).get('path') == file_path for d in self.model.action_item_data): + continue + + name = os.path.basename(file_path) + + new_item = { + "id": name, + "metadata": {"path": file_path, "questions": []}, + "inputs": [{"type": "video", "name": name, "path": file_path}], + "captions": [] + } + + self.model.action_item_data.append(new_item) + + # Add entry to the tree model + item = self.main.tree_model.add_entry(name=name, path=file_path, source_files=[file_path]) + self.model.action_item_map[file_path] = item + + # [NEW] Capture the index of the first added item + if added_count == 0: + first_new_idx = item.index() + + added_count += 1 + + if added_count > 0: + self.model.is_data_dirty = True + self.main.show_temp_msg("Added", f"Added {added_count} items.") + self.main.update_save_export_button_state() + self.apply_action_filter() + + # [CRITICAL FIX] Auto-select and play the first added video + # This triggers on_item_selected -> media_controller.load_and_play + if first_new_idx and first_new_idx.isValid(): + tree = self.ui.description_ui.left_panel.tree + tree.setCurrentIndex(first_new_idx) + tree.setFocus() # Ensure keyboard shortcuts work immediately + + def apply_action_filter(self): + """Filters the tree items based on Done/Not Done status.""" + idx = self.ui.description_ui.left_panel.filter_combo.currentIndex() + tree_view = self.ui.description_ui.left_panel.tree + model = self.main.tree_model + + FILTER_DONE = self.main.FILTER_DONE + FILTER_NOT_DONE = self.main.FILTER_NOT_DONE + + root = model.invisibleRootItem() + for i in range(root.rowCount()): + item = root.child(i) + path = item.data(ProjectTreeModel.FilePathRole) + + is_done = False + + data_item = None + for d in self.model.action_item_data: + if d.get("metadata", {}).get("path") == path: + data_item = d + break + + if not data_item: + for d in self.model.action_item_data: + if d.get("id") == item.text(): + data_item = d + break + + if data_item: + captions = data_item.get("captions", []) + if captions and captions[0].get("text", "").strip(): + is_done = True + + should_hide = False + if idx == FILTER_DONE and not is_done: should_hide = True + elif idx == FILTER_NOT_DONE and is_done: should_hide = True + + tree_view.setRowHidden(i, QModelIndex(), should_hide) + + # ------------------------------------------------------------------------- + # Tree Navigation Helpers + # ------------------------------------------------------------------------- + def nav_prev_action(self): self._nav_tree(step=-1, level='top') + def nav_next_action(self): self._nav_tree(step=1, level='top') + def nav_prev_clip(self): self._nav_tree(step=-1, level='child') + def nav_next_clip(self): self._nav_tree(step=1, level='child') + + def _nav_tree(self, step, level): + tree = self.ui.description_ui.left_panel.tree + curr = tree.currentIndex() + if not curr.isValid(): return + + model = self.main.tree_model + + if level == 'top': + if curr.parent().isValid(): curr = curr.parent() + new_row = curr.row() + step + + if 0 <= new_row < model.rowCount(QModelIndex()): + new_idx = model.index(new_row, 0, QModelIndex()) + tree.setCurrentIndex(new_idx); tree.scrollTo(new_idx) + + elif level == 'child': + parent = curr.parent() + if not parent.isValid(): + if step == 1 and model.rowCount(curr) > 0: + child = model.index(0, 0, curr) + tree.setCurrentIndex(child) + elif step == -1: + self.nav_prev_action() + else: + new_row = curr.row() + step + if 0 <= new_row < model.rowCount(parent): + new_idx = model.index(new_row, 0, parent) + tree.setCurrentIndex(new_idx) + else: + if step == 1: self.nav_next_action() + else: self.nav_prev_action() \ No newline at end of file diff --git a/Tool/controllers/history_manager.py b/annotation_tool/controllers/history_manager.py similarity index 68% rename from Tool/controllers/history_manager.py rename to annotation_tool/controllers/history_manager.py index 29132e4..2a126d3 100644 --- a/Tool/controllers/history_manager.py +++ b/annotation_tool/controllers/history_manager.py @@ -1,11 +1,11 @@ import copy from models import CmdType -from ui.widgets import DynamicSingleLabelGroup, DynamicMultiLabelGroup +from ui.classification.event_editor import DynamicSingleLabelGroup, DynamicMultiLabelGroup class HistoryManager: """ - 通用历史管理器:负责处理 Undo/Redo 栈的操作。 - 支持 Classification 和 Localization 两种模式。 + General History Manager: Responsible for handling operations on the Undo/Redo stack. + Supports Classification, Localization, Description, and Dense Description modes. """ def __init__(self, main_window): self.main = main_window @@ -39,24 +39,42 @@ def perform_redo(self): def _refresh_active_view(self): """ - 智能刷新:根据当前是 Classification 还是 Localization 界面,调用对应的刷新逻辑。 + Refresh: Depending on whether the current interface is Classification, Localization, + Description, or Dense Description, invoke the corresponding refresh logic. """ current_widget = self.main.ui.stack_layout.currentWidget() # 1. Localization Mode if current_widget == self.main.ui.localization_ui: - # 刷新 Schema (Tabs) + # Refresh Schema (Tabs) self.main.loc_manager._refresh_schema_ui() - # 刷新 Events (Table & Timeline) + # Refresh Events (Table & Timeline) self.main.loc_manager._refresh_current_clip_events() - # 刷新左侧树图标 + # Refresh left side self.main.loc_manager.populate_tree() + + # 2. Description Mode + elif current_widget == self.main.ui.description_ui: + # Refresh the editor text by re-triggering selection logic + tree = self.main.ui.description_ui.left_panel.tree + current_idx = tree.selectionModel().currentIndex() + if current_idx.isValid(): + # Force reload of data from model to UI (pass None as previous index) + self.main.desc_nav_manager.on_item_selected(current_idx, None) + + # 3. [NEW] Dense Description Mode + elif current_widget == self.main.ui.dense_description_ui: + # Refresh the table and timeline markers + # Using the path stored in dense_manager + path = self.main.dense_manager.current_video_path + if path: + self.main.dense_manager._display_events_for_item(path) - # 2. Classification Mode + # 4. Classification Mode (Default) else: - # 重建右侧动态控件 + # Rebuild the right-side dynamic control self.main.setup_dynamic_ui() - # 刷新左侧树和标注状态 + # Refresh the left and annotation status self.main.refresh_ui_after_undo_redo(self.main.get_current_action_path()) def _apply_state_change(self, cmd, is_undo): @@ -77,11 +95,11 @@ def _apply_state_change(self, cmd, is_undo): path = cmd['path'] if self.main.get_current_action_path() == path: val = cmd['old_val'] if is_undo else cmd['new_val'] - grp = self.ui.right_panel.label_groups.get(cmd['head']) + grp = self.ui.classification_ui.right_panel.label_groups.get(cmd['head']) if grp: if isinstance(grp, DynamicSingleLabelGroup): grp.set_checked_label(val) else: grp.set_checked_labels(val) - + # ========================================================= # 2. Localization Specific (Events) # ========================================================= @@ -127,12 +145,98 @@ def _apply_state_change(self, cmd, is_undo): idx = events.index(target) events[idx] = replacement except ValueError: - pass # Event not found, possibly concurrent edit? + pass # Event not found self._refresh_active_view() # ========================================================= - # 3. Schema Changes (Shared but handled differently) + # 3. Description Specific + # ========================================================= + elif ctype == CmdType.DESC_EDIT: + path = cmd['path'] + # Determine whether to apply old or new data + data_to_apply = cmd['old_data'] if is_undo else cmd['new_data'] + + # Find the corresponding item in the data model + target_entry = None + for item in self.model.action_item_data: + if item.get("metadata", {}).get("path") == path: + target_entry = item + break + + if target_entry: + # 1. Restore the 'captions' list + target_entry["captions"] = copy.deepcopy(data_to_apply) + + # 2. Update the tree icon status (Empty vs Done) + has_text = False + if data_to_apply and len(data_to_apply) > 0: + text_val = data_to_apply[0].get("text", "") + if text_val and text_val.strip(): + has_text = True + + tree_item = self.model.action_item_map.get(path) + if tree_item: + tree_item.setIcon(self.main.done_icon if has_text else self.main.empty_icon) + + self._refresh_active_view() + + # ========================================================= + # 4. Dense Description Specific [NEW] + # ========================================================= + elif ctype == CmdType.DENSE_EVENT_ADD: + path = cmd['video_path'] + evt = cmd['event'] + events = self.model.dense_description_events.get(path, []) + + if is_undo: + # Undo Add -> Remove + if evt in events: events.remove(evt) + else: + # Redo Add -> Add + events.append(evt) + + self.model.dense_description_events[path] = events + self._refresh_active_view() + + elif ctype == CmdType.DENSE_EVENT_DEL: + path = cmd['video_path'] + evt = cmd['event'] + events = self.model.dense_description_events.get(path, []) + + if is_undo: + # Undo Del -> Add back + events.append(evt) + if path not in self.model.dense_description_events: + self.model.dense_description_events[path] = events + else: + # Redo Del -> Remove + if evt in events: events.remove(evt) + + self._refresh_active_view() + + elif ctype == CmdType.DENSE_EVENT_MOD: + path = cmd['video_path'] + old_e = cmd['old_event'] + new_e = cmd['new_event'] + events = self.model.dense_description_events.get(path, []) + + # Undo -> revert to old; Redo -> set to new + target = new_e if is_undo else old_e + replacement = old_e if is_undo else new_e + + try: + # We rely on dictionary equality or object identity if not copied + idx = events.index(target) + events[idx] = replacement + except ValueError: + # Should not happen if logic is correct + pass + + self._refresh_active_view() + + # ========================================================= + # 5. Schema Changes (Shared) # ========================================================= elif ctype == CmdType.SCHEMA_ADD_CAT: head = cmd['head'] @@ -146,32 +250,26 @@ def _apply_state_change(self, cmd, is_undo): elif ctype == CmdType.SCHEMA_DEL_CAT: head = cmd['head'] if is_undo: - # Restore Definition self.model.label_definitions[head] = cmd['definition'] - # Restore Classification Data if 'affected_data' in cmd: for k, v in cmd['affected_data'].items(): if k not in self.model.manual_annotations: self.model.manual_annotations[k] = {} self.model.manual_annotations[k][head] = v - # Restore Localization Data if 'loc_affected_events' in cmd: for vid, events_list in cmd['loc_affected_events'].items(): if vid not in self.model.localization_events: self.model.localization_events[vid] = [] self.model.localization_events[vid].extend(events_list) else: - # Delete Definition if head in self.model.label_definitions: del self.model.label_definitions[head] - # Delete Classification Data if 'affected_data' in cmd: for k in cmd['affected_data']: if head in self.model.manual_annotations.get(k, {}): del self.model.manual_annotations[k][head] - # Delete Localization Data if 'loc_affected_events' in cmd: for vid in self.model.localization_events: self.model.localization_events[vid] = [ @@ -188,17 +286,13 @@ def _apply_state_change(self, cmd, is_undo): src = new_n if is_undo else old_n dst = old_n if is_undo else new_n - # 1. Rename Definition Key if src in self.model.label_definitions: self.model.label_definitions[dst] = self.model.label_definitions.pop(src) - # 2. Update Classification Annotations - # (Loop through all annotations and rename keys) for anno in self.model.manual_annotations.values(): if src in anno: anno[dst] = anno.pop(src) - # 3. Update Localization Events for events in self.model.localization_events.values(): for evt in events: if evt.get('head') == src: @@ -215,8 +309,6 @@ def _apply_state_change(self, cmd, is_undo): else: if lbl not in lst: lst.append(lbl); lst.sort() - # Refresh Specific UI Component if possible, else full refresh - # Localization UI needs full refresh to update Tab buttons self._refresh_active_view() elif ctype == CmdType.SCHEMA_DEL_LBL: @@ -226,7 +318,6 @@ def _apply_state_change(self, cmd, is_undo): if is_undo: if lbl not in lst: lst.append(lbl); lst.sort() - # Restore Classif if 'affected_data' in cmd: for k, v in cmd['affected_data'].items(): if k not in self.model.manual_annotations: self.model.manual_annotations[k] = {} @@ -237,7 +328,6 @@ def _apply_state_change(self, cmd, is_undo): if lbl not in cur: cur.append(lbl) self.model.manual_annotations[k][head] = cur - # Restore Loc if 'loc_affected_events' in cmd: for vid, events_list in cmd['loc_affected_events'].items(): if vid not in self.model.localization_events: self.model.localization_events[vid] = [] @@ -245,7 +335,6 @@ def _apply_state_change(self, cmd, is_undo): else: if lbl in lst: lst.remove(lbl) - # Delete Classif if 'affected_data' in cmd: for k in cmd['affected_data']: anno = self.model.manual_annotations.get(k, {}) @@ -254,7 +343,6 @@ def _apply_state_change(self, cmd, is_undo): else: if lbl in anno.get(head, []): anno[head].remove(lbl) - # Delete Loc if 'loc_affected_events' in cmd: for vid in self.model.localization_events: self.model.localization_events[vid] = [ @@ -278,7 +366,6 @@ def _apply_state_change(self, cmd, is_undo): idx = lst.index(src) lst[idx] = dst - # Rename in Classif for anno in self.model.manual_annotations.values(): val = anno.get(head) if isinstance(val, str) and val == src: @@ -286,10 +373,9 @@ def _apply_state_change(self, cmd, is_undo): elif isinstance(val, list) and src in val: val[val.index(src)] = dst - # Rename in Loc for events in self.model.localization_events.values(): for evt in events: if evt.get('head') == head and evt.get('label') == src: evt['label'] = dst - self._refresh_active_view() + self._refresh_active_view() \ No newline at end of file diff --git a/annotation_tool/controllers/localization/README.md b/annotation_tool/controllers/localization/README.md new file mode 100644 index 0000000..ad5ed88 --- /dev/null +++ b/annotation_tool/controllers/localization/README.md @@ -0,0 +1,44 @@ +# 📍 Localization Controllers + +This module contains the business logic specifically designed for the **Action Spotting (Localization)** task. It handles the "timestamp-based" annotation workflow, bridging the gap between the complex Localization UI and the data model. + +## 📂 Module Contents + +### 1. `loc_file_manager.py` +**Responsibility:** Data Persistence & Project Lifecycle. + +This controller manages the loading, saving, and creation of localization project files (`.json`). It ensures data integrity and handles file path resolution. + +* **Project Loading**: Validates the JSON schema (checking for `events` and `inputs` fields) before loading data into the `AppStateModel`. +* **Path Resolution**: Implements smart path fallback mechanisms. If an absolute path to a video is missing (e.g., when moving projects between computers), it attempts to resolve the video path relative to the JSON file. +* **Exporting**: Converts the internal data model into the standardized JSON format required for the SoccerNet ecosystem. +* **Workspace Management**: Handles the logic for clearing the interface (resetting the player, table, and lists) when closing a project. + +### 2. `localization_manager.py` +**Responsibility:** User Interaction & Logic Orchestration. + +This is the central "brain" for the localization view. It connects the visual components (Player, Timeline, Table) with the data model. + +* **Event Spotting**: Captures the current timestamp from the media player when a user clicks a label button or uses a hotkey, creating a new event in the model. +* **Synchronization**: Keeps the three main views in sync: + * **Media Player**: Seeks to the specific timestamp when a table row is clicked. + * **Timeline**: Draws visual markers (red lines) on the custom timeline widget corresponding to event times. + * **Table**: Updates the list of events dynamically as users add or remove annotations. +* **Dynamic Schema Handling**: Manages the logic for adding, removing, or renaming "Heads" (Categories) via the Tab interface. +* **Undo/Redo Integration**: Wraps user actions (adding/deleting events) into Command objects to support the global Undo/Redo history. + +--- + +## 🔄 Workflow Diagram + +1. **User Action**: User clicks "Goal" button at `00:15`. +2. **`localization_manager.py`**: + * Gets current time `15000ms`. + * Creates event object: `{'head': 'Action', 'label': 'Goal', 'position_ms': 15000}`. + * Updates `AppStateModel`. + * Triggers UI refresh. +3. **UI Update**: + * `TimelineWidget` paints a red line at 15s. + * `AnnotationTableWidget` inserts a new row. +4. **Save**: User clicks Save. +5. **`loc_file_manager.py`**: Writes the model state to disk as JSON. diff --git a/annotation_tool/controllers/localization/loc_file_manager.py b/annotation_tool/controllers/localization/loc_file_manager.py new file mode 100644 index 0000000..952c71f --- /dev/null +++ b/annotation_tool/controllers/localization/loc_file_manager.py @@ -0,0 +1,330 @@ +import os +import json + +from PyQt6.QtWidgets import QFileDialog, QMessageBox +from PyQt6.QtCore import QUrl + +from utils import natural_sort_key + + +class LocFileManager: + def __init__(self, main_window): + self.main = main_window + self.model = main_window.model + self.ui = main_window.ui + + def create_new_project(self): + """ + Create a new Localization project (Blank). + Skips the wizard dialog and immediately unlocks the workspace. + """ + + # 2) Clear the existing workspace (Full Reset) + # This clears the UI and resets model data + self._clear_workspace(full_reset=True) + + # 3) Initialize default "Blank Project" state in the Model + self.model.current_task_name = "Untitled Task" + self.model.project_description = "" + self.model.modalities = ["video"] + # Empty schema initially. User will add heads via the UI. + self.model.label_definitions = {} + + # Set Localization-mode states + self.model.current_working_directory = None + self.model.current_json_path = None + + # [KEY STEP] Mark project as loaded to enable UI interactions + self.model.json_loaded = True + self.model.is_data_dirty = True + + # 4) Refresh Localization UI + # This updates the tabs to show ONLY the "+" button. + self.main.loc_manager.right_panel.annot_mgmt.update_schema(self.model.label_definitions) + + # Reset the left tree (empty at creation time) + self.main.loc_manager.populate_tree() + + # 5) Switch view to Localization + self.main.ui.show_localization_view() + self.main.update_save_export_button_state() + + # 6) [CRITICAL] Explicitly unlock the Localization UI + # This ensures the Right Panel is enabled (clickable) even with 0 videos. + if hasattr(self.main, "prepare_new_localization_ui"): + self.main.prepare_new_localization_ui() + self.main.statusBar().showMessage("Project Created — Localization Workspace Ready", 5000) + + + def load_project(self, data, file_path): + """ + Load a Localization project from JSON. + """ + # Validate JSON if the model provides a validator + if hasattr(self.model, "validate_loc_json"): + is_valid, error_msg, warning_msg = self.model.validate_loc_json(data) + + if not is_valid: + if len(error_msg) > 800: + error_msg = error_msg[:800] + "\n... (truncated)" + error_text = ( + "Critical errors found in JSON. Load aborted.\n\n" + f"{error_msg}\n\n" + "--------------------------------------------------\n" + "💡 Please download the correct Localization JSON format from:\n" + "https://huggingface.co/datasets/OpenSportsLab/soccernetpro-localization-snbas" + ) + + QMessageBox.critical( + self.main, + "Validation Error", + error_text, + ) + return False + + if warning_msg: + if len(warning_msg) > 800: + warning_msg = warning_msg[:800] + "\n... (truncated)" + res = QMessageBox.warning( + self.main, + "Validation Warnings", + "The file contains warnings:\n\n" + + warning_msg + + "\n\nDo you want to continue loading?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if res != QMessageBox.StandardButton.Yes: + return False + + # Clear workspace before loading + self._clear_workspace(full_reset=True) + + project_root = os.path.dirname(os.path.abspath(file_path)) + self.model.current_working_directory = project_root + + # Basic metadata + self.model.current_task_name = data.get("dataset_name", data.get("task", "Localization Task")) + self.model.modalities = data.get("modalities", ["video"]) + + # Labels / schema + if "labels" in data: + self.model.label_definitions = data["labels"] + self.main.loc_manager.right_panel.annot_mgmt.update_schema(self.model.label_definitions) + + # Choose a reasonable default head + default_head = None + if "ball_action" in self.model.label_definitions: + default_head = "ball_action" + elif "action" in self.model.label_definitions: + default_head = "action" + elif list(self.model.label_definitions.keys()): + default_head = list(self.model.label_definitions.keys())[0] + + if default_head: + self.main.loc_manager.current_head = default_head + self.main.loc_manager.right_panel.annot_mgmt.tabs.set_current_head(default_head) + + missing_files = [] + loaded_count = 0 + + # Load items (clips) and events + for item in data.get("data", []): + inputs = item.get("inputs", []) + if not inputs or not isinstance(inputs, list): + continue + + raw_path = inputs[0].get("path", "") + aid = item.get("id") + if not aid: + aid = os.path.splitext(os.path.basename(raw_path))[0] + + final_path = raw_path + + # Path resolution logic + if os.path.isabs(raw_path) and os.path.exists(raw_path): + final_path = raw_path + else: + norm_raw = raw_path.replace("\\", "/") + abs_path_strict = os.path.normpath(os.path.join(project_root, norm_raw)) + + if os.path.exists(abs_path_strict): + final_path = abs_path_strict + else: + filename = os.path.basename(norm_raw) + abs_path_flat = os.path.join(project_root, filename) + + if os.path.exists(abs_path_flat): + final_path = abs_path_flat + else: + final_path = abs_path_strict + missing_files.append(f"{aid}: {filename}") + + # Register clip in the model + self.model.action_item_data.append( + {"name": aid, "path": final_path, "source_files": [final_path]} + ) + self.model.action_path_to_name[final_path] = aid + + # Process events + raw_events = item.get("events", []) + processed_events = [] + + if isinstance(raw_events, list): + for evt in raw_events: + if not isinstance(evt, dict): + continue + try: + pos_ms = int(evt.get("position_ms", 0)) + except ValueError: + pos_ms = 0 + + processed_events.append( + { + "head": evt.get("head", "action"), + "label": evt.get("label", "?"), + "position_ms": pos_ms, + } + ) + + if processed_events: + self.model.localization_events[final_path] = processed_events + + loaded_count += 1 + + # Update model status after loading + self.model.current_json_path = file_path + self.model.json_loaded = True + + # Refresh UI tree + self.main.loc_manager.populate_tree() + + # Report missing files (if any) + if missing_files: + shown_missing = missing_files[:5] + msg = ( + f"Loaded {loaded_count} clips.\n\n" + f"WARNING: {len(missing_files)} videos not found locally:\n" + + "\n".join(shown_missing) + ) + if len(missing_files) > 5: + msg += "\n..." + QMessageBox.warning(self.main, "Load Warning", msg) + else: + self.main.statusBar().showMessage( + f"Mode Switched — Loaded {loaded_count} clips. Current Mode: LOCALIZATION", + 1500 + ) + + + return True + + def overwrite_json(self): + """Overwrite current JSON if exists, else export.""" + if self.model.current_json_path: + return self._write_json(self.model.current_json_path) + return self.export_json() + + def export_json(self): + """Export Localization JSON to a user-selected file path.""" + path, _ = QFileDialog.getSaveFileName( + self.main, "Export Localization JSON", "", "JSON (*.json)" + ) + if path: + if self._write_json(path): + self.model.current_json_path = path + self.model.is_data_dirty = False + return True + return False + + def _write_json(self, path): + """Write the current Localization project state into a JSON file.""" + output = { + "version": "2.0", + "date": "2025-12-16", + "task": "action_spotting", + "dataset_name": self.model.current_task_name, + "metadata": { + "source": "Annotation Tool Export", + "created_by": "User", + }, + "labels": self.model.label_definitions, + "data": [], + } + + base_dir = os.path.dirname(path) + sorted_items = sorted( + self.model.action_item_data, key=lambda d: natural_sort_key(d.get("name", "")) + ) + + for data in sorted_items: + abs_path = data["path"] + events = self.model.localization_events.get(abs_path, []) + + # Store path as relative if possible + try: + rel_path = os.path.relpath(abs_path, base_dir).replace(os.sep, "/") + except Exception: + rel_path = abs_path + + # Convert events to export format + export_events = [] + for e in events: + export_events.append( + { + "head": e.get("head"), + "label": e.get("label"), + "position_ms": str(e.get("position_ms")), + } + ) + + entry = { + "inputs": [ + { + "type": "video", + "path": rel_path, + "fps": 25.0, + } + ], + "events": export_events, + } + output["data"].append(entry) + + try: + with open(path, "w", encoding="utf-8") as f: + json.dump(output, f, indent=4, ensure_ascii=False) + + self.model.is_data_dirty = False + self.main.statusBar().showMessage(f"Saved — {os.path.basename(path)}", 1500) + return True + except Exception as e: + QMessageBox.critical(self.main, "Error", f"Save failed: {e}") + return False + + def _clear_workspace(self, full_reset=False): + """ + Clear UI panels and reset the model state. + """ + if hasattr(self.main, "loc_manager"): + # Left panel: clear clip tree + self.main.tree_model.clear() + + # Center panel: stop media preview and clear source + self.main.loc_manager.center_panel.media_preview.stop() + self.main.loc_manager.center_panel.media_preview.player.setSource(QUrl()) + + # ✅ [FIX] Reset timeline UI (markers + label + slider) + tl = self.main.loc_manager.center_panel.timeline + tl.set_markers([]) + tl.set_duration(0) + tl.set_position(0) + + # Right panel: clear table and schema + self.main.loc_manager.right_panel.table.set_data([]) + self.main.loc_manager.right_panel.annot_mgmt.update_schema({}) + + # Reset model data + self.model.reset(full_reset) + + # Optionally show welcome screen + if full_reset: + self.main.ui.show_welcome_view() diff --git a/Tool/controllers/localization/localization_manager.py b/annotation_tool/controllers/localization/localization_manager.py similarity index 57% rename from Tool/controllers/localization/localization_manager.py rename to annotation_tool/controllers/localization/localization_manager.py index 8cb4a19..9ef7461 100644 --- a/Tool/controllers/localization/localization_manager.py +++ b/annotation_tool/controllers/localization/localization_manager.py @@ -1,48 +1,57 @@ import os import copy -from PyQt6.QtWidgets import QMessageBox, QInputDialog, QTreeWidgetItem, QFileDialog, QMenu -from PyQt6.QtCore import Qt, QUrl +from PyQt6.QtWidgets import QMessageBox, QInputDialog, QMenu, QFileDialog +from PyQt6.QtCore import Qt, QUrl, QModelIndex, QTimer from PyQt6.QtGui import QColor -from PyQt6.QtMultimedia import QMediaPlayer # [新增] 必须引入 QMediaPlayer +from PyQt6.QtMultimedia import QMediaPlayer + from utils import natural_sort_key from models import CmdType +# [NEW] Import the unified MediaController +from controllers.media_controller import MediaController class LocalizationManager: """ Manages logic for the UI2 Localization Interface. - Redesigned to support Multi-Head Tabs, Integrated Label Management, and Table Interaction. + Refactored to support QTreeView + QStandardItemModel (MV Architecture). """ def __init__(self, main_window): self.main = main_window self.model = main_window.model - self.ui_root = main_window.ui.localization_ui + self.tree_model = main_window.tree_model + self.ui_root = main_window.ui.localization_ui self.left_panel = self.ui_root.left_panel self.center_panel = self.ui_root.center_panel self.right_panel = self.ui_root.right_panel + # [NEW] Initialize Media Controller + # We access the underlying QMediaPlayer from the UI wrapper + preview_widget = self.center_panel.media_preview + player = preview_widget.player + + # [CRITICAL FIX] Retrieve the actual QVideoWidget to allow forced repaints + # This fixes the "stuck frame" issue when switching modes + video_widget = preview_widget.video_widget + + self.media_controller = MediaController(player, video_widget) + self.current_video_path = None self.current_head = None def setup_connections(self): # --- Left Panel --- pc = self.left_panel.project_controls - pc.loadRequested.connect(self._on_load_clicked) pc.addVideoRequested.connect(self._on_add_video_clicked) pc.saveRequested.connect(self._on_save_clicked) pc.exportRequested.connect(self._on_export_clicked) # Tree Interactions - self.left_panel.clip_tree.currentItemChanged.connect(self.on_clip_selected) - - # Right Click Context Menu - self.left_panel.clip_tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) - self.left_panel.clip_tree.customContextMenuRequested.connect(self._on_tree_context_menu) - + self.left_panel.tree.selectionModel().currentChanged.connect(self.on_clip_selected) + self.left_panel.tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.left_panel.tree.customContextMenuRequested.connect(self._on_tree_context_menu) self.left_panel.filter_combo.currentIndexChanged.connect(self._apply_clip_filter) - - # Clear All Button - self.left_panel.btn_clear_all.clicked.connect(self._on_clear_all_clicked) + self.left_panel.clear_btn.clicked.connect(self._on_clear_all_clicked) # --- Center Panel --- media = self.center_panel.media_preview @@ -53,10 +62,13 @@ def setup_connections(self): media.durationChanged.connect(timeline.set_duration) timeline.seekRequested.connect(media.set_position) - pb.stopRequested.connect(media.stop) + # [CHANGED] Use MediaController for playback control + pb.stopRequested.connect(self.media_controller.stop) + pb.playPauseRequested.connect(self.media_controller.toggle_play_pause) + pb.playbackRateRequested.connect(media.set_playback_rate) pb.seekRelativeRequested.connect(lambda d: media.set_position(media.player.position() + d)) - pb.playPauseRequested.connect(media.toggle_play_pause) + pb.nextPrevClipRequested.connect(self._navigate_clip) pb.nextPrevAnnotRequested.connect(self._navigate_annotation) @@ -64,47 +76,97 @@ def setup_connections(self): tabs = self.right_panel.annot_mgmt.tabs table = self.right_panel.table - # Head Management (Tabs) tabs.headAdded.connect(self._on_head_added) tabs.headRenamed.connect(self._on_head_renamed) tabs.headDeleted.connect(self._on_head_deleted) tabs.headSelected.connect(self._on_head_selected) - # Label & Spotting Logic (From inside Tabs) tabs.spottingTriggered.connect(self._on_spotting_triggered) tabs.labelAddReq.connect(self._on_label_add_req) tabs.labelRenameReq.connect(self._on_label_rename_req) tabs.labelDeleteReq.connect(self._on_label_delete_req) - # Table Logic table.annotationSelected.connect(lambda ms: media.set_position(ms)) table.annotationDeleted.connect(self._on_delete_single_annotation) table.annotationModified.connect(self._on_annotation_modified) - # --- Media Sync --- 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) - # --- Head Management (Tab Operations) --- - def _on_head_selected(self, head_name): - self.current_head = head_name + # --- Video Loading Logic (Strict Classification Style via Controller) --- + def on_clip_selected(self, current_idx, previous_idx): + if not current_idx.isValid(): + self.current_video_path = None + return + + path = current_idx.data(Qt.ItemDataRole.UserRole) + + if path == self.current_video_path: + return + + if path and os.path.exists(path): + self.current_video_path = path + + # [CHANGED] Use MediaController for standardized playback + # This handles the Stop -> Load -> Delay -> Play sequence automatically + self.media_controller.load_and_play(path) + + # Update UI for events + self._display_events_for_item(path) + + else: + if path: QMessageBox.warning(self.main, "Error", f"File not found: {path}") + + def _on_add_video_clicked(self): + start_dir = self.model.current_working_directory or "" + files, _ = QFileDialog.getOpenFileNames(self.main, "Select Video(s)", start_dir, "Video (*.mp4 *.avi *.mov *.mkv)") + if not files: return + if not self.model.current_working_directory: + self.model.current_working_directory = os.path.dirname(files[0]) + + added_count = 0 + first_new_item_idx = None + + 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]}) + self.model.action_path_to_name[file_path] = name + item = self.tree_model.add_entry(name=name, path=file_path, source_files=[file_path]) + self.model.action_item_map[file_path] = item + + if added_count == 0: + first_new_item_idx = item.index() + added_count += 1 + + if added_count > 0: + self.model.is_data_dirty = True + self.main.show_temp_msg("Videos Added", f"Added {added_count} clips.") + + # Auto-select the first added video + if first_new_item_idx and first_new_item_idx.isValid(): + self.left_panel.tree.setCurrentIndex(first_new_item_idx) + # Manually trigger load since setting index via code sometimes skips the signal + self.on_clip_selected(first_new_item_idx, None) + + # --- Head Management --- + def handle_add_head(self): + text, ok = QInputDialog.getText(self.main, "New Category", "Enter name for new Category (Head):") + if ok and text.strip(): self._on_head_added(text.strip()) + + def _on_head_selected(self, head_name): self.current_head = head_name def _on_head_added(self, head_name): if any(h.lower() == head_name.lower() for h in self.model.label_definitions): - self.main.show_temp_msg("Error", f"Head '{head_name}' already exists!", icon=QMessageBox.Icon.Warning) - return - + self.main.show_temp_msg("Error", f"Head '{head_name}' already exists!", icon=QMessageBox.Icon.Warning); return definition = {"type": "single_label", "labels": []} - - # 1. Push Undo self.model.push_undo(CmdType.SCHEMA_ADD_CAT, head=head_name, definition=definition) - - # 2. Execute self.model.label_definitions[head_name] = definition self.model.is_data_dirty = True - self._refresh_schema_ui() self.right_panel.annot_mgmt.tabs.set_current_head(head_name) self.main.show_temp_msg("Head Added", f"Created '{head_name}'") @@ -112,165 +174,84 @@ def _on_head_added(self, head_name): def _on_head_renamed(self, old_name, new_name): if old_name == new_name: return - if any(h.lower() == new_name.lower() for h in self.model.label_definitions): - self.main.show_temp_msg("Error", "Name already exists!", icon=QMessageBox.Icon.Warning) - return - - # 1. Push Undo + self.main.show_temp_msg("Error", "Name already exists!", icon=QMessageBox.Icon.Warning); return self.model.push_undo(CmdType.SCHEMA_REN_CAT, old_name=old_name, new_name=new_name) - - # 2. Execute (Update Defs) self.model.label_definitions[new_name] = self.model.label_definitions.pop(old_name) - - # Execute (Update Events) - count = 0 for vid_path, events in self.model.localization_events.items(): for evt in events: - if evt.get('head') == old_name: - evt['head'] = new_name - count += 1 - + if evt.get('head') == old_name: evt['head'] = new_name self.model.is_data_dirty = True self._refresh_schema_ui() self.right_panel.annot_mgmt.tabs.set_current_head(new_name) self._refresh_current_clip_events() - self.main.show_temp_msg("Head Renamed", f"Updated {count} events.") + self.main.show_temp_msg("Head Renamed", "Updated events.") self.main.update_save_export_button_state() def _on_head_deleted(self, head_name): - display_name = head_name.replace('_', ' ') - res = QMessageBox.warning( - self.main, "Delete Head", - f"Delete head '{display_name}'? ALL associated events will be deleted.", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel - ) + res = QMessageBox.warning(self.main, "Delete Head", f"Delete head '{head_name}'?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel) if res != QMessageBox.StandardButton.Yes: return - - # 1. Capture Affected Data for Undo loc_affected = {} - removed_count = 0 for vid_path, events in self.model.localization_events.items(): affected_evts = [copy.deepcopy(e) for e in events if e.get('head') == head_name] - if affected_evts: - loc_affected[vid_path] = affected_evts - removed_count += len(affected_evts) - + if affected_evts: loc_affected[vid_path] = affected_evts definition = copy.deepcopy(self.model.label_definitions.get(head_name)) - - # 2. Push Undo - self.model.push_undo( - CmdType.SCHEMA_DEL_CAT, - head=head_name, - definition=definition, - loc_affected_events=loc_affected - ) - - # 3. Execute - if head_name in self.model.label_definitions: - del self.model.label_definitions[head_name] - + self.model.push_undo(CmdType.SCHEMA_DEL_CAT, head=head_name, definition=definition, loc_affected_events=loc_affected) + del self.model.label_definitions[head_name] for vid_path in self.model.localization_events: - self.model.localization_events[vid_path] = [ - e for e in self.model.localization_events[vid_path] - if e.get('head') != head_name - ] - + self.model.localization_events[vid_path] = [e for e in self.model.localization_events[vid_path] if e.get('head') != head_name] self.model.is_data_dirty = True self._refresh_schema_ui() self._refresh_current_clip_events() - self.main.show_temp_msg("Head Deleted", f"Removed {removed_count} events.") + self.main.show_temp_msg("Head Deleted", "Removed.") self.main.update_save_export_button_state() # --- Label Management --- def _on_label_add_req(self, head): - """ - [修改] 暂停视频 -> 获取时间 -> 输入标签 -> 添加Schema & 打点 -> 恢复播放 - """ - # 1. 获取播放器状态并暂停 player = self.center_panel.media_preview.player was_playing = (player.playbackState() == QMediaPlayer.PlaybackState.PlayingState) - if was_playing: - player.pause() - + if was_playing: player.pause() current_pos = player.position() - time_str = self._fmt_ms(current_pos) - - # 2. 弹出对话框 - text, ok = QInputDialog.getText( - self.main, - "Add New Label & Spot", - f"Add new label to '{head}' and spot at {time_str}?" - ) - + text, ok = QInputDialog.getText(self.main, "Add Label", f"Add label to '{head}':") if not ok or not text.strip(): - # 取消则恢复播放 if was_playing: player.play() return - label_name = text.strip() labels_list = self.model.label_definitions[head].get('labels', []) - if any(l.lower() == label_name.lower() for l in labels_list): self.main.show_temp_msg("Error", "Label exists!", icon=QMessageBox.Icon.Warning) if was_playing: player.play() return - - # 3. 动作 1: 修改 Schema (添加标签) self.model.push_undo(CmdType.SCHEMA_ADD_LBL, head=head, label=label_name) labels_list.append(label_name) - self.model.label_definitions[head]['labels'] = labels_list self.model.is_data_dirty = True - - # 4. 动作 2: 修改 Data (打点) if self.current_video_path: - new_event = { - "head": head, - "label": label_name, - "position_ms": current_pos - } - # 注意:Undo Stack 此时会有两个操作,按 Undo 两次才能完全撤销,符合直觉 + new_event = {"head": head, "label": label_name, "position_ms": current_pos} self.model.push_undo(CmdType.LOC_EVENT_ADD, video_path=self.current_video_path, event=new_event) - - if self.current_video_path not in self.model.localization_events: - self.model.localization_events[self.current_video_path] = [] + if self.current_video_path not in self.model.localization_events: self.model.localization_events[self.current_video_path] = [] self.model.localization_events[self.current_video_path].append(new_event) - - # 5. 刷新 UI self._refresh_schema_ui() - self.right_panel.annot_mgmt.tabs.set_current_head(head) # 保持 Tab 选中 - self._display_events_for_item(self.current_video_path) - self.populate_tree() - self.main.show_temp_msg("Added & Spotted", f"{head}: {label_name} at {time_str}") + self.right_panel.annot_mgmt.tabs.set_current_head(head) + if self.current_video_path: + self._display_events_for_item(self.current_video_path) + self.refresh_tree_icons() + self.main.show_temp_msg("Added", f"{head}: {label_name}") self.main.update_save_export_button_state() - - # 6. 恢复播放 - if was_playing: - player.play() + if was_playing: player.play() def _on_label_rename_req(self, head, old_label): new_label, ok = QInputDialog.getText(self.main, "Rename Label", f"Rename '{old_label}' to:", text=old_label) if not ok or not new_label.strip() or new_label == old_label: return new_label = new_label.strip() labels_list = self.model.label_definitions[head].get('labels', []) - if any(l.lower() == new_label.lower() for l in labels_list if l != old_label): self.main.show_temp_msg("Error", "Label exists!", icon=QMessageBox.Icon.Warning); return - - # 1. Push Undo self.model.push_undo(CmdType.SCHEMA_REN_LBL, head=head, old_lbl=old_label, new_lbl=new_label) - - # 2. Execute index = labels_list.index(old_label) labels_list[index] = new_label - - count = 0 for vid_path, events in self.model.localization_events.items(): for evt in events: - if evt.get('head') == head and evt.get('label') == old_label: - evt['label'] = new_label - count += 1 - + if evt.get('head') == head and evt.get('label') == old_label: evt['label'] = new_label self.model.is_data_dirty = True self._refresh_schema_ui() self.right_panel.annot_mgmt.tabs.set_current_head(head) @@ -278,40 +259,18 @@ def _on_label_rename_req(self, head, old_label): self.main.update_save_export_button_state() def _on_label_delete_req(self, head, label): - res = QMessageBox.warning( - self.main, "Delete Label", - f"Delete label '{label}'? ALL associated events will be deleted.", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel - ) + res = QMessageBox.warning(self.main, "Delete Label", f"Delete '{label}'?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel) if res != QMessageBox.StandardButton.Yes: return - - # 1. Capture Affected Data loc_affected = {} - removed_count = 0 for vid_path, events in self.model.localization_events.items(): aff = [copy.deepcopy(e) for e in events if e.get('head') == head and e.get('label') == label] - if aff: - loc_affected[vid_path] = aff - removed_count += len(aff) - - # 2. Push Undo - self.model.push_undo( - CmdType.SCHEMA_DEL_LBL, - head=head, - label=label, - loc_affected_events=loc_affected - ) - - # 3. Execute + if aff: loc_affected[vid_path] = aff + self.model.push_undo(CmdType.SCHEMA_DEL_LBL, head=head, label=label, loc_affected_events=loc_affected) labels_list = self.model.label_definitions[head].get('labels', []) - if label in labels_list: - labels_list.remove(label) - + if label in labels_list: labels_list.remove(label) for vid_path in self.model.localization_events: events = self.model.localization_events[vid_path] - new_events = [e for e in events if not (e.get('head') == head and e.get('label') == label)] - self.model.localization_events[vid_path] = new_events - + self.model.localization_events[vid_path] = [e for e in events if not (e.get('head') == head and e.get('label') == label)] self.model.is_data_dirty = True self._refresh_schema_ui() self.right_panel.annot_mgmt.tabs.set_current_head(head) @@ -320,92 +279,55 @@ def _on_label_delete_req(self, head, label): # --- Spotting (Data Creation) --- def _on_spotting_triggered(self, head, label): - if not self.current_video_path: - QMessageBox.warning(self.main, "Warning", "No video selected."); return + if not self.current_video_path: QMessageBox.warning(self.main, "Warning", "No video selected."); return pos_ms = self.center_panel.media_preview.player.position() - - new_event = { - "head": head, - "label": label, - "position_ms": pos_ms - } - - # 1. Push Undo + new_event = {"head": head, "label": label, "position_ms": pos_ms} self.model.push_undo(CmdType.LOC_EVENT_ADD, video_path=self.current_video_path, event=new_event) - - # 2. Execute - if self.current_video_path not in self.model.localization_events: - self.model.localization_events[self.current_video_path] = [] + if self.current_video_path not in self.model.localization_events: self.model.localization_events[self.current_video_path] = [] self.model.localization_events[self.current_video_path].append(new_event) - self.model.is_data_dirty = True self._display_events_for_item(self.current_video_path) - self.populate_tree() + self.refresh_tree_icons() self.main.show_temp_msg("Event Created", f"{head}: {label}") self.main.update_save_export_button_state() - # --- Table Modification (New Logic) --- + # --- Table Modification --- def _on_annotation_modified(self, old_event, new_event): events = self.model.localization_events.get(self.current_video_path, []) - try: - index = events.index(old_event) - except ValueError: - return - - # 1. Push Undo - self.model.push_undo( - CmdType.LOC_EVENT_MOD, - video_path=self.current_video_path, - old_event=copy.deepcopy(old_event), - new_event=new_event - ) - - # 2. Execute + try: index = events.index(old_event) + except ValueError: return + self.model.push_undo(CmdType.LOC_EVENT_MOD, video_path=self.current_video_path, old_event=copy.deepcopy(old_event), new_event=new_event) new_head = new_event['head'] new_label = new_event['label'] schema_changed = False - - # Logic to auto-create schema if edited via Table (Optional but good UX) if new_head not in self.model.label_definitions: self.model.label_definitions[new_head] = {"type": "single_label", "labels": []} schema_changed = True - if new_label and new_label != "???": labels_list = self.model.label_definitions[new_head]['labels'] if not any(l.lower() == new_label.lower() for l in labels_list): labels_list.append(new_label) schema_changed = True - events[index] = new_event self.model.is_data_dirty = True - if schema_changed: self._refresh_schema_ui() self.right_panel.annot_mgmt.tabs.set_current_head(new_head) - self._display_events_for_item(self.current_video_path) - self.populate_tree() + self.refresh_tree_icons() self.main.show_temp_msg("Event Updated", "Modified") self.main.update_save_export_button_state() 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 - - reply = QMessageBox.question( - self.main, "Delete Event", "Delete this event?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No - ) + reply = QMessageBox.question(self.main, "Delete Event", "Delete this event?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) if reply != QMessageBox.StandardButton.Yes: return - - # 1. Push Undo self.model.push_undo(CmdType.LOC_EVENT_DEL, video_path=self.current_video_path, event=copy.deepcopy(item_data)) - - # 2. Execute events.remove(item_data) self.model.is_data_dirty = True self._display_events_for_item(self.current_video_path) - self.populate_tree() + self.refresh_tree_icons() self.main.update_save_export_button_state() # --- Helper Refresh Methods --- @@ -414,232 +336,124 @@ def _refresh_schema_ui(self): self.right_panel.annot_mgmt.update_schema(self.model.label_definitions) def _refresh_current_clip_events(self): - if self.current_video_path: - self._display_events_for_item(self.current_video_path) + if self.current_video_path: self._display_events_for_item(self.current_video_path) # --- Video & Project Logic --- - def _on_load_clicked(self): - self.main.router.import_annotations() - - def _on_add_video_clicked(self): - start_dir = self.model.current_working_directory or "" - files, _ = QFileDialog.getOpenFileNames(self.main, "Select Video(s)", start_dir, "Video (*.mp4 *.avi *.mov *.mkv)") - if not files: return - if not self.model.current_working_directory: - self.model.current_working_directory = os.path.dirname(files[0]) - added_count = 0 - 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]}) - self.model.action_path_to_name[file_path] = name - added_count += 1 - if added_count > 0: - self.model.is_data_dirty = True - self.populate_tree() - self.main.show_temp_msg("Videos Added", f"Added {added_count} clips.") - - # --- Clear All & Remove Video Logic --- + def _on_load_clicked(self): self.main.router.import_annotations() + def _on_save_clicked(self): self.main.router.loc_fm.overwrite_json() + def _on_export_clicked(self): self.main.router.loc_fm.export_json() def _on_clear_all_clicked(self): - """Removes all videos, clears player, and clears right panel including Schema.""" - if not self.model.action_item_data: - return - - res = QMessageBox.question( - self.main, "Clear All", - "Are you sure you want to remove ALL videos and clear the workspace?\n" - "This will remove all videos and RESET the label schema.", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No - ) - if res != QMessageBox.StandardButton.Yes: - return - - # 1. Clear Data (Includes Schema/Label Definitions) + if not self.model.action_item_data: return + res = QMessageBox.question(self.main, "Clear All", "Are you sure?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) + if res != QMessageBox.StandardButton.Yes: return self.model.action_item_data = [] self.model.action_path_to_name = {} self.model.localization_events = {} - self.model.label_definitions = {} # 清空 Schema + self.model.label_definitions = {} self.model.is_data_dirty = False self.current_video_path = None self.current_head = None - - # Undo stack should probably be cleared on full reset self.model.undo_stack.clear() self.model.redo_stack.clear() - - # 2. Clear Player - self.center_panel.media_preview.stop() + + # [CHANGED] Use MediaController stop + self.media_controller.stop() self.center_panel.media_preview.player.setSource(QUrl()) self.center_panel.media_preview.video_widget.update() - self.center_panel.timeline.set_markers([]) - - # 3. Clear Tree - self.left_panel.clip_tree.clear() - # 4. Clear Right Panel (Top & Bottom) + self.center_panel.timeline.set_markers([]) + self.tree_model.clear() self._refresh_schema_ui() self.right_panel.table.set_data([]) - self.main.show_temp_msg("Cleared", "Workspace reset.") self.main.update_save_export_button_state() def _on_tree_context_menu(self, pos): - item = self.left_panel.clip_tree.itemAt(pos) - if not item: return - - path = item.data(0, Qt.ItemDataRole.UserRole) - name = item.text(0) - - menu = QMenu(self.left_panel.clip_tree) + index = self.left_panel.tree.indexAt(pos) + if not index.isValid(): return + path = index.data(Qt.ItemDataRole.UserRole) + name = index.data(Qt.ItemDataRole.DisplayRole) + menu = QMenu(self.left_panel.tree) remove_action = menu.addAction(f"Remove '{name}'") - - action = menu.exec(self.left_panel.clip_tree.mapToGlobal(pos)) - - if action == remove_action: - self._remove_single_video(path) + action = menu.exec(self.left_panel.tree.mapToGlobal(pos)) + if action == remove_action: self._remove_single_video(path, index) - def _remove_single_video(self, path): - # 1. Remove from Model + def _remove_single_video(self, path, index): self.model.action_item_data = [d for d in self.model.action_item_data if d['path'] != path] - if path in self.model.action_path_to_name: - del self.model.action_path_to_name[path] - if path in self.model.localization_events: - del self.model.localization_events[path] - + if path in self.model.action_path_to_name: del self.model.action_path_to_name[path] + if path in self.model.localization_events: del self.model.localization_events[path] self.model.is_data_dirty = True - - # 2. If removing the CURRENTLY playing video if self.current_video_path == path: self.current_video_path = None - self.center_panel.media_preview.stop() + + # [CHANGED] Use MediaController stop + self.media_controller.stop() self.center_panel.media_preview.player.setSource(QUrl()) + self.right_panel.table.set_data([]) self.center_panel.timeline.set_markers([]) - - # 3. Refresh Tree - self.populate_tree() + if index.isValid(): self.tree_model.removeRow(index.row(), index.parent()) self.main.show_temp_msg("Removed", "Video removed from list.") self.main.update_save_export_button_state() - # ---------------------------------------------- - def populate_tree(self): - """ - Refreshes the clip tree. - Blocks signals throughout the entire process to prevent - accidental triggering of on_clip_selected (which resets video playback). - """ - previous_path = self.current_video_path - - # 1. Start blocking signals BEFORE clearing - self.left_panel.clip_tree.blockSignals(True) - - self.left_panel.clip_tree.clear() - + self.left_panel.tree.blockSignals(True) + 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', ''))) - item_to_restore = None - first_item = None - + first_idx = None for i, data in enumerate(sorted_list): name = data['name'] path = data['path'] - item = QTreeWidgetItem(self.left_panel.clip_tree, [name]) - item.setData(0, Qt.ItemDataRole.UserRole, path) + item = self.tree_model.add_entry(name, path, data.get('source_files')) + self.model.action_item_map[path] = item events = self.model.localization_events.get(path, []) - item.setIcon(0, self.main.done_icon if events else self.main.empty_icon) - - if i == 0: first_item = item - if path == previous_path: item_to_restore = item - + item.setIcon(self.main.done_icon if events else self.main.empty_icon) + if i == 0: first_idx = item.index() self.left_panel.project_controls.set_project_loaded_state(True) self._refresh_schema_ui() - - if self.current_head: - self.right_panel.annot_mgmt.tabs.set_current_head(self.current_head) - + if self.current_head: self.right_panel.annot_mgmt.tabs.set_current_head(self.current_head) self._apply_clip_filter(self.left_panel.filter_combo.currentIndex()) - - # 2. Restore Selection Logic - if item_to_restore: - self.left_panel.clip_tree.setCurrentItem(item_to_restore) - elif previous_path is None and first_item: - self.left_panel.clip_tree.setCurrentItem(first_item) - - # 3. Finally Unblock Signals - self.left_panel.clip_tree.blockSignals(False) + if first_idx and first_idx.isValid(): + self.left_panel.tree.setCurrentIndex(first_idx) + self.on_clip_selected(first_idx, None) + self.left_panel.tree.blockSignals(False) - # 4. Handle the "New Load" case manually - if not item_to_restore and previous_path is None and first_item: - self.on_clip_selected(first_item, None) + def refresh_tree_icons(self): + for path, item in self.model.action_item_map.items(): + events = self.model.localization_events.get(path, []) + item.setIcon(self.main.done_icon if events else self.main.empty_icon) - def _apply_clip_filter(self, index): - root = self.left_panel.clip_tree.invisibleRootItem() - for i in range(root.childCount()): + def _apply_clip_filter(self, combo_index): + root = self.tree_model.invisibleRootItem() + for i in range(root.rowCount()): item = root.child(i) - path = item.data(0, Qt.ItemDataRole.UserRole) + path = item.data(Qt.ItemDataRole.UserRole) events = self.model.localization_events.get(path, []) has_anno = len(events) > 0 should_hide = False - if index == 1 and not has_anno: should_hide = True - elif index == 2 and has_anno: should_hide = True - item.setHidden(should_hide) - - def on_clip_selected(self, current, previous): - if not current: - self.current_video_path = None - return - path = current.data(0, Qt.ItemDataRole.UserRole) - - if path == self.current_video_path: - return - - if path and os.path.exists(path): - self.current_video_path = path - self.center_panel.media_preview.load_video(path) - self._display_events_for_item(path) - else: - if path: QMessageBox.warning(self.main, "Error", f"File not found: {path}") - + if combo_index == 1 and not has_anno: should_hide = True + elif combo_index == 2 and has_anno: should_hide = True + self.left_panel.tree.setRowHidden(i, QModelIndex(), should_hide) + def _display_events_for_item(self, path): events = self.model.localization_events.get(path, []) display_data = [] clip_name = os.path.basename(path) for e in events: - d = e.copy() - d['clip'] = clip_name - display_data.append(e) + d = e.copy(); d['clip'] = clip_name; display_data.append(e) display_data.sort(key=lambda x: x.get('position_ms', 0)) self.right_panel.table.set_data(display_data) - markers = [{'start_ms': e.get('position_ms', 0), 'color': QColor("#00BFFF")} for e in events] self.center_panel.timeline.set_markers(markers) - def _on_save_clicked(self): - self.main.router.loc_fm.overwrite_json() - - def _on_export_clicked(self): - self.main.router.loc_fm.export_json() - def _navigate_clip(self, step): - tree = self.left_panel.clip_tree - curr = tree.currentItem() - if not curr: return - visible_items = [] - root = tree.invisibleRootItem() - for i in range(root.childCount()): - item = root.child(i) - if not item.isHidden(): - visible_items.append(item) - if not visible_items: return - try: - curr_idx = visible_items.index(curr) - new_idx = curr_idx + step - if 0 <= new_idx < len(visible_items): - tree.setCurrentItem(visible_items[new_idx]) - except ValueError: - pass + tree = self.left_panel.tree + curr_idx = tree.currentIndex() + if not curr_idx.isValid(): return + next_idx = tree.indexBelow(curr_idx) if step > 0 else tree.indexAbove(curr_idx) + if next_idx.isValid(): tree.setCurrentIndex(next_idx) def _navigate_annotation(self, step): if not self.current_video_path: return @@ -650,14 +464,10 @@ def _navigate_annotation(self, step): target_time = None if step > 0: for e in sorted_events: - if e.get('position_ms', 0) > current_pos + 100: - target_time = e.get('position_ms') - break + if e.get('position_ms', 0) > current_pos + 100: target_time = e.get('position_ms'); break else: for e in reversed(sorted_events): - if e.get('position_ms', 0) < current_pos - 100: - target_time = e.get('position_ms') - break + if e.get('position_ms', 0) < current_pos - 100: target_time = e.get('position_ms'); break if target_time is not None: self.center_panel.media_preview.set_position(target_time) self._select_row_by_time(target_time) @@ -672,10 +482,6 @@ def _select_row_by_time(self, time_ms): self.right_panel.table.table.scrollTo(idx) break - def _fmt_ms(self, ms): - s = ms // 1000 - return f"{s//60:02}:{s%60:02}.{ms%1000:03}" - def _fmt_ms_full(self, ms): s = ms // 1000 m = s // 60 diff --git a/annotation_tool/controllers/media_controller.py b/annotation_tool/controllers/media_controller.py new file mode 100644 index 0000000..f3a3907 --- /dev/null +++ b/annotation_tool/controllers/media_controller.py @@ -0,0 +1,81 @@ +from PyQt6.QtCore import QUrl, QTimer, QObject +from PyQt6.QtMultimedia import QMediaPlayer +from PyQt6.QtWidgets import QWidget + +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) + """ + 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. + self.play_timer = QTimer() + self.play_timer.setSingleShot(True) + self.play_timer.setInterval(150) # 150ms delay + self.play_timer.timeout.connect(self._execute_play) + + def load_and_play(self, file_path: str, auto_play: bool = True): + """ + Standardized sequence to load and play a video. + """ + # 1. Force Stop & Cancel any pending play requests + 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.""" + self.player.play() + + def toggle_play_pause(self): + """Toggle between Play and Pause.""" + if self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState: + self.player.pause() + else: + self.player.play() + + def stop(self): + """ + Stops playback, clears source, cancels timers, and forces UI refresh. + """ + # A. Cancel pending auto-play if user clicked away quickly + if self.play_timer.isActive(): + self.play_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: + self.player.setLoops(QMediaPlayer.Loops.Once) + + def set_position(self, position): + self.player.setPosition(position) \ No newline at end of file diff --git a/annotation_tool/controllers/router.py b/annotation_tool/controllers/router.py new file mode 100644 index 0000000..b0f3456 --- /dev/null +++ b/annotation_tool/controllers/router.py @@ -0,0 +1,147 @@ +import json +from PyQt6.QtWidgets import QFileDialog, QMessageBox + +from controllers.classification.class_file_manager import ClassFileManager +from controllers.localization.loc_file_manager import LocFileManager +from controllers.description.desc_file_manager import DescFileManager +from controllers.dense_description.dense_file_manager import DenseFileManager + +from ui.common.dialogs import ProjectTypeDialog + +class AppRouter: + """ + Handles application entry points and routing: + 1. Open JSON / Create New Project + 2. Determine Mode (Classification vs Localization vs Description vs Dense) + 3. Delegate to specific Managers + 4. Handle Project Closure + """ + def __init__(self, main_window): + self.main = main_window + self.class_fm = ClassFileManager(main_window) + self.loc_fm = LocFileManager(main_window) + self.desc_fm = DescFileManager(main_window) + self.dense_fm = DenseFileManager(main_window) + + def create_new_project_flow(self): + """Unified entry point for creating a new project.""" + if not self.main.check_and_close_current_project(): + return + + dlg = ProjectTypeDialog(self.main) + if dlg.exec(): + mode = dlg.selected_mode + + if mode == "classification": + self.class_fm.create_new_project() + elif mode == "localization": + self.loc_fm.create_new_project() + elif mode == "description": + self.desc_fm.create_new_project() + elif mode == "dense_description": + self.dense_fm.create_new_project() + + def import_annotations(self): + """Global entry point for loading a JSON file.""" + if not self.main.check_and_close_current_project(): + return + + file_path, _ = QFileDialog.getOpenFileName( + self.main, "Select Project JSON", "", "JSON Files (*.json)" + ) + if not file_path: + return + + try: + with open(file_path, 'r', encoding='utf-8') as f: + data = json.load(f) + except Exception as e: + QMessageBox.critical(self.main, "Error", f"Invalid JSON: {e}") + return + + # Detect the type using heuristics + json_type = self._detect_json_type(data) + + if json_type == "classification": + if self.class_fm.load_project(data, file_path): + self.main.ui.show_classification_view() + + elif json_type == "localization": + if self.loc_fm.load_project(data, file_path): + self.main.ui.show_localization_view() + + elif json_type == "description": + # [FIXED] Check return value to ensure validation passed before switching view + if self.desc_fm.load_project(data, file_path): + self.main.ui.show_description_view() + + elif json_type == "dense_description": + if self.dense_fm.load_project(data, file_path): + self.main.ui.show_dense_description_view() + + else: + QMessageBox.critical(self.main, "Error", "Unknown JSON format or Task Type.") + + def close_project(self): + """Handles closing the current project.""" + if not self.main.check_and_close_current_project(): + return + + self.class_fm._clear_workspace(full_reset=True) + self.loc_fm._clear_workspace(full_reset=True) + self.desc_fm._clear_workspace(full_reset=True) + self.dense_fm._clear_workspace(full_reset=True) + + self.main.ui.show_welcome_view() + self.main.show_temp_msg("Project Closed", "Returned to Home Screen", duration=1000) + + def _detect_json_type(self, data): + """ + Heuristics to identify the project type from JSON structure. + Refined to better detect Description tasks even if malformed. + """ + task = str(data.get("task", "")).lower() + + # 1. Explicit task string check (Highest Priority) + if "dense" in task: + return "dense_description" + + if "caption" in task or "description" in task: + return "description" + + if "spotting" in task or "localization" in task: + return "localization" + + if "classification" in task: + return "classification" + + # 2. Top-level Structure Check + if "labels" in data and isinstance(data["labels"], dict): + return "localization" + + # 3. Item Structure Heuristics (Fallback) + items = data.get("data", []) + if not items: + return "unknown" + + first = items[0] if isinstance(items[0], dict) else {} + + # Dense checks + if "dense_captions" in first: + return "dense_description" + if "events" in first: + evts = first.get("events", []) + if evts and isinstance(evts, list) and len(evts) > 0 and "text" in evts[0]: + return "dense_description" + if evts and isinstance(evts, list) and len(evts) > 0 and "label" in evts[0]: + return "localization" + + # Description checks + if "captions" in first: + return "description" + + # Classification checks + if "labels" in first: + return "classification" + + return "unknown" \ No newline at end of file diff --git a/annotation_tool/image/README.md b/annotation_tool/image/README.md new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/annotation_tool/image/README.md @@ -0,0 +1 @@ + diff --git a/annotation_tool/image/logo.png b/annotation_tool/image/logo.png new file mode 100644 index 0000000..1f23a06 Binary files /dev/null and b/annotation_tool/image/logo.png differ diff --git a/Tool/main.py b/annotation_tool/main.py similarity index 100% rename from Tool/main.py rename to annotation_tool/main.py diff --git a/annotation_tool/models/README.md b/annotation_tool/models/README.md new file mode 100644 index 0000000..301cda8 --- /dev/null +++ b/annotation_tool/models/README.md @@ -0,0 +1,35 @@ +# 📦 Data Models & State Management + +This directory contains the **Model** layer of the MVC architecture. It is responsible for data storage, state validation, and providing standard interfaces for the View layer (`ui/`) and Controller layer (`controllers/`). + +## 📂 Module Descriptions + +### 1. `app_state.py` (Core Logic) +* **Purpose:** The central repository for the application's runtime state. +* **Key Class:** **`AppStateModel`** + * **Responsibility:** + * Stores Project Metadata (Path, Task Name, Modalities). + * Manages JSON Schema Definitions (`label_definitions`). + * Stores Annotation Data (Classification labels & Localization events). + * Manages the **Undo/Redo Stack**. + * **Validation:** Contains logic to validate imported JSON structures (`validate_gac_json`, `validate_loc_json`). +* **Key Enum:** **`CmdType`** + * Defines types of commands (e.g., `SCHEMA_ADD_LBL`, `LOC_EVENT_ADD`) used by the `HistoryManager` to track user actions. + +### 2. `project_tree.py` (UI Data Model) +* **Purpose:** A specialized Qt Model for the Left Sidebar (Clip Explorer). +* **Key Class:** **`ProjectTreeModel`** + * **Inheritance:** `QStandardItemModel` + * **Responsibility:** + * Adapts the raw data list into a hierarchical format suitable for `QTreeView`. + * Handles standard Item data (Display Name, Icon) and User Roles (File Path). + * Allows the UI to be decoupled from the raw list logic. + * **Usage:** + * Instantiated in `viewer.py`. + * Shared across both Classification and Localization views. + * Manipulated by Controllers (`NavigationManager`, `LocalizationManager`). + +## 🔄 Data Flow +1. **Controllers** update `AppStateModel` (business data) and `ProjectTreeModel` (UI list data) simultaneously. +2. **Views** (`QTreeView`) automatically reflect changes in `ProjectTreeModel` via Qt signals (`rowsInserted`, etc.). +3. **Serialization** (Save/Export) reads directly from `AppStateModel`. diff --git a/annotation_tool/models/__init__.py b/annotation_tool/models/__init__.py new file mode 100644 index 0000000..da3a0b5 --- /dev/null +++ b/annotation_tool/models/__init__.py @@ -0,0 +1,2 @@ +from .app_state import AppStateModel, CmdType +from .project_tree import ProjectTreeModel \ No newline at end of file diff --git a/annotation_tool/models/app_state.py b/annotation_tool/models/app_state.py new file mode 100644 index 0000000..284e79a --- /dev/null +++ b/annotation_tool/models/app_state.py @@ -0,0 +1,795 @@ +import os +import copy +from enum import Enum, auto + + +class CmdType(Enum): + """Command types recorded in the undo/redo history.""" + + # --- Classification commands --- + ANNOTATION_CONFIRM = auto() # Persist a user-confirmed annotation to the model + UI_CHANGE = auto() # Fine-grained UI toggle (radio/checkbox changes) + + # --- Shared schema commands (used by both modes) --- + SCHEMA_ADD_CAT = auto() # Add a category/head + SCHEMA_DEL_CAT = auto() # Delete a category/head + SCHEMA_REN_CAT = auto() # Rename a category/head + + SCHEMA_ADD_LBL = auto() # Add a label option under a head + SCHEMA_DEL_LBL = auto() # Delete a label option under a head + SCHEMA_REN_LBL = auto() # Rename a label option under a head + + # --- Localization commands --- + LOC_EVENT_ADD = auto() + LOC_EVENT_DEL = auto() + LOC_EVENT_MOD = auto() + + DESC_EDIT = auto() # Records text changes in Description mode + + # --- Dense Description commands --- + DENSE_EVENT_ADD = auto() + DENSE_EVENT_DEL = auto() + DENSE_EVENT_MOD = auto() + + +class AppStateModel: + """ + Centralized application state container. + - Owns project metadata, schema, annotations/events, and undo/redo stacks. + - Does not touch UI widgets (UI is managed elsewhere). + """ + + def __init__(self): + # --- Project metadata --- + self.current_working_directory = None + self.current_json_path = None + self.json_loaded = False + self.is_data_dirty = False + self.project_description = "" # Keep a default to avoid missing-field issues + self.current_task_name = "Untitled Task" + self.modalities = ["video"] + + # --- Schema / labels --- + # Format: { head_name: { "type": "single|multi", "labels": [..] } } + self.label_definitions = {} + + # --- Classification data --- + # Format: { video_path: { "Head": "Label", "Head2": ["L1", "L2"] } } + self.manual_annotations = {} + + # Classification import metadata (kept for backward compatibility) + self.imported_input_metadata = {} # key: (action_id, filename) + self.imported_action_metadata = {} # key: action_id + + # --- Localization / action spotting data --- + # Format: { video_path: [ { "head": ..., "label": ..., "position_ms": ... }, ... ] } + self.localization_events = {} + + # --- Common clip list --- + # Each item: { "name": "...", "path": "...", "source_files": [...] } + # This is the shared source of truth for the Project Tree + self.action_item_data = [] + self.action_item_map = {} # path -> QStandardItem (populated by UI layer) + self.action_path_to_name = {} # path -> name + + # --- Dense Description data --- + # Format: { video_path: [ { "position_ms": ..., "lang": "en", "text": "..." }, ... ] } + self.dense_description_events = {} + + # --- Undo/redo stacks --- + self.undo_stack = [] + self.redo_stack = [] + + def reset(self, full_reset: bool = False): + """Reset runtime state. If full_reset is True, also clears schema and project metadata.""" + self.current_json_path = None + self.json_loaded = False + self.is_data_dirty = False + + self.manual_annotations = {} + self.localization_events = {} + + self.imported_input_metadata = {} + self.imported_action_metadata = {} + + self.action_item_data = [] + self.action_item_map = {} + self.action_path_to_name = {} + self.dense_description_events = {} + + self.undo_stack = [] + self.redo_stack = [] + + if full_reset: + self.label_definitions = {} + self.current_working_directory = None + self.current_task_name = "Untitled Task" + self.project_description = "" + + def push_undo(self, cmd_type: CmdType, **kwargs): + """Push a command onto the undo stack and clear the redo stack.""" + command = {"type": cmd_type, **kwargs} + self.undo_stack.append(command) + self.redo_stack.clear() + self.is_data_dirty = True + + # ------------------------------------------------------------ + # Validation: Classification (Action Classification) + # ------------------------------------------------------------ + def validate_gac_json(self, data): + """ + Strict validation for Classification JSON. + Returns: (is_valid, error_msg, warning_msg) + """ + errors = [] + warnings = [] + + # --- Top-level Structure Checks --- + if not isinstance(data, dict): + return False, "Root JSON must be a dictionary.", "" + + # 1. Modalities Check + if "modalities" not in data: + errors.append("Critical: Missing top-level key 'modalities'.") + else: + mods = data["modalities"] + if not isinstance(mods, list): + errors.append(f"Critical: 'modalities' must be a list. Found: {type(mods).__name__}") + elif len(mods) == 0: + errors.append("Critical: 'modalities' list is empty.") + + # 2. Labels Schema Check + if "labels" not in data: + errors.append("Critical: Missing top-level key 'labels'.") + else: + lbls_def = data["labels"] + if not isinstance(lbls_def, dict): + errors.append("Critical: Top-level 'labels' must be a dictionary.") + else: + # Check individual heads + for head, content in lbls_def.items(): + if not isinstance(content, dict): + errors.append(f"Label definition for '{head}' must be a dictionary.") + continue + + # Check Type + if "type" not in content: + errors.append(f"Label head '{head}' missing 'type' field.") + + # Check Labels list + if "labels" not in content: + errors.append(f"Label head '{head}' missing 'labels' list.") + elif not isinstance(content["labels"], list): + errors.append(f"Label head '{head}' 'labels' must be a list.") + elif len(content["labels"]) == 0: + # Depending on policy, an empty label list might be useless/invalid + errors.append(f"Label head '{head}' has an empty 'labels' list.") + + # 3. Data Items Check + if "data" not in data: + errors.append("Critical: Missing top-level key 'data'.") + elif not isinstance(data["data"], list): + errors.append("Critical: Top-level 'data' must be a list.") + else: + # Per-item Validation + err_inputs_missing = [] + err_inputs_not_list = [] + err_inputs_empty = [] + err_input_path_missing = [] + err_input_type_wrong = [] + + for i, item in enumerate(data["data"]): + if not isinstance(item, dict): + errors.append(f"Item #{i} is not a dictionary.") + continue + + # Inputs check + if "inputs" not in item: + err_inputs_missing.append(f"Item #{i}") + continue + + inputs = item["inputs"] + if not isinstance(inputs, list): + err_inputs_not_list.append(f"Item #{i}") + continue + + if not inputs: + err_inputs_empty.append(f"Item #{i}") + continue + + # Check first input (Classification usually assumes 1 main video/clip) + inp0 = inputs[0] + if isinstance(inp0, dict): + if "path" not in inp0: + err_input_path_missing.append(f"Item #{i}") + if inp0.get("type") != "video": + err_input_type_wrong.append(f"Item #{i} type='{inp0.get('type')}'") + else: + err_inputs_not_list.append(f"Item #{i} (inputs[0] not dict)") + + def _fmt(title, lst): + if not lst: return None + preview = "\n ".join(lst[:5]) + ("\n ..." if len(lst) > 5 else "") + return f"{title} ({len(lst)}):\n {preview}" + + crit_item_errors = [ + _fmt("Items missing 'inputs'", err_inputs_missing), + _fmt("Items 'inputs' not a list", err_inputs_not_list), + _fmt("Items 'inputs' empty", err_inputs_empty), + _fmt("Items missing 'path' in input", err_input_path_missing), + _fmt("Items input type not 'video'", err_input_type_wrong) + ] + + errors.extend([e for e in crit_item_errors if e]) + + if errors: + return False, "\n\n".join(errors), "" + + return True, "", "\n".join(warnings) + + # ------------------------------------------------------------ + # Validation: Global Description / Video Captioning + # ------------------------------------------------------------ + def validate_desc_json(self, data): + """ + Validation for Description / Video Captioning JSON. + Returns: (is_valid, error_msg, warning_msg) + """ + errors = [] + + # 1. Root Check + if not isinstance(data, dict): + return False, "Root must be a dictionary.", "" + + if "data" not in data: + return False, "Critical: Missing top-level key 'data'.", "" + + items = data["data"] + if not isinstance(items, list): + return False, "Critical: 'data' must be a list.", "" + + # 2. Item Check + err_inputs_missing = [] + + for i, item in enumerate(items): + if not isinstance(item, dict): + errors.append(f"Item #{i} is not a dictionary.") + continue + + # Check inputs (Videos) + if "inputs" not in item: + err_inputs_missing.append(f"Item #{i}") + elif not isinstance(item["inputs"], list): + errors.append(f"Item #{i} 'inputs' must be a list.") + + # Check captions existence (soft check) + if "captions" not in item and "labels" not in item: + # Not hard-failing here, depends on your app policy. + pass + + if err_inputs_missing: + errors.append(f"Items missing 'inputs': {', '.join(err_inputs_missing[:5])}...") + + if errors: + return False, "\n".join(errors), "" + + return True, "", f"Validated {len(items)} items for Description." + + # ------------------------------------------------------------ + # Validation: Localization / Action Spotting + # ------------------------------------------------------------ + def validate_loc_json(self, data): + """ + Strict validation for Localization / Action Spotting JSON. + + Returns: + (is_valid, error_msg, warning_msg) + """ + errors = [] + warnings = [] + + # --- Top-level checks --- + if not isinstance(data, dict): + return False, "Root JSON must be a dictionary.", "" + + if "data" not in data: + return False, "Critical: Missing top-level key 'data'.", "" + + if not isinstance(data["data"], list): + return False, "Critical: Top-level 'data' must be a list.", "" + + if "labels" not in data: + return False, "Critical: Missing top-level key 'labels'.", "" + + labels_def = data["labels"] + if not isinstance(labels_def, dict): + return False, "Critical: Top-level 'labels' must be a dictionary.", "" + + # --- Schema checks --- + valid_heads = set() + head_label_map = {} + + for head, content in labels_def.items(): + if not isinstance(content, dict): + errors.append(f"Label definition for '{head}' must be a dictionary.") + continue + + lbls = content.get("labels") + if not isinstance(lbls, list): + errors.append(f"Critical: 'labels' field for head '{head}' must be a list.") + continue + + valid_heads.add(head) + head_label_map[head] = set(lbls) + + if errors: + return False, "\n".join(errors), "" + + # --- Per-item checks --- + err_inputs_missing = [] + err_inputs_not_list = [] + err_inputs_empty = [] + err_input_type = [] + err_input_path = [] + err_input_fps = [] + + err_events_missing = [] + err_events_not_list = [] + + err_evt_missing_fields = [] + err_evt_unknown_head = [] + err_evt_unknown_label = [] + err_evt_pos_format = [] + err_evt_pos_neg = [] + + warn_duplicates = [] + + items_list = data["data"] + + for i, item in enumerate(items_list): + if not isinstance(item, dict): + errors.append(f"Item #{i} is not a dictionary.") + continue + + # Inputs + if "inputs" not in item: + err_inputs_missing.append(f"Item #{i}") + continue + + inputs = item["inputs"] + if not isinstance(inputs, list): + err_inputs_not_list.append(f"Item #{i}") + continue + + if len(inputs) == 0: + err_inputs_empty.append(f"Item #{i}") + continue + + first_inp = inputs[0] + if not isinstance(first_inp, dict): + err_inputs_not_list.append(f"Item #{i} (inputs[0] is not a dict)") + continue + + if first_inp.get("type") != "video": + err_input_type.append(f"Item #{i} type='{first_inp.get('type')}'") + + if "path" not in first_inp: + err_input_path.append(f"Item #{i}") + + fps = first_inp.get("fps") + if fps is None or not isinstance(fps, (int, float)) or fps <= 0: + err_input_fps.append(f"Item #{i} fps={fps}") + + # Events + if "events" not in item: + err_events_missing.append(f"Item #{i}") + continue + + events = item["events"] + if not isinstance(events, list): + err_events_not_list.append(f"Item #{i}") + continue + + seen_events = set() + for j, evt in enumerate(events): + if not isinstance(evt, dict): + continue + + missing_fields = [] + if "head" not in evt: + missing_fields.append("head") + if "label" not in evt: + missing_fields.append("label") + if "position_ms" not in evt: + missing_fields.append("position_ms") + + if missing_fields: + err_evt_missing_fields.append(f"Item #{i} Evt #{j} missing {missing_fields}") + continue + + head = evt["head"] + label = evt["label"] + pos_val = evt["position_ms"] + + if head not in valid_heads: + err_evt_unknown_head.append(f"Item #{i} Evt #{j} head='{head}'") + continue + + allowed_labels = head_label_map[head] + if label not in allowed_labels: + err_evt_unknown_label.append( + f"Item #{i} Evt #{j} label='{label}' not in head '{head}'" + ) + continue + + try: + pos_int = int(pos_val) + if pos_int < 0: + err_evt_pos_neg.append(f"Item #{i} Evt #{j} pos={pos_int}") + except (ValueError, TypeError): + err_evt_pos_format.append(f"Item #{i} Evt #{j} pos='{pos_val}'") + continue + + sig = (head, label, pos_int) + if sig in seen_events: + warn_duplicates.append(f"Item #{i} ({head}, {label}, {pos_val})") + else: + seen_events.add(sig) + + def _fmt(title, lst): + if not lst: + return None + preview = "\n ".join(lst[:5]) + ("\n ..." if len(lst) > 5 else "") + return f"{title} ({len(lst)}):\n {preview}" + + crit_errors = [ + _fmt("Data items missing 'inputs'", err_inputs_missing), + _fmt("Data items 'inputs' is not a list", err_inputs_not_list), + _fmt("Data items 'inputs' is empty", err_inputs_empty), + _fmt("Inputs type is not 'video'", err_input_type), + _fmt("Inputs missing 'path'", err_input_path), + _fmt("Inputs FPS invalid (<= 0)", err_input_fps), + _fmt("Data items missing 'events'", err_events_missing), + _fmt("Data items 'events' is not a list", err_events_not_list), + _fmt("Events missing keys (head/label/position_ms)", err_evt_missing_fields), + _fmt("Unknown event head (not in labels)", err_evt_unknown_head), + _fmt("Unknown event label (not in schema)", err_evt_unknown_label), + _fmt("Position invalid format (not int)", err_evt_pos_format), + _fmt("Position is negative", err_evt_pos_neg), + ] + + final_errors = [e for e in crit_errors if e] + errors + if final_errors: + return False, "\n\n".join(final_errors), "" + + if warn_duplicates: + warnings.append(_fmt("Duplicate events found", warn_duplicates)) + + return True, "", "\n\n".join(warnings) + + + + + # ------------------------------------------------------------ + # Validation: Global Description / Video Captioning (Strict) + # ------------------------------------------------------------ + def validate_desc_json(self, data): + """ + Strict validation for Description / Video Captioning JSON. + Matches strict criteria: + - Top level: version, date (YYYY-MM-DD), task (must be captioning), dataset_name, data (list). + - Per Item: unique id, inputs (video), captions (list of {text/description, lang}). + + Returns: (is_valid, error_msg, warning_msg) + """ + import re + errors = [] + warnings = [] + + # --- Top-level Checks --- + if not isinstance(data, dict): + return False, "Root JSON must be a dictionary.", "" + + # 1. Missing Task / Wrong Task + if "task" not in data: + errors.append("Critical: Missing top-level key 'task'.") + else: + task_str = str(data["task"]).lower() + if "caption" not in task_str and "description" not in task_str: + errors.append(f"Critical: Task '{data['task']}' is not a valid Description/Captioning task.") + + # 2. Missing Dataset Name + if "dataset_name" not in data: + errors.append("Critical: Missing top-level key 'dataset_name'.") + + # 3. Bad Date Format (YYYY-MM-DD) + if "date" in data: + date_str = str(data["date"]) + # Simple regex for YYYY-MM-DD + if not re.match(r"^\d{4}-\d{2}-\d{2}$", date_str): + errors.append(f"Critical: Date '{date_str}' is not in YYYY-MM-DD format.") + else: + errors.append("Critical: Missing top-level key 'date'.") + + # 4. Data Not Array + if "data" not in data: + errors.append("Critical: Missing top-level key 'data'.") + elif not isinstance(data["data"], list): + errors.append(f"Critical: 'data' must be a list. Found: {type(data['data']).__name__}") + return False, "\n".join(errors), "" # Stop here if data structure is wrong + + # --- Item-level Checks --- + items = data["data"] + + seen_ids = set() + err_dup_ids = [] + + err_inputs_missing = [] + err_inputs_not_list = [] + err_input_type = [] + + err_captions_missing = [] + err_captions_not_list = [] + + err_cap_missing_text = [] + err_cap_empty_text = [] + + for i, item in enumerate(items): + if not isinstance(item, dict): + errors.append(f"Item #{i} is not a dictionary.") + continue + + # 5. Duplicate Sample ID + # Assuming 'id' is required for strict projects, though some legacy might not have it. + # If ID exists, check duplicates. + if "id" in item: + aid = str(item["id"]) + if aid in seen_ids: + err_dup_ids.append(aid) + else: + seen_ids.add(aid) + + # 6. Missing Inputs / Not Array + if "inputs" not in item: + err_inputs_missing.append(f"Item #{i}") + elif not isinstance(item["inputs"], list): + err_inputs_not_list.append(f"Item #{i}") + elif len(item["inputs"]) > 0: + # 7. Input Type Not Video + inp0 = item["inputs"][0] + if isinstance(inp0, dict): + if inp0.get("type") != "video": + err_input_type.append(f"Item #{i} (type='{inp0.get('type')}')") + else: + err_inputs_not_list.append(f"Item #{i} input[0]") + + # 8. Missing Captions / Not Array + # Supports key "captions" (standard) or legacy keys if needed, focusing on "captions" based on your files. + if "captions" not in item: + err_captions_missing.append(f"Item #{i}") + elif not isinstance(item["captions"], list): + err_captions_not_list.append(f"Item #{i}") + else: + # 9. Caption Content Checks + for c_idx, cap in enumerate(item["captions"]): + if not isinstance(cap, dict): + continue + + # Accept 'text' or 'description' or 'sentence' + text_val = cap.get("text", cap.get("description", cap.get("sentence"))) + + if text_val is None: + err_cap_missing_text.append(f"Item #{i} Cap #{c_idx}") + elif not str(text_val).strip(): + err_cap_empty_text.append(f"Item #{i} Cap #{c_idx}") + + # Formatting Errors + def _fmt(title, lst): + if not lst: return None + preview = ", ".join(lst[:5]) + (", ..." if len(lst) > 5 else "") + return f"{title} ({len(lst)}): {preview}" + + crit_errors = [ + _fmt("Duplicate IDs found", err_dup_ids), + _fmt("Items missing 'inputs'", err_inputs_missing), + _fmt("Items 'inputs' not list", err_inputs_not_list), + _fmt("Inputs not type 'video'", err_input_type), + _fmt("Items missing 'captions'", err_captions_missing), + _fmt("Items 'captions' not list", err_captions_not_list), + _fmt("Captions missing 'text' field", err_cap_missing_text), + _fmt("Captions have empty text", err_cap_empty_text), + ] + + final_errors = [e for e in crit_errors if e] + errors + + if final_errors: + return False, "\n\n".join(final_errors), "" + + return True, "", "\n".join(warnings) + + # ------------------------------------------------------------ + # Validation: Dense Description / Dense Video Captioning + # ------------------------------------------------------------ + def validate_dense_json(self, data): + """ + Strict validation for Dense Description JSON (dense_video_captioning). + Returns: (is_valid, error_msg, warning_msg) + """ + errors = [] + warnings = [] + + # --- Top-level checks --- + if not isinstance(data, dict): + return False, "Root JSON must be a dictionary.", "" + + if "data" not in data: + return False, "Critical: Missing top-level key 'data'.", "" + + items = data["data"] + if not isinstance(items, list): + return False, "Critical: Top-level 'data' must be a list.", "" + + # --- Per-item checks --- + err_item_not_dict = [] + + err_inputs_missing = [] + err_inputs_not_list = [] + err_inputs_empty = [] + err_input0_not_dict = [] + err_input0_type = [] + err_input0_path = [] + err_input0_fps = [] + + err_dense_missing = [] + err_dense_not_list = [] + err_dense_item_not_dict = [] + + err_cap_missing_fields = [] + err_cap_pos_format = [] + err_cap_pos_neg = [] + err_cap_lang_missing = [] + err_cap_lang_empty = [] + err_cap_text_missing = [] + err_cap_text_empty = [] + + warn_duplicates = [] + + for i, item in enumerate(items): + if not isinstance(item, dict): + err_item_not_dict.append(f"Item #{i}") + continue + + # inputs + if "inputs" not in item: + err_inputs_missing.append(f"Item #{i}") + continue + + inputs = item["inputs"] + if not isinstance(inputs, list): + err_inputs_not_list.append(f"Item #{i}") + continue + + if len(inputs) == 0: + err_inputs_empty.append(f"Item #{i}") + continue + + inp0 = inputs[0] + if not isinstance(inp0, dict): + err_input0_not_dict.append(f"Item #{i}") + continue + + if inp0.get("type") != "video": + err_input0_type.append(f"Item #{i} type='{inp0.get('type')}'") + + if "path" not in inp0: + err_input0_path.append(f"Item #{i}") + + fps = inp0.get("fps") + if fps is None or not isinstance(fps, (int, float)) or fps <= 0: + err_input0_fps.append(f"Item #{i} fps={fps}") + + # dense_captions (required) + if "dense_captions" not in item: + err_dense_missing.append(f"Item #{i}") + continue + + dense_caps = item["dense_captions"] + if not isinstance(dense_caps, list): + err_dense_not_list.append(f"Item #{i}") + continue + + # Allow empty dense_captions? depends on your policy. + # Here: empty is allowed (unannotated), but structure must be correct. + seen = set() + for j, cap in enumerate(dense_caps): + if not isinstance(cap, dict): + err_dense_item_not_dict.append(f"Item #{i} Cap #{j}") + continue + + missing = [] + if "position_ms" not in cap: + missing.append("position_ms") + if "lang" not in cap: + missing.append("lang") + if "text" not in cap: + missing.append("text") + + if missing: + err_cap_missing_fields.append(f"Item #{i} Cap #{j} missing {missing}") + continue + + # position_ms + pos_val = cap.get("position_ms") + try: + pos_int = int(pos_val) + except (ValueError, TypeError): + err_cap_pos_format.append(f"Item #{i} Cap #{j} pos='{pos_val}'") + continue + + if pos_int < 0: + err_cap_pos_neg.append(f"Item #{i} Cap #{j} pos={pos_int}") + continue + + # lang + lang = cap.get("lang") + if lang is None: + err_cap_lang_missing.append(f"Item #{i} Cap #{j}") + elif not isinstance(lang, str): + err_cap_lang_missing.append(f"Item #{i} Cap #{j} lang_type={type(lang).__name__}") + elif lang.strip() == "": + err_cap_lang_empty.append(f"Item #{i} Cap #{j}") + + # text + text = cap.get("text") + if text is None: + err_cap_text_missing.append(f"Item #{i} Cap #{j}") + elif not isinstance(text, str): + err_cap_text_missing.append(f"Item #{i} Cap #{j} text_type={type(text).__name__}") + elif text.strip() == "": + err_cap_text_empty.append(f"Item #{i} Cap #{j}") + + # duplicate warning + sig = (pos_int, str(lang), str(text)) + if sig in seen: + warn_duplicates.append(f"Item #{i} duplicate (pos={pos_int}, lang={lang})") + else: + seen.add(sig) + + def _fmt(title, lst): + if not lst: + return None + preview = "\n ".join(lst[:5]) + ("\n ..." if len(lst) > 5 else "") + return f"{title} ({len(lst)}):\n {preview}" + + crit_errors = [ + _fmt("Data items are not dict", err_item_not_dict), + + _fmt("Data items missing 'inputs'", err_inputs_missing), + _fmt("Data items 'inputs' is not a list", err_inputs_not_list), + _fmt("Data items 'inputs' is empty", err_inputs_empty), + _fmt("inputs[0] is not a dict", err_input0_not_dict), + _fmt("inputs[0].type is not 'video'", err_input0_type), + _fmt("inputs[0] missing 'path'", err_input0_path), + _fmt("inputs[0] fps invalid (<= 0 or not number)", err_input0_fps), + + _fmt("Data items missing 'dense_captions'", err_dense_missing), + _fmt("Data items 'dense_captions' is not a list", err_dense_not_list), + _fmt("dense_captions entries not dict", err_dense_item_not_dict), + + _fmt("dense caption missing keys (position_ms/lang/text)", err_cap_missing_fields), + _fmt("dense caption position_ms invalid format (not int)", err_cap_pos_format), + _fmt("dense caption position_ms is negative", err_cap_pos_neg), + _fmt("dense caption lang missing/invalid type", err_cap_lang_missing), + _fmt("dense caption lang empty string", err_cap_lang_empty), + _fmt("dense caption text missing/invalid type", err_cap_text_missing), + _fmt("dense caption text empty string", err_cap_text_empty), + ] + + final_errors = [e for e in crit_errors if e] + errors + if final_errors: + return False, "\n\n".join(final_errors), "" + + if warn_duplicates: + warnings.append(_fmt("Duplicate dense captions found", warn_duplicates)) + + return True, "", "\n\n".join(warnings) \ No newline at end of file diff --git a/annotation_tool/models/project_tree.py b/annotation_tool/models/project_tree.py new file mode 100644 index 0000000..3be1ed4 --- /dev/null +++ b/annotation_tool/models/project_tree.py @@ -0,0 +1,40 @@ +from PyQt6.QtGui import QStandardItemModel, QStandardItem +from PyQt6.QtCore import Qt + +class ProjectTreeModel(QStandardItemModel): + """ + A standalone Model component compliant with Qt's Model/View architecture. + It manages the hierarchical data for clips and sequences. + """ + + # Define custom role for storing file paths + FilePathRole = Qt.ItemDataRole.UserRole + + def __init__(self, parent=None): + super().__init__(parent) + self.setColumnCount(1) + + def add_entry(self, name: str, path: str, source_files: list = None, icon=None) -> QStandardItem: + """ + Creates and appends a new row to the model. + Returns the created QStandardItem for external reference. + """ + item = QStandardItem(name) + item.setEditable(False) + item.setData(path, self.FilePathRole) + + if icon: + item.setIcon(icon) + + # Handle child items (e.g., multi-view inputs) + if source_files and len(source_files) > 1: + for src in source_files: + import os + child_name = os.path.basename(src) + child = QStandardItem(child_name) + child.setEditable(False) + child.setData(src, self.FilePathRole) + item.appendRow(child) + + self.appendRow(item) + return item \ No newline at end of file diff --git a/Tool/requirements.txt b/annotation_tool/requirements.txt similarity index 100% rename from Tool/requirements.txt rename to annotation_tool/requirements.txt diff --git a/annotation_tool/style/README.md b/annotation_tool/style/README.md new file mode 100644 index 0000000..c81b0f6 --- /dev/null +++ b/annotation_tool/style/README.md @@ -0,0 +1,37 @@ + +# 🎨 Application Stylesheets (QSS) + +This directory contains the **Qt Style Sheets (.qss)** that define the visual appearance of the application. The tool currently ships with a single default theme: **Dark Mode**. + +## 📂 Files + +### 1. `style.qss` (Dark Mode) +* **Type:** Default Theme. +* **Palette:** + * **Backgrounds:** Deep Grays (`#2E2E2E`, `#3C3C3C`). + * **Text:** Off-White (`#F0F0F0`). + * **Accents:** Bright Blue (`#00BFFF`) and darker greys for borders. +* **Usage:** Optimized for long annotation sessions to reduce eye strain in low-light environments. + +> Note: `style_day.qss` (Light/Day Mode) has been removed. The application no longer supports theme switching out of the box. + +--- + +## 🛠 Technical Details + +These files use standard CSS-like syntax adapted for Qt Widgets (`QWidget`, `QPushButton`, `QTreeWidget`, etc.). + +### Key Styling Features +1. **Collapsible Group Boxes** + * We utilize a CSS trick to animate the `QGroupBox` folding mechanism. + * The selector `QGroupBox::checkable:checked > QWidget` controls the `max-height` property to hide or show content without requiring complex Python animation code. + +2. **Custom Indicators** + * `QRadioButton` and `QCheckBox` indicators are customized to match the theme colors. + * `QTreeWidget` headers are styled to blend seamlessly with the panel backgrounds. + +3. **Interactive States** + * Buttons define specific styles for `:hover`, `:pressed`, and `:disabled` states to provide immediate visual feedback to the user. + +## 🔄 Loading Logic +The stylesheet is loaded during application startup (typically in `main.py` or `viewer.py`). The application reads the `.qss` file content and applies it via `setStyleSheet(...)`. diff --git a/annotation_tool/style/style.qss b/annotation_tool/style/style.qss new file mode 100644 index 0000000..7eb18dd --- /dev/null +++ b/annotation_tool/style/style.qss @@ -0,0 +1,650 @@ +QWidget { + background-color: #2E2E2E; + color: #F0F0F0; + font-family: Arial, sans-serif; + font-size: 14px; +} + +QLabel#titleLabel { + font-size: 18px; + font-weight: bold; + padding-bottom: 10px; + border-bottom: 2px solid #555; +} + +QLabel#subtitleLabel { + font-size: 16px; + font-weight: bold; + color: #00AACC; + padding-top: 10px; +} + +QPushButton { + background-color: #555; + border: 1px solid #666; + padding: 8px; + border-radius: 4px; +} + +QPushButton:hover { + background-color: #666; +} + +QPushButton:pressed { + background-color: #444; +} + +QPushButton#startButton { + background-color: #007ACC; + font-size: 16px; + font-weight: bold; +} + +QPushButton#startButton:hover { + background-color: #008AE6; +} + +QPushButton:disabled { + background-color: #4A5864; +} + +QTreeWidget { + background-color: #3C3C3C; + border: 1px solid #555; +} + +QHeaderView::section { + background-color: #555; + padding: 4px; + border: 1px solid #666; + font-weight: bold; +} + +QProgressBar { + border-radius: 5px; + text-align: center; + color: white; +} + +QProgressBar::chunk { + background-color: #007ACC; + border-radius: 5px; +} + +/* --- Additional styles --- */ + +QGroupBox { + font-size: 16px; + font-weight: bold; + color: #F0F0F0; + background-color: #3C3C3C; + border: 1px solid #555; + border-radius: 4px; + margin-top: 10px; /* Leave space for the title bar */ +} + +/* GroupBox title + checkable toggle */ +QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + padding: 5px 25px; /* Extra left padding to make room for the indicator */ + background-color: #4A4A4A; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + border-bottom: 1px solid #555; +} + +/* Checkable indicator (collapse/expand) */ +QGroupBox::indicator { + width: 18px; + height: 18px; + /* Positioned on the left side of the title */ + position: absolute; + left: 5px; + top: 5px; +} + +QGroupBox::indicator:unchecked { + /* Collapsed state (arrow pointing right) */ + image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAABvSURBVDhPzZAxCgAgDAQhR/f+V80hgkWsUBsLs3/cqiDgBYH4xBcBvjFpFiQbka0cDDc720BKKcmyLFMvSZIyIfO9Evf1k9Tf8zDVB80wzPM8WlW1lQJwni8AJZJk7QAVVTWtqhYAz/NJkiRbAWoOQMbDDB3iAAAAAElFTkSuQmCC); +} + +QGroupBox::indicator:checked { + /* Expanded state (arrow pointing down) */ + image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAABqSURBVDhPzYxBCgAwCAOj+/+vbZCKIpA5NFAuTcsxZJk/YgkB8YkmAHzjeZ4lSZIsqyopZYrleZ7d+wP3rNcANE0biqL8m0EQFIVRWKu1xBABvO8/SlJERAQhBL/vH0EQEiL+N4FmBt0L2dOkAAAAAElFTkSuQmCC); +} + +QRadioButton { + padding: 4px; + color: #F0F0F0; +} + +QRadioButton::indicator { + width: 16px; + height: 16px; +} + +/* --- Collapsible GroupBox behavior --- */ +/* + * When a QGroupBox is checkable and unchecked (collapsed), + * hide the internal content widget by forcing its max-height to 0 + * and stripping spacing so it fully disappears. + */ +QGroupBox::checkable:!checked > QWidget { + max-height: 0px; + margin: 0; + padding: 0; + border: none; +} + +/* + * When a QGroupBox is checkable and checked (expanded), + * restore a sufficiently large max-height so content becomes visible. + */ +QGroupBox::checkable:checked > QWidget { + max-height: 9999px; + /* Optional: add padding for the expanded content area */ + /* padding: 10px; */ +} + +/* --- QSlider styling --- */ + +QSlider::groove:horizontal { + border: 1px solid #555; + height: 6px; /* Track thickness */ + background: #444; + margin: 2px 0; + border-radius: 3px; +} + +QSlider::handle:horizontal { + background: #f2f6f8ff; + border: 1px solid #005699; + width: 12px; /* Handle size */ + height: 12px; /* Handle size */ + margin: -4px 0; /* Vertically center the handle */ + border-radius: 6px; /* Circular handle */ +} + +QSlider::handle:horizontal:hover { + background: #00AACC; +} + +QSlider::sub-page:horizontal { + background: #007ACC; /* Filled portion */ + border-radius: 3px; +} + +/* ======================================================= + Unified UI Component Styles + ======================================================= */ + +/* --- Project Controls (Left Panel Buttons) --- */ +/* Target: ui/common/project_controls.py */ +QPushButton[class="project_control_btn"] { + border-radius: 6px; + padding: 5px; + background-color: #444; + color: #EEE; + border: 1px solid #555; + font-weight: bold; + min-height: 35px; /* Moved from setMinimumHeight in Python if desired, or keep logic there */ +} + +QPushButton[class="project_control_btn"]:hover { + background-color: #555; + border-color: #777; +} + +QPushButton[class="project_control_btn"]:pressed { + background-color: #0078D7; + border-color: #0078D7; +} + +QPushButton[class="project_control_btn"]:disabled { + background-color: #333; + color: #777; + border-color: #333; +} + +/* --- Dialogs: Project Type Selection --- */ +/* Target: ui/common/dialogs.py */ + +/* The instruction text */ +QLabel[class="dialog_instruction_lbl"] { + font-size: 14px; + font-weight: bold; + color: #ccc; +} + +/* The large Classification/Localization buttons */ +QPushButton[class="dialog_mode_btn"] { + font-size: 16px; + background-color: #2A2A2A; + border: 2px solid #444; + border-radius: 8px; + min-height: 80px; /* Enforce height here */ +} + +QPushButton[class="dialog_mode_btn"]:hover { + background-color: #3A3A3A; + border-color: #00BFFF; /* The cyan highlight color */ +} + +/* --- Welcome Screen --- */ +/* Target: ui/common/welcome_widget.py */ + +QWidget#welcome_page { + background-color: #555; +} + +QWidget#welcome_page QLabel { + background-color: transparent; +} + +QLabel#welcome_title_lbl { + font-size: 24px; + font-weight: bold; + color: #00ff2f; +} + +QPushButton[class="welcome_action_btn"] { + font-size: 16px; + font-weight: bold; + background-color: #2E2E2E; + border: 1px solid #111; + color: #F0F0F0; + border-radius: 6px; +} + +QPushButton[class="welcome_action_btn"]:hover { + background-color: #3C3C3C; + border-color: #555; +} + +QPushButton[class="welcome_action_btn"]:pressed { + background-color: #1A1A1A; +} + +QPushButton[class="welcome_secondary_btn"] { + font-size: 14px; + font-weight: bold; + background-color: transparent; + border: 1px solid #84ff00; + color: #84ff00; + border-radius: 6px; +} + +QPushButton[class="welcome_secondary_btn"]:hover { + background-color: rgba(0, 191, 255, 0.1); + border-color: #84ff00; + color: #84ff00; +} + +QPushButton[class="welcome_secondary_btn"]:pressed { + background-color: rgba(0, 191, 255, 0.2); +} + + +/* --- Common Project Tree Panel --- */ +/* Target: ui/common/clip_explorer.py */ + +QLabel[class="panel_header_lbl"] { + font-weight: bold; + color: #888; + margin-top: 10px; +} + +QPushButton#panel_clear_btn { + padding: 4px 8px; +} + +/* ======================================================= + Classification Mode Styles + ======================================================= */ + +/* --- Event Editor Panel --- */ +/* Target: ui/classification/event_editor/editor.py */ + +/* Undo/Redo Buttons */ +QPushButton[class="editor_control_btn"] { + background-color: #444; + color: #DDD; + border: 1px solid #555; + border-radius: 4px; + padding: 4px; + font-weight: bold; +} + +QPushButton[class="editor_control_btn"]:hover { + background-color: #555; + border-color: #777; +} + +QPushButton[class="editor_control_btn"]:disabled { + color: #666; + background-color: #333; +} + +/* Task Info Label */ +QLabel[class="editor_task_lbl"] { + font-weight: bold; + font-size: 14px; + margin-bottom: 5px; +} + +/* Save/Confirm Annotation Button */ +QPushButton[class="editor_save_btn"] { + background-color: #0078D7; + color: white; + font-weight: bold; +} + +QPushButton[class="editor_save_btn"]:hover { + background-color: #008AE6; +} + +/* --- Dynamic Widgets (Label Groups) --- */ +/* Target: ui/classification/event_editor/dynamic_widgets.py */ + +/* Common Header Style */ +QLabel[class="group_head_lbl"] { + font-weight: bold; + font-size: 13px; +} + +/* Specific Colors for Single vs Multi Label Headers */ +QLabel[class="group_head_single"] { + color: #00BFFF; /* Cyan */ +} + +QLabel[class="group_head_multi"] { + color: #32CD32; /* Lime Green */ +} + +/* Small 'X' Remove Buttons (Replaces utils.get_square_remove_btn_style) */ +QPushButton[class="icon_remove_btn"] { + background-color: transparent; + color: #888; + border: none; + font-weight: bold; + font-size: 16px; + border-radius: 3px; +} + +QPushButton[class="icon_remove_btn"]:hover { + color: #FF4444; /* Red on hover */ + background-color: rgba(255, 68, 68, 0.1); +} + +/* --- Media Player --- */ +/* Target: ui/classification/media_player/preview.py */ + +/* Time Display Label (00:00 / 00:00) */ +QLabel[class="player_time_lbl"] { + font-family: Menlo, Consolas, "Courier New", monospace; + font-weight: bold; + color: #EEE; +} + + + +/* ======================================================= + Localization Mode Styles + ======================================================= */ + +/* --- Media Player Preview --- */ +/* Target: ui/localization/media_player/preview.py */ +QWidget[class="video_preview_widget"] { + background-color: black; +} + +/* --- Timeline Widget --- */ +/* Target: ui/localization/media_player/timeline.py */ + +/* Time Label (00:00 / 00:00) */ +QLabel[class="timeline_time_lbl"] { + font-family: 'Courier New', Menlo, monospace; + font-size: 12px; + font-weight: bold; + color: #EEE; +} + +/* Zoom Buttons (+/-) */ +QPushButton[class="timeline_zoom_btn"] { + background-color: #444; + color: white; + border: 1px solid #555; + border-radius: 4px; + font-weight: bold; + font-size: 14px; +} +QPushButton[class="timeline_zoom_btn"]:hover { background-color: #555; } +QPushButton[class="timeline_zoom_btn"]:pressed { background-color: #666; } + +/* Timeline ScrollArea (Container) */ +QScrollArea[class="timeline_scroll_area"] { + background: transparent; + border: none; +} + +/* Timeline ScrollBar */ +QScrollArea[class="timeline_scroll_area"] QScrollBar:horizontal { + border: none; + background: #222; + height: 12px; + margin: 0px; + border-radius: 6px; +} +QScrollArea[class="timeline_scroll_area"] QScrollBar::handle:horizontal { + background: #666; + min-width: 20px; + border-radius: 6px; +} +QScrollArea[class="timeline_scroll_area"] QScrollBar::add-line:horizontal, +QScrollArea[class="timeline_scroll_area"] QScrollBar::sub-line:horizontal { + width: 0px; +} + +/* Timeline Slider (The Track) */ +QSlider[class="timeline_slider"]::groove:horizontal { + border: 1px solid #3A3A3A; + height: 6px; + background: #202020; + margin: 0px; + border-radius: 3px; +} +QSlider[class="timeline_slider"]::handle:horizontal { + background: #FF3333; + border: 1px solid #FF3333; + width: 8px; + height: 16px; + margin: -5px 0; + border-radius: 4px; +} +QSlider[class="timeline_slider"]::sub-page:horizontal { + background: #444; + border-radius: 3px; +} + +/* --- Spotting Controls (Tabs & Labels) --- */ +/* Target: ui/localization/event_editor/spotting_controls.py */ + +/* Label Grid Buttons */ +QPushButton[class="spotting_label_btn"] { + background-color: #444; + color: white; + border: 1px solid #555; + border-radius: 6px; + font-weight: bold; + font-size: 13px; + text-align: center; + padding: 4px; +} +QPushButton[class="spotting_label_btn"]:hover { background-color: #555; border-color: #777; } +QPushButton[class="spotting_label_btn"]:pressed { background-color: #0078D7; border-color: #0078D7; } + +/* Time Display in Tab */ +QLabel[class="spotting_time_lbl"] { + color: #00BFFF; + font-weight: bold; + font-family: Menlo, monospace; + font-size: 14px; +} + +/* Transparent ScrollArea for Buttons */ +QScrollArea[class="spotting_scroll_area"] { + background: transparent; + border: none; +} + +/* "Add Label" Button */ +QPushButton[class="spotting_add_btn"] { + background-color: #0078D7; + color: white; + border: 1px solid #005A9E; + border-radius: 6px; + font-weight: bold; + font-size: 13px; + min-height: 45px; +} +QPushButton[class="spotting_add_btn"]:hover { background-color: #1084E3; border-color: #2094F3; } +QPushButton[class="spotting_add_btn"]:pressed { background-color: #005A9E; } + +/* Spotting Tab Widget */ +QTabWidget[class="spotting_tabs"]::pane { + border: 1px solid #444; + border-radius: 4px; + background: #2E2E2E; +} +QTabWidget[class="spotting_tabs"] > QTabBar::tab { + background: #3A3A3A; + color: #BBB; + padding: 8px 12px; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + margin-right: 2px; +} +QTabWidget[class="spotting_tabs"] > QTabBar::tab:selected { + background: #2E2E2E; + color: white; + font-weight: bold; + border-bottom: 2px solid #00BFFF; +} +QTabWidget[class="spotting_tabs"] > QTabBar::tab:hover { + background: #444; + color: white; +} + +/* --- Annotation Table --- */ +/* Target: ui/localization/event_editor/annotation_table.py */ + +QTableView[class="annotation_table"] { + background-color: #2E2E2E; + gridline-color: #555; + color: #DDD; + selection-background-color: #0078D7; + selection-color: white; + alternate-background-color: #3A3A3A; +} +QTableView[class="annotation_table"] QHeaderView::section { + background-color: #444; color: white; border: 1px solid #555; padding: 4px; +} + +QStatusBar { + border-top: 1px solid #444; + padding: 4px; +} + +/* ======================================================= */ +/* Description Mode Specifics */ +/* ======================================================= */ + +/* 1. Caption Text Editor (Right Panel) */ +QTextEdit#descCaptionEdit { + background-color: #1E1E1E; + color: #F0F0F0; + border: 1px solid #555; + border-radius: 4px; + padding: 8px; + font-size: 13px; + /* line-height is handled by Qt block format, css support is limited but kept for ref */ + selection-background-color: #0078d4; +} + +/* 2. Confirm Button (Blue Style) */ +QPushButton#descConfirmBtn { + background-color: #0078d4; + color: white; + border-radius: 4px; + font-weight: bold; + border: none; + font-size: 14px; +} + +QPushButton#descConfirmBtn:hover { + background-color: #0063b1; +} + +QPushButton#descConfirmBtn:pressed { + background-color: #005a9e; +} + +QPushButton#descConfirmBtn:disabled { + background-color: #444; + color: #888; +} + +/* 3. Clear Button (Standard Style override if needed) */ +QPushButton#descClearBtn { + /* Inherits standard QPushButton style, you can add specifics here */ + font-weight: bold; +} + +/* 4. Player Time Label (Center Panel) */ +QLabel#descTimeLabel { + color: #888; + font-weight: bold; + font-size: 12px; +} + + + +/* ======================================================= + Dense Description - Right Panel Widgets + Target: ui/dense_description/event_editor/desc_input_widget.py + ======================================================= */ + +QLabel[class="dense_time_display"] { + font-size: 14px; + font-weight: bold; + /* Match Localization "spotting_time_lbl" */ + color: #00BFFF; + font-family: Menlo, monospace; +} + +QTextEdit[class="dense_desc_editor"] { + background-color: #222; + color: #EEE; + border: 1px solid #444; +} + +/* Match Localization "spotting_add_btn" (blue) */ +QPushButton[class="dense_confirm_btn"] { + background-color: #0078D7; + color: white; + border: 1px solid #005A9E; + border-radius: 6px; + font-weight: bold; + font-size: 13px; + padding: 8px; +} + +QPushButton[class="dense_confirm_btn"]:hover { + background-color: #1084E3; + border-color: #2094F3; +} + +QPushButton[class="dense_confirm_btn"]:pressed { + background-color: #005A9E; +} diff --git a/annotation_tool/ui/.DS_Store b/annotation_tool/ui/.DS_Store new file mode 100644 index 0000000..738e20d Binary files /dev/null and b/annotation_tool/ui/.DS_Store differ diff --git a/annotation_tool/ui/README.md b/annotation_tool/ui/README.md new file mode 100644 index 0000000..cfa28d8 --- /dev/null +++ b/annotation_tool/ui/README.md @@ -0,0 +1,98 @@ +# 🎨 User Interface (UI) Module + +This directory contains the **View layer** of the application's MVC architecture. It is responsible solely for graphical presentation and user interaction. + +**Note:** No business logic or data manipulation is performed here. All user interactions (clicks, edits, playback controls) are emitted as **Qt Signals** to be handled by the `controllers` module. + +## 📂 Directory Structure + +The UI is organized by functional domain, with a robust **Common** library supporting four distinct annotation modes: + +```text +ui/ +├── common/ # Shared architecture (Main Window, Workspace Skeleton, Dialogs) +│ ├── main_window.py # Top-level Stacked Layout orchestrator +│ ├── workspace.py # Unified 3-column layout skeleton +│ ├── video_surface.py # Shared video rendering widget +│ └── ... +│ +├── classification/ # [Mode 1] Whole-Video Classification +├── localization/ # [Mode 2] Action Spotting (Timestamps) +├── description/ # [Mode 3] Global Description (Captions) +└── dense_description/ # [Mode 4] Dense Description (Timestamped Text) + +``` + +--- + +## 🧩 Modules Description + +### 1. Common (`ui/common/`) + +The backbone of the application, ensuring a consistent user experience across all modes. + +* **`main_window.py`**: The application entry point. It manages a `QStackedLayout` to switch between the **Welcome Screen** and the four **Workspaces** without destroying state. +* **`workspace.py`**: Defines the `UnifiedTaskPanel`. This is the generic 3-column skeleton (Left Tree | Center Player | Right Editor) used by **every** mode to maintain layout consistency. +* **`video_surface.py`**: A pure rendering widget wrapping `QMediaPlayer` and `QVideoWidget`. It handles video output while leaving playback logic to the controllers. +* **`clip_explorer.py`**: The **Left Sidebar**. Refactored to use **Qt Model/View** (`QTreeView`) for high performance. It handles file navigation and filtering (e.g., "Show Labelled Only"). +* **`dialogs.py`**: +* `ProjectTypeDialog`: Updated wizard allowing selection of **Classification**, **Localization**, **Description**, or **Dense Description**. +* `FolderPickerDialog`: A custom file tree allowing multi-folder selection. + + + +### 2. Classification (`ui/classification/`) + +Implements the interface for **Whole-Video Classification** (assigning categories to an entire clip). + +* **`media_player/`**: Standard player with basic seek controls. +* **`event_editor/`**: Dynamic form generation (Radio buttons/Checkboxes) based on the project Schema. + +### 3. Localization (`ui/localization/`) + +Implements the interface for **Action Spotting** (identifying specific timestamps). + +* **`media_player/`**: +* Features the **Zoomable Timeline**, visual event markers, and frame-stepping tools. + + +* **`event_editor/`**: +* **Spotting Tabs:** Rapid-fire buttons for defining event categories. +* **Annotation Table:** A spreadsheet view for editing timestamps and labels. + + + +### 4. Description (`ui/description/`) [NEW] + +Implements the interface for **Global Captioning** (one text description per video). + +* **`media_player/`**: +* **Composite Player:** Combines the video surface with a specialized navigation toolbar. +* **Behavior:** Defaults to **Infinite Loop** to allow repeated viewing while typing. + + +* **`event_editor/`**: +* **Text Input:** A large `QTextEdit` for free-form text. +* **Actions:** Simple "Confirm" and "Clear" workflow. + + + +### 5. Dense Description (`ui/dense_description/`) [NEW] + +Implements the interface for **Dense Captioning** (text descriptions anchored to specific timestamps). + +* **`event_editor/`**: +* **Input Widget:** A specialized panel showing the current video time alongside a text input area. +* **Dense Table:** A subclass of the Localization table. It replaces the "Label" column with a "Description" column and auto-sizes to a **2:1:4 ratio** (Time : Lang : Text). + + +* **Reuse:** This mode reuses the **Localization Center Panel** (Timeline + Player) to allow precise navigation between text events. + +--- + +## 🎨 Design Principles + +1. **Passive View:** These classes do not modify data directly. They display data provided by the controller and emit signals (e.g., `confirm_clicked`, `request_remove_item`) when the user acts. +2. **Unified Skeleton:** All modes inherit the same `UnifiedTaskPanel` structure. This ensures that the Sidebar and Media Player always appear in the same relative locations, reducing cognitive load for the user. +3. **Composite Design:** Complex widgets (like the Description Player) are built by composing smaller, single-purpose widgets (VideoSurface + Controls + Slider) rather than monolithic classes. +4. **Dynamic Generation:** Where possible, forms and tables adjust their content dynamically based on the loaded JSON schema or data model. diff --git a/Tool/ui2/__init__.py b/annotation_tool/ui/__init__.py similarity index 100% rename from Tool/ui2/__init__.py rename to annotation_tool/ui/__init__.py diff --git a/annotation_tool/ui/classification/.DS_Store b/annotation_tool/ui/classification/.DS_Store new file mode 100644 index 0000000..ffe21a5 Binary files /dev/null and b/annotation_tool/ui/classification/.DS_Store differ diff --git a/annotation_tool/ui/classification/README.md b/annotation_tool/ui/classification/README.md new file mode 100644 index 0000000..3abee97 --- /dev/null +++ b/annotation_tool/ui/classification/README.md @@ -0,0 +1,41 @@ +# 🏷️ Classification UI Module + +This directory contains the user interface components specifically designed for the **Whole-Video Classification** task. + +In this mode, users assign attributes (labels) to an entire video clip rather than specific timestamps. The UI is designed to dynamically adapt to the project's JSON schema. + +## 📂 File Descriptions + +### `panels.py` + +This file defines the structural containers for the classification interface. It arranges the screen into three distinct areas: + +* **`LeftPanel`**: + * Hosts the **Project Controls** (imported from `ui/common`). + * Displays the **Action Clip List** (File Tree). + * Manages filtering options (All / Done / Not Done). +* **`CenterPanel`**: + * Contains the video player (`VideoViewAndControl`). + * Hosts navigation buttons (Previous/Next Action, Previous/Next Clip). +* **`RightPanel`**: + * **Dynamic Form Area**: Automatically generates input fields based on the project Schema. + * **Manual Annotation Box**: Displays the current selection state and confirmation buttons. + +### `widgets.py` +**Task-Specific Components** + +This file contains specialized widgets that are mostly generated programmatically based on the user's label definitions: + +* **`DynamicSingleLabelGroup`**: A `QGroupBox` containing **Radio Buttons**. Used when the schema defines a "single_label" type (Mutually exclusive). +* **`DynamicMultiLabelGroup`**: A `QGroupBox` containing **Checkboxes**. Used when the schema defines a "multi_label" type (Multiple selections allowed). +* **`VideoViewAndControl`**: A wrapper widget combining `QVideoWidget`, a custom clickable seek slider, and time labels specific to the classification workflow. + +### `__init__.py` +* Exposes the classes from `panels` and `widgets` to the rest of the application, simplifying import statements. + +--- + +## 💡 Key Concepts + +1. **Dynamic UI Generation**: The **RightPanel** does not have hardcoded buttons for labels (e.g., "Goal", "Foul"). Instead, it reads the `label_definitions` from the Model and instantiates the appropriate `Dynamic...LabelGroup` widgets from `widgets.py` at runtime. +2. **Shared Controls**: The **LeftPanel** embeds the `UnifiedProjectControls` from the `../common/` directory to ensure the "Save/Load/Export" experience is consistent with the Localization mode. diff --git a/annotation_tool/ui/classification/event_editor/README.md b/annotation_tool/ui/classification/event_editor/README.md new file mode 100644 index 0000000..ee842ce --- /dev/null +++ b/annotation_tool/ui/classification/event_editor/README.md @@ -0,0 +1,100 @@ +# Classification Event Editor + +**Path:** `ui/classification/widgets/event_editor/` + +## Overview + +The **Event Editor** package is responsible for the **Right Panel** of the Classification Mode. It serves as the primary interface for manual data annotation. unlike the Localization mode which deals with timestamps, this editor focuses on assigning global attributes (labels) to entire video clips or actions. + +It features a **schema-driven UI**, meaning the widgets (Radio Buttons or Checkboxes) are dynamically rendered based on the loaded project JSON configuration. + +## Key Features + +* **Dynamic Rendering:** Automatically generates UI groups based on the label definition (Schema). +* **Schema Management:** Allows users to add new Categories (Heads) and Labels dynamically during runtime. +* **Annotation Input:** + * **Single-choice:** Uses Radio Button groups. + * **Multi-choice:** Uses Checkbox groups. +* **Task Information:** Displays the current task name. +* **History Control:** Hosts the Undo/Redo buttons for the classification workflow. + +## File Structure + +```text +event_editor/ +├── __init__.py # Package entry point; exports the main classes. +├── editor.py # Defines the main container widget (ClassificationEventEditor). +└── dynamic_widgets.py # Defines the atomic UI components (Label Groups). + +``` + +## Module Descriptions + +### 1. `editor.py` + +Contains the `ClassificationEventEditor` class (formerly `ClassRightPanel`). + +**Responsibilities:** + +* Layout management (Vertical layout). +* **Top Section:** Undo/Redo controls. +* **Info Section:** Task name display. +* **Schema Editor:** Input field and button to add new Label Heads. +* **Scroll Area:** Holds the dynamic list of label groups. +* **Bottom Section:** "Save Annotation" and "Clear Selection" buttons. + +**Key Signals:** + +* `add_head_clicked(str)`: Emitted when the user wants to add a new category. +* `remove_head_clicked(str)`: Emitted when a category is deleted. + +### 2. `dynamic_widgets.py` + +Contains the reusable widgets that represent a single Label Head (Category). + +* **`DynamicSingleLabelGroup`**: +* Used when the schema type is `single_label`. +* Renders a `QButtonGroup` with `QRadioButton`s. +* Allows adding/removing individual labels within the group. + + +* **`DynamicMultiLabelGroup`**: +* Used when the schema type is `multi_label`. +* Renders a list of `QCheckBox`es. +* Allows multiple selections simultaneously. + + + +### 3. `__init__.py` + +Exposes the classes to the rest of the application. + +```python +from .editor import ClassificationEventEditor +from .dynamic_widgets import DynamicSingleLabelGroup, DynamicMultiLabelGroup + +``` + +## Usage Example + +This module is typically instantiated by the `UnifiedTaskPanel` in the Main Window. + +```python +# In ui/common/main_window.py +from ui.classification.widgets.event_editor import ClassificationEventEditor + +self.right_panel = ClassificationEventEditor() + +# To populate the UI based on JSON schema: +self.right_panel.setup_dynamic_labels(label_definitions_dict) + +# To get the user's current selection: +user_data = self.right_panel.get_annotation() + +``` + +## Dependencies + +* **PyQt6**: Core UI framework. +* **utils**: Uses `get_square_remove_btn_style` for the delete buttons. + diff --git a/annotation_tool/ui/classification/event_editor/__init__.py b/annotation_tool/ui/classification/event_editor/__init__.py new file mode 100644 index 0000000..51455f6 --- /dev/null +++ b/annotation_tool/ui/classification/event_editor/__init__.py @@ -0,0 +1,4 @@ +from .editor import ClassificationEventEditor +from .dynamic_widgets import DynamicSingleLabelGroup, DynamicMultiLabelGroup + +ClassRightPanel = ClassificationEventEditor \ No newline at end of file diff --git a/annotation_tool/ui/classification/event_editor/dynamic_widgets.py b/annotation_tool/ui/classification/event_editor/dynamic_widgets.py new file mode 100644 index 0000000..32a8561 --- /dev/null +++ b/annotation_tool/ui/classification/event_editor/dynamic_widgets.py @@ -0,0 +1,188 @@ +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, + QLineEdit, QButtonGroup, QRadioButton, QCheckBox +) +from PyQt6.QtCore import pyqtSignal, Qt + +class DynamicSingleLabelGroup(QWidget): + value_changed = pyqtSignal(str, str) # head, selected_label + remove_category_signal = pyqtSignal(str) # head + remove_label_signal = pyqtSignal(str, str) # label, head + + def __init__(self, head_name, definition, parent=None): + super().__init__(parent) + self.head_name = head_name + self.definition = definition + + self.layout = QVBoxLayout(self) + self.layout.setContentsMargins(0, 5, 0, 15) + + # Header + header_layout = QHBoxLayout() + self.lbl_head = QLabel(head_name) + self.lbl_head.setProperty("class", "group_head_lbl group_head_single") + + self.btn_del_cat = QPushButton("×") + self.btn_del_cat.setCursor(Qt.CursorShape.PointingHandCursor) + self.btn_del_cat.setProperty("class", "icon_remove_btn") + self.btn_del_cat.clicked.connect(lambda: self.remove_category_signal.emit(self.head_name)) + + header_layout.addWidget(self.lbl_head) + header_layout.addStretch() + header_layout.addWidget(self.btn_del_cat) + self.layout.addLayout(header_layout) + + # Radio Group + self.radio_group = QButtonGroup(self) + 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.layout.addWidget(self.radio_container) + + # Input for new label + input_layout = QHBoxLayout() + self.input_field = QLineEdit() + self.input_field.setPlaceholderText(f"Add option to {head_name}...") + self.add_btn = QPushButton("+") + self.add_btn.setFixedSize(30, 30) + self.add_btn.setCursor(Qt.CursorShape.PointingHandCursor) + + input_layout.addWidget(self.input_field) + input_layout.addWidget(self.add_btn) + self.layout.addLayout(input_layout) + + # Initial Population + self.update_radios(definition.get('labels', [])) + self.radio_group.buttonClicked.connect(self._on_radio_clicked) + + def update_radios(self, labels): + for btn in self.radio_group.buttons(): + self.radio_group.removeButton(btn) + btn.deleteLater() + + while self.radio_layout.count(): + item = self.radio_layout.takeAt(0) + if item.widget(): item.widget().deleteLater() + + for i, lbl_text in enumerate(labels): + row_widget = QWidget() + row_layout = QHBoxLayout(row_widget) + row_layout.setContentsMargins(0, 2, 0, 2) + + rb = QRadioButton(lbl_text) + self.radio_group.addButton(rb, i) + + del_label_btn = QPushButton("×") + del_label_btn.setCursor(Qt.CursorShape.PointingHandCursor) + del_label_btn.setProperty("class", "icon_remove_btn") + del_label_btn.clicked.connect(lambda _, l=lbl_text: self.remove_label_signal.emit(l, self.head_name)) + + row_layout.addWidget(rb) + row_layout.addStretch() + row_layout.addWidget(del_label_btn) + self.radio_layout.addWidget(row_widget) + + def _on_radio_clicked(self, btn): + self.value_changed.emit(self.head_name, btn.text()) + + def get_checked_label(self): + btn = self.radio_group.checkedButton() + return btn.text() if btn else None + + def set_checked_label(self, label_text): + if not label_text: + btn = self.radio_group.checkedButton() + if btn: + self.radio_group.setExclusive(False) + btn.setChecked(False) + self.radio_group.setExclusive(True) + return + for btn in self.radio_group.buttons(): + if btn.text() == label_text: + btn.setChecked(True); break + +class DynamicMultiLabelGroup(QWidget): + value_changed = pyqtSignal(str, list) + remove_category_signal = pyqtSignal(str) + remove_label_signal = pyqtSignal(str, str) + + def __init__(self, head_name, definition, parent=None): + super().__init__(parent) + self.head_name = head_name + self.definition = definition + + self.layout = QVBoxLayout(self) + self.layout.setContentsMargins(0, 5, 0, 15) + + # Header + header_layout = QHBoxLayout() + self.lbl_head = QLabel(head_name + " (Multi)") + self.lbl_head.setProperty("class", "group_head_lbl group_head_multi") + + self.btn_del_cat = QPushButton("×") + self.btn_del_cat.setProperty("class", "icon_remove_btn") + self.btn_del_cat.clicked.connect(lambda: self.remove_category_signal.emit(self.head_name)) + + header_layout.addWidget(self.lbl_head) + header_layout.addStretch() + header_layout.addWidget(self.btn_del_cat) + self.layout.addLayout(header_layout) + + self.checkbox_container = QWidget() + self.checkbox_layout = QVBoxLayout(self.checkbox_container) + self.checkbox_layout.setContentsMargins(10, 0, 0, 0) + self.layout.addWidget(self.checkbox_container) + + # Input + input_layout = QHBoxLayout() + self.input_field = QLineEdit() + self.input_field.setPlaceholderText(f"Add option...") + self.add_btn = QPushButton("+") + self.add_btn.setFixedSize(30, 30) + + input_layout.addWidget(self.input_field) + input_layout.addWidget(self.add_btn) + self.layout.addLayout(input_layout) + + self.checkboxes = {} + self.update_checkboxes(definition.get('labels', [])) + + def update_checkboxes(self, new_types): + while self.checkbox_layout.count(): + item = self.checkbox_layout.takeAt(0) + if item.widget(): item.widget().deleteLater() + + self.checkboxes.clear() + + for type_name in sorted(list(set(new_types))): + row_widget = QWidget() + row_layout = QHBoxLayout(row_widget) + row_layout.setContentsMargins(0, 2, 0, 2) + + cb = QCheckBox(type_name) + cb.clicked.connect(self._on_box_clicked) + self.checkboxes[type_name] = cb + + del_label_btn = QPushButton("×") + del_label_btn.setCursor(Qt.CursorShape.PointingHandCursor) + del_label_btn.setProperty("class", "icon_remove_btn") + del_label_btn.clicked.connect(lambda _, n=type_name: self.remove_label_signal.emit(n, self.head_name)) + + row_layout.addWidget(cb) + row_layout.addStretch() + row_layout.addWidget(del_label_btn) + self.checkbox_layout.addWidget(row_widget) + + def _on_box_clicked(self): + self.value_changed.emit(self.head_name, self.get_checked_labels()) + + def get_checked_labels(self): + return [cb.text() for cb in self.checkboxes.values() if cb.isChecked()] + + def set_checked_labels(self, label_list): + self.blockSignals(True) + if not label_list: label_list = [] + for text, cb in self.checkboxes.items(): + cb.setChecked(text in label_list) + self.blockSignals(False) diff --git a/annotation_tool/ui/classification/event_editor/editor.py b/annotation_tool/ui/classification/event_editor/editor.py new file mode 100644 index 0000000..02c3c5d --- /dev/null +++ b/annotation_tool/ui/classification/event_editor/editor.py @@ -0,0 +1,128 @@ +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, + QGroupBox, QLineEdit, QScrollArea, QFrame +) +from PyQt6.QtCore import Qt, pyqtSignal + +from .dynamic_widgets import DynamicSingleLabelGroup, DynamicMultiLabelGroup + +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) + + def __init__(self, parent=None): + super().__init__(parent) + self.setFixedWidth(350) + layout = QVBoxLayout(self) + + # 1. Undo/Redo Controls + h_undo = QHBoxLayout() + self.undo_btn = QPushButton("Undo") + self.redo_btn = QPushButton("Redo") + for btn in [self.undo_btn, self.redo_btn]: + btn.setCursor(Qt.CursorShape.PointingHandCursor) + btn.setEnabled(False) + btn.setProperty("class", "editor_control_btn") + h_undo.addWidget(self.undo_btn) + h_undo.addWidget(self.redo_btn) + layout.addLayout(h_undo) + + # 2. Task Information + self.task_label = QLabel("Task: N/A") + self.task_label.setProperty("class", "editor_task_lbl") + layout.addWidget(self.task_label) + + # 3. Schema Editor + schema_box = QGroupBox("Category Editor") + schema_layout = QHBoxLayout(schema_box) + self.new_head_edit = QLineEdit() + self.new_head_edit.setPlaceholderText("New Category Name...") + self.add_head_btn = QPushButton("Add Head") + self.add_head_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self.add_head_btn.clicked.connect(lambda: self.add_head_clicked.emit(self.new_head_edit.text())) + schema_layout.addWidget(self.new_head_edit) + schema_layout.addWidget(self.add_head_btn) + layout.addWidget(schema_box) + + # 4. Dynamic Annotation Area + self.manual_box = QGroupBox("Annotations") + self.manual_box.setEnabled(False) + manual_layout = QVBoxLayout(self.manual_box) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.NoFrame) + + self.label_container = QWidget() + self.label_container_layout = QVBoxLayout(self.label_container) + self.label_container_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + scroll.setWidget(self.label_container) + manual_layout.addWidget(scroll) + + btn_row = QHBoxLayout() + self.confirm_btn = QPushButton("Save 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) + + btn_row.addWidget(self.confirm_btn) + btn_row.addWidget(self.clear_sel_btn) + manual_layout.addLayout(btn_row) + + layout.addWidget(self.manual_box, 1) + + self.label_groups = {} + + def setup_dynamic_labels(self, label_definitions): + while self.label_container_layout.count(): + item = self.label_container_layout.takeAt(0) + if item.widget(): item.widget().deleteLater() + self.label_groups = {} + + for head, defn in label_definitions.items(): + l_type = defn.get('type', 'single_label') + if l_type == 'single_label': + group = DynamicSingleLabelGroup(head, defn) + else: + group = DynamicMultiLabelGroup(head, defn) + + group.remove_category_signal.connect(self.remove_head_clicked.emit) + self.label_container_layout.addWidget(group) + self.label_groups[head] = group + + self.label_container_layout.addStretch() + + def set_annotation(self, data): + if not data: data = {} + for head, group in self.label_groups.items(): + val = data.get(head) + if hasattr(group, 'set_checked_label'): + group.set_checked_label(val) + elif hasattr(group, 'set_checked_labels'): + group.set_checked_labels(val) + + def get_annotation(self): + result = {} + for head, group in self.label_groups.items(): + if hasattr(group, 'get_checked_label'): + val = group.get_checked_label() + if val: result[head] = val + elif hasattr(group, 'get_checked_labels'): + vals = group.get_checked_labels() + if vals: result[head] = vals + return result + + def clear_selection(self): + 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 diff --git a/annotation_tool/ui/classification/media_player/README.md b/annotation_tool/ui/classification/media_player/README.md new file mode 100644 index 0000000..bbe5bb9 --- /dev/null +++ b/annotation_tool/ui/classification/media_player/README.md @@ -0,0 +1,119 @@ +# Classification Media Player Widget + +## Overview + +This package (`ui/classification/media_player`) contains the UI components responsible for video playback and navigation within the **Classification** workflow of the SoccerNet Pro Analysis Tool. + +It is designed to be the **Center Panel** of the classification interface. It separates the video rendering logic from the navigation control logic, assembling them into a unified widget. + +## Directory Structure + +```text +media_player/ +├── __init__.py # Assembles components and exports 'ClassificationMediaPlayer' +├── preview.py # Handles video rendering, seeking, and the clickable slider +└── controls.py # Contains the bottom navigation toolbar (buttons) + +``` + +--- + +## Components + +### 1. `ClassificationMediaPlayer` (in `__init__.py`) + +**Role:** The main container widget exposed to the rest of the application. + +* **Layout:** Vertical (`QVBoxLayout`). +* **Structure:** +1. **Top:** A `QStackedLayout` containing the `MediaPreview` (and a placeholder for future Multi-View grid). +2. **Bottom:** The `NavigationToolbar`. + + +* **Public Methods:** +* `show_single_view(path: str)`: Loads a video file into the player and brings the single-view widget to the front. +* `toggle_play_pause()`: Toggles the playback state of the internal player. + + +* **Exposed Attributes:** +It exposes buttons from the toolbar directly so external Controllers (like `NavigationManager`) can connect signals easily: +* `self.prev_action` +* `self.prev_clip` +* `self.play_btn` +* `self.next_clip` +* `self.next_action` +* `self.multi_view_btn` + + + +### 2. `MediaPreview` (in `preview.py`) + +**Role:** Handles the actual media playback logic using `PyQt6.QtMultimedia`. + +* **Key Features:** +* **QAudioOutput Integration:** Explicitly sets up audio output to prevent video rendering issues (black screens) on certain platforms. +* **Custom Slider:** Uses `ClickableSlider` to allow users to jump to a specific timeframe by clicking anywhere on the bar (instead of just stepping). +* **Time Display:** Shows current position vs. total duration (e.g., `00:05 / 00:15`). +* **Auto-Loop:** Playback is set to infinite loop by default. + + + +### 3. `NavigationToolbar` (in `controls.py`) + +**Role:** A simple widget container for the playback control buttons. + +* **Buttons:** +* `<< Prev Action`: Jump to the previous top-level action in the tree. +* `< Prev Clip`: Jump to the previous video file (if multiple views exist). +* `Play / Pause`: Toggle playback. +* `Next Clip >`: Jump to the next video file. +* `Next Action >>`: Jump to the next top-level action. +* `Multi-View`: (Toggle) Intended to switch between Single View and Grid View. + + + +--- + +## Usage Example + +Typically, this widget is instantiated in `ui/common/main_window.py` and passed to the `UnifiedTaskPanel`. + +**Instantiation:** + +```python +from ui.classification.widgets.media_player import ClassificationMediaPlayer + +# Create the player widget +player_widget = ClassificationMediaPlayer() + +``` + +**Connecting Signals (in `viewer.py` or Controllers):** +The app logic connects directly to the exposed buttons: + +```python +# In viewer.py -> connect_signals() + +# Connect Play/Pause button +player_widget.play_btn.clicked.connect(self.nav_manager.play_video) + +# Connect Navigation buttons +player_widget.next_action.clicked.connect(self.nav_manager.nav_next_action) + +``` + +**Loading a Video:** + +```python +# In NavigationManager +path = "/path/to/video.mp4" +player_widget.show_single_view(path) + +``` + +## Dependencies + +* **PyQt6.QtWidgets**: `QWidget`, `QVBoxLayout`, `QPushButton`, `QSlider`, etc. +* **PyQt6.QtMultimedia**: `QMediaPlayer`, `QAudioOutput`. +* **PyQt6.QtMultimediaWidgets**: `QVideoWidget`. + diff --git a/annotation_tool/ui/classification/media_player/__init__.py b/annotation_tool/ui/classification/media_player/__init__.py new file mode 100644 index 0000000..5b1340f --- /dev/null +++ b/annotation_tool/ui/classification/media_player/__init__.py @@ -0,0 +1,63 @@ +from PyQt6.QtWidgets import QWidget, QVBoxLayout +from PyQt6.QtMultimedia import QMediaPlayer + +from .player_panel import PlayerPanel +from .controls import PlaybackControlBar + +class ClassificationMediaPlayer(QWidget): + """ + Main container for Classification Mode Media Player. + Combines PlayerPanel (Video Surface + Timeline/Slider) and PlaybackControlBar. + """ + def __init__(self, parent=None): + super().__init__(parent) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + # 1. Instantiate the Panel (Video + Slider) + self.single_view_widget = PlayerPanel() + layout.addWidget(self.single_view_widget, 1) # Stretch factor 1 + + # 2. Instantiate Controls + self.controls = PlaybackControlBar() + layout.addWidget(self.controls) + + # 3. Expose components for external Controllers + # Player reference + self.player = self.single_view_widget.player + + # Button references + self.play_btn = self.controls.play_btn + self.prev_action = self.controls.prev_action + self.prev_clip = self.controls.prev_clip + self.next_clip = self.controls.next_clip + self.next_action = self.controls.next_action + + # ========================================================= + + def show_single_view(self, path): + """ + Loads a single video into the player. + Called by ClassFileManager and NavigationManager. + """ + self.single_view_widget.load_video(path) + + def toggle_play_pause(self): + """ + Toggles playback state. + Called by NavigationManager. + """ + if self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState: + self.player.pause() + else: + self.player.play() + + def show_all_views(self, paths): + """ + Stub for Multi-View (Grid) support. + If you haven't implemented grid view yet, just load the first video to avoid crash. + """ + if paths: + # Fallback: Just show the first video in single view for now + self.show_single_view(paths[0]) diff --git a/annotation_tool/ui/classification/media_player/controls.py b/annotation_tool/ui/classification/media_player/controls.py new file mode 100644 index 0000000..4cb156d --- /dev/null +++ b/annotation_tool/ui/classification/media_player/controls.py @@ -0,0 +1,28 @@ +from PyQt6.QtWidgets import ( + QWidget, QHBoxLayout, QPushButton +) +from PyQt6.QtCore import Qt + +class PlaybackControlBar(QWidget): + """ + Navigation buttons for Classification Mode. + """ + def __init__(self, parent=None): + super().__init__(parent) + self.layout = QHBoxLayout(self) + self.layout.setContentsMargins(5, 5, 5, 5) + + self.prev_action = QPushButton("<< Prev Action") + self.prev_clip = QPushButton("< Prev Clip") + self.play_btn = QPushButton("Play / Pause") + self.next_clip = QPushButton("Next Clip >") + self.next_action = QPushButton("Next Action >>") + + btns = [ + self.prev_action, self.prev_clip, self.play_btn, + self.next_clip, self.next_action + ] + + for b in btns: + b.setCursor(Qt.CursorShape.PointingHandCursor) + self.layout.addWidget(b) diff --git a/annotation_tool/ui/classification/media_player/player_panel.py b/annotation_tool/ui/classification/media_player/player_panel.py new file mode 100644 index 0000000..1201243 --- /dev/null +++ b/annotation_tool/ui/classification/media_player/player_panel.py @@ -0,0 +1,121 @@ +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QSlider, QLabel, + QStyleOptionSlider, QStyle +) +from PyQt6.QtCore import Qt + +# [CHANGED] Import from common +from ui.common.video_surface import VideoSurface + +class ClickableSlider(QSlider): + """A custom QSlider that jumps to the click position immediately.""" + def mousePressEvent(self, event): + super().mousePressEvent(event) + if event.button() == Qt.MouseButton.LeftButton: + val = self._pixel_pos_to_value(event.pos()) + self.setValue(val) + self.sliderMoved.emit(val) + + def _pixel_pos_to_value(self, pos): + opt = QStyleOptionSlider() + self.initStyleOption(opt) + gr = self.style().subControlRect(QStyle.ComplexControl.CC_Slider, opt, QStyle.SubControl.SC_SliderGroove, self) + sr = self.style().subControlRect(QStyle.ComplexControl.CC_Slider, opt, QStyle.SubControl.SC_SliderHandle, self) + + if self.orientation() == Qt.Orientation.Horizontal: + slider_length = sr.width() + slider_min = gr.x() + slider_max = gr.right() - slider_length + 1 + pos_x = pos.x() + else: + slider_length = sr.height() + slider_min = gr.y() + slider_max = gr.bottom() - slider_length + 1 + pos_x = pos.y() + + return QStyle.sliderValueFromPosition(self.minimum(), self.maximum(), pos_x - slider_min, + slider_max - slider_min, opt.upsideDown) + +class PlayerPanel(QWidget): + """ + Container Widget for Classification. + Combines the Shared VideoSurface (Top) and Control Slider/Label (Bottom). + """ + def __init__(self, parent=None): + super().__init__(parent) + + # 1. Main Layout + self.v_layout = QVBoxLayout(self) + self.v_layout.setContentsMargins(0, 0, 0, 0) + self.v_layout.setSpacing(5) + + # 2. Instantiate the Shared Video Surface + self.video_surface = VideoSurface() + self.v_layout.addWidget(self.video_surface, 1) + + # Expose player for controller access + self.player = self.video_surface.player + + # 3. Controls (Slider & Label) + self.slider = ClickableSlider(Qt.Orientation.Horizontal) + self.slider.setRange(0, 1000) + self.slider.setEnabled(False) + self.slider.setCursor(Qt.CursorShape.PointingHandCursor) + + self.time_label = QLabel("00:00 / 00:00") + self.time_label.setFixedWidth(120) + self.time_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + self.time_label.setProperty("class", "player_time_lbl") + + h_controls = QHBoxLayout() + h_controls.setContentsMargins(5, 0, 5, 5) + h_controls.addWidget(self.slider) + h_controls.addWidget(self.time_label) + self.v_layout.addLayout(h_controls) + + # 4. Connections + self.player.positionChanged.connect(self._on_position_changed) + self.player.durationChanged.connect(self._on_duration_changed) + self.player.errorOccurred.connect(self._on_error) + + self.slider.sliderMoved.connect(self._on_slider_moved) + self.slider.sliderPressed.connect(self.player.pause) + self.slider.sliderReleased.connect(self.player.play) + + self._duration_ms = 0 + + def load_video(self, path): + """Delegates loading to the shared video surface.""" + self.video_surface.load_source(path) + # Classification usually auto-plays via logic in navigation_manager, + # but we can trigger play here if strictly desired, + # though standard pattern prefers Controller to handle playback state. + + def _on_duration_changed(self, duration): + self._duration_ms = duration + if duration > 0: + self.slider.setRange(0, duration) + self.slider.setEnabled(True) + self.slider.setValue(0) + self._update_time_label(0, duration) + else: + self.slider.setEnabled(False) + + def _on_position_changed(self, position): + if not self.slider.isSliderDown(): + self.slider.setValue(position) + self._update_time_label(position, self._duration_ms) + + def _on_slider_moved(self, position): + self.player.setPosition(position) + self._update_time_label(position, self._duration_ms) + + def _update_time_label(self, current_ms, total_ms): + def fmt(ms): + s = (ms // 1000) % 60 + m = (ms // 60000) % 60 + return f"{m:02}:{s:02}" + self.time_label.setText(f"{fmt(current_ms)} / {fmt(total_ms)}") + + def _on_error(self): + print(f"Video Player Error: {self.player.errorString()}") \ No newline at end of file diff --git a/annotation_tool/ui/common/.DS_Store b/annotation_tool/ui/common/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/annotation_tool/ui/common/.DS_Store differ diff --git a/annotation_tool/ui/common/README.md b/annotation_tool/ui/common/README.md new file mode 100644 index 0000000..eac5d45 --- /dev/null +++ b/annotation_tool/ui/common/README.md @@ -0,0 +1,80 @@ +# 🛠️ Common UI Components + +This directory contains **shared interface widgets** that are used across all four modes of the application (**Classification, Localization, Description, and Dense Description**). + +The goal of this module is to adhere to the **DRY (Don't Repeat Yourself)** principle, ensuring a consistent layout and user experience regardless of the active task. + +## 📂 Files & Components + +### 1. `workspace.py` + +* **Class:** `UnifiedTaskPanel` +* **Purpose:** The fundamental **Skeleton Layout** used by every operating mode. +* **Structure:** A horizontal layout composed of three distinct sections: +1. **Left Panel:** Contains the `CommonProjectTreePanel` (Clip Explorer). +2. **Center Panel:** A stretchable area for the Media Player and Timelines. +3. **Right Panel:** A task-specific area for Event Editors, Text Inputs, or Class Selectors. + + +* **Usage:** This widget instantiates the Left Panel automatically, while the Center and Right widgets are injected via the constructor. + +### 2. `main_window.py` + +* **Class:** `MainWindowUI` +* **Purpose:** The top-level container that orchestrates the **Screen Stack**. +* **Architecture:** Uses a `QStackedLayout` to switch between views without destroying them. +* **View Indices:** +* **Index 0:** Welcome Screen (`WelcomeWidget`) +* **Index 1:** Classification Workspace +* **Index 2:** Localization Workspace +* **Index 3:** Description Workspace (Global Captioning) +* **Index 4:** **[NEW]** Dense Description Workspace (Timestamped Captioning) + + + +### 3. `clip_explorer.py` + +* **Class:** `CommonProjectTreePanel` +* **Purpose:** The standardized **Left Sidebar** for file navigation. +* **Key Features:** +* **Architecture:** Refactored to use **Qt Model/View** (`QTreeView`) instead of `QTreeWidget` for better performance and separation of data. +* **Integrated Controls:** Embeds `UnifiedProjectControls` at the top. +* **Filtering:** Provides a "Show All / Labelled / Unlabelled" filter combo box. +* **Context Menu:** Supports right-click actions (e.g., "Remove Item"). + + + +### 4. `video_surface.py` + +* **Class:** `VideoSurface` +* **Purpose:** A lightweight, logic-free wrapper for video rendering. +* **Components:** Encapsulates `QMediaPlayer`, `QAudioOutput` (with volume preset to 100%), and `QVideoWidget`. +* **Role:** Acts strictly as the **View** layer for media. Playback logic (Play/Pause/Seek) is handled externally by the `MediaController` to prevent audio/visual desync. + +### 5. `project_controls.py` + +* **Class:** `UnifiedProjectControls` +* **Purpose:** A standardized 3x2 **Project Management Grid**. +* **Layout:** +* **Row 1:** `New Project`, `Load Project` +* **Row 2:** `Add Data`, `Close Project` +* **Row 3:** `Save JSON`, `Export JSON` + + +* **Signals:** Emits signals (`createRequested`, `saveRequested`, etc.) to be caught by the main Controller, keeping this widget purely presentational. + +### 6. `dialogs.py` + +* **Classes:** +* `ProjectTypeDialog`: The "New Project" wizard. Now updated to support **4 Modes**: Classification, Localization, Description, and **Dense Description**. +* `FolderPickerDialog`: A custom file dialog allowing **Multi-Folder Selection** via a `QTreeView` with checkboxes/click-toggle. + + + +### 7. `welcome_widget.py` + +* **Class:** `WelcomeWidget` +* **Purpose:** The landing screen displayed on application startup. +* **Actions:** Provides large, accessible entry points to "Create New Project" or "Import Project JSON". + +--- diff --git a/annotation_tool/ui/common/clip_explorer.py b/annotation_tool/ui/common/clip_explorer.py new file mode 100644 index 0000000..e57db6d --- /dev/null +++ b/annotation_tool/ui/common/clip_explorer.py @@ -0,0 +1,91 @@ +import os +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QTreeView, # CHANGED: QTreeView instead of QTreeWidget + QLabel, QComboBox, QPushButton, QMenu, QAbstractItemView +) +from PyQt6.QtCore import Qt, pyqtSignal, QModelIndex + +# Import the shared controls +from ui.common.project_controls import UnifiedProjectControls + +class CommonProjectTreePanel(QWidget): + """ + A unified Left Panel for both Classification and Localization. + Refactored to follow Model/View architecture using QTreeView. + """ + + # Signal emitted when "Remove Item" is clicked in context menu + # Emits the QModelIndex of the item to be removed + request_remove_item = pyqtSignal(QModelIndex) + + def __init__(self, + tree_title="Project Items", + filter_items=None, + clear_text="Clear All", + enable_context_menu=True, + parent=None): + super().__init__(parent) + self.setFixedWidth(300) + + # Main Layout + layout = QVBoxLayout(self) + layout.setContentsMargins(5, 5, 5, 5) + layout.setSpacing(5) + + # 1. Project Controls + self.project_controls = UnifiedProjectControls() + + # Expose control buttons for external controller connections + self.import_btn = self.project_controls.btn_load + self.create_btn = self.project_controls.btn_create + self.add_data_btn = self.project_controls.btn_add + + layout.addWidget(self.project_controls) + + # 2. Tree Title + self.lbl_title = QLabel(tree_title) + self.lbl_title.setProperty("class", "panel_header_lbl") + layout.addWidget(self.lbl_title) + + # 3. The Tree View (MV Architecture) + self.tree = QTreeView() + self.tree.setHeaderHidden(True) + self.tree.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.tree.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + layout.addWidget(self.tree) + + # Context Menu Logic + if enable_context_menu: + self.tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.tree.customContextMenuRequested.connect(self._show_context_menu) + + # 4. Filter & Clear Row + bottom_layout = QHBoxLayout() + bottom_layout.setContentsMargins(0, 5, 0, 0) + + self.lbl_filter = QLabel("Filter:") + self.filter_combo = QComboBox() + if filter_items: + self.filter_combo.addItems(filter_items) + + self.clear_btn = QPushButton(clear_text) + self.clear_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self.clear_btn.setObjectName("panel_clear_btn") + + bottom_layout.addWidget(self.lbl_filter) + bottom_layout.addWidget(self.filter_combo, 1) # Stretch + bottom_layout.addWidget(self.clear_btn) + + layout.addLayout(bottom_layout) + + def _show_context_menu(self, pos): + """ + Handles the context menu request. Maps position to Model Index. + """ + index = self.tree.indexAt(pos) + if index.isValid(): + menu = QMenu() + remove_action = menu.addAction("Remove Item") + action = menu.exec(self.tree.mapToGlobal(pos)) + if action == remove_action: + self.request_remove_item.emit(index) \ No newline at end of file diff --git a/annotation_tool/ui/common/dialogs.py b/annotation_tool/ui/common/dialogs.py new file mode 100644 index 0000000..dbe70f6 --- /dev/null +++ b/annotation_tool/ui/common/dialogs.py @@ -0,0 +1,123 @@ +import os +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QRadioButton, QTreeView, QDialogButtonBox, + QAbstractItemView, QGroupBox, QFormLayout, QLineEdit, QHBoxLayout, + QCheckBox, QFrame, QListWidget, QComboBox, QPushButton, QLabel, + QMessageBox, QWidget, QListWidgetItem, QStyle, QButtonGroup, QScrollArea +) +from PyQt6.QtCore import QDir, Qt, QSize +from PyQt6.QtGui import QFileSystemModel, QIcon +from utils import get_square_remove_btn_style + +class ProjectTypeDialog(QDialog): + """ + Project type chooser. + Shown after clicking 'New Project' to select the operating mode. + Updated to include Classification, Localization, and Description. + """ + + def __init__(self, parent=None) -> None: + super().__init__(parent) + + self.setWindowTitle("Select Project Type") + self.resize(600, 250) # Widen slightly to fit 3 buttons + self.selected_mode: str | None = None + + layout = QVBoxLayout(self) + layout.setSpacing(15) + layout.setContentsMargins(30, 30, 30, 30) + + lbl = QLabel("Please select the type of project you want to create:") + lbl.setProperty("class", "dialog_instruction_lbl") + layout.addWidget(lbl) + + # Three large buttons side-by-side + btn_layout = QHBoxLayout() + btn_layout.setSpacing(20) + + # 1. Classification Button + self.btn_cls = QPushButton("Classification") + self.btn_cls.setMinimumSize(QSize(0, 80)) + self.btn_cls.setProperty("class", "project_type_btn") # CSS class for styling + + # 2. Localization Button + self.btn_loc = QPushButton("Localization") + self.btn_loc.setMinimumSize(QSize(0, 80)) + self.btn_loc.setProperty("class", "project_type_btn") + + # 3. [NEW] Description Button + self.btn_desc = QPushButton("Description") + self.btn_desc.setMinimumSize(QSize(0, 80)) + self.btn_desc.setProperty("class", "project_type_btn") + + # 3. [NEW] Description Button + self.btn_dense = QPushButton("Dense Description") + self.btn_dense.setMinimumSize(QSize(0, 80)) + self.btn_dense.setProperty("class", "project_type_btn") + + # Add buttons to layout + btn_layout.addWidget(self.btn_cls) + btn_layout.addWidget(self.btn_loc) + btn_layout.addWidget(self.btn_desc) # [NEW] + btn_layout.addWidget(self.btn_dense) # [NEW] + + layout.addLayout(btn_layout) + + # Connect signals + # Lambda is used to pass the mode string to the handler + self.btn_cls.clicked.connect(lambda: self.finalize_selection("classification")) + self.btn_loc.clicked.connect(lambda: self.finalize_selection("localization")) + self.btn_desc.clicked.connect(lambda: self.finalize_selection("description")) + self.btn_dense.clicked.connect(lambda: self.finalize_selection("dense_description")) # [NEW] + + def finalize_selection(self, mode: str): + """Stores the selected mode and closes the dialog.""" + self.selected_mode = mode + self.accept() + + +class FolderPickerDialog(QDialog): + """ + Custom folder picker that allows multi-selection of folders. + Used for selecting scene folders when creating a project. + """ + + def __init__(self, initial_dir: str = "", parent=None) -> None: + super().__init__(parent) + + self.setWindowTitle("Select Scene Folders (Click to Toggle Multiple)") + self.resize(900, 600) + + layout = QVBoxLayout(self) + layout.addWidget(QRadioButton("Tip: Click multiple folders to select them. No need to hold Ctrl.")) + + self.model = QFileSystemModel() + self.model.setRootPath(QDir.rootPath()) + self.model.setFilter(QDir.Filter.AllDirs | QDir.Filter.NoDotAndDotDot) + + self.tree = QTreeView() + self.tree.setModel(self.model) + self.tree.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection) + + # Optimize column view (Hide size/type/date, only show name) + self.tree.setColumnWidth(0, 400) + for i in range(1, 4): + self.tree.hideColumn(i) + + # Set initial directory + start_path = initial_dir if initial_dir and os.path.exists(initial_dir) else QDir.rootPath() + self.tree.setRootIndex(self.model.index(start_path)) + + layout.addWidget(self.tree) + + # Standard OK/Cancel buttons + bbox = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + bbox.accepted.connect(self.accept) + bbox.rejected.connect(self.reject) + layout.addWidget(bbox) + + 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 diff --git a/annotation_tool/ui/common/main_window.py b/annotation_tool/ui/common/main_window.py new file mode 100644 index 0000000..0fbad69 --- /dev/null +++ b/annotation_tool/ui/common/main_window.py @@ -0,0 +1,106 @@ +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QStackedLayout + +# 1. Import the generic skeleton +from ui.common.welcome_widget import WelcomeWidget +from ui.common.workspace import UnifiedTaskPanel + +# 2. Import Classification components +from ui.classification.media_player import ClassificationMediaPlayer +from ui.classification.event_editor import ClassificationEventEditor + +# 3. Import Localization components +from ui.localization.media_player import LocCenterPanel +from ui.localization.event_editor import LocRightPanel + +# 4. [NEW] Import Description components +from ui.description.media_player import DescriptionMediaPlayer +from ui.description.event_editor import DescriptionEventEditor + +from ui.dense_description.event_editor import DenseRightPanel + +class MainWindowUI(QWidget): + """ + The main container that switches between Welcome, Classification, Localization, + and the new Description views. + + It uses a QStackedLayout to manage the different modes. + """ + def __init__(self, parent=None): + super().__init__(parent) + + self.main_layout = QVBoxLayout(self) + self.main_layout.setContentsMargins(0, 0, 0, 0) + + self.stack_layout = QStackedLayout() + + # --- View 0: Welcome Screen --- + self.welcome_widget = WelcomeWidget() + + # --- View 1: Classification Workspace --- + self.classification_ui = UnifiedTaskPanel( + center_widget=ClassificationMediaPlayer(), + right_widget=ClassificationEventEditor(), + tree_title="Clips / Sequences", + filter_items=["Show All", "Show Labelled", "No Labelled"], + clear_text="Clear All" + ) + + # --- View 2: Localization Workspace --- + self.localization_ui = UnifiedTaskPanel( + center_widget=LocCenterPanel(), + right_widget=LocRightPanel(), + tree_title="Clips / Sequences", + filter_items=["Show All", "Show Labelled", "No Labelled"], + clear_text="Clear All" + ) + + # --- View 3: [NEW] Description Workspace --- + # This panel uses the shared tree structure but loads the Description-specific + # media player and event editor. + self.description_ui = UnifiedTaskPanel( + center_widget=DescriptionMediaPlayer(), + right_widget=DescriptionEventEditor(), + tree_title="Action / Inputs", # Adapted title for Description structure + filter_items=["Show All", "Show Completed", "Show Incomplete"], + clear_text="Clear All" + ) + + # --- View 4: Dense Description Workspace --- + self.dense_description_ui = UnifiedTaskPanel( + center_widget=LocCenterPanel(), # Reuse Localization's player + timeline + right_widget=DenseRightPanel(), # Our new custom panel + tree_title="Videos", + filter_items=["Show All", "Show Annotated", "Not Annotated"], + clear_text="Clear All" + ) + + # Add all views to the Stack + self.stack_layout.addWidget(self.welcome_widget) # Index 0 + self.stack_layout.addWidget(self.classification_ui) # Index 1 + self.stack_layout.addWidget(self.localization_ui) # Index 2 + self.stack_layout.addWidget(self.description_ui) # Index 3 + self.stack_layout.addWidget(self.dense_description_ui) # Index 4 [NEW] + + self.main_layout.addLayout(self.stack_layout) + + # Start at Welcome screen + self.show_welcome_view() + + def show_welcome_view(self): + """Switch to the Welcome Screen (Index 0).""" + self.stack_layout.setCurrentIndex(0) + + def show_classification_view(self): + """Switch to the Classification Workspace (Index 1).""" + self.stack_layout.setCurrentIndex(1) + + def show_localization_view(self): + """Switch to the Localization Workspace (Index 2).""" + self.stack_layout.setCurrentIndex(2) + + def show_description_view(self): + """[NEW] Switch to the Description Workspace (Index 3).""" + self.stack_layout.setCurrentIndex(3) + + def show_dense_description_view(self): + self.stack_layout.setCurrentIndex(4) \ No newline at end of file diff --git a/annotation_tool/ui/common/project_controls.py b/annotation_tool/ui/common/project_controls.py new file mode 100644 index 0000000..4ce7815 --- /dev/null +++ b/annotation_tool/ui/common/project_controls.py @@ -0,0 +1,88 @@ +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QGroupBox, QPushButton, QGridLayout +from PyQt6.QtCore import pyqtSignal, Qt + +class UnifiedProjectControls(QWidget): + """ + A standardized 3x2 grid control panel for project management. + Used by both Classification and Localization tasks to ensure UI consistency. + + Layout: + [ Create ] [ Load ] + [ Add ] [ Close ] + [ Save ] [ Export ] + """ + # Define signals for all 6 actions + createRequested = pyqtSignal() + loadRequested = pyqtSignal() + addVideoRequested = pyqtSignal() + closeRequested = pyqtSignal() + saveRequested = pyqtSignal() + exportRequested = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + group = QGroupBox("Project Controls") + + # Use GridLayout for 3x2 arrangement + grid_layout = QGridLayout(group) + grid_layout.setSpacing(10) + grid_layout.setContentsMargins(10, 20, 10, 10) + + # --- Row 1: Lifecycle --- + self.btn_create = QPushButton("New Project") + self.btn_load = QPushButton("Load Project") + + # --- Row 2: Content / Nav --- + self.btn_add = QPushButton("Add Data") + self.btn_close = QPushButton("Close Project") + + # --- Row 3: Persistence --- + self.btn_save = QPushButton("Save JSON") + self.btn_export = QPushButton("Export JSON") + + # Initial state: Save/Export disabled until project loaded + self.btn_save.setEnabled(False) + self.btn_export.setEnabled(False) + + # Apply Styling + btns = [ + self.btn_create, self.btn_load, + self.btn_add, self.btn_close, + self.btn_save, self.btn_export + ] + + for btn in btns: + btn.setMinimumHeight(35) + btn.setCursor(Qt.CursorShape.PointingHandCursor) + btn.setProperty("class", "project_control_btn") + + # Add to Grid (Widget, Row, Column) + grid_layout.addWidget(self.btn_create, 0, 0) + grid_layout.addWidget(self.btn_load, 0, 1) + + grid_layout.addWidget(self.btn_add, 1, 0) + grid_layout.addWidget(self.btn_close, 1, 1) + + grid_layout.addWidget(self.btn_save, 2, 0) + grid_layout.addWidget(self.btn_export, 2, 1) + + layout.addWidget(group) + + # Connect internal clicks to external signals + self.btn_create.clicked.connect(self.createRequested.emit) + self.btn_load.clicked.connect(self.loadRequested.emit) + self.btn_add.clicked.connect(self.addVideoRequested.emit) + self.btn_close.clicked.connect(self.closeRequested.emit) + self.btn_save.clicked.connect(self.saveRequested.emit) + self.btn_export.clicked.connect(self.exportRequested.emit) + + def set_project_loaded_state(self, loaded: bool): + """ + Updates button states based on whether a project is currently active. + """ + self.btn_save.setEnabled(loaded) + self.btn_export.setEnabled(loaded) + # Create/Load/Close are always enabled to allow switching or exiting \ No newline at end of file diff --git a/annotation_tool/ui/common/video_surface.py b/annotation_tool/ui/common/video_surface.py new file mode 100644 index 0000000..4c160c7 --- /dev/null +++ b/annotation_tool/ui/common/video_surface.py @@ -0,0 +1,49 @@ +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QSizePolicy +from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput +from PyQt6.QtMultimediaWidgets import QVideoWidget +from PyQt6.QtCore import QUrl + +class VideoSurface(QWidget): + """ + A shared, standalone widget dedicated to rendering video content. + It encapsulates the QMediaPlayer, QAudioOutput, and QVideoWidget. + + Used by Classification, Localization, and Description modes to ensure + consistent rendering behavior. + """ + def __init__(self, parent=None): + super().__init__(parent) + + # 1. Layout setup + self.layout = QVBoxLayout(self) + self.layout.setContentsMargins(0, 0, 0, 0) + self.layout.setSpacing(0) + + # 2. Player Components + self.player = QMediaPlayer() + + self.audio_output = QAudioOutput() + # [CRITICAL] Ensure volume is UP. This fixes the "no sound" issue in other modes. + self.audio_output.setVolume(1.0) + self.player.setAudioOutput(self.audio_output) + + self.video_widget = QVideoWidget() + self.video_widget.setProperty("class", "video_preview_widget") + self.video_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.player.setVideoOutput(self.video_widget) + + # 3. Add video widget to layout + self.layout.addWidget(self.video_widget) + + def load_source(self, path): + """ + Loads the video source. + Note: The playing logic (delay, auto-play) is handled by MediaController. + """ + # Reset source to clear buffer + self.player.stop() + self.player.setSource(QUrl()) + + if path: + url = QUrl.fromLocalFile(path) + self.player.setSource(url) \ No newline at end of file diff --git a/annotation_tool/ui/common/welcome_widget.py b/annotation_tool/ui/common/welcome_widget.py new file mode 100644 index 0000000..93eab20 --- /dev/null +++ b/annotation_tool/ui/common/welcome_widget.py @@ -0,0 +1,84 @@ +import os +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton +) +from PyQt6.QtCore import Qt, QUrl +from PyQt6.QtGui import QPixmap, QDesktopServices + +class WelcomeWidget(QWidget): + """ + The Welcome Screen Widget. + Provides entry points to Create or Import a project. + """ + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("welcome_page") + self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + + layout = QVBoxLayout(self) + layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.setSpacing(20) + + # --- 1. Title & Logo --- + title_layout = QHBoxLayout() + title_layout.setAlignment(Qt.AlignmentFlag.AlignHCenter) + title_layout.setSpacing(15) + + title = QLabel("SoccerNetPro Annotation Tool") + title.setObjectName("welcome_title_lbl") + + self.logo_lbl = QLabel() + + current_dir = os.path.dirname(os.path.abspath(__file__)) + root_dir = os.path.dirname(os.path.dirname(current_dir)) + logo_path = os.path.join(root_dir, "image", "logo.png") + + pixmap = QPixmap(logo_path) + if not pixmap.isNull(): + scaled_pixmap = pixmap.scaledToHeight(40, Qt.TransformationMode.SmoothTransformation) + self.logo_lbl.setPixmap(scaled_pixmap) + else: + self.logo_lbl.setText("(Logo missing)") + + title_layout.addWidget(title) + title_layout.addWidget(self.logo_lbl) + + layout.addLayout(title_layout) + + # --- 2. Primary Actions (Vertical) --- + self.create_btn = QPushButton("Create New Project") + self.create_btn.setFixedSize(200, 50) + self.create_btn.setProperty("class", "welcome_action_btn") + self.create_btn.setCursor(Qt.CursorShape.PointingHandCursor) + + self.import_btn = QPushButton("Import Project JSON") + self.import_btn.setFixedSize(200, 50) + self.import_btn.setProperty("class", "welcome_action_btn") + self.import_btn.setCursor(Qt.CursorShape.PointingHandCursor) + + layout.addWidget(self.create_btn, alignment=Qt.AlignmentFlag.AlignHCenter) + layout.addWidget(self.import_btn, alignment=Qt.AlignmentFlag.AlignHCenter) + + layout.addSpacing(15) + + # --- 3. Secondary Actions (Horizontal) --- + links_layout = QHBoxLayout() + links_layout.setAlignment(Qt.AlignmentFlag.AlignHCenter) + links_layout.setSpacing(20) + + self.tutorial_btn = QPushButton("📺 Video Tutorial") + 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.github_btn = QPushButton("🐙 GitHub Repo") + self.github_btn.setFixedSize(160, 40) + self.github_btn.setProperty("class", "welcome_secondary_btn") + self.github_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self.github_btn.clicked.connect(lambda: QDesktopServices.openUrl(QUrl("https://github.com/OpenSportsLab/soccernetpro-ui/tree/dev-jintao"))) + + links_layout.addWidget(self.tutorial_btn) + links_layout.addWidget(self.github_btn) + + layout.addLayout(links_layout) diff --git a/annotation_tool/ui/common/workspace.py b/annotation_tool/ui/common/workspace.py new file mode 100644 index 0000000..8e58fea --- /dev/null +++ b/annotation_tool/ui/common/workspace.py @@ -0,0 +1,59 @@ +from PyQt6.QtWidgets import QWidget, QHBoxLayout + +# Import the common tree panel +from ui.common.clip_explorer import CommonProjectTreePanel + +class UnifiedTaskPanel(QWidget): + """ + A generic 3-column workspace container used for both Classification and Localization. + + Structure: + [ Left: Project Tree ] -- [ Center: Player/Visualizer ] -- [ Right: Editor/Controls ] + + This unifies the layout logic so both modes look consistent. + """ + def __init__(self, + center_widget: QWidget, + right_widget: QWidget, + tree_title: str = "Clips / Sequences", + filter_items: list = None, + clear_text: str = "Clear All", + parent=None): + """ + Args: + center_widget: The widget to place in the center (expandable). + right_widget: The widget to place on the right (fixed width usually). + tree_title: Title for the left tree panel (e.g. 'Clips / Sequences'). + filter_items: Items for the filter dropdown. + clear_text: Text for the clear button. + """ + super().__init__(parent) + + # 1. Setup Layout + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(5) + + # 2. Instantiate Left Panel (Common) + # Default to Localization-style naming if not provided + if filter_items is None: + filter_items = ["Show All", "Show Labelled", "No Labelled"] + + self.left_panel = CommonProjectTreePanel( + tree_title=tree_title, + filter_items=filter_items, + clear_text=clear_text, + enable_context_menu=True + ) + + # 3. Assign Center and Right Panels + self.center_panel = center_widget + self.right_panel = right_widget + + # 4. Add to Layout + # Left panel is fixed width (handled inside CommonProjectTreePanel) + layout.addWidget(self.left_panel) + # Center panel gets stretch factor 1 (takes remaining space) + layout.addWidget(self.center_panel, 1) + # Right panel is fixed width (handled inside specific right widgets) + layout.addWidget(self.right_panel) \ No newline at end of file diff --git a/annotation_tool/ui/dense_description/README.md b/annotation_tool/ui/dense_description/README.md new file mode 100644 index 0000000..cae3968 --- /dev/null +++ b/annotation_tool/ui/dense_description/README.md @@ -0,0 +1,94 @@ +# 📝 UI: Dense Description Mode + +This directory contains the user interface components specifically designed for the **Dense Description** mode. + +In this mode, users annotate videos by providing **text descriptions at specific timestamps**. Unlike global description (one caption per video), this mode supports multiple, time-anchored events, making it a hybrid between Localization (timestamps) and Description (free text). + +## 📂 Directory Structure + +```text +ui/dense_description/ +└── event_editor/ + ├── __init__.py # The main Right Panel container + ├── desc_input_widget.py # The Input Area (Top half) + └── dense_table.py # The Event List (Bottom half) + +``` + +--- + +## 🧩 Components + +### 1. Right Panel Container (`event_editor/__init__.py`) + +* **Class:** `DenseRightPanel` +* **Location:** Placed in the **Right Panel** of the Unified Workspace. +* **Purpose:** Acts as the main controller for the Dense Description side panel, stacking the input widget above the event table. + +#### Key Features: + +* **Layout Strategy:** Uses a vertical layout with a fixed width of `400px`. +* **Undo/Redo:** Integrated directly into the header for quick access. +* **Component Assembly:** +1. **Header:** "Dense Annotation" label + Undo/Redo buttons. +2. **Input:** Instance of `DenseDescriptionInputWidget` (Top). +3. **Table:** Instance of `AnnotationTableWidget` with a swapped-in `DenseTableModel` (Bottom). + + +* **Signal Rewiring:** +* Explicitly replaces the default `AnnotationTableModel` with `DenseTableModel`. +* Reconnects `itemChanged` signals to ensure edits in the table (e.g., changing text or time) propagate correctly to the backend. +* Reconnects selection signals so clicking a row jumps the video player to that timestamp. + + +* **Responsive Column Sizing:** +* Implements a custom `resizeEvent` and `_apply_dense_column_ratio`. +* Enforces a **2 : 1 : 4** width ratio for **[Time : Lang : Description]** columns, ensuring the description text always gets the most space. + + + +--- + +### 2. Input Widget (`event_editor/desc_input_widget.py`) + +* **Class:** `DenseDescriptionInputWidget` +* **Purpose:** The primary interface for creating new annotations or editing existing ones. + +#### Key Features: + +* **Time Display:** Shows the current video timestamp (e.g., `Current Time: 00:12.450`) to give context for the annotation. +* **Text Editor:** A large `QTextEdit` for multi-line free-text entry. +* **Submission:** A "Confirm Description" button that emits the `descriptionSubmitted` signal with the text content. +* **Programmatic Access:** Includes `set_text()` to populate the field when a user selects an existing event from the table (for editing). + +--- + +### 3. Data Table (`event_editor/dense_table.py`) + +* **Class:** `DenseTableModel` (Inherits from `AnnotationTableModel`) +* **Purpose:** Adapts the standard localization table to handle textual descriptions instead of categorical labels. + +#### Key Modifications: + +* **Columns:** Redefined to **[Time, Lang, Description]**. +* **Time:** The timestamp in `mm:ss` format. +* **Lang:** The language code (default `en`). +* **Description:** The full text content. + + +* **Editability:** +* Overrides `flags()` to explicitly ensure `ItemIsEditable` is true for all cells. +* Implements `setData()` to handle updates: +* **Column 0:** Parses time strings back to milliseconds. +* **Column 2:** Updates the free-text description. + + +* **Data Binding:** Directly maps to the underlying dictionary keys: `position_ms`, `lang`, and `text`. + + +### 🔄 Interaction Flow + +1. **Create:** User pauses video -> Types text in `Input` -> Clicks "Confirm" -> New row added to `Table`. +2. **Edit Text:** User clicks row in `Table` -> `Input` is populated with text -> User edits & Confirms -> `Table` updates. +3. **Edit Time:** User double-clicks "Time" column in `Table` -> Types new time -> Row re-sorts automatically. +4. **Jump:** User clicks row in `Table` -> Video player jumps to that timestamp. diff --git a/annotation_tool/ui/dense_description/event_editor/README.md b/annotation_tool/ui/dense_description/event_editor/README.md new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/annotation_tool/ui/dense_description/event_editor/README.md @@ -0,0 +1 @@ + diff --git a/annotation_tool/ui/dense_description/event_editor/__init__.py b/annotation_tool/ui/dense_description/event_editor/__init__.py new file mode 100644 index 0000000..e392ad4 --- /dev/null +++ b/annotation_tool/ui/dense_description/event_editor/__init__.py @@ -0,0 +1,103 @@ +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, + QAbstractItemView, QHeaderView +) +from PyQt6.QtCore import Qt, QTimer + +from .desc_input_widget import DenseDescriptionInputWidget +from .dense_table import DenseTableModel, AnnotationTableWidget + + +class DenseRightPanel(QWidget): + """ + Right Panel for Dense Description Mode. + Contains Undo/Redo, Input Field, and Description Table. + """ + def __init__(self, parent=None): + super().__init__(parent) + + # This panel has a fixed width; column ratio will be computed within this width. + self.setFixedWidth(400) + + layout = QVBoxLayout(self) + + # 1. Header with Undo/Redo (Reusing Localization Style) + header_layout = QHBoxLayout() + self.undo_btn = QPushButton("Undo") + self.redo_btn = QPushButton("Redo") + + header_layout.addWidget(QLabel("Dense Annotation")) + header_layout.addStretch() + header_layout.addWidget(self.undo_btn) + header_layout.addWidget(self.redo_btn) + layout.addLayout(header_layout) + + # 2. Top: Input Widget + self.input_widget = DenseDescriptionInputWidget() + layout.addWidget(self.input_widget, 1) + + # 3. Bottom: Table Widget + self.table = AnnotationTableWidget() + + # [CRITICAL FIX] Swap Model and Reconnect Signals + # --------------------------------------------------------- + # 1. Create and set the specific Dense model + self.dense_model = DenseTableModel() + self.table.model = self.dense_model # Update python reference + self.table.table.setModel(self.dense_model) # Update QTableView + + # 2. Reconnect editing signal (Fix: "Cannot modify") + self.dense_model.itemChanged.connect(self.table.annotationModified.emit) + + # 3. Reconnect selection signal (Fix: "Timeline does not jump") + selection_model = self.table.table.selectionModel() + selection_model.selectionChanged.connect(self.table._on_selection_changed) + + # 4. Enforce edit triggers + self.table.table.setEditTriggers( + QAbstractItemView.EditTrigger.DoubleClicked | + QAbstractItemView.EditTrigger.EditKeyPressed | + QAbstractItemView.EditTrigger.AnyKeyPressed + ) + # --------------------------------------------------------- + + # ---- Column width ratio: 2 : 1 : 4 ---- + # We enforce fixed section sizes so the ratio is stable and not overridden by content. + header = self.table.table.horizontalHeader() + header.setSectionResizeMode(QHeaderView.ResizeMode.Fixed) + header.setStretchLastSection(False) + + # Apply ratio after the widget is laid out (viewport width becomes valid). + QTimer.singleShot(0, self._apply_dense_column_ratio) + + layout.addWidget(self.table, 2) + + def _apply_dense_column_ratio(self): + """ + Apply a fixed 2:1:4 width ratio for [Time, Lang, Description]. + This uses the table viewport width (actual drawable area). + """ + view = self.table.table # QTableView + w = view.viewport().width() + + if w <= 0: + return + + # Total parts: 2 + 1 + 4 = 7 + total_parts = 7 + unit = max(20, w // total_parts) # Add a lower bound to avoid too narrow columns + + c0 = unit * 2 # Time + c1 = unit * 1 # Lang + c2 = max(80, w - c0 - c1) # Description takes the remaining width (with min width) + + view.setColumnWidth(0, c0) + view.setColumnWidth(1, c1) + view.setColumnWidth(2, c2) + + def resizeEvent(self, event): + """ + Re-apply column ratio on resize to keep [Time, Lang, Description] as 2:1:4. + """ + super().resizeEvent(event) + self._apply_dense_column_ratio() diff --git a/annotation_tool/ui/dense_description/event_editor/dense_table.py b/annotation_tool/ui/dense_description/event_editor/dense_table.py new file mode 100644 index 0000000..b979e6a --- /dev/null +++ b/annotation_tool/ui/dense_description/event_editor/dense_table.py @@ -0,0 +1,60 @@ +from ui.localization.event_editor.annotation_table import AnnotationTableModel, AnnotationTableWidget +from PyQt6.QtCore import Qt + +class DenseTableModel(AnnotationTableModel): + """ + Modified Model for Dense Description. + Columns: [Time, Lang, Text] + """ + def __init__(self, annotations=None): + super().__init__(annotations) + self._headers = ["Time", "Lang", "Description"] + + def flags(self, index): + """ + [FIX] Explicitly ensure items are editable. + """ + if not index.isValid(): + return Qt.ItemFlag.NoItemFlags + + # Allow selection and editing for all valid cells + return (Qt.ItemFlag.ItemIsEnabled | + Qt.ItemFlag.ItemIsSelectable | + Qt.ItemFlag.ItemIsEditable) + + def data(self, index, role=Qt.ItemDataRole.DisplayRole): + if not index.isValid(): return None + row = index.row() + item = self._data[row] + col = index.column() + + if role in [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole]: + if col == 0: + return self._fmt_ms(item.get('position_ms', 0)) + elif col == 1: + return item.get('lang', 'en') + elif col == 2: + return item.get('text', '') + return super().data(index, role) + + def setData(self, index, value, role=Qt.ItemDataRole.EditRole): + if not index.isValid() or role != Qt.ItemDataRole.EditRole: return False + + row, col = index.row(), index.column() + old_item = self._data[row] + new_item = old_item.copy() + val_str = str(value).strip() + + # Handle columns + if col == 0: + try: new_item['position_ms'] = self._parse_time_str(val_str) + except ValueError: return False + elif col == 1: new_item['lang'] = val_str + elif col == 2: new_item['text'] = val_str + + # Only emit change if actual data differs + if new_item != old_item: + self._data[row] = new_item # Update internal data immediately for consistency + self.itemChanged.emit(old_item, new_item) + return True + return False \ No newline at end of file diff --git a/annotation_tool/ui/dense_description/event_editor/desc_input_widget.py b/annotation_tool/ui/dense_description/event_editor/desc_input_widget.py new file mode 100644 index 0000000..bc9209b --- /dev/null +++ b/annotation_tool/ui/dense_description/event_editor/desc_input_widget.py @@ -0,0 +1,53 @@ +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QTextEdit, QPushButton +from PyQt6.QtCore import Qt, pyqtSignal + + +class DenseDescriptionInputWidget(QWidget): + """ + Panel for entering free-text descriptions at specific timestamps. + """ + descriptionSubmitted = pyqtSignal(str) # Emits the text to be added + + def __init__(self, parent=None): + super().__init__(parent) + layout = QVBoxLayout(self) + layout.setContentsMargins(10, 10, 10, 10) + layout.setSpacing(10) + + # 1. Title & Time Display + self.title_lbl = QLabel("Create/Edit Description") + self.title_lbl.setProperty("class", "panel_header_lbl") + layout.addWidget(self.title_lbl) + + self.time_display = QLabel("Current Time: 00:00.000") + self.time_display.setProperty("class", "dense_time_display") + self.time_display.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(self.time_display) + + # 2. Text Input (Long Text) + layout.addWidget(QLabel("Description Text:")) + self.text_editor = QTextEdit() + self.text_editor.setProperty("class", "dense_desc_editor") + self.text_editor.setPlaceholderText("Enter the description of the event here...") + self.text_editor.setMinimumHeight(150) + layout.addWidget(self.text_editor) + + # 3. Add/Update Button + self.add_btn = QPushButton("Confirm Description") + self.add_btn.setProperty("class", "dense_confirm_btn") + self.add_btn.setFixedHeight(40) + self.add_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self.add_btn.clicked.connect(self._on_submit) + layout.addWidget(self.add_btn) + + def update_time(self, time_str): + self.time_display.setText(f"Current Time: {time_str}") + + def set_text(self, text): + """Programmatically set the text in the editor (for editing existing events).""" + self.text_editor.setPlainText(text) + + def _on_submit(self): + text = self.text_editor.toPlainText().strip() + if text: + self.descriptionSubmitted.emit(text) \ No newline at end of file diff --git a/annotation_tool/ui/description/.DS_Store b/annotation_tool/ui/description/.DS_Store new file mode 100644 index 0000000..4dcda3f Binary files /dev/null and b/annotation_tool/ui/description/.DS_Store differ diff --git a/annotation_tool/ui/description/README.md b/annotation_tool/ui/description/README.md new file mode 100644 index 0000000..df47933 --- /dev/null +++ b/annotation_tool/ui/description/README.md @@ -0,0 +1,83 @@ +# 📝 UI: Description Mode + +This directory contains the user interface components specifically designed for the **Description (Global Captioning)** mode. + +In this mode, the focus is on providing a holistic text description for video content. The interface is divided into a **Composite Media Player** (Center) and a **Text Event Editor** (Right). + +## 📂 Directory Structure + +```text +ui/description/ +├── event_editor/ +│ ├── __init__.py +│ └── editor.py # Right Panel: Text Input & Action Buttons +│ +└── media_player/ + ├── __init__.py # Center Panel: Composite Widget (Combines Preview + Controls) + ├── player_panel.py # Internal: Video Surface, Slider, & Time Label + └── controls.py # Internal: Navigation Toolbar (Play/Pause, Next/Prev) + +``` + +--- + +## 🧩 Components + +### 1. Media Player (Center Panel) + +The Center Panel is a **Composite Widget** defined in `media_player/__init__.py`. It orchestrates the video display and the navigation controls into a single layout. + +#### A. Main Wrapper (`media_player/__init__.py`) + +* **Class:** `DescriptionMediaPlayer` +* **Purpose:** Acts as the main container for the center of the screen. +* **Layout:** A `QVBoxLayout` that stacks: +1. `DescriptionMediaPreview` (The Video & Slider) - *Stretches to fill space*. +2. `DescriptionNavToolbar` (The Buttons) - *Fixed height at bottom*. + + +* **API:** It exposes crucial UI elements (`player`, `play_btn`, `next_clip`, etc.) so the Controller can easily connect signals without digging into child widgets. + +#### B. Visual Preview (`media_player/player_panel.py`) + +* **Class:** `DescriptionMediaPreview` +* **Purpose:** Renders the video and handles timeline interaction. +* **Key Features:** +* **Shared Surface:** Uses the common `VideoSurface` for consistent rendering. +* **Infinite Loop:** Sets `QMediaPlayer.Loops.Infinite` to allow repeated viewing while typing. +* **Clickable Slider:** A custom `QSlider` allowing absolute positioning on click. +* **Time Label:** Displays current position vs. total duration. + + + +#### C. Navigation Toolbar (`media_player/controls.py`) + +* **Class:** `DescriptionNavToolbar` +* **Purpose:** Provides playback and navigation controls. +* **Buttons:** +* **Action Navigation:** `<< Prev Action` / `Next Action >>` (Navigates logical events). +* **Clip Navigation:** `< Prev Clip` / `Next Clip >` (Navigates physical video files). +* **Playback:** `Play / Pause` toggle. + + + +--- + +### 2. Event Editor (Right Panel) + +Located in `event_editor/editor.py`. + +* **Class:** `DescriptionEventEditor` +* **Purpose:** The input interface for the captioning task. +* **Components:** +* **Text Area:** A `QTextEdit` for multi-line description entry. +* **History:** `Undo` and `Redo` buttons (linked to the generic history manager). +* **Actions:** +* **Clear:** Resets the text field. +* **Confirm:** Submits the text as the description for the current clip. + + + + + +--- diff --git a/annotation_tool/ui/description/__init__.py b/annotation_tool/ui/description/__init__.py new file mode 100644 index 0000000..e6152b8 --- /dev/null +++ b/annotation_tool/ui/description/__init__.py @@ -0,0 +1,12 @@ +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel +from PyQt6.QtCore import Qt + +class DescriptionMediaPlayer(QWidget): + """Placeholder for the Description Video Player""" + def __init__(self, parent=None): + super().__init__(parent) + layout = QVBoxLayout(self) + label = QLabel("Description Player (Center)\nPlays: video1 / video2") + label.setAlignment(Qt.AlignmentFlag.AlignCenter) + label.setStyleSheet("font-size: 18px; color: #888;") + layout.addWidget(label) \ No newline at end of file diff --git a/annotation_tool/ui/description/event_editor/.DS_Store b/annotation_tool/ui/description/event_editor/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/annotation_tool/ui/description/event_editor/.DS_Store differ diff --git a/annotation_tool/ui/description/event_editor/__init__.py b/annotation_tool/ui/description/event_editor/__init__.py new file mode 100644 index 0000000..052c897 --- /dev/null +++ b/annotation_tool/ui/description/event_editor/__init__.py @@ -0,0 +1,4 @@ +from .editor import DescriptionEventEditor + +# Export the class +__all__ = ["DescriptionEventEditor"] \ No newline at end of file diff --git a/annotation_tool/ui/description/event_editor/editor.py b/annotation_tool/ui/description/event_editor/editor.py new file mode 100644 index 0000000..d976e06 --- /dev/null +++ b/annotation_tool/ui/description/event_editor/editor.py @@ -0,0 +1,86 @@ +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, + QTextEdit, QFrame +) +from PyQt6.QtCore import Qt, pyqtSignal + +class DescriptionEventEditor(QWidget): + """ + Right Panel for Description Mode. + Single text area for Q&A style descriptions. + """ + + # Signals + undo_clicked = pyqtSignal() + redo_clicked = pyqtSignal() + confirm_clicked = pyqtSignal() # Save changes + clear_clicked = pyqtSignal() # Clear text + + def __init__(self, parent=None): + super().__init__(parent) + self.setFixedWidth(350) + + self.layout = QVBoxLayout(self) + self.layout.setSpacing(10) + self.layout.setContentsMargins(15, 15, 15, 15) + + # --- 1. Undo/Redo Controls --- + h_undo = QHBoxLayout() + self.undo_btn = QPushButton("Undo") + self.redo_btn = QPushButton("Redo") + + for btn in [self.undo_btn, self.redo_btn]: + btn.setCursor(Qt.CursorShape.PointingHandCursor) + btn.setEnabled(False) + # Use property for styling if needed, or objectName + btn.setProperty("class", "editor_control_btn") + + self.undo_btn.clicked.connect(self.undo_clicked.emit) + self.redo_btn.clicked.connect(self.redo_clicked.emit) + + h_undo.addWidget(self.undo_btn) + h_undo.addWidget(self.redo_btn) + self.layout.addLayout(h_undo) + + # --- 2. Text Editor Area --- + lbl_instr = QLabel("Description / Caption:") + # You can also move this style to QSS if you want complete separation + lbl_instr.setStyleSheet("font-weight: bold; color: #ccc;") + self.layout.addWidget(lbl_instr) + + self.caption_edit = QTextEdit() + self.caption_edit.setPlaceholderText("Type description here...") + + # Style via QSS + self.caption_edit.setObjectName("descCaptionEdit") + + self.layout.addWidget(self.caption_edit, 1) + + # --- 3. Action Buttons --- + h_btns = QHBoxLayout() + h_btns.setSpacing(10) + + # Confirm Button (Blue) + self.confirm_btn = QPushButton("Confirm") + self.confirm_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self.confirm_btn.setMinimumHeight(40) + + # Style via QSS + self.confirm_btn.setObjectName("descConfirmBtn") + self.confirm_btn.clicked.connect(self.confirm_clicked.emit) + + # Clear Button + self.clear_btn = QPushButton("Clear") + self.clear_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self.clear_btn.setMinimumHeight(40) + + # Style via QSS + self.clear_btn.setObjectName("descClearBtn") + self.clear_btn.clicked.connect(self.clear_clicked.emit) + + # [CHANGED] Swap Order: Clear (Left) -> Confirm (Right) + # Stretch factors: Clear gets 1 share, Confirm gets 2 shares (wider) + h_btns.addWidget(self.clear_btn, 1) + h_btns.addWidget(self.confirm_btn, 2) + + self.layout.addLayout(h_btns) \ No newline at end of file diff --git a/annotation_tool/ui/description/media_player/.DS_Store b/annotation_tool/ui/description/media_player/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/annotation_tool/ui/description/media_player/.DS_Store differ diff --git a/annotation_tool/ui/description/media_player/__init__.py b/annotation_tool/ui/description/media_player/__init__.py new file mode 100644 index 0000000..809db41 --- /dev/null +++ b/annotation_tool/ui/description/media_player/__init__.py @@ -0,0 +1,40 @@ +from PyQt6.QtWidgets import QWidget, QVBoxLayout +from PyQt6.QtMultimedia import QMediaPlayer + +from .player_panel import DescriptionMediaPreview +from .controls import DescriptionNavToolbar + +class DescriptionMediaPlayer(QWidget): + """ + Center Panel for Description Mode. + Combines DescriptionMediaPreview and DescriptionNavToolbar. + """ + def __init__(self, parent=None): + super().__init__(parent) + self.layout = QVBoxLayout(self) + self.layout.setContentsMargins(0, 0, 0, 0) + + # 1. Video Player + self.preview = DescriptionMediaPreview() + self.layout.addWidget(self.preview, 1) # Stretch factor 1 + + # 2. Controls + self.controls = DescriptionNavToolbar() + self.layout.addWidget(self.controls) + + # Expose widgets for Controller access + self.player = self.preview.player + self.prev_action = self.controls.prev_action + self.prev_clip = self.controls.prev_clip + self.play_btn = self.controls.play_btn + self.next_clip = self.controls.next_clip + self.next_action = self.controls.next_action + + def load_video(self, path): + self.preview.load_video(path) + + def toggle_play_pause(self): + if self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState: + self.player.pause() + else: + self.player.play() \ No newline at end of file diff --git a/annotation_tool/ui/description/media_player/controls.py b/annotation_tool/ui/description/media_player/controls.py new file mode 100644 index 0000000..a93c8d8 --- /dev/null +++ b/annotation_tool/ui/description/media_player/controls.py @@ -0,0 +1,29 @@ +from PyQt6.QtWidgets import ( + QWidget, QHBoxLayout, QPushButton +) +from PyQt6.QtCore import Qt + +class DescriptionNavToolbar(QWidget): + """ + Navigation buttons for Description Mode. + Similar to Classification but tailored for Action/Input navigation. + """ + def __init__(self, parent=None): + super().__init__(parent) + self.layout = QHBoxLayout(self) + self.layout.setContentsMargins(5, 5, 5, 5) + + self.prev_action = QPushButton("<< Prev Action") + self.prev_clip = QPushButton("< Prev Clip") + self.play_btn = QPushButton("Play / Pause") + self.next_clip = QPushButton("Next Clip >") + self.next_action = QPushButton("Next Action >>") + + btns = [ + self.prev_action, self.prev_clip, self.play_btn, + self.next_clip, self.next_action + ] + + for b in btns: + b.setCursor(Qt.CursorShape.PointingHandCursor) + self.layout.addWidget(b) \ No newline at end of file diff --git a/annotation_tool/ui/description/media_player/player_panel.py b/annotation_tool/ui/description/media_player/player_panel.py new file mode 100644 index 0000000..2dc16d2 --- /dev/null +++ b/annotation_tool/ui/description/media_player/player_panel.py @@ -0,0 +1,129 @@ +import os +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QStyleOptionSlider, QStyle, QSlider +) +from PyQt6.QtCore import Qt +from PyQt6.QtMultimedia import QMediaPlayer + +# Import from common +from ui.common.video_surface import VideoSurface + +class ClickableSlider(QSlider): + """A custom QSlider that jumps to the click position immediately.""" + def mousePressEvent(self, event): + super().mousePressEvent(event) + if event.button() == Qt.MouseButton.LeftButton: + val = self._pixel_pos_to_value(event.pos()) + self.setValue(val) + self.sliderMoved.emit(val) + + def _pixel_pos_to_value(self, pos): + opt = QStyleOptionSlider() + self.initStyleOption(opt) + gr = self.style().subControlRect(QStyle.ComplexControl.CC_Slider, opt, QStyle.SubControl.SC_SliderGroove, self) + sr = self.style().subControlRect(QStyle.ComplexControl.CC_Slider, opt, QStyle.SubControl.SC_SliderHandle, self) + + if self.orientation() == Qt.Orientation.Horizontal: + slider_length = sr.width() + slider_min = gr.x() + slider_max = gr.right() - slider_length + 1 + pos_x = pos.x() + else: + slider_length = sr.height() + slider_min = gr.y() + slider_max = gr.bottom() - slider_length + 1 + pos_x = pos.y() + + return QStyle.sliderValueFromPosition(self.minimum(), self.maximum(), pos_x - slider_min, + slider_max - slider_min, opt.upsideDown) + +class DescriptionMediaPreview(QWidget): + """ + Player Panel for Description Mode. + Uses Shared VideoSurface but enables Infinite Loop. + """ + def __init__(self, parent=None): + super().__init__(parent) + + # 1. Main Layout + self.v_layout = QVBoxLayout(self) + self.v_layout.setContentsMargins(0, 0, 0, 0) + self.v_layout.setSpacing(5) + + # 2. Instantiate the Shared Video Surface + self.surface = VideoSurface() + self.v_layout.addWidget(self.surface, 1) + + # Expose player + self.player = self.surface.player + + # Description mode specific: Enable Infinite Loop + self.player.setLoops(QMediaPlayer.Loops.Infinite) + + # 3. Controls (Slider & Label) + self.slider = ClickableSlider(Qt.Orientation.Horizontal) + self.slider.setRange(0, 1000) + self.slider.setEnabled(False) + self.slider.setCursor(Qt.CursorShape.PointingHandCursor) + + self.time_label = QLabel("00:00 / 00:00") + self.time_label.setFixedWidth(120) + self.time_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + + # [CHANGED] Moved style to QSS (ID: descTimeLabel) + self.time_label.setObjectName("descTimeLabel") + + h_controls = QHBoxLayout() + h_controls.setContentsMargins(5, 0, 5, 5) + h_controls.addWidget(self.slider) + h_controls.addWidget(self.time_label) + self.v_layout.addLayout(h_controls) + + # 4. Connections + self.player.positionChanged.connect(self._on_position_changed) + self.player.durationChanged.connect(self._on_duration_changed) + self.player.errorOccurred.connect(self._on_error) + + self.slider.sliderMoved.connect(self._on_slider_moved) + self.slider.sliderPressed.connect(self.player.pause) + self.slider.sliderReleased.connect(self.player.play) + + self._duration_ms = 0 + + def load_video(self, path): + """Delegates loading to shared surface.""" + self.surface.load_source(path) + + # Description mode auto-play logic + if path: + self.player.play() + + def _on_duration_changed(self, duration): + self._duration_ms = duration + if duration > 0: + self.slider.setRange(0, duration) + self.slider.setEnabled(True) + self.slider.setValue(0) + self._update_time_label(0, duration) + else: + self.slider.setEnabled(False) + + def _on_position_changed(self, position): + if not self.slider.isSliderDown(): + self.slider.setValue(position) + self._update_time_label(position, self._duration_ms) + + def _on_slider_moved(self, position): + self.player.setPosition(position) + self._update_time_label(position, self._duration_ms) + + def _update_time_label(self, current_ms, total_ms): + def fmt(ms): + s = (ms // 1000) % 60 + m = (ms // 60000) % 60 + return f"{m:02}:{s:02}" + self.time_label.setText(f"{fmt(current_ms)} / {fmt(total_ms)}") + + def _on_error(self): + print(f"Video Player Error: {self.player.errorString()}") \ No newline at end of file diff --git a/annotation_tool/ui/localization/.DS_Store b/annotation_tool/ui/localization/.DS_Store new file mode 100644 index 0000000..8a5b1d5 Binary files /dev/null and b/annotation_tool/ui/localization/.DS_Store differ diff --git a/annotation_tool/ui/localization/README.md b/annotation_tool/ui/localization/README.md new file mode 100644 index 0000000..dc2466a --- /dev/null +++ b/annotation_tool/ui/localization/README.md @@ -0,0 +1,61 @@ +# 📍 Localization UI Module + +This directory contains the user interface components specifically designed for the **Action Spotting (Localization)** task. In this mode, users identify specific timestamps (events) within a video timeline, rather than categorizing the whole video. + +## 📂 Directory Structure + +```text +localization/ +├── panels.py # High-level layout container for the Localization view +├── widgets/ # Specialized functional components +│ ├── clip_explorer.py # Left sidebar: Video list & project controls +│ ├── media_player.py # Center area: Video player & timeline +│ └── event_editor.py # Right sidebar: Event buttons & data table +└── __init__.py + +``` + +--- + +## 📝 File Descriptions + +### 1. `panels.py` + +**Purpose:** Layout Management. +This file defines the `LocalizationUI` class, which acts as the main container. It uses a `QHBoxLayout` (Horizontal Box Layout) to assemble the three main working areas: + +* **Left:** Clip Explorer +* **Center:** Media Player +* **Right:** Event Editor + +It serves as the integration point where these distinct widgets are instantiated and arranged. + +### 2. `widgets/clip_explorer.py` (Left Sidebar) + +**Purpose:** Resource Navigation & Project Management. + +* **Clip Tree:** Displays the list of video files available in the project. It handles filtering (e.g., showing only "Done" or "Not Done" clips) and visual status indicators (checkmarks). +* **Project Controls:** Integrates the shared `UnifiedProjectControls` (from `ui/common`), providing standard buttons for saving, loading, and exporting the project. + +### 3. `widgets/media_player.py` (Center Area) + +**Purpose:** Video Playback & Visualization. + +* **`MediaPreviewWidget`**: A wrapper around `QVideoWidget` for displaying the video content. +* **`TimelineWidget`**: A custom-painted widget that represents the video duration horizontally. It supports: +* **Visual Markers**: Draws red lines where events have been spotted. +* **Zooming**: Allows expanding the timeline for precise frame selection. +* **Auto-scrolling**: Keeps the playhead in view during playback. + + +* **`PlaybackControlBar`**: Provides granular control, including frame stepping (`<< 1s`, `>> 1s`), variable playback speed (0.25x - 4.0x), and seeking. + +### 4. `widgets/event_editor.py` (Right Sidebar) + +**Purpose:** Data Entry & Modification. + +* **`AnnotationManagementWidget`**: A tabbed interface allowing users to organize annotations by categories (Headers). Inside each tab, dynamic buttons allow users to "spot" an action at the current timestamp. +* **`AnnotationTableWidget`**: A table view listing all recorded events for the current video. It supports: +* **In-place Editing**: Users can double-click cells to modify timestamps or labels. +* **Selection Sync**: Clicking a row jumps the video player to that event's time. + diff --git a/annotation_tool/ui/localization/event_editor/.DS_Store b/annotation_tool/ui/localization/event_editor/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/annotation_tool/ui/localization/event_editor/.DS_Store differ diff --git a/annotation_tool/ui/localization/event_editor/README.md b/annotation_tool/ui/localization/event_editor/README.md new file mode 100644 index 0000000..c4cf298 --- /dev/null +++ b/annotation_tool/ui/localization/event_editor/README.md @@ -0,0 +1,113 @@ +# Event Editor Widget + +## Overview + +This module is responsible for the **Right Panel** of the Localization (Action Spotting) interface. It provides the primary mechanisms for users to: +1. **Create Events**: "Spot" actions at specific timestamps using dynamic category buttons. +2. **Manage Schema**: Add, rename, or delete annotation categories (Heads) and labels. +3. **Edit Events**: View, sort, and modify existing events in a detailed table view. +4. **Control History**: Access Undo/Redo functionality for the localization task. + +## Directory Structure + +```text +ui/localization/event_editor/ +├── __init__.py # Package entry point; assembles components into LocRightPanel. +├── spotting_controls.py # Top section: Tabbed interface for action spotting buttons. +└── annotation_table.py # Bottom section: Data grid showing the list of events. + +``` + +## Components Breakdown + +### 1. `__init__.py` + +**Main Class:** `LocRightPanel` + +* **Role**: The main container widget that acts as the "Right Panel" in the Localization layout. +* **Composition**: +* **Header**: Contains the "Annotation Controls" label and the **Undo/Redo** buttons. +* **Top Widget**: `AnnotationManagementWidget` (imported from `spotting_controls.py`). +* **Bottom Widget**: `AnnotationTableWidget` (imported from `annotation_table.py`). + + +* **Usage**: This class is instantiated by the `UnifiedTaskPanel` (or `MainWindowUI`) to construct the UI. + +### 2. `spotting_controls.py` + +**Role**: Handles the dynamic creation of buttons based on the JSON schema. It allows users to click a button to record an event at the current video timestamp. + +**Key Classes:** + +* **`SpottingTabWidget`**: +* A `QTabWidget` where each tab represents a **Head** (Category, e.g., "Pass", "Shot"). +* Supports context menus on tabs to **Rename** or **Delete** heads. +* Contains a special `+` tab to add new heads dynamically. + + +* **`HeadSpottingPage`**: +* The widget inside each tab. +* Displays a grid of `LabelButton`s for each label defined in the schema. +* Includes an "Add new label" button to extend the schema on the fly. + + +* **`LabelButton`**: +* A custom `QPushButton` that emits signals for Right-Click (Context Menu) and Double-Click events. + + + +**Signals:** + +* `spottingTriggered(head, label)`: Emitted when a user spots an action. +* `headAdded`, `headRenamed`, `headDeleted`: Emitted when schema structure changes. + +### 3. `annotation_table.py` + +**Role**: Displays the list of recorded events for the currently selected video. It supports direct cell editing. + +**Key Classes:** + +* **`AnnotationTableModel` (`QAbstractTableModel`)**: +* The underlying data model connecting the UI to the list of events. +* Columns: **Time** (formatted `MM:SS.mmm`), **Head**, **Label**. +* Implements `setData` to allow users to double-click a cell and modify the time or label directly. + + +* **`AnnotationTableWidget`**: +* Wraps the `QTableView`. +* Handles row selection (syncs with the video player seek). +* Provides a context menu to **Delete** events. + + + +**Signals:** + +* `annotationSelected(position_ms)`: Emitted when a row is clicked (tells the player to seek). +* `annotationModified(old_data, new_data)`: Emitted after a cell edit (tells the Controller to push an Undo command). +* `annotationDeleted(event_item)`: Emitted via context menu. + +## Interaction Flow + +1. **Initialization**: The `LocalizationManager` calls `update_schema()` on the `spotting_controls` to build the tabs. +2. **Spotting**: +* User clicks a button in `HeadSpottingPage`. +* Signal bubbles up to `LocRightPanel` -> `LocalizationManager`. +* Manager grabs current player time and adds an event to the Model. + + +3. **Data Refresh**: +* The Model updates the `AnnotationTableModel`. +* The table refreshes to show the new row. + + +4. **Editing**: +* User edits a timestamp in the table. +* `AnnotationTableModel` validates the input. +* If valid, it updates the internal data and signals the Manager to record the change for Undo/Redo. + + + +## Dependencies + +* **PyQt6**: `QtWidgets`, `QtCore`, `QtGui`. +* **Project Utils**: Standard signal/slot mechanisms defined in the Controller layer. diff --git a/annotation_tool/ui/localization/event_editor/__init__.py b/annotation_tool/ui/localization/event_editor/__init__.py new file mode 100644 index 0000000..d19042f --- /dev/null +++ b/annotation_tool/ui/localization/event_editor/__init__.py @@ -0,0 +1,63 @@ +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel +) +from PyQt6.QtCore import Qt + +# Import the separated components from the same package +from .spotting_controls import AnnotationManagementWidget +from .annotation_table import AnnotationTableWidget + +# --- [Assembled] Localization Right Panel --- +class LocRightPanel(QWidget): + """ + Right Panel for Localization Mode. + Contains: Undo/Redo Buttons, Annotation Tabs (Top), and Events Table (Bottom). + """ + def __init__(self, parent=None): + super().__init__(parent) + self.setFixedWidth(400) + layout = QVBoxLayout(self) + + # --- Undo/Redo Button Header --- + header_layout = QHBoxLayout() + header_layout.setContentsMargins(0, 0, 0, 5) + + lbl = QLabel("Annotation Controls") + lbl.setStyleSheet("font-weight: bold; color: #BBB; font-size: 13px;") + + self.undo_btn = QPushButton("Undo") + self.redo_btn = QPushButton("Redo") + + # Button Styling + btn_style = """ + QPushButton { + background-color: #444; color: #DDD; + border: 1px solid #555; border-radius: 4px; padding: 4px 10px; + font-weight: bold; + } + QPushButton:hover { background-color: #555; border-color: #777; } + QPushButton:pressed { background-color: #333; } + QPushButton:disabled { color: #777; background-color: #333; border-color: #444; } + """ + for btn in [self.undo_btn, self.redo_btn]: + btn.setCursor(Qt.CursorShape.PointingHandCursor) + btn.setStyleSheet(btn_style) + btn.setFixedWidth(60) + btn.setEnabled(False) + + header_layout.addWidget(lbl) + 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. Bottom: Labelled Event List (Table) + self.table = AnnotationTableWidget() + + layout.addWidget(self.annot_mgmt, 3) + layout.addWidget(self.table, 2) \ 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 new file mode 100644 index 0000000..3f1e471 --- /dev/null +++ b/annotation_tool/ui/localization/event_editor/annotation_table.py @@ -0,0 +1,190 @@ +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QLabel, QTableView, QHeaderView, QMenu, + QAbstractItemView +) +from PyQt6.QtCore import pyqtSignal, Qt, QAbstractTableModel + +# ==================== Table Model ==================== +class AnnotationTableModel(QAbstractTableModel): + """ + Data model for the events table. + """ + # Signal emitted when a cell is edited: old_data, new_data + itemChanged = pyqtSignal(dict, dict) + + def __init__(self, annotations=None): + super().__init__() + self._data = annotations or [] + self._headers = ["Time", "Head", "Label"] + + def rowCount(self, parent=None): + return len(self._data) + + def columnCount(self, parent=None): + return len(self._headers) + + def flags(self, index): + if not index.isValid(): + return Qt.ItemFlag.NoItemFlags + # Enable selection and editing + return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEditable + + def data(self, index, role=Qt.ItemDataRole.DisplayRole): + if not index.isValid(): + return None + + row = index.row() + item = self._data[row] + + if role == Qt.ItemDataRole.DisplayRole or role == Qt.ItemDataRole.EditRole: + col = index.column() + if col == 0: + return self._fmt_ms(item.get('position_ms', 0)) + elif col == 1: + return item.get('head', '').replace('_', ' ') + elif col == 2: + return item.get('label', '').replace('_', ' ') + + elif role == Qt.ItemDataRole.UserRole: + return item + + return None + + def setData(self, index, value, role=Qt.ItemDataRole.EditRole): + """ + Handle user edits directly from the table cells. + """ + if not index.isValid() or role != Qt.ItemDataRole.EditRole: + return False + + row = index.row() + col = index.column() + + # Get the original object + old_item = self._data[row] + # Create a shallow copy to modify + new_item = old_item.copy() + + text_val = str(value).strip() + + if col == 0: # Time Column + try: + ms = self._parse_time_str(text_val) + new_item['position_ms'] = ms + except ValueError: + # Invalid time format, reject the edit + return False + elif col == 1: # Head Column + new_item['head'] = text_val + elif col == 2: # Label Column + new_item['label'] = text_val + + # If data changed, emit signal for the Manager to handle (Undo Stack) + if new_item != old_item: + self.itemChanged.emit(old_item, new_item) + return True + + return False + + def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole): + if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole: + return self._headers[section] + return None + + def set_annotations(self, annotations): + self.beginResetModel() + self._data = annotations + self.endResetModel() + + def get_annotation_at(self, row): + if 0 <= row < len(self._data): + return self._data[row] + return None + + def _fmt_ms(self, ms): + s = ms // 1000 + m = s // 60 + return f"{m:02}:{s%60:02}.{ms%1000:03}" + + def _parse_time_str(self, time_str): + if not time_str: + return 0 + parts = time_str.split(':') + total_seconds = 0.0 + if len(parts) == 3: # HH:MM:SS.mmm + total_seconds += float(parts[0]) * 3600 + total_seconds += float(parts[1]) * 60 + total_seconds += float(parts[2]) + elif len(parts) == 2: # MM:SS.mmm + total_seconds += float(parts[0]) * 60 + total_seconds += float(parts[1]) + elif len(parts) == 1: # SS.mmm + total_seconds += float(parts[0]) + return int(total_seconds * 1000) + + +# ==================== Table Widget ==================== +class AnnotationTableWidget(QWidget): + """ + Widget containing the list of events (QTableView). + """ + annotationSelected = pyqtSignal(int) + annotationModified = pyqtSignal(dict, dict) # old_event, new_event + annotationDeleted = pyqtSignal(dict) + + def __init__(self, parent=None): + super().__init__(parent) + layout = QVBoxLayout(self) + + lbl = QLabel("Events List") + lbl.setProperty("class", "panel_header_lbl") + layout.addWidget(lbl) + + self.table = QTableView() + self.table.setProperty("class", "annotation_table") + + self.model = AnnotationTableModel() + self.model.itemChanged.connect(self.annotationModified.emit) + + self.table.setModel(self.model) + self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + self.table.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows) + self.table.setSelectionMode(QTableView.SelectionMode.SingleSelection) + self.table.setAlternatingRowColors(True) + self.table.selectionModel().selectionChanged.connect(self._on_selection_changed) + + self.table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.table.customContextMenuRequested.connect(self._show_context_menu) + + layout.addWidget(self.table) + + self.current_schema = {} + + def set_data(self, annotations): + self.model.set_annotations(annotations) + + def set_schema(self, schema): + self.current_schema = schema + + def _on_selection_changed(self, selected, deselected): + indexes = selected.indexes() + if indexes: + row = indexes[0].row() + item = self.model.get_annotation_at(row) + if item: + self.annotationSelected.emit(item.get('position_ms', 0)) + + def _show_context_menu(self, pos): + index = self.table.indexAt(pos) + if not index.isValid(): return + + row = index.row() + item = self.model.get_annotation_at(row) + if not item: return + + menu = QMenu(self) + act_delete = menu.addAction("Delete Event") + selected_action = menu.exec(self.table.mapToGlobal(pos)) + + if selected_action == act_delete: + self.annotationDeleted.emit(item) \ 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 new file mode 100644 index 0000000..380ab99 --- /dev/null +++ b/annotation_tool/ui/localization/event_editor/spotting_controls.py @@ -0,0 +1,282 @@ +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QTabWidget, + QGridLayout, QLabel, QScrollArea, QMenu, QInputDialog, QSizePolicy +) +from PyQt6.QtCore import pyqtSignal, Qt + +# ==================== Custom Widgets ==================== + +class LabelButton(QPushButton): + """ + Custom Label Button that supports Right-Click signal. + Used for the grid of labels inside each Head page. + """ + rightClicked = pyqtSignal() + doubleClicked = pyqtSignal() + + def __init__(self, text, parent=None): + super().__init__(text, parent) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setMinimumHeight(40) + self.setProperty("class", "spotting_label_btn") + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.RightButton: + self.rightClicked.emit() + else: + super().mousePressEvent(event) + + def mouseDoubleClickEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + self.doubleClicked.emit() + else: + super().mouseDoubleClickEvent(event) + + +class HeadSpottingPage(QWidget): + """ + A single page (Head/Category) containing a grid of LabelButtons. + This corresponds to the content of one tab. + """ + labelClicked = pyqtSignal(str) + addLabelRequested = pyqtSignal() + renameLabelRequested = pyqtSignal(str) + deleteLabelRequested = pyqtSignal(str) + + def __init__(self, head_name, labels, parent=None): + super().__init__(parent) + self.head_name = head_name + self.labels = labels + + layout = QVBoxLayout(self) + layout.setContentsMargins(5, 5, 5, 5) + layout.setSpacing(10) + + # 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.setWidget(self.grid_container) + layout.addWidget(scroll) + + self._populate_grid() + + def update_time_display(self, text): + self.time_label.setText(f"Current Time: {text}") + + def refresh_labels(self, new_labels): + self.labels = 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 + + # Add label buttons + 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 + + # Add "Add Label" button at the bottom + add_btn = QPushButton("Add new label at current time") + add_btn.setCursor(Qt.CursorShape.PointingHandCursor) + add_btn.setMinimumHeight(45) + add_btn.setProperty("class", "spotting_add_btn") + + add_btn.clicked.connect(self.addLabelRequested.emit) + + if col != 0: + row += 1 + self.grid_layout.addWidget(add_btn, row, 0, 1, 2) + + def _show_context_menu(self, label): + display_label = label.replace('_', ' ') + menu = QMenu(self) + rename_action = menu.addAction(f"Rename '{display_label}'") + delete_action = menu.addAction(f"Delete '{display_label}'") + + action = menu.exec(self.cursor().pos()) + if action == rename_action: + self.renameLabelRequested.emit(label) + elif action == delete_action: + self.deleteLabelRequested.emit(label) + + +class SpottingTabWidget(QTabWidget): + """ + Tab Widget managing multiple HeadSpottingPages. + Supports adding/removing heads via tab interactions. + """ + headAdded = pyqtSignal(str) + headRenamed = pyqtSignal(str, str) + headDeleted = pyqtSignal(str) + headSelected = pyqtSignal(str) + spottingTriggered = pyqtSignal(str, str) + labelAddReq = pyqtSignal(str) + labelRenameReq = pyqtSignal(str, str) + labelDeleteReq = pyqtSignal(str, str) + + def __init__(self, parent=None): + super().__init__(parent) + self.setTabBarAutoHide(False) + self.setMovable(False) + self.setTabsClosable(False) + self.setProperty("class", "spotting_tabs") + + self.tabBar().tabBarClicked.connect(self._on_tab_bar_clicked) + + self.currentChanged.connect(self._on_tab_changed) + + self.tabBar().setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.tabBar().customContextMenuRequested.connect(self._show_tab_context_menu) + + self._ignore_change = False + self._plus_tab_index = -1 + self._head_keys_map = [] + self._previous_index = -1 + + def update_schema(self, label_definitions): + """Rebuilds the tabs based on the new schema.""" + self._ignore_change = True + self.clear() + self._head_keys_map = [] + + heads = sorted(label_definitions.keys()) + for head in heads: + labels = label_definitions[head].get('labels', []) + page = HeadSpottingPage(head, labels) + + # Forward signals from page to main controller + page.labelClicked.connect(lambda l, h=head: self.spottingTriggered.emit(h, l)) + page.addLabelRequested.connect(lambda h=head: self.labelAddReq.emit(h)) + page.renameLabelRequested.connect(lambda l, h=head: self.labelRenameReq.emit(h, l)) + page.deleteLabelRequested.connect(lambda l, h=head: self.labelDeleteReq.emit(h, l)) + + display_head = head.replace('_', ' ') + self.addTab(page, display_head) + self._head_keys_map.append(head) + + # Add the "+" tab at the end + self._plus_tab_index = self.addTab(QWidget(), "+") + self._ignore_change = False + + def update_current_time(self, time_str): + current_widget = self.currentWidget() + if isinstance(current_widget, HeadSpottingPage): + current_widget.update_time_display(time_str) + + def set_current_head(self, head_name): + if head_name in self._head_keys_map: + idx = self._head_keys_map.index(head_name) + self.setCurrentIndex(idx) + self._previous_index = idx + + def _on_tab_bar_clicked(self, index): + """ + [NEW] Handles clicks on the tab bar. + Specifically catches clicks on the "+" tab to trigger add_head. + """ + if index == self._plus_tab_index and index != -1: + # If user clicked the plus tab, prompt for new head + self._handle_add_head() + + def _on_tab_changed(self, index): + """ + Handles navigation between valid Head tabs. + Ignores the "+" tab (handled by clicked event). + """ + if self._ignore_change: return + + # If we somehow navigated to the plus tab (e.g. keyboard), + # we can either trigger the add or just try to bounce back. + # Since we handle triggering in `tabBarClicked`, we mostly just ignore logic here + # or update the valid head selection. + + if index != self._plus_tab_index and index != -1: + # Logic for valid head selection + if 0 <= index < len(self._head_keys_map): + real_head = self._head_keys_map[index] + self.headSelected.emit(real_head) + self._previous_index = index + + elif index == self._plus_tab_index: + # If we landed on the plus tab (via keyboard/code), + # ideally we stay on the previous one to avoid showing an empty page, + # but if it's the *only* tab, we can't switch away. + pass + + def _handle_add_head(self): + """Opens dialog to add a new category.""" + name, ok = QInputDialog.getText(self, "New Task Head", "Enter head name (e.g. 'player_action'):") + if ok and name.strip(): + self.headAdded.emit(name.strip()) + + def _show_tab_context_menu(self, pos): + index = self.tabBar().tabAt(pos) + if index == -1 or index == self._plus_tab_index: return + + if 0 <= index < len(self._head_keys_map): + real_head_name = self._head_keys_map[index] + display_head_name = self.tabText(index) + + menu = QMenu(self) + rename_act = menu.addAction(f"Rename '{display_head_name}'") + delete_act = menu.addAction(f"Delete '{display_head_name}'") + + action = menu.exec(self.mapToGlobal(pos)) + if action == rename_act: + new_name, ok = QInputDialog.getText(self, "Rename Head", f"Rename '{real_head_name}' to:", text=real_head_name) + if ok and new_name.strip() and new_name != real_head_name: + self.headRenamed.emit(real_head_name, new_name.strip()) + elif action == delete_act: + self.headDeleted.emit(real_head_name) + + +class AnnotationManagementWidget(QWidget): + """ + Wrapper for SpottingTabWidget with a title. + """ + def __init__(self, parent=None): + super().__init__(parent) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(5) + + title_label = QLabel("Create Annotation") + title_label.setProperty("class", "panel_header_lbl") + layout.addWidget(title_label) + + self.tabs = SpottingTabWidget() + layout.addWidget(self.tabs) + + def update_schema(self, label_definitions): + self.tabs.update_schema(label_definitions) \ No newline at end of file diff --git a/annotation_tool/ui/localization/media_player/.DS_Store b/annotation_tool/ui/localization/media_player/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/annotation_tool/ui/localization/media_player/.DS_Store differ diff --git a/annotation_tool/ui/localization/media_player/README.md b/annotation_tool/ui/localization/media_player/README.md new file mode 100644 index 0000000..85dbf55 --- /dev/null +++ b/annotation_tool/ui/localization/media_player/README.md @@ -0,0 +1,95 @@ +# Localization Media Player Widget + +This package contains the UI components responsible for video playback, timeline visualization, and transport controls within the **Localization (Action Spotting)** mode. + +It is designed to handle frame-accurate video navigation and visual feedback for temporal events. + +## 📂 Directory Structure + +```text +media_player/ +├── __init__.py # Exports LocCenterPanel and assembles the components +├── preview.py # Handles the QMediaPlayer and Video Surface +├── timeline.py # Handles the zoomable Timeline and Event Markers +└── controls.py # Handles Play/Pause, Seek, and Speed controls + +``` + +--- + +## 🧩 Components Detail + +### 1. `preview.py` (MediaPreviewWidget) + +**Responsibility**: Core media rendering. + +* **Video Output**: Wraps `QVideoWidget` to display the video content. +* **Audio Handling**: Explicitly initializes `QAudioOutput` to ensure compatibility with PyQt6 (prevents black screen issues on some platforms). +* **State Management**: Emits signals for position, duration, and playback state changes. +* **API**: +* `load_video(path)`: Loads a video file. +* `set_position(ms)`: Seeks to a specific timestamp. +* `set_playback_rate(rate)`: Adjusts speed (e.g., 0.5x, 2.0x). + + + +### 2. `timeline.py` (TimelineWidget) + +**Responsibility**: Temporal visualization and navigation. + +* **Custom Slider**: Uses `AnnotationSlider` (subclass of `QSlider`) to paint colored markers on the groove representing spotting events. +* **Zoom Logic**: Supports zooming in/out to increase precision for short clips or view the entire video duration. +* **Auto-Scrolling**: The timeline automatically follows the playhead during playback. If the user drags the scrollbar manually, auto-scrolling pauses until the playhead catches up. +* **Interaction**: Dragging the slider handle emits seek requests to the player. + +### 3. `controls.py` (PlaybackControlBar) + +**Responsibility**: User input for navigation. + +* **Transport Buttons**: Play/Pause, Stop. +* **Seeking**: +* Fine steps: +/- 1 second. +* Large steps: +/- 5 seconds. +* Clip Navigation: Jump to Previous/Next video in the project list. +* Event Navigation: Jump to Previous/Next annotated event. + + +* **Speed Control**: Buttons to toggle playback speed (0.25x to 4.0x). + +### 4. `__init__.py` (LocCenterPanel) + +**Responsibility**: Assembly. + +* It imports the three widgets above and arranges them in a vertical layout (`QVBoxLayout`). +* This class is exposed to the main window as the "Center Panel" for the Localization interface. + +--- + +## 🔄 Data Flow & Signal Wiring + +The wiring between these components is primarily handled by the **LocalizationManager** (Controller), not inside this package. This ensures the View remains decoupled from the Logic. + +**Typical Flow:** + +1. **User Click**: User clicks "Play" in `PlaybackControlBar`. +2. **Signal**: `PlaybackControlBar` emits `playPauseRequested`. +3. **Controller**: `LocalizationManager` receives the signal and calls `MediaPreviewWidget.toggle_play_pause()`. +4. **Feedback**: `MediaPreviewWidget` updates the video state. +5. **Sync**: `MediaPreviewWidget` emits `positionChanged`, which is connected to `TimelineWidget.set_position()`, updating the slider UI. + +## 🛠 Usage + +To use this component in the main application layout: + +```python +from ui.localization.media_player import LocCenterPanel + +# Inside the main window setup +self.center_panel = LocCenterPanel() +layout.addWidget(self.center_panel) + +``` + +## ⚠️ Notes + +* **PyQt6 Requirement**: This package relies on `PyQt6.QtMultimedia`. Ensure your environment has the necessary codecs installed (e.g., K-Lite Codec Pack on Windows) if you encounter playback issues with specific video formats. diff --git a/annotation_tool/ui/localization/media_player/__init__.py b/annotation_tool/ui/localization/media_player/__init__.py new file mode 100644 index 0000000..eee74a0 --- /dev/null +++ b/annotation_tool/ui/localization/media_player/__init__.py @@ -0,0 +1,23 @@ +from PyQt6.QtWidgets import QWidget, QVBoxLayout + +# Import components from sibling files +from .player_panel import MediaPreviewWidget +from .timeline import TimelineWidget +from .controls import PlaybackControlBar + +class LocCenterPanel(QWidget): + """ + Center Panel for Localization Mode. + Contains: MediaPreview, Timeline, Playback Controls. + """ + def __init__(self, parent=None): + super().__init__(parent) + layout = QVBoxLayout(self) + + self.media_preview = MediaPreviewWidget() + self.timeline = TimelineWidget() + self.playback = PlaybackControlBar() + + layout.addWidget(self.media_preview, 1) # Expandable + layout.addWidget(self.timeline) + layout.addWidget(self.playback) \ No newline at end of file diff --git a/annotation_tool/ui/localization/media_player/controls.py b/annotation_tool/ui/localization/media_player/controls.py new file mode 100644 index 0000000..cb378a4 --- /dev/null +++ b/annotation_tool/ui/localization/media_player/controls.py @@ -0,0 +1,52 @@ +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QPushButton +from PyQt6.QtCore import Qt, pyqtSignal + +class PlaybackControlBar(QWidget): + seekRelativeRequested = pyqtSignal(int) + stopRequested = pyqtSignal() + playPauseRequested = pyqtSignal() + nextPrevClipRequested = pyqtSignal(int) + nextPrevAnnotRequested = pyqtSignal(int) + playbackRateRequested = pyqtSignal(float) + + def __init__(self, parent=None): + super().__init__(parent) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 5, 0, 5) + + # Row 1: Navigation + r1 = QHBoxLayout() + btns_r1 = [ + ("Prev Clip", lambda: self.nextPrevClipRequested.emit(-1)), + ("<< 5s", lambda: self.seekRelativeRequested.emit(-5000)), + ("<< 1s", lambda: self.seekRelativeRequested.emit(-1000)), + ("Play/Pause", lambda: self.playPauseRequested.emit()), + ("1s >>", lambda: self.seekRelativeRequested.emit(1000)), + ("5s >>", lambda: self.seekRelativeRequested.emit(5000)), + ("Next Clip", lambda: self.nextPrevClipRequested.emit(1)) + ] + for txt, func in btns_r1: + b = QPushButton(txt) + b.setCursor(Qt.CursorShape.PointingHandCursor) + b.clicked.connect(func) + r1.addWidget(b) + layout.addLayout(r1) + + # Row 2: Speed & Event Jump + r2 = QHBoxLayout() + + btn_prev_ann = QPushButton("Prev Event") + btn_prev_ann.clicked.connect(lambda: self.nextPrevAnnotRequested.emit(-1)) + r2.addWidget(btn_prev_ann) + + speeds = [0.25, 0.5, 1.0, 2.0, 4.0] + for s in speeds: + b = QPushButton(f"{s}x") + b.clicked.connect(lambda _, rate=s: self.playbackRateRequested.emit(rate)) + r2.addWidget(b) + + btn_next_ann = QPushButton("Next Event") + btn_next_ann.clicked.connect(lambda: self.nextPrevAnnotRequested.emit(1)) + r2.addWidget(btn_next_ann) + + layout.addLayout(r2) \ No newline at end of file diff --git a/annotation_tool/ui/localization/media_player/player_panel.py b/annotation_tool/ui/localization/media_player/player_panel.py new file mode 100644 index 0000000..acc9419 --- /dev/null +++ b/annotation_tool/ui/localization/media_player/player_panel.py @@ -0,0 +1,70 @@ +from PyQt6.QtWidgets import QWidget, QVBoxLayout +from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput +from PyQt6.QtCore import pyqtSignal, QUrl + +# [CHANGED] Import from common +from ui.common.video_surface import VideoSurface + +class MediaPreviewWidget(QWidget): + """ + Wrapper widget for the Localization media player. + It delegates rendering to the shared VideoSurface. + """ + positionChanged = pyqtSignal(int) + durationChanged = pyqtSignal(int) + stateChanged = pyqtSignal(object) + + def __init__(self, parent=None): + super().__init__(parent) + layout = QVBoxLayout(self) + layout.setContentsMargins(0,0,0,0) + + # [Refactor] Instantiate the Shared VideoSurface + self.surface = VideoSurface() + layout.addWidget(self.surface) + + # [Compatibility] Expose internal components for Localization Manager + self.player = self.surface.player + self.audio = self.surface.audio_output + self.video_widget = self.surface.video_widget + + # Forward signals + self.player.positionChanged.connect(self.positionChanged.emit) + self.player.durationChanged.connect(self.durationChanged.emit) + self.player.playbackStateChanged.connect(self.stateChanged.emit) + self.player.errorOccurred.connect(self._on_error) + + def load_video(self, path): + """Loads source via shared surface but does not auto-play.""" + # Note: Localization Manager handles the delayed play() call + self.surface.load_source(path) + + # Ensure visibility for rendering + if not self.video_widget.isVisible(): + self.video_widget.show() + + def play(self): + self.player.play() + + def pause(self): + self.player.pause() + + def stop(self): + self.player.stop() + + def toggle_play_pause(self): + if self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState: + self.player.pause() + else: + if self.player.mediaStatus() == QMediaPlayer.MediaStatus.EndOfMedia: + self.player.setPosition(0) + self.player.play() + + def set_position(self, ms): + self.player.setPosition(ms) + + def set_playback_rate(self, rate): + self.player.setPlaybackRate(rate) + + def _on_error(self): + print(f"Media Error: {self.player.errorString()}") \ No newline at end of file diff --git a/annotation_tool/ui/localization/media_player/preview.py b/annotation_tool/ui/localization/media_player/preview.py new file mode 100644 index 0000000..4a22d2b --- /dev/null +++ b/annotation_tool/ui/localization/media_player/preview.py @@ -0,0 +1,80 @@ +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QSizePolicy +from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput +from PyQt6.QtMultimediaWidgets import QVideoWidget +from PyQt6.QtCore import Qt, pyqtSignal, QUrl + +class MediaPreviewWidget(QWidget): + positionChanged = pyqtSignal(int) + durationChanged = pyqtSignal(int) + stateChanged = pyqtSignal(object) + + def __init__(self, parent=None): + super().__init__(parent) + layout = QVBoxLayout(self) + layout.setContentsMargins(0,0,0,0) + + self.video_widget = QVideoWidget() + self.video_widget.setProperty("class", "video_preview_widget") + self.video_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + + self.player = QMediaPlayer() + self.audio = QAudioOutput() + + # [Fix] Max volume ensures audio sink keeps the clock running + self.audio.setVolume(1.0) + self.player.setAudioOutput(self.audio) + self.player.setVideoOutput(self.video_widget) + + layout.addWidget(self.video_widget) + + self.player.positionChanged.connect(self.positionChanged.emit) + self.player.durationChanged.connect(self.durationChanged.emit) + self.player.playbackStateChanged.connect(self.stateChanged.emit) + self.player.errorOccurred.connect(self._on_error) + + def load_video(self, path): + """ + Loads the video source BUT DOES NOT PLAY. + Playback is handled by the Controller via QTimer to prevent macOS rendering freeze. + """ + # 1. Reset pipeline + self.player.stop() + self.player.setSource(QUrl()) + + # 2. Force widget visibility before loading + if not self.video_widget.isVisible(): + self.video_widget.show() + + # 3. Load + self.player.setSource(QUrl.fromLocalFile(path)) + + # [CRITICAL CHANGE] REMOVED self.player.play() + # We leave the player in StoppedState. The Manager will trigger play() + # after a short delay (200ms) to ensure the window handle is valid. + + def play(self): + self.player.play() + + def pause(self): + self.player.pause() + + def stop(self): + self.player.stop() + + def toggle_play_pause(self): + if self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState: + self.player.pause() + else: + # Auto-restart if at end + if self.player.mediaStatus() == QMediaPlayer.MediaStatus.EndOfMedia: + self.player.setPosition(0) + self.player.play() + + def set_position(self, ms): + self.player.setPosition(ms) + + def set_playback_rate(self, rate): + self.player.setPlaybackRate(rate) + + def _on_error(self): + print(f"Media Error: {self.player.errorString()}") \ No newline at end of file diff --git a/Tool/ui2/widgets/center_widgets.py b/annotation_tool/ui/localization/media_player/timeline.py similarity index 54% rename from Tool/ui2/widgets/center_widgets.py rename to annotation_tool/ui/localization/media_player/timeline.py index bf03cab..fa01887 100644 --- a/Tool/ui2/widgets/center_widgets.py +++ b/annotation_tool/ui/localization/media_player/timeline.py @@ -1,60 +1,47 @@ from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QSlider, QLabel, QPushButton, - QStyle, QStyleOptionSlider, QSizePolicy, QScrollArea, QScrollBar + QStyle, QStyleOptionSlider, QScrollArea, QScrollBar ) -from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput -from PyQt6.QtMultimediaWidgets import QVideoWidget -from PyQt6.QtCore import Qt, pyqtSignal, QUrl +from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtGui import QPainter, QColor, QPen -class MediaPreviewWidget(QWidget): - positionChanged = pyqtSignal(int) - durationChanged = pyqtSignal(int) - stateChanged = pyqtSignal(object) - - def __init__(self, parent=None): - super().__init__(parent) - layout = QVBoxLayout(self) - layout.setContentsMargins(0,0,0,0) +class AnnotationSlider(QSlider): + def __init__(self, orientation, parent=None): + super().__init__(orientation, parent) + self.markers = [] + + def paintEvent(self, event): + # 1. Call system draw + super().paintEvent(event) - self.video_widget = QVideoWidget() - self.video_widget.setStyleSheet("background-color: black;") - self.video_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + if not self.markers or self.maximum() <= 0: return - self.player = QMediaPlayer() - self.audio = QAudioOutput() - self.player.setAudioOutput(self.audio) - self.player.setVideoOutput(self.video_widget) + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) - layout.addWidget(self.video_widget) + opt = QStyleOptionSlider() + self.initStyleOption(opt) - self.player.positionChanged.connect(self.positionChanged.emit) - self.player.durationChanged.connect(self.durationChanged.emit) - self.player.playbackStateChanged.connect(self.stateChanged.emit) - self.player.errorOccurred.connect(self._on_error) - - def load_video(self, path): - self.player.setSource(QUrl.fromLocalFile(path)) - self.player.pause() - self.player.setPosition(0) - - def play(self): self.player.play() - def pause(self): self.player.pause() - def stop(self): self.player.stop() + groove = self.style().subControlRect(QStyle.ComplexControl.CC_Slider, opt, QStyle.SubControl.SC_SliderGroove, self) + handle_rect = self.style().subControlRect(QStyle.ComplexControl.CC_Slider, opt, QStyle.SubControl.SC_SliderHandle, self) - def toggle_play_pause(self): - if self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState: - self.player.pause() - else: - if self.player.position() >= self.player.duration() and self.player.duration() > 0: - self.player.setPosition(0) - self.player.play() + available_width = groove.width() + x_offset = groove.x() + + # 2. Draw markers + for m in self.markers: + start_ms = m.get('start_ms', 0) + ratio = start_ms / self.maximum() + x = x_offset + int(available_width * ratio) + + c = m.get('color', QColor('red')) + painter.setPen(QPen(c, 2)) + painter.drawLine(x, groove.top() - 2, x, groove.bottom() + 2) - def set_position(self, ms): self.player.setPosition(ms) - def set_playback_rate(self, rate): self.player.setPlaybackRate(rate) - - def _on_error(self): - print(f"Media Error: {self.player.errorString()}") + # 3. Redraw handle on top + painter.setPen(QPen(QColor("#FF3333"), 1)) + painter.setBrush(QColor("#FF3333")) + painter.drawRoundedRect(handle_rect, 4, 4) class TimelineWidget(QWidget): @@ -64,7 +51,6 @@ class TimelineWidget(QWidget): def __init__(self, parent=None): super().__init__(parent) - # 整体高度 self.setFixedHeight(60) main_layout = QVBoxLayout(self) @@ -74,7 +60,7 @@ def __init__(self, parent=None): # 1. Time Label self.time_label = QLabel("00:00.000 / 00:00.000") self.time_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.time_label.setStyleSheet("font-family: 'Courier New', monospace; font-size: 12px; font-weight: bold; color: #EEE;") + self.time_label.setProperty("class", "timeline_time_lbl") main_layout.addWidget(self.time_label) # 2. Timeline Row @@ -83,20 +69,10 @@ def __init__(self, parent=None): timeline_row.setContentsMargins(5, 0, 5, 0) timeline_row.setAlignment(Qt.AlignmentFlag.AlignVCenter) - # 按钮样式 - btn_style = """ - QPushButton { - background-color: #444; color: white; border: 1px solid #555; - border-radius: 4px; font-weight: bold; font-size: 14px; - } - QPushButton:hover { background-color: #555; } - QPushButton:pressed { background-color: #666; } - """ - - # Zoom Out Button (-) + # Zoom Out self.btn_zoom_out = QPushButton("-") self.btn_zoom_out.setFixedSize(24, 24) - self.btn_zoom_out.setStyleSheet(btn_style) + self.btn_zoom_out.setProperty("class", "timeline_zoom_btn") self.btn_zoom_out.clicked.connect(lambda: self._change_zoom(-1)) timeline_row.addWidget(self.btn_zoom_out) @@ -107,53 +83,15 @@ def __init__(self, parent=None): self.scroll_area.setFixedHeight(30) self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.scroll_area.setProperty("class", "timeline_scroll_area") - # [修改] 增大滚动条高度,方便拖动 - self.scroll_area.setStyleSheet(""" - QScrollArea { background: transparent; } - QScrollBar:horizontal { - border: none; - background: #222; - height: 12px; /* [修改] 从 4px 增加到 12px */ - margin: 0px; - border-radius: 6px; - } - QScrollBar::handle:horizontal { - background: #666; - min-width: 20px; - border-radius: 6px; /* [修改] 圆角调整 */ - } - QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0px; } - """) - # 监听底部滚动条操作 self.scroll_bar = self.scroll_area.horizontalScrollBar() self.scroll_bar.sliderPressed.connect(self._on_user_scroll_start) self.scroll_bar.sliderReleased.connect(self._on_user_scroll_end) self.slider = AnnotationSlider(Qt.Orientation.Horizontal) - # 样式表:定义了红色把手的外观 - self.slider.setStyleSheet(""" - QSlider::groove:horizontal { - border: 1px solid #3A3A3A; - height: 6px; - background: #202020; - margin: 0px; - border-radius: 3px; - } - QSlider::handle:horizontal { - background: #FF3333; - border: 1px solid #FF3333; - width: 8px; - height: 16px; - margin: -5px 0; - border-radius: 4px; - } - QSlider::sub-page:horizontal { - background: #444; - border-radius: 3px; - } - """) + self.slider.setProperty("class", "timeline_slider") self.slider.sliderPressed.connect(self._on_slider_pressed) self.slider.sliderMoved.connect(self._on_slider_moved) self.slider.sliderReleased.connect(self._on_slider_released) @@ -161,10 +99,10 @@ def __init__(self, parent=None): self.scroll_area.setWidget(self.slider) timeline_row.addWidget(self.scroll_area) - # Zoom In Button (+) + # Zoom In self.btn_zoom_in = QPushButton("+") self.btn_zoom_in.setFixedSize(24, 24) - self.btn_zoom_in.setStyleSheet(btn_style) + self.btn_zoom_in.setProperty("class", "timeline_zoom_btn") self.btn_zoom_in.clicked.connect(lambda: self._change_zoom(1)) timeline_row.addWidget(self.btn_zoom_in) @@ -175,7 +113,7 @@ def __init__(self, parent=None): self.is_dragging = False self.user_is_scrolling = False self.zoom_level = 1.0 - self.auto_scroll_active = True # 控制自动跟随 + self.auto_scroll_active = True def resizeEvent(self, event): super().resizeEvent(event) @@ -213,7 +151,6 @@ def _change_zoom(self, direction): def _update_slider_width(self): viewport_width = self.scroll_area.viewport().width() - if self.zoom_level <= 1.0: self.scroll_area.setWidgetResizable(True) self.slider.setMinimumWidth(0) @@ -246,11 +183,8 @@ def _check_and_restore_auto_follow(self): self.auto_scroll_active = True def _auto_scroll_to_playhead(self, current_ms): - if self.zoom_level <= 1.0 or self.duration <= 0: - return - - if self.user_is_scrolling or not self.auto_scroll_active: - return + if self.zoom_level <= 1.0 or self.duration <= 0: return + if self.user_is_scrolling or not self.auto_scroll_active: return ratio = current_ms / self.duration slider_width = self.slider.width() @@ -293,96 +227,4 @@ def _on_slider_pressed(self): def _on_slider_moved(self, val): self._update_label(val) def _on_slider_released(self): self.is_dragging = False - self.seekRequested.emit(self.slider.value()) - - -class AnnotationSlider(QSlider): - def __init__(self, orientation, parent=None): - super().__init__(orientation, parent) - self.markers = [] - - def paintEvent(self, event): - # 1. 先调用系统绘制(画轨道和系统自带的 Handle) - super().paintEvent(event) - - if not self.markers or self.maximum() <= 0: return - - painter = QPainter(self) - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - - opt = QStyleOptionSlider() - self.initStyleOption(opt) - - # 获取轨道和把手的几何区域 - groove = self.style().subControlRect(QStyle.ComplexControl.CC_Slider, opt, QStyle.SubControl.SC_SliderGroove, self) - handle_rect = self.style().subControlRect(QStyle.ComplexControl.CC_Slider, opt, QStyle.SubControl.SC_SliderHandle, self) - - available_width = groove.width() - x_offset = groove.x() - - # 2. 绘制事件标记 (覆盖在系统把手上) - for m in self.markers: - start_ms = m.get('start_ms', 0) - ratio = start_ms / self.maximum() - x = x_offset + int(available_width * ratio) - - c = m.get('color', QColor('red')) - painter.setPen(QPen(c, 2)) - painter.drawLine(x, groove.top() - 2, x, groove.bottom() + 2) - - # 3. 手动再绘制一次红色把手 (覆盖在标记上) - painter.setPen(QPen(QColor("#FF3333"), 1)) - painter.setBrush(QColor("#FF3333")) - painter.drawRoundedRect(handle_rect, 4, 4) - - -class PlaybackControlBar(QWidget): - seekRelativeRequested = pyqtSignal(int) - stopRequested = pyqtSignal() - playPauseRequested = pyqtSignal() - nextPrevClipRequested = pyqtSignal(int) - nextPrevAnnotRequested = pyqtSignal(int) - playbackRateRequested = pyqtSignal(float) - - def __init__(self, parent=None): - super().__init__(parent) - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 5, 0, 5) - - # Row 1: Navigation - r1 = QHBoxLayout() - btns_r1 = [ - ("Prev Clip", lambda: self.nextPrevClipRequested.emit(-1)), - ("<< 5s", lambda: self.seekRelativeRequested.emit(-5000)), - ("<< 1s", lambda: self.seekRelativeRequested.emit(-1000)), - ("Play/Pause", lambda: self.playPauseRequested.emit()), - ("1s >>", lambda: self.seekRelativeRequested.emit(1000)), - ("5s >>", lambda: self.seekRelativeRequested.emit(5000)), - ("Next Clip", lambda: self.nextPrevClipRequested.emit(1)) - ] - for txt, func in btns_r1: - b = QPushButton(txt) - b.setCursor(Qt.CursorShape.PointingHandCursor) - b.clicked.connect(func) - r1.addWidget(b) - layout.addLayout(r1) - - # Row 2: Speed & Event Jump - r2 = QHBoxLayout() - - btn_prev_ann = QPushButton("Prev Event") - btn_prev_ann.clicked.connect(lambda: self.nextPrevAnnotRequested.emit(-1)) - r2.addWidget(btn_prev_ann) - - # [修改] 添加了 4.0 倍速 - speeds = [0.25, 0.5, 1.0, 2.0, 4.0] - for s in speeds: - b = QPushButton(f"{s}x") - b.clicked.connect(lambda _, rate=s: self.playbackRateRequested.emit(rate)) - r2.addWidget(b) - - btn_next_ann = QPushButton("Next Event") - btn_next_ann.clicked.connect(lambda: self.nextPrevAnnotRequested.emit(1)) - r2.addWidget(btn_next_ann) - - layout.addLayout(r2) + self.seekRequested.emit(self.slider.value()) \ No newline at end of file diff --git a/Tool/utils.py b/annotation_tool/utils.py similarity index 94% rename from Tool/utils.py rename to annotation_tool/utils.py index 13812b0..81f425b 100644 --- a/Tool/utils.py +++ b/annotation_tool/utils.py @@ -17,13 +17,10 @@ # --- Helper Functions --- def resource_path(relative_path): """Get absolute path to resource, works for dev and for PyInstaller.""" - # PyInstaller runtime if hasattr(sys, "_MEIPASS"): base_path = sys._MEIPASS else: - # Source run: base is Tool/ (where utils.py lives) base_path = os.path.dirname(os.path.abspath(__file__)) - return os.path.join(base_path, relative_path) @@ -69,4 +66,4 @@ def natural_sort_key(s): # Safety check for None or non-string if not isinstance(s, str): return [] - return [int(text) if text.isdigit() else text.lower() for text in re.split('([0-9]+)', s)] + return [int(text) if text.isdigit() else text.lower() for text in re.split('([0-9]+)', s)] \ No newline at end of file diff --git a/annotation_tool/viewer.py b/annotation_tool/viewer.py new file mode 100644 index 0000000..087f497 --- /dev/null +++ b/annotation_tool/viewer.py @@ -0,0 +1,702 @@ +import os + +from PyQt6.QtCore import Qt, QTimer, QModelIndex, QUrl +from PyQt6.QtGui import QColor, QIcon, QKeySequence, QShortcut, QStandardItem +from PyQt6.QtMultimedia import QMediaPlayer +from PyQt6.QtWidgets import QMainWindow, QMessageBox,QSizePolicy + +from controllers.classification.class_annotation_manager import AnnotationManager +from controllers.classification.class_navigation_manager import NavigationManager +from controllers.history_manager import HistoryManager +from controllers.localization.localization_manager import LocalizationManager +# Import Description Managers +from controllers.description.desc_navigation_manager import DescNavigationManager +from controllers.description.desc_annotation_manager import DescAnnotationManager +# [NEW] Import Dense Description Manager +from controllers.dense_description.dense_manager import DenseManager + +from controllers.router import AppRouter +from models import AppStateModel + +from ui.common.main_window import MainWindowUI +from models.project_tree import ProjectTreeModel +from utils import create_checkmark_icon, natural_sort_key, resource_path + + +class ActionClassifierApp(QMainWindow): + """Main application window for annotation + localization + description + dense workflows.""" + + FILTER_ALL = 0 + FILTER_DONE = 1 + FILTER_NOT_DONE = 2 + + def __init__(self) -> None: + super().__init__() + + self.setWindowTitle("SoccerNet Pro Analysis Tool") + self.setGeometry(100, 100, 600, 400) + + # --- MVC wiring --- + self.ui = MainWindowUI() + self.setCentralWidget(self.ui) + self.model = AppStateModel() + + # Instantiate the Project Tree Model + self.tree_model = ProjectTreeModel(self) + + # Bind the model to Views + self.ui.classification_ui.left_panel.tree.setModel(self.tree_model) + self.ui.localization_ui.left_panel.tree.setModel(self.tree_model) + self.ui.description_ui.left_panel.tree.setModel(self.tree_model) + # [NEW] Bind to Dense Description View + self.ui.dense_description_ui.left_panel.tree.setModel(self.tree_model) + + # --- Controllers --- + self.router = AppRouter(self) + self.history_manager = HistoryManager(self) + self.annot_manager = AnnotationManager(self) + self.nav_manager = NavigationManager(self) + self.loc_manager = LocalizationManager(self) + + # Description Mode Controllers + self.desc_nav_manager = DescNavigationManager(self) + self.desc_annot_manager = DescAnnotationManager(self) + + # [NEW] Dense Description Controller + self.dense_manager = DenseManager(self) + + # --- Local UI state (icons, etc.) --- + bright_blue = QColor("#00BFFF") + self.done_icon = create_checkmark_icon(bright_blue) + self.empty_icon = QIcon() + + # --- Setup --- + self.connect_signals() + self.load_stylesheet() + + # Default state: classification right panel is disabled until project loads + self.ui.classification_ui.right_panel.manual_box.setEnabled(False) + + self.setup_dynamic_ui() + self._setup_shortcuts() + + self.ui.stack_layout.currentChanged.connect(self._adjust_window_size) + # Start at welcome screen + self.ui.show_welcome_view() + self._adjust_window_size(0) + + # --------------------------------------------------------------------- + # Global Media Control to Prevent Freezing/Ghost Frames + # --------------------------------------------------------------------- + def stop_all_players(self): + """ + Forcefully stops ALL media players in ALL modes and clears their sources. + This prevents the 'stuck frame' or 'black screen' issue when switching + projects or modes. + """ + # 1. Classification + if hasattr(self.nav_manager, 'media_controller'): + self.nav_manager.media_controller.stop() + + # 2. Localization + if hasattr(self.loc_manager, 'media_controller'): + self.loc_manager.media_controller.stop() + + # 3. Description + if hasattr(self.desc_nav_manager, 'media_controller'): + self.desc_nav_manager.media_controller.stop() + + # 4. [NEW] Dense Description + if hasattr(self.dense_manager, 'media_controller'): + self.dense_manager.media_controller.stop() + + def _adjust_window_size(self, index: int) -> None: + """ + Dynamically exchange the size of windows + """ + for i in range(self.ui.stack_layout.count()): + widget = self.ui.stack_layout.widget(i) + if not widget: + continue + + if i == index: + widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + else: + widget.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored) + + self.ui.updateGeometry() + + if index == 0: + if self.isMaximized(): + self.showNormal() + + self.setMinimumSize(0, 0) + + self.resize(600, 400) + else: + self.setMinimumSize(1000, 700) + + self.resize(1400, 900) + + def _safe_import_annotations(self): + """Wrapper to ensure players are stopped before loading a new project.""" + self.stop_all_players() + self.router.import_annotations() + + def _safe_create_project(self): + """Wrapper to ensure players are stopped before creating a new project.""" + self.stop_all_players() + self.router.create_new_project_flow() + + # --------------------------------------------------------------------- + # Wiring + # --------------------------------------------------------------------- + def connect_signals(self) -> None: + """Connect UI signals to controller actions.""" + + # Welcome screen + self.ui.welcome_widget.import_btn.clicked.connect(self._safe_import_annotations) + self.ui.welcome_widget.create_btn.clicked.connect(self._safe_create_project) + + # --- Classification - Left panel --- + cls_left = self.ui.classification_ui.left_panel + cls_controls = cls_left.project_controls + + cls_controls.createRequested.connect(self._safe_create_project) + cls_controls.loadRequested.connect(self._safe_import_annotations) + + cls_controls.addVideoRequested.connect(self.nav_manager.add_items_via_dialog) + cls_controls.closeRequested.connect(self.router.close_project) + cls_controls.saveRequested.connect(self.router.class_fm.save_json) + cls_controls.exportRequested.connect(self.router.class_fm.export_json) + + cls_left.clear_btn.clicked.connect(self._on_class_clear_clicked) + cls_left.request_remove_item.connect(self._on_remove_item_requested) + cls_left.tree.selectionModel().currentChanged.connect(self._on_tree_selection_changed) + cls_left.filter_combo.currentIndexChanged.connect(self.nav_manager.apply_action_filter) + + # --- Classification - Center panel --- + cls_center = self.ui.classification_ui.center_panel + cls_center.play_btn.clicked.connect(self.nav_manager.play_video) + cls_center.prev_action.clicked.connect(self.nav_manager.nav_prev_action) + cls_center.prev_clip.clicked.connect(self.nav_manager.nav_prev_clip) + cls_center.next_clip.clicked.connect(self.nav_manager.nav_next_clip) + cls_center.next_action.clicked.connect(self.nav_manager.nav_next_action) + + # --- 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) + 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) + + # 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) + self.ui.localization_ui.right_panel.undo_btn.clicked.connect(self.history_manager.perform_undo) + self.ui.localization_ui.right_panel.redo_btn.clicked.connect(self.history_manager.perform_redo) + + # --- Localization panel --- + loc_controls = self.ui.localization_ui.left_panel.project_controls + loc_controls.createRequested.connect(self._safe_create_project) + loc_controls.loadRequested.connect(self._safe_import_annotations) + loc_controls.closeRequested.connect(self.router.close_project) + + self.loc_manager.setup_connections() + + # --- Description Panel Wiring --- + desc_left = self.ui.description_ui.left_panel + desc_controls = desc_left.project_controls + + desc_controls.createRequested.connect(self._safe_create_project) + desc_controls.loadRequested.connect(self._safe_import_annotations) + desc_controls.closeRequested.connect(self.router.close_project) + + desc_controls.addVideoRequested.connect(self.desc_nav_manager.add_items_via_dialog) + desc_controls.saveRequested.connect(self.router.desc_fm.save_json) + desc_controls.exportRequested.connect(self.router.desc_fm.export_json) + + desc_left.filter_combo.currentIndexChanged.connect(self.desc_nav_manager.apply_action_filter) + desc_left.clear_btn.clicked.connect(self._on_desc_clear_clicked) + + self.desc_nav_manager.setup_connections() + self.desc_annot_manager.setup_connections() + + desc_right = self.ui.description_ui.right_panel + desc_right.undo_btn.clicked.connect(self.history_manager.perform_undo) + desc_right.redo_btn.clicked.connect(self.history_manager.perform_redo) + + # --- [NEW] Dense Description Panel Wiring --- + dense_left = self.ui.dense_description_ui.left_panel + dense_controls = dense_left.project_controls + + dense_controls.createRequested.connect(self._safe_create_project) + dense_controls.loadRequested.connect(self._safe_import_annotations) + dense_controls.closeRequested.connect(self.router.close_project) + + dense_controls.addVideoRequested.connect(self.dense_manager._on_add_video_clicked) + dense_controls.saveRequested.connect(self.router.dense_fm.overwrite_json) + dense_controls.exportRequested.connect(self.router.dense_fm.export_json) + + dense_left.filter_combo.currentIndexChanged.connect(self.dense_manager._apply_clip_filter) + dense_left.clear_btn.clicked.connect(self.dense_manager._on_clear_all_clicked) + dense_left.request_remove_item.connect(self.dense_manager.remove_single_item) + + # Initialize connections for Dense logic + self.dense_manager.setup_connections() + + # Connect Undo/Redo for Dense Right Panel + dense_right = self.ui.dense_description_ui.right_panel + dense_right.undo_btn.clicked.connect(self.history_manager.perform_undo) + dense_right.redo_btn.clicked.connect(self.history_manager.perform_redo) + + def _setup_shortcuts(self) -> None: + """Register common keyboard shortcuts.""" + QShortcut(QKeySequence("Ctrl+O"), self).activated.connect(self._safe_import_annotations) + + QShortcut(QKeySequence("Ctrl+S"), self).activated.connect(self._dispatch_save) + QShortcut(QKeySequence("Ctrl+Shift+S"), self).activated.connect(self._dispatch_export) + + QShortcut(QKeySequence("Ctrl+E"), self).activated.connect( + lambda: self.show_temp_msg("Settings", "Settings dialog not implemented yet.") + ) + QShortcut(QKeySequence("Ctrl+D"), self).activated.connect( + lambda: self.show_temp_msg("Downloader", "Dataset downloader not implemented yet.") + ) + + QShortcut(QKeySequence.StandardKey.Undo, self).activated.connect(self.history_manager.perform_undo) + QShortcut(QKeySequence.StandardKey.Redo, self).activated.connect(self.history_manager.perform_redo) + + QShortcut(QKeySequence(Qt.Key.Key_Space), self).activated.connect(self._dispatch_play_pause) + QShortcut(QKeySequence(Qt.Key.Key_Left), self).activated.connect(lambda: self._dispatch_seek(-40)) + QShortcut(QKeySequence(Qt.Key.Key_Right), self).activated.connect(lambda: self._dispatch_seek(40)) + QShortcut(QKeySequence("Ctrl+Left"), self).activated.connect(lambda: self._dispatch_seek(-1000)) + QShortcut(QKeySequence("Ctrl+Right"), self).activated.connect(lambda: self._dispatch_seek(1000)) + QShortcut(QKeySequence("Ctrl+Shift+Left"), self).activated.connect(lambda: self._dispatch_seek(-5000)) + QShortcut(QKeySequence("Ctrl+Shift+Right"), self).activated.connect(lambda: self._dispatch_seek(5000)) + + QShortcut(QKeySequence("A"), self).activated.connect(self._dispatch_add_annotation) + QShortcut(QKeySequence("S"), self).activated.connect( + lambda: self.show_temp_msg("Info", "Select an event and edit time via right-click.") + ) + + # --------------------------------------------------------------------- + # MV Adapter Methods + # --------------------------------------------------------------------- + def _on_tree_selection_changed(self, current: QModelIndex, previous: QModelIndex): + # Description and Dense handle their own signals in their respective managers + if self._is_desc_mode() or self._is_dense_mode(): + return + + if current.isValid(): + self.nav_manager.on_item_selected(current, previous) + + def _on_remove_item_requested(self, index: QModelIndex): + """Handle context menu remove request via Model Index.""" + if index.isValid(): + self.nav_manager.remove_single_action_item(index) + + # --------------------------------------------------------------------- + # Mode-aware dispatchers + # --------------------------------------------------------------------- + def _is_loc_mode(self) -> bool: + return self.ui.stack_layout.currentWidget() == self.ui.localization_ui + + def _is_desc_mode(self) -> bool: + """Helper to check if current view is Global Description.""" + if self.ui.stack_layout.currentWidget() == self.ui.description_ui: + return True + task = str(self.model.current_task_name).lower() + return ("caption" in task or "description" in task) and "dense" not in task + + def _is_dense_mode(self) -> bool: + """[NEW] Helper to check if current view is Dense Description.""" + return self.ui.stack_layout.currentWidget() == self.ui.dense_description_ui + + def _dispatch_save(self) -> None: + if self._is_loc_mode(): + self.router.loc_fm.overwrite_json() + elif self._is_desc_mode(): + self.desc_annot_manager.save_current_annotation() + self.router.desc_fm.save_json() + elif self._is_dense_mode(): + # [NEW] Save Dense Description + self.router.dense_fm.overwrite_json() + else: + self.router.class_fm.save_json() + + def _dispatch_export(self) -> None: + if self._is_loc_mode(): + self.router.loc_fm.export_json() + elif self._is_desc_mode(): + self.router.desc_fm.export_json() + elif self._is_dense_mode(): + # [NEW] Export Dense Description + self.router.dense_fm.export_json() + else: + self.router.class_fm.export_json() + + def _dispatch_play_pause(self) -> None: + if self._is_loc_mode(): + self.loc_manager.media_controller.toggle_play_pause() + elif self._is_desc_mode(): + self.desc_nav_manager.media_controller.toggle_play_pause() + elif self._is_dense_mode(): + # [NEW] Forward to Dense Controller + self.dense_manager.media_controller.toggle_play_pause() + else: + self.nav_manager.media_controller.toggle_play_pause() + + def _dispatch_seek(self, delta_ms: int) -> None: + player = None + if self._is_loc_mode(): + player = self.loc_manager.center_panel.media_preview.player + elif self._is_desc_mode(): + player = self.ui.description_ui.center_panel.player + elif self._is_dense_mode(): + # [NEW] Get player from Dense View (shared with LocCenterPanel structure) + player = self.dense_manager.center_panel.media_preview.player + else: + player = self.ui.classification_ui.center_panel.single_view_widget.player + + if player: + player.setPosition(max(0, player.position() + delta_ms)) + + def _dispatch_add_annotation(self) -> None: + """Handles the 'A' shortcut based on mode.""" + if self._is_loc_mode(): + current_head = self.loc_manager.current_head + if not current_head: + self.show_temp_msg("Warning", "No head/category selected.", icon=QMessageBox.Icon.Warning) + return + self.loc_manager._on_label_add_req(current_head) + elif self._is_desc_mode(): + self.desc_annot_manager.save_current_annotation() + elif self._is_dense_mode(): + # [NEW] Trigger description submission from Input Widget + self.dense_manager.right_panel.input_widget._on_submit() + else: + self.annot_manager.save_manual_annotation() + + # --------------------------------------------------------------------- + # UI actions / helpers + # --------------------------------------------------------------------- + def _on_class_clear_clicked(self) -> None: + if not self.model.json_loaded and not self.model.action_item_data: + return + + msg = QMessageBox(self) + msg.setWindowTitle("Clear Workspace") + msg.setText("Clear workspace? Unsaved changes will be lost.") + msg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel) + + if msg.exec() == QMessageBox.StandardButton.Yes: + self.stop_all_players() + self.router.class_fm._clear_workspace(full_reset=True) + + def _on_desc_clear_clicked(self) -> None: + if not self.model.json_loaded and not self.model.action_item_data: + return + + msg = QMessageBox(self) + msg.setWindowTitle("Clear Workspace") + msg.setText("Clear description workspace? Unsaved changes will be lost.") + msg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel) + + if msg.exec() == QMessageBox.StandardButton.Yes: + self.stop_all_players() + self.router.desc_fm._clear_workspace(full_reset=True) + + def prepare_new_project_ui(self) -> None: + self.ui.classification_ui.right_panel.manual_box.setEnabled(True) + self.ui.classification_ui.right_panel.task_label.setText(f"Task: {self.model.current_task_name}") + self.show_temp_msg("New Project Created", "Classification Workspace ready.") + + def prepare_new_localization_ui(self) -> None: + self.ui.localization_ui.right_panel.setEnabled(True) + self.statusBar().showMessage("New Project Created — Localization Workspace ready.", 1500) + + def prepare_new_description_ui(self) -> None: + self.ui.description_ui.right_panel.setEnabled(True) + self.statusBar().showMessage("New Project Created — Description Workspace ready.", 1500) + + def prepare_new_dense_ui(self) -> None: + """[NEW] Unlocks the Dense Description UI components.""" + self.ui.dense_description_ui.right_panel.setEnabled(True) + self.statusBar().showMessage("New Project Created — Dense Description Workspace ready.", 1500) + + def load_stylesheet(self) -> None: + style_path = resource_path(os.path.join("style", "style.qss")) + try: + with open(style_path, "r", encoding="utf-8") as f: + self.setStyleSheet(f.read()) + except Exception as exc: + print(f"Style error: {exc}") + + def check_and_close_current_project(self) -> bool: + if not self.model.json_loaded: + return True + + msg_box = QMessageBox(self) + msg_box.setWindowTitle("Close Project") + msg_box.setText("Opening a new project or closing will clear the current workspace. Continue?") + msg_box.setIcon(QMessageBox.Icon.Warning) + + if self.model.is_data_dirty: + msg_box.setInformativeText("You have unsaved changes in the current project.") + + btn_yes = msg_box.addButton("Yes", QMessageBox.ButtonRole.AcceptRole) + btn_no = msg_box.addButton("No", QMessageBox.ButtonRole.RejectRole) + msg_box.setDefaultButton(btn_no) + msg_box.exec() + + if msg_box.clickedButton() == btn_yes: + self.stop_all_players() + + return msg_box.clickedButton() == btn_yes + + def closeEvent(self, event) -> None: + is_loc = self._is_loc_mode() + is_desc = self._is_desc_mode() + is_dense = self._is_dense_mode() # [NEW] + + has_data = False + if is_loc: + has_data = bool(self.model.localization_events) + elif is_desc: + has_data = self.model.json_loaded + elif is_dense: + # [NEW] Check dense data + has_data = bool(self.model.dense_description_events) + else: + has_data = bool(self.model.manual_annotations) + + can_export = self.model.json_loaded and has_data + + if not self.model.is_data_dirty or not can_export: + self.stop_all_players() + event.accept() + return + + msg = QMessageBox(self) + msg.setWindowTitle("Unsaved Annotations") + msg.setText("Do you want to save your annotations before quitting?") + msg.setIcon(QMessageBox.Icon.Question) + + save_btn = msg.addButton("Save & Exit", QMessageBox.ButtonRole.AcceptRole) + discard_btn = msg.addButton("Discard & Exit", QMessageBox.ButtonRole.DestructiveRole) + msg.addButton("Cancel", QMessageBox.ButtonRole.RejectRole) + + msg.setDefaultButton(save_btn) + msg.exec() + + if msg.clickedButton() == save_btn: + ok = False + if is_loc: + ok = self.router.loc_fm.overwrite_json() + elif is_desc: + self.desc_annot_manager.save_current_annotation() + ok = self.router.desc_fm.save_json() + elif is_dense: + # [NEW] Save Dense + ok = self.router.dense_fm.overwrite_json() + else: + ok = self.router.class_fm.save_json() + + if ok: + self.stop_all_players() + event.accept() + else: + event.ignore() + elif msg.clickedButton() == discard_btn: + self.stop_all_players() + event.accept() + else: + event.ignore() + + def update_save_export_button_state(self) -> None: + is_loc = self._is_loc_mode() + is_desc = self._is_desc_mode() + is_dense = self._is_dense_mode() # [NEW] + + has_data = False + if is_loc: + has_data = bool(self.model.localization_events) + elif is_desc: + has_data = self.model.json_loaded + elif is_dense: + # [NEW] + has_data = bool(self.model.dense_description_events) + else: + has_data = bool(self.model.manual_annotations) + + 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 + + # Update controls across all mode panels + for panel in [self.ui.classification_ui, self.ui.localization_ui, + self.ui.description_ui, self.ui.dense_description_ui]: + panel.left_panel.project_controls.btn_save.setEnabled(can_save) + panel.left_panel.project_controls.btn_export.setEnabled(can_export) + + can_undo = len(self.model.undo_stack) > 0 + can_redo = len(self.model.redo_stack) > 0 + + self.ui.classification_ui.right_panel.undo_btn.setEnabled(can_undo) + self.ui.classification_ui.right_panel.redo_btn.setEnabled(can_redo) + self.ui.localization_ui.right_panel.undo_btn.setEnabled(can_undo) + self.ui.localization_ui.right_panel.redo_btn.setEnabled(can_redo) + self.ui.description_ui.right_panel.undo_btn.setEnabled(can_undo) + self.ui.description_ui.right_panel.redo_btn.setEnabled(can_redo) + # [NEW] Dense right panel buttons + self.ui.dense_description_ui.right_panel.undo_btn.setEnabled(can_undo) + self.ui.dense_description_ui.right_panel.redo_btn.setEnabled(can_redo) + + def show_temp_msg(self, title: str, msg: str, duration: int = 1500, **kwargs) -> None: + one_line = " ".join(str(msg).splitlines()).strip() + text = f"{title} — {one_line}" if title else one_line + self.statusBar().showMessage(text, duration) + + def get_current_action_path(self): + """Return the selected action path from the tree (top-level item path).""" + tree_view = None + if self._is_loc_mode(): + tree_view = self.ui.localization_ui.left_panel.tree + elif self._is_desc_mode(): + tree_view = self.ui.description_ui.left_panel.tree + elif self._is_dense_mode(): + tree_view = self.ui.dense_description_ui.left_panel.tree + else: + tree_view = self.ui.classification_ui.left_panel.tree + + idx = tree_view.selectionModel().currentIndex() + if not idx.isValid(): + return None + if idx.parent().isValid(): + return idx.parent().data(ProjectTreeModel.FilePathRole) + return idx.data(ProjectTreeModel.FilePathRole) + + 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", ""))) + + for data in sorted_list: + item = self.tree_model.add_entry( + name=data["name"], + path=data["path"], + source_files=data.get("source_files") + ) + self.model.action_item_map[data["path"]] = item + + for path in self.model.action_item_map.keys(): + self.update_action_item_status(path) + + # Decide which manager handles the navigation logic + if self._is_loc_mode(): + self.loc_manager._apply_clip_filter(self.ui.localization_ui.left_panel.filter_combo.currentIndex()) + elif self._is_desc_mode(): + self.desc_nav_manager.apply_action_filter() + elif self._is_dense_mode(): + self.dense_manager._apply_clip_filter(self.ui.dense_description_ui.left_panel.filter_combo.currentIndex()) + else: + self.nav_manager.apply_action_filter() + + active_tree = None + if self._is_desc_mode(): + active_tree = self.ui.description_ui.left_panel.tree + elif not self._is_loc_mode() and not self._is_dense_mode(): + active_tree = self.ui.classification_ui.left_panel.tree + + if active_tree and self.tree_model.rowCount() > 0: + first_index = self.tree_model.index(0, 0) + if first_index.isValid(): + active_tree.setCurrentIndex(first_index) + + + + def update_action_item_status(self, action_path: str) -> None: + item: QStandardItem = self.model.action_item_map.get(action_path) + if not item: + return + + is_done = False + if self._is_loc_mode(): + is_done = action_path in self.model.localization_events and bool(self.model.localization_events[action_path]) + elif self._is_desc_mode(): + # Correctly identify if the action has ANY non-empty caption text + for d in self.model.action_item_data: + # Support both path and ID matching + if d.get("path") == action_path or d.get("id") == action_path: + captions = d.get("captions", []) + if any(cap.get("text", "").strip() for cap in captions): + is_done = True + break + + 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]) + + item.setIcon(self.done_icon if is_done else self.empty_icon) + + + def setup_dynamic_ui(self) -> None: + cls_right = self.ui.classification_ui.right_panel + cls_right.setup_dynamic_labels(self.model.label_definitions) + cls_right.task_label.setText(f"Task: {self.model.current_task_name}") + self._connect_dynamic_type_buttons() + + def _connect_dynamic_type_buttons(self) -> None: + for head, group in self.ui.classification_ui.right_panel.label_groups.items(): + try: + group.add_btn.clicked.disconnect() + except Exception: + pass + group.add_btn.clicked.connect(lambda _, h=head: self.annot_manager.add_custom_type(h)) + group.remove_label_signal.connect(lambda lbl, h=head: self.annot_manager.remove_custom_type(h, lbl)) + group.value_changed.connect(lambda h, v: self.annot_manager.handle_ui_selection_change(h, v)) + + 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. + """ + if not action_path: + return + + # 1. Update the tree icon status + self.update_action_item_status(action_path) + + # 2. Ensure the item is selected in the active tree + active_tree = None + if self._is_loc_mode(): + active_tree = self.ui.localization_ui.left_panel.tree + elif self._is_desc_mode(): + active_tree = self.ui.description_ui.left_panel.tree + elif self._is_dense_mode(): + active_tree = self.ui.dense_description_ui.left_panel.tree + else: + active_tree = self.ui.classification_ui.left_panel.tree + + item: QStandardItem = self.model.action_item_map.get(action_path) + if item and active_tree: + idx = item.index() + if active_tree.currentIndex() != idx: + active_tree.setCurrentIndex(idx) + + # 3. Refresh the Right Panel Content + if self._is_loc_mode(): + self.loc_manager._display_events_for_item(action_path) + 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() diff --git a/docs/readme.md b/docs/README.md similarity index 100% rename from docs/readme.md rename to docs/README.md diff --git a/docs/assets/README.md b/docs/assets/README.md new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/docs/assets/README.md @@ -0,0 +1 @@ + diff --git a/docs/assets/classification-UI.png b/docs/assets/classification-UI.png index 9a6f729..69ab071 100644 Binary files a/docs/assets/classification-UI.png and b/docs/assets/classification-UI.png differ diff --git a/docs/assets/dense-description-UI.png b/docs/assets/dense-description-UI.png new file mode 100644 index 0000000..74c17e4 Binary files /dev/null and b/docs/assets/dense-description-UI.png differ diff --git a/docs/assets/description-UI.png b/docs/assets/description-UI.png new file mode 100644 index 0000000..2cd39f3 Binary files /dev/null and b/docs/assets/description-UI.png differ diff --git a/docs/assets/landing-page.png b/docs/assets/landing-page.png new file mode 100644 index 0000000..f2ab24f Binary files /dev/null and b/docs/assets/landing-page.png differ diff --git a/docs/assets/localization-UI.png b/docs/assets/localization-UI.png index aa92b9b..7cf743c 100644 Binary files a/docs/assets/localization-UI.png and b/docs/assets/localization-UI.png differ diff --git a/docs/gui_overview.md b/docs/gui_overview.md index 76207d6..82aa8e4 100644 --- a/docs/gui_overview.md +++ b/docs/gui_overview.md @@ -3,8 +3,41 @@ The OSL Annotation Tool supports two distinct annotation modes: **Classification** and **Localization (Action Spotting)**. The interface adapts based on the project type selected at startup. --- +# 1. Welcome Page (Startup Interface) -## 1. Classification Mode +The Welcome Page is the entry point of the OSL Annotation Tool. It allows users to create or import projects and access additional resources. + +![Welcome Interface](assets/landing-page.png) + +### Main Actions + +* **Create New Project** + + * Start a new annotation project. + * You will be prompted to select the annotation mode (Classification, Localization, Description, or Dense Description). + * Initializes an empty workspace. + +* **Import Project JSON** + + * Load an existing annotation project from a JSON file. + * The interface automatically adapts to the project type. + * If the JSON contains validation errors, a detailed error message will be displayed. + +### Additional Resources + +* **Video Tutorial** + + * Opens a guided tutorial explaining how to use the tool. + * Recommended for first-time users. + +* **GitHub Repo** + + * Redirects to the official GitHub repository. + * Includes documentation, updates, and issue tracking. + +--- + +## 2. Classification Mode Designed for assigning global labels (Single or Multi-label) to video clips. @@ -15,7 +48,8 @@ Designed for assigning global labels (Single or Multi-label) to video clips. - **Status Icons:** A checkmark (✓) indicates the clip has been annotated. - **Add Data:** Import new video files into the current project. - **Clear:** Clears the current workspace. -- **Undo/Redo:** Controls to undo or redo annotation actions. +- **Project Controls:** New Project, Load Project, Add Data, Save JSON, Export JSON. + ### Center Panel: Video Player - **Video Display:** Main playback area for the selected clip. @@ -23,7 +57,6 @@ Designed for assigning global labels (Single or Multi-label) to video clips. - Standard Play/Pause. - Frame stepping and seeking (1s, 5s). - Playback speed control (0.25x to 4.0x). - - **Multi-View:** (If supported) Toggle distinct views for the clip. ### Right Panel: Labeling - **Task Info:** Displays the current task name. @@ -33,21 +66,21 @@ Designed for assigning global labels (Single or Multi-label) to video clips. - **Dynamic Editing:** Users can add new label options on the fly using the input field within each group. - **Controls:** - **Confirm Annotation:** Saves the current selection to the clip. + - **Undo/Redo:** Controls to undo or redo annotation actions. - **Clear Selection:** Resets the current selection. - **Save/Export:** Options to save the project JSON. --- -## 2. Localization Mode (Action Spotting) +## 3. Localization Mode (Action Spotting) Designed for marking specific timestamps (spotting) with event labels. ![Localization Interface](assets/localization-UI.png) -*(Ensure you have a screenshot named `localization_ui.png` in your assets folder)* ### Left Panel: Sequence Management - **Clip List:** Hierarchical view of video sequences. -- **Project Controls:** Load, Add Video, Save, and Export JSON. +- **Project Controls:** New Project, Load Project, Add Data, Save JSON, Export JSON. - **Filter:** Filter the list to show "All", "Labelled Only", or "Unlabelled Only". - **Clear All:** Resets the entire workspace. @@ -77,3 +110,152 @@ This panel is divided into a header, a spotting area, and an event list. - **Interaction:** - **Double-click:** Jumps the video player to the event's timestamp. - **Right-click:** Opens a context menu to **Edit Time**, **Change Head/Label**, or **Delete Event**. + +--- + +# 4. Description Mode (Clip-level Description / Captioning) + +Designed for assigning structured textual descriptions to short video clips, including question–answer style annotations. + +![Description Interface](assets/description-UI.png) + +### Left Panel: Action / Clip Management + +* **Action List:** Displays imported action clips (e.g., XFouls_test_action_*). + + * A checkmark (✓) indicates the clip has been annotated. +* **Project Controls:** New Project, Load Project, Add Data, Save JSON, Export JSON. +* **Filter:** Filter by annotation status. +* **Clear All:** Clears the current workspace. + +--- + +### Center Panel: Video Player + +* **Video Display:** Main playback area for the selected action clip. +* **Timeline Slider:** Shows clip progress. +* **Playback Controls:** + + * Play / Pause + * Navigate between actions or clips + * Fine seeking +* **Time Indicator:** Displays current time and total clip duration. + +--- + +### Right Panel: Description / Caption Annotation + +This panel is dedicated to structured textual annotation. + +#### Description / Caption Text Area + +* Large editable text field. +* Supports multi-line structured annotations. +* Typical format includes: + + * Question–Answer (Q/A) + * Event reasoning explanations + * Referee decision analysis + +Example structure: + +``` +Q: "Is it a foul or not? Why?" +A: "..." +``` + +#### Controls + +* **Confirm** + + * Saves the description to the current clip. +* **Clear** + + * Clears the text field without saving. +* **Undo / Redo** + + * Reverts recent text changes. +--- + +# 5. Dense Description Mode (Event-level Captioning) + +Designed for fine-grained event-level captioning across full-length videos. + +![Dense Description Interface](assets/dense-description-UI.png) + +This mode combines timestamped events with free-text descriptions. + +--- + +## Left Panel: Video Management + +* **Video List:** Displays imported video halves (e.g., GB1415_TE_001_H1, H2). + + * ✓ indicates that the video contains at least one annotated event. +* **Project Controls:** New Project, Load Project, Add Data, Save JSON, Export JSON. +* **Filter:** Filter videos by annotation state. +* **Clear All:** Resets workspace. + +--- + +## Center Panel: Timeline & Player + +* **Media Preview:** Main video playback window. +* **Timeline:** + + * Visual representation of the full video duration. + * **Markers:** + + * Yellow ticks represent annotated events. + * Red indicator represents the current playhead position. +* **Current Time Display:** Shown above the description input. +* **Playback Controls:** + + * Frame stepping + * 1s / 5s seeking + * Speed control (0.25x–4.0x) + * Previous/Next event navigation + +--- + +## Right Panel: Dense Annotation + +Divided into two functional areas. + +--- + +### Top: Create / Edit Description + +* **Current Time Indicator** + + * Displays the precise timestamp of the playhead. +* **Description Text Box** + + * Enter detailed natural-language descriptions of events. + * Designed for dense commentary-style annotation. +* **Confirm Description** + + * Saves a new event at the current timestamp. + * Adds a marker to the timeline. + * Appends the event to the event table. + +--- + +### Bottom: Events List (Table) + +Displays all annotated events in chronological order. + +#### Columns: + +* **Time** – Timestamp of the event. +* **Lang** – Language tag (e.g., "en"). +* **Description** – The textual annotation. + +#### Interaction: + +* **Single-click** – Select event. +* **Double-click** – Jump to event timestamp. +* **Editing** – Modify description text and confirm to update. +* **Delete** – Remove event from table and timeline. + +--- diff --git a/readme.md b/readme.md deleted file mode 100644 index 48e8409..0000000 --- a/readme.md +++ /dev/null @@ -1,196 +0,0 @@ -# SoccerNetPro Analyzer (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). - ---- - -## Features - -- Open and visualize SoccerNetPro-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. - ---- - -## 🔧 Environment Setup - -We recommend using [Anaconda](https://www.anaconda.com/) or [Miniconda](https://docs.conda.io/en/latest/miniconda.html) for managing your Python environment. - -> **Note:** The GUI project lives in the `Tool/` subdirectory of this repository, and dependencies are defined in `Tool/requirements.txt`. - -### Step 0 – Clone the repository - -```bash -git clone https://github.com/OpenSportsLab/soccernetpro-ui.git -cd soccernetpro-ui -``` - - -### Step 1 – Create a new Conda environment - -```bash -conda create -n soccernetpro-ui python=3.9 -y -conda activate soccernetpro-ui -``` - - -### Step 2 – Install dependencies -```bash -pip install -r Tool/requirements.txt -``` ---- - -## 🚀 Run the GUI -From the repository root, launch the app with: -```bash -python Tool/main.py -``` -A window will open where you can load your data and start working. - - ---- - - -## 📦 Download Test Datasets - -This project provides **test datasets** for two tasks: **Classification** and **Localization**. -More details are available at:[`/test_data`](https://github.com/OpenSportsLab/soccernetpro-ui/tree/main/test_data) - - -> ⚠️ **Important** -> For both tasks, the corresponding **JSON annotation file must be placed in the same directory** -> as the data folder (`classification/` or `england efl/`), otherwise the GUI will not load the data correctly. -> Some Hugging Face datasets (including SoccerNetPro localization and classification datasets) are restricted / gated. So you must: - -1.Have access to the dataset on Hugging Face - -2.Be authenticated locally using your Hugging Face account - - -### **Requirements** - -* Python 3.x -* `huggingface_hub` Python package (install with `pip install huggingface_hub`) - - -### 🟦 Classification – Test Data - -**Data location (HuggingFace):** -[Classification Dataset](https://huggingface.co/datasets/OpenSportsLab/soccernetpro-classification-vars) - -This folder contains multiple action-category subfolders (e.g. `action_0`, `action_1`, …). - -#### 📥 Download via command line - -**Classification – svfouls** - -```bash -python test_data/download_osl_hf.py \ - --url https://huggingface.co/datasets/OpenSportsLab/soccernetpro-classification-vars/blob/svfouls/annotations_test.json \ - --output-dir Test_Data/Classification/svfouls -``` - -**Classification – mvfouls** - -```bash -python test_data/download_osl_hf.py \ - --url https://huggingface.co/datasets/OpenSportsLab/soccernetpro-classification-vars/blob/mvfouls/annotations_test.json \ - --output-dir Test_Data/Classification/mvfouls -``` - -### 🟩 Localization – Test Data -**Data location (HuggingFace):** -[Localization Dataset](https://huggingface.co/datasets/OpenSportsLab/soccernetpro-localization-snas) - -Each folder (e.g., `england efl/`) contains video clips for localization testing. - -#### 📥 Download via command line - -From the repository root: - -```bash -python test_data/download_osl_hf.py \ - --url https://huggingface.co/datasets/OpenSportsLab/soccernetpro-localization-snbas/blob/224p/annotations-test.json \ - --output-dir Test_Data/Localization -``` ---- - - -## 🧰 Build a standalone app (PyInstaller) -### **macOS (.app)** - -From the repository root: -```bash -cd Tool -pyinstaller --noconfirm --clean --windowed \ - --name "SoccerNetProAnalyzer" \ - --add-data "style:style" \ - --add-data "ui:ui" \ - --add-data "ui2:ui2" \ - main.py -``` - -### **Windows / Linux** (one-file binary) - -From the repository root: - -```bash -cd Tool -pyinstaller --noconfirm --clean --windowed --onefile \ - --name "SoccerNetProAnalyzer" \ - --add-data "style:style" \ - --add-data "ui:ui" \ - --add-data "ui2:ui2" \ - main.py -``` - -In GitHub Actions, the Windows ```bash--add-data``` separator is ```;``` instead of ```:```. - - -### 🤖 How executables are built (CI / GitHub Releases) - -In addition to manual PyInstaller builds, standalone executables are automatically built and published using GitHub Actions. - -When a version tag (v* or V* such as V1.0.7) is pushed to the repository, a release workflow is triggered (`.github/workflows/release.yml`). -This workflow: - -- Builds standalone GUI executables using PyInstaller -- Targets Windows, macOS, and Linux separately -- Bundles required UI assets (`ui/`, `ui2/`, `style/`) into the binary -- Packages each platform binary into a ZIP archive -- Uploads the artifacts to the corresponding GitHub Release - -The build logic in the CI pipeline mirrors the manual PyInstaller commands described above, ensuring consistency between local and automated builds. - -CI workflows overview: - -- `ci.yml`: Continuous integration (linting / checks) -- `release.yml`: Multi-platform executable build and GitHub Release publishing -- `deploy_docs.yml`: Documentation build and deployment (MkDocs) - ---- -## 📚 Build the docs -```bash -pip install mkdocs mkdocs-material mkdocstrings[python] -mkdocs gh-deploy --force -``` - ---- - - -## 📜 License - -This Soccernet Pro 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 -), 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. - - - - diff --git a/test_data/download_osl_hf.py b/test_data/download_osl_hf.py index 6f39240..189e834 100644 --- a/test_data/download_osl_hf.py +++ b/test_data/download_osl_hf.py @@ -5,7 +5,7 @@ from huggingface_hub import hf_hub_download, snapshot_download, HfApi -def human_size(num): +def human_size(num: int) -> str: """Convert a file size in bytes to a human-readable string (B, KB, MB, GB, TB).""" for unit in ["B", "KB", "MB", "GB", "TB"]: if num < 1024.0: @@ -14,25 +14,30 @@ def human_size(num): return f"{num:.1f} PB" -def fix_hf_url(hf_url): +def fix_hf_url(hf_url: str) -> str: """Convert a HuggingFace 'blob' URL to a 'resolve' URL for direct download.""" return hf_url.replace("/blob/", "/resolve/") -def parse_hf_url(hf_url): +def parse_hf_url(hf_url: str): """ Parse a Hugging Face dataset file URL (supports 'blob' or 'resolve' forms). Returns (repo_id, revision, path_in_repo). + Example: + https://huggingface.co/datasets/ORG/REPO/blob/main/annotations_test.json + -> repo_id="ORG/REPO", revision="main", path_in_repo="annotations_test.json" """ url = fix_hf_url(hf_url) parsed = urlparse(url) parts = parsed.path.strip("/").split("/") + # Remove leading "datasets" if present if "datasets" in parts: datasets_idx = parts.index("datasets") parts = parts[datasets_idx + 1 :] - if len(parts) < 4 or parts[2] != "resolve": + # Expected: ORG / REPO / resolve / REVISION / + if len(parts) < 5 or parts[2] != "resolve": raise ValueError(f"URL does not look like a valid HuggingFace dataset file URL: {url}") repo_id = f"{parts[0]}/{parts[1]}" @@ -42,53 +47,88 @@ def parse_hf_url(hf_url): return repo_id, revision, path_in_repo -def get_json_repo_folder(path_in_repo): - """ - Return the folder containing the JSON inside the repo, or '' if at root. - """ +def get_json_repo_folder(path_in_repo: str) -> str: + """Return the folder containing the JSON inside the repo, or '' if at root.""" folder = os.path.dirname(path_in_repo) return folder if folder and folder != "." else "" -def extract_video_paths(osl_json): +def parse_types_arg(types_arg: str): """ - Extract video paths from different OSL / SoccerNetPro JSON schemas. + Parse --types argument. + - "all" means include any input that has a "path". + - Otherwise it's a comma-separated list of input types (e.g. "video,captions,features"). + """ + types_arg = (types_arg or "video").strip().lower() + if types_arg in ("all", "*"): + return "all" + return {t.strip() for t in types_arg.split(",") if t.strip()} + + +def extract_repo_paths_from_json(osl_json: dict, want_types): + """ + Extract file paths from different OSL / SoccerNetPro JSON schemas. Supported formats: - - videos[].path - - data[].inputs[].path (where type == "video") + - videos[].path (legacy/simple) + - data[].inputs[].path (OSL v2) + where input has fields: {type, path, ...} + + want_types: + - "all" -> any input with a "path" + - set(...) -> only inputs whose inp["type"] is in the set """ repo_paths = [] - # Legacy / simple format - if "videos" in osl_json: - for v in osl_json.get("videos", []): - if "path" in v: - repo_paths.append(v["path"].lstrip("/")) + # Legacy/simple format + if "videos" in osl_json and isinstance(osl_json.get("videos"), list): + # Only include if caller wants videos + if want_types == "all" or ("video" in want_types): + for v in osl_json.get("videos", []): + if isinstance(v, dict) and "path" in v: + repo_paths.append(str(v["path"]).lstrip("/")) - # SoccerNetPro / OSL v2 format - elif "data" in osl_json: + # OSL v2 format + if "data" in osl_json and isinstance(osl_json.get("data"), list): for item in osl_json.get("data", []): for inp in item.get("inputs", []): - if inp.get("type") == "video" and "path" in inp: - repo_paths.append(inp["path"].lstrip("/")) + if not isinstance(inp, dict): + continue + p = inp.get("path") + if not p: + continue + inp_type = str(inp.get("type", "")).strip().lower() + + if want_types == "all": + repo_paths.append(str(p).lstrip("/")) + else: + if inp_type in want_types: + repo_paths.append(str(p).lstrip("/")) if not repo_paths: - raise ValueError("No video paths found in the provided OSL JSON.") + if want_types == "all": + raise ValueError("No file paths found in the provided JSON (no inputs with 'path').") + else: + raise ValueError( + f"No matching file paths found for requested types={sorted(list(want_types))}. " + "Check your JSON schema and --types." + ) return repo_paths -def main(osl_json_url, output_dir="downloaded_data", dry_run=False): +def main(osl_json_url: str, output_dir: str = "downloaded_data", dry_run: bool = False, types_arg: str = "video"): api = HfApi() + want_types = parse_types_arg(types_arg) # Parse HuggingFace URL repo_id, revision, path_in_repo = parse_hf_url(osl_json_url) repo_json_folder = get_json_repo_folder(path_in_repo) - print(f"⬇️ Downloading OSL JSON from {repo_id}@{revision}: {path_in_repo}") + print(f"⬇️ Downloading JSON from {repo_id}@{revision}: {path_in_repo}") os.makedirs(output_dir, exist_ok=True) + # Download JSON itself hf_json_path = hf_hub_download( repo_id=repo_id, repo_type="dataset", @@ -97,28 +137,32 @@ def main(osl_json_url, output_dir="downloaded_data", dry_run=False): local_dir=output_dir, local_dir_use_symlinks=False, ) - - print(f" → Saved as {hf_json_path}") + print(f" → Saved as: {hf_json_path}") # Load JSON - with open(hf_json_path, "r") as f: + with open(hf_json_path, "r", encoding="utf-8") as f: osl = json.load(f) - # Extract video paths (schema-aware) - repo_paths = extract_video_paths(osl) - print(f"Found {len(repo_paths)} video files to download.") - - def repo_full_path(rel_path): - if repo_json_folder and not rel_path.startswith(repo_json_folder + "/"): - return os.path.join(repo_json_folder, rel_path) + # Extract repo paths (schema-aware) + repo_paths = extract_repo_paths_from_json(osl, want_types) + print(f"Found {len(repo_paths)} referenced files for types={types_arg}.") + + # If JSON file lives in a repo subfolder, some inputs may be relative to that folder. + # We keep your original behavior: if path doesn't start with repo_json_folder, prefix it. + def repo_full_path(rel_path: str) -> str: + rel_path = rel_path.lstrip("/") + if repo_json_folder: + prefix = repo_json_folder.rstrip("/") + "/" + if not rel_path.startswith(prefix): + return prefix + rel_path return rel_path - # Unique, repo-relative paths allow_patterns = sorted(set(repo_full_path(p) for p in repo_paths)) if dry_run: print("Running in DRY-RUN mode (no files will be downloaded).") + # Fetch file sizes via repo metadata (best effort) try: info_obj = api.repo_info( repo_id=repo_id, @@ -152,9 +196,11 @@ def repo_full_path(rel_path): print(f"Total estimated storage needed: {human_size(total_size)}") if missing_files: - print(f"WARNING: {len(missing_files)} files not found in repo:") - for f in missing_files: + print(f"WARNING: {len(missing_files)} files not found in repo metadata:") + for f in missing_files[:50]: print(f" - {f}") + if len(missing_files) > 50: + print(f" ... and {len(missing_files) - 50} more") else: print(f"Downloading {len(allow_patterns)} files using snapshot_download...") @@ -166,26 +212,36 @@ def repo_full_path(rel_path): allow_patterns=allow_patterns, max_workers=8, ) - print(f" → All requested files downloaded to: {output_dir}") + print(f"✅ Done. All requested files downloaded to: {output_dir}") if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Download videos referenced in an OSL JSON from HuggingFace.") + parser = argparse.ArgumentParser( + description="Download files referenced in an OSL JSON from Hugging Face (dataset repo)." + ) parser.add_argument( "--url", required=True, - help="URL of the OSL JSON file on HuggingFace", + help="URL of the OSL JSON file on Hugging Face (blob/resolve both supported)", ) parser.add_argument( "--output-dir", default="downloaded_data", help="Directory to store downloaded files", ) + parser.add_argument( + "--types", + default="video", + help=( + "Comma-separated input types to download from item.inputs (e.g. 'video', 'video,captions', " + "'video,captions,features'), or 'all' to download all inputs with a path. Default: video" + ), + ) parser.add_argument( "--dry-run", action="store_true", - help="List files to download without downloading them", + help="List files to download without downloading them (estimates total size if possible).", ) args = parser.parse_args() - main(args.url, args.output_dir, dry_run=args.dry_run) \ No newline at end of file + main(args.url, args.output_dir, dry_run=args.dry_run, types_arg=args.types) diff --git a/test_data/invalid_json/invalid_classification_json/IMPORT02.1.json b/test_data/invalid_json/invalid_classification_json/labels_empty.json similarity index 100% rename from test_data/invalid_json/invalid_classification_json/IMPORT02.1.json rename to test_data/invalid_json/invalid_classification_json/labels_empty.json diff --git a/test_data/invalid_json/invalid_classification_json/IMPORT01.1.json b/test_data/invalid_json/invalid_classification_json/modalities_empty.json similarity index 100% rename from test_data/invalid_json/invalid_classification_json/IMPORT01.1.json rename to test_data/invalid_json/invalid_classification_json/modalities_empty.json diff --git a/test_data/invalid_json/invalid_classification_json/IMPORT03.json b/test_data/invalid_json/invalid_classification_json/modalities_type_is_wrong.json similarity index 100% rename from test_data/invalid_json/invalid_classification_json/IMPORT03.json rename to test_data/invalid_json/invalid_classification_json/modalities_type_is_wrong.json diff --git a/test_data/invalid_json/invalid_classification_json/IMPORT02-2.json b/test_data/invalid_json/invalid_classification_json/no_labels.json similarity index 100% rename from test_data/invalid_json/invalid_classification_json/IMPORT02-2.json rename to test_data/invalid_json/invalid_classification_json/no_labels.json diff --git a/test_data/invalid_json/invalid_classification_json/IMPORT01-2.json b/test_data/invalid_json/invalid_classification_json/no_modalities.json similarity index 100% rename from test_data/invalid_json/invalid_classification_json/IMPORT01-2.json rename to test_data/invalid_json/invalid_classification_json/no_modalities.json diff --git a/test_data/invalid_json/invalid_classification_json/IMPORT04.json b/test_data/invalid_json/invalid_classification_json/syntax_error.json similarity index 100% rename from test_data/invalid_json/invalid_classification_json/IMPORT04.json rename to test_data/invalid_json/invalid_classification_json/syntax_error.json diff --git a/test_data/invalid_json/invalid_dense_description_json/README.md b/test_data/invalid_json/invalid_dense_description_json/README.md new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/test_data/invalid_json/invalid_dense_description_json/README.md @@ -0,0 +1 @@ + diff --git a/test_data/invalid_json/invalid_dense_description_json/data_is_not_list.json b/test_data/invalid_json/invalid_dense_description_json/data_is_not_list.json new file mode 100644 index 0000000..2667638 --- /dev/null +++ b/test_data/invalid_json/invalid_dense_description_json/data_is_not_list.json @@ -0,0 +1,6 @@ +{ + "version": "1.0", + "task": "dense_video_captioning", + "dataset_name": "OSL-Football-DenseCap (test)", + "data": {} +} diff --git a/test_data/invalid_json/invalid_dense_description_json/dataitem_is_not_dict.json.json b/test_data/invalid_json/invalid_dense_description_json/dataitem_is_not_dict.json.json new file mode 100644 index 0000000..6dfcfed --- /dev/null +++ b/test_data/invalid_json/invalid_dense_description_json/dataitem_is_not_dict.json.json @@ -0,0 +1,6 @@ +{ + "version": "1.0", + "task": "dense_video_captioning", + "dataset_name": "OSL-Football-DenseCap (test)", + "data": ["not_a_dict"] +} diff --git a/test_data/invalid_json/invalid_dense_description_json/dense_caption_missing_lang.json b/test_data/invalid_json/invalid_dense_description_json/dense_caption_missing_lang.json new file mode 100644 index 0000000..4f45be7 --- /dev/null +++ b/test_data/invalid_json/invalid_dense_description_json/dense_caption_missing_lang.json @@ -0,0 +1,16 @@ +{ + "version": "1.0", + "task": "dense_video_captioning", + "dataset_name": "OSL-Football-DenseCap (test)", + "data": [ + { + "id": "X1", + "inputs": [ + { "type": "video", "path": "a/b.mp4", "fps": 25 } + ], + "dense_captions": [ + { "position_ms": 1000, "text": "hello" } + ] + } + ] +} diff --git a/test_data/invalid_json/invalid_dense_description_json/dense_caption_missing_position_ms.json b/test_data/invalid_json/invalid_dense_description_json/dense_caption_missing_position_ms.json new file mode 100644 index 0000000..1fa7d78 --- /dev/null +++ b/test_data/invalid_json/invalid_dense_description_json/dense_caption_missing_position_ms.json @@ -0,0 +1,16 @@ +{ + "version": "1.0", + "task": "dense_video_captioning", + "dataset_name": "OSL-Football-DenseCap (test)", + "data": [ + { + "id": "X1", + "inputs": [ + { "type": "video", "path": "a/b.mp4", "fps": 25 } + ], + "dense_captions": [ + { "lang": "en", "text": "hello" } + ] + } + ] +} diff --git a/test_data/invalid_json/invalid_dense_description_json/dense_caption_missing_text.json b/test_data/invalid_json/invalid_dense_description_json/dense_caption_missing_text.json new file mode 100644 index 0000000..a65ccc1 --- /dev/null +++ b/test_data/invalid_json/invalid_dense_description_json/dense_caption_missing_text.json @@ -0,0 +1,16 @@ +{ + "version": "1.0", + "task": "dense_video_captioning", + "dataset_name": "OSL-Football-DenseCap (test)", + "data": [ + { + "id": "X1", + "inputs": [ + { "type": "video", "path": "a/b.mp4", "fps": 25 } + ], + "dense_captions": [ + { "position_ms": 1000, "lang": "en" } + ] + } + ] +} diff --git a/test_data/invalid_json/invalid_dense_description_json/dense_captions_is_not_list.json b/test_data/invalid_json/invalid_dense_description_json/dense_captions_is_not_list.json new file mode 100644 index 0000000..7c79342 --- /dev/null +++ b/test_data/invalid_json/invalid_dense_description_json/dense_captions_is_not_list.json @@ -0,0 +1,14 @@ +{ + "version": "1.0", + "task": "dense_video_captioning", + "dataset_name": "OSL-Football-DenseCap (test)", + "data": [ + { + "id": "X1", + "inputs": [ + { "type": "video", "path": "a/b.mp4", "fps": 25 } + ], + "dense_captions": {} + } + ] +} diff --git a/test_data/invalid_json/invalid_dense_description_json/fps_is_invalid.json b/test_data/invalid_json/invalid_dense_description_json/fps_is_invalid.json new file mode 100644 index 0000000..edf7b1b --- /dev/null +++ b/test_data/invalid_json/invalid_dense_description_json/fps_is_invalid.json @@ -0,0 +1,14 @@ +{ + "version": "1.0", + "task": "dense_video_captioning", + "dataset_name": "OSL-Football-DenseCap (test)", + "data": [ + { + "id": "X1", + "inputs": [ + { "type": "video", "path": "a/b.mp4", "fps": 0 } + ], + "dense_captions": [] + } + ] +} diff --git a/test_data/invalid_json/invalid_dense_description_json/input0_is_not_dict.json b/test_data/invalid_json/invalid_dense_description_json/input0_is_not_dict.json new file mode 100644 index 0000000..bcb2571 --- /dev/null +++ b/test_data/invalid_json/invalid_dense_description_json/input0_is_not_dict.json @@ -0,0 +1,12 @@ +{ + "version": "1.0", + "task": "dense_video_captioning", + "dataset_name": "OSL-Football-DenseCap (test)", + "data": [ + { + "id": "X1", + "inputs": ["video.mp4"], + "dense_captions": [] + } + ] +} diff --git a/test_data/invalid_json/invalid_dense_description_json/input0_is_not_video.json b/test_data/invalid_json/invalid_dense_description_json/input0_is_not_video.json new file mode 100644 index 0000000..c63afa2 --- /dev/null +++ b/test_data/invalid_json/invalid_dense_description_json/input0_is_not_video.json @@ -0,0 +1,14 @@ +{ + "version": "1.0", + "task": "dense_video_captioning", + "dataset_name": "OSL-Football-DenseCap (test)", + "data": [ + { + "id": "X1", + "inputs": [ + { "type": "features", "path": "features/I3D/x.npy", "dim": 1024 } + ], + "dense_captions": [] + } + ] +} diff --git a/test_data/invalid_json/invalid_dense_description_json/input0_missing_path.json b/test_data/invalid_json/invalid_dense_description_json/input0_missing_path.json new file mode 100644 index 0000000..dd09a1e --- /dev/null +++ b/test_data/invalid_json/invalid_dense_description_json/input0_missing_path.json @@ -0,0 +1,14 @@ +{ + "version": "1.0", + "task": "dense_video_captioning", + "dataset_name": "OSL-Football-DenseCap (test)", + "data": [ + { + "id": "X1", + "inputs": [ + { "type": "video", "fps": 25 } + ], + "dense_captions": [] + } + ] +} diff --git a/test_data/invalid_json/invalid_dense_description_json/inputs_is_empty_list.json b/test_data/invalid_json/invalid_dense_description_json/inputs_is_empty_list.json new file mode 100644 index 0000000..e3568b1 --- /dev/null +++ b/test_data/invalid_json/invalid_dense_description_json/inputs_is_empty_list.json @@ -0,0 +1,12 @@ +{ + "version": "1.0", + "task": "dense_video_captioning", + "dataset_name": "OSL-Football-DenseCap (test)", + "data": [ + { + "id": "X1", + "inputs": [], + "dense_captions": [] + } + ] +} diff --git a/test_data/invalid_json/invalid_dense_description_json/inputs_is_not_list.json b/test_data/invalid_json/invalid_dense_description_json/inputs_is_not_list.json new file mode 100644 index 0000000..4ba88c7 --- /dev/null +++ b/test_data/invalid_json/invalid_dense_description_json/inputs_is_not_list.json @@ -0,0 +1,12 @@ +{ + "version": "1.0", + "task": "dense_video_captioning", + "dataset_name": "OSL-Football-DenseCap (test)", + "data": [ + { + "id": "X1", + "inputs": {}, + "dense_captions": [] + } + ] +} diff --git a/test_data/invalid_json/invalid_dense_description_json/lang_empty.json b/test_data/invalid_json/invalid_dense_description_json/lang_empty.json new file mode 100644 index 0000000..2155eab --- /dev/null +++ b/test_data/invalid_json/invalid_dense_description_json/lang_empty.json @@ -0,0 +1,16 @@ +{ + "version": "1.0", + "task": "dense_video_captioning", + "dataset_name": "OSL-Football-DenseCap (test)", + "data": [ + { + "id": "X1", + "inputs": [ + { "type": "video", "path": "a/b.mp4", "fps": 25 } + ], + "dense_captions": [ + { "position_ms": 1000, "lang": "", "text": "hello" } + ] + } + ] +} diff --git a/test_data/invalid_json/invalid_dense_description_json/missing_data.json b/test_data/invalid_json/invalid_dense_description_json/missing_data.json new file mode 100644 index 0000000..57601ec --- /dev/null +++ b/test_data/invalid_json/invalid_dense_description_json/missing_data.json @@ -0,0 +1,5 @@ +{ + "version": "1.0", + "task": "dense_video_captioning", + "dataset_name": "OSL-Football-DenseCap (test)" +} diff --git a/test_data/invalid_json/invalid_dense_description_json/no_dense_captions.json b/test_data/invalid_json/invalid_dense_description_json/no_dense_captions.json new file mode 100644 index 0000000..b8ff42b --- /dev/null +++ b/test_data/invalid_json/invalid_dense_description_json/no_dense_captions.json @@ -0,0 +1,13 @@ +{ + "version": "1.0", + "task": "dense_video_captioning", + "dataset_name": "OSL-Football-DenseCap (test)", + "data": [ + { + "id": "X1", + "inputs": [ + { "type": "video", "path": "a/b.mp4", "fps": 25 } + ] + } + ] +} diff --git a/test_data/invalid_json/invalid_dense_description_json/no_inputs_json.json b/test_data/invalid_json/invalid_dense_description_json/no_inputs_json.json new file mode 100644 index 0000000..a0e9d89 --- /dev/null +++ b/test_data/invalid_json/invalid_dense_description_json/no_inputs_json.json @@ -0,0 +1,11 @@ +{ + "version": "1.0", + "task": "dense_video_captioning", + "dataset_name": "OSL-Football-DenseCap (test)", + "data": [ + { + "id": "X1", + "dense_captions": [] + } + ] +} diff --git a/test_data/invalid_json/invalid_dense_description_json/position_ms_invalid_format.json b/test_data/invalid_json/invalid_dense_description_json/position_ms_invalid_format.json new file mode 100644 index 0000000..a0c9ecf --- /dev/null +++ b/test_data/invalid_json/invalid_dense_description_json/position_ms_invalid_format.json @@ -0,0 +1,16 @@ +{ + "version": "1.0", + "task": "dense_video_captioning", + "dataset_name": "OSL-Football-DenseCap (test)", + "data": [ + { + "id": "X1", + "inputs": [ + { "type": "video", "path": "a/b.mp4", "fps": 25 } + ], + "dense_captions": [ + { "position_ms": "12.3s", "lang": "en", "text": "hello" } + ] + } + ] +} diff --git a/test_data/invalid_json/invalid_dense_description_json/position_ms_negative.json b/test_data/invalid_json/invalid_dense_description_json/position_ms_negative.json new file mode 100644 index 0000000..7681d89 --- /dev/null +++ b/test_data/invalid_json/invalid_dense_description_json/position_ms_negative.json @@ -0,0 +1,16 @@ +{ + "version": "1.0", + "task": "dense_video_captioning", + "dataset_name": "OSL-Football-DenseCap (test)", + "data": [ + { + "id": "X1", + "inputs": [ + { "type": "video", "path": "a/b.mp4", "fps": 25 } + ], + "dense_captions": [ + { "position_ms": -1, "lang": "en", "text": "hello" } + ] + } + ] +} diff --git a/test_data/invalid_json/invalid_dense_description_json/text_empty.json b/test_data/invalid_json/invalid_dense_description_json/text_empty.json new file mode 100644 index 0000000..9580c58 --- /dev/null +++ b/test_data/invalid_json/invalid_dense_description_json/text_empty.json @@ -0,0 +1,16 @@ +{ + "version": "1.0", + "task": "dense_video_captioning", + "dataset_name": "OSL-Football-DenseCap (test)", + "data": [ + { + "id": "X1", + "inputs": [ + { "type": "video", "path": "a/b.mp4", "fps": 25 } + ], + "dense_captions": [ + { "position_ms": 1000, "lang": "en", "text": "" } + ] + } + ] +} diff --git a/test_data/invalid_json/invalid_description_json/BadDateFormat.json b/test_data/invalid_json/invalid_description_json/BadDateFormat.json new file mode 100644 index 0000000..f166233 --- /dev/null +++ b/test_data/invalid_json/invalid_description_json/BadDateFormat.json @@ -0,0 +1,7 @@ +{ + "version": "1.0", + "date": "02/01/2026", + "task": "video_captioning", + "dataset_name": "demo", + "data": [] +} diff --git a/test_data/invalid_json/invalid_description_json/CaptionMissingText.json b/test_data/invalid_json/invalid_description_json/CaptionMissingText.json new file mode 100644 index 0000000..b9d6389 --- /dev/null +++ b/test_data/invalid_json/invalid_description_json/CaptionMissingText.json @@ -0,0 +1,13 @@ +{ + "version": "1.0", + "date": "2026-02-01", + "task": "video_captioning", + "dataset_name": "demo", + "data": [ + { + "id": "x0", + "inputs": [{ "type": "video", "name": "video1", "path": "a/clip_0.mp4" }], + "captions": [{ "lang": "en", "question": "Q?" }] + } + ] +} diff --git a/test_data/invalid_json/invalid_description_json/DataNotArray.json b/test_data/invalid_json/invalid_description_json/DataNotArray.json new file mode 100644 index 0000000..29f5581 --- /dev/null +++ b/test_data/invalid_json/invalid_description_json/DataNotArray.json @@ -0,0 +1,7 @@ +{ + "version": "1.0", + "date": "2026-02-01", + "task": "video_captioning", + "dataset_name": "demo", + "data": {} +} diff --git a/test_data/invalid_json/invalid_description_json/DuplicateSampleId.json b/test_data/invalid_json/invalid_description_json/DuplicateSampleId.json new file mode 100644 index 0000000..fcbb4e8 --- /dev/null +++ b/test_data/invalid_json/invalid_description_json/DuplicateSampleId.json @@ -0,0 +1,18 @@ +{ + "version": "1.0", + "date": "2026-02-01", + "task": "video_captioning", + "dataset_name": "demo", + "data": [ + { + "id": "dup_0", + "inputs": [{ "type": "video", "name": "video1", "path": "a/clip_0.mp4" }], + "captions": [{ "lang": "en", "text": "Caption 1" }] + }, + { + "id": "dup_0", + "inputs": [{ "type": "video", "name": "video1", "path": "b/clip_0.mp4" }], + "captions": [{ "lang": "en", "text": "Caption 2" }] + } + ] +} diff --git a/test_data/invalid_json/invalid_description_json/EmptyCaptionText.json b/test_data/invalid_json/invalid_description_json/EmptyCaptionText.json new file mode 100644 index 0000000..8655c26 --- /dev/null +++ b/test_data/invalid_json/invalid_description_json/EmptyCaptionText.json @@ -0,0 +1,13 @@ +{ + "version": "1.0", + "date": "2026-02-01", + "task": "video_captioning", + "dataset_name": "demo", + "data": [ + { + "id": "x0", + "inputs": [{ "type": "video", "name": "video1", "path": "a/clip_0.mp4" }], + "captions": [{ "lang": "en", "text": "" }] + } + ] +} diff --git a/test_data/invalid_json/invalid_description_json/InputTypeNotVideo.json b/test_data/invalid_json/invalid_description_json/InputTypeNotVideo.json new file mode 100644 index 0000000..7e1010b --- /dev/null +++ b/test_data/invalid_json/invalid_description_json/InputTypeNotVideo.json @@ -0,0 +1,13 @@ +{ + "version": "1.0", + "date": "2026-02-01", + "task": "video_captioning", + "dataset_name": "demo", + "data": [ + { + "id": "x0", + "inputs": [{ "type": "image", "name": "img1", "path": "a/0.jpg" }], + "captions": [{ "lang": "en", "text": "Hello" }] + } + ] +} diff --git a/test_data/invalid_json/invalid_description_json/MissingCaptions.json b/test_data/invalid_json/invalid_description_json/MissingCaptions.json new file mode 100644 index 0000000..0356496 --- /dev/null +++ b/test_data/invalid_json/invalid_description_json/MissingCaptions.json @@ -0,0 +1,12 @@ +{ + "version": "1.0", + "date": "2026-02-01", + "task": "video_captioning", + "dataset_name": "demo", + "data": [ + { + "id": "x0", + "inputs": [{ "type": "video", "name": "video1", "path": "a/clip_0.mp4" }] + } + ] +} diff --git a/test_data/invalid_json/invalid_description_json/MissingDatasetName.json b/test_data/invalid_json/invalid_description_json/MissingDatasetName.json new file mode 100644 index 0000000..c1ec752 --- /dev/null +++ b/test_data/invalid_json/invalid_description_json/MissingDatasetName.json @@ -0,0 +1,6 @@ +{ + "version": "1.0", + "date": "2026-02-01", + "task": "video_captioning", + "data": [] +} diff --git a/test_data/invalid_json/invalid_description_json/MissingInputs.json b/test_data/invalid_json/invalid_description_json/MissingInputs.json new file mode 100644 index 0000000..4e2af19 --- /dev/null +++ b/test_data/invalid_json/invalid_description_json/MissingInputs.json @@ -0,0 +1,12 @@ +{ + "version": "1.0", + "date": "2026-02-01", + "task": "video_captioning", + "dataset_name": "demo", + "data": [ + { + "id": "x0", + "captions": [{ "lang": "en", "text": "Hello" }] + } + ] +} diff --git a/test_data/invalid_json/invalid_description_json/MissingTask.json b/test_data/invalid_json/invalid_description_json/MissingTask.json new file mode 100644 index 0000000..a3d2bfc --- /dev/null +++ b/test_data/invalid_json/invalid_description_json/MissingTask.json @@ -0,0 +1,6 @@ +{ + "version": "1.0", + "date": "2026-02-01", + "dataset_name": "demo", + "data": [] +} diff --git a/test_data/invalid_json/invalid_description_json/README.md b/test_data/invalid_json/invalid_description_json/README.md new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/test_data/invalid_json/invalid_description_json/README.md @@ -0,0 +1 @@ + diff --git a/test_data/invalid_json/invalid_description_json/WrongTask.json b/test_data/invalid_json/invalid_description_json/WrongTask.json new file mode 100644 index 0000000..f0f4794 --- /dev/null +++ b/test_data/invalid_json/invalid_description_json/WrongTask.json @@ -0,0 +1,7 @@ +{ + "version": "1.0", + "date": "2026-02-01", + "task": "action_classification", + "dataset_name": "demo", + "data": [] +}