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:
|
||||
dataset_volume:
|
||||
name: datamate-dataset-volume
|
||||
external: true
|
||||
|
||||
networks:
|
||||
datamate:
|
||||
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 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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
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) {
|
||||
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}`);
|
||||
}
|
||||
|
||||
// 主动学习相关接口
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
@@ -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.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
|
||||
@@ -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;
|
||||
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user