You've already forked DataMate
feat(annotation): 添加标注任务自定义配置功能
- 新增 LabelStudioEmbed 组件用于嵌入式标注界面预览 - 在创建标注任务对话框中添加 XML 配置编辑器 - 支持从现有模板加载配置并进行自定义修改 - 实现标注界面实时预览功能 - 后端支持直接传递 label_config 覆盖模板配置 - 更新 CreateAnnotationTaskRequest 模型添加 labelConfig 字段
This commit is contained in:
120
frontend/src/components/business/LabelStudioEmbed.tsx
Normal file
120
frontend/src/components/business/LabelStudioEmbed.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { useEffect, useRef, useState, useMemo } from "react";
|
||||||
|
import { Spin, message } from "antd";
|
||||||
|
|
||||||
|
const LSF_IFRAME_SRC = "/lsf/lsf.html";
|
||||||
|
|
||||||
|
export interface LabelStudioEmbedProps {
|
||||||
|
config: string;
|
||||||
|
task?: any;
|
||||||
|
user?: any;
|
||||||
|
interfaces?: string[];
|
||||||
|
height?: string | number;
|
||||||
|
className?: string;
|
||||||
|
onSubmit?: (result: any) => void;
|
||||||
|
onUpdate?: (result: any) => void;
|
||||||
|
onError?: (error: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LabelStudioEmbed({
|
||||||
|
config,
|
||||||
|
task = { id: 1, data: { text: "Preview Text" } },
|
||||||
|
user = { id: "preview-user" },
|
||||||
|
interfaces = [
|
||||||
|
"panel",
|
||||||
|
"update",
|
||||||
|
"controls",
|
||||||
|
"side-column",
|
||||||
|
"annotations:tabs",
|
||||||
|
"annotations:menu",
|
||||||
|
"annotations:current",
|
||||||
|
"annotations:history",
|
||||||
|
],
|
||||||
|
height = "100%",
|
||||||
|
className = "",
|
||||||
|
onSubmit,
|
||||||
|
onUpdate,
|
||||||
|
onError,
|
||||||
|
}: LabelStudioEmbedProps) {
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||||
|
const [iframeReady, setIframeReady] = useState(false);
|
||||||
|
const [lsReady, setLsReady] = useState(false);
|
||||||
|
const origin = useMemo(() => window.location.origin, []);
|
||||||
|
|
||||||
|
const postToIframe = (type: string, payload?: any) => {
|
||||||
|
const win = iframeRef.current?.contentWindow;
|
||||||
|
if (!win) return;
|
||||||
|
win.postMessage({ type, payload }, origin);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIframeReady(false);
|
||||||
|
setLsReady(false);
|
||||||
|
}, [config]); // Reset when config changes
|
||||||
|
|
||||||
|
// Initialize LS when iframe is ready and config is available
|
||||||
|
useEffect(() => {
|
||||||
|
if (iframeReady && config) {
|
||||||
|
postToIframe("LS_INIT", {
|
||||||
|
labelConfig: config,
|
||||||
|
task,
|
||||||
|
user,
|
||||||
|
interfaces,
|
||||||
|
selectedAnnotationIndex: 0,
|
||||||
|
allowCreateEmptyAnnotation: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [iframeReady, config, task, user, interfaces]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (event: MessageEvent) => {
|
||||||
|
if (event.origin !== origin) return;
|
||||||
|
const msg = event.data || {};
|
||||||
|
if (!msg?.type) return;
|
||||||
|
|
||||||
|
if (msg.type === "LS_IFRAME_READY") {
|
||||||
|
setIframeReady(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === "LS_READY") {
|
||||||
|
setLsReady(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === "LS_EXPORT_RESULT" || msg.type === "LS_SUBMIT") {
|
||||||
|
if (onSubmit) onSubmit(msg.payload);
|
||||||
|
else if (onUpdate) onUpdate(msg.payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === "LS_UPDATE_ANNOTATION") {
|
||||||
|
if (onUpdate) onUpdate(msg.payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === "LS_ERROR") {
|
||||||
|
if (onError) onError(msg.payload);
|
||||||
|
else message.error(msg.payload?.message || "编辑器发生错误");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("message", handler);
|
||||||
|
return () => window.removeEventListener("message", handler);
|
||||||
|
}, [origin, onSubmit, onUpdate, onError]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative ${className}`} style={{ height }}>
|
||||||
|
{!lsReady && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/70">
|
||||||
|
<Spin tip={!iframeReady ? "加载资源..." : "初始化编辑器..."} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
title="Label Studio Frontend"
|
||||||
|
src={LSF_IFRAME_SRC}
|
||||||
|
className="w-full h-full border-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { queryDatasetsUsingGet } from "@/pages/DataManagement/dataset.api";
|
import { queryDatasetsUsingGet } from "@/pages/DataManagement/dataset.api";
|
||||||
import { mapDataset } from "@/pages/DataManagement/dataset.const";
|
import { mapDataset } from "@/pages/DataManagement/dataset.const";
|
||||||
import { Button, Form, Input, Modal, Select, message, Tabs, Slider, Checkbox } from "antd";
|
import { Button, Form, Input, Modal, Select, message, Tabs, Slider, Checkbox, Radio, Space, Typography } from "antd";
|
||||||
import TextArea from "antd/es/input/TextArea";
|
import TextArea from "antd/es/input/TextArea";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -11,10 +11,12 @@ import {
|
|||||||
import DatasetFileTransfer from "@/components/business/DatasetFileTransfer";
|
import DatasetFileTransfer from "@/components/business/DatasetFileTransfer";
|
||||||
import { DatasetType, type Dataset, type DatasetFile } from "@/pages/DataManagement/dataset.model";
|
import { DatasetType, type Dataset, type DatasetFile } from "@/pages/DataManagement/dataset.model";
|
||||||
import type { AnnotationTemplate } from "../../annotation.model";
|
import type { AnnotationTemplate } from "../../annotation.model";
|
||||||
|
import LabelStudioEmbed from "@/components/business/LabelStudioEmbed";
|
||||||
|
|
||||||
const { Option } = Select;
|
const { Option } = Select;
|
||||||
|
|
||||||
const COCO_CLASSES = [
|
const COCO_CLASSES = [
|
||||||
|
// ... (keep existing COCO_CLASSES)
|
||||||
{ id: 0, name: "person", label: "人" },
|
{ id: 0, name: "person", label: "人" },
|
||||||
{ id: 1, name: "bicycle", label: "自行车" },
|
{ id: 1, name: "bicycle", label: "自行车" },
|
||||||
{ id: 2, name: "car", label: "汽车" },
|
{ id: 2, name: "car", label: "汽车" },
|
||||||
@@ -114,6 +116,10 @@ export default function CreateAnnotationTask({
|
|||||||
const [nameManuallyEdited, setNameManuallyEdited] = useState(false);
|
const [nameManuallyEdited, setNameManuallyEdited] = useState(false);
|
||||||
const [activeMode, setActiveMode] = useState<"manual" | "auto">("manual");
|
const [activeMode, setActiveMode] = useState<"manual" | "auto">("manual");
|
||||||
|
|
||||||
|
// Custom template state
|
||||||
|
const [customXml, setCustomXml] = useState("");
|
||||||
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
|
|
||||||
const [selectAllClasses, setSelectAllClasses] = useState(true);
|
const [selectAllClasses, setSelectAllClasses] = useState(true);
|
||||||
const [selectedFilesMap, setSelectedFilesMap] = useState<Record<string, DatasetFile>>({});
|
const [selectedFilesMap, setSelectedFilesMap] = useState<Record<string, DatasetFile>>({});
|
||||||
const [selectedDataset, setSelectedDataset] = useState<Dataset | null>(null);
|
const [selectedDataset, setSelectedDataset] = useState<Dataset | null>(null);
|
||||||
@@ -126,17 +132,16 @@ export default function CreateAnnotationTask({
|
|||||||
// Fetch datasets
|
// Fetch datasets
|
||||||
const { data: datasetData } = await queryDatasetsUsingGet({
|
const { data: datasetData } = await queryDatasetsUsingGet({
|
||||||
page: 0,
|
page: 0,
|
||||||
pageSize: 1000, // Use camelCase for HTTP params
|
pageSize: 1000,
|
||||||
});
|
});
|
||||||
setDatasets(datasetData.content.map(mapDataset) || []);
|
setDatasets(datasetData.content.map(mapDataset) || []);
|
||||||
|
|
||||||
// Fetch templates
|
// Fetch templates
|
||||||
const templateResponse = await queryAnnotationTemplatesUsingGet({
|
const templateResponse = await queryAnnotationTemplatesUsingGet({
|
||||||
page: 1,
|
page: 1,
|
||||||
size: 100, // Backend max is 100 (template API uses 'size' not 'pageSize')
|
size: 100,
|
||||||
});
|
});
|
||||||
|
|
||||||
// The API returns: {code, message, data: {content, total, page, ...}}
|
|
||||||
if (templateResponse.code === 200 && templateResponse.data) {
|
if (templateResponse.code === 200 && templateResponse.data) {
|
||||||
const fetchedTemplates = templateResponse.data.content || [];
|
const fetchedTemplates = templateResponse.data.content || [];
|
||||||
console.log("Fetched templates:", fetchedTemplates);
|
console.log("Fetched templates:", fetchedTemplates);
|
||||||
@@ -164,6 +169,8 @@ export default function CreateAnnotationTask({
|
|||||||
setSelectedFilesMap({});
|
setSelectedFilesMap({});
|
||||||
setSelectedDataset(null);
|
setSelectedDataset(null);
|
||||||
setImageFileCount(0);
|
setImageFileCount(0);
|
||||||
|
setCustomXml("");
|
||||||
|
setShowPreview(false);
|
||||||
}
|
}
|
||||||
}, [open, manualForm, autoForm]);
|
}, [open, manualForm, autoForm]);
|
||||||
|
|
||||||
@@ -179,13 +186,19 @@ export default function CreateAnnotationTask({
|
|||||||
const handleManualSubmit = async () => {
|
const handleManualSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
const values = await manualForm.validateFields();
|
const values = await manualForm.validateFields();
|
||||||
|
|
||||||
|
if (!customXml.trim()) {
|
||||||
|
message.error("请配置标注模板或选择一个现有模板");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
// Send templateId instead of labelingConfig
|
|
||||||
const requestData = {
|
const requestData = {
|
||||||
name: values.name,
|
name: values.name,
|
||||||
description: values.description,
|
description: values.description,
|
||||||
datasetId: values.datasetId,
|
datasetId: values.datasetId,
|
||||||
templateId: values.templateId,
|
templateId: values.templateId, // Can be null/undefined if user just typed XML
|
||||||
|
labelConfig: customXml, // Pass the custom XML
|
||||||
};
|
};
|
||||||
await createAnnotationTaskUsingPost(requestData);
|
await createAnnotationTaskUsingPost(requestData);
|
||||||
message?.success?.("创建标注任务成功");
|
message?.success?.("创建标注任务成功");
|
||||||
@@ -201,6 +214,7 @@ export default function CreateAnnotationTask({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAutoSubmit = async () => {
|
const handleAutoSubmit = async () => {
|
||||||
|
// ... (keep existing handleAutoSubmit)
|
||||||
try {
|
try {
|
||||||
const values = await autoForm.validateFields();
|
const values = await autoForm.validateFields();
|
||||||
|
|
||||||
@@ -253,237 +267,303 @@ export default function CreateAnnotationTask({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<>
|
||||||
open={open}
|
<Modal
|
||||||
onCancel={onClose}
|
open={open}
|
||||||
title="创建标注任务"
|
onCancel={onClose}
|
||||||
footer={
|
title="创建标注任务"
|
||||||
<>
|
footer={
|
||||||
<Button onClick={onClose} disabled={submitting}>
|
<>
|
||||||
取消
|
<Button onClick={onClose} disabled={submitting}>
|
||||||
</Button>
|
取消
|
||||||
<Button
|
</Button>
|
||||||
type="primary"
|
<Button
|
||||||
onClick={activeMode === "manual" ? handleManualSubmit : handleAutoSubmit}
|
type="primary"
|
||||||
loading={submitting}
|
onClick={activeMode === "manual" ? handleManualSubmit : handleAutoSubmit}
|
||||||
>
|
loading={submitting}
|
||||||
确定
|
>
|
||||||
</Button>
|
确定
|
||||||
</>
|
</Button>
|
||||||
}
|
</>
|
||||||
width={800}
|
}
|
||||||
>
|
width={800}
|
||||||
<Tabs
|
>
|
||||||
activeKey={activeMode}
|
<Tabs
|
||||||
onChange={(key) => setActiveMode(key as "manual" | "auto")}
|
activeKey={activeMode}
|
||||||
items={[
|
onChange={(key) => setActiveMode(key as "manual" | "auto")}
|
||||||
{
|
items={[
|
||||||
key: "manual",
|
{
|
||||||
label: "手动标注",
|
key: "manual",
|
||||||
children: (
|
label: "手动标注",
|
||||||
<Form form={manualForm} layout="vertical">
|
children: (
|
||||||
{/* 数据集 与 标注工程名称 并排显示(数据集在左) */}
|
<Form form={manualForm} layout="vertical">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
{/* 数据集 与 标注工程名称 并排显示(数据集在左) */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Form.Item
|
||||||
|
label="数据集"
|
||||||
|
name="datasetId"
|
||||||
|
rules={[{ required: true, message: "请选择数据集" }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder="请选择数据集"
|
||||||
|
options={datasets.map((dataset) => {
|
||||||
|
return {
|
||||||
|
label: (
|
||||||
|
<div className="flex items-center justify-between gap-3 py-2">
|
||||||
|
<div className="flex items-center font-sm text-gray-900">
|
||||||
|
<span className="mr-2">{(dataset as any).icon}</span>
|
||||||
|
<span>{dataset.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">{dataset.size}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
value: dataset.id,
|
||||||
|
};
|
||||||
|
})}
|
||||||
|
onChange={(value) => {
|
||||||
|
// 如果用户未手动修改名称,则用数据集名称作为默认任务名
|
||||||
|
if (!nameManuallyEdited) {
|
||||||
|
const ds = datasets.find((d) => d.id === value);
|
||||||
|
if (ds) {
|
||||||
|
let defaultName = ds.name || "";
|
||||||
|
if (defaultName.length < 3) {
|
||||||
|
defaultName = `${defaultName}-标注`;
|
||||||
|
}
|
||||||
|
manualForm.setFieldsValue({ name: defaultName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="标注工程名称"
|
||||||
|
name="name"
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
validator: (_rule, value) => {
|
||||||
|
const trimmed = (value || "").trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return Promise.reject(new Error("请输入任务名称"));
|
||||||
|
}
|
||||||
|
if (trimmed.length < 3) {
|
||||||
|
return Promise.reject(
|
||||||
|
new Error("任务名称至少需要 3 个字符(不含首尾空格,Label Studio 限制)"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="输入标注工程名称"
|
||||||
|
onChange={() => setNameManuallyEdited(true)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
{/* 描述变为可选 */}
|
||||||
|
<Form.Item label="描述" name="description">
|
||||||
|
<TextArea placeholder="(可选)详细描述标注任务的要求和目标" rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* 标注模板选择 */}
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm font-medium text-gray-700 after:content-['*'] after:text-red-500 after:ml-1">标注配置</span>
|
||||||
|
<Button type="link" size="small" onClick={() => setShowPreview(true)} disabled={!customXml}>
|
||||||
|
预览配置效果
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-50 p-4 rounded-md border border-gray-200">
|
||||||
|
<Form.Item
|
||||||
|
label="加载现有模板 (可选)"
|
||||||
|
name="templateId"
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
help="选择模板后,配置代码将自动填充到下方编辑器中,您可以继续修改。"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder="选择一个模板作为基础(可选)"
|
||||||
|
showSearch
|
||||||
|
allowClear
|
||||||
|
optionFilterProp="label"
|
||||||
|
options={templates.map((template) => ({
|
||||||
|
label: template.name,
|
||||||
|
value: template.id,
|
||||||
|
title: template.description,
|
||||||
|
config: template.labelConfig,
|
||||||
|
}))}
|
||||||
|
onChange={(value, option: any) => {
|
||||||
|
if (option && option.config) {
|
||||||
|
setCustomXml(option.config);
|
||||||
|
} else if (!value) {
|
||||||
|
// If cleared, maybe clear XML? Or keep it?
|
||||||
|
// User might clear selection to say "I am customizing now".
|
||||||
|
// Let's keep it to be safe, or user can clear manually.
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
optionRender={(option) => (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500 }}>{option.label}</div>
|
||||||
|
{option.data.title && (
|
||||||
|
<div style={{ fontSize: 12, color: '#999', marginTop: 2 }}>
|
||||||
|
{option.data.title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="XML 配置编辑器"
|
||||||
|
required
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
rows={8}
|
||||||
|
value={customXml}
|
||||||
|
onChange={(e) => setCustomXml(e.target.value)}
|
||||||
|
placeholder="<View>...</View>"
|
||||||
|
style={{ fontFamily: 'monospace', fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "auto",
|
||||||
|
label: "自动标注",
|
||||||
|
children: (
|
||||||
|
<Form form={autoForm} layout="vertical" preserve={false}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="数据集"
|
name="name"
|
||||||
|
label="任务名称"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: "请输入任务名称" },
|
||||||
|
{ max: 100, message: "任务名称不能超过100个字符" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入任务名称" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="选择数据集和图像文件" required>
|
||||||
|
<DatasetFileTransfer
|
||||||
|
open
|
||||||
|
selectedFilesMap={selectedFilesMap}
|
||||||
|
onSelectedFilesChange={setSelectedFilesMap}
|
||||||
|
onDatasetSelect={(dataset) => {
|
||||||
|
setSelectedDataset(dataset as Dataset | null);
|
||||||
|
autoForm.setFieldsValue({ datasetId: dataset?.id ?? "" });
|
||||||
|
}}
|
||||||
|
datasetTypeFilter={DatasetType.IMAGE}
|
||||||
|
/>
|
||||||
|
{selectedDataset && (
|
||||||
|
<div className="mt-2 p-2 bg-blue-50 rounded border border-blue-200 text-xs">
|
||||||
|
当前数据集:<span className="font-medium">{selectedDataset.name}</span> - 已选择
|
||||||
|
<span className="font-medium text-blue-600"> {imageFileCount} </span>个图像文件
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
hidden
|
||||||
name="datasetId"
|
name="datasetId"
|
||||||
rules={[{ required: true, message: "请选择数据集" }]}
|
rules={[{ required: true, message: "请选择数据集" }]}
|
||||||
>
|
>
|
||||||
<Select
|
<Input type="hidden" />
|
||||||
placeholder="请选择数据集"
|
|
||||||
options={datasets.map((dataset) => {
|
|
||||||
return {
|
|
||||||
label: (
|
|
||||||
<div className="flex items-center justify-between gap-3 py-2">
|
|
||||||
<div className="flex items-center font-sm text-gray-900">
|
|
||||||
<span className="mr-2">{(dataset as any).icon}</span>
|
|
||||||
<span>{dataset.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500">{dataset.size}</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
value: dataset.id,
|
|
||||||
};
|
|
||||||
})}
|
|
||||||
onChange={(value) => {
|
|
||||||
// 如果用户未手动修改名称,则用数据集名称作为默认任务名
|
|
||||||
if (!nameManuallyEdited) {
|
|
||||||
const ds = datasets.find((d) => d.id === value);
|
|
||||||
if (ds) {
|
|
||||||
let defaultName = ds.name || "";
|
|
||||||
if (defaultName.length < 3) {
|
|
||||||
defaultName = `${defaultName}-标注`;
|
|
||||||
}
|
|
||||||
manualForm.setFieldsValue({ name: defaultName });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="标注工程名称"
|
name="modelSize"
|
||||||
name="name"
|
label="模型规模"
|
||||||
rules={[
|
rules={[{ required: true, message: "请选择模型规模" }]}
|
||||||
{
|
initialValue="l"
|
||||||
validator: (_rule, value) => {
|
|
||||||
const trimmed = (value || "").trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
return Promise.reject(new Error("请输入任务名称"));
|
|
||||||
}
|
|
||||||
if (trimmed.length < 3) {
|
|
||||||
return Promise.reject(
|
|
||||||
new Error("任务名称至少需要 3 个字符(不含首尾空格,Label Studio 限制)"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Promise.resolve();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
<Input
|
<Select>
|
||||||
placeholder="输入标注工程名称"
|
<Option value="n">YOLOv8n (最快)</Option>
|
||||||
onChange={() => setNameManuallyEdited(true)}
|
<Option value="s">YOLOv8s</Option>
|
||||||
|
<Option value="m">YOLOv8m</Option>
|
||||||
|
<Option value="l">YOLOv8l (推荐)</Option>
|
||||||
|
<Option value="x">YOLOv8x (最精确)</Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="confThreshold"
|
||||||
|
label="置信度阈值"
|
||||||
|
rules={[{ required: true, message: "请选择置信度阈值" }]}
|
||||||
|
initialValue={0.7}
|
||||||
|
>
|
||||||
|
<Slider
|
||||||
|
min={0.1}
|
||||||
|
max={0.9}
|
||||||
|
step={0.05}
|
||||||
|
tooltip={{ formatter: (v) => `${(v || 0) * 100}%` }}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</div>
|
|
||||||
{/* 描述变为可选 */}
|
|
||||||
<Form.Item label="描述" name="description">
|
|
||||||
<TextArea placeholder="(可选)详细描述标注任务的要求和目标" rows={3} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
{/* 标注模板选择 */}
|
<Form.Item label="目标类别">
|
||||||
<Form.Item
|
<Checkbox
|
||||||
label="标注模板"
|
checked={selectAllClasses}
|
||||||
name="templateId"
|
onChange={(e) => handleClassSelectionChange(e.target.checked)}
|
||||||
rules={[{ required: true, message: "请选择标注模板" }]}
|
>
|
||||||
>
|
选中所有类别
|
||||||
<Select
|
</Checkbox>
|
||||||
placeholder={templates.length === 0 ? "暂无可用模板,请先创建模板" : "请选择标注模板"}
|
{!selectAllClasses && (
|
||||||
showSearch
|
<Form.Item name="targetClasses" noStyle>
|
||||||
optionFilterProp="label"
|
<Select mode="multiple" placeholder="选择目标类别" style={{ marginTop: 8 }}>
|
||||||
notFoundContent={templates.length === 0 ? "暂无模板,请前往「标注模板」页面创建" : "未找到匹配的模板"}
|
{COCO_CLASSES.map((cls) => (
|
||||||
options={templates.map((template) => ({
|
<Option key={cls.id} value={cls.id}>
|
||||||
label: template.name,
|
{cls.label} ({cls.name})
|
||||||
value: template.id,
|
</Option>
|
||||||
// Add description as subtitle
|
))}
|
||||||
title: template.description,
|
</Select>
|
||||||
}))}
|
</Form.Item>
|
||||||
optionRender={(option) => (
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 500 }}>{option.label}</div>
|
|
||||||
{option.data.title && (
|
|
||||||
<div style={{ fontSize: 12, color: '#999', marginTop: 2 }}>
|
|
||||||
{option.data.title}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
/>
|
</Form.Item>
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "auto",
|
|
||||||
label: "自动标注",
|
|
||||||
children: (
|
|
||||||
<Form form={autoForm} layout="vertical" preserve={false}>
|
|
||||||
<Form.Item
|
|
||||||
name="name"
|
|
||||||
label="任务名称"
|
|
||||||
rules={[
|
|
||||||
{ required: true, message: "请输入任务名称" },
|
|
||||||
{ max: 100, message: "任务名称不能超过100个字符" },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Input placeholder="请输入任务名称" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item label="选择数据集和图像文件" required>
|
<Form.Item name="outputDatasetName" label="输出数据集名称 (可选)">
|
||||||
<DatasetFileTransfer
|
<Input placeholder="留空则将结果写入原数据集的标签中" />
|
||||||
open
|
</Form.Item>
|
||||||
selectedFilesMap={selectedFilesMap}
|
</Form>
|
||||||
onSelectedFilesChange={setSelectedFilesMap}
|
),
|
||||||
onDatasetSelect={(dataset) => {
|
},
|
||||||
setSelectedDataset(dataset as Dataset | null);
|
]}
|
||||||
autoForm.setFieldsValue({ datasetId: dataset?.id ?? "" });
|
/>
|
||||||
}}
|
</Modal>
|
||||||
datasetTypeFilter={DatasetType.IMAGE}
|
|
||||||
/>
|
|
||||||
{selectedDataset && (
|
|
||||||
<div className="mt-2 p-2 bg-blue-50 rounded border border-blue-200 text-xs">
|
|
||||||
当前数据集:<span className="font-medium">{selectedDataset.name}</span> - 已选择
|
|
||||||
<span className="font-medium text-blue-600"> {imageFileCount} </span>个图像文件
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
{/* Preview Modal */}
|
||||||
hidden
|
<Modal
|
||||||
name="datasetId"
|
open={showPreview}
|
||||||
rules={[{ required: true, message: "请选择数据集" }]}
|
onCancel={() => setShowPreview(false)}
|
||||||
>
|
title="标注界面预览"
|
||||||
<Input type="hidden" />
|
width={1000}
|
||||||
</Form.Item>
|
footer={[
|
||||||
|
<Button key="close" onClick={() => setShowPreview(false)}>
|
||||||
<Form.Item
|
关闭
|
||||||
name="modelSize"
|
</Button>
|
||||||
label="模型规模"
|
|
||||||
rules={[{ required: true, message: "请选择模型规模" }]}
|
|
||||||
initialValue="l"
|
|
||||||
>
|
|
||||||
<Select>
|
|
||||||
<Option value="n">YOLOv8n (最快)</Option>
|
|
||||||
<Option value="s">YOLOv8s</Option>
|
|
||||||
<Option value="m">YOLOv8m</Option>
|
|
||||||
<Option value="l">YOLOv8l (推荐)</Option>
|
|
||||||
<Option value="x">YOLOv8x (最精确)</Option>
|
|
||||||
</Select>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="confThreshold"
|
|
||||||
label="置信度阈值"
|
|
||||||
rules={[{ required: true, message: "请选择置信度阈值" }]}
|
|
||||||
initialValue={0.7}
|
|
||||||
>
|
|
||||||
<Slider
|
|
||||||
min={0.1}
|
|
||||||
max={0.9}
|
|
||||||
step={0.05}
|
|
||||||
tooltip={{ formatter: (v) => `${(v || 0) * 100}%` }}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item label="目标类别">
|
|
||||||
<Checkbox
|
|
||||||
checked={selectAllClasses}
|
|
||||||
onChange={(e) => handleClassSelectionChange(e.target.checked)}
|
|
||||||
>
|
|
||||||
选中所有类别
|
|
||||||
</Checkbox>
|
|
||||||
{!selectAllClasses && (
|
|
||||||
<Form.Item name="targetClasses" noStyle>
|
|
||||||
<Select mode="multiple" placeholder="选择目标类别" style={{ marginTop: 8 }}>
|
|
||||||
{COCO_CLASSES.map((cls) => (
|
|
||||||
<Option key={cls.id} value={cls.id}>
|
|
||||||
{cls.label} ({cls.name})
|
|
||||||
</Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</Form.Item>
|
|
||||||
)}
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="outputDatasetName" label="输出数据集名称 (可选)">
|
|
||||||
<Input placeholder="留空则将结果写入原数据集的标签中" />
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
/>
|
>
|
||||||
</Modal>
|
<div style={{ height: '600px', overflow: 'hidden' }}>
|
||||||
|
{showPreview && (
|
||||||
|
<LabelStudioEmbed
|
||||||
|
config={customXml}
|
||||||
|
task={{
|
||||||
|
id: 1,
|
||||||
|
data: {
|
||||||
|
image: "https://labelstud.io/images/opa-header.png",
|
||||||
|
text: "这是示例文本,用于预览标注界面。",
|
||||||
|
audio: "https://labelstud.io/files/sample.wav"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,11 @@ async def create_mapping(
|
|||||||
label_config = template.label_config
|
label_config = template.label_config
|
||||||
logger.debug(f"Template label config loaded for template: {template.name}")
|
logger.debug(f"Template label config loaded for template: {template.name}")
|
||||||
|
|
||||||
|
# 如果直接提供了 label_config (自定义或修改后的),则覆盖模板配置
|
||||||
|
if request.label_config:
|
||||||
|
label_config = request.label_config
|
||||||
|
logger.debug("Using custom label config from request")
|
||||||
|
|
||||||
# DataMate-only:不再创建/依赖 Label Studio Server 项目。
|
# DataMate-only:不再创建/依赖 Label Studio Server 项目。
|
||||||
# 为兼容既有 schema 字段(labeling_project_id 长度 8),生成一个 8 位数字 ID。
|
# 为兼容既有 schema 字段(labeling_project_id 长度 8),生成一个 8 位数字 ID。
|
||||||
labeling_project_id = str(uuid.uuid4().int % 10**8).zfill(8)
|
labeling_project_id = str(uuid.uuid4().int % 10**8).zfill(8)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class DatasetMappingCreateRequest(BaseModel):
|
|||||||
name: Optional[str] = Field(None, alias="name", description="标注项目名称")
|
name: Optional[str] = Field(None, alias="name", description="标注项目名称")
|
||||||
description: Optional[str] = Field(None, alias="description", description="标注项目描述")
|
description: Optional[str] = Field(None, alias="description", description="标注项目描述")
|
||||||
template_id: Optional[str] = Field(None, alias="templateId", description="标注模板ID")
|
template_id: Optional[str] = Field(None, alias="templateId", description="标注模板ID")
|
||||||
|
label_config: Optional[str] = Field(None, alias="labelConfig", description="Label Studio XML配置")
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
# allow population by field name when constructing model programmatically
|
# allow population by field name when constructing model programmatically
|
||||||
|
|||||||
Reference in New Issue
Block a user