feat: Add support for ADAS project in labeling and export processes

- Implemented class name loading for ADAS campaigns in `_class_names_for_campaign`.
- Enhanced annotation parsing to support cuboid and 2D rectangle labels in `_parse_ls_annotations`.
- Updated catalog building to include ADAS packs and batches in `build_catalog_signature`.
- Added ADAS cuboid promotion logic in `adas_cuboid.py`, supporting both quaternion_json and YOLO HBB formats.
- Introduced validation for ADAS batches to check for YOLO HBB labels in `adas_cuboid.py`.
- Modified delivery scanning to include ADAS project deliveries in `scan.py`.
- Extended job execution to handle ADAS exports for YOLO format in `runner.py`.
- Updated labeling export logic to accommodate ADAS project in `export_cuboid_batch.py`.
- Enhanced format conversion to support rectangle labels in `format_converter.py`.
- Improved CVAT task handling in `service.py` to ensure task ID updates on each labeling job.
- Updated web API endpoints to reflect ADAS project changes in `hsap-api.ts`.
- Added ADAS catalog types and UI components for displaying ADAS packs and batches in `dmsCatalog.ts`, `CatalogPage.tsx`, and `ExportPage.tsx`.
- Adjusted workflow registry to align with new ADAS project structure.
This commit is contained in:
2026-07-16 16:03:04 +08:00
parent be8a885b07
commit 1bde0fe430
23 changed files with 472 additions and 83 deletions

View File

