Files
deepdata/runtime/deepdata-python/app/module/annotation/interface/auto.py
T

1318 lines
50 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""FastAPI routes for Auto Annotation tasks.
These routes back the frontend AutoAnnotation module:
- GET /api/annotation/auto
- POST /api/annotation/auto
- DELETE /api/annotation/auto/{task_id}
- GET /api/annotation/auto/{task_id}/status (simple wrapper)
"""
from __future__ import annotations
from typing import List, Optional, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, Path
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db
from app.db.models.annotation_management import LabelingProject, AutoAnnotationTask
from app.module.shared.schema import StandardResponse
from app.module.dataset import DatasetManagementService
from app.core.logging import get_logger
from app.core.config import settings
from ..schema.auto import (
CreateAutoAnnotationTaskRequest,
AutoAnnotationTaskResponse,
AutoAnnotationConfig,
UpdateAutoAnnotationTaskFilesRequest,
ImportFromLabelStudioRequest,
)
from ..service.auto import AutoAnnotationTaskService
from ..service.mapping import DatasetMappingService
from ..service.prediction import PredictionSyncService
from ..service.sync import SyncService
from ..service.ls_annotation_sync import LSAnnotationSyncService
from ..client import LabelStudioClient
router = APIRouter(
prefix="/auto",
tags=["annotation/auto"],
)
logger = get_logger(__name__)
service = AutoAnnotationTaskService()
# COCO 80 类别映射(需与 runtime/ops/annotation/image_object_detection_bounding_box/process.py 保持一致)
COCO_CLASS_MAP: Dict[int, str] = {
0: "Person",
1: "Bicycle",
2: "Car",
3: "Motorcycle",
4: "Airplane",
5: "Bus",
6: "Train",
7: "Truck",
8: "Boat",
9: "Traffic Light",
10: "Fire Hydrant",
11: "Stop Sign",
12: "Parking Meter",
13: "Bench",
14: "Bird",
15: "Cat",
16: "Dog",
17: "Horse",
18: "Sheep",
19: "Cow",
20: "Elephant",
21: "Bear",
22: "Zebra",
23: "Giraffe",
24: "Backpack",
25: "Umbrella",
26: "Handbag",
27: "Tie",
28: "Suitcase",
29: "Frisbee",
30: "Skis",
31: "Snowboard",
32: "Sports Ball",
33: "Kite",
34: "Baseball Bat",
35: "Baseball Glove",
36: "Skateboard",
37: "Surfboard",
38: "Tennis Racket",
39: "Bottle",
40: "Wine Glass",
41: "Cup",
42: "Fork",
43: "Knife",
44: "Spoon",
45: "Bowl",
46: "Banana",
47: "Apple",
48: "Sandwich",
49: "Orange",
50: "Broccoli",
51: "Carrot",
52: "Hot Dog",
53: "Pizza",
54: "Donut",
55: "Cake",
56: "Chair",
57: "Couch",
58: "Potted Plant",
59: "Bed",
60: "Dining Table",
61: "Toilet",
62: "TV",
63: "Laptop",
64: "Mouse",
65: "Remote",
66: "Keyboard",
67: "Cell Phone",
68: "Microwave",
69: "Oven",
70: "Toaster",
71: "Sink",
72: "Refrigerator",
73: "Book",
74: "Clock",
75: "Vase",
76: "Scissors",
77: "Teddy Bear",
78: "Hair Drier",
79: "Toothbrush",
}
def _build_label_studio_config_from_auto_config(config: AutoAnnotationConfig) -> str:
"""根据自动标注配置生成 Label Studio XML 配置。
- 使用 Image + RectangleLabels 模板;
- label 列表来自 targetClasses 对应的 COCO 类别;
targetClasses 为空时表示全部 80 类。
"""
# 目标类别 ID 列表;为空则使用全部 COCO 类别
if config.target_classes:
class_ids = sorted({int(cid) for cid in config.target_classes})
else:
class_ids = sorted(COCO_CLASS_MAP.keys())
labels = []
for cid in class_ids:
name = COCO_CLASS_MAP.get(int(cid), f"class_{cid}")
labels.append(name)
lines = [
"<View>",
' <Image name="image" value="$image"/>',
' <RectangleLabels name="label" toName="image">',
]
for name in labels:
# Label value 需要与 YOLO 导出的 annotations["detections"][*]["label"] 一致
lines.append(f' <Label value="{name}"/>')
lines.extend([
" </RectangleLabels>",
"</View>",
])
return "\n".join(lines)
async def _ensure_ls_mapping_for_auto_task(
db: AsyncSession,
*,
dataset_id: str,
dataset_name: Optional[str],
config: AutoAnnotationConfig,
task_name: str,
file_ids: Optional[List[str]] = None,
auto_task_id: Optional[str] = None,
delete_orphans: bool = False,
) -> Optional[str]:
"""确保给定数据集存在一个 Label Studio 项目映射,并按需同步子集文件。
说明:
- 仍然保持「1 个数据集 -> 多个 LS 项目」的模型不变;
- 如果已有映射,则复用最新项目,仅根据 file_ids 进行增量同步;
- 如果没有映射,则创建新项目 + 本地存储 + 映射,再按照 file_ids/全量进行一次同步。
返回 Label Studio project_id,失败时返回 None。
"""
mapping_service = DatasetMappingService(db)
dm_client = DatasetManagementService(db)
ls_client = LabelStudioClient(
base_url=settings.label_studio_base_url,
token=settings.label_studio_user_token,
)
sync_service = SyncService(dm_client, ls_client, mapping_service)
# 获取主数据集信息(用于项目描述等)。
# 如果获取失败,不再中断流程,只在描述中降级使用 dataset_id/dataset_name。
dataset = None
try:
dataset = await dm_client.get_dataset(dataset_id)
except Exception as e: # pragma: no cover - 容错
logger.warning(
"Failed to fetch dataset info from DM when creating LS project %s: %s",
dataset_id,
e,
)
existing = await mapping_service.get_mappings_by_dataset_id(dataset_id)
# 仅在同一数据集下找到“名称等于任务名”的映射时才复用,
# 确保每个自动标注任务对应独立的 LS 项目。
target_mapping = None
for m in existing:
if m.name == task_name:
target_mapping = m
break
if target_mapping is not None:
mapping = target_mapping
project_id = mapping.labeling_project_id
logger.info(
"Reuse existing Label Studio mapping for dataset %s and auto task '%s': project_id=%s",
dataset_id,
task_name,
project_id,
)
else:
logger.info(
"No existing Label Studio mapping for dataset %s and auto task '%s', creating one",
dataset_id,
task_name,
)
# Label Studio 项目标题与自动标注任务名称保持一致(DM 侧映射 name=task_name),
# 但 Label Studio 要求 title 至少 3 个字符,这里只对传给 LS 的 title 做安全填充,
# 映射表中的 name 仍然保存为原始 task_name,方便在 DM 中一一对应。
project_name = task_name
# 构造项目描述,尽量带上数据集名称,但获取失败时回退到 dataset_id
dataset_display_name = None
if dataset is not None:
dataset_display_name = getattr(dataset, "name", None) or str(dataset_id)
elif dataset_name:
dataset_display_name = dataset_name
else:
dataset_display_name = str(dataset_id)
project_description = (
f"Auto annotation project for dataset {dataset_display_name} ({dataset_id}), task '{task_name}'"
)
label_config = _build_label_studio_config_from_auto_config(config)
# 为满足 LS 对 title 长度的校验,确保传入的 title 至少 3 个字符
safe_title = project_name if project_name and len(project_name) >= 3 else (project_name or "")
if len(safe_title) < 3:
safe_title = safe_title.ljust(3, "_")
project_data = await ls_client.create_project(
title=safe_title,
description=project_description,
label_config=label_config,
)
if not project_data:
logger.error("Failed to create Label Studio project for dataset %s", dataset_id)
return None
project_id = project_data.get("id")
logger.info("Created Label Studio project %s for dataset %s", project_id, dataset_id)
# 配置主数据集的本地存储,复用与手动映射相同的路径规则
local_storage_path = f"{settings.label_studio_local_document_root}/{dataset_id}"
storage_result = await ls_client.create_local_storage(
project_id=int(project_id),
path=local_storage_path,
title="Dataset_BLOB",
use_blob_urls=True,
description=f"Local storage for dataset {dataset_display_name}",
)
if not storage_result:
logger.warning(
"Failed to configure local storage for auto task project %s (dataset %s)",
project_id,
dataset_id,
)
# 将项目与数据集写入映射表,并在 configuration 中记录 autoTaskId 关联关系
config_payload: Dict[str, Any] = {}
if auto_task_id:
config_payload["autoTaskId"] = auto_task_id
config_payload["autoTaskName"] = task_name
labeling_project = LabelingProject(
dataset_id=dataset_id,
labeling_project_id=str(project_id),
name=project_name,
configuration=config_payload or None,
)
mapping = await mapping_service.create_mapping(labeling_project)
# 无论是复用还是新建,都根据 file_ids 做一次文件同步:
# - 未指定 file_ids 时,同步主数据集的全部文件;
# - 指定 file_ids 时,允许跨多个数据集,最终都汇总到同一个 LS 项目下。
try:
from typing import Set as _Set
from sqlalchemy import select
from app.db.models.dataset_management import DatasetFiles
if not file_ids:
# 兼容老逻辑:未指定 file_ids 时,同步整个主数据集
await sync_service.sync_files(
mapping,
100,
delete_orphans=delete_orphans,
)
else:
# 按 file_ids 反查所属数据集:dataset_id -> set(file_ids)
stmt = (
select(DatasetFiles.dataset_id, DatasetFiles.id)
.where(DatasetFiles.id.in_(file_ids))
)
result = await db.execute(stmt)
rows = result.fetchall()
grouped: Dict[str, _Set[str]] = {}
resolved_ids: _Set[str] = set()
for ds_id, fid in rows:
if not ds_id or not fid:
continue
fid_str = str(fid)
grouped.setdefault(str(ds_id), set()).add(fid_str)
resolved_ids.add(fid_str)
# 将未能解析到数据集的文件,全部归入主数据集,避免丢失
unresolved_ids = {str(fid) for fid in file_ids} - resolved_ids
if unresolved_ids:
logger.warning(
"Some file_ids could not be resolved to dataset_id when syncing auto task files: %s",
",".join(sorted(unresolved_ids)),
)
grouped.setdefault(str(dataset_id), set()).update(unresolved_ids)
# 为所有涉及到的额外数据集提前配置本地存储,避免首次引用该数据集但尚未
# 在任意项目中注册 local storage 时,/data/local-files 返回 404。
try:
for extra_ds_id in grouped.keys():
# 主数据集已在上方配置过,这里只为额外数据集创建存储记录
if str(extra_ds_id) == str(dataset_id):
continue
extra_local_storage_path = f"{settings.label_studio_local_document_root}/{extra_ds_id}"
extra_storage_result = await ls_client.create_local_storage(
project_id=int(project_id),
path=extra_local_storage_path,
title=f"Dataset_BLOB_{extra_ds_id}",
use_blob_urls=True,
description=f"Local storage for dataset {extra_ds_id} (multi-dataset auto task)",
)
if not extra_storage_result:
logger.warning(
"Failed to configure extra local storage for auto task project %s (dataset %s)",
project_id,
extra_ds_id,
)
else:
logger.info(
"Extra local storage configured for auto task project %s: %s",
project_id,
extra_local_storage_path,
)
except Exception as e:
logger.warning(
"Error while configuring extra local storage for auto task project %s: %s",
project_id,
e,
)
if not grouped:
# 极端情况:完全无法解析,退回到仅按主数据集+给定 file_ids 同步
await sync_service.sync_files(
mapping,
100,
allowed_file_ids={str(fid) for fid in file_ids},
delete_orphans=delete_orphans,
)
else:
# 对每个涉及到的数据集,使用 override_dataset_id 将其文件同步到同一个项目
for ds_id, ds_file_ids in grouped.items():
await sync_service.sync_files(
mapping,
100,
allowed_file_ids=ds_file_ids,
override_dataset_id=ds_id,
delete_orphans=delete_orphans,
)
except Exception as e: # pragma: no cover - 同步失败不影响项目创建
logger.warning(
"Failed to sync dataset files for auto task LS project %s: %s",
project_id,
e,
)
return str(project_id)
@router.get("", response_model=StandardResponse[List[AutoAnnotationTaskResponse]])
async def list_auto_annotation_tasks(
db: AsyncSession = Depends(get_db),
):
"""获取自动标注任务列表。
前端当前不传分页参数,这里直接返回所有未删除任务。
"""
tasks = await service.list_tasks(db)
return StandardResponse(
code="0",
message="success",
data=tasks,
)
@router.post("", response_model=StandardResponse[AutoAnnotationTaskResponse])
async def create_auto_annotation_task(
request: CreateAutoAnnotationTaskRequest,
db: AsyncSession = Depends(get_db),
):
"""创建自动标注任务。
当前仅创建任务记录并置为 pending,实际执行由后续调度/worker 完成。
"""
logger.info(
"Creating auto annotation task: name=%s, dataset_id=%s, config=%s, file_ids=%s",
request.name,
request.dataset_id,
request.config.model_dump(by_alias=True),
request.file_ids,
)
# 尝试获取数据集名称和文件数量用于冗余字段,失败时不阻塞任务创建
dataset_name = None
total_images = 0
try:
dm_client = DatasetManagementService(db)
# Service.get_dataset 返回 DatasetResponse,包含 name 和 fileCount
dataset = await dm_client.get_dataset(request.dataset_id)
if dataset is not None:
dataset_name = dataset.name
# 如果提供了 file_ids,则 total_images 为选中文件数;否则使用数据集文件数
if request.file_ids:
total_images = len(request.file_ids)
else:
total_images = getattr(dataset, "fileCount", 0) or 0
except Exception as e: # pragma: no cover - 容错
logger.warning("Failed to fetch dataset name for auto task: %s", e)
task = await service.create_task(
db,
request,
dataset_name=dataset_name,
total_images=total_images,
)
# 创建/复用一个与主数据集关联的 Label Studio 项目,
# 并在该项目下为本次任务选中的所有文件(可跨多个数据集)创建任务。
try:
await _ensure_ls_mapping_for_auto_task(
db,
dataset_id=request.dataset_id,
dataset_name=dataset_name,
config=request.config,
task_name=request.name,
file_ids=[str(fid) for fid in (request.file_ids or [])],
auto_task_id=task.id,
)
except Exception as e: # pragma: no cover - 容错,不阻塞任务创建
logger.warning(
"Failed to ensure Label Studio mapping when creating auto task %s: %s",
task.id,
e,
)
return StandardResponse(
code="0",
message="success",
data=task,
)
@router.put("/{task_id}/files", response_model=StandardResponse[AutoAnnotationTaskResponse])
async def update_auto_annotation_task_files(
task_id: str = Path(..., description="任务ID"),
request: UpdateAutoAnnotationTaskFilesRequest = ..., # 通过 body 传入 datasetId 与 fileIds
db: AsyncSession = Depends(get_db),
):
"""更新自动标注任务所关联的数据集文件,并同步到 Label Studio。
最新约定:
1. 创建任务时选择的文件集合视为“基础集合”,后续编辑时不允许将其移除,只能追加新文件;
2. runtime worker 仅对新增文件执行自动标注,历史文件结果和输出数据集保持不变;
3. 编辑任务数据集时,仅将新增文件同步到 Label Studio,不再删除已有任务。
"""
# 1. 获取现有任务(响应模型)以便读取当前配置
existing = await service.get_task(db, task_id)
if not existing:
raise HTTPException(status_code=404, detail="Task not found")
# 1.1 计算旧文件集合与本次提交集合:
# - 旧集合视为“锁定”,不允许通过编辑接口移除;
# - 新集合与旧集合求并集后才会写回任务记录;
# - 仅对 (新集合 - 旧集合) 这一部分视为“新增文件”,用于后续 LS 同步。
old_ids = {str(fid) for fid in (existing.file_ids or [])}
requested_ids = {str(fid) for fid in (request.file_ids or [])}
added_ids = sorted(requested_ids - old_ids)
final_ids = sorted(old_ids | requested_ids)
# datasetId 若未显式传入,则沿用原任务值
dataset_id = request.dataset_id or existing.dataset_id
# 2. 更新底层任务记录(ORM),重置状态与文件列表(文件集合为旧集合 ∪ 本次提交集合)
updated = await service.update_task_files(
db,
task_id=task_id,
dataset_id=str(dataset_id),
file_ids=final_ids,
)
if not updated:
raise HTTPException(status_code=404, detail="Task not found")
# 3. 使用当前配置 + 新 file_ids 同步 LS 项目映射。
# 解析失败时退回到一个默认配置(与其它接口保持一致)。
try:
auto_config = AutoAnnotationConfig.model_validate(existing.config) # type: ignore[arg-type]
except Exception as e: # pragma: no cover - 降级使用默认配置
logger.warning(
"Failed to parse auto task config when updating LS mapping for task %s: %s",
task_id,
e,
)
auto_config = AutoAnnotationConfig(
model_size="l",
conf_threshold=0.5,
target_classes=[],
)
# 仅在存在“新增文件”时,才将这部分文件同步到 Label Studio;
# 不再删除 LS 中已有任务(delete_orphans=False)。
if added_ids:
try:
await _ensure_ls_mapping_for_auto_task(
db,
dataset_id=str(dataset_id),
dataset_name=updated.dataset_name,
config=auto_config,
task_name=updated.name,
file_ids=added_ids,
auto_task_id=updated.id,
delete_orphans=False,
)
except Exception as e: # pragma: no cover - 映射同步失败不阻塞前端
logger.warning(
"Failed to sync Label Studio mapping when updating auto task %s: %s",
task_id,
e,
)
return StandardResponse(
code="0",
message="success",
data=updated,
)
@router.get("/{task_id}/status", response_model=StandardResponse[AutoAnnotationTaskResponse])
async def get_auto_annotation_task_status(
task_id: str = Path(..., description="任务ID"),
db: AsyncSession = Depends(get_db),
):
"""获取单个自动标注任务状态。
前端当前主要通过列表轮询,这里提供按 ID 查询的补充接口。
"""
task = await service.get_task(db, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return StandardResponse(
code="0",
message="success",
data=task,
)
@router.get("/{task_id}/files", response_model=StandardResponse[List[Dict[str, Any]]])
async def get_auto_annotation_task_files(
task_id: str = Path(..., description="任务ID"),
db: AsyncSession = Depends(get_db),
):
"""查询自动标注任务当前关联的 DM 文件列表。
该接口主要用于前端“编辑任务数据集”弹窗的初始选中状态:
- 若任务记录中存在 file_ids,则按这些 ID 精确查询;
- 若 file_ids 为空,则回退为查询主数据集下的全部 ACTIVE 文件。
返回的每一项仅包含前端需要的基础字段,避免一次性返回过多无关信息。
"""
from sqlalchemy import select # 本地导入避免循环依赖
from app.db.models.dataset_management import DatasetFiles, Dataset
# 先获取任务,确保存在
result = await db.execute(
select(AutoAnnotationTask).where(
AutoAnnotationTask.id == task_id,
AutoAnnotationTask.deleted_at.is_(None),
)
)
task_row = result.scalar_one_or_none()
if not task_row:
raise HTTPException(status_code=404, detail="Task not found")
file_ids = getattr(task_row, "file_ids", None) or []
dataset_id = getattr(task_row, "dataset_id", None)
files_query = None
params: Dict[str, Any] = {}
if file_ids:
# 按任务记录中的 file_ids 精确查询
files_query = select(DatasetFiles).where(DatasetFiles.id.in_(file_ids))
else:
# 未显式记录 file_ids 时,回退为主数据集下所有 ACTIVE 文件
if not dataset_id:
return StandardResponse(code="0", message="success", data=[])
files_query = select(DatasetFiles).where(
DatasetFiles.dataset_id == dataset_id,
DatasetFiles.status == "ACTIVE",
)
files_result = await db.execute(files_query)
files = list(files_result.scalars().all())
# 为涉及到的 dataset_id 一次性查询名称映射,方便前端展示
dataset_ids = {str(f.dataset_id) for f in files if getattr(f, "dataset_id", None)}
dataset_name_map: Dict[str, str] = {}
if dataset_ids:
ds_result = await db.execute(
select(Dataset.id, Dataset.name).where(Dataset.id.in_(dataset_ids))
)
for ds_id, ds_name in ds_result.fetchall():
dataset_name_map[str(ds_id)] = ds_name or ""
data: List[Dict[str, Any]] = []
for f in files:
fid = str(getattr(f, "id"))
ds_id = str(getattr(f, "dataset_id")) if getattr(f, "dataset_id", None) else None
item: Dict[str, Any] = {
"id": fid,
"datasetId": ds_id,
"datasetName": dataset_name_map.get(ds_id or "", ""),
"fileName": getattr(f, "file_name", ""),
"fileSize": int(getattr(f, "file_size", 0) or 0),
"filePath": getattr(f, "file_path", ""),
}
data.append(item)
return StandardResponse(code="0", message="success", data=data)
@router.get("/{task_id}/label-studio-project", response_model=StandardResponse[Dict[str, str]])
async def get_auto_annotation_label_studio_project(
task_id: str = Path(..., description="任务ID"),
db: AsyncSession = Depends(get_db),
):
"""获取与指定自动标注任务关联的 Label Studio 项目。
优先依据 LabelingProject.configuration.autoTaskId 进行精确匹配,
若未找到,则回退为在同一数据集下按项目名称 (name == task.name) 匹配。
"""
# 1. 获取自动标注任务,确保存在
task = await service.get_task(db, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
from sqlalchemy import select # 本地导入以避免循环依赖问题
# 2. 查询该数据集下所有未删除的 LabelingProject
result = await db.execute(
select(LabelingProject)
.where(
LabelingProject.dataset_id == task.dataset_id,
LabelingProject.deleted_at.is_(None),
)
.order_by(LabelingProject.created_at.desc())
)
projects = list(result.scalars().all())
target: Optional[LabelingProject] = None
# 2.1 优先根据 configuration.autoTaskId 精确匹配
for proj in projects:
cfg = getattr(proj, "configuration", None) or {}
if isinstance(cfg, dict) and cfg.get("autoTaskId") == task_id:
target = proj
break
# 2.2 若未命中,则回退为按名称匹配(与当前自动任务同名的项目)
if target is None:
for proj in projects:
if proj.name == task.name:
target = proj
break
# 2.3 如果仍未找到,则尝试自动为该任务创建一个隐藏的标注项目
if target is None:
try:
try:
auto_config = AutoAnnotationConfig.model_validate(task.config)
except Exception as e: # pragma: no cover - 容错,使用默认配置
logger.warning(
"Failed to parse auto task config when auto-creating LS project: %s",
e,
)
auto_config = AutoAnnotationConfig(
model_size="l",
conf_threshold=0.5,
target_classes=[],
)
project_id = await _ensure_ls_mapping_for_auto_task(
db,
dataset_id=task.dataset_id,
dataset_name=task.dataset_name,
config=auto_config,
task_name=task.name,
file_ids=[str(fid) for fid in (task.file_ids or [])],
auto_task_id=task.id,
)
if project_id:
# 刚创建的项目此时还不在 projects 列表中,这里根据 labeling_project_id 重新查询一次
result2 = await db.execute(
select(LabelingProject).where(
LabelingProject.labeling_project_id == str(project_id),
LabelingProject.deleted_at.is_(None),
)
)
target = result2.scalar_one_or_none()
except Exception as e: # pragma: no cover - 创建失败不抛出到前端
logger.warning(
"Failed to auto-create LS project when resolving auto task %s: %s",
task_id,
e,
)
if target is None:
raise HTTPException(
status_code=404,
detail="Label Studio project not found for this auto task",
)
data = {
"projectId": str(target.labeling_project_id),
"name": target.name or "",
"datasetId": str(target.dataset_id),
}
return StandardResponse(code="0", message="success", data=data)
@router.delete("/{task_id}", response_model=StandardResponse[bool])
async def delete_auto_annotation_task(
task_id: str = Path(..., description="任务ID"),
db: AsyncSession = Depends(get_db),
):
"""删除(软删除)自动标注任务,并清理可能存在的自动创建标注项目。
逻辑:
1. 先根据 task_id 查询任务,拿到 dataset_id;
2. 软删除自动标注任务记录;
3. 尝试为该数据集查找名称以 " - 自动标注" 结尾的映射(LabelingProject);
若存在,则删除对应的 Label Studio 项目并软删除映射记录。
这样即使后台为自动标注创建了一个“隐藏”的标注项目,在删除自动标注任务时也会一并清理,
避免数据库和 Label Studio 中遗留孤立项目。
"""
# 1. 先获取任务,确保存在并拿到 dataset_id
task = await service.get_task(db, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
dataset_id = task.dataset_id
# 2. 软删除自动标注任务
ok = await service.soft_delete_task(db, task_id)
if not ok:
raise HTTPException(status_code=404, detail="Task not found")
# 3. 尝试清理该数据集下与本自动标注任务关联的 Label Studio 项目
try: # 清理失败不影响主流程
mapping_service = DatasetMappingService(db)
mappings = await mapping_service.get_mappings_by_dataset_id(dataset_id)
if mappings:
ls_client = LabelStudioClient(
base_url=settings.label_studio_base_url,
token=settings.label_studio_user_token,
)
for mapping in mappings:
# 仅删除“名称与当前自动标注任务名称相同”的项目,
# 同时兼容老版本以 " - 自动标注" 结尾的自动创建项目。
name = (mapping.name or "") if hasattr(mapping, "name") else ""
if name != task.name and not name.endswith(" - 自动标注"):
continue
labeling_project_id = mapping.labeling_project_id
# 删除 Label Studio 项目(忽略失败)
try:
logger.info(
"Deleting Label Studio project %s (name=%s) for dataset %s when deleting auto task %s",
labeling_project_id,
name,
dataset_id,
task_id,
)
await ls_client.delete_project(int(labeling_project_id))
except Exception as e: # pragma: no cover - 清理失败不影响主流程
logger.warning(
"Failed to delete Label Studio project %s for dataset %s: %s",
labeling_project_id,
dataset_id,
e,
)
# 软删除映射记录
try:
await mapping_service.soft_delete_mapping(mapping.id)
except Exception as e: # pragma: no cover
logger.warning(
"Failed to soft delete mapping %s for auto dataset %s: %s",
mapping.id,
dataset_id,
e,
)
except Exception as e: # pragma: no cover
logger.warning(
"Failed to cleanup auto-created labeling projects when deleting auto task %s: %s",
task_id,
e,
)
return StandardResponse(
code="0",
message="success",
data=True,
)
@router.get("/{task_id}/download")
async def download_auto_annotation_result(
task_id: str = Path(..., description="任务ID"),
db: AsyncSession = Depends(get_db),
):
"""下载指定自动标注任务的结果 ZIP。"""
import io
import os
import zipfile
import tempfile
# 复用服务层获取任务信息
task = await service.get_task(db, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
if not task.output_path:
raise HTTPException(status_code=400, detail="Task has no output path")
output_dir = task.output_path
if not os.path.isdir(output_dir):
raise HTTPException(status_code=404, detail="Output directory not found")
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".zip")
os.close(tmp_fd)
with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf:
for root, _, files in os.walk(output_dir):
for filename in files:
file_path = os.path.join(root, filename)
arcname = os.path.relpath(file_path, output_dir)
zf.write(file_path, arcname)
file_size = os.path.getsize(tmp_path)
if file_size == 0:
raise HTTPException(status_code=500, detail="Generated ZIP is empty")
def iterfile():
with open(tmp_path, "rb") as f:
while True:
chunk = f.read(8192)
if not chunk:
break
yield chunk
filename = f"{task.name}_annotations.zip"
headers = {
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Length": str(file_size),
}
return StreamingResponse(iterfile(), media_type="application/zip", headers=headers)
@router.post("/{task_id}/sync-label-studio", response_model=StandardResponse[int])
async def sync_auto_annotation_to_label_studio(
task_id: str = Path(..., description="任务ID"),
db: AsyncSession = Depends(get_db),
):
"""将指定自动标注任务的检测结果,同步到 Label Studio 作为 predictions。
流程:
1. 根据 task_id 查询自动标注任务信息,校验状态为 completed,且存在输出目录;
2. 基于任务的 dataset_id 查找对应的数据集映射,获取 Label Studio project_id;
3. 使用 PredictionSyncService 读取 `output_path/annotations` 下的 JSON,
将检测结果转换为 predictions 写入对应的 Label Studio 任务。
返回成功创建 prediction 的任务数量。
"""
# 1. 获取并校验自动标注任务
task = await service.get_task(db, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
if task.status != "completed":
raise HTTPException(status_code=400, detail="Task is not completed yet")
if not task.output_path:
raise HTTPException(status_code=400, detail="Task has no output path")
import os
output_dir = task.output_path
if not os.path.isdir(output_dir):
raise HTTPException(status_code=404, detail="Output directory not found")
# 兼容两种目录结构:
# 1) 旧版本:output_dir 为数据集根或中间目录,JSON 位于 output_dir/annotations/
# 2) 新版本:JSON 直接位于 output_dir/ 下
annotations_dir = os.path.join(output_dir, "annotations")
if not os.path.isdir(annotations_dir):
annotations_dir = output_dir
if not os.path.isdir(annotations_dir):
raise HTTPException(status_code=404, detail="Annotations directory not found")
# 2. 查找或自动创建与主数据集关联的 Label Studio 项目。
# 项目下的任务可能来自多个数据集,但在 DeepData 中仍然以主数据集进行归类,
# 以保持与手动标注映射模型的一致性。
mapping_service = DatasetMappingService(db)
mappings = await mapping_service.get_mappings_by_dataset_id(task.dataset_id)
project_id: Optional[str] = None
# 优先复用 configuration.autoTaskId 关联的项目,其次按名称匹配,
# 保证每个自动标注任务对应一个独立项目。
for m in mappings:
cfg = getattr(m, "configuration", None) or {}
if isinstance(cfg, dict) and cfg.get("autoTaskId") == task.id:
project_id = str(m.labeling_project_id)
break
if project_id is None:
for m in mappings:
if m.name == task.name:
project_id = str(m.labeling_project_id)
break
if project_id is None:
# 尚未为该自动标注任务创建过标注项目,则基于自动标注配置自动创建
try:
auto_config = AutoAnnotationConfig.model_validate(task.config)
except Exception as e: # pragma: no cover - 降级使用默认配置
logger.warning("Failed to parse auto task config when creating LS project: %s", e)
auto_config = AutoAnnotationConfig(
model_size="l",
conf_threshold=0.5,
target_classes=[],
)
project_id = await _ensure_ls_mapping_for_auto_task(
db,
dataset_id=task.dataset_id,
dataset_name=task.dataset_name,
config=auto_config,
task_name=task.name,
file_ids=[str(fid) for fid in (task.file_ids or [])],
auto_task_id=task.id,
)
if not project_id:
raise HTTPException(
status_code=500,
detail=(
"Failed to create Label Studio project for this dataset "
"when syncing auto annotation results."
),
)
# 3. 调用 PredictionSyncService 将 YOLO JSON 推送为 predictions
ls_client = LabelStudioClient(
base_url=settings.label_studio_base_url,
token=settings.label_studio_user_token,
)
sync_service = PredictionSyncService(ls_client)
created_count = await sync_service.sync_predictions_from_dir(
project_id=str(project_id),
annotations_dir=annotations_dir,
)
return StandardResponse(
code="0",
message="success",
data=created_count,
)
@router.post("/{task_id}/sync-label-studio-back", response_model=StandardResponse[bool])
async def import_from_label_studio_to_dataset(
task_id: str = Path(..., description="任务ID"),
body: ImportFromLabelStudioRequest | None = None,
db: AsyncSession = Depends(get_db),
):
"""将指定自动标注任务在 Label Studio 中的标注结果导回到某个数据集。
行为说明:
- 基于自动标注任务找到/创建对应的 Label Studio 项目;
- 调用 Label Studio 项目导出接口,按 exportFormat 下载完整导出文件;
- 将该导出文件作为一个普通数据集文件写入 targetDatasetId 对应的数据集目录;
- 不解析每条标注、不修改现有 tags,仅作为“标注导出工件”落盘,方便后续人工使用。
"""
import os
import tempfile
from datetime import datetime
if body is None:
raise HTTPException(status_code=400, detail="Request body is required")
# 1. 获取并校验自动标注任务
task = await service.get_task(db, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
# 2. 确定目标数据集:若请求体未显式指定,则使用自动标注任务绑定的数据集
dm_service = DatasetManagementService(db)
target_dataset_id = body.target_dataset_id or task.dataset_id
dataset = await dm_service.get_dataset(target_dataset_id)
if not dataset:
raise HTTPException(status_code=404, detail="Target dataset not found")
# 3. 查找或创建与该自动标注任务关联的 Label Studio 项目
mapping_service = DatasetMappingService(db)
mappings = await mapping_service.get_mappings_by_dataset_id(task.dataset_id)
project_id: Optional[str] = None
for m in mappings:
cfg = getattr(m, "configuration", None) or {}
if isinstance(cfg, dict) and cfg.get("autoTaskId") == task.id:
project_id = str(m.labeling_project_id)
break
if project_id is None:
for m in mappings:
if m.name == task.name:
project_id = str(m.labeling_project_id)
break
if project_id is None:
# 与前向同步逻辑保持一致:如无现成项目则按自动标注配置自动创建。
try:
auto_config = AutoAnnotationConfig.model_validate(task.config)
except Exception as e: # pragma: no cover
logger.warning("Failed to parse auto task config when creating LS project for back sync: %s", e)
auto_config = AutoAnnotationConfig(
model_size="l",
conf_threshold=0.5,
target_classes=[],
)
project_id = await _ensure_ls_mapping_for_auto_task(
db,
dataset_id=task.dataset_id,
dataset_name=task.dataset_name,
config=auto_config,
task_name=task.name,
file_ids=[str(fid) for fid in (task.file_ids or [])],
auto_task_id=task.id,
)
if not project_id:
raise HTTPException(
status_code=500,
detail=(
"Failed to create or resolve Label Studio project for this auto task "
"when importing annotations back."
),
)
# 4. 调用 Label Studio 导出接口
ls_client = LabelStudioClient(
base_url=settings.label_studio_base_url,
token=settings.label_studio_user_token,
)
export_format = (body.export_format or "JSON").upper()
# 简单根据导出格式推断文件扩展名,仅用于提高可读性
ext_map = {
"JSON": ".json",
"JSON_MIN": ".json",
"CSV": ".csv",
"TSV": ".tsv",
"COCO": ".json",
"YOLO": ".json",
"YOLOV8": ".json",
}
file_ext = ext_map.get(export_format, ".json")
content = await ls_client.export_project(int(project_id), export_type=export_format)
if content is None or len(content) == 0:
raise HTTPException(status_code=500, detail="Failed to export project from Label Studio")
# 5. 将导出结果写入临时文件,再通过 DatasetManagementService 复制到数据集目录并注册记录
tmp_fd, tmp_path = tempfile.mkstemp(suffix=file_ext)
os.close(tmp_fd)
try:
with open(tmp_path, "wb") as f:
f.write(content)
# 生成一个具备时间戳的文件名,避免与现有文件冲突;
# 若前端提供了自定义文件名,则优先使用其主体部分,再附加正确的扩展名。
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
def _sanitize_base_name(raw: str) -> str:
# 去掉路径分隔符,仅保留最后一段
name = (raw or "").strip().replace("\\", "/").split("/")[-1]
# 去掉用户自带的扩展名,避免与服务器推断的后缀冲突
if "." in name:
name = name.rsplit(".", 1)[0]
# 退回到默认前缀
return name or f"ls_export_{project_id}_{timestamp}"
if getattr(body, "file_name", None):
base_stem = _sanitize_base_name(body.file_name) # type: ignore[arg-type]
base_name = f"{base_stem}{file_ext}"
else:
base_name = f"ls_export_{project_id}_{timestamp}{file_ext}"
# DatasetManagementService.add_files_to_dataset 会使用源文件名决定目标文件名,
# 因此这里将临时文件重命名为期望的可读名称后再导入。
tmp_dir = os.path.dirname(tmp_path)
target_tmp_path = os.path.join(tmp_dir, base_name)
os.replace(tmp_path, target_tmp_path)
# 统一写入源数据集下的 "标注数据" 目录,便于管理导出工件
await dm_service.add_files_to_dataset_subdir(target_dataset_id, [target_tmp_path], "标注数据")
finally:
# 清理临时文件(若仍存在)
if os.path.exists(tmp_path):
try:
os.remove(tmp_path)
except Exception:
pass
# 也尝试清理重命名后的临时文件
if "target_tmp_path" in locals() and os.path.exists(target_tmp_path):
try:
os.remove(target_tmp_path)
except Exception:
pass
return StandardResponse(code="0", message="success", data=True)
@router.post("/{task_id}/sync-db", response_model=StandardResponse[int])
async def sync_auto_task_annotations_to_database(
task_id: str = Path(..., description="任务ID"),
db: AsyncSession = Depends(get_db),
):
"""将指定自动标注任务在 Label Studio 中的标注结果同步回 DM 数据库。
行为:
- 根据自动标注任务找到或创建对应的 Label Studio 项目(与 /sync-label-studio-back 相同的解析逻辑);
- 先执行 DM -> Label Studio 的文件差异同步(确保任务文件集合最新);
- 再执行双向标签同步(LS <-> DM),按时间戳保留较新标注。
返回同步变更数量(LS->DM + DM->LS)。
"""
# 1. 获取并校验自动标注任务
task = await service.get_task(db, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
# 2. 查找或创建与该自动标注任务关联的 Label Studio 项目
mapping_service = DatasetMappingService(db)
mappings = await mapping_service.get_mappings_by_dataset_id(task.dataset_id)
project_id: Optional[str] = None
selected_mapping = None
for m in mappings:
cfg = getattr(m, "configuration", None) or {}
if isinstance(cfg, dict) and cfg.get("autoTaskId") == task.id:
project_id = str(m.labeling_project_id)
selected_mapping = m
break
if project_id is None:
for m in mappings:
if m.name == task.name:
project_id = str(m.labeling_project_id)
selected_mapping = m
break
if project_id is None:
# 与前向/后向同步逻辑保持一致:如无现成项目则按自动标注配置自动创建。
try:
auto_config = AutoAnnotationConfig.model_validate(task.config)
except Exception as e: # pragma: no cover
logger.warning(
"Failed to parse auto task config when creating LS project for db sync: %s",
e,
)
auto_config = AutoAnnotationConfig(
model_size="l",
conf_threshold=0.5,
target_classes=[],
)
project_id = await _ensure_ls_mapping_for_auto_task(
db,
dataset_id=task.dataset_id,
dataset_name=task.dataset_name,
config=auto_config,
task_name=task.name,
file_ids=[str(fid) for fid in (task.file_ids or [])],
auto_task_id=task.id,
)
if not project_id:
raise HTTPException(
status_code=500,
detail=(
"Failed to create or resolve Label Studio project for this auto task "
"when syncing annotations to database."
),
)
selected_mapping = await mapping_service.get_mapping_by_labeling_project_id(project_id)
if selected_mapping is None and project_id is not None:
selected_mapping = await mapping_service.get_mapping_by_labeling_project_id(project_id)
if selected_mapping is None:
raise HTTPException(
status_code=500,
detail="Failed to resolve mapping for auto task when syncing files to Label Studio.",
)
ls_client = LabelStudioClient(
base_url=settings.label_studio_base_url,
token=settings.label_studio_user_token,
)
# 3. 先执行文件差异同步,确保 LS 工程任务集合与当前自动标注任务文件集合一致
dm_client = DatasetManagementService(db)
sync_orchestrator = SyncService(dm_client, ls_client, mapping_service)
allowed_file_ids = None
if task.file_ids:
allowed_file_ids = {str(fid) for fid in task.file_ids}
file_sync_result = await sync_orchestrator.sync_files(
selected_mapping,
batch_size=50,
allowed_file_ids=allowed_file_ids,
delete_orphans=True,
)
logger.info(
"Auto sync-db pre file-sync done: task_id=%s, created=%s, deleted=%s, total=%s",
task_id,
file_sync_result.get("created", 0),
file_sync_result.get("deleted", 0),
file_sync_result.get("total", 0),
)
# 4. 执行双向标签同步(基于时间戳决策覆盖)
annotation_sync_result = await sync_orchestrator.sync_annotations_bidirectional(
selected_mapping,
batch_size=50,
overwrite=True,
overwrite_ls=True,
sync_files_first=False,
)
total_synced = annotation_sync_result.synced_to_dm + annotation_sync_result.synced_to_ls
return StandardResponse(code="0", message="success", data=total_synced)