You've already forked DataMate
feat(annotation): 添加标注任务算子编排前端页面和测试算子
## 功能概述 为标注任务通用算子编排功能添加完整的前端界面,包括任务创建、列表管理、详情查看等功能,并提供测试算子用于功能验证。 ## 改动内容 ### 前端功能 #### 1. 算子编排页面 - 新增两步创建流程: - 第一步:基本信息(数据集选择、任务名称等) - 第二步:算子编排(选择算子、配置参数、预览 pipeline) - 核心文件: - frontend/src/pages/DataAnnotation/OperatorCreate/CreateTask.tsx - frontend/src/pages/DataAnnotation/OperatorCreate/hooks/useOperatorOperations.ts - frontend/src/pages/DataAnnotation/OperatorCreate/hooks/useDragOperators.ts - frontend/src/pages/DataAnnotation/OperatorCreate/hooks/useCreateStepTwo.tsx #### 2. UI 组件 - 算子库(OperatorLibrary):显示可用算子,支持分类筛选 - 编排区(OperatorOrchestration):拖拽排序算子 - 参数面板(OperatorConfig):配置算子参数 - Pipeline 预览(PipelinePreview):预览算子链 - 核心文件:frontend/src/pages/DataAnnotation/OperatorCreate/components/ #### 3. 任务列表管理 - 在数据标注首页同一 Tab 中添加任务列表 - 支持状态筛选(pending/running/completed/failed/stopped) - 支持关键词搜索 - 支持轮询刷新 - 支持停止任务 - 支持下载结果 - 核心文件:frontend/src/pages/DataAnnotation/Home/components/AutoAnnotationTaskList.tsx #### 4. 任务详情抽屉 - 点击任务名打开详情抽屉 - 显示任务基本信息(名称、状态、进度、时间等) - 显示 pipeline 配置(算子链和参数) - 显示错误信息(如果失败) - 显示产物路径和下载按钮 - 核心文件:frontend/src/pages/DataAnnotation/Home/components/AutoAnnotationTaskDetailDrawer.tsx #### 5. API 集成 - 封装自动标注任务相关接口: - list:获取任务列表 - create:创建任务 - detail:获取任务详情 - delete:删除任务 - stop:停止任务 - download:下载结果 - 核心文件:frontend/src/pages/DataAnnotation/annotation.api.ts #### 6. 路由配置 - 新增路由:/data/annotation/create-auto-task - 集成到数据标注首页 - 核心文件: - frontend/src/routes/routes.ts - frontend/src/pages/DataAnnotation/Home/DataAnnotation.tsx #### 7. 算子模型增强 - 新增 runtime 字段用于标注算子筛选 - 核心文件:frontend/src/pages/OperatorMarket/operator.model.ts ### 后端功能 #### 1. 测试算子(test_annotation_marker) - 功能:在图片上绘制测试标记并输出 JSON 标注 - 用途:测试标注功能是否正常工作 - 实现文件: - runtime/ops/annotation/test_annotation_marker/process.py - runtime/ops/annotation/test_annotation_marker/metadata.yml - runtime/ops/annotation/test_annotation_marker/__init__.py #### 2. 算子注册 - 将测试算子注册到 annotation ops 包 - 添加到运行时白名单 - 核心文件: - runtime/ops/annotation/__init__.py - runtime/python-executor/datamate/auto_annotation_worker.py #### 3. 数据库初始化 - 添加测试算子到数据库 - 添加算子分类关联 - 核心文件:scripts/db/data-operator-init.sql ### 问题修复 #### 1. outputDir 默认值覆盖问题 - 问题:前端设置空字符串默认值导致 worker 无法注入真实输出目录 - 解决:过滤掉空/null 的 outputDir,确保 worker 能注入真实输出目录 - 修改位置:frontend/src/pages/DataAnnotation/OperatorCreate/hooks/useOperatorOperations.ts #### 2. targetClasses 默认值类型问题 - 问题:YOLO 算子 metadata 中 targetClasses 默认值是字符串 '[]' 而不是列表 - 解决:改为列表 [] - 修改位置:runtime/ops/annotation/image_object_detection_bounding_box/metadata.yml ## 关键特性 ### 用户体验 - 统一的算子编排界面(与数据清洗保持一致) - 直观的拖拽操作 - 实时的 pipeline 预览 - 完整的任务管理功能 ### 功能完整性 - 任务创建:两步流程,清晰明了 - 任务管理:列表展示、状态筛选、搜索 - 任务操作:停止、下载 - 任务详情:完整的信息展示 ### 可测试性 - 提供测试算子用于功能验证 - 支持快速测试标注流程 ## 验证结果 - ESLint 检查:✅ 通过 - 前端构建:✅ 通过(10.91s) - 功能测试:✅ 所有功能正常 ## 部署说明 1. 执行数据库初始化脚本(如果是新环境) 2. 重启前端服务 3. 重启后端服务(如果修改了 worker 白名单) ## 使用说明 1. 进入数据标注页面 2. 点击创建自动标注任务 3. 选择数据集和文件 4. 从算子库拖拽算子到编排区 5. 配置算子参数 6. 预览 pipeline 7. 提交任务 8. 在任务列表中查看进度 9. 点击任务名查看详情 10. 下载标注结果 ## 相关文件 - 前端页面:frontend/src/pages/DataAnnotation/OperatorCreate/ - 任务管理:frontend/src/pages/DataAnnotation/Home/components/ - API 集成:frontend/src/pages/DataAnnotation/annotation.api.ts - 测试算子:runtime/ops/annotation/test_annotation_marker/ - 数据库脚本:scripts/db/data-operator-init.sql
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
queryCategoryTreeUsingGet,
|
||||
queryOperatorsUsingPost,
|
||||
} from "@/pages/OperatorMarket/operator.api";
|
||||
import { CategoryI, ConfigI, OperatorI } from "@/pages/OperatorMarket/operator.model";
|
||||
|
||||
type OperatorConfigMap = Record<string, ConfigI>;
|
||||
type OperatorWithDefaults = OperatorI & {
|
||||
defaultParams?: Record<string, unknown>;
|
||||
};
|
||||
type CategoryNode = CategoryI & {
|
||||
label?: string;
|
||||
value?: string;
|
||||
count?: number;
|
||||
};
|
||||
type CategoryGroup = {
|
||||
name: string;
|
||||
categories: CategoryNode[];
|
||||
};
|
||||
|
||||
const ANNOTATION_OPERATOR_ID_WHITELIST = new Set([
|
||||
"ImageObjectDetectionBoundingBox",
|
||||
"test_annotation_marker",
|
||||
]);
|
||||
|
||||
const ensureArray = (value: unknown): string[] => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => String(item));
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const parseSettings = (settings?: string): OperatorConfigMap => {
|
||||
if (!settings) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(settings);
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return {};
|
||||
}
|
||||
return parsed as OperatorConfigMap;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const mapOperator = (operator: OperatorI): OperatorWithDefaults => {
|
||||
const configs = parseSettings(operator.settings);
|
||||
const defaultParams: Record<string, unknown> = {};
|
||||
|
||||
Object.entries(configs).forEach(([key, config]) => {
|
||||
if (!(config && typeof config === "object" && "defaultVal" in config)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultVal = config.defaultVal as unknown;
|
||||
const normalizedKey = key.trim().toLowerCase().replace(/_/g, "");
|
||||
|
||||
if (normalizedKey === "outputdir" && (defaultVal === "" || defaultVal === null || defaultVal === undefined)) {
|
||||
return;
|
||||
}
|
||||
|
||||
defaultParams[key] = defaultVal;
|
||||
});
|
||||
|
||||
return {
|
||||
...operator,
|
||||
categories: ensureArray(operator.categories),
|
||||
configs,
|
||||
defaultParams,
|
||||
};
|
||||
};
|
||||
|
||||
const isAnnotationOperator = (operator: OperatorWithDefaults) => {
|
||||
if (ANNOTATION_OPERATOR_ID_WHITELIST.has(operator.id)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const name = operator.name?.toLowerCase() || "";
|
||||
const desc = operator.description?.toLowerCase() || "";
|
||||
const runtime = operator.runtime?.toLowerCase() || "";
|
||||
|
||||
if (runtime.includes("annotation") || runtime.includes("/annotation/")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return name.includes("标注") || desc.includes("标注") || name.includes("annotation");
|
||||
};
|
||||
|
||||
export function useOperatorOperations() {
|
||||
const [operators, setOperators] = useState<OperatorWithDefaults[]>([]);
|
||||
const [selectedOperators, setSelectedOperators] = useState<OperatorWithDefaults[]>(
|
||||
[]
|
||||
);
|
||||
const [configOperator, setConfigOperator] = useState<OperatorWithDefaults | null>(
|
||||
null
|
||||
);
|
||||
const [categoryOptions, setCategoryOptions] = useState<CategoryI[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const selectedOperatorIds = useMemo(
|
||||
() => new Set(selectedOperators.map((operator) => operator.id)),
|
||||
[selectedOperators]
|
||||
);
|
||||
|
||||
const selectedCategoryOptions = useMemo(
|
||||
() =>
|
||||
categoryOptions.filter((category) =>
|
||||
operators.some((operator) => operator.categories?.includes(category.id))
|
||||
),
|
||||
[categoryOptions, operators]
|
||||
);
|
||||
|
||||
const initOperators = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [categoryRes, operatorRes] = await Promise.all([
|
||||
queryCategoryTreeUsingGet(),
|
||||
queryOperatorsUsingPost({ page: 0, size: 1000 }),
|
||||
]);
|
||||
|
||||
const allOperators = (operatorRes?.data?.content || []).map(mapOperator);
|
||||
const annotationOperators = allOperators.filter(isAnnotationOperator);
|
||||
setOperators(annotationOperators);
|
||||
|
||||
const options = ((categoryRes?.data?.content || []) as CategoryGroup[]).reduce(
|
||||
(acc: CategoryNode[], item) => {
|
||||
const children = (item.categories || []).map((category) => {
|
||||
const matchedCount = annotationOperators.filter((operator) =>
|
||||
operator.categories?.includes(category.id)
|
||||
).length;
|
||||
return {
|
||||
...category,
|
||||
type: item.name,
|
||||
label: category.name,
|
||||
value: category.id,
|
||||
count: matchedCount,
|
||||
};
|
||||
});
|
||||
acc.push(...children);
|
||||
return acc;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
setCategoryOptions(
|
||||
options.filter((item) => (item.count || 0) > 0) as CategoryI[]
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
initOperators();
|
||||
}, []);
|
||||
|
||||
const toggleOperator = (operator: OperatorWithDefaults) => {
|
||||
if (selectedOperatorIds.has(operator.id)) {
|
||||
setSelectedOperators((prev) => prev.filter((item) => item.id !== operator.id));
|
||||
if (configOperator?.id === operator.id) {
|
||||
setConfigOperator(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setSelectedOperators((prev) => [...prev, { ...operator }]);
|
||||
};
|
||||
|
||||
const removeOperator = (id: string) => {
|
||||
setSelectedOperators((prev) => prev.filter((operator) => operator.id !== id));
|
||||
if (configOperator?.id === id) {
|
||||
setConfigOperator(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfigChange = (
|
||||
operatorId: string,
|
||||
paramKey: string,
|
||||
value: unknown
|
||||
) => {
|
||||
setSelectedOperators((prev) =>
|
||||
prev.map((operator) =>
|
||||
operator.id === operatorId
|
||||
? {
|
||||
...operator,
|
||||
overrides: {
|
||||
...(operator.overrides || operator.defaultParams || {}),
|
||||
[paramKey]: value,
|
||||
},
|
||||
}
|
||||
: operator
|
||||
)
|
||||
);
|
||||
|
||||
setConfigOperator((prev) => {
|
||||
if (!prev || prev.id !== operatorId) return prev;
|
||||
return {
|
||||
...prev,
|
||||
overrides: {
|
||||
...(prev.overrides || prev.defaultParams || {}),
|
||||
[paramKey]: value,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
loading,
|
||||
operators,
|
||||
selectedOperators,
|
||||
configOperator,
|
||||
categoryOptions: selectedCategoryOptions,
|
||||
setConfigOperator,
|
||||
setSelectedOperators,
|
||||
handleConfigChange,
|
||||
toggleOperator,
|
||||
removeOperator,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user