You've already forked DataMate
feat(annotation): 支持图像标注项目并添加内置标注模板
- 扩展标注编辑器支持 TEXT/IMAGE 数据类型 - 添加三个内置图像标注模板:目标检测、语义分割(掩码)、语义分割(多边形) - 实现内置标注模板的数据库初始化功能 - 集成标注配置验证和模板管理服务 - 更新项目不支持提示信息以反映新的数据类型支持
This commit is contained in:
@@ -621,7 +621,7 @@ export default function LabelStudioTextEditor() {
|
|||||||
<Card style={{ maxWidth: 640 }}>
|
<Card style={{ maxWidth: 640 }}>
|
||||||
<Typography.Title level={4}>暂不支持该数据类型</Typography.Title>
|
<Typography.Title level={4}>暂不支持该数据类型</Typography.Title>
|
||||||
<Typography.Paragraph type="secondary">
|
<Typography.Paragraph type="secondary">
|
||||||
{project.unsupportedReason || "当前仅支持文本(TEXT)项目的内嵌编辑器。"}
|
{project.unsupportedReason || "当前仅支持 TEXT/IMAGE 项目的内嵌编辑器。"}
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button onClick={() => navigate("/data/annotation")}>返回</Button>
|
<Button onClick={() => navigate("/data/annotation")}>返回</Button>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
|
|||||||
from .core.config import settings
|
from .core.config import settings
|
||||||
from .core.logging import setup_logging, get_logger
|
from .core.logging import setup_logging, get_logger
|
||||||
from .db.session import AsyncSessionLocal
|
from .db.session import AsyncSessionLocal
|
||||||
|
from .module.annotation.service.builtin_templates import ensure_builtin_annotation_templates
|
||||||
from .exception import (
|
from .exception import (
|
||||||
starlette_http_exception_handler,
|
starlette_http_exception_handler,
|
||||||
fastapi_http_exception_handler,
|
fastapi_http_exception_handler,
|
||||||
@@ -32,6 +33,14 @@ async def lifespan(app: FastAPI):
|
|||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
await session.execute(text("SELECT 1"))
|
await session.execute(text("SELECT 1"))
|
||||||
logger.info(f"Database: mysql+aiomysql://{'*' * len(settings.mysql_user)}:{'*' * len(settings.mysql_password)}@{settings.mysql_host}:{settings.mysql_port}/{settings.mysql_database}")
|
logger.info(f"Database: mysql+aiomysql://{'*' * len(settings.mysql_user)}:{'*' * len(settings.mysql_password)}@{settings.mysql_host}:{settings.mysql_port}/{settings.mysql_database}")
|
||||||
|
try:
|
||||||
|
inserted = await ensure_builtin_annotation_templates(session)
|
||||||
|
if inserted > 0:
|
||||||
|
logger.info(f"内置标注模板初始化完成,新增 {inserted} 条")
|
||||||
|
else:
|
||||||
|
logger.info("内置标注模板已存在,跳过初始化")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"内置标注模板初始化失败: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Database connection validation failed: {e}")
|
logger.error(f"Database connection validation failed: {e}")
|
||||||
logger.debug(f"Connection details: {settings.database_url}")
|
logger.debug(f"Connection details: {settings.database_url}")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Label Studio Editor(前端嵌入式)接口
|
|||||||
说明:
|
说明:
|
||||||
- 不依赖 Label Studio Server;仅复用其“编辑器”前端库
|
- 不依赖 Label Studio Server;仅复用其“编辑器”前端库
|
||||||
- DataMate 负责提供 tasks/annotations 数据与保存能力
|
- DataMate 负责提供 tasks/annotations 数据与保存能力
|
||||||
- 当前为 TEXT POC:只支持 dataset_type=TEXT 的项目
|
- 当前支持 dataset_type=TEXT/IMAGE 的项目
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.db.models.annotation_management import AnnotationTemplate
|
||||||
|
from app.module.annotation.utils.config_validator import LabelStudioConfigValidator
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
DATA_TYPE_IMAGE = "image"
|
||||||
|
CATEGORY_COMPUTER_VISION = "computer-vision"
|
||||||
|
STYLE_HORIZONTAL = "horizontal"
|
||||||
|
VERSION_DEFAULT = "1.0.0"
|
||||||
|
|
||||||
|
OBJECT_DETECTION_LABEL_CONFIG = """<View>
|
||||||
|
<Image name=\"image\" value=\"$image\"/>
|
||||||
|
<RectangleLabels name=\"label\" toName=\"image\">
|
||||||
|
<Label value=\"Airplane\" background=\"green\"/>
|
||||||
|
<Label value=\"Car\" background=\"blue\"/>
|
||||||
|
</RectangleLabels>
|
||||||
|
</View>"""
|
||||||
|
|
||||||
|
SEMANTIC_SEGMENTATION_MASK_LABEL_CONFIG = """<View>
|
||||||
|
<Image name=\"image\" value=\"$image\" zoom=\"true\"/>
|
||||||
|
<BrushLabels name=\"tag\" toName=\"image\">
|
||||||
|
<Label value=\"Airplane\" background=\"rgba(255, 0, 0, 0.7)\"/>
|
||||||
|
<Label value=\"Car\" background=\"rgba(0, 0, 255, 0.7)\"/>
|
||||||
|
</BrushLabels>
|
||||||
|
</View>"""
|
||||||
|
|
||||||
|
SEMANTIC_SEGMENTATION_POLYGON_LABEL_CONFIG = """<View>
|
||||||
|
<Header value=\"选择标签并点击图像开始\"/>
|
||||||
|
<Image name=\"image\" value=\"$image\" zoom=\"true\"/>
|
||||||
|
<PolygonLabels name=\"label\" toName=\"image\" strokeWidth=\"3\" pointSize=\"small\" opacity=\"0.9\">
|
||||||
|
<Label value=\"Airplane\" background=\"red\"/>
|
||||||
|
<Label value=\"Car\" background=\"blue\"/>
|
||||||
|
</PolygonLabels>
|
||||||
|
</View>"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BuiltInTemplateDefinition:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
data_type: str
|
||||||
|
labeling_type: str
|
||||||
|
label_config: str
|
||||||
|
style: str
|
||||||
|
category: str
|
||||||
|
version: str
|
||||||
|
|
||||||
|
|
||||||
|
BUILT_IN_TEMPLATES: List[BuiltInTemplateDefinition] = [
|
||||||
|
BuiltInTemplateDefinition(
|
||||||
|
id="tpl-object-detection-001",
|
||||||
|
name="目标检测(边界框)",
|
||||||
|
description=(
|
||||||
|
"在目标周围绘制边界框,适用于自动驾驶、交通监控、安防监控、零售分析等场景。"
|
||||||
|
"关联模型:YOLO、R-CNN、SSD"
|
||||||
|
),
|
||||||
|
data_type=DATA_TYPE_IMAGE,
|
||||||
|
labeling_type="object-detection",
|
||||||
|
label_config=OBJECT_DETECTION_LABEL_CONFIG,
|
||||||
|
style=STYLE_HORIZONTAL,
|
||||||
|
category=CATEGORY_COMPUTER_VISION,
|
||||||
|
version=VERSION_DEFAULT,
|
||||||
|
),
|
||||||
|
BuiltInTemplateDefinition(
|
||||||
|
id="tpl-semantic-segmentation-mask-001",
|
||||||
|
name="语义分割(掩码)",
|
||||||
|
description=(
|
||||||
|
"使用画笔工具在目标周围绘制掩码,适用于自动驾驶、医学图像分析、卫星图像分析等场景。"
|
||||||
|
"关联模型:U-Net、DeepLab、Mask R-CNN"
|
||||||
|
),
|
||||||
|
data_type=DATA_TYPE_IMAGE,
|
||||||
|
labeling_type="semantic-segmentation-mask",
|
||||||
|
label_config=SEMANTIC_SEGMENTATION_MASK_LABEL_CONFIG,
|
||||||
|
style=STYLE_HORIZONTAL,
|
||||||
|
category=CATEGORY_COMPUTER_VISION,
|
||||||
|
version=VERSION_DEFAULT,
|
||||||
|
),
|
||||||
|
BuiltInTemplateDefinition(
|
||||||
|
id="tpl-semantic-segmentation-polygon-001",
|
||||||
|
name="语义分割(多边形)",
|
||||||
|
description=(
|
||||||
|
"在目标周围绘制多边形,适用于自动驾驶、医学图像、卫星图像、精准农业等场景。"
|
||||||
|
"关联模型:DeepLab、PSPNet、U-Net"
|
||||||
|
),
|
||||||
|
data_type=DATA_TYPE_IMAGE,
|
||||||
|
labeling_type="semantic-segmentation-polygon",
|
||||||
|
label_config=SEMANTIC_SEGMENTATION_POLYGON_LABEL_CONFIG,
|
||||||
|
style=STYLE_HORIZONTAL,
|
||||||
|
category=CATEGORY_COMPUTER_VISION,
|
||||||
|
version=VERSION_DEFAULT,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
assert len({template.id for template in BUILT_IN_TEMPLATES}) == len(BUILT_IN_TEMPLATES), (
|
||||||
|
"内置模板ID不能重复"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_builtin_annotation_templates(db: AsyncSession) -> int:
|
||||||
|
inserted = 0
|
||||||
|
|
||||||
|
for template in BUILT_IN_TEMPLATES:
|
||||||
|
label_config = template.label_config.strip()
|
||||||
|
assert label_config, f"内置模板 {template.id} 的 label_config 不能为空"
|
||||||
|
|
||||||
|
valid, error = LabelStudioConfigValidator.validate_xml(label_config)
|
||||||
|
if not valid:
|
||||||
|
raise ValueError(f"内置模板 {template.id} 的 label_config 无效: {error}")
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(AnnotationTemplate).where(AnnotationTemplate.id == template.id)
|
||||||
|
)
|
||||||
|
existing = result.scalar_one_or_none()
|
||||||
|
if existing:
|
||||||
|
continue
|
||||||
|
|
||||||
|
record = AnnotationTemplate(
|
||||||
|
id=template.id,
|
||||||
|
name=template.name,
|
||||||
|
description=template.description,
|
||||||
|
data_type=template.data_type,
|
||||||
|
labeling_type=template.labeling_type,
|
||||||
|
configuration=None,
|
||||||
|
label_config=label_config,
|
||||||
|
style=template.style,
|
||||||
|
category=template.category,
|
||||||
|
built_in=True,
|
||||||
|
version=template.version,
|
||||||
|
created_at=datetime.now(),
|
||||||
|
)
|
||||||
|
db.add(record)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await db.commit()
|
||||||
|
inserted += 1
|
||||||
|
except IntegrityError:
|
||||||
|
await db.rollback()
|
||||||
|
logger.warning(f"内置模板已存在,跳过插入: {template.id}")
|
||||||
|
except Exception:
|
||||||
|
await db.rollback()
|
||||||
|
logger.exception(f"写入内置模板失败: {template.id}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
return inserted
|
||||||
Reference in New Issue
Block a user