yolo数据集标记

This commit is contained in:
2025-12-25 17:41:18 +08:00
parent 1a135cdda7
commit a0555ac6b5
22 changed files with 10194 additions and 1 deletions
Generated
-1
View File
@@ -2,6 +2,5 @@
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+185
View File
@@ -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.
[![Python Version](https://img.shields.io/badge/python-3.10-blue.svg)](https://www.python.org/downloads/release/python-3100/)
[![License](https://img.shields.io/badge/license-MIT-green.svg)](https://opensource.org/licenses/MIT)
[![Status](https://img.shields.io/badge/status-Iterating-brightgreen.svg)]()
---
## ✨ 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
+192
View File
@@ -0,0 +1,192 @@
# Zero-to-YOLO-Yard: 本地化、AI驱动的下一代计算机视觉标注工具
---
![0949644658292c488a2d4a301543d86c](https://github.com/user-attachments/assets/2df18d52-f146-4cc5-b7f2-40185668d0f0)
---
**Zero-to-YOLO-Yard** 是一个专为本地化部署而深度优化的开源工具,旨在为您提供从原始视频/图像到可训练数据集的全流程解决方案。它集成了最前沿的 AI 技术,将繁琐的标注工作转变为简单、高效、甚至充满探索乐趣的体验。无论您是机器人开发者、无人机爱好者还是计算机视觉研究者,此工具都能极大地加速您的数据处理流程,且所有数据都安全地保留在您自己的计算机上。
[![Python Version](https://img.shields.io/badge/python-3.10-blue.svg)](https://www.python.org/downloads/release/python-3100/)
[![License](https://img.shields.io/badge/license-MIT-green.svg)](https://opensource.org/licenses/MIT)
[![Status](https://img.shields.io/badge/status-迭代中-brightgreen.svg)]()
---
## ✨ 核心亮点:不止于标注
- **🚀 新一代 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 与我们交流!
+625
View File
@@ -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
+692
View File
@@ -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))
+159
View File
@@ -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)
+39
View File
@@ -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')
+541
View File
@@ -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]
+284
View File
@@ -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.
+103
View File
@@ -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
+844
View File
@@ -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>
+20
View File
@@ -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">&times;</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
+75
View File
@@ -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>
+955
View File
@@ -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>&times;</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 %}
+291
View File
@@ -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