yolo数据集标记
This commit is contained in:
Generated
-1
@@ -2,6 +2,5 @@
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,185 @@
|
||||
# Zero-to-YOLO-Yard: A Localized, AI-Powered, Next-Generation Computer Vision Annotation Tool
|
||||
<img width="1629" height="527" alt="image" src="https://github.com/user-attachments/assets/4a4ec469-a1b9-48e4-8acf-2854d61fbc72" />
|
||||
|
||||
**Zero-to-YOLO-Yard** is an open-source tool deeply optimized for local deployment, designed to provide you with an end-to-end solution from raw video/images to a trainable dataset. It integrates cutting-edge AI technology to transform the tedious work of annotation into a simple, efficient, and even fun exploratory experience. Whether you are a robotics developer, a drone enthusiast, or a computer vision researcher, this tool will significantly accelerate your data processing workflow, with all data remaining securely on your own computer.
|
||||
|
||||
[](https://www.python.org/downloads/release/python-3100/)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[]()
|
||||
|
||||
---
|
||||
|
||||
## ✨ Core Highlights: More Than Just Annotation
|
||||
|
||||
- **🚀 Next-Generation AI-Assisted Annotation Engine**
|
||||
- **SAM 2.1 Point-to-Box**: Integrates Meta's latest SAM 2.1 model. A simple click on the target object automatically generates a pixel-perfect bounding box.
|
||||
- **Smart Select**: An original "Smart Select" feature powered by the robust feature extraction capabilities of **DINOv2**. Simply box one or two "positive" examples (you can even circle "negative" examples to exclude them), and the AI will find all similar objects in the entire image, enabling one-click batch annotation.
|
||||
- **Dataset-Driven Search**: Fully leverage your existing annotated data! Select a class, and the AI will learn its features across the entire dataset and automatically identify all potential targets in new images.
|
||||
- **Advanced Object Tracking**: After annotating the first frame of a video, enable automatic tracking with a single click. Two modes are available:
|
||||
1. **Interactive Mode**: Real-time tracking and feedback, allowing you to pause and correct at any time, ideal for complex and dynamic scenes.
|
||||
2. **High-Accuracy Batch Processing**: Utilizes the official `SAM2VideoPredictor` to process video clips in one go, achieving higher quality tracking results in stable scenes.
|
||||
- **Keyframe Interpolation**: For long-duration, simple movements, just annotate the start and end frames. The bounding boxes for all intermediate frames will be automatically generated via linear interpolation.
|
||||
|
||||
- **📊 In-depth Dataset Analysis and Visualization**
|
||||
- Perform a comprehensive "health check" on your dataset before exporting. Gain insights through interactive charts:
|
||||
- **Class Distribution**: Check for data imbalance issues.
|
||||
- **Object Density**: Analyze the distribution of the number of objects per image.
|
||||
- **Size and Aspect Ratio**: Discover objects with abnormal sizes or proportions.
|
||||
- **Spatial Heatmap**: See the common locations of objects within the images.
|
||||
- **Smart Filtering and Browsing**: The built-in "Image Gallery" allows you to filter for data "outliers" with one click, such as objects with the largest/smallest area or highly overlapping duplicate annotations, helping you quickly locate and correct labeling errors.
|
||||
|
||||
- **⚙️ Powerful Online Data Augmentation**
|
||||
- Configure a rich set of data augmentation strategies (rotation, cropping, color transformation, noise, Cutout, etc.) directly on the web interface when creating your dataset.
|
||||
- **WYSIWYG Augmentation Previewer**: See a live preview of augmentation effects and adjust parameters intuitively, ensuring your augmentation strategy meets expectations without trial and error.
|
||||
|
||||
- **📦 Complete Workflow Loop**
|
||||
- **Multi-Source Data Import**: Supports uploading `.mp4` video files or directly importing existing image folders.
|
||||
- **Collaborative Task Management**: Assign different frame ranges to different team members for easy team collaboration.
|
||||
- **One-Click Export to YOLO Format**: All annotated data can be packaged into a `.zip` dataset compatible with mainstream training frameworks like YOLOv8 with a single click.
|
||||
|
||||
- **💻 Purely Local, Secure, and Private**
|
||||
- No internet connection or cloud services required. All data and model computations are performed on your local machine, ensuring absolute data security.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Environment Setup
|
||||
|
||||
Please ensure you have **Python 3.10** and `pip` installed on your system.
|
||||
|
||||
**Clone the Project**
|
||||
```bash
|
||||
git clone https://github.com/BlueDarkUP/Zero2YoloYard.git
|
||||
cd Zero-to-YOLO-Yard
|
||||
```
|
||||
|
||||
**Install Dependencies**
|
||||
We strongly recommend using a virtual environment to isolate project dependencies.
|
||||
|
||||
```bash
|
||||
# Create and activate a virtual environment (Linux/macOS)
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
# (Windows)
|
||||
python -m venv venv
|
||||
.\venv\Scripts\activate
|
||||
|
||||
# Install all required libraries
|
||||
# If you have an NVIDIA GPU, it is recommended to first install the corresponding PyTorch version for your CUDA version
|
||||
# PyTorch official website: https://pytorch.org/get-started/locally/
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2. Launch the Application
|
||||
|
||||
Run the following command in the project's root directory to start the web server:
|
||||
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
|
||||
Once the server starts successfully, you will see output in the terminal similar to this:
|
||||
```
|
||||
INFO:waitress:Serving on http://127.0.0.1:5000
|
||||
```
|
||||
|
||||
Now, open your browser and navigate to **[http://127.0.0.1:5000](http://127.0.0.1:5000)** to begin your AI annotation journey!
|
||||
|
||||
---
|
||||
|
||||
## 📖 Workflow Guide
|
||||
|
||||
### Step 1: Upload and Manage Data
|
||||
|
||||
1. In the **"Videos"** tab, click **"Upload Video"** to upload a video, or click the **"Import"** button (<i class="bi bi-images"></i> icon) in the video list to import a local image folder.
|
||||
2. The system will automatically process the data. Once the status changes to `READY`, you can proceed to the next step.
|
||||
|
||||
https://github.com/user-attachments/assets/11147a8c-e949-402a-ac81-0b914f7f47b5
|
||||
|
||||
### Step 2: Create Annotation Tasks
|
||||
|
||||
1. Click the **"Manage Tasks"** <i class="bi bi-card-checklist"></i> button next to the video.
|
||||
2. Assign a name to the person responsible for the task (e.g., `Alice`) and specify the **Start Frame** and **End Frame** they need to annotate.
|
||||
|
||||
https://github.com/user-attachments/assets/b4a97574-9cce-4ee4-ba4a-dcbd8115c5a5
|
||||
|
||||
### Step 3: Efficient Annotation
|
||||
|
||||
Once in the annotation interface, you can combine the following methods, choosing the most efficient tool for the job:
|
||||
|
||||
- **Basic Operations**:
|
||||
- Create or select a class in the **"Classes"** panel on the right.
|
||||
- **Manual Drawing**: Click and drag the left mouse button to draw a rectangle.
|
||||
- **Hotkeys**: `S` to Save, `A`/`D` for Previous/Next Page, `Delete` to remove the selected box, `Ctrl+Z` to Undo.
|
||||
|
||||
https://github.com/user-attachments/assets/00eaf98a-7e1c-47e9-957d-9e135a4024e1
|
||||
|
||||
- **AI-Assisted**:
|
||||
1. **Point-and-Click Annotation**: Click **"Enable SAM (Point)"** <i class="bi bi-magic"></i>, then click on the target object, and the AI will automatically generate a bounding box for you.
|
||||
|
||||
https://github.com/user-attachments/assets/131f2b62-da8f-4d81-8d9f-7d32208c29fd
|
||||
|
||||
2. **Smart Select**:
|
||||
- Click **"Enable Smart Select"** <i class="bi bi-stars"></i> to activate this mode.
|
||||
- By default, it is in **"Positive Sample"** mode. Draw a box around one or two examples of the object you want to find.
|
||||
- (Optional) Switch to **"Negative Sample"** mode to box out backgrounds or distractors you don't want to select.
|
||||
- Click **"Find Similar Objects"** <i class="bi bi-search"></i>, and the AI will display all similar objects found.
|
||||
- Select a class, then click on the blue preview boxes to accept them as official annotations.
|
||||
|
||||
https://github.com/user-attachments/assets/e8d0bb47-3396-49dc-8cf3-1cba531f7c8f
|
||||
|
||||
3. **Auto-Tracking**:
|
||||
- In any frame of the video, finish annotating all target objects.
|
||||
- Click **"Track Objects with SAM2"** <i class="bi bi-play-circle"></i>.
|
||||
- In the pop-up window, choose **"Interactive Tracking"** (for real-time correction) or **"High-Accuracy Batch Mode"** (for higher quality, offline processing).
|
||||
- The system will automatically process the subsequent frames. You can review, correct, and bulk-save the results in **"Review Mode"**.
|
||||
|
||||
https://github.com/user-attachments/assets/a5bb6cb5-5bf5-4648-b52e-fbf45e0d2e35
|
||||
|
||||
### Step 4: Analyze and Gain Insights (New Feature!)
|
||||
|
||||
1. After annotation is complete, create a dataset in the **"Datasets"** tab on the main interface and link the annotated videos.
|
||||
2. Once the dataset status changes to `READY`, click the **"Analyze"** <i class="bi bi-bar-chart-line"></i> button.
|
||||
3. On the analysis page, you can:
|
||||
- View various statistical charts to understand data quality.
|
||||
- Use the **"Augmentation Previewer"** to debug data augmentation effects in real-time.
|
||||
- Use filters in the **"Image Gallery"** to quickly find and navigate to problematic annotations for correction.
|
||||
|
||||
https://github.com/user-attachments/assets/287ec74e-3a69-4504-bfe7-12f313540400
|
||||
|
||||
### Step 5: Create and Export Dataset
|
||||
|
||||
1. In the **"Datasets"** tab, click **"Create Dataset"**.
|
||||
2. Select the videos to be packaged and set the train/validation/test split ratios.
|
||||
3. **(Optional)** Expand and enable **"Data Augmentation Options"** to configure your desired augmentation strategies.
|
||||
4. After successful creation, click **"Download"** <i class="bi bi-download"></i> to get the YOLO format `.zip` file ready for model training.
|
||||
|
||||
> **Tip**: Once a dataset is created, its contents are fixed. If you modify the video annotations later, you will need to create a new dataset version to include these updates.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Core Tech Stack
|
||||
|
||||
- **Backend**: Flask, Waitress
|
||||
- **Database**: SQLite
|
||||
- **AI Models**:
|
||||
- **Segmentation & Tracking**: [Ultralytics (YOLOv8-SAM)](https://github.com/ultralytics/ultralytics) & [SAM 2.1](https://github.com/facebookresearch/segment-anything)
|
||||
- **Feature Extraction (Smart Select)**: [DINOv2](https://github.com/facebookresearch/dinov2)
|
||||
- **Data Augmentation**: Albumentations
|
||||
- **Frontend**: Bootstrap, jQuery, Chart.js
|
||||
|
||||
## 📂 File Structure
|
||||
|
||||
- **`local_storage/`**: Stores all user data, including videos, frames, datasets, and models.
|
||||
- **`checkpoints/`**: Stores the weight files for AI models like SAM (needs to be downloaded by the user).
|
||||
- **`ftc_ml.db`**: The SQLite database file that manages all metadata, such as project descriptions, annotation information, tasks, etc.
|
||||
|
||||
## 🤝 Contribution and Acknowledgements
|
||||
|
||||
This project is a major functional extension and localization refactoring of [FMLTC (FIRST Machine Learning Toolchain)](https://github.com/FIRST-Tech-Challenge/fmltc).
|
||||
|
||||
Special thanks to **BlueDarkUP** for their outstanding contributions to the project's development.
|
||||
|
||||
We welcome contributions of any kind, whether it's feature suggestions, code submissions, or bug reports. Please feel free to communicate with us via Pull Requests or Issues
|
||||
@@ -0,0 +1,192 @@
|
||||
# Zero-to-YOLO-Yard: 本地化、AI驱动的下一代计算机视觉标注工具
|
||||
---
|
||||

|
||||
|
||||
|
||||
---
|
||||
|
||||
**Zero-to-YOLO-Yard** 是一个专为本地化部署而深度优化的开源工具,旨在为您提供从原始视频/图像到可训练数据集的全流程解决方案。它集成了最前沿的 AI 技术,将繁琐的标注工作转变为简单、高效、甚至充满探索乐趣的体验。无论您是机器人开发者、无人机爱好者还是计算机视觉研究者,此工具都能极大地加速您的数据处理流程,且所有数据都安全地保留在您自己的计算机上。
|
||||
|
||||
[](https://www.python.org/downloads/release/python-3100/)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[]()
|
||||
|
||||
|
||||
---
|
||||
|
||||
## ✨ 核心亮点:不止于标注
|
||||
|
||||
- **🚀 新一代 AI 辅助标注引擎**
|
||||
- **SAM 2.1 点选成框**: 集成Meta最新的SAM 2.1模型,只需在目标物体上轻轻一点,即可自动生成像素级精确的边界框。
|
||||
- **智能选择 (Smart Select)**: 独创的“智能选择”功能,由 **DINOv2** 强大的特征提取能力驱动。您只需框选一两个“正例”样本(甚至可以圈出“反例”来排除干扰),AI 就能在整张图中找出所有相似对象,实现一键批量标注。
|
||||
- **数据集驱动查找**: 充分利用您已有的标注数据!选择一个类别,AI 会学习该类别在整个数据集中的特征,并自动在新图像中找出所有潜在目标。
|
||||
- **高级对象跟踪**: 在视频首帧完成标注后,一键开启自动跟踪。提供两种模式:
|
||||
1. **交互模式**: 实时跟踪并反馈,可随时暂停、修正,适合复杂多变的场景。
|
||||
2. **高精度批处理**: 调用官方 `SAM2VideoPredictor`,一次性处理视频片段,在稳定场景下获得更高质量的跟踪结果。
|
||||
- **关键帧插值**: 对于长时段的简单运动,只需标注起点和终点两帧,中间所有帧的边界框将自动线性插值生成。
|
||||
|
||||
- **📊 深度数据集分析与可视化**
|
||||
- 在导出前,对您的数据集进行一次全面“体检”。通过交互式图表洞察:
|
||||
- **类别分布**: 检查是否存在数据不均衡问题。
|
||||
- **目标密度**: 分析每张图的目标数量分布。
|
||||
- **尺寸与宽高比**: 发现异常尺寸或比例的目标。
|
||||
- **空间位置热力图**: 查看目标在图像中的常见位置。
|
||||
- **智能筛选与浏览**: 内置“图像画廊”,可一键筛选出数据“异常点”,如面积最大/最小的目标、重叠度过高的重复标注等,帮助您快速定位并修正标注错误。
|
||||
|
||||
- **⚙️ 强大的在线数据增强**
|
||||
- 在创建数据集时,直接在网页上配置丰富的数据增强策略(旋转、裁剪、色彩变换、噪声、Cutout等)。
|
||||
- **所见即所得的增强预览器**: 实时预览增强效果,直观调整参数,确保增强策略符合您的预期,无需反复试错。
|
||||
|
||||
- **📦 完整的工作流闭环**
|
||||
- **多源数据导入**: 支持上传 `.mp4` 视频文件,或直接导入已有的图片文件夹。
|
||||
- **任务协同管理**: 为不同成员分配不同的标注帧范围,轻松实现团队协作。
|
||||
- **一键导出YOLO格式**: 所有标注数据可一键打包为与YOLOv8等主流训练框架兼容的 `.zip` 数据集。
|
||||
|
||||
- **💻 纯本地化,安全私密**
|
||||
- 无需联网,无需云服务,所有数据和模型运算均在您的本地计算机完成,确保数据绝对安全。
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速上手
|
||||
|
||||
### 1. 环境配置
|
||||
|
||||
请确保您的系统中已安装 **Python 3.10** 及 `pip`。
|
||||
|
||||
**克隆项目**
|
||||
```bash
|
||||
git clone https://github.com/BlueDarkUP/Zero2YoloYard.git
|
||||
cd Zero-to-YOLO-Yard
|
||||
```
|
||||
|
||||
**安装依赖**
|
||||
我们强烈建议使用虚拟环境来隔离项目依赖。
|
||||
|
||||
```bash
|
||||
# 创建并激活虚拟环境 (Linux/macOS)
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
# (Windows)
|
||||
python -m venv venv
|
||||
.\venv\Scripts\activate
|
||||
|
||||
# 安装所有必需的库
|
||||
# 如果您有NVIDIA显卡,建议先根据您的CUDA版本安装对应的PyTorch
|
||||
# PyTorch官网: https://pytorch.org/get-started/locally/
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2. 启动应用
|
||||
|
||||
在项目根目录下运行以下命令启动 Web 服务器:
|
||||
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
|
||||
服务器成功启动后,您将在终端看到类似输出:
|
||||
```
|
||||
INFO:waitress:Serving on http://127.0.0.1:5000
|
||||
```
|
||||
|
||||
现在,打开您的浏览器并访问 **[http://127.0.0.1:5000](http://127.0.0.1:5000)** 即可开始您的 AI 标注之旅!
|
||||
|
||||
---
|
||||
|
||||
## 📖 工作流指南
|
||||
|
||||
### 步骤 1: 上传与管理数据
|
||||
|
||||
1. 在 **"Videos"** 选项卡中,点击 **"Upload Video"** 上传视频,或点击视频列表中的 **"Import"** 按钮(图标为 <i class="bi bi-images"></i>)导入本地图片文件夹。
|
||||
2. 系统会自动处理数据,状态变为 `READY` 后即可开始下一步。
|
||||
|
||||
https://github.com/user-attachments/assets/11147a8c-e949-402a-ac81-0b914f7f47b5
|
||||
|
||||
### 步骤 2: 创建标注任务
|
||||
|
||||
1. 点击视频旁的 **"Manage Tasks"** <i class="bi bi-card-checklist"></i> 按钮。
|
||||
2. 为任务分配一个负责人名称(如 `Alice`),并指定他/她需要标注的 **起始帧** 和 **结束帧**。
|
||||
|
||||
https://github.com/user-attachments/assets/b4a97574-9cce-4ee4-ba4a-dcbd8115c5a5
|
||||
|
||||
### 步骤 3: 高效标注
|
||||
|
||||
进入标注界面后,您可以组合使用以下多种方式,选择最高效的工具来完成工作:
|
||||
|
||||
- **基础操作**:
|
||||
- 在右侧 **"Classes"** 面板中创建或选择一个类别。
|
||||
- **手动绘制**: 按住鼠标左键拖拽即可绘制矩形框。
|
||||
- **快捷键**: `S` 保存, `A`/`D` 前后翻页, `Delete` 删除选中框, `Ctrl+Z` 撤销。
|
||||
|
||||
https://github.com/user-attachments/assets/00eaf98a-7e1c-47e9-957d-9e135a4024e1
|
||||
|
||||
- **AI 辅助**:
|
||||
1. **点选标注**: 点击 **"Enable SAM (Point)"** <i class="bi bi-magic"></i>,然后在目标上单击,AI将自动为您生成边界框。
|
||||
|
||||
https://github.com/user-attachments/assets/131f2b62-da8f-4d81-8d9f-7d32208c29fd
|
||||
|
||||
2. **智能选择**:
|
||||
- 点击 **"Enable Smart Select"** <i class="bi bi-stars"></i> 激活此模式。
|
||||
- 默认处于 **"Positive Sample"** 模式,绘制一两个您想找的目标。
|
||||
- (可选)切换到 **"Negative Sample"** 模式,框出您不想选择的背景或干扰物。
|
||||
- 点击 **"Find Similar Objects"** <i class="bi bi-search"></i>,AI将展示所有找到的相似目标。
|
||||
- 选择一个类别,然后点击蓝色的预览框即可将其采纳为正式标注。
|
||||
|
||||
https://github.com/user-attachments/assets/e8d0bb47-3396-49dc-8cf3-1cba531f7c8f
|
||||
|
||||
3. **自动跟踪**:
|
||||
- 在视频的任意一帧,完成对所有目标的标注。
|
||||
- 点击 **"Track Objects with SAM2"** <i class="bi bi-play-circle"></i>。
|
||||
- 在弹出的窗口中选择 **"Interactive Tracking"** (实时修正) 或 **"High-Accuracy Batch Mode"** (更高质量,离线处理)。
|
||||
- 系统将自动处理后续帧。您可以在 **"Review Mode"** 中检查、修正并批量保存结果。
|
||||
|
||||
https://github.com/user-attachments/assets/a5bb6cb5-5bf5-4648-b52e-fbf45e0d2e35
|
||||
|
||||
### 步骤 4: 分析与洞察 (新功能!)
|
||||
|
||||
1. 标注完成后,在主界面的 **"Datasets"** 选项卡创建一个数据集,并关联已标注的视频。
|
||||
2. 当数据集状态变为 `READY` 后,点击 **"Analyze"** <i class="bi bi-bar-chart-line"></i> 按钮。
|
||||
3. 在分析页面,您可以:
|
||||
- 查看各类统计图表,了解数据质量。
|
||||
- 使用 **"Augmentation Previewer"** 实时调试数据增强效果。
|
||||
- 在 **"Image Gallery"** 中使用筛选器快速找到并跳转到有问题的标注进行修正。
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/287ec74e-3a69-4504-bfe7-12f313540400
|
||||
|
||||
|
||||
### 步骤 5: 创建与导出数据集
|
||||
|
||||
1. 在 **"Datasets"** 选项卡,点击 **"Create Dataset"**。
|
||||
2. 选择需要打包的视频,设置训练/验证/测试集比例。
|
||||
3. **(可选)** 展开并启用 **"Data Augmentation Options"**,配置您需要的增强策略。
|
||||
4. 创建成功后,点击 **"Download"** <i class="bi bi-download"></i> 即可获取用于模型训练的 YOLO 格式 `.zip` 文件。
|
||||
|
||||
> **提示**: 数据集一旦创建,其内容便已固定。如果您后续修改了视频标注,需要重新创建一个新的数据集版本以包含这些更新。
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 技术栈核心
|
||||
|
||||
- **后端**: Flask, Waitress
|
||||
- **数据库**: SQLite
|
||||
- **AI 模型**:
|
||||
- **分割与跟踪**: [Ultralytics (YOLOv8-SAM)](https://github.com/ultralytics/ultralytics) & [SAM 2.1](https://github.com/facebookresearch/segment-anything)
|
||||
- **特征提取 (智能选择)**: [DINOv2](https://github.com/facebookresearch/dinov2)
|
||||
- **数据增强**: Albumentations
|
||||
- **前端**: Bootstrap, jQuery, Chart.js
|
||||
|
||||
## 📂 文件结构
|
||||
|
||||
- **`local_storage/`**: 存储所有用户数据,包括视频、帧、数据集和模型。
|
||||
- **`checkpoints/`**: 存放SAM等AI模型的权重文件(需自行下载)。
|
||||
- **`ftc_ml.db`**: SQLite 数据库文件,管理所有元数据,如项目描述、标注信息、任务等。
|
||||
|
||||
## 🤝 贡献与致谢
|
||||
|
||||
本项目是对 [FMLTC (FIRST Machine Learning Toolchain)](https://github.com/FIRST-Tech-Challenge/fmltc) 的一次重大功能扩展和本地化重构。
|
||||
|
||||
特别感谢 **BlueDarkUP** 在项目开发中的卓越贡献。
|
||||
|
||||
我们欢迎任何形式的贡献,无论是功能建议、代码提交还是问题反馈。请通过 Pull Request 或 Issues 与我们交流!
|
||||
@@ -0,0 +1,625 @@
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
from sklearn.cluster import KMeans
|
||||
from sklearn.metrics import silhouette_score
|
||||
except ImportError:
|
||||
logging.warning("scikit-learn not found. Sub-prototype clustering will be disabled. Run 'pip install scikit-learn'")
|
||||
KMeans = None
|
||||
silhouette_score = None
|
||||
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
import torchvision
|
||||
from PIL import Image
|
||||
from torch.amp import autocast
|
||||
from torchvision.models import MobileNet_V3_Large_Weights, MobileNet_V3_Small_Weights
|
||||
from torchvision.ops import nms, box_iou
|
||||
from torchvision.transforms.functional import to_tensor
|
||||
|
||||
import config
|
||||
import database
|
||||
import file_storage
|
||||
import settings_manager
|
||||
from bbox_writer import convert_text_to_rects_and_labels
|
||||
from collections import defaultdict
|
||||
|
||||
try:
|
||||
import ultralytics_sam_tasks as sam_tasks
|
||||
except ImportError:
|
||||
logging.warning("ultralytics_sam_tasks.py not found or failed to import. All SAM features will be disabled.")
|
||||
sam_tasks = None
|
||||
|
||||
models = {}
|
||||
PREPROCESSED_DATA_CACHE = {}
|
||||
PROTOTYPE_CACHE = {}
|
||||
|
||||
AI_MODEL_LOCK = threading.RLock()
|
||||
|
||||
PROTOTYPE_LOCKS = {}
|
||||
_PROTOTYPE_LOCKS_LOCK = threading.Lock()
|
||||
_cache_save_lock = threading.Lock()
|
||||
_last_cache_save_time = 0
|
||||
|
||||
|
||||
def _get_class_lock(class_name):
|
||||
with _PROTOTYPE_LOCKS_LOCK:
|
||||
if class_name not in PROTOTYPE_LOCKS:
|
||||
PROTOTYPE_LOCKS[class_name] = threading.Lock()
|
||||
return PROTOTYPE_LOCKS[class_name]
|
||||
|
||||
|
||||
_mobilenet_pytorch_cache = {"model": None, "name": None}
|
||||
|
||||
|
||||
def get_features_for_single_bbox(pil_image, target_rects):
|
||||
if 'feature_extractor_pytorch' not in models:
|
||||
raise RuntimeError("PyTorch特征提取模型未加载,请检查启动过程。")
|
||||
|
||||
input_size = 0
|
||||
if target_rects is not None:
|
||||
if isinstance(target_rects, np.ndarray):
|
||||
input_size = target_rects.shape[0]
|
||||
else:
|
||||
try:
|
||||
input_size = len(target_rects)
|
||||
except TypeError:
|
||||
input_size = 0
|
||||
|
||||
if input_size == 0:
|
||||
return None
|
||||
|
||||
DEVICE = settings_manager.get_device()
|
||||
pytorch_model = models['feature_extractor_pytorch']
|
||||
|
||||
with torch.no_grad():
|
||||
img_tensor = to_tensor(pil_image).to(DEVICE)
|
||||
|
||||
if not isinstance(target_rects, np.ndarray):
|
||||
target_rects_np = np.array(target_rects, dtype=np.float32)
|
||||
else:
|
||||
target_rects_np = target_rects.astype(np.float32)
|
||||
|
||||
boxes_for_crop = torch.from_numpy(target_rects_np).to(DEVICE)
|
||||
|
||||
box_indices = torch.zeros(boxes_for_crop.size(0), 1, device=DEVICE)
|
||||
boxes_for_roi = torch.cat([box_indices, boxes_for_crop], dim=1)
|
||||
|
||||
batch_of_crops = torchvision.ops.roi_align(
|
||||
img_tensor.unsqueeze(0),
|
||||
boxes_for_roi,
|
||||
output_size=(224, 224),
|
||||
spatial_scale=1.0,
|
||||
aligned=True
|
||||
)
|
||||
|
||||
IMAGENET_MEAN = torch.tensor([0.485, 0.456, 0.406], device=DEVICE).view(1, 3, 1, 1)
|
||||
IMAGENET_STD = torch.tensor([0.229, 0.224, 0.225], device=DEVICE).view(1, 3, 1, 1)
|
||||
batch_tensor = (batch_of_crops - IMAGENET_MEAN) / IMAGENET_STD
|
||||
|
||||
with torch.amp.autocast(device_type=DEVICE.type, enabled=(DEVICE.type == 'cuda')):
|
||||
features_map = pytorch_model.features(batch_tensor)
|
||||
pooled_features = pytorch_model.avgpool(features_map)
|
||||
final_features = torch.flatten(pooled_features, 1)
|
||||
|
||||
return final_features
|
||||
|
||||
def save_prototypes_to_disk():
|
||||
try:
|
||||
with _get_class_lock("__global_save__"):
|
||||
cpu_cache = {k: v.cpu() for k, v in PROTOTYPE_CACHE.items()}
|
||||
torch.save(cpu_cache, config.PROTOTYPE_FILE)
|
||||
logging.info(f"成功将 {len(cpu_cache)} 个原型保存至 {config.PROTOTYPE_FILE}")
|
||||
except Exception as e:
|
||||
logging.error(f"保存原型文件失败: {e}", exc_info=True)
|
||||
|
||||
|
||||
def save_preprocessed_cache_to_disk():
|
||||
global _last_cache_save_time
|
||||
with _cache_save_lock:
|
||||
logging.info("正在尝试保存预处理缓存...")
|
||||
cache_copy = dict(PREPROCESSED_DATA_CACHE)
|
||||
if not cache_copy:
|
||||
logging.info("预处理缓存为空,无需保存。")
|
||||
return
|
||||
|
||||
try:
|
||||
cpu_cache = {}
|
||||
for key, value in cache_copy.items():
|
||||
cpu_cache[key] = {
|
||||
'all_boxes': value['all_boxes'].cpu(),
|
||||
'all_features': value['all_features'].cpu()
|
||||
}
|
||||
|
||||
torch.save(cpu_cache, config.PREPROCESSED_CACHE_FILE)
|
||||
_last_cache_save_time = time.time()
|
||||
logging.info(f"成功将 {len(cpu_cache)} 个预处理帧数据保存至文件。")
|
||||
except Exception as e:
|
||||
logging.error(f"保存预处理缓存文件失败: {e}", exc_info=True)
|
||||
|
||||
|
||||
def load_prototypes_from_disk():
|
||||
global PROTOTYPE_CACHE
|
||||
DEVICE = settings_manager.get_device()
|
||||
if os.path.exists(config.PROTOTYPE_FILE):
|
||||
try:
|
||||
loaded_cache = torch.load(config.PROTOTYPE_FILE, map_location=DEVICE)
|
||||
PROTOTYPE_CACHE = loaded_cache
|
||||
logging.info(f"成功从文件加载了 {len(PROTOTYPE_CACHE)} 个类别原型。")
|
||||
except Exception as e:
|
||||
logging.error(f"加载原型文件失败,将在需要时重新构建: {e}")
|
||||
PROTOTYPE_CACHE = {}
|
||||
else:
|
||||
logging.info("未找到原型文件。将在首次需要时自动创建。")
|
||||
PROTOTYPE_CACHE = {}
|
||||
|
||||
|
||||
def clear_feature_extractor_cache():
|
||||
global _mobilenet_cache
|
||||
logging.info("Clearing Feature Extractor model cache due to setting change.")
|
||||
_mobilenet_cache = {"model": None, "name": None}
|
||||
if 'feature_extractor' in models:
|
||||
del models['feature_extractor']
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
|
||||
def load_preprocessed_cache_from_disk():
|
||||
global PREPROCESSED_DATA_CACHE
|
||||
DEVICE = settings_manager.get_device()
|
||||
if os.path.exists(config.PREPROCESSED_CACHE_FILE):
|
||||
try:
|
||||
logging.info("正在从磁盘加载预处理缓存...")
|
||||
loaded_cache = torch.load(config.PREPROCESSED_CACHE_FILE, map_location='cpu')
|
||||
|
||||
for key, value in loaded_cache.items():
|
||||
PREPROCESSED_DATA_CACHE[key] = {
|
||||
'all_boxes': value['all_boxes'].to(DEVICE),
|
||||
'all_features': value['all_features'].to(DEVICE)
|
||||
}
|
||||
logging.info(f"成功从文件加载了 {len(PREPROCESSED_DATA_CACHE)} 个预处理帧数据。")
|
||||
except Exception as e:
|
||||
logging.error(f"加载预处理缓存文件失败: {e}")
|
||||
PREPROCESSED_DATA_CACHE = {}
|
||||
else:
|
||||
logging.info("未找到预处理缓存文件。")
|
||||
PREPROCESSED_DATA_CACHE = {}
|
||||
|
||||
|
||||
def startup_ai_models():
|
||||
load_prototypes_from_disk()
|
||||
load_preprocessed_cache_from_disk()
|
||||
|
||||
global _mobilenet_pytorch_cache
|
||||
DEVICE = settings_manager.get_device()
|
||||
|
||||
if sam_tasks:
|
||||
logging.info("正在检查 SAM 点选/跟踪模型...")
|
||||
sam_tasks.get_sam_model()
|
||||
logging.info("SAM 点选/跟踪模型检查完成。")
|
||||
|
||||
try:
|
||||
settings = settings_manager.load_settings()
|
||||
target_model_name = settings.get("feature_extractor_model_name", "mobilenet_v3_large")
|
||||
|
||||
if (_mobilenet_pytorch_cache.get("model") is not None and
|
||||
_mobilenet_pytorch_cache.get("name") == target_model_name and
|
||||
next(_mobilenet_pytorch_cache["model"].parameters()).device == DEVICE):
|
||||
models['feature_extractor_pytorch'] = _mobilenet_pytorch_cache["model"]
|
||||
logging.info(f"已从缓存加载 PyTorch 特征提取器 '{target_model_name}'。")
|
||||
return
|
||||
|
||||
logging.info(f"正在加载原生 PyTorch 特征提取器 '{target_model_name}' 到设备 '{DEVICE}'...")
|
||||
|
||||
if target_model_name == "mobilenet_v3_small":
|
||||
model = torchvision.models.mobilenet_v3_small(weights=MobileNet_V3_Small_Weights.IMAGENET1K_V1)
|
||||
else:
|
||||
model = torchvision.models.mobilenet_v3_large(weights=MobileNet_V3_Large_Weights.IMAGENET1K_V1)
|
||||
|
||||
model.classifier = torch.nn.Identity()
|
||||
|
||||
model.eval()
|
||||
model.to(DEVICE)
|
||||
|
||||
_mobilenet_pytorch_cache["model"] = model
|
||||
_mobilenet_pytorch_cache["name"] = target_model_name
|
||||
|
||||
models['feature_extractor_pytorch'] = model
|
||||
|
||||
logging.info(f"PyTorch 特征提取器 '{target_model_name}' 加载成功。")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"加载 PyTorch 特征提取器失败: {e}", exc_info=True)
|
||||
if 'feature_extractor_pytorch' in models:
|
||||
del models['feature_extractor_pytorch']
|
||||
_mobilenet_pytorch_cache = {"model": None, "name": None}
|
||||
|
||||
|
||||
def postprocess_sam_results(results, nms_iou_threshold):
|
||||
DEVICE = settings_manager.get_device()
|
||||
if not results or not results[0].masks:
|
||||
return torch.empty(0, 4, device=DEVICE), torch.empty(0, 1, 1, device=DEVICE)
|
||||
all_boxes = results[0].boxes.xyxy.to(DEVICE)
|
||||
all_scores = results[0].boxes.conf.to(DEVICE)
|
||||
all_masks = results[0].masks.data.to(DEVICE)
|
||||
kept_indices = nms(all_boxes, all_scores, nms_iou_threshold)
|
||||
logging.info(f"[智能选择] NMS: 从 {len(all_boxes)} 个初始掩码中保留了 {len(kept_indices)} 个。")
|
||||
final_boxes = all_boxes[kept_indices]
|
||||
final_masks = all_masks[kept_indices]
|
||||
return final_boxes, final_masks
|
||||
|
||||
|
||||
def find_best_matching_masks_by_iou(reference_boxes_np, candidate_boxes_tensor):
|
||||
DEVICE = settings_manager.get_device()
|
||||
if len(reference_boxes_np) == 0 or len(candidate_boxes_tensor) == 0:
|
||||
return torch.tensor([], dtype=torch.long, device=DEVICE)
|
||||
reference_boxes_tensor = torch.tensor(reference_boxes_np, dtype=torch.float32, device=DEVICE)
|
||||
iou_matrix = box_iou(reference_boxes_tensor, candidate_boxes_tensor)
|
||||
best_match_indices = torch.argmax(iou_matrix, dim=1)
|
||||
return best_match_indices
|
||||
|
||||
|
||||
def get_features_for_all_masks(video_uuid, frame_number):
|
||||
if 'feature_extractor_pytorch' not in models:
|
||||
raise RuntimeError("PyTorch特征提取模型未加载。")
|
||||
|
||||
DEVICE = settings_manager.get_device()
|
||||
cache_key = f"{video_uuid}_{frame_number}"
|
||||
|
||||
if cache_key in PREPROCESSED_DATA_CACHE:
|
||||
return PREPROCESSED_DATA_CACHE[cache_key]
|
||||
|
||||
with AI_MODEL_LOCK:
|
||||
if cache_key in PREPROCESSED_DATA_CACHE:
|
||||
return PREPROCESSED_DATA_CACHE[cache_key]
|
||||
|
||||
with torch.no_grad(), torch.amp.autocast(device_type=DEVICE.type, enabled=(DEVICE.type == 'cuda')):
|
||||
logging.info(f"正在为 {cache_key} 开始新的预处理...")
|
||||
frame_path = file_storage.get_frame_path(video_uuid, frame_number)
|
||||
if not os.path.exists(frame_path):
|
||||
raise FileNotFoundError(f"帧图像文件未找到于 {frame_path}")
|
||||
|
||||
sam_model = sam_tasks.get_sam_model()
|
||||
if not sam_model:
|
||||
raise RuntimeError("SAM model not loaded.")
|
||||
|
||||
settings = settings_manager.load_settings()
|
||||
results = sam_model(frame_path, verbose=False, conf=settings.get('sam_mask_confidence', 0.35))
|
||||
all_boxes, all_masks = postprocess_sam_results(results,
|
||||
nms_iou_threshold=settings.get('nms_iou_threshold', 0.7))
|
||||
|
||||
if len(all_masks) == 0:
|
||||
cached_data = {"all_boxes": torch.empty(0, 4, device=DEVICE),
|
||||
"all_features": torch.empty(0, 1, device=DEVICE)}
|
||||
PREPROCESSED_DATA_CACHE[cache_key] = cached_data
|
||||
return cached_data
|
||||
|
||||
pil_image = Image.open(frame_path).convert("RGB")
|
||||
|
||||
all_features = get_features_for_single_bbox(pil_image, all_boxes.cpu().numpy())
|
||||
|
||||
if all_features is None:
|
||||
raise RuntimeError(f"为 {cache_key} 提取特征时返回了 None。")
|
||||
|
||||
cached_data = {"all_boxes": all_boxes, "all_features": all_features}
|
||||
PREPROCESSED_DATA_CACHE[cache_key] = cached_data
|
||||
logging.info(f"为 {cache_key} 的预处理完成并已缓存。")
|
||||
|
||||
cache_save_interval = settings.get('cache_save_interval_seconds', 30)
|
||||
if time.time() - _last_cache_save_time > cache_save_interval:
|
||||
threading.Thread(target=save_preprocessed_cache_to_disk).start()
|
||||
|
||||
return cached_data
|
||||
|
||||
|
||||
def get_features_for_specific_bboxes(video_uuid, frame_number, target_rects):
|
||||
try:
|
||||
processed_data = get_features_for_all_masks(video_uuid, frame_number)
|
||||
all_boxes = processed_data.get("all_boxes")
|
||||
all_features = processed_data.get("all_features")
|
||||
|
||||
if all_boxes is None or all_boxes.numel() == 0 or all_features is None or all_features.numel() == 0:
|
||||
return None
|
||||
|
||||
matching_indices = find_best_matching_masks_by_iou(np.array(target_rects), all_boxes)
|
||||
if matching_indices.numel() > 0:
|
||||
return all_features[matching_indices]
|
||||
else:
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logging.warning(f"Skipping frame {frame_number} for specific feature extraction due to error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_prototypes_from_drawn_boxes(drawn_samples_data):
|
||||
all_prototypes = []
|
||||
if not drawn_samples_data:
|
||||
return None
|
||||
|
||||
logging.info(f"Building on-the-fly prototypes from {len(drawn_samples_data)} user-drawn sample frames.")
|
||||
|
||||
for frame_key, rects in drawn_samples_data.items():
|
||||
try:
|
||||
video_uuid, frame_number_str = frame_key.split(';')
|
||||
frame_num = int(frame_number_str)
|
||||
target_rects = [np.array(rect) for rect in rects]
|
||||
if not target_rects: continue
|
||||
|
||||
embeddings = get_features_for_specific_bboxes(video_uuid, frame_num, target_rects)
|
||||
|
||||
if embeddings is not None and embeddings.numel() > 0:
|
||||
all_prototypes.append(embeddings)
|
||||
|
||||
except Exception as e:
|
||||
logging.warning(f"Skipping frame {frame_key} for on-the-fly prototype building due to error: {e}")
|
||||
|
||||
if not all_prototypes:
|
||||
logging.error("Could not extract any valid on-the-fly prototypes after processing drawn samples.")
|
||||
return None
|
||||
|
||||
return torch.cat(all_prototypes, dim=0)
|
||||
|
||||
|
||||
def predict_from_one_shot(video_uuid, frame_number, positive_prompt_box):
|
||||
with AI_MODEL_LOCK:
|
||||
processed_data = get_features_for_all_masks(video_uuid, frame_number)
|
||||
all_boxes = processed_data.get("all_boxes")
|
||||
all_features = processed_data.get("all_features")
|
||||
|
||||
if all_boxes is None or all_boxes.numel() == 0: return []
|
||||
|
||||
prompt_rect = [positive_prompt_box['x1'], positive_prompt_box['y1'], positive_prompt_box['x2'],
|
||||
positive_prompt_box['y2']]
|
||||
|
||||
target_feature_tensor = get_features_for_specific_bboxes(video_uuid, frame_number, [prompt_rect])
|
||||
if target_feature_tensor is None or target_feature_tensor.numel() == 0:
|
||||
raise ValueError("Could not extract features for the provided positive prompt box.")
|
||||
|
||||
target_feature = target_feature_tensor[0].unsqueeze(0)
|
||||
DEVICE = settings_manager.get_device()
|
||||
with torch.no_grad(), autocast(device_type=DEVICE.type, enabled=(DEVICE.type == 'cuda')):
|
||||
sim_scores = F.cosine_similarity(target_feature, all_features, dim=1)
|
||||
settings = settings_manager.load_settings()
|
||||
nms_iou = settings.get('nms_iou_threshold', 0.7)
|
||||
kept_indices = nms(all_boxes, sim_scores, nms_iou)
|
||||
|
||||
final_results = []
|
||||
final_scores_np = sim_scores.cpu().numpy()
|
||||
for i in kept_indices:
|
||||
box_coords = all_boxes[i].cpu().numpy().astype(int).tolist()
|
||||
final_results.append({"box": box_coords, "score": float(final_scores_np[i])})
|
||||
|
||||
return final_results
|
||||
|
||||
|
||||
def _calculate_similarity_scores(all_embeddings, positive_prototypes, negative_prototypes=None):
|
||||
settings = settings_manager.load_settings()
|
||||
score_temperature = settings.get('prototype_temperature', 0.07)
|
||||
DEVICE = settings_manager.get_device()
|
||||
|
||||
with torch.no_grad(), autocast(device_type=DEVICE.type, enabled=(DEVICE.type == 'cuda')):
|
||||
|
||||
sim_matrix = F.cosine_similarity(all_embeddings.unsqueeze(1), positive_prototypes.unsqueeze(0), dim=2)
|
||||
|
||||
positive_scores_sim, _ = torch.max(sim_matrix, dim=1)
|
||||
|
||||
if negative_prototypes is not None and len(negative_prototypes) > 0:
|
||||
if negative_prototypes.dim() > 1 and negative_prototypes.shape[0] > 1:
|
||||
neg_sim_matrix = F.cosine_similarity(all_embeddings.unsqueeze(1), negative_prototypes.unsqueeze(0), dim=2)
|
||||
negative_scores_sim, _ = torch.max(neg_sim_matrix, dim=1)
|
||||
else:
|
||||
mean_negative_prototype = torch.mean(negative_prototypes, dim=0, keepdim=True)
|
||||
negative_scores_sim = F.cosine_similarity(all_embeddings, mean_negative_prototype)
|
||||
|
||||
logits = torch.stack([negative_scores_sim, positive_scores_sim], dim=1)
|
||||
probabilities = F.softmax(logits / score_temperature, dim=1)
|
||||
final_scores = probabilities[:, 1]
|
||||
else:
|
||||
final_scores = torch.sigmoid(positive_scores_sim / score_temperature)
|
||||
|
||||
return final_scores
|
||||
|
||||
|
||||
def predict_with_prototypes(video_uuid, frame_number, positive_prototypes, negative_prototypes=None,
|
||||
confidence_threshold=0.5):
|
||||
with AI_MODEL_LOCK:
|
||||
processed_data = get_features_for_all_masks(video_uuid, frame_number)
|
||||
all_boxes = processed_data.get("all_boxes")
|
||||
all_features = processed_data.get("all_features")
|
||||
|
||||
if all_boxes is None or all_boxes.numel() == 0:
|
||||
return []
|
||||
|
||||
final_scores = _calculate_similarity_scores(all_features, positive_prototypes, negative_prototypes)
|
||||
|
||||
settings = settings_manager.load_settings()
|
||||
nms_iou = settings.get('nms_iou_threshold', 0.7)
|
||||
|
||||
high_conf_indices = torch.where(final_scores > confidence_threshold)[0]
|
||||
if high_conf_indices.numel() == 0:
|
||||
return []
|
||||
|
||||
boxes_to_nms = all_boxes[high_conf_indices]
|
||||
scores_to_nms = final_scores[high_conf_indices]
|
||||
|
||||
kept_indices_after_nms = nms(boxes_to_nms, scores_to_nms, nms_iou)
|
||||
final_kept_indices = high_conf_indices[kept_indices_after_nms]
|
||||
|
||||
final_results = []
|
||||
final_scores_np = final_scores.cpu().numpy()
|
||||
for i in final_kept_indices:
|
||||
box_coords = all_boxes[i].cpu().numpy().astype(int).tolist()
|
||||
final_results.append({"box": box_coords, "score": float(final_scores_np[i])})
|
||||
|
||||
return final_results
|
||||
|
||||
|
||||
def _calculate_prototype_from_db(class_name):
|
||||
all_class_features_tensors = []
|
||||
sample_frames = database.get_all_frames_with_class(class_name)
|
||||
if not sample_frames:
|
||||
logging.warning(f"在数据库中找不到类别 '{class_name}' 的任何样本。")
|
||||
return None
|
||||
|
||||
settings = settings_manager.load_settings()
|
||||
sample_limit = settings.get('prototype_sample_limit', 50)
|
||||
|
||||
if len(sample_frames) > sample_limit:
|
||||
sample_frames = random.sample(sample_frames, sample_limit)
|
||||
|
||||
grouped_boxes = defaultdict(list)
|
||||
for frame_data in sample_frames:
|
||||
frame_key = (frame_data['video_uuid'], frame_data['frame_number'])
|
||||
rects, labels, _ = convert_text_to_rects_and_labels(frame_data['bboxes_text'])
|
||||
target_rects_in_frame = [rect for i, rect in enumerate(rects) if labels[i] == class_name]
|
||||
if target_rects_in_frame:
|
||||
grouped_boxes[frame_key].extend(target_rects_in_frame)
|
||||
|
||||
if not grouped_boxes:
|
||||
return None
|
||||
|
||||
for (video_uuid, frame_number), all_rects_for_frame in grouped_boxes.items():
|
||||
try:
|
||||
pil_image = Image.open(file_storage.get_frame_path(video_uuid, frame_number)).convert("RGB")
|
||||
for i in range(0, len(all_rects_for_frame), 64):
|
||||
rect_chunk = all_rects_for_frame[i:i + 64]
|
||||
features = get_features_for_single_bbox(pil_image, rect_chunk)
|
||||
if features is not None and features.numel() > 0:
|
||||
all_class_features_tensors.append(features)
|
||||
except Exception as e:
|
||||
logging.warning(f"为原型构建跳过帧 {video_uuid}/{frame_number} 时出错: {e}")
|
||||
|
||||
if not all_class_features_tensors:
|
||||
logging.error(f"未能为类别 '{class_name}' 提取任何有效的特征向量。")
|
||||
return None
|
||||
|
||||
all_features = torch.cat(all_class_features_tensors, dim=0)
|
||||
num_samples = all_features.shape[0]
|
||||
|
||||
MIN_SAMPLES_FOR_CLUSTERING = 15
|
||||
if KMeans is None or num_samples < MIN_SAMPLES_FOR_CLUSTERING:
|
||||
logging.info(f"样本过少或 scikit-learn 未安装,为 '{class_name}' 创建单个平均原型。")
|
||||
return torch.mean(all_features, dim=0, keepdim=True)
|
||||
|
||||
logging.info(f"正在为 '{class_name}' ({num_samples} 个样本) 运行聚类分析以发现子原型...")
|
||||
best_k = 1
|
||||
best_score = -1
|
||||
max_clusters = min(5, num_samples - 1)
|
||||
|
||||
features_np = all_features.cpu().numpy()
|
||||
|
||||
for k in range(2, max_clusters + 1):
|
||||
kmeans = KMeans(n_clusters=k, random_state=42, n_init='auto')
|
||||
labels = kmeans.fit_predict(features_np)
|
||||
try:
|
||||
score = silhouette_score(features_np, labels)
|
||||
logging.info(f" - 测试 k={k}, 轮廓系数(Silhouette Score): {score:.4f}")
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_k = k
|
||||
except ValueError:
|
||||
logging.warning(f" - k={k} 无法计算轮廓系数,跳过。")
|
||||
|
||||
SILHOUETTE_THRESHOLD = 0.55
|
||||
if best_k > 1 and best_score > SILHOUETTE_THRESHOLD:
|
||||
logging.info(
|
||||
f"发现 {best_k} 个清晰的子类别 (得分: {best_score:.4f})。正在为 '{class_name}' 创建 {best_k} 个子原型。")
|
||||
kmeans = KMeans(n_clusters=best_k, random_state=42, n_init='auto')
|
||||
kmeans.fit(features_np)
|
||||
prototypes_np = kmeans.cluster_centers_
|
||||
prototypes = torch.from_numpy(prototypes_np).to(all_features.device)
|
||||
else:
|
||||
logging.info(f"未发现足够清晰的子类别 (最高分: {best_score:.4f})。为 '{class_name}' 创建单个平均原型。")
|
||||
prototypes = torch.mean(all_features, dim=0, keepdim=True)
|
||||
|
||||
return prototypes
|
||||
|
||||
|
||||
def build_prototypes_for_class(class_name):
|
||||
if class_name in PROTOTYPE_CACHE:
|
||||
return PROTOTYPE_CACHE[class_name]
|
||||
|
||||
class_lock = _get_class_lock(class_name)
|
||||
with class_lock:
|
||||
if class_name in PROTOTYPE_CACHE:
|
||||
return PROTOTYPE_CACHE[class_name]
|
||||
prototype_tensor = _calculate_prototype_from_db(class_name)
|
||||
|
||||
if prototype_tensor is not None:
|
||||
if prototype_tensor.dim() == 1:
|
||||
prototype_tensor = prototype_tensor.unsqueeze(0)
|
||||
|
||||
PROTOTYPE_CACHE[class_name] = prototype_tensor
|
||||
logging.info(f"类别 '{class_name}' 的原型构建完成并已缓存。Shape: {prototype_tensor.shape}")
|
||||
save_prototypes_to_disk()
|
||||
|
||||
return prototype_tensor
|
||||
|
||||
|
||||
def update_prototype_for_class(class_name):
|
||||
class_lock = _get_class_lock(class_name)
|
||||
with class_lock:
|
||||
logging.info(f"后台任务开始更新类别 '{class_name}' 的原型。")
|
||||
new_prototype = _calculate_prototype_from_db(class_name)
|
||||
|
||||
if new_prototype is not None:
|
||||
PROTOTYPE_CACHE[class_name] = new_prototype
|
||||
logging.info(f"类别 '{class_name}' 的原型已在后台成功更新。")
|
||||
save_prototypes_to_disk()
|
||||
else:
|
||||
logging.error(f"后台更新原型失败: 无法为 '{class_name}' 计算新原型。")
|
||||
|
||||
|
||||
def get_all_prototypes():
|
||||
all_labels = database.get_all_class_labels()
|
||||
prototype_library = {}
|
||||
for label in all_labels:
|
||||
prototype = build_prototypes_for_class(label)
|
||||
if prototype is not None:
|
||||
prototype_library[label] = prototype
|
||||
return prototype_library
|
||||
|
||||
|
||||
def lam_predict(video_uuid, frame_number, point_coords):
|
||||
with AI_MODEL_LOCK:
|
||||
frame_path = file_storage.get_frame_path(video_uuid, frame_number)
|
||||
sam_model = sam_tasks.get_sam_model()
|
||||
if not sam_model:
|
||||
raise RuntimeError("SAM 模型不可用。")
|
||||
|
||||
results = sam_model(frame_path, points=[point_coords], labels=[1], verbose=False)
|
||||
if not results or not results[0].boxes or results[0].boxes.xyxy.numel() == 0:
|
||||
return None, "SAM 未在指定点找到对象。"
|
||||
|
||||
box_tensor = results[0].boxes.xyxy[0]
|
||||
bbox_coords = box_tensor.cpu().numpy()
|
||||
bbox_dict = {'x1': int(bbox_coords[0]), 'y1': int(bbox_coords[1]), 'x2': int(bbox_coords[2]),
|
||||
'y2': int(bbox_coords[3])}
|
||||
|
||||
feature_vector = get_features_for_specific_bboxes(video_uuid, frame_number, [bbox_coords])
|
||||
if feature_vector is None or feature_vector.numel() == 0:
|
||||
return None, "无法为 SAM 找到的物体提取特征。"
|
||||
|
||||
prototype_library = get_all_prototypes()
|
||||
if not prototype_library:
|
||||
return {"bbox": bbox_dict, "suggestions": []}, None
|
||||
|
||||
scores = []
|
||||
DEVICE = settings_manager.get_device()
|
||||
|
||||
with torch.no_grad(), autocast(device_type=DEVICE.type, enabled=(DEVICE.type == 'cuda')):
|
||||
for class_name, prototype in prototype_library.items():
|
||||
sim_matrix = F.cosine_similarity(feature_vector.unsqueeze(1), prototype.unsqueeze(0), dim=2)
|
||||
max_similarity = torch.max(sim_matrix)
|
||||
scores.append({"label": class_name, "score": round(max_similarity.item(), 4)})
|
||||
|
||||
sorted_suggestions = sorted(scores, key=lambda x: x['score'], reverse=True)
|
||||
|
||||
return {"bbox": bbox_dict, "suggestions": sorted_suggestions[:5]}, None
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,692 @@
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import time
|
||||
import traceback
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import tensorflow as tf
|
||||
import torch
|
||||
import yaml
|
||||
|
||||
import ai_models
|
||||
import config
|
||||
import database
|
||||
import file_storage
|
||||
from bbox_writer import extract_labels
|
||||
from multiprocessing import Pool, cpu_count
|
||||
|
||||
try:
|
||||
import ultralytics_sam_tasks
|
||||
except ImportError:
|
||||
ultralytics_sam_tasks = None
|
||||
|
||||
try:
|
||||
import albumentations as A
|
||||
|
||||
|
||||
class BboxSafeCoarseDropout(A.CoarseDropout):
|
||||
def apply_to_bbox(self, bbox, **params):
|
||||
return bbox
|
||||
|
||||
except ImportError:
|
||||
logging.warning(
|
||||
"albumentations library not found. Data augmentation will be disabled. Run 'pip install albumentations opencv-python-headless'")
|
||||
A = None
|
||||
|
||||
|
||||
active_tasks = {}
|
||||
tracking_sessions = {}
|
||||
|
||||
|
||||
def apply_prototypes_to_video_task(video_uuid, class_name, negative_samples, confidence_threshold, app_context):
|
||||
if active_tasks.get(video_uuid):
|
||||
logging.warning(f"Cannot start applying prototypes for {video_uuid}, another task is active.")
|
||||
return
|
||||
|
||||
active_tasks[video_uuid] = 'APPLYING_PROTOTYPES'
|
||||
logging.info(
|
||||
f"Starting to apply suggestions for class '{class_name}' to video {video_uuid} with threshold {confidence_threshold}")
|
||||
|
||||
try:
|
||||
with app_context:
|
||||
database.update_video_status(video_uuid, 'APPLYING_PROTOTYPES', f"Initializing for '{class_name}'...")
|
||||
|
||||
database.update_video_status(video_uuid, 'APPLYING_PROTOTYPES',
|
||||
f"Building positive prototypes for '{class_name}'...")
|
||||
positive_prototypes = ai_models.build_prototypes_for_class(class_name)
|
||||
if positive_prototypes is None or len(positive_prototypes) == 0:
|
||||
raise ValueError(f"Could not build positive prototypes for class '{class_name}'.")
|
||||
logging.info(f"Successfully built {len(positive_prototypes)} positive prototypes for '{class_name}'.")
|
||||
|
||||
negative_prototypes = None
|
||||
if negative_samples:
|
||||
database.update_video_status(video_uuid, 'APPLYING_PROTOTYPES', "Building negative prototypes...")
|
||||
negative_prototypes = ai_models.get_prototypes_from_drawn_boxes(negative_samples)
|
||||
if negative_prototypes is not None and len(negative_prototypes) > 0:
|
||||
logging.info(
|
||||
f"Successfully built {len(negative_prototypes)} negative prototypes from user samples.")
|
||||
else:
|
||||
logging.warning("User provided negative samples, but failed to build prototypes from them.")
|
||||
|
||||
all_frames = database.get_video_frames(video_uuid)
|
||||
unlabeled_frames = [f for f in all_frames if not f['bboxes_text'].strip()]
|
||||
total_frames = len(unlabeled_frames)
|
||||
logging.info(f"Found {total_frames} unlabeled frames to process in video {video_uuid}.")
|
||||
|
||||
for i, frame_info in enumerate(unlabeled_frames):
|
||||
frame_number = frame_info['frame_number']
|
||||
current_status = database.get_video_entity(video_uuid)['status']
|
||||
if current_status == 'CANCELLING':
|
||||
logging.info(f"Task for {video_uuid} cancelled by user.")
|
||||
database.update_video_status(video_uuid, 'READY', 'Task was cancelled.')
|
||||
return
|
||||
|
||||
database.update_video_status(video_uuid, 'APPLYING_PROTOTYPES',
|
||||
f"Processing frame {i + 1}/{total_frames}")
|
||||
|
||||
try:
|
||||
predictions = ai_models.predict_with_prototypes(
|
||||
video_uuid, frame_number, positive_prototypes,
|
||||
negative_prototypes=negative_prototypes,
|
||||
confidence_threshold=confidence_threshold
|
||||
)
|
||||
|
||||
if predictions:
|
||||
suggested_text = "\n".join(
|
||||
[
|
||||
f"{int(p['box'][0])},{int(p['box'][1])},{int(p['box'][2])},{int(p['box'][3])},{class_name},{p['score']:.4f}"
|
||||
for p in predictions])
|
||||
database.save_frame_suggestions(video_uuid, frame_number, suggested_text)
|
||||
|
||||
except Exception as frame_e:
|
||||
logging.error(f"Failed to process frame {frame_number} for {video_uuid}: {frame_e}")
|
||||
|
||||
cache_key = f"{video_uuid}_{frame_number}"
|
||||
if cache_key in ai_models.PREPROCESSED_DATA_CACHE:
|
||||
del ai_models.PREPROCESSED_DATA_CACHE[cache_key]
|
||||
|
||||
database.update_video_status(video_uuid, 'READY',
|
||||
f"Finished applying '{class_name}' suggestions. Review suggestions.")
|
||||
logging.info(f"Task for {video_uuid} completed successfully.")
|
||||
|
||||
except Exception as e:
|
||||
error_message = f"Failed to apply prototypes to video {video_uuid}"
|
||||
logging.error(f"{error_message}: {e}")
|
||||
logging.error(traceback.format_exc())
|
||||
database.update_video_status(video_uuid, status="FAILED", message=str(e))
|
||||
finally:
|
||||
if active_tasks.get(video_uuid) == 'APPLYING_PROTOTYPES':
|
||||
del active_tasks[video_uuid]
|
||||
|
||||
|
||||
def start_sam2_tracking_task(video_uuid, tracker_uuid, start_frame, end_frame, init_bboxes_text):
|
||||
if active_tasks.get(video_uuid):
|
||||
logging.warning(f"A task is already running for video {video_uuid}.")
|
||||
tracking_sessions[tracker_uuid] = {'status': 'FAILED', 'message': 'Another task is active.'}
|
||||
return
|
||||
|
||||
if ultralytics_sam_tasks is None:
|
||||
logging.error("Ultralytics SAM Tasks module not available.")
|
||||
tracking_sessions[tracker_uuid] = {'status': 'FAILED',
|
||||
'message': 'Ultralytics library not installed or configured on server.'}
|
||||
return
|
||||
|
||||
active_tasks[video_uuid] = tracker_uuid
|
||||
session = {
|
||||
'status': 'STARTING',
|
||||
'progress': 0,
|
||||
'total': (end_frame - start_frame) + 1,
|
||||
'results': {},
|
||||
'stop_requested': False,
|
||||
'message': ''
|
||||
}
|
||||
tracking_sessions[tracker_uuid] = session
|
||||
|
||||
try:
|
||||
logging.info(
|
||||
f"Starting INTERACTIVE SAM tracking for video {video_uuid} from frame {start_frame} to {end_frame}")
|
||||
session['status'] = 'PROCESSING'
|
||||
|
||||
ultralytics_sam_tasks.track_video_ultralytics(
|
||||
video_uuid,
|
||||
start_frame,
|
||||
end_frame,
|
||||
init_bboxes_text,
|
||||
session
|
||||
)
|
||||
|
||||
final_status = session.get('status', 'COMPLETED')
|
||||
logging.info(f"Interactive SAM tracking for {tracker_uuid} finished with status: {final_status}.")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error during Interactive SAM tracking for {video_uuid}: {e}\n{traceback.format_exc()}")
|
||||
session['status'] = 'FAILED'
|
||||
session['message'] = str(e)
|
||||
finally:
|
||||
logging.info(f"Cleaning up resources for Interactive SAM tracking task {tracker_uuid}...")
|
||||
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
logging.info("Emptied PyTorch CUDA cache.")
|
||||
|
||||
if active_tasks.get(video_uuid) == tracker_uuid:
|
||||
del active_tasks[video_uuid]
|
||||
|
||||
logging.info(f"Resource cleanup for task {tracker_uuid} complete.")
|
||||
|
||||
|
||||
def start_sam2_batch_tracking_task(video_uuid, tracker_uuid, start_frame, end_frame, init_bboxes_text):
|
||||
if active_tasks.get(video_uuid):
|
||||
logging.warning(f"A task is already running for video {video_uuid}.")
|
||||
tracking_sessions[tracker_uuid] = {'status': 'FAILED', 'message': 'Another task is active.'}
|
||||
return
|
||||
|
||||
if ultralytics_sam_tasks is None:
|
||||
logging.error("Ultralytics SAM Tasks module not available for batch tracking.")
|
||||
tracking_sessions[tracker_uuid] = {'status': 'FAILED',
|
||||
'message': 'Ultralytics library not installed or configured on server.'}
|
||||
return
|
||||
|
||||
active_tasks[video_uuid] = tracker_uuid
|
||||
session = {
|
||||
'status': 'BATCH_PROCESSING',
|
||||
'progress': 0,
|
||||
'total': (end_frame - start_frame) + 1,
|
||||
'results': {},
|
||||
'stop_requested': False,
|
||||
'message': 'Preparing temporary video clip...'
|
||||
}
|
||||
tracking_sessions[tracker_uuid] = session
|
||||
|
||||
try:
|
||||
logging.info(
|
||||
f"Starting BATCH SAM tracking for video {video_uuid} from frame {start_frame} to {end_frame}")
|
||||
|
||||
all_results = ultralytics_sam_tasks.run_batch_tracking_with_predictor(
|
||||
video_uuid,
|
||||
start_frame,
|
||||
end_frame,
|
||||
init_bboxes_text,
|
||||
session
|
||||
)
|
||||
|
||||
session['results'] = all_results
|
||||
session['progress'] = session['total']
|
||||
session['status'] = 'COMPLETED'
|
||||
session['message'] = 'Batch processing complete. Ready for review.'
|
||||
logging.info(f"Batch SAM tracking for {tracker_uuid} finished successfully.")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error during Batch SAM tracking for {video_uuid}: {e}\n{traceback.format_exc()}")
|
||||
session['status'] = 'FAILED'
|
||||
session['message'] = str(e)
|
||||
finally:
|
||||
if active_tasks.get(video_uuid) == tracker_uuid:
|
||||
del active_tasks[video_uuid]
|
||||
logging.info(f"Batch tracking task for {tracker_uuid} cleaned up.")
|
||||
|
||||
|
||||
def extract_frames_task(video_uuid):
|
||||
if active_tasks.get(video_uuid) == 'EXTRACTING':
|
||||
logging.warning(f"Extraction for {video_uuid} is already running.")
|
||||
return
|
||||
|
||||
active_tasks[video_uuid] = 'EXTRACTING'
|
||||
logging.info(f"Starting frame extraction for {video_uuid}")
|
||||
video_path = file_storage.get_video_path(video_uuid)
|
||||
|
||||
try:
|
||||
vid = cv2.VideoCapture(video_path)
|
||||
if not vid.isOpened():
|
||||
raise IOError("Cannot open video file")
|
||||
|
||||
width = int(vid.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
height = int(vid.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
fps = vid.get(cv2.CAP_PROP_FPS)
|
||||
|
||||
frame_count = int(vid.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
if frame_count <= 0 or frame_count > config.MAX_FRAMES_PER_VIDEO:
|
||||
vid.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
||||
frame_count = 0
|
||||
while vid.grab():
|
||||
frame_count += 1
|
||||
vid.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
||||
|
||||
if frame_count > config.MAX_FRAMES_PER_VIDEO:
|
||||
raise ValueError(f"Video has more than {config.MAX_FRAMES_PER_VIDEO} frames.")
|
||||
|
||||
database.update_video_after_extraction_start(video_uuid, width, height, fps, frame_count)
|
||||
|
||||
count = 0
|
||||
while True:
|
||||
success, frame = vid.read()
|
||||
if not success:
|
||||
break
|
||||
|
||||
success, buffer = cv2.imencode('.jpg', frame, [int(cv2.IMWRITE_JPEG_QUALITY), 75])
|
||||
if success:
|
||||
file_storage.save_frame_image(video_uuid, count, buffer.tobytes())
|
||||
database.update_extracted_frame_count(video_uuid, count + 1)
|
||||
|
||||
count += 1
|
||||
|
||||
vid.release()
|
||||
database.update_video_status(video_uuid, 'READY')
|
||||
logging.info(f"Frame extraction for {video_uuid} completed successfully.")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error extracting frames for {video_uuid}: {e}")
|
||||
database.update_video_status(video_uuid, 'FAILED', str(e))
|
||||
finally:
|
||||
if active_tasks.get(video_uuid) == 'EXTRACTING':
|
||||
del active_tasks[video_uuid]
|
||||
|
||||
|
||||
def pre_annotate_video_task(video_uuid, model_uuid, options):
|
||||
if active_tasks.get(video_uuid):
|
||||
logging.warning(f"Cannot start pre-annotation for {video_uuid}, another task is active.")
|
||||
return
|
||||
|
||||
active_tasks[video_uuid] = 'PRE_ANNOTATING'
|
||||
logging.info(f"Starting pre-annotation for video {video_uuid} with options: {options}")
|
||||
|
||||
try:
|
||||
confidence_threshold = options['confidence']
|
||||
start_frame = options['start_frame']
|
||||
end_frame = options['end_frame']
|
||||
merge_strategy = options['merge_strategy']
|
||||
|
||||
video = database.get_video_entity(video_uuid)
|
||||
model_info = database.get_model_entity(model_uuid)
|
||||
model_type = model_info['model_type']
|
||||
|
||||
database.update_video_status(video_uuid, 'PRE_ANNOTATING', f"Using model: {model_info['description']}")
|
||||
database.update_pre_annotation_info(video_uuid, model_uuid, model_info['description'])
|
||||
|
||||
model_path = file_storage.get_model_path(model_uuid)
|
||||
label_path = file_storage.get_label_file_path(model_uuid)
|
||||
|
||||
interpreter = tf.lite.Interpreter(model_path=model_path)
|
||||
interpreter.allocate_tensors()
|
||||
|
||||
with open(label_path, 'r') as f:
|
||||
labels = [line.strip() for line in f.readlines()]
|
||||
|
||||
input_details = interpreter.get_input_details()
|
||||
output_details = interpreter.get_output_details()
|
||||
height = input_details[0]['shape'][1]
|
||||
width = input_details[0]['shape'][2]
|
||||
|
||||
all_frames = database.get_video_frames(video_uuid)
|
||||
frames_to_process = []
|
||||
for frame_info in all_frames:
|
||||
if start_frame <= frame_info['frame_number'] <= end_frame:
|
||||
if merge_strategy == 'skip_labeled' and frame_info.get('bboxes_text', '').strip():
|
||||
continue
|
||||
frames_to_process.append(frame_info)
|
||||
|
||||
total_frames_to_process = len(frames_to_process)
|
||||
logging.info(f"Total frames to process after filtering: {total_frames_to_process}")
|
||||
|
||||
for i, frame_info in enumerate(frames_to_process):
|
||||
if i % 10 == 0:
|
||||
current_status = database.get_video_entity(video_uuid)['status']
|
||||
if current_status == 'CANCELLING':
|
||||
logging.info(f"Pre-annotation for {video_uuid} cancelled by user.")
|
||||
database.update_video_status(video_uuid, 'READY', 'Task was cancelled.')
|
||||
return
|
||||
|
||||
if (i + 1) % 20 == 0:
|
||||
progress_msg = f"Processed {i + 1}/{total_frames_to_process} frames"
|
||||
database.update_video_status(video_uuid, 'PRE_ANNOTATING', progress_msg)
|
||||
|
||||
frame_path = file_storage.get_frame_path(video_uuid, frame_info['frame_number'])
|
||||
if not os.path.exists(frame_path):
|
||||
continue
|
||||
|
||||
frame_img = cv2.imread(frame_path)
|
||||
imH, imW, _ = frame_img.shape
|
||||
frame_rgb = cv2.cvtColor(frame_img, cv2.COLOR_BGR2RGB)
|
||||
image_resized = cv2.resize(frame_rgb, (width, height))
|
||||
input_data = np.expand_dims(image_resized, axis=0)
|
||||
|
||||
if model_type == 'float32':
|
||||
input_data = np.float32(input_data) / 255.0
|
||||
|
||||
interpreter.set_tensor(input_details[0]['index'], input_data)
|
||||
interpreter.invoke()
|
||||
|
||||
scores_raw = interpreter.get_tensor(output_details[0]['index'])[0]
|
||||
boxes_raw = interpreter.get_tensor(output_details[1]['index'])[0]
|
||||
classes_raw = interpreter.get_tensor(output_details[3]['index'])[0]
|
||||
|
||||
scores_details = output_details[0]
|
||||
if scores_details['dtype'] == np.uint8 and scores_details.get('quantization'):
|
||||
scale, zero_point = scores_details['quantization']
|
||||
scores = (np.float32(scores_raw) - zero_point) * scale
|
||||
else:
|
||||
scores = scores_raw
|
||||
|
||||
boxes_details = output_details[1]
|
||||
if boxes_details['dtype'] == np.uint8 and boxes_details.get('quantization'):
|
||||
scale, zero_point = boxes_details['quantization']
|
||||
boxes = (np.float32(boxes_raw) - zero_point) * scale
|
||||
else:
|
||||
boxes = boxes_raw
|
||||
classes = classes_raw
|
||||
|
||||
bboxes_text_lines = []
|
||||
for j in range(len(scores)):
|
||||
if scores[j] > confidence_threshold:
|
||||
ymin = int(max(0, boxes[j][0] * imH))
|
||||
xmin = int(max(0, boxes[j][1] * imW))
|
||||
ymax = int(min(imH, boxes[j][2] * imH))
|
||||
xmax = int(min(imW, boxes[j][3] * imW))
|
||||
|
||||
object_id = int(classes[j])
|
||||
if object_id < len(labels):
|
||||
object_name = labels[object_id]
|
||||
bboxes_text_lines.append(f"{xmin},{ymin},{xmax},{ymax},{object_name}")
|
||||
|
||||
final_bboxes_text = "\n".join(bboxes_text_lines)
|
||||
database.save_frame_bboxes(video_uuid, frame_info['frame_number'], final_bboxes_text)
|
||||
|
||||
database.update_video_status(video_uuid, 'READY', "Pre-annotation complete")
|
||||
logging.info(f"Pre-annotation for {video_uuid} completed successfully.")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error during pre-annotation for {video_uuid}: {e}", exc_info=True)
|
||||
database.update_video_status(video_uuid, 'READY', f"Pre-annotation failed: {e}")
|
||||
finally:
|
||||
if active_tasks.get(video_uuid) == 'PRE_ANNOTATING':
|
||||
del active_tasks[video_uuid]
|
||||
|
||||
|
||||
def start_tracking_task(video_uuid, tracker_uuid, tracker_name, scale, init_frame_number, init_bboxes_text):
|
||||
if active_tasks.get(video_uuid):
|
||||
logging.warning(f"A task (tracking/extraction) is already running for video {video_uuid}.")
|
||||
tracking_sessions[tracker_uuid] = {'status': 'FAILED', 'message': 'Another task is active.'}
|
||||
return
|
||||
|
||||
active_tasks[video_uuid] = tracker_uuid
|
||||
video_path = file_storage.get_video_path(video_uuid)
|
||||
video_info = database.get_video_entity(video_uuid)
|
||||
|
||||
tracker_fns = {
|
||||
'CSRT': cv2.legacy.TrackerCSRT_create, 'MedianFlow': cv2.legacy.TrackerMedianFlow_create,
|
||||
'MIL': cv2.legacy.TrackerMIL_create, 'MOSSE': cv2.legacy.TrackerMOSSE_create,
|
||||
'TLD': cv2.legacy.TrackerTLD_create, 'KCF': cv2.legacy.TrackerKCF_create,
|
||||
'Boosting': cv2.legacy.TrackerBoosting_create,
|
||||
}
|
||||
|
||||
try:
|
||||
logging.info(f"Starting tracking for video {video_uuid} with tracker {tracker_name}")
|
||||
vid = cv2.VideoCapture(video_path)
|
||||
if not vid.isOpened(): raise IOError("Cannot open video file")
|
||||
vid.set(cv2.CAP_PROP_POS_FRAMES, init_frame_number)
|
||||
session = {'status': 'RUNNING', 'current_frame': init_frame_number, 'bboxes_text': init_bboxes_text,
|
||||
'last_client_update': time.time(), 'stop_requested': False}
|
||||
tracking_sessions[tracker_uuid] = session
|
||||
frame_number = init_frame_number
|
||||
trackers = None
|
||||
while not session['stop_requested']:
|
||||
success, frame = vid.read()
|
||||
if not success:
|
||||
session['status'] = 'COMPLETED'
|
||||
break
|
||||
if trackers is None or session['current_frame'] == frame_number:
|
||||
from bbox_writer import parse_bboxes_text
|
||||
bboxes, classes = parse_bboxes_text(session['bboxes_text'], scale)
|
||||
tracker_fn = tracker_fns[tracker_name]
|
||||
trackers = []
|
||||
for bbox in bboxes:
|
||||
tracker = tracker_fn()
|
||||
tracker.init(frame, tuple(bbox))
|
||||
trackers.append(tracker)
|
||||
new_bboxes = []
|
||||
for tracker in trackers:
|
||||
ok, bbox = tracker.update(frame)
|
||||
new_bboxes.append(np.array(bbox) if ok else None)
|
||||
from bbox_writer import format_bboxes_text
|
||||
session['bboxes_text'] = format_bboxes_text(new_bboxes, classes, scale, video_info['width'],
|
||||
video_info['height'])
|
||||
session['current_frame'] = frame_number
|
||||
while session['current_frame'] == frame_number and not session['stop_requested']:
|
||||
time.sleep(0.1)
|
||||
if time.time() - session['last_client_update'] > 60:
|
||||
logging.warning(f"Tracking session {tracker_uuid} timed out.")
|
||||
session['status'] = 'TIMED OUT'
|
||||
session['stop_requested'] = True
|
||||
frame_number += 1
|
||||
vid.release()
|
||||
except Exception as e:
|
||||
logging.error(f"Error during tracking for {video_uuid}: {e}\n{traceback.format_exc()}")
|
||||
if tracker_uuid in tracking_sessions:
|
||||
tracking_sessions[tracker_uuid]['status'] = 'FAILED'
|
||||
tracking_sessions[tracker_uuid]['message'] = str(e)
|
||||
finally:
|
||||
if active_tasks.get(video_uuid) == tracker_uuid: del active_tasks[video_uuid]
|
||||
if tracker_uuid in tracking_sessions and tracking_sessions[tracker_uuid]['status'] == 'RUNNING':
|
||||
tracking_sessions[tracker_uuid]['status'] = 'STOPPED'
|
||||
logging.info(
|
||||
f"Tracking task for {video_uuid} finished with status: {tracking_sessions.get(tracker_uuid, {}).get('status')}")
|
||||
|
||||
|
||||
def build_augmentation_pipeline(options):
|
||||
if A is None: return None
|
||||
transforms = []
|
||||
if options.get('hflip', {}).get('enabled'):
|
||||
transforms.append(A.HorizontalFlip(p=options['hflip']['p']))
|
||||
if options.get('vflip', {}).get('enabled'):
|
||||
transforms.append(A.VerticalFlip(p=options['vflip']['p']))
|
||||
if options.get('rotate90', {}).get('enabled'):
|
||||
transforms.append(A.RandomRotate90(p=options['rotate90']['p']))
|
||||
if options.get('rotate', {}).get('enabled'):
|
||||
transforms.append(
|
||||
A.Rotate(limit=options['rotate']['limit'], p=options['rotate']['p'], border_mode=cv2.BORDER_CONSTANT,
|
||||
value=0))
|
||||
if options.get('ssr', {}).get('enabled'):
|
||||
transforms.append(A.ShiftScaleRotate(shift_limit=options['ssr']['shift'], scale_limit=options['ssr']['scale'],
|
||||
rotate_limit=options['ssr']['rotate'], p=options['ssr']['p'],
|
||||
border_mode=cv2.BORDER_CONSTANT, value=0))
|
||||
if options.get('affine', {}).get('enabled'):
|
||||
limit = options['affine']['shear']
|
||||
transforms.append(
|
||||
A.Affine(shear={'x': (-limit, limit), 'y': (-limit, limit)}, p=options['affine']['p'], cval=0))
|
||||
if options.get('crop', {}).get('enabled'):
|
||||
transforms.append(A.RandomSizedBBoxSafeCrop(height=1024, width=1024, erosion_rate=0.2,
|
||||
p=options['crop']['p']))
|
||||
|
||||
if options.get('grayscale', {}).get('enabled'):
|
||||
transforms.append(A.ToGray(p=options['grayscale']['p']))
|
||||
if options.get('hsv', {}).get('enabled'):
|
||||
transforms.append(A.HueSaturationValue(hue_shift_limit=options['hsv']['h'], sat_shift_limit=options['hsv']['s'],
|
||||
val_shift_limit=options['hsv']['v'], p=options['hsv']['p']))
|
||||
if options.get('bc', {}).get('enabled'):
|
||||
transforms.append(
|
||||
A.RandomBrightnessContrast(brightness_limit=options['bc']['b'], contrast_limit=options['bc']['c'],
|
||||
p=options['bc']['p']))
|
||||
|
||||
if options.get('blur', {}).get('enabled'):
|
||||
transforms.append(A.GaussianBlur(blur_limit=(3, options['blur']['limit']), p=options['blur']['p']))
|
||||
if options.get('noise', {}).get('enabled'):
|
||||
transforms.append(A.GaussNoise(var_limit=(10.0, options['noise']['limit']), p=options['noise']['p']))
|
||||
|
||||
if options.get('cutout', {}).get('enabled'):
|
||||
transforms.append(
|
||||
BboxSafeCoarseDropout(max_holes=options['cutout']['holes'], max_height=options['cutout']['size'],
|
||||
max_width=options['cutout']['size'], fill_value=0, p=options['cutout']['p']))
|
||||
|
||||
if not transforms: return None
|
||||
return A.Compose(transforms,
|
||||
bbox_params=A.BboxParams(format='yolo', label_fields=['class_labels'], min_visibility=0.1))
|
||||
|
||||
|
||||
def process_frame_worker(args):
|
||||
frame_info, target_img_dir, target_lbl_dir, class_map, augmentation_options = args
|
||||
|
||||
augment_pipeline = None
|
||||
is_augmented = frame_info.get("type") == "augmented"
|
||||
if is_augmented and augmentation_options and augmentation_options.get("enabled", False):
|
||||
augment_pipeline = build_augmentation_pipeline(augmentation_options)
|
||||
|
||||
try:
|
||||
if is_augmented:
|
||||
base_filename = frame_info["augmented_id"]
|
||||
else:
|
||||
base_filename = f"{frame_info['video_uuid']}_{frame_info['frame_number']:05d}"
|
||||
|
||||
src_img_path = file_storage.get_frame_path(frame_info['video_uuid'], frame_info['frame_number'])
|
||||
if not os.path.exists(src_img_path):
|
||||
logging.warning(f"源文件未找到,跳过: {src_img_path}")
|
||||
return None
|
||||
|
||||
image = cv2.imread(src_img_path)
|
||||
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
||||
yolo_bboxes, class_indices = file_storage.get_yolo_bboxes(
|
||||
frame_info['bboxes_text'], frame_info['width'], frame_info['height'], class_map
|
||||
)
|
||||
|
||||
if not yolo_bboxes:
|
||||
return None
|
||||
|
||||
if is_augmented and augment_pipeline:
|
||||
transformed = augment_pipeline(image=image, bboxes=yolo_bboxes, class_labels=class_indices)
|
||||
image_aug_rgb = transformed['image']
|
||||
bboxes_aug_yolo_tuples = transformed['bboxes']
|
||||
labels_aug_indices = transformed['class_labels']
|
||||
bboxes_aug_yolo = [(labels_aug_indices[i], *box) for i, box in enumerate(bboxes_aug_yolo_tuples)]
|
||||
else:
|
||||
image_aug_rgb = image
|
||||
bboxes_aug_yolo = [(class_indices[i], *box) for i, box in enumerate(yolo_bboxes)]
|
||||
|
||||
final_image_bgr = cv2.cvtColor(image_aug_rgb, cv2.COLOR_RGB2BGR)
|
||||
output_image_path = os.path.join(target_img_dir, base_filename + '.jpg')
|
||||
cv2.imwrite(output_image_path, final_image_bgr)
|
||||
|
||||
yolo_content_lines = [f"{class_id} {x:.6f} {y:.6f} {w:.6f} {h:.6f}" for class_id, x, y, w, h in bboxes_aug_yolo]
|
||||
output_label_path = os.path.join(target_lbl_dir, base_filename + '.txt')
|
||||
with open(output_label_path, 'w') as f:
|
||||
f.write("\n".join(yolo_content_lines))
|
||||
|
||||
return output_image_path
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"处理帧 {frame_info.get('augmented_id') or frame_info.get('frame_number')} 时发生错误: {e}")
|
||||
logging.error(traceback.format_exc())
|
||||
return None
|
||||
|
||||
|
||||
def create_dataset_task(dataset_uuid, video_uuids, eval_percent, test_percent, augmentation_options=None):
|
||||
if augmentation_options is None:
|
||||
augmentation_options = {}
|
||||
|
||||
logging.info(f"Starting dataset creation task for UUID: {dataset_uuid} with augmentations: {augmentation_options}")
|
||||
try:
|
||||
if eval_percent is None: eval_percent = 20.0
|
||||
if test_percent is None: test_percent = 10.0
|
||||
if eval_percent + test_percent >= 100.0:
|
||||
raise ValueError(
|
||||
f"The sum of validation ({eval_percent}%) and test ({test_percent}%) percentages must be less than 100.")
|
||||
|
||||
database.update_dataset_status(dataset_uuid, status="PROCESSING", message="Gathering labeled frames...")
|
||||
|
||||
frames_to_include = []
|
||||
all_labels = set()
|
||||
logging.info(f"Gathering frames from {len(video_uuids)} selected video(s)...")
|
||||
for video_uuid in video_uuids:
|
||||
video = database.get_video_entity(video_uuid)
|
||||
all_video_frames = database.get_video_frames(video_uuid)
|
||||
for frame in all_video_frames:
|
||||
if frame.get('bboxes_text') and frame['bboxes_text'].strip():
|
||||
frames_to_include.append({
|
||||
"video_uuid": video_uuid, "frame_number": frame['frame_number'],
|
||||
"bboxes_text": frame['bboxes_text'], "width": video['width'], "height": video['height']
|
||||
})
|
||||
labels_in_frame = extract_labels(frame['bboxes_text'])
|
||||
for label in labels_in_frame: all_labels.add(label)
|
||||
|
||||
if not frames_to_include:
|
||||
raise ValueError("No labeled frames with valid bounding boxes were found in the selected videos.")
|
||||
|
||||
sorted_labels = sorted(list(all_labels))
|
||||
class_map = {name: i for i, name in enumerate(sorted_labels)}
|
||||
logging.info(f"Dataset classes (sorted): {sorted_labels}")
|
||||
|
||||
is_aug_enabled = A is not None and augmentation_options.get("enabled", False)
|
||||
multiplication_factor = int(augmentation_options.get("multiply_factor", 1)) if is_aug_enabled else 1
|
||||
final_frames_to_process = []
|
||||
if is_aug_enabled and multiplication_factor > 1:
|
||||
for frame_info in frames_to_include:
|
||||
final_frames_to_process.append({"type": "original", **frame_info})
|
||||
for i in range(multiplication_factor - 1):
|
||||
aug_id = f"aug_{i}_{frame_info['video_uuid']}_{frame_info['frame_number']:05d}"
|
||||
final_frames_to_process.append({"type": "augmented", "augmented_id": aug_id, **frame_info})
|
||||
else:
|
||||
final_frames_to_process = [{"type": "original", **frame_info} for frame_info in frames_to_include]
|
||||
|
||||
random.shuffle(final_frames_to_process)
|
||||
total_count = len(final_frames_to_process)
|
||||
val_count = int(total_count * eval_percent / 100.0)
|
||||
test_count = int(total_count * test_percent / 100.0)
|
||||
|
||||
val_data = final_frames_to_process[:val_count]
|
||||
test_data = final_frames_to_process[val_count:val_count + test_count]
|
||||
train_data = final_frames_to_process[val_count + test_count:]
|
||||
|
||||
dataset_dir = file_storage.get_dataset_dir(dataset_uuid)
|
||||
if os.path.exists(dataset_dir): shutil.rmtree(dataset_dir)
|
||||
dir_map = {
|
||||
'train': (os.path.join(dataset_dir, 'images', 'train'), os.path.join(dataset_dir, 'labels', 'train')),
|
||||
'val': (os.path.join(dataset_dir, 'images', 'val'), os.path.join(dataset_dir, 'labels', 'val')),
|
||||
'test': (os.path.join(dataset_dir, 'images', 'test'), os.path.join(dataset_dir, 'labels', 'test')),
|
||||
}
|
||||
for img_dir, lbl_dir in dir_map.values():
|
||||
os.makedirs(img_dir, exist_ok=True)
|
||||
os.makedirs(lbl_dir, exist_ok=True)
|
||||
all_tasks = []
|
||||
for split_name, split_data in [('train', train_data), ('val', val_data), ('test', test_data)]:
|
||||
img_dir, lbl_dir = dir_map[split_name]
|
||||
for frame_info in split_data:
|
||||
all_tasks.append((frame_info, img_dir, lbl_dir, class_map, augmentation_options))
|
||||
|
||||
database.update_dataset_status(dataset_uuid, status="PROCESSING",
|
||||
message=f"Processing {len(all_tasks)} images across {cpu_count()} CPU cores...")
|
||||
logging.info(f"Starting parallel processing of {len(all_tasks)} images using up to {cpu_count()} cores.")
|
||||
|
||||
processed_count = 0
|
||||
with Pool(processes=cpu_count()) as pool:
|
||||
for result in pool.imap_unordered(process_frame_worker, all_tasks):
|
||||
if result:
|
||||
processed_count += 1
|
||||
if processed_count % 50 == 0:
|
||||
progress_msg = f"Processed {processed_count}/{len(all_tasks)} images..."
|
||||
database.update_dataset_status(dataset_uuid, status="PROCESSING", message=progress_msg)
|
||||
|
||||
logging.info(f"Parallel processing finished. Processed {processed_count} images successfully.")
|
||||
|
||||
if yaml:
|
||||
yaml_content = {'path': f"../datasets/{dataset_uuid}", 'train': 'images/train', 'val': 'images/val',
|
||||
'test': 'images/test', 'nc': len(sorted_labels), 'names': sorted_labels}
|
||||
with open(os.path.join(dataset_dir, 'data.yaml'), 'w') as f:
|
||||
yaml.dump(yaml_content, f, sort_keys=False)
|
||||
else:
|
||||
logging.error("PyYAML is not installed! Cannot create data.yaml for the dataset.")
|
||||
|
||||
database.update_dataset_status(dataset_uuid, status="PROCESSING", message="Creating ZIP archive...")
|
||||
zip_path_base = os.path.join(config.STORAGE_DIR, 'datasets', dataset_uuid)
|
||||
zip_path = shutil.make_archive(zip_path_base, 'zip', dataset_dir)
|
||||
shutil.rmtree(dataset_dir)
|
||||
|
||||
logging.info(f"ZIP archive created at: {zip_path}")
|
||||
database.update_dataset_status(dataset_uuid, status="READY", zip_path=zip_path, sorted_label_list=sorted_labels)
|
||||
logging.info(f"Dataset {dataset_uuid} task completed successfully.")
|
||||
|
||||
except Exception as e:
|
||||
error_message = f"Failed to create dataset {dataset_uuid}"
|
||||
logging.error(f"{error_message}: {e}")
|
||||
logging.error(traceback.format_exc())
|
||||
database.update_dataset_status(dataset_uuid, status="FAILED", message=str(e))
|
||||
@@ -0,0 +1,159 @@
|
||||
import logging
|
||||
import numpy as np
|
||||
import config
|
||||
|
||||
|
||||
def __convert_bbox_to_text(bbox, scale, x_max, y_max):
|
||||
p0 = bbox[:2].astype(float)
|
||||
p1 = p0 + bbox[2:].astype(float)
|
||||
size = p1 - p0
|
||||
center = p0 + (size / 2)
|
||||
new_size = scale * size
|
||||
p0 = center - new_size / 2
|
||||
p1 = center + new_size / 2
|
||||
scaled_bbox = np.array([p0, p1 - p0]).reshape(-1)
|
||||
p0 = scaled_bbox[:2]
|
||||
size = scaled_bbox[2:]
|
||||
p1 = p0 + size
|
||||
return "%d,%d,%d,%d" % (
|
||||
int(max(p0[0], 0)),
|
||||
int(max(p0[1], 0)),
|
||||
int(min(p1[0], x_max)),
|
||||
int(min(p1[1], y_max)))
|
||||
|
||||
|
||||
def __convert_bboxes_and_labels_to_text(bboxes, scale, max_x, max_y, labels):
|
||||
assert (len(bboxes) == len(labels))
|
||||
bboxes_text = ""
|
||||
for i in range(len(bboxes)):
|
||||
bbox = bboxes[i]
|
||||
label = labels[i]
|
||||
if bbox is None or label is None:
|
||||
continue
|
||||
bboxes_text += "%s,%s\n" % (__convert_bbox_to_text(bbox, scale, max_x, max_y), label)
|
||||
return bboxes_text
|
||||
|
||||
|
||||
def __convert_rects_to_bboxes(rects):
|
||||
bboxes = []
|
||||
for rect in rects:
|
||||
p0 = rect[:2]
|
||||
p1 = rect[2:]
|
||||
size = p1 - p0
|
||||
bbox = np.array([p0, size]).reshape(-1)
|
||||
bboxes.append(bbox)
|
||||
return bboxes
|
||||
|
||||
|
||||
def validate_bboxes_text(s):
|
||||
if s is None:
|
||||
return ""
|
||||
lines = s.split("\n")
|
||||
for line in lines:
|
||||
if len(line.strip()) > 0:
|
||||
try:
|
||||
parts = line.strip().split(',', 4)
|
||||
if len(parts) != 5:
|
||||
raise ValueError("Line does not have enough parts for rect and label.")
|
||||
rect_str = parts[:4]
|
||||
np.array(rect_str, dtype=float).astype(int)
|
||||
except Exception as e:
|
||||
message = f"Error: Line '{line}' is not a valid bbox format. Details: {e}"
|
||||
logging.critical(message)
|
||||
raise ValueError(message)
|
||||
return s
|
||||
|
||||
def convert_text_to_rects_and_labels(bboxes_text):
|
||||
rects = []
|
||||
labels = []
|
||||
object_ids = []
|
||||
if not bboxes_text:
|
||||
return rects, labels, object_ids
|
||||
|
||||
lines = [line for line in bboxes_text.split('\n') if line.strip()]
|
||||
for line in lines:
|
||||
try:
|
||||
parts = line.strip().split(',', 5)
|
||||
if len(parts) < 5:
|
||||
logging.warning(f"Skipping malformed bbox line (not enough parts): '{line}'")
|
||||
continue
|
||||
|
||||
rect_str = parts[:4]
|
||||
label = parts[4]
|
||||
object_id = parts[5] if len(parts) > 5 else None
|
||||
|
||||
coords = np.array(rect_str, dtype=float).astype(int)
|
||||
x1, y1, x2, y2 = coords
|
||||
rect = np.array([min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2)])
|
||||
|
||||
rects.append(rect)
|
||||
labels.append(label)
|
||||
object_ids.append(object_id)
|
||||
except (ValueError, IndexError) as e:
|
||||
logging.warning(f"Skipping malformed bbox line (parsing error): '{line}'. Error: {e}")
|
||||
continue
|
||||
return rects, labels, object_ids
|
||||
|
||||
|
||||
def count_boxes(bboxes_text):
|
||||
if not bboxes_text:
|
||||
return 0
|
||||
return len([line for line in bboxes_text.split('\n') if line.strip()])
|
||||
|
||||
|
||||
def __convert_text_to_bboxes_and_labels(bboxes_text):
|
||||
rects, labels, _ = convert_text_to_rects_and_labels(bboxes_text)
|
||||
bboxes = __convert_rects_to_bboxes(rects)
|
||||
return bboxes, labels
|
||||
|
||||
|
||||
def __scale_bboxes(bboxes, scale):
|
||||
scaled_bboxes = []
|
||||
for bbox in bboxes:
|
||||
if bbox is None:
|
||||
scaled_bboxes.append(None)
|
||||
else:
|
||||
p0 = bbox[:2].astype(float)
|
||||
p1 = p0 + bbox[2:].astype(float)
|
||||
size = p1 - p0
|
||||
center = p0 + (size / 2)
|
||||
new_size = scale * size
|
||||
p0 = center - new_size / 2
|
||||
p1 = center + new_size / 2
|
||||
scaled_bboxes.append(np.array([p0, p1 - p0]).reshape(-1))
|
||||
return scaled_bboxes
|
||||
|
||||
|
||||
def parse_bboxes_text(bboxes_text, scale=1):
|
||||
bboxes_, labels = __convert_text_to_bboxes_and_labels(bboxes_text)
|
||||
bboxes = __scale_bboxes(bboxes_, scale)
|
||||
return bboxes, labels
|
||||
|
||||
|
||||
def extract_labels(bboxes_text):
|
||||
_, labels, _ = convert_text_to_rects_and_labels(bboxes_text)
|
||||
return labels
|
||||
|
||||
|
||||
def format_bboxes_text(bboxes, labels, scale, max_x, max_y):
|
||||
return __convert_bboxes_and_labels_to_text(bboxes, 1 / scale, max_x, max_y, labels)
|
||||
|
||||
|
||||
def convert_to_yolo_format(bboxes_text, class_map, image_width, image_height):
|
||||
rects, labels, _ = convert_text_to_rects_and_labels(bboxes_text)
|
||||
yolo_lines = []
|
||||
for i, rect in enumerate(rects):
|
||||
label = labels[i]
|
||||
if label not in class_map: continue
|
||||
class_id = class_map[label]
|
||||
x1, y1, x2, y2 = rect
|
||||
box_width = float(x2 - x1)
|
||||
box_height = float(y2 - y1)
|
||||
x_center = float(x1) + (box_width / 2)
|
||||
y_center = float(y1) + (box_height / 2)
|
||||
x_center_norm = x_center / image_width
|
||||
y_center_norm = y_center / image_height
|
||||
width_norm = box_width / image_width
|
||||
height_norm = box_height / image_height
|
||||
yolo_lines.append(f"{class_id} {x_center_norm:.6f} {y_center_norm:.6f} {width_norm:.6f} {height_norm:.6f}")
|
||||
return "\n".join(yolo_lines)
|
||||
@@ -0,0 +1,39 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
if getattr(sys, 'frozen', False):
|
||||
BASE_DIR = os.path.dirname(sys.executable)
|
||||
else:
|
||||
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
DATABASE_FILE = os.path.join(BASE_DIR, 'ftc_ml.db')
|
||||
STORAGE_DIR = os.path.join(BASE_DIR, 'local_storage')
|
||||
PROTOTYPE_FILE = os.path.join(STORAGE_DIR, 'prototype_library.pt')
|
||||
PREPROCESSED_CACHE_FILE = os.path.join(STORAGE_DIR, 'preprocessed_cache.pt')
|
||||
|
||||
|
||||
MAX_DESCRIPTION_LENGTH = 30
|
||||
MAX_VIDEO_SIZE_MB = 10000
|
||||
MAX_VIDEO_SIZE_BYTES = MAX_VIDEO_SIZE_MB * 1000 * 1000
|
||||
MAX_VIDEO_LENGTH_SECONDS = 120
|
||||
MAX_FRAMES_PER_VIDEO = 10000
|
||||
MAX_VIDEO_RESOLUTION_WIDTH = 3840
|
||||
MAX_VIDEO_RESOLUTION_HEIGHT = 2160
|
||||
MAX_DATASETS_PER_TEAM = 50
|
||||
MAX_VIDEOS_PER_TEAM = 50
|
||||
TRACKER_FNS = [
|
||||
'CSRT', 'MedianFlow', 'MIL', 'MOSSE', 'TLD', 'KCF', 'Boosting',
|
||||
]
|
||||
|
||||
def get_limit_data_for_render_template():
|
||||
return {
|
||||
'MAX_VIDEO_SIZE_BYTES': MAX_VIDEO_SIZE_BYTES,
|
||||
'MAX_VIDEO_SIZE_MB': MAX_VIDEO_SIZE_MB,
|
||||
'MAX_VIDEO_LENGTH_SECONDS': MAX_VIDEO_LENGTH_SECONDS,
|
||||
'MAX_FRAMES_PER_VIDEO': MAX_FRAMES_PER_VIDEO,
|
||||
'MAX_DESCRIPTION_LENGTH': MAX_DESCRIPTION_LENGTH,
|
||||
}
|
||||
|
||||
ONNX_MODELS_DIR = "onnx_models"
|
||||
MOBILENET_LARGE_ONNX = os.path.join(ONNX_MODELS_DIR, 'mobilenet_v3_large.onnx')
|
||||
MOBILENET_SMALL_ONNX = os.path.join(ONNX_MODELS_DIR, 'mobilenet_v3_small.onnx')
|
||||
@@ -0,0 +1,541 @@
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
import time
|
||||
import uuid
|
||||
import config
|
||||
import file_storage
|
||||
from bbox_writer import extract_labels
|
||||
|
||||
def get_db_connection():
|
||||
conn = sqlite3.connect(config.DATABASE_FILE)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def _add_column_if_not_exists(cursor, table_name, column_name, column_type):
|
||||
cursor.execute(f"PRAGMA table_info({table_name})")
|
||||
columns = [row['name'] for row in cursor.fetchall()]
|
||||
if column_name not in columns:
|
||||
cursor.execute(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_type}")
|
||||
print(f"Added column '{column_name}' to table '{table_name}'.")
|
||||
|
||||
|
||||
def migrate_db():
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
_add_column_if_not_exists(cursor, 'datasets', 'eval_percent', 'REAL')
|
||||
_add_column_if_not_exists(cursor, 'datasets', 'test_percent', 'REAL')
|
||||
_add_column_if_not_exists(cursor, 'models', 'label_filename', 'TEXT')
|
||||
_add_column_if_not_exists(cursor, 'models', 'model_type', 'TEXT')
|
||||
_add_column_if_not_exists(cursor, 'videos', 'last_pre_annotation_info', 'TEXT')
|
||||
_add_column_if_not_exists(cursor, 'video_frames', 'tags', 'TEXT')
|
||||
_add_column_if_not_exists(cursor, 'video_frames', 'suggested_bboxes_text', 'TEXT')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def init_db():
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS videos (
|
||||
video_uuid TEXT PRIMARY KEY,
|
||||
description TEXT NOT NULL UNIQUE,
|
||||
video_filename TEXT,
|
||||
file_size INTEGER,
|
||||
create_time_ms INTEGER,
|
||||
status TEXT,
|
||||
status_message TEXT,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
fps REAL,
|
||||
frame_count INTEGER,
|
||||
extracted_frame_count INTEGER DEFAULT 0,
|
||||
included_frame_count INTEGER DEFAULT 0,
|
||||
labeled_frame_count INTEGER DEFAULT 0,
|
||||
last_pre_annotation_info TEXT
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS video_frames (
|
||||
frame_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
video_uuid TEXT,
|
||||
frame_number INTEGER,
|
||||
bboxes_text TEXT,
|
||||
suggested_bboxes_text TEXT,
|
||||
tags TEXT,
|
||||
include_frame_in_dataset INTEGER,
|
||||
FOREIGN KEY (video_uuid) REFERENCES videos (video_uuid) ON DELETE CASCADE
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS datasets (
|
||||
dataset_uuid TEXT PRIMARY KEY,
|
||||
description TEXT NOT NULL UNIQUE,
|
||||
video_uuids TEXT,
|
||||
create_time_ms INTEGER,
|
||||
status TEXT,
|
||||
status_message TEXT,
|
||||
zip_path TEXT,
|
||||
sorted_label_list TEXT,
|
||||
eval_percent REAL,
|
||||
test_percent REAL
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS models (
|
||||
model_uuid TEXT PRIMARY KEY,
|
||||
description TEXT NOT NULL UNIQUE,
|
||||
create_time_ms INTEGER,
|
||||
label_filename TEXT,
|
||||
model_type TEXT
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS annotation_tasks (
|
||||
task_uuid TEXT PRIMARY KEY,
|
||||
video_uuid TEXT NOT NULL,
|
||||
assigned_to TEXT NOT NULL,
|
||||
description TEXT,
|
||||
start_frame INTEGER NOT NULL,
|
||||
end_frame INTEGER NOT NULL,
|
||||
status TEXT,
|
||||
create_time_ms INTEGER,
|
||||
FOREIGN KEY (video_uuid) REFERENCES videos (video_uuid) ON DELETE CASCADE
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS class_labels (
|
||||
label_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
label_name TEXT NOT NULL UNIQUE,
|
||||
create_time_ms INTEGER
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS class_tags (
|
||||
tag_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tag_name TEXT NOT NULL UNIQUE,
|
||||
create_time_ms INTEGER
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS frame_labels (
|
||||
frame_id INTEGER NOT NULL,
|
||||
label_name TEXT NOT NULL,
|
||||
FOREIGN KEY (frame_id) REFERENCES video_frames (frame_id) ON DELETE CASCADE
|
||||
)
|
||||
''')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_frame_labels_name ON frame_labels (label_name)')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def create_video_entry(description, video_filename, file_size, create_time_ms):
|
||||
conn = get_db_connection()
|
||||
video_uuid = str(uuid.uuid4().hex)
|
||||
conn.execute(
|
||||
'INSERT INTO videos (video_uuid, description, video_filename, file_size, create_time_ms, status) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
(video_uuid, description, video_filename, file_size, create_time_ms, 'UPLOADING')
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return video_uuid
|
||||
|
||||
|
||||
def get_ready_videos_with_labels():
|
||||
conn = get_db_connection()
|
||||
videos = conn.execute(
|
||||
'SELECT * FROM videos WHERE status = "READY" AND labeled_frame_count > 0 ORDER BY create_time_ms DESC').fetchall()
|
||||
conn.close()
|
||||
return [dict(row) for row in videos]
|
||||
|
||||
|
||||
def get_all_video_list():
|
||||
conn = get_db_connection()
|
||||
videos = conn.execute('SELECT * FROM videos ORDER BY create_time_ms DESC').fetchall()
|
||||
conn.close()
|
||||
return [dict(row) for row in videos]
|
||||
|
||||
|
||||
def get_video_entity(video_uuid):
|
||||
conn = get_db_connection()
|
||||
video = conn.execute('SELECT * FROM videos WHERE video_uuid = ?', (video_uuid,)).fetchone()
|
||||
conn.close()
|
||||
return dict(video) if video else None
|
||||
|
||||
|
||||
def update_video_status(video_uuid, status, message=""):
|
||||
conn = get_db_connection()
|
||||
conn.execute('UPDATE videos SET status = ?, status_message = ? WHERE video_uuid = ?', (status, message, video_uuid))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_pre_annotation_info(video_uuid, model_uuid, model_desc):
|
||||
conn = get_db_connection()
|
||||
info = {
|
||||
"model_uuid": model_uuid,
|
||||
"model_desc": model_desc,
|
||||
"time_ms": int(time.time() * 1000)
|
||||
}
|
||||
conn.execute('UPDATE videos SET last_pre_annotation_info = ? WHERE video_uuid = ?', (json.dumps(info), video_uuid))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_video_after_extraction_start(video_uuid, width, height, fps, frame_count):
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'UPDATE videos SET width=?, height=?, fps=?, frame_count=?, included_frame_count=?, status=? WHERE video_uuid=?',
|
||||
(width, height, fps, frame_count, frame_count, 'EXTRACTING', video_uuid)
|
||||
)
|
||||
frames_to_insert = [(video_uuid, i, '', '', '', 1) for i in range(frame_count)]
|
||||
cursor.executemany(
|
||||
'INSERT INTO video_frames (video_uuid, frame_number, bboxes_text, suggested_bboxes_text, tags, include_frame_in_dataset) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
frames_to_insert
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_extracted_frame_count(video_uuid, count):
|
||||
conn = get_db_connection()
|
||||
conn.execute('UPDATE videos SET extracted_frame_count = ? WHERE video_uuid = ?', (count, video_uuid))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def delete_video(video_uuid):
|
||||
conn = get_db_connection()
|
||||
conn.execute('DELETE FROM videos WHERE video_uuid = ?', (video_uuid,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_video_frames(video_uuid):
|
||||
conn = get_db_connection()
|
||||
frames = conn.execute('SELECT * FROM video_frames WHERE video_uuid = ? ORDER BY frame_number ASC',
|
||||
(video_uuid,)).fetchall()
|
||||
conn.close()
|
||||
return [dict(row) for row in frames]
|
||||
|
||||
|
||||
def save_frame_bboxes(video_uuid, frame_number, bboxes_text):
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
frame = cursor.execute(
|
||||
'SELECT frame_id FROM video_frames WHERE video_uuid = ? AND frame_number = ?',
|
||||
(video_uuid, frame_number)
|
||||
).fetchone()
|
||||
|
||||
if not frame:
|
||||
conn.close()
|
||||
logging.error(f"无法为 video {video_uuid}, frame {frame_number} 找到 frame_id。")
|
||||
return
|
||||
|
||||
frame_id = frame['frame_id']
|
||||
cursor.execute(
|
||||
'UPDATE video_frames SET bboxes_text = ?, suggested_bboxes_text = ? WHERE frame_id = ?',
|
||||
(bboxes_text, '', frame_id)
|
||||
)
|
||||
cursor.execute('DELETE FROM frame_labels WHERE frame_id = ?', (frame_id,))
|
||||
unique_labels_in_frame = set(extract_labels(bboxes_text))
|
||||
if unique_labels_in_frame:
|
||||
labels_to_insert = [(frame_id, label) for label in unique_labels_in_frame]
|
||||
cursor.executemany(
|
||||
'INSERT INTO frame_labels (frame_id, label_name) VALUES (?, ?)',
|
||||
labels_to_insert
|
||||
)
|
||||
new_labeled_count = cursor.execute(
|
||||
"SELECT COUNT(*) FROM video_frames WHERE video_uuid = ? AND ((bboxes_text IS NOT NULL AND bboxes_text != '') OR (tags IS NOT NULL AND tags != '[]' AND tags != ''))",
|
||||
(video_uuid,)
|
||||
).fetchone()[0]
|
||||
cursor.execute(
|
||||
'UPDATE videos SET labeled_frame_count = ? WHERE video_uuid = ?',
|
||||
(new_labeled_count, video_uuid)
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def save_frame_suggestions(video_uuid, frame_number, suggested_bboxes_text):
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'UPDATE video_frames SET suggested_bboxes_text = ? WHERE video_uuid = ? AND frame_number = ?',
|
||||
(suggested_bboxes_text, video_uuid, frame_number)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def save_frame_tags(video_uuid, frame_number, tags_json_string):
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'UPDATE video_frames SET tags = ? WHERE video_uuid = ? AND frame_number = ?',
|
||||
(tags_json_string, video_uuid, frame_number)
|
||||
)
|
||||
new_labeled_count = cursor.execute(
|
||||
"SELECT COUNT(*) FROM video_frames WHERE video_uuid = ? AND ((bboxes_text IS NOT NULL AND bboxes_text != '') OR (tags IS NOT NULL AND tags != '[]' AND tags != ''))",
|
||||
(video_uuid,)
|
||||
).fetchone()[0]
|
||||
cursor.execute(
|
||||
'UPDATE videos SET labeled_frame_count = ? WHERE video_uuid = ?',
|
||||
(new_labeled_count, video_uuid)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def add_frames_from_upload(video_uuid, frame_files):
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
video_data = cursor.execute('SELECT frame_count FROM videos WHERE video_uuid = ?', (video_uuid,)).fetchone()
|
||||
if not video_data:
|
||||
raise ValueError("Video UUID not found in database.")
|
||||
start_frame_number = video_data['frame_count']
|
||||
frames_to_insert = []
|
||||
for i, file_storage_obj in enumerate(frame_files):
|
||||
new_frame_number = start_frame_number + i
|
||||
image_bytes = file_storage_obj.read()
|
||||
file_storage.save_frame_image(video_uuid, new_frame_number, image_bytes)
|
||||
frames_to_insert.append((video_uuid, new_frame_number, '', '', '', 1))
|
||||
if frames_to_insert:
|
||||
cursor.executemany(
|
||||
'INSERT INTO video_frames (video_uuid, frame_number, bboxes_text, suggested_bboxes_text, tags, include_frame_in_dataset) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
frames_to_insert
|
||||
)
|
||||
new_total_frames = start_frame_number + len(frames_to_insert)
|
||||
cursor.execute(
|
||||
'UPDATE videos SET frame_count = ?, extracted_frame_count = ?, included_frame_count = ? WHERE video_uuid = ?',
|
||||
(new_total_frames, new_total_frames, new_total_frames, video_uuid)
|
||||
)
|
||||
conn.commit()
|
||||
return len(frames_to_insert)
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
raise e
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def create_annotation_task(video_uuid, assigned_to, description, start_frame, end_frame):
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
existing_tasks = cursor.execute(
|
||||
'SELECT start_frame, end_frame FROM annotation_tasks WHERE video_uuid = ?', (video_uuid,)
|
||||
).fetchall()
|
||||
for task in existing_tasks:
|
||||
if start_frame <= task['end_frame'] and end_frame >= task['start_frame']:
|
||||
conn.close()
|
||||
raise ValueError(f"Frame range overlaps with an existing task ({task['start_frame']}-{task['end_frame']}).")
|
||||
task_uuid = str(uuid.uuid4().hex)
|
||||
create_time_ms = int(time.time() * 1000)
|
||||
cursor.execute(
|
||||
'''INSERT INTO annotation_tasks
|
||||
(task_uuid, video_uuid, assigned_to, description, start_frame, end_frame, status, create_time_ms)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)''',
|
||||
(task_uuid, video_uuid, assigned_to, description, start_frame, end_frame, 'PENDING', create_time_ms)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return task_uuid
|
||||
|
||||
|
||||
def get_tasks_for_video(video_uuid):
|
||||
conn = get_db_connection()
|
||||
tasks = conn.execute('SELECT * FROM annotation_tasks WHERE video_uuid = ? ORDER BY create_time_ms DESC',
|
||||
(video_uuid,)).fetchall()
|
||||
conn.close()
|
||||
return [dict(row) for row in tasks]
|
||||
|
||||
|
||||
def get_task_entity(task_uuid):
|
||||
conn = get_db_connection()
|
||||
task = conn.execute('SELECT * FROM annotation_tasks WHERE task_uuid = ?', (task_uuid,)).fetchone()
|
||||
conn.close()
|
||||
return dict(task) if task else None
|
||||
|
||||
|
||||
def delete_task(task_uuid):
|
||||
conn = get_db_connection()
|
||||
conn.execute('DELETE FROM annotation_tasks WHERE task_uuid = ?', (task_uuid,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_task_status(task_uuid, status):
|
||||
conn = get_db_connection()
|
||||
conn.execute('UPDATE annotation_tasks SET status = ? WHERE task_uuid = ?', (status, task_uuid))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def add_class_label(label_name):
|
||||
conn = get_db_connection()
|
||||
try:
|
||||
create_time_ms = int(time.time() * 1000)
|
||||
conn.execute('INSERT INTO class_labels (label_name, create_time_ms) VALUES (?, ?)',
|
||||
(label_name, create_time_ms))
|
||||
conn.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
pass
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_all_class_labels():
|
||||
conn = get_db_connection()
|
||||
labels = conn.execute('SELECT label_name FROM class_labels ORDER BY label_name ASC').fetchall()
|
||||
conn.close()
|
||||
return [row['label_name'] for row in labels]
|
||||
|
||||
|
||||
def delete_class_label(label_name):
|
||||
conn = get_db_connection()
|
||||
conn.execute('DELETE FROM class_labels WHERE label_name = ?', (label_name,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_all_frames_with_class(class_name):
|
||||
|
||||
conn = get_db_connection()
|
||||
query = """
|
||||
SELECT T1.*, T2.width, T2.height
|
||||
FROM video_frames AS T1
|
||||
INNER JOIN videos AS T2 ON T1.video_uuid = T2.video_uuid
|
||||
INNER JOIN frame_labels AS T3 ON T1.frame_id = T3.frame_id
|
||||
WHERE T3.label_name = ? \
|
||||
"""
|
||||
frames = conn.execute(query, (class_name,)).fetchall()
|
||||
conn.close()
|
||||
|
||||
return [dict(row) for row in frames]
|
||||
|
||||
|
||||
def add_class_tag(tag_name):
|
||||
conn = get_db_connection()
|
||||
try:
|
||||
create_time_ms = int(time.time() * 1000)
|
||||
conn.execute('INSERT INTO class_tags (tag_name, create_time_ms) VALUES (?, ?)', (tag_name, create_time_ms))
|
||||
conn.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
pass
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_all_class_tags():
|
||||
conn = get_db_connection()
|
||||
tags = conn.execute('SELECT tag_name FROM class_tags ORDER BY tag_name ASC').fetchall()
|
||||
conn.close()
|
||||
return [row['tag_name'] for row in tags]
|
||||
|
||||
|
||||
def delete_class_tag(tag_name):
|
||||
conn = get_db_connection()
|
||||
conn.execute('DELETE FROM class_tags WHERE tag_name = ?', (tag_name,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def create_dataset_entry(description, video_uuids, create_time_ms, eval_percent, test_percent):
|
||||
conn = get_db_connection()
|
||||
dataset_uuid = str(uuid.uuid4().hex)
|
||||
conn.execute(
|
||||
'INSERT INTO datasets (dataset_uuid, description, video_uuids, create_time_ms, status, eval_percent, test_percent) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
(dataset_uuid, description, json.dumps(video_uuids), create_time_ms, 'PENDING', eval_percent, test_percent)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return dataset_uuid
|
||||
|
||||
|
||||
def update_dataset_status(dataset_uuid, status, message="", zip_path="", sorted_label_list=None):
|
||||
conn = get_db_connection()
|
||||
if sorted_label_list is not None:
|
||||
conn.execute(
|
||||
'UPDATE datasets SET status=?, status_message=?, zip_path=?, sorted_label_list=? WHERE dataset_uuid=?',
|
||||
(status, message, zip_path, json.dumps(sorted_label_list), dataset_uuid)
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
'UPDATE datasets SET status=?, status_message=?, zip_path=? WHERE dataset_uuid=?',
|
||||
(status, message, zip_path, dataset_uuid)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_dataset_list():
|
||||
conn = get_db_connection()
|
||||
datasets = conn.execute('SELECT * FROM datasets ORDER BY create_time_ms DESC').fetchall()
|
||||
conn.close()
|
||||
return [dict(row) for row in datasets]
|
||||
|
||||
|
||||
def get_dataset_entity(dataset_uuid):
|
||||
conn = get_db_connection()
|
||||
dataset = conn.execute('SELECT * FROM datasets WHERE dataset_uuid = ?', (dataset_uuid,)).fetchone()
|
||||
conn.close()
|
||||
return dict(dataset) if dataset else None
|
||||
|
||||
|
||||
def delete_dataset(dataset_uuid):
|
||||
conn = get_db_connection()
|
||||
conn.execute('DELETE FROM datasets WHERE dataset_uuid = ?', (dataset_uuid,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def import_model_metadata(description, label_filename, model_type, create_time_ms):
|
||||
conn = get_db_connection()
|
||||
model_uuid = str(uuid.uuid4().hex)
|
||||
conn.execute(
|
||||
'INSERT INTO models (model_uuid, description, label_filename, model_type, create_time_ms) VALUES (?, ?, ?, ?, ?)',
|
||||
(model_uuid, description, label_filename, model_type, create_time_ms)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return model_uuid
|
||||
|
||||
|
||||
def get_model_list():
|
||||
conn = get_db_connection()
|
||||
models = conn.execute('SELECT * FROM models ORDER BY create_time_ms DESC').fetchall()
|
||||
conn.close()
|
||||
return [dict(row) for row in models]
|
||||
|
||||
|
||||
def get_model_entity(model_uuid):
|
||||
conn = get_db_connection()
|
||||
model = conn.execute('SELECT * FROM models WHERE model_uuid = ?', (model_uuid,)).fetchone()
|
||||
conn.close()
|
||||
return dict(model) if model else None
|
||||
|
||||
|
||||
def delete_model(model_uuid):
|
||||
conn = get_db_connection()
|
||||
conn.execute('DELETE FROM models WHERE model_uuid = ?', (model_uuid,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def get_frame_numbers_for_video(video_uuid):
|
||||
conn = get_db_connection()
|
||||
frames = conn.execute('SELECT frame_number FROM video_frames WHERE video_uuid = ? ORDER BY frame_number ASC',
|
||||
(video_uuid,)).fetchall()
|
||||
conn.close()
|
||||
return [row['frame_number'] for row in frames]
|
||||
@@ -0,0 +1,284 @@
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import yaml
|
||||
|
||||
import config
|
||||
from bbox_writer import convert_text_to_rects_and_labels
|
||||
|
||||
|
||||
def init_storage():
|
||||
os.makedirs(config.STORAGE_DIR, exist_ok=True)
|
||||
os.makedirs(os.path.join(config.STORAGE_DIR, 'videos'), exist_ok=True)
|
||||
os.makedirs(os.path.join(config.STORAGE_DIR, 'frames'), exist_ok=True)
|
||||
os.makedirs(os.path.join(config.STORAGE_DIR, 'datasets'), exist_ok=True)
|
||||
os.makedirs(os.path.join(config.STORAGE_DIR, 'models'), exist_ok=True)
|
||||
|
||||
|
||||
def get_video_path(video_uuid):
|
||||
return os.path.join(config.STORAGE_DIR, 'videos', f"{video_uuid}.mp4")
|
||||
|
||||
|
||||
def save_uploaded_video(file_storage_obj, video_uuid):
|
||||
video_path = get_video_path(video_uuid)
|
||||
file_storage_obj.save(video_path)
|
||||
return video_path
|
||||
|
||||
|
||||
def delete_video_file(video_uuid):
|
||||
video_path = get_video_path(video_uuid)
|
||||
if os.path.exists(video_path):
|
||||
os.remove(video_path)
|
||||
|
||||
|
||||
def get_frame_dir(video_uuid):
|
||||
return os.path.join(config.STORAGE_DIR, 'frames', video_uuid)
|
||||
|
||||
|
||||
def get_frame_path(video_uuid, frame_number):
|
||||
frame_dir = get_frame_dir(video_uuid)
|
||||
return os.path.join(frame_dir, f"frame_{frame_number:05d}.jpg")
|
||||
|
||||
|
||||
def save_frame_image(video_uuid, frame_number, image_data_bytes):
|
||||
frame_dir = get_frame_dir(video_uuid)
|
||||
os.makedirs(frame_dir, exist_ok=True)
|
||||
frame_path = get_frame_path(video_uuid, frame_number)
|
||||
with open(frame_path, 'wb') as f:
|
||||
f.write(image_data_bytes)
|
||||
|
||||
|
||||
def delete_frames_for_video(video_uuid):
|
||||
frame_dir = get_frame_dir(video_uuid)
|
||||
if os.path.isdir(frame_dir):
|
||||
shutil.rmtree(frame_dir)
|
||||
|
||||
|
||||
def get_dataset_dir(dataset_uuid):
|
||||
return os.path.join(config.STORAGE_DIR, 'datasets', dataset_uuid)
|
||||
|
||||
|
||||
def get_dataset_zip_path(dataset_uuid):
|
||||
return os.path.join(config.STORAGE_DIR, 'datasets', f"{dataset_uuid}.zip")
|
||||
|
||||
|
||||
def get_yolo_bboxes(bboxes_text, width, height, class_map):
|
||||
rects, labels, _ = convert_text_to_rects_and_labels(bboxes_text)
|
||||
if not rects: return [], []
|
||||
|
||||
yolo_bboxes = []
|
||||
class_indices = []
|
||||
|
||||
for i, r in enumerate(rects):
|
||||
x_min, y_min, x_max, y_max = r[0], r[1], r[2], r[3]
|
||||
if x_min > x_max:
|
||||
x_min, x_max = x_max, x_min
|
||||
if y_min > y_max:
|
||||
y_min, y_max = y_max, y_min
|
||||
|
||||
norm_x1 = x_min / width
|
||||
norm_y1 = y_min / height
|
||||
norm_x2 = x_max / width
|
||||
norm_y2 = y_max / height
|
||||
|
||||
norm_x1 = max(0.0, min(1.0, norm_x1))
|
||||
norm_y1 = max(0.0, min(1.0, norm_y1))
|
||||
norm_x2 = max(0.0, min(1.0, norm_x2))
|
||||
norm_y2 = max(0.0, min(1.0, norm_y2))
|
||||
|
||||
box_w = norm_x2 - norm_x1
|
||||
box_h = norm_y2 - norm_y1
|
||||
x_center = norm_x1 + box_w / 2
|
||||
y_center = norm_y1 + box_h / 2
|
||||
|
||||
if box_w > 0 and box_h > 0:
|
||||
yolo_bboxes.append([x_center, y_center, box_w, box_h])
|
||||
class_indices.append(class_map[labels[i]])
|
||||
|
||||
return yolo_bboxes, class_indices
|
||||
|
||||
|
||||
def create_mosaic_image(image_infos, class_map):
|
||||
output_dim = max(info['width'] for info in image_infos)
|
||||
s = output_dim
|
||||
yc, xc = [int(random.uniform(s * 0.5, s * 1.5)) for _ in range(2)]
|
||||
|
||||
mosaic_border = [-s // 2, -s // 2]
|
||||
mosaic_img = np.full((s * 2, s * 2, 3), 114, dtype=np.uint8)
|
||||
final_bboxes = []
|
||||
|
||||
for i, info in enumerate(image_infos):
|
||||
img = cv2.imread(get_frame_path(info['video_uuid'], info['frame_number']))
|
||||
h, w, _ = img.shape
|
||||
|
||||
if i == 0:
|
||||
x1a, y1a, x2a, y2a = max(xc - w, 0), max(yc - h, 0), xc, yc
|
||||
x1b, y1b, x2b, y2b = w - (x2a - x1a), h - (y2a - y1a), w, h
|
||||
elif i == 1:
|
||||
x1a, y1a, x2a, y2a = xc, max(yc - h, 0), min(xc + w, s * 2), yc
|
||||
x1b, y1b, x2b, y2b = 0, h - (y2a - y1a), min(w, x2a - x1a), h
|
||||
elif i == 2:
|
||||
x1a, y1a, x2a, y2a = max(xc - w, 0), yc, xc, min(s * 2, yc + h)
|
||||
x1b, y1b, x2b, y2b = w - (x2a - x1a), 0, w, min(y2a - y1a, h)
|
||||
elif i == 3:
|
||||
x1a, y1a, x2a, y2a = xc, yc, min(xc + w, s * 2), min(s * 2, yc + h)
|
||||
x1b, y1b, x2b, y2b = 0, 0, min(w, x2a - x1a), min(y2a - y1a, h)
|
||||
|
||||
mosaic_img[y1a:y2a, x1a:x2a] = img[y1b:y2b, x1b:x2b]
|
||||
padw = x1a - x1b
|
||||
padh = y1a - y1b
|
||||
|
||||
yolo_boxes, class_indices = get_yolo_bboxes(info['bboxes_text'], w, h, class_map)
|
||||
|
||||
for j, box in enumerate(yolo_boxes):
|
||||
x_center, y_center, box_w, box_h = box
|
||||
|
||||
x_center_abs, box_w_abs = x_center * w, box_w * w
|
||||
y_center_abs, box_h_abs = y_center * h, box_h * h
|
||||
|
||||
new_x_center = (x_center_abs + padw) / (s * 2)
|
||||
new_y_center = (y_center_abs + padh) / (s * 2)
|
||||
new_w = box_w_abs / (s * 2)
|
||||
new_h = box_h_abs / (s * 2)
|
||||
|
||||
final_bboxes.append((class_indices[j], new_x_center, new_y_center, new_w, new_h))
|
||||
|
||||
return mosaic_img, final_bboxes
|
||||
|
||||
|
||||
def create_yolo_dataset_zip(dataset_uuid, frames_data, all_labels, eval_percent, test_percent, augment_pipeline=None,
|
||||
mosaic_options=None):
|
||||
dataset_dir = get_dataset_dir(dataset_uuid)
|
||||
if os.path.exists(dataset_dir): shutil.rmtree(dataset_dir)
|
||||
img_train_dir = os.path.join(dataset_dir, 'images', 'train')
|
||||
lbl_train_dir = os.path.join(dataset_dir, 'labels', 'train')
|
||||
img_val_dir = os.path.join(dataset_dir, 'images', 'val')
|
||||
lbl_val_dir = os.path.join(dataset_dir, 'labels', 'val')
|
||||
img_test_dir = os.path.join(dataset_dir, 'images', 'test')
|
||||
lbl_test_dir = os.path.join(dataset_dir, 'labels', 'test')
|
||||
os.makedirs(img_train_dir);
|
||||
os.makedirs(lbl_train_dir)
|
||||
os.makedirs(img_val_dir)
|
||||
os.makedirs(lbl_val_dir)
|
||||
os.makedirs(img_test_dir)
|
||||
os.makedirs(lbl_test_dir)
|
||||
|
||||
class_map = {name: i for i, name in enumerate(all_labels)}
|
||||
random.shuffle(frames_data)
|
||||
total_count = len(frames_data)
|
||||
val_count = int(total_count * eval_percent / 100.0)
|
||||
test_count = int(total_count * test_percent / 100.0)
|
||||
val_data = frames_data[:val_count]
|
||||
test_data = frames_data[val_count:val_count + test_count]
|
||||
train_data = frames_data[val_count + test_count:]
|
||||
|
||||
dataset_parts = [(train_data, img_train_dir, lbl_train_dir), (val_data, img_val_dir, lbl_val_dir),
|
||||
(test_data, img_test_dir, lbl_test_dir)]
|
||||
|
||||
for part_data, target_img_dir, target_lbl_dir in dataset_parts:
|
||||
is_training_set = (target_img_dir == img_train_dir)
|
||||
|
||||
while part_data:
|
||||
use_mosaic = is_training_set and mosaic_options.get('enabled') and random.random() < mosaic_options.get('p',
|
||||
0) and len(
|
||||
part_data) >= 4
|
||||
|
||||
if use_mosaic:
|
||||
mosaic_infos = [part_data.pop(random.randrange(len(part_data))) for _ in range(4)]
|
||||
base_filename = f"mosaic_{mosaic_infos[0]['video_uuid']}_{mosaic_infos[0]['frame_number']}"
|
||||
image_aug, bboxes_aug_yolo = create_mosaic_image(mosaic_infos, class_map)
|
||||
final_image_bgr = image_aug
|
||||
else:
|
||||
frame_info = part_data.pop(0)
|
||||
is_augmented = frame_info.get("type") == "augmented"
|
||||
if is_augmented:
|
||||
base_filename = frame_info["augmented_id"]
|
||||
else:
|
||||
base_filename = f"{frame_info['video_uuid']}_{frame_info['frame_number']:05d}"
|
||||
|
||||
src_img_path = get_frame_path(frame_info['video_uuid'], frame_info['frame_number'])
|
||||
if not os.path.exists(src_img_path): continue
|
||||
|
||||
image = cv2.imread(src_img_path)
|
||||
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
||||
yolo_bboxes, class_indices = get_yolo_bboxes(frame_info['bboxes_text'], frame_info['width'],
|
||||
frame_info['height'], class_map)
|
||||
|
||||
if not yolo_bboxes: continue
|
||||
|
||||
if is_augmented and augment_pipeline:
|
||||
try:
|
||||
transformed = augment_pipeline(image=image, bboxes=yolo_bboxes, class_labels=class_indices)
|
||||
except ValueError as e:
|
||||
logging.error(f"--- DEBUG INFO ---")
|
||||
logging.error(f"Albumentations failed for source file: {src_img_path}")
|
||||
logging.error(f"Image shape passed to augment: {image.shape}")
|
||||
logging.error(f"Problematic YOLO bboxes passed to augment: {yolo_bboxes}")
|
||||
logging.error(f"--- END DEBUG INFO ---")
|
||||
raise e
|
||||
|
||||
image_aug_rgb = transformed['image']
|
||||
bboxes_aug_yolo_tuples = transformed['bboxes']
|
||||
labels_aug_indices = transformed['class_labels']
|
||||
bboxes_aug_yolo = [(labels_aug_indices[i], *box) for i, box in enumerate(bboxes_aug_yolo_tuples)]
|
||||
else:
|
||||
image_aug_rgb = image
|
||||
bboxes_aug_yolo = [(class_indices[i], *box) for i, box in enumerate(yolo_bboxes)]
|
||||
|
||||
final_image_bgr = cv2.cvtColor(image_aug_rgb, cv2.COLOR_RGB2BGR)
|
||||
|
||||
cv2.imwrite(os.path.join(target_img_dir, base_filename + '.jpg'), final_image_bgr)
|
||||
yolo_content_lines = [f"{class_id} {x:.6f} {y:.6f} {w:.6f} {h:.6f}" for class_id, x, y, w, h in
|
||||
bboxes_aug_yolo]
|
||||
with open(os.path.join(target_lbl_dir, base_filename + '.txt'), 'w') as f:
|
||||
f.write("\n".join(yolo_content_lines))
|
||||
|
||||
yaml_content = {'path': f"../datasets/{dataset_uuid}", 'train': 'images/train', 'val': 'images/val',
|
||||
'test': 'images/test', 'nc': len(all_labels), 'names': all_labels}
|
||||
with open(os.path.join(dataset_dir, 'data.yaml'), 'w') as f:
|
||||
yaml.dump(yaml_content, f, sort_keys=False)
|
||||
zip_path = get_dataset_zip_path(dataset_uuid)
|
||||
shutil.make_archive(os.path.join(config.STORAGE_DIR, 'datasets', dataset_uuid), 'zip', dataset_dir)
|
||||
shutil.rmtree(dataset_dir)
|
||||
return zip_path
|
||||
|
||||
|
||||
def delete_dataset_files(dataset_uuid):
|
||||
dataset_dir = get_dataset_dir(dataset_uuid)
|
||||
zip_path = get_dataset_zip_path(dataset_uuid)
|
||||
if os.path.isdir(dataset_dir): shutil.rmtree(dataset_dir)
|
||||
if os.path.exists(zip_path): os.remove(zip_path)
|
||||
|
||||
|
||||
def get_model_path(model_uuid):
|
||||
return os.path.join(config.STORAGE_DIR, 'models', f"{model_uuid}.tflite")
|
||||
|
||||
|
||||
def get_label_file_path(model_uuid):
|
||||
return os.path.join(config.STORAGE_DIR, 'models', f"{model_uuid}.txt")
|
||||
|
||||
|
||||
def save_imported_model(file_storage_obj, model_uuid):
|
||||
model_path = get_model_path(model_uuid)
|
||||
file_storage_obj.save(model_path)
|
||||
return model_path
|
||||
|
||||
|
||||
def save_imported_label_file(file_storage_obj, model_uuid):
|
||||
label_path = get_label_file_path(model_uuid)
|
||||
file_storage_obj.save(label_path)
|
||||
return label_path
|
||||
|
||||
|
||||
def delete_model_file(model_uuid):
|
||||
model_path = get_model_path(model_uuid)
|
||||
if os.path.exists(model_path): os.remove(model_path)
|
||||
|
||||
|
||||
def delete_label_file(model_uuid):
|
||||
label_path = get_label_file_path(model_uuid)
|
||||
if os.path.exists(label_path): os.remove(label_path)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,103 @@
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
import config
|
||||
import torch
|
||||
|
||||
SETTINGS_FILE = os.path.join(config.BASE_DIR, 'settings.json')
|
||||
DEFAULT_SETTINGS = {
|
||||
"sam_model_name": "SAM 2.1 Tiny",
|
||||
"sam_model_checkpoint": "sam2.1_t.pt",
|
||||
"feature_extractor_model_name": "mobilenet_v3_large",
|
||||
"gpu_device": "auto",
|
||||
|
||||
"sam_mask_confidence": 0.35,
|
||||
"nms_iou_threshold": 0.7,
|
||||
"prototype_temperature": 0.07,
|
||||
"prototype_sample_limit": 50,
|
||||
|
||||
"batch_tracking_imgsz": 1024,
|
||||
"batch_tracking_conf": 0.30,
|
||||
"batch_tracking_chunk_size": 10,
|
||||
|
||||
"default_preannotation_conf": 0.5,
|
||||
"default_opencv_tracker": "CSRT",
|
||||
"frame_extraction_jpeg_quality": 75,
|
||||
"default_annotation_mode": "manual",
|
||||
"autosave_enabled": False,
|
||||
|
||||
"cache_save_interval_seconds": 30,
|
||||
"class_colors": {}
|
||||
}
|
||||
_device = None
|
||||
|
||||
|
||||
def get_device():
|
||||
global _device
|
||||
if _device is not None:
|
||||
return _device
|
||||
|
||||
settings = load_settings()
|
||||
device_setting = settings.get("gpu_device", "auto")
|
||||
|
||||
if device_setting == "auto":
|
||||
if torch.cuda.is_available():
|
||||
_device = torch.device("cuda:0")
|
||||
logging.info("Auto-detected and using CUDA device: cuda:0")
|
||||
else:
|
||||
_device = torch.device("cpu")
|
||||
logging.info("Auto-detected and using CPU.")
|
||||
elif "cuda" in device_setting and torch.cuda.is_available():
|
||||
try:
|
||||
device_id = int(device_setting.split(':')[1])
|
||||
if device_id < torch.cuda.device_count():
|
||||
_device = torch.device(device_setting)
|
||||
logging.info(f"Using specified CUDA device: {device_setting}")
|
||||
else:
|
||||
_device = torch.device("cuda:0")
|
||||
logging.warning(f"Device {device_setting} not found, falling back to cuda:0.")
|
||||
except (IndexError, ValueError):
|
||||
_device = torch.device("cuda:0")
|
||||
logging.warning(f"Invalid CUDA device format '{device_setting}', falling back to cuda:0.")
|
||||
else:
|
||||
if "cuda" in device_setting:
|
||||
logging.warning("CUDA device specified but not available. Falling back to CPU.")
|
||||
_device = torch.device("cpu")
|
||||
logging.info("Using CPU.")
|
||||
|
||||
return _device
|
||||
|
||||
|
||||
def update_device():
|
||||
global _device
|
||||
_device = None
|
||||
logging.info("Device setting updated. Will re-evaluate on next use.")
|
||||
|
||||
def load_settings():
|
||||
if not os.path.exists(SETTINGS_FILE):
|
||||
logging.info(f"Settings file not found. Creating a new one at {SETTINGS_FILE}")
|
||||
save_settings(DEFAULT_SETTINGS)
|
||||
return DEFAULT_SETTINGS
|
||||
try:
|
||||
with open(SETTINGS_FILE, 'r') as f:
|
||||
settings = json.load(f)
|
||||
for key, value in DEFAULT_SETTINGS.items():
|
||||
if key not in settings:
|
||||
settings[key] = value
|
||||
elif isinstance(value, dict):
|
||||
for sub_key, sub_value in value.items():
|
||||
if key in settings and isinstance(settings[key], dict) and sub_key not in settings[key]:
|
||||
settings[key][sub_key] = sub_value
|
||||
return settings
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
logging.error(f"Failed to load settings file: {e}. Returning default settings.")
|
||||
return DEFAULT_SETTINGS
|
||||
|
||||
def save_settings(settings_data):
|
||||
try:
|
||||
with open(SETTINGS_FILE, 'w') as f:
|
||||
json.dump(settings_data, f, indent=4)
|
||||
return True
|
||||
except IOError as e:
|
||||
logging.error(f"Failed to save settings file: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,844 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap');
|
||||
|
||||
:root {
|
||||
/* --- 亮色模式:粉彩/透亮风格 (Pastel & Light) --- */
|
||||
|
||||
/* 核心色:变得非常浅,像水彩画 */
|
||||
--color-primary: #a5bbf1; /* 极淡的矢车菊蓝 */
|
||||
--color-primary-hover: #bac8ff; /* 悬停时更亮 */
|
||||
--color-primary-soft: rgba(165, 187, 241, 0.12); /* 几乎透明 */
|
||||
|
||||
/* 功能色:全部调整为粉彩色系 */
|
||||
--color-success: #69dbb1; /* 淡薄荷 */
|
||||
--color-warning: #ffe066; /* 淡奶油黄 */
|
||||
--color-danger: #ffc9c9; /* 淡樱花粉 */
|
||||
--color-info: #99e9f2; /* 淡冰蓝 */
|
||||
|
||||
/* 背景:保持极高的亮度 */
|
||||
--bg-body: #f8f9fa;
|
||||
--bg-surface: #ffffff;
|
||||
--bg-surface-secondary: #f3f4f6;
|
||||
--bg-input: #fdfdfd; /* 几乎纯白 */
|
||||
--bg-hover: #f1f3f5;
|
||||
|
||||
/* 边框:极淡 */
|
||||
--border-color: #eff2f5;
|
||||
--border-color-light: #f8f9fa;
|
||||
|
||||
/* 文字:保持深灰以确保阅读,但按钮文字需要变深 */
|
||||
--text-main: #495057; /* 稍微浅一点的深灰 */
|
||||
--text-secondary: #868e96;
|
||||
--text-muted: #ced4da;
|
||||
|
||||
/* 关键变化:因为主色很浅,按钮上的文字必须是深色 */
|
||||
--text-on-primary: #343a40;
|
||||
|
||||
/* 阴影:极度扩散,几乎看不见 */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.02);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.03);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.03);
|
||||
|
||||
--radius-sm: 0.5rem;
|
||||
--radius-md: 0.75rem;
|
||||
--radius-lg: 1rem;
|
||||
--radius-xl: 1.5rem;
|
||||
|
||||
--canvas-bg: #e9ecef;
|
||||
--crosshair-color: rgba(165, 187, 241, 0.8);
|
||||
--navbar-height: 60px;
|
||||
--badge-neutral-bg: #f1f3f5;
|
||||
--badge-neutral-text: #868e96;
|
||||
}
|
||||
|
||||
body.dark-mode {
|
||||
/* --- 深色模式:幽暗/沉稳风格 (Muted & Dim) --- */
|
||||
|
||||
/* 核心色:变得暗沉,不发光,融入背景 */
|
||||
--color-primary: #3b526b; /* 深蓝灰 */
|
||||
--color-primary-hover: #46607a;
|
||||
--color-primary-soft: rgba(59, 82, 107, 0.2);
|
||||
|
||||
/* 功能色:大地色系/低明度 */
|
||||
--color-success: #2f6f4e; /* 苔藓绿 */
|
||||
--color-warning: #8a6a26; /* 暗金色 */
|
||||
--color-danger: #8c3b3b; /* 暗砖红 */
|
||||
--color-info: #2a5d78; /* 深湖蓝 */
|
||||
|
||||
/* 背景:深邃 */
|
||||
--bg-body: #18191c; /* 接近黑的深炭色 */
|
||||
--bg-surface: #202226; /* 稍微提亮 */
|
||||
--bg-surface-secondary: #272a30;
|
||||
--bg-input: #1c1e21;
|
||||
--bg-hover: #2c2f36;
|
||||
|
||||
/* 边框:非常暗 */
|
||||
--border-color: #2c2f36;
|
||||
--border-color-light: #25282e;
|
||||
|
||||
/* 文字:降低亮度,避免刺眼 */
|
||||
--text-main: #c1c2c5; /* 灰白而非亮白 */
|
||||
--text-secondary: #909296;
|
||||
--text-muted: #5c5f66;
|
||||
|
||||
/* 深色模式下的按钮文字保持浅色 */
|
||||
--text-on-primary: #dce4f5;
|
||||
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.4);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.5);
|
||||
|
||||
--canvas-bg: #141517;
|
||||
|
||||
--badge-neutral-bg: rgba(255, 255, 255, 0.03);
|
||||
--badge-neutral-text: #909296;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-body);
|
||||
color: var(--text-main);
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
letter-spacing: 0.015em;
|
||||
font-weight: 400;
|
||||
transition: background-color 0.4s cubic-bezier(0.25, 0.8, 0.25, 1), color 0.4s ease;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-main); /* 链接平时不突出颜色,像普通文字 */
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--color-primary); /* 下划线用淡色 */
|
||||
text-underline-offset: 3px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--text-main);
|
||||
text-decoration-color: var(--color-primary-hover);
|
||||
background-color: var(--color-primary-soft);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* 滚动条极简 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
|
||||
@keyframes slideInUp {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.slide-in-up {
|
||||
animation: slideInUp 0.6s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background-color: var(--bg-surface) !important;
|
||||
border-bottom: 1px solid transparent;
|
||||
box-shadow: var(--shadow-sm);
|
||||
height: var(--navbar-height);
|
||||
padding: 0.5rem 1.5rem;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
color: var(--text-main) !important;
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.navbar-text {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background-color: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--text-main);
|
||||
font-weight: 600;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-top-left-radius: var(--radius-md) !important;
|
||||
border-top-right-radius: var(--radius-md) !important;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 1.5rem;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
background-color: var(--bg-surface);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* 按钮样式:特别注意文字颜色 */
|
||||
.btn {
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 500;
|
||||
padding: 0.5rem 1.25rem;
|
||||
transition: all 0.2s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
border: 1px solid transparent;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.btn:focus {
|
||||
box-shadow: 0 0 0 3px var(--color-primary-soft) !important;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--text-on-primary); /* 使用动态文字颜色 */
|
||||
}
|
||||
|
||||
.btn-primary:hover, .btn-primary:active {
|
||||
background-color: var(--color-primary-hover);
|
||||
border-color: var(--color-primary-hover);
|
||||
color: var(--text-on-primary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--bg-surface-secondary);
|
||||
border-color: transparent;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--bg-hover);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.btn-info {
|
||||
color: var(--text-on-primary); /* 亮色模式下可能需要深色文字 */
|
||||
background: var(--color-info);
|
||||
border: none;
|
||||
}
|
||||
/* 修正 Info 按钮在亮色模式下的文字颜色 */
|
||||
:root .btn-info { color: #224a5e; }
|
||||
body.dark-mode .btn-info { color: #d0ebff; }
|
||||
|
||||
.btn-link {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-link:hover {
|
||||
color: var(--text-main);
|
||||
background-color: var(--bg-hover);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.form-control, .custom-select, .custom-file-label {
|
||||
background-color: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-main);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.form-control:focus, .custom-select:focus {
|
||||
background-color: var(--bg-surface);
|
||||
color: var(--text-main);
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.form-control::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.custom-file-label::after {
|
||||
background-color: var(--bg-surface-secondary);
|
||||
color: var(--text-main);
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.table {
|
||||
color: var(--text-main);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.table th, .table td {
|
||||
padding: 1rem 1.5rem;
|
||||
vertical-align: middle;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.table thead th {
|
||||
vertical-align: bottom;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background-color: var(--bg-surface-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.table-hover tbody tr:hover {
|
||||
background-color: var(--bg-hover);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.modal-header, .modal-footer {
|
||||
border-color: var(--border-color);
|
||||
background-color: var(--bg-surface);
|
||||
}
|
||||
|
||||
.modal-title { color: var(--text-main); }
|
||||
|
||||
.close {
|
||||
color: var(--text-secondary);
|
||||
text-shadow: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
color: var(--text-main);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link {
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-weight: 500;
|
||||
background: transparent;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link:hover {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link.active {
|
||||
background-color: transparent;
|
||||
border-color: transparent;
|
||||
border-bottom-color: var(--color-primary);
|
||||
color: var(--text-main); /* Tab激活文字不用主色,用深色更稳重 */
|
||||
}
|
||||
|
||||
.list-group-item {
|
||||
background-color: var(--bg-surface);
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.list-group-item-action:hover {
|
||||
background-color: var(--bg-hover);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
/* IDE 布局 */
|
||||
.ide-layout {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: calc(100vh - var(--navbar-height));
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
background-color: var(--bg-body);
|
||||
}
|
||||
|
||||
.ide-canvas-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
background-color: var(--bg-surface);
|
||||
border-right: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.ide-toolbar {
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background-color: var(--bg-surface);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ide-canvas-wrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
background-color: var(--canvas-bg);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
#canvas-container {
|
||||
position: relative;
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: inline-block;
|
||||
background-color: var(--canvas-bg);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#canvas-container canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#drawing-canvas {
|
||||
pointer-events: auto !important;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
#crosshair-canvas {
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.ide-sidebar {
|
||||
width: 320px;
|
||||
flex-shrink: 0;
|
||||
background-color: var(--bg-surface);
|
||||
border-left: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
#frame-slider {
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
accent-color: var(--text-secondary); /* 滑块颜色也不要太鲜艳 */
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
padding: 2rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 300px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chart-card-body {
|
||||
padding: 1rem;
|
||||
background-color: var(--bg-surface);
|
||||
}
|
||||
|
||||
#gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background-color: var(--bg-body);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.gallery-item {
|
||||
background-color: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.gallery-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.gallery-item .caption {
|
||||
padding: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
background-color: var(--bg-surface);
|
||||
border-top: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
#aug-preview-gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
#aug-preview-gallery img {
|
||||
width: 100%;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.aug-preview-source-img {
|
||||
width: 80px;
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
border: 2px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.aug-preview-source-img:hover { opacity: 0.8; }
|
||||
|
||||
.aug-preview-source-img.active {
|
||||
border-color: var(--text-secondary); /* 选中状态用灰色框 */
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
#bbox-list .list-group-item {
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 4px;
|
||||
background-color: transparent;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
#bbox-list .list-group-item:hover {
|
||||
background-color: var(--bg-surface-secondary);
|
||||
}
|
||||
|
||||
#bbox-list .list-group-item.selected {
|
||||
background-color: var(--color-primary-soft);
|
||||
color: var(--text-main); /* 选中时不改变文字颜色,保持柔和 */
|
||||
}
|
||||
|
||||
#class-list .list-group-item {
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 2px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
#class-list .list-group-item.active {
|
||||
background-color: var(--color-primary-soft);
|
||||
color: var(--text-main);
|
||||
border-color: var(--color-primary-soft);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.shortcut-key {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
margin: 0 2px;
|
||||
background-color: var(--bg-surface-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
#interpolation-banner {
|
||||
background-color: rgba(52, 211, 153, 0.05); /* 极淡 */
|
||||
border: 1px solid transparent;
|
||||
color: var(--color-success);
|
||||
padding: 0.5rem;
|
||||
text-align: center;
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 1rem;
|
||||
display: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#track-controls, #interactive-segment-controls, #suggestion-review-controls {
|
||||
border: 1px dashed var(--border-color);
|
||||
background-color: var(--bg-surface-secondary);
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
#suggestion-review-controls {
|
||||
border-color: transparent;
|
||||
background-color: rgba(251, 191, 36, 0.05);
|
||||
}
|
||||
|
||||
#toast-notification {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
background-color: var(--bg-surface);
|
||||
color: var(--text-main);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 50px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 9999;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#toast-notification.show {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
.theme-toggle-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.2rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 50%;
|
||||
transition: background-color 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.theme-toggle-btn:hover {
|
||||
background-color: var(--bg-hover);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
/* Badge 样式优化:透明度大幅提升 */
|
||||
.badge {
|
||||
padding: 0.35em 0.65em;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
/* 亮色模式 Badge:使用极淡背景 + 深色文字 */
|
||||
.badge-success { background-color: rgba(105, 219, 177, 0.15); color: #2b6e56; }
|
||||
.badge-warning { background-color: rgba(255, 224, 102, 0.15); color: #947600; }
|
||||
.badge-danger { background-color: rgba(255, 201, 201, 0.25); color: #a33e3e; }
|
||||
.badge-info { background-color: rgba(153, 233, 242, 0.2); color: #106e7a; }
|
||||
|
||||
.badge-secondary {
|
||||
background-color: var(--bg-surface-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.badge-neutral {
|
||||
background-color: var(--badge-neutral-bg);
|
||||
color: var(--badge-neutral-text);
|
||||
}
|
||||
|
||||
.bg-warning-soft {
|
||||
background-color: rgba(255, 224, 102, 0.1);
|
||||
color: #947600;
|
||||
}
|
||||
|
||||
/* Dark Mode 覆盖:修正颜色以适应暗背景 */
|
||||
body.dark-mode .bg-light {
|
||||
background-color: var(--bg-surface-secondary) !important;
|
||||
color: var(--text-main) !important;
|
||||
}
|
||||
|
||||
body.dark-mode .bg-white {
|
||||
background-color: var(--bg-surface) !important;
|
||||
color: var(--text-main) !important;
|
||||
}
|
||||
|
||||
body.dark-mode .border,
|
||||
body.dark-mode .border-top,
|
||||
body.dark-mode .border-bottom,
|
||||
body.dark-mode .border-left,
|
||||
body.dark-mode .border-right {
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
body.dark-mode .text-dark {
|
||||
color: var(--text-main) !important;
|
||||
}
|
||||
|
||||
body.dark-mode .text-muted {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
body.dark-mode .text-primary {
|
||||
color: #668bb0 !important; /* 暗淡的蓝字 */
|
||||
}
|
||||
|
||||
body.dark-mode .bg-warning-soft {
|
||||
background-color: rgba(138, 106, 38, 0.15);
|
||||
color: #e0c888;
|
||||
}
|
||||
|
||||
body.dark-mode .input-group-text {
|
||||
background-color: var(--bg-surface-secondary);
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
/* 深色模式 Badge:使用极低透明度背景 + 浅色文字 */
|
||||
body.dark-mode .badge-success { color: #8ce99a; background-color: rgba(47, 111, 78, 0.2); }
|
||||
body.dark-mode .badge-warning { color: #ffe066; background-color: rgba(138, 106, 38, 0.2); }
|
||||
body.dark-mode .badge-danger { color: #ffa8a8; background-color: rgba(140, 59, 59, 0.2); }
|
||||
body.dark-mode .badge-info { color: #99e9f2; background-color: rgba(42, 93, 120, 0.2); }
|
||||
|
||||
body.dark-mode .btn-secondary {
|
||||
background-color: var(--bg-surface-secondary);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
body.dark-mode .btn-secondary:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
body.dark-mode .form-control,
|
||||
body.dark-mode .custom-select {
|
||||
background-color: var(--bg-input);
|
||||
border-color: transparent;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
body.dark-mode .form-control:focus,
|
||||
body.dark-mode .custom-select:focus {
|
||||
background-color: var(--bg-surface);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.nav-pills-fluent .nav-link {
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
padding: 0.5rem 1rem;
|
||||
transition: all 0.2s ease;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.nav-pills-fluent .nav-link:hover {
|
||||
background-color: var(--bg-hover);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.nav-pills-fluent .nav-link.active {
|
||||
background-color: var(--bg-surface-secondary);
|
||||
color: var(--text-main); /* 激活也不要变色,只变背景 */
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
body.dark-mode .nav-pills-fluent .nav-link.active {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.fluent-card {
|
||||
background-color: var(--bg-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-bottom: 1.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.settings-section-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-main);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.table-fluent {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.table-fluent thead th {
|
||||
background-color: var(--bg-surface-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
border-top: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table-fluent td {
|
||||
padding: 1rem 1.5rem;
|
||||
vertical-align: middle;
|
||||
border-top: 1px solid var(--border-color-light);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.table-fluent tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.75rem;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
<!-- START OF FILE _augmentation_controls.html -->
|
||||
<div class="accordion shadow-sm" id="aug-accordion">
|
||||
|
||||
<!-- 1. Geometric Transforms -->
|
||||
<div class="card border-0 mb-1">
|
||||
<div class="card-header bg-white p-0" id="headingGeo">
|
||||
<button class="btn btn-block text-left font-weight-bold text-dark py-3 px-4 d-flex align-items-center" type="button" data-toggle="collapse" data-target="#collapse-geometric">
|
||||
<i class="bi bi-arrows-move mr-2 text-primary"></i> Geometric Transforms
|
||||
</button>
|
||||
</div>
|
||||
<div id="collapse-geometric" class="collapse show" data-parent="#aug-accordion">
|
||||
<div class="card-body bg-light pt-3 pb-3 px-4">
|
||||
<div class="row">
|
||||
<!-- HorizontalFlip -->
|
||||
<div class="col-md-6 mb-3 aug-option" data-controls="hflip-controls">
|
||||
<div class="custom-control custom-switch">
|
||||
<input type="checkbox" class="custom-control-input" id="aug-hflip-enabled" checked>
|
||||
<label class="custom-control-label font-weight-bold text-dark" for="aug-hflip-enabled">Horizontal Flip</label>
|
||||
</div>
|
||||
<div id="hflip-controls" class="pl-4 mt-2">
|
||||
<label class="d-flex justify-content-between small text-muted font-weight-bold mb-1">
|
||||
<span>Probability</span>
|
||||
<span class="val-display text-primary">0.5</span>
|
||||
</label>
|
||||
<input type="range" class="custom-range" id="aug-hflip-p" min="0" max="1" step="0.1" value="0.5">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VerticalFlip -->
|
||||
<div class="col-md-6 mb-3 aug-option" data-controls="vflip-controls">
|
||||
<div class="custom-control custom-switch">
|
||||
<input type="checkbox" class="custom-control-input" id="aug-vflip-enabled">
|
||||
<label class="custom-control-label font-weight-bold text-dark" for="aug-vflip-enabled">Vertical Flip</label>
|
||||
</div>
|
||||
<div id="vflip-controls" class="pl-4 mt-2">
|
||||
<label class="d-flex justify-content-between small text-muted font-weight-bold mb-1">
|
||||
<span>Probability</span>
|
||||
<span class="val-display text-primary">0.5</span>
|
||||
</label>
|
||||
<input type="range" class="custom-range" id="aug-vflip-p" min="0" max="1" step="0.1" value="0.5">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rotate90 -->
|
||||
<div class="col-md-6 mb-3 aug-option" data-controls="rotate90-controls">
|
||||
<div class="custom-control custom-switch">
|
||||
<input type="checkbox" class="custom-control-input" id="aug-rotate90-enabled">
|
||||
<label class="custom-control-label font-weight-bold text-dark" for="aug-rotate90-enabled">Random Rotate 90°</label>
|
||||
</div>
|
||||
<div id="rotate90-controls" class="pl-4 mt-2">
|
||||
<label class="d-flex justify-content-between small text-muted font-weight-bold mb-1">
|
||||
<span>Probability</span>
|
||||
<span class="val-display text-primary">0.5</span>
|
||||
</label>
|
||||
<input type="range" class="custom-range" id="aug-rotate90-p" min="0" max="1" step="0.1" value="0.5">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rotate -->
|
||||
<div class="col-md-6 mb-3 aug-option" data-controls="rotate-controls">
|
||||
<div class="custom-control custom-switch">
|
||||
<input type="checkbox" class="custom-control-input" id="aug-rotate-enabled" checked>
|
||||
<label class="custom-control-label font-weight-bold text-dark" for="aug-rotate-enabled">Fine Rotation</label>
|
||||
</div>
|
||||
<div id="rotate-controls" class="pl-4 mt-2">
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<label class="small text-muted font-weight-bold mb-1">Prob: <span class="val-display text-primary">0.5</span></label>
|
||||
<input type="range" class="custom-range" id="aug-rotate-p" min="0" max="1" step="0.1" value="0.5">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="small text-muted font-weight-bold mb-1">Limit: ±<span class="val-display text-primary">30</span>°</label>
|
||||
<input type="range" class="custom-range" id="aug-rotate-limit" min="0" max="90" step="5" value="30">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ShiftScaleRotate -->
|
||||
<div class="col-12 mb-3 aug-option" data-controls="ssr-controls">
|
||||
<div class="custom-control custom-switch">
|
||||
<input type="checkbox" class="custom-control-input" id="aug-ssr-enabled">
|
||||
<label class="custom-control-label font-weight-bold text-dark" for="aug-ssr-enabled">Shift / Scale / Rotate (SSR)</label>
|
||||
</div>
|
||||
<div id="ssr-controls" class="pl-4 mt-2">
|
||||
<div class="row">
|
||||
<div class="col-md-3 col-6 mb-2">
|
||||
<label class="small text-muted font-weight-bold mb-1">Prob: <span class="val-display text-primary">0.5</span></label>
|
||||
<input type="range" class="custom-range" id="aug-ssr-p" min="0" max="1" step="0.1" value="0.5">
|
||||
</div>
|
||||
<div class="col-md-3 col-6 mb-2">
|
||||
<label class="small text-muted font-weight-bold mb-1">Rot: ±<span class="val-display text-primary">25</span>°</label>
|
||||
<input type="range" class="custom-range" id="aug-ssr-rotate" min="0" max="90" step="5" value="25">
|
||||
</div>
|
||||
<div class="col-md-3 col-6 mb-2">
|
||||
<label class="small text-muted font-weight-bold mb-1">Shift: <span class="val-display text-primary">0.06</span></label>
|
||||
<input type="range" class="custom-range" id="aug-ssr-shift" min="0" max="0.5" step="0.01" value="0.06">
|
||||
</div>
|
||||
<div class="col-md-3 col-6 mb-2">
|
||||
<label class="small text-muted font-weight-bold mb-1">Scale: <span class="val-display text-primary">0.1</span></label>
|
||||
<input type="range" class="custom-range" id="aug-ssr-scale" min="0" max="0.9" step="0.05" value="0.1">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Affine -->
|
||||
<div class="col-12 aug-option" data-controls="affine-controls">
|
||||
<div class="custom-control custom-switch">
|
||||
<input type="checkbox" class="custom-control-input" id="aug-affine-enabled">
|
||||
<label class="custom-control-label font-weight-bold text-dark" for="aug-affine-enabled">Affine (Shear)</label>
|
||||
</div>
|
||||
<div id="affine-controls" class="pl-4 mt-2">
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<label class="small text-muted font-weight-bold mb-1">Prob: <span class="val-display text-primary">0.5</span></label>
|
||||
<input type="range" class="custom-range" id="aug-affine-p" min="0" max="1" step="0.1" value="0.5">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="small text-muted font-weight-bold mb-1">Shear: ±<span class="val-display text-primary">12</span>°</label>
|
||||
<input type="range" class="custom-range" id="aug-affine-shear" min="0" max="45" step="1" value="12">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Crop & Pad -->
|
||||
<div class="card border-0 mb-1">
|
||||
<div class="card-header bg-white p-0">
|
||||
<button class="btn btn-block text-left font-weight-bold text-dark py-3 px-4 collapsed d-flex align-items-center" type="button" data-toggle="collapse" data-target="#collapse-crop">
|
||||
<i class="bi bi-crop mr-2 text-info"></i> Crop & Pad
|
||||
</button>
|
||||
</div>
|
||||
<div id="collapse-crop" class="collapse" data-parent="#aug-accordion">
|
||||
<div class="card-body bg-light pt-3 pb-3 px-4">
|
||||
<div class="row aug-option" data-controls="crop-controls">
|
||||
<div class="col-12">
|
||||
<div class="custom-control custom-switch">
|
||||
<input type="checkbox" class="custom-control-input" id="aug-crop-enabled">
|
||||
<label class="custom-control-label font-weight-bold text-dark" for="aug-crop-enabled">Random Sized BBox Safe Crop</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 pl-4 mt-2" id="crop-controls">
|
||||
<p class="text-muted small mb-2"><i class="bi bi-info-circle mr-1"></i> Crops a random part of the image ensuring objects are kept, then resizes to training size.</p>
|
||||
<label class="d-flex justify-content-between small text-muted font-weight-bold mb-1">
|
||||
<span>Probability</span>
|
||||
<span class="val-display text-primary">0.5</span>
|
||||
</label>
|
||||
<input type="range" class="custom-range" id="aug-crop-p" min="0" max="1" step="0.1" value="0.5">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Color & Lighting -->
|
||||
<div class="card border-0 mb-1">
|
||||
<div class="card-header bg-white p-0">
|
||||
<button class="btn btn-block text-left font-weight-bold text-dark py-3 px-4 collapsed d-flex align-items-center" type="button" data-toggle="collapse" data-target="#collapse-color">
|
||||
<i class="bi bi-palette mr-2 text-warning"></i> Color & Lighting
|
||||
</button>
|
||||
</div>
|
||||
<div id="collapse-color" class="collapse" data-parent="#aug-accordion">
|
||||
<div class="card-body bg-light pt-3 pb-3 px-4">
|
||||
<div class="row">
|
||||
<!-- Grayscale -->
|
||||
<div class="col-md-6 mb-3 aug-option" data-controls="grayscale-controls">
|
||||
<div class="custom-control custom-switch">
|
||||
<input type="checkbox" class="custom-control-input" id="aug-grayscale-enabled">
|
||||
<label class="custom-control-label font-weight-bold text-dark" for="aug-grayscale-enabled">To Gray</label>
|
||||
</div>
|
||||
<div id="grayscale-controls" class="pl-4 mt-2">
|
||||
<label class="d-flex justify-content-between small text-muted font-weight-bold mb-1">
|
||||
<span>Probability</span>
|
||||
<span class="val-display text-primary">0.1</span>
|
||||
</label>
|
||||
<input type="range" class="custom-range" id="aug-grayscale-p" min="0" max="1" step="0.1" value="0.1">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HSV -->
|
||||
<div class="col-12 mb-3 aug-option" data-controls="hsv-controls">
|
||||
<div class="custom-control custom-switch">
|
||||
<input type="checkbox" class="custom-control-input" id="aug-hsv-enabled" checked>
|
||||
<label class="custom-control-label font-weight-bold text-dark" for="aug-hsv-enabled">Hue Saturation Value</label>
|
||||
</div>
|
||||
<div id="hsv-controls" class="pl-4 mt-2">
|
||||
<div class="row">
|
||||
<div class="col-md-3 col-6 mb-2">
|
||||
<label class="small text-muted font-weight-bold mb-1">Prob: <span class="val-display text-primary">0.5</span></label>
|
||||
<input type="range" class="custom-range" id="aug-hsv-p" min="0" max="1" step="0.1" value="0.5">
|
||||
</div>
|
||||
<div class="col-md-3 col-6 mb-2">
|
||||
<label class="small text-muted font-weight-bold mb-1">Hue: ±<span class="val-display text-primary">20</span></label>
|
||||
<input type="range" class="custom-range" id="aug-hsv-h" min="0" max="100" step="5" value="20">
|
||||
</div>
|
||||
<div class="col-md-3 col-6 mb-2">
|
||||
<label class="small text-muted font-weight-bold mb-1">Sat: ±<span class="val-display text-primary">30</span></label>
|
||||
<input type="range" class="custom-range" id="aug-hsv-s" min="0" max="100" step="5" value="30">
|
||||
</div>
|
||||
<div class="col-md-3 col-6 mb-2">
|
||||
<label class="small text-muted font-weight-bold mb-1">Val: ±<span class="val-display text-primary">20</span></label>
|
||||
<input type="range" class="custom-range" id="aug-hsv-v" min="0" max="100" step="5" value="20">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Brightness Contrast -->
|
||||
<div class="col-12 aug-option" data-controls="bc-controls">
|
||||
<div class="custom-control custom-switch">
|
||||
<input type="checkbox" class="custom-control-input" id="aug-bc-enabled" checked>
|
||||
<label class="custom-control-label font-weight-bold text-dark" for="aug-bc-enabled">Brightness & Contrast</label>
|
||||
</div>
|
||||
<div id="bc-controls" class="pl-4 mt-2">
|
||||
<div class="row">
|
||||
<div class="col-md-4 col-12 mb-2">
|
||||
<label class="small text-muted font-weight-bold mb-1">Prob: <span class="val-display text-primary">0.5</span></label>
|
||||
<input type="range" class="custom-range" id="aug-bc-p" min="0" max="1" step="0.1" value="0.5">
|
||||
</div>
|
||||
<div class="col-md-4 col-6 mb-2">
|
||||
<label class="small text-muted font-weight-bold mb-1">Bright: ±<span class="val-display text-primary">0.2</span></label>
|
||||
<input type="range" class="custom-range" id="aug-bc-b" min="0" max="1" step="0.05" value="0.2">
|
||||
</div>
|
||||
<div class="col-md-4 col-6 mb-2">
|
||||
<label class="small text-muted font-weight-bold mb-1">Contrast: ±<span class="val-display text-primary">0.2</span></label>
|
||||
<input type="range" class="custom-range" id="aug-bc-c" min="0" max="1" step="0.05" value="0.2">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. Blur & Noise -->
|
||||
<div class="card border-0 mb-1">
|
||||
<div class="card-header bg-white p-0">
|
||||
<button class="btn btn-block text-left font-weight-bold text-dark py-3 px-4 collapsed d-flex align-items-center" type="button" data-toggle="collapse" data-target="#collapse-blur">
|
||||
<i class="bi bi-droplet-half mr-2 text-secondary"></i> Blur & Noise
|
||||
</button>
|
||||
</div>
|
||||
<div id="collapse-blur" class="collapse" data-parent="#aug-accordion">
|
||||
<div class="card-body bg-light pt-3 pb-3 px-4">
|
||||
<div class="row">
|
||||
<!-- Blur -->
|
||||
<div class="col-12 mb-3 aug-option" data-controls="blur-controls">
|
||||
<div class="custom-control custom-switch">
|
||||
<input type="checkbox" class="custom-control-input" id="aug-blur-enabled">
|
||||
<label class="custom-control-label font-weight-bold text-dark" for="aug-blur-enabled">Gaussian Blur</label>
|
||||
</div>
|
||||
<div id="blur-controls" class="pl-4 mt-2">
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<label class="small text-muted font-weight-bold mb-1">Prob: <span class="val-display text-primary">0.3</span></label>
|
||||
<input type="range" class="custom-range" id="aug-blur-p" min="0" max="1" step="0.1" value="0.3">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="small text-muted font-weight-bold mb-1">Limit: <span class="val-display text-primary">7</span>px</label>
|
||||
<input type="range" class="custom-range" id="aug-blur-limit" min="3" max="21" step="2" value="7">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Noise -->
|
||||
<div class="col-12 aug-option" data-controls="noise-controls">
|
||||
<div class="custom-control custom-switch">
|
||||
<input type="checkbox" class="custom-control-input" id="aug-noise-enabled">
|
||||
<label class="custom-control-label font-weight-bold text-dark" for="aug-noise-enabled">Gaussian Noise</label>
|
||||
</div>
|
||||
<div id="noise-controls" class="pl-4 mt-2">
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<label class="small text-muted font-weight-bold mb-1">Prob: <span class="val-display text-primary">0.3</span></label>
|
||||
<input type="range" class="custom-range" id="aug-noise-p" min="0" max="1" step="0.1" value="0.3">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="small text-muted font-weight-bold mb-1">Var Limit: <span class="val-display text-primary">50</span></label>
|
||||
<input type="range" class="custom-range" id="aug-noise-limit" min="10" max="250" step="10" value="50">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 5. Dropout & Cutout -->
|
||||
<div class="card border-0 mb-1">
|
||||
<div class="card-header bg-white p-0">
|
||||
<button class="btn btn-block text-left font-weight-bold text-dark py-3 px-4 collapsed d-flex align-items-center" type="button" data-toggle="collapse" data-target="#collapse-dropout">
|
||||
<i class="bi bi-grid-3x3 mr-2 text-danger"></i> Dropout (Cutout)
|
||||
</button>
|
||||
</div>
|
||||
<div id="collapse-dropout" class="collapse" data-parent="#aug-accordion">
|
||||
<div class="card-body bg-light pt-3 pb-3 px-4">
|
||||
<div class="row aug-option" data-controls="cutout-controls">
|
||||
<div class="col-12">
|
||||
<div class="custom-control custom-switch">
|
||||
<input type="checkbox" class="custom-control-input" id="aug-cutout-enabled">
|
||||
<label class="custom-control-label font-weight-bold text-dark" for="aug-cutout-enabled">CoarseDropout</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 pl-4 mt-2" id="cutout-controls">
|
||||
<div class="row">
|
||||
<div class="col-6 mb-2">
|
||||
<label class="small text-muted font-weight-bold mb-1">Prob: <span class="val-display text-primary">0.5</span></label>
|
||||
<input type="range" class="custom-range" id="aug-cutout-p" min="0" max="1" step="0.1" value="0.5">
|
||||
</div>
|
||||
<div class="col-6 mb-2">
|
||||
<label class="small text-muted font-weight-bold mb-1">Holes: <span class="val-display text-primary">1</span></label>
|
||||
<input type="range" class="custom-range" id="aug-cutout-holes" min="1" max="16" step="1" value="1">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="small text-muted font-weight-bold mb-1">Size: <span class="val-display text-primary">64</span>px</label>
|
||||
<input type="range" class="custom-range" id="aug-cutout-size" min="8" max="128" step="8" value="64">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 6. Advanced -->
|
||||
<div class="card border-0 mb-1">
|
||||
<div class="card-header bg-white p-0">
|
||||
<button class="btn btn-block text-left font-weight-bold text-dark py-3 px-4 collapsed d-flex align-items-center" type="button" data-toggle="collapse" data-target="#collapse-advanced">
|
||||
<i class="bi bi-puzzle mr-2 text-success"></i> Advanced
|
||||
</button>
|
||||
</div>
|
||||
<div id="collapse-advanced" class="collapse" data-parent="#aug-accordion">
|
||||
<div class="card-body bg-light pt-3 pb-3 px-4">
|
||||
<div class="row aug-option" data-controls="mosaic-controls">
|
||||
<div class="col-12">
|
||||
<div class="custom-control custom-switch">
|
||||
<input type="checkbox" class="custom-control-input" id="aug-mosaic-enabled">
|
||||
<label class="custom-control-label font-weight-bold text-dark" for="aug-mosaic-enabled">Mosaic</label>
|
||||
</div>
|
||||
<div id="mosaic-controls" class="pl-4 mt-2">
|
||||
<label class="d-flex justify-content-between small text-muted font-weight-bold mb-1">
|
||||
<span>Probability</span>
|
||||
<span class="val-display text-primary">0.5</span>
|
||||
</label>
|
||||
<input type="range" class="custom-range" id="aug-mosaic-p" min="0" max="1" step="0.1" value="0.5">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
{% macro render_modal(id, title) %}
|
||||
<div class="modal fade" id="{{ id }}" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ title }}</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{{ caller() }}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
@@ -0,0 +1,804 @@
|
||||
<!-- START OF FILE dataset_analysis.html -->
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
/* 图表容器固定高度,防止抖动 */
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 350px;
|
||||
width: 100%;
|
||||
}
|
||||
.chart-card-body {
|
||||
padding: 1.25rem;
|
||||
background-color: var(--bg-surface);
|
||||
}
|
||||
|
||||
/* 图片画廊 Grid */
|
||||
#gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 1rem;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
background-color: var(--bg-body);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
#gallery .gallery-item {
|
||||
position: relative;
|
||||
background-color: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
#gallery .gallery-item:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: var(--color-primary-soft); /* 柔和边框 */
|
||||
}
|
||||
|
||||
#gallery img {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#gallery .gallery-item.hidden { display: none; }
|
||||
|
||||
#gallery .caption {
|
||||
background: var(--bg-surface);
|
||||
padding: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.caption-text {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
#loading-indicator {
|
||||
height: 60vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 增强预览器样式 */
|
||||
#aug-preview-gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
#aug-preview-gallery img {
|
||||
width: 100%;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.aug-preview-source-img {
|
||||
width: 80px;
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
border: 2px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.aug-preview-source-img.active, .aug-preview-source-img:hover {
|
||||
opacity: 1;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-primary-soft);
|
||||
}
|
||||
|
||||
#preview-aug-controls {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
padding-right: 10px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid content-wrapper fade-in">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4 pb-3 border-bottom">
|
||||
<div>
|
||||
<h3 class="mb-1 font-weight-bold" style="color: var(--text-main);">Dataset Analysis</h3>
|
||||
<!-- 移除 text-primary,改用 text-secondary 避免太亮 -->
|
||||
<p class="lead mb-0" style="font-size: 1.1rem; color: var(--text-secondary);">{{ dataset.description }}</p>
|
||||
</div>
|
||||
<a href="/" class="btn btn-secondary shadow-sm"><i class="bi bi-arrow-left"></i> Back to Home</a>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div id="loading-indicator">
|
||||
<!-- 移除 text-primary,使用内联样式引用柔和变量 -->
|
||||
<div class="spinner-border mb-3" role="status" style="width: 3rem; height: 3rem; color: var(--color-primary);"></div>
|
||||
<h4 class="font-weight-normal" style="color: var(--text-main);">Analyzing Dataset...</h4>
|
||||
<p class="text-muted">Crunching numbers and generating previews.</p>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div id="analysis-content" style="display: none;">
|
||||
|
||||
<!-- Summary Card -->
|
||||
<div class="card mb-4 shadow-sm border-0">
|
||||
<!-- 移除 bg-light,使用柔和背景色 -->
|
||||
<div class="card-header border-0" style="background-color: var(--bg-surface-secondary);">
|
||||
<strong style="color: var(--text-main);"><i class="bi bi-clipboard-data mr-2"></i>Summary & Health Check</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="summary-container"></div>
|
||||
|
||||
<div id="consistency-check-container" class="mt-4 pt-3 border-top">
|
||||
<h6 class="font-weight-bold mb-2" style="color: var(--text-main);"><i class="bi bi-robot mr-1"></i> AI Consistency Review</h6>
|
||||
<p class="text-muted small mb-3">Detect potential labeling errors (e.g., mismatched classes) using embeddings.</p>
|
||||
|
||||
<div class="d-flex align-items-center flex-wrap">
|
||||
<div class="custom-control custom-switch mr-4 mb-2">
|
||||
<input type="checkbox" class="custom-control-input" id="enable-color-check" checked>
|
||||
<label class="custom-control-label font-weight-bold small" for="enable-color-check" style="color: var(--text-secondary);">Strict Color Check</label>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-primary shadow-sm mb-2" id="run-consistency-check-btn">
|
||||
<i class="bi bi-search mr-1"></i> Run AI Review
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row 1 -->
|
||||
<div class="row">
|
||||
<div class="col-lg-4 mb-4">
|
||||
<div class="card h-100 shadow-sm">
|
||||
<!-- 移除 bg-white -->
|
||||
<div class="card-header font-weight-bold small text-uppercase text-muted" style="background-color: var(--bg-surface);">Instance Counts</div>
|
||||
<div class="card-body chart-card-body">
|
||||
<div class="chart-container"><canvas id="class-counts-chart"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4 mb-4">
|
||||
<div class="card h-100 shadow-sm">
|
||||
<div class="card-header font-weight-bold small text-uppercase text-muted" style="background-color: var(--bg-surface);">Objects per Image</div>
|
||||
<div class="card-body chart-card-body">
|
||||
<div class="chart-container"><canvas id="objects-per-image-chart"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4 mb-4">
|
||||
<div class="card h-100 shadow-sm">
|
||||
<div class="card-header font-weight-bold small text-uppercase text-muted" style="background-color: var(--bg-surface);">Brightness Distribution</div>
|
||||
<div class="card-body chart-card-body">
|
||||
<div class="chart-container"><canvas id="brightness-chart"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row 2 -->
|
||||
<div class="row">
|
||||
<div class="col-lg-4 mb-4">
|
||||
<div class="card h-100 shadow-sm">
|
||||
<div class="card-header font-weight-bold small text-uppercase text-muted" style="background-color: var(--bg-surface);">Aspect Ratio</div>
|
||||
<div class="card-body chart-card-body">
|
||||
<div class="chart-container"><canvas id="aspect-ratio-chart"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-8 mb-4">
|
||||
<div class="card h-100 shadow-sm">
|
||||
<div class="card-header font-weight-bold small text-uppercase text-muted" style="background-color: var(--bg-surface);">Bounding Box Heatmap</div>
|
||||
<div class="card-body chart-card-body">
|
||||
<!-- 移除 bg-light,改为柔和背景变量 -->
|
||||
<div id="bbox-heatmap-container" class="chart-container rounded border" style="background-color: var(--bg-surface-secondary);">
|
||||
<canvas id="bbox-center-scatter-plot"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Augmentation Previewer -->
|
||||
<div class="row">
|
||||
<div class="col-12 mb-4">
|
||||
<div class="card shadow-sm border-0">
|
||||
<!-- 移除 bg-primary text-white,改用柔和的背景和深色文字 -->
|
||||
<div class="card-header d-flex align-items-center" style="background-color: var(--color-primary-soft); color: var(--text-main); border-bottom: 1px solid var(--border-color);">
|
||||
<i class="bi bi-magic mr-2" style="color: var(--color-primary);"></i> <strong>Augmentation Previewer</strong>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="row no-gutters">
|
||||
<!-- Left: Controls -->
|
||||
<!-- 移除 bg-light -->
|
||||
<div class="col-lg-4 border-right p-3" style="background-color: var(--bg-surface-secondary);">
|
||||
<h6 class="font-weight-bold mb-3 border-bottom pb-2">1. Configure Parameters</h6>
|
||||
<div id="preview-aug-controls" class="custom-scrollbar">
|
||||
{% include '_augmentation_controls.html' %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Preview -->
|
||||
<div class="col-lg-8 p-3" style="background-color: var(--bg-surface);">
|
||||
<h6 class="font-weight-bold mb-3 border-bottom pb-2">2. Live Preview</h6>
|
||||
|
||||
<div class="mb-3">
|
||||
<small class="text-muted d-block mb-2 font-weight-bold text-uppercase">Select Source Image:</small>
|
||||
<div id="aug-preview-source-selector" class="d-flex flex-wrap" style="gap: 10px;"></div>
|
||||
</div>
|
||||
|
||||
<div id="aug-preview-gallery-container" class="border rounded p-3" style="min-height: 300px; background-color: var(--bg-surface-secondary);">
|
||||
<div id="aug-preview-gallery"></div>
|
||||
<div id="aug-preview-loader" class="text-center mt-5" style="display: none;">
|
||||
<div class="spinner-border" role="status" style="color: var(--color-primary);"></div>
|
||||
<p class="mt-2 text-muted small">Generating augmented samples...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Annotator Stats -->
|
||||
<div class="row">
|
||||
<div class="col-lg-12 mb-4">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header font-weight-bold small text-uppercase text-muted" style="background-color: var(--bg-surface);">Annotator Statistics</div>
|
||||
<div class="card-body chart-card-body">
|
||||
<div class="chart-container"><canvas id="annotator-chart"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Gallery -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-header border-bottom py-3 d-flex justify-content-between align-items-center flex-wrap" style="background-color: var(--bg-surface);">
|
||||
<div>
|
||||
<strong style="color: var(--text-main);"><i class="bi bi-images mr-2"></i>Dataset Gallery</strong>
|
||||
<span class="badge badge-secondary ml-2"><span id="gallery-count">0</span> images</span>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center mt-2 mt-md-0">
|
||||
<label class="mr-2 mb-0 small font-weight-bold text-muted">Filter:</label>
|
||||
<select id="outlier-filter" class="form-control form-control-sm mr-2 shadow-none border-secondary" style="width: auto; max-width: 250px;">
|
||||
<option value="none">Show All</option>
|
||||
<option value="area_smallest">Smallest Objects (Top 10)</option>
|
||||
<option value="area_largest">Largest Objects (Top 10)</option>
|
||||
<option value="ar_smallest">Squarish AR (Top 10)</option>
|
||||
<option value="ar_largest">Extreme AR (Top 10)</option>
|
||||
<option value="iou_high">High Overlap (IoU > 0.95)</option>
|
||||
<!-- 修改硬编码的粉色为 danger 变量 -->
|
||||
<option value="consistency_outliers" style="display: none; color: var(--color-danger); font-weight: bold;">AI Review Outliers</option>
|
||||
</select>
|
||||
<button id="reset-filter-btn" class="btn btn-sm btn-outline-secondary"><i class="bi bi-x-lg"></i> Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="gallery" class="custom-scrollbar border-0 rounded-0"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
const datasetUuid = "{{ dataset.dataset_uuid }}";
|
||||
let originalAnalysisData = null;
|
||||
let fullGalleryData = [];
|
||||
let samplePoolData = [];
|
||||
let allBboxesData = [];
|
||||
let suspiciousPairsData = [];
|
||||
let imageClassMap = {};
|
||||
let consistencyOutlierIndices = [];
|
||||
|
||||
let charts = {
|
||||
classCounts: null,
|
||||
objectsPerImage: null,
|
||||
brightness: null,
|
||||
aspectRatio: null,
|
||||
centerScatter: null,
|
||||
annotator: null
|
||||
};
|
||||
|
||||
// --- 核心配色逻辑 ---
|
||||
// 根据模式返回对应的柔和颜色
|
||||
function getColor(type) {
|
||||
const isDark = document.body.classList.contains('dark-mode');
|
||||
switch (type) {
|
||||
case 'primary': // 实例计数
|
||||
return isDark ? 'rgba(59, 82, 107, 0.6)' : 'rgba(165, 187, 241, 0.7)';
|
||||
case 'success': // 每张图片对象数
|
||||
return isDark ? 'rgba(47, 111, 78, 0.6)' : 'rgba(105, 219, 177, 0.7)';
|
||||
case 'warning': // 亮度分布
|
||||
return isDark ? 'rgba(138, 106, 38, 0.6)' : 'rgba(255, 224, 102, 0.7)';
|
||||
case 'info': // 长宽比
|
||||
return isDark ? 'rgba(80, 70, 120, 0.6)' : 'rgba(200, 190, 255, 0.7)';
|
||||
case 'danger': // 标注员
|
||||
return isDark ? 'rgba(140, 59, 59, 0.6)' : 'rgba(255, 201, 201, 0.7)';
|
||||
case 'scatter': // 散点图
|
||||
return isDark ? 'rgba(129, 161, 193, 0.6)' : 'rgba(92, 124, 250, 0.6)';
|
||||
default:
|
||||
return isDark ? '#888' : '#ddd';
|
||||
}
|
||||
}
|
||||
|
||||
// 主题配置函数 (适配 dark/light)
|
||||
function getChartThemeOptions() {
|
||||
const isDarkMode = document.body.classList.contains('dark-mode');
|
||||
// 使用 CSS 变量对应的值
|
||||
const gridColor = isDarkMode ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.03)';
|
||||
const textColor = isDarkMode ? '#909296' : '#868e96';
|
||||
|
||||
return {
|
||||
maintainAspectRatio: false,
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { labels: { color: textColor } },
|
||||
tooltip: {
|
||||
// Tooltip 背景适配:深色模式用深灰,浅色模式用白
|
||||
backgroundColor: isDarkMode ? 'rgba(43, 45, 51, 0.95)' : 'rgba(255, 255, 255, 0.95)',
|
||||
titleColor: isDarkMode ? '#d8dee9' : '#343a40',
|
||||
bodyColor: isDarkMode ? '#d8dee9' : '#343a40',
|
||||
borderColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)',
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
cornerRadius: 8
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: gridColor },
|
||||
ticks: { color: textColor },
|
||||
title: { display: true, color: textColor }
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: gridColor },
|
||||
ticks: { color: textColor },
|
||||
title: { display: true, color: textColor }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 监听主题切换,实时更新图表
|
||||
const themeObserver = new MutationObserver((mutationsList) => {
|
||||
for (const mutation of mutationsList) {
|
||||
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
|
||||
const newOptions = getChartThemeOptions();
|
||||
|
||||
// 更新所有图表
|
||||
if(charts.classCounts) {
|
||||
charts.classCounts.data.datasets[0].backgroundColor = getColor('primary');
|
||||
updateChartOptions(charts.classCounts, newOptions);
|
||||
}
|
||||
if(charts.objectsPerImage) {
|
||||
charts.objectsPerImage.data.datasets[0].backgroundColor = getColor('success');
|
||||
updateChartOptions(charts.objectsPerImage, newOptions);
|
||||
}
|
||||
if(charts.brightness) {
|
||||
charts.brightness.data.datasets[0].backgroundColor = getColor('warning');
|
||||
updateChartOptions(charts.brightness, newOptions);
|
||||
}
|
||||
if(charts.aspectRatio) {
|
||||
charts.aspectRatio.data.datasets[0].backgroundColor = getColor('info');
|
||||
updateChartOptions(charts.aspectRatio, newOptions);
|
||||
}
|
||||
if(charts.annotator) {
|
||||
charts.annotator.data.datasets[0].backgroundColor = getColor('danger');
|
||||
updateChartOptions(charts.annotator, newOptions);
|
||||
}
|
||||
if(charts.centerScatter) {
|
||||
charts.centerScatter.data.datasets[0].backgroundColor = getColor('scatter');
|
||||
updateChartOptions(charts.centerScatter, newOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
themeObserver.observe(document.body, { attributes: true });
|
||||
|
||||
function updateChartOptions(chart, newOptions) {
|
||||
chart.options.plugins.legend.labels.color = newOptions.plugins.legend.labels.color;
|
||||
chart.options.plugins.tooltip = newOptions.plugins.tooltip; // 更新 tooltip 样式
|
||||
chart.options.scales.x.grid.color = newOptions.scales.x.grid.color;
|
||||
chart.options.scales.x.ticks.color = newOptions.scales.x.ticks.color;
|
||||
chart.options.scales.y.grid.color = newOptions.scales.y.grid.color;
|
||||
chart.options.scales.y.ticks.color = newOptions.scales.y.ticks.color;
|
||||
chart.update();
|
||||
}
|
||||
|
||||
// --- Augmentation Controls Logic ---
|
||||
function setupAugmentationListeners(contextSelector) {
|
||||
const context = $(contextSelector);
|
||||
context.on('input', 'input[type="range"]', function() {
|
||||
$(this).closest('div').find('.val-display').text($(this).val());
|
||||
if (contextSelector === '#preview-aug-controls') {
|
||||
triggerAugmentationPreview(); // Live update
|
||||
}
|
||||
});
|
||||
context.on('change', '.aug-option input[type="checkbox"]', function() {
|
||||
const controlsId = $(this).closest('.aug-option').data('controls');
|
||||
context.find('#' + controlsId).toggle($(this).is(':checked'));
|
||||
if (contextSelector === '#preview-aug-controls') {
|
||||
triggerAugmentationPreview(); // Live update
|
||||
}
|
||||
});
|
||||
context.find('.aug-option input[type="checkbox"]').each(function() {
|
||||
const controlsId = $(this).closest('.aug-option').data('controls');
|
||||
context.find('#' + controlsId).toggle($(this).is(':checked'));
|
||||
});
|
||||
}
|
||||
|
||||
function getAugmentationOptions(isForPreview) {
|
||||
const context = $('#preview-aug-controls');
|
||||
const getVal = (selector, type = 'float') => type === 'int' ? parseInt(context.find(selector).val()) : parseFloat(context.find(selector).val());
|
||||
const isEnabled = (selector) => context.find(selector).is(':checked');
|
||||
|
||||
let augOptions = {
|
||||
enabled: true,
|
||||
hflip: { enabled: isEnabled('#aug-hflip-enabled'), p: getVal('#aug-hflip-p') },
|
||||
vflip: { enabled: isEnabled('#aug-vflip-enabled'), p: getVal('#aug-vflip-p') },
|
||||
rotate90: { enabled: isEnabled('#aug-rotate90-enabled'), p: getVal('#aug-rotate90-p') },
|
||||
rotate: { enabled: isEnabled('#aug-rotate-enabled'), p: getVal('#aug-rotate-p'), limit: getVal('#aug-rotate-limit', 'int') },
|
||||
ssr: { enabled: isEnabled('#aug-ssr-enabled'), p: getVal('#aug-ssr-p'), rotate: getVal('#aug-ssr-rotate', 'int'), shift: getVal('#aug-ssr-shift'), scale: getVal('#aug-ssr-scale') },
|
||||
affine: { enabled: isEnabled('#aug-affine-enabled'), p: getVal('#aug-affine-p'), shear: getVal('#aug-affine-shear', 'int') },
|
||||
crop: { enabled: isEnabled('#aug-crop-enabled'), p: getVal('#aug-crop-p') },
|
||||
grayscale: { enabled: isEnabled('#aug-grayscale-enabled'), p: getVal('#aug-grayscale-p') },
|
||||
hsv: { enabled: isEnabled('#aug-hsv-enabled'), p: getVal('#aug-hsv-p'), h: getVal('#aug-hsv-h', 'int'), s: getVal('#aug-hsv-s', 'int'), v: getVal('#aug-hsv-v', 'int') },
|
||||
bc: { enabled: isEnabled('#aug-bc-enabled'), p: getVal('#aug-bc-p'), b: getVal('#aug-bc-b'), c: getVal('#aug-bc-c') },
|
||||
blur: { enabled: isEnabled('#aug-blur-enabled'), p: getVal('#aug-blur-p'), limit: getVal('#aug-blur-limit', 'int') },
|
||||
noise: { enabled: isEnabled('#aug-noise-enabled'), p: getVal('#aug-noise-p'), limit: getVal('#aug-noise-limit') },
|
||||
cutout: { enabled: isEnabled('#aug-cutout-enabled'), p: getVal('#aug-cutout-p'), holes: getVal('#aug-cutout-holes', 'int'), size: getVal('#aug-cutout-size', 'int') },
|
||||
mosaic: { enabled: isEnabled('#aug-mosaic-enabled'), p: getVal('#aug-mosaic-p') }
|
||||
};
|
||||
|
||||
if(!isForPreview){
|
||||
augOptions.multiply_factor = 3;
|
||||
}
|
||||
return augOptions;
|
||||
}
|
||||
|
||||
let previewTimeout;
|
||||
function triggerAugmentationPreview() {
|
||||
clearTimeout(previewTimeout);
|
||||
previewTimeout = setTimeout(() => {
|
||||
const activeSource = $('#aug-preview-source-selector .active');
|
||||
if (activeSource.length > 0) {
|
||||
const videoUuid = activeSource.data('video-uuid');
|
||||
const frameNumber = activeSource.data('frame-number');
|
||||
fetchAugmentationPreviews(videoUuid, frameNumber);
|
||||
}
|
||||
}, 500); // 防抖
|
||||
}
|
||||
|
||||
function fetchAugmentationPreviews(videoUuid, frameNumber) {
|
||||
const gallery = $('#aug-preview-gallery');
|
||||
const loader = $('#aug-preview-loader');
|
||||
gallery.empty();
|
||||
loader.show();
|
||||
|
||||
const augOptions = getAugmentationOptions(true);
|
||||
const payload = {
|
||||
video_uuid: videoUuid, frame_number: frameNumber,
|
||||
augmentation_options: augOptions, sample_pool: samplePoolData
|
||||
};
|
||||
|
||||
$.ajax({
|
||||
url: '/api/previewAugmentations', type: 'POST', contentType: 'application/json',
|
||||
data: JSON.stringify(payload),
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
response.previews.forEach(imgData => {
|
||||
gallery.append(`<div><img src="${imgData}" alt="Augmented Preview" class="shadow-sm"></div>`);
|
||||
});
|
||||
} else {
|
||||
gallery.html(`<div class="alert alert-danger w-100">${response.message}</div>`);
|
||||
}
|
||||
},
|
||||
error: function() { gallery.html('<div class="alert alert-danger w-100">Server error fetching previews.</div>'); },
|
||||
complete: function() { loader.hide(); }
|
||||
});
|
||||
}
|
||||
|
||||
// --- Chart Rendering Functions ---
|
||||
function createHistogramData(data, bins = 10, min, max) {
|
||||
if (data.length === 0) return { labels: [], counts: [] };
|
||||
min = min ?? Math.min(...data);
|
||||
max = max ?? Math.max(...data);
|
||||
if (min === max) { min -= 0.5; max += 0.5; }
|
||||
const binSize = (max - min) / bins;
|
||||
const counts = Array(bins).fill(0);
|
||||
const labels = [];
|
||||
for (let i = 0; i < bins; i++) {
|
||||
const binStart = min + i * binSize;
|
||||
const binEnd = binStart + binSize;
|
||||
labels.push(`${binStart.toFixed(2)}-${binEnd.toFixed(2)}`);
|
||||
}
|
||||
for (const value of data) {
|
||||
let binIndex = Math.floor((value - min) / binSize);
|
||||
binIndex = Math.max(0, Math.min(bins - 1, binIndex));
|
||||
if (value === max) binIndex = bins - 1;
|
||||
counts[binIndex]++;
|
||||
}
|
||||
return { labels, counts };
|
||||
}
|
||||
|
||||
function renderSummaryAndWarnings(summary, warnings) {
|
||||
const container = $('#summary-container');
|
||||
container.empty();
|
||||
if (summary) {
|
||||
// 使用柔和的 Info 样式
|
||||
container.append(`<div class="alert shadow-sm border-0" style="background-color: var(--color-primary-soft); color: var(--text-main);">${summary}</div>`);
|
||||
}
|
||||
if (warnings && warnings.length > 0) {
|
||||
const warningsList = $('<div class="list-group"></div>');
|
||||
warnings.forEach(warning => {
|
||||
warningsList.append(`<div class="list-group-item small border-0 mb-1" style="background-color: rgba(251, 191, 36, 0.15); color: var(--text-main);"><i class="bi bi-exclamation-triangle-fill mr-2" style="color: var(--color-warning)"></i>${warning}</div>`);
|
||||
});
|
||||
container.append(warningsList);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCenterScatterPlot(points) {
|
||||
if(charts.centerScatter) charts.centerScatter.destroy();
|
||||
const options = getChartThemeOptions();
|
||||
$.extend(true, options.scales, {
|
||||
x: { type: 'linear', position: 'bottom', min: 0, max: 1, title: { display: true, text: 'Normalized X Position' }},
|
||||
y: { min: 0, max: 1, reverse: true, title: { display: true, text: 'Normalized Y Position' }}
|
||||
});
|
||||
options.plugins.legend.display = false;
|
||||
charts.centerScatter = new Chart(document.getElementById('bbox-center-scatter-plot').getContext('2d'), {
|
||||
type: 'scatter',
|
||||
data: { datasets: [{ label: 'BBox Center', data: points, backgroundColor: getColor('scatter'), pointRadius: 3, pointHoverRadius: 5 }] },
|
||||
options: options
|
||||
});
|
||||
}
|
||||
|
||||
function renderGallery(images) {
|
||||
const gallery = $('#gallery');
|
||||
gallery.empty();
|
||||
$('#gallery-count').text(images.length);
|
||||
images.forEach((image, index) => {
|
||||
const annotatedUrl = `/media/annotated_frame/${image.video_uuid}/${image.frame}.jpg`;
|
||||
const goToLabelUrl = image.task_uuid ? `/labelVideo?task_uuid=${image.task_uuid}&frame=${image.frame}` : '#';
|
||||
const buttonDisabled = image.task_uuid ? '' : 'disabled';
|
||||
// 使用 btn-secondary 而不是 btn-outline-primary 以减少视觉噪点
|
||||
const item = $(`
|
||||
<div class="gallery-item" data-index="${index}">
|
||||
<img src="${annotatedUrl}" alt="Frame ${image.frame}" loading="lazy">
|
||||
<div class="caption">
|
||||
<span class="caption-text text-truncate w-100" title="${image.video}">${image.video}</span>
|
||||
<span class="text-muted small">Frame ${image.frame}</span>
|
||||
<a href="${goToLabelUrl}" class="btn btn-sm btn-secondary btn-block mt-2 ${buttonDisabled}">Go to Label</a>
|
||||
</div>
|
||||
</div>`);
|
||||
item.find('img').on('click', () => window.open(image.original_url, '_blank'));
|
||||
gallery.append(item);
|
||||
});
|
||||
}
|
||||
|
||||
function filterGalleryByClass(className) {
|
||||
const classImageIndices = [];
|
||||
for (const [imageIndex, classList] of Object.entries(imageClassMap)) {
|
||||
if (classList.includes(className)) { classImageIndices.push(parseInt(imageIndex)); }
|
||||
}
|
||||
$('#outlier-filter').val('none');
|
||||
$('#gallery .gallery-item').each(function() {
|
||||
const itemIndex = parseInt($(this).data('index'));
|
||||
$(this).toggleClass('hidden', !classImageIndices.includes(itemIndex));
|
||||
});
|
||||
}
|
||||
|
||||
function resetGalleryFilter() {
|
||||
$('#outlier-filter').val('none');
|
||||
$('#gallery .gallery-item').removeClass('hidden');
|
||||
}
|
||||
|
||||
$('#outlier-filter').on('change', function() {
|
||||
const filterType = $(this).val();
|
||||
if (filterType === 'none') { $('#gallery .gallery-item').removeClass('hidden'); return; }
|
||||
let sortedBboxes, targetImageIndices;
|
||||
switch(filterType) {
|
||||
case 'area_smallest': sortedBboxes = [...allBboxesData].sort((a, b) => a.area - b.area); break;
|
||||
case 'area_largest': sortedBboxes = [...allBboxesData].sort((a, b) => b.area - a.area); break;
|
||||
case 'ar_smallest': sortedBboxes = [...allBboxesData].sort((a, b) => Math.abs(a.aspect_ratio - 1) - Math.abs(b.aspect_ratio - 1)); break;
|
||||
case 'ar_largest': sortedBboxes = [...allBboxesData].sort((a, b) => Math.abs(b.aspect_ratio - 1) - Math.abs(a.aspect_ratio - 1)); break;
|
||||
case 'iou_high': targetImageIndices = [...new Set(suspiciousPairsData.map(p => p.image_index))]; break;
|
||||
case 'consistency_outliers': targetImageIndices = consistencyOutlierIndices; break;
|
||||
}
|
||||
if (sortedBboxes) { targetImageIndices = [...new Set(sortedBboxes.slice(0, 10).map(b => b.image_index))]; }
|
||||
$('#gallery .gallery-item').each(function() {
|
||||
const itemIndex = parseInt($(this).data('index'));
|
||||
$(this).toggleClass('hidden', !targetImageIndices.includes(itemIndex));
|
||||
});
|
||||
});
|
||||
|
||||
$('#reset-filter-btn').on('click', resetGalleryFilter);
|
||||
|
||||
function renderClassCounts(classCounts) {
|
||||
// 使用 getColor('primary')
|
||||
const chartData = { labels: Object.keys(classCounts), datasets: [{ label: '# of Instances', data: Object.values(classCounts), backgroundColor: getColor('primary') }]};
|
||||
if (charts.classCounts) { charts.classCounts.destroy(); }
|
||||
const options = getChartThemeOptions();
|
||||
options.onHover = (event, chartElement) => { event.native.target.style.cursor = chartElement[0] ? 'pointer' : 'default'; };
|
||||
options.onClick = (event, elements) => {
|
||||
if (elements.length > 0) {
|
||||
const clickedIndex = elements[0].index;
|
||||
const className = charts.classCounts.data.labels[clickedIndex];
|
||||
filterGalleryByClass(className);
|
||||
} else { resetGalleryFilter(); }
|
||||
};
|
||||
const ctx = document.getElementById('class-counts-chart');
|
||||
charts.classCounts = new Chart(ctx, { type: 'bar', data: chartData, options: options });
|
||||
}
|
||||
|
||||
function renderObjectsPerImage(data) {
|
||||
const counts = data.reduce((acc, val) => { acc[val] = (acc[val] || 0) + 1; return acc; }, {});
|
||||
// 使用 getColor('success')
|
||||
const chartData = { labels: Object.keys(counts), datasets: [{ label: '# of Images', data: Object.values(counts), backgroundColor: getColor('success') }] };
|
||||
if(charts.objectsPerImage) charts.objectsPerImage.destroy();
|
||||
const options = getChartThemeOptions();
|
||||
options.scales.x.title.text = 'Number of Objects';
|
||||
charts.objectsPerImage = new Chart($('#objects-per-image-chart'), { type: 'bar', data: chartData, options: options });
|
||||
}
|
||||
|
||||
function renderBrightness(data) {
|
||||
const histData = createHistogramData(data, 20, 0, 255);
|
||||
// 使用 getColor('warning')
|
||||
const chartData = { labels: histData.labels, datasets: [{ label: 'Image Count', data: histData.counts, backgroundColor: getColor('warning') }] };
|
||||
if(charts.brightness) charts.brightness.destroy();
|
||||
charts.brightness = new Chart($('#brightness-chart'), { type: 'bar', data: chartData, options: getChartThemeOptions() });
|
||||
}
|
||||
|
||||
function renderAspectRatio(data) {
|
||||
const logData = data.map(d => d > 0 ? Math.log10(d) : 0);
|
||||
const minLog = logData.length > 0 ? Math.min(...logData) : 0;
|
||||
const maxLog = logData.length > 0 ? Math.max(...logData) : 1;
|
||||
const histData = createHistogramData(logData, 15, minLog, maxLog);
|
||||
histData.labels = histData.labels.map(l => {
|
||||
const range = l.split('-').map(parseFloat);
|
||||
return `${Math.pow(10, range[0]).toFixed(2)}-${Math.pow(10, range[1]).toFixed(2)}`;
|
||||
});
|
||||
// 使用 getColor('info')
|
||||
const chartData = { labels: histData.labels, datasets: [{ label: 'BBox Count', data: histData.counts, backgroundColor: getColor('info') }] };
|
||||
if(charts.aspectRatio) charts.aspectRatio.destroy();
|
||||
const options = getChartThemeOptions();
|
||||
options.scales.x.title.text = 'Width / Height';
|
||||
charts.aspectRatio = new Chart($('#aspect-ratio-chart'), { type: 'bar', data: chartData, options: options });
|
||||
}
|
||||
|
||||
function renderAnnotatorStats(stats) {
|
||||
const users = Object.keys(stats);
|
||||
if (users.length === 0) { $('#annotator-chart').parent().html('<p class="text-muted text-center pt-5">No annotator data found.</p>'); return; }
|
||||
const imageCounts = users.map(u => stats[u].image_count);
|
||||
// 使用 getColor('danger')
|
||||
const chartData = { labels: users, datasets: [{ label: '# Labeled Images', data: imageCounts, backgroundColor: getColor('danger') }] };
|
||||
if(charts.annotator) charts.annotator.destroy();
|
||||
charts.annotator = new Chart($('#annotator-chart'), { type: 'bar', data: chartData, options: getChartThemeOptions() });
|
||||
}
|
||||
|
||||
// --- Main Fetch Logic ---
|
||||
$.ajax({
|
||||
url: `/api/datasetAnalysis/${datasetUuid}`, type: 'GET',
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
$('#loading-indicator').hide();
|
||||
$('#analysis-content').fadeIn();
|
||||
|
||||
originalAnalysisData = response;
|
||||
fullGalleryData = response.gallery_images;
|
||||
allBboxesData = response.all_bboxes;
|
||||
suspiciousPairsData = response.suspicious_pairs;
|
||||
imageClassMap = response.image_class_map;
|
||||
|
||||
renderSummaryAndWarnings(response.summary_text, response.warnings);
|
||||
renderClassCounts(response.class_counts);
|
||||
renderObjectsPerImage(response.objects_per_image);
|
||||
renderAspectRatio(response.aspect_ratios);
|
||||
renderCenterScatterPlot(response.center_points);
|
||||
if (response.brightness_levels) renderBrightness(response.brightness_levels);
|
||||
if (response.annotator_stats) renderAnnotatorStats(response.annotator_stats);
|
||||
|
||||
renderGallery(fullGalleryData);
|
||||
|
||||
setupAugmentationListeners('#preview-aug-controls');
|
||||
const sourceSelector = $('#aug-preview-source-selector');
|
||||
const sampleImages = fullGalleryData.slice(0, 5);
|
||||
samplePoolData = sampleImages.map(img => ({ video_uuid: img.video_uuid, frame_number: img.frame }));
|
||||
|
||||
sampleImages.forEach((img, index) => {
|
||||
const thumb = $(`<img src="${img.original_url}" class="aug-preview-source-img shadow-sm" data-video-uuid="${img.video_uuid}" data-frame-number="${img.frame}">`);
|
||||
if (index === 0) thumb.addClass('active');
|
||||
sourceSelector.append(thumb);
|
||||
});
|
||||
|
||||
if (sampleImages.length > 0) {
|
||||
triggerAugmentationPreview();
|
||||
} else {
|
||||
$('#aug-preview-gallery-container').html('<p class="text-muted text-center pt-5">No images available in this dataset to generate previews.</p>');
|
||||
}
|
||||
|
||||
sourceSelector.on('click', 'img', function() {
|
||||
sourceSelector.find('img').removeClass('active');
|
||||
$(this).addClass('active');
|
||||
triggerAugmentationPreview();
|
||||
});
|
||||
} else { $('#loading-indicator').html(`<div class="alert alert-danger shadow-sm">Error loading analysis: ${response.message}</div>`); }
|
||||
},
|
||||
error: function() { $('#loading-indicator').html('<div class="alert alert-danger shadow-sm">Failed to fetch analysis data from the server.</div>'); }
|
||||
});
|
||||
|
||||
// AI Consistency Check Button
|
||||
$('#run-consistency-check-btn').on('click', function() {
|
||||
const $btn = $(this);
|
||||
$btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm" role="status"></span> Running...');
|
||||
|
||||
const isColorCheckEnabled = $('#enable-color-check').is(':checked');
|
||||
|
||||
$.ajax({
|
||||
url: `/api/datasetAnalysis/${datasetUuid}/consistency_check`,
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({ enable_color_check: isColorCheckEnabled }),
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
consistencyOutlierIndices = response.outlier_image_indices;
|
||||
// 使用柔和的 success 样式
|
||||
const resultMessage = $(`<div class="alert mt-3 shadow-sm" style="background-color: rgba(52, 211, 153, 0.15); color: var(--text-main); border: 1px solid var(--color-success);">${response.message}</div>`);
|
||||
$('#consistency-check-container').append(resultMessage);
|
||||
|
||||
if (consistencyOutlierIndices.length > 0) {
|
||||
$('#outlier-filter option[value="consistency_outliers"]').show();
|
||||
$('#outlier-filter').val('consistency_outliers').trigger('change');
|
||||
resultMessage.append(' <br><strong>Filter applied to show outliers below.</strong>');
|
||||
}
|
||||
$btn.hide();
|
||||
} else {
|
||||
alert('AI Review Failed: ' + response.message);
|
||||
$btn.prop('disabled', false).html('<i class="bi bi-search mr-1"></i> Run AI Review');
|
||||
}
|
||||
},
|
||||
error: function(xhr) {
|
||||
const errorMsg = xhr.responseJSON ? xhr.responseJSON.message : "Server error.";
|
||||
alert(errorMsg);
|
||||
$btn.prop('disabled', false).html('<i class="bi bi-search mr-1"></i> Run AI Review');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<title>FTC-ML Local</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modern.css') }}">
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body class="d-flex flex-column min-vh-100">
|
||||
<nav class="navbar navbar-expand-lg navbar-light bg-transparent pt-3 pb-3 px-4">
|
||||
<a class="navbar-brand d-flex align-items-center" href="/">
|
||||
<div class="d-flex align-items-center justify-content-center bg-primary text-white rounded-circle mr-2 shadow-sm" style="width: 32px; height: 32px;">
|
||||
<i class="bi bi-box-seam" style="font-size: 0.9rem;"></i>
|
||||
</div>
|
||||
<span class="font-weight-bold" style="font-size: 1.1rem; letter-spacing: -0.01em;">Zero 2 YOLO Yard</span>
|
||||
</a>
|
||||
|
||||
<button class="navbar-toggler border-0" type="button" data-toggle="collapse" data-target="#navbarContent">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="navbarContent">
|
||||
<ul class="navbar-nav mr-auto"></ul>
|
||||
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="small mr-3 font-weight-500 text-muted d-none d-md-block" style="opacity: 0.7;">
|
||||
Produced By BlueDarkUP -- Based On FIRST ML Toolchain --
|
||||
</span>
|
||||
<button class="theme-toggle-btn" id="theme-toggle-btn" title="Toggle Theme">
|
||||
<i class="bi bi-moon-fill" id="theme-toggle-icon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main role="main" class="flex-grow-1">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<script src="https://code.jquery.com/jquery-3.5.1.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const themeToggleBtn = document.getElementById('theme-toggle-btn');
|
||||
const themeToggleIcon = document.getElementById('theme-toggle-icon');
|
||||
const body = document.body;
|
||||
const applyTheme = (isDark) => {
|
||||
if (isDark) {
|
||||
body.classList.add('dark-mode');
|
||||
themeToggleIcon.className = 'bi bi-sun-fill';
|
||||
} else {
|
||||
body.classList.remove('dark-mode');
|
||||
themeToggleIcon.className = 'bi bi-moon-fill';
|
||||
}
|
||||
};
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const systemPrefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (savedTheme === 'dark' || (!savedTheme && systemPrefersDark)) applyTheme(true);
|
||||
else applyTheme(false);
|
||||
|
||||
themeToggleBtn.addEventListener('click', () => {
|
||||
const isDark = body.classList.contains('dark-mode');
|
||||
applyTheme(!isDark);
|
||||
localStorage.setItem('theme', !isDark ? 'dark' : 'light');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,955 @@
|
||||
{% extends "layout.html" %}
|
||||
{% from "_macros.html" import render_modal %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-4 px-lg-5 content-wrapper fade-in">
|
||||
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-end page-header">
|
||||
<div>
|
||||
<h1 class="page-title mb-0">Dashboard</h1>
|
||||
<p class="page-subtitle">Manage your computer vision pipeline.</p>
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-pills-fluent mt-3 mt-md-0" id="mainTabs" role="tablist">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" id="videos-tab" data-toggle="tab" href="#videos" role="tab">
|
||||
Videos
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" id="datasets-tab" data-toggle="tab" href="#datasets" role="tab">
|
||||
Datasets
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" id="models-tab" data-toggle="tab" href="#models" role="tab">
|
||||
Models
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" id="settings-tab" data-toggle="tab" href="#settings" role="tab">
|
||||
Settings
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="tab-content" id="mainTabContent">
|
||||
|
||||
<div class="tab-pane fade show active" id="videos" role="tabpanel">
|
||||
<div class="d-flex justify-content-end mb-3">
|
||||
<button class="btn btn-primary shadow-sm" data-toggle="modal" data-target="#uploadVideoModal">
|
||||
<i class="bi bi-plus-lg mr-2"></i>New Video
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input type="file" id="frame-import-input" webkitdirectory directory multiple style="display: none;" />
|
||||
|
||||
<div class="fluent-card">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-fluent table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 25%">Description</th>
|
||||
<th>Filename</th>
|
||||
<th>Status</th>
|
||||
<th>Progress</th>
|
||||
<th>Labels</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="video-list"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="datasets" role="tabpanel">
|
||||
<div class="d-flex justify-content-end mb-3">
|
||||
<button class="btn btn-primary shadow-sm" data-toggle="modal" data-target="#createDatasetModal">
|
||||
<i class="bi bi-plus-lg mr-2"></i>Create Dataset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="fluent-card">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-fluent table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 25%">Name</th>
|
||||
<th>Status</th>
|
||||
<th>Composition</th>
|
||||
<th>Classes</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dataset-list"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="models" role="tabpanel">
|
||||
<div class="d-flex justify-content-end mb-3">
|
||||
<button class="btn btn-primary shadow-sm" data-toggle="modal" data-target="#importModelModal">
|
||||
<i class="bi bi-upload mr-2"></i>Import Model
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="fluent-card">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-fluent table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th>Type</th>
|
||||
<th>Label Map</th>
|
||||
<th>Date</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="model-list"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="settings" role="tabpanel">
|
||||
<form id="settings-form">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-10 col-xl-9">
|
||||
|
||||
<div class="fluent-card settings-section">
|
||||
<div class="settings-section-title">
|
||||
<i class="bi bi-cpu mr-2 text-primary"></i> AI Engine & Hardware
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-md-6">
|
||||
<label class="small text-muted font-weight-bold">Computation Device</label>
|
||||
<select id="setting-gpu-device" class="form-control custom-select">
|
||||
<option value="auto">Auto-detect</option>
|
||||
<option value="cpu">CPU Only</option>
|
||||
<option value="cuda:0">NVIDIA GPU #0</option>
|
||||
<option value="cuda:1">NVIDIA GPU #1</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group col-md-6">
|
||||
<label class="small text-muted font-weight-bold">Assistance Model (SAM)</label>
|
||||
<select id="setting-sam-model" class="form-control custom-select">
|
||||
<option value="sam2.1_t.pt" data-name="SAM 2.1 Tiny">SAM 2.1 Tiny (Fast)</option>
|
||||
<option value="sam2.1_s.pt" data-name="SAM 2.1 Small">SAM 2.1 Small (Balanced)</option>
|
||||
<option value="sam2.1_b.pt" data-name="SAM 2.1 Base">SAM 2.1 Base</option>
|
||||
<option value="sam2.1_l.pt" data-name="SAM 2.1 Large">SAM 2.1 Large (Precise)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group col-md-6">
|
||||
<label class="small text-muted font-weight-bold">Feature Extractor</label>
|
||||
<select id="setting-feature-extractor-model" class="form-control custom-select">
|
||||
<option value="mobilenet_v3_large">MobileNetV3-Large</option>
|
||||
<option value="mobilenet_v3_small">MobileNetV3-Small</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fluent-card settings-section">
|
||||
<div class="settings-section-title">
|
||||
<i class="bi bi-sliders mr-2 text-primary"></i> Algorithm Parameters
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-md-4">
|
||||
<label class="d-flex justify-content-between small text-muted font-weight-bold">
|
||||
SAM Mask Conf. <span id="sam-mask-conf-val" class="text-primary">0.35</span>
|
||||
</label>
|
||||
<input type="range" class="custom-range" id="setting-sam-mask-confidence" min="0.1" max="0.9" step="0.05">
|
||||
</div>
|
||||
<div class="form-group col-md-4">
|
||||
<label class="d-flex justify-content-between small text-muted font-weight-bold">
|
||||
NMS IoU <span id="nms-iou-val" class="text-primary">0.70</span>
|
||||
</label>
|
||||
<input type="range" class="custom-range" id="setting-nms-iou-threshold" min="0.1" max="1.0" step="0.05">
|
||||
</div>
|
||||
<div class="form-group col-md-4">
|
||||
<label class="d-flex justify-content-between small text-muted font-weight-bold">
|
||||
Proto. Temp <span id="prototype-temp-val" class="text-primary">0.07</span>
|
||||
</label>
|
||||
<input type="range" class="custom-range" id="setting-prototype-temperature" min="0.01" max="0.5" step="0.01">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fluent-card settings-section">
|
||||
<div class="settings-section-title">
|
||||
<i class="bi bi-ui-checks mr-2 text-primary"></i> Workflow
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-md-6">
|
||||
<label class="small text-muted font-weight-bold">Default Tool</label>
|
||||
<select id="setting-default-annotation-mode" class="form-control custom-select">
|
||||
<option value="manual">Manual Draw</option>
|
||||
<option value="sam">SAM (Point)</option>
|
||||
<option value="lam">LAM (Click to Label)</option>
|
||||
<option value="smart_select">Smart Select</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group col-md-6">
|
||||
<label class="small text-muted font-weight-bold">Auto-Save</label>
|
||||
<div class="custom-control custom-switch mt-2">
|
||||
<input type="checkbox" class="custom-control-input" id="setting-autosave-enabled">
|
||||
<label class="custom-control-label" for="setting-autosave-enabled">Save on navigation</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-md-6">
|
||||
<label class="small text-muted font-weight-bold">JPEG Quality</label>
|
||||
<input type="number" id="setting-jpeg-quality" class="form-control" min="10" max="100" step="5">
|
||||
</div>
|
||||
<div class="form-group col-md-6">
|
||||
<label class="small text-muted font-weight-bold">Legacy Tracker</label>
|
||||
<select id="setting-opencv-tracker" class="form-control custom-select">
|
||||
{% for tracker in tracker_fns %}<option value="{{ tracker }}">{{ tracker }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fluent-card settings-section">
|
||||
<div class="settings-section-title">
|
||||
<i class="bi bi-palette mr-2 text-primary"></i> Class Colors
|
||||
</div>
|
||||
<div id="class-color-manager" class="d-flex flex-wrap custom-scrollbar" style="max-height: 200px; overflow-y: auto; gap: 10px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="fluent-card settings-section" style="border-left: 4px solid var(--color-warning);">
|
||||
<div class="settings-section-title text-warning">
|
||||
<i class="bi bi-tools mr-2"></i> Maintenance
|
||||
</div>
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div class="form-group mb-0 mr-3 flex-grow-1">
|
||||
<label class="small text-muted font-weight-bold">Cache Save Interval (s)</label>
|
||||
<input type="number" class="form-control" id="setting-cache-save-interval" min="10" max="300">
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-warning" id="clear-cache-btn">
|
||||
<i class="bi bi-eraser-fill mr-1"></i> Clear Cache
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-right pb-5">
|
||||
<button type="submit" class="btn btn-primary px-4 shadow">
|
||||
Apply Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% call render_modal('uploadVideoModal', 'Upload New Video') %}
|
||||
<form id="upload-video-form">
|
||||
<div class="form-group">
|
||||
<label class="font-weight-600">Description</label>
|
||||
<input type="text" class="form-control" id="video-description" name="description" placeholder="E.g., Field Test Match 1" required maxlength="{{ limit_data.MAX_DESCRIPTION_LENGTH }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="font-weight-600">Video File (.mp4)</label>
|
||||
<div class="custom-file">
|
||||
<input type="file" class="custom-file-input" id="video-file" name="video_file" accept="video/mp4" required>
|
||||
<label class="custom-file-label" for="video-file">Choose file...</label>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block">Upload & Process</button>
|
||||
</form>
|
||||
{% endcall %}
|
||||
|
||||
{% call render_modal('createDatasetModal', 'Create Dataset') %}
|
||||
<form id="create-dataset-form">
|
||||
<div class="form-group">
|
||||
<label class="font-weight-600">Name</label>
|
||||
<input type="text" class="form-control" id="dataset-description" placeholder="E.g., Season 2024 V1" required maxlength="{{ limit_data.MAX_DESCRIPTION_LENGTH }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="font-weight-600">Videos</label>
|
||||
<select multiple class="form-control custom-select" id="dataset-videos" required size="5"></select>
|
||||
<small class="text-muted">Ctrl/Cmd + Click to select multiple.</small>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-6">
|
||||
<label class="font-weight-600">Valid %</label>
|
||||
<input type="number" class="form-control" id="eval-percent" value="20">
|
||||
</div>
|
||||
<div class="form-group col-6">
|
||||
<label class="font-weight-600">Test %</label>
|
||||
<input type="number" class="form-control" id="test-percent" value="10">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-light p-3 rounded mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0 font-weight-bold">Augmentation</h6>
|
||||
<div class="custom-control custom-switch">
|
||||
<input type="checkbox" class="custom-control-input" id="aug-enabled">
|
||||
<label class="custom-control-label" for="aug-enabled"></label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="augmentation-options-panel" class="mt-3" style="display: none;">
|
||||
<div class="form-group">
|
||||
<label class="small text-muted font-weight-bold">Copies per Image</label>
|
||||
<input type="number" class="form-control" id="aug-multiply-factor" value="3">
|
||||
</div>
|
||||
{% include '_augmentation_controls.html' %}
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block">Create Dataset</button>
|
||||
</form>
|
||||
{% endcall %}
|
||||
|
||||
{% call render_modal('importModelModal', 'Import Model') %}
|
||||
<form id="import-model-form">
|
||||
<div class="form-group">
|
||||
<label class="font-weight-600">Description</label>
|
||||
<input type="text" class="form-control" id="model-description" name="description" required maxlength="{{ limit_data.MAX_DESCRIPTION_LENGTH }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="font-weight-600">Model (.tflite)</label>
|
||||
<div class="custom-file">
|
||||
<input type="file" class="custom-file-input" id="model-file" name="model_file" accept=".tflite" required>
|
||||
<label class="custom-file-label" for="model-file">Select file...</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="font-weight-600">Label Map (.txt)</label>
|
||||
<div class="custom-file">
|
||||
<input type="file" class="custom-file-input" id="label-file" name="label_file" accept=".txt,.labels" required>
|
||||
<label class="custom-file-label" for="label-file">Select file...</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="font-weight-600">Type</label>
|
||||
<select class="form-control custom-select" id="model-type" name="model_type" required>
|
||||
<option value="float32">Float32 (Standard)</option>
|
||||
<option value="uint8">UINT8 (Edge TPU)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block">Import</button>
|
||||
</form>
|
||||
{% endcall %}
|
||||
|
||||
{% call render_modal('preAnnotateModal', 'Auto-Label') %}
|
||||
<form id="pre-annotate-form">
|
||||
<input type="hidden" id="pre-annotate-video-uuid" data-frame-count="0" data-labeled-count="0">
|
||||
<div class="form-group">
|
||||
<label class="font-weight-600">Model</label>
|
||||
<select id="pre-annotate-model-select" class="form-control custom-select" required></select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-6">
|
||||
<label class="font-weight-600">Start</label>
|
||||
<input type="number" id="pre-annotate-start-frame" class="form-control">
|
||||
</div>
|
||||
<div class="form-group col-6">
|
||||
<label class="font-weight-600">End</label>
|
||||
<input type="number" id="pre-annotate-end-frame" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="d-flex justify-content-between font-weight-600">
|
||||
Conf. <span id="confidence-value" class="text-primary">0.5</span>
|
||||
</label>
|
||||
<input type="range" class="custom-range" id="pre-annotate-confidence" min="0.1" max="0.95" step="0.05" value="0.5">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="font-weight-600 d-block mb-2">Strategy</label>
|
||||
<div class="custom-control custom-radio custom-control-inline">
|
||||
<input type="radio" id="strategy-overwrite" name="merge_strategy" value="overwrite" class="custom-control-input" checked>
|
||||
<label class="custom-control-label" for="strategy-overwrite">Overwrite</label>
|
||||
</div>
|
||||
<div class="custom-control custom-radio custom-control-inline">
|
||||
<input type="radio" id="strategy-skip" name="merge_strategy" value="skip_labeled" class="custom-control-input">
|
||||
<label class="custom-control-label" for="strategy-skip">Fill Gaps</label>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block">Start</button>
|
||||
</form>
|
||||
{% endcall %}
|
||||
|
||||
<div class="modal fade" id="manageTasksModal" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content border-0 shadow-lg" style="border-radius: var(--radius-lg);">
|
||||
<div class="modal-header border-bottom-0 pb-0">
|
||||
<h5 class="modal-title font-weight-bold" id="manageTasksModalTitle">Tasks</h5>
|
||||
<button type="button" class="close" data-dismiss="modal"><span>×</span></button>
|
||||
</div>
|
||||
<div class="modal-body pt-3">
|
||||
<div class="table-responsive rounded bg-light mb-4 border-0">
|
||||
<table class="table table-sm table-hover mb-0 bg-transparent">
|
||||
<tbody id="task-list-in-modal"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<h6 class="font-weight-bold mb-3">New Task</h6>
|
||||
<form id="create-task-form">
|
||||
<input type="hidden" id="task-video-uuid">
|
||||
<div class="form-row">
|
||||
<div class="form-group col-md-4">
|
||||
<input type="text" class="form-control form-control-sm" id="task-assigned-to" placeholder="User" required>
|
||||
</div>
|
||||
<div class="form-group col-md-3">
|
||||
<input type="number" class="form-control form-control-sm" id="task-start-frame" placeholder="Start" required>
|
||||
</div>
|
||||
<div class="form-group col-md-3">
|
||||
<input type="number" class="form-control form-control-sm" id="task-end-frame" placeholder="End" required>
|
||||
</div>
|
||||
<div class="form-group col-md-2">
|
||||
<button type="submit" class="btn btn-sm btn-success btn-block">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
let currentSettings = {};
|
||||
|
||||
$('.custom-file-input').on('change', function() {
|
||||
let fileName = $(this).val().split('\\').pop();
|
||||
$(this).next('.custom-file-label').addClass("selected").html(fileName);
|
||||
});
|
||||
|
||||
function getStatusBadge(status, message) {
|
||||
let badgeClass = 'secondary';
|
||||
let icon = '';
|
||||
|
||||
if (status === 'READY') {
|
||||
badgeClass = 'warning';
|
||||
if(!message) badgeClass = 'success';
|
||||
icon = '<i class="bi bi-check-circle-fill mr-1"></i>';
|
||||
} else if (['PROCESSING', 'EXTRACTING', 'PENDING', 'PRE_ANNOTATING', 'APPLYING_PROTOTYPES'].includes(status)) {
|
||||
badgeClass = 'info';
|
||||
icon = '<span class="spinner-border spinner-border-sm mr-1"></span>';
|
||||
} else if (status === 'CANCELLING') {
|
||||
badgeClass = 'warning';
|
||||
icon = '<i class="bi bi-x-circle mr-1"></i>';
|
||||
} else if (status === 'FAILED') {
|
||||
badgeClass = 'danger';
|
||||
icon = '<i class="bi bi-exclamation-triangle-fill mr-1"></i>';
|
||||
}
|
||||
|
||||
return `<span class="badge badge-${badgeClass} d-inline-flex align-items-center">${icon} ${status}</span>`;
|
||||
}
|
||||
|
||||
function showSuccess(message) { alert(message); }
|
||||
function showError(message) { alert('Error: ' + message); }
|
||||
|
||||
function refreshVideos() {
|
||||
$.get('/listVideos', function(data) {
|
||||
const videoList = $('#video-list');
|
||||
videoList.empty();
|
||||
data.all_videos.forEach(video => {
|
||||
const isReady = video.status === 'READY';
|
||||
|
||||
let actions = '';
|
||||
if (isReady) {
|
||||
actions += `<button class="btn btn-sm btn-info btn-import-frames mr-1" data-uuid="${video.video_uuid}" title="Import frames"><i class="bi bi-images"></i></button>`;
|
||||
actions += `<button class="btn btn-sm btn-warning btn-pre-annotate mr-1" data-uuid="${video.video_uuid}" data-frame-count="${video.frame_count}" data-labeled-count="${video.labeled_frame_count}" title="Auto-Label"><i class="bi bi-robot"></i></button>`;
|
||||
actions += `<button class="btn btn-sm btn-success btn-manage-tasks mr-1" data-uuid="${video.video_uuid}" data-description="${video.description}" data-framecount="${video.frame_count}" title="Tasks"><i class="bi bi-card-checklist"></i></button>`;
|
||||
} else {
|
||||
actions += `<button class="btn btn-sm btn-secondary mr-1" disabled><i class="bi bi-images"></i></button>`;
|
||||
}
|
||||
|
||||
if (['PRE_ANNOTATING', 'APPLYING_PROTOTYPES'].includes(video.status)) {
|
||||
actions += `<button class="btn btn-sm btn-danger btn-cancel-task mr-1" data-uuid="${video.video_uuid}" title="Cancel Task"><i class="bi bi-stop-circle"></i></button>`;
|
||||
}
|
||||
|
||||
actions += `<button class="btn btn-sm btn-outline-danger btn-delete-video" data-uuid="${video.video_uuid}" title="Delete"><i class="bi bi-trash"></i></button>`;
|
||||
|
||||
const progress = video.extracted_frame_count ? `${video.extracted_frame_count} / ${video.frame_count || '?'}` : '-';
|
||||
const statusHtml = getStatusBadge(video.status, video.status_message);
|
||||
const statusMsg = video.status_message ? `<div class="small text-muted mt-1">${video.status_message}</div>` : '';
|
||||
|
||||
const row = `
|
||||
<tr>
|
||||
<td class="align-middle"><strong>${video.description}</strong></td>
|
||||
<td class="align-middle text-muted small">${video.video_filename || ''}</td>
|
||||
<td class="align-middle">${statusHtml}${statusMsg}</td>
|
||||
<td class="align-middle font-weight-bold text-muted small">${progress}</td>
|
||||
<td class="align-middle"><span class="badge badge-neutral">${video.labeled_frame_count}</span></td>
|
||||
<td class="align-middle text-right">${actions}</td>
|
||||
</tr>`;
|
||||
videoList.append(row);
|
||||
});
|
||||
|
||||
const datasetVideoSelect = $('#dataset-videos');
|
||||
datasetVideoSelect.empty();
|
||||
data.ready_videos_for_dataset.forEach(video => {
|
||||
datasetVideoSelect.append(`<option value="${video.video_uuid}">${video.description} (${video.labeled_frame_count} labels)</option>`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function refreshDatasets() {
|
||||
$.get('/listDatasets', function(data) {
|
||||
const datasetList = $('#dataset-list');
|
||||
datasetList.empty();
|
||||
data.datasets.forEach(dataset => {
|
||||
let actions = '';
|
||||
if (dataset.status === 'READY') {
|
||||
actions += `<a href="/downloadDataset/${dataset.dataset_uuid}" class="btn btn-sm btn-success mr-1" title="Download"><i class="bi bi-download"></i></a>`;
|
||||
actions += `<a href="/datasetAnalysis/${dataset.dataset_uuid}" class="btn btn-sm btn-info mr-1" title="Analyze"><i class="bi bi-bar-chart-line"></i></a>`;
|
||||
actions += `<button class="btn btn-sm btn-secondary btn-regenerate-dataset mr-1" data-uuid="${dataset.dataset_uuid}" title="Update"><i class="bi bi-arrow-repeat"></i></button>`;
|
||||
} else if (dataset.status === 'FAILED') {
|
||||
actions += `<button class="btn btn-sm btn-warning btn-regenerate-dataset mr-1" data-uuid="${dataset.dataset_uuid}" title="Retry"><i class="bi bi-arrow-repeat"></i></button>`;
|
||||
}
|
||||
actions += `<button class="btn btn-sm btn-outline-danger btn-delete-dataset" data-uuid="${dataset.dataset_uuid}" title="Delete"><i class="bi bi-trash"></i></button>`;
|
||||
|
||||
const videosCount = JSON.parse(dataset.video_uuids || '[]').length;
|
||||
|
||||
const classes = JSON.parse(dataset.sorted_label_list || '[]')
|
||||
.map(c => `<span class="badge badge-neutral badge-pill mr-1 mb-1">${c}</span>`).join('');
|
||||
|
||||
const row = `
|
||||
<tr>
|
||||
<td class="align-middle"><strong>${dataset.description}</strong></td>
|
||||
<td class="align-middle">${getStatusBadge(dataset.status, dataset.status_message)}</td>
|
||||
<td class="align-middle">${videosCount} video(s)</td>
|
||||
<td class="align-middle" style="line-height: 1.8;">${classes || '<span class="text-muted">-</span>'}</td>
|
||||
<td class="align-middle text-right">${actions}</td>
|
||||
</tr>`;
|
||||
datasetList.append(row);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function refreshModels() {
|
||||
$.get('/listModels', function(data) {
|
||||
const modelList = $('#model-list');
|
||||
modelList.empty();
|
||||
data.models.forEach(model => {
|
||||
const createdDate = new Date(model.create_time_ms).toLocaleDateString();
|
||||
const typeBadge = model.model_type === 'uint8'
|
||||
? `<span class="badge badge-info">INT8 (TPU)</span>`
|
||||
: `<span class="badge badge-primary">FP32</span>`;
|
||||
|
||||
const row = `
|
||||
<tr>
|
||||
<td class="align-middle"><strong>${model.description}</strong></td>
|
||||
<td class="align-middle">${typeBadge}</td>
|
||||
<td class="align-middle text-muted small">${model.label_filename || 'N/A'}</td>
|
||||
<td class="align-middle text-muted small">${createdDate}</td>
|
||||
<td class="align-middle text-right">
|
||||
<button class="btn btn-sm btn-outline-danger btn-delete-model" data-uuid="${model.model_uuid}"><i class="bi bi-trash"></i></button>
|
||||
</td>
|
||||
</tr>`;
|
||||
modelList.append(row);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function loadSettings() {
|
||||
$.get('/api/settings', function(data) {
|
||||
if (data.success) {
|
||||
currentSettings = data.settings;
|
||||
const s = data.settings;
|
||||
|
||||
$('#setting-gpu-device').val(s.gpu_device);
|
||||
$('#setting-sam-model').val(s.sam_model_checkpoint);
|
||||
$('#setting-feature-extractor-model').val(s.feature_extractor_model_name);
|
||||
$('#setting-sam-mask-confidence').val(s.sam_mask_confidence).trigger('input');
|
||||
$('#setting-nms-iou-threshold').val(s.nms_iou_threshold).trigger('input');
|
||||
$('#setting-prototype-temperature').val(s.prototype_temperature).trigger('input');
|
||||
$('#setting-prototype-sample-limit').val(s.prototype_sample_limit);
|
||||
$('#setting-batch-tracking-imgsz').val(s.batch_tracking_imgsz);
|
||||
$('#setting-batch-tracking-conf').val(s.batch_tracking_conf).trigger('input');
|
||||
$('#setting-batch-tracking-chunk-size').val(s.batch_tracking_chunk_size);
|
||||
$('#setting-default-annotation-mode').val(s.default_annotation_mode);
|
||||
$('#setting-autosave-enabled').prop('checked', s.autosave_enabled);
|
||||
$('#setting-preannotation-conf').val(s.default_preannotation_conf).trigger('input');
|
||||
$('#setting-jpeg-quality').val(s.frame_extraction_jpeg_quality);
|
||||
$('#setting-opencv-tracker').val(s.default_opencv_tracker);
|
||||
$('#setting-cache-save-interval').val(s.cache_save_interval_seconds);
|
||||
|
||||
loadClassColorManager();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function loadClassColorManager() {
|
||||
$.get('/listClasses', function(data) {
|
||||
if (data.success) {
|
||||
const container = $('#class-color-manager');
|
||||
container.empty();
|
||||
if (data.labels.length === 0) {
|
||||
container.html('<p class="text-muted small pl-2">No classes found. Add classes in the annotation view first.</p>');
|
||||
return;
|
||||
}
|
||||
const classColors = currentSettings.class_colors || {};
|
||||
data.labels.forEach(label => {
|
||||
const color = classColors[label] || '#000000';
|
||||
const item = `
|
||||
<div class="d-flex align-items-center bg-white border rounded px-2 py-1 mr-2 mb-2 shadow-sm" style="min-width: 120px;">
|
||||
<input type="color" class="border-0 p-0 mr-2 rounded-circle" value="${color}" data-class-name="${label}"
|
||||
style="width: 24px; height: 24px; cursor: pointer; background: none;" title="Change color for ${label}">
|
||||
<span class="small font-weight-bold text-truncate" style="max-width: 100px;">${label}</span>
|
||||
</div>`;
|
||||
container.append(item);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setupAugmentationListeners(contextSelector) {
|
||||
const context = $(contextSelector);
|
||||
context.on('input', 'input[type="range"]', function() {
|
||||
$(this).closest('div').find('.val-display').text($(this).val());
|
||||
});
|
||||
context.on('change', '.aug-option input[type="checkbox"]', function() {
|
||||
const controlsId = $(this).closest('.aug-option').data('controls');
|
||||
context.find('#' + controlsId).toggle($(this).is(':checked'));
|
||||
});
|
||||
context.find('.aug-option input[type="checkbox"]').each(function() {
|
||||
const controlsId = $(this).closest('.aug-option').data('controls');
|
||||
context.find('#' + controlsId).toggle($(this).is(':checked'));
|
||||
});
|
||||
}
|
||||
$('#aug-enabled').on('change', function() {
|
||||
$('#augmentation-options-panel').slideToggle($(this).is(':checked'));
|
||||
});
|
||||
setupAugmentationListeners('#createDatasetModal');
|
||||
|
||||
function getAugmentationOptions(contextSelector) {
|
||||
const context = $(contextSelector);
|
||||
const getVal = (selector, type = 'float') => type === 'int' ? parseInt(context.find(selector).val()) : parseFloat(context.find(selector).val());
|
||||
const isEnabled = (selector) => context.find(selector).is(':checked');
|
||||
return {
|
||||
enabled: isEnabled('#aug-enabled'),
|
||||
multiply_factor: getVal('#aug-multiply-factor', 'int'),
|
||||
hflip: { enabled: isEnabled('#aug-hflip-enabled'), p: getVal('#aug-hflip-p') },
|
||||
vflip: { enabled: isEnabled('#aug-vflip-enabled'), p: getVal('#aug-vflip-p') },
|
||||
rotate90: { enabled: isEnabled('#aug-rotate90-enabled'), p: getVal('#aug-rotate90-p') },
|
||||
rotate: { enabled: isEnabled('#aug-rotate-enabled'), p: getVal('#aug-rotate-p'), limit: getVal('#aug-rotate-limit', 'int') },
|
||||
ssr: { enabled: isEnabled('#aug-ssr-enabled'), p: getVal('#aug-ssr-p'), rotate: getVal('#aug-ssr-rotate', 'int'), shift: getVal('#aug-ssr-shift'), scale: getVal('#aug-ssr-scale') },
|
||||
affine: { enabled: isEnabled('#aug-affine-enabled'), p: getVal('#aug-affine-p'), shear: getVal('#aug-affine-shear', 'int') },
|
||||
crop: { enabled: isEnabled('#aug-crop-enabled'), p: getVal('#aug-crop-p') },
|
||||
grayscale: { enabled: isEnabled('#aug-grayscale-enabled'), p: getVal('#aug-grayscale-p') },
|
||||
hsv: { enabled: isEnabled('#aug-hsv-enabled'), p: getVal('#aug-hsv-p'), h: getVal('#aug-hsv-h', 'int'), s: getVal('#aug-hsv-s', 'int'), v: getVal('#aug-hsv-v', 'int') },
|
||||
bc: { enabled: isEnabled('#aug-bc-enabled'), p: getVal('#aug-bc-p'), b: getVal('#aug-bc-b'), c: getVal('#aug-bc-c') },
|
||||
blur: { enabled: isEnabled('#aug-blur-enabled'), p: getVal('#aug-blur-p'), limit: getVal('#aug-blur-limit', 'int') },
|
||||
noise: { enabled: isEnabled('#aug-noise-enabled'), p: getVal('#aug-noise-p'), limit: getVal('#aug-noise-limit') },
|
||||
cutout: { enabled: isEnabled('#aug-cutout-enabled'), p: getVal('#aug-cutout-p'), holes: getVal('#aug-cutout-holes', 'int'), size: getVal('#aug-cutout-size', 'int') },
|
||||
mosaic: { enabled: isEnabled('#aug-mosaic-enabled'), p: getVal('#aug-mosaic-p') }
|
||||
};
|
||||
}
|
||||
|
||||
$('#create-dataset-form').on('submit', function(e) {
|
||||
e.preventDefault();
|
||||
const augmentation_options = getAugmentationOptions('#createDatasetModal');
|
||||
const datasetData = {
|
||||
description: $('#dataset-description').val(),
|
||||
video_uuids: $('#dataset-videos').val(),
|
||||
eval_percent: $('#eval-percent').val(),
|
||||
test_percent: $('#test-percent').val(),
|
||||
augmentation_options: augmentation_options
|
||||
};
|
||||
$.ajax({
|
||||
url: '/createDataset', type: 'POST', contentType: 'application/json',
|
||||
data: JSON.stringify(datasetData),
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
showSuccess('Dataset creation started!');
|
||||
$('#createDatasetModal').modal('hide');
|
||||
refreshDatasets();
|
||||
} else { showError(response.message); }
|
||||
},
|
||||
error: function(xhr) { showError(xhr.responseJSON ? xhr.responseJSON.message : 'Unknown error.'); }
|
||||
});
|
||||
});
|
||||
|
||||
$('#upload-video-form').on('submit', function(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(this);
|
||||
$.ajax({
|
||||
url: '/uploadVideo', type: 'POST', data: formData, processData: false, contentType: false,
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
showSuccess('Upload successful! Processing started.');
|
||||
$('#uploadVideoModal').modal('hide');
|
||||
$(this).trigger('reset');
|
||||
$('.custom-file-label').html('Choose file...');
|
||||
refreshVideos();
|
||||
} else { showError(response.message); }
|
||||
}.bind(this),
|
||||
error: function(xhr) { showError(xhr.responseJSON ? xhr.responseJSON.message : 'Unknown error.'); }
|
||||
});
|
||||
});
|
||||
|
||||
$('#import-model-form').on('submit', function(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(this);
|
||||
$.ajax({
|
||||
url: '/importModel', type: 'POST', data: formData, processData: false, contentType: false,
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
showSuccess('Model imported!');
|
||||
$('#importModelModal').modal('hide');
|
||||
$(this).trigger('reset');
|
||||
$('.custom-file-label').html('Choose file...');
|
||||
refreshModels();
|
||||
} else { showError(response.message); }
|
||||
}.bind(this),
|
||||
error: function(xhr) { showError(xhr.responseJSON ? xhr.responseJSON.message : 'Unknown error.'); }
|
||||
});
|
||||
});
|
||||
|
||||
$('#settings-form').on('submit', function(e) {
|
||||
e.preventDefault();
|
||||
const selectedSamOption = $('#setting-sam-model').find('option:selected');
|
||||
const classColors = {};
|
||||
$('#class-color-manager input[type="color"]').each(function() {
|
||||
classColors[$(this).data('class-name')] = $(this).val();
|
||||
});
|
||||
|
||||
const settingsData = {
|
||||
gpu_device: $('#setting-gpu-device').val(),
|
||||
sam_model_checkpoint: selectedSamOption.val(),
|
||||
sam_model_name: selectedSamOption.data('name'),
|
||||
feature_extractor_model_name: $('#setting-feature-extractor-model').val(),
|
||||
sam_mask_confidence: parseFloat($('#setting-sam-mask-confidence').val()),
|
||||
nms_iou_threshold: parseFloat($('#setting-nms-iou-threshold').val()),
|
||||
prototype_temperature: parseFloat($('#setting-prototype-temperature').val()),
|
||||
prototype_sample_limit: parseInt($('#setting-prototype-sample-limit').val()),
|
||||
batch_tracking_imgsz: parseInt($('#setting-batch-tracking-imgsz').val()),
|
||||
batch_tracking_conf: parseFloat($('#setting-batch-tracking-conf') ? $('#setting-batch-tracking-conf').val() : 0.3),
|
||||
batch_tracking_chunk_size: parseInt($('#setting-batch-tracking-chunk-size') ? $('#setting-batch-tracking-chunk-size').val() : 10),
|
||||
default_annotation_mode: $('#setting-default-annotation-mode').val(),
|
||||
autosave_enabled: $('#setting-autosave-enabled').is(':checked'),
|
||||
default_preannotation_conf: parseFloat($('#setting-preannotation-conf').val() || 0.5),
|
||||
frame_extraction_jpeg_quality: parseInt($('#setting-jpeg-quality').val()),
|
||||
default_opencv_tracker: $('#setting-opencv-tracker').val(),
|
||||
cache_save_interval_seconds: parseInt($('#setting-cache-save-interval').val()),
|
||||
class_colors: classColors
|
||||
};
|
||||
|
||||
$.ajax({
|
||||
url: '/api/settings', type: 'POST', contentType: 'application/json',
|
||||
data: JSON.stringify(settingsData),
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
alert(response.restart_required ? "Settings saved! AI model/device changes will apply to new tasks." : "Settings saved successfully!");
|
||||
} else { showError(response.message); }
|
||||
},
|
||||
error: function(xhr) { showError(xhr.responseJSON ? xhr.responseJSON.message : 'Unknown error.'); }
|
||||
});
|
||||
});
|
||||
|
||||
['sam-mask-confidence', 'nms-iou-threshold', 'prototype-temperature', 'batch-tracking-conf', 'preannotation-conf'].forEach(id => {
|
||||
$(`#setting-${id}`).on('input', function() {
|
||||
$(this).closest('.form-group').find('.text-primary').text(parseFloat($(this).val()).toFixed(2));
|
||||
});
|
||||
});
|
||||
|
||||
$('#clear-cache-btn').on('click', function() {
|
||||
if (confirm("Clear 'Smart Select' temporary cache?")) {
|
||||
const $btn = $(this);
|
||||
$btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm"></span> Clearing...');
|
||||
$.ajax({
|
||||
url: '/api/clear_cache', type: 'POST',
|
||||
success: function(res) { alert(res.message); },
|
||||
complete: function() { $btn.prop('disabled', false).html('<i class="bi bi-eraser-fill"></i> Clear Feature Cache'); }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on("click", ".btn-manage-tasks", function() {
|
||||
const videoUuid = $(this).data("uuid");
|
||||
const videoDesc = $(this).data("description");
|
||||
const frameCount = $(this).data("framecount");
|
||||
|
||||
$('#manageTasksModalTitle').text('Tasks: ' + videoDesc);
|
||||
$('#task-video-uuid').val(videoUuid);
|
||||
$('#task-start-frame').attr('max', frameCount - 1);
|
||||
$('#task-end-frame').attr('max', frameCount - 1);
|
||||
|
||||
refreshTasksInModal(videoUuid);
|
||||
$('#manageTasksModal').modal('show');
|
||||
});
|
||||
|
||||
function refreshTasksInModal(videoUuid) {
|
||||
const taskListBody = $('#task-list-in-modal');
|
||||
taskListBody.html('<tr><td colspan="4" class="text-center text-muted"><span class="spinner-border spinner-border-sm"></span> Loading...</td></tr>');
|
||||
|
||||
$.get(`/listTasks?video_uuid=${videoUuid}`, function(data) {
|
||||
taskListBody.empty();
|
||||
if (data.success && data.tasks.length > 0) {
|
||||
data.tasks.forEach(task => {
|
||||
const row = `
|
||||
<tr>
|
||||
<td class="align-middle font-weight-bold">${task.assigned_to}</td>
|
||||
<td class="align-middle">${task.start_frame} - ${task.end_frame}</td>
|
||||
<td class="align-middle text-muted small">${task.description || '-'}</td>
|
||||
<td class="align-middle text-right">
|
||||
<a href="/labelVideo?task_uuid=${task.task_uuid}" class="btn btn-sm btn-primary" title="Label"><i class="bi bi-pencil-square"></i></a>
|
||||
<button class="btn btn-sm btn-outline-danger btn-delete-task" data-task-uuid="${task.task_uuid}" data-video-uuid="${videoUuid}"><i class="bi bi-trash"></i></button>
|
||||
</td>
|
||||
</tr>`;
|
||||
taskListBody.append(row);
|
||||
});
|
||||
} else {
|
||||
taskListBody.html('<tr><td colspan="4" class="text-center text-muted p-3">No tasks created yet.</td></tr>');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$('#create-task-form').on('submit', function(e) {
|
||||
e.preventDefault();
|
||||
const videoUuid = $('#task-video-uuid').val();
|
||||
const taskData = {
|
||||
video_uuid: videoUuid,
|
||||
assigned_to: $('#task-assigned-to').val(),
|
||||
description: $('#task-description').val(),
|
||||
start_frame: $('#task-start-frame').val(),
|
||||
end_frame: $('#task-end-frame').val(),
|
||||
};
|
||||
$.ajax({
|
||||
url: '/createTask', type: 'POST', contentType: 'application/json', data: JSON.stringify(taskData),
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
$('#create-task-form')[0].reset();
|
||||
refreshTasksInModal(videoUuid);
|
||||
} else { showError(response.message); }
|
||||
},
|
||||
error: function() { showError('Error creating task.'); }
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on("click", ".btn-pre-annotate", function() {
|
||||
const videoUuid = $(this).data('uuid');
|
||||
const frameCount = parseInt($(this).data('frame-count'));
|
||||
const labeledCount = parseInt($(this).data('labeled-count'));
|
||||
|
||||
$('#pre-annotate-video-uuid').val(videoUuid).data('frame-count', frameCount).data('labeled-count', labeledCount);
|
||||
const modelSelect = $('#pre-annotate-model-select');
|
||||
modelSelect.html('<option>Loading models...</option>');
|
||||
|
||||
$('#pre-annotate-start-frame').val(0).attr('max', frameCount - 1);
|
||||
$('#pre-annotate-end-frame').val(frameCount - 1).attr('max', frameCount - 1);
|
||||
|
||||
$.get('/listModels', function(data) {
|
||||
modelSelect.empty();
|
||||
if (data.models && data.models.length > 0) {
|
||||
data.models.forEach(model => {
|
||||
modelSelect.append(`<option value="${model.model_uuid}">${model.description} (${model.model_type})</option>`);
|
||||
});
|
||||
} else {
|
||||
modelSelect.append('<option disabled>No models available. Import one first.</option>');
|
||||
}
|
||||
});
|
||||
$('#preAnnotateModal').modal('show');
|
||||
});
|
||||
|
||||
$('#pre-annotate-confidence').on('input', function() { $('#confidence-value').text($(this).val()); });
|
||||
|
||||
$('#pre-annotate-form').on('submit', function(e) {
|
||||
e.preventDefault();
|
||||
const videoUuid = $('#pre-annotate-video-uuid').val();
|
||||
const modelUuid = $('#pre-annotate-model-select').val();
|
||||
const labeledCount = parseInt($('#pre-annotate-video-uuid').data('labeled-count'));
|
||||
|
||||
if (!modelUuid) return showError("Please select a model.");
|
||||
|
||||
let msg = "Start auto-labeling?";
|
||||
if (labeledCount > 0 && $('input[name="merge_strategy"]:checked').val() === 'overwrite') {
|
||||
msg = `WARNING: This video has ${labeledCount} labels. This will OVERWRITE them in the selected range. Continue?`;
|
||||
}
|
||||
if (!confirm(msg)) return;
|
||||
|
||||
const options = {
|
||||
start_frame: $('#pre-annotate-start-frame').val(),
|
||||
end_frame: $('#pre-annotate-end-frame').val(),
|
||||
confidence: $('#pre-annotate-confidence').val(),
|
||||
merge_strategy: $('input[name="merge_strategy"]:checked').val()
|
||||
};
|
||||
|
||||
$.ajax({
|
||||
url: '/startPreAnnotation', type: 'POST', contentType: 'application/json',
|
||||
data: JSON.stringify({ video_uuid: videoUuid, model_uuid: modelUuid, options: options }),
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
showSuccess('Auto-labeling started.');
|
||||
$('#preAnnotateModal').modal('hide');
|
||||
refreshVideos();
|
||||
} else { showError(response.message); }
|
||||
},
|
||||
error: function(xhr) { showError(xhr.responseJSON ? xhr.responseJSON.message : 'Unknown error.'); }
|
||||
});
|
||||
});
|
||||
|
||||
let videoUuidForImport = null;
|
||||
$(document).on("click", ".btn-import-frames", function() {
|
||||
videoUuidForImport = $(this).data('uuid');
|
||||
$('#frame-import-input').click();
|
||||
});
|
||||
|
||||
$('#frame-import-input').on('change', function(e) {
|
||||
if (!videoUuidForImport || e.target.files.length === 0) return;
|
||||
const formData = new FormData(this);
|
||||
formData.append('video_uuid', videoUuidForImport);
|
||||
for (let i = 0; i < e.target.files.length; i++) formData.append('frame_files', e.target.files[i]);
|
||||
|
||||
alert(`Importing ${e.target.files.length} frames...`);
|
||||
$.ajax({
|
||||
url: '/importFrames', type: 'POST', data: formData, processData: false, contentType: false,
|
||||
success: function(res) {
|
||||
if(res.success) { showSuccess(`Imported ${res.imported_count} frames.`); refreshVideos(); }
|
||||
else showError(res.message);
|
||||
},
|
||||
error: function() { showError('Import failed.'); },
|
||||
complete: function() { videoUuidForImport = null; $('#frame-import-input').val(''); }
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on("click", ".btn-delete-video", function() {
|
||||
if (confirm('Delete this video and ALL labels?')) {
|
||||
$.ajax({ url: '/deleteVideo', type: 'POST', contentType: 'application/json', data: JSON.stringify({ video_uuid: $(this).data('uuid') }), success: refreshVideos });
|
||||
}
|
||||
});
|
||||
$(document).on("click", ".btn-delete-dataset", function() {
|
||||
if (confirm('Delete this dataset?')) {
|
||||
$.ajax({ url: '/deleteDataset', type: 'POST', contentType: 'application/json', data: JSON.stringify({ dataset_uuid: $(this).data('uuid') }), success: refreshDatasets });
|
||||
}
|
||||
});
|
||||
$(document).on("click", ".btn-delete-model", function() {
|
||||
if (confirm('Delete this model?')) {
|
||||
$.ajax({ url: '/deleteModel', type: 'POST', contentType: 'application/json', data: JSON.stringify({ model_uuid: $(this).data('uuid') }), success: refreshModels });
|
||||
}
|
||||
});
|
||||
$(document).on("click", ".btn-delete-task", function() {
|
||||
const videoUuid = $(this).data('video-uuid');
|
||||
if (confirm('Delete this task?')) {
|
||||
$.ajax({ url: '/deleteTask', type: 'POST', contentType: 'application/json', data: JSON.stringify({ task_uuid: $(this).data('task-uuid') }), success: function() { refreshTasksInModal(videoUuid); } });
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on("click", ".btn-regenerate-dataset", function() {
|
||||
if (confirm('Update dataset? Old files will be replaced.')) {
|
||||
$.ajax({ url: '/regenerateDataset', type: 'POST', contentType: 'application/json', data: JSON.stringify({ dataset_uuid: $(this).data('uuid') }), success: function(res) { if(res.success){ alert('Update started.'); refreshDatasets(); } else alert(res.message); } });
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on("click", ".btn-cancel-task", function() {
|
||||
if (confirm('Cancel running task?')) {
|
||||
$.ajax({ url: '/cancelTask', type: 'POST', contentType: 'application/json', data: JSON.stringify({ video_uuid: $(this).data('uuid') }), success: function(res) { if(res.success){ alert('Cancellation requested.'); refreshVideos(); } else alert(res.message); } });
|
||||
}
|
||||
});
|
||||
|
||||
loadSettings();
|
||||
function runAllRefreshes() { refreshVideos(); refreshDatasets(); refreshModels(); }
|
||||
runAllRefreshes();
|
||||
setInterval(runAllRefreshes, 5000);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,291 @@
|
||||
import logging
|
||||
import os
|
||||
import torch
|
||||
import numpy as np
|
||||
import cv2
|
||||
import uuid
|
||||
|
||||
try:
|
||||
from ultralytics import SAM
|
||||
from ultralytics.models.sam import SAM2VideoPredictor
|
||||
from ultralytics.engine.results import Results
|
||||
except ImportError:
|
||||
logging.critical("FATAL: ultralytics library is not installed. Please run 'pip install ultralytics'.")
|
||||
SAM = None
|
||||
SAM2VideoPredictor = None
|
||||
Results = None
|
||||
|
||||
import config
|
||||
import database
|
||||
import file_storage
|
||||
from bbox_writer import convert_text_to_rects_and_labels
|
||||
import settings_manager
|
||||
|
||||
_sam_model_cache = {"model": None, "path": None}
|
||||
|
||||
|
||||
def get_sam_model():
|
||||
global _sam_model_cache
|
||||
DEVICE = settings_manager.get_device()
|
||||
|
||||
settings = settings_manager.load_settings()
|
||||
checkpoint_filename = settings.get('sam_model_checkpoint', 'sam2.1_t.pt')
|
||||
sam_checkpoint_path = os.path.join(config.BASE_DIR, "checkpoints", checkpoint_filename)
|
||||
|
||||
if _sam_model_cache["path"] != sam_checkpoint_path or \
|
||||
(_sam_model_cache["model"] is not None and str(_sam_model_cache["model"].device) != str(DEVICE)):
|
||||
logging.info(f"Model/device change detected. Reloading SAM model to {DEVICE}. New model: {checkpoint_filename}")
|
||||
_sam_model_cache["model"] = None
|
||||
_sam_model_cache["path"] = None
|
||||
|
||||
if _sam_model_cache["model"] is not None:
|
||||
return _sam_model_cache["model"]
|
||||
|
||||
if SAM is None:
|
||||
logging.error("Ultralytics SAM class is not available due to import error.")
|
||||
return None
|
||||
|
||||
if not os.path.exists(sam_checkpoint_path):
|
||||
logging.error(f"Ultralytics SAM checkpoint not found at {sam_checkpoint_path}. All SAM features are disabled.")
|
||||
return None
|
||||
|
||||
try:
|
||||
logging.info(f"Loading Ultralytics SAM model ('{checkpoint_filename}') to device '{DEVICE}'...")
|
||||
model = SAM(sam_checkpoint_path)
|
||||
model.to(DEVICE)
|
||||
_sam_model_cache["model"] = model
|
||||
_sam_model_cache["path"] = sam_checkpoint_path
|
||||
logging.info("Ultralytics SAM model loaded successfully.")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to load Ultralytics SAM model: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
return _sam_model_cache["model"]
|
||||
|
||||
|
||||
def predict_box_from_point_ultralytics(image_path, point_coords):
|
||||
model = get_sam_model()
|
||||
if model is None:
|
||||
raise RuntimeError("Ultralytics SAM model is not available.")
|
||||
results = model(image_path, points=[point_coords], labels=[1])
|
||||
if results and results[0].boxes and results[0].boxes.xyxy.numel() > 0:
|
||||
box = results[0].boxes.xyxy[0].cpu().numpy()
|
||||
x1, y1, x2, y2 = map(int, box)
|
||||
return {'x1': x1, 'y1': y1, 'x2': x2, 'y2': y2}
|
||||
return None
|
||||
|
||||
|
||||
def _get_bbox_from_mask(mask_data, original_width, original_height):
|
||||
if mask_data is None:
|
||||
return None
|
||||
if isinstance(mask_data, torch.Tensor):
|
||||
mask_data = mask_data.cpu().numpy()
|
||||
mask = (mask_data * 255).astype(np.uint8)
|
||||
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
if contours:
|
||||
largest_contour = max(contours, key=cv2.contourArea)
|
||||
x, y, w, h = cv2.boundingRect(largest_contour)
|
||||
x1 = max(0, x)
|
||||
y1 = max(0, y)
|
||||
x2 = min(original_width, x + w)
|
||||
y2 = min(original_height, y + h)
|
||||
if x2 > x1 and y2 > y1:
|
||||
return [x1, y1, x2, y2]
|
||||
return None
|
||||
|
||||
|
||||
def track_video_ultralytics(video_uuid, start_frame, end_frame, init_bboxes_text, session):
|
||||
model = get_sam_model()
|
||||
if model is None:
|
||||
raise RuntimeError("Ultralytics SAM model is not available for tracking.")
|
||||
video_info = database.get_video_entity(video_uuid)
|
||||
original_width = video_info['width']
|
||||
original_height = video_info['height']
|
||||
init_rects, init_labels, init_ids = convert_text_to_rects_and_labels(init_bboxes_text)
|
||||
if not init_rects:
|
||||
raise ValueError("No initial bounding boxes provided for tracking.")
|
||||
|
||||
tracked_objects = {
|
||||
(init_ids[i] or f"obj_{i}"): {"label": init_labels[i], "bbox": init_rects[i]}
|
||||
for i in range(len(init_rects))
|
||||
}
|
||||
|
||||
session['results'][start_frame] = init_bboxes_text
|
||||
session['progress'] = 1
|
||||
|
||||
for current_frame_num in range(start_frame + 1, end_frame + 1):
|
||||
if session.get('stop_requested', False):
|
||||
logging.info(f"Tracking for {video_uuid} stopped by user request.")
|
||||
session['status'] = 'STOPPED'
|
||||
break
|
||||
|
||||
frame_path = file_storage.get_frame_path(video_uuid, current_frame_num)
|
||||
if not os.path.exists(frame_path):
|
||||
logging.warning(f"Frame {current_frame_num} not found, skipping.")
|
||||
continue
|
||||
|
||||
prompts_bboxes_list = [obj_data['bbox'] for obj_id, obj_data in tracked_objects.items()]
|
||||
original_ids = list(tracked_objects.keys())
|
||||
|
||||
if not prompts_bboxes_list:
|
||||
logging.warning(f"Lost all objects at frame {current_frame_num}. Stopping tracking.")
|
||||
break
|
||||
|
||||
prompts_bboxes_np = np.array(prompts_bboxes_list)
|
||||
|
||||
results = model(frame_path, bboxes=prompts_bboxes_np)
|
||||
|
||||
if isinstance(results, Results):
|
||||
results = [results]
|
||||
|
||||
new_tracked_objects = {}
|
||||
current_frame_bboxes_text_lines = []
|
||||
|
||||
if results and results[0].masks:
|
||||
new_masks = results[0].masks.data
|
||||
|
||||
if len(new_masks) != len(prompts_bboxes_list):
|
||||
logging.warning(
|
||||
f"Frame {current_frame_num}: Mismatch between prompted boxes ({len(prompts_bboxes_list)}) and returned masks ({len(new_masks)}). Using previous frame's boxes.")
|
||||
for obj_id, obj_data in tracked_objects.items():
|
||||
x1, y1, x2, y2 = obj_data['bbox']
|
||||
current_frame_bboxes_text_lines.append(f"{x1},{y1},{x2},{y2},{obj_data['label']},{obj_id}")
|
||||
new_tracked_objects = tracked_objects
|
||||
else:
|
||||
for i, new_mask in enumerate(new_masks):
|
||||
new_bbox = _get_bbox_from_mask(new_mask, original_width, original_height)
|
||||
if new_bbox:
|
||||
original_id = original_ids[i]
|
||||
label = tracked_objects[original_id]['label']
|
||||
x1, y1, x2, y2 = new_bbox
|
||||
current_frame_bboxes_text_lines.append(f"{x1},{y1},{x2},{y2},{label},{original_id}")
|
||||
new_tracked_objects[original_id] = {"label": label, "bbox": new_bbox}
|
||||
|
||||
tracked_objects = new_tracked_objects
|
||||
session['results'][current_frame_num] = "\n".join(current_frame_bboxes_text_lines)
|
||||
session['progress'] = (current_frame_num - start_frame) + 1
|
||||
|
||||
if 'status' not in session or session['status'] == 'PROCESSING':
|
||||
session['status'] = 'COMPLETED'
|
||||
|
||||
|
||||
def run_batch_tracking_with_predictor(video_uuid, start_frame, end_frame, init_bboxes_text, session):
|
||||
if SAM2VideoPredictor is None:
|
||||
raise ImportError("SAM2VideoPredictor could not be imported. Please check your ultralytics installation.")
|
||||
if get_sam_model() is None:
|
||||
raise RuntimeError("SAM model is not available for batch tracking.")
|
||||
|
||||
settings = settings_manager.load_settings()
|
||||
model_checkpoint_filename = settings.get('sam_model_checkpoint', 'sam2.1_t.pt')
|
||||
model_absolute_path = os.path.join(config.BASE_DIR, "checkpoints", model_checkpoint_filename)
|
||||
if not os.path.exists(model_absolute_path):
|
||||
raise FileNotFoundError(f"Batch tracking model not found at path: {model_absolute_path}")
|
||||
|
||||
video_info = database.get_video_entity(video_uuid)
|
||||
width, height, fps = video_info['width'], video_info['height'], video_info['fps']
|
||||
if not fps or fps <= 0:
|
||||
fps = 30
|
||||
logging.warning(f"Video {video_uuid} has invalid FPS, falling back to {fps}.")
|
||||
|
||||
init_rects, init_labels, _ = convert_text_to_rects_and_labels(init_bboxes_text)
|
||||
if not init_rects:
|
||||
raise ValueError("No initial bounding boxes provided for tracking.")
|
||||
|
||||
all_frame_results = {start_frame: init_bboxes_text}
|
||||
last_known_rects = init_rects
|
||||
|
||||
total_frames_to_process = end_frame - start_frame
|
||||
|
||||
imgsz = settings.get('batch_tracking_imgsz', 1024)
|
||||
conf = settings.get('batch_tracking_conf', 0.30)
|
||||
chunk_size = settings.get('batch_tracking_chunk_size', 10)
|
||||
device = str(settings_manager.get_device())
|
||||
|
||||
for i in range(0, total_frames_to_process, chunk_size):
|
||||
chunk_start_frame = start_frame + i
|
||||
chunk_end_frame = min(start_frame + i + chunk_size - 1, end_frame)
|
||||
|
||||
if chunk_start_frame > end_frame:
|
||||
break
|
||||
|
||||
logging.info(f"Processing chunk: frames {chunk_start_frame} to {chunk_end_frame}")
|
||||
|
||||
temp_video_filename = f"temp_chunk_{uuid.uuid4().hex}.mp4"
|
||||
temp_video_path = os.path.join(config.STORAGE_DIR, 'videos', temp_video_filename)
|
||||
os.makedirs(os.path.dirname(temp_video_path), exist_ok=True)
|
||||
|
||||
video_writer = cv2.VideoWriter(temp_video_path, cv2.VideoWriter_fourcc(*'mp4v'), fps, (width, height))
|
||||
if not video_writer.isOpened():
|
||||
raise IOError(f"Failed to create temporary video writer for chunk {chunk_start_frame}-{chunk_end_frame}.")
|
||||
|
||||
predictor = None
|
||||
try:
|
||||
for frame_num in range(chunk_start_frame, chunk_end_frame + 1):
|
||||
frame_path = file_storage.get_frame_path(video_uuid, frame_num)
|
||||
if os.path.exists(frame_path):
|
||||
img = cv2.imread(frame_path)
|
||||
if img.shape[1] != width or img.shape[0] != height:
|
||||
img = cv2.resize(img, (width, height))
|
||||
video_writer.write(img)
|
||||
video_writer.release()
|
||||
last_known_rects_np = np.array(last_known_rects)
|
||||
current_prompts = [[int((r[0] + r[2]) / 2), int((r[1] + r[3]) / 2)] for r in last_known_rects_np]
|
||||
labels_prompt = [1] * len(current_prompts)
|
||||
|
||||
overrides = dict(
|
||||
conf=conf,
|
||||
task="segment",
|
||||
mode="predict",
|
||||
imgsz=imgsz,
|
||||
model=model_absolute_path,
|
||||
device=device
|
||||
)
|
||||
predictor = SAM2VideoPredictor(overrides=overrides)
|
||||
|
||||
results_generator = predictor(source=temp_video_path, points=current_prompts, labels=labels_prompt,
|
||||
stream=True)
|
||||
|
||||
latest_rects_in_chunk = None
|
||||
|
||||
for frame_idx, results in enumerate(results_generator):
|
||||
actual_frame_num = chunk_start_frame + frame_idx
|
||||
session['progress'] = (actual_frame_num - start_frame)
|
||||
session['message'] = f'Processing frame {actual_frame_num}'
|
||||
|
||||
if not results.masks:
|
||||
all_frame_results[actual_frame_num] = ""
|
||||
latest_rects_in_chunk = []
|
||||
continue
|
||||
|
||||
masks = results.masks.data
|
||||
bboxes_text_lines = []
|
||||
current_frame_rects = []
|
||||
|
||||
for obj_idx in range(len(init_labels)):
|
||||
if obj_idx < len(masks):
|
||||
mask_data = masks[obj_idx]
|
||||
bbox = _get_bbox_from_mask(mask_data, width, height)
|
||||
if bbox:
|
||||
x1, y1, x2, y2 = bbox
|
||||
bboxes_text_lines.append(f"{x1},{y1},{x2},{y2},{init_labels[obj_idx]}")
|
||||
current_frame_rects.append(bbox)
|
||||
|
||||
all_frame_results[actual_frame_num] = "\n".join(bboxes_text_lines)
|
||||
latest_rects_in_chunk = current_frame_rects
|
||||
|
||||
if latest_rects_in_chunk and len(latest_rects_in_chunk) > 0:
|
||||
last_known_rects = latest_rects_in_chunk
|
||||
else:
|
||||
logging.warning("Lost all objects in chunk. Stopping batch processing.")
|
||||
break
|
||||
|
||||
finally:
|
||||
if predictor is not None:
|
||||
del predictor
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
if os.path.exists(temp_video_path):
|
||||
os.remove(temp_video_path)
|
||||
logging.info(f"Finished chunk, cleared predictor, cache, and temp file.")
|
||||
|
||||
return all_frame_results
|
||||
Reference in New Issue
Block a user