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

2
.gitignore vendored
View File

@@ -49,6 +49,8 @@ datasets/**/inbox/
datasets/**/archive/ datasets/**/archive/
datasets/**/sources/ datasets/**/sources/
datasets/**/DATASET/ datasets/**/DATASET/
datasets/**/versions/
datasets/**/manifests/ingest_log.jsonl
datasets/**/*.jpg datasets/**/*.jpg
datasets/**/*.jpeg datasets/**/*.jpeg
datasets/**/*.png datasets/**/*.png

View File

@@ -1,13 +1,18 @@
# ADAS MOON-3D 训练包 `adas_moon3d_v1` # ADAS 前向训练包 `adas_v1`
> **2026-07 更新**pack 已从 `adas_moon3d_v1` 重命名为 `adas_v1`(见 `workflow.registry.yaml`)。
> 新增 YOLO HBB 导出路径和 2D 矩形标注支持。
## 目录结构 ## 目录结构
```text ```text
datasets/adas/packs/adas_moon3d_v1/ datasets/adas/packs/adas_v1/
├── sources/{batch}/ ├── sources/{batch}/
│ ├── images/ │ ├── images/
│ ├── calib/ │ ├── calib/
│ └── labels/quaternion_json/ │ └── labels/
│ ├── quaternion_json/ # 3D cuboid 导出(有 calib 时优先)
│ └── yolo-hbb/ # 2D YOLO HBB 导出(无 calib 回退)
├── lists/ ├── lists/
│ ├── train_stems.txt │ ├── train_stems.txt
│ └── val_stems.txt │ └── val_stems.txt
@@ -32,20 +37,34 @@ datasets/adas/packs/adas_moon3d_v1/
## 管线 ## 管线
1. **labeling_export** — CVAT ls_annotations → `labels/quaternion_json/*.json` 1. **labeling_export** — CVAT ls_annotations → `labels/quaternion_json/*.json`
- 支持 **cuboid**3D 框)和 **rectanglelabels**2D 矩形)两种标注类型
- 2D 矩形自动转为 quaternion_json detection`fit_ok=false`, `source="2d_rect"`
2. **cuboid_fit_3d**(有 calib 时自动触发)— 补全 3D 字段 2. **cuboid_fit_3d**(有 calib 时自动触发)— 补全 3D 字段
3. **build_adas**(审核)— `promote_batch` 复制到 pack + 刷新 stem 列表 3. **build_adas**(审核)— `promote_batch` 复制到 pack + 刷新 stem 列表
- 优先使用 quaternion_json无 cuboid 标注时回退到 YOLO HBB
### 导出路径说明
| 标注类型 | 导出产物 | 条件 |
|---------|---------|------|
| cuboid (3D) | `labels/quaternion_json/*.json` | CVAT cuboid 标注 |
| rectanglelabels (2D) | `labels/quaternion_json/*.json` | 2D 矩形自动桥接 |
| YOLO HBB | `labels/yolo-hbb/*.txt` | 无 cuboid 时使用 `export_adas_yolo` |
## CLI ## CLI
```bash ```bash
# 导出(平台 Job 或脚本 # cuboid 导出(平台 Job
PYTHONPATH=platform python3 -c "from as_platform.labeling.export_cuboid_batch import export_batch; ..." PYTHONPATH=platform python3 -c "from as_platform.labeling.export_cuboid_batch import export_batch; ..."
# YOLO HBB 导出(平台 Job无 cuboid 时自动选择)
# 由 jobs/runner.py 调用 datasets/adas/scripts/export_ls_to_yolo.py
# 3D 拟合 # 3D 拟合
PYTHONPATH=platform python3 -c "from as_platform.labeling.fit_cuboid_batch import fit_batch; ..." PYTHONPATH=platform python3 -c "from as_platform.labeling.fit_cuboid_batch import fit_batch; ..."
# 入包 # 入包
python as.py build adas cuboid_7cls --batch val_front6mm_pilot --pack adas_moon3d_v1 python as.py build adas cuboid_7cls --batch val_front6mm_pilot --pack adas_v1
``` ```
## Smoke ## Smoke
@@ -54,7 +73,6 @@ python as.py build adas cuboid_7cls --batch val_front6mm_pilot --pack adas_moon3
bash scripts/smoke_adas_promote.sh bash scripts/smoke_adas_promote.sh
``` ```
## 与 dms/packs/adas_v1 的区别 ## 数据目录
- `dms/packs/adas_v1`2D YOLO 历史包([`scripts/organize_adas.py`](../scripts/organize_adas.py) ADAS 前向数据在 **数据目录****前向** 视图中展示,按 pack → batch 层级显示图片数、标注数和类别分布。
- `datasets/adas/packs/adas_moon3d_v1`MOON-3D quaternion_json 3D GT

View File

@@ -18,7 +18,7 @@
- 数据候选:`platform/as_platform/db/models.py``DatasetCandidate` - 数据候选:`platform/as_platform/db/models.py``DatasetCandidate`
- 上传 API`server.py``upload/file``inspect-upload` - 上传 API`server.py``upload/file``inspect-upload`
- 审核:`audit/queue.py``/api/v1/approvals/*` - 审核:`audit/queue.py``/api/v1/system/audit/*`
- 入湖 CLI`as.py build` / `add` + `ingest_incremental.py` - 入湖 CLI`as.py build` / `add` + `ingest_incremental.py`
## 建议下一里程碑(未在本汇总 plan 全量实现) ## 建议下一里程碑(未在本汇总 plan 全量实现)

View File

@@ -119,7 +119,7 @@ bash scripts/smoke_dms_e2e_2img.sh setup
### Commit `0b8ade0` — Unified Ingest SDK ### Commit `0b8ade0` — Unified Ingest SDK
- `platform/as_platform/data/promote/` — DMS/ADAS promote_batch - `platform/as_platform/data/promote/` — DMS/ADAS promote_batch
- ADAS cuboid export / fit / `adas_moon3d_v1` - ADAS cuboid export / fit / `adas_v1`
- `platform/as_platform/tests/test_unified_ingest_sdk.py`7 项单元测试) - `platform/as_platform/tests/test_unified_ingest_sdk.py`7 项单元测试)
- `scripts/smoke_adas_promote.sh` - `scripts/smoke_adas_promote.sh`
- `scripts/smoke_labeling_api.sh` 已接入上述测试 - `scripts/smoke_labeling_api.sh` 已接入上述测试

