feat: 完善数据标注导出格式兼容性验证

- 后端:添加 YOLO 格式对 TEXT 数据集的限制验证
- 后端:统一 COCO/YOLO 兼容性校验规则(仅允许图像类或目标检测类数据集)
- 后端:修复 datasetType 字段传递,在任务列表响应中补充 dataset_type
- 前端:在导出对话框中禁用 TEXT 数据集的 COCO/YOLO 选项
- 前端:添加 datasetType 和 labelingType 字段传递
- 前端:对齐前后端 COCO/YOLO 兼容性规则
- 前端:优化提示文案,明确说明格式适用范围

修改文件:
- runtime/datamate-python/app/module/annotation/service/export.py
- runtime/datamate-python/app/module/annotation/service/mapping.py
- runtime/datamate-python/app/module/annotation/schema/mapping.py
- frontend/src/pages/DataAnnotation/Home/ExportAnnotationDialog.tsx
- frontend/src/pages/DataAnnotation/Home/DataAnnotation.tsx
- frontend/src/pages/DataAnnotation/annotation.const.tsx
This commit is contained in:
2026-02-07 16:05:27 +08:00
parent 36b410ba7b
commit 3dd4035005
6 changed files with 73 additions and 19 deletions

View File

@@ -378,6 +378,8 @@ export default function DataAnnotation() {
open={!!exportTask} open={!!exportTask}
projectId={exportTask?.id || ""} projectId={exportTask?.id || ""}
projectName={exportTask?.name || ""} projectName={exportTask?.name || ""}
datasetType={exportTask?.datasetType}
labelingType={exportTask?.labelingType}
onClose={() => setExportTask(null)} onClose={() => setExportTask(null)}
/> />
</div> </div>

View File

@@ -11,7 +11,7 @@ import {
message, message,
Alert, Alert,
} from "antd"; } from "antd";
import { FileTextOutlined, CheckCircleOutlined } from "@ant-design/icons"; import { FileTextOutlined, CheckCircleOutlined, InfoCircleOutlined } from "@ant-design/icons";
import { import {
getExportStatsUsingGet, getExportStatsUsingGet,
downloadAnnotationsUsingGet, downloadAnnotationsUsingGet,
@@ -22,6 +22,8 @@ interface ExportAnnotationDialogProps {
open: boolean; open: boolean;
projectId: string; projectId: string;
projectName: string; projectName: string;
datasetType?: string;
labelingType?: string;
onClose: () => void; onClose: () => void;
} }
@@ -57,6 +59,8 @@ export default function ExportAnnotationDialog({
open, open,
projectId, projectId,
projectName, projectName,
datasetType,
labelingType,
onClose, onClose,
}: ExportAnnotationDialogProps) { }: ExportAnnotationDialogProps) {
const [form] = Form.useForm(); const [form] = Form.useForm();
@@ -67,6 +71,15 @@ export default function ExportAnnotationDialog({
annotatedFiles: number; annotatedFiles: number;
} | null>(null); } | null>(null);
const normalizedDatasetType = datasetType?.toUpperCase().replace(/-/g, "_") || "";
const normalizedLabelingType = labelingType?.toUpperCase().replace(/-/g, "_") || "";
const hasDatasetType = Boolean(normalizedDatasetType);
const isTextDataset = normalizedDatasetType === "TEXT";
const isDetectionCompatible =
normalizedDatasetType === "IMAGE" ||
normalizedDatasetType === "OBJECT_DETECTION" ||
normalizedLabelingType === "OBJECT_DETECTION";
// 加载导出统计信息 // 加载导出统计信息
useEffect(() => { useEffect(() => {
if (open && projectId) { if (open && projectId) {
@@ -176,20 +189,46 @@ export default function ExportAnnotationDialog({
rules={[{ required: true, message: "请选择导出格式" }]} rules={[{ required: true, message: "请选择导出格式" }]}
> >
<Select <Select
options={FORMAT_OPTIONS.map((opt) => ({ options={FORMAT_OPTIONS.map((opt) => {
label: ( const isDetectionFormat = opt.value === "coco" || opt.value === "yolo";
<div className="py-1"> const isDisabledForText = isDetectionFormat && isTextDataset;
<div className="font-medium">{opt.label}</div> const isDisabledForIncompatibleType =
<div className="text-xs text-gray-400">{opt.description}</div> isDetectionFormat && hasDatasetType && !isTextDataset && !isDetectionCompatible;
</div> const isDisabled = isDisabledForText || isDisabledForIncompatibleType;
), return {
value: opt.value, label: (
simpleLabel: opt.label, <div className="py-1 relative">
}))} <div className={`font-medium ${isDisabled ? "text-gray-400" : ""}`}>{opt.label}</div>
<div className={`text-xs ${isDisabled ? "text-gray-300" : "text-gray-400"}`}>
{opt.description}
{isDisabled && (
<span className="block mt-1 text-orange-500">
<InfoCircleOutlined className="mr-1" />
</span>
)}
</div>
</div>
),
value: opt.value,
simpleLabel: opt.label,
disabled: isDisabled,
};
})}
optionLabelProp="simpleLabel" optionLabelProp="simpleLabel"
/> />
</Form.Item> </Form.Item>
{(isTextDataset || (hasDatasetType && !isDetectionCompatible)) && (
<Alert
type="info"
message="导出格式兼容性提示"
description="COCO 和 YOLO 格式仅适用于图像类或目标检测类数据集,当前项目建议使用 JSON、JSON Lines 或 CSV 格式导出。"
showIcon
className="mt-4"
/>
)}
<Form.Item name="onlyAnnotated" valuePropName="checked"> <Form.Item name="onlyAnnotated" valuePropName="checked">
<Checkbox></Checkbox> <Checkbox></Checkbox>
</Form.Item> </Form.Item>

View File

@@ -23,6 +23,8 @@ type AnnotationTaskPayload = {
datasetId?: string; datasetId?: string;
datasetName?: string; datasetName?: string;
dataset_name?: string; dataset_name?: string;
datasetType?: string;
dataset_type?: string;
labelingType?: string; labelingType?: string;
labeling_type?: string; labeling_type?: string;
template?: { template?: {
@@ -54,6 +56,7 @@ export type AnnotationTaskListItem = {
description?: string; description?: string;
datasetId?: string; datasetId?: string;
datasetName?: string; datasetName?: string;
datasetType?: string;
labelingType?: string; labelingType?: string;
totalCount?: number; totalCount?: number;
annotatedCount?: number; annotatedCount?: number;
@@ -97,6 +100,7 @@ export function mapAnnotationTask(task: AnnotationTaskPayload): AnnotationTaskLi
const labelingProjId = task?.labelingProjId || task?.labelingProjectId || task?.projId || task?.labeling_project_id || ""; const labelingProjId = task?.labelingProjId || task?.labelingProjectId || task?.projId || task?.labeling_project_id || "";
const segmentationEnabled = task?.segmentationEnabled ?? task?.segmentation_enabled ?? false; const segmentationEnabled = task?.segmentationEnabled ?? task?.segmentation_enabled ?? false;
const inProgressCount = task?.inProgressCount ?? task?.in_progress_count ?? 0; const inProgressCount = task?.inProgressCount ?? task?.in_progress_count ?? 0;
const datasetType = task?.datasetType || task?.dataset_type;
const labelingType = const labelingType =
task?.labelingType || task?.labelingType ||
task?.labeling_type || task?.labeling_type ||
@@ -119,6 +123,7 @@ export function mapAnnotationTask(task: AnnotationTaskPayload): AnnotationTaskLi
projId: labelingProjId, projId: labelingProjId,
segmentationEnabled, segmentationEnabled,
inProgressCount, inProgressCount,
datasetType,
labelingType, labelingType,
name: task.name, name: task.name,
description: task.description || "", description: task.description || "",

View File

@@ -61,6 +61,7 @@ class DatasetMappingResponse(BaseModel):
id: str = Field(..., description="映射UUID") id: str = Field(..., description="映射UUID")
dataset_id: str = Field(..., alias="datasetId", description="源数据集ID") dataset_id: str = Field(..., alias="datasetId", description="源数据集ID")
dataset_name: Optional[str] = Field(None, alias="datasetName", description="数据集名称") dataset_name: Optional[str] = Field(None, alias="datasetName", description="数据集名称")
dataset_type: Optional[str] = Field(None, alias="datasetType", description="数据集类型")
labeling_project_id: str = Field(..., alias="labelingProjectId", description="标注项目ID") labeling_project_id: str = Field(..., alias="labelingProjectId", description="标注项目ID")
name: Optional[str] = Field(None, description="标注项目名称") name: Optional[str] = Field(None, description="标注项目名称")
description: Optional[str] = Field(None, description="标注项目描述") description: Optional[str] = Field(None, description="标注项目描述")

View File

@@ -84,7 +84,7 @@ DATASET_TYPE_IMAGE = "IMAGE"
DATASET_TYPE_OBJECT_DETECTION = "OBJECT_DETECTION" DATASET_TYPE_OBJECT_DETECTION = "OBJECT_DETECTION"
LABELING_TYPE_CONFIG_KEY = "labeling_type" LABELING_TYPE_CONFIG_KEY = "labeling_type"
LABELING_TYPE_OBJECT_DETECTION = "OBJECT_DETECTION" LABELING_TYPE_OBJECT_DETECTION = "OBJECT_DETECTION"
COCO_COMPATIBLE_DATASET_TYPES = { DETECTION_COMPATIBLE_DATASET_TYPES = {
DATASET_TYPE_IMAGE, DATASET_TYPE_IMAGE,
DATASET_TYPE_OBJECT_DETECTION, DATASET_TYPE_OBJECT_DETECTION,
} }
@@ -236,7 +236,7 @@ class AnnotationExportService:
project: LabelingProject, project: LabelingProject,
format_type: ExportFormat, format_type: ExportFormat,
) -> None: ) -> None:
if format_type != ExportFormat.COCO: if format_type not in (ExportFormat.COCO, ExportFormat.YOLO):
return return
dataset_type = self._normalize_type_value( dataset_type = self._normalize_type_value(
@@ -249,11 +249,11 @@ class AnnotationExportService:
if dataset_type == DATASET_TYPE_TEXT: if dataset_type == DATASET_TYPE_TEXT:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="导出格式 COCO 不支持文本类数据集(TEXT),请改用 JSON/JSONL/CSV 格式", detail=f"导出格式 {format_type.value.upper()} 不支持文本类数据集(TEXT),请改用 JSON/JSONL/CSV 格式",
) )
if ( if (
dataset_type in COCO_COMPATIBLE_DATASET_TYPES dataset_type in DETECTION_COMPATIBLE_DATASET_TYPES
or labeling_type == LABELING_TYPE_OBJECT_DETECTION or labeling_type == LABELING_TYPE_OBJECT_DETECTION
): ):
return return
@@ -261,7 +261,7 @@ class AnnotationExportService:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=( detail=(
"导出格式 COCO 仅适用于图像类或目标检测类数据集," f"导出格式 {format_type.value.upper()} 仅适用于图像类或目标检测类数据集,"
f"当前数据集类型: {dataset_type or 'UNKNOWN'}" f"当前数据集类型: {dataset_type or 'UNKNOWN'}"
f"标注类型: {labeling_type or 'UNKNOWN'}" f"标注类型: {labeling_type or 'UNKNOWN'}"
), ),

View File

@@ -32,7 +32,8 @@ class DatasetMappingService:
"""Build base query with dataset name joined""" """Build base query with dataset name joined"""
return select( return select(
LabelingProject, LabelingProject,
Dataset.name.label('dataset_name') Dataset.name.label('dataset_name'),
Dataset.dataset_type.label('dataset_type'),
).outerjoin( ).outerjoin(
Dataset, Dataset,
LabelingProject.dataset_id == Dataset.id LabelingProject.dataset_id == Dataset.id
@@ -98,6 +99,7 @@ class DatasetMappingService:
""" """
mapping = row[0] # LabelingProject object mapping = row[0] # LabelingProject object
dataset_name = row[1] # dataset_name from join dataset_name = row[1] # dataset_name from join
dataset_type = row[2] # dataset_type from join
# Get template_id from mapping # Get template_id from mapping
template_id = getattr(mapping, 'template_id', None) template_id = getattr(mapping, 'template_id', None)
@@ -134,6 +136,7 @@ class DatasetMappingService:
"id": mapping.id, "id": mapping.id,
"dataset_id": mapping.dataset_id, "dataset_id": mapping.dataset_id,
"dataset_name": dataset_name, "dataset_name": dataset_name,
"dataset_type": dataset_type,
"labeling_project_id": mapping.labeling_project_id, "labeling_project_id": mapping.labeling_project_id,
"name": mapping.name, "name": mapping.name,
"description": description, "description": description,
@@ -166,12 +169,15 @@ class DatasetMappingService:
""" """
# Fetch dataset name # Fetch dataset name
dataset_name = None dataset_name = None
dataset_type = None
dataset_id = getattr(mapping, 'dataset_id', None) dataset_id = getattr(mapping, 'dataset_id', None)
if dataset_id: if dataset_id:
dataset_result = await self.db.execute( dataset_result = await self.db.execute(
select(Dataset.name).where(Dataset.id == dataset_id) select(Dataset.name, Dataset.dataset_type).where(Dataset.id == dataset_id)
) )
dataset_name = dataset_result.scalar_one_or_none() dataset_row = dataset_result.one_or_none()
if dataset_row:
dataset_name, dataset_type = dataset_row
# Get template_id from mapping # Get template_id from mapping
template_id = getattr(mapping, 'template_id', None) template_id = getattr(mapping, 'template_id', None)
@@ -211,6 +217,7 @@ class DatasetMappingService:
"id": mapping.id, "id": mapping.id,
"dataset_id": dataset_id, "dataset_id": dataset_id,
"dataset_name": dataset_name, "dataset_name": dataset_name,
"dataset_type": dataset_type,
"labeling_project_id": mapping.labeling_project_id, "labeling_project_id": mapping.labeling_project_id,
"name": mapping.name, "name": mapping.name,
"description": description, "description": description,