Files
HSAP/platform/as_platform/api/labeling_routes.py
Chengfang Lu 483e027482 feat: 合并 Docker Compose、标注表格优化与部署文档
将 platform + CVAT 合并为单文件 docker-compose.yml,完善 .env 与 init/dev_up 脚本;
新增 docs/DEPLOY.md 与更新 README 以支持新机器部署;含数据湖示例、车队地图、
紧凑表格 UI、ADAS det_7cls 路径与批次台账等近期改动。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 17:06:31 +08:00

558 lines
18 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.
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,
get_batch_export_stats,
trigger_cuboid_fit,
)
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),
stages: str | None = Query(None, description="逗号分隔多阶段,一次扫描返回"),
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
refresh: bool = Query(False, description="true 时先重建索引再返回"),
q: str | None = Query(None, description="搜索批次名/任务/项目"),
) -> dict[str, Any]:
stage_list = [s.strip() for s in stages.split(",")] if stages else None
return list_labeling_batches(
stage=stage, stages=stage_list, offset=offset, limit=limit, refresh=refresh, q=q,
)
@router.post("/api/v1/labeling/batches/rebuild-index")
def api_rebuild_batch_index(
_user: Annotated[User, Depends(require_permission("write:labeling_assign"))],
) -> dict[str, Any]:
from as_platform.labeling.batch_index import rebuild_batch_index
return rebuild_batch_index()
@router.post("/api/v1/labeling/batches/{campaign_id}/archive")
def api_archive_batch(
campaign_id: str,
_user: Annotated[User, Depends(require_permission("write:labeling_assign"))],
) -> dict[str, Any]:
from as_platform.labeling.batch_index import archive_batch
try:
return archive_batch(campaign_id)
except FileNotFoundError:
raise HTTPException(404, "batch not found") from None
except ValueError as e:
raise HTTPException(400, str(e)) from e
@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-stats")
def api_batch_export_stats(
campaign_id: str,
_user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
try:
return get_batch_export_stats(campaign_id)
except FileNotFoundError:
raise HTTPException(404, "campaign not found") from None
@router.post("/api/v1/labeling/campaigns/{campaign_id}/cuboid-fit")
def api_cuboid_fit(
campaign_id: str,
_user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
try:
return trigger_cuboid_fit(campaign_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/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:
data = get_review_image(campaign_id, path)
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, Any]:
from as_platform.audit.review import review_progress
return review_progress(campaign_id)
@router.get("/api/v1/labeling/review-progress")
def api_review_progress_batch(
_user: Annotated[User, Depends(require_permission("read:pending"))],
campaign_ids: str = Query(..., description="逗号分隔 campaign id最多 50 个"),
) -> dict[str, Any]:
from as_platform.audit.review import review_progress_batch
ids = [x.strip() for x in campaign_ids.split(",") if x.strip()]
return review_progress_batch(ids)