feat: Unified Ingest SDK for DMS/ADAS promote, cuboid export and 3D fit
Replace subprocess build with promote_batch SDK, add ADAS cuboid export/fit/validate pipeline, stage normalization, and offline unit tests wired into smoke_labeling_api. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -162,6 +162,12 @@ export const hsapApi = {
|
||||
labelingExport: (campaignId: string) =>
|
||||
postJson<{ ok: boolean; job?: { id: string } }>(`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/export`),
|
||||
|
||||
labelingExportStats: (campaignId: string) =>
|
||||
fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/export-stats`),
|
||||
|
||||
cuboidFit: (campaignId: string) =>
|
||||
postJson<Record<string, unknown>>(`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/cuboid-fit`),
|
||||
|
||||
submitLabelingCampaign: (campaignId: string) =>
|
||||
postJson<Record<string, unknown>>(`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/submit`),
|
||||
|
||||
|
||||
@@ -339,7 +339,13 @@ export const CampaignsPage: React.FC = () => {
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleExport(b.campaign_id!)}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg bg-gray-50 text-gray-600 hover:bg-gray-100 transition-colors"
|
||||
disabled={!["labeling_submitted", "returned"].includes(b.stage || "")}
|
||||
title={!["labeling_submitted", "returned"].includes(b.stage || "") ? "质检通过后才可导出" : undefined}
|
||||
className={`inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
|
||||
["labeling_submitted", "returned"].includes(b.stage || "")
|
||||
? "bg-gray-50 text-gray-600 hover:bg-gray-100"
|
||||
: "bg-gray-50 text-gray-300 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
📤 导出
|
||||
</button>
|
||||
@@ -347,7 +353,7 @@ export const CampaignsPage: React.FC = () => {
|
||||
onClick={() => handleSubmit(b.campaign_id!)}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg bg-green-50 text-green-700 hover:bg-green-100 transition-colors"
|
||||
>
|
||||
✅ 提交
|
||||
✅ 提交质检
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleExpand(b.campaign_id!)}
|
||||
|
||||
@@ -10,7 +10,11 @@ export const ExportPage: React.FC = () => {
|
||||
const [batches, setBatches] = useState<LabelingBatchRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
const [importingId, setImportingId] = useState<string | null>(null);
|
||||
const [statsMap, setStatsMap] = useState<Record<string, Record<string, unknown>>>({});
|
||||
const [buildingId, setBuildingId] = useState<string | null>(null);
|
||||
const [fittingId, setFittingId] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [stageFilter, setStageFilter] = useState("");
|
||||
|
||||
@@ -33,6 +37,20 @@ export const ExportPage: React.FC = () => {
|
||||
if (returned.status === "fulfilled") results.push(...((returned.value.items || []) as LabelingBatchRow[]));
|
||||
if (submitted.status === "rejected" && returned.status === "rejected") setError(String(submitted.reason));
|
||||
setBatches(results);
|
||||
const adasReturned = results.filter((b) => b.stage === "returned" && b.project === "adas" && b.campaign_id);
|
||||
if (adasReturned.length) {
|
||||
const entries = await Promise.allSettled(
|
||||
adasReturned.map(async (b) => {
|
||||
const s = await hsapApi.labelingExportStats(b.campaign_id!);
|
||||
return [b.campaign_id!, s] as const;
|
||||
}),
|
||||
);
|
||||
const map: Record<string, Record<string, unknown>> = {};
|
||||
for (const e of entries) {
|
||||
if (e.status === "fulfilled") map[e.value[0]] = e.value[1];
|
||||
}
|
||||
setStatsMap(map);
|
||||
}
|
||||
} catch (e) { setError(String(e)); }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
@@ -40,10 +58,41 @@ export const ExportPage: React.FC = () => {
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleExport = async (campaignId: string) => {
|
||||
try { await hsapApi.labelingExport(campaignId); load(); }
|
||||
try { await hsapApi.labelingExport(campaignId); setInfo("导出任务已提交"); load(); }
|
||||
catch (e) { setError(String(e)); }
|
||||
};
|
||||
|
||||
const handleCuboidFit = async (campaignId: string) => {
|
||||
setFittingId(campaignId);
|
||||
try {
|
||||
await hsapApi.cuboidFit(campaignId);
|
||||
setInfo("3D 拟合任务已提交");
|
||||
load();
|
||||
} catch (e) { setError(String(e)); }
|
||||
setFittingId(null);
|
||||
};
|
||||
|
||||
const handleSubmitBuild = async (b: LabelingBatchRow) => {
|
||||
if (!b.task || !b.batch) return;
|
||||
setBuildingId(b.campaign_id || b.batch);
|
||||
setError(null);
|
||||
try {
|
||||
await hsapApi.submitBuildBatch({
|
||||
project: b.project || "dms",
|
||||
task: b.task,
|
||||
batch: b.batch,
|
||||
pack: b.pack || (b.project === "adas" ? "adas_moon3d_v1" : "dms_v2"),
|
||||
location: b.location || "inbox",
|
||||
note: `入库 ${b.batch}`,
|
||||
});
|
||||
setInfo("build 已提交至审核队列");
|
||||
load();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
setBuildingId(null);
|
||||
};
|
||||
|
||||
const handleImportVendor = async (campaignId: string) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file"; input.accept = ".zip";
|
||||
@@ -64,9 +113,11 @@ export const ExportPage: React.FC = () => {
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1>导出与入库</h1>
|
||||
<p>标注完成后的导出、供应商回标导入、入库流程</p>
|
||||
<p>质检通过后的格式转换、供应商回标、build 入库</p>
|
||||
</div>
|
||||
|
||||
{info && <div className="bg-green-50 border border-green-200 rounded p-3 mb-4 text-sm text-green-700">{info}</div>}
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-3 mb-4">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex-1 min-w-[200px] relative">
|
||||
@@ -77,7 +128,7 @@ export const ExportPage: React.FC = () => {
|
||||
placeholder="搜索批次/任务..." value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
{["全部", "待导出", "待入库"].map((label, i) => {
|
||||
{["全部", "待导出", "待 build"].map((label, i) => {
|
||||
const val = i === 0 ? "" : ["labeling_submitted", "returned"][i - 1];
|
||||
return <button key={val} onClick={() => setStageFilter(val)} className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${stageFilter === val ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}>{label}</button>;
|
||||
})}
|
||||
@@ -86,33 +137,29 @@ export const ExportPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workflow guide */}
|
||||
<div className="card mb-4">
|
||||
<div className="card-header">流程说明</div>
|
||||
<div className="text-sm text-gray-600 space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-8 h-8 rounded-full bg-blue-100 text-blue-700 flex items-center justify-center text-xs font-bold">1</span>
|
||||
<span><strong>标注提交</strong> — 标注员在 Campaign 中完成标注后,点击"提交批次"</span>
|
||||
<span><strong>提交质检</strong> — 标注员完成标注后,在标注进度页点击「提交质检」</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-8 h-8 rounded-full bg-blue-100 text-blue-700 flex items-center justify-center text-xs font-bold">2</span>
|
||||
<span>
|
||||
<strong>导出标注</strong> — 在此页面点击"执行导出",将标注结果转为 YOLO 格式
|
||||
{hasData && <span className="text-gray-400">(下表有待导出批次)</span>}
|
||||
</span>
|
||||
<span><strong>质检通过</strong> — 协调员在质检页审核,通过后批次进入「待导出」</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-8 h-8 rounded-full bg-orange-100 text-orange-700 flex items-center justify-center text-xs font-bold">3</span>
|
||||
<span>
|
||||
<strong>供应商回标</strong> — 如果是外部供应商标注的,点击"导入供应商"上传 ZIP 回标文件
|
||||
<span className="block text-xs text-gray-400 mt-0.5">ZIP 格式要求:每张图片对应一个同名 .txt 标注文件(YOLO 格式),放在同一目录下打包</span>
|
||||
<strong>执行导出</strong> — 将 CVAT 标注转为训练格式(DMS→YOLO,ADAS→quaternion_json)
|
||||
{hasData && <span className="text-gray-400">(下表有待处理批次)</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-8 h-8 rounded-full bg-green-100 text-green-700 flex items-center justify-center text-xs font-bold">4</span>
|
||||
<span>
|
||||
<strong>入库 build</strong> — 导出完成后,批次进入 <Badge variant="success" size="small">待入库</Badge> 状态,可通过审核队列提交 build
|
||||
<span className="block text-xs text-gray-400 mt-0.5">build 成功后自动生成数据集版本快照</span>
|
||||
<strong>提交 build</strong> — 导出完成后进入 <Badge variant="warning" size="small">待 build</Badge>,在此提交 build 并经审核队列批准后变为 <Badge variant="success" size="small">已入库</Badge>
|
||||
<span className="block text-xs text-gray-400 mt-0.5">「待 build」≠「已入库」;ingested 批次不会出现在本页</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -127,10 +174,17 @@ export const ExportPage: React.FC = () => {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm">{b.batch}</span>
|
||||
<span className="text-xs text-gray-400">{b.task || "—"}</span>
|
||||
<Badge variant={b.stage === "returned" ? "success" : "warning"}>{b.stage === "labeling_submitted" ? "待导出" : "待入库"}</Badge>
|
||||
<span className="text-xs text-gray-400">{b.project}/{b.task || "—"}</span>
|
||||
<Badge variant={b.stage === "returned" ? "warning" : "warning"}>{b.stage === "labeling_submitted" ? "待导出" : "待 build"}</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 font-mono mt-1">{b.campaign_id?.slice(0, 16) || "—"}</div>
|
||||
{b.stage === "returned" && b.project === "adas" && b.campaign_id && statsMap[b.campaign_id] && (
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
quaternion: {String(statsMap[b.campaign_id].quaternion_files ?? "—")} ·
|
||||
fit_ok: {((Number(statsMap[b.campaign_id].fit_ok_ratio) || 0) * 100).toFixed(0)}% ·
|
||||
pack: adas_moon3d_v1
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{b.campaign_id && b.stage === "labeling_submitted" && (
|
||||
@@ -139,7 +193,24 @@ export const ExportPage: React.FC = () => {
|
||||
<Button size="small" variant="default" loading={importingId === b.campaign_id} onClick={() => handleImportVendor(b.campaign_id!)}>📥 导入供应商</Button>
|
||||
</>
|
||||
)}
|
||||
{b.stage === "returned" && <span className="text-green-600 text-sm font-medium">✓ 已入库</span>}
|
||||
{b.stage === "returned" && (
|
||||
<>
|
||||
{b.project === "adas" && b.campaign_id && (
|
||||
<Button size="small" variant="default" loading={fittingId === b.campaign_id} onClick={() => handleCuboidFit(b.campaign_id!)}>
|
||||
补全 3D
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
variant="primary"
|
||||
loading={buildingId === (b.campaign_id || b.batch)}
|
||||
onClick={() => handleSubmitBuild(b)}
|
||||
>
|
||||
🏗 提交 build
|
||||
</Button>
|
||||
<Link to="/system/audit" className="text-xs text-blue-600 hover:underline">审核队列 →</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,8 +218,8 @@ export const ExportPage: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="card text-center py-12">
|
||||
<p className="text-gray-400 text-lg mb-3">暂无待导出或待入库的批次</p>
|
||||
<p className="text-gray-400 text-sm mb-4">完成标注后,在标注进度页提交批次,即可在此处导出</p>
|
||||
<p className="text-gray-400 text-lg mb-3">暂无待导出或待 build 的批次</p>
|
||||
<p className="text-gray-400 text-sm mb-4">完成标注并质检通过后,批次会出现在此处</p>
|
||||
<Link to="/labeling/campaigns"><Button variant="default" size="small">去标注进度 →</Button></Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -33,7 +33,7 @@ const ReviewListPage: React.FC = () => {
|
||||
const results: LabelingBatchRow[] = [];
|
||||
const [inReview, approved, rejected] = await Promise.allSettled([
|
||||
hsapApi.labelingBatches({ stage: "in_review", limit: 100 }),
|
||||
hsapApi.labelingBatches({ stage: "review_approved", limit: 50 }),
|
||||
hsapApi.labelingBatches({ stage: "labeling_submitted", limit: 50 }),
|
||||
hsapApi.labelingBatches({ stage: "review_rejected", limit: 50 }),
|
||||
]);
|
||||
if (inReview.status === "fulfilled") results.push(...((inReview.value.items || []) as LabelingBatchRow[]));
|
||||
@@ -80,7 +80,7 @@ const ReviewListPage: React.FC = () => {
|
||||
{/* Filter chips */}
|
||||
<div className="flex gap-1.5">
|
||||
{["全部", "质检中", "已通过", "已退回"].map((label, i) => {
|
||||
const val = i === 0 ? "" : ["in_review", "review_approved", "review_rejected"][i - 1];
|
||||
const val = i === 0 ? "" : ["in_review", "labeling_submitted", "review_rejected"][i - 1];
|
||||
return (
|
||||
<button key={val} onClick={() => setStageFilter(val)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
@@ -120,7 +120,9 @@ const ReviewListPage: React.FC = () => {
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{b.stage === "in_review" && <Link to={`/labeling/review/${b.campaign_id}`}><Button size="small" variant="primary">▶ 开始质检</Button></Link>}
|
||||
{b.stage === "review_approved" && <span className="text-green-600 text-sm font-medium">✓ 已通过</span>}
|
||||
{b.stage === "labeling_submitted" && (
|
||||
<Link to="/labeling/export"><Button size="small" variant="default">去导出 →</Button></Link>
|
||||
)}
|
||||
{b.stage === "review_rejected" && <span className="text-red-600 text-sm font-medium">✗ 已退回</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -198,6 +198,12 @@ export const WorkbenchPage: React.FC = () => {
|
||||
✏️ 进入标注
|
||||
</Link>
|
||||
)}
|
||||
{b.stage === "returned" && (
|
||||
<Link to="/labeling/export"
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg bg-orange-50 text-orange-700 hover:bg-orange-100 transition-colors">
|
||||
🏗 提交 build
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user