View File

@@ -350,7 +350,7 @@ python as.py train dms dam --track platform # 平台训练(需审核)
| `/api/v1/system` | `system_routes.py` | 审核队列、任务监控、执行日志 | | `/api/v1/system` | `system_routes.py` | 审核队列、任务监控、执行日志 |
| `/api/v1/delivery` | `delivery_routes.py` | 批次交付、Bitable 集成 | | `/api/v1/delivery` | `delivery_routes.py` | 批次交付、Bitable 集成 |
| `/api/v1/feishu` | `feishu_routes.py` | Bitable Webhook 回调 | | `/api/v1/feishu` | `feishu_routes.py` | Bitable Webhook 回调 |
| `/api/v1/` (根) | `server.py` | health, dashboard, catalog, approvals, simulate, agents | | `/api/v1/` (根) | `server.py` | health, dashboard, catalog, simulate, agents |
### 6.2 核心端点速查 ### 6.2 核心端点速查
@@ -366,10 +366,10 @@ GET /api/v1/catalog
POST /api/v1/catalog POST /api/v1/catalog
# 审核流程 # 审核流程
GET /api/v1/approvals GET /api/v1/system/audit
POST /api/v1/approvals/{id}/approve POST /api/v1/system/audit/{id}/approve
POST /api/v1/approvals/{id}/reject POST /api/v1/system/audit/{id}/reject
GET /api/v1/approvals/{id}/preview GET /api/v1/system/audit/{id}/preview
# 任务队列 # 任务队列
GET /api/v1/jobs GET /api/v1/jobs

View File

