diff --git a/README.md b/README.md index 00402d12c..be09ca4fc 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ ## 特性 -- 支持图片 拉框、点、线(包含曲线)、多边形(包含闭合曲线)、立体框标注 +- 支持图片 拉框、点、线(包含曲线)、多边形(包含闭合曲线)、立体框,同时支持目标检测之间的关联关系标注 - 支持视频标注 - 支持音频标注 - 原子化模块,可自由组合 diff --git a/README_en-US.md b/README_en-US.md index 64ca76e31..33a15a9dc 100644 --- a/README_en-US.md +++ b/README_en-US.md @@ -9,7 +9,7 @@ ## Features -- Supports 2D bounding box, point, line (spline), cuboid, and polygon (closed-spline) annotation for images +- Supports 2D bounding box, point, line (spline), cuboid, and polygon (closed-spline) and relation (for bbox / polygon) annotation for images - Supports video annotation - Supports audio annotation - Modular components that can be freely combined diff --git a/apps/frontend/package.json b/apps/frontend/package.json index a910a8830..dbe7dab09 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -1,18 +1,18 @@ { "name": "@labelu/frontend", - "version": "5.8.8", + "version": "5.9.0", "private": true, "dependencies": { "@ant-design/icons": "^4.6.2", - "@labelu/i18n": "1.0.6", - "@labelu/audio-annotator-react": "1.8.3", - "@labelu/components-react": "1.7.10", - "@labelu/image": "1.4.0", + "@labelu/i18n": "1.1.0", + "@labelu/audio-annotator-react": "1.9.0", + "@labelu/components-react": "1.8.0", + "@labelu/image": "1.5.0", "@labelu/formatter": "1.0.2", - "@labelu/image-annotator-react": "2.4.3", + "@labelu/image-annotator-react": "2.5.0", "@labelu/interface": "1.3.1", - "@labelu/video-annotator-react": "1.4.9", - "@labelu/video-react": "1.5.2", + "@labelu/video-annotator-react": "1.5.0", + "@labelu/video-react": "1.6.0", "@tanstack/react-query": "^5.0.0", "antd": "5.10.1", "axios": "^1.3.4", diff --git a/apps/frontend/src/constants/toolName.ts b/apps/frontend/src/constants/toolName.ts index c27dec2ab..512da9f42 100644 --- a/apps/frontend/src/constants/toolName.ts +++ b/apps/frontend/src/constants/toolName.ts @@ -1,4 +1,4 @@ -import { i18n } from '@labelu/i18n' +import { i18n } from '@labelu/i18n'; import { EAudioToolName, EGlobalToolName, EVideoToolName, ImageToolName } from '@/enums'; @@ -10,8 +10,9 @@ export const TOOL_NAME: Record = { [ImageToolName.Polygon]: i18n.t('polygon'), [ImageToolName.Cuboid]: i18n.t('cuboid'), [ImageToolName.Line]: i18n.t('line'), - [EVideoToolName.VideoSegmentTool]: i18n.t("segment"), - [EVideoToolName.VideoFrameTool]: i18n.t("timestamp"), - [EAudioToolName.AudioSegmentTool]: i18n.t("segment"), - [EAudioToolName.AudioFrameTool]: i18n.t("timestamp"), + [ImageToolName.Relation]: i18n.t('relation'), + [EVideoToolName.VideoSegmentTool]: i18n.t('segment'), + [EVideoToolName.VideoFrameTool]: i18n.t('timestamp'), + [EAudioToolName.AudioSegmentTool]: i18n.t('segment'), + [EAudioToolName.AudioFrameTool]: i18n.t('timestamp'), }; diff --git a/apps/frontend/src/enums/index.ts b/apps/frontend/src/enums/index.ts index 4045efe53..4b4c8c313 100644 --- a/apps/frontend/src/enums/index.ts +++ b/apps/frontend/src/enums/index.ts @@ -8,6 +8,8 @@ export enum ImageToolName { Line = 'lineTool', /** 立体框 */ Cuboid = 'cuboidTool', + /** 关联关系 */ + Relation = 'relationTool', } export enum EVideoToolName { diff --git a/apps/frontend/src/pages/tasks.[id].edit/partials/AnnotationConfig/formConfig/index.tsx b/apps/frontend/src/pages/tasks.[id].edit/partials/AnnotationConfig/formConfig/index.tsx index 58e150aaa..45f1228db 100644 --- a/apps/frontend/src/pages/tasks.[id].edit/partials/AnnotationConfig/formConfig/index.tsx +++ b/apps/frontend/src/pages/tasks.[id].edit/partials/AnnotationConfig/formConfig/index.tsx @@ -1,9 +1,9 @@ // import { ImageToolName, TOOL_NAME, EVideoToolName, EAudioToolName } from '@labelu/annotation'; import type { FormProps, SelectProps, TabsProps } from 'antd'; -import { Popconfirm, Button, Form, Tabs, Select } from 'antd'; +import { Popconfirm, Button, Form, Tabs, Select, Tooltip } from 'antd'; import React, { useContext, useEffect, useCallback, useMemo, useState } from 'react'; import _, { cloneDeep, find } from 'lodash-es'; -import { PlusOutlined } from '@ant-design/icons'; +import { InfoCircleOutlined, PlusOutlined } from '@ant-design/icons'; import { FlexLayout } from '@labelu/components-react'; import { createGlobalStyle } from 'styled-components'; import { useTranslation } from '@labelu/i18n'; @@ -18,6 +18,7 @@ import { TaskCreationContext } from '../../../taskCreation.context'; import { FancyAttributeList } from './customFancy/ListAttribute.fancy'; import { FancyCategoryAttribute } from './customFancy/CategoryAttribute.fancy'; import lineTemplate from './templates/line.template'; +import relationTemplate from './templates/relation.template'; import rectTemplate from './templates/rect.template'; import polygonTemplate from './templates/polygon.template'; import cuboidTemplate from './templates/cuboid.template'; @@ -47,6 +48,7 @@ const graphicTools = [ ImageToolName.Polygon, ImageToolName.Line, ImageToolName.Cuboid, + ImageToolName.Relation, ]; const videoAnnotationTools = [EVideoToolName.VideoSegmentTool, EVideoToolName.VideoFrameTool]; const audioAnnotationTools = [EAudioToolName.AudioSegmentTool, EAudioToolName.AudioFrameTool]; @@ -91,6 +93,7 @@ const templateMapping: Record = { [ImageToolName.Polygon]: polygonTemplate, [ImageToolName.Point]: pointTemplate, [ImageToolName.Cuboid]: cuboidTemplate, + [ImageToolName.Relation]: relationTemplate, [EGlobalToolName.Tag]: tagTemplate, [EGlobalToolName.Text]: textTemplate, [EVideoToolName.VideoSegmentTool]: videoSegmentTemplate, @@ -228,11 +231,32 @@ const FormConfig = () => { }, { label: t('tools'), - options: _.map(toolOptions, ({ value, label }) => ({ - disabled: selectedTools.includes(value), - value: value, - label: {label}, - })), + options: _.map(toolOptions, ({ value, label }) => { + let disabled = selectedTools.includes(value); + + if ( + !selectedTools.includes('rectTool') && + !selectedTools.includes('polygonTool') && + value === 'relationTool' + ) { + disabled = true; + } + + return { + disabled, + value: value, + label: ( + + {label} + {value === 'relationTool' && ( + + + + )} + + ), + }; + }), }, ]; }, [selectedTools, task?.media_type, t]); diff --git a/apps/frontend/src/pages/tasks.[id].edit/partials/AnnotationConfig/formConfig/templates/line.template.tsx b/apps/frontend/src/pages/tasks.[id].edit/partials/AnnotationConfig/formConfig/templates/line.template.tsx index adef0ce3d..f3e0cd61c 100644 --- a/apps/frontend/src/pages/tasks.[id].edit/partials/AnnotationConfig/formConfig/templates/line.template.tsx +++ b/apps/frontend/src/pages/tasks.[id].edit/partials/AnnotationConfig/formConfig/templates/line.template.tsx @@ -18,13 +18,6 @@ export default [ field: 'config', type: 'group', children: [ - { - field: 'attributeConfigurable', - key: 'attributeConfigurable', - type: 'boolean', - hidden: true, - initialValue: true, - }, { field: 'lineType', key: 'lineType', @@ -38,6 +31,29 @@ export default [ ], }, }, + { + field: 'arrowType', + key: 'arrowType', + type: 'enum', + label: i18n.t('arrowType'), + initialValue: 'none', + antProps: { + options: [ + { label: i18n.t('single'), value: 'single' }, + { label: i18n.t('double'), value: 'double' }, + { label: i18n.t('none'), value: 'none' }, + ], + }, + renderFormItem({ antProps, ...props }, form, fullField) { + const lineType = form.getFieldValue([...(fullField as any[]).slice(0, -1), 'lineType']); + + if (lineType === 1) { + return null; + } + + return ; + }, + }, { type: 'group', key: 'pointNum', diff --git a/apps/frontend/src/pages/tasks.[id].edit/partials/AnnotationConfig/formConfig/templates/relation.template.tsx b/apps/frontend/src/pages/tasks.[id].edit/partials/AnnotationConfig/formConfig/templates/relation.template.tsx new file mode 100644 index 000000000..6e249d787 --- /dev/null +++ b/apps/frontend/src/pages/tasks.[id].edit/partials/AnnotationConfig/formConfig/templates/relation.template.tsx @@ -0,0 +1,81 @@ +import { i18n } from '@labelu/i18n'; + +import type { FancyItemIdentifier } from '@/components/FancyInput/types'; +import FancyInput from '@/components/FancyInput'; + +export default [ + { + field: 'tool', + key: 'tool', + type: 'string', + hidden: true, + initialValue: 'relationTool', + }, + { + key: 'config', + field: 'config', + type: 'group', + children: [ + { + field: 'lineStyle', + key: 'lineStyle', + type: 'enum', + label: i18n.t('lineStyle'), + initialValue: 'dashed', + antProps: { + options: [ + { label: i18n.t('solid'), value: 'solid' }, + { label: i18n.t('dashed'), value: 'dashed' }, + { label: i18n.t('dotted'), value: 'dotted' }, + ], + }, + renderFormItem({ antProps, ...props }, form, fullField) { + const lineType = form.getFieldValue([...(fullField as any[]).slice(0, -1), 'lineType']); + + if (lineType === 1) { + return null; + } + + return ; + }, + }, + + { + field: 'arrowType', + key: 'arrowType', + type: 'enum', + label: i18n.t('arrowType'), + initialValue: 'single', + antProps: { + options: [ + { label: i18n.t('single'), value: 'single' }, + { label: i18n.t('double'), value: 'double' }, + { label: i18n.t('none'), value: 'none' }, + ], + }, + renderFormItem({ antProps, ...props }, form, fullField) { + const lineType = form.getFieldValue([...(fullField as any[]).slice(0, -1), 'lineType']); + + if (lineType === 1) { + return null; + } + + return ; + }, + }, + { + field: 'attributes', + key: 'attributes', + type: 'list-attribute', + label: i18n.t('labelConfig'), + initialValue: [ + { + color: '#ff6600', + key: i18n.t('label1'), + value: 'label-1', + }, + ], + }, + ], + }, +] as FancyItemIdentifier[]; diff --git a/apps/frontend/src/pages/tasks.[id].edit/partials/InputData/imagePreAnnotationJson.schema.json b/apps/frontend/src/pages/tasks.[id].edit/partials/InputData/imagePreAnnotationJson.schema.json index 295afef8e..fcf48c9f6 100644 --- a/apps/frontend/src/pages/tasks.[id].edit/partials/InputData/imagePreAnnotationJson.schema.json +++ b/apps/frontend/src/pages/tasks.[id].edit/partials/InputData/imagePreAnnotationJson.schema.json @@ -47,6 +47,9 @@ { "$ref": "#/definitions/PolygonTool" }, + { + "$ref": "#/definitions/RelationTool" + }, { "$ref": "#/definitions/CuboidTool" }, @@ -323,6 +326,55 @@ }, "required": ["toolName", "result"] }, + "RelationTool": { + "type": "object", + "properties": { + "toolName": { + "type": "string", + "const": "relationTool" + }, + "result": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "唯一标识" + }, + "sourceId": { + "type": "string", + "description": "起始标注id" + }, + "targetId": { + "type": "string", + "description": "目标标注id" + }, + "visible": { + "type": "boolean", + "description": "是否可见", + "default": true + }, + "attributes": { + "$ref": "#/definitions/Attribute" + }, + "order": { + "type": "integer", + "description": "标注顺序", + "minimum": 0 + }, + "label": { + "type": "string", + "description": "标注标签", + "default": "none" + } + }, + "required": ["sourceId", "targetId", "id", "order", "label"] + } + } + }, + "required": ["toolName", "result"] + }, "RectTool": { "type": "object", "properties": { diff --git a/apps/frontend/src/pages/tasks.[id].edit/partials/InputData/imagePreAnnotationJsonl.schema.json b/apps/frontend/src/pages/tasks.[id].edit/partials/InputData/imagePreAnnotationJsonl.schema.json index 774d2ed24..49b39b914 100644 --- a/apps/frontend/src/pages/tasks.[id].edit/partials/InputData/imagePreAnnotationJsonl.schema.json +++ b/apps/frontend/src/pages/tasks.[id].edit/partials/InputData/imagePreAnnotationJsonl.schema.json @@ -33,6 +33,12 @@ "$ref": "#/definitions/LabelItem" } }, + "relationTool": { + "type": "array", + "items": { + "$ref": "#/definitions/LabelItem" + } + }, "cuboidTool": { "type": "array", "items": { @@ -161,6 +167,24 @@ }, "required": ["toolName", "result"] }, + "relationTool": { + "type": "object", + "properties": { + "toolName": { + "type": "string", + "const": "relationTool", + "default": "relationTool" + }, + "result": { + "description": "标注结果", + "type": "array", + "items": { + "$ref": "#/definitions/RelationTool" + } + } + }, + "required": ["toolName", "result"] + }, "textTool": { "type": "object", "properties": { @@ -608,6 +632,55 @@ }, "required": ["direction", "front", "back", "x", "y", "width", "height", "id", "order", "label"] }, + "RelationTool": { + "type": "object", + "properties": { + "toolName": { + "type": "string", + "const": "relationTool" + }, + "result": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "唯一标识" + }, + "sourceId": { + "type": "string", + "description": "起始标注id" + }, + "targetId": { + "type": "string", + "description": "目标标注id" + }, + "visible": { + "type": "boolean", + "description": "是否可见", + "default": true + }, + "attributes": { + "$ref": "#/definitions/Attribute" + }, + "order": { + "type": "integer", + "description": "标注顺序", + "minimum": 0 + }, + "label": { + "type": "string", + "description": "标注标签", + "default": "none" + } + }, + "required": ["sourceId", "targetId", "id", "order", "label"] + } + } + }, + "required": ["toolName", "result"] + }, "TextTool": { "type": "array", "items": { diff --git a/apps/frontend/src/pages/tasks.[id].samples.[id]/components/annotationRightCorner/index.tsx b/apps/frontend/src/pages/tasks.[id].samples.[id]/components/annotationRightCorner/index.tsx index 0dd362649..614fbd8d1 100644 --- a/apps/frontend/src/pages/tasks.[id].samples.[id]/components/annotationRightCorner/index.tsx +++ b/apps/frontend/src/pages/tasks.[id].samples.[id]/components/annotationRightCorner/index.tsx @@ -18,6 +18,7 @@ import { updateSampleState, updateSampleAnnotationResult } from '@/api/services/ import { message } from '@/StaticAnt'; import useMe from '@/hooks/useMe'; import { UserAvatar } from '@/components/UserAvatar'; +import { generateDefaultValues } from '@/utils/generateGlobalToolDefaultValues'; import AnnotationContext from '../../annotation.context'; @@ -247,6 +248,23 @@ const AnnotationRightCorner = ({ noSave, fetchNext, totalSize }: AnnotationRight innerSample = await audioAnnotationRef?.current?.getSample(); } + // 全局标注没有值的话,填充默认值 + const tagConfig = task.config.tools.find((tool) => tool.tool === 'tagTool'); + if (!result.tagTool?.result?.length && tagConfig) { + result.tagTool = { + toolName: 'tagTool', + result: generateDefaultValues(tagConfig?.config.attributes), + }; + } + + const textConfig = task.config.tools.find((tool) => tool.tool === 'textTool'); + if (!result.textTool?.result?.length && textConfig) { + result.textTool = { + toolName: 'textTool', + result: generateDefaultValues(textConfig?.config.attributes), + }; + } + // 防止sampleid保存错乱,使用标注时传入的sampleid const body = set('data.result')(JSON.stringify(result))(currentSample); @@ -255,7 +273,7 @@ const AnnotationRightCorner = ({ noSave, fetchNext, totalSize }: AnnotationRight annotated_count: getAnnotationCount(body.data!.result), state: SampleState.DONE, }); - }, [currentSample, isMeTheCurrentUser, noSave, task.media_type, taskId]); + }, [currentSample, isMeTheCurrentUser, noSave, task?.config?.tools, task?.media_type, taskId]); const handleComplete = useCallback(async () => { await saveCurrentSample(); diff --git a/apps/frontend/src/pages/tasks.[id].samples.[id]/index.tsx b/apps/frontend/src/pages/tasks.[id].samples.[id]/index.tsx index 9df3ba9d0..13a5b1e46 100644 --- a/apps/frontend/src/pages/tasks.[id].samples.[id]/index.tsx +++ b/apps/frontend/src/pages/tasks.[id].samples.[id]/index.tsx @@ -38,6 +38,9 @@ export const imageAnnotationRef = createRef(); export const videoAnnotationRef = createRef(); export const audioAnnotationRef = createRef(); +const PREVIEW_OFFSET_TOP = 102; +const OFFSET_TOP = 158; + const AnnotationPage = () => { const routeParams = useParams(); const { task } = useRouteLoaderData('task') as TaskLoaderResult; @@ -101,13 +104,13 @@ const AnnotationPage = () => { } if (task?.media_type === MediaType.IMAGE) { - return convertImageAnnotations(_annotations, preAnnotationConfig); + return convertImageAnnotations(_annotations); } else if (task?.media_type === MediaType.VIDEO || task?.media_type === MediaType.AUDIO) { - return convertMediaAnnotations(task.media_type, _annotations, preAnnotationConfig); + return convertMediaAnnotations(task.media_type, _annotations); } return {}; - }, [preAnnotation, preAnnotationConfig, task?.media_type]); + }, [preAnnotation, task?.media_type]); const [searchParams] = useSearchParams(); const taskConfig = _.get(task, 'config'); @@ -145,9 +148,15 @@ const AnnotationPage = () => { [t], ); + // 默认加载数量常量 + const PAGE_SIZE = 40; // 滚动加载 const [totalCount, setTotalCount] = useState(0); const currentPage = useRef(1); + if (currentPage.current === 1) { + currentPage.current = sample?.data.inner_id ? Math.floor(sample.data.inner_id / PAGE_SIZE) + 1 : 1; + } + const fetchSamples = useCallback(async () => { if (!routeParams.taskId) { return Promise.resolve([]); @@ -156,7 +165,7 @@ const AnnotationPage = () => { const { data, meta_data } = await getSamples({ task_id: +routeParams.taskId!, page: currentPage.current, - size: 40, + size: PAGE_SIZE, }); currentPage.current += 1; @@ -204,11 +213,11 @@ const AnnotationPage = () => { const editingSample = useMemo(() => { if (task?.media_type === MediaType.IMAGE) { - return convertImageSample(sample?.data, editorConfig); + return convertImageSample(sample?.data); } else if (task?.media_type === MediaType.VIDEO || task?.media_type === MediaType.AUDIO) { - return convertAudioAndVideoSample(sample?.data, editorConfig, task.media_type); + return convertAudioAndVideoSample(sample?.data, task.media_type); } - }, [editorConfig, sample?.data, task?.media_type]); + }, [sample?.data, task?.media_type]); const renderSidebar = useMemo(() => { return () => leftSiderContent; @@ -275,6 +284,10 @@ const AnnotationPage = () => { const [labelMapping, setLabelMapping] = useState>(); const handleLabelChange = useCallback((toolName: any, label: ILabel) => { + if (!label) { + return; + } + // 缓存当前标签 setLabelMapping((prev) => { return { @@ -303,7 +316,8 @@ const AnnotationPage = () => { toolbarRight={topActionContent} ref={imageAnnotationRef} onError={onError} - offsetTop={configFromParent ? 100 : 156} + // windows platform pixel issue + offsetTop={configFromParent ? PREVIEW_OFFSET_TOP : OFFSET_TOP} editingSample={editingSample} config={config} disabled={disabled} @@ -321,7 +335,7 @@ const AnnotationPage = () => { { { const tasks = _.get(tasksResponse, 'data', []); const meta_data = _.get(tasksResponse, 'meta_data'); const labeluVersion = _.get(routerLoaderData, ['headers', 'labelu-version']); - const pageSize = usePageSize(); const { t, i18n } = useTranslation(); - const [searchParams, setSearchParams] = useSearchParams({ - size: String(pageSize), - }); + const [searchParams, setSearchParams] = useSearchParams(); + const finalPageSize = searchParams.get('size') ? +searchParams.get('size')! : 16; const createTask = () => { navigate('/tasks/0/edit?isNew=true'); @@ -122,11 +119,11 @@ const TaskList = () => { labelu@{labeluVersion} - {meta_data && searchParams && meta_data?.total > pageSize && ( + {meta_data && searchParams && meta_data?.total > finalPageSize && ( { searchParams.set('size', String(_pageSize)); searchParams.set('page', String(value)); diff --git a/apps/frontend/src/utils/convertAudioAndVideoSample.ts b/apps/frontend/src/utils/convertAudioAndVideoSample.ts index ddaeacac3..1a202e7fc 100644 --- a/apps/frontend/src/utils/convertAudioAndVideoSample.ts +++ b/apps/frontend/src/utils/convertAudioAndVideoSample.ts @@ -1,18 +1,12 @@ import _ from 'lodash'; -import type { AnnotationsWithGlobal, MediaAnnotatorConfig, MediaSample } from '@labelu/audio-annotator-react'; +import type { AnnotationsWithGlobal, MediaSample } from '@labelu/audio-annotator-react'; import type { ParsedResult, SampleResponse } from '@/api/types'; -import { MediaType, SampleState } from '@/api/types'; +import { MediaType } from '@/api/types'; import { jsonParse } from './index'; -import { generateDefaultValues } from './generateGlobalToolDefaultValues'; -export function convertMediaAnnotations( - mediaType: MediaType, - result: ParsedResult, - config: MediaAnnotatorConfig, - state?: SampleState, -) { +export function convertMediaAnnotations(mediaType: MediaType, result: ParsedResult) { // annotation const pool = [ ['segment', MediaType.VIDEO === mediaType ? 'videoSegmentTool' : 'audioSegmentTool'], @@ -25,11 +19,6 @@ export function convertMediaAnnotations( .map(([type, key]) => { const items = _.get(result, [key, 'result'], []); - if (!items.length && (type === 'tag' || type === 'text') && state !== SampleState.NEW) { - // 生成全局工具的默认值 - return [type, generateDefaultValues(config?.[type])]; - } - return [ type, _.map(items, (item) => { @@ -44,11 +33,7 @@ export function convertMediaAnnotations( .value() as AnnotationsWithGlobal; } -export function convertAudioAndVideoSample( - sample: SampleResponse, - config: MediaAnnotatorConfig, - mediaType?: MediaType, -): MediaSample | undefined { +export function convertAudioAndVideoSample(sample: SampleResponse, mediaType?: MediaType): MediaSample | undefined { if (!sample) { return; } @@ -65,6 +50,6 @@ export function convertAudioAndVideoSample( url: [MediaType.VIDEO, MediaType.AUDIO].includes(mediaType as MediaType) ? sample.file.url.replace('attachment', 'partial') : sample.file.url, - data: convertMediaAnnotations(mediaType!, resultParsed, config, sample.state), + data: convertMediaAnnotations(mediaType!, resultParsed), }; } diff --git a/apps/frontend/src/utils/convertImageConfig.ts b/apps/frontend/src/utils/convertImageConfig.ts index 4c55d542f..4c598420b 100644 --- a/apps/frontend/src/utils/convertImageConfig.ts +++ b/apps/frontend/src/utils/convertImageConfig.ts @@ -30,6 +30,17 @@ export function convertImageConfig(taskConfig?: ToolsConfigState) { editorConfig.line!.lineType = item.config.lineType === 0 ? 'line' : 'spline'; editorConfig.line!.minPointAmount = item.config.lowerLimitPointNum; editorConfig.line!.maxPointAmount = item.config.upperLimitPointNum; + editorConfig.line!.style = { + ...editorConfig.line!.style, + arrowType: item.config.arrowType, + }; + } + + if (toolName === 'relation') { + editorConfig.relation!.style = { + lineStyle: item.config.lineStyle, + arrowType: item.config.arrowType, + }; } if (toolName === 'point') { diff --git a/apps/frontend/src/utils/convertImageSample.ts b/apps/frontend/src/utils/convertImageSample.ts index 6b22c9912..9b5bedfe9 100644 --- a/apps/frontend/src/utils/convertImageSample.ts +++ b/apps/frontend/src/utils/convertImageSample.ts @@ -1,19 +1,14 @@ import _ from 'lodash'; -import type { GlobalToolConfig, ImageAnnotatorOptions, ImageSample } from '@labelu/image-annotator-react'; +import type { ImageSample } from '@labelu/image-annotator-react'; import { omit } from 'lodash/fp'; import type { ToolName } from '@labelu/image'; import { TOOL_NAMES } from '@labelu/image'; -import { SampleState, type ParsedResult, type SampleResponse } from '@/api/types'; +import type { ParsedResult, SampleResponse } from '@/api/types'; import { jsonParse } from './index'; -import { generateDefaultValues } from './generateGlobalToolDefaultValues'; -export function convertImageAnnotations( - result: ParsedResult, - config: Pick & GlobalToolConfig, - state?: SampleState, -) { +export function convertImageAnnotations(result: ParsedResult) { // annotation const pool = [ ['line', 'lineTool'], @@ -21,6 +16,7 @@ export function convertImageAnnotations( ['rect', 'rectTool'], ['polygon', 'polygonTool'], ['cuboid', 'cuboidTool'], + ['relation', 'relationTool'], ['text', 'textTool'], ['tag', 'tagTool'], ] as const; @@ -32,10 +28,6 @@ export function convertImageAnnotations( } const items = _.get(result, [key, 'result']) || _.get(result, [type, 'result'], []); - if (!items.length && (type === 'tag' || type === 'text') && state !== SampleState.NEW) { - // 生成全局工具的默认值 - return [type, generateDefaultValues(config?.[type])]; - } return [ type, @@ -62,10 +54,7 @@ export function convertImageAnnotations( .value(); } -export function convertImageSample( - sample: SampleResponse | undefined, - config: Pick & GlobalToolConfig, -): ImageSample | undefined { +export function convertImageSample(sample: SampleResponse | undefined): ImageSample | undefined { if (!sample) { return; } @@ -81,7 +70,7 @@ export function convertImageSample( return { id, url, - data: convertImageAnnotations(resultParsed, config, sample.state), + data: convertImageAnnotations(resultParsed), meta: _.pick(resultParsed, ['width', 'height', 'rotate']), }; } diff --git a/apps/website/src/pages/image/index.tsx b/apps/website/src/pages/image/index.tsx index 718ebfe54..c0a810509 100644 --- a/apps/website/src/pages/image/index.tsx +++ b/apps/website/src/pages/image/index.tsx @@ -726,6 +726,12 @@ export default function ImagePage() { }; }, []); + const initialValues = useMemo(() => { + return { + tools: defaultConfig, + }; + }, []); + return ( <> (false); const { t } = useTranslation(); + const [modified, setModified] = useState(false); const { globalAnnotations, globalAnnotationsWithPreAnnotation, mediaAnnotationGroup, defaultActiveKeys } = useMemo(() => { @@ -223,7 +224,7 @@ export function AttributePanel() { ...(preAnnotationsWithGlobal?.frame ?? []), ] as MediaAnnotationInUI[]; - if (!_globalAnnotations.length) { + if (!_globalAnnotations.length && !modified) { [preAnnotationsWithGlobal?.tag, preAnnotationsWithGlobal?.text].forEach((values) => { if (values) { _globalAnnotationsWithPreAnnotation.push(...(values as GlobalAnnotation[])); @@ -261,6 +262,7 @@ export function AttributePanel() { preAnnotationsWithGlobal?.tag, preAnnotationsWithGlobal?.text, sortedMediaAnnotations, + modified, ]); const globals = useMemo(() => { @@ -399,6 +401,8 @@ export function AttributePanel() { return; } + setModified(true); + onAnnotationClear(); }; diff --git a/packages/audio-annotator-react/src/MediaAnnotatorWrapper.tsx b/packages/audio-annotator-react/src/MediaAnnotatorWrapper.tsx index c529d0a9c..1a22962ae 100644 --- a/packages/audio-annotator-react/src/MediaAnnotatorWrapper.tsx +++ b/packages/audio-annotator-react/src/MediaAnnotatorWrapper.tsx @@ -272,9 +272,13 @@ function ForwardAnnotator( const annotatorRef = useRef(null); const samples = useMemo(() => propsSamples ?? [], [propsSamples]); const selectedIndexRef = useRef(-1); - const isSampleDataEmpty = useMemo(() => { - return Object.values(currentSample?.data ?? {}).every((item) => item.length === 0); - }, [currentSample]); + const isPreAnnotationEmpty = useMemo(() => { + if (typeof preAnnotations === 'undefined') { + return true; + } + + return Object.values(preAnnotations).every((item) => item.length === 0); + }, [preAnnotations]); const labels = useMemo(() => { if (!currentTool) { return []; @@ -372,8 +376,8 @@ function ForwardAnnotator( ); const convertedAnnotations = useMemo(() => { - return convertAnnotationDataToUI(isSampleDataEmpty && preAnnotations ? preAnnotations : annotationsFromSample); - }, [annotationsFromSample, isSampleDataEmpty, preAnnotations]); + return convertAnnotationDataToUI(!isPreAnnotationEmpty ? preAnnotations! : annotationsFromSample); + }, [annotationsFromSample, isPreAnnotationEmpty, preAnnotations]); // ================== sample state ================== const [annotationsWithGlobal, updateAnnotationsWithGlobal, redo, undo, pastRef, futureRef, reset] = diff --git a/packages/audio-react/package.json b/packages/audio-react/package.json index 8227379a3..c452efcb5 100644 --- a/packages/audio-react/package.json +++ b/packages/audio-react/package.json @@ -1,6 +1,6 @@ { "name": "@labelu/audio-react", - "version": "1.5.2", + "version": "1.6.0", "description": "labelu audio annotation component for react", "main": "./dist/index.mjs", "module": "./dist/index.mjs", @@ -40,7 +40,7 @@ "vite-tsconfig-paths": "^3.5.0" }, "dependencies": { - "@labelu/components-react": "1.7.10", + "@labelu/components-react": "1.8.0", "polished": "^4.2.2", "react-hotkeys-hook": "^4.4.1", "styled-components": "^6.1.15", diff --git a/packages/components-react/package.json b/packages/components-react/package.json index 14028ee7f..d22afa67f 100644 --- a/packages/components-react/package.json +++ b/packages/components-react/package.json @@ -1,6 +1,6 @@ { "name": "@labelu/components-react", - "version": "1.7.10", + "version": "1.8.0", "description": "basic react components for labelU", "main": "./dist/index.mjs", "module": "./dist/index.mjs", @@ -35,7 +35,7 @@ "react" ], "dependencies": { - "@labelu/i18n": "1.0.6", + "@labelu/i18n": "1.1.0", "polished": "^4.2.2", "rc-collapse": "^3.7.1", "rc-dialog": "^9.2.0", diff --git a/packages/components-react/src/AttributeTree/index.tsx b/packages/components-react/src/AttributeTree/index.tsx index 8670c07f0..6774021c0 100644 --- a/packages/components-react/src/AttributeTree/index.tsx +++ b/packages/components-react/src/AttributeTree/index.tsx @@ -1,4 +1,10 @@ -import type { InnerAttribute, TagAnnotationEntity, TextAnnotationEntity, TextAttribute } from '@labelu/interface'; +import type { + EnumerableAttribute, + InnerAttribute, + TagAnnotationEntity, + TextAnnotationEntity, + TextAttribute, +} from '@labelu/interface'; import type { CollapseProps } from 'rc-collapse'; import Collapse from 'rc-collapse'; import { useEffect, useMemo } from 'react'; @@ -194,12 +200,15 @@ export function AttributeTree({ data, config, onChange, className, disabled }: A }); tagConfig?.forEach((item) => { + const defaultValue = (item as EnumerableAttribute).options + .filter((option) => option.isDefault) + .map((option) => option.value); if (!_tagData[item.value]) { _tagData[item.value] = { id: uid(), type: 'tag', value: { - [item.value]: [], + [item.value]: defaultValue, }, } as TagAnnotationEntity; } @@ -211,7 +220,7 @@ export function AttributeTree({ data, config, onChange, className, disabled }: A id: uid(), type: 'text', value: { - [item.value]: '', + [item.value]: (item as TextAttribute).defaultValue, }, } as TextAnnotationEntity; } diff --git a/packages/i18n/package.json b/packages/i18n/package.json index 4d4ba422c..1320b5646 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@labelu/i18n", - "version": "1.0.6", + "version": "1.1.0", "description": "i18n for labelu and components", "scripts": { "build": "vite build && npm run build:types", diff --git a/packages/i18n/src/locales/en-US.json b/packages/i18n/src/locales/en-US.json index 1a4d5c626..2bfaac1bb 100644 --- a/packages/i18n/src/locales/en-US.json +++ b/packages/i18n/src/locales/en-US.json @@ -312,6 +312,8 @@ "rect": "Rectangle", "polygon": "Polygon", "cuboid": "Cuboid", + "relation": "Relation", + "relationWithTips": "Relation (Only with rectangle or polygon tools)", "tag": "Tag", "textDescription": "Text", "strokeWidth": "Stroke width", @@ -355,5 +357,15 @@ "tryDemo": "Try Demo", "demoDescription": "Provide a variety of annotation tools, click 'Try Demo' to start experiencing", - "createTaskDescription": "Provide a variety of annotation tools, click 'Create Task' to start annotating" + "createTaskDescription": "Provide a variety of annotation tools, click 'Create Task' to start annotating", + + "lineStyle": "Line style", + "arrowType": "Arrow type", + "solid": "Solid", + "dashed": "Dashed", + "dotted": "Dotted", + "single": "Single", + "double": "Double", + "none": "None", + "relationToolTooltip": "The relation tool can only be used with rectangle or polygon tools" } diff --git a/packages/i18n/src/locales/zh-CN.json b/packages/i18n/src/locales/zh-CN.json index 6ed70c6ed..eff9e8df7 100644 --- a/packages/i18n/src/locales/zh-CN.json +++ b/packages/i18n/src/locales/zh-CN.json @@ -310,6 +310,8 @@ "toolStyle": "工具样式", "point": "标点", "line": "标线", + "relation": "关联关系", + "relationWithTips": "关联关系(仅限关联矩形/多边形)", "rect": "拉框", "polygon": "多边形", "cuboid": "立体框", @@ -354,5 +356,14 @@ "plainPolygon": "多边形", "deletePoint": "删除点", "makeCuboid": "标立体框", - "cropOverlap": "裁剪重叠区域" + "cropOverlap": "裁剪重叠区域", + "lineStyle": "线条样式", + "arrowType": "箭头类型", + "solid": "实线", + "dashed": "虚线", + "dotted": "点线", + "single": "单向箭头", + "double": "双向箭头", + "none": "无箭头", + "relationToolTooltip": "关联关系工具只能与矩形或多边形工具一起使用" } diff --git a/packages/image-annotator-react/package.json b/packages/image-annotator-react/package.json index 4aa444de4..5d3710f1f 100644 --- a/packages/image-annotator-react/package.json +++ b/packages/image-annotator-react/package.json @@ -1,6 +1,6 @@ { "name": "@labelu/image-annotator-react", - "version": "2.4.3", + "version": "2.5.0", "description": "image annotator for react", "main": "./dist/index.mjs", "module": "./dist/index.mjs", @@ -29,10 +29,10 @@ "react" ], "dependencies": { - "@labelu/components-react": "1.7.10", - "@labelu/image": "1.4.0", + "@labelu/components-react": "1.8.0", + "@labelu/image": "1.5.0", "@labelu/interface": "1.3.1", - "@labelu/i18n": "1.0.6", + "@labelu/i18n": "1.1.0", "lodash.clonedeep": "^4.5.0", "polished": "^4.2.2", "react-hotkeys-hook": "^4.4.1", diff --git a/packages/image-annotator-react/src/AttributePanel/AsideAttributeItem.tsx b/packages/image-annotator-react/src/AttributePanel/AsideAttributeItem.tsx index 24684fc35..33810f123 100644 --- a/packages/image-annotator-react/src/AttributePanel/AsideAttributeItem.tsx +++ b/packages/image-annotator-react/src/AttributePanel/AsideAttributeItem.tsx @@ -13,6 +13,7 @@ import { ReactComponent as LineToolIcon } from '@/assets/tools/line.svg'; import { ReactComponent as RectToolIcon } from '@/assets/tools/rect.svg'; import { ReactComponent as PolygonToolIcon } from '@/assets/tools/polygon.svg'; import { ReactComponent as CuboidToolIcon } from '@/assets/tools/cuboid.svg'; +import { ReactComponent as RelationToolIcon } from '@/assets/tools/relation.svg'; import { ReactComponent as UnknownIcon } from '@/assets/tools/unknown.svg'; import { openAttributeModal } from '@/LabelSection'; import type { AnnotationDataInUI } from '@/context/annotation.context'; @@ -32,6 +33,7 @@ const ToolIconMapping: Record< rect: RectToolIcon, polygon: PolygonToolIcon, cuboid: CuboidToolIcon, + relation: RelationToolIcon, }; interface AttributeItemProps { diff --git a/packages/image-annotator-react/src/AttributePanel/index.tsx b/packages/image-annotator-react/src/AttributePanel/index.tsx index 8610858a4..8856b0d68 100644 --- a/packages/image-annotator-react/src/AttributePanel/index.tsx +++ b/packages/image-annotator-react/src/AttributePanel/index.tsx @@ -213,11 +213,13 @@ export function AttributePanel() { disabled, } = useAnnotationCtx(); const [collapsed, setCollapsed] = useState(false); + const [modified, setModified] = useState(false); const globalAnnotations = useMemo(() => { return Object.values(annotationsWithGlobal).filter((item) => ['text', 'tag'].includes((item as GlobalAnnotation).type), ) as GlobalAnnotation[]; }, [annotationsWithGlobal]); + // @ts-ignore const { t } = useTranslation(); @@ -267,10 +269,10 @@ export function AttributePanel() { return _globals; }, [globalToolConfig.tag, globalToolConfig.text, preLabelMapping?.tag, preLabelMapping?.text]); - const flatGlobalAnnotations = useMemo(() => { + const flatGlobalTagAnnotations = useMemo(() => { const result = globalAnnotations; - if (globalAnnotations.length === 0) { + if (globalAnnotations.length === 0 && !modified) { [preAnnotationsWithGlobal?.tag, preAnnotationsWithGlobal?.text].forEach((values) => { if (values) { result.push(...(values as GlobalAnnotation[])); @@ -279,7 +281,7 @@ export function AttributePanel() { } return result; - }, [globalAnnotations, preAnnotationsWithGlobal?.tag, preAnnotationsWithGlobal?.text]); + }, [globalAnnotations, preAnnotationsWithGlobal?.tag, preAnnotationsWithGlobal?.text, modified]); const titles = useMemo(() => { const _titles = []; @@ -311,7 +313,7 @@ export function AttributePanel() { }); } - if (config?.line || config?.point || config?.polygon || config?.rect || config?.cuboid) { + if (config?.line || config?.point || config?.polygon || config?.rect || config?.cuboid || config?.relation) { _titles.push({ title: t('labels'), key: 'label' as const, @@ -331,6 +333,7 @@ export function AttributePanel() { config?.polygon, config?.rect, config?.cuboid, + config?.relation, globalAnnotations, sortedImageAnnotations.length, ]); @@ -386,6 +389,8 @@ export function AttributePanel() { return; } + setModified(true); + onAnnotationClear(); if (activeKey === 'label') { engine?.clearData(); @@ -450,7 +455,7 @@ export function AttributePanel() { })} - + diff --git a/packages/image-annotator-react/src/ImageAnnotator.tsx b/packages/image-annotator-react/src/ImageAnnotator.tsx index ca6cf8215..e6437d180 100644 --- a/packages/image-annotator-react/src/ImageAnnotator.tsx +++ b/packages/image-annotator-react/src/ImageAnnotator.tsx @@ -194,15 +194,18 @@ function ForwardAnnotator( // remember last tool const memorizeToolLabel = useRef>({} as Record); const [attributeModalOpen, setAttributeModalOpen] = useState(false); + const isPreAnnotationEmpty = useMemo(() => { + if (typeof preAnnotations === 'undefined') { + return true; + } + + return Object.values(preAnnotations).every((item) => item.length === 0); + }, [preAnnotations]); useEffect(() => { setCurrentSample(editingSample || samples?.[0]); }, [editingSample, samples, setCurrentSample]); - const isSampleDataEmpty = useMemo(() => { - return Object.values(currentSample?.data ?? {}).every((item) => item.length === 0); - }, [currentSample]); - // ================== tool ================== const containerRef = useRef(null); const [currentTool, setCurrentTool] = useState(propsSelectedTool); @@ -344,7 +347,7 @@ function ForwardAnnotator( } }); - if (isSampleDataEmpty) { + if (!isPreAnnotationEmpty) { Object.keys(preAnnotations ?? {}).forEach((key) => { if (TOOL_NAMES.includes(key as ToolName)) { engine?.loadData( @@ -360,7 +363,7 @@ function ForwardAnnotator( } }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [annotationsFromSample, config, currentSample, engine, isSampleDataEmpty, preAnnotations, tools]); + }, [annotationsFromSample, config, currentSample, engine, preAnnotations, tools, isPreAnnotationEmpty]); const selectedIndexRef = useRef(-1); @@ -392,7 +395,7 @@ function ForwardAnnotator( const _data = currentSample?.data ?? {}; const _preData = preAnnotations ?? {}; - if (isSampleDataEmpty) { + if (!isPreAnnotationEmpty) { Object.keys(_preData).forEach((key) => { _preData[key as AllAnnotationType]?.forEach((item) => { mapping[item.id] = { @@ -413,7 +416,7 @@ function ForwardAnnotator( }); return mapping; - }, [currentSample?.data, isSampleDataEmpty, preAnnotations]); + }, [currentSample?.data, isPreAnnotationEmpty, preAnnotations]); const [annotationsWithGlobal, updateAnnotationsWithGlobal, redo, undo, pastRef, futureRef, reset] = useRedoUndo(annotationsMapping, { @@ -589,9 +592,9 @@ function ForwardAnnotator( useEffect(() => { // 删除标记 const handleDelete = (annotation: AnnotationData) => { - const newAnnotations = omit(annotationsWithGlobal, annotation.id); - console.log('newAnnotations', newAnnotations); - updateAnnotationsWithGlobal(newAnnotations); + updateAnnotationsWithGlobal((pre) => { + return omit(pre!, annotation.id); + }); setSelectedAnnotation((pre) => { if (pre?.id === annotation.id) { return undefined; @@ -608,6 +611,22 @@ function ForwardAnnotator( }; }, [annotationsWithGlobal, engine, updateAnnotationsWithGlobal]); + useEffect(() => { + const handleRelationDelete = (relations: AnnotationData[]) => { + updateAnnotationsWithGlobal((pre) => { + return relations.reduce((acc, item) => { + return omit(acc, item.id); + }, pre!); + }); + }; + + engine?.on('relatedRelationDelete', handleRelationDelete); + + return () => { + engine?.off('relatedRelationDelete', handleRelationDelete); + }; + }, [annotationsWithGlobal, engine, updateAnnotationsWithGlobal]); + useEffect(() => { const handleAttributesChange = (annotation: AnnotationData) => { if (!engine) { @@ -671,7 +690,7 @@ function ForwardAnnotator( const _label = engine?.activeToolName ? labelMappingByTool[engine.activeToolName][label] : undefined; setSelectedLabel(_label); - propsOnLabelChange?.(currentTool, _label); + propsOnLabelChange?.(engine!.activeToolName!, _label); }; // 改变标签 engine?.on('labelChange', handleLabelChange); diff --git a/packages/image-annotator-react/src/Toolbar/index.tsx b/packages/image-annotator-react/src/Toolbar/index.tsx index 69f39122a..85d6d679f 100644 --- a/packages/image-annotator-react/src/Toolbar/index.tsx +++ b/packages/image-annotator-react/src/Toolbar/index.tsx @@ -10,6 +10,7 @@ import { ReactComponent as LineIcon } from '@/assets/tools/line.svg'; import { ReactComponent as RectIcon } from '@/assets/tools/rect.svg'; import { ReactComponent as PolygonIcon } from '@/assets/tools/polygon.svg'; import { ReactComponent as CuboidIcon } from '@/assets/tools/cuboid.svg'; +import { ReactComponent as RelationIcon } from '@/assets/tools/relation.svg'; import { useTool } from '@/context/tool.context'; import { useAnnotationCtx } from '@/context/annotation.context'; import { useHistoryCtx } from '@/context/history.context'; @@ -28,6 +29,7 @@ const iconMapping = { rect: , polygon: , cuboid: , + relation: , }; export interface IToolbarInEditorProps { @@ -61,6 +63,7 @@ export function AnnotatorToolbar({ right }: IToolbarInEditorProps) { rect: t('rect'), polygon: t('polygon'), cuboid: t('cuboid'), + relation: t('relationWithTips'), }), [t], ); diff --git a/packages/image-annotator-react/src/assets/tools/relation.svg b/packages/image-annotator-react/src/assets/tools/relation.svg new file mode 100644 index 000000000..c7169bf32 --- /dev/null +++ b/packages/image-annotator-react/src/assets/tools/relation.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/image-annotator-react/src/assets/tools/unknown.svg b/packages/image-annotator-react/src/assets/tools/unknown.svg index 3d9308968..fb99b0ec5 100644 --- a/packages/image-annotator-react/src/assets/tools/unknown.svg +++ b/packages/image-annotator-react/src/assets/tools/unknown.svg @@ -1 +1 @@ - + diff --git a/packages/image/package.json b/packages/image/package.json index 864d3c230..9388f0d77 100644 --- a/packages/image/package.json +++ b/packages/image/package.json @@ -1,6 +1,6 @@ { "name": "@labelu/image", - "version": "1.4.0", + "version": "1.5.0", "description": "Image annotation tool for labelU", "author": { "name": "GaryShen", diff --git a/packages/image/src/Annotator.ts b/packages/image/src/Annotator.ts index 62de217f8..2574f7193 100644 --- a/packages/image/src/Annotator.ts +++ b/packages/image/src/Annotator.ts @@ -405,7 +405,9 @@ export class Annotator extends AnnotatorBase { const AnnotationClass = AnnotationMapping[activeToolName]; - currentTool.setLabel(value); + if (!currentTool.setLabel(value)) { + return; + } if (this.cursorManager) { this.cursorManager.color = AnnotationClass.labelStatic.getLabelColor(value); diff --git a/packages/image/src/AnnotatorBase.ts b/packages/image/src/AnnotatorBase.ts index 87518048e..1b1740ff9 100644 --- a/packages/image/src/AnnotatorBase.ts +++ b/packages/image/src/AnnotatorBase.ts @@ -16,6 +16,8 @@ import type { CursorManager } from './core/CursorManager'; import { createCursorManager } from './singletons/cursorManager'; import { createConfig } from './singletons/annotationConfig'; import type { AnnotatorOptions } from './core/AnnotatorConfig'; +// import { relationManager } from './singletons/relationManager'; +import { RelationTool } from './tools/Relation.tool'; const ToolMapping = { line: LineTool, @@ -23,6 +25,7 @@ const ToolMapping = { rect: RectTool, polygon: PolygonTool, cuboid: CuboidTool, + relation: RelationTool, } as const; export class AnnotatorBase { @@ -135,6 +138,7 @@ export class AnnotatorBase { ...(config[toolName] as any), requestEdit: typeof config.requestEdit === 'function' ? config.requestEdit : () => true, showOrder: config.showOrder ?? false, + getTools: () => this.tools, }), ); } @@ -190,6 +194,9 @@ export class AnnotatorBase { annotations.forEach((annotation) => { annotation.render(renderer!.ctx!); }); + + // relationManager.render(renderer!.ctx!); + // 草稿在最上层 draft?.render(renderer!.ctx!); }; @@ -233,6 +240,7 @@ export class AnnotatorBase { showOrder: config.showOrder ?? false, requestEdit: typeof config.requestEdit === 'function' ? config.requestEdit : () => true, data: data as AllTypeAnnotationDataGroup, + getTools: () => this.tools, }), ); } else { diff --git a/packages/image/src/annotations/Annotation.ts b/packages/image/src/annotations/Annotation.ts index 950601b5a..73e23cbd1 100644 --- a/packages/image/src/annotations/Annotation.ts +++ b/packages/image/src/annotations/Annotation.ts @@ -1,13 +1,16 @@ -import type { BasicImageAnnotation } from '../interface'; +import type { Line } from '../shapes'; +import type { BasicImageAnnotation, ToolName } from '../interface'; import { Group } from '../shapes/Group'; -import { type Shape } from '../shapes'; -import { monitor } from '../singletons'; +import { eventEmitter, monitor } from '../singletons'; import { DEFAULT_LABEL_COLOR } from '../constant'; +import { DomPortal } from '../core/DomPortal'; +import { EInternalEvent } from '../enums/internalEvent.enum'; // TODO: 去除本类的any export interface AnnotationParams { id: string; data: Data; + name: ToolName; style: Style; hoveredStyle?: Style | ((style: Style) => Style); @@ -22,14 +25,30 @@ export interface AnnotationParams { onPick?: (e: MouseEvent, annotation: any) => void; } -export class Annotation, Style> { +export interface TextPositionParams { + shape: Line; + container: HTMLElement; + isAboveLine: boolean; +} + +export interface TextPosition { + x: number; + y: number; + rotate: number; +} + +export class Annotation { public id: string; + public doms: DomPortal[] = []; + public data: Data; + public name: ToolName; + public style: Style; - public group: Group; + public group: Group; public hoveredStyle?: Style | ((style: Style) => Style); @@ -45,33 +64,132 @@ export class Annotation Annotation.rotationThreshold) { + finalRotate = rotate + 180; + } + + return { x, y, rotate: finalRotate }; + } + + static createTextDomPortal( + content: string, + isAboveLine: boolean, + order: number, + bindShape: Line, + style?: Record, + ): DomPortal { + return new DomPortal({ + content, + getPosition: (shape, container) => + Annotation.calculateTextPosition({ shape: shape as Line, container, isAboveLine }), + order, + preventPointerEvents: true, + bindShape, + style, + }); + } + public get isHovered() { return false; } - constructor({ id, data, style, hoveredStyle, showOrder }: AnnotationParams) { + constructor({ id, data, style, hoveredStyle, showOrder, name }: AnnotationParams) { this.id = id; this.data = data; this.style = style; this.hoveredStyle = hoveredStyle; this.showOrder = showOrder; - + this.name = name; this.group = new Group(id, data.order); // 建立order和id的映射关系 monitor?.setOrderIndexedAnnotationIds(data.order, id); + + this.group.on(EInternalEvent.MouseOver, this.__handleMouseOver); + this.group.on(EInternalEvent.MouseOut, this.__handleMouseOut); + eventEmitter.on(EInternalEvent.NoTarget, this.__handleMouseOut); } + private __handleMouseOver = () => { + this.doms.forEach((dom) => dom.toTop()); + }; + + private __handleMouseOut = () => { + this.doms.forEach((dom) => dom.resetZIndex()); + }; + public get bbox() { return this.group.bbox; } + public getCenter() { + const width = this.bbox.maxX - this.bbox.minX; + const height = this.bbox.maxY - this.bbox.minY; + + return { + x: this.bbox.minX + width / 2, + y: this.bbox.minY + height / 2, + }; + } + public render(_ctx: CanvasRenderingContext2D) { this.group.render(_ctx); } public destroy() { + this.doms.forEach((dom) => dom.destroy()); this.data = null as any; this.group.destroy(); + this.group.off(EInternalEvent.MouseOver, this.__handleMouseOver); + this.group.off(EInternalEvent.MouseOut, this.__handleMouseOut); + eventEmitter.off(EInternalEvent.NoTarget, this.__handleMouseOut); + } + + protected generateLabelDom(text: string, style?: string, extra?: string) { + return ` +
+ ${this.showOrder ? this.data.order + ' ' : ''}${text} + ${extra ?? ''} +
+ `; + } + + protected generateAttributeDom(text: string, style?: string, extra?: string) { + return ` +
+ ${text + .split('\n') + .map((line) => `
${line}
`) + .join('')} + ${extra ?? ''} +
+ `; } } diff --git a/packages/image/src/annotations/Cuboid.annotation.ts b/packages/image/src/annotations/Cuboid.annotation.ts index 40b732467..60de119fc 100644 --- a/packages/image/src/annotations/Cuboid.annotation.ts +++ b/packages/image/src/annotations/Cuboid.annotation.ts @@ -2,6 +2,7 @@ import Color from 'color'; import type { ILabel } from '@labelu/interface'; import uid from '@/utils/uid'; +import { DomPortal } from '@/core/DomPortal'; import type { BasicImageAnnotation } from '../interface'; import type { AnnotationParams } from './Annotation'; @@ -42,9 +43,9 @@ export interface CuboidData extends BasicImageAnnotation { back: CuboidVertex; } -export type CuboidGroup = Group; +export type CuboidGroup = Group; -export class AnnotationCuboid extends Annotation { +export class AnnotationCuboid extends Annotation { private _realFront: Polygon | null = null; public labelColor: string = LabelBase.DEFAULT_COLOR; @@ -225,23 +226,44 @@ export class AnnotationCuboid extends Annotation ({ + x: shape.dynamicCoordinate[0].x, + y: shape.dynamicCoordinate[0].y - container.clientHeight, + }), + order: data.order, + preventPointerEvents: true, + bindShape: backShape, style: { - opacity: visible ? 1 : 0, - fill: labelColor, + display: visible ? 'block' : 'none', }, }), ); + + if (attributesText) { + this.doms.push( + new DomPortal({ + content: this.generateAttributeDom(attributesText), + getPosition: (shape) => ({ + x: shape.dynamicCoordinate[3].x, + y: shape.dynamicCoordinate[3].y + 5, + }), + order: data.order, + preventPointerEvents: true, + bindShape: frontShape, + style: { + display: visible ? 'block' : 'none', + }, + }), + ); + } } public destroy(): void { diff --git a/packages/image/src/annotations/Line.annotation.ts b/packages/image/src/annotations/Line.annotation.ts index 6659ebc69..ad36e4102 100644 --- a/packages/image/src/annotations/Line.annotation.ts +++ b/packages/image/src/annotations/Line.annotation.ts @@ -1,7 +1,7 @@ import type { ILabel } from '@labelu/interface'; import Color from 'color'; -import uid from '@/utils/uid'; +import { DomPortal } from '@/core/DomPortal'; import type { BasicImageAnnotation } from '../interface'; import type { AnnotationParams } from './Annotation'; @@ -9,7 +9,7 @@ import { Annotation } from './Annotation'; import type { LineStyle } from '../shapes/Line.shape'; import { Line } from '../shapes/Line.shape'; import type { AxisPoint } from '../shapes/Point.shape'; -import type { Group, TextStyle } from '../shapes'; +import type { Group } from '../shapes'; import { Spline, ShapeText } from '../shapes'; import { LabelBase } from './Label.base'; import { EInternalEvent } from '../enums'; @@ -19,7 +19,7 @@ export interface PointItem extends AxisPoint { id: string; } -export type LineGroup = Group; +export type LineGroup = Group; export interface LineData extends BasicImageAnnotation { points: PointItem[]; @@ -31,7 +31,7 @@ export interface LineData extends BasicImageAnnotation { controlPoints?: AxisPoint[]; } -export class AnnotationLine extends Annotation { +export class AnnotationLine extends Annotation { public labelColor: string = LabelBase.DEFAULT_COLOR; public strokeColor: string = LabelBase.DEFAULT_COLOR; @@ -86,7 +86,7 @@ export class AnnotationLine extends Annotation ({ + x: shape.dynamicCoordinate[0].x, + y: shape.dynamicCoordinate[0].y - container.clientHeight, + }), + order: data.order, + preventPointerEvents: true, + bindShape: group.shapes[0] as Line, style: { - opacity: visible ? 1 : 0, - fill: labelColor, + display: visible ? 'block' : 'none', }, }), ); + + if (attributesText) { + this.doms.push( + new DomPortal({ + content: this.generateAttributeDom(attributesText), + getPosition: (shape) => ({ + x: shape.dynamicCoordinate[0].x, + y: shape.dynamicCoordinate[0].y, + }), + order: data.order, + preventPointerEvents: true, + bindShape: group.shapes[0], + style: { + display: visible ? 'block' : 'none', + }, + }), + ); + } } private _handleMouseOver = () => { diff --git a/packages/image/src/annotations/Point.annotation.ts b/packages/image/src/annotations/Point.annotation.ts index 63eeff1ed..e06aa85be 100644 --- a/packages/image/src/annotations/Point.annotation.ts +++ b/packages/image/src/annotations/Point.annotation.ts @@ -2,6 +2,7 @@ import type { ILabel } from '@labelu/interface'; import Color from 'color'; import uid from '@/utils/uid'; +import { DomPortal } from '@/core/DomPortal'; import type { BasicImageAnnotation } from '../interface'; import type { AnnotationParams } from './Annotation'; @@ -16,9 +17,9 @@ import { EInternalEvent } from '../enums'; export type PointData = BasicImageAnnotation & AxisPoint; -export type PointGroup = Group; +export type PointGroup = Group; -export class AnnotationPoint extends Annotation { +export class AnnotationPoint extends Annotation { public labelColor: string = LabelBase.DEFAULT_COLOR; public strokeColor: string = LabelBase.DEFAULT_COLOR; @@ -62,22 +63,59 @@ export class AnnotationPoint extends Annotation ({ + x: shape.dynamicCoordinate[0].x, + y: shape.dynamicCoordinate[0].y - container.clientHeight - Annotation.strokeWidth - 4, + }), + order: data.order, + preventPointerEvents: true, + bindShape: group.shapes[0], style: { - opacity: visible ? 1 : 0, - fill: labelColor, + display: visible ? 'block' : 'none', }, }), ); + + if (attributesText) { + this.doms.push( + new DomPortal({ + content: this.generateAttributeDom(attributesText), + getPosition: (shape, container) => ({ + x: shape.dynamicCoordinate[0].x - container.clientWidth / 2, + y: shape.dynamicCoordinate[0].y + 4, + }), + order: data.order, + preventPointerEvents: true, + bindShape: group.shapes[0], + style: { + display: visible ? 'block' : 'none', + }, + }), + ); + } + + // const attributesText = AnnotationPoint.labelStatic.getLabelTextWithAttributes(data.label, data.attributes); + + // group.add( + // new ShapeText({ + // id: uid(), + // coordinate: { + // x: data.x, + // y: data.y, + // }, + // text: `${this.showOrder ? data.order + ' ' : ''}${attributesText}`, + // style: { + // opacity: visible ? 1 : 0, + // fill: labelColor, + // }, + // }), + // ); } private _handleMouseOver = () => { diff --git a/packages/image/src/annotations/Polygon.annotation.ts b/packages/image/src/annotations/Polygon.annotation.ts index 3fa7ea394..4f998a774 100644 --- a/packages/image/src/annotations/Polygon.annotation.ts +++ b/packages/image/src/annotations/Polygon.annotation.ts @@ -3,6 +3,7 @@ import type { ILabel } from '@labelu/interface'; import Color from 'color'; import uid from '@/utils/uid'; +import { DomPortal } from '@/core/DomPortal'; import type { BasicImageAnnotation } from '../interface'; import type { AnnotationParams } from './Annotation'; @@ -25,9 +26,9 @@ export interface PolygonData extends BasicImageAnnotation { controlPoints?: AxisPoint[]; } -export type PolygonGroup = Group; +export type PolygonGroup = Group; -export class AnnotationPolygon extends Annotation { +export class AnnotationPolygon extends Annotation { public labelColor: string = LabelBase.DEFAULT_COLOR; public strokeColor: string = LabelBase.DEFAULT_COLOR; @@ -101,19 +102,64 @@ export class AnnotationPolygon extends Annotation { + // 找到group中y最小的点 + let point = shape.dynamicCoordinate[0]; + + for (const coord of shape.dynamicCoordinate) { + if (coord.y < point.y) { + point = coord; + } + } + + return { + x: point.x, + y: point.y - container.clientHeight - Annotation.strokeWidth - 2, + }; + }, + order: data.order, + preventPointerEvents: true, + bindShape: group.shapes[0], style: { - opacity: visible ? 1 : 0, - fill: labelColor, + display: visible ? 'block' : 'none', }, }), ); + + if (attributesText) { + this.doms.push( + new DomPortal({ + content: this.generateAttributeDom(attributesText), + getPosition: (shape, container) => { + // 找到group中y最大的点 + let point = shape.dynamicCoordinate[0]; + + for (const coord of shape.dynamicCoordinate) { + if (coord.y > point.y) { + point = coord; + } + } + + return { + x: point.x, + y: point.y + container.clientHeight + Annotation.strokeWidth + 2, + }; + }, + order: data.order, + preventPointerEvents: true, + bindShape: group.shapes[0], + style: { + display: visible ? 'block' : 'none', + }, + }), + ); + } } private _handleMouseOver = () => { @@ -162,6 +208,20 @@ export class AnnotationPolygon extends Annotation item.x)); + const maxY = Math.max(...group.shapes[0].dynamicCoordinate.map((item) => item.y)); + const minX = Math.min(...group.shapes[0].dynamicCoordinate.map((item) => item.x)); + const minY = Math.min(...group.shapes[0].dynamicCoordinate.map((item) => item.y)); + + return { + x: (maxX + minX) / 2, + y: (maxY + minY) / 2, + }; + } + public destroy(): void { super.destroy(); eventEmitter.off(EInternalEvent.NoTarget, this._handleMouseOut); diff --git a/packages/image/src/annotations/Rect.annotation.ts b/packages/image/src/annotations/Rect.annotation.ts index 0d3f3c7df..edbbc17e2 100644 --- a/packages/image/src/annotations/Rect.annotation.ts +++ b/packages/image/src/annotations/Rect.annotation.ts @@ -7,12 +7,12 @@ import type { BasicImageAnnotation } from '../interface'; import type { AnnotationParams } from './Annotation'; import { Annotation } from './Annotation'; import { Rect, type RectStyle } from '../shapes/Rect.shape'; -import type { Line } from '../shapes/Line.shape'; import { ShapeText } from '../shapes/Text.shape'; import type { Group } from '../shapes'; import { LabelBase } from './Label.base'; import { EInternalEvent } from '../enums'; import { eventEmitter } from '../singletons'; +import { DomPortal } from '../core/DomPortal'; export interface RectData extends BasicImageAnnotation { x: number; @@ -21,9 +21,9 @@ export interface RectData extends BasicImageAnnotation { height: number; } -export type RectGroup = Group; +export type RectGroup = Group; -export class AnnotationRect extends Annotation { +export class AnnotationRect extends Annotation { public labelColor: string = LabelBase.DEFAULT_COLOR; public strokeColor: string = LabelBase.DEFAULT_COLOR; @@ -47,7 +47,7 @@ export class AnnotationRect extends Annotation ({ + x: shape.dynamicCoordinate[0].x - Annotation.strokeWidth / 2, + y: shape.dynamicCoordinate[0].y - container.clientHeight, + }), + order: data.order, + preventPointerEvents: true, + bindShape: group.shapes[0] as Rect, style: { - opacity: visible ? 1 : 0, - fill: labelColor, + display: visible ? 'block' : 'none', }, }), ); + + if (attributesText) { + this.doms.push( + new DomPortal({ + content: this.generateAttributeDom(attributesText), + getPosition: (shape) => ({ + x: shape.dynamicCoordinate[0].x, + y: shape.dynamicCoordinate[0].y + (shape as Rect).dynamicHeight + 5, + }), + order: data.order, + preventPointerEvents: true, + bindShape: group.shapes[0] as Rect, + style: { + display: visible ? 'block' : 'none', + }, + }), + ); + } } private _handleMouseOver = () => { @@ -131,6 +151,17 @@ export class AnnotationRect extends Annotation + | Annotation + | Annotation; + +export interface RelationAnnotationParams extends AnnotationParams { + getAnnotation: (id: string) => ValidAnnotationType | undefined; +} + +export class AnnotationRelation extends Annotation { + public labelColor: string = LabelBase.DEFAULT_COLOR; + public strokeColor: string = LabelBase.DEFAULT_COLOR; + + private _getAnnotation: (id: string) => ValidAnnotationType | undefined; + + constructor({ getAnnotation, ...params }: RelationAnnotationParams) { + super(params); + this._getAnnotation = getAnnotation; + this._initializeColors(params.data.label); + this._setupShapes(); + this._setupEventListeners(); + } + + static buildLabelMapping(labels: ILabel[]) { + AnnotationRelation.labelStatic = new LabelBase(labels); + } + + static labelStatic: LabelBase; + + /** + * 将数组分块 + * @param arr 要分块的数组 + * @param size 每块的大小 + * @returns 分块后的数组 + */ + static chunk(arr: T[], size: number): T[][] { + const result: T[][] = []; + for (let i = 0; i < arr.length; i += size) { + result.push(arr.slice(i, i + size)); + } + return result; + } + + /** + * 初始化颜色 + */ + private _initializeColors(label: string | undefined): void { + this.labelColor = AnnotationRelation.labelStatic.getLabelColor(label || ''); + this.strokeColor = Color(this.labelColor).alpha(Annotation.strokeOpacity).string(); + } + + /** + * 设置事件监听器 + */ + private _setupEventListeners(): void { + this.group.on(EInternalEvent.MouseOver, this._handleMouseOver); + this.group.on(EInternalEvent.MouseOut, this._handleMouseOut); + eventEmitter.on(EInternalEvent.NoTarget, this._handleMouseOut); + } + + /** + * 创建连接线 + */ + private _createConnectionLine( + sourceCenter: { x: number; y: number }, + targetCenter: { x: number; y: number }, + commonStyle: any, + ): Line { + return new Line({ + id: uid(), + coordinate: [ + { + x: axis!.getOriginalX(sourceCenter.x), + y: axis!.getOriginalY(sourceCenter.y), + }, + { + x: axis!.getOriginalX(targetCenter.x), + y: axis!.getOriginalY(targetCenter.y), + }, + ], + style: { + ...commonStyle, + stroke: this.strokeColor, + strokeWidth: Annotation.strokeWidth, + }, + }); + } + + /** + * 获取标注中心点 + */ + private _getAnnotationCenters(): { + sourceCenter: { x: number; y: number } | null; + targetCenter: { x: number; y: number } | null; + } { + const sourceAnnotation = this._getAnnotation(this.data.sourceId); + const targetAnnotation = this._getAnnotation(this.data.targetId); + + return { + sourceCenter: sourceAnnotation?.getCenter() || null, + targetCenter: targetAnnotation?.getCenter() || null, + }; + } + + /** + * 设置形状 + */ + private _setupShapes(): void { + const { data, group, style } = this; + const { visible = true } = data; + + const commonStyle = { + ...style, + opacity: visible ? 1 : 0, + }; + + const { sourceCenter, targetCenter } = this._getAnnotationCenters(); + + if (!sourceCenter || !targetCenter) { + console.error(`无法找到源标注或目标标注: sourceId=${this.data.sourceId}, targetId=${this.data.targetId}`); + return; + } + + // 创建连接线 + const line = this._createConnectionLine(sourceCenter, targetCenter, commonStyle); + group.add(line); + + // 创建标签文本 + const labelText = AnnotationRelation.labelStatic.getLabelText(data.label); + this.doms.push( + Annotation.createTextDomPortal( + this.generateLabelDom(labelText), + false, // 标签文本在线条下方 + data.order, + group.shapes[0] as Line, + { + display: visible ? 'block' : 'none', + }, + ), + ); + + const attributesText = AnnotationRelation.labelStatic.getAttributeTexts(data.label, data.attributes); + if (attributesText) { + this.doms.push( + Annotation.createTextDomPortal( + this.generateAttributeDom(attributesText), + true, // 属性文本在线条上方 + data.order, + group.shapes[0] as Line, + { + display: visible ? 'block' : 'none', + }, + ), + ); + } + } + + /** + * 处理鼠标悬停事件 + */ + private _handleMouseOver = (): void => { + const { data, group, style, hoveredStyle } = this; + const { visible = true } = data; + + const commonStyle = { + ...style, + opacity: visible ? 1 : 0, + }; + + if (hoveredStyle) { + group.updateStyle(typeof hoveredStyle === 'function' ? hoveredStyle(style) : hoveredStyle); + } else { + this._updateShapeStyles(group, { + ...commonStyle, + stroke: this.strokeColor, + strokeWidth: Annotation.strokeWidth + CONSTANTS.STROKE_WIDTH_INCREASE, + }); + } + }; + + /** + * 处理鼠标离开事件 + */ + private _handleMouseOut = (): void => { + const { data, style, group } = this; + const { visible = true } = data; + + const commonStyle = { + ...style, + opacity: visible ? 1 : 0, + }; + + this._updateShapeStyles(group, { + ...commonStyle, + stroke: this.strokeColor, + strokeWidth: Annotation.strokeWidth, + }); + }; + + /** + * 更新形状样式 + */ + private _updateShapeStyles(group: any, style: any): void { + group.each((shape: any) => { + if (!(shape instanceof ShapeText)) { + shape.updateStyle(style); + } + }); + } + + /** + * 销毁实例 + */ + public destroy(): void { + super.destroy(); + eventEmitter.off(EInternalEvent.NoTarget, this._handleMouseOut); + } +} diff --git a/packages/image/src/annotations/index.ts b/packages/image/src/annotations/index.ts index 11cbb96b4..9ff6ebb46 100644 --- a/packages/image/src/annotations/index.ts +++ b/packages/image/src/annotations/index.ts @@ -3,6 +3,7 @@ import { AnnotationLine } from './Line.annotation'; import { AnnotationPoint } from './Point.annotation'; import { AnnotationPolygon } from './Polygon.annotation'; import { AnnotationRect } from './Rect.annotation'; +import { AnnotationRelation } from './Relation.annotation'; export * from './Cuboid.annotation'; export * from './Polygon.annotation'; @@ -10,6 +11,7 @@ export * from './Rect.annotation'; export * from './Line.annotation'; export * from './Point.annotation'; export * from './Annotation'; +export * from './Relation.annotation'; export const AnnotationMapping = { cuboid: AnnotationCuboid, @@ -17,4 +19,5 @@ export const AnnotationMapping = { rect: AnnotationRect, line: AnnotationLine, point: AnnotationPoint, + relation: AnnotationRelation, }; diff --git a/packages/image/src/constant/index.ts b/packages/image/src/constant/index.ts index ac7ad5e44..388b6a638 100644 --- a/packages/image/src/constant/index.ts +++ b/packages/image/src/constant/index.ts @@ -1,4 +1,5 @@ -export const TOOL_NAMES = ['point', 'line', 'rect', 'polygon', 'cuboid'] as const; +export const TOOL_NAMES = ['point', 'line', 'rect', 'polygon', 'cuboid', 'relation'] as const; + export const DEFAULT_LABEL_TEXT = 'noneLabel'; export const DEFAULT_LABEL_VALUE = 'noneAttribute'; @@ -6,3 +7,5 @@ export const DEFAULT_LABEL_VALUE = 'noneAttribute'; export const DEFAULT_LABEL_COLOR = '#999'; export const DEFAULT_BACKGROUND_COLOR = '#F0F0F0'; + +export const VALID_RELATION_TOOLS = ['rect', 'polygon'] as const; diff --git a/packages/image/src/core/AnnotatorConfig.ts b/packages/image/src/core/AnnotatorConfig.ts index 14dcfe44e..48647f7f0 100644 --- a/packages/image/src/core/AnnotatorConfig.ts +++ b/packages/image/src/core/AnnotatorConfig.ts @@ -5,6 +5,7 @@ import type { PointToolOptions, PolygonToolOptions, RectToolOptions, + RelationToolOptions, } from '@/tools'; export interface AnnotatorOptions { @@ -24,6 +25,8 @@ export interface AnnotatorOptions { cuboid?: CuboidToolOptions; + relation?: RelationToolOptions; + /** * 全局的是否可编辑设置,权重高于requestEdit函数 * @@ -94,6 +97,8 @@ export default class AnnotatorConfig { public cuboid?: CuboidToolOptions; + public relation?: RelationToolOptions; + public editable?: boolean = true; public image: { @@ -123,6 +128,7 @@ export default class AnnotatorConfig { this.height = options.height; this.line = options.line; this.point = options.point; + this.relation = options.relation; this.rect = options.rect; this.polygon = options.polygon; this.cuboid = options.cuboid; diff --git a/packages/image/src/core/CustomRBush.ts b/packages/image/src/core/CustomRBush.ts index 4da829ed0..77dcba252 100644 --- a/packages/image/src/core/CustomRBush.ts +++ b/packages/image/src/core/CustomRBush.ts @@ -2,18 +2,19 @@ import type { BBox } from 'rbush'; import RBush from 'rbush'; import uid from '@/utils/uid'; +import type { AllShape } from '@/shapes/types'; import type { AxisPoint, Shape } from '../shapes'; -import { Line, Point } from '../shapes'; +import { Line, Point, ShapeText } from '../shapes'; import type { Group } from '../shapes/Group'; import { axis, eventEmitter } from '../singletons'; import { EInternalEvent } from '../enums'; -import { getDistanceToLine, getLatestPointOnLine } from '../shapes/math.util'; +import { getDistanceToLine, getLatestPointOnLine, isBBoxIntersect } from '../shapes/math.util'; export interface RBushItem extends BBox { id: string; _shape?: Shape; - _group?: Group, any>; + _group?: Group; /** 标注顺序,目前只在group当中有这个值 */ _order?: number; } @@ -47,7 +48,7 @@ export class CustomRBush extends RBush { this._mapping.delete(item.id); if (_group && _shape) { - _group.remove(_shape); + _group.remove(_shape as AllShape); } super.remove(item); @@ -85,6 +86,34 @@ export class CustomRBush extends RBush { }); } + /** + * 判断点是否在画布图形的任一包围盒中(文字除外) + * + * @param coordinate 坐标点 + * @returns 是否在包围盒中 + */ + public getRBushItemsByPointInBBox(coordinate: AxisPoint) { + const rbushItems = this.scanCanvasObject(coordinate, 0); + + return rbushItems.filter((item) => { + const _bbox = item._group?.getBBoxByFilter((shape) => !(shape instanceof ShapeText)); + + if (!_bbox) { + return false; + } + + // 创建一个以coordinate为中心的极小bbox来检测点是否在_bbox内 + const pointBBox: BBox = { + minX: coordinate.x, + minY: coordinate.y, + maxX: coordinate.x, + maxY: coordinate.y, + }; + + return isBBoxIntersect(pointBBox, _bbox); + }); + } + /** * 扫描多边形并设置最近的点 * diff --git a/packages/image/src/core/DomPortal.ts b/packages/image/src/core/DomPortal.ts index 52ca72782..1cf76f7e6 100644 --- a/packages/image/src/core/DomPortal.ts +++ b/packages/image/src/core/DomPortal.ts @@ -1,13 +1,21 @@ import { EInternalEvent } from '../enums'; -import type { AxisPoint, Shape } from '../shapes'; import { axis, eventEmitter } from '../singletons'; +import type { AllShape } from '../shapes/types'; -export interface DomPortalParams { +interface DomPortalPosition { x: number; y: number; - offset?: AxisPoint; - element: HTMLElement; - bindShape: Shape; + rotate?: number; +} + +export interface DomPortalParams { + rotate?: number; + order?: number; + getPosition?: (shape: AllShape, wrapper: HTMLElement) => DomPortalPosition; + content: HTMLElement | string; + bindShape: AllShape; + preventPointerEvents?: boolean; + style?: Record; } export class DomPortal { @@ -15,89 +23,153 @@ export class DomPortal { public y: number = 0; - public offset: AxisPoint = { x: 0, y: 0 }; + public order: number = 2; + + /** + * html string or dom element + */ + private _content: string | HTMLElement | null = null; - private _element: HTMLElement | null = null; + private _rotate: number = 0; + + private _preventPointerEvents: boolean = false; private _container: HTMLElement = axis!.renderer!.canvas.parentElement!; - private _shape: Shape; + private _wrapper: HTMLElement = document.createElement('div'); + + private _shape: AllShape; + + private _getPosition: () => DomPortalPosition; - constructor({ x, y, element, bindShape, offset }: DomPortalParams) { - this.x = x; - this.y = y; - this._element = element; + constructor({ + content, + bindShape, + preventPointerEvents = false, + order = 2, + rotate = 0, + getPosition, + style, + }: DomPortalParams) { + this._content = content; this._shape = bindShape; + this._preventPointerEvents = preventPointerEvents; + this.order = order; + this._rotate = rotate; + this._getPosition = () => { + let position: DomPortalPosition = { + x: 0, + y: 0, + }; + + if (typeof getPosition === 'function') { + position = getPosition(this._shape, this._wrapper); + } else { + position = { + x: this._shape.dynamicCoordinate[0].x, + y: this._shape.dynamicCoordinate[0].y, + }; + } - if (offset) { - this.offset = offset; - } + this.x = position.x; + this.y = position.y; + + if (position.rotate) { + this._rotate = position.rotate; + } + + return position; + }; if (bindShape) { eventEmitter.on(EInternalEvent.AxisChange, this._handleUpdatePosition); eventEmitter.on(EInternalEvent.MouseMove, this._handleUpdatePositionByMouse); } - if (!element) { + if (!content) { throw new Error('Element must be set'); } - if (this._container.contains(element)) { + if (this._container.contains(this._wrapper)) { console.warn('Container already contains the element'); } - this._container.appendChild(element); + if (typeof this._content === 'string') { + this._wrapper.innerHTML = this._content; + } else { + this._wrapper.appendChild(this._content); + } + + this._container.appendChild(this._wrapper); this._setupElementStyle(); + + if (style) { + Object.assign(this._wrapper.style, style); + } } private _setupElementStyle() { - const { _element, x, y, offset } = this; + const { _wrapper } = this; - if (!_element) { - return; - } + _wrapper.style.position = 'absolute'; + _wrapper.style.left = '0'; + _wrapper.style.top = '0'; + _wrapper.style.userSelect = 'none'; + _wrapper.style.display = 'block'; + _wrapper.style.transformOrigin = 'center center'; + _wrapper.style.zIndex = `${this.order}`; + _wrapper.style.pointerEvents = this._preventPointerEvents ? 'none' : 'auto'; // 让鼠标穿透元素 - _element.style.position = 'absolute'; - _element.style.left = '0'; - _element.style.top = '0'; - _element.style.userSelect = 'none'; - _element.style.display = 'block'; - _element.style.zIndex = '2'; - _element.style.transform = `translate(${x + offset.x}px, ${y + offset.y}px)`; + const position = this._getPosition(); + + _wrapper.style.transform = `translate(${position.x}px, ${position.y}px) rotate(${this._rotate}deg)`; } private _handleUpdatePosition = () => { - const { _shape } = this; - - this._updatePosition(_shape.dynamicCoordinate[0].x, _shape.dynamicCoordinate[0].y); + this._updatePosition(); }; private _handleUpdatePositionByMouse = () => { if (axis?.distance.x || axis?.distance.y) { - const { _shape } = this; - - this._updatePosition(_shape.dynamicCoordinate[0].x, _shape.dynamicCoordinate[0].y); + this._updatePosition(); } }; - private _updatePosition(x: number, y: number) { - const { _element, offset } = this; + private _updatePosition() { + const { _wrapper } = this; - this.x = x; - this.y = y; + const position = this._getPosition(); - if (_element) { - if (x < 0 || y < 0) { - _element.style.display = 'none'; - } else { - _element.style.display = 'block'; - } - _element.style.transform = `translate(${this.x + offset.x}px, ${this.y + offset.y}px)`; - } + _wrapper.style.transform = `translate(${position.x}px, ${position.y}px) rotate(${this._rotate}deg)`; + } + + public set rotate(rotate: number) { + this._rotate = rotate; + this._wrapper.style.transform = `translate(${this.x}px, ${this.y}px) rotate(${rotate}deg)`; + } + + public get rotate() { + return this._rotate; + } + + public show() { + this._wrapper.style.display = 'block'; + } + + public hide() { + this._wrapper.style.display = 'none'; + } + + public toTop() { + this._wrapper.style.zIndex = '1049'; + } + + public resetZIndex() { + this._wrapper.style.zIndex = `${this.order}`; } public destroy() { - this._element?.remove(); + this._wrapper.remove(); eventEmitter.off(EInternalEvent.AxisChange, this._handleUpdatePosition); eventEmitter.off(EInternalEvent.MouseMove, this._handleUpdatePositionByMouse); } diff --git a/packages/image/src/core/Monitor.ts b/packages/image/src/core/Monitor.ts index 740198a84..52154bde4 100644 --- a/packages/image/src/core/Monitor.ts +++ b/packages/image/src/core/Monitor.ts @@ -30,9 +30,9 @@ export interface MonitorOption { export class Monitor { private _canvas: HTMLCanvasElement; - public _hoveredGroup: GroupInAnnotation | null = null; + public hoveredGroup: GroupInAnnotation | null = null; - private _hoveredShape: AnnotationShape | null = null; + public hoveredShape: AnnotationShape | null = null; public selectedAnnotationId: string | null = null; @@ -229,11 +229,13 @@ export class Monitor { * @description 用于处理鼠标移动到标注上时,触发标注的 hover 事件;同时,选中标注的逻辑也会依赖此处理函数 */ private _handleMouseOver = (e: MouseEvent) => { - const { _hoveredGroup, _hoveredShape } = this; + const { hoveredGroup, hoveredShape } = this; const rbushItems = rbush.scanCanvasObject({ x: e.offsetX, y: e.offsetY }); const orderIndexedGroup: any[] = []; let topGroup: any | null = null; + const oldHoveredShape = hoveredShape; + for (const rbushItem of rbushItems) { if (rbushItem._group) { const isUnderCursor = rbushItem._group.isShapesUnderCursor({ @@ -256,6 +258,8 @@ export class Monitor { topGroup = rbushItem._group; } } + } else if (rbushItem._shape && rbushItem._shape.isUnderCursor({ x: e.offsetX, y: e.offsetY })) { + this.hoveredShape = rbushItem._shape as any; } } // 最后一个表示order最大的group @@ -268,10 +272,15 @@ export class Monitor { lastGroup.reverseEach((shape: any) => { // 曲线的slopeEdge是一个group,不是shape if (shape instanceof Group) { - (shape as Group).reverseEach((innerShape: any) => { + shape.reverseEach((innerShape: any) => { if (innerShape.isUnderCursor({ x: e.offsetX, y: e.offsetY })) { innerShape.emit(EInternalEvent.ShapeOver, e, innerShape); - this._hoveredShape = innerShape; + + if (hoveredShape && hoveredShape.id !== innerShape.id) { + hoveredShape.emit(EInternalEvent.ShapeOut, e, hoveredShape); + } + + this.hoveredShape = innerShape; // 只给一个shape发送事件,避免多个shape同时hover return false; } @@ -279,22 +288,23 @@ export class Monitor { return false; } else if (shape.isUnderCursor({ x: e.offsetX, y: e.offsetY })) { shape.emit(EInternalEvent.ShapeOver, e, shape); - this._hoveredShape = shape; + + this.hoveredShape = shape; // 只给一个shape发送事件,避免多个shape同时hover return false; } }); - if (_hoveredGroup && _hoveredGroup.id !== lastGroup.id) { + if (hoveredGroup && hoveredGroup.id !== lastGroup.id) { // 向上一次hover的group发送鼠标离开事件,避免多个group同时hover - _hoveredGroup.emit(EInternalEvent.MouseOut, e); + hoveredGroup.emit(EInternalEvent.MouseOut, e); } - if (_hoveredShape && _hoveredShape.id !== this._hoveredShape?.id) { - _hoveredShape.emit(EInternalEvent.ShapeOut, e, _hoveredShape); + if (oldHoveredShape && oldHoveredShape.id !== this.hoveredShape?.id) { + oldHoveredShape.emit(EInternalEvent.ShapeOut, e, oldHoveredShape); } - this._hoveredGroup = lastGroup; + this.hoveredGroup = lastGroup; for (const rbushItem of rbushItems) { if (rbushItem._group && lastGroup.id !== rbushItem._group.id) { @@ -308,14 +318,18 @@ export class Monitor { } else { eventEmitter.emit(EInternalEvent.NoTarget, e); - if (_hoveredShape) { - _hoveredShape.emit(EInternalEvent.ShapeOut, e, _hoveredShape); - this._hoveredShape = null; + if (this.hoveredShape) { + this.hoveredShape.emit(EInternalEvent.ShapeOver, e, this.hoveredShape); + } + + if (oldHoveredShape && !oldHoveredShape.isUnderCursor({ x: e.offsetX, y: e.offsetY })) { + oldHoveredShape.emit(EInternalEvent.ShapeOut, e, oldHoveredShape); + this.hoveredShape = null; } rbush.nearestPoint?.destroy(); rbush.nearestPoint = null; - this._hoveredGroup = null; + this.hoveredGroup = null; } }; @@ -344,18 +358,18 @@ export class Monitor { return; } - const { _hoveredGroup, selectedAnnotationId } = this; + const { hoveredGroup, selectedAnnotationId } = this; - if (_hoveredGroup) { - if (selectedAnnotationId && _hoveredGroup.id !== selectedAnnotationId) { + if (hoveredGroup) { + if (selectedAnnotationId && hoveredGroup.id !== selectedAnnotationId) { this.selectedAnnotationId = null; } - _hoveredGroup.emit(EInternalEvent.Select, e); - this.selectedAnnotationId = _hoveredGroup.id; + hoveredGroup.emit(EInternalEvent.Select, e); + this.selectedAnnotationId = hoveredGroup.id; } - eventEmitter.emit('rightClick', e, _hoveredGroup?.id); + eventEmitter.emit('rightClick', e, hoveredGroup?.id); }; public setSelectedAnnotationId(id: string | null) { diff --git a/packages/image/src/drafts/ClosedSpline.draft.ts b/packages/image/src/drafts/ClosedSpline.draft.ts index 0ba9c7dd5..88dd73163 100644 --- a/packages/image/src/drafts/ClosedSpline.draft.ts +++ b/packages/image/src/drafts/ClosedSpline.draft.ts @@ -1,10 +1,12 @@ import cloneDeep from 'lodash.clonedeep'; import Color from 'color'; -import type { LineStyle, Line } from '../shapes/Line.shape'; +import type { AllShape } from '@/shapes/types'; + +import type { LineStyle } from '../shapes/Line.shape'; import type { PolygonData } from '../annotations'; import { AnnotationLine, AnnotationPolygon } from '../annotations'; -import type { PointStyle, Point, PolygonStyle, AxisPoint } from '../shapes'; +import type { PolygonStyle, AxisPoint } from '../shapes'; import { Spline, ClosedSpline } from '../shapes'; import type { AnnotationParams } from '../annotations/Annotation'; import type { ControllerPoint } from './ControllerPoint'; @@ -18,7 +20,7 @@ interface EffectedCurve { curve: Spline; } -export class DraftPolygonCurve extends Draft { +export class DraftPolygonCurve extends Draft { public config: PolygonToolOptions; private _isControllerPicked: boolean = false; @@ -94,7 +96,7 @@ export class DraftPolygonCurve extends Draft { +export class DraftCuboid extends Draft { public config: CuboidToolOptions; private _previousDynamicCoordinate: AxisPoint[] | null = null; @@ -289,13 +289,15 @@ export class DraftCuboid extends Draft { + return { + x: shape.dynamicCoordinate[0].x - 36, + y: shape.dynamicCoordinate[0].y + 10, + }; }, - element: elem, + content: elem, bindShape: controlFrontTl!, }); } diff --git a/packages/image/src/drafts/Draft.ts b/packages/image/src/drafts/Draft.ts index 3ab1e38ed..d03cbda7d 100644 --- a/packages/image/src/drafts/Draft.ts +++ b/packages/image/src/drafts/Draft.ts @@ -7,12 +7,13 @@ import type { LineToolOptions } from '@/tools/Line.tool'; import type { CuboidToolOptions } from '@/tools/Cuboid.tool'; import type { PolygonToolOptions } from '@/tools/Polygon.tool'; import type { PointToolOptions } from '@/tools/Point.tool'; +import type { AllShape } from '@/shapes/types'; import { axis, eventEmitter } from '../singletons'; import { EInternalEvent } from '../enums'; import { Annotation, type AnnotationParams } from '../annotations/Annotation'; import type { BasicImageAnnotation, EditType, ToolName } from '../interface'; -import type { AxisPoint, Shape } from '../shapes'; +import type { AxisPoint } from '../shapes'; import { Point, Group, Spline, ClosedSpline } from '../shapes'; import { ControllerPoint } from './ControllerPoint'; import { ControllerEdge } from './ControllerEdge'; @@ -20,11 +21,7 @@ import { LabelBase } from '../annotations/Label.base'; type MouseEventHandler = (e: MouseEvent) => void; -export class Draft< - Data extends BasicImageAnnotation, - IShape extends Shape