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:
Jason Wang
2025-11-11 09:14:14 +08:00
committed by GitHub
parent 451d3c8207
commit c5ccc56cca
24 changed files with 2794 additions and 253 deletions

View File

@@ -52,8 +52,10 @@ volumes:
label-studio-db:
dataset_volume:
name: datamate-dataset-volume
external: true
networks:
datamate:
driver: bridge
name: datamate-network
name: datamate-network
external: true

View File

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

View File

@@ -3,10 +3,9 @@ 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 } from "../../annotation.api";
import { createAnnotationTaskUsingPost, queryAnnotationTemplatesUsingGet } from "../../annotation.api";
import { Dataset } from "@/pages/DataManagement/dataset.model";
import LabelingConfigEditor from "./LabelingConfigEditor";
import { useRef } from "react";
import type { AnnotationTemplate } from "../../annotation.model";
export default function CreateAnnotationTask({
open,
@@ -19,21 +18,42 @@ export default function CreateAnnotationTask({
}) {
const [form] = Form.useForm();
const [datasets, setDatasets] = useState<Dataset[]>([]);
const [templates, setTemplates] = useState<AnnotationTemplate[]>([]);
const [submitting, setSubmitting] = useState(false);
const [nameManuallyEdited, setNameManuallyEdited] = useState(false);
const editorRef = useRef<any>(null);
const EDITOR_LIST_HEIGHT = 420;
useEffect(() => {
if (!open) return;
const fetchDatasets = async () => {
const { data } = await queryDatasetsUsingGet({
page: 0,
size: 1000,
});
setDatasets(data.content.map(mapDataset) || []);
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([]);
}
};
fetchDatasets();
fetchData();
}, [open]);
// Reset form and manual-edit flag when modal opens
@@ -48,26 +68,28 @@ export default function CreateAnnotationTask({
try {
const values = await form.validateFields();
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?.("创建标注任务成功");
onClose();
onRefresh();
} catch (err: any) {
console.error("Create annotation task failed", err);
const msg = err?.message || err?.data?.message || "创建失败,请稍后重试";
// show a user friendly message
(message as any)?.error?.(msg);
} finally {
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 (
<Modal
open={open}
@@ -83,7 +105,7 @@ export default function CreateAnnotationTask({
</Button>
</>
}
width={1200}
width={800}
>
<Form form={form} layout="vertical">
{/* 数据集 与 标注工程名称 并排显示(数据集在左) */}
@@ -132,67 +154,41 @@ export default function CreateAnnotationTask({
/>
</Form.Item>
</div>
{/* 描述变为可选 */}
<Form.Item label="描述" name="description">
<TextArea placeholder="(可选)详细描述标注任务的要求和目标" rows={3} />
</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
name="labelingConfig"
rules={[
{
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 />
</Form.Item>
</div>
{/* Row 2, Col 2: 预览,与编辑列表在同一行,保持一致高度 */}
<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>
</div>
</div>
</div>
{/* 标注模板选择 */}
<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>
);

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { Card, Button, Table, message, Modal } from "antd";
import { Card, Button, Table, message, Modal, Tabs } from "antd";
import {
PlusOutlined,
EditOutlined,
@@ -16,13 +16,14 @@ import {
syncAnnotationTaskUsingPost,
} from "../annotation.api";
import { mapAnnotationTask } from "../annotation.const";
import CreateAnnotationTask from "../Create/components/CreateAnnptationTaskDialog";
import CreateAnnotationTask from "../Create/components/CreateAnnotationTaskDialog";
import { ColumnType } from "antd/es/table";
import { TemplateList } from "../Template";
// Note: DevelopmentInProgress intentionally not used here
export default function DataAnnotation() {
// 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 [showCreateDialog, setShowCreateDialog] = useState(false);
@@ -46,10 +47,10 @@ export default function DataAnnotation() {
let mounted = true;
(async () => {
try {
const baseUrl = `http://${window.location.hostname}:${window.location.port + 1}`;
if (mounted) setLabelStudioBase(baseUrl);
const baseUrl = `http://${window.location.hostname}:${parseInt(window.location.port) + 1}`;
if (mounted) setLabelStudioBase(baseUrl);
} catch (e) {
if (mounted) setLabelStudioBase(null);
if (mounted) setLabelStudioBase(null);
}
})();
return () => {
@@ -294,73 +295,104 @@ export default function DataAnnotation() {
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold"></h1>
<div className="flex items-center space-x-2">
{/* Batch action buttons - availability depends on selection count */}
<div className="flex items-center space-x-1">
<Button
onClick={() => handleBatchSync(50)}
disabled={selectedRowKeys.length === 0}
>
</Button>
<Button
danger
onClick={handleBatchDelete}
disabled={selectedRowKeys.length === 0}
>
</Button>
</div>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setShowCreateDialog(true)}
>
</Button>
</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 */}
{viewMode === "list" ? (
<Card>
<Table
key="id"
rowKey="id"
loading={loading}
columns={columns}
dataSource={tableData}
pagination={pagination}
rowSelection={{
selectedRowKeys,
onChange: (keys, rows) => {
setSelectedRowKeys(keys as (string | number)[]);
setSelectedRows(rows as any[]);
},
}}
scroll={{ x: "max-content", y: "calc(100vh - 20rem)" }}
/>
</Card>
) : (
<CardView data={tableData} operations={operations as any} pagination={pagination} loading={loading} />
)}
<CreateAnnotationTask
open={showCreateDialog}
onClose={() => setShowCreateDialog(false)}
onRefresh={fetchData}
{/* 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
onClick={() => handleBatchSync(50)}
disabled={selectedRowKeys.length === 0}
>
</Button>
<Button
danger
onClick={handleBatchDelete}
disabled={selectedRowKeys.length === 0}
>
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setShowCreateDialog(true)}
>
</Button>
</div>
</div>
{/* Task List/Card */}
{viewMode === "list" ? (
<Card>
<Table
key="id"
rowKey="id"
loading={loading}
columns={columns}
dataSource={tableData}
pagination={pagination}
rowSelection={{
selectedRowKeys,
onChange: (keys, rows) => {
setSelectedRowKeys(keys as (string | number)[]);
setSelectedRows(rows as any[]);
},
}}
scroll={{ x: "max-content", y: "calc(100vh - 24rem)" }}
/>
</Card>
) : (
<CardView
data={tableData}
operations={operations as any}
pagination={pagination}
loading={loading}
/>
)}
<CreateAnnotationTask
open={showCreateDialog}
onClose={() => setShowCreateDialog(false)}
onRefresh={fetchData}
/>
</div>
),
},
{
key: "templates",
label: "标注模板",
children: <TemplateList />,
},
]}
/>
</div>
);

View 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;

View 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;

View 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 };

View File

@@ -0,0 +1,3 @@
export { default as TemplateList } from "./TemplateList";
export { default as TemplateForm } from "./TemplateForm";
export { default as TemplateDetail } from "./TemplateDetail";

View File

@@ -102,30 +102,30 @@ export function getAnnotationStatisticsUsingGet(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) {
return post("/api/v1/annotation/templates", data);
return post("/api/annotation/templates", data);
}
export function queryAnnotationTemplateByIdUsingGet(
templateId: string | number
) {
return get(`/api/v1/annotation/templates/${templateId}`);
return get(`/api/annotation/templates/${templateId}`);
}
export function updateAnnotationTemplateByIdUsingPut(
templateId: string | number,
data: any
) {
return put(`/api/v1/annotation/templates/${templateId}`, data);
return put(`/api/annotation/templates/${templateId}`, data);
}
export function deleteAnnotationTemplateByIdUsingDelete(
templateId: string | number
) {
return del(`/api/v1/annotation/templates/${templateId}`);
return del(`/api/annotation/templates/${templateId}`);
}
// 主动学习相关接口

View File

@@ -51,8 +51,9 @@ export function mapAnnotationTask(task: any) {
projId: labelingProjId,
name: task.name,
description: task.description || "",
createdAt: task.createdAt,
updatedAt: task.updatedAt,
datasetName: task.datasetName || task.dataset_name || "-",
createdAt: task.createdAt || task.created_at || "-",
updatedAt: task.updatedAt || task.updated_at || "-",
icon: <StickyNote />,
iconColor: "bg-blue-100",
status: {

View File

@@ -13,9 +13,9 @@ export interface AnnotationTask {
name: string;
labelingProjId: string;
datasetId: string;
annotationCount: number;
description?: string;
assignedTo?: string;
progress: number;
@@ -27,7 +27,54 @@ export interface AnnotationTask {
status: AnnotationTaskStatus;
totalDataCount: number;
type: DatasetType;
createdAt: 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;
}

View File

@@ -16,11 +16,6 @@ import CleansingTemplateCreate from "@/pages/DataCleansing/Create/CreateTempate"
import DataAnnotation from "@/pages/DataAnnotation/Home/DataAnnotation";
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 InstructionTemplateCreate from "@/pages/SynthesisTask/CreateTemplate";
@@ -139,28 +134,6 @@ const router = createBrowserRouter([
path: "create-task",
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,
},
],
},
],
},
{

View File

@@ -3,25 +3,32 @@ Tables of Annotation Management Module
"""
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 app.db.session import Base
class AnnotationTemplate(Base):
"""标注模板模型"""
"""标注配置模板模型"""
__tablename__ = "t_dm_annotation_templates"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="UUID主键ID")
name = Column(String(32), nullable=False, comment="模板名称")
description = Column(String(255), nullable=True, comment="模板描述")
configuration = Column(JSON, nullable=True, comment="配置信息(JSON格式)")
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="UUID")
name = Column(String(100), nullable=False, comment="模板名称")
description = Column(String(500), nullable=True, comment="模板描述")
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="创建时间")
updated_at = Column(TIMESTAMP, server_default=func.current_timestamp(), onupdate=func.current_timestamp(), comment="更新时间")
deleted_at = Column(TIMESTAMP, nullable=True, comment="删除时间(软删除)")
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
def is_deleted(self) -> bool:
@@ -29,21 +36,23 @@ class AnnotationTemplate(Base):
return self.deleted_at is not None
class LabelingProject(Base):
"""标注工程表"""
"""标注项目模型"""
__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")
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")
configuration = Column(JSON, nullable=True, comment="标签配置")
progress = Column(JSON, nullable=True, comment="标注进度统计")
template_id = Column(String(36), ForeignKey('t_dm_annotation_templates.id', ondelete='SET NULL'), nullable=True, comment="使用的模板ID")
configuration = Column(JSON, nullable=True, comment="项目配置(可能包含对模板的自定义修改)")
progress = Column(JSON, nullable=True, 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="删除时间(软删除)")
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
def is_deleted(self) -> bool:

View File

@@ -103,6 +103,7 @@ class Client:
"""创建Label Studio项目"""
try:
logger.debug(f"Creating Label Studio project: {title}")
logger.debug(f"Label Studio URL: {self.base_url}/api/projects")
project_data = {
"title": title,
@@ -123,10 +124,28 @@ class Client:
return project
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
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
async def import_tasks(

View File

@@ -3,6 +3,7 @@ from fastapi import APIRouter
from .about import router as about_router
from .project import router as project_router
from .task import router as task_router
from .template import router as template_router
router = APIRouter(
prefix="/annotation",
@@ -11,4 +12,5 @@ router = APIRouter(
router.include_router(about_router)
router.include_router(project_router)
router.include_router(task_router)
router.include_router(task_router)
router.include_router(template_router)

View File

@@ -1,5 +1,6 @@
from typing import Optional
import math
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
@@ -14,6 +15,7 @@ from app.core.config import settings
from ..client import LabelStudioClient
from ..service.mapping import DatasetMappingService
from ..service.sync import SyncService
from ..service.template import AnnotationTemplateService
from ..schema import (
DatasetMappingCreateRequest,
DatasetMappingCreateResponse,
@@ -39,6 +41,8 @@ async def create_mapping(
在数据库中记录这一关联关系,返回Label Studio数据集的ID
注意:一个数据集可以创建多个标注项目
支持通过 template_id 指定标注模板,如果提供了模板ID,则使用模板的配置
"""
try:
dm_client = DatasetManagementService(db)
@@ -46,6 +50,7 @@ async def create_mapping(
token=settings.label_studio_user_token)
mapping_service = DatasetMappingService(db)
sync_service = SyncService(dm_client, ls_client, mapping_service)
template_service = AnnotationTemplateService()
logger.info(f"Create dataset mapping request: {request.dataset_id}")
@@ -65,10 +70,24 @@ async def create_mapping(
dataset_info.description or \
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中创建项目
project_data = await ls_client.create_project(
title=project_name,
description=project_description,
label_config=label_config # 传递模板配置
)
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}")
labeling_project = LabelingProject(
id=str(uuid.uuid4()), # Generate UUID here
dataset_id=request.dataset_id,
labeling_project_id=str(project_id),
name=project_name,
template_id=request.template_id, # Save template_id to database
)
# 创建映射关系,包含项目名称(先持久化映射以获得 mapping.id)

View File

@@ -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)

View File

@@ -11,13 +11,14 @@ class DatasetMappingCreateRequest(BaseModel):
Accept both snake_case and camelCase field names from frontend JSON by
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,
description).
description, template_id).
"""
dataset_id: str = Field(..., alias="datasetId", description="源数据集ID")
name: Optional[str] = Field(None, alias="name", description="标注项目名称")
description: Optional[str] = Field(None, alias="description", description="标注项目描述")
template_id: Optional[str] = Field(None, alias="templateId", description="标注模板ID")
class Config:
# allow population by field name when constructing model programmatically
@@ -34,13 +35,16 @@ class DatasetMappingUpdateRequest(BaseResponseModel):
dataset_id: Optional[str] = Field(None, description="源数据集ID")
class DatasetMappingResponse(BaseModel):
dataset_id: str = Field(..., description="源数据集ID")
"""数据集映射 查询 响应模型"""
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="标注项目名称")
created_at: datetime = Field(..., description="创建时间")
deleted_at: Optional[datetime] = Field(None, description="删除时间")
description: Optional[str] = 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:
from_attributes = True

View File

@@ -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)

View File

@@ -1,12 +1,14 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy import update, func
from sqlalchemy.orm import aliased
from typing import Optional, List, Tuple
from datetime import datetime
import uuid
from app.core.logging import get_logger
from app.db.models import LabelingProject
from app.db.models.dataset_management import Dataset
from app.module.annotation.schema import (
DatasetMappingCreateRequest,
DatasetMappingUpdateRequest,
@@ -21,6 +23,61 @@ class DatasetMappingService:
def __init__(self, db: AsyncSession):
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(
self,
labeling_project: LabelingProject
@@ -28,19 +85,13 @@ class DatasetMappingService:
"""创建数据集映射"""
logger.info(f"Create dataset mapping: {labeling_project.dataset_id} -> {labeling_project.labeling_project_id}")
db_mapping = LabelingProject(
id=str(uuid.uuid4()),
dataset_id=labeling_project.dataset_id,
labeling_project_id=labeling_project.labeling_project_id,
name=labeling_project.name
)
self.db.add(db_mapping)
# Use the passed object directly
self.db.add(labeling_project)
await self.db.commit()
await self.db.refresh(db_mapping)
await self.db.refresh(labeling_project)
logger.debug(f"Mapping created: {db_mapping.id}")
return DatasetMappingResponse.model_validate(db_mapping)
logger.debug(f"Mapping created: {labeling_project.id}")
return await self._to_response(labeling_project)
async def get_mapping_by_source_uuid(
self,
@@ -59,7 +110,7 @@ class DatasetMappingService:
if mapping:
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}")
return None
@@ -72,7 +123,7 @@ class DatasetMappingService:
"""根据源数据集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
)
@@ -82,10 +133,10 @@ class DatasetMappingService:
result = await self.db.execute(
query.order_by(LabelingProject.created_at.desc())
)
mappings = result.scalars().all()
rows = result.all()
logger.debug(f"Found {len(mappings)} mappings")
return [DatasetMappingResponse.model_validate(mapping) for mapping in mappings]
logger.debug(f"Found {len(rows)} mappings")
return [self._to_response_from_row(row) for row in rows]
async def get_mapping_by_labeling_project_id(
self,
@@ -103,8 +154,8 @@ class DatasetMappingService:
mapping = result.scalar_one_or_none()
if mapping:
logger.debug(f"Found mapping: {mapping.mapping_id}")
return DatasetMappingResponse.model_validate(mapping)
logger.debug(f"Found mapping: {mapping.id}")
return await self._to_response(mapping)
logger.debug(f"No mapping found for Label Studio project id: {labeling_project_id}")
return None
@@ -123,9 +174,9 @@ class DatasetMappingService:
if mapping:
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
async def update_mapping(
@@ -184,17 +235,20 @@ class DatasetMappingService:
"""获取所有有效映射"""
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(
select(LabelingProject)
.where(LabelingProject.deleted_at.is_(None))
query
.offset(skip)
.limit(limit)
.order_by(LabelingProject.created_at.desc())
)
mappings = result.scalars().all()
rows = result.all()
logger.debug(f"Found {len(mappings)} mappings")
return [DatasetMappingResponse.model_validate(mapping) for mapping in mappings]
logger.debug(f"Found {len(rows)} mappings")
return [self._to_response_from_row(row) for row in rows]
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}")
# 构建查询
query = select(LabelingProject)
query = self._build_query_with_dataset_name()
if not include_deleted:
query = query.where(LabelingProject.deleted_at.is_(None))
@@ -235,10 +289,10 @@ class DatasetMappingService:
.limit(limit)
.order_by(LabelingProject.created_at.desc())
)
mappings = result.scalars().all()
rows = result.all()
logger.debug(f"Found {len(mappings)} mappings, total: {total}")
return [DatasetMappingResponse.model_validate(mapping) for mapping in mappings], total
logger.debug(f"Found {len(rows)} mappings, total: {total}")
return [self._to_response_from_row(row) for row in rows], total
async def get_mappings_by_source_with_count(
self,
@@ -251,7 +305,7 @@ class DatasetMappingService:
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
)
@@ -275,7 +329,7 @@ class DatasetMappingService:
.limit(limit)
.order_by(LabelingProject.created_at.desc())
)
mappings = result.scalars().all()
rows = result.all()
logger.debug(f"Found {len(mappings)} mappings, total: {total}")
return [DatasetMappingResponse.model_validate(mapping) for mapping in mappings], total
logger.debug(f"Found {len(rows)} mappings, total: {total}")
return [self._to_response_from_row(row) for row in rows], total

View File

@@ -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

View File

@@ -0,0 +1,6 @@
"""
Annotation Module Utilities
"""
from .config_validator import LabelStudioConfigValidator
__all__ = ['LabelStudioConfigValidator']

View File

@@ -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}")

View File

@@ -1,21 +1,379 @@
use datamate;
CREATE TABLE t_dm_annotation_templates (
id VARCHAR(36) PRIMARY KEY,
name VARCHAR(32) NOT NULL COMMENT '模板名称',
description VARCHAR(255) COMMENT '模板描述',
configuration JSON,
id VARCHAR(36) PRIMARY KEY COMMENT 'UUID',
name VARCHAR(100) NOT NULL COMMENT '模板名称',
description VARCHAR(500) COMMENT '模板描述',
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 '创建时间',
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 (
id VARCHAR(36) PRIMARY KEY,
id VARCHAR(36) PRIMARY KEY COMMENT 'UUID',
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',
configuration JSON,
progress JSON,
template_id VARCHAR(36) NULL COMMENT '使用的模板ID',
configuration JSON COMMENT '项目配置(可能包含对模板的自定义修改)',
progress JSON 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();