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>
This commit is contained in:
2026-06-15 17:25:28 +08:00
parent e72bc061c5
commit 672ef61e17
5281 changed files with 5194 additions and 1315918 deletions

View File

@@ -1,4 +1,4 @@
"""标注画布:批次目录、LS 配置 XML、任务列表、标注 JSON、媒体文件。"""
"""标注数据湖:批次目录、任务列表、标注 JSON、媒体文件CVAT 为唯一标注引擎)"""
from __future__ import annotations
import hashlib
@@ -8,55 +8,27 @@ from pathlib import Path
from typing import Any
from urllib.parse import quote
import yaml
from as_platform.config import WORKSPACE
from as_platform.data.batch import IMG_EXTS
from as_platform.data.core import load_wf, proj_root, resolve_pack_dir
from as_platform.db.engine import session_scope
from as_platform.db.models import LabelingCampaign, User
from as_platform.labeling.scope import (
enrich_batch_labels,
labeling_profile_key,
load_dms_registry,
load_labeling_registry,
)
from as_platform.labeling.scope import enrich_batch_labels, load_dms_registry
# 历史目录名保留,导出脚本仍读取 labels/ls_annotations/
ANNOTATIONS_DIRNAME = "ls_annotations"
def _label_config_dir(project: str) -> Path:
return WORKSPACE / "datasets" / project / "configs" / "label_studio"
_FALLBACK_XML = """<View>
<Image name="image" value="$image"/>
<RectangleLabels name="label" toName="image">
<Label value="object"/>
</RectangleLabels>
</View>"""
def _load_campaign(campaign_id: str) -> LabelingCampaign | None:
with session_scope() as db:
return db.get(LabelingCampaign, campaign_id)
def resolve_editor_xml(project: str, task: str, mode: str | None) -> str:
reg = load_dms_registry() if project == "dms" else None
pk = labeling_profile_key(project, task or "lane_v1", mode, reg)
prof = (load_labeling_registry().get("profiles") or {}).get(pk) or {}
default_tpl = "dam_15cls.xml" if project == "dms" else "lane_ufld_mask.xml"
template = prof.get("editor_template") or default_tpl
path = _label_config_dir(project) / template
if path.is_file():
return path.read_text(encoding="utf-8")
return _FALLBACK_XML
def resolve_campaign_batch_dir(camp: LabelingCampaign) -> Path:
wf = load_wf()
root = proj_root(wf, camp.project)
if camp.project == "dms":
import yaml
reg = yaml.safe_load((root / wf["projects"]["dms"]["registry"]).read_text(encoding="utf-8"))
tcfg = reg["tasks"][camp.task]
if camp.location == "sources":
@@ -78,6 +50,10 @@ def resolve_campaign_batch_dir(camp: LabelingCampaign) -> Path:
if mode:
return (root / "inbox" / camp.task / mode / camp.batch).resolve()
return (root / "inbox" / camp.task / camp.batch).resolve()
if camp.project == "adas":
if not camp.task:
raise ValueError("adas campaign 需要 task")
return (root / "inbox" / camp.task / camp.batch).resolve()
if camp.location == "pack" and camp.pack:
try:
from as_platform.data.core import resolve_pack
@@ -128,23 +104,13 @@ def _annotations_dir(batch_dir: Path) -> Path:
return d
def sync_campaign_config_xml(camp: LabelingCampaign) -> str:
"""始终与 labeling.registry + 模板文件对齐,避免 campaign 卡在旧 fallback XML。"""
xml = resolve_editor_xml(camp.project, camp.task, camp.mode)
if camp.config_xml != xml:
camp.config_xml = xml
return xml
def campaign_bootstrap(campaign_id: str) -> dict[str, Any]:
with session_scope() as db:
camp = db.get(LabelingCampaign, campaign_id)
if not camp:
raise FileNotFoundError("campaign not found")
sync_campaign_config_xml(camp)
reg = load_dms_registry() if camp.project == "dms" else None
row = enrich_batch_labels(camp.to_dict(), reg)
row["config_xml"] = camp.config_xml
try:
batch_dir = resolve_campaign_batch_dir(camp)
row["batch_path"] = str(batch_dir)
@@ -153,6 +119,9 @@ def campaign_bootstrap(campaign_id: str) -> dict[str, Any]:
row["batch_path"] = None
row["image_count"] = 0
row["batch_error"] = str(e)
row["editor"] = "cvat"
row["cvat_task_id"] = camp.cvat_task_id
row["cvat_job_url"] = camp.cvat_job_url
return row
@@ -259,7 +228,7 @@ def save_annotation(
batch_dir = resolve_campaign_batch_dir(camp)
path = _annotations_dir(batch_dir) / f"{task_id}.json"
now = datetime.now(timezone.utc).isoformat()
extra: dict[str, Any] = {}
extra: dict[str, Any] = {"source": "hsap", "saved_at": now}
if user:
extra["completed_by_user_id"] = user.id
extra["completed_at"] = now

View File

@@ -0,0 +1,254 @@
"""CVAT 标注引擎客户端:通过 REST API 管理 Task/Job上传数据拉取标注结果。"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import urljoin, urlparse
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
# ── 配置 ──────────────────────────────────────────────
# CVAT_HOST: 容器内 REST API 地址HSAP 后端调用,无需 CVAT 账号)
# CVAT_PUBLIC_URL: 浏览器 iframe 嵌入地址(标注画布,用户只通过 HSAP 进入)
_CVAT_HOST = os.environ.get("CVAT_HOST", "http://cvat_traefik:8080")
_CVAT_PUBLIC_URL = os.environ.get("CVAT_PUBLIC_URL", "http://127.0.0.1:8080").rstrip("/")
_CVAT_EXTRA_HEADERS = {"Host": "localhost"} # traefik host-based routing for internal Docker access
def public_job_url(task_id: int, job_id: int) -> str:
"""浏览器可访问的标注页 URL由 HSAP iframe 嵌入,不暴露 CVAT 账号体系)。"""
return f"{_CVAT_PUBLIC_URL}/tasks/{task_id}/jobs/{job_id}"
def public_job_url_with_frame(job_url: str, frame_index: int) -> str:
"""在 CVAT Job URL 上附加帧索引,用于定位到指定图片。"""
if frame_index < 0:
return job_url
sep = "&" if "?" in job_url else "?"
return f"{job_url}{sep}frame={frame_index}"
def _session() -> requests.Session:
s = requests.Session()
s.headers.update({"Accept": "application/vnd.cvat+json; version=2.0"})
s.headers.update(_CVAT_EXTRA_HEADERS)
retry = Retry(total=2, backoff_factor=0.3, status_forcelist=[429, 502, 503, 504])
adapter = HTTPAdapter(max_retries=retry)
s.mount("http://", adapter)
s.mount("https://", adapter)
return s
@dataclass
class CVATTask:
id: int
name: str
status: str
url: str
job_url: str | None = None
job_id: int | None = None
class CVATClient:
"""封装 CVAT REST API提供创建任务、上传数据、拉取标注等功能。"""
def __init__(self, host: str | None = None):
self.host = (host or _CVAT_HOST).rstrip("/")
self._session = _session()
self._api = f"{self.host}/api"
# ── 健康检查 ───────────────────────────────────────
def ping(self) -> bool:
try:
r = self._session.get(f"{self._api}/tasks?page_size=1", timeout=5)
return 200 <= r.status_code < 400
except Exception:
return False
# ── Task CRUD ──────────────────────────────────────
def create_task(
self,
name: str,
labels: list[dict[str, Any]],
*,
project_id: int | None = None,
subset: str | None = None,
bug_tracker: str | None = None,
) -> CVATTask:
"""创建 CVAT Task带标注标签定义"""
payload: dict[str, Any] = {
"name": name,
"labels": labels,
}
if project_id:
payload["project_id"] = project_id
if subset:
payload["subset"] = subset
if bug_tracker:
payload["bug_tracker"] = bug_tracker
r = self._session.post(f"{self._api}/tasks", json=payload)
r.raise_for_status()
data = r.json()
return CVATTask(id=data["id"], name=data["name"], status=data.get("status", ""), url=data.get("url", ""))
def _resolve_job(self, data: dict, task_id: int) -> tuple[int | None, str | None]:
jobs = data.get("jobs") or {}
if isinstance(jobs, list):
if jobs:
jid = jobs[0].get("id")
return jid, public_job_url(task_id, jid)
elif isinstance(jobs, dict):
count = jobs.get("count", 0)
if count > 0:
job_url = f"{self._api}/jobs?task_id={task_id}"
jr = self._session.get(job_url)
jr.raise_for_status()
jresults = jr.json().get("results", [])
if jresults:
jid = jresults[0].get("id")
return jid, public_job_url(task_id, jid)
return None, None
def get_task(self, task_id: int) -> CVATTask:
r = self._session.get(f"{self._api}/tasks/{task_id}")
r.raise_for_status()
data = r.json()
job_id, job_url = self._resolve_job(data, task_id)
return CVATTask(id=data["id"], name=data["name"], status=data.get("status", ""), url=data.get("url", ""), job_url=job_url, job_id=job_id)
def list_tasks(self, *, status: str | None = None, name: str | None = None) -> list[CVATTask]:
params: dict[str, str] = {}
if status:
params["status"] = status
if name:
params["search"] = name
r = self._session.get(f"{self._api}/tasks", params=params)
r.raise_for_status()
results = r.json().get("results", [])
tasks = []
for data in results:
job_id, job_url = self._resolve_job(data, data["id"])
tasks.append(CVATTask(id=data["id"], name=data["name"], status=data.get("status", ""), url=data.get("url", ""), job_url=job_url, job_id=job_id))
return tasks
def delete_task(self, task_id: int) -> None:
r = self._session.delete(f"{self._api}/tasks/{task_id}")
r.raise_for_status()
def get_task_status(self, task_id: int) -> str:
return self.get_task(task_id).status
# ── 数据上传 ───────────────────────────────────────
def upload_images(self, task_id: int, image_paths: list[Path]) -> None:
"""将图片文件上传到指定 Task。"""
# CVAT Data API: POST /api/tasks/{id}/data
files = {}
opened: list = []
for i, p in enumerate(image_paths):
if not p.is_file():
continue
f = open(p, "rb")
opened.append(f)
files[f"client_files[{i}]"] = (p.name, f, "image/jpeg")
try:
r = self._session.post(f"{self._api}/tasks/{task_id}/data", files=files, data={"image_quality": 70})
r.raise_for_status()
finally:
for f in opened:
f.close()
def upload_annotations(self, task_id: int, annotation_file: Path, fmt: str = "KITTI 1.0") -> None:
"""上传已有标注(如 KITTI 格式的 label_2"""
with open(annotation_file, "rb") as f:
r = self._session.put(
f"{self._api}/tasks/{task_id}/annotations",
files={"annotation_file": (annotation_file.name, f)},
data={"format": fmt},
)
r.raise_for_status()
# ── 标注拉取 ───────────────────────────────────────
def download_annotations(self, task_id: int, fmt: str = "KITTI 1.0") -> bytes:
"""下载标注结果,返回原始字节。"""
r = self._session.get(f"{self._api}/tasks/{task_id}/annotations", params={"format": fmt})
r.raise_for_status()
return r.content
def download_annotations_json(self, task_id: int) -> dict[str, Any]:
"""拉取 Job 级标注 JSONCVAT 2.x 已废弃 task 级 export GET"""
task = self.get_task(task_id)
if not task.job_id:
raise ValueError(f"CVAT task {task_id} 尚无 Job请等待数据上传完成")
return self.get_job_annotations(task.job_id)
def get_job_annotations(self, job_id: int) -> dict[str, Any]:
r = self._session.get(f"{self._api}/jobs/{job_id}/annotations")
r.raise_for_status()
return r.json()
def get_job_data_meta(self, job_id: int) -> dict[str, Any]:
r = self._session.get(f"{self._api}/jobs/{job_id}/data/meta")
r.raise_for_status()
return r.json()
def get_job_label_map(self, job_id: int) -> dict[int, str]:
r = self._session.get(f"{self._api}/labels", params={"job_id": job_id})
r.raise_for_status()
return {lb["id"]: lb["name"] for lb in r.json().get("results", [])}
# ── Job 管理 ───────────────────────────────────────
def get_job_url(self, task_id: int, job_index: int = 0) -> str | None:
"""获取可用于 iframe 嵌入的 Job URL。"""
task = self.get_task(task_id)
return task.job_url
def get_job_status(self, task_id: int) -> str:
task = self.get_task(task_id)
return task.status
# ── 3D Cuboid 标注 ─────────────────────────────────
def upload_cuboid_xml(self, task_id: int, xml_content: str) -> None:
"""上传 3D cuboid 标注CVAT for images 1.1 XML 格式)。"""
import io
r = self._session.post(
f"{self._api}/tasks/{task_id}/annotations?format=CVAT+1.1",
files={"annotation_file": ("annotations.xml", io.BytesIO(xml_content.encode()), "application/xml")},
)
r.raise_for_status()
return r.json()
# ── Project 管理(可选) ────────────────────────────
def create_project(self, name: str, labels: list[dict[str, Any]]) -> dict[str, Any]:
r = self._session.post(f"{self._api}/projects", json={"name": name, "labels": labels})
r.raise_for_status()
return r.json()
def list_projects(self) -> list[dict[str, Any]]:
r = self._session.get(f"{self._api}/projects")
r.raise_for_status()
return r.json().get("results", [])
# ── 全局客户端实例 ────────────────────────────────────
_client: CVATClient | None = None
def get_cvat_client() -> CVATClient:
global _client
if _client is None:
_client = CVATClient()
return _client

View File

@@ -0,0 +1,148 @@
"""CVAT 标注配置:为 DMS / ADAS / Lane 生成 label schema唯一标注引擎"""
from __future__ import annotations
from typing import Any
from as_platform.config import WORKSPACE
from as_platform.labeling.scope import labeling_profile_key, load_dms_registry, load_labeling_registry
# Lane 车道线 — 折线
LANE_LABELS: list[dict[str, Any]] = [
{
"name": "lane_line",
"type": "polyline",
"attributes": [
{
"name": "type",
"mutable": False,
"input_type": "select",
"values": ["solid", "dashed", "double_solid", "double_dashed", "solid_dashed"],
"default_value": "solid",
},
{
"name": "color",
"mutable": False,
"input_type": "select",
"values": ["white", "yellow", "blue", "other"],
"default_value": "white",
},
],
},
{
"name": "curb",
"type": "polyline",
"attributes": [
{
"name": "type",
"mutable": False,
"input_type": "select",
"values": ["high", "low", "none"],
"default_value": "low",
},
],
},
{"name": "stop_line", "type": "polyline"},
]
# ADAS cuboid_7cls — 单目图像 cuboid
ADAS_CUBOID_7CLS_LABELS: list[dict[str, Any]] = [
{"name": "car", "type": "cuboid"},
{"name": "pedestrian", "type": "cuboid"},
{"name": "truck", "type": "cuboid"},
{"name": "bus", "type": "cuboid"},
{"name": "motorcycle", "type": "cuboid"},
{"name": "tricycle", "type": "cuboid"},
{"name": "traffic cone", "type": "cuboid"},
]
def _rect_label(name: str, **attrs: Any) -> dict[str, Any]:
lb: dict[str, Any] = {"name": name, "type": "rectangle"}
if attrs:
lb["attributes"] = attrs
return lb
def _dms_registry_task_config(task: str, mode: str | None) -> dict[str, Any]:
import sys
scripts = WORKSPACE / "datasets" / "dms" / "scripts"
if str(scripts) not in sys.path:
sys.path.insert(0, str(scripts))
from task_registry import get_mode_config, resolve_task_id
reg = load_dms_registry()
task_r, mode_r = resolve_task_id(task, mode)
return get_mode_config(task_r, mode_r, reg)
def _labels_from_class_names(names: list[str] | dict[int | str, str]) -> list[dict[str, Any]]:
if isinstance(names, dict):
ordered = [names[k] for k in sorted(names, key=lambda x: int(x))]
else:
ordered = list(names)
return [_rect_label(str(n)) for n in ordered]
def _dms_pose_labels() -> list[dict[str, Any]]:
labels: list[dict[str, Any]] = [_rect_label("face")]
labels.extend({"name": f"kp_{i:02d}", "type": "points"} for i in range(37))
return labels
def _dms_labels(task: str, mode: str | None) -> list[dict[str, Any]]:
tcfg = _dms_registry_task_config(task, mode)
ttype = tcfg.get("type") or "detect"
if ttype == "pose":
return _dms_pose_labels()
names = tcfg.get("names")
if names:
return _labels_from_class_names(names)
return [_rect_label("object")]
def _labels_from_registry_profile(project: str, task: str, mode: str | None) -> list[dict[str, Any]] | None:
reg = load_dms_registry() if project == "dms" else None
pk = labeling_profile_key(project, task, mode, reg)
prof = (load_labeling_registry().get("profiles") or {}).get(pk) or {}
cvat_names = prof.get("cvat_labels")
if cvat_names:
return [{"name": str(n), "type": "cuboid"} for n in cvat_names]
return None
def build_cvat_labels(
project: str,
task: str | None = None,
mode: str | None = None,
annotation_types: list[str] | None = None,
) -> list[dict[str, Any]]:
"""根据 HSAP project/task/mode 生成 CVAT Task 的 labels 定义。"""
task = task or ""
prof_labels = _labels_from_registry_profile(project, task, mode)
if prof_labels is not None:
return prof_labels
if project == "adas":
if task == "cuboid_7cls":
return ADAS_CUBOID_7CLS_LABELS
return ADAS_CUBOID_7CLS_LABELS
if project == "lane":
return LANE_LABELS
if project == "dms":
return _dms_labels(task, mode)
return [_rect_label("object")]
def resolve_annotation_types(project: str, task: str | None = None, mode: str | None = None) -> list[str]:
mapping = {
"dms": ["bbox", "keypoint"],
"adas": ["cuboid"],
"lane": ["polyline"],
}
if project == "adas" and task == "cuboid_7cls":
return ["cuboid"]
return mapping.get(project, ["bbox"])

View File

@@ -0,0 +1,625 @@
"""标注格式转换器KITTI / CVAT JSON / YOLO / COCO / HSAP quaternion 互转。"""
from __future__ import annotations
import json
import math
from pathlib import Path
from typing import Any
# ═══════════════════════════════════════════════════════
# Quaternion ↔ Euler 辅助
# ═══════════════════════════════════════════════════════
def quat_to_rot_y(qw: float, qx: float, qy: float, qz: float) -> float:
"""四元数 → 绕 Y 轴旋转角 (KITTI rot_y)。"""
# rot_y = atan2(2*(qw*qy + qx*qz), 1 - 2*(qy^2 + qx^2))
sin_y = 2.0 * (qw * qy + qx * qz)
cos_y = 1.0 - 2.0 * (qy * qy + qx * qx)
return math.atan2(sin_y, cos_y)
def rot_y_to_quat(rot_y: float) -> tuple[float, float, float, float]:
"""绕 Y 轴旋转角 → 四元数 (qw, qx, qy, qz)。"""
half = rot_y / 2.0
return (math.cos(half), 0.0, math.sin(half), 0.0)
# ═══════════════════════════════════════════════════════
# 原始 quaternion 格式 → KITTI label_2
# ═══════════════════════════════════════════════════════
def quaternion_line_to_kitti(line: str, calib_bbox_fn=None) -> str | None:
"""将一行 quaternion 格式转为 KITTI label_2 行。
输入格式 (空格分隔):
Class x y z w l h qw qx qy qz class_id truncation [bbox_2d_8values] [extra...]
输出格式 (KITTI label_2):
Class truncated occluded alpha bbox_x1 bbox_y1 bbox_x2 bbox_y2 h w l x y z rot_y [score]
"""
parts = line.strip().split()
if len(parts) < 13:
return None
label = parts[0]
x = float(parts[1])
y = float(parts[2])
z = float(parts[3])
w = float(parts[4])
l = float(parts[5])
h_dim = float(parts[6])
qw = float(parts[7])
qx = float(parts[8])
qy = float(parts[9])
qz = float(parts[10])
class_id = int(parts[11]) if len(parts) > 11 else 0
truncation = int(parts[12]) if len(parts) > 12 else 0
rot_y = quat_to_rot_y(qw, qx, qy, qz)
# KITTI: alpha = rot_y - arctan(center_x / center_z), 简化处理
alpha = rot_y
# 2D bbox (如果存在: 后续8个值)
bbox_2d = None
if len(parts) >= 21:
try:
bbox_2d = [float(p) for p in parts[13:21]]
except ValueError:
pass
# 截断和遮挡
occluded = 0 # quaternion 格式没有直接对应
# KITTI 位置是 camera coordinate: x(right), y(down), z(forward)
# quaternion 格式是 LiDAR coordinate: x(forward), y(left), z(up)
# 简化转换x_kitti = -y_lidar, y_kitti = -z_lidar, z_kitti = x_lidar
kitti_x = -y
kitti_y = -z
kitti_z = x
# KITTI 3D 尺寸: height, width, length
if bbox_2d and len(bbox_2d) == 8:
x1, y1, x2, y2 = bbox_2d[0:4]
else:
x1 = y1 = x2 = y2 = 0
# Format: Class truncated occluded alpha x1 y1 x2 y2 h w l x y z rot_y
return (
f"{label} {truncation} {occluded} {alpha:.6f} "
f"{x1:.2f} {y1:.2f} {x2:.2f} {y2:.2f} "
f"{h_dim:.6f} {w:.6f} {l:.6f} "
f"{kitti_x:.6f} {kitti_y:.6f} {kitti_z:.6f} "
f"{rot_y:.6f}"
)
# ═══════════════════════════════════════════════════════
# KITTI → 原始 quaternion 格式
# ═══════════════════════════════════════════════════════
def kitti_line_to_quaternion(line: str) -> str | None:
"""KITTI label_2 → quaternion 格式回传HSAP"""
parts = line.strip().split()
if len(parts) < 15:
return None
label = parts[0]
alpha = float(parts[3])
bbox = [float(p) for p in parts[4:8]]
h_dim = float(parts[8])
w = float(parts[9])
l = float(parts[10])
kx = float(parts[11])
ky = float(parts[12])
kz = float(parts[13])
rot_y = float(parts[14])
# 逆转换
x = kz # LiDAR X = KITTI Z
y = -kx # LiDAR Y = -KITTI X
z = -ky # LiDAR Z = -KITTI Y
qw, qx, qy, qz = rot_y_to_quat(rot_y)
# 输出 quaternion 格式
return (
f"{label} {x:.6f} {y:.6f} {z:.6f} {w:.6f} {l:.6f} {h_dim:.6f} "
f"{qw:.6f} {qx:.6f} {qy:.6f} {qz:.6f} 0 0 "
f"{bbox[0]:.2f} {bbox[1]:.2f} {bbox[2]:.2f} {bbox[3]:.2f} "
f"0 0 0 0 0 0 1"
)
# ═══════════════════════════════════════════════════════
# CVAT Job API shapes → HSAP / YOLO
# ═══════════════════════════════════════════════════════
def cvat_shape_to_result_item(
shape: dict[str, Any],
label_map: dict[int, str],
) -> dict[str, Any]:
"""CVAT Job annotations API 单条 shape → HSAP result 条目。"""
label = label_map.get(shape.get("label_id"), "unknown")
stype = shape.get("type", "")
item: dict[str, Any] = {
"type": stype,
"label": label,
"source": "cvat",
"cvat_id": shape.get("id"),
"frame": shape.get("frame", 0),
}
if stype == "rectangle":
item["points"] = [
shape.get("xtl", 0),
shape.get("ytl", 0),
shape.get("xbr", 0),
shape.get("ybr", 0),
]
elif stype == "cuboid":
for key in (
"xtl1", "ytl1", "xtr1", "ytr1", "xbl1", "ybl1", "xbr1", "ybr1",
"xtl2", "ytl2", "xtr2", "ytr2", "xbl2", "ybl2", "xbr2", "ybr2",
):
if key in shape:
item[key] = shape[key]
if shape.get("points"):
item["points"] = shape["points"]
elif stype in ("polyline", "polygon", "points"):
item["points"] = shape.get("points", [])
return item
def cvat_job_shapes_to_yolo_lines(
shapes: list[dict[str, Any]],
label_map: dict[int, str],
class_map: dict[str, int],
img_width: int,
img_height: int,
) -> list[str]:
lines: list[str] = []
for shape in shapes:
if shape.get("type") != "rectangle":
continue
label = label_map.get(shape.get("label_id"), "")
class_id = class_map.get(label)
if class_id is None:
# 尝试大小写不敏感匹配
for name, cid in class_map.items():
if name.lower() == label.lower():
class_id = cid
break
if class_id is None:
continue
x1, y1, x2, y2 = (
float(shape.get("xtl", 0)),
float(shape.get("ytl", 0)),
float(shape.get("xbr", 0)),
float(shape.get("ybr", 0)),
)
if img_width <= 0 or img_height <= 0:
continue
cx = ((x1 + x2) / 2) / img_width
cy = ((y1 + y2) / 2) / img_height
bw = (x2 - x1) / img_width
bh = (y2 - y1) / img_height
lines.append(f"{class_id} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}")
return lines
def group_cvat_job_shapes_by_frame(
job_annotations: dict[str, Any],
) -> dict[int, list[dict[str, Any]]]:
grouped: dict[int, list[dict[str, Any]]] = {}
for shape in job_annotations.get("shapes") or []:
frame = int(shape.get("frame", 0))
grouped.setdefault(frame, []).append(shape)
return grouped
def cvat_shapes_to_export_regions(
shapes: list[dict[str, Any]],
label_map: dict[int, str],
img_width: int,
img_height: int,
) -> list[dict[str, Any]]:
"""CVAT Job shapes → HSAP 导出链兼容的 result[](原 Label Studio 字段布局)。"""
if img_width <= 0 or img_height <= 0:
return []
regions: list[dict[str, Any]] = []
for shape in shapes:
stype = shape.get("type") or ""
label = label_map.get(shape.get("label_id"), "unknown")
base = {
"id": str(shape.get("id", "")),
"original_width": img_width,
"original_height": img_height,
}
if stype == "rectangle":
xtl = float(shape.get("xtl", 0))
ytl = float(shape.get("ytl", 0))
xbr = float(shape.get("xbr", 0))
ybr = float(shape.get("ybr", 0))
regions.append({
**base,
"type": "rectanglelabels",
"value": {
"x": xtl / img_width * 100.0,
"y": ytl / img_height * 100.0,
"width": (xbr - xtl) / img_width * 100.0,
"height": (ybr - ytl) / img_height * 100.0,
"rotation": 0,
"rectanglelabels": [label],
},
})
elif stype == "points":
pts = shape.get("points") or []
if len(pts) < 2:
continue
regions.append({
**base,
"type": "keypointlabels",
"value": {
"x": float(pts[0]) / img_width * 100.0,
"y": float(pts[1]) / img_height * 100.0,
"width": 0.5,
"keypointlabels": [label],
},
})
elif stype in ("polyline", "polygon"):
regions.append({
**base,
"type": "polyline",
"label": label,
"points": list(shape.get("points") or []),
})
elif stype == "cuboid":
item = cvat_shape_to_result_item(shape, label_map)
item["original_width"] = img_width
item["original_height"] = img_height
regions.append(item)
return regions
# ═══════════════════════════════════════════════════════
# CVAT JSON → YOLO bbox
# ═══════════════════════════════════════════════════════
def cvat_json_to_yolo(
cvat_annotations: dict[str, Any],
class_map: dict[str, int],
img_width: int = 1920,
img_height: int = 1080,
) -> dict[str, list[str]]:
"""CVAT annotations JSON → YOLO 格式文件内容。
返回 {image_name: [yolo_line, ...]} 的字典。
"""
result: dict[str, list[str]] = {}
for img_ann in cvat_annotations.get("annotations", []):
frame = img_ann.get("frame", 0)
img_name = _resolve_image_name(cvat_annotations, img_ann)
lines: list[str] = []
for shape in img_ann.get("shapes", []):
shape_type = shape.get("type", "")
label_name = shape.get("label", "")
class_id = class_map.get(label_name)
if class_id is None:
continue
if shape_type == "rectangle":
# YOLO: class_id cx cy w h (归一化 0-1)
x1, y1, x2, y2 = (shape.get(p, 0) for p in ("xtl", "ytl", "xbr", "ybr"))
cx = ((x1 + x2) / 2) / img_width
cy = ((y1 + y2) / 2) / img_height
bw = (x2 - x1) / img_width
bh = (y2 - y1) / img_height
lines.append(f"{class_id} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}")
if lines:
result[img_name] = lines
return result
# ═══════════════════════════════════════════════════════
# CVAT JSON → COCO keypoints
# ═══════════════════════════════════════════════════════
def cvat_json_to_coco_keypoints(
cvat_annotations: dict[str, Any],
keypoint_labels: list[str],
image_dir: Path | None = None,
) -> dict[str, Any]:
"""提取 CVAT 关键点标注 → COCO keypoints 格式。"""
images: list[dict[str, Any]] = []
annotations: list[dict[str, Any]] = []
# 构建 keypoint_label → id 映射
kp_map = {name: i for i, name in enumerate(keypoint_labels)}
ann_id = 0
for img_idx, img_ann in enumerate(cvat_annotations.get("annotations", [])):
img_name = _resolve_image_name(cvat_annotations, img_ann)
img_w = img_ann.get("width", 1920)
img_h = img_ann.get("height", 1080)
img_id = img_idx + 1
images.append({"id": img_id, "file_name": img_name, "width": img_w, "height": img_h})
for shape in img_ann.get("shapes", []):
if shape.get("type") != "points":
continue
points = shape.get("points", [])
if not points:
continue
# points 格式: [[x1,y1], [x2,y2], ...]
keypoints_list: list[float] = []
num_keypoints = 0
for kp_label in keypoint_labels:
kp_data = next((p for p in points if p.get("label") == kp_label), None)
if kp_data:
keypoints_list.extend([kp_data.get("x", 0), kp_data.get("y", 0), 2]) # visible
num_keypoints += 1
else:
keypoints_list.extend([0, 0, 0]) # not labeled
annotations.append({
"id": ann_id,
"image_id": img_id,
"category_id": 1,
"keypoints": keypoints_list,
"num_keypoints": num_keypoints,
"bbox": _keypoint_bbox(keypoints_list, img_w, img_h),
})
ann_id += 1
return {
"images": images,
"annotations": annotations,
"categories": [{"id": 1, "name": "person", "keypoints": keypoint_labels, "skeleton": []}],
}
# ═══════════════════════════════════════════════════════
# CVAT JSON → HSAP Lane polyline
# ═══════════════════════════════════════════════════════
def cvat_json_to_lane_polylines(
cvat_annotations: dict[str, Any],
) -> dict[str, list[dict[str, Any]]]:
"""提取 CVAT 折线标注 → HSAP 车道线格式。"""
result: dict[str, list[dict[str, Any]]] = {}
for img_ann in cvat_annotations.get("annotations", []):
img_name = _resolve_image_name(cvat_annotations, img_ann)
polylines: list[dict[str, Any]] = []
for shape in img_ann.get("shapes", []):
if shape.get("type") not in ("polyline", "polygon"):
continue
points = shape.get("points", [])
if not points:
continue
attrs = {a.get("name"): a.get("value") for a in (shape.get("attributes") or [])}
polylines.append({
"label": shape.get("label", "lane_line"),
"attributes": attrs,
"points": [[p.get("x", 0), p.get("y", 0)] for p in points],
})
if polylines:
result[img_name] = polylines
return result
# ═══════════════════════════════════════════════════════
# 辅助函数
# ═══════════════════════════════════════════════════════
def _resolve_image_name(annotations: dict[str, Any], img_ann: dict[str, Any]) -> str:
"""从 CVAT annotation JSON 中解析图像文件名。"""
frame = img_ann.get("frame", 0)
images = annotations.get("images", [])
if isinstance(images, list) and frame < len(images):
img_info = images[frame]
if isinstance(img_info, dict):
return img_info.get("file_name", f"frame_{frame}")
return img_ann.get("name", f"frame_{frame}")
def _keypoint_bbox(kpts: list[float], img_w: int, img_h: int) -> list[float]:
"""从 keypoints 列表计算 bbox [x, y, w, h]。"""
xs = [kpts[i] for i in range(0, len(kpts), 3) if kpts[i + 2] > 0]
ys = [kpts[i + 1] for i in range(0, len(kpts), 3) if kpts[i + 2] > 0]
if not xs or not ys:
return [0, 0, 0, 0]
x_min, x_max = min(xs), max(xs)
y_min, y_max = min(ys), max(ys)
return [x_min, y_min, x_max - x_min, y_max - y_min]
# ═══════════════════════════════════════════════════════
# 批量 KITTI 转换
# ═══════════════════════════════════════════════════════
def convert_quaternion_dir_to_kitti(label_dir: Path, output_dir: Path) -> int:
"""将 quaternion 格式目录批量转换为 KITTI label_2 格式。"""
output_dir.mkdir(parents=True, exist_ok=True)
count = 0
for txt_file in sorted(label_dir.rglob("*.txt")):
kitti_lines: list[str] = []
for line in txt_file.read_text(encoding="utf-8").strip().split("\n"):
if not line.strip():
continue
kitti_line = quaternion_line_to_kitti(line)
if kitti_line:
kitti_lines.append(kitti_line)
if kitti_lines:
out_file = output_dir / txt_file.name
out_file.write_text("\n".join(kitti_lines) + "\n", encoding="utf-8")
count += 1
return count
def convert_cvat_kitti_export_to_hsap(kitti_data: bytes, output_dir: Path) -> int:
"""将 CVAT KITTI 导出zip 字节)解压并转为 HSAP quaternion 格式。"""
import io
import zipfile
output_dir.mkdir(parents=True, exist_ok=True)
count = 0
with zipfile.ZipFile(io.BytesIO(kitti_data)) as zf:
for name in zf.namelist():
if not name.endswith(".txt") or "label_2" not in name:
continue
content = zf.read(name).decode("utf-8")
hsap_lines: list[str] = []
for line in content.strip().split("\n"):
if not line.strip():
continue
hsap_line = kitti_line_to_quaternion(line)
if hsap_line:
hsap_lines.append(hsap_line)
if hsap_lines:
fname = Path(name).name
(output_dir / fname).write_text("\n".join(hsap_lines) + "\n", encoding="utf-8")
count += 1
return count
# ═══════════════════════════════════════════════════════
# ADAS 3D Quaternion JSON → CVAT cuboid XML
# ═══════════════════════════════════════════════════════
from xml.etree.ElementTree import Element, SubElement, tostring
from xml.dom import minidom
from datetime import datetime, timezone
def _get_np():
"""Lazy numpy import."""
import numpy as np
return np
def _quat_to_rotation_matrix(qw: float, qx: float, qy: float, qz: float):
np = _get_np()
return np.array([
[1 - 2*qy**2 - 2*qz**2, 2*qx*qy - 2*qz*qw, 2*qx*qz + 2*qy*qw],
[2*qx*qy + 2*qz*qw, 1 - 2*qx**2 - 2*qz**2, 2*qy*qz - 2*qx*qw],
[2*qx*qz - 2*qy*qw, 2*qy*qz + 2*qx*qw, 1 - 2*qx**2 - 2*qy**2],
])
def _get_3d_corners(center, w, l, h, qw, qx, qy, qz):
"""Compute 8 corners in camera coordinates.
Object frame: x=forward(±l/2), y=left(±w/2), z=up(±h/2)."""
np = _get_np()
ox = np.array([-l/2, -l/2, -l/2, -l/2, l/2, l/2, l/2, l/2])
oy = np.array([-w/2, w/2, w/2, -w/2, -w/2, w/2, w/2, -w/2])
oz = np.array([-h/2, -h/2, h/2, h/2, -h/2, -h/2, h/2, h/2])
corners_obj = np.stack([ox, oy, oz], axis=1)
R = _quat_to_rotation_matrix(qw, qx, qy, qz)
return (R @ corners_obj.T).T + np.array(center)
def _project_2d(pts_3d, K):
pts = pts_3d @ K.T
return pts[:, :2] / pts[:, 2:]
def quaternion_json_to_cvat_cuboid_xml(
json_dir: str | Path,
image_names: list[str],
task_id: int | None = None,
) -> str:
"""将 ADAS 3D quaternion JSON 标注转换为 CVAT cuboid XML。
Args:
json_dir: 包含 .json 标注文件的目录
image_names: 图像文件名列表(与 CVAT task 中的 frame 顺序对应)
task_id: 可选 CVAT task ID
Returns:
CVAT for images 1.1 XML 字符串
"""
json_dir = Path(json_dir)
root = Element("annotations")
SubElement(root, "version").text = "1.1"
meta = SubElement(root, "meta")
te = SubElement(meta, "task")
SubElement(te, "id").text = str(task_id or 0)
SubElement(te, "name").text = "ADAS 3D"
SubElement(te, "size").text = str(len(image_names))
SubElement(te, "mode").text = "annotation"
SubElement(te, "overlap").text = "0"
now = datetime.now(timezone.utc).isoformat()
SubElement(te, "created").text = now
SubElement(te, "updated").text = now
le = SubElement(te, "labels")
for lbl in ["car", "pedestrian", "truck", "bus", "motorcycle", "tricycle", "traffic cone"]:
l = SubElement(le, "label"); SubElement(l, "name").text = lbl; SubElement(l, "attributes")
se = SubElement(te, "segments"); s = SubElement(se, "segment")
SubElement(s, "id").text = "1"; SubElement(s, "start").text = "0"
SubElement(s, "stop").text = str(len(image_names) - 1)
ow = SubElement(te, "owner"); SubElement(ow, "username").text = "platform"; SubElement(ow, "email").text = ""
SubElement(meta, "dumped").text = now
total = 0
for fid, img_name in enumerate(image_names):
stem = Path(img_name).stem
jp = json_dir / f"{stem}.json"
if not jp.is_file():
continue
ann = json.loads(jp.read_text(encoding="utf-8"))
np = _get_np()
K = np.array(ann["K"])
img_w, img_h = ann["image_size"]
ie = SubElement(root, "image")
ie.set("id", str(fid))
ie.set("name", Path(img_name).name)
ie.set("width", str(img_w))
ie.set("height", str(img_h))
for det in ann.get("detections", []):
w, l, h = det["dimensions_wlh"]
c3d = _get_3d_corners(det["center_3d"], w, l, h, *det["quaternion_wxyz"])
if _get_np().any(c3d[:, 2] <= 0):
continue
c2d = _project_2d(c3d, K)
# 4 edge-pairs: (rear, front) × (tl, tr, bl, br)
pairs = [(3, 7), (2, 6), (0, 4), (1, 5)]
pd = []
for ri, fi in pairs:
mid = (c2d[ri] + c2d[fi]) / 2.0
f1_i, f2_i = (fi, ri) if c3d[fi, 2] <= c3d[ri, 2] else (ri, fi)
pd.append({"mid": mid, "f1_i": f1_i, "f2_i": f2_i})
pd.sort(key=lambda p: p["mid"][1])
top = sorted(pd[:2], key=lambda p: p["mid"][0])
bot = sorted(pd[2:], key=lambda p: p["mid"][0])
tl, tr = top[0], top[1]
bl, br = bot[0], bot[1]
cub = SubElement(ie, "cuboid")
cub.set("label", det["class_name"]); cub.set("source", "manual"); cub.set("occluded", "0")
cub.set("xtl1", f"{c2d[tl['f1_i']][0]:.2f}"); cub.set("ytl1", f"{c2d[tl['f1_i']][1]:.2f}")
cub.set("xtr1", f"{c2d[tr['f1_i']][0]:.2f}"); cub.set("ytr1", f"{c2d[tr['f1_i']][1]:.2f}")
cub.set("xbl1", f"{c2d[bl['f1_i']][0]:.2f}"); cub.set("ybl1", f"{c2d[bl['f1_i']][1]:.2f}")
cub.set("xbr1", f"{c2d[br['f1_i']][0]:.2f}"); cub.set("ybr1", f"{c2d[br['f1_i']][1]:.2f}")
cub.set("xtl2", f"{c2d[tl['f2_i']][0]:.2f}"); cub.set("ytl2", f"{c2d[tl['f2_i']][1]:.2f}")
cub.set("xtr2", f"{c2d[tr['f2_i']][0]:.2f}"); cub.set("ytr2", f"{c2d[tr['f2_i']][1]:.2f}")
cub.set("xbl2", f"{c2d[bl['f2_i']][0]:.2f}"); cub.set("ybl2", f"{c2d[bl['f2_i']][1]:.2f}")
cub.set("xbr2", f"{c2d[br['f2_i']][0]:.2f}"); cub.set("ybr2", f"{c2d[br['f2_i']][1]:.2f}")
cub.set("z_order", "0")
total += 1
xml_str = minidom.parseString(tostring(root, 'utf-8')).toprettyxml(indent=" ")
return xml_str

View File

@@ -98,15 +98,23 @@ def campaign_progress(campaign_id: str) -> dict[str, Any]:
)
user_ids = {r.user_id for r in rows} | {r.completed_by_user_id for r in rows if r.completed_by_user_id}
names = _user_name_map(db, user_ids)
assignment_rows = [
{
"user_id": r.user_id,
"task_id": r.task_id,
"completed_at": r.completed_at,
}
for r in rows
]
total = len(all_ids)
completed = len(completed_ids)
assigned = len(rows)
assigned = len(assignment_rows)
by_user_agg: dict[int, dict[str, int]] = defaultdict(lambda: {"assigned": 0, "completed": 0})
for r in rows:
by_user_agg[r.user_id]["assigned"] += 1
if r.completed_at or r.task_id in completed_ids:
by_user_agg[r.user_id]["completed"] += 1
for r in assignment_rows:
by_user_agg[r["user_id"]]["assigned"] += 1
if r["completed_at"] or r["task_id"] in completed_ids:
by_user_agg[r["user_id"]]["completed"] += 1
by_user = []
for uid, stats in sorted(by_user_agg.items(), key=lambda x: names.get(x[0], "")):
@@ -155,9 +163,66 @@ def get_assigned_task_ids(campaign_id: str, user_id: int | None = None) -> set[s
return {row[0] for row in q.all()}
def _assign_result(campaign_id: str, created: int) -> dict[str, Any]:
def _assign_result(
campaign_id: str,
created: int,
notifications: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
prog = campaign_progress(campaign_id)
return {"assigned": created, "by_user": prog["by_user"], "progress": prog}
return {
"assigned": created,
"by_user": prog["by_user"],
"progress": prog,
"notifications": notifications or [],
}
def _build_notify_payload(
assigned_counts: dict[int, int],
user_map: dict[int, User],
camp: LabelingCampaign | None,
campaign_id: str,
) -> tuple[str, str, list[dict[str, Any]]]:
"""在 session 内提取通知所需字段,避免 DetachedInstanceError。"""
task = (camp.task if camp else "") or ""
batch = (camp.batch if camp else "") or campaign_id
targets: list[dict[str, Any]] = []
for uid, count in assigned_counts.items():
if count <= 0:
continue
u = user_map.get(uid)
if not u or not u.feishu_open_id:
continue
targets.append({
"open_id": u.feishu_open_id,
"name": u.name or f"user-{uid}",
"count": count,
})
return task, batch, targets
def _notify_assignees(
campaign_id: str,
task: str,
batch: str,
targets: list[dict[str, Any]],
) -> list[dict[str, Any]]:
if not targets:
return []
from as_platform.integrations.feishu_notify import notify_labeling_assignment
results: list[dict[str, Any]] = []
for t in targets:
r = notify_labeling_assignment(
open_id=t["open_id"],
assignee_name=t["name"],
task=task,
batch=batch,
count=t["count"],
campaign_id=campaign_id,
)
results.append(r)
return results
def assign_tasks_even(
@@ -181,7 +246,10 @@ def assign_tasks_even(
users = db.query(User).filter(User.id.in_(user_ids)).all()
if len(users) != len(set(user_ids)):
raise ValueError("存在无效 user_id")
user_map = {u.id: u for u in users}
camp = db.get(LabelingCampaign, campaign_id)
created = 0
assigned_counts: dict[int, int] = defaultdict(int)
for i, tid in enumerate(unassigned):
uid = user_ids[i % len(user_ids)]
db.add(
@@ -194,8 +262,73 @@ def assign_tasks_even(
)
)
created += 1
assigned_counts[uid] += 1
db.flush()
return _assign_result(campaign_id, created)
task, batch, notify_targets = _build_notify_payload(
dict(assigned_counts), user_map, camp, campaign_id
)
notifications = _notify_assignees(campaign_id, task, batch, notify_targets)
return _assign_result(campaign_id, created, notifications)
def assign_tasks_quantized(
campaign_id: str,
items: list[dict[str, Any]],
*,
assigned_by_user_id: int,
) -> dict[str, Any]:
"""按指定数量分配未分配任务。
items: [{"user_id": 1, "count": 10}, ...]
每个用户从剩余未分配任务中分配最多 count 个。
"""
if not items:
raise ValueError("items 不能为空")
now = _utcnow()
all_ids = list_campaign_task_ids(campaign_id)
with session_scope() as db:
existing = {
r.task_id
for r in db.query(LabelingTaskAssignment)
.filter(LabelingTaskAssignment.campaign_id == campaign_id)
.all()
}
unassigned = [tid for tid in all_ids if tid not in existing]
user_ids = [int(item["user_id"]) for item in items]
users = db.query(User).filter(User.id.in_(user_ids)).all()
user_map = {u.id: u for u in users}
for uid in user_ids:
if uid not in user_map:
raise ValueError(f"用户不存在: {uid}")
camp = db.get(LabelingCampaign, campaign_id)
created = 0
assigned_counts: dict[int, int] = defaultdict(int)
unassigned_iter = iter(unassigned)
for item in items:
uid = int(item["user_id"])
count = int(item.get("count", 0))
for _ in range(count):
try:
tid = next(unassigned_iter)
except StopIteration:
break # 没有更多未分配任务了
db.add(
LabelingTaskAssignment(
campaign_id=campaign_id,
task_id=tid,
user_id=uid,
assigned_by_user_id=assigned_by_user_id,
assigned_at=now,
)
)
created += 1
assigned_counts[uid] += 1
db.flush()
task, batch, notify_targets = _build_notify_payload(
dict(assigned_counts), user_map, camp, campaign_id
)
notifications = _notify_assignees(campaign_id, task, batch, notify_targets)
return _assign_result(campaign_id, created, notifications)
def assign_tasks_explicit(
@@ -207,6 +340,9 @@ def assign_tasks_explicit(
now = _utcnow()
all_set = set(list_campaign_task_ids(campaign_id))
created = 0
assigned_counts: dict[int, int] = defaultdict(int)
user_map: dict[int, User] = {}
camp: LabelingCampaign | None = None
with session_scope() as db:
existing = {
r.task_id
@@ -214,11 +350,13 @@ def assign_tasks_explicit(
.filter(LabelingTaskAssignment.campaign_id == campaign_id)
.all()
}
camp = db.get(LabelingCampaign, campaign_id)
for item in items:
uid = int(item["user_id"])
user = db.get(User, uid)
if not user:
raise ValueError(f"用户不存在: {uid}")
user_map[uid] = user
for tid in item.get("task_ids") or []:
if tid not in all_set:
raise ValueError(f"无效 task_id: {tid}")
@@ -235,8 +373,13 @@ def assign_tasks_explicit(
)
existing.add(tid)
created += 1
assigned_counts[uid] += 1
db.flush()
return _assign_result(campaign_id, created)
task, batch, notify_targets = _build_notify_payload(
dict(assigned_counts), user_map, camp, campaign_id
)
notifications = _notify_assignees(campaign_id, task, batch, notify_targets)
return _assign_result(campaign_id, created, notifications)
def reassign_task(campaign_id: str, task_id: str, user_id: int) -> dict[str, Any]:
@@ -311,3 +454,110 @@ def mark_task_completed(campaign_id: str, task_id: str, user_id: int) -> None:
if row and not row.completed_at:
row.completed_at = now
row.completed_by_user_id = user_id
def list_my_assignments(user_id: int) -> dict[str, Any]:
"""当前用户跨 Campaign 的分配汇总(我的标注收件箱)。"""
from as_platform.config import FRONTEND_URL
items: list[dict[str, Any]] = []
total_pending = 0
base = FRONTEND_URL.rstrip("/")
with session_scope() as db:
rows = (
db.query(LabelingTaskAssignment)
.filter(LabelingTaskAssignment.user_id == user_id)
.all()
)
by_campaign: dict[str, list[dict[str, Any]]] = defaultdict(list)
for r in rows:
by_campaign[r.campaign_id].append({
"task_id": r.task_id,
"completed_at": r.completed_at,
})
for cid, assigns in by_campaign.items():
camp = db.get(LabelingCampaign, cid)
if not camp:
continue
batch_dir = resolve_campaign_batch_dir(camp)
completed_ids = count_completed_tasks(batch_dir)
all_ids = [_task_id_for_image(img, batch_dir) for img in _iter_batch_images(batch_dir)]
assigned = len(assigns)
completed = sum(
1 for a in assigns
if a["completed_at"] or a["task_id"] in completed_ids
)
pending = max(0, assigned - completed)
total_pending += pending
items.append({
"campaign_id": cid,
"batch": camp.batch,
"task": camp.task,
"project": camp.project,
"status": camp.status,
"assigned": assigned,
"completed": completed,
"pending": pending,
"campaign_total": len(all_ids),
"annotate_url": f"{base}/labeling/annotate/{cid}",
})
items.sort(key=lambda x: (-x["pending"], x.get("batch") or ""))
return {"items": items, "total_pending": total_pending}
def campaign_my_tasks(campaign_id: str, user_id: int) -> dict[str, Any]:
"""当前用户在指定 Campaign 下的逐张任务清单。"""
with session_scope() as db:
camp = db.get(LabelingCampaign, campaign_id)
if not camp:
raise FileNotFoundError("campaign not found")
batch_dir = resolve_campaign_batch_dir(camp)
images = list(_iter_batch_images(batch_dir))
completed_ids = count_completed_tasks(batch_dir)
task_id_to_index = {
_task_id_for_image(img, batch_dir): i for i, img in enumerate(images)
}
rows = (
db.query(LabelingTaskAssignment)
.filter(
LabelingTaskAssignment.campaign_id == campaign_id,
LabelingTaskAssignment.user_id == user_id,
)
.all()
)
assignment_map = {r.task_id: r.completed_at for r in rows}
items: list[dict[str, Any]] = []
for task_id, completed_at in assignment_map.items():
idx = task_id_to_index.get(task_id)
filename = ""
relative_path = ""
if idx is not None:
img = images[idx]
try:
relative_path = img.relative_to(batch_dir).as_posix()
except ValueError:
relative_path = img.name
filename = img.name
completed = bool(completed_at) or task_id in completed_ids
items.append({
"task_id": task_id,
"filename": filename,
"relative_path": relative_path,
"completed": completed,
"frame_index": idx if idx is not None else -1,
})
items.sort(key=lambda x: (x["frame_index"] if x["frame_index"] >= 0 else 9999, x["filename"]))
assigned = len(items)
completed_count = sum(1 for i in items if i["completed"])
return {
"campaign_id": campaign_id,
"items": items,
"assigned": assigned,
"completed": completed_count,
"pending": max(0, assigned - completed_count),
}

View File

@@ -11,6 +11,8 @@ DOMAIN_LABELS = {"dms": "舱内 DMS", "forward": "前向 ADAS"}
def format_scope_key(project: str, task: str, mode: str | None = None) -> str:
if project == "adas":
return f"adas:{task}"
if project == "lane":
return f"lane:{task}"
if mode:
@@ -41,6 +43,8 @@ def load_labeling_registry() -> dict[str, Any]:
def labeling_profile_key(project: str, task: str, mode: str | None, reg: dict | None = None) -> str:
if project == "adas":
return task
if project == "lane":
return f"lane__{task}"
get_mode_config, resolve_task_id, train_yaml_key = _dms_registry_api()
@@ -71,6 +75,10 @@ def enrich_batch_labels(batch: dict[str, Any], reg: dict | None = None) -> dict[
except Exception:
out["domain"] = "dms"
out["domain_label"] = DOMAIN_LABELS["dms"]
elif project == "adas":
out["domain"] = "adas"
out["domain_label"] = "前向 ADAS"
out["task_label"] = task
else:
out["domain_label"] = "车道线 Lane"
out["task_label"] = task

View File

@@ -5,6 +5,7 @@ import hashlib
import json
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from as_platform.config import WORKSPACE
@@ -12,7 +13,7 @@ from as_platform.data.core import get_pending_report, load_wf
from as_platform.db.engine import session_scope
from as_platform.db.models import LabelingCampaign, LabelingExportJob, User
from as_platform.jobs.queue import enqueue_job, get_job
from as_platform.labeling.annotate import resolve_editor_xml, sync_campaign_config_xml
from as_platform.labeling.annotate import resolve_campaign_batch_dir, _iter_batch_images
from as_platform.labeling.batch_stage import (
on_labeling_export_job_succeeded,
update_campaign_batch_meta_stage,
@@ -35,6 +36,8 @@ def _parse_scope_key(scope_key: str) -> tuple[str, str, str | None]:
parts = scope_key.split(":")
if parts[0] == "lane":
return "lane", parts[1] if len(parts) > 1 else "lane_v1", None
if parts[0] == "adas":
return "adas", parts[1] if len(parts) > 1 else "cuboid_7cls", None
if len(parts) >= 3:
return "dms", parts[1], parts[2]
if len(parts) == 2:
@@ -115,6 +118,8 @@ def list_labeling_batches(
allowed_stages = ("raw_pool", "out_for_labeling", "returned", "labeling_submitted", "in_review", "review_approved", "review_rejected")
def _append(b: dict[str, Any]) -> None:
if b.get("registry_only"):
return
if stage and b.get("stage") != stage:
return
if b.get("stage") not in allowed_stages:
@@ -169,10 +174,24 @@ def open_campaign(
mode: str | None = None,
pack: str | None = None,
location: str = "inbox",
annotation_types: list[str] | None = None,
) -> dict[str, Any]:
cid = _campaign_id(project, task, mode, batch, location)
config_xml = resolve_editor_xml(project, task, mode)
now = datetime.now(timezone.utc)
ann_types = annotation_types or _resolve_default_annotation_types(project, task, mode)
from as_platform.labeling.cvat_client import get_cvat_client
from as_platform.labeling.cvat_config import build_cvat_labels
cvat = get_cvat_client()
if not cvat.ping():
raise ValueError("CVAT 标注引擎不可用,请执行: docker compose -f docker-compose.yml -f docker-compose.cvat.yml up -d")
cvat_labels = build_cvat_labels(project, task, mode, ann_types)
cvat_task = cvat.create_task(name=cid, labels=cvat_labels)
cvat_task_id = cvat_task.id
cvat_job_url = cvat_task.job_url
with session_scope() as db:
camp = db.get(LabelingCampaign, cid)
if not camp:
@@ -185,7 +204,9 @@ def open_campaign(
pack=pack,
location=location,
status="in_progress",
config_xml=config_xml,
cvat_task_id=cvat_task_id,
cvat_job_url=cvat_job_url,
annotation_types=ann_types,
created_at=now,
updated_at=now,
)
@@ -193,10 +214,25 @@ def open_campaign(
else:
camp.status = "in_progress"
camp.updated_at = now
sync_campaign_config_xml(camp)
if cvat_task_id and not camp.cvat_task_id:
camp.cvat_task_id = cvat_task_id
camp.cvat_job_url = cvat_job_url
if ann_types and not camp.annotation_types:
camp.annotation_types = ann_types
db.flush()
out = camp.to_dict()
out["config_xml"] = camp.config_xml
# CVAT 图片上传(异步,不阻塞)
try:
batch_dir = resolve_campaign_batch_dir(camp)
images = _iter_batch_images(batch_dir)
if images:
import threading
cvat_uploader = _cvat_upload_thread(cvat_task_id, images)
threading.Thread(target=cvat_uploader, daemon=True).start()
except Exception:
pass
update_campaign_batch_meta_stage(camp, "out_for_labeling")
reg = load_dms_registry() if project == "dms" else None
row = enrich_batch_labels(out, reg)
@@ -210,7 +246,6 @@ def get_campaign(campaign_id: str) -> dict[str, Any] | None:
if not camp:
return None
row = camp.to_dict()
row["config_xml"] = camp.config_xml
reg = load_dms_registry() if row.get("project") == "dms" else None
return enrich_batch_labels(row, reg)
@@ -285,21 +320,55 @@ def list_campaign_export_jobs(campaign_id: str, *, limit: int = 30) -> dict[str,
def list_labeling_assignees() -> dict[str, Any]:
"""可指派为批次负责人的用户(标注相关角色)"""
"""可指派用户:从飞书通讯录同步组织全员,供分配下拉选择"""
from as_platform.auth.feishu import is_feishu_configured, sync_feishu_users_to_db
role_codes = ("labeler", "internal_labeler", "vendor_labeler", "engineer", "admin")
sync_meta: dict[str, Any] = {"feishu_configured": is_feishu_configured()}
if sync_meta["feishu_configured"]:
from as_platform.config import FEISHU_APP_ID
sync_meta["contact_scope_url"] = (
f"https://open.feishu.cn/app/{FEISHU_APP_ID}/auth"
"?q=contact:contact:readonly_as_app,contact:department.organize:readonly,contact:contact.base:readonly"
"&op_from=openapi&token_type=tenant"
)
sync_meta["publish_url"] = f"https://open.feishu.cn/app/{FEISHU_APP_ID}/appPublish"
with session_scope() as db:
if is_feishu_configured():
try:
sync_result = sync_feishu_users_to_db(db)
sync_meta.update(sync_result)
except Exception as exc:
sync_meta["error"] = str(exc)
users = (
db.query(User)
.filter(User.is_active.is_(True))
.filter(User.is_active.is_(True), User.feishu_open_id.isnot(None))
.order_by(User.name)
.all()
)
if not users:
users = (
db.query(User)
.filter(User.is_active.is_(True))
.order_by(User.name)
.all()
)
users = [
u for u in users
if {r.code for r in (u.roles or [])}.intersection(role_codes)
]
sync_meta["fallback"] = "local_roles"
items = []
for u in users:
codes = {r.code for r in (u.roles or [])}
if codes.intersection(role_codes):
items.append({"id": u.id, "name": u.name or f"user-{u.id}", "roles": sorted(codes)})
return {"items": items}
items.append({
"id": u.id,
"name": u.name or f"user-{u.id}",
"avatar_url": u.avatar_url,
"roles": sorted({r.code for r in (u.roles or [])}),
"department_names": u.feishu_department_ids(),
"feishu_open_id": u.feishu_open_id,
})
return {"items": items, "sync": sync_meta}
def _find_batch_for_campaign_id(campaign_id: str) -> dict[str, Any] | None:
@@ -399,3 +468,148 @@ def trigger_labeling_export(campaign_id: str) -> dict[str, Any]:
)
ej = _record_export_job(campaign_id, "labeling_export", job)
return {"ok": True, "job": job, "export_job": ej, "export_default": row.get("export_default")}
# ═══════════════════════════════════════════════════════
# CVAT 集成辅助
# ═══════════════════════════════════════════════════════
def _resolve_default_annotation_types(project: str, task: str | None, mode: str | None) -> list[str]:
"""根据 project 推断默认标注类型。"""
from as_platform.labeling.cvat_config import resolve_annotation_types
return resolve_annotation_types(project, task, mode)
def _cvat_upload_thread(cvat_task_id: int, image_paths: list):
"""在线程中上传图片到 CVAT。"""
def _run():
try:
from as_platform.labeling.cvat_client import get_cvat_client
cvat = get_cvat_client()
cvat.upload_images(cvat_task_id, image_paths)
except Exception:
pass
return _run
def sync_cvat_annotations(campaign_id: str) -> dict[str, Any]:
"""从 CVAT Job 拉取标注,写入 HSAP 数据湖 labels/ls_annotations。"""
from datetime import datetime, timezone
from as_platform.labeling.annotate import _annotations_dir, _iter_batch_images, _task_id_for_image
from as_platform.labeling.cvat_client import get_cvat_client
from as_platform.labeling.format_converter import (
cvat_job_shapes_to_yolo_lines,
cvat_shapes_to_export_regions,
group_cvat_job_shapes_by_frame,
)
with session_scope() as db:
camp = db.get(LabelingCampaign, campaign_id)
if not camp:
raise FileNotFoundError("campaign not found")
if not camp.cvat_task_id:
raise ValueError("该 campaign 未关联 CVAT Task")
cvat = get_cvat_client()
task = cvat.get_task(camp.cvat_task_id)
if not task.job_id:
raise ValueError("CVAT Job 尚未就绪,请等待图片上传完成")
job_id = task.job_id
job_ann = cvat.get_job_annotations(job_id)
meta = cvat.get_job_data_meta(job_id)
label_map = cvat.get_job_label_map(job_id)
frames = meta.get("frames") or []
shapes_by_frame = group_cvat_job_shapes_by_frame(job_ann)
batch_dir = resolve_campaign_batch_dir(camp)
images = _iter_batch_images(batch_dir)
name_to_path = {p.name: p for p in images}
ann_dir = _annotations_dir(batch_dir)
synced_at = datetime.now(timezone.utc).isoformat()
from as_platform.labeling.scope import load_dms_registry
reg = load_dms_registry() if camp.project == "dms" else None
class_map = _build_class_map(camp, reg)
saved_count = 0
shape_count = 0
for frame_idx, shapes in shapes_by_frame.items():
if frame_idx >= len(frames):
continue
frame_name = frames[frame_idx].get("name") or f"frame_{frame_idx}"
img_path = name_to_path.get(Path(frame_name).name)
if not img_path:
continue
task_id = _task_id_for_image(img_path, batch_dir)
fw = int(frames[frame_idx].get("width") or 1920)
fh = int(frames[frame_idx].get("height") or 1080)
result_items = cvat_shapes_to_export_regions(shapes, label_map, fw, fh)
shape_count += len(result_items)
payload: dict[str, Any] = {
"task_id": task_id,
"result": result_items,
"source": "cvat",
"synced_at": synced_at,
"cvat_job_id": job_id,
"image": frame_name,
}
ann_file = ann_dir / f"{task_id}.json"
ann_file.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
saved_count += 1
# DMS / ADAS 2D额外写 YOLO txt 供训练导出
if camp.project in ("dms", "adas") and class_map:
yolo_lines = cvat_job_shapes_to_yolo_lines(shapes, label_map, class_map, fw, fh)
if yolo_lines:
yolo_dir = batch_dir / "labels" / "yolo"
yolo_dir.mkdir(parents=True, exist_ok=True)
stem = Path(frame_name).stem
(yolo_dir / f"{stem}.txt").write_text("\n".join(yolo_lines) + "\n", encoding="utf-8")
return {
"ok": True,
"saved": saved_count,
"shapes": shape_count,
"campaign_id": campaign_id,
"cvat_job_id": job_id,
}
def _build_class_map(camp, reg: dict | None) -> dict[str, int]:
"""从 DMS registry 构建 class_name → class_id 映射。"""
if reg:
tasks = reg.get("tasks") or {}
tcfg = tasks.get(camp.task) or {}
names = tcfg.get("names") or []
if isinstance(names, list):
return {n: i for i, n in enumerate(names)}
return {}
def get_cvat_status(campaign_id: str) -> dict[str, Any]:
"""查询 CVAT 侧 Task 状态。"""
with session_scope() as db:
camp = db.get(LabelingCampaign, campaign_id)
if not camp:
raise FileNotFoundError("campaign not found")
if not camp.cvat_task_id:
return {"cvat_available": False, "campaign_id": campaign_id}
from as_platform.labeling.cvat_client import get_cvat_client
cvat = get_cvat_client()
try:
task = cvat.get_task(camp.cvat_task_id)
return {
"cvat_available": True,
"campaign_id": campaign_id,
"cvat_task_id": camp.cvat_task_id,
"cvat_job_url": task.job_url or camp.cvat_job_url,
"cvat_status": task.status,
}
except Exception as e:
return {"cvat_available": False, "campaign_id": campaign_id, "error": str(e)}