Files
HSAP/platform/as_platform/api/labeling_routes.py
Chengfang Lu 672ef61e17 feat: CVAT 标注引擎、我的标注收件箱与 ADAS Cuboid 送标
- 统一标注引擎为 CVAT:客户端/配置/格式转换、iframe 标注页、docker-compose.cvat.yml 与 no_auth 补丁
- 移除 Label Studio 相关配置与构建脚本,清理 embedded.bak 备份与误提交的 node_modules
- 新增「我的标注」:跨 Campaign 收件箱、逐张清单、CVAT frame 跳转
- 飞书任务分配:通讯录同步选人、按量分配、分配后 DM 通知(含 my-tasks 链接)
- ADAS cuboid_7cls 数据湖接入:workflow 路径、register-batch、开标上传与标注同步
- 数据湖挂载 AS_DATA_LAKE_ROOT、datasets/adas 符号链接、reset_labeling 运维脚本
- 补充 docs/HANDOVER.md 项目交接文档

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 17:25:28 +08:00

508 lines
17 KiB
Python

from __future__ import annotations
import zipfile
from typing import Annotated, Any
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from as_platform.auth.deps import require_any_permission, require_permission
from as_platform.db.models import User
from as_platform.labeling.annotate import (
campaign_bootstrap,
campaign_tasks,
get_annotation,
resolve_media_file,
save_annotation,
)
from as_platform.labeling.lock import acquire_lock, release_lock, renew_lock
from as_platform.labeling.progress import (
assign_tasks_even,
assign_tasks_explicit,
assign_tasks_quantized,
campaign_my_tasks,
campaign_progress,
list_my_assignments,
release_task_assignment,
reassign_task,
user_is_coordinator,
)
from as_platform.labeling.service import (
assign_campaign,
get_campaign,
list_campaign_export_jobs,
list_labeling_assignees,
list_labeling_batches,
open_campaign,
submit_campaign,
trigger_labeling_export,
)
from as_platform.labeling.vendor_import import import_vendor_zip, list_registry_profiles
router = APIRouter(tags=["labeling"])
class OpenCampaignBody(BaseModel):
project: str = Field(..., pattern="^(dms|lane|adas)$")
task: str
batch: str
mode: str | None = None
pack: str | None = None
location: str = "inbox"
annotation_types: list[str] | None = None
class AssignCampaignBody(BaseModel):
user_id: int | None = None
class AnnotationBody(BaseModel):
result: list[dict[str, Any]] | dict[str, Any] | None = None
annotations: list[dict[str, Any]] | None = None
class AssignTasksExplicitItem(BaseModel):
user_id: int
task_ids: list[str] = Field(default_factory=list)
class AssignTasksQuantizedItem(BaseModel):
user_id: int
count: int = 0
class AssignTasksBody(BaseModel):
mode: str = "even"
user_ids: list[int] | None = None
items: list[AssignTasksExplicitItem] | None = None
quantized_items: list[AssignTasksQuantizedItem] | None = None
class ReassignTaskBody(BaseModel):
user_id: int
@router.get("/api/v1/labeling/assignees")
def api_labeling_assignees(
_user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
return list_labeling_assignees()
@router.get("/api/v1/labeling/my-assignments")
def api_my_assignments(
user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
return list_my_assignments(user.id)
@router.get("/api/v1/labeling/campaigns/{campaign_id}/my-tasks")
def api_campaign_my_tasks(
campaign_id: str,
user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
try:
return campaign_my_tasks(campaign_id, user.id)
except FileNotFoundError:
raise HTTPException(404, "campaign not found") from None
@router.patch("/api/v1/labeling/campaigns/{campaign_id}/assign")
def api_assign_campaign(
campaign_id: str,
body: AssignCampaignBody,
_user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
try:
return assign_campaign(campaign_id, body.user_id)
except FileNotFoundError:
raise HTTPException(404, "campaign not found") from None
except ValueError as e:
raise HTTPException(400, str(e)) from e
@router.get("/api/v1/labeling/batches")
def api_labeling_batches(
_user: Annotated[User, Depends(require_permission("read:pending"))],
stage: str | None = Query(None),
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
) -> dict[str, Any]:
return list_labeling_batches(stage=stage, offset=offset, limit=limit)
@router.post("/api/v1/labeling/campaigns/open")
def api_open_campaign(
body: OpenCampaignBody,
_user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
return open_campaign(
project=body.project,
task=body.task,
batch=body.batch,
mode=body.mode,
pack=body.pack,
location=body.location,
)
@router.get("/api/v1/labeling/campaigns/{campaign_id}")
def api_get_campaign(
campaign_id: str,
_user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
row = get_campaign(campaign_id)
if not row:
raise HTTPException(404, "campaign not found")
return row
@router.get("/api/v1/labeling/campaigns/{campaign_id}/bootstrap")
def api_campaign_bootstrap(
campaign_id: str,
_user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
try:
return campaign_bootstrap(campaign_id)
except FileNotFoundError:
raise HTTPException(404, "campaign not found") from None
except Exception as e:
raise HTTPException(400, str(e)) from e
@router.get("/api/v1/labeling/campaigns/{campaign_id}/progress")
def api_campaign_progress(
campaign_id: str,
_user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
try:
return campaign_progress(campaign_id)
except FileNotFoundError:
raise HTTPException(404, "campaign not found") from None
@router.post("/api/v1/labeling/campaigns/{campaign_id}/assign-tasks")
def api_assign_tasks(
campaign_id: str,
body: AssignTasksBody,
user: Annotated[User, Depends(require_any_permission("write:labeling_assign", "read:pending"))],
) -> dict[str, Any]:
if not user_is_coordinator(user):
raise HTTPException(403, "仅协调员可分配任务")
try:
if body.mode == "explicit":
items = [{"user_id": i.user_id, "task_ids": i.task_ids} for i in (body.items or [])]
return assign_tasks_explicit(campaign_id, items, assigned_by_user_id=user.id)
if body.mode == "quantized":
items = [{"user_id": i.user_id, "count": i.count} for i in (body.quantized_items or [])]
return assign_tasks_quantized(campaign_id, items, assigned_by_user_id=user.id)
if not body.user_ids:
raise ValueError("even 模式需要 user_ids")
return assign_tasks_even(campaign_id, body.user_ids, assigned_by_user_id=user.id)
except FileNotFoundError:
raise HTTPException(404, "campaign not found") from None
except ValueError as e:
raise HTTPException(400, str(e)) from e
@router.patch("/api/v1/labeling/campaigns/{campaign_id}/assignments/{task_id}")
def api_reassign_task(
campaign_id: str,
task_id: str,
body: ReassignTaskBody,
user: Annotated[User, Depends(require_any_permission("write:labeling_assign", "read:pending"))],
) -> dict[str, Any]:
if not user_is_coordinator(user):
raise HTTPException(403, "仅协调员可改派任务")
try:
return reassign_task(campaign_id, task_id, body.user_id)
except FileNotFoundError:
raise HTTPException(404, "assignment not found") from None
except ValueError as e:
raise HTTPException(400, str(e)) from e
@router.delete("/api/v1/labeling/campaigns/{campaign_id}/assignments/{task_id}")
def api_release_assignment(
campaign_id: str,
task_id: str,
user: Annotated[User, Depends(require_any_permission("write:labeling_assign", "read:pending"))],
) -> dict[str, Any]:
if not user_is_coordinator(user):
raise HTTPException(403, "仅协调员可释放任务")
try:
return release_task_assignment(campaign_id, task_id)
except FileNotFoundError:
raise HTTPException(404, "assignment not found") from None
@router.get("/api/v1/labeling/campaigns/{campaign_id}/tasks")
def api_campaign_tasks(
campaign_id: str,
user: Annotated[User, Depends(require_permission("read:pending"))],
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
assignee: str | None = Query(None),
) -> dict[str, Any]:
try:
eff = assignee
if eff is None and not user_is_coordinator(user):
eff = "me"
return campaign_tasks(campaign_id, offset=offset, limit=limit, user=user, assignee=eff)
except FileNotFoundError:
raise HTTPException(404, "campaign not found") from None
except PermissionError as e:
raise HTTPException(403, str(e)) from e
except Exception as e:
raise HTTPException(400, str(e)) from e
@router.get("/api/v1/labeling/media/{campaign_id}/{file_path:path}")
def api_labeling_media(
campaign_id: str,
file_path: str,
_user: Annotated[User, Depends(require_permission("read:pending"))],
):
try:
target = resolve_media_file(campaign_id, file_path)
except FileNotFoundError:
raise HTTPException(404, "not found") from None
except PermissionError:
raise HTTPException(403, "forbidden") from None
except Exception as e:
raise HTTPException(400, str(e)) from e
return FileResponse(target)
@router.get("/api/v1/labeling/campaigns/{campaign_id}/annotations/{task_id}")
def api_get_annotation(
campaign_id: str,
task_id: str,
_user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
try:
return get_annotation(campaign_id, task_id)
except FileNotFoundError:
raise HTTPException(404, "campaign not found") from None
@router.put("/api/v1/labeling/campaigns/{campaign_id}/annotations/{task_id}")
def api_put_annotation(
campaign_id: str,
task_id: str,
body: AnnotationBody,
user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
try:
return save_annotation(campaign_id, task_id, body.model_dump(exclude_none=True), user=user)
except FileNotFoundError:
raise HTTPException(404, "campaign not found") from None
except PermissionError as e:
raise HTTPException(403, str(e)) from e
except Exception as e:
raise HTTPException(400, str(e)) from e
@router.post("/api/v1/labeling/campaigns/{campaign_id}/export")
def api_labeling_export(
campaign_id: str,
_user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
try:
return trigger_labeling_export(campaign_id)
except FileNotFoundError:
raise HTTPException(404, "campaign not found") from None
@router.get("/api/v1/labeling/campaigns/{campaign_id}/export-jobs")
def api_campaign_export_jobs(
campaign_id: str,
_user: Annotated[User, Depends(require_permission("read:pending"))],
limit: int = Query(30, ge=1, le=100),
) -> dict[str, Any]:
if not get_campaign(campaign_id):
raise HTTPException(404, "campaign not found")
return list_campaign_export_jobs(campaign_id, limit=limit)
@router.post("/api/v1/labeling/campaigns/{campaign_id}/submit")
def api_campaign_submit(
campaign_id: str,
_user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
try:
return submit_campaign(campaign_id)
except ValueError as e:
raise HTTPException(400, str(e)) from None
except FileNotFoundError:
raise HTTPException(404, "campaign not found") from None
@router.get("/api/v1/labeling/registry-profiles")
def api_labeling_registry_profiles(
_user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
return list_registry_profiles()
@router.post("/api/v1/labeling/campaigns/{campaign_id}/import-vendor")
async def api_import_vendor(
campaign_id: str,
_user: Annotated[User, Depends(require_permission("write:labeling_vendor"))],
file: UploadFile = File(...),
) -> dict[str, Any]:
raw = await file.read()
try:
return import_vendor_zip(campaign_id, raw)
except FileNotFoundError:
raise HTTPException(404, "campaign not found") from None
except (zipfile.BadZipFile, ValueError) as e:
raise HTTPException(400, str(e)) from e
@router.post("/api/v1/labeling/campaigns/{campaign_id}/tasks/{task_id}/lock")
def api_labeling_lock_acquire(
campaign_id: str,
task_id: str,
user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
if not get_campaign(campaign_id):
raise HTTPException(404, "campaign not found")
result = acquire_lock(campaign_id, task_id, user_id=user.id, user_name=user.name)
if not result.get("ok"):
raise HTTPException(409, detail=result)
return result
@router.delete("/api/v1/labeling/campaigns/{campaign_id}/tasks/{task_id}/lock")
def api_labeling_lock_release(
campaign_id: str,
task_id: str,
user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
result = release_lock(campaign_id, task_id, user_id=user.id)
if not result.get("ok"):
raise HTTPException(409, detail=result)
return result
@router.post("/api/v1/labeling/campaigns/{campaign_id}/tasks/{task_id}/lock/renew")
def api_labeling_lock_renew(
campaign_id: str,
task_id: str,
user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
result = renew_lock(campaign_id, task_id, user_id=user.id)
if not result.get("ok"):
raise HTTPException(409, detail=result)
return result
# ── CVAT 集成端点 ──
@router.get("/api/v1/labeling/cvat/status/{campaign_id}")
def api_cvat_status(
campaign_id: str,
_user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
from as_platform.labeling.service import get_cvat_status
try:
return get_cvat_status(campaign_id)
except FileNotFoundError:
raise HTTPException(404, "campaign not found") from None
@router.post("/api/v1/labeling/cvat/sync/{campaign_id}")
def api_cvat_sync(
campaign_id: str,
_user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
from as_platform.labeling.service import sync_cvat_annotations
try:
return sync_cvat_annotations(campaign_id)
except FileNotFoundError:
raise HTTPException(404, "campaign not found") from None
except ValueError as e:
raise HTTPException(400, str(e)) from e
except Exception as e:
raise HTTPException(500, f"CVAT 同步失败: {e}") from e
# ── 标注质检 (Quality Review) ──
class ReviewScoreBody(BaseModel):
image_path: str
score: str # good / fine / bad
comment: str | None = None
class ReviewBatchBody(BaseModel):
scores: list[ReviewScoreBody]
@router.get("/api/v1/labeling/campaigns/{campaign_id}/review-queue")
def api_review_queue(
campaign_id: str,
_user: Annotated[User, Depends(require_permission("write:approval_review"))],
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
) -> dict[str, Any]:
from as_platform.audit.review import get_review_queue
return get_review_queue(campaign_id, offset=offset, limit=limit)
@router.get("/api/v1/labeling/campaigns/{campaign_id}/review-image")
def api_review_image(
campaign_id: str,
_user: Annotated[User, Depends(require_permission("write:approval_review"))],
path: str = Query(...),
) -> FileResponse:
from as_platform.audit.review import get_review_image
import tempfile
try:
# Get class names from registry
import yaml
from as_platform.data.core import load_wf, proj_root
wf = load_wf()
root = proj_root(wf, "dms")
reg = yaml.safe_load((root / wf["projects"]["dms"]["registry"]).read_text())
# Build class_names dict from campaign scope
class_names: dict[int, str] = {}
# Try to get from the specific task
tasks = reg.get("tasks", {})
for task_cfg in tasks.values():
names = task_cfg.get("names")
if isinstance(names, list):
for i, n in enumerate(names):
class_names[i] = n
data = get_review_image(campaign_id, path, class_names)
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp.write(data)
tmp.close()
return FileResponse(tmp.name, media_type="image/jpeg")
except FileNotFoundError as e:
raise HTTPException(404, str(e)) from e
@router.post("/api/v1/labeling/campaigns/{campaign_id}/review-submit")
def api_review_submit(
campaign_id: str,
body: ReviewBatchBody,
user: Annotated[User, Depends(require_permission("write:approval_review"))],
) -> dict[str, Any]:
from as_platform.audit.review import submit_review_scores
items = [s.model_dump() for s in body.scores]
return submit_review_scores(campaign_id, items, reviewer_user_id=user.id, reviewer_name=user.name)
@router.get("/api/v1/labeling/campaigns/{campaign_id}/review-progress")
def api_review_progress(
campaign_id: str,
_user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, int]:
from as_platform.audit.review import review_progress
return review_progress(campaign_id)