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:
2026-02-08 08:17:35 +08:00
parent 2f49fc4199
commit 78624915b7
22 changed files with 2847 additions and 16 deletions

View File

@@ -24,6 +24,7 @@ import CreateAnnotationTask from "../Create/components/CreateAnnotationTaskDialo
import ExportAnnotationDialog from "./ExportAnnotationDialog"; import ExportAnnotationDialog from "./ExportAnnotationDialog";
import { ColumnType } from "antd/es/table"; import { ColumnType } from "antd/es/table";
import { TemplateList } from "../Template"; import { TemplateList } from "../Template";
import AutoAnnotationTaskList from "./components/AutoAnnotationTaskList";
// Note: DevelopmentInProgress intentionally not used here // Note: DevelopmentInProgress intentionally not used here
type AnnotationTaskRowKey = string | number; type AnnotationTaskRowKey = string | number;
@@ -325,6 +326,11 @@ export default function DataAnnotation() {
> >
</Button> </Button>
<Button
onClick={() => navigate("/data/annotation/create-auto-task")}
>
</Button>
<Button <Button
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined />}
@@ -364,6 +370,8 @@ export default function DataAnnotation() {
/> />
)} )}
<AutoAnnotationTaskList />
<CreateAnnotationTask <CreateAnnotationTask
open={showCreateDialog || !!editTask} open={showCreateDialog || !!editTask}
onClose={() => { onClose={() => {

View File

@@ -0,0 +1,543 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Alert,
App,
Badge,
Button,
Card,
Descriptions,
Drawer,
Empty,
Progress,
Space,
Spin,
Tag,
Typography,
} from "antd";
import {
DownloadOutlined,
PauseCircleOutlined,
ReloadOutlined,
} from "@ant-design/icons";
import { formatDateTime } from "@/utils/unit";
import {
downloadAnnotationOperatorTaskResultUsingGet,
queryAnnotationOperatorTaskByIdUsingGet,
stopAnnotationOperatorTaskByIdUsingPost,
} from "../../annotation.api";
type AutoAnnotationTaskStatus =
| "pending"
| "running"
| "completed"
| "failed"
| "stopped";
type StatusMeta = {
label: string;
value: string;
color: string;
};
type AutoAnnotationPipelineStepRaw = {
operatorId?: string;
operator_id?: string;
id?: string;
overrides?: Record<string, unknown>;
settingsOverride?: Record<string, unknown>;
settings_override?: Record<string, unknown>;
[key: string]: unknown;
};
type AutoAnnotationTaskDetailRaw = {
id?: string;
name?: string;
datasetId?: string;
dataset_id?: string;
datasetName?: string;
dataset_name?: string;
status?: string;
progress?: number;
totalImages?: number;
total_images?: number;
processedImages?: number;
processed_images?: number;
detectedObjects?: number;
detected_objects?: number;
outputPath?: string;
output_path?: string;
outputDatasetId?: string;
output_dataset_id?: string;
stopRequested?: boolean;
stop_requested?: boolean;
errorMessage?: string;
error_message?: string;
taskMode?: string;
task_mode?: string;
executorType?: string;
executor_type?: string;
sourceDatasets?: string[];
source_datasets?: string[];
createdBy?: string;
created_by?: string;
createdAt?: string;
created_at?: string;
updatedAt?: string;
updated_at?: string;
startedAt?: string;
started_at?: string;
heartbeatAt?: string;
heartbeat_at?: string;
completedAt?: string;
completed_at?: string;
pipeline?: unknown;
config?: Record<string, unknown>;
};
type AutoAnnotationPipelineStep = {
operatorId: string;
overrides: Record<string, unknown>;
};
type AutoAnnotationTaskDetail = {
id: string;
name: string;
datasetId: string;
datasetName: string;
status: StatusMeta;
progress: number;
totalImages: number;
processedImages: number;
detectedObjects: number;
outputPath?: string;
outputDatasetId?: string;
stopRequested: boolean;
errorMessage?: string;
taskMode: string;
executorType: string;
sourceDatasets: string[];
createdBy: string;
createdAt: string;
updatedAt: string;
startedAt: string;
heartbeatAt: string;
completedAt: string;
pipeline: AutoAnnotationPipelineStep[];
config: Record<string, unknown>;
};
interface AutoAnnotationTaskDetailDrawerProps {
open: boolean;
taskId?: string;
onClose: () => void;
onRefreshList?: () => void;
}
const AUTO_ANNOTATION_STATUS_MAP: Record<AutoAnnotationTaskStatus, StatusMeta> = {
pending: {
label: "待处理",
value: "pending",
color: "gray",
},
running: {
label: "进行中",
value: "running",
color: "blue",
},
completed: {
label: "已完成",
value: "completed",
color: "green",
},
failed: {
label: "失败",
value: "failed",
color: "red",
},
stopped: {
label: "已停止",
value: "stopped",
color: "orange",
},
};
const toSafeNumber = (value: unknown): number =>
typeof value === "number" && Number.isFinite(value) ? value : 0;
const resolveStatus = (status?: string): StatusMeta => {
const normalizedStatus = (status || "").toLowerCase();
if (normalizedStatus in AUTO_ANNOTATION_STATUS_MAP) {
return AUTO_ANNOTATION_STATUS_MAP[normalizedStatus as AutoAnnotationTaskStatus];
}
return {
label: normalizedStatus || "未知",
value: normalizedStatus || "unknown",
color: "default",
};
};
const formatTaskTime = (value?: string) => {
if (!value) {
return "-";
}
return formatDateTime(value);
};
const normalizeStringArray = (value: unknown): string[] => {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item) => (typeof item === "string" ? item.trim() : ""))
.filter(Boolean);
};
const parsePipeline = (value: unknown): AutoAnnotationPipelineStep[] => {
let rawPipeline = value;
if (typeof rawPipeline === "string") {
try {
rawPipeline = JSON.parse(rawPipeline);
} catch {
rawPipeline = [];
}
}
if (!Array.isArray(rawPipeline)) {
return [];
}
return rawPipeline.map((step, index) => {
const stepObject: AutoAnnotationPipelineStepRaw =
step && typeof step === "object"
? (step as AutoAnnotationPipelineStepRaw)
: {};
const operatorIdRaw =
stepObject.operatorId || stepObject.operator_id || stepObject.id;
const operatorId =
typeof operatorIdRaw === "string" && operatorIdRaw.trim().length > 0
? operatorIdRaw
: `step_${index + 1}`;
const overridesRaw =
stepObject.overrides ||
stepObject.settingsOverride ||
stepObject.settings_override;
const overrides =
overridesRaw && typeof overridesRaw === "object" && !Array.isArray(overridesRaw)
? (overridesRaw as Record<string, unknown>)
: {};
return {
operatorId,
overrides,
};
});
};
const mapAutoAnnotationTaskDetail = (
task: Partial<AutoAnnotationTaskDetailRaw>
): AutoAnnotationTaskDetail => {
const status = resolveStatus(task.status);
return {
id: task.id || "",
name: task.name || "-",
datasetId: task.datasetId || task.dataset_id || "",
datasetName: task.datasetName || task.dataset_name || "-",
status,
progress: Math.max(0, Math.min(100, toSafeNumber(task.progress))),
totalImages: toSafeNumber(task.totalImages ?? task.total_images),
processedImages: toSafeNumber(task.processedImages ?? task.processed_images),
detectedObjects: toSafeNumber(task.detectedObjects ?? task.detected_objects),
outputPath: task.outputPath || task.output_path,
outputDatasetId: task.outputDatasetId || task.output_dataset_id,
stopRequested: task.stopRequested ?? task.stop_requested ?? false,
errorMessage: task.errorMessage || task.error_message,
taskMode: task.taskMode || task.task_mode || "-",
executorType: task.executorType || task.executor_type || "-",
sourceDatasets: normalizeStringArray(task.sourceDatasets || task.source_datasets),
createdBy: task.createdBy || task.created_by || "-",
createdAt: formatTaskTime(task.createdAt || task.created_at),
updatedAt: formatTaskTime(task.updatedAt || task.updated_at),
startedAt: formatTaskTime(task.startedAt || task.started_at),
heartbeatAt: formatTaskTime(task.heartbeatAt || task.heartbeat_at),
completedAt: formatTaskTime(task.completedAt || task.completed_at),
pipeline: parsePipeline(task.pipeline),
config:
task.config && typeof task.config === "object" && !Array.isArray(task.config)
? task.config
: {},
};
};
const resolveProgressStatus = (statusValue: string) => {
if (statusValue === "failed") {
return "exception" as const;
}
if (statusValue === "completed") {
return "success" as const;
}
if (statusValue === "running") {
return "active" as const;
}
return "normal" as const;
};
export default function AutoAnnotationTaskDetailDrawer({
open,
taskId,
onClose,
onRefreshList,
}: AutoAnnotationTaskDetailDrawerProps) {
const { message } = App.useApp();
const [loading, setLoading] = useState(false);
const [taskDetail, setTaskDetail] = useState<AutoAnnotationTaskDetail | null>(null);
const fetchTaskDetail = useCallback(async () => {
if (!taskId) {
setTaskDetail(null);
return;
}
try {
setLoading(true);
const response = await queryAnnotationOperatorTaskByIdUsingGet(taskId);
const payload = response?.data;
if (!payload || typeof payload !== "object") {
setTaskDetail(null);
return;
}
setTaskDetail(mapAutoAnnotationTaskDetail(payload));
} catch (error) {
console.error(error);
message.error("获取自动标注任务详情失败");
setTaskDetail(null);
} finally {
setLoading(false);
}
}, [taskId, message]);
useEffect(() => {
if (!open) {
return;
}
void fetchTaskDetail();
}, [open, fetchTaskDetail]);
const canStop = useMemo(
() =>
!!taskDetail &&
["pending", "running"].includes(taskDetail.status.value) &&
!taskDetail.stopRequested,
[taskDetail]
);
const canDownload = useMemo(
() =>
!!taskDetail &&
["completed", "stopped", "failed"].includes(taskDetail.status.value) &&
!!taskDetail.outputPath,
[taskDetail]
);
const handleStopTask = async () => {
if (!taskId) {
return;
}
try {
await stopAnnotationOperatorTaskByIdUsingPost(taskId);
message.success("已发送停止请求");
onRefreshList?.();
await fetchTaskDetail();
} catch (error) {
console.error(error);
message.error("停止任务失败,请稍后重试");
}
};
const handleDownload = async () => {
if (!taskId || !taskDetail) {
return;
}
try {
await downloadAnnotationOperatorTaskResultUsingGet(
taskId,
`${taskDetail.name || taskId}_annotations.zip`
);
message.success("结果下载已开始");
} catch (error) {
console.error(error);
message.error("下载失败,请确认任务已生成结果");
}
};
return (
<Drawer
title="自动标注任务详情"
width={840}
open={open}
onClose={onClose}
extra={
<Space>
<Button icon={<ReloadOutlined />} onClick={() => void fetchTaskDetail()}>
</Button>
<Button
icon={<PauseCircleOutlined />}
disabled={!canStop}
onClick={() => void handleStopTask()}
>
</Button>
<Button
type="primary"
icon={<DownloadOutlined />}
disabled={!canDownload}
onClick={() => void handleDownload()}
>
</Button>
</Space>
}
>
{loading ? (
<div className="h-full min-h-60 flex items-center justify-center">
<Spin />
</div>
) : !taskDetail ? (
<Empty description="暂无任务详情" />
) : (
<Space direction="vertical" size={16} className="w-full">
<Card title="基本信息">
<Descriptions column={2} size="small" bordered>
<Descriptions.Item label="任务名称">{taskDetail.name}</Descriptions.Item>
<Descriptions.Item label="任务ID">{taskDetail.id || "-"}</Descriptions.Item>
<Descriptions.Item label="状态">
<Space>
<Badge color={taskDetail.status.color} text={taskDetail.status.label} />
{taskDetail.stopRequested && <Tag color="orange"></Tag>}
</Space>
</Descriptions.Item>
<Descriptions.Item label="进度">
<Progress
percent={taskDetail.progress}
size="small"
status={resolveProgressStatus(taskDetail.status.value)}
/>
</Descriptions.Item>
<Descriptions.Item label="数据集">{taskDetail.datasetName}</Descriptions.Item>
<Descriptions.Item label="数据集ID">{taskDetail.datasetId || "-"}</Descriptions.Item>
<Descriptions.Item label="任务模式">{taskDetail.taskMode}</Descriptions.Item>
<Descriptions.Item label="执行器">{taskDetail.executorType}</Descriptions.Item>
<Descriptions.Item label="处理进度">
{taskDetail.processedImages}/{taskDetail.totalImages}
</Descriptions.Item>
<Descriptions.Item label="检测对象数">
{taskDetail.detectedObjects}
</Descriptions.Item>
<Descriptions.Item label="创建人">{taskDetail.createdBy}</Descriptions.Item>
<Descriptions.Item label="来源数据集">
{taskDetail.sourceDatasets.length > 0
? taskDetail.sourceDatasets.join("、")
: "-"}
</Descriptions.Item>
<Descriptions.Item label="创建时间">{taskDetail.createdAt}</Descriptions.Item>
<Descriptions.Item label="启动时间">{taskDetail.startedAt}</Descriptions.Item>
<Descriptions.Item label="更新时间">{taskDetail.updatedAt}</Descriptions.Item>
<Descriptions.Item label="完成时间">{taskDetail.completedAt}</Descriptions.Item>
<Descriptions.Item label="心跳时间" span={2}>
{taskDetail.heartbeatAt}
</Descriptions.Item>
</Descriptions>
</Card>
<Card title="Pipeline 配置(算子链与参数)">
{taskDetail.pipeline.length > 0 ? (
<Space direction="vertical" size={12} className="w-full">
{taskDetail.pipeline.map((step, index) => (
<Card
key={`${step.operatorId}-${index}`}
size="small"
title={`步骤 ${index + 1} · ${step.operatorId}`}
>
{Object.keys(step.overrides).length > 0 ? (
<pre className="mb-0 rounded bg-gray-50 p-3 text-xs whitespace-pre-wrap break-all">
{JSON.stringify(step.overrides, null, 2)}
</pre>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</Card>
))}
</Space>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="该任务未配置 pipeline"
/>
)}
{Object.keys(taskDetail.config).length > 0 && (
<div className="mt-4">
<Typography.Title level={5}></Typography.Title>
<pre className="mb-0 rounded bg-gray-50 p-3 text-xs whitespace-pre-wrap break-all">
{JSON.stringify(taskDetail.config, null, 2)}
</pre>
</div>
)}
</Card>
{taskDetail.errorMessage && (
<Card title="错误信息">
<Alert
type="error"
showIcon
message="任务执行异常"
description={
<span className="whitespace-pre-wrap break-all">
{taskDetail.errorMessage}
</span>
}
/>
</Card>
)}
<Card title="产物信息">
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="输出路径">
{taskDetail.outputPath ? (
<Typography.Text code copyable>
{taskDetail.outputPath}
</Typography.Text>
) : (
"-"
)}
</Descriptions.Item>
<Descriptions.Item label="输出数据集ID">
{taskDetail.outputDatasetId || "-"}
</Descriptions.Item>
</Descriptions>
<div className="mt-4">
<Button
type="primary"
icon={<DownloadOutlined />}
disabled={!canDownload}
onClick={() => void handleDownload()}
>
</Button>
</div>
</Card>
</Space>
)}
</Drawer>
);
}

View File

@@ -0,0 +1,459 @@
import { useMemo, useState } from "react";
import { App, Badge, Button, Card, Progress, Table, Tooltip } from "antd";
import { DownloadOutlined, PauseCircleOutlined } from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import { useNavigate } from "react-router";
import { SearchControls } from "@/components/SearchControls";
import useFetchData from "@/hooks/useFetchData";
import { formatDateTime } from "@/utils/unit";
import {
downloadAnnotationOperatorTaskResultUsingGet,
queryAnnotationOperatorTasksUsingGet,
stopAnnotationOperatorTaskByIdUsingPost,
} from "../../annotation.api";
import AutoAnnotationTaskDetailDrawer from "./AutoAnnotationTaskDetailDrawer";
type AutoAnnotationTaskStatus =
| "pending"
| "running"
| "completed"
| "failed"
| "stopped";
type AutoAnnotationTaskPayload = {
id?: string;
name?: string;
datasetId?: string;
dataset_id?: string;
datasetName?: string;
dataset_name?: string;
status?: string;
progress?: number;
totalImages?: number;
total_images?: number;
processedImages?: number;
processed_images?: number;
detectedObjects?: number;
detected_objects?: number;
outputPath?: string;
output_path?: string;
stopRequested?: boolean;
stop_requested?: boolean;
createdAt?: string;
created_at?: string;
updatedAt?: string;
updated_at?: string;
completedAt?: string;
completed_at?: string;
};
type StatusMeta = {
label: string;
value: string;
color: string;
};
type AutoAnnotationTaskRow = {
id: string;
name: string;
datasetId: string;
datasetName: string;
status: StatusMeta;
progress: number;
totalImages: number;
processedImages: number;
detectedObjects: number;
outputPath?: string;
stopRequested: boolean;
createdAt: string;
updatedAt: string;
completedAt: string;
};
type FetchParams = Record<string, unknown>;
type FetchResult<T> = {
data: {
content: Partial<T>[];
total: number;
};
};
const AUTO_ANNOTATION_STATUS_MAP: Record<AutoAnnotationTaskStatus, StatusMeta> = {
pending: {
label: "待处理",
value: "pending",
color: "gray",
},
running: {
label: "进行中",
value: "running",
color: "blue",
},
completed: {
label: "已完成",
value: "completed",
color: "green",
},
failed: {
label: "失败",
value: "failed",
color: "red",
},
stopped: {
label: "已停止",
value: "stopped",
color: "orange",
},
};
const toSafeNumber = (value: unknown): number =>
typeof value === "number" && Number.isFinite(value) ? value : 0;
const resolveStatus = (status?: string): StatusMeta => {
const normalizedStatus = (status || "").toLowerCase();
if (normalizedStatus in AUTO_ANNOTATION_STATUS_MAP) {
return AUTO_ANNOTATION_STATUS_MAP[normalizedStatus as AutoAnnotationTaskStatus];
}
return {
label: normalizedStatus || "未知",
value: normalizedStatus || "unknown",
color: "default",
};
};
const formatTaskTime = (value?: string) => {
if (!value) {
return "-";
}
return formatDateTime(value);
};
const mapAutoAnnotationTask = (
task: Partial<AutoAnnotationTaskPayload>
): AutoAnnotationTaskRow => {
const status = resolveStatus(task.status);
return {
id: task.id || "",
name: task.name || "-",
datasetId: task.datasetId || task.dataset_id || "",
datasetName: task.datasetName || task.dataset_name || "-",
status,
progress: Math.max(0, Math.min(100, toSafeNumber(task.progress))),
totalImages: toSafeNumber(task.totalImages ?? task.total_images),
processedImages: toSafeNumber(task.processedImages ?? task.processed_images),
detectedObjects: toSafeNumber(task.detectedObjects ?? task.detected_objects),
outputPath: task.outputPath || task.output_path,
stopRequested: task.stopRequested ?? task.stop_requested ?? false,
createdAt: formatTaskTime(task.createdAt || task.created_at),
updatedAt: formatTaskTime(task.updatedAt || task.updated_at),
completedAt: formatTaskTime(task.completedAt || task.completed_at),
};
};
const fetchAutoAnnotationTasks = async (
params?: FetchParams
): Promise<FetchResult<AutoAnnotationTaskPayload>> => {
const response = await queryAnnotationOperatorTasksUsingGet();
const tasks = Array.isArray(response?.data)
? (response.data as AutoAnnotationTaskPayload[])
: [];
const keyword =
typeof params?.keyword === "string" ? params.keyword.trim().toLowerCase() : "";
const status = typeof params?.status === "string" ? params.status.toLowerCase() : "";
const page =
typeof params?.page === "number" && params.page >= 0 ? params.page : 0;
const size =
typeof params?.size === "number" && params.size > 0 ? params.size : 12;
const filtered = tasks
.filter((task) => {
if (!keyword) {
return true;
}
const text = [task.id, task.name, task.datasetName, task.dataset_name]
.filter((value) => typeof value === "string")
.join(" ")
.toLowerCase();
return text.includes(keyword);
})
.filter((task) => {
if (!status) {
return true;
}
return (task.status || "").toLowerCase() === status;
})
.sort((a, b) => {
const timeA = new Date(a.createdAt || a.created_at || 0).getTime();
const timeB = new Date(b.createdAt || b.created_at || 0).getTime();
return timeB - timeA;
});
const start = page * size;
const end = start + size;
return {
data: {
content: filtered.slice(start, end),
total: filtered.length,
},
};
};
export default function AutoAnnotationTaskList() {
const navigate = useNavigate();
const { message } = App.useApp();
const [detailOpen, setDetailOpen] = useState(false);
const [detailTaskId, setDetailTaskId] = useState("");
const {
loading,
tableData,
pagination,
searchParams,
handleFiltersChange,
handleKeywordChange,
fetchData,
} = useFetchData<AutoAnnotationTaskRow>(
fetchAutoAnnotationTasks,
mapAutoAnnotationTask,
10000,
true,
[],
0
);
const filterOptions = useMemo(
() => [
{
key: "status",
label: "状态",
options: Object.values(AUTO_ANNOTATION_STATUS_MAP),
},
],
[]
);
const handleOpenDetail = (task: AutoAnnotationTaskRow) => {
if (!task.id) {
message.warning("任务ID缺失,无法查看详情");
return;
}
setDetailTaskId(task.id);
setDetailOpen(true);
};
const handleStopTask = async (task: AutoAnnotationTaskRow) => {
try {
await stopAnnotationOperatorTaskByIdUsingPost(task.id);
message.success("已发送停止请求");
fetchData();
} catch (error) {
console.error(error);
message.error("停止任务失败,请稍后重试");
}
};
const handleDownload = async (task: AutoAnnotationTaskRow) => {
try {
await downloadAnnotationOperatorTaskResultUsingGet(
task.id,
`${task.name || task.id}_annotations.zip`
);
message.success("结果下载已开始");
} catch (error) {
console.error(error);
message.error("下载失败,请确认任务已生成结果");
}
};
const columns: ColumnsType<AutoAnnotationTaskRow> = [
{
title: "任务名称",
dataIndex: "name",
key: "name",
width: 180,
fixed: "left",
ellipsis: true,
render: (_, record) => (
<Button
type="link"
style={{ paddingInline: 0 }}
onClick={() => handleOpenDetail(record)}
>
{record.name}
</Button>
),
},
{
title: "任务ID",
dataIndex: "id",
key: "id",
width: 160,
ellipsis: true,
},
{
title: "源数据集",
dataIndex: "datasetName",
key: "datasetName",
width: 180,
ellipsis: true,
render: (_, record) => {
if (!record.datasetId) {
return record.datasetName;
}
return (
<Button
type="link"
onClick={() => navigate(`/data/management/detail/${record.datasetId}`)}
>
{record.datasetName}
</Button>
);
},
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 120,
render: (status: StatusMeta) => (
<Badge color={status.color} text={status.label} />
),
},
{
title: "进度",
dataIndex: "progress",
key: "progress",
width: 180,
render: (_progress, record) => {
const progressStatus =
record.status.value === "failed"
? "exception"
: record.status.value === "completed"
? "success"
: record.status.value === "running"
? "active"
: "normal";
return (
<Progress
percent={record.progress}
size="small"
status={progressStatus}
/>
);
},
},
{
title: "已处理/总数",
dataIndex: "processedImages",
key: "processedImages",
width: 120,
align: "right",
render: (_, record) => `${record.processedImages}/${record.totalImages}`,
},
{
title: "检测对象数",
dataIndex: "detectedObjects",
key: "detectedObjects",
width: 120,
align: "right",
},
{
title: "创建时间",
dataIndex: "createdAt",
key: "createdAt",
width: 180,
ellipsis: true,
},
{
title: "更新时间",
dataIndex: "updatedAt",
key: "updatedAt",
width: 180,
ellipsis: true,
},
{
title: "完成时间",
dataIndex: "completedAt",
key: "completedAt",
width: 180,
ellipsis: true,
},
{
title: "操作",
dataIndex: "actions",
key: "actions",
width: 140,
fixed: "right",
render: (_, record) => {
const canStop =
["pending", "running"].includes(record.status.value) &&
!record.stopRequested;
const canDownload =
["completed", "stopped", "failed"].includes(record.status.value) &&
!!record.outputPath;
return (
<div className="flex items-center gap-1">
<Tooltip title={record.stopRequested ? "已请求停止" : "停止任务"}>
<Button
type="text"
icon={<PauseCircleOutlined />}
disabled={!canStop}
onClick={() => handleStopTask(record)}
/>
</Tooltip>
<Tooltip title={canDownload ? "下载结果" : "任务完成后可下载"}>
<Button
type="text"
icon={<DownloadOutlined />}
disabled={!canDownload}
onClick={() => handleDownload(record)}
/>
</Tooltip>
</div>
);
},
},
];
return (
<Card title="自动标注任务">
<div className="mb-4">
<SearchControls
searchTerm={searchParams.keyword}
onSearchChange={handleKeywordChange}
searchPlaceholder="搜索任务名称、任务ID、数据集"
filters={filterOptions}
onFiltersChange={handleFiltersChange}
showViewToggle={false}
onReload={fetchData}
/>
</div>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={tableData}
pagination={pagination}
scroll={{ x: "max-content", y: "calc(100vh - 34rem)" }}
/>
<AutoAnnotationTaskDetailDrawer
open={detailOpen}
taskId={detailTaskId}
onClose={() => setDetailOpen(false)}
onRefreshList={() => fetchData()}
/>
</Card>
);
}

View File

@@ -0,0 +1,281 @@
import { useEffect, useMemo, useState } from "react";
import { Button, Form, Input, message, Select, Steps } from "antd";
import TextArea from "antd/es/input/TextArea";
import { SaveOutlined, DatabaseOutlined } from "@ant-design/icons";
import { ArrowLeft } from "lucide-react";
import { Link, useNavigate } from "react-router";
import { queryDatasetsUsingGet } from "@/pages/DataManagement/dataset.api";
import { mapDataset } from "@/pages/DataManagement/dataset.const";
import { Dataset, DatasetType } from "@/pages/DataManagement/dataset.model";
import { createAnnotationOperatorTaskUsingPost } from "../annotation.api";
import { useCreateStepTwo } from "./hooks/useCreateStepTwo";
import PipelinePreview from "./components/PipelinePreview";
interface TaskConfigValues {
name: string;
description?: string;
datasetId: string;
outputDatasetName: string;
}
const buildDefaultOutputDatasetName = (dataset?: Dataset) => {
if (!dataset?.name) {
return "自动标注结果集";
}
return `${dataset.name}_auto_annotation`;
};
export default function AnnotationOperatorTaskCreate() {
const navigate = useNavigate();
const [form] = Form.useForm<TaskConfigValues>();
const [currentStep, setCurrentStep] = useState(1);
const [datasets, setDatasets] = useState<Dataset[]>([]);
const [submitting, setSubmitting] = useState(false);
const [outputNameTouched, setOutputNameTouched] = useState(false);
const { loading: operatorLoading, selectedOperators, renderStepTwo } = useCreateStepTwo();
const selectedDatasetId = Form.useWatch("datasetId", form);
const selectedDataset = useMemo(
() => datasets.find((dataset) => dataset.id === selectedDatasetId),
[datasets, selectedDatasetId]
);
const fetchDatasets = async () => {
try {
const { data } = await queryDatasetsUsingGet({ page: 0, pageSize: 1000 });
const content = data?.content || [];
const mappedDatasets = content.map((item) => mapDataset(item));
setDatasets(mappedDatasets);
} catch (error) {
console.error("加载数据集失败", error);
message.error("加载数据集失败");
}
};
useEffect(() => {
fetchDatasets();
}, []);
useEffect(() => {
if (!selectedDataset || outputNameTouched) {
return;
}
form.setFieldValue(
"outputDatasetName",
buildDefaultOutputDatasetName(selectedDataset)
);
}, [form, outputNameTouched, selectedDataset]);
const canProceed = () => {
if (currentStep === 1) {
const values = form.getFieldsValue();
return !!values.name && !!values.datasetId && !!values.outputDatasetName;
}
if (currentStep === 2) {
return selectedOperators.length > 0;
}
return false;
};
const handleNext = async () => {
try {
if (currentStep === 1) {
await form.validateFields();
if (selectedDataset?.datasetType !== DatasetType.IMAGE) {
message.error("自动标注算子编排当前仅支持图片数据集");
return;
}
}
setCurrentStep((prev) => Math.min(prev + 1, 2));
} catch {
message.error("请完善基本信息");
}
};
const handlePrev = () => {
setCurrentStep((prev) => Math.max(prev - 1, 1));
};
const handleSave = async () => {
try {
const values = await form.validateFields();
if (selectedOperators.length === 0) {
message.error("请至少选择一个标注算子");
return;
}
if (selectedDataset?.datasetType !== DatasetType.IMAGE) {
message.error("自动标注算子编排当前仅支持图片数据集");
return;
}
const outputDatasetName = values.outputDatasetName?.trim();
const pipeline = selectedOperators.map((operator, index) => {
const overrides = {
...(operator.defaultParams || {}),
...(operator.overrides || {}),
} as Record<string, unknown>;
if (index === 0 && outputDatasetName) {
overrides.outputDatasetName = outputDatasetName;
}
return {
operatorId: operator.id,
overrides,
};
});
const payload = {
name: values.name,
datasetId: values.datasetId,
taskMode: "pipeline",
executorType: "annotation_local",
pipeline,
} as Record<string, unknown>;
if (values.description) {
payload.description = values.description;
}
setSubmitting(true);
await createAnnotationOperatorTaskUsingPost(payload);
message.success("自动标注任务创建成功");
navigate("/data/annotation");
} catch (error: unknown) {
const err = error as { message?: string; data?: { message?: string } };
const msg = err?.message || err?.data?.message || "创建失败,请稍后重试";
message.error(msg);
console.error(error);
} finally {
setSubmitting(false);
}
};
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center">
<Link to="/data/annotation">
<Button type="text">
<ArrowLeft className="w-4 h-4 mr-1" />
</Button>
</Link>
<h1 className="text-xl font-bold"></h1>
</div>
<div className="w-1/2">
<Steps
size="small"
current={currentStep - 1}
items={[{ title: "基本信息" }, { title: "算子编排" }]}
/>
</div>
</div>
<div className="flex-overflow-auto bg-white border-card">
<div className="flex-1 overflow-auto m-6">
{currentStep === 1 ? (
<Form form={form} layout="vertical">
<Form.Item
label="任务名称"
name="name"
rules={[{ required: true, message: "请输入任务名称" }]}
>
<Input placeholder="请输入自动标注任务名称" />
</Form.Item>
<Form.Item label="任务描述" name="description">
<TextArea
rows={3}
placeholder="可选:描述任务目标、执行策略或注意事项"
/>
</Form.Item>
<Form.Item
label="选择数据集"
name="datasetId"
rules={[{ required: true, message: "请选择数据集" }]}
extra="自动标注算子编排当前仅支持图片数据集"
>
<Select
placeholder="请选择图片数据集"
optionFilterProp="label"
options={datasets.map((dataset) => ({
label: (
<div className="flex items-center justify-between gap-3 py-2">
<div className="font-medium text-gray-900">
{(dataset as Dataset & { icon?: React.ReactNode }).icon || (
<DatabaseOutlined className="mr-2" />
)}
{dataset.name}
</div>
<div className="text-xs text-gray-500">
{dataset?.fileCount} {dataset.size}
</div>
</div>
),
value: dataset.id,
disabled: dataset.datasetType !== DatasetType.IMAGE,
}))}
/>
</Form.Item>
<Form.Item
label="输出数据集名称"
name="outputDatasetName"
rules={[{ required: true, message: "请输入输出数据集名称" }]}
>
<Input
placeholder="自动标注结果集名称"
onChange={() => setOutputNameTouched(true)}
/>
</Form.Item>
</Form>
) : (
<div className="flex flex-col gap-4 h-full">
<div className="flex-1 min-h-0">{renderStepTwo}</div>
<PipelinePreview operators={selectedOperators} />
</div>
)}
</div>
<div className="flex justify-end p-6 gap-3 border-top">
<Button onClick={() => navigate("/data/annotation")} disabled={submitting}>
</Button>
{currentStep > 1 && (
<Button onClick={handlePrev} disabled={submitting || operatorLoading}>
</Button>
)}
{currentStep === 2 ? (
<Button
type="primary"
icon={<SaveOutlined />}
onClick={handleSave}
disabled={!canProceed()}
loading={submitting}
>
</Button>
) : (
<Button
type="primary"
onClick={handleNext}
disabled={!canProceed()}
>
</Button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import React from "react";
import { Divider, Form, Tag } from "antd";
import { Settings } from "lucide-react";
import { OperatorI } from "@/pages/OperatorMarket/operator.model";
import ParamConfig from "./ParamConfig";
interface OperatorConfigProps {
selectedOperator: OperatorI | null;
handleConfigChange: (
operatorId: string,
paramKey: string,
value: unknown
) => void;
}
const OperatorConfig: React.FC<OperatorConfigProps> = ({
selectedOperator,
handleConfigChange,
}) => {
return (
<div className="w-1/4 min-w-3xs flex flex-col h-full">
<div className="px-4 pb-4 border-b border-gray-200">
<span className="font-semibold text-base flex items-center gap-2">
<Settings className="w-5 h-5" />
</span>
</div>
<div className="flex-1 overflow-auto p-4">
{selectedOperator ? (
<div>
<div className="mb-4">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium">{selectedOperator.name}</span>
</div>
<div className="text-sm text-gray-500">
{selectedOperator.description}
</div>
<div className="flex flex-wrap gap-1 mt-2">
{selectedOperator.tags?.map((tag: string) => (
<Tag key={tag} color="default">
{tag}
</Tag>
))}
</div>
</div>
<Divider />
<Form layout="vertical">
{Object.entries(selectedOperator.configs || {}).map(([key, param]) => (
<ParamConfig
key={key}
operator={selectedOperator}
paramKey={key}
param={param}
onParamChange={handleConfigChange}
/>
))}
</Form>
</div>
) : (
<div className="text-center py-12 text-gray-400">
<Settings className="w-full h-10 mb-4 opacity-50" />
<div></div>
</div>
)}
</div>
</div>
);
};
export default OperatorConfig;

View File

@@ -0,0 +1,330 @@
import React, { useEffect, useMemo, useState } from "react";
import {
Button,
Card,
Checkbox,
Input,
Select,
Tag,
Tooltip,
} from "antd";
import { SearchOutlined, StarFilled, StarOutlined } from "@ant-design/icons";
import { Layers } from "lucide-react";
import { CategoryI, OperatorI } from "@/pages/OperatorMarket/operator.model";
import { updateOperatorByIdUsingPut } from "@/pages/OperatorMarket/operator.api";
type GroupedCategoryOption = {
label: string;
title: string;
options: Array<Omit<CategoryI, "type">>;
};
interface OperatorListProps {
operators: OperatorI[];
favorites: Set<string>;
toggleFavorite: (id: string) => void;
toggleOperator: (operator: OperatorI) => void;
selectedOperators: OperatorI[];
onDragOperator: (
e: React.DragEvent,
item: OperatorI,
source: "library"
) => void;
}
const handleStar = async (
operator: OperatorI,
toggleFavorite: (id: string) => void
) => {
const data = {
id: operator.id,
isStar: !operator.isStar,
};
await updateOperatorByIdUsingPut(operator.id, data);
toggleFavorite(operator.id);
};
const OperatorList: React.FC<OperatorListProps> = ({
operators,
favorites,
toggleFavorite,
toggleOperator,
selectedOperators,
onDragOperator,
}) => (
<div className="grid grid-cols-1 gap-2">
{operators.map((operator) => {
const isSelected = selectedOperators.some((op) => op.id === operator.id);
return (
<Card
size="small"
key={operator.id}
draggable
hoverable
onDragStart={(event) => onDragOperator(event, operator, "library")}
onClick={() => toggleOperator(operator)}
>
<div className="flex items-center justify-between">
<div className="flex flex-1 min-w-0 items-center gap-2">
<Checkbox checked={isSelected} />
<span className="flex-1 min-w-0 font-medium text-sm overflow-hidden text-ellipsis whitespace-nowrap">
{operator.name}
</span>
</div>
<span
className="cursor-pointer"
onClick={(event) => {
event.stopPropagation();
handleStar(operator, toggleFavorite);
}}
>
{favorites.has(operator.id) ? (
<StarFilled style={{ color: "#FFD700" }} />
) : (
<StarOutlined />
)}
</span>
</div>
</Card>
);
})}
</div>
);
interface OperatorLibraryProps {
selectedOperators: OperatorI[];
operatorList: OperatorI[];
categoryOptions: CategoryI[];
setSelectedOperators: (operators: OperatorI[]) => void;
toggleOperator: (operator: OperatorI) => void;
handleDragStart: (
e: React.DragEvent,
item: OperatorI,
source: "library"
) => void;
}
const OperatorLibrary: React.FC<OperatorLibraryProps> = ({
selectedOperators,
operatorList,
categoryOptions,
setSelectedOperators,
toggleOperator,
handleDragStart,
}) => {
const [searchTerm, setSearchTerm] = useState("");
const [showFavorites, setShowFavorites] = useState(false);
const [favorites, setFavorites] = useState<Set<string>>(new Set());
const [selectedCategory, setSelectedCategory] = useState<string[]>([]);
const [operatorListFiltered, setOperatorListFiltered] = useState<OperatorI[]>(
[]
);
useMemo(() => {
const groups: Record<string, { type: string; operators: OperatorI[] }> = {};
let operatorFilteredList: OperatorI[];
categoryOptions.forEach((cat) => {
groups[cat.id] = {
type: cat.type,
operators: operatorList.filter((op) => op.categories?.includes(cat.id)),
};
});
if (selectedCategory.length) {
const groupedFiltered: Record<string, OperatorI[]> = {};
selectedCategory.forEach((cat: string) => {
const parent = groups[cat]?.type;
if (!parent) {
return;
}
if (!groupedFiltered[parent]) {
groupedFiltered[parent] = groups[cat].operators;
} else {
groupedFiltered[parent] = Array.from(
new Map(
[...groupedFiltered[parent], ...groups[cat].operators].map((item) => [
item.id,
item,
])
).values()
);
}
});
operatorFilteredList = Object.values(groupedFiltered).reduce<OperatorI[]>(
(acc, currentList) => {
if (acc.length === 0) {
return currentList;
}
const currentIds = new Set(currentList.map((item) => item.id));
return acc.filter((item) => currentIds.has(item.id));
},
[]
);
} else {
operatorFilteredList = [...operatorList];
}
if (searchTerm) {
operatorFilteredList = operatorFilteredList.filter((operator) =>
operator.name.toLowerCase().includes(searchTerm.toLowerCase())
);
}
if (showFavorites) {
operatorFilteredList = operatorFilteredList.filter((operator) =>
favorites.has(operator.id)
);
}
setOperatorListFiltered([...operatorFilteredList]);
return groups;
}, [
categoryOptions,
selectedCategory,
operatorList,
searchTerm,
showFavorites,
favorites,
]);
const toggleFavorite = (operatorId: string) => {
const newFavorites = new Set(favorites);
if (newFavorites.has(operatorId)) {
newFavorites.delete(operatorId);
} else {
newFavorites.add(operatorId);
}
setFavorites(newFavorites);
};
useEffect(() => {
const newFavorites = new Set<string>();
operatorList.forEach((item) => {
if (item.isStar) {
newFavorites.add(item.id);
}
});
setFavorites(newFavorites);
}, [operatorList]);
const handleSelectAll = (operators: OperatorI[]) => {
const newSelected = [...selectedOperators];
operators.forEach((operator) => {
if (!newSelected.some((op) => op.id === operator.id)) {
newSelected.push(operator);
}
});
setSelectedOperators(newSelected);
};
const handleSelectCategory = (source: CategoryI[]): GroupedCategoryOption[] => {
const groups: Record<string, GroupedCategoryOption> = {};
const tree: GroupedCategoryOption[] = [];
source.forEach((item) => {
const groupName = item.type || "未分组";
if (!groups[groupName]) {
const newGroup = {
label: groupName,
title: groupName,
options: [],
};
groups[groupName] = newGroup;
tree.push(newGroup);
}
const childItem: Omit<CategoryI, "type"> = {
id: item.id,
name: item.name,
count: item.count,
parentId: item.parentId,
value: item.value,
createdAt: item.createdAt,
};
groups[groupName].options.push(childItem);
});
return tree;
};
return (
<div className="w-1/4 h-full min-w-3xs flex flex-col">
<div className="pb-4 border-b border-gray-200">
<span className="flex items-center font-semibold text-base">
<Layers className="w-4 h-4 mr-2" />
({operatorList.length})
</span>
</div>
<div className="flex flex-col h-full pt-4 pr-4 overflow-hidden">
<div className="flex flex-wrap gap-2 border-b border-gray-100 pb-2">
<Input
prefix={<SearchOutlined />}
placeholder="搜索算子名称..."
value={searchTerm}
allowClear
onChange={(event) => setSearchTerm(event.target.value)}
/>
<Select
value={selectedCategory}
options={handleSelectCategory(categoryOptions)}
onChange={setSelectedCategory}
mode="multiple"
allowClear
className="flex-1"
placeholder="选择分类"
/>
<Tooltip title="只看收藏">
<span
className="cursor-pointer"
onClick={() => setShowFavorites(!showFavorites)}
>
{showFavorites ? (
<StarFilled style={{ color: "#FFD700" }} />
) : (
<StarOutlined />
)}
</span>
</Tooltip>
<div className="flex items-center justify-right w-full">
<Button
type="link"
size="small"
onClick={(event) => {
event.stopPropagation();
handleSelectAll(operatorListFiltered);
}}
>
<Tag>{operatorListFiltered.length}</Tag>
</Button>
</div>
</div>
<div className="flex-1 overflow-auto">
<OperatorList
selectedOperators={selectedOperators}
operators={operatorListFiltered}
favorites={favorites}
toggleOperator={toggleOperator}
onDragOperator={handleDragStart}
toggleFavorite={toggleFavorite}
/>
{operatorListFiltered.length === 0 && (
<div className="text-center py-8 text-gray-400">
<SearchOutlined className="text-3xl mb-2 opacity-50" />
<div></div>
</div>
)}
</div>
</div>
</div>
);
};
export default OperatorLibrary;

View File

@@ -0,0 +1,202 @@
import React, { useMemo, useState } from "react";
import { Card, Input, Tag, Button } from "antd";
import { DeleteOutlined } from "@ant-design/icons";
import { Workflow } from "lucide-react";
import { CategoryI, OperatorI } from "@/pages/OperatorMarket/operator.model";
interface OperatorOrchestrationProps {
selectedOperators: OperatorI[];
configOperator: OperatorI | null;
categoryOptions: CategoryI[];
setSelectedOperators: (operators: OperatorI[]) => void;
setConfigOperator: (operator: OperatorI | null) => void;
removeOperator: (id: string) => void;
handleDragStart: (
e: React.DragEvent,
operator: OperatorI,
source: "sort"
) => void;
handleItemDragOver: (e: React.DragEvent, itemId: string) => void;
handleItemDragLeave: (e: React.DragEvent) => void;
handleItemDrop: (e: React.DragEvent, index: number) => void;
handleContainerDragOver: (e: React.DragEvent) => void;
handleContainerDragLeave: (e: React.DragEvent) => void;
handleDragEnd: (e: React.DragEvent) => void;
handleDropToContainer: (e: React.DragEvent) => void;
}
const OperatorOrchestration: React.FC<OperatorOrchestrationProps> = ({
selectedOperators,
configOperator,
categoryOptions,
setSelectedOperators,
setConfigOperator,
removeOperator,
handleDragStart,
handleItemDragOver,
handleItemDragLeave,
handleItemDrop,
handleContainerDragOver,
handleContainerDragLeave,
handleDragEnd,
handleDropToContainer,
}) => {
const [editingIndex, setEditingIndex] = useState<string | null>(null);
const categoryMap = useMemo(() => {
const map: Record<string, CategoryI> = {};
categoryOptions.forEach((category) => {
map[category.id] = category;
});
return map;
}, [categoryOptions]);
const handleIndexChange = (operatorId: string, newIndex: string) => {
const index = Number.parseInt(newIndex, 10);
if (Number.isNaN(index) || index < 1 || index > selectedOperators.length) {
return;
}
const currentIndex = selectedOperators.findIndex((op) => op.id === operatorId);
if (currentIndex === -1) return;
const targetIndex = index - 1;
if (currentIndex === targetIndex) {
setEditingIndex(null);
return;
}
const newOperators = [...selectedOperators];
const [movedOperator] = newOperators.splice(currentIndex, 1);
newOperators.splice(targetIndex, 0, movedOperator);
setSelectedOperators(newOperators);
setEditingIndex(null);
};
const renderCategoryTag = (categoryId: string) => {
const category = categoryMap[categoryId];
if (!category) return null;
return (
<Tag key={`${categoryId}-tag`} color="default">
{category.name}
</Tag>
);
};
return (
<div className="w-1/2 h-full min-w-xs flex-1 flex flex-col border-x border-gray-200">
<div className="px-4 pb-2 border-b border-gray-200">
<div className="flex flex-wrap gap-2 justify-between items-start">
<span className="font-semibold text-base flex items-center gap-2">
<Workflow className="w-5 h-5" />
({selectedOperators.length})
<Button
type="link"
size="small"
onClick={() => {
setConfigOperator(null);
setSelectedOperators([]);
}}
disabled={selectedOperators.length === 0}
>
</Button>
</span>
</div>
</div>
<div
className="flex-overflow-auto p-4 gap-2"
onDragOver={handleContainerDragOver}
onDragLeave={handleContainerDragLeave}
onDrop={handleDropToContainer}
>
{selectedOperators.map((operator, index) => (
<Card
size="small"
key={operator.id}
style={
configOperator?.id === operator.id
? { borderColor: "#1677ff" }
: {}
}
hoverable
draggable
onDragStart={(event) => handleDragStart(event, operator, "sort")}
onDragEnd={handleDragEnd}
onDragOver={(event) => handleItemDragOver(event, operator.id)}
onDragLeave={handleItemDragLeave}
onDrop={(event) => handleItemDrop(event, index)}
onClick={() => setConfigOperator(operator)}
>
<div className="flex items-center gap-1">
<span></span>
{editingIndex === operator.id ? (
<Input
type="number"
min={1}
max={selectedOperators.length}
defaultValue={index + 1}
className="w-10 h-6 text-xs text-center"
style={{ width: 60 }}
autoFocus
onBlur={(event) =>
handleIndexChange(operator.id, event.target.value)
}
onKeyDown={(event) => {
if (event.key === "Enter") {
handleIndexChange(
operator.id,
(event.target as HTMLInputElement).value
);
} else if (event.key === "Escape") {
setEditingIndex(null);
}
}}
onClick={(event) => event.stopPropagation()}
/>
) : (
<Tag
color="default"
onClick={(event) => {
event.stopPropagation();
setEditingIndex(operator.id);
}}
>
{index + 1}
</Tag>
)}
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className="font-medium text-sm truncate">{operator.name}</span>
</div>
{operator.categories?.map(renderCategoryTag)}
<span
className="cursor-pointer text-red-500"
onClick={(event) => {
event.stopPropagation();
removeOperator(operator.id);
}}
>
<DeleteOutlined />
</span>
</div>
</Card>
))}
{selectedOperators.length === 0 && (
<div className="text-center py-16 text-gray-400 border-2 border-dashed border-gray-100 rounded-lg">
<Workflow className="w-full h-10 mb-4 opacity-50" />
<div className="text-lg font-medium mb-2"></div>
<div className="text-sm"></div>
</div>
)}
</div>
</div>
);
};
export default OperatorOrchestration;

View File

@@ -0,0 +1,224 @@
import React from "react";
import {
Checkbox,
Form,
Input,
InputNumber,
Radio,
Select,
Slider,
Space,
} from "antd";
import { ConfigI, OperatorI } from "@/pages/OperatorMarket/operator.model";
interface ParamConfigProps {
operator: OperatorI;
paramKey: string;
param: ConfigI;
onParamChange?: (operatorId: string, paramKey: string, value: unknown) => void;
}
const ParamConfig: React.FC<ParamConfigProps> = ({
operator,
paramKey,
param,
onParamChange,
}) => {
let defaultVal: unknown = param.defaultVal;
if (param.type === "range") {
defaultVal = Array.isArray(param.defaultVal)
? param.defaultVal
: [
param?.properties?.[0]?.defaultVal,
param?.properties?.[1]?.defaultVal,
];
}
const [value, setValue] = React.useState(param.value ?? defaultVal);
const updateValue = (newValue: unknown) => {
setValue(newValue);
return onParamChange && onParamChange(operator.id, paramKey, newValue);
};
switch (param.type) {
case "input":
return (
<Form.Item label={param.name} tooltip={param.description} key={paramKey}>
<Input
value={value as string}
onChange={(event) => updateValue(event.target.value)}
placeholder={`请输入${param.name}`}
className="w-full"
/>
</Form.Item>
);
case "select":
return (
<Form.Item label={param.name} tooltip={param.description} key={paramKey}>
<Select
value={value}
onChange={updateValue}
options={(param.options || []).map((option) =>
typeof option === "string"
? { label: option, value: option }
: option
)}
placeholder={`请选择${param.name}`}
className="w-full"
/>
</Form.Item>
);
case "radio":
return (
<Form.Item label={param.name} tooltip={param.description} key={paramKey}>
<Radio.Group
value={value}
onChange={(event) => updateValue(event.target.value)}
>
{(param.options || []).map((option) => (
<Radio
key={typeof option === "string" ? option : option.value}
value={typeof option === "string" ? option : option.value}
>
{typeof option === "string" ? option : option.label}
</Radio>
))}
</Radio.Group>
</Form.Item>
);
case "checkbox":
return (
<Form.Item label={param.name} tooltip={param.description} key={paramKey}>
<Checkbox.Group
value={value as string[]}
onChange={updateValue}
options={param.options || []}
/>
</Form.Item>
);
case "slider":
return (
<Form.Item label={param.name} tooltip={param.description} key={paramKey}>
<div className="flex items-center gap-1">
<Slider
value={value as number}
onChange={updateValue}
tooltip={{ open: true }}
marks={{
[param.min || 0]: `${param.min || 0}`,
[param.min + (param.max - param.min) / 2]: `${
(param.min + param.max) / 2
}`,
[param.max || 100]: `${param.max || 100}`,
}}
min={param.min || 0}
max={param.max || 100}
step={param.step || 1}
className="flex-1"
/>
<InputNumber
min={param.min || 0}
max={param.max || 100}
step={param.step || 1}
value={value as number}
onChange={updateValue}
style={{ width: 80 }}
/>
</div>
</Form.Item>
);
case "range": {
const min = param.min || param?.properties?.[0]?.min || 0;
const max = param.max || param?.properties?.[0]?.max || 1;
const step = param.step || param?.properties?.[0]?.step || 0.1;
return (
<Form.Item label={param.name} tooltip={param.description} key={paramKey}>
<Slider
value={Array.isArray(value) ? value : [value, value]}
onChange={(val: number | [number, number]) =>
updateValue(Array.isArray(val) ? val : [val, val])
}
range
min={min}
max={max}
step={step}
className="w-full"
/>
<Space>
<InputNumber
min={min}
max={max}
value={(value as number[])?.[0]}
onChange={(val1) =>
updateValue([val1 ?? (value as number[])?.[0], (value as number[])?.[1]])
}
changeOnWheel
/>
~
<InputNumber
min={min}
max={max}
value={(value as number[])?.[1]}
onChange={(val2) =>
updateValue([(value as number[])?.[0], val2 ?? (value as number[])?.[1]])
}
changeOnWheel
/>
</Space>
</Form.Item>
);
}
case "inputNumber":
return (
<Form.Item label={param.name} tooltip={param.description} key={paramKey}>
<InputNumber
value={value as number}
onChange={(val) => updateValue(val)}
placeholder={`请输入${param.name}`}
className="w-full"
min={param.min}
max={param.max}
step={param.step || 1}
/>
</Form.Item>
);
case "switch":
return (
<Form.Item label={param.name} tooltip={param.description} key={paramKey}>
<Checkbox
checked={Boolean(value)}
onChange={(event) => updateValue(event.target.checked)}
>
{param.name}
</Checkbox>
</Form.Item>
);
case "multiple":
return (
<div className="pl-4 border-l border-gray-300">
{param.properties?.map((subParam) => (
<ParamConfig
key={subParam.key}
operator={operator}
paramKey={subParam.key}
param={subParam}
onParamChange={onParamChange}
/>
))}
</div>
);
default:
return null;
}
};
export default ParamConfig;

View File

@@ -0,0 +1,45 @@
import React from "react";
import { Card, Empty, Typography } from "antd";
import { GitBranch } from "lucide-react";
import { OperatorI } from "@/pages/OperatorMarket/operator.model";
interface PipelinePreviewProps {
operators: OperatorI[];
}
const PipelinePreview: React.FC<PipelinePreviewProps> = ({ operators }) => {
const pipelinePreview = operators.map((operator) => ({
operatorId: operator.id,
overrides: {
...(operator.defaultParams || {}),
...(operator.overrides || {}),
},
}));
const previewText = JSON.stringify(pipelinePreview, null, 2);
return (
<Card
size="small"
title={
<span className="flex items-center gap-2">
<GitBranch className="w-4 h-4" />
Pipeline
</span>
}
>
{operators.length === 0 ? (
<Empty description="暂无算子,请先从左侧选择" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<Typography.Paragraph
code
className="!mb-0 whitespace-pre-wrap break-all"
>
{previewText}
</Typography.Paragraph>
)}
</Card>
);
};
export default PipelinePreview;

View File

@@ -0,0 +1,88 @@
import OperatorConfig from "../components/OperatorConfig";
import OperatorLibrary from "../components/OperatorLibrary";
import OperatorOrchestration from "../components/OperatorOrchestration";
import { useDragOperators } from "./useDragOperators";
import { useOperatorOperations } from "./useOperatorOperations";
import { OperatorI } from "@/pages/OperatorMarket/operator.model";
type DragFromLibrary = (
e: React.DragEvent,
item: OperatorI,
source: "library"
) => void;
type DragForSort = (
e: React.DragEvent,
item: OperatorI,
source: "sort"
) => void;
export function useCreateStepTwo() {
const {
loading,
operators,
selectedOperators,
configOperator,
categoryOptions,
setConfigOperator,
setSelectedOperators,
handleConfigChange,
toggleOperator,
removeOperator,
} = useOperatorOperations();
const {
handleDragStart,
handleDragEnd,
handleContainerDragOver,
handleContainerDragLeave,
handleItemDragOver,
handleItemDragLeave,
handleItemDrop,
handleDropToContainer,
} = useDragOperators({
operators: selectedOperators,
setOperators: setSelectedOperators,
});
const renderStepTwo = (
<div className="flex w-full h-full">
<OperatorLibrary
categoryOptions={categoryOptions}
selectedOperators={selectedOperators}
operatorList={operators}
setSelectedOperators={setSelectedOperators}
toggleOperator={toggleOperator}
handleDragStart={handleDragStart as DragFromLibrary}
/>
<OperatorOrchestration
selectedOperators={selectedOperators}
configOperator={configOperator}
categoryOptions={categoryOptions}
setSelectedOperators={setSelectedOperators}
setConfigOperator={setConfigOperator}
removeOperator={removeOperator}
handleDragStart={handleDragStart as DragForSort}
handleContainerDragLeave={handleContainerDragLeave}
handleContainerDragOver={handleContainerDragOver}
handleItemDragOver={handleItemDragOver}
handleItemDragLeave={handleItemDragLeave}
handleItemDrop={handleItemDrop}
handleDropToContainer={handleDropToContainer}
handleDragEnd={handleDragEnd}
/>
<OperatorConfig
selectedOperator={configOperator}
handleConfigChange={handleConfigChange}
/>
</div>
);
return {
loading,
operators,
selectedOperators,
renderStepTwo,
};
}

View File

@@ -0,0 +1,141 @@
import { OperatorI } from "@/pages/OperatorMarket/operator.model";
import React, { useState } from "react";
export function useDragOperators({
operators,
setOperators,
}: {
operators: OperatorI[];
setOperators: (operators: OperatorI[]) => void;
}) {
const [draggingItem, setDraggingItem] = useState<OperatorI | null>(null);
const [draggingSource, setDraggingSource] = useState<
"library" | "sort" | null
>(null);
const [insertPosition, setInsertPosition] = useState<
"above" | "below" | null
>(null);
const handleDragStart = (
e: React.DragEvent,
item: OperatorI,
source: "library" | "sort"
) => {
setDraggingItem({
...item,
originalId: item.id,
});
setDraggingSource(source);
e.dataTransfer.effectAllowed = "move";
};
const handleDragEnd = () => {
setDraggingItem(null);
setInsertPosition(null);
};
const handleContainerDragOver = (e: React.DragEvent) => {
e.preventDefault();
};
const handleContainerDragLeave = (e: React.DragEvent) => {
if (!e.currentTarget.contains(e.relatedTarget)) {
setInsertPosition(null);
}
};
const handleItemDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
const mouseY = e.clientY;
const elementMiddle = rect.top + rect.height / 2;
const newPosition = mouseY < elementMiddle ? "above" : "below";
setInsertPosition(newPosition);
};
const handleItemDragLeave = (e: React.DragEvent) => {
if (!e.currentTarget.contains(e.relatedTarget)) {
setInsertPosition(null);
}
};
const handleDropToContainer = (e: React.DragEvent) => {
e.preventDefault();
if (!draggingItem) return;
if (draggingSource === "library") {
const exists = operators.some((item) => item.id === draggingItem.id);
if (!exists) {
setOperators([...operators, draggingItem]);
}
}
resetDragState();
};
const handleItemDrop = (e: React.DragEvent, targetIndex: number) => {
e.preventDefault();
e.stopPropagation();
if (!draggingItem) return;
if (draggingSource === "library") {
if (targetIndex !== -1) {
const insertIndex =
insertPosition === "above" ? targetIndex : targetIndex + 1;
const exists = operators.some((item) => item.id === draggingItem.id);
if (!exists) {
const newRightItems = [...operators];
newRightItems.splice(insertIndex, 0, draggingItem);
setOperators(newRightItems);
}
}
} else if (draggingSource === "sort") {
const draggedIndex = operators.findIndex(
(item) => item.id === draggingItem.id
);
if (
draggedIndex !== -1 &&
targetIndex !== -1 &&
draggedIndex !== targetIndex
) {
const newItems = [...operators];
const [draggedOperator] = newItems.splice(draggedIndex, 1);
let insertIndex =
insertPosition === "above" ? targetIndex : targetIndex + 1;
if (draggedIndex < insertIndex) {
insertIndex--;
}
newItems.splice(insertIndex, 0, draggedOperator);
setOperators(newItems);
}
}
resetDragState();
};
const resetDragState = () => {
setDraggingItem(null);
setInsertPosition(null);
};
return {
handleDragStart,
handleDragEnd,
handleContainerDragOver,
handleContainerDragLeave,
handleItemDragOver,
handleItemDragLeave,
handleItemDrop,
handleDropToContainer,
};
}

View File

@@ -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,
};
}

View File

@@ -18,6 +18,30 @@ export function createAnnotationTaskUsingPost(data: RequestPayload) {
export function syncAnnotationTaskUsingPost(data: RequestPayload) { export function syncAnnotationTaskUsingPost(data: RequestPayload) {
return post(`/api/annotation/task/sync`, data); return post(`/api/annotation/task/sync`, data);
} }
export function queryAnnotationOperatorTasksUsingGet(params?: RequestParams) {
return get("/api/annotation/operator-tasks", params);
}
export function createAnnotationOperatorTaskUsingPost(data: RequestPayload) {
return post("/api/annotation/operator-tasks", data);
}
export function queryAnnotationOperatorTaskByIdUsingGet(taskId: string) {
return get(`/api/annotation/operator-tasks/${taskId}`);
}
export function deleteAnnotationOperatorTaskByIdUsingDelete(taskId: string) {
return del(`/api/annotation/operator-tasks/${taskId}`);
}
export function stopAnnotationOperatorTaskByIdUsingPost(taskId: string) {
return post(`/api/annotation/operator-tasks/${taskId}/stop`, {});
}
export function downloadAnnotationOperatorTaskResultUsingGet(taskId: string, filename?: string) {
return download(`/api/annotation/operator-tasks/${taskId}/download`, null, filename);
}
export function deleteAnnotationTaskByIdUsingDelete(mappingId: string) { export function deleteAnnotationTaskByIdUsingDelete(mappingId: string) {
// Backend expects mapping UUID as path parameter // Backend expects mapping UUID as path parameter

View File

@@ -21,23 +21,24 @@ export interface ConfigI {
properties?: ConfigI[]; // 用于嵌套配置 properties?: ConfigI[]; // 用于嵌套配置
} }
export interface OperatorI { export interface OperatorI {
id: string; id: string;
name: string; name: string;
type: string; type: string;
version: string; version: string;
inputs: string; inputs: string;
outputs: string; outputs: string;
icon: React.ReactNode; runtime?: string;
iconColor?: string; // 图标背景色,用于区分不同类型算子 icon: React.ReactNode;
description: string; iconColor?: string; // 图标背景色,用于区分不同类型算子
description: string;
tags: string[]; tags: string[];
isStar?: boolean; isStar?: boolean;
originalId?: string; // 用于标识原始算子ID,便于去重 originalId?: string; // 用于标识原始算子ID,便于去重
categories: string[]; // 分类列表 categories: string[]; // 分类列表
settings: string; settings: string;
overrides?: { [key: string]: any }; // 用户配置的参数 overrides?: Record<string, unknown>; // 用户配置的参数
defaultParams?: { [key: string]: any }; // 默认参数 defaultParams?: Record<string, unknown>; // 默认参数
configs: { configs: {
[key: string]: ConfigI; [key: string]: ConfigI;
}; };

View File

@@ -19,6 +19,7 @@ import CleansingTemplateCreate from "@/pages/DataCleansing/Create/CreateTemplate
import DataAnnotation from "@/pages/DataAnnotation/Home/DataAnnotation"; import DataAnnotation from "@/pages/DataAnnotation/Home/DataAnnotation";
import AnnotationTaskCreate from "@/pages/DataAnnotation/Create/CreateTask"; import AnnotationTaskCreate from "@/pages/DataAnnotation/Create/CreateTask";
import LabelStudioTextEditor from "@/pages/DataAnnotation/Annotate/LabelStudioTextEditor"; import LabelStudioTextEditor from "@/pages/DataAnnotation/Annotate/LabelStudioTextEditor";
import AnnotationOperatorTaskCreate from "@/pages/DataAnnotation/OperatorCreate/CreateTask";
import DataSynthesisPage from "@/pages/SynthesisTask/DataSynthesis"; import DataSynthesisPage from "@/pages/SynthesisTask/DataSynthesis";
import InstructionTemplateCreate from "@/pages/SynthesisTask/CreateTemplate"; import InstructionTemplateCreate from "@/pages/SynthesisTask/CreateTemplate";
@@ -183,6 +184,10 @@ const router = createBrowserRouter([
path: "create-task", path: "create-task",
Component: AnnotationTaskCreate, Component: AnnotationTaskCreate,
}, },
{
path: "create-auto-task",
Component: AnnotationOperatorTaskCreate,
},
{ {
path: "annotate/:projectId", path: "annotate/:projectId",
Component: LabelStudioTextEditor, Component: LabelStudioTextEditor,

View File

@@ -2,7 +2,9 @@
"""Annotation-related operators (e.g. YOLO detection).""" """Annotation-related operators (e.g. YOLO detection)."""
from . import image_object_detection_bounding_box from . import image_object_detection_bounding_box
from . import test_annotation_marker
__all__ = [ __all__ = [
"image_object_detection_bounding_box", "image_object_detection_bounding_box",
"test_annotation_marker",
] ]

View File

@@ -40,7 +40,7 @@ settings:
name: '目标类别' name: '目标类别'
description: 'COCO 类别 ID 列表;为空表示全部类别。' description: 'COCO 类别 ID 列表;为空表示全部类别。'
type: 'input' type: 'input'
defaultVal: '[]' defaultVal: []
outputDir: outputDir:
name: '输出目录' name: '输出目录'
description: '算子输出目录(由运行时注入)。' description: '算子输出目录(由运行时注入)。'

View File

@@ -0,0 +1,12 @@
"""Test annotation marker operator package."""
from datamate.core.base_op import OPERATORS
from .process import test_annotation_marker
OPERATORS.register_module(
module_name="test_annotation_marker",
module_path="ops.annotation.test_annotation_marker.process",
)
__all__ = ["test_annotation_marker"]

View File

@@ -0,0 +1,37 @@
name: '测试标注标记'
name_en: 'Test Annotation Marker'
description: '系统测试算子:在图片上添加测试标记并输出对应标注文件。'
description_en: 'System testing operator: draw test marker on images and emit annotation json.'
language: 'python'
vendor: 'datamate'
raw_id: 'test_annotation_marker'
version: '1.0.0'
types:
- 'annotation'
modal: 'image'
inputs: 'image'
outputs: 'image'
settings:
markerText:
name: '标记文本'
description: '绘制在图片上的测试标记文本。'
type: 'input'
defaultVal: 'TEST_ANNOTATION'
markerColor:
name: '标记颜色'
description: '标记颜色(OpenCV BGR,格式如 0,255,0)。'
type: 'input'
defaultVal: '0,255,0'
markerThickness:
name: '线宽'
description: '标记框线宽。'
type: 'inputNumber'
defaultVal: 2
min: 1
max: 10
step: 1
outputDir:
name: '输出目录'
description: '算子输出目录(由运行时注入)。'
type: 'input'
defaultVal: ''

View File

@@ -0,0 +1,122 @@
from __future__ import annotations
import json
import os
from typing import Any, Dict, Tuple
import cv2
from loguru import logger
from datamate.core.base_op import Mapper
class test_annotation_marker(Mapper):
def __init__(self, *args, **kwargs):
super(test_annotation_marker, self).__init__(*args, **kwargs)
self._marker_text = str(kwargs.get("markerText", "TEST_ANNOTATION"))
self._marker_color = self._parse_color(kwargs.get("markerColor", "0,255,0"))
self._marker_thickness = int(kwargs.get("markerThickness", 2) or 2)
self._output_dir = kwargs.get("outputDir")
@staticmethod
def _parse_color(value: Any) -> Tuple[int, int, int]:
if isinstance(value, (list, tuple)) and len(value) >= 3:
try:
bgr = tuple(int(max(min(float(item), 255), 0)) for item in value[:3])
return bgr # type: ignore[return-value]
except Exception:
return 0, 255, 0
if isinstance(value, str):
parts = [part.strip() for part in value.split(",")]
if len(parts) >= 3:
try:
bgr = tuple(int(max(min(float(item), 255), 0)) for item in parts[:3])
return bgr # type: ignore[return-value]
except Exception:
return 0, 255, 0
return 0, 255, 0
def execute(self, sample: Dict[str, Any]) -> Dict[str, Any]:
image_path = sample.get(self.image_key) or sample.get("image")
if not image_path or not os.path.exists(image_path):
logger.warning("test_annotation_marker: image not found: {}", image_path)
return sample
image = cv2.imread(image_path)
if image is None:
logger.warning("test_annotation_marker: failed to read image: {}", image_path)
return sample
image_height, image_width = image.shape[:2]
margin = max(min(image_width, image_height) // 20, 10)
x1, y1 = margin, margin
x2, y2 = image_width - margin, image_height - margin
cv2.rectangle(
image,
(x1, y1),
(x2, y2),
self._marker_color,
self._marker_thickness,
)
cv2.putText(
image,
self._marker_text,
(x1, max(y1 - 10, 20)),
cv2.FONT_HERSHEY_SIMPLEX,
0.8,
self._marker_color,
max(self._marker_thickness, 1),
cv2.LINE_AA,
)
if self._output_dir and os.path.exists(self._output_dir):
output_dir = self._output_dir
else:
output_dir = os.path.dirname(image_path)
images_dir = os.path.join(output_dir, "images")
annotations_dir = os.path.join(output_dir, "annotations")
os.makedirs(images_dir, exist_ok=True)
os.makedirs(annotations_dir, exist_ok=True)
base_name = os.path.basename(image_path)
name_without_ext = os.path.splitext(base_name)[0]
output_image_path = os.path.join(images_dir, base_name)
output_json_path = os.path.join(annotations_dir, f"{name_without_ext}.json")
cv2.imwrite(output_image_path, image)
annotations = {
"image": base_name,
"width": image_width,
"height": image_height,
"marker": {
"text": self._marker_text,
"color_bgr": list(self._marker_color),
"thickness": self._marker_thickness,
},
"detections": [
{
"label": self._marker_text,
"class_id": -1,
"confidence": 1.0,
"bbox_xyxy": [x1, y1, x2, y2],
"bbox_xywh": [x1, y1, x2 - x1, y2 - y1],
}
],
}
with open(output_json_path, "w", encoding="utf-8") as file:
json.dump(annotations, file, indent=2, ensure_ascii=False)
sample["output_image"] = output_image_path
sample["annotations_file"] = output_json_path
sample["annotations"] = annotations
sample["detection_count"] = 1
return sample

View File

@@ -122,7 +122,7 @@ DEFAULT_OUTPUT_ROOT = os.getenv(
DEFAULT_OPERATOR_WHITELIST = os.getenv( DEFAULT_OPERATOR_WHITELIST = os.getenv(
"AUTO_ANNOTATION_OPERATOR_WHITELIST", "AUTO_ANNOTATION_OPERATOR_WHITELIST",
"ImageObjectDetectionBoundingBox", "ImageObjectDetectionBoundingBox,test_annotation_marker",
) )

View File

@@ -72,6 +72,7 @@ VALUES ('64465bec-b46b-11f0-8291-00155d0e4808', '模态', 'modal', 'predefined'
INSERT IGNORE INTO t_operator INSERT IGNORE INTO t_operator
(id, name, description, version, inputs, outputs, runtime, settings, file_name, is_star) (id, name, description, version, inputs, outputs, runtime, settings, file_name, is_star)
VALUES ('MineruFormatter', 'MinerU PDF文本抽取', '基于MinerU API,抽取PDF中的文本。', '1.0.0', 'text', 'text', null, null, '', false), VALUES ('MineruFormatter', 'MinerU PDF文本抽取', '基于MinerU API,抽取PDF中的文本。', '1.0.0', 'text', 'text', null, null, '', false),
('test_annotation_marker', '测试标注标记', '系统测试算子:在图片上添加测试标记并输出标注文件。', '1.0.0', 'image', 'image', 'ops.annotation.test_annotation_marker.process', '{"markerText": {"name": "标记文本", "description": "绘制在图片上的测试标记文本。", "type": "input", "defaultVal": "TEST_ANNOTATION"}, "markerColor": {"name": "标记颜色", "description": "标记颜色(OpenCV BGR,格式如 0,255,0)。", "type": "input", "defaultVal": "0,255,0"}, "markerThickness": {"name": "线宽", "description": "标记框线宽。", "type": "inputNumber", "defaultVal": 2, "min": 1, "max": 10, "step": 1}, "outputDir": {"name": "输出目录", "description": "算子输出目录(由运行时注入)。", "type": "input", "defaultVal": ""}}', '', 'false'),
('FileWithHighRepeatPhraseRateFilter', '文档词重复率检查', '去除重复词过多的文档。', '1.0.0', 'text', 'text', null, '{"repeatPhraseRatio": {"name": "文档词重复率", "description": "某个词的统计数/文档总词数 > 设定值,该文档被去除。", "type": "slider", "defaultVal": 0.5, "min": 0, "max": 1, "step": 0.1}, "hitStopwords": {"name": "去除停用词", "description": "统计重复词时,选择是否要去除停用词。", "type": "switch", "defaultVal": false, "required": true, "checkedLabel": "去除", "unCheckedLabel": "不去除"}}', '', 'false'), ('FileWithHighRepeatPhraseRateFilter', '文档词重复率检查', '去除重复词过多的文档。', '1.0.0', 'text', 'text', null, '{"repeatPhraseRatio": {"name": "文档词重复率", "description": "某个词的统计数/文档总词数 > 设定值,该文档被去除。", "type": "slider", "defaultVal": 0.5, "min": 0, "max": 1, "step": 0.1}, "hitStopwords": {"name": "去除停用词", "description": "统计重复词时,选择是否要去除停用词。", "type": "switch", "defaultVal": false, "required": true, "checkedLabel": "去除", "unCheckedLabel": "不去除"}}', '', 'false'),
('FileWithHighRepeatWordRateFilter', '文档字重复率检查', '去除重复字过多的文档。', '1.0.0', 'text', 'text', null, '{"repeatWordRatio": {"name": "文档字重复率", "description": "某个字的统计数/文档总字数 > 设定值,该文档被去除。", "type": "slider", "defaultVal": 0.5, "min": 0, "max": 1, "step": 0.1}}', '', 'false'), ('FileWithHighRepeatWordRateFilter', '文档字重复率检查', '去除重复字过多的文档。', '1.0.0', 'text', 'text', null, '{"repeatWordRatio": {"name": "文档字重复率", "description": "某个字的统计数/文档总字数 > 设定值,该文档被去除。", "type": "slider", "defaultVal": 0.5, "min": 0, "max": 1, "step": 0.1}}', '', 'false'),
('FileWithHighSpecialCharRateFilter', '文档特殊字符率检查', '去除特殊字符过多的文档。', '1.0.0', 'text', 'text', null, '{"specialCharRatio": {"name": "文档特殊字符率", "description": "特殊字符的统计数/文档总字数 > 设定值,该文档被去除。", "type": "slider", "defaultVal": 0.3, "min": 0, "max": 1, "step": 0.1}}', '', 'false'), ('FileWithHighSpecialCharRateFilter', '文档特殊字符率检查', '去除特殊字符过多的文档。', '1.0.0', 'text', 'text', null, '{"specialCharRatio": {"name": "文档特殊字符率", "description": "特殊字符的统计数/文档总字数 > 设定值,该文档被去除。", "type": "slider", "defaultVal": 0.3, "min": 0, "max": 1, "step": 0.1}}', '', 'false'),
@@ -359,6 +360,14 @@ WHERE c.id IN ('de36b61c-9e8a-4422-8c31-d30585c7100f', '9eda9d5d-072b-499b-916c-
'image_remove_background_mapper', 'image_segment_mapper', 'image_tagging_mapper', 'image_remove_background_mapper', 'image_segment_mapper', 'image_tagging_mapper',
'imgdiff_difference_area_generator_mapper'); 'imgdiff_difference_area_generator_mapper');
INSERT IGNORE INTO t_operator_category_relation(category_id, operator_id)
SELECT c.id, o.id
FROM t_operator_category c
CROSS JOIN t_operator o
WHERE c.id IN ('de36b61c-9e8a-4422-8c31-d30585c7100f', '9eda9d5d-072b-499b-916c-797a0a8750e1',
'96a3b07a-3439-4557-a835-525faad60ca3', '431e7798-5426-4e1a-aae6-b9905a836b34')
AND o.id IN ('test_annotation_marker');
INSERT IGNORE INTO t_operator_category_relation(category_id, operator_id) INSERT IGNORE INTO t_operator_category_relation(category_id, operator_id)
SELECT c.id, o.id SELECT c.id, o.id
FROM t_operator_category c FROM t_operator_category c