@@ -68,7 +68,13 @@ def _class_names_for_campaign(camp) -> dict[int, str]:
import yaml
from as_platform.data.core import load_wf, proj_root
if not camp or camp.project != "dms":
if not camp:
return {}
if camp.project == "adas":
from as_platform.labeling.class_map import load_adas_class_names
names = load_adas_class_names()
return {i: n for i, n in enumerate(names)}
if camp.project != "dms":
return {}
wf = load_wf()
root = proj_root(wf, "dms")
@@ -114,21 +120,42 @@ def _parse_ls_annotations(path: Path, class_names: dict[int, str]) -> list[dict[
return []
out: list[dict[str, Any]] = []
for item in data.get("result") or []:
if item.get("type") not in ("rectanglelabels", "rectangle"):
continue
val = item.get("value") or {}
w_pct = float(val.get("width") or 0)
h_pct = float(val.get("height") or 0)
if w_pct <= 0 or h_pct <= 0:
continue
x_pct = float(val.get("x") or 0)
y_pct = float(val.get("y") or 0)
labels = val.get("rectanglelabels") or val.get("labels") or []
label = labels[0] if labels else "unknown"
cid = _name_to_class_id(str(label), class_names)
cx = (x_pct + w_pct / 2) / 100.0
cy = (y_pct + h_pct / 2) / 100.0
out.append({"class_id": cid, "bbox": (cx, cy, w_pct / 100.0, h_pct / 100.0)})
item_type = item.get("type")
if item_type == "cuboid":
# Cuboid: 8 vertices in pixel coords → compute 2D bbox
points = item.get("points") or []
if len(points) < 16:
continue
label = item.get("label") or "unknown"
cid = _name_to_class_id(str(label), class_names)
# Compute min/max from 8 (x,y) pairs
xs = [points[i] for i in range(0, len(points), 2)]
ys = [points[i] for i in range(1, len(points), 2)]
min_x, max_x = min(xs), max(xs)
min_y, max_y = min(ys), max(ys)
if max_x <= min_x or max_y <= min_y:
continue
orig_w = float(item.get("original_width") or 1920)
orig_h = float(item.get("original_height") or 1080)
bw = (max_x - min_x) / orig_w
bh = (max_y - min_y) / orig_h
cx = (min_x + max_x) / 2.0 / orig_w
cy = (min_y + max_y) / 2.0 / orig_h
out.append({"class_id": cid, "bbox": (cx, cy, bw, bh)})
elif item_type in ("rectanglelabels", "rectangle"):
val = item.get("value") or {}
w_pct = float(val.get("width") or 0)
h_pct = float(val.get("height") or 0)
if w_pct <= 0 or h_pct <= 0:
continue
x_pct = float(val.get("x") or 0)
y_pct = float(val.get("y") or 0)
labels = val.get("rectanglelabels") or val.get("labels") or []
label = labels[0] if labels else "unknown"
cid = _name_to_class_id(str(label), class_names)
cx = (x_pct + w_pct / 2) / 100.0
cy = (y_pct + h_pct / 2) / 100.0
out.append({"class_id": cid, "bbox": (cx, cy, w_pct / 100.0, h_pct / 100.0)})
return out

View File

@@ -83,7 +83,7 @@ def build_catalog_signature(wf: dict, proj_root_fn) -> dict[str, Any]:
files.append({"path": str(p), "missing": True})
dirs: list[dict[str, Any]] = []
for pname in ("dms", "lane"):
for pname in ("dms", "lane", "adas"):
root = proj_root_fn(wf, pname)
dirs.append(_dir_fingerprint(root, scan_children=False))
dirs.append(_dir_fingerprint(root / "inbox"))

View File

@@ -11,7 +11,7 @@ from typing import Any
import yaml
from as_platform.config import WORKSPACE, WORKSPACE_ROOT, LANE_DATA_VIZ_ENABLED
from as_platform.data.batch import META_FILENAME, dms_has_images, enrich_batch, write_meta
from as_platform.data.batch import META_FILENAME, dms_has_images, enrich_batch, read_meta, write_meta
from as_platform.data.catalog_cache import (
build_catalog_signature,
get_cached_catalog,
@@ -811,7 +811,7 @@ def _normalize_catalog_dms(dms: dict[str, Any], reg: dict) -> dict[str, Any]:
def _build_catalog(wf: dict, *, prefer_reports: bool = True) -> tuple[dict[str, Any], str]:
out: dict[str, Any] = {"workspace": str(WORKSPACE), "dms": {}, "lane": {}}
out: dict[str, Any] = {"workspace": str(WORKSPACE), "dms": {}, "lane": {}, "adas": {}}
build_source = "scan"
reports = load_dms_reports()
@@ -966,6 +966,71 @@ def _build_catalog(wf: dict, *, prefer_reports: bool = True) -> tuple[dict[str,
"add_template": "python as.py add lane --src <archive> --engineer <name> --date YYYYMMDD",
"quality": lane_quality,
}
# ── ADAS catalog ──
adas_root = proj_root(wf, "adas")
out["adas"] = {}
adas_packs_dir = adas_root / "packs"
if adas_packs_dir.is_dir():
for pack_dir in sorted(adas_packs_dir.iterdir()):
if pack_dir.name.startswith(".") or not pack_dir.is_dir():
continue
pack_name = pack_dir.name
sources_dir = pack_dir / "sources"
pack_entry: dict[str, Any] = {
"name": pack_name,
"path": str(pack_dir.relative_to(adas_root)),
"enabled": pack_name in wf["projects"]["adas"].get("active_packs", []),
"batches": [],
"total_images": 0,
"total_labels": 0,
"class_counts": {},
}
if sources_dir.is_dir():
for batch_dir in sorted(sources_dir.iterdir()):
if batch_dir.name.startswith(".") or not batch_dir.is_dir():
continue
batch_name = batch_dir.name
meta = read_meta(batch_dir) or {}
stage = meta.get("stage", "unknown")
task = meta.get("task", "")
counts = meta.get("counts", {})
img_count = counts.get("images", 0)
# Count quaternion_json detections
qdir = batch_dir / "labels" / "quaternion_json"
label_count = 0
class_counts: dict[str, int] = {}
if qdir.is_dir():
for qf in sorted(qdir.glob("*.json")):
try:
qdata = json.loads(qf.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
for det in qdata.get("detections") or []:
label_count += 1
cls = str(det.get("class_name") or det.get("class_id", "unknown"))
class_counts[cls] = class_counts.get(cls, 0) + 1
# Count images from directory if meta is missing
if img_count == 0:
imgs_dir = batch_dir / "images"
if imgs_dir.is_dir():
img_count = sum(1 for f in imgs_dir.rglob("*") if f.suffix.lower() in IMG_EXTS)
batch_entry = {
"batch": batch_name,
"task": task,
"stage": stage,
"images": img_count,
"labels": label_count,
"class_counts": class_counts,
"ingested_at": meta.get("ingested_at"),
}
pack_entry["batches"].append(batch_entry)
pack_entry["total_images"] += img_count
pack_entry["total_labels"] += label_count
for cls, n in class_counts.items():
pack_entry["class_counts"][cls] = pack_entry["class_counts"].get(cls, 0) + n
out["adas"][pack_name] = pack_entry
return out, build_source
@@ -996,7 +1061,9 @@ def get_catalog(
result = {"task": task_or_pack, **(full_catalog.get("dms", {}).get(task_or_pack, {}))}
elif project == "lane" and task_or_pack:
result = {"pack": task_or_pack, **(full_catalog.get("lane", {}).get(task_or_pack, {}))}
elif project in ("dms", "lane"):
elif project == "adas" and task_or_pack:
result = {"pack": task_or_pack, **(full_catalog.get("adas", {}).get(task_or_pack, {}))}
elif project in ("dms", "lane", "adas"):
result = {"workspace": full_catalog.get("workspace", str(WORKSPACE)), project: full_catalog.get(project, {})}
else:
result = dict(full_catalog)

View File

@@ -86,14 +86,20 @@ class AdasCuboidPromoteAdapter(PackPromoteAdapter):
def promote(self, ctx: PromoteContext) -> PromoteResult:
warnings = list(ctx.extra.get("validate_warnings") or [])
qdir = ctx.batch_dir / "labels" / "quaternion_json"
if not qdir.is_dir() or not any(qdir.glob("*.json")):
has_quat = qdir.is_dir() and any(qdir.glob("*.json"))
# YOLO HBB 回退
labels_dir = ctx.batch_dir / "labels"
has_yolo = (labels_dir / "yolo-hbb").is_dir() and any((labels_dir / "yolo-hbb").glob("*.txt"))
if not has_quat and not has_yolo:
return PromoteResult(
ok=False,
project=ctx.project,
task=ctx.task,
batch=ctx.batch,
pack=ctx.pack,
warnings=["missing quaternion_json export"],
warnings=["missing quaternion_json or YOLO HBB export"],
)
pack_dir = ctx.project_root / "packs" / ctx.pack
@@ -119,7 +125,9 @@ class AdasCuboidPromoteAdapter(PackPromoteAdapter):
if src_sub.is_dir():
copied += _sync_tree(src_sub, dest / sub)
normalized = _normalize_quaternion_json(dest)
normalized = _normalize_quaternion_json(dest) if has_quat else 0
if has_yolo and not has_quat:
warnings.append("using YOLO HBB labels (no quaternion_json)")
meta = read_meta(ctx.batch_dir) or {}
meta.update({
@@ -130,6 +138,7 @@ class AdasCuboidPromoteAdapter(PackPromoteAdapter):
"pack": ctx.pack,
"ingested_at": datetime.now(timezone.utc).isoformat(),
"pipeline_version": 2,
"label_format": "quaternion_json" if has_quat else "yolo_hbb",
})
write_meta(dest, meta)
write_meta(ctx.batch_dir, meta)

View File

@@ -1,4 +1,4 @@
"""ADAS cuboid batch validation before promote."""
"""ADAS batch validation before promote(支持 quaternion_json 和 YOLO HBB"""
from __future__ import annotations
import json
@@ -7,20 +7,87 @@ from pathlib import Path
from as_platform.labeling.class_map import load_adas_class_names
def _has_yolo_hbb_labels(batch_dir: Path) -> bool:
"""检查是否有 YOLO HBB txt 标注文件。"""
yolo_dir = batch_dir / "labels" / "yolo-hbb"
return yolo_dir.is_dir() and any(yolo_dir.glob("*.txt"))
def _validate_yolo_hbb(batch_dir: Path, expected_names: list[str]) -> tuple[list[str], list[str], dict]:
"""校验 YOLO HBB 格式标注。"""
errors: list[str] = []
warnings: list[str] = []
n_classes = len(expected_names)
labels_dir = batch_dir / "labels" / "yolo-hbb"
txt_files = sorted(labels_dir.glob("*.txt")) if labels_dir.is_dir() else []
total_boxes = 0
files_with_boxes = 0
for p in txt_files:
try:
lines = p.read_text(encoding="utf-8").strip().splitlines()
except OSError as e:
errors.append(f"{p.name}: read error ({e})")
continue
if not lines:
continue
has_valid = False
for line in lines:
line = line.strip()
if not line:
continue
parts = line.split()
if len(parts) < 5:
warnings.append(f"{p.name}: invalid YOLO line '{line[:40]}...'")
continue
try:
cid = int(parts[0])
if cid < 0 or cid >= n_classes:
errors.append(f"{p.name}: class_id {cid} out of range (0-{n_classes - 1})")
continue
# 验证 bbox 值在 [0,1]
for v in parts[1:5]:
fv = float(v)
if fv < 0 or fv > 1:
warnings.append(f"{p.name}: bbox value {fv} out of [0,1]")
except ValueError:
warnings.append(f"{p.name}: non-numeric value in '{line[:40]}...'")
continue
total_boxes += 1
has_valid = True
if has_valid:
files_with_boxes += 1
if files_with_boxes == 0:
errors.append("no valid YOLO HBB label files")
stats = {
"format": "yolo_hbb",
"label_files": len(txt_files),
"files_with_boxes": files_with_boxes,
"total_boxes": total_boxes,
}
return errors, warnings, stats
def validate_adas_cuboid_batch(
batch_dir: Path,
*,
allow_partial_3d: bool = False,
min_fit_ratio: float = 0.8,
) -> tuple[list[str], list[str], dict]:
"""Return (errors, warnings, stats)."""
"""Return (errors, warnings, stats). 优先 quaternion_json其次 YOLO HBB。"""
errors: list[str] = []
warnings: list[str] = []
qdir = batch_dir / "labels" / "quaternion_json"
expected_names = load_adas_class_names()
if not qdir.is_dir():
errors.append(f"missing labels/quaternion_json under {batch_dir}")
# 回退到 YOLO HBB 格式
if _has_yolo_hbb_labels(batch_dir):
return _validate_yolo_hbb(batch_dir, expected_names)
errors.append(f"missing labels/quaternion_json or YOLO HBB labels under {batch_dir}")
return errors, warnings, {}
files = sorted(qdir.glob("*.json"))

View File

@@ -34,7 +34,13 @@ def _scan_project_inbox(project: str, wf: dict | None = None) -> list[dict[str,
with session_scope() as db:
deliveries = {
(r.project, r.task or "", r.mode or "", r.batch_name): r
(r.project, r.task or "", r.mode or "", r.batch_name): {
"id": r.id,
"status": r.status,
"collection_start": r.collection_start,
"collection_end": r.collection_end,
"created_at": r.created_at,
}
for r in db.query(BatchDelivery).filter(BatchDelivery.project == project).all()
}
indexed = {
@@ -78,13 +84,13 @@ def _scan_project_inbox(project: str, wf: dict | None = None) -> list[dict[str,
"has_labels": has_labels,
"stage_hint": stage_hint,
"source_type": "inbox_scan",
"delivery_id": delivery.id if delivery else None,
"delivery_status": delivery.status if delivery else None,
"delivery_id": delivery["id"] if delivery else None,
"delivery_status": delivery["status"] if delivery else None,
"in_ledger": delivery is not None,
"in_workbench": in_index,
"collection_start": delivery.collection_start if delivery else _dir_mtime_iso(batch_dir),
"collection_end": delivery.collection_end if delivery else None,
"created_at": delivery.created_at.isoformat() if delivery and delivery.created_at else None,
"collection_start": delivery["collection_start"] if delivery else _dir_mtime_iso(batch_dir),
"collection_end": delivery["collection_end"] if delivery else None,
"created_at": delivery["created_at"].isoformat() if delivery and delivery["created_at"] else None,
"needs_ledger": delivery is None,
"needs_workbench": not in_index,
})

View File

@@ -277,6 +277,24 @@ def execute_action(action: str, params: dict[str, Any]) -> dict[str, Any]:
f"skipped_empty={conv.get('skipped_empty')} missing_ann={conv.get('missing_ann')}"
)
return {"ok": True, "stdout": json.dumps(conv, ensure_ascii=False), "stderr": "", "export_convert": conv}
if row.get("project") == "adas" and export == "yolo":
scripts_dir = WORKSPACE / "datasets" / "adas" / "scripts"
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
from export_ls_to_yolo import export_batch as export_adas_yolo
with session_scope() as db:
camp = db.get(LabelingCampaign, campaign_id)
if not camp:
raise ValueError("campaign not found")
batch_dir = resolve_campaign_batch_dir(camp)
conv = export_adas_yolo(batch_dir, task=task, out_subdir="labels/yolo-hbb")
if conv.get("written", 0) == 0:
raise ValueError(
"export_adas_yolo: 无有效 YOLO HBB 标注可导出 (written=0); "
f"skipped_empty={conv.get('skipped_empty')} missing_ann={conv.get('missing_ann')}"
)
return {"ok": True, "stdout": json.dumps(conv, ensure_ascii=False), "stderr": "", "export_convert": conv}
if row.get("project") == "adas" and export == "cvat_cuboid":
from as_platform.labeling.export_cuboid_batch import export_batch as export_cuboid_batch

View File

@@ -119,8 +119,8 @@ def on_labeling_export_job_succeeded(job: dict) -> None:
except Exception:
return
project = camp.project or "dms"
if _batch_has_export_labels(project, batch_dir):
_advance_campaign_stage(str(cid), "returned")
# job 已成功,无论是否有 CLI 导出产物,均进入 returned 阶段
_advance_campaign_stage(str(cid), "returned")
if project == "adas" and _batch_has_calib(batch_dir):
from as_platform.jobs.queue import enqueue_job

View File

@@ -102,8 +102,45 @@ def _resolve_image_for_ann(data: dict[str, Any], batch_dir: Path, task_id: str)
return None
def _rect_item_to_detection(item: dict[str, Any], class_map: dict[str, int]) -> dict[str, Any] | None:
"""2D rectanglelabels 条目 → quaternion_json detection无 3D 信息,仅 bbox2d"""
value = item.get("value") or {}
w_pct = float(value.get("width") or 0)
h_pct = float(value.get("height") or 0)
if w_pct <= 0 or h_pct <= 0:
return None
labels = value.get("rectanglelabels") or []
label = str(labels[0]) if labels else "unknown"
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:
return None
img_w = int(item.get("original_width") or 1920)
img_h = int(item.get("original_height") or 1080)
x_pct = float(value.get("x") or 0)
y_pct = float(value.get("y") or 0)
x1 = int(x_pct / 100.0 * img_w)
y1 = int(y_pct / 100.0 * img_h)
x2 = int((x_pct + w_pct) / 100.0 * img_w)
y2 = int((y_pct + h_pct) / 100.0 * img_h)
return {
"class_id": class_id,
"class_name": label,
"score": 1.0,
"box2d_xyxy": [x1, y1, x2, y2],
"fit_ok": False,
"source": "2d_rect",
}
def export_batch(batch_dir: Path) -> dict[str, Any]:
"""导出 cuboid ls_annotations → quaternion_json。"""
"""导出 ls_annotationscuboid + 2D rectangle→ quaternion_json。"""
batch_dir = batch_dir.resolve()
class_map = _load_cuboid_class_map()
calib_path, K, calib_size = _find_calib(batch_dir)
@@ -124,7 +161,9 @@ def export_batch(batch_dir: Path) -> dict[str, Any]:
continue
regions = _extract_result_regions(data)
cuboids = [r for r in regions if r.get("type") == "cuboid"]
if not cuboids:
rectangles = [r for r in regions if r.get("type") == "rectanglelabels"]
if not cuboids and not rectangles:
skipped_empty += 1
continue
@@ -134,16 +173,24 @@ def export_batch(batch_dir: Path) -> dict[str, Any]:
continue
detections: list[dict[str, Any]] = []
# 处理 cuboid 标注
for item in cuboids:
det = cuboid_item_to_detection(item, class_map, K=K)
if det:
detections.append(det)
# 处理 2D 矩形标注 → quaternion_json detection
for item in rectangles:
det = _rect_item_to_detection(item, class_map)
if det:
detections.append(det)
if not detections:
skipped_empty += 1
continue
img_w = int((cuboids[0].get("original_width") or (calib_size or [1920, 1080])[0]))
img_h = int((cuboids[0].get("original_height") or (calib_size or [1920, 1080])[1]))
# 获取图片尺寸:优先 cuboid其次 rectangle
first_region = cuboids[0] if cuboids else rectangles[0]
img_w = int((first_region.get("original_width") or (calib_size or [1920, 1080])[0]))
img_h = int((first_region.get("original_height") or (calib_size or [1920, 1080])[1]))
payload: dict[str, Any] = {
"image": str(image_path),

View File

@@ -238,10 +238,14 @@ def cvat_shapes_to_export_regions(
}
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))
pts = shape.get("points") or []
if len(pts) >= 4:
xtl, ytl, xbr, ybr = float(pts[0]), float(pts[1]), float(pts[2]), float(pts[3])
else:
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",

View File

@@ -186,9 +186,9 @@ def open_campaign(
else:
camp.status = "in_progress"
camp.updated_at = now
if cvat_task_id and not camp.cvat_task_id:
camp.cvat_task_id = cvat_task_id
camp.cvat_job_url = cvat_job_url
# 每次开标都创建新 CVAT Task始终更新 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()
@@ -531,6 +531,7 @@ def _cvat_upload_thread(cvat_task_id: int, image_paths: list, campaign_id: str |
with session_scope() as db:
camp = db.get(LabelingCampaign, campaign_id)
if camp and camp.cvat_job_url is None:
camp.cvat_task_id = cvat_task_id
camp.cvat_job_url = f"_UPLOAD_FAILED_: {e}"
camp.status = "upload_failed"
updated = True
@@ -542,12 +543,13 @@ def _cvat_upload_thread(cvat_task_id: int, image_paths: list, campaign_id: str |
try:
with session_scope() as db:
camp = db.get(LabelingCampaign, campaign_id)
# 上传成功后,尝试回填 job_url
# 上传成功后,回填 job_url 和 task_id
if camp and camp.cvat_job_url is None:
from as_platform.labeling.cvat_client import get_cvat_client
cvat = get_cvat_client()
task = cvat.get_task(cvat_task_id)
if task.job_url:
camp.cvat_task_id = cvat_task_id
camp.cvat_job_url = task.job_url
camp.status = "in_progress"
updated = True
@@ -657,7 +659,11 @@ def _build_class_map(camp, reg: dict | None) -> dict[str, int]:
def get_cvat_status(campaign_id: str) -> dict[str, Any]:
"""查询 CVAT 侧 Task 状态,包含上传进度信息。"""
"""查询 CVAT 侧 Task 状态,包含上传进度信息。
自动恢复:当检测到 cvat_job_url 为 None 且 CVAT Task 无数据时,
说明上传线程已丢失(如容器重启),自动重新触发上传。
"""
with session_scope() as db:
camp = db.get(LabelingCampaign, campaign_id)
if not camp:
@@ -690,12 +696,31 @@ def get_cvat_status(campaign_id: str) -> dict[str, Any]:
images = _iter_batch_images(batch_dir)
image_count = len(images)
except Exception:
images = []
pass
# 自动恢复cvat_job_url 为空且 CVAT Task 无 Job → 上传线程已丢失
resolved_url = task.job_url or camp.cvat_job_url
if not resolved_url and image_count > 0:
# CVAT Task 存在但无数据,重新触发上传
import threading
_needs_recovery = True
try:
r = cvat._session.get(f"{cvat._api}/tasks/{camp.cvat_task_id}/data/meta")
_needs_recovery = r.status_code >= 400 # 400 = 未上传数据
except Exception:
pass
if _needs_recovery:
print(f"[CVAT] 检测到断线任务,自动恢复上传 task={camp.cvat_task_id} campaign={campaign_id}")
uploader = _cvat_upload_thread(camp.cvat_task_id, images, campaign_id=campaign_id)
threading.Thread(target=uploader, daemon=True).start()
camp.cvat_job_url = None # 重置,等待线程回填
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_job_url": resolved_url,
"cvat_status": task.status,
"image_count": image_count,
}

View File

@@ -396,18 +396,18 @@ export const hsapApi = {
if (opts?.status) p.set("status", opts.status);
if (opts?.offset != null) p.set("offset", String(opts.offset));
if (opts?.limit != null) p.set("limit", String(opts.limit));
return fetchJson<PagedResult<Record<string, unknown>>>(`${API_BASE}/api/v1/approvals?${p}`);
return fetchJson<PagedResult<Record<string, unknown>>>(`${API_BASE}/api/v1/system/audit?${p}`);
},
getApproval: (id: string) => fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/approvals/${id}`),
getApprovalPreview: (id: string) => fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/approvals/${id}/preview`),
getApproval: (id: string) => fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/system/audit/${id}`),
getApprovalPreview: (id: string) => fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/system/audit/${id}/preview`),
listApprovalImages: (id: string, offset = 0, limit = 60) =>
fetchJson<{ total: number; items: { id: string }[] }>(`${API_BASE}/api/v1/approvals/${id}/images?offset=${offset}&limit=${limit}`),
fetchJson<{ total: number; items: { id: string }[] }>(`${API_BASE}/api/v1/system/audit/${id}/images?offset=${offset}&limit=${limit}`),
fetchApprovalImageBlob: async (approvalId: string, imageId: string, thumb = true) => {
const q = thumb ? "?thumb=true" : "?thumb=false";
const res = await fetch(`${API_BASE}/api/v1/approvals/${approvalId}/images/${imageId}${q}`, { headers: authHeaders(), cache: "no-store" });
const res = await fetch(`${API_BASE}/api/v1/system/audit/${approvalId}/images/${imageId}${q}`, { headers: authHeaders(), cache: "no-store" });
if (!res.ok) throw new Error(await res.text());
return URL.createObjectURL(await res.blob());
},

View File

@@ -2,6 +2,7 @@
export type CatalogReport = Record<string, unknown> & {
dms?: Record<string, DmsTaskEntry>;
lane?: Record<string, Record<string, unknown>>;
adas?: Record<string, AdasPackEntry>;
projects?: { dms?: { active_packs?: string[] }; lane?: { active_packs?: string[] } };
_cache?: Record<string, unknown>;
};
@@ -19,6 +20,28 @@ export type DmsPackRow = {
total_boxes?: number; label_files?: number; sampled?: boolean;
};
// ── ADAS catalog types ──
export type AdasPackEntry = {
name: string;
path: string;
enabled: boolean;
total_images: number;
total_labels: number;
class_counts: Record<string, number>;
batches: AdasBatchEntry[];
};
export type AdasBatchEntry = {
batch: string;
task: string;
stage: string;
images: number;
labels: number;
class_counts: Record<string, number>;
ingested_at?: string;
};
export type SplitCounts = { train: number; val: number; test: number };
export function aggregateSplitCounts(packs: DmsPackRow[]): SplitCounts {

View File

@@ -10,6 +10,7 @@ import type { LabelingBatchRow } from "@/lib/types";
type ExportBatchTableProps = {
batches: LabelingBatchRow[];
importingId: string | null;
exportingId: string | null;
buildingId: string | null;
onExport: (campaignId: string) => void;
onImportVendor: (campaignId: string) => void;
@@ -21,6 +22,7 @@ const COLS = ["34%", "14%", "8%", "10%", "auto", "7.5rem"];
export const ExportBatchTable: React.FC<ExportBatchTableProps> = ({
batches,
importingId,
exportingId,
buildingId,
onExport,
onImportVendor,
@@ -67,7 +69,7 @@ export const ExportBatchTable: React.FC<ExportBatchTableProps> = ({
<td className="py-2 px-2 whitespace-nowrap w-[7.5rem]">
{cid && isExport && (
<div className="inline-flex items-center justify-center gap-0.5">
<Button size="small" variant="primary" className="!px-2" onClick={() => onExport(cid)}>
<Button size="small" variant="primary" className="!px-2" loading={exportingId === cid} onClick={() => onExport(cid)}>
</Button>
<Button

View File

@@ -13,7 +13,8 @@ import {
buildDmsPackTree, dmsPacks, dmsTaskModes,
findPackRow, isDmsCatalogScope, packTaskKey, parseCatalogScope,
parsePackTaskKey, primaryPack, scopeKeyFromSelection, selectionFromScopeKey,
splitCountsFromPack, type CatalogReport, type CatalogUiSelection,
splitCountsFromPack, type AdasBatchEntry, type AdasPackEntry,
type CatalogReport, type CatalogUiSelection,
type CatalogViewKind, type DmsPackRow, type DmsTaskEntry,
} from "@/lib/dmsCatalog";
@@ -183,8 +184,8 @@ export const CatalogPage: React.FC = () => {
))}
</div>
{/* DMS / Forward sub-views */}
{domain !== "lane" && (
{/* DMS sub-views */}
{domain === "dms" && (
<div className="flex flex-wrap gap-2 mb-4 text-sm">
<p className="text-xs text-gray-500 m-0 self-center mr-2"></p>
{(["pack", "batch"] as const).map((v) => (
@@ -198,8 +199,8 @@ export const CatalogPage: React.FC = () => {
</div>
)}
{/* Pack selector */}
{domain !== "lane" && subView === "pack" && packTree.length > 0 && (
{/* Pack selector — only for DMS */}
{domain === "dms" && subView === "pack" && packTree.length > 0 && (
<div className="flex flex-wrap gap-3 items-end mb-4">
<label className="flex-1 min-w-[8rem] text-xs text-gray-500">
<select className={selectClass} value={ui.pack} onChange={(e) => setPack(e.target.value)}>
@@ -214,8 +215,75 @@ export const CatalogPage: React.FC = () => {
</div>
)}
{/* DMS/Forward: Charts */}
{domain !== "lane" && taskEntry && isDmsCatalogScope(scope) && (
{/* ADAS 前向Pack + Batch 表格 */}
{domain === "forward" && cat?.adas && Object.keys(cat.adas).length > 0 && (
<div className="space-y-4">
{Object.entries(cat.adas as Record<string, AdasPackEntry>).map(([packName, pack]) => (
<div key={packName} className="rounded-xl border border-gray-200 bg-white overflow-hidden">
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200 flex items-center gap-3">
<span className="font-semibold text-base">{packName}</span>
{pack.enabled && <Badge variant="success"></Badge>}
<span className="text-sm text-gray-500 ml-auto">
{pack.total_images} · {pack.total_labels} · {pack.batches.length}
</span>
</div>
{pack.batches.length > 0 ? (
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 text-left border-b border-gray-200">
<th className="px-4 py-2"></th>
<th className="px-4 py-2"></th>
<th className="px-4 py-2"></th>
<th className="px-4 py-2"></th>
<th className="px-4 py-2"></th>
<th className="px-4 py-2"></th>
<th className="px-4 py-2"></th>
</tr>
</thead>
<tbody>
{pack.batches.map((b: AdasBatchEntry) => (
<tr key={b.batch} className="border-b border-gray-100 hover:bg-gray-50">
<td className="px-4 py-2 font-mono text-xs">{b.batch}</td>
<td className="px-4 py-2">{b.task}</td>
<td className="px-4 py-2">
<Badge variant={b.stage === "ingested" ? "success" : "default"} size="small">{b.stage}</Badge>
</td>
<td className="px-4 py-2">{b.images}</td>
<td className="px-4 py-2">{b.labels}</td>
<td className="px-4 py-2">
<div className="flex flex-wrap gap-1">
{Object.entries(b.class_counts).map(([cls, n]) => (
<span key={cls} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-blue-50 text-blue-700 text-xs">
{cls} <span className="font-medium">{n}</span>
</span>
))}
</div>
</td>
<td className="px-4 py-2 text-xs text-gray-400">
{b.ingested_at ? new Date(b.ingested_at).toLocaleString("zh-CN") : "—"}
</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="px-4 py-8 text-center text-gray-400 text-sm"></div>
)}
</div>
))}
</div>
)}
{/* Empty state for forward */}
{domain === "forward" && (!cat?.adas || Object.keys(cat.adas).length === 0) && (
<div className="card text-center py-12 text-gray-400">
<p className="text-lg mb-2"> ADAS </p>
<p className="text-sm"> quaternion_json </p>
</div>
)}
{/* DMS/Forward: Charts — only for DMS domain */}
{domain === "dms" && taskEntry && isDmsCatalogScope(scope) && (
<div className="rounded-xl border border-gray-200 bg-white p-3 mb-4">
<div className="flex flex-wrap gap-2 items-center mb-2">
<span className="text-base font-semibold">{activePackTask?.label || taskEntry.label || scope.task}</span>
@@ -253,8 +321,8 @@ export const CatalogPage: React.FC = () => {
</div>
)}
{/* Empty state */}
{domain !== "lane" && !taskEntry && (
{/* Empty state for DMS */}
{domain === "dms" && !taskEntry && (
<div className="card text-center py-12 text-gray-400">
<p className="text-lg mb-2"> {DOMAIN_TABS.find((t) => t.key === domain)?.label} </p>
<p className="text-sm"></p>
@@ -266,8 +334,8 @@ export const CatalogPage: React.FC = () => {
</div>
)}
{/* Pack table */}
{domain !== "lane" && isDmsCatalogScope(scope) && taskEntry && tablePacks.length > 0 && (
{/* DMS pack table — only for DMS domain */}
{domain === "dms" && isDmsCatalogScope(scope) && taskEntry && tablePacks.length > 0 && (
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead><tr className="bg-gray-50 text-left border-b border-gray-200"><th className="px-4 py-2"></th><th></th><th>train</th><th>val</th><th>test</th></tr></thead>

View File

@@ -19,6 +19,7 @@ export const ExportPage: React.FC = () => {
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [importingId, setImportingId] = useState<string | null>(null);
const [exportingId, setExportingId] = useState<string | null>(null);
const [buildingId, setBuildingId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [stageFilter, setStageFilter] = useState("");
@@ -60,8 +61,11 @@ export const ExportPage: React.FC = () => {
const reloadCurrent = useCallback(() => load(offset, limit), [load, offset, limit]);
const handleExport = async (campaignId: string) => {
setExportingId(campaignId);
setError(null);
try { await hsapApi.labelingExport(campaignId); setInfo("导出任务已提交"); reloadCurrent(); }
catch (e) { setError(String(e)); }
setExportingId(null);
};
const handleSubmitBuild = async (b: LabelingBatchRow) => {
@@ -135,6 +139,7 @@ export const ExportPage: React.FC = () => {
<ExportBatchTable
batches={batches}
importingId={importingId}
exportingId={exportingId}
buildingId={buildingId}
onExport={handleExport}
onImportVendor={handleImportVendor}