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:
@@ -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
|
||||
|
||||
254
platform/as_platform/labeling/cvat_client.py
Normal file
254
platform/as_platform/labeling/cvat_client.py
Normal 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 级标注 JSON(CVAT 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
|
||||
148
platform/as_platform/labeling/cvat_config.py
Normal file
148
platform/as_platform/labeling/cvat_config.py
Normal 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"])
|
||||
625
platform/as_platform/labeling/format_converter.py
Normal file
625
platform/as_platform/labeling/format_converter.py
Normal 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
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user