You've already forked DataMate
feat: Add labeling template (#72)
* feat: Enhance annotation module with template management and validation - Added DatasetMappingCreateRequest and DatasetMappingUpdateRequest schemas to handle dataset mapping requests with camelCase and snake_case support. - Introduced Annotation Template schemas including CreateAnnotationTemplateRequest, UpdateAnnotationTemplateRequest, and AnnotationTemplateResponse for managing annotation templates. - Implemented AnnotationTemplateService for creating, updating, retrieving, and deleting annotation templates, including validation of configurations and XML generation. - Added utility class LabelStudioConfigValidator for validating Label Studio configurations and XML formats. - Updated database schema for annotation templates and labeling projects to include new fields and constraints. - Seeded initial annotation templates for various use cases including image classification, object detection, and text classification. * feat: Enhance TemplateForm with improved validation and dynamic field rendering; update LabelStudio config validation for camelCase support * feat: Update docker-compose.yml to mark datamate dataset volume and network as external
This commit is contained in:
@@ -52,8 +52,10 @@ volumes:
|
|||||||
label-studio-db:
|
label-studio-db:
|
||||||
dataset_volume:
|
dataset_volume:
|
||||||
name: datamate-dataset-volume
|
name: datamate-dataset-volume
|
||||||
|
external: true
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
datamate:
|
datamate:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
name: datamate-network
|
name: datamate-network
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { queryDatasetsUsingGet } from "@/pages/DataManagement/dataset.api";
|
||||||
|
import { mapDataset } from "@/pages/DataManagement/dataset.const";
|
||||||
|
import { Button, Form, Input, Modal, Select, message } from "antd";
|
||||||
|
import TextArea from "antd/es/input/TextArea";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { createAnnotationTaskUsingPost, queryAnnotationTemplatesUsingGet } from "../../annotation.api";
|
||||||
|
import { Dataset } from "@/pages/DataManagement/dataset.model";
|
||||||
|
import type { AnnotationTemplate } from "../../annotation.model";
|
||||||
|
|
||||||
|
export default function CreateAnnotationTask({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onRefresh,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}) {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [datasets, setDatasets] = useState<Dataset[]>([]);
|
||||||
|
const [templates, setTemplates] = useState<AnnotationTemplate[]>([]);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [nameManuallyEdited, setNameManuallyEdited] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
// Fetch datasets
|
||||||
|
const { data: datasetData } = await queryDatasetsUsingGet({
|
||||||
|
page: 0,
|
||||||
|
size: 1000,
|
||||||
|
});
|
||||||
|
setDatasets(datasetData.content.map(mapDataset) || []);
|
||||||
|
|
||||||
|
// Fetch templates
|
||||||
|
const templateResponse = await queryAnnotationTemplatesUsingGet({
|
||||||
|
page: 1,
|
||||||
|
size: 100, // Backend max is 100
|
||||||
|
});
|
||||||
|
|
||||||
|
// The API returns: {code, message, data: {content, total, page, ...}}
|
||||||
|
if (templateResponse.code === 200 && templateResponse.data) {
|
||||||
|
const fetchedTemplates = templateResponse.data.content || [];
|
||||||
|
console.log("Fetched templates:", fetchedTemplates);
|
||||||
|
setTemplates(fetchedTemplates);
|
||||||
|
} else {
|
||||||
|
console.error("Failed to fetch templates:", templateResponse);
|
||||||
|
setTemplates([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching data:", error);
|
||||||
|
setTemplates([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchData();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Reset form and manual-edit flag when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
form.resetFields();
|
||||||
|
setNameManuallyEdited(false);
|
||||||
|
}
|
||||||
|
}, [open, form]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
setSubmitting(true);
|
||||||
|
|
||||||
|
// Send templateId instead of labelingConfig
|
||||||
|
const requestData = {
|
||||||
|
name: values.name,
|
||||||
|
description: values.description,
|
||||||
|
datasetId: values.datasetId,
|
||||||
|
templateId: values.templateId,
|
||||||
|
};
|
||||||
|
|
||||||
|
await createAnnotationTaskUsingPost(requestData);
|
||||||
|
message?.success?.("创建标注任务成功");
|
||||||
|
onClose();
|
||||||
|
onRefresh();
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Create annotation task failed", err);
|
||||||
|
const msg = err?.message || err?.data?.message || "创建失败,请稍后重试";
|
||||||
|
(message as any)?.error?.(msg);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onCancel={onClose}
|
||||||
|
title="创建标注任务"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button onClick={onClose} disabled={submitting}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" onClick={handleSubmit} loading={submitting}>
|
||||||
|
确定
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
width={800}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
{/* 数据集 与 标注工程名称 并排显示(数据集在左) */}
|
||||||
|
<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) {
|
||||||
|
form.setFieldsValue({ name: ds.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="标注工程名称"
|
||||||
|
name="name"
|
||||||
|
rules={[{ required: true, message: "请输入任务名称" }]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="输入标注工程名称"
|
||||||
|
onChange={() => setNameManuallyEdited(true)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 描述变为可选 */}
|
||||||
|
<Form.Item label="描述" name="description">
|
||||||
|
<TextArea placeholder="(可选)详细描述标注任务的要求和目标" rows={3} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* 标注模板选择 */}
|
||||||
|
<Form.Item
|
||||||
|
label="标注模板"
|
||||||
|
name="templateId"
|
||||||
|
rules={[{ required: true, message: "请选择标注模板" }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder={templates.length === 0 ? "暂无可用模板,请先创建模板" : "请选择标注模板"}
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
notFoundContent={templates.length === 0 ? "暂无模板,请前往「标注模板」页面创建" : "未找到匹配的模板"}
|
||||||
|
options={templates.map((template) => ({
|
||||||
|
label: template.name,
|
||||||
|
value: template.id,
|
||||||
|
// Add description as subtitle
|
||||||
|
title: template.description,
|
||||||
|
}))}
|
||||||
|
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>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,10 +3,9 @@ import { mapDataset } from "@/pages/DataManagement/dataset.const";
|
|||||||
import { Button, Form, Input, Modal, Select, message } from "antd";
|
import { Button, Form, Input, Modal, Select, message } 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 { createAnnotationTaskUsingPost } from "../../annotation.api";
|
import { createAnnotationTaskUsingPost, queryAnnotationTemplatesUsingGet } from "../../annotation.api";
|
||||||
import { Dataset } from "@/pages/DataManagement/dataset.model";
|
import { Dataset } from "@/pages/DataManagement/dataset.model";
|
||||||
import LabelingConfigEditor from "./LabelingConfigEditor";
|
import type { AnnotationTemplate } from "../../annotation.model";
|
||||||
import { useRef } from "react";
|
|
||||||
|
|
||||||
export default function CreateAnnotationTask({
|
export default function CreateAnnotationTask({
|
||||||
open,
|
open,
|
||||||
@@ -19,21 +18,42 @@ export default function CreateAnnotationTask({
|
|||||||
}) {
|
}) {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [datasets, setDatasets] = useState<Dataset[]>([]);
|
const [datasets, setDatasets] = useState<Dataset[]>([]);
|
||||||
|
const [templates, setTemplates] = useState<AnnotationTemplate[]>([]);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [nameManuallyEdited, setNameManuallyEdited] = useState(false);
|
const [nameManuallyEdited, setNameManuallyEdited] = useState(false);
|
||||||
const editorRef = useRef<any>(null);
|
|
||||||
const EDITOR_LIST_HEIGHT = 420;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const fetchDatasets = async () => {
|
const fetchData = async () => {
|
||||||
const { data } = await queryDatasetsUsingGet({
|
try {
|
||||||
|
// Fetch datasets
|
||||||
|
const { data: datasetData } = await queryDatasetsUsingGet({
|
||||||
page: 0,
|
page: 0,
|
||||||
size: 1000,
|
size: 1000,
|
||||||
});
|
});
|
||||||
setDatasets(data.content.map(mapDataset) || []);
|
setDatasets(datasetData.content.map(mapDataset) || []);
|
||||||
|
|
||||||
|
// Fetch templates
|
||||||
|
const templateResponse = await queryAnnotationTemplatesUsingGet({
|
||||||
|
page: 1,
|
||||||
|
size: 100, // Backend max is 100
|
||||||
|
});
|
||||||
|
|
||||||
|
// The API returns: {code, message, data: {content, total, page, ...}}
|
||||||
|
if (templateResponse.code === 200 && templateResponse.data) {
|
||||||
|
const fetchedTemplates = templateResponse.data.content || [];
|
||||||
|
console.log("Fetched templates:", fetchedTemplates);
|
||||||
|
setTemplates(fetchedTemplates);
|
||||||
|
} else {
|
||||||
|
console.error("Failed to fetch templates:", templateResponse);
|
||||||
|
setTemplates([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching data:", error);
|
||||||
|
setTemplates([]);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
fetchDatasets();
|
fetchData();
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
// Reset form and manual-edit flag when modal opens
|
// Reset form and manual-edit flag when modal opens
|
||||||
@@ -48,26 +68,28 @@ export default function CreateAnnotationTask({
|
|||||||
try {
|
try {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
await createAnnotationTaskUsingPost(values);
|
|
||||||
|
// Send templateId instead of labelingConfig
|
||||||
|
const requestData = {
|
||||||
|
name: values.name,
|
||||||
|
description: values.description,
|
||||||
|
datasetId: values.datasetId,
|
||||||
|
templateId: values.templateId,
|
||||||
|
};
|
||||||
|
|
||||||
|
await createAnnotationTaskUsingPost(requestData);
|
||||||
message?.success?.("创建标注任务成功");
|
message?.success?.("创建标注任务成功");
|
||||||
onClose();
|
onClose();
|
||||||
onRefresh();
|
onRefresh();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Create annotation task failed", err);
|
console.error("Create annotation task failed", err);
|
||||||
const msg = err?.message || err?.data?.message || "创建失败,请稍后重试";
|
const msg = err?.message || err?.data?.message || "创建失败,请稍后重试";
|
||||||
// show a user friendly message
|
|
||||||
(message as any)?.error?.(msg);
|
(message as any)?.error?.(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Placeholder function: generates labeling interface from config
|
|
||||||
// For now it simply returns the parsed config (per requirement)
|
|
||||||
const generateLabelingInterface = (config: any) => {
|
|
||||||
return config;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
@@ -83,7 +105,7 @@ export default function CreateAnnotationTask({
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
width={1200}
|
width={800}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical">
|
||||||
{/* 数据集 与 标注工程名称 并排显示(数据集在左) */}
|
{/* 数据集 与 标注工程名称 并排显示(数据集在左) */}
|
||||||
@@ -132,67 +154,41 @@ export default function CreateAnnotationTask({
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 描述变为可选 */}
|
{/* 描述变为可选 */}
|
||||||
<Form.Item label="描述" name="description">
|
<Form.Item label="描述" name="description">
|
||||||
<TextArea placeholder="(可选)详细描述标注任务的要求和目标" rows={3} />
|
<TextArea placeholder="(可选)详细描述标注任务的要求和目标" rows={3} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
{/* 标注页面设计 模块:左侧为配置编辑,右侧为预览(作为表单的一部分,与其他字段同级) */}
|
{/* 标注模板选择 */}
|
||||||
<div style={{ marginTop: 8 }}>
|
|
||||||
<label className="block font-medium mb-2">标注页面设计</label>
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "minmax(360px, 1fr) 1fr", gridTemplateRows: "auto 1fr", gap: 16 }}>
|
|
||||||
{/* Row 1: buttons on the left, spacer on the right so preview aligns with editor below */}
|
|
||||||
<div style={{ gridColumn: 1, gridRow: 1, display: 'flex', gap: 8 }}>
|
|
||||||
<Button onClick={() => editorRef.current?.addLabel?.()}>添加标签</Button>
|
|
||||||
<Button type="primary" onClick={() => editorRef.current?.generate?.()}>生成标注页面配置</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* empty spacer to occupy top-right cell so preview starts on the second row */}
|
|
||||||
<div style={{ gridColumn: 2, gridRow: 1 }} />
|
|
||||||
|
|
||||||
{/* Row 2, Col 1: 编辑列表(固定高度) */}
|
|
||||||
<div style={{ gridColumn: 1, gridRow: 2, height: EDITOR_LIST_HEIGHT, overflowY: 'auto', paddingRight: 8, border: '1px solid #e6e6e6', borderRadius: 6, padding: 12 }}>
|
|
||||||
<LabelingConfigEditor
|
|
||||||
ref={editorRef}
|
|
||||||
hideFooter={true}
|
|
||||||
initial={undefined}
|
|
||||||
onGenerate={(config: any) => {
|
|
||||||
form.setFieldsValue({ labelingConfig: JSON.stringify(config, null, 2), labelingInterface: JSON.stringify(generateLabelingInterface(config), null, 2) });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="labelingConfig"
|
label="标注模板"
|
||||||
rules={[
|
name="templateId"
|
||||||
{
|
rules={[{ required: true, message: "请选择标注模板" }]}
|
||||||
validator: async (_, value) => {
|
|
||||||
if (!value || value === "") return Promise.resolve();
|
|
||||||
try {
|
|
||||||
JSON.parse(value);
|
|
||||||
return Promise.resolve();
|
|
||||||
} catch (e) {
|
|
||||||
return Promise.reject(new Error("请输入有效的 JSON"));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
style={{ display: "none" }}
|
|
||||||
>
|
>
|
||||||
<Input />
|
<Select
|
||||||
</Form.Item>
|
placeholder={templates.length === 0 ? "暂无可用模板,请先创建模板" : "请选择标注模板"}
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
notFoundContent={templates.length === 0 ? "暂无模板,请前往「标注模板」页面创建" : "未找到匹配的模板"}
|
||||||
|
options={templates.map((template) => ({
|
||||||
|
label: template.name,
|
||||||
|
value: template.id,
|
||||||
|
// Add description as subtitle
|
||||||
|
title: template.description,
|
||||||
|
}))}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
{/* Row 2, Col 2: 预览,与编辑列表在同一行,保持一致高度 */}
|
</div>
|
||||||
<div style={{ gridColumn: 2, gridRow: 2, display: 'flex', flexDirection: 'column' }}>
|
)}
|
||||||
<Form.Item name="labelingInterface" style={{ flex: 1 }}>
|
|
||||||
<TextArea
|
|
||||||
placeholder="标注页面设计(只读,由标注配置生成)"
|
|
||||||
disabled
|
|
||||||
style={{ height: EDITOR_LIST_HEIGHT, resize: 'none' }}
|
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Card, Button, Table, message, Modal } from "antd";
|
import { Card, Button, Table, message, Modal, Tabs } from "antd";
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
@@ -16,13 +16,14 @@ import {
|
|||||||
syncAnnotationTaskUsingPost,
|
syncAnnotationTaskUsingPost,
|
||||||
} from "../annotation.api";
|
} from "../annotation.api";
|
||||||
import { mapAnnotationTask } from "../annotation.const";
|
import { mapAnnotationTask } from "../annotation.const";
|
||||||
import CreateAnnotationTask from "../Create/components/CreateAnnptationTaskDialog";
|
import CreateAnnotationTask from "../Create/components/CreateAnnotationTaskDialog";
|
||||||
import { ColumnType } from "antd/es/table";
|
import { ColumnType } from "antd/es/table";
|
||||||
|
import { TemplateList } from "../Template";
|
||||||
// Note: DevelopmentInProgress intentionally not used here
|
// Note: DevelopmentInProgress intentionally not used here
|
||||||
|
|
||||||
export default function DataAnnotation() {
|
export default function DataAnnotation() {
|
||||||
// return <DevelopmentInProgress showTime="2025.10.30" />;
|
// return <DevelopmentInProgress showTime="2025.10.30" />;
|
||||||
// navigate not needed for label studio external redirect
|
const [activeTab, setActiveTab] = useState("tasks");
|
||||||
const [viewMode, setViewMode] = useState<"list" | "card">("list");
|
const [viewMode, setViewMode] = useState<"list" | "card">("list");
|
||||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||||
|
|
||||||
@@ -46,7 +47,7 @@ export default function DataAnnotation() {
|
|||||||
let mounted = true;
|
let mounted = true;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const baseUrl = `http://${window.location.hostname}:${window.location.port + 1}`;
|
const baseUrl = `http://${window.location.hostname}:${parseInt(window.location.port) + 1}`;
|
||||||
if (mounted) setLabelStudioBase(baseUrl);
|
if (mounted) setLabelStudioBase(baseUrl);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) setLabelStudioBase(null);
|
if (mounted) setLabelStudioBase(null);
|
||||||
@@ -294,9 +295,38 @@ export default function DataAnnotation() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-xl font-bold">数据标注</h1>
|
<h1 className="text-xl font-bold">数据标注</h1>
|
||||||
<div className="flex items-center space-x-2">
|
</div>
|
||||||
{/* Batch action buttons - availability depends on selection count */}
|
|
||||||
<div className="flex items-center space-x-1">
|
{/* Tabs */}
|
||||||
|
<Tabs
|
||||||
|
activeKey={activeTab}
|
||||||
|
onChange={setActiveTab}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: "tasks",
|
||||||
|
label: "标注任务",
|
||||||
|
children: (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* Search, Filters and Buttons in one row */}
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
{/* Left side: Search and view controls */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<SearchControls
|
||||||
|
searchTerm={searchParams.keyword}
|
||||||
|
onSearchChange={(keyword) =>
|
||||||
|
setSearchParams({ ...searchParams, keyword })
|
||||||
|
}
|
||||||
|
searchPlaceholder="搜索任务名称、描述"
|
||||||
|
onFiltersChange={handleFiltersChange}
|
||||||
|
viewMode={viewMode}
|
||||||
|
onViewModeChange={setViewMode}
|
||||||
|
showViewToggle={true}
|
||||||
|
onReload={fetchData}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side: All action buttons */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleBatchSync(50)}
|
onClick={() => handleBatchSync(50)}
|
||||||
disabled={selectedRowKeys.length === 0}
|
disabled={selectedRowKeys.length === 0}
|
||||||
@@ -310,7 +340,6 @@ export default function DataAnnotation() {
|
|||||||
>
|
>
|
||||||
批量删除
|
批量删除
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
@@ -321,19 +350,6 @@ export default function DataAnnotation() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters Toolbar */}
|
|
||||||
<SearchControls
|
|
||||||
searchTerm={searchParams.keyword}
|
|
||||||
onSearchChange={(keyword) =>
|
|
||||||
setSearchParams({ ...searchParams, keyword })
|
|
||||||
}
|
|
||||||
searchPlaceholder="搜索任务名称、描述"
|
|
||||||
onFiltersChange={handleFiltersChange}
|
|
||||||
viewMode={viewMode}
|
|
||||||
onViewModeChange={setViewMode}
|
|
||||||
showViewToggle={true}
|
|
||||||
onReload={fetchData}
|
|
||||||
/>
|
|
||||||
{/* Task List/Card */}
|
{/* Task List/Card */}
|
||||||
{viewMode === "list" ? (
|
{viewMode === "list" ? (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -351,17 +367,33 @@ export default function DataAnnotation() {
|
|||||||
setSelectedRows(rows as any[]);
|
setSelectedRows(rows as any[]);
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
scroll={{ x: "max-content", y: "calc(100vh - 20rem)" }}
|
scroll={{ x: "max-content", y: "calc(100vh - 24rem)" }}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<CardView data={tableData} operations={operations as any} pagination={pagination} loading={loading} />
|
<CardView
|
||||||
|
data={tableData}
|
||||||
|
operations={operations as any}
|
||||||
|
pagination={pagination}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CreateAnnotationTask
|
<CreateAnnotationTask
|
||||||
open={showCreateDialog}
|
open={showCreateDialog}
|
||||||
onClose={() => setShowCreateDialog(false)}
|
onClose={() => setShowCreateDialog(false)}
|
||||||
onRefresh={fetchData}
|
onRefresh={fetchData}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "templates",
|
||||||
|
label: "标注模板",
|
||||||
|
children: <TemplateList />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
152
frontend/src/pages/DataAnnotation/Template/TemplateDetail.tsx
Normal file
152
frontend/src/pages/DataAnnotation/Template/TemplateDetail.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Modal, Descriptions, Tag, Space, Divider, Card, Typography } from "antd";
|
||||||
|
import type { AnnotationTemplate } from "../annotation.model";
|
||||||
|
|
||||||
|
const { Text, Paragraph } = Typography;
|
||||||
|
|
||||||
|
interface TemplateDetailProps {
|
||||||
|
visible: boolean;
|
||||||
|
template?: AnnotationTemplate;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TemplateDetail: React.FC<TemplateDetailProps> = ({
|
||||||
|
visible,
|
||||||
|
template,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
if (!template) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="Template Details"
|
||||||
|
open={visible}
|
||||||
|
onCancel={onClose}
|
||||||
|
footer={null}
|
||||||
|
width={800}
|
||||||
|
>
|
||||||
|
<Descriptions bordered column={2}>
|
||||||
|
<Descriptions.Item label="Name" span={2}>
|
||||||
|
{template.name}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Description" span={2}>
|
||||||
|
{template.description || "-"}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Data Type">
|
||||||
|
<Tag color="cyan">{template.dataType}</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Labeling Type">
|
||||||
|
<Tag color="geekblue">{template.labelingType}</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Category">
|
||||||
|
<Tag color="blue">{template.category}</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Style">
|
||||||
|
{template.style}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Type">
|
||||||
|
<Tag color={template.builtIn ? "gold" : "default"}>
|
||||||
|
{template.builtIn ? "Built-in" : "Custom"}
|
||||||
|
</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Version">
|
||||||
|
{template.version}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Created At" span={2}>
|
||||||
|
{new Date(template.createdAt).toLocaleString()}
|
||||||
|
</Descriptions.Item>
|
||||||
|
{template.updatedAt && (
|
||||||
|
<Descriptions.Item label="Updated At" span={2}>
|
||||||
|
{new Date(template.updatedAt).toLocaleString()}
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
<Divider>Configuration</Divider>
|
||||||
|
|
||||||
|
<Card title="Data Objects" size="small" style={{ marginBottom: 16 }}>
|
||||||
|
<Space direction="vertical" style={{ width: "100%" }}>
|
||||||
|
{template.configuration.objects.map((obj, index) => (
|
||||||
|
<Card key={index} size="small" type="inner">
|
||||||
|
<Space>
|
||||||
|
<Text strong>Name:</Text>
|
||||||
|
<Tag>{obj.name}</Tag>
|
||||||
|
<Text strong>Type:</Text>
|
||||||
|
<Tag color="blue">{obj.type}</Tag>
|
||||||
|
<Text strong>Value:</Text>
|
||||||
|
<Tag color="green">{obj.value}</Tag>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Label Controls" size="small" style={{ marginBottom: 16 }}>
|
||||||
|
<Space direction="vertical" style={{ width: "100%" }} size="middle">
|
||||||
|
{template.configuration.labels.map((label, index) => (
|
||||||
|
<Card key={index} size="small" type="inner" title={`Control ${index + 1}`}>
|
||||||
|
<Space direction="vertical" style={{ width: "100%" }}>
|
||||||
|
<div>
|
||||||
|
<Text strong>From Name: </Text>
|
||||||
|
<Tag>{label.fromName}</Tag>
|
||||||
|
<Text strong style={{ marginLeft: 16 }}>To Name: </Text>
|
||||||
|
<Tag>{label.toName}</Tag>
|
||||||
|
<Text strong style={{ marginLeft: 16 }}>Type: </Text>
|
||||||
|
<Tag color="purple">{label.type}</Tag>
|
||||||
|
{label.required && <Tag color="red">Required</Tag>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{label.description && (
|
||||||
|
<div>
|
||||||
|
<Text strong>Description: </Text>
|
||||||
|
<Text type="secondary">{label.description}</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{label.options && label.options.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Text strong>Options: </Text>
|
||||||
|
<div style={{ marginTop: 4 }}>
|
||||||
|
{label.options.map((opt, i) => (
|
||||||
|
<Tag key={i} color="cyan">{opt}</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{label.labels && label.labels.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Text strong>Labels: </Text>
|
||||||
|
<div style={{ marginTop: 4 }}>
|
||||||
|
{label.labels.map((lbl, i) => (
|
||||||
|
<Tag key={i} color="geekblue">{lbl}</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{template.labelConfig && (
|
||||||
|
<Card title="Label Studio XML Configuration" size="small">
|
||||||
|
<Paragraph>
|
||||||
|
<pre style={{
|
||||||
|
background: "#f5f5f5",
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 4,
|
||||||
|
overflow: "auto",
|
||||||
|
maxHeight: 300
|
||||||
|
}}>
|
||||||
|
{template.labelConfig}
|
||||||
|
</pre>
|
||||||
|
</Paragraph>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TemplateDetail;
|
||||||
454
frontend/src/pages/DataAnnotation/Template/TemplateForm.tsx
Normal file
454
frontend/src/pages/DataAnnotation/Template/TemplateForm.tsx
Normal file
@@ -0,0 +1,454 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Button,
|
||||||
|
Space,
|
||||||
|
message,
|
||||||
|
Divider,
|
||||||
|
Card,
|
||||||
|
Checkbox,
|
||||||
|
} from "antd";
|
||||||
|
import { PlusOutlined, MinusCircleOutlined } from "@ant-design/icons";
|
||||||
|
import {
|
||||||
|
createAnnotationTemplateUsingPost,
|
||||||
|
updateAnnotationTemplateByIdUsingPut,
|
||||||
|
} from "../annotation.api";
|
||||||
|
import type { AnnotationTemplate } from "../annotation.model";
|
||||||
|
|
||||||
|
const { TextArea } = Input;
|
||||||
|
const { Option } = Select;
|
||||||
|
|
||||||
|
interface TemplateFormProps {
|
||||||
|
visible: boolean;
|
||||||
|
mode: "create" | "edit";
|
||||||
|
template?: AnnotationTemplate;
|
||||||
|
onSuccess: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TemplateForm: React.FC<TemplateFormProps> = ({
|
||||||
|
visible,
|
||||||
|
mode,
|
||||||
|
template,
|
||||||
|
onSuccess,
|
||||||
|
onCancel,
|
||||||
|
}) => {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible && template && mode === "edit") {
|
||||||
|
form.setFieldsValue({
|
||||||
|
name: template.name,
|
||||||
|
description: template.description,
|
||||||
|
dataType: template.dataType,
|
||||||
|
labelingType: template.labelingType,
|
||||||
|
style: template.style,
|
||||||
|
category: template.category,
|
||||||
|
labels: template.configuration.labels,
|
||||||
|
objects: template.configuration.objects,
|
||||||
|
});
|
||||||
|
} else if (visible && mode === "create") {
|
||||||
|
form.resetFields();
|
||||||
|
// Set default values
|
||||||
|
form.setFieldsValue({
|
||||||
|
style: "horizontal",
|
||||||
|
category: "custom",
|
||||||
|
labels: [],
|
||||||
|
objects: [{ name: "image", type: "Image", value: "$image" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [visible, template, mode, form]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
console.log("Form values:", values);
|
||||||
|
|
||||||
|
const requestData = {
|
||||||
|
name: values.name,
|
||||||
|
description: values.description,
|
||||||
|
dataType: values.dataType,
|
||||||
|
labelingType: values.labelingType,
|
||||||
|
style: values.style,
|
||||||
|
category: values.category,
|
||||||
|
configuration: {
|
||||||
|
labels: values.labels,
|
||||||
|
objects: values.objects,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("Request data:", requestData);
|
||||||
|
|
||||||
|
let response;
|
||||||
|
if (mode === "create") {
|
||||||
|
response = await createAnnotationTemplateUsingPost(requestData);
|
||||||
|
} else {
|
||||||
|
response = await updateAnnotationTemplateByIdUsingPut(template!.id, requestData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.code === 200) {
|
||||||
|
message.success(`模板${mode === "create" ? "创建" : "更新"}成功`);
|
||||||
|
form.resetFields();
|
||||||
|
onSuccess();
|
||||||
|
} else {
|
||||||
|
message.error(response.message || `模板${mode === "create" ? "创建" : "更新"}失败`);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.errorFields) {
|
||||||
|
message.error("请填写所有必填字段");
|
||||||
|
} else {
|
||||||
|
message.error(`模板${mode === "create" ? "创建" : "更新"}失败`);
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const controlTypes = [
|
||||||
|
{ value: "Choices", label: "选项 (单选/多选)" },
|
||||||
|
{ value: "RectangleLabels", label: "矩形框 (目标检测)" },
|
||||||
|
{ value: "PolygonLabels", label: "多边形" },
|
||||||
|
{ value: "Labels", label: "标签 (文本高亮)" },
|
||||||
|
{ value: "TextArea", label: "文本区域" },
|
||||||
|
{ value: "Rating", label: "评分" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const objectTypes = [
|
||||||
|
{ value: "Image", label: "图像" },
|
||||||
|
{ value: "Text", label: "文本" },
|
||||||
|
{ value: "Audio", label: "音频" },
|
||||||
|
{ value: "Video", label: "视频" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const needsOptions = (type: string) => {
|
||||||
|
return ["Choices", "RectangleLabels", "PolygonLabels", "Labels"].includes(type);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={mode === "create" ? "创建模板" : "编辑模板"}
|
||||||
|
open={visible}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onOk={handleSubmit}
|
||||||
|
confirmLoading={loading}
|
||||||
|
width={900}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
style={{ maxHeight: "70vh", overflowY: "auto", paddingRight: 8 }}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
label="模板名称"
|
||||||
|
name="name"
|
||||||
|
rules={[{ required: true, message: "请输入模板名称" }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="例如:产品质量分类" maxLength={100} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="描述" name="description">
|
||||||
|
<TextArea
|
||||||
|
placeholder="描述此模板的用途"
|
||||||
|
rows={2}
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Space style={{ width: "100%" }} size="large">
|
||||||
|
<Form.Item
|
||||||
|
label="数据类型"
|
||||||
|
name="dataType"
|
||||||
|
rules={[{ required: true, message: "请选择数据类型" }]}
|
||||||
|
style={{ width: 200 }}
|
||||||
|
>
|
||||||
|
<Select placeholder="选择数据类型">
|
||||||
|
<Option value="image">图像</Option>
|
||||||
|
<Option value="text">文本</Option>
|
||||||
|
<Option value="audio">音频</Option>
|
||||||
|
<Option value="video">视频</Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="标注类型"
|
||||||
|
name="labelingType"
|
||||||
|
rules={[{ required: true, message: "请选择标注类型" }]}
|
||||||
|
style={{ width: 220 }}
|
||||||
|
>
|
||||||
|
<Select placeholder="选择标注类型">
|
||||||
|
<Option value="classification">分类</Option>
|
||||||
|
<Option value="object-detection">目标检测</Option>
|
||||||
|
<Option value="segmentation">分割</Option>
|
||||||
|
<Option value="ner">命名实体识别</Option>
|
||||||
|
<Option value="multi-stage">多阶段</Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="样式"
|
||||||
|
name="style"
|
||||||
|
style={{ width: 150 }}
|
||||||
|
>
|
||||||
|
<Select>
|
||||||
|
<Option value="horizontal">水平</Option>
|
||||||
|
<Option value="vertical">垂直</Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="分类"
|
||||||
|
name="category"
|
||||||
|
style={{ width: 180 }}
|
||||||
|
>
|
||||||
|
<Select>
|
||||||
|
<Option value="computer-vision">计算机视觉</Option>
|
||||||
|
<Option value="nlp">自然语言处理</Option>
|
||||||
|
<Option value="audio">音频</Option>
|
||||||
|
<Option value="quality-control">质量控制</Option>
|
||||||
|
<Option value="custom">自定义</Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Divider>数据对象</Divider>
|
||||||
|
|
||||||
|
<Form.List name="objects">
|
||||||
|
{(fields, { add, remove }) => (
|
||||||
|
<>
|
||||||
|
{fields.map((field) => (
|
||||||
|
<Card key={field.key} size="small" style={{ marginBottom: 8 }}>
|
||||||
|
<Space align="start" style={{ width: "100%" }}>
|
||||||
|
<Form.Item
|
||||||
|
{...field}
|
||||||
|
label="名称"
|
||||||
|
name={[field.name, "name"]}
|
||||||
|
rules={[{ required: true, message: "必填" }]}
|
||||||
|
style={{ marginBottom: 0, width: 150 }}
|
||||||
|
>
|
||||||
|
<Input placeholder="例如:image" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
{...field}
|
||||||
|
label="类型"
|
||||||
|
name={[field.name, "type"]}
|
||||||
|
rules={[{ required: true, message: "必填" }]}
|
||||||
|
style={{ marginBottom: 0, width: 150 }}
|
||||||
|
>
|
||||||
|
<Select>
|
||||||
|
{objectTypes.map((t) => (
|
||||||
|
<Option key={t.value} value={t.value}>
|
||||||
|
{t.label}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
{...field}
|
||||||
|
label="值"
|
||||||
|
name={[field.name, "value"]}
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: "必填" },
|
||||||
|
{ pattern: /^\$/, message: "必须以 $ 开头" },
|
||||||
|
]}
|
||||||
|
style={{ marginBottom: 0, width: 150 }}
|
||||||
|
>
|
||||||
|
<Input placeholder="$image" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{fields.length > 1 && (
|
||||||
|
<MinusCircleOutlined
|
||||||
|
style={{ marginTop: 30, color: "red" }}
|
||||||
|
onClick={() => remove(field.name)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
|
||||||
|
添加对象
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
|
|
||||||
|
<Divider>标签控件</Divider>
|
||||||
|
|
||||||
|
<Form.List name="labels">
|
||||||
|
{(fields, { add, remove }) => (
|
||||||
|
<>
|
||||||
|
{fields.map((field) => (
|
||||||
|
<Card
|
||||||
|
key={field.key}
|
||||||
|
size="small"
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<span>控件 {fields.indexOf(field) + 1}</span>
|
||||||
|
<Form.Item noStyle shouldUpdate>
|
||||||
|
{() => {
|
||||||
|
const controlType = form.getFieldValue(["labels", field.name, "type"]);
|
||||||
|
const fromName = form.getFieldValue(["labels", field.name, "fromName"]);
|
||||||
|
if (controlType || fromName) {
|
||||||
|
return (
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 'normal', color: '#999' }}>
|
||||||
|
({fromName || '未命名'} - {controlType || '未设置类型'})
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<MinusCircleOutlined
|
||||||
|
style={{ color: "red" }}
|
||||||
|
onClick={() => remove(field.name)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" style={{ width: "100%" }} size="middle">
|
||||||
|
{/* Row 1: 控件名称, 标注目标对象, 控件类型 */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '180px 220px 1fr auto', gap: 12, alignItems: 'flex-end' }}>
|
||||||
|
<Form.Item
|
||||||
|
{...field}
|
||||||
|
label="来源名称"
|
||||||
|
name={[field.name, "fromName"]}
|
||||||
|
rules={[{ required: true, message: "必填" }]}
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
tooltip="此控件的唯一标识符"
|
||||||
|
>
|
||||||
|
<Input placeholder="例如:choice" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
{...field}
|
||||||
|
label="标注目标对象"
|
||||||
|
name={[field.name, "toName"]}
|
||||||
|
rules={[{ required: true, message: "必填" }]}
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
tooltip="选择此控件将标注哪个数据对象"
|
||||||
|
dependencies={['objects']}
|
||||||
|
>
|
||||||
|
<Select placeholder="选择数据对象">
|
||||||
|
{(form.getFieldValue("objects") || []).map((obj: any, idx: number) => (
|
||||||
|
<Option key={idx} value={obj?.name || ''}>
|
||||||
|
{obj?.name || `对象 ${idx + 1}`} ({obj?.type || '未知类型'})
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
{...field}
|
||||||
|
label="控件类型"
|
||||||
|
name={[field.name, "type"]}
|
||||||
|
rules={[{ required: true, message: "必填" }]}
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
>
|
||||||
|
<Select placeholder="选择控件类型">
|
||||||
|
{controlTypes.map((t) => (
|
||||||
|
<Option key={t.value} value={t.value}>
|
||||||
|
{t.label}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
{...field}
|
||||||
|
label=" "
|
||||||
|
name={[field.name, "required"]}
|
||||||
|
valuePropName="checked"
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
>
|
||||||
|
<Checkbox>必填</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: 取值范围定义(添加选项) - Conditionally rendered based on type */}
|
||||||
|
<Form.Item
|
||||||
|
noStyle
|
||||||
|
shouldUpdate={(prevValues, currentValues) => {
|
||||||
|
const prevType = prevValues.labels?.[field.name]?.type;
|
||||||
|
const currType = currentValues.labels?.[field.name]?.type;
|
||||||
|
return prevType !== currType;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ getFieldValue }) => {
|
||||||
|
const controlType = getFieldValue(["labels", field.name, "type"]);
|
||||||
|
const fieldName = controlType === "Choices" ? "options" : "labels";
|
||||||
|
|
||||||
|
if (needsOptions(controlType)) {
|
||||||
|
return (
|
||||||
|
<Form.Item
|
||||||
|
{...field}
|
||||||
|
label={controlType === "Choices" ? "选项" : "标签"}
|
||||||
|
name={[field.name, fieldName]}
|
||||||
|
rules={[{ required: true, message: "至少需要一个选项" }]}
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="tags"
|
||||||
|
open={false}
|
||||||
|
placeholder={
|
||||||
|
controlType === "Choices"
|
||||||
|
? "输入选项内容,按回车添加。例如:是、否、不确定"
|
||||||
|
: "输入标签名称,按回车添加。例如:人物、车辆、建筑物"
|
||||||
|
}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* Row 3: 描述 */}
|
||||||
|
<Form.Item
|
||||||
|
{...field}
|
||||||
|
label="描述"
|
||||||
|
name={[field.name, "description"]}
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
tooltip="向标注人员显示的帮助信息"
|
||||||
|
>
|
||||||
|
<Input placeholder="为标注人员提供此控件的使用说明" maxLength={200} />
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
onClick={() =>
|
||||||
|
add({
|
||||||
|
fromName: "",
|
||||||
|
toName: "",
|
||||||
|
type: "Choices",
|
||||||
|
required: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
block
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
>
|
||||||
|
添加标签控件
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TemplateForm;
|
||||||
393
frontend/src/pages/DataAnnotation/Template/TemplateList.tsx
Normal file
393
frontend/src/pages/DataAnnotation/Template/TemplateList.tsx
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Table,
|
||||||
|
Space,
|
||||||
|
Tag,
|
||||||
|
message,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Tooltip,
|
||||||
|
Popconfirm,
|
||||||
|
Card,
|
||||||
|
} from "antd";
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
FilterOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import type { ColumnsType } from "antd/es/table";
|
||||||
|
import {
|
||||||
|
queryAnnotationTemplatesUsingGet,
|
||||||
|
deleteAnnotationTemplateByIdUsingDelete,
|
||||||
|
} from "../annotation.api";
|
||||||
|
import type { AnnotationTemplate } from "../annotation.model";
|
||||||
|
import TemplateForm from "./TemplateForm.tsx";
|
||||||
|
import TemplateDetail from "./TemplateDetail.tsx";
|
||||||
|
|
||||||
|
const { Search } = Input;
|
||||||
|
const { Option } = Select;
|
||||||
|
|
||||||
|
const TemplateList: React.FC = () => {
|
||||||
|
const [templates, setTemplates] = useState<AnnotationTemplate[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [size, setSize] = useState(10);
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState<string | undefined>();
|
||||||
|
const [dataTypeFilter, setDataTypeFilter] = useState<string | undefined>();
|
||||||
|
const [labelingTypeFilter, setLabelingTypeFilter] = useState<string | undefined>();
|
||||||
|
const [builtInFilter, setBuiltInFilter] = useState<boolean | undefined>();
|
||||||
|
|
||||||
|
// Modals
|
||||||
|
const [isFormVisible, setIsFormVisible] = useState(false);
|
||||||
|
const [isDetailVisible, setIsDetailVisible] = useState(false);
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState<AnnotationTemplate | undefined>();
|
||||||
|
const [formMode, setFormMode] = useState<"create" | "edit">("create");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTemplates();
|
||||||
|
}, [page, size, categoryFilter, dataTypeFilter, labelingTypeFilter, builtInFilter]);
|
||||||
|
|
||||||
|
const fetchTemplates = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params: any = {
|
||||||
|
page,
|
||||||
|
size,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (categoryFilter) params.category = categoryFilter;
|
||||||
|
if (dataTypeFilter) params.dataType = dataTypeFilter;
|
||||||
|
if (labelingTypeFilter) params.labelingType = labelingTypeFilter;
|
||||||
|
if (builtInFilter !== undefined) params.builtIn = builtInFilter;
|
||||||
|
|
||||||
|
const response = await queryAnnotationTemplatesUsingGet(params);
|
||||||
|
if (response.code === 200 && response.data) {
|
||||||
|
setTemplates(response.data.content || []);
|
||||||
|
setTotal(response.data.total || 0);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error("获取模板列表失败");
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
setFormMode("create");
|
||||||
|
setSelectedTemplate(undefined);
|
||||||
|
setIsFormVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (template: AnnotationTemplate) => {
|
||||||
|
setFormMode("edit");
|
||||||
|
setSelectedTemplate(template);
|
||||||
|
setIsFormVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleView = (template: AnnotationTemplate) => {
|
||||||
|
setSelectedTemplate(template);
|
||||||
|
setIsDetailVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (templateId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await deleteAnnotationTemplateByIdUsingDelete(templateId);
|
||||||
|
if (response.code === 200) {
|
||||||
|
message.success("模板删除成功");
|
||||||
|
fetchTemplates();
|
||||||
|
} else {
|
||||||
|
message.error(response.message || "删除模板失败");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error("删除模板失败");
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormSuccess = () => {
|
||||||
|
setIsFormVisible(false);
|
||||||
|
fetchTemplates();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearFilters = () => {
|
||||||
|
setCategoryFilter(undefined);
|
||||||
|
setDataTypeFilter(undefined);
|
||||||
|
setLabelingTypeFilter(undefined);
|
||||||
|
setBuiltInFilter(undefined);
|
||||||
|
setSearchText("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCategoryColor = (category: string) => {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
"computer-vision": "blue",
|
||||||
|
"nlp": "green",
|
||||||
|
"audio": "purple",
|
||||||
|
"quality-control": "orange",
|
||||||
|
"custom": "default",
|
||||||
|
};
|
||||||
|
return colors[category] || "default";
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnsType<AnnotationTemplate> = [
|
||||||
|
{
|
||||||
|
title: "模板名称",
|
||||||
|
dataIndex: "name",
|
||||||
|
key: "name",
|
||||||
|
width: 200,
|
||||||
|
ellipsis: true,
|
||||||
|
filteredValue: searchText ? [searchText] : null,
|
||||||
|
onFilter: (value, record) =>
|
||||||
|
record.name.toLowerCase().includes(value.toString().toLowerCase()) ||
|
||||||
|
(record.description?.toLowerCase().includes(value.toString().toLowerCase()) ?? false),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "描述",
|
||||||
|
dataIndex: "description",
|
||||||
|
key: "description",
|
||||||
|
ellipsis: {
|
||||||
|
showTitle: false,
|
||||||
|
},
|
||||||
|
render: (description: string) => (
|
||||||
|
<Tooltip title={description}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'normal',
|
||||||
|
lineHeight: '1.5em',
|
||||||
|
maxHeight: '3em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "数据类型",
|
||||||
|
dataIndex: "dataType",
|
||||||
|
key: "dataType",
|
||||||
|
width: 120,
|
||||||
|
render: (dataType: string) => (
|
||||||
|
<Tag color="cyan">{dataType}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "标注类型",
|
||||||
|
dataIndex: "labelingType",
|
||||||
|
key: "labelingType",
|
||||||
|
width: 150,
|
||||||
|
render: (labelingType: string) => (
|
||||||
|
<Tag color="geekblue">{labelingType}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "分类",
|
||||||
|
dataIndex: "category",
|
||||||
|
key: "category",
|
||||||
|
width: 150,
|
||||||
|
render: (category: string) => (
|
||||||
|
<Tag color={getCategoryColor(category)}>{category}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "类型",
|
||||||
|
dataIndex: "builtIn",
|
||||||
|
key: "builtIn",
|
||||||
|
width: 100,
|
||||||
|
render: (builtIn: boolean) => (
|
||||||
|
<Tag color={builtIn ? "gold" : "default"}>
|
||||||
|
{builtIn ? "系统内置" : "自定义"}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "版本",
|
||||||
|
dataIndex: "version",
|
||||||
|
key: "version",
|
||||||
|
width: 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "创建时间",
|
||||||
|
dataIndex: "createdAt",
|
||||||
|
key: "createdAt",
|
||||||
|
width: 180,
|
||||||
|
render: (date: string) => new Date(date).toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "操作",
|
||||||
|
key: "action",
|
||||||
|
width: 200,
|
||||||
|
fixed: "right",
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space size="small">
|
||||||
|
<Tooltip title="查看详情">
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
onClick={() => handleView(record)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
{!record.builtIn && (
|
||||||
|
<>
|
||||||
|
<Tooltip title="编辑">
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => handleEdit(record)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定要删除这个模板吗?"
|
||||||
|
onConfirm={() => handleDelete(record.id)}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Tooltip title="删除">
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Popconfirm>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const hasActiveFilters = categoryFilter || dataTypeFilter || labelingTypeFilter || builtInFilter !== undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* Search, Filters and Buttons in one row */}
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
{/* Left side: Search and Filters */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Search
|
||||||
|
placeholder="搜索模板..."
|
||||||
|
allowClear
|
||||||
|
style={{ width: 300 }}
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
placeholder="分类"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 140 }}
|
||||||
|
value={categoryFilter}
|
||||||
|
onChange={setCategoryFilter}
|
||||||
|
>
|
||||||
|
<Option value="computer-vision">计算机视觉</Option>
|
||||||
|
<Option value="nlp">自然语言处理</Option>
|
||||||
|
<Option value="audio">音频</Option>
|
||||||
|
<Option value="quality-control">质量控制</Option>
|
||||||
|
<Option value="custom">自定义</Option>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
placeholder="数据类型"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 120 }}
|
||||||
|
value={dataTypeFilter}
|
||||||
|
onChange={setDataTypeFilter}
|
||||||
|
>
|
||||||
|
<Option value="image">图像</Option>
|
||||||
|
<Option value="text">文本</Option>
|
||||||
|
<Option value="audio">音频</Option>
|
||||||
|
<Option value="video">视频</Option>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
placeholder="标注类型"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 140 }}
|
||||||
|
value={labelingTypeFilter}
|
||||||
|
onChange={setLabelingTypeFilter}
|
||||||
|
>
|
||||||
|
<Option value="classification">分类</Option>
|
||||||
|
<Option value="object-detection">目标检测</Option>
|
||||||
|
<Option value="segmentation">分割</Option>
|
||||||
|
<Option value="ner">命名实体识别</Option>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
placeholder="模板类型"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 120 }}
|
||||||
|
value={builtInFilter}
|
||||||
|
onChange={setBuiltInFilter}
|
||||||
|
>
|
||||||
|
<Option value={true}>系统内置</Option>
|
||||||
|
<Option value={false}>自定义</Option>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<Button icon={<FilterOutlined />} onClick={handleClearFilters}>
|
||||||
|
清空筛选
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side: Create button */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
|
||||||
|
创建模板
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={templates}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize: size,
|
||||||
|
total: total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (total) => `共 ${total} 个模板`,
|
||||||
|
onChange: (page, pageSize) => {
|
||||||
|
setPage(page);
|
||||||
|
setSize(pageSize);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
scroll={{ x: 1400, y: "calc(100vh - 24rem)" }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<TemplateForm
|
||||||
|
visible={isFormVisible}
|
||||||
|
mode={formMode}
|
||||||
|
template={selectedTemplate}
|
||||||
|
onSuccess={handleFormSuccess}
|
||||||
|
onCancel={() => setIsFormVisible(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TemplateDetail
|
||||||
|
visible={isDetailVisible}
|
||||||
|
template={selectedTemplate}
|
||||||
|
onClose={() => setIsDetailVisible(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TemplateList;
|
||||||
|
export { TemplateList };
|
||||||
3
frontend/src/pages/DataAnnotation/Template/index.ts
Normal file
3
frontend/src/pages/DataAnnotation/Template/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as TemplateList } from "./TemplateList";
|
||||||
|
export { default as TemplateForm } from "./TemplateForm";
|
||||||
|
export { default as TemplateDetail } from "./TemplateDetail";
|
||||||
@@ -102,30 +102,30 @@ export function getAnnotationStatisticsUsingGet(params?: any) {
|
|||||||
|
|
||||||
// 标注模板管理
|
// 标注模板管理
|
||||||
export function queryAnnotationTemplatesUsingGet(params?: any) {
|
export function queryAnnotationTemplatesUsingGet(params?: any) {
|
||||||
return get("/api/v1/annotation/templates", params);
|
return get("/api/annotation/templates", params);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAnnotationTemplateUsingPost(data: any) {
|
export function createAnnotationTemplateUsingPost(data: any) {
|
||||||
return post("/api/v1/annotation/templates", data);
|
return post("/api/annotation/templates", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function queryAnnotationTemplateByIdUsingGet(
|
export function queryAnnotationTemplateByIdUsingGet(
|
||||||
templateId: string | number
|
templateId: string | number
|
||||||
) {
|
) {
|
||||||
return get(`/api/v1/annotation/templates/${templateId}`);
|
return get(`/api/annotation/templates/${templateId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateAnnotationTemplateByIdUsingPut(
|
export function updateAnnotationTemplateByIdUsingPut(
|
||||||
templateId: string | number,
|
templateId: string | number,
|
||||||
data: any
|
data: any
|
||||||
) {
|
) {
|
||||||
return put(`/api/v1/annotation/templates/${templateId}`, data);
|
return put(`/api/annotation/templates/${templateId}`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteAnnotationTemplateByIdUsingDelete(
|
export function deleteAnnotationTemplateByIdUsingDelete(
|
||||||
templateId: string | number
|
templateId: string | number
|
||||||
) {
|
) {
|
||||||
return del(`/api/v1/annotation/templates/${templateId}`);
|
return del(`/api/annotation/templates/${templateId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 主动学习相关接口
|
// 主动学习相关接口
|
||||||
|
|||||||
@@ -51,8 +51,9 @@ export function mapAnnotationTask(task: any) {
|
|||||||
projId: labelingProjId,
|
projId: labelingProjId,
|
||||||
name: task.name,
|
name: task.name,
|
||||||
description: task.description || "",
|
description: task.description || "",
|
||||||
createdAt: task.createdAt,
|
datasetName: task.datasetName || task.dataset_name || "-",
|
||||||
updatedAt: task.updatedAt,
|
createdAt: task.createdAt || task.created_at || "-",
|
||||||
|
updatedAt: task.updatedAt || task.updated_at || "-",
|
||||||
icon: <StickyNote />,
|
icon: <StickyNote />,
|
||||||
iconColor: "bg-blue-100",
|
iconColor: "bg-blue-100",
|
||||||
status: {
|
status: {
|
||||||
|
|||||||
@@ -31,3 +31,50 @@ export interface AnnotationTask {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 标注模板相关类型
|
||||||
|
export interface LabelDefinition {
|
||||||
|
fromName: string;
|
||||||
|
toName: string;
|
||||||
|
type: string;
|
||||||
|
options?: string[];
|
||||||
|
labels?: string[];
|
||||||
|
required?: boolean;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObjectDefinition {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateConfiguration {
|
||||||
|
labels: LabelDefinition[];
|
||||||
|
objects: ObjectDefinition[];
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnotationTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
dataType: string;
|
||||||
|
labelingType: string;
|
||||||
|
configuration: TemplateConfiguration;
|
||||||
|
labelConfig?: string;
|
||||||
|
style: string;
|
||||||
|
category: string;
|
||||||
|
builtIn: boolean;
|
||||||
|
version: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnotationTemplateListResponse {
|
||||||
|
content: AnnotationTemplate[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,11 +16,6 @@ import CleansingTemplateCreate from "@/pages/DataCleansing/Create/CreateTempate"
|
|||||||
|
|
||||||
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 AnnotationWorkspace from "@/pages/DataAnnotation/Annotate/AnnotationWorkSpace";
|
|
||||||
import TextAnnotationWorkspace from "@/pages/DataAnnotation/Annotate/components/TextAnnotation";
|
|
||||||
import ImageAnnotationWorkspace from "@/pages/DataAnnotation/Annotate/components/ImageAnnotation";
|
|
||||||
import AudioAnnotationWorkspace from "@/pages/DataAnnotation/Annotate/components/AudioAnnotation";
|
|
||||||
import VideoAnnotationWorkspace from "@/pages/DataAnnotation/Annotate/components/VideoAnnotation";
|
|
||||||
|
|
||||||
import DataSynthesisPage from "@/pages/SynthesisTask/DataSynthesis";
|
import DataSynthesisPage from "@/pages/SynthesisTask/DataSynthesis";
|
||||||
import InstructionTemplateCreate from "@/pages/SynthesisTask/CreateTemplate";
|
import InstructionTemplateCreate from "@/pages/SynthesisTask/CreateTemplate";
|
||||||
@@ -139,28 +134,6 @@ const router = createBrowserRouter([
|
|||||||
path: "create-task",
|
path: "create-task",
|
||||||
Component: AnnotationTaskCreate,
|
Component: AnnotationTaskCreate,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "task-annotate",
|
|
||||||
Component: AnnotationWorkspace,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: "text/:id",
|
|
||||||
Component: TextAnnotationWorkspace,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "image/:id",
|
|
||||||
Component: ImageAnnotationWorkspace,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "audio/:id",
|
|
||||||
Component: AudioAnnotationWorkspace,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "video/:id",
|
|
||||||
Component: VideoAnnotationWorkspace,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,25 +3,32 @@ Tables of Annotation Management Module
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from sqlalchemy import Column, String, BigInteger, Boolean, TIMESTAMP, Text, Integer, JSON, Date
|
from sqlalchemy import Column, String, BigInteger, Boolean, TIMESTAMP, Text, Integer, JSON, Date, ForeignKey
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
from app.db.session import Base
|
from app.db.session import Base
|
||||||
|
|
||||||
class AnnotationTemplate(Base):
|
class AnnotationTemplate(Base):
|
||||||
"""标注模板模型"""
|
"""标注配置模板模型"""
|
||||||
|
|
||||||
__tablename__ = "t_dm_annotation_templates"
|
__tablename__ = "t_dm_annotation_templates"
|
||||||
|
|
||||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="UUID主键ID")
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="UUID")
|
||||||
name = Column(String(32), nullable=False, comment="模板名称")
|
name = Column(String(100), nullable=False, comment="模板名称")
|
||||||
description = Column(String(255), nullable=True, comment="模板描述")
|
description = Column(String(500), nullable=True, comment="模板描述")
|
||||||
configuration = Column(JSON, nullable=True, comment="配置信息(JSON格式)")
|
data_type = Column(String(50), nullable=False, comment="数据类型: image/text/audio/video/timeseries")
|
||||||
|
labeling_type = Column(String(50), nullable=False, comment="标注类型: classification/detection/segmentation/ner/relation/etc")
|
||||||
|
configuration = Column(JSON, nullable=False, comment="标注配置(包含labels定义等)")
|
||||||
|
style = Column(String(32), nullable=False, comment="样式配置: horizontal/vertical")
|
||||||
|
category = Column(String(50), default='custom', comment="模板分类: medical/general/custom/system")
|
||||||
|
built_in = Column(Boolean, default=False, comment="是否系统内置模板")
|
||||||
|
version = Column(String(20), default='1.0', comment="模板版本")
|
||||||
created_at = Column(TIMESTAMP, server_default=func.current_timestamp(), comment="创建时间")
|
created_at = Column(TIMESTAMP, server_default=func.current_timestamp(), comment="创建时间")
|
||||||
|
updated_at = Column(TIMESTAMP, server_default=func.current_timestamp(), onupdate=func.current_timestamp(), comment="更新时间")
|
||||||
deleted_at = Column(TIMESTAMP, nullable=True, comment="删除时间(软删除)")
|
deleted_at = Column(TIMESTAMP, nullable=True, comment="删除时间(软删除)")
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<AnnotationTemplate(id={self.id}, name={self.name})>"
|
return f"<AnnotationTemplate(id={self.id}, name={self.name}, data_type={self.data_type})>"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_deleted(self) -> bool:
|
def is_deleted(self) -> bool:
|
||||||
@@ -29,21 +36,23 @@ class AnnotationTemplate(Base):
|
|||||||
return self.deleted_at is not None
|
return self.deleted_at is not None
|
||||||
|
|
||||||
class LabelingProject(Base):
|
class LabelingProject(Base):
|
||||||
"""标注工程表"""
|
"""标注项目模型"""
|
||||||
|
|
||||||
__tablename__ = "t_dm_labeling_projects"
|
__tablename__ = "t_dm_labeling_projects"
|
||||||
|
|
||||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="UUID主键ID")
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="UUID")
|
||||||
dataset_id = Column(String(36), nullable=False, comment="数据集ID")
|
dataset_id = Column(String(36), nullable=False, comment="数据集ID")
|
||||||
name = Column(String(32), nullable=False, comment="项目名称")
|
name = Column(String(100), nullable=False, comment="项目名称")
|
||||||
labeling_project_id = Column(String(8), nullable=False, comment="Label Studio项目ID")
|
labeling_project_id = Column(String(8), nullable=False, comment="Label Studio项目ID")
|
||||||
configuration = Column(JSON, nullable=True, comment="标签配置")
|
template_id = Column(String(36), ForeignKey('t_dm_annotation_templates.id', ondelete='SET NULL'), nullable=True, comment="使用的模板ID")
|
||||||
progress = Column(JSON, nullable=True, comment="标注进度统计")
|
configuration = Column(JSON, nullable=True, comment="项目配置(可能包含对模板的自定义修改)")
|
||||||
|
progress = Column(JSON, nullable=True, comment="项目进度信息")
|
||||||
created_at = Column(TIMESTAMP, server_default=func.current_timestamp(), comment="创建时间")
|
created_at = Column(TIMESTAMP, server_default=func.current_timestamp(), comment="创建时间")
|
||||||
|
updated_at = Column(TIMESTAMP, server_default=func.current_timestamp(), onupdate=func.current_timestamp(), comment="更新时间")
|
||||||
deleted_at = Column(TIMESTAMP, nullable=True, comment="删除时间(软删除)")
|
deleted_at = Column(TIMESTAMP, nullable=True, comment="删除时间(软删除)")
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<LabelingProject(id={self.id}, dataset_id={self.dataset_id}, name={self.name})>"
|
return f"<LabelingProject(id={self.id}, name={self.name}, dataset_id={self.dataset_id})>"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_deleted(self) -> bool:
|
def is_deleted(self) -> bool:
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ class Client:
|
|||||||
"""创建Label Studio项目"""
|
"""创建Label Studio项目"""
|
||||||
try:
|
try:
|
||||||
logger.debug(f"Creating Label Studio project: {title}")
|
logger.debug(f"Creating Label Studio project: {title}")
|
||||||
|
logger.debug(f"Label Studio URL: {self.base_url}/api/projects")
|
||||||
|
|
||||||
project_data = {
|
project_data = {
|
||||||
"title": title,
|
"title": title,
|
||||||
@@ -123,10 +124,28 @@ class Client:
|
|||||||
return project
|
return project
|
||||||
|
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
logger.error(f"Create project failed HTTP {e.response.status_code}: {e.response.text}")
|
logger.error(
|
||||||
|
f"Create project failed - HTTP {e.response.status_code}\n"
|
||||||
|
f"URL: {e.request.url}\n"
|
||||||
|
f"Response Headers: {dict(e.response.headers)}\n"
|
||||||
|
f"Response Body: {e.response.text[:1000]}" # First 1000 chars
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
except httpx.ConnectError as e:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to connect to Label Studio at {self.base_url}\n"
|
||||||
|
f"Error: {str(e)}\n"
|
||||||
|
f"Possible causes:\n"
|
||||||
|
f" - Label Studio service is not running\n"
|
||||||
|
f" - Incorrect URL configuration\n"
|
||||||
|
f" - Network connectivity issue"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
except httpx.TimeoutException as e:
|
||||||
|
logger.error(f"Request to Label Studio timed out after {self.timeout}s: {str(e)}")
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error while creating Label Studio project: {e}")
|
logger.error(f"Error while creating Label Studio project: {str(e)}", exc_info=True)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def import_tasks(
|
async def import_tasks(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter
|
|||||||
from .about import router as about_router
|
from .about import router as about_router
|
||||||
from .project import router as project_router
|
from .project import router as project_router
|
||||||
from .task import router as task_router
|
from .task import router as task_router
|
||||||
|
from .template import router as template_router
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/annotation",
|
prefix="/annotation",
|
||||||
@@ -12,3 +13,4 @@ router = APIRouter(
|
|||||||
router.include_router(about_router)
|
router.include_router(about_router)
|
||||||
router.include_router(project_router)
|
router.include_router(project_router)
|
||||||
router.include_router(task_router)
|
router.include_router(task_router)
|
||||||
|
router.include_router(template_router)
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
import math
|
import math
|
||||||
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -14,6 +15,7 @@ from app.core.config import settings
|
|||||||
from ..client import LabelStudioClient
|
from ..client import LabelStudioClient
|
||||||
from ..service.mapping import DatasetMappingService
|
from ..service.mapping import DatasetMappingService
|
||||||
from ..service.sync import SyncService
|
from ..service.sync import SyncService
|
||||||
|
from ..service.template import AnnotationTemplateService
|
||||||
from ..schema import (
|
from ..schema import (
|
||||||
DatasetMappingCreateRequest,
|
DatasetMappingCreateRequest,
|
||||||
DatasetMappingCreateResponse,
|
DatasetMappingCreateResponse,
|
||||||
@@ -39,6 +41,8 @@ async def create_mapping(
|
|||||||
在数据库中记录这一关联关系,返回Label Studio数据集的ID
|
在数据库中记录这一关联关系,返回Label Studio数据集的ID
|
||||||
|
|
||||||
注意:一个数据集可以创建多个标注项目
|
注意:一个数据集可以创建多个标注项目
|
||||||
|
|
||||||
|
支持通过 template_id 指定标注模板,如果提供了模板ID,则使用模板的配置
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
dm_client = DatasetManagementService(db)
|
dm_client = DatasetManagementService(db)
|
||||||
@@ -46,6 +50,7 @@ async def create_mapping(
|
|||||||
token=settings.label_studio_user_token)
|
token=settings.label_studio_user_token)
|
||||||
mapping_service = DatasetMappingService(db)
|
mapping_service = DatasetMappingService(db)
|
||||||
sync_service = SyncService(dm_client, ls_client, mapping_service)
|
sync_service = SyncService(dm_client, ls_client, mapping_service)
|
||||||
|
template_service = AnnotationTemplateService()
|
||||||
|
|
||||||
logger.info(f"Create dataset mapping request: {request.dataset_id}")
|
logger.info(f"Create dataset mapping request: {request.dataset_id}")
|
||||||
|
|
||||||
@@ -65,10 +70,24 @@ async def create_mapping(
|
|||||||
dataset_info.description or \
|
dataset_info.description or \
|
||||||
f"Imported from DM dataset {dataset_info.name} ({dataset_info.id})"
|
f"Imported from DM dataset {dataset_info.name} ({dataset_info.id})"
|
||||||
|
|
||||||
|
# 如果提供了模板ID,获取模板配置
|
||||||
|
label_config = None
|
||||||
|
if request.template_id:
|
||||||
|
logger.info(f"Using template: {request.template_id}")
|
||||||
|
template = await template_service.get_template(db, request.template_id)
|
||||||
|
if not template:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Template not found: {request.template_id}"
|
||||||
|
)
|
||||||
|
label_config = template.label_config
|
||||||
|
logger.debug(f"Template label config loaded for template: {template.name}")
|
||||||
|
|
||||||
# 在Label Studio中创建项目
|
# 在Label Studio中创建项目
|
||||||
project_data = await ls_client.create_project(
|
project_data = await ls_client.create_project(
|
||||||
title=project_name,
|
title=project_name,
|
||||||
description=project_description,
|
description=project_description,
|
||||||
|
label_config=label_config # 传递模板配置
|
||||||
)
|
)
|
||||||
|
|
||||||
if not project_data:
|
if not project_data:
|
||||||
@@ -96,9 +115,11 @@ async def create_mapping(
|
|||||||
logger.info(f"Local storage configured for project {project_id}: {local_storage_path}")
|
logger.info(f"Local storage configured for project {project_id}: {local_storage_path}")
|
||||||
|
|
||||||
labeling_project = LabelingProject(
|
labeling_project = LabelingProject(
|
||||||
|
id=str(uuid.uuid4()), # Generate UUID here
|
||||||
dataset_id=request.dataset_id,
|
dataset_id=request.dataset_id,
|
||||||
labeling_project_id=str(project_id),
|
labeling_project_id=str(project_id),
|
||||||
name=project_name,
|
name=project_name,
|
||||||
|
template_id=request.template_id, # Save template_id to database
|
||||||
)
|
)
|
||||||
|
|
||||||
# 创建映射关系,包含项目名称(先持久化映射以获得 mapping.id)
|
# 创建映射关系,包含项目名称(先持久化映射以获得 mapping.id)
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""
|
||||||
|
Annotation Template API Endpoints
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.module.shared.schema import StandardResponse
|
||||||
|
from app.module.annotation.schema.template import (
|
||||||
|
CreateAnnotationTemplateRequest,
|
||||||
|
UpdateAnnotationTemplateRequest,
|
||||||
|
AnnotationTemplateResponse,
|
||||||
|
AnnotationTemplateListResponse
|
||||||
|
)
|
||||||
|
from app.module.annotation.service.template import AnnotationTemplateService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/templates", tags=["Annotation Template"])
|
||||||
|
|
||||||
|
template_service = AnnotationTemplateService()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"",
|
||||||
|
response_model=StandardResponse[AnnotationTemplateResponse],
|
||||||
|
summary="创建标注模板"
|
||||||
|
)
|
||||||
|
async def create_template(
|
||||||
|
request: CreateAnnotationTemplateRequest,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
创建新的标注模板
|
||||||
|
|
||||||
|
- **name**: 模板名称(必填,最多100字符)
|
||||||
|
- **description**: 模板描述(可选,最多500字符)
|
||||||
|
- **dataType**: 数据类型(必填)
|
||||||
|
- **labelingType**: 标注类型(必填)
|
||||||
|
- **configuration**: 标注配置(必填,包含labels和objects)
|
||||||
|
- **style**: 样式配置(默认horizontal)
|
||||||
|
- **category**: 模板分类(默认custom)
|
||||||
|
"""
|
||||||
|
template = await template_service.create_template(db, request)
|
||||||
|
return StandardResponse(code=200, message="success", data=template)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{template_id}",
|
||||||
|
response_model=StandardResponse[AnnotationTemplateResponse],
|
||||||
|
summary="获取模板详情"
|
||||||
|
)
|
||||||
|
async def get_template(
|
||||||
|
template_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
根据ID获取模板详情
|
||||||
|
"""
|
||||||
|
template = await template_service.get_template(db, template_id)
|
||||||
|
if not template:
|
||||||
|
raise HTTPException(status_code=404, detail="Template not found")
|
||||||
|
return StandardResponse(code=200, message="success", data=template)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"",
|
||||||
|
response_model=StandardResponse[AnnotationTemplateListResponse],
|
||||||
|
summary="获取模板列表"
|
||||||
|
)
|
||||||
|
async def list_templates(
|
||||||
|
page: int = Query(1, ge=1, description="页码"),
|
||||||
|
size: int = Query(10, ge=1, le=100, description="每页大小"),
|
||||||
|
category: Optional[str] = Query(None, description="分类筛选"),
|
||||||
|
dataType: Optional[str] = Query(None, alias="dataType", description="数据类型筛选"),
|
||||||
|
labelingType: Optional[str] = Query(None, alias="labelingType", description="标注类型筛选"),
|
||||||
|
builtIn: Optional[bool] = Query(None, alias="builtIn", description="是否内置模板"),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取模板列表,支持分页和筛选
|
||||||
|
|
||||||
|
- **page**: 页码(从1开始)
|
||||||
|
- **size**: 每页大小(1-100)
|
||||||
|
- **category**: 模板分类筛选
|
||||||
|
- **dataType**: 数据类型筛选
|
||||||
|
- **labelingType**: 标注类型筛选
|
||||||
|
- **builtIn**: 是否只显示内置模板
|
||||||
|
"""
|
||||||
|
templates = await template_service.list_templates(
|
||||||
|
db=db,
|
||||||
|
page=page,
|
||||||
|
size=size,
|
||||||
|
category=category,
|
||||||
|
data_type=dataType,
|
||||||
|
labeling_type=labelingType,
|
||||||
|
built_in=builtIn
|
||||||
|
)
|
||||||
|
return StandardResponse(code=200, message="success", data=templates)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{template_id}",
|
||||||
|
response_model=StandardResponse[AnnotationTemplateResponse],
|
||||||
|
summary="更新模板"
|
||||||
|
)
|
||||||
|
async def update_template(
|
||||||
|
template_id: str,
|
||||||
|
request: UpdateAnnotationTemplateRequest,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
更新模板信息
|
||||||
|
|
||||||
|
所有字段都是可选的,只更新提供的字段
|
||||||
|
"""
|
||||||
|
template = await template_service.update_template(db, template_id, request)
|
||||||
|
if not template:
|
||||||
|
raise HTTPException(status_code=404, detail="Template not found")
|
||||||
|
return StandardResponse(code=200, message="success", data=template)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{template_id}",
|
||||||
|
response_model=StandardResponse[bool],
|
||||||
|
summary="删除模板"
|
||||||
|
)
|
||||||
|
async def delete_template(
|
||||||
|
template_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
删除模板(软删除)
|
||||||
|
"""
|
||||||
|
success = await template_service.delete_template(db, template_id)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=404, detail="Template not found")
|
||||||
|
return StandardResponse(code=200, message="success", data=True)
|
||||||
@@ -11,13 +11,14 @@ class DatasetMappingCreateRequest(BaseModel):
|
|||||||
|
|
||||||
Accept both snake_case and camelCase field names from frontend JSON by
|
Accept both snake_case and camelCase field names from frontend JSON by
|
||||||
declaring explicit aliases. Frontend sends `datasetId`, `name`,
|
declaring explicit aliases. Frontend sends `datasetId`, `name`,
|
||||||
`description` (camelCase), so provide aliases so pydantic will map them
|
`description`, `templateId` (camelCase), so provide aliases so pydantic will map them
|
||||||
to the internal attributes used in the service code (dataset_id, name,
|
to the internal attributes used in the service code (dataset_id, name,
|
||||||
description).
|
description, template_id).
|
||||||
"""
|
"""
|
||||||
dataset_id: str = Field(..., alias="datasetId", description="源数据集ID")
|
dataset_id: str = Field(..., alias="datasetId", description="源数据集ID")
|
||||||
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")
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
# allow population by field name when constructing model programmatically
|
# allow population by field name when constructing model programmatically
|
||||||
@@ -34,13 +35,16 @@ class DatasetMappingUpdateRequest(BaseResponseModel):
|
|||||||
dataset_id: Optional[str] = Field(None, description="源数据集ID")
|
dataset_id: Optional[str] = Field(None, description="源数据集ID")
|
||||||
|
|
||||||
class DatasetMappingResponse(BaseModel):
|
class DatasetMappingResponse(BaseModel):
|
||||||
dataset_id: str = Field(..., description="源数据集ID")
|
|
||||||
"""数据集映射 查询 响应模型"""
|
"""数据集映射 查询 响应模型"""
|
||||||
id: str = Field(..., description="映射UUID")
|
id: str = Field(..., description="映射UUID")
|
||||||
labeling_project_id: str = Field(..., description="标注项目ID")
|
dataset_id: str = Field(..., alias="datasetId", description="源数据集ID")
|
||||||
|
dataset_name: Optional[str] = Field(None, alias="datasetName", description="数据集名称")
|
||||||
|
labeling_project_id: str = Field(..., alias="labelingProjectId", description="标注项目ID")
|
||||||
name: Optional[str] = Field(None, description="标注项目名称")
|
name: Optional[str] = Field(None, description="标注项目名称")
|
||||||
created_at: datetime = Field(..., description="创建时间")
|
description: Optional[str] = Field(None, description="标注项目描述")
|
||||||
deleted_at: Optional[datetime] = Field(None, description="删除时间")
|
created_at: datetime = Field(..., alias="createdAt", description="创建时间")
|
||||||
|
updated_at: Optional[datetime] = Field(None, alias="updatedAt", description="更新时间")
|
||||||
|
deleted_at: Optional[datetime] = Field(None, alias="deletedAt", description="删除时间")
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""
|
||||||
|
Annotation Template Schemas
|
||||||
|
"""
|
||||||
|
from typing import List, Dict, Any, Optional, Literal
|
||||||
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel, Field, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class LabelDefinition(BaseModel):
|
||||||
|
"""标签定义"""
|
||||||
|
from_name: str = Field(alias="fromName", description="控件名称")
|
||||||
|
to_name: str = Field(alias="toName", description="目标对象名称")
|
||||||
|
type: str = Field(description="控件类型: choices/rectanglelabels/polygonlabels/textarea/etc")
|
||||||
|
options: Optional[List[str]] = Field(None, description="选项列表(用于choices类型)")
|
||||||
|
labels: Optional[List[str]] = Field(None, description="标签列表(用于rectanglelabels等类型)")
|
||||||
|
required: bool = Field(False, description="是否必填")
|
||||||
|
description: Optional[str] = Field(None, description="标签描述")
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectDefinition(BaseModel):
|
||||||
|
"""对象定义"""
|
||||||
|
name: str = Field(description="对象标识符")
|
||||||
|
type: str = Field(description="对象类型: Image/Text/Audio/Video/etc")
|
||||||
|
value: str = Field(description="变量名,如$image")
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateConfiguration(BaseModel):
|
||||||
|
"""模板配置结构"""
|
||||||
|
labels: List[LabelDefinition] = Field(description="标签定义列表")
|
||||||
|
objects: List[ObjectDefinition] = Field(description="对象定义列表")
|
||||||
|
metadata: Optional[Dict[str, Any]] = Field(None, description="额外元数据")
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CreateAnnotationTemplateRequest(BaseModel):
|
||||||
|
"""创建标注模板请求"""
|
||||||
|
name: str = Field(..., min_length=1, max_length=100, description="模板名称")
|
||||||
|
description: Optional[str] = Field(None, max_length=500, description="模板描述")
|
||||||
|
data_type: str = Field(alias="dataType", description="数据类型")
|
||||||
|
labeling_type: str = Field(alias="labelingType", description="标注类型")
|
||||||
|
configuration: TemplateConfiguration = Field(..., description="标注配置")
|
||||||
|
style: str = Field(default="horizontal", description="样式配置")
|
||||||
|
category: str = Field(default="custom", description="模板分类")
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateAnnotationTemplateRequest(BaseModel):
|
||||||
|
"""更新标注模板请求"""
|
||||||
|
name: Optional[str] = Field(None, min_length=1, max_length=100, description="模板名称")
|
||||||
|
description: Optional[str] = Field(None, max_length=500, description="模板描述")
|
||||||
|
data_type: Optional[str] = Field(None, alias="dataType", description="数据类型")
|
||||||
|
labeling_type: Optional[str] = Field(None, alias="labelingType", description="标注类型")
|
||||||
|
configuration: Optional[TemplateConfiguration] = Field(None, description="标注配置")
|
||||||
|
style: Optional[str] = Field(None, description="样式配置")
|
||||||
|
category: Optional[str] = Field(None, description="模板分类")
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AnnotationTemplateResponse(BaseModel):
|
||||||
|
"""标注模板响应"""
|
||||||
|
id: str = Field(..., description="模板ID")
|
||||||
|
name: str = Field(..., description="模板名称")
|
||||||
|
description: Optional[str] = Field(None, description="模板描述")
|
||||||
|
data_type: str = Field(alias="dataType", description="数据类型")
|
||||||
|
labeling_type: str = Field(alias="labelingType", description="标注类型")
|
||||||
|
configuration: TemplateConfiguration = Field(..., description="标注配置")
|
||||||
|
label_config: Optional[str] = Field(None, alias="labelConfig", description="生成的Label Studio XML配置")
|
||||||
|
style: str = Field(..., description="样式配置")
|
||||||
|
category: str = Field(..., description="模板分类")
|
||||||
|
built_in: bool = Field(alias="builtIn", description="是否内置模板")
|
||||||
|
version: str = Field(..., description="版本号")
|
||||||
|
created_at: datetime = Field(alias="createdAt", description="创建时间")
|
||||||
|
updated_at: Optional[datetime] = Field(None, alias="updatedAt", description="更新时间")
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True, from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AnnotationTemplateListResponse(BaseModel):
|
||||||
|
"""模板列表响应"""
|
||||||
|
content: List[AnnotationTemplateResponse] = Field(..., description="模板列表")
|
||||||
|
total: int = Field(..., description="总数")
|
||||||
|
page: int = Field(..., description="当前页")
|
||||||
|
size: int = Field(..., description="每页大小")
|
||||||
|
total_pages: int = Field(alias="totalPages", description="总页数")
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
from sqlalchemy import update, func
|
from sqlalchemy import update, func
|
||||||
|
from sqlalchemy.orm import aliased
|
||||||
from typing import Optional, List, Tuple
|
from typing import Optional, List, Tuple
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from app.core.logging import get_logger
|
from app.core.logging import get_logger
|
||||||
from app.db.models import LabelingProject
|
from app.db.models import LabelingProject
|
||||||
|
from app.db.models.dataset_management import Dataset
|
||||||
from app.module.annotation.schema import (
|
from app.module.annotation.schema import (
|
||||||
DatasetMappingCreateRequest,
|
DatasetMappingCreateRequest,
|
||||||
DatasetMappingUpdateRequest,
|
DatasetMappingUpdateRequest,
|
||||||
@@ -21,6 +23,61 @@ class DatasetMappingService:
|
|||||||
def __init__(self, db: AsyncSession):
|
def __init__(self, db: AsyncSession):
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
|
def _build_query_with_dataset_name(self):
|
||||||
|
"""Build base query with dataset name joined"""
|
||||||
|
return select(
|
||||||
|
LabelingProject,
|
||||||
|
Dataset.name.label('dataset_name')
|
||||||
|
).outerjoin(
|
||||||
|
Dataset,
|
||||||
|
LabelingProject.dataset_id == Dataset.id
|
||||||
|
)
|
||||||
|
|
||||||
|
def _to_response_from_row(self, row) -> DatasetMappingResponse:
|
||||||
|
"""Convert query row (mapping + dataset_name) to response"""
|
||||||
|
mapping = row[0] # LabelingProject object
|
||||||
|
dataset_name = row[1] # dataset_name from join
|
||||||
|
|
||||||
|
response_data = {
|
||||||
|
"id": mapping.id,
|
||||||
|
"dataset_id": mapping.dataset_id,
|
||||||
|
"dataset_name": dataset_name,
|
||||||
|
"labeling_project_id": mapping.labeling_project_id,
|
||||||
|
"name": mapping.name,
|
||||||
|
"description": getattr(mapping, 'description', None),
|
||||||
|
"created_at": mapping.created_at,
|
||||||
|
"updated_at": mapping.updated_at,
|
||||||
|
"deleted_at": mapping.deleted_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
return DatasetMappingResponse(**response_data)
|
||||||
|
|
||||||
|
async def _to_response(self, mapping: LabelingProject) -> DatasetMappingResponse:
|
||||||
|
"""Convert ORM model to response with dataset name (for single entity operations)"""
|
||||||
|
# Fetch dataset name
|
||||||
|
dataset_name = None
|
||||||
|
dataset_id = getattr(mapping, 'dataset_id', None)
|
||||||
|
if dataset_id:
|
||||||
|
dataset_result = await self.db.execute(
|
||||||
|
select(Dataset.name).where(Dataset.id == dataset_id)
|
||||||
|
)
|
||||||
|
dataset_name = dataset_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
# Create response dict with all fields
|
||||||
|
response_data = {
|
||||||
|
"id": mapping.id,
|
||||||
|
"dataset_id": dataset_id,
|
||||||
|
"dataset_name": dataset_name,
|
||||||
|
"labeling_project_id": mapping.labeling_project_id,
|
||||||
|
"name": mapping.name,
|
||||||
|
"description": getattr(mapping, 'description', None),
|
||||||
|
"created_at": mapping.created_at,
|
||||||
|
"updated_at": mapping.updated_at,
|
||||||
|
"deleted_at": mapping.deleted_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
return DatasetMappingResponse(**response_data)
|
||||||
|
|
||||||
async def create_mapping(
|
async def create_mapping(
|
||||||
self,
|
self,
|
||||||
labeling_project: LabelingProject
|
labeling_project: LabelingProject
|
||||||
@@ -28,19 +85,13 @@ class DatasetMappingService:
|
|||||||
"""创建数据集映射"""
|
"""创建数据集映射"""
|
||||||
logger.info(f"Create dataset mapping: {labeling_project.dataset_id} -> {labeling_project.labeling_project_id}")
|
logger.info(f"Create dataset mapping: {labeling_project.dataset_id} -> {labeling_project.labeling_project_id}")
|
||||||
|
|
||||||
db_mapping = LabelingProject(
|
# Use the passed object directly
|
||||||
id=str(uuid.uuid4()),
|
self.db.add(labeling_project)
|
||||||
dataset_id=labeling_project.dataset_id,
|
|
||||||
labeling_project_id=labeling_project.labeling_project_id,
|
|
||||||
name=labeling_project.name
|
|
||||||
)
|
|
||||||
|
|
||||||
self.db.add(db_mapping)
|
|
||||||
await self.db.commit()
|
await self.db.commit()
|
||||||
await self.db.refresh(db_mapping)
|
await self.db.refresh(labeling_project)
|
||||||
|
|
||||||
logger.debug(f"Mapping created: {db_mapping.id}")
|
logger.debug(f"Mapping created: {labeling_project.id}")
|
||||||
return DatasetMappingResponse.model_validate(db_mapping)
|
return await self._to_response(labeling_project)
|
||||||
|
|
||||||
async def get_mapping_by_source_uuid(
|
async def get_mapping_by_source_uuid(
|
||||||
self,
|
self,
|
||||||
@@ -59,7 +110,7 @@ class DatasetMappingService:
|
|||||||
|
|
||||||
if mapping:
|
if mapping:
|
||||||
logger.debug(f"Found mapping: {mapping.id}")
|
logger.debug(f"Found mapping: {mapping.id}")
|
||||||
return DatasetMappingResponse.model_validate(mapping)
|
return await self._to_response(mapping)
|
||||||
|
|
||||||
logger.debug(f"No mapping found for source dataset id: {dataset_id}")
|
logger.debug(f"No mapping found for source dataset id: {dataset_id}")
|
||||||
return None
|
return None
|
||||||
@@ -72,7 +123,7 @@ class DatasetMappingService:
|
|||||||
"""根据源数据集ID获取所有映射关系"""
|
"""根据源数据集ID获取所有映射关系"""
|
||||||
logger.debug(f"Get all mappings by source dataset id: {dataset_id}")
|
logger.debug(f"Get all mappings by source dataset id: {dataset_id}")
|
||||||
|
|
||||||
query = select(LabelingProject).where(
|
query = self._build_query_with_dataset_name().where(
|
||||||
LabelingProject.dataset_id == dataset_id
|
LabelingProject.dataset_id == dataset_id
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -82,10 +133,10 @@ class DatasetMappingService:
|
|||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
query.order_by(LabelingProject.created_at.desc())
|
query.order_by(LabelingProject.created_at.desc())
|
||||||
)
|
)
|
||||||
mappings = result.scalars().all()
|
rows = result.all()
|
||||||
|
|
||||||
logger.debug(f"Found {len(mappings)} mappings")
|
logger.debug(f"Found {len(rows)} mappings")
|
||||||
return [DatasetMappingResponse.model_validate(mapping) for mapping in mappings]
|
return [self._to_response_from_row(row) for row in rows]
|
||||||
|
|
||||||
async def get_mapping_by_labeling_project_id(
|
async def get_mapping_by_labeling_project_id(
|
||||||
self,
|
self,
|
||||||
@@ -103,8 +154,8 @@ class DatasetMappingService:
|
|||||||
mapping = result.scalar_one_or_none()
|
mapping = result.scalar_one_or_none()
|
||||||
|
|
||||||
if mapping:
|
if mapping:
|
||||||
logger.debug(f"Found mapping: {mapping.mapping_id}")
|
logger.debug(f"Found mapping: {mapping.id}")
|
||||||
return DatasetMappingResponse.model_validate(mapping)
|
return await self._to_response(mapping)
|
||||||
|
|
||||||
logger.debug(f"No mapping found for Label Studio project id: {labeling_project_id}")
|
logger.debug(f"No mapping found for Label Studio project id: {labeling_project_id}")
|
||||||
return None
|
return None
|
||||||
@@ -123,9 +174,9 @@ class DatasetMappingService:
|
|||||||
|
|
||||||
if mapping:
|
if mapping:
|
||||||
logger.debug(f"Found mapping: {mapping.id}")
|
logger.debug(f"Found mapping: {mapping.id}")
|
||||||
return DatasetMappingResponse.model_validate(mapping)
|
return await self._to_response(mapping)
|
||||||
|
|
||||||
logger.debug(f"Mapping not found: {mapping_id}")
|
logger.debug(f"No mapping found for mapping id: {mapping_id}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def update_mapping(
|
async def update_mapping(
|
||||||
@@ -184,17 +235,20 @@ class DatasetMappingService:
|
|||||||
"""获取所有有效映射"""
|
"""获取所有有效映射"""
|
||||||
logger.debug(f"List all mappings, skip: {skip}, limit: {limit}")
|
logger.debug(f"List all mappings, skip: {skip}, limit: {limit}")
|
||||||
|
|
||||||
|
query = self._build_query_with_dataset_name().where(
|
||||||
|
LabelingProject.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
select(LabelingProject)
|
query
|
||||||
.where(LabelingProject.deleted_at.is_(None))
|
|
||||||
.offset(skip)
|
.offset(skip)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.order_by(LabelingProject.created_at.desc())
|
.order_by(LabelingProject.created_at.desc())
|
||||||
)
|
)
|
||||||
mappings = result.scalars().all()
|
rows = result.all()
|
||||||
|
|
||||||
logger.debug(f"Found {len(mappings)} mappings")
|
logger.debug(f"Found {len(rows)} mappings")
|
||||||
return [DatasetMappingResponse.model_validate(mapping) for mapping in mappings]
|
return [self._to_response_from_row(row) for row in rows]
|
||||||
|
|
||||||
async def count_mappings(self, include_deleted: bool = False) -> int:
|
async def count_mappings(self, include_deleted: bool = False) -> int:
|
||||||
"""统计映射总数"""
|
"""统计映射总数"""
|
||||||
@@ -216,7 +270,7 @@ class DatasetMappingService:
|
|||||||
logger.debug(f"List all mappings with count, skip: {skip}, limit: {limit}")
|
logger.debug(f"List all mappings with count, skip: {skip}, limit: {limit}")
|
||||||
|
|
||||||
# 构建查询
|
# 构建查询
|
||||||
query = select(LabelingProject)
|
query = self._build_query_with_dataset_name()
|
||||||
if not include_deleted:
|
if not include_deleted:
|
||||||
query = query.where(LabelingProject.deleted_at.is_(None))
|
query = query.where(LabelingProject.deleted_at.is_(None))
|
||||||
|
|
||||||
@@ -235,10 +289,10 @@ class DatasetMappingService:
|
|||||||
.limit(limit)
|
.limit(limit)
|
||||||
.order_by(LabelingProject.created_at.desc())
|
.order_by(LabelingProject.created_at.desc())
|
||||||
)
|
)
|
||||||
mappings = result.scalars().all()
|
rows = result.all()
|
||||||
|
|
||||||
logger.debug(f"Found {len(mappings)} mappings, total: {total}")
|
logger.debug(f"Found {len(rows)} mappings, total: {total}")
|
||||||
return [DatasetMappingResponse.model_validate(mapping) for mapping in mappings], total
|
return [self._to_response_from_row(row) for row in rows], total
|
||||||
|
|
||||||
async def get_mappings_by_source_with_count(
|
async def get_mappings_by_source_with_count(
|
||||||
self,
|
self,
|
||||||
@@ -251,7 +305,7 @@ class DatasetMappingService:
|
|||||||
logger.debug(f"Get mappings by source dataset id with count: {dataset_id}")
|
logger.debug(f"Get mappings by source dataset id with count: {dataset_id}")
|
||||||
|
|
||||||
# 构建查询
|
# 构建查询
|
||||||
query = select(LabelingProject).where(
|
query = self._build_query_with_dataset_name().where(
|
||||||
LabelingProject.dataset_id == dataset_id
|
LabelingProject.dataset_id == dataset_id
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -275,7 +329,7 @@ class DatasetMappingService:
|
|||||||
.limit(limit)
|
.limit(limit)
|
||||||
.order_by(LabelingProject.created_at.desc())
|
.order_by(LabelingProject.created_at.desc())
|
||||||
)
|
)
|
||||||
mappings = result.scalars().all()
|
rows = result.all()
|
||||||
|
|
||||||
logger.debug(f"Found {len(mappings)} mappings, total: {total}")
|
logger.debug(f"Found {len(rows)} mappings, total: {total}")
|
||||||
return [DatasetMappingResponse.model_validate(mapping) for mapping in mappings], total
|
return [self._to_response_from_row(row) for row in rows], total
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
"""
|
||||||
|
Annotation Template Service
|
||||||
|
"""
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from uuid import uuid4
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.db.models.annotation_management import AnnotationTemplate
|
||||||
|
from app.module.annotation.schema.template import (
|
||||||
|
CreateAnnotationTemplateRequest,
|
||||||
|
UpdateAnnotationTemplateRequest,
|
||||||
|
AnnotationTemplateResponse,
|
||||||
|
AnnotationTemplateListResponse,
|
||||||
|
TemplateConfiguration
|
||||||
|
)
|
||||||
|
from app.module.annotation.utils.config_validator import LabelStudioConfigValidator
|
||||||
|
|
||||||
|
|
||||||
|
class AnnotationTemplateService:
|
||||||
|
"""标注模板服务"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_label_studio_config(config: TemplateConfiguration) -> str:
|
||||||
|
"""
|
||||||
|
从配置JSON生成Label Studio XML配置
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: 模板配置对象
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Label Studio XML字符串
|
||||||
|
"""
|
||||||
|
xml_parts = ['<View>']
|
||||||
|
|
||||||
|
# 生成对象定义
|
||||||
|
for obj in config.objects:
|
||||||
|
obj_attrs = [
|
||||||
|
f'name="{obj.name}"',
|
||||||
|
f'value="{obj.value}"'
|
||||||
|
]
|
||||||
|
xml_parts.append(f' <{obj.type} {" ".join(obj_attrs)}/>')
|
||||||
|
|
||||||
|
# 生成标签定义
|
||||||
|
for label in config.labels:
|
||||||
|
label_attrs = [
|
||||||
|
f'name="{label.from_name}"',
|
||||||
|
f'toName="{label.to_name}"'
|
||||||
|
]
|
||||||
|
|
||||||
|
# 添加可选属性
|
||||||
|
if label.required:
|
||||||
|
label_attrs.append('required="true"')
|
||||||
|
|
||||||
|
tag_type = label.type.capitalize() if label.type else "Choices"
|
||||||
|
|
||||||
|
# 处理带选项的标签类型
|
||||||
|
if label.options or label.labels:
|
||||||
|
choices = label.options or label.labels or []
|
||||||
|
xml_parts.append(f' <{tag_type} {" ".join(label_attrs)}>')
|
||||||
|
for choice in choices:
|
||||||
|
xml_parts.append(f' <Label value="{choice}"/>')
|
||||||
|
xml_parts.append(f' </{tag_type}>')
|
||||||
|
else:
|
||||||
|
# 处理简单标签类型
|
||||||
|
xml_parts.append(f' <{tag_type} {" ".join(label_attrs)}/>')
|
||||||
|
|
||||||
|
xml_parts.append('</View>')
|
||||||
|
return '\n'.join(xml_parts)
|
||||||
|
|
||||||
|
async def create_template(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
request: CreateAnnotationTemplateRequest
|
||||||
|
) -> AnnotationTemplateResponse:
|
||||||
|
"""
|
||||||
|
创建标注模板
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: 数据库会话
|
||||||
|
request: 创建请求
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
创建的模板响应
|
||||||
|
"""
|
||||||
|
# 验证配置JSON
|
||||||
|
config_dict = request.configuration.model_dump(mode='json', by_alias=False)
|
||||||
|
valid, error = LabelStudioConfigValidator.validate_configuration_json(config_dict)
|
||||||
|
if not valid:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid configuration: {error}")
|
||||||
|
|
||||||
|
# 生成Label Studio XML配置(用于验证,但不存储)
|
||||||
|
label_config = self.generate_label_studio_config(request.configuration)
|
||||||
|
|
||||||
|
# 验证生成的XML
|
||||||
|
valid, error = LabelStudioConfigValidator.validate_xml(label_config)
|
||||||
|
if not valid:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Generated XML is invalid: {error}")
|
||||||
|
|
||||||
|
# 创建模板对象(不包含label_config字段)
|
||||||
|
template = AnnotationTemplate(
|
||||||
|
id=str(uuid4()),
|
||||||
|
name=request.name,
|
||||||
|
description=request.description,
|
||||||
|
data_type=request.data_type,
|
||||||
|
labeling_type=request.labeling_type,
|
||||||
|
configuration=config_dict,
|
||||||
|
style=request.style,
|
||||||
|
category=request.category,
|
||||||
|
built_in=False,
|
||||||
|
version="1.0.0",
|
||||||
|
created_at=datetime.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(template)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(template)
|
||||||
|
|
||||||
|
return self._to_response(template)
|
||||||
|
|
||||||
|
async def get_template(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
template_id: str
|
||||||
|
) -> Optional[AnnotationTemplateResponse]:
|
||||||
|
"""
|
||||||
|
获取单个模板
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: 数据库会话
|
||||||
|
template_id: 模板ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
模板响应或None
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(AnnotationTemplate)
|
||||||
|
.where(
|
||||||
|
AnnotationTemplate.id == template_id,
|
||||||
|
AnnotationTemplate.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
template = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if template:
|
||||||
|
return self._to_response(template)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def list_templates(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
page: int = 1,
|
||||||
|
size: int = 10,
|
||||||
|
category: Optional[str] = None,
|
||||||
|
data_type: Optional[str] = None,
|
||||||
|
labeling_type: Optional[str] = None,
|
||||||
|
built_in: Optional[bool] = None
|
||||||
|
) -> AnnotationTemplateListResponse:
|
||||||
|
"""
|
||||||
|
获取模板列表
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: 数据库会话
|
||||||
|
page: 页码
|
||||||
|
size: 每页大小
|
||||||
|
category: 分类筛选
|
||||||
|
data_type: 数据类型筛选
|
||||||
|
labeling_type: 标注类型筛选
|
||||||
|
built_in: 是否内置模板筛选
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
模板列表响应
|
||||||
|
"""
|
||||||
|
# 构建查询条件
|
||||||
|
conditions: List = [AnnotationTemplate.deleted_at.is_(None)]
|
||||||
|
|
||||||
|
if category:
|
||||||
|
conditions.append(AnnotationTemplate.category == category) # type: ignore
|
||||||
|
if data_type:
|
||||||
|
conditions.append(AnnotationTemplate.data_type == data_type) # type: ignore
|
||||||
|
if labeling_type:
|
||||||
|
conditions.append(AnnotationTemplate.labeling_type == labeling_type) # type: ignore
|
||||||
|
if built_in is not None:
|
||||||
|
conditions.append(AnnotationTemplate.built_in == built_in) # type: ignore
|
||||||
|
|
||||||
|
# 查询总数
|
||||||
|
count_result = await db.execute(
|
||||||
|
select(func.count()).select_from(AnnotationTemplate).where(*conditions)
|
||||||
|
)
|
||||||
|
total = count_result.scalar() or 0
|
||||||
|
|
||||||
|
# 分页查询
|
||||||
|
result = await db.execute(
|
||||||
|
select(AnnotationTemplate)
|
||||||
|
.where(*conditions)
|
||||||
|
.order_by(AnnotationTemplate.created_at.desc())
|
||||||
|
.limit(size)
|
||||||
|
.offset((page - 1) * size)
|
||||||
|
)
|
||||||
|
templates = result.scalars().all()
|
||||||
|
|
||||||
|
return AnnotationTemplateListResponse(
|
||||||
|
content=[self._to_response(t) for t in templates],
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
size=size,
|
||||||
|
totalPages=(total + size - 1) // size
|
||||||
|
)
|
||||||
|
|
||||||
|
async def update_template(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
template_id: str,
|
||||||
|
request: UpdateAnnotationTemplateRequest
|
||||||
|
) -> Optional[AnnotationTemplateResponse]:
|
||||||
|
"""
|
||||||
|
更新模板
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: 数据库会话
|
||||||
|
template_id: 模板ID
|
||||||
|
request: 更新请求
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
更新后的模板响应或None
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(AnnotationTemplate)
|
||||||
|
.where(
|
||||||
|
AnnotationTemplate.id == template_id,
|
||||||
|
AnnotationTemplate.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
template = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not template:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 更新字段
|
||||||
|
update_data = request.model_dump(exclude_unset=True, by_alias=False)
|
||||||
|
|
||||||
|
for field, value in update_data.items():
|
||||||
|
if field == 'configuration' and value is not None:
|
||||||
|
# 验证配置JSON
|
||||||
|
config_dict = value.model_dump(mode='json', by_alias=False)
|
||||||
|
valid, error = LabelStudioConfigValidator.validate_configuration_json(config_dict)
|
||||||
|
if not valid:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid configuration: {error}")
|
||||||
|
|
||||||
|
# 重新生成Label Studio XML配置(用于验证)
|
||||||
|
label_config = self.generate_label_studio_config(value)
|
||||||
|
|
||||||
|
# 验证生成的XML
|
||||||
|
valid, error = LabelStudioConfigValidator.validate_xml(label_config)
|
||||||
|
if not valid:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Generated XML is invalid: {error}")
|
||||||
|
|
||||||
|
# 只更新configuration字段,不存储label_config
|
||||||
|
setattr(template, field, config_dict)
|
||||||
|
else:
|
||||||
|
setattr(template, field, value)
|
||||||
|
|
||||||
|
template.updated_at = datetime.now() # type: ignore
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(template)
|
||||||
|
|
||||||
|
return self._to_response(template)
|
||||||
|
|
||||||
|
async def delete_template(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
template_id: str
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
删除模板(软删除)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: 数据库会话
|
||||||
|
template_id: 模板ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否删除成功
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(AnnotationTemplate)
|
||||||
|
.where(
|
||||||
|
AnnotationTemplate.id == template_id,
|
||||||
|
AnnotationTemplate.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
template = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not template:
|
||||||
|
return False
|
||||||
|
|
||||||
|
template.deleted_at = datetime.now() # type: ignore
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _to_response(self, template: AnnotationTemplate) -> AnnotationTemplateResponse:
|
||||||
|
"""
|
||||||
|
转换为响应对象
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template: 数据库模型对象
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
模板响应对象
|
||||||
|
"""
|
||||||
|
# 将配置JSON转换为TemplateConfiguration对象
|
||||||
|
from typing import cast, Dict, Any
|
||||||
|
config_dict = cast(Dict[str, Any], template.configuration)
|
||||||
|
config = TemplateConfiguration(**config_dict)
|
||||||
|
|
||||||
|
# 动态生成Label Studio XML配置
|
||||||
|
label_config = self.generate_label_studio_config(config)
|
||||||
|
|
||||||
|
# 使用model_validate从ORM对象创建响应对象
|
||||||
|
response = AnnotationTemplateResponse.model_validate(template)
|
||||||
|
response.configuration = config
|
||||||
|
response.label_config = label_config # type: ignore
|
||||||
|
|
||||||
|
return response
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""
|
||||||
|
Annotation Module Utilities
|
||||||
|
"""
|
||||||
|
from .config_validator import LabelStudioConfigValidator
|
||||||
|
|
||||||
|
__all__ = ['LabelStudioConfigValidator']
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
"""
|
||||||
|
Label Studio Configuration Validation Utilities
|
||||||
|
"""
|
||||||
|
from typing import Dict, List, Tuple, Optional
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
|
||||||
|
class LabelStudioConfigValidator:
|
||||||
|
"""验证Label Studio配置的工具类"""
|
||||||
|
|
||||||
|
# 支持的控件类型
|
||||||
|
CONTROL_TYPES = {
|
||||||
|
'Choices', 'RectangleLabels', 'PolygonLabels', 'Labels',
|
||||||
|
'TextArea', 'Rating', 'KeyPointLabels', 'BrushLabels',
|
||||||
|
'EllipseLabels', 'VideoRectangle', 'AudioPlus'
|
||||||
|
}
|
||||||
|
|
||||||
|
# 支持的对象类型
|
||||||
|
OBJECT_TYPES = {
|
||||||
|
'Image', 'Text', 'Audio', 'Video', 'HyperText',
|
||||||
|
'AudioPlus', 'Paragraphs', 'Table'
|
||||||
|
}
|
||||||
|
|
||||||
|
# 需要子标签的控件类型
|
||||||
|
LABEL_BASED_CONTROLS = {
|
||||||
|
'Choices', 'RectangleLabels', 'PolygonLabels', 'Labels',
|
||||||
|
'KeyPointLabels', 'BrushLabels', 'EllipseLabels'
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validate_xml(xml_string: str) -> Tuple[bool, Optional[str]]:
|
||||||
|
"""
|
||||||
|
验证XML格式是否正确
|
||||||
|
|
||||||
|
Args:
|
||||||
|
xml_string: Label Studio XML配置字符串
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(是否有效, 错误信息)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(xml_string)
|
||||||
|
|
||||||
|
# 检查根元素
|
||||||
|
if root.tag != 'View':
|
||||||
|
return False, "Root element must be <View>"
|
||||||
|
|
||||||
|
# 检查是否有对象定义
|
||||||
|
objects = [child for child in root if child.tag in LabelStudioConfigValidator.OBJECT_TYPES]
|
||||||
|
if not objects:
|
||||||
|
return False, "No data objects (Image, Text, etc.) found"
|
||||||
|
|
||||||
|
# 检查是否有控件定义
|
||||||
|
controls = [child for child in root if child.tag in LabelStudioConfigValidator.CONTROL_TYPES]
|
||||||
|
if not controls:
|
||||||
|
return False, "No annotation controls found"
|
||||||
|
|
||||||
|
# 验证每个控件
|
||||||
|
for control in controls:
|
||||||
|
valid, error = LabelStudioConfigValidator._validate_control(control)
|
||||||
|
if not valid:
|
||||||
|
return False, f"Control {control.tag}: {error}"
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
except ET.ParseError as e:
|
||||||
|
return False, f"XML parse error: {str(e)}"
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Validation error: {str(e)}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_control(control: ET.Element) -> Tuple[bool, Optional[str]]:
|
||||||
|
"""
|
||||||
|
验证单个控件元素
|
||||||
|
|
||||||
|
Args:
|
||||||
|
control: 控件XML元素
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(是否有效, 错误信息)
|
||||||
|
"""
|
||||||
|
# 检查必需属性
|
||||||
|
if 'name' not in control.attrib:
|
||||||
|
return False, "Missing 'name' attribute"
|
||||||
|
|
||||||
|
if 'toName' not in control.attrib:
|
||||||
|
return False, "Missing 'toName' attribute"
|
||||||
|
|
||||||
|
# 检查标签型控件是否有子标签
|
||||||
|
if control.tag in LabelStudioConfigValidator.LABEL_BASED_CONTROLS:
|
||||||
|
labels = control.findall('Label')
|
||||||
|
if not labels:
|
||||||
|
return False, f"{control.tag} must have at least one <Label> child"
|
||||||
|
|
||||||
|
# 检查每个标签是否有value
|
||||||
|
for label in labels:
|
||||||
|
if 'value' not in label.attrib:
|
||||||
|
return False, "Label missing 'value' attribute"
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def extract_label_values(xml_string: str) -> Dict[str, List[str]]:
|
||||||
|
"""
|
||||||
|
从XML中提取所有标签值
|
||||||
|
|
||||||
|
Args:
|
||||||
|
xml_string: Label Studio XML配置字符串
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
字典,键为控件名称,值为标签值列表
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(xml_string)
|
||||||
|
controls = [child for child in root if child.tag in LabelStudioConfigValidator.LABEL_BASED_CONTROLS]
|
||||||
|
|
||||||
|
for control in controls:
|
||||||
|
control_name = control.get('name', 'unknown')
|
||||||
|
labels = control.findall('Label')
|
||||||
|
label_values = [label.get('value', '') for label in labels]
|
||||||
|
result[control_name] = label_values
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validate_configuration_json(config: Dict) -> Tuple[bool, Optional[str]]:
|
||||||
|
"""
|
||||||
|
验证配置JSON结构
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: 配置字典
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(是否有效, 错误信息)
|
||||||
|
"""
|
||||||
|
# 检查必需字段
|
||||||
|
if 'labels' not in config:
|
||||||
|
return False, "Missing 'labels' field"
|
||||||
|
|
||||||
|
if 'objects' not in config:
|
||||||
|
return False, "Missing 'objects' field"
|
||||||
|
|
||||||
|
if not isinstance(config['labels'], list):
|
||||||
|
return False, "'labels' must be an array"
|
||||||
|
|
||||||
|
if not isinstance(config['objects'], list):
|
||||||
|
return False, "'objects' must be an array"
|
||||||
|
|
||||||
|
if not config['labels']:
|
||||||
|
return False, "'labels' array cannot be empty"
|
||||||
|
|
||||||
|
if not config['objects']:
|
||||||
|
return False, "'objects' array cannot be empty"
|
||||||
|
|
||||||
|
# 验证每个标签定义
|
||||||
|
for idx, label in enumerate(config['labels']):
|
||||||
|
valid, error = LabelStudioConfigValidator._validate_label_definition(label)
|
||||||
|
if not valid:
|
||||||
|
return False, f"Label {idx}: {error}"
|
||||||
|
|
||||||
|
# 验证每个对象定义
|
||||||
|
for idx, obj in enumerate(config['objects']):
|
||||||
|
valid, error = LabelStudioConfigValidator._validate_object_definition(obj)
|
||||||
|
if not valid:
|
||||||
|
return False, f"Object {idx}: {error}"
|
||||||
|
|
||||||
|
# 验证toName引用
|
||||||
|
object_names = {obj['name'] for obj in config['objects']}
|
||||||
|
for label in config['labels']:
|
||||||
|
to_name = label.get('toName') or label.get('to_name')
|
||||||
|
from_name = label.get('fromName') or label.get('from_name')
|
||||||
|
if to_name not in object_names:
|
||||||
|
return False, f"Label '{from_name}' references unknown object '{to_name}'"
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_label_definition(label: Dict) -> Tuple[bool, Optional[str]]:
|
||||||
|
"""验证标签定义"""
|
||||||
|
# Support both camelCase and snake_case
|
||||||
|
from_name = label.get('fromName') or label.get('from_name')
|
||||||
|
to_name = label.get('toName') or label.get('to_name')
|
||||||
|
label_type = label.get('type')
|
||||||
|
|
||||||
|
if not from_name:
|
||||||
|
return False, "Missing required field 'fromName'"
|
||||||
|
if not to_name:
|
||||||
|
return False, "Missing required field 'toName'"
|
||||||
|
if not label_type:
|
||||||
|
return False, "Missing required field 'type'"
|
||||||
|
|
||||||
|
# 检查类型是否支持
|
||||||
|
if label_type not in LabelStudioConfigValidator.CONTROL_TYPES:
|
||||||
|
return False, f"Unsupported control type '{label_type}'"
|
||||||
|
|
||||||
|
# 检查标签型控件是否有选项或标签
|
||||||
|
if label_type in LabelStudioConfigValidator.LABEL_BASED_CONTROLS:
|
||||||
|
if 'options' not in label and 'labels' not in label:
|
||||||
|
return False, f"{label_type} must have 'options' or 'labels' field"
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_object_definition(obj: Dict) -> Tuple[bool, Optional[str]]:
|
||||||
|
"""验证对象定义"""
|
||||||
|
required_fields = ['name', 'type', 'value']
|
||||||
|
|
||||||
|
for field in required_fields:
|
||||||
|
if field not in obj:
|
||||||
|
return False, f"Missing required field '{field}'"
|
||||||
|
|
||||||
|
# 检查类型是否支持
|
||||||
|
if obj['type'] not in LabelStudioConfigValidator.OBJECT_TYPES:
|
||||||
|
return False, f"Unsupported object type '{obj['type']}'"
|
||||||
|
|
||||||
|
# 检查value格式
|
||||||
|
if not obj['value'].startswith('$'):
|
||||||
|
return False, "Object value must start with '$' (e.g., '$image')"
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
|
# 使用示例
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# 验证XML
|
||||||
|
xml = """<View>
|
||||||
|
<Image name="image" value="$image"/>
|
||||||
|
<Choices name="choice" toName="image" required="true">
|
||||||
|
<Label value="Cat"/>
|
||||||
|
<Label value="Dog"/>
|
||||||
|
</Choices>
|
||||||
|
</View>"""
|
||||||
|
|
||||||
|
valid, error = LabelStudioConfigValidator.validate_xml(xml)
|
||||||
|
print(f"XML Valid: {valid}, Error: {error}")
|
||||||
|
|
||||||
|
# 验证配置JSON
|
||||||
|
config = {
|
||||||
|
"labels": [
|
||||||
|
{
|
||||||
|
"fromName": "choice",
|
||||||
|
"toName": "image",
|
||||||
|
"type": "Choices",
|
||||||
|
"options": ["Cat", "Dog"],
|
||||||
|
"required": True
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"objects": [
|
||||||
|
{
|
||||||
|
"name": "image",
|
||||||
|
"type": "Image",
|
||||||
|
"value": "$image"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
valid, error = LabelStudioConfigValidator.validate_configuration_json(config)
|
||||||
|
print(f"Config Valid: {valid}, Error: {error}")
|
||||||
@@ -1,21 +1,379 @@
|
|||||||
use datamate;
|
use datamate;
|
||||||
|
|
||||||
CREATE TABLE t_dm_annotation_templates (
|
CREATE TABLE t_dm_annotation_templates (
|
||||||
id VARCHAR(36) PRIMARY KEY,
|
id VARCHAR(36) PRIMARY KEY COMMENT 'UUID',
|
||||||
name VARCHAR(32) NOT NULL COMMENT '模板名称',
|
name VARCHAR(100) NOT NULL COMMENT '模板名称',
|
||||||
description VARCHAR(255) COMMENT '模板描述',
|
description VARCHAR(500) COMMENT '模板描述',
|
||||||
configuration JSON,
|
data_type VARCHAR(50) NOT NULL COMMENT '数据类型: image/text/audio/video/timeseries',
|
||||||
|
labeling_type VARCHAR(50) NOT NULL COMMENT '标注类型: classification/detection/segmentation/ner/relation/etc',
|
||||||
|
configuration JSON NOT NULL COMMENT '标注配置(包含labels定义等)',
|
||||||
|
style VARCHAR(32) NOT NULL COMMENT '样式配置: horizontal/vertical',
|
||||||
|
category VARCHAR(50) DEFAULT 'custom' COMMENT '模板分类: medical/general/custom/system',
|
||||||
|
built_in BOOLEAN DEFAULT FALSE COMMENT '是否系统内置模板',
|
||||||
|
version VARCHAR(20) DEFAULT '1.0' COMMENT '模板版本',
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
deleted_at TIMESTAMP NULL COMMENT '删除时间(软删除)'
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
);
|
deleted_at TIMESTAMP NULL COMMENT '删除时间(软删除)',
|
||||||
|
INDEX idx_data_type (data_type),
|
||||||
|
INDEX idx_labeling_type (labeling_type),
|
||||||
|
INDEX idx_category (category),
|
||||||
|
INDEX idx_built_in (built_in)
|
||||||
|
) COMMENT='标注配置模板表';
|
||||||
|
|
||||||
CREATE TABLE t_dm_labeling_projects (
|
CREATE TABLE t_dm_labeling_projects (
|
||||||
id VARCHAR(36) PRIMARY KEY,
|
id VARCHAR(36) PRIMARY KEY COMMENT 'UUID',
|
||||||
dataset_id VARCHAR(36) NOT NULL COMMENT '数据集ID',
|
dataset_id VARCHAR(36) NOT NULL COMMENT '数据集ID',
|
||||||
name VARCHAR(32) NOT NULL COMMENT '项目名称',
|
name VARCHAR(100) NOT NULL COMMENT '项目名称',
|
||||||
labeling_project_id VARCHAR(8) NOT NULL COMMENT 'Label Studio项目ID',
|
labeling_project_id VARCHAR(8) NOT NULL COMMENT 'Label Studio项目ID',
|
||||||
configuration JSON,
|
template_id VARCHAR(36) NULL COMMENT '使用的模板ID',
|
||||||
progress JSON,
|
configuration JSON COMMENT '项目配置(可能包含对模板的自定义修改)',
|
||||||
|
progress JSON COMMENT '项目进度信息',
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
deleted_at TIMESTAMP NULL COMMENT '删除时间(软删除)'
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
);
|
deleted_at TIMESTAMP NULL COMMENT '删除时间(软删除)',
|
||||||
|
FOREIGN KEY (template_id) REFERENCES t_dm_annotation_templates(id) ON DELETE SET NULL,
|
||||||
|
INDEX idx_dataset_id (dataset_id),
|
||||||
|
INDEX idx_template_id (template_id),
|
||||||
|
INDEX idx_labeling_project_id (labeling_project_id)
|
||||||
|
) COMMENT='标注项目表';
|
||||||
|
|
||||||
|
|
||||||
|
-- 内置标注模板初始化数据
|
||||||
|
-- 这些模板将在系统首次启动时自动创建
|
||||||
|
-- 使用 INSERT ... ON DUPLICATE KEY UPDATE 来覆盖已存在的记录
|
||||||
|
|
||||||
|
-- 1. 图像分类模板
|
||||||
|
INSERT INTO t_dm_annotation_templates (
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
data_type,
|
||||||
|
labeling_type,
|
||||||
|
configuration,
|
||||||
|
style,
|
||||||
|
category,
|
||||||
|
built_in,
|
||||||
|
version,
|
||||||
|
created_at
|
||||||
|
) VALUES (
|
||||||
|
'tpl-image-classification-001',
|
||||||
|
'Image Classification',
|
||||||
|
'Simple image classification with multiple choice labels',
|
||||||
|
'image',
|
||||||
|
'classification',
|
||||||
|
JSON_OBJECT(
|
||||||
|
'labels', JSON_ARRAY(
|
||||||
|
JSON_OBJECT(
|
||||||
|
'fromName', 'choice',
|
||||||
|
'toName', 'image',
|
||||||
|
'type', 'Choices',
|
||||||
|
'options', JSON_ARRAY('Cat', 'Dog', 'Bird', 'Other'),
|
||||||
|
'required', true,
|
||||||
|
'description', 'Select the category that best describes the image'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'objects', JSON_ARRAY(
|
||||||
|
JSON_OBJECT(
|
||||||
|
'name', 'image',
|
||||||
|
'type', 'Image',
|
||||||
|
'value', '$image'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'horizontal',
|
||||||
|
'computer-vision',
|
||||||
|
1,
|
||||||
|
'1.0.0',
|
||||||
|
NOW()
|
||||||
|
)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
description = VALUES(description),
|
||||||
|
data_type = VALUES(data_type),
|
||||||
|
labeling_type = VALUES(labeling_type),
|
||||||
|
configuration = VALUES(configuration),
|
||||||
|
style = VALUES(style),
|
||||||
|
category = VALUES(category),
|
||||||
|
built_in = VALUES(built_in),
|
||||||
|
version = VALUES(version),
|
||||||
|
updated_at = NOW();
|
||||||
|
|
||||||
|
-- 2. 目标检测模板(矩形框)
|
||||||
|
INSERT INTO t_dm_annotation_templates (
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
data_type,
|
||||||
|
labeling_type,
|
||||||
|
configuration,
|
||||||
|
style,
|
||||||
|
category,
|
||||||
|
built_in,
|
||||||
|
version,
|
||||||
|
created_at
|
||||||
|
) VALUES (
|
||||||
|
'tpl-object-detection-001',
|
||||||
|
'Object Detection (Bounding Box)',
|
||||||
|
'Object detection using rectangular bounding boxes',
|
||||||
|
'image',
|
||||||
|
'object-detection',
|
||||||
|
JSON_OBJECT(
|
||||||
|
'labels', JSON_ARRAY(
|
||||||
|
JSON_OBJECT(
|
||||||
|
'fromName', 'label',
|
||||||
|
'toName', 'image',
|
||||||
|
'type', 'RectangleLabels',
|
||||||
|
'labels', JSON_ARRAY('Person', 'Vehicle', 'Animal', 'Object'),
|
||||||
|
'required', false,
|
||||||
|
'description', 'Draw bounding boxes around objects'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'objects', JSON_ARRAY(
|
||||||
|
JSON_OBJECT(
|
||||||
|
'name', 'image',
|
||||||
|
'type', 'Image',
|
||||||
|
'value', '$image'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'horizontal',
|
||||||
|
'computer-vision',
|
||||||
|
1,
|
||||||
|
'1.0.0',
|
||||||
|
NOW()
|
||||||
|
)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
description = VALUES(description),
|
||||||
|
data_type = VALUES(data_type),
|
||||||
|
labeling_type = VALUES(labeling_type),
|
||||||
|
configuration = VALUES(configuration),
|
||||||
|
style = VALUES(style),
|
||||||
|
category = VALUES(category),
|
||||||
|
built_in = VALUES(built_in),
|
||||||
|
version = VALUES(version),
|
||||||
|
updated_at = NOW();
|
||||||
|
|
||||||
|
-- 3. 图像分割模板(多边形)
|
||||||
|
INSERT INTO t_dm_annotation_templates (
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
data_type,
|
||||||
|
labeling_type,
|
||||||
|
configuration,
|
||||||
|
style,
|
||||||
|
category,
|
||||||
|
built_in,
|
||||||
|
version,
|
||||||
|
created_at
|
||||||
|
) VALUES (
|
||||||
|
'tpl-image-segmentation-001',
|
||||||
|
'Image Segmentation (Polygon)',
|
||||||
|
'Semantic segmentation using polygon annotations',
|
||||||
|
'image',
|
||||||
|
'segmentation',
|
||||||
|
JSON_OBJECT(
|
||||||
|
'labels', JSON_ARRAY(
|
||||||
|
JSON_OBJECT(
|
||||||
|
'fromName', 'label',
|
||||||
|
'toName', 'image',
|
||||||
|
'type', 'PolygonLabels',
|
||||||
|
'labels', JSON_ARRAY('Background', 'Foreground', 'Person', 'Car'),
|
||||||
|
'required', false,
|
||||||
|
'description', 'Draw polygons to segment regions'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'objects', JSON_ARRAY(
|
||||||
|
JSON_OBJECT(
|
||||||
|
'name', 'image',
|
||||||
|
'type', 'Image',
|
||||||
|
'value', '$image'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'horizontal',
|
||||||
|
'computer-vision',
|
||||||
|
1,
|
||||||
|
'1.0.0',
|
||||||
|
NOW()
|
||||||
|
)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
description = VALUES(description),
|
||||||
|
data_type = VALUES(data_type),
|
||||||
|
labeling_type = VALUES(labeling_type),
|
||||||
|
configuration = VALUES(configuration),
|
||||||
|
style = VALUES(style),
|
||||||
|
category = VALUES(category),
|
||||||
|
built_in = VALUES(built_in),
|
||||||
|
version = VALUES(version),
|
||||||
|
updated_at = NOW();
|
||||||
|
|
||||||
|
-- 4. 文本分类模板
|
||||||
|
INSERT INTO t_dm_annotation_templates (
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
data_type,
|
||||||
|
labeling_type,
|
||||||
|
configuration,
|
||||||
|
style,
|
||||||
|
category,
|
||||||
|
built_in,
|
||||||
|
version,
|
||||||
|
created_at
|
||||||
|
) VALUES (
|
||||||
|
'tpl-text-classification-001',
|
||||||
|
'Text Classification',
|
||||||
|
'Classify text into predefined categories',
|
||||||
|
'text',
|
||||||
|
'classification',
|
||||||
|
JSON_OBJECT(
|
||||||
|
'labels', JSON_ARRAY(
|
||||||
|
JSON_OBJECT(
|
||||||
|
'fromName', 'choice',
|
||||||
|
'toName', 'text',
|
||||||
|
'type', 'Choices',
|
||||||
|
'options', JSON_ARRAY('Positive', 'Negative', 'Neutral'),
|
||||||
|
'required', true,
|
||||||
|
'description', 'Sentiment classification'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'objects', JSON_ARRAY(
|
||||||
|
JSON_OBJECT(
|
||||||
|
'name', 'text',
|
||||||
|
'type', 'Text',
|
||||||
|
'value', '$text'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'vertical',
|
||||||
|
'nlp',
|
||||||
|
1,
|
||||||
|
'1.0.0',
|
||||||
|
NOW()
|
||||||
|
)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
description = VALUES(description),
|
||||||
|
data_type = VALUES(data_type),
|
||||||
|
labeling_type = VALUES(labeling_type),
|
||||||
|
configuration = VALUES(configuration),
|
||||||
|
style = VALUES(style),
|
||||||
|
category = VALUES(category),
|
||||||
|
built_in = VALUES(built_in),
|
||||||
|
version = VALUES(version),
|
||||||
|
updated_at = NOW();
|
||||||
|
|
||||||
|
-- 5. 命名实体识别(NER)模板
|
||||||
|
INSERT INTO t_dm_annotation_templates (
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
data_type,
|
||||||
|
labeling_type,
|
||||||
|
configuration,
|
||||||
|
style,
|
||||||
|
category,
|
||||||
|
built_in,
|
||||||
|
version,
|
||||||
|
created_at
|
||||||
|
) VALUES (
|
||||||
|
'tpl-ner-001',
|
||||||
|
'Named Entity Recognition',
|
||||||
|
'Extract and label named entities in text',
|
||||||
|
'text',
|
||||||
|
'ner',
|
||||||
|
JSON_OBJECT(
|
||||||
|
'labels', JSON_ARRAY(
|
||||||
|
JSON_OBJECT(
|
||||||
|
'fromName', 'label',
|
||||||
|
'toName', 'text',
|
||||||
|
'type', 'Labels',
|
||||||
|
'labels', JSON_ARRAY('PERSON', 'ORG', 'LOC', 'DATE', 'MISC'),
|
||||||
|
'required', false,
|
||||||
|
'description', 'Highlight and classify named entities'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'objects', JSON_ARRAY(
|
||||||
|
JSON_OBJECT(
|
||||||
|
'name', 'text',
|
||||||
|
'type', 'Text',
|
||||||
|
'value', '$text'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'vertical',
|
||||||
|
'nlp',
|
||||||
|
1,
|
||||||
|
'1.0.0',
|
||||||
|
NOW()
|
||||||
|
)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
description = VALUES(description),
|
||||||
|
data_type = VALUES(data_type),
|
||||||
|
labeling_type = VALUES(labeling_type),
|
||||||
|
configuration = VALUES(configuration),
|
||||||
|
style = VALUES(style),
|
||||||
|
category = VALUES(category),
|
||||||
|
built_in = VALUES(built_in),
|
||||||
|
version = VALUES(version),
|
||||||
|
updated_at = NOW();
|
||||||
|
|
||||||
|
-- 6. 音频分类模板
|
||||||
|
INSERT INTO t_dm_annotation_templates (
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
data_type,
|
||||||
|
labeling_type,
|
||||||
|
configuration,
|
||||||
|
style,
|
||||||
|
category,
|
||||||
|
built_in,
|
||||||
|
version,
|
||||||
|
created_at
|
||||||
|
) VALUES (
|
||||||
|
'tpl-audio-classification-001',
|
||||||
|
'Audio Classification',
|
||||||
|
'Classify audio clips into categories',
|
||||||
|
'audio',
|
||||||
|
'classification',
|
||||||
|
JSON_OBJECT(
|
||||||
|
'labels', JSON_ARRAY(
|
||||||
|
JSON_OBJECT(
|
||||||
|
'fromName', 'choice',
|
||||||
|
'toName', 'audio',
|
||||||
|
'type', 'Choices',
|
||||||
|
'options', JSON_ARRAY('Speech', 'Music', 'Noise', 'Silence'),
|
||||||
|
'required', true,
|
||||||
|
'description', 'Audio content classification'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'objects', JSON_ARRAY(
|
||||||
|
JSON_OBJECT(
|
||||||
|
'name', 'audio',
|
||||||
|
'type', 'Audio',
|
||||||
|
'value', '$audio'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'horizontal',
|
||||||
|
'audio',
|
||||||
|
1,
|
||||||
|
'1.0.0',
|
||||||
|
NOW()
|
||||||
|
)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
description = VALUES(description),
|
||||||
|
data_type = VALUES(data_type),
|
||||||
|
labeling_type = VALUES(labeling_type),
|
||||||
|
configuration = VALUES(configuration),
|
||||||
|
style = VALUES(style),
|
||||||
|
category = VALUES(category),
|
||||||
|
built_in = VALUES(built_in),
|
||||||
|
version = VALUES(version),
|
||||||
|
updated_at = NOW();
|
||||||
|
|||||||
Reference in New Issue
Block a user