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

@@ -20,7 +20,10 @@ from as_platform.labeling.lock import acquire_lock, release_lock, renew_lock
from as_platform.labeling.progress import (
assign_tasks_even,
assign_tasks_explicit,
assign_tasks_quantized,
campaign_my_tasks,
campaign_progress,
list_my_assignments,
release_task_assignment,
reassign_task,
user_is_coordinator,
@@ -41,12 +44,13 @@ router = APIRouter(tags=["labeling"])
class OpenCampaignBody(BaseModel):
project: str = Field(..., pattern="^(dms|lane)$")
project: str = Field(..., pattern="^(dms|lane|adas)$")
task: str
batch: str
mode: str | None = None
pack: str | None = None
location: str = "inbox"
annotation_types: list[str] | None = None
class AssignCampaignBody(BaseModel):
@@ -63,10 +67,16 @@ class AssignTasksExplicitItem(BaseModel):
task_ids: list[str] = Field(default_factory=list)
class AssignTasksQuantizedItem(BaseModel):
user_id: int
count: int = 0
class AssignTasksBody(BaseModel):
mode: str = "even"
user_ids: list[int] | None = None
items: list[AssignTasksExplicitItem] | None = None
quantized_items: list[AssignTasksQuantizedItem] | None = None
class ReassignTaskBody(BaseModel):
@@ -80,6 +90,24 @@ def api_labeling_assignees(
return list_labeling_assignees()
@router.get("/api/v1/labeling/my-assignments")
def api_my_assignments(
user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
return list_my_assignments(user.id)
@router.get("/api/v1/labeling/campaigns/{campaign_id}/my-tasks")
def api_campaign_my_tasks(
campaign_id: str,
user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
try:
return campaign_my_tasks(campaign_id, user.id)
except FileNotFoundError:
raise HTTPException(404, "campaign not found") from None
@router.patch("/api/v1/labeling/campaigns/{campaign_id}/assign")
def api_assign_campaign(
campaign_id: str,
@@ -166,6 +194,9 @@ def api_assign_tasks(
if body.mode == "explicit":
items = [{"user_id": i.user_id, "task_ids": i.task_ids} for i in (body.items or [])]
return assign_tasks_explicit(campaign_id, items, assigned_by_user_id=user.id)
if body.mode == "quantized":
items = [{"user_id": i.user_id, "count": i.count} for i in (body.quantized_items or [])]
return assign_tasks_quantized(campaign_id, items, assigned_by_user_id=user.id)
if not body.user_ids:
raise ValueError("even 模式需要 user_ids")
return assign_tasks_even(campaign_id, body.user_ids, assigned_by_user_id=user.id)
@@ -368,6 +399,37 @@ def api_labeling_lock_renew(
return result
# ── CVAT 集成端点 ──
@router.get("/api/v1/labeling/cvat/status/{campaign_id}")
def api_cvat_status(
campaign_id: str,
_user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
from as_platform.labeling.service import get_cvat_status
try:
return get_cvat_status(campaign_id)
except FileNotFoundError:
raise HTTPException(404, "campaign not found") from None
@router.post("/api/v1/labeling/cvat/sync/{campaign_id}")
def api_cvat_sync(
campaign_id: str,
_user: Annotated[User, Depends(require_permission("read:pending"))],
) -> dict[str, Any]:
from as_platform.labeling.service import sync_cvat_annotations
try:
return sync_cvat_annotations(campaign_id)
except FileNotFoundError:
raise HTTPException(404, "campaign not found") from None
except ValueError as e:
raise HTTPException(400, str(e)) from e
except Exception as e:
raise HTTPException(500, f"CVAT 同步失败: {e}") from e
# ── 标注质检 (Quality Review) ──
class ReviewScoreBody(BaseModel):

View File

@@ -708,18 +708,14 @@ def api_scan_inbox(
if batch_name in registered:
continue # 已登记
# Count images
img_count = 0
lbl_count = 0
has_labels = False
for ext in ["*.jpg", "*.jpeg", "*.png", "*.bmp"]:
for _ in batch_dir.glob(ext):
img_count += 1
if (batch_dir / "labels").is_dir():
has_labels = True
for ext in ["*.txt", "*.json"]:
for _ in (batch_dir / "labels").glob(ext):
lbl_count += 1
# Count images (含 images/ 子目录)
from as_platform.data.batch import count_images, count_label_files, dms_has_labels
img_count = count_images(batch_dir)
if not img_count and (batch_dir / "images").is_dir():
img_count = count_images(batch_dir / "images")
lbl_count = count_label_files(batch_dir / "labels") if (batch_dir / "labels").is_dir() else 0
has_labels = lbl_count > 0 or dms_has_labels(batch_dir)
items.append({
"project": project,
@@ -879,9 +875,6 @@ def _mount_ui() -> None:
assets = ui / "assets"
if assets.is_dir():
app.mount("/assets", StaticFiles(directory=str(assets)), name="ui-assets")
annotate = ui / "annotate"
if annotate.is_dir():
app.mount("/annotate", StaticFiles(directory=str(annotate), html=True), name="ui-annotate")
return
@@ -889,14 +882,10 @@ _mount_ui()
@app.get("/labeling/campaigns/{campaign_id}/annotate", include_in_schema=False)
def serve_annotate_app(campaign_id: str):
"""标注编辑器页面返回旧 Label Studio 构建产物"""
if not _UI_DIR:
raise HTTPException(404, "UI not built")
annotate_index = _UI_DIR / "annotate" / "index.html"
if annotate_index.is_file():
return FileResponse(annotate_index)
raise HTTPException(404, "标注编辑器未构建")
def serve_annotate_app_redirect(campaign_id: str):
"""标注编辑器 — 已迁移至 CVAT302 重定向到新路由"""
from fastapi.responses import RedirectResponse
return RedirectResponse(f"/labeling/annotate/{campaign_id}", status_code=302)
@app.get("/{full_path:path}", include_in_schema=False)

View File

@@ -1,6 +1,7 @@
"""飞书 OAuth 登录。"""
from __future__ import annotations
import json
import secrets
import urllib.parse
from datetime import datetime, timedelta, timezone
@@ -8,8 +9,10 @@ from typing import Any
import httpx
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from as_platform.config import FEISHU_APP_ID, FEISHU_APP_SECRET, FEISHU_REDIRECT_URI, JWT_SECRET
from as_platform.db.models import User
FEISHU_AUTHORIZE_URL = "https://passport.feishu.cn/suite/passport/oauth/authorize"
FEISHU_TOKEN_URL = "https://open.feishu.cn/open-apis/authen/v1/access_token"
@@ -18,6 +21,8 @@ FEISHU_TENANT_TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_acces
FEISHU_CONTACT_USER_URL = "https://open.feishu.cn/open-apis/contact/v3/users/{user_id}"
FEISHU_CONTACT_USERS_URL = "https://open.feishu.cn/open-apis/contact/v3/users"
FEISHU_DEPARTMENTS_URL = "https://open.feishu.cn/open-apis/contact/v3/departments"
FEISHU_DEPARTMENT_CHILDREN_URL = "https://open.feishu.cn/open-apis/contact/v3/departments/{department_id}/children"
FEISHU_USERS_BY_DEPT_URL = "https://open.feishu.cn/open-apis/contact/v3/users/find_by_department"
STATE_ALG = "HS256"
STATE_EXPIRE_MINUTES = 10
@@ -144,79 +149,196 @@ def _get_contact_user_profile(
return data.get("data", {}).get("user", {})
def fetch_feishu_users(page_size: int = 100) -> tuple[list[dict[str, Any]], dict[str, str]]:
"""从飞书通讯录批量拉取用户列表并获取部门名称映射,返回 (users, dept_name_map)。
如果应用无通讯录权限则返回空列表。"""
def _parse_contact_user(item: dict[str, Any]) -> dict[str, Any]:
return {
"open_id": item.get("open_id"),
"union_id": item.get("union_id"),
"user_id": item.get("user_id"),
"name": item.get("name") or item.get("en_name") or item.get("nickname"),
"email": item.get("email") or item.get("enterprise_email"),
"mobile": item.get("mobile"),
"avatar_url": item.get("avatar", {}).get("avatar_240")
if isinstance(item.get("avatar"), dict)
else None,
"department_ids": item.get("department_ids", []),
}
def _paginate_feishu_get(
client: httpx.Client,
token: str,
url: str,
params: dict[str, str | int],
) -> tuple[list[dict[str, Any]], str | None]:
"""分页 GET返回 (items, error_message)。"""
items: list[dict[str, Any]] = []
page_token = ""
error_msg: str | None = None
while True:
req = dict(params)
if page_token:
req["page_token"] = page_token
resp = client.get(url, params=req, headers={"Authorization": f"Bearer {token}"})
data = resp.json() if resp.content else {}
if resp.status_code >= 400 or data.get("code") not in (0, None):
return items, data.get("msg") or f"HTTP {resp.status_code}"
if data.get("code") == 0:
items.extend(data.get("data", {}).get("items", []))
page_token = data.get("data", {}).get("page_token", "")
if not page_token:
break
return items, error_msg
def _fetch_department_map(client: httpx.Client, token: str) -> tuple[dict[str, str], str | None]:
"""递归拉取全部部门,返回 {open_department_id: name}。"""
dept_map: dict[str, str] = {"0": "全员"}
# 优先:从根部门递归子部门(需全员通讯录范围)
children, err = _paginate_feishu_get(
client,
token,
FEISHU_DEPARTMENT_CHILDREN_URL.format(department_id="0"),
{
"department_id_type": "open_department_id",
"fetch_child": "true",
"page_size": 50,
},
)
if err and "no dept authority" in err:
return {}, err
for item in children:
did = item.get("open_department_id", "")
if did:
dept_map[did] = item.get("name", "") or did
if dept_map and len(dept_map) > 1:
return dept_map, None
# 回退:平铺部门列表
flat, err2 = _paginate_feishu_get(
client,
token,
FEISHU_DEPARTMENTS_URL,
{"page_size": 50, "department_id_type": "open_department_id"},
)
for item in flat:
did = item.get("open_department_id", "")
if did:
dept_map[did] = item.get("name", "") or did
return dept_map, err2 or err
def _fetch_users_by_department(
client: httpx.Client, token: str, department_id: str
) -> tuple[list[dict[str, Any]], str | None]:
raw, err = _paginate_feishu_get(
client,
token,
FEISHU_USERS_BY_DEPT_URL,
{
"department_id": department_id,
"user_id_type": "open_id",
"department_id_type": "open_department_id",
"page_size": 50,
},
)
return [_parse_contact_user(x) for x in raw if x.get("open_id")], err
def _fetch_users_list(
client: httpx.Client,
token: str,
*,
department_id: str | None = None,
page_size: int = 50,
) -> tuple[list[dict[str, Any]], str | None]:
params: dict[str, str | int] = {
"user_id_type": "open_id",
"department_id_type": "open_department_id",
"page_size": min(page_size, 50),
}
if department_id is not None:
params["department_id"] = department_id
raw, err = _paginate_feishu_get(client, token, FEISHU_CONTACT_USERS_URL, params)
return [_parse_contact_user(x) for x in raw if x.get("open_id")], err
def fetch_feishu_users(page_size: int = 50) -> tuple[list[dict[str, Any]], dict[str, str], str | None]:
"""从飞书通讯录拉取组织用户(按部门递归 + 多策略回退)。
返回 (users, dept_name_map, error_message)。
若应用通讯录范围不是「全部员工」,可能只能同步到极少数成员。
"""
if not is_feishu_configured():
return [], {}
return [], {}, "飞书应用未配置"
error_msg: str | None = None
scope_warning: str | None = None
users_by_id: dict[str, dict[str, Any]] = {}
try:
with httpx.Client(timeout=30) as client:
with httpx.Client(timeout=60) as client:
token = _get_tenant_access_token(client)
dept_map, dept_err = _fetch_department_map(client, token)
# 先获取部门列表(如果应用有通讯录权限
dept_map: dict[str, str] = {}
try:
page_token = ""
while True:
params: dict[str, str | int] = {"page_size": 100, "department_id_type": "open_department_id"}
if page_token:
params["page_token"] = page_token
resp = client.get(FEISHU_DEPARTMENTS_URL, params=params, headers={"Authorization": f"Bearer {token}"})
if resp.status_code >= 400:
break # 无部门权限,跳过
d = resp.json()
if d.get("code") == 0:
for item in d.get("data", {}).get("items", []):
dept_map[item.get("open_department_id", "")] = item.get("name", "")
page_token = d.get("data", {}).get("page_token", "")
if not page_token:
break
except Exception:
pass # 无需部门信息也可同步用户
# 策略 1按部门拉取直属用户可覆盖全员
if dept_map:
dept_ids = [d for d in dept_map if d != "0"] or ["0"]
if "0" not in dept_ids:
dept_ids = ["0", *dept_ids]
for did in dept_ids:
batch, err = _fetch_users_by_department(client, token, did)
if err and not error_msg:
error_msg = err
for u in batch:
oid = u.get("open_id")
if oid:
users_by_id[oid] = u
# 再获取用户列表(如无通讯录权限则跳过)
users: list[dict[str, Any]] = []
try:
page_token = ""
while True:
params = {
"user_id_type": "open_id",
"department_id_type": "open_department_id",
"page_size": min(page_size, 100),
}
if page_token:
params["page_token"] = page_token # type: ignore
resp = client.get(FEISHU_CONTACT_USERS_URL, params=params, headers={"Authorization": f"Bearer {token}"}) # type: ignore
if resp.status_code >= 400:
break
d = resp.json()
if d.get("code") == 0:
for item in d.get("data", {}).get("items", []):
users.append({
"open_id": item.get("open_id"),
"union_id": item.get("union_id"),
"user_id": item.get("user_id"),
"name": item.get("name"),
"email": item.get("email"),
"mobile": item.get("mobile"),
"avatar_url": item.get("avatar", {}).get("avatar_240") if isinstance(item.get("avatar"), dict) else None,
"department_ids": item.get("department_ids", []),
})
page_token = d.get("data", {}).get("page_token", "")
if not page_token:
break
except Exception:
pass # 无通讯录权限,跳过用户列表拉取
# 策略 2根部门用户列表
if not users_by_id:
batch, err = _fetch_users_list(client, token, department_id="0", page_size=page_size)
if err and not error_msg:
error_msg = err
for u in batch:
oid = u.get("open_id")
if oid:
users_by_id[oid] = u
return users, dept_map
except Exception:
return [], {}
# 策略 3权限范围内独立成员回退通常很少
if not users_by_id:
batch, err = _fetch_users_list(client, token, page_size=page_size)
if err and not error_msg:
error_msg = err
for u in batch:
oid = u.get("open_id")
if oid:
users_by_id[oid] = u
if dept_err and "no dept authority" in (dept_err or ""):
scope_warning = (
"应用通讯录范围未包含「全部员工」,无法按部门拉取全员。"
"请在开放平台 → 版本管理与发布 → 可用范围/通讯录权限 设为全部员工后重新发布。"
)
elif len(users_by_id) <= 5 and dept_err:
scope_warning = (
f"仅同步到 {len(users_by_id)} 人,可能通讯录范围过窄。"
"请将应用通讯录权限范围调整为全部员工。"
)
users = list(users_by_id.values())
if scope_warning:
# 附带在 error 字段供前端展示(有用户时不算硬错误)
if not users:
error_msg = error_msg or scope_warning
elif not error_msg:
error_msg = scope_warning
return users, dept_map, error_msg if not users else (scope_warning or None)
except Exception as exc:
return [], {}, str(exc)
def sync_feishu_users_to_db(db: Session) -> dict[str, int]:
"""同步飞书用户到数据库,返回 {created, updated, total}。"""
users, dept_map = fetch_feishu_users()
def sync_feishu_users_to_db(db: Session) -> dict[str, int | str | None]:
"""同步飞书用户到数据库,返回 {created, updated, total, error}。"""
users, dept_map, error_msg = fetch_feishu_users()
created, updated = 0, 0
for info in users:
open_id = info.get("open_id")
@@ -231,7 +353,12 @@ def sync_feishu_users_to_db(db: Session) -> dict[str, int]:
updated += 1
user.feishu_union_id = info.get("union_id") or user.feishu_union_id
user.feishu_user_id = info.get("user_id") or user.feishu_user_id
user.name = info.get("name") or user.name
user.name = (
info.get("name")
or info.get("email")
or user.name
or f"飞书用户-{open_id[-6:]}"
)
user.email = info.get("email") or user.email or user.email
user.avatar_url = info.get("avatar_url") or user.avatar_url
dept_ids = info.get("department_ids")
@@ -240,4 +367,4 @@ def sync_feishu_users_to_db(db: Session) -> dict[str, int]:
user.feishu_department_ids_json = json.dumps(dept_names, ensure_ascii=False)
user.is_active = True
db.flush()
return {"created": created, "updated": updated, "total": len(users)}
return {"created": created, "updated": updated, "total": len(users), "error": error_msg}

View File

@@ -289,6 +289,26 @@ def get_pending_report(wf: dict | None = None) -> dict[str, Any]:
)
)
if pname == "adas":
inbox_adas = root / "inbox"
if inbox_adas.is_dir():
for task_dir in sorted(inbox_adas.iterdir()):
if not task_dir.is_dir() or task_dir.name.startswith("."):
continue
for batch_dir in sorted(task_dir.iterdir()):
if not batch_dir.is_dir() or batch_dir.name.startswith("."):
continue
report["batches"].append(
enrich_batch(
batch_dir,
project="adas",
task=task_dir.name,
pack=None,
batch=batch_dir.name,
location="inbox",
)
)
if pname == "lane":
proj["packs"] = {}
for pack_name in all_names:
@@ -1024,6 +1044,10 @@ def register_batch(
batch_dir = pack_dir / tcfg["task_dir"] / src_sub / batch
else:
batch_dir = root / "inbox" / task / batch
elif project == "adas":
if not task:
raise ValueError("adas register-batch 需要 task")
batch_dir = root / "inbox" / task / batch
else:
if location == "pack" and pack:
try:
@@ -1057,6 +1081,16 @@ def register_batch(
}
if not data["counts"]["images"] and dms_has_images(batch_dir):
data["counts"]["images"] = 1
elif project == "adas":
from as_platform.data.batch import count_images, count_label_files, dms_has_images
data["format"] = "cvat_cuboid"
data["counts"] = {
"images": count_images(batch_dir),
"labels": count_label_files(batch_dir / "labels"),
}
if not data["counts"]["images"] and dms_has_images(batch_dir):
data["counts"]["images"] = count_images(batch_dir / "images")
else:
data["format"] = "ufld_archive"
tg = batch_dir / "list" / "train_gt.txt"

View File

@@ -11,6 +11,7 @@ from sqlalchemy import (
Float,
ForeignKey,
Integer,
JSON,
String,
Table,
Text,
@@ -534,6 +535,9 @@ class LabelingCampaign(Base):
assigned_to_user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
assigned_to_name = Column(String(128), nullable=True)
config_xml = Column(Text, nullable=True)
cvat_task_id = Column(Integer, nullable=True, index=True)
cvat_job_url = Column(String(512), nullable=True)
annotation_types = Column(JSON, nullable=True)
created_at = Column(DateTime(timezone=True), default=_utcnow)
updated_at = Column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
@@ -550,6 +554,9 @@ class LabelingCampaign(Base):
"assigned_to_user_id": self.assigned_to_user_id,
"assigned_to_name": self.assigned_to_name,
"config_xml": self.config_xml,
"cvat_task_id": self.cvat_task_id,
"cvat_job_url": self.cvat_job_url,
"annotation_types": self.annotation_types,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import json
import logging
from typing import Any
import httpx
@@ -9,8 +10,58 @@ import httpx
from as_platform.auth.feishu import _get_tenant_access_token
from as_platform.config import FEISHU_APP_ID, FEISHU_APP_SECRET, FEISHU_LABELING_CHAT_ID
logger = logging.getLogger(__name__)
IM_MSG_URL = "https://open.feishu.cn/open-apis/im/v1/messages"
IM_MESSAGE_SCOPE_URL = (
f"https://open.feishu.cn/app/{FEISHU_APP_ID}/auth"
"?q=im:message:send_as_bot,im:message:send,im:message"
"&op_from=openapi&token_type=tenant"
if FEISHU_APP_ID
else ""
)
FEISHU_BOT_SETUP_URL = f"https://open.feishu.cn/app/{FEISHU_APP_ID}/bot" if FEISHU_APP_ID else ""
FEISHU_PUBLISH_URL = f"https://open.feishu.cn/app/{FEISHU_APP_ID}/appPublish" if FEISHU_APP_ID else ""
def _feishu_send_error_hint(message: str) -> dict[str, str]:
"""将飞书 API 英文报错映射为可操作的中文提示。"""
msg = message or ""
if "Bot ability is not activated" in msg:
return {
"reason": "bot_not_activated",
"help_text": "应用未启用「机器人」能力:开放平台 → 应用能力 → 添加机器人 → 创建版本并发布",
"help_url": FEISHU_BOT_SETUP_URL or IM_MESSAGE_SCOPE_URL,
}
if "NO availability" in msg or "230013" in msg:
return {
"reason": "user_out_of_scope",
"help_text": "你在机器人可用范围外:版本管理与发布 → 编辑可用范围 → 加入全员或你的部门 → 重新发布",
"help_url": FEISHU_PUBLISH_URL or IM_MESSAGE_SCOPE_URL,
}
if "im:message" in msg or "Access denied" in msg:
return {
"reason": "missing_im_scope",
"help_text": "应用未开通「发送消息」权限,请在权限管理中申请 im:message:send_as_bot",
"help_url": IM_MESSAGE_SCOPE_URL,
}
return {
"reason": "send_failed",
"help_text": msg[:120],
"help_url": IM_MESSAGE_SCOPE_URL,
}
def _parse_feishu_send_response(resp: httpx.Response) -> dict[str, Any]:
data = resp.json() if resp.content else {}
if resp.status_code < 400 and data.get("code") in (0, None):
return {"ok": True}
msg = data.get("msg") or f"HTTP {resp.status_code}"
hint = _feishu_send_error_hint(msg)
return {"ok": False, "message": msg, **hint}
def is_notify_configured() -> bool:
return bool(FEISHU_APP_ID and FEISHU_APP_SECRET and FEISHU_LABELING_CHAT_ID)
@@ -32,23 +83,133 @@ def send_chat_text(text: str) -> dict[str, Any]:
},
)
resp.raise_for_status()
data = resp.json()
if data.get("code") != 0:
return {"ok": False, "message": data.get("msg") or "send failed"}
return {"ok": True}
return _parse_feishu_send_response(resp)
def send_chat_async(text: str) -> None:
"""异步发送飞书消息,不阻塞主流程。"""
"""异步发送飞书消息,不阻塞主流程。"""
import threading
threading.Thread(target=_send_safe, args=(text,), daemon=True, name="feishu-notify").start()
threading.Thread(target=_send_chat_safe, args=(text,), daemon=True, name="feishu-notify").start()
def _send_safe(text: str) -> None:
def send_user_text(open_id: str, text: str) -> dict[str, Any]:
"""向指定飞书用户发送私聊消息(需应用具备 im:message 权限)。"""
if not FEISHU_APP_ID or not FEISHU_APP_SECRET:
return {"ok": False, "message": "飞书未配置"}
if not open_id:
return {"ok": False, "message": "open_id 为空"}
try:
with httpx.Client(timeout=30.0) as client:
token = _get_tenant_access_token(client)
resp = client.post(
IM_MSG_URL,
params={"receive_id_type": "open_id"},
headers={"Authorization": f"Bearer {token}"},
json={
"receive_id": open_id,
"msg_type": "text",
"content": json.dumps({"text": text}, ensure_ascii=False),
},
)
data = resp.json() if resp.content else {}
if resp.status_code >= 400 or data.get("code") not in (0, None):
result = _parse_feishu_send_response(resp)
logger.warning(
"飞书私聊发送失败 open_id=%s reason=%s: %s",
open_id[:12],
result.get("reason"),
result.get("message"),
)
return result
return {"ok": True}
except Exception as exc:
logger.warning("飞书私聊发送异常 open_id=%s: %s", open_id[:12], exc)
hint = _feishu_send_error_hint(str(exc))
return {"ok": False, "message": str(exc), **hint}
def send_user_async(open_id: str, text: str) -> None:
"""异步向飞书用户发私聊,不阻塞主流程。"""
import threading
threading.Thread(
target=_send_user_safe,
args=(open_id, text),
daemon=True,
name="feishu-user-notify",
).start()
def _send_chat_safe(text: str) -> None:
try:
send_chat_text(text)
except Exception:
pass # 通知失败不影响业务流程
pass
def _send_user_safe(open_id: str, text: str) -> None:
try:
send_user_text(open_id, text)
except Exception:
pass
def notify_labeling_assignment(
*,
open_id: str,
assignee_name: str,
task: str,
batch: str,
count: int,
campaign_id: str,
) -> dict[str, Any]:
"""分配标注任务后向被指派人发送飞书私聊通知,返回发送结果。"""
from as_platform.config import FRONTEND_URL
link = f"{FRONTEND_URL.rstrip('/')}/labeling/my-tasks?campaign={campaign_id}"
text = (
f"[HSAP] 您有新的标注任务\n"
f"被指派人: {assignee_name}\n"
f"任务: {task} / 批次: {batch}\n"
f"分配数量: {count}\n"
f"请打开我的标注: {link}"
)
result = send_user_text(open_id, text)
if not result.get("ok") and is_notify_configured():
# 私聊失败时尝试发到标注协作群
fallback = send_chat_text(
f"[HSAP] 任务分配通知\n"
f"@{assignee_name} 您有 {count} 张新标注任务\n"
f"任务: {task} / 批次: {batch}\n"
f"打开我的标注: {link}"
)
if fallback.get("ok"):
return {"ok": True, "channel": "chat", "name": assignee_name}
return {**result, "channel": "dm", "name": assignee_name}
def notify_labeling_assignment_async(
*,
open_id: str,
assignee_name: str,
task: str,
batch: str,
count: int,
campaign_id: str,
) -> None:
import threading
threading.Thread(
target=notify_labeling_assignment,
kwargs={
"open_id": open_id,
"assignee_name": assignee_name,
"task": task,
"batch": batch,
"count": count,
"campaign_id": campaign_id,
},
daemon=True,
name="feishu-assign-notify",
).start()
def notify_batch_progress(

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)}