deepdata: core product code (backend, frontend, runtime, deepflow module)

This commit is contained in:
figmar
2026-08-30 19:23:38 +08:00
commit bd3db30be9
1405 changed files with 271727 additions and 0 deletions
@@ -0,0 +1,203 @@
"""Service layer for Auto Annotation tasks"""
from __future__ import annotations
from typing import List, Optional
from datetime import datetime
from uuid import uuid4
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models.annotation_management import AutoAnnotationTask
from app.db.models.dataset_management import Dataset, DatasetFiles
from ..schema.auto import (
CreateAutoAnnotationTaskRequest,
AutoAnnotationTaskResponse,
)
class AutoAnnotationTaskService:
"""自动标注任务服务(仅管理任务元数据,真正执行由 runtime 负责)"""
async def create_task(
self,
db: AsyncSession,
request: CreateAutoAnnotationTaskRequest,
dataset_name: Optional[str] = None,
total_images: int = 0,
) -> AutoAnnotationTaskResponse:
"""创建自动标注任务,初始状态为 pending。
这里仅插入任务记录,不负责真正执行 YOLO 推理,
后续可以由调度器/worker 读取该表并更新进度。
"""
now = datetime.now()
task = AutoAnnotationTask(
id=str(uuid4()),
name=request.name,
dataset_id=request.dataset_id,
dataset_name=dataset_name,
config=request.config.model_dump(by_alias=True),
file_ids=request.file_ids, # 存储用户选择的文件ID列表
status="pending",
progress=0,
total_images=total_images,
processed_images=0,
detected_objects=0,
created_at=now,
updated_at=now,
)
db.add(task)
await db.commit()
await db.refresh(task)
# 创建后附带 sourceDatasets 信息(通常只有一个原始数据集)
resp = AutoAnnotationTaskResponse.model_validate(task)
try:
resp.source_datasets = await self._compute_source_datasets(db, task)
except Exception:
resp.source_datasets = [dataset_name] if dataset_name else [request.dataset_id]
return resp
async def list_tasks(self, db: AsyncSession) -> List[AutoAnnotationTaskResponse]:
"""获取未软删除的自动标注任务列表,按创建时间倒序。"""
result = await db.execute(
select(AutoAnnotationTask)
.where(AutoAnnotationTask.deleted_at.is_(None))
.order_by(AutoAnnotationTask.created_at.desc())
)
tasks: List[AutoAnnotationTask] = list(result.scalars().all())
responses: List[AutoAnnotationTaskResponse] = []
for task in tasks:
resp = AutoAnnotationTaskResponse.model_validate(task)
try:
resp.source_datasets = await self._compute_source_datasets(db, task)
except Exception:
# 出错时降级为单个 datasetName/datasetId
fallback_name = getattr(task, "dataset_name", None)
fallback_id = getattr(task, "dataset_id", "")
resp.source_datasets = [fallback_name] if fallback_name else [fallback_id]
responses.append(resp)
return responses
async def get_task(self, db: AsyncSession, task_id: str) -> Optional[AutoAnnotationTaskResponse]:
result = await db.execute(
select(AutoAnnotationTask).where(
AutoAnnotationTask.id == task_id,
AutoAnnotationTask.deleted_at.is_(None),
)
)
task = result.scalar_one_or_none()
if not task:
return None
resp = AutoAnnotationTaskResponse.model_validate(task)
try:
resp.source_datasets = await self._compute_source_datasets(db, task)
except Exception:
fallback_name = getattr(task, "dataset_name", None)
fallback_id = getattr(task, "dataset_id", "")
resp.source_datasets = [fallback_name] if fallback_name else [fallback_id]
return resp
async def _compute_source_datasets(
self,
db: AsyncSession,
task: AutoAnnotationTask,
) -> List[str]:
"""根据任务的 file_ids 推断实际涉及到的所有数据集名称。
- 如果存在 file_ids,则通过 t_dm_dataset_files 反查 dataset_id,再关联 t_dm_datasets 获取名称;
- 如果没有 file_ids,则退回到任务上冗余的 dataset_name/dataset_id。
"""
file_ids = task.file_ids or []
if file_ids:
stmt = (
select(Dataset.name)
.join(DatasetFiles, Dataset.id == DatasetFiles.dataset_id)
.where(DatasetFiles.id.in_(file_ids))
.distinct()
)
result = await db.execute(stmt)
names = [row[0] for row in result.fetchall() if row[0]]
if names:
return names
# 回退:只显示一个数据集
if task.dataset_name:
return [task.dataset_name]
if task.dataset_id:
return [task.dataset_id]
return []
async def soft_delete_task(self, db: AsyncSession, task_id: str) -> bool:
result = await db.execute(
select(AutoAnnotationTask).where(
AutoAnnotationTask.id == task_id,
AutoAnnotationTask.deleted_at.is_(None),
)
)
task = result.scalar_one_or_none()
if not task:
return False
task.deleted_at = datetime.now()
await db.commit()
return True
async def update_task_files(
self,
db: AsyncSession,
task_id: str,
*,
dataset_id: str,
file_ids: List[str],
) -> Optional[AutoAnnotationTaskResponse]:
"""更新自动标注任务关联的数据集与文件列表。
- 覆盖任务的 dataset_id 与 file_ids;
- 将任务重置为 pending,供 worker 重新调度;
- 保留已有的 output_path 与统计信息,确保同一任务始终复用同一个输出数据集,
worker 内部会根据 output_path 中已有的 images 仅对新增文件执行自动标注。
"""
result = await db.execute(
select(AutoAnnotationTask).where(
AutoAnnotationTask.id == task_id,
AutoAnnotationTask.deleted_at.is_(None),
)
)
task = result.scalar_one_or_none()
if not task:
return None
now = datetime.now()
task.dataset_id = dataset_id
task.file_ids = file_ids or []
# 将任务标记为待处理,让 worker 在同一个输出目录下仅对新增文件执行自动标注
task.status = "pending"
task.progress = 0
task.updated_at = now
db.add(task)
await db.commit()
await db.refresh(task)
resp = AutoAnnotationTaskResponse.model_validate(task)
try:
resp.source_datasets = await self._compute_source_datasets(db, task)
except Exception:
fallback_name = getattr(task, "dataset_name", None)
fallback_id = getattr(task, "dataset_id", "")
resp.source_datasets = [fallback_name] if fallback_name else [fallback_id]
return resp
@@ -0,0 +1,229 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.logging import get_logger
from app.db.models import DatasetFiles
from ..client import LabelStudioClient
logger = get_logger(__name__)
class LSAnnotationSyncService:
"""将 Label Studio 项目中的标注结果同步回 DM 数据库。
约定:
- Label Studio task.data 中包含由 SyncService 写入的 DM 元信息:
{"file_id": "...", "dataset_id": "...", "file_path": "...", "original_name": "..."}
- 对于每个 task,我们会:
* 读取该 task 的 annotations 列表;
* 结合 task.predictions 一并写入 t_dm_dataset_files.annotation;
* 从 annotations/predictions 的 result 字段中提取标签,写入 t_dm_dataset_files.tags;
* 更新时间戳 tags_updated_at。
"""
def __init__(self, db: AsyncSession, ls_client: LabelStudioClient) -> None:
self.db = db
self.ls_client = ls_client
async def sync_project_annotations_to_dm(self, project_id: str) -> int:
"""从指定 Label Studio 项目中同步所有任务的标注到 DM 文件表。
Args:
project_id: Label Studio 项目 ID(字符串形式)。
Returns:
成功更新的文件数量。
"""
try:
page_size = getattr(settings, "ls_task_page_size", 1000)
result = await self.ls_client.get_project_tasks(
project_id=project_id,
page=None,
page_size=page_size,
)
except Exception as e: # pragma: no cover - 防御性日志
logger.error("Failed to fetch tasks from Label Studio project %s: %s", project_id, e)
return 0
if not result:
logger.warning("No tasks returned for project %s when syncing annotations", project_id)
return 0
tasks: List[Dict[str, Any]] = result.get("tasks", []) # type: ignore[assignment]
if not tasks:
logger.info("Project %s has no tasks to sync", project_id)
return 0
updated_files = 0
for task in tasks:
data: Dict[str, Any] = task.get("data") or {}
raw_file_id = data.get("file_id")
if not raw_file_id:
# 旧任务可能没有 DM file_id,跳过但记录日志
logger.debug(
"Skip LS task %s because data.file_id is missing",
task.get("id"),
)
continue
file_id = str(raw_file_id)
# 获取该任务的 annotations 列表
annotations: Optional[List[Dict[str, Any]]] = None
try:
annotations = await self.ls_client.get_task_annotations(int(task["id"])) # type: ignore[arg-type]
except Exception as e: # pragma: no cover
logger.error(
"Failed to fetch annotations for LS task %s (file_id=%s): %s",
task.get("id"),
file_id,
e,
)
predictions: List[Dict[str, Any]] = task.get("predictions") or []
annotation_payload: Dict[str, Any] = {
"task": {
"id": task.get("id"),
"project": task.get("project"),
"data": data,
},
"annotations": annotations or [],
"predictions": predictions,
}
tags = self._extract_tags_from_results(
annotations or [],
predictions,
)
success = await self._update_dataset_file(file_id, tags, annotation_payload)
if success:
updated_files += 1
logger.info(
"Synced annotations from LS project %s to DM, updated %d files",
project_id,
updated_files,
)
return updated_files
async def _update_dataset_file(
self,
file_id: str,
tags: List[Dict[str, Any]],
annotation: Dict[str, Any],
) -> bool:
"""将提取出的 tags 与 annotation 写回 t_dm_dataset_files。
直接更新 DatasetFiles.tags / tags_updated_at / annotation 字段,
不走模板驱动的转换逻辑(模板转换在用户主动批量更新时使用)。
"""
try:
result = await self.db.execute(
select(DatasetFiles).where(DatasetFiles.id == file_id)
)
record = result.scalar_one_or_none()
if not record:
logger.warning("Dataset file not found when syncing LS annotations: %s", file_id)
return False
update_time = datetime.utcnow()
record.tags = tags # type: ignore[assignment]
record.tags_updated_at = update_time # type: ignore[assignment]
# annotation 可能较大,但为 JSONB,直接整体覆盖
setattr(record, "annotation", annotation)
await self.db.commit()
await self.db.refresh(record)
logger.debug(
"Updated DM file %s with %d tags from LS annotations",
file_id,
len(tags),
)
return True
except Exception as e: # pragma: no cover
logger.error("Failed to update DM file %s from LS annotations: %s", file_id, e)
try:
await self.db.rollback()
except Exception:
pass
return False
@staticmethod
def _extract_tags_from_results(
annotations: List[Dict[str, Any]],
predictions: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""从 annotations 和 predictions 的 result 字段中提取通用标签结构。
输出格式与 DM 前端当前解析逻辑兼容:
- 每个元素包含: id/type/from_name/values;
- 其中 values 是一个字典,键通常与 type 一致,例如 "rectanglelabels";
- 前端通过 tag.values[tag.type] 提取字符串标签集合。
"""
def iter_results() -> List[Dict[str, Any]]:
res: List[Dict[str, Any]] = []
for ann in annotations or []:
for item in ann.get("result", []) or []:
if isinstance(item, dict):
res.append(item)
for pred in predictions or []:
for item in pred.get("result", []) or []:
if isinstance(item, dict):
res.append(item)
return res
results = iter_results()
if not results:
return []
normalized: List[Dict[str, Any]] = []
for r in results:
r_type = r.get("type")
if isinstance(r_type, str):
r_type = r_type.strip().lower()
from_name = r.get("from_name") or r.get("fromName")
to_name = r.get("to_name") or r.get("toName")
value_obj = r.get("value") or {}
if not isinstance(value_obj, dict):
continue
# 将 Label Studio 的 value 映射为 values,方便前端统一解析
values: Dict[str, Any] = {}
for key, v in value_obj.items():
normalized_key = str(key).strip().lower() if key is not None else ""
if normalized_key:
values[normalized_key] = v
if isinstance(r_type, str) and r_type:
if r_type in values:
values = {r_type: values[r_type]}
elif len(values) == 1:
only_value = next(iter(values.values()))
values = {r_type: only_value}
tag = {
"id": r.get("id"),
"type": r_type,
"from_name": from_name,
"to_name": to_name,
"values": values,
}
normalized.append(tag)
return normalized
@@ -0,0 +1,484 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy import update, func
from sqlalchemy.orm import aliased
from typing import Optional, List, Tuple
from datetime import datetime
import uuid
from app.core.logging import get_logger
from app.db.models import LabelingProject, AnnotationTemplate
from app.db.models.dataset_management import Dataset
from app.module.annotation.schema import (
DatasetMappingCreateRequest,
DatasetMappingUpdateRequest,
DatasetMappingResponse,
AnnotationTemplateResponse
)
logger = get_logger(__name__)
class DatasetMappingService:
"""数据集映射服务"""
def __init__(self, db: AsyncSession):
self.db = db
def _build_query_with_dataset_name(self):
"""Build base query with dataset name joined"""
return select(
LabelingProject,
Dataset.name.label('dataset_name')
).outerjoin(
Dataset,
LabelingProject.dataset_id == Dataset.id
)
async def _to_response_from_row(
self,
row,
include_template: bool = False
) -> DatasetMappingResponse:
"""
Convert query row (mapping + dataset_name) to response
Args:
row: Query result row containing (LabelingProject, dataset_name)
include_template: If True, fetch and include full template details
"""
mapping = row[0] # LabelingProject object
dataset_name = row[1] # dataset_name from join
# Get template_id from mapping
template_id = getattr(mapping, 'template_id', None)
# Optionally fetch full template details
template_response = None
if include_template and template_id:
from ..service.template import AnnotationTemplateService
template_service = AnnotationTemplateService()
template_response = await template_service.get_template(self.db, template_id)
logger.debug(f"Included template details for template_id: {template_id}")
response_data = {
"id": mapping.id,
"dataset_id": mapping.dataset_id,
"dataset_name": dataset_name,
"labeling_project_id": mapping.labeling_project_id,
"name": mapping.name,
"description": getattr(mapping, 'description', None),
"template_id": template_id,
"template": template_response,
"created_at": mapping.created_at,
"updated_at": mapping.updated_at,
"deleted_at": mapping.deleted_at,
}
return DatasetMappingResponse(**response_data)
async def _to_response(
self,
mapping: LabelingProject,
include_template: bool = False
) -> DatasetMappingResponse:
"""
Convert ORM model to response with dataset name (for single entity operations)
Args:
mapping: LabelingProject ORM object
include_template: If True, fetch and include full template details
"""
# Fetch dataset name
dataset_name = None
dataset_id = getattr(mapping, 'dataset_id', None)
if dataset_id:
dataset_result = await self.db.execute(
select(Dataset.name).where(Dataset.id == dataset_id)
)
dataset_name = dataset_result.scalar_one_or_none()
# Get template_id from mapping
template_id = getattr(mapping, 'template_id', None)
# Optionally fetch full template details
template_response = None
if include_template and template_id:
from ..service.template import AnnotationTemplateService
template_service = AnnotationTemplateService()
template_response = await template_service.get_template(self.db, template_id)
logger.debug(f"Included template details for template_id: {template_id}")
# Create response dict with all fields
response_data = {
"id": mapping.id,
"dataset_id": dataset_id,
"dataset_name": dataset_name,
"labeling_project_id": mapping.labeling_project_id,
"name": mapping.name,
"description": getattr(mapping, 'description', None),
"template_id": template_id,
"template": template_response,
"created_at": mapping.created_at,
"updated_at": mapping.updated_at,
"deleted_at": mapping.deleted_at,
}
return DatasetMappingResponse(**response_data)
async def create_mapping(
self,
labeling_project: LabelingProject
) -> DatasetMappingResponse:
"""创建数据集映射"""
logger.debug(f"Create dataset mapping: {labeling_project.dataset_id} -> {labeling_project.labeling_project_id}")
# Use the passed object directly
self.db.add(labeling_project)
await self.db.commit()
await self.db.refresh(labeling_project)
logger.debug(f"Mapping created: {labeling_project.id}")
return await self._to_response(labeling_project)
async def get_mapping_by_source_uuid(
self,
dataset_id: str
) -> Optional[DatasetMappingResponse]:
"""根据源数据集ID获取映射(返回第一个未删除的)"""
logger.debug(f"Get mapping by source dataset id: {dataset_id}")
result = await self.db.execute(
select(LabelingProject).where(
LabelingProject.dataset_id == dataset_id,
LabelingProject.deleted_at.is_(None)
)
)
mapping = result.scalar_one_or_none()
if mapping:
logger.debug(f"Found mapping: {mapping.id}")
return await self._to_response(mapping)
logger.debug(f"No mapping found for source dataset id: {dataset_id}")
return None
async def get_mappings_by_dataset_id(
self,
dataset_id: str,
include_deleted: bool = False
) -> List[DatasetMappingResponse]:
"""根据源数据集ID获取所有映射关系"""
logger.debug(f"Get all mappings by source dataset id: {dataset_id}")
query = self._build_query_with_dataset_name().where(
LabelingProject.dataset_id == dataset_id
)
if not include_deleted:
query = query.where(LabelingProject.deleted_at.is_(None))
result = await self.db.execute(
query.order_by(LabelingProject.created_at.desc())
)
rows = result.all()
logger.debug(f"Found {len(rows)} mappings")
# Convert rows to responses (async comprehension)
responses = []
for row in rows:
response = await self._to_response_from_row(row, include_template=False)
responses.append(response)
return responses
async def get_mapping_by_labeling_project_id(
self,
labeling_project_id: str
) -> Optional[DatasetMappingResponse]:
"""根据Label Studio项目ID获取映射"""
logger.debug(f"Get mapping by Label Studio project id: {labeling_project_id}")
result = await self.db.execute(
select(LabelingProject).where(
LabelingProject.labeling_project_id == labeling_project_id,
LabelingProject.deleted_at.is_(None)
)
)
mapping = result.scalar_one_or_none()
if mapping:
logger.debug(f"Found mapping: {mapping.id}")
return await self._to_response(mapping)
logger.debug(f"No mapping found for Label Studio project id: {labeling_project_id}")
return None
async def get_mapping_by_uuid(
self,
mapping_id: str,
include_template: bool = False
) -> Optional[DatasetMappingResponse]:
"""
根据映射UUID获取映射
Args:
mapping_id: 映射UUID
include_template: 是否包含完整的模板信息
"""
logger.debug(f"Get mapping: {mapping_id}, include_template={include_template}")
result = await self.db.execute(
select(LabelingProject).where(
LabelingProject.id == mapping_id,
LabelingProject.deleted_at.is_(None)
)
)
mapping = result.scalar_one_or_none()
if mapping:
logger.debug(f"Found mapping: {mapping.id}")
return await self._to_response(mapping, include_template=include_template)
logger.debug(f"No mapping found for mapping id: {mapping_id}")
return None
async def update_mapping(
self,
mapping_id: str,
update_data: DatasetMappingUpdateRequest
) -> Optional[DatasetMappingResponse]:
"""更新映射信息"""
logger.info(f"Update mapping: {mapping_id}")
mapping = await self.get_mapping_by_uuid(mapping_id)
if not mapping:
return None
update_values = update_data.model_dump(exclude_unset=True)
update_values["last_updated_at"] = datetime.now()
result = await self.db.execute(
update(LabelingProject)
.where(LabelingProject.id == mapping_id)
.values(**update_values)
)
await self.db.commit()
if result.rowcount and result.rowcount > 0: # type: ignore
return await self.get_mapping_by_uuid(mapping_id)
return None
async def soft_delete_mapping(self, mapping_id: str) -> bool:
"""软删除映射"""
logger.info(f"Soft delete mapping: {mapping_id}")
result = await self.db.execute(
update(LabelingProject)
.where(
LabelingProject.id == mapping_id,
LabelingProject.deleted_at.is_(None)
)
.values(deleted_at=datetime.now())
)
await self.db.commit()
success = result.rowcount and result.rowcount > 0 # type: ignore
if success:
logger.info(f"Mapping soft-deleted: {mapping_id}")
else:
logger.warning(f"Mapping not exists or already deleted: {mapping_id}")
return success
async def get_all_mappings(
self,
skip: int = 0,
limit: int = 100
) -> List[DatasetMappingResponse]:
"""获取所有有效映射"""
logger.debug(f"List all mappings, skip: {skip}, limit: {limit}")
query = self._build_query_with_dataset_name().where(
LabelingProject.deleted_at.is_(None)
)
result = await self.db.execute(
query
.offset(skip)
.limit(limit)
.order_by(LabelingProject.created_at.desc())
)
rows = result.all()
logger.debug(f"Found {len(rows)} mappings")
# Convert rows to responses (async comprehension)
responses = []
for row in rows:
response = await self._to_response_from_row(row, include_template=False)
responses.append(response)
return responses
async def count_mappings(self, include_deleted: bool = False) -> int:
"""统计映射总数"""
query = select(func.count()).select_from(LabelingProject)
if not include_deleted:
query = query.where(LabelingProject.deleted_at.is_(None))
result = await self.db.execute(query)
return result.scalar_one()
async def get_all_mappings_with_count(
self,
skip: int = 0,
limit: int = 100,
include_deleted: bool = False,
include_template: bool = False
) -> Tuple[List[DatasetMappingResponse], int]:
"""
获取所有映射及总数(用于分页)
Args:
skip: 跳过记录数
limit: 返回记录数
include_deleted: 是否包含已删除的记录
include_template: 是否包含完整的模板信息
"""
logger.debug(f"List all mappings with count, skip: {skip}, limit: {limit}, include_template={include_template}")
# 构建查询
query = self._build_query_with_dataset_name()
if not include_deleted:
query = query.where(LabelingProject.deleted_at.is_(None))
# 获取总数
count_query = select(func.count()).select_from(LabelingProject)
if not include_deleted:
count_query = count_query.where(LabelingProject.deleted_at.is_(None))
count_result = await self.db.execute(count_query)
total = count_result.scalar_one()
# 获取数据
result = await self.db.execute(
query
.offset(skip)
.limit(limit)
.order_by(LabelingProject.created_at.desc())
)
rows = result.all()
# 过滤掉由自动标注任务自动创建的隐藏工程(configuration.autoTaskId 存在)
filtered_rows = []
for row in rows:
mapping: LabelingProject = row[0]
cfg = getattr(mapping, "configuration", None) or {}
if isinstance(cfg, dict) and cfg.get("autoTaskId"):
continue
filtered_rows.append(row)
logger.debug(
f"Found {len(filtered_rows)} visible mappings (filtered from {len(rows)}), total: {total}"
)
# Convert rows to responses (async comprehension)
responses = []
for row in filtered_rows:
response = await self._to_response_from_row(row, include_template=include_template)
responses.append(response)
# total 仍然返回数据库总数,目前前端只关注当前页内容数量,故不调整 total
return responses, total
async def get_template_id_by_dataset_id(self, dataset_id: str) -> Optional[str]:
"""
Get template ID for a dataset by finding its labeling project
Args:
dataset_id: Dataset UUID
Returns:
Template ID or None if no labeling project found or no template associated
"""
logger.debug(f"Looking up template for dataset: {dataset_id}")
result = await self.db.execute(
select(LabelingProject.template_id)
.where(
LabelingProject.dataset_id == dataset_id,
LabelingProject.deleted_at.is_(None)
)
.limit(1)
)
template_id = result.scalar_one_or_none()
if template_id:
logger.debug(f"Found template {template_id} for dataset {dataset_id}")
else:
logger.warning(f"No template found for dataset {dataset_id}")
return template_id
async def get_mappings_by_source_with_count(
self,
dataset_id: str,
skip: int = 0,
limit: int = 100,
include_deleted: bool = False,
include_template: bool = False
) -> Tuple[List[DatasetMappingResponse], int]:
"""
根据源数据集ID获取映射关系及总数(用于分页)
Args:
dataset_id: 数据集ID
skip: 跳过记录数
limit: 返回记录数
include_deleted: 是否包含已删除的记录
include_template: 是否包含完整的模板信息
"""
logger.debug(f"Get mappings by source dataset id with count: {dataset_id}, include_template={include_template}")
# 构建查询
query = self._build_query_with_dataset_name().where(
LabelingProject.dataset_id == dataset_id
)
if not include_deleted:
query = query.where(LabelingProject.deleted_at.is_(None))
# 获取总数
count_query = select(func.count()).select_from(LabelingProject).where(
LabelingProject.dataset_id == dataset_id
)
if not include_deleted:
count_query = count_query.where(LabelingProject.deleted_at.is_(None))
count_result = await self.db.execute(count_query)
total = count_result.scalar_one()
# 获取数据
result = await self.db.execute(
query
.offset(skip)
.limit(limit)
.order_by(LabelingProject.created_at.desc())
)
rows = result.all()
# 过滤掉由自动标注任务自动创建的隐藏工程(configuration.autoTaskId 存在)
filtered_rows = []
for row in rows:
mapping: LabelingProject = row[0]
cfg = getattr(mapping, "configuration", None) or {}
if isinstance(cfg, dict) and cfg.get("autoTaskId"):
continue
filtered_rows.append(row)
logger.debug(
f"Found {len(filtered_rows)} mappings for dataset {dataset_id} (filtered from {len(rows)}), total: {total}"
)
# Convert rows to responses (async comprehension)
responses = []
for row in filtered_rows:
response = await self._to_response_from_row(row, include_template=include_template)
responses.append(response)
return responses, total
@@ -0,0 +1,208 @@
"""服务:将自动标注(YOLO)结果同步到 Label Studio 作为 predictions。
该服务不负责执行模型推理,只负责:
- 读取本地 annotations JSON(由 runtime/ops/annotation/image_object_detection_bounding_box 产出);
- 根据 Label Studio 项目的任务列表,将每个文件对应到具体 task;
- 将检测结果转换为 Label Studio 的 prediction 结构并写入。
"""
from __future__ import annotations
import json
import os
import uuid
from typing import Dict, Any, List, Optional
from app.core.logging import get_logger
from ..client import LabelStudioClient
logger = get_logger(__name__)
class PredictionSyncService:
"""将 YOLO 检测结果推送到 Label Studio 的预测服务。"""
def __init__(self, ls_client: LabelStudioClient) -> None:
self.ls_client = ls_client
async def _build_task_index(self, project_id: str) -> Dict[str, int]:
"""为指定项目构建 "文件名 -> task_id" 索引。
优先使用 data.original_name,其次退回到从 data.image / data.file_path
中解析出的文件名,保证与 YOLO JSON 中的 annotations["image"] 对得上。
"""
result = await self.ls_client.get_project_tasks(project_id=project_id, page=None)
if not result:
logger.warning("No tasks found for project %s", project_id)
return {}
tasks = result.get("tasks", []) or []
index: Dict[str, int] = {}
for task in tasks:
task_id = task.get("id")
data = task.get("data", {}) or {}
if task_id is None:
continue
# candidate names that may appear in YOLO annotations["image"]
candidates: List[str] = []
original_name = data.get("original_name")
if isinstance(original_name, str) and original_name:
candidates.append(original_name)
image_val = data.get("image") or data.get("file_path")
if isinstance(image_val, str) and image_val:
basename = os.path.basename(image_val)
if basename:
candidates.append(basename)
for name in candidates:
# 后写覆盖前写没关系,同一文件名对应同一个 task 的预期
index[name] = task_id
logger.debug(
"Built task index for project %s with %d entries", project_id, len(index)
)
return index
@staticmethod
def _build_ls_result_from_detections(
annotations: Dict[str, Any],
from_name: str = "label",
to_name: str = "image",
) -> List[Dict[str, Any]]:
"""将 YOLO annotations["detections"] 转为 Label Studio result 列表。"""
width = float(annotations.get("width") or 0) or 1.0
height = float(annotations.get("height") or 0) or 1.0
detections = annotations.get("detections") or []
results: List[Dict[str, Any]] = []
for det in detections:
try:
label = det.get("label")
bbox = det.get("bbox_xywh") or det.get("bbox_xyxy")
if not label or not bbox or len(bbox) < 4:
continue
# 优先使用 xywh,单位像素
if len(bbox) == 4:
x, y, w, h = map(float, bbox)
else:
# 回退:如果给的是 xyxy
x1, y1, x2, y2 = map(float, bbox[:4])
x, y, w, h = x1, y1, x2 - x1, y2 - y1
x_pct = max(0.0, min(100.0, x / width * 100.0))
y_pct = max(0.0, min(100.0, y / height * 100.0))
w_pct = max(0.0, min(100.0, w / width * 100.0))
h_pct = max(0.0, min(100.0, h / height * 100.0))
region_id = uuid.uuid4().hex[:10]
results.append(
{
"id": region_id,
"type": "rectanglelabels",
"value": {
"x": x_pct,
"y": y_pct,
"width": w_pct,
"height": h_pct,
"rotation": 0,
"rectanglelabels": [label],
},
"origin": "prediction",
"to_name": to_name,
"from_name": from_name,
"image_rotation": 0,
"original_width": width,
"original_height": height,
}
)
except Exception as exc: # 防御性:单条失败不影响其他
logger.warning("Failed to convert detection to LS result: %s", exc)
continue
return results
async def sync_predictions_from_dir(
self,
project_id: str,
annotations_dir: str,
*,
model_version: Optional[str] = None,
from_name: str = "label",
to_name: str = "image",
) -> int:
"""将某个目录下的 YOLO JSON 同步为给定项目的 predictions。
返回成功写入 prediction 的任务数。
"""
if not os.path.isdir(annotations_dir):
logger.warning("annotations_dir '%s' is not a directory", annotations_dir)
return 0
task_index = await self._build_task_index(project_id)
if not task_index:
logger.warning(
"No tasks found when syncing predictions for project %s", project_id
)
return 0
created_count = 0
for filename in os.listdir(annotations_dir):
if not filename.lower().endswith(".json"):
continue
json_path = os.path.join(annotations_dir, filename)
try:
with open(json_path, "r", encoding="utf-8") as f:
annotations = json.load(f)
except Exception as exc:
logger.warning("Failed to load annotations json %s: %s", json_path, exc)
continue
image_name = annotations.get("image") or filename.rsplit(".", 1)[0]
task_id = task_index.get(image_name)
if task_id is None:
logger.info(
"No matching task for image '%s' in project %s, skip", image_name, project_id
)
continue
result = self._build_ls_result_from_detections(
annotations, from_name=from_name, to_name=to_name
)
if not result:
logger.info("No detections for %s, skip creating prediction", image_name)
continue
mv = model_version
if mv is None:
model_size = annotations.get("model_size") or ""
mv = f"yolov8{model_size}" if model_size else "yolov8"
prediction = await self.ls_client.create_prediction(
task_id=task_id,
result=result,
model_version=mv,
)
if prediction:
created_count += 1
logger.info(
"Synced YOLO predictions from '%s' to project %s, created %d predictions",
annotations_dir,
project_id,
created_count,
)
return created_count
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,341 @@
"""
Annotation Template Service
"""
from typing import Optional, List
from datetime import datetime
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from uuid import uuid4
from fastapi import HTTPException
from app.db.models.annotation_management import AnnotationTemplate
from app.module.annotation.schema.template import (
CreateAnnotationTemplateRequest,
UpdateAnnotationTemplateRequest,
AnnotationTemplateResponse,
AnnotationTemplateListResponse,
TemplateConfiguration
)
from app.module.annotation.utils.config_validator import LabelStudioConfigValidator
from app.module.annotation.config import LabelStudioTagConfig
class AnnotationTemplateService:
"""标注模板服务"""
@staticmethod
def generate_label_studio_config(config: TemplateConfiguration) -> str:
"""
从配置JSON生成Label Studio XML配置
Args:
config: 模板配置对象
Returns:
Label Studio XML字符串
"""
tag_config = LabelStudioTagConfig()
xml_parts = ['<View>']
# 生成对象定义
for obj in config.objects:
obj_attrs = [
f'name="{obj.name}"',
f'value="{obj.value}"'
]
xml_parts.append(f' <{obj.type} {" ".join(obj_attrs)}/>')
# 生成标签定义
for label in config.labels:
label_attrs = [
f'name="{label.from_name}"',
f'toName="{label.to_name}"'
]
# 添加可选属性
if label.required:
label_attrs.append('required="true"')
if label.choice:
label_attrs.append(f'choice="{label.choice}"')
# xuhuanqing
#tag_type = label.type.capitalize() if label.type else "Choices"
tag_type = label.type if label.type else "Choices"
# 检查是否需要子元素
if label.options or label.labels:
choices = label.options or label.labels or []
xml_parts.append(f' <{tag_type} {" ".join(label_attrs)}>')
# 从配置获取子元素标签名
child_tag = tag_config.get_child_tag(tag_type)
if not child_tag:
# 默认使用 Label
child_tag = "Label"
for choice in choices:
xml_parts.append(f' <{child_tag} value="{choice}"/>')
xml_parts.append(f' </{tag_type}>')
else:
# 处理简单标签类型(不需要子元素)
xml_parts.append(f' <{tag_type} {" ".join(label_attrs)}/>')
xml_parts.append('</View>')
return '\n'.join(xml_parts)
async def create_template(
self,
db: AsyncSession,
request: CreateAnnotationTemplateRequest
) -> AnnotationTemplateResponse:
"""
创建标注模板
Args:
db: 数据库会话
request: 创建请求
Returns:
创建的模板响应
"""
# 验证配置JSON
config_dict = request.configuration.model_dump(mode='json', by_alias=False)
valid, error = LabelStudioConfigValidator.validate_configuration_json(config_dict)
if not valid:
raise HTTPException(status_code=400, detail=f"Invalid configuration: {error}")
# 生成Label Studio XML配置(用于验证,但不存储)
label_config = self.generate_label_studio_config(request.configuration)
# 验证生成的XML
valid, error = LabelStudioConfigValidator.validate_xml(label_config)
if not valid:
raise HTTPException(status_code=400, detail=f"Generated XML is invalid: {error}")
# 创建模板对象(不包含label_config字段)
template = AnnotationTemplate(
id=str(uuid4()),
name=request.name,
description=request.description,
data_type=request.data_type,
labeling_type=request.labeling_type,
configuration=config_dict,
style=request.style,
category=request.category,
built_in=False,
version="1.0.0",
created_at=datetime.now()
)
db.add(template)
await db.commit()
await db.refresh(template)
return self._to_response(template)
async def get_template(
self,
db: AsyncSession,
template_id: str
) -> Optional[AnnotationTemplateResponse]:
"""
获取单个模板
Args:
db: 数据库会话
template_id: 模板ID
Returns:
模板响应或None
"""
result = await db.execute(
select(AnnotationTemplate)
.where(
AnnotationTemplate.id == template_id,
AnnotationTemplate.deleted_at.is_(None)
)
)
template = result.scalar_one_or_none()
if template:
return self._to_response(template)
return None
async def list_templates(
self,
db: AsyncSession,
page: int = 1,
size: int = 10,
category: Optional[str] = None,
data_type: Optional[str] = None,
labeling_type: Optional[str] = None,
built_in: Optional[bool] = None
) -> AnnotationTemplateListResponse:
"""
获取模板列表
Args:
db: 数据库会话
page: 页码
size: 每页大小
category: 分类筛选
data_type: 数据类型筛选
labeling_type: 标注类型筛选
built_in: 是否内置模板筛选
Returns:
模板列表响应
"""
# 构建查询条件
conditions: List = [AnnotationTemplate.deleted_at.is_(None)]
if category:
conditions.append(AnnotationTemplate.category == category) # type: ignore
if data_type:
conditions.append(AnnotationTemplate.data_type == data_type) # type: ignore
if labeling_type:
conditions.append(AnnotationTemplate.labeling_type == labeling_type) # type: ignore
if built_in is not None:
conditions.append(AnnotationTemplate.built_in == built_in) # type: ignore
# 查询总数
count_result = await db.execute(
select(func.count()).select_from(AnnotationTemplate).where(*conditions)
)
total = count_result.scalar() or 0
# 分页查询
result = await db.execute(
select(AnnotationTemplate)
.where(*conditions)
.order_by(AnnotationTemplate.created_at.desc())
.limit(size)
.offset((page - 1) * size)
)
templates = result.scalars().all()
return AnnotationTemplateListResponse(
content=[self._to_response(t) for t in templates],
totalElements=total,
page=page,
size=size,
totalPages=(total + size - 1) // size
)
async def update_template(
self,
db: AsyncSession,
template_id: str,
request: UpdateAnnotationTemplateRequest
) -> Optional[AnnotationTemplateResponse]:
"""
更新模板
Args:
db: 数据库会话
template_id: 模板ID
request: 更新请求
Returns:
更新后的模板响应或None
"""
result = await db.execute(
select(AnnotationTemplate)
.where(
AnnotationTemplate.id == template_id,
AnnotationTemplate.deleted_at.is_(None)
)
)
template = result.scalar_one_or_none()
if not template:
return None
# 更新字段
update_data = request.model_dump(exclude_unset=True, by_alias=False)
for field, value in update_data.items():
if field == 'configuration' and value is not None:
# 验证配置JSON
config_dict = value.model_dump(mode='json', by_alias=False)
valid, error = LabelStudioConfigValidator.validate_configuration_json(config_dict)
if not valid:
raise HTTPException(status_code=400, detail=f"Invalid configuration: {error}")
# 重新生成Label Studio XML配置(用于验证)
label_config = self.generate_label_studio_config(value)
# 验证生成的XML
valid, error = LabelStudioConfigValidator.validate_xml(label_config)
if not valid:
raise HTTPException(status_code=400, detail=f"Generated XML is invalid: {error}")
# 只更新configuration字段,不存储label_config
setattr(template, field, config_dict)
else:
setattr(template, field, value)
template.updated_at = datetime.now() # type: ignore
await db.commit()
await db.refresh(template)
return self._to_response(template)
async def delete_template(
self,
db: AsyncSession,
template_id: str
) -> bool:
"""
删除模板(软删除)
Args:
db: 数据库会话
template_id: 模板ID
Returns:
是否删除成功
"""
result = await db.execute(
select(AnnotationTemplate)
.where(
AnnotationTemplate.id == template_id,
AnnotationTemplate.deleted_at.is_(None)
)
)
template = result.scalar_one_or_none()
if not template:
return False
template.deleted_at = datetime.now() # type: ignore
await db.commit()
return True
def _to_response(self, template: AnnotationTemplate) -> AnnotationTemplateResponse:
"""
转换为响应对象
Args:
template: 数据库模型对象
Returns:
模板响应对象
"""
# 将配置JSON转换为TemplateConfiguration对象
from typing import cast, Dict, Any
config_dict = cast(Dict[str, Any], template.configuration)
config = TemplateConfiguration(**config_dict)
# 动态生成Label Studio XML配置
label_config = self.generate_label_studio_config(config)
# 使用model_validate从ORM对象创建响应对象
response = AnnotationTemplateResponse.model_validate(template)
response.configuration = config
response.label_config = label_config # type: ignore
return response