@@ -15,7 +15,7 @@
**协调员 QA 门禁路径(推荐):** 标完 → **提交质检** (`in_review`) → 质检通过 (`labeling_submitted`) → **执行导出**`returned`**提交 build**(审核)→ `ingested` **协调员 QA 门禁路径(推荐):** 标完 → **提交质检** (`in_review`) → 质检通过 (`labeling_submitted`) → **执行导出**`returned`**提交 build**(审核)→ `ingested`
ADAS cuboid 详见 [ADAS_MOON3D_PACK.md](./ADAS_MOON3D_PACK.md):导出 → 可选 `cuboid_fit_3d``build_adas``adas_moon3d_v1` ADAS cuboid 详见 [ADAS_MOON3D_PACK.md](./ADAS_MOON3D_PACK.md):导出(支持 cuboid 3D / 2D 矩形 / YOLO HBB 回退)→ 可选 `cuboid_fit_3d``build_adas``adas_v1`
**登记 meta**`更多操作` 内)只写入 `batch.meta.yaml`**不等于**已送标。 **登记 meta**`更多操作` 内)只写入 `batch.meta.yaml`**不等于**已送标。
@@ -191,6 +191,7 @@ datasets/dms/inbox/dam/batch_0516/
|------|--------|--------|------| |------|--------|--------|------|
| **训练包**(默认) | `dms_v1` 等 | `dam` / `addw_face` / `前向·粗检测` / `前向·细分类` … | 该包下各任务的 train/val 快照;前向在 registry 为单任务双 mode目录按 mode 拆开显示 | | **训练包**(默认) | `dms_v1` 等 | `dam` / `addw_face` / `前向·粗检测` / `前向·细分类` … | 该包下各任务的 train/val 快照;前向在 registry 为单任务双 mode目录按 mode 拆开显示 |
| **采集批次** | 任务(如 `dam` | `0516 批次` 等 mode | 单波送标质量 | | **采集批次** | 任务(如 `dam` | `0516 批次` 等 mode | 单波送标质量 |
| **前向** | `adas_v1` 等 pack | 批次列表 | ADAS 3D/2D 标注批次,含图片数、标注数、类别分布;支持 quaternion_json 和 YOLO HBB |
| **车道线** | lane pack | — | Lane 列表统计 | | **车道线** | lane pack | — | Lane 列表统计 |
增量条(合并前)仍在磁盘 `packs/<pack>/<task>/sources/<批次名>/`;合并用 `python HSAP/as.py build dms dam --pack dms_v1 --all-sources` 增量条(合并前)仍在磁盘 `packs/<pack>/<task>/sources/<批次名>/`;合并用 `python HSAP/as.py build dms dam --pack dms_v1 --all-sources`

View File

@@ -68,7 +68,13 @@ def _class_names_for_campaign(camp) -> dict[int, str]:
import yaml import yaml
from as_platform.data.core import load_wf, proj_root 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 {} return {}
wf = load_wf() wf = load_wf()
root = proj_root(wf, "dms") root = proj_root(wf, "dms")
@@ -114,21 +120,42 @@ def _parse_ls_annotations(path: Path, class_names: dict[int, str]) -> list[dict[
return [] return []
out: list[dict[str, Any]] = [] out: list[dict[str, Any]] = []
for item in data.get("result") or []: for item in data.get("result") or []:
if item.get("type") not in ("rectanglelabels", "rectangle"): item_type = item.get("type")
continue if item_type == "cuboid":
val = item.get("value") or {} # Cuboid: 8 vertices in pixel coords → compute 2D bbox
w_pct = float(val.get("width") or 0) points = item.get("points") or []
h_pct = float(val.get("height") or 0) if len(points) < 16:
if w_pct <= 0 or h_pct <= 0: continue
continue label = item.get("label") or "unknown"
x_pct = float(val.get("x") or 0) cid = _name_to_class_id(str(label), class_names)
y_pct = float(val.get("y") or 0) # Compute min/max from 8 (x,y) pairs
labels = val.get("rectanglelabels") or val.get("labels") or [] xs = [points[i] for i in range(0, len(points), 2)]
label = labels[0] if labels else "unknown" ys = [points[i] for i in range(1, len(points), 2)]
cid = _name_to_class_id(str(label), class_names) min_x, max_x = min(xs), max(xs)
cx = (x_pct + w_pct / 2) / 100.0 min_y, max_y = min(ys), max(ys)
cy = (y_pct + h_pct / 2) / 100.0 if max_x <= min_x or max_y <= min_y:
out.append({"class_id": cid, "bbox": (cx, cy, w_pct / 100.0, h_pct / 100.0)}) 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 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}) files.append({"path": str(p), "missing": True})
dirs: list[dict[str, Any]] = [] dirs: list[dict[str, Any]] = []
for pname in ("dms", "lane"): for pname in ("dms", "lane", "adas"):
root = proj_root_fn(wf, pname) root = proj_root_fn(wf, pname)
dirs.append(_dir_fingerprint(root, scan_children=False)) dirs.append(_dir_fingerprint(root, scan_children=False))
dirs.append(_dir_fingerprint(root / "inbox")) dirs.append(_dir_fingerprint(root / "inbox"))

View File

@@ -11,7 +11,7 @@ from typing import Any
import yaml import yaml
from as_platform.config import WORKSPACE, WORKSPACE_ROOT, LANE_DATA_VIZ_ENABLED 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 ( from as_platform.data.catalog_cache import (
build_catalog_signature, build_catalog_signature,
get_cached_catalog, 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]: 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" build_source = "scan"
reports = load_dms_reports() 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", "add_template": "python as.py add lane --src <archive> --engineer <name> --date YYYYMMDD",
"quality": lane_quality, "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 return out, build_source
@@ -996,7 +1061,9 @@ def get_catalog(
result = {"task": task_or_pack, **(full_catalog.get("dms", {}).get(task_or_pack, {}))} result = {"task": task_or_pack, **(full_catalog.get("dms", {}).get(task_or_pack, {}))}
elif project == "lane" and task_or_pack: elif project == "lane" and task_or_pack:
result = {"pack": task_or_pack, **(full_catalog.get("lane", {}).get(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, {})} result = {"workspace": full_catalog.get("workspace", str(WORKSPACE)), project: full_catalog.get(project, {})}
else: else:
result = dict(full_catalog) result = dict(full_catalog)

View File

@@ -86,14 +86,20 @@ class AdasCuboidPromoteAdapter(PackPromoteAdapter):
def promote(self, ctx: PromoteContext) -> PromoteResult: def promote(self, ctx: PromoteContext) -> PromoteResult:
warnings = list(ctx.extra.get("validate_warnings") or []) warnings = list(ctx.extra.get("validate_warnings") or [])
qdir = ctx.batch_dir / "labels" / "quaternion_json" 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( return PromoteResult(
ok=False, ok=False,
project=ctx.project, project=ctx.project,
task=ctx.task, task=ctx.task,
batch=ctx.batch, batch=ctx.batch,
pack=ctx.pack, pack=ctx.pack,
warnings=["missing quaternion_json export"], warnings=["missing quaternion_json or YOLO HBB export"],
) )
pack_dir = ctx.project_root / "packs" / ctx.pack pack_dir = ctx.project_root / "packs" / ctx.pack
@@ -119,7 +125,9 @@ class AdasCuboidPromoteAdapter(PackPromoteAdapter):
if src_sub.is_dir(): if src_sub.is_dir():
copied += _sync_tree(src_sub, dest / sub) 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 = read_meta(ctx.batch_dir) or {}
meta.update({ meta.update({
@@ -130,6 +138,7 @@ class AdasCuboidPromoteAdapter(PackPromoteAdapter):
"pack": ctx.pack, "pack": ctx.pack,
"ingested_at": datetime.now(timezone.utc).isoformat(), "ingested_at": datetime.now(timezone.utc).isoformat(),
"pipeline_version": 2, "pipeline_version": 2,
"label_format": "quaternion_json" if has_quat else "yolo_hbb",
}) })
write_meta(dest, meta) write_meta(dest, meta)
write_meta(ctx.batch_dir, 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 from __future__ import annotations
import json import json
@@ -7,20 +7,87 @@ from pathlib import Path
from as_platform.labeling.class_map import load_adas_class_names 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( def validate_adas_cuboid_batch(
batch_dir: Path, batch_dir: Path,
*, *,
allow_partial_3d: bool = False, allow_partial_3d: bool = False,
min_fit_ratio: float = 0.8, min_fit_ratio: float = 0.8,
) -> tuple[list[str], list[str], dict]: ) -> tuple[list[str], list[str], dict]:
"""Return (errors, warnings, stats).""" """Return (errors, warnings, stats). 优先 quaternion_json其次 YOLO HBB。"""
errors: list[str] = [] errors: list[str] = []
warnings: list[str] = [] warnings: list[str] = []
qdir = batch_dir / "labels" / "quaternion_json" qdir = batch_dir / "labels" / "quaternion_json"
expected_names = load_adas_class_names() expected_names = load_adas_class_names()
if not qdir.is_dir(): 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, {} return errors, warnings, {}
files = sorted(qdir.glob("*.json")) 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: with session_scope() as db:
deliveries = { 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() for r in db.query(BatchDelivery).filter(BatchDelivery.project == project).all()
} }
indexed = { indexed = {
@@ -78,13 +84,13 @@ def _scan_project_inbox(project: str, wf: dict | None = None) -> list[dict[str,
"has_labels": has_labels, "has_labels": has_labels,
"stage_hint": stage_hint, "stage_hint": stage_hint,
"source_type": "inbox_scan", "source_type": "inbox_scan",
"delivery_id": delivery.id if delivery else None, "delivery_id": delivery["id"] if delivery else None,
"delivery_status": delivery.status if delivery else None, "delivery_status": delivery["status"] if delivery else None,
"in_ledger": delivery is not None, "in_ledger": delivery is not None,
"in_workbench": in_index, "in_workbench": in_index,
"collection_start": delivery.collection_start if delivery else _dir_mtime_iso(batch_dir), "collection_start": delivery["collection_start"] if delivery else _dir_mtime_iso(batch_dir),
"collection_end": delivery.collection_end if delivery else None, "collection_end": delivery["collection_end"] if delivery else None,
"created_at": delivery.created_at.isoformat() if delivery and delivery.created_at else None, "created_at": delivery["created_at"].isoformat() if delivery and delivery["created_at"] else None,
"needs_ledger": delivery is None, "needs_ledger": delivery is None,
"needs_workbench": not in_index, "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')}" 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} 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": if row.get("project") == "adas" and export == "cvat_cuboid":
from as_platform.labeling.export_cuboid_batch import export_batch as export_cuboid_batch 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: except Exception:
return return
project = camp.project or "dms" project = camp.project or "dms"
if _batch_has_export_labels(project, batch_dir): # job 已成功,无论是否有 CLI 导出产物,均进入 returned 阶段
_advance_campaign_stage(str(cid), "returned") _advance_campaign_stage(str(cid), "returned")
if project == "adas" and _batch_has_calib(batch_dir): if project == "adas" and _batch_has_calib(batch_dir):
from as_platform.jobs.queue import enqueue_job 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 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]: 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() batch_dir = batch_dir.resolve()
class_map = _load_cuboid_class_map() class_map = _load_cuboid_class_map()
calib_path, K, calib_size = _find_calib(batch_dir) calib_path, K, calib_size = _find_calib(batch_dir)
@@ -124,7 +161,9 @@ def export_batch(batch_dir: Path) -> dict[str, Any]:
continue continue
regions = _extract_result_regions(data) regions = _extract_result_regions(data)
cuboids = [r for r in regions if r.get("type") == "cuboid"] 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 skipped_empty += 1
continue continue
@@ -134,16 +173,24 @@ def export_batch(batch_dir: Path) -> dict[str, Any]:
continue continue
detections: list[dict[str, Any]] = [] detections: list[dict[str, Any]] = []
# 处理 cuboid 标注
for item in cuboids: for item in cuboids:
det = cuboid_item_to_detection(item, class_map, K=K) det = cuboid_item_to_detection(item, class_map, K=K)
if det: if det:
detections.append(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: if not detections:
skipped_empty += 1 skipped_empty += 1
continue continue
img_w = int((cuboids[0].get("original_width") or (calib_size or [1920, 1080])[0])) # 获取图片尺寸:优先 cuboid其次 rectangle
img_h = int((cuboids[0].get("original_height") or (calib_size or [1920, 1080])[1])) 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] = { payload: dict[str, Any] = {
"image": str(image_path), "image": str(image_path),

View File

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

View File

@@ -186,9 +186,9 @@ def open_campaign(
else: else:
camp.status = "in_progress" camp.status = "in_progress"
camp.updated_at = now camp.updated_at = now
if cvat_task_id and not camp.cvat_task_id: # 每次开标都创建新 CVAT Task始终更新 task_id
camp.cvat_task_id = cvat_task_id camp.cvat_task_id = cvat_task_id
camp.cvat_job_url = cvat_job_url camp.cvat_job_url = cvat_job_url
if ann_types and not camp.annotation_types: if ann_types and not camp.annotation_types:
camp.annotation_types = ann_types camp.annotation_types = ann_types
db.flush() 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: with session_scope() as db:
camp = db.get(LabelingCampaign, campaign_id) camp = db.get(LabelingCampaign, campaign_id)
if camp and camp.cvat_job_url is None: 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.cvat_job_url = f"_UPLOAD_FAILED_: {e}"
camp.status = "upload_failed" camp.status = "upload_failed"
updated = True updated = True
@@ -542,12 +543,13 @@ def _cvat_upload_thread(cvat_task_id: int, image_paths: list, campaign_id: str |
try: try:
with session_scope() as db: with session_scope() as db:
camp = db.get(LabelingCampaign, campaign_id) camp = db.get(LabelingCampaign, campaign_id)
# 上传成功后,尝试回填 job_url # 上传成功后,回填 job_url 和 task_id
if camp and camp.cvat_job_url is None: if camp and camp.cvat_job_url is None:
from as_platform.labeling.cvat_client import get_cvat_client from as_platform.labeling.cvat_client import get_cvat_client
cvat = get_cvat_client() cvat = get_cvat_client()
task = cvat.get_task(cvat_task_id) task = cvat.get_task(cvat_task_id)
if task.job_url: if task.job_url:
camp.cvat_task_id = cvat_task_id
camp.cvat_job_url = task.job_url camp.cvat_job_url = task.job_url
camp.status = "in_progress" camp.status = "in_progress"
updated = True 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]: 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: with session_scope() as db:
camp = db.get(LabelingCampaign, campaign_id) camp = db.get(LabelingCampaign, campaign_id)
if not camp: if not camp:
@@ -690,12 +696,31 @@ def get_cvat_status(campaign_id: str) -> dict[str, Any]:
images = _iter_batch_images(batch_dir) images = _iter_batch_images(batch_dir)
image_count = len(images) image_count = len(images)
except Exception: except Exception:
images = []
pass 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 { return {
"cvat_available": True, "cvat_available": True,
"campaign_id": campaign_id, "campaign_id": campaign_id,
"cvat_task_id": camp.cvat_task_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, "cvat_status": task.status,
"image_count": image_count, "image_count": image_count,
} }

View File

@@ -396,18 +396,18 @@ export const hsapApi = {
if (opts?.status) p.set("status", opts.status); if (opts?.status) p.set("status", opts.status);
if (opts?.offset != null) p.set("offset", String(opts.offset)); if (opts?.offset != null) p.set("offset", String(opts.offset));
if (opts?.limit != null) p.set("limit", String(opts.limit)); 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}`), 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/approvals/${id}/preview`), getApprovalPreview: (id: string) => fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/system/audit/${id}/preview`),
listApprovalImages: (id: string, offset = 0, limit = 60) => 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) => { fetchApprovalImageBlob: async (approvalId: string, imageId: string, thumb = true) => {
const q = thumb ? "?thumb=true" : "?thumb=false"; 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()); if (!res.ok) throw new Error(await res.text());
return URL.createObjectURL(await res.blob()); return URL.createObjectURL(await res.blob());
}, },

View File

@@ -2,6 +2,7 @@
export type CatalogReport = Record<string, unknown> & { export type CatalogReport = Record<string, unknown> & {
dms?: Record<string, DmsTaskEntry>; dms?: Record<string, DmsTaskEntry>;
lane?: Record<string, Record<string, unknown>>; lane?: Record<string, Record<string, unknown>>;
adas?: Record<string, AdasPackEntry>;
projects?: { dms?: { active_packs?: string[] }; lane?: { active_packs?: string[] } }; projects?: { dms?: { active_packs?: string[] }; lane?: { active_packs?: string[] } };
_cache?: Record<string, unknown>; _cache?: Record<string, unknown>;
}; };
@@ -19,6 +20,28 @@ export type DmsPackRow = {
total_boxes?: number; label_files?: number; sampled?: boolean; 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 type SplitCounts = { train: number; val: number; test: number };
export function aggregateSplitCounts(packs: DmsPackRow[]): SplitCounts { export function aggregateSplitCounts(packs: DmsPackRow[]): SplitCounts {

View File

@@ -10,6 +10,7 @@ import type { LabelingBatchRow } from "@/lib/types";
type ExportBatchTableProps = { type ExportBatchTableProps = {
batches: LabelingBatchRow[]; batches: LabelingBatchRow[];
importingId: string | null; importingId: string | null;
exportingId: string | null;
buildingId: string | null; buildingId: string | null;
onExport: (campaignId: string) => void; onExport: (campaignId: string) => void;
onImportVendor: (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> = ({ export const ExportBatchTable: React.FC<ExportBatchTableProps> = ({
batches, batches,
importingId, importingId,
exportingId,
buildingId, buildingId,
onExport, onExport,
onImportVendor, onImportVendor,
@@ -67,7 +69,7 @@ export const ExportBatchTable: React.FC<ExportBatchTableProps> = ({
<td className="py-2 px-2 whitespace-nowrap w-[7.5rem]"> <td className="py-2 px-2 whitespace-nowrap w-[7.5rem]">
{cid && isExport && ( {cid && isExport && (
<div className="inline-flex items-center justify-center gap-0.5"> <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>
<Button <Button

View File

@@ -13,7 +13,8 @@ import {
buildDmsPackTree, dmsPacks, dmsTaskModes, buildDmsPackTree, dmsPacks, dmsTaskModes,
findPackRow, isDmsCatalogScope, packTaskKey, parseCatalogScope, findPackRow, isDmsCatalogScope, packTaskKey, parseCatalogScope,
parsePackTaskKey, primaryPack, scopeKeyFromSelection, selectionFromScopeKey, parsePackTaskKey, primaryPack, scopeKeyFromSelection, selectionFromScopeKey,
splitCountsFromPack, type CatalogReport, type CatalogUiSelection, splitCountsFromPack, type AdasBatchEntry, type AdasPackEntry,
type CatalogReport, type CatalogUiSelection,
type CatalogViewKind, type DmsPackRow, type DmsTaskEntry, type CatalogViewKind, type DmsPackRow, type DmsTaskEntry,
} from "@/lib/dmsCatalog"; } from "@/lib/dmsCatalog";
@@ -183,8 +184,8 @@ export const CatalogPage: React.FC = () => {
))} ))}
</div> </div>
{/* DMS / Forward sub-views */} {/* DMS sub-views */}
{domain !== "lane" && ( {domain === "dms" && (
<div className="flex flex-wrap gap-2 mb-4 text-sm"> <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> <p className="text-xs text-gray-500 m-0 self-center mr-2"></p>
{(["pack", "batch"] as const).map((v) => ( {(["pack", "batch"] as const).map((v) => (
@@ -198,8 +199,8 @@ export const CatalogPage: React.FC = () => {
</div> </div>
)} )}
{/* Pack selector */} {/* Pack selector — only for DMS */}
{domain !== "lane" && subView === "pack" && packTree.length > 0 && ( {domain === "dms" && subView === "pack" && packTree.length > 0 && (
<div className="flex flex-wrap gap-3 items-end mb-4"> <div className="flex flex-wrap gap-3 items-end mb-4">
<label className="flex-1 min-w-[8rem] text-xs text-gray-500"> <label className="flex-1 min-w-[8rem] text-xs text-gray-500">
<select className={selectClass} value={ui.pack} onChange={(e) => setPack(e.target.value)}> <select className={selectClass} value={ui.pack} onChange={(e) => setPack(e.target.value)}>
@@ -214,8 +215,75 @@ export const CatalogPage: React.FC = () => {
</div> </div>
)} )}
{/* DMS/Forward: Charts */} {/* ADAS 前向Pack + Batch 表格 */}
{domain !== "lane" && taskEntry && isDmsCatalogScope(scope) && ( {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="rounded-xl border border-gray-200 bg-white p-3 mb-4">
<div className="flex flex-wrap gap-2 items-center mb-2"> <div className="flex flex-wrap gap-2 items-center mb-2">
<span className="text-base font-semibold">{activePackTask?.label || taskEntry.label || scope.task}</span> <span className="text-base font-semibold">{activePackTask?.label || taskEntry.label || scope.task}</span>
@@ -253,8 +321,8 @@ export const CatalogPage: React.FC = () => {
</div> </div>
)} )}
{/* Empty state */} {/* Empty state for DMS */}
{domain !== "lane" && !taskEntry && ( {domain === "dms" && !taskEntry && (
<div className="card text-center py-12 text-gray-400"> <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-lg mb-2"> {DOMAIN_TABS.find((t) => t.key === domain)?.label} </p>
<p className="text-sm"></p> <p className="text-sm"></p>
@@ -266,8 +334,8 @@ export const CatalogPage: React.FC = () => {
</div> </div>
)} )}
{/* Pack table */} {/* DMS pack table — only for DMS domain */}
{domain !== "lane" && isDmsCatalogScope(scope) && taskEntry && tablePacks.length > 0 && ( {domain === "dms" && isDmsCatalogScope(scope) && taskEntry && tablePacks.length > 0 && (
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden"> <div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
<table className="w-full text-sm"> <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> <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 [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useState<string | null>(null);
const [importingId, setImportingId] = 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 [buildingId, setBuildingId] = useState<string | null>(null);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [stageFilter, setStageFilter] = useState(""); const [stageFilter, setStageFilter] = useState("");
@@ -60,8 +61,11 @@ export const ExportPage: React.FC = () => {
const reloadCurrent = useCallback(() => load(offset, limit), [load, offset, limit]); const reloadCurrent = useCallback(() => load(offset, limit), [load, offset, limit]);
const handleExport = async (campaignId: string) => { const handleExport = async (campaignId: string) => {
setExportingId(campaignId);
setError(null);
try { await hsapApi.labelingExport(campaignId); setInfo("导出任务已提交"); reloadCurrent(); } try { await hsapApi.labelingExport(campaignId); setInfo("导出任务已提交"); reloadCurrent(); }
catch (e) { setError(String(e)); } catch (e) { setError(String(e)); }
setExportingId(null);
}; };
const handleSubmitBuild = async (b: LabelingBatchRow) => { const handleSubmitBuild = async (b: LabelingBatchRow) => {
@@ -135,6 +139,7 @@ export const ExportPage: React.FC = () => {
<ExportBatchTable <ExportBatchTable
batches={batches} batches={batches}
importingId={importingId} importingId={importingId}
exportingId={exportingId}
buildingId={buildingId} buildingId={buildingId}
onExport={handleExport} onExport={handleExport}
onImportVendor={handleImportVendor} onImportVendor={handleImportVendor}

View File

@@ -55,9 +55,9 @@ projects:
# 数据湖:宿主机 DATA/data/送标/adas → inbox/{task}/{batch}/images/ # 数据湖:宿主机 DATA/data/送标/adas → inbox/{task}/{batch}/images/
registry: adas.registry.yaml registry: adas.registry.yaml
packs_registry: data_packs.yaml packs_registry: data_packs.yaml
base_pack: adas_moon3d_v1 base_pack: adas_v1
active_packs: active_packs:
- adas_moon3d_v1 - adas_v1
merge: merge:
train: lists/train_stems.txt train: lists/train_stems.txt
val: lists/val_stems.txt val: lists/val_stems.txt