From 957c327c5768e1721493eca13a0ffe527d4a6c36 Mon Sep 17 00:00:00 2001 From: cefeng06 Date: Fri, 29 Dec 2023 16:41:05 +0800 Subject: [PATCH] feat(quicklabel): update quicklabel component --- packages/app/src/pages/Annotator/index.tsx | 141 +----- packages/app/src/pages/Annotator/model.ts | 28 +- .../components/ImageFilter/index.less | 5 + .../components/ImageFilter/index.tsx | 63 +++ .../components/ImageList/index.less | 28 ++ .../QuickLabel/components/ImageList/index.tsx | 70 +++ .../components/QuickstartModal/index.less | 24 ++ .../components/QuickstartModal/index.tsx | 110 +++++ .../QuickLabel/hooks/useQuickLabelModel.ts | 242 +++++++++++ packages/components/src/QuickLabel/index.less | 20 + packages/components/src/QuickLabel/index.tsx | 151 +++++++ packages/components/src/QuickLabel/type.ts | 66 +++ .../src/QuickLabel/utils/adapter.ts | 401 ++++++++++++++++++ .../src/QuickLabel/utils/idConverter.ts | 41 ++ .../components/src/Upload/assets/checked.svg | 13 + .../components/src/Upload/assets/upload.svg | 4 + .../components/FilePreviewList/index.less | 78 ++++ .../components/FilePreviewList/index.tsx | 132 ++++++ packages/components/src/Upload/index.less | 100 +++++ packages/components/src/Upload/index.tsx | 234 ++++++++++ .../src/UploadPreAnno/assets/upload_file.svg | 10 + .../components/src/UploadPreAnno/index.less | 30 ++ .../components/src/UploadPreAnno/index.tsx | 54 +++ packages/components/src/index.ts | 1 + packages/components/src/locales/en-US.ts | 62 +++ packages/components/src/locales/zh-CN.ts | 60 +++ packages/utils/src/file.ts | 88 ++++ 27 files changed, 2094 insertions(+), 162 deletions(-) create mode 100644 packages/components/src/QuickLabel/components/ImageFilter/index.less create mode 100644 packages/components/src/QuickLabel/components/ImageFilter/index.tsx create mode 100644 packages/components/src/QuickLabel/components/ImageList/index.less create mode 100644 packages/components/src/QuickLabel/components/ImageList/index.tsx create mode 100644 packages/components/src/QuickLabel/components/QuickstartModal/index.less create mode 100644 packages/components/src/QuickLabel/components/QuickstartModal/index.tsx create mode 100644 packages/components/src/QuickLabel/hooks/useQuickLabelModel.ts create mode 100644 packages/components/src/QuickLabel/index.less create mode 100644 packages/components/src/QuickLabel/index.tsx create mode 100644 packages/components/src/QuickLabel/type.ts create mode 100644 packages/components/src/QuickLabel/utils/adapter.ts create mode 100644 packages/components/src/QuickLabel/utils/idConverter.ts create mode 100644 packages/components/src/Upload/assets/checked.svg create mode 100644 packages/components/src/Upload/assets/upload.svg create mode 100644 packages/components/src/Upload/components/FilePreviewList/index.less create mode 100644 packages/components/src/Upload/components/FilePreviewList/index.tsx create mode 100644 packages/components/src/Upload/index.less create mode 100644 packages/components/src/Upload/index.tsx create mode 100644 packages/components/src/UploadPreAnno/assets/upload_file.svg create mode 100644 packages/components/src/UploadPreAnno/index.less create mode 100644 packages/components/src/UploadPreAnno/index.tsx diff --git a/packages/app/src/pages/Annotator/index.tsx b/packages/app/src/pages/Annotator/index.tsx index 6bee1b9..39d7088 100644 --- a/packages/app/src/pages/Annotator/index.tsx +++ b/packages/app/src/pages/Annotator/index.tsx @@ -1,141 +1,10 @@ -import React, { useEffect, useState } from 'react'; -import { history, useModel } from '@umijs/max'; -import styles from './index.less'; -import { AnnotateEditor, EditorMode } from 'dds-components/Annotator'; -import { ImageList } from './components/ImageList'; -import { Button } from 'antd'; -import { SettingOutlined } from '@ant-design/icons'; -import { FormModal } from './components/FormModal'; -import { useLocale } from 'dds-utils/locale'; -import { useKeyPress } from 'ahooks'; -import { BaseObject } from '@/types'; +import React from 'react'; +import QuickLabel from 'dds-components/QuickLabel'; +import { useModel } from '@umijs/max'; const Page: React.FC = () => { - const { - images, - setImages, - current, - setCurrent, - categories, - setCategories, - exportAnnotations, - } = useModel('Annotator.model'); - - const { localeText } = useLocale(); - const [openModal, setModalOpen] = useState(true); - - useEffect(() => { - // const handleBeforeUnload = (event: BeforeUnloadEvent) => { - // event.preventDefault(); - // event.returnValue = - // 'The current changes will not be saved. Please export before leaving.'; - // }; - // window.addEventListener('beforeunload', handleBeforeUnload); - // return () => { - // window.removeEventListener('beforeunload', handleBeforeUnload); - // }; - }, []); - - // local test - useEffect( - () => { - // if(images.length > 0 && categories.length > 0) { - // localStorage.setItem('images', JSON.stringify(images)); - // localStorage.setItem('categories', JSON.stringify(categories)); - // console.log('>>> save localStorage'); - // } - const images = localStorage.getItem('images'); - const categories = localStorage.getItem('categories'); - if (images && categories) { - setImages(JSON.parse(images)); - setCategories(JSON.parse(categories)); - setModalOpen(false); - } - }, - // [images, categories] - [], - ); - - useKeyPress( - 'uparrow', - () => { - setCurrent(Math.max(0, current - 1)); - }, - { exactMatch: true }, - ); - - useKeyPress( - 'downarrow', - () => { - setCurrent(Math.min(current + 1, images.length - 1)); - }, - { exactMatch: true }, - ); - - return ( -
-
{ - event.stopPropagation(); - }} - onMouseUp={(event) => { - event.stopPropagation(); - }} - > - - { - setCurrent(index); - }} - /> -
-
- - {localeText('annotator.export')} - , - ]} - onAutoSave={(annos: BaseObject[], naturalSize: ISize) => { - setImages((images) => { - if (images[current]) { - images[current].objects = annos; - images[current].width = naturalSize.width; - images[current].height = naturalSize.height; - } - }); - }} - onCancel={() => history.push('/')} - /> -
-
e.stopPropagation()} - onMouseUp={(e) => e.stopPropagation()} - > - -
-
- ); + const props = useModel('Annotator.model'); + return ; }; export default Page; diff --git a/packages/app/src/pages/Annotator/model.ts b/packages/app/src/pages/Annotator/model.ts index ece9ed0..476cb7e 100644 --- a/packages/app/src/pages/Annotator/model.ts +++ b/packages/app/src/pages/Annotator/model.ts @@ -1,29 +1,5 @@ -import { useImmer } from 'use-immer'; -import { useState } from 'react'; -import { genFileNameByTimestamp, saveObejctToJsonFile } from 'dds-utils/file'; -import { convertToCocoDateset } from '@/utils/adapter'; -import { LabelImageFile } from '@/types/annotator'; -import { Category } from '@/types'; +import useQuickLabelModel from 'dds-components/QuickLabel/hooks/useQuickLabelModel'; export default () => { - const [images, setImages] = useImmer([]); - const [current, setCurrent] = useState(0); - const [categories, setCategories] = useImmer([]); - - /** Export with COCO formats*/ - const exportAnnotations = () => { - const dataset = convertToCocoDateset(images, categories); - const fileName = genFileNameByTimestamp(Date.now(), 'Annotations'); - saveObejctToJsonFile(dataset, fileName); - }; - - return { - images, - setImages, - current, - setCurrent, - categories, - setCategories, - exportAnnotations, - }; + return useQuickLabelModel(); }; diff --git a/packages/components/src/QuickLabel/components/ImageFilter/index.less b/packages/components/src/QuickLabel/components/ImageFilter/index.less new file mode 100644 index 0000000..cab1f6e --- /dev/null +++ b/packages/components/src/QuickLabel/components/ImageFilter/index.less @@ -0,0 +1,5 @@ +.dds-quicklabel-image-filter { + display: flex; + align-items: center; + gap: 10px; +} diff --git a/packages/components/src/QuickLabel/components/ImageFilter/index.tsx b/packages/components/src/QuickLabel/components/ImageFilter/index.tsx new file mode 100644 index 0000000..dd3231e --- /dev/null +++ b/packages/components/src/QuickLabel/components/ImageFilter/index.tsx @@ -0,0 +1,63 @@ +import { ClearOutlined } from '@ant-design/icons'; +import { Button, Select } from 'antd'; +import { Category } from '@/Annotator/type'; +import { globalLocaleText } from 'dds-utils/locale'; +import './index.less'; + +interface IProps { + categories: Category[]; + filterCategoryName: string | null; + onSelectFilter: (name: string) => void; + onClearFilter: () => void; +} + +const ImageFilter: React.FC = ({ + categories, + filterCategoryName, + onSelectFilter, + onClearFilter, +}) => { + return ( +
+
{globalLocaleText('quicklabel.imageFilter')}
+ +
+ ); +}; + +export default ImageFilter; diff --git a/packages/components/src/QuickLabel/components/ImageList/index.less b/packages/components/src/QuickLabel/components/ImageList/index.less new file mode 100644 index 0000000..d9d6615 --- /dev/null +++ b/packages/components/src/QuickLabel/components/ImageList/index.less @@ -0,0 +1,28 @@ +.dds-quicklabel-options-list { + height: 100vh; + + &-virtual { + border-radius: 8px; + } + + &-image { + margin: 8px 0; + width: 100%; + height: 120px; + box-sizing: border-box; + object-fit: cover; + border-radius: 8px; + background-color: #fff; + cursor: pointer; + transition: transform 0.3s ease; + } + + &-image:hover { + transform: scale(0.95); + } + + &-image-selected { + border: 3px solid #fff; + border-radius: 8px; + } +} diff --git a/packages/components/src/QuickLabel/components/ImageList/index.tsx b/packages/components/src/QuickLabel/components/ImageList/index.tsx new file mode 100644 index 0000000..9424f53 --- /dev/null +++ b/packages/components/src/QuickLabel/components/ImageList/index.tsx @@ -0,0 +1,70 @@ +import VirtualList from 'rc-virtual-list'; +import { useCallback, useEffect, useState } from 'react'; +import { QsAnnotatorFile } from '../../type'; +import './index.less'; + +interface IProps { + images: QsAnnotatorFile[]; + selected: number; + onImageSelected: (index: number) => void; +} + +export const ImageList: React.FC = ({ + images, + selected, + onImageSelected, +}: IProps) => { + const [containerHeight, setContainerHeight] = useState(0); + const itemHeight = 120; + + const handleResize = useCallback(() => { + const container = document.getElementById('image-options-container'); + if (container) { + const height = container.offsetHeight || 0; + setContainerHeight(height - 56); + } + }, []); + + useEffect(() => { + handleResize(); + window.addEventListener('resize', handleResize); + return () => { + window.removeEventListener('resize', handleResize); + }; + }, [handleResize]); + + const handleImageSelect = (index: number) => { + if (index < 0 || index >= images.length) return; + onImageSelected(index); + }; + + return ( +
+ + {(item, index) => { + const selectedClassName = + index === selected + ? 'dds-quicklabel-options-list-image-selected' + : ''; + return ( +
+ handleImageSelect(index)} + /> +
+ ); + }} +
+
+ ); +}; diff --git a/packages/components/src/QuickLabel/components/QuickstartModal/index.less b/packages/components/src/QuickLabel/components/QuickstartModal/index.less new file mode 100644 index 0000000..a49a82c --- /dev/null +++ b/packages/components/src/QuickLabel/components/QuickstartModal/index.less @@ -0,0 +1,24 @@ +.dds-quicklabel-subtitle { + font-size: 16px; + font-weight: 500; + margin: 20px 0 10px; +} + +.dds-quicklabel-upload { + width: 100%; + height: 360px; +} + +.dds-quicklabel-upload-tip { + margin: 10px 0 0; + background-color: transparent; + border-width: 0; +} + +.dds-quicklabel-upload-preannot-btn { + width: 100%; + height: 42px; + font-weight: 600; + border-radius: 5px; + background: #fff; +} diff --git a/packages/components/src/QuickLabel/components/QuickstartModal/index.tsx b/packages/components/src/QuickLabel/components/QuickstartModal/index.tsx new file mode 100644 index 0000000..011a4bc --- /dev/null +++ b/packages/components/src/QuickLabel/components/QuickstartModal/index.tsx @@ -0,0 +1,110 @@ +import { Alert, Button, Modal, UploadFile as AntdUploadFile } from 'antd'; +import Upload, { UploadFile } from 'dds-components/Upload'; +import { UploadOutlined } from '@ant-design/icons'; +import { UploadChangeParam } from 'antd/es/upload'; +import UploadPreAnno from 'dds-components/UploadPreAnno'; +import { globalLocaleText } from 'dds-utils/locale'; +import './index.less'; + +const MAX_COUNT = 1000; +const MAX_SIZE = 10; + +interface IProps { + open: boolean; + isInit: boolean; + fileList: UploadFile[]; + setFileList: React.Dispatch>; + onClickOk: () => void; + onClickCancel: () => void; + limitRemoveFile?: (index: number) => boolean; + limitClose?: boolean; + okText?: string; + uploadPreAnnot: AntdUploadFile[]; + onChangePreAnnotFile: (info: UploadChangeParam>) => void; + onRemovePreAnnotFile: (file: AntdUploadFile) => void; +} + +const QuickstartModal: React.FC = ({ + open, + isInit, + fileList, + setFileList, + onClickOk, + onClickCancel, + limitRemoveFile, + okText, + limitClose, + uploadPreAnnot, + onChangePreAnnotFile, + onRemovePreAnnotFile, +}: IProps) => { + return ( +
e.stopPropagation()} + onMouseUp={(e) => e.stopPropagation()} + > + + +
+ {globalLocaleText('quicklabel.formModal.importImages')} +
+
+ +
+ + {isInit && ( + + + + )} +
+
+ ); +}; + +export default QuickstartModal; diff --git a/packages/components/src/QuickLabel/hooks/useQuickLabelModel.ts b/packages/components/src/QuickLabel/hooks/useQuickLabelModel.ts new file mode 100644 index 0000000..30467da --- /dev/null +++ b/packages/components/src/QuickLabel/hooks/useQuickLabelModel.ts @@ -0,0 +1,242 @@ +import { useCallback, useMemo, useState } from 'react'; +import { Updater, useImmer } from 'use-immer'; +import { genFileNameByTimestamp, saveObejctToJsonFile } from 'dds-utils/file'; +import { + convertToCocoDateset, + convertCocoDatasetToAnnotStates, + validateCocoData, +} from '../utils/adapter'; +import { COCO, QsAnnotatorFile } from '../type'; +import { Category } from 'dds-components/Annotator'; +import { history } from '@umijs/max'; +import { + message, + notification, + UploadFile as AntdUploadFile, + UploadProps, +} from 'antd'; +import { globalLocaleText } from 'dds-utils/locale'; +import { UploadFile } from 'dds-components/Upload'; +import { UploadChangeParam } from 'antd/es/upload'; + +const INIT_PRE_ANNOT = { + info: {}, + images: [], + annotations: [], + categories: [], +}; + +export interface QuickLabelModel { + qsModalVisible: boolean; + setQsModalVisible: React.Dispatch>; + uploadFiles: UploadFile[]; + setUploadFiles: React.Dispatch>; + images: QsAnnotatorFile[]; + setImages: Updater; + filterImages: QsAnnotatorFile[]; + onClickQuickstart: () => void; + onCancelUploadFiles: () => void; + onConfirmUploadFiles: () => void; + limitRemoveFile: (index: number) => boolean; + current: number; + setCurrent: React.Dispatch>; + categories: Category[]; + setCategories: Updater; + filterCategoryName: string | null; + setFilterCategoryName: Updater; + exportAnnotations: () => Promise; + uploadPreAnnot: AntdUploadFile[]; + onChangePreAnnotFile: (info: UploadChangeParam>) => void; + onRemovePreAnnotFile: (file: AntdUploadFile) => void; + onSelectFilterCategory: (name: string) => void; + onClearFilterCategory: () => void; +} + +export default (): QuickLabelModel => { + const [images, setImages] = useImmer([]); + const [current, setCurrent] = useState(-1); + + const [info, setInfo] = useState({ + year: new Date().getFullYear(), + version: '1.0', + description: 'Annotations in COCO format, labeled by DeepDataSpace', + contributor: '', + date_created: new Date().toISOString(), + }); + + const [categories, setCategories] = useImmer([ + { + id: 'default', + name: 'default', + }, + ]); + + const [filterCategoryName, setFilterCategoryName] = useImmer( + null, + ); + + const filterImages = useMemo(() => { + if (!filterCategoryName) return images; + return images.filter((image) => + image.objects.find( + (object) => object.categoryName === filterCategoryName, + ), + ); + }, [images, filterCategoryName]); + + const [uploadFiles, setUploadFiles] = useState([]); + const [qsModalVisible, setQsModalVisible] = useState(false); + + const [uploadPreAnnot, setUploadPreAnnot] = useState([]); + + const [preAnnots, setPreAnnots] = useImmer(INIT_PRE_ANNOT); + + const syncUploadFilesToImage = () => { + const confirmedImages: QsAnnotatorFile[] = uploadFiles.map( + (item, index) => { + const image = images.find((image) => image.id === item.id); + return { + objects: [], + urlFullRes: item.url, + ...item, + ...image, + originalIndex: index, + }; + }, + ); + + const { + info: updatedInfo, + categories: updatedCategories, + images: updatedImages, + } = convertCocoDatasetToAnnotStates(preAnnots, { + info, + categories, + images: confirmedImages, + }); + + setInfo(updatedInfo); + setCategories(updatedCategories); + setImages(updatedImages); + }; + + const onClickQuickstart = () => { + syncUploadFilesToImage(); + setQsModalVisible(false); + setCurrent(current > -1 ? current : 0); + history.push('/quickstart'); + }; + + const onCancelUploadFiles = () => { + if (images.length <= 0) { + return; + } + setUploadFiles(images); + setQsModalVisible(false); + }; + + const onConfirmUploadFiles = () => { + syncUploadFilesToImage(); + setQsModalVisible(false); + setCurrent(-1); + }; + + const hasAnnotsOnImage = useCallback( + (index: number) => { + const image = images.find((item) => item.id === uploadFiles[index].id); + return image && image.objects.length > 0; + }, + [images, uploadFiles], + ); + + const limitRemoveFile = useCallback( + (index: number) => { + if (hasAnnotsOnImage(index)) { + notification.error({ + message: globalLocaleText('quicklabel.formModal.deleteImage.title'), + description: globalLocaleText( + 'quicklabel.formModal.deleteImage.desc', + ), + duration: 3, + }); + return true; + } + return false; + }, + [hasAnnotsOnImage], + ); + + /** Export with COCO formats*/ + const exportAnnotations = async () => { + const dataset = await convertToCocoDateset({ info, images, categories }); + const fileName = genFileNameByTimestamp(Date.now(), 'Annotations'); + saveObejctToJsonFile(dataset, fileName); + }; + + const onChangePreAnnotFile: UploadProps['onChange'] = ({ + file, + fileList, + }) => { + if (fileList.length === 0 || !fileList[0].originFileObj) return; + + const fileReader = new FileReader(); + fileReader.readAsText(fileList[0].originFileObj); + + fileReader.onload = function (event) { + const parsedData = JSON.parse(event.target?.result as string); + const result = validateCocoData(parsedData); + if (result.success) { + setUploadPreAnnot([file]); + setPreAnnots(parsedData); + } else { + message.error(result.message); + } + }; + }; + + const onRemovePreAnnotFile = (file: AntdUploadFile) => { + const index = uploadPreAnnot.findIndex((item) => item.uid === file.uid); + uploadPreAnnot.splice(index, 1); + setUploadPreAnnot([...uploadPreAnnot]); + setPreAnnots(INIT_PRE_ANNOT); + }; + + const onSelectFilterCategory = (name: string) => { + setFilterCategoryName(name); + setCurrent(-1); + }; + + const onClearFilterCategory = () => { + setFilterCategoryName(null); + setCurrent(-1); + }; + + return { + qsModalVisible, + setQsModalVisible, + uploadFiles, + setUploadFiles, + + images, + setImages, + filterImages, + onClickQuickstart, + onCancelUploadFiles, + onConfirmUploadFiles, + limitRemoveFile, + + current, + setCurrent, + categories, + setCategories, + filterCategoryName, + setFilterCategoryName, + exportAnnotations, + + uploadPreAnnot, + onChangePreAnnotFile, + onRemovePreAnnotFile, + onSelectFilterCategory, + onClearFilterCategory, + }; +}; diff --git a/packages/components/src/QuickLabel/index.less b/packages/components/src/QuickLabel/index.less new file mode 100644 index 0000000..bef233b --- /dev/null +++ b/packages/components/src/QuickLabel/index.less @@ -0,0 +1,20 @@ +.dds-quicklabel { + position: relative; + display: flex; + background: #212121; + + &-list { + display: flex; + flex-direction: column; + align-items: stretch; + width: 200px; + height: 100%; + padding: 16px; + gap: 8px; + } + + &-workspace { + flex: 1; + height: 100%; + } +} diff --git a/packages/components/src/QuickLabel/index.tsx b/packages/components/src/QuickLabel/index.tsx new file mode 100644 index 0000000..56e8d1b --- /dev/null +++ b/packages/components/src/QuickLabel/index.tsx @@ -0,0 +1,151 @@ +import React, { useEffect } from 'react'; +import { history } from '@umijs/max'; +import { + AnnotateEditor, + BaseObject, + EditorMode, +} from 'dds-components/Annotator'; +import { Button } from 'antd'; +import { SettingOutlined } from '@ant-design/icons'; +import { useKeyPress } from 'ahooks'; +import { ImageList } from './components/ImageList'; +import QuickstartModal from './components/QuickstartModal'; +import ImageFilter from './components/ImageFilter'; +import { QuickLabelModel } from './hooks/useQuickLabelModel'; +import { globalLocaleText } from 'dds-utils/locale'; +import './index.less'; + +const QuickLabel: React.FC = (props) => { + const { + images, + filterImages, + current, + categories, + qsModalVisible, + uploadFiles, + uploadPreAnnot, + filterCategoryName, + setImages, + setCurrent, + setCategories, + setQsModalVisible, + setUploadFiles, + limitRemoveFile, + onCancelUploadFiles, + onConfirmUploadFiles, + exportAnnotations, + onChangePreAnnotFile, + onRemovePreAnnotFile, + onSelectFilterCategory, + onClearFilterCategory, + } = props; + + useEffect(() => { + if (images.length <= 0) { + setQsModalVisible(true); + } + }, []); + + useKeyPress( + 'uparrow', + () => { + setCurrent(Math.max(0, current - 1)); + }, + { exactMatch: true }, + ); + + useKeyPress( + 'downarrow', + () => { + setCurrent(Math.min(current + 1, images.length - 1)); + }, + { exactMatch: true }, + ); + + const onAutoSave = (annos: BaseObject[], naturalSize: ISize) => { + if (!filterImages[current]) return; + const originalIndex = filterImages[current].originalIndex; + setImages((images) => { + if (images[originalIndex]) { + images[originalIndex].objects = annos; + images[originalIndex].width = naturalSize.width; + images[originalIndex].height = naturalSize.height; + } + }); + }; + + return ( +
+
{ + event.stopPropagation(); + }} + onMouseUp={(event) => { + event.stopPropagation(); + }} + > + + { + setCurrent(index); + }} + /> +
+
+ , + ]} + actionElements={[ + , + ]} + onAutoSave={onAutoSave} + onCancel={() => history.push('/')} + /> +
+ +
+ ); +}; + +export default QuickLabel; diff --git a/packages/components/src/QuickLabel/type.ts b/packages/components/src/QuickLabel/type.ts new file mode 100644 index 0000000..3fe359e --- /dev/null +++ b/packages/components/src/QuickLabel/type.ts @@ -0,0 +1,66 @@ +import { UploadFile } from 'dds-components/Upload'; +import { BaseObject } from 'dds-components/Annotator'; + +export interface QsAnnotatorFile extends UploadFile { + urlFullRes: string; + objects: BaseObject[]; + width?: number; + height?: number; + originalIndex: number; +} + +/* eslint-disable @typescript-eslint/no-namespace */ + +export namespace COCO { + export interface Info { + year?: number; + version?: string; + description?: string; + contributor?: string; + url?: string; + date_created?: string; + } + + export interface Image { + id: number; + width: number; + height: number; + file_name: string; + license?: number; + flickr_url?: string; + coco_url?: string; + date_captured?: string; + } + + export interface Annotation { + id: number; + image_id: number; + category_id?: number; + bbox?: number[]; + area?: number; + segmentation?: + | number[][] + | { + size: [number, number]; // [height, width] + counts: number[] | string; + }; + iscrowd?: number; + keypoints?: number[]; + num_keypoints?: number; + } + + export interface Category { + id: number; + name: string; + supercategory?: string; + keypoints?: string[]; + skeleton?: number[][]; + } + + export interface Dataset { + info?: Info; + images: Image[]; + annotations: Annotation[]; + categories: Category[]; + } +} diff --git a/packages/components/src/QuickLabel/utils/adapter.ts b/packages/components/src/QuickLabel/utils/adapter.ts new file mode 100644 index 0000000..a5044dd --- /dev/null +++ b/packages/components/src/QuickLabel/utils/adapter.ts @@ -0,0 +1,401 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +import { Category } from 'dds-components/Annotator'; +import { rleToCanvas } from 'dds-components/Annotator/tools/useMask'; +import { + calculatePolygonArea, + convertToVerticesArray, + getMaskInfoByCanvas, + translateBoundingBoxToRect, + translateRectToBoundingBox, +} from 'dds-components/Annotator/utils/compute'; +import { idConverter } from './idConverter'; +import { getImageDimensions } from 'dds-utils/file'; +import { COCO, QsAnnotatorFile } from '../type'; + +interface IAnnotatorStates { + info: COCO.Info; + categories: Category[]; + images: QsAnnotatorFile[]; +} + +const IMPORT_CATEGORYID_PRIFIX = 'user_import_category'; +const IMPORT_IMAGE_PRIFIX = 'user_import_image'; +const IMPORT_ANNOT_PRIFIX = 'user_import_annot'; + +export const ddsRleToCocoRle = (ddsRle: number[], imageSize: ISize) => { + const { width, height } = imageSize; + const counts: number[] = []; + + let pos: number = 0; + + for (let i = 0; i < Math.floor(ddsRle.length / 2); i++) { + counts.push(ddsRle[2 * i] - pos); + counts.push(ddsRle[2 * i + 1]); + pos = ddsRle[2 * i] + ddsRle[2 * i + 1]; + } + + if (pos < width * height) { + counts.push(width * height - pos); + } + + return { + size: [imageSize.height, imageSize.width], + counts: counts, + }; +}; + +export const convertToCocoDateset = async ({ + info, + images, + categories, +}: IAnnotatorStates) => { + const cocoDataset: COCO.Dataset = { + info: {}, + images: [], + categories: [], + annotations: [], + }; + + // update info + cocoDataset.info = { + ...info, + year: new Date().getFullYear(), + date_created: new Date().toISOString(), + }; + + const { getIntItemId: getIntCategoryId } = idConverter( + IMPORT_CATEGORYID_PRIFIX, + categories, + ); + + // export imported category (with original id) & created category + const categoryMap: Record = {}; + categories.forEach((category) => { + let categoryId = getIntCategoryId(category.id); + categoryMap[category.name] = categoryId; + cocoDataset.categories.push({ + id: categoryId, + name: category.name, + }); + }); + + // Convert image and annotation data + const { getIntItemId: getIntImageId } = idConverter( + IMPORT_IMAGE_PRIFIX, + images, + ); + + for (const image of images) { + const imageId = getIntImageId(image.id); + + let imageSize: ISize = { + width: 0, + height: 0, + }; + + if (!image.width || !image.height) { + const size = await getImageDimensions(image.urlFullRes); + imageSize = size; + } else { + imageSize.width = image.width; + imageSize.height = image.height; + } + + cocoDataset.images.push({ + id: imageId, + file_name: image.name, + ...imageSize, + }); + + image.objects.forEach((annotation) => { + const newAnnotation: COCO.Annotation = { + id: cocoDataset.annotations.length, + image_id: imageId, + }; + + if ( + categoryMap && + annotation.categoryName && + categoryMap[annotation.categoryName] !== undefined + ) { + newAnnotation.category_id = categoryMap[annotation.categoryName]; + } + + if (annotation.boundingBox) { + const { x, y, width, height } = translateBoundingBoxToRect( + annotation.boundingBox, + imageSize, + ); + const area = width * height; + const bbox = [x, y, width, height]; + Object.assign(newAnnotation, { area, bbox }); + } + + if (annotation.segmentation) { + const segmentation = annotation.segmentation.split('/').map((group) => { + return group.split(',').map((pos) => parseFloat(pos)); + }); + + const area = segmentation.reduce((sum, group) => { + const vertices = convertToVerticesArray(group); + const area = calculatePolygonArea(vertices); + return sum + area; + }, 0); + + Object.assign(newAnnotation, { segmentation, area }); + } + + if (annotation.mask && annotation.mask.length > 0) { + const ddsRle = annotation.mask; + const canvas = rleToCanvas(ddsRle, imageSize, '#fff'); + const segmentation = ddsRleToCocoRle(ddsRle, imageSize); + if (canvas) { + const { area } = getMaskInfoByCanvas(canvas); + Object.assign(newAnnotation, { + segmentation, + area, + }); + } else { + Object.assign(newAnnotation, { segmentation }); + } + } + + if (annotation.points && annotation.points.length > 0) { + const { points } = annotation; + const keypoints: number[] = []; + let num_keypoints = 0; + for (let i = 0; i * 6 < points.length; i++) { + keypoints.push(points[i * 6], points[i * 6 + 1], points[i * 6 + 4]); + num_keypoints += 1; + } + Object.assign(newAnnotation, { + keypoints, + num_keypoints, + }); + } + + cocoDataset.annotations.push(newAnnotation); + }); + } + + cocoDataset.categories.sort((curr, next) => curr.id - next.id); + + cocoDataset.images.sort((curr, next) => curr.id - next.id); + + return cocoDataset; +}; + +export const convertCocoDatasetToAnnotStates = ( + dataset: COCO.Dataset, + currStates: IAnnotatorStates, +): IAnnotatorStates => { + const { + info: cocoInfo, + categories: cocoCategories, + images: cocoImages, + annotations: cocoAnnots, + } = dataset; + const { + info: currInfo, + categories: currCategories, + images: currUploadImages, + } = currStates; + + const { getStringItemId: getStringCategoryID } = idConverter( + IMPORT_CATEGORYID_PRIFIX, + [], + ); + const { getStringItemId: getStringImageID } = idConverter( + IMPORT_IMAGE_PRIFIX, + [], + ); + const { getStringItemId: getStringAnnotID } = idConverter( + IMPORT_ANNOT_PRIFIX, + [], + ); + + const res: IAnnotatorStates = { + info: { ...currInfo, ...cocoInfo }, + categories: currCategories, + images: currUploadImages, + }; + + if (cocoCategories && cocoCategories.length > 0) { + res.categories = cocoCategories?.map(({ id, name }) => ({ + id: getStringCategoryID(id), + name, + })); + } + + if (cocoImages && cocoImages.length > 0) { + const imageMap = new Map(res.images.map((image) => [image.name, image])); + + cocoImages.forEach((cocoImage) => { + const image = imageMap.get(cocoImage.file_name); + if (image) { + image.id = getStringImageID(cocoImage.id); + image.width = cocoImage.width; + image.height = cocoImage.height; + } + }); + } + + if (cocoAnnots && cocoAnnots.length > 0) { + const cocoImageMap = new Map(cocoImages.map((image) => [image.id, image])); + const uploadImageMap = new Map( + res.images.map((image) => [image.id, image]), + ); + + cocoAnnots.forEach((cocoAnnot) => { + const { + id: cocoAnnotId, + image_id: cocoImageId, + category_id: cocoCategoryId, + bbox: cocoBbox, + } = cocoAnnot; + + const cocoImageData = cocoImageMap.get(cocoImageId); + const targetImageData = uploadImageMap.get(getStringImageID(cocoImageId)); + + if (cocoImageData && targetImageData) { + const { width: imgWidth, height: imgHeight } = cocoImageData; + const [x, y, width, height] = cocoBbox!; + const newObject = { + id: getStringAnnotID(cocoAnnotId), + categoryId: getStringCategoryID(cocoCategoryId!), + categoryName: cocoCategories?.find( + (item) => item.id === cocoCategoryId, + )?.name, + boundingBox: translateRectToBoundingBox( + { x, y, width, height }, + { width: imgWidth, height: imgHeight }, + ), + }; + + if (!targetImageData.objects) { + targetImageData.objects = []; + } + targetImageData.objects.push(newObject); + } + }); + } + return res; +}; + +export const validateCocoData = ( + data: any, +): { + success: boolean; + message?: string; +} => { + if (!data || typeof data !== 'object') { + return { + success: false, + message: 'Format Error', + }; + } + + if (!data.images || !Array.isArray(data.images) || data.images.length === 0) { + return { + success: false, + message: 'Field Images Empty', + }; + } + + if ( + !data.images.every( + (img: any) => + typeof img === 'object' && + img.hasOwnProperty('id') && + img.hasOwnProperty('file_name'), + ) + ) { + return { + success: false, + message: 'Invalid Image Data', + }; + } + + if (!data.annotations || !Array.isArray(data.annotations)) { + return { + success: false, + message: 'Annotations Format Error', + }; + } + + if ( + !data.annotations.every( + (ann: any) => + typeof ann === 'object' && + ann.hasOwnProperty('id') && + ann.hasOwnProperty('image_id'), + ) + ) { + return { + success: false, + message: 'Invalid Annotation Data', + }; + } + + if (!data.categories || !Array.isArray(data.categories)) { + return { + success: false, + message: 'Categories Format Error', + }; + } + + if ( + !data.categories.every( + (cat: any) => + typeof cat === 'object' && + cat.hasOwnProperty('id') && + cat.hasOwnProperty('name'), + ) + ) { + return { + success: false, + message: 'Invalid Category Data', + }; + } + + const checkFieldsId = (array: any[], fieldName: string) => { + const ids = new Set(); + for (const item of array) { + if (typeof item.id === undefined) { + return { + success: false, + message: `Missing ${fieldName} ID`, + }; + } + if (!Number.isInteger(item.id)) { + return { + success: false, + message: `Int ID Required for ${fieldName}`, + }; + } + if (ids.has(item.id)) { + return { + success: false, + message: `Duplicate ${fieldName} ID`, + }; + } + ids.add(item.id); + } + }; + + const validationResults = [ + checkFieldsId(data.images, 'Image'), + checkFieldsId(data.annotations, 'Annotation'), + checkFieldsId(data.categories, 'Category'), + ]; + + for (const result of validationResults) { + if (result) { + return result; + } + } + + return { + success: true, + }; +}; diff --git a/packages/components/src/QuickLabel/utils/idConverter.ts b/packages/components/src/QuickLabel/utils/idConverter.ts new file mode 100644 index 0000000..15e7ba6 --- /dev/null +++ b/packages/components/src/QuickLabel/utils/idConverter.ts @@ -0,0 +1,41 @@ +export const idConverter = (prefix: string, items: { id?: string }[]) => { + const getStringItemId = (intId: number): string => { + return `${prefix}${intId}`; + }; + + const isImportItem = (id: string) => id.startsWith(prefix); + + const getOriginalId = (stringId: string): number => { + const intPart = stringId.substring(prefix.length); + const intId = parseInt(intPart); + if (!isNaN(intId)) { + return intId; + } + return -1; + }; + + const getMaxIdOfImportItems = (items: { id?: string }[]): number => { + const ids: number[] = items + .filter((item) => !!item.id && isImportItem(item.id)) + .map((item) => getOriginalId(item.id!)); + if (ids.length > 0) { + return Math.max(...ids); + } + return -1; + }; + + let nextAvailableId = getMaxIdOfImportItems(items) + 1; + + const getIntItemId = (id?: string) => { + if (!!id && isImportItem(id)) { + return getOriginalId(id); + } else { + return nextAvailableId++; + } + }; + + return { + getStringItemId, + getIntItemId, + }; +}; diff --git a/packages/components/src/Upload/assets/checked.svg b/packages/components/src/Upload/assets/checked.svg new file mode 100644 index 0000000..3ea2985 --- /dev/null +++ b/packages/components/src/Upload/assets/checked.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/packages/components/src/Upload/assets/upload.svg b/packages/components/src/Upload/assets/upload.svg new file mode 100644 index 0000000..dba9625 --- /dev/null +++ b/packages/components/src/Upload/assets/upload.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/components/src/Upload/components/FilePreviewList/index.less b/packages/components/src/Upload/components/FilePreviewList/index.less new file mode 100644 index 0000000..e75e034 --- /dev/null +++ b/packages/components/src/Upload/components/FilePreviewList/index.less @@ -0,0 +1,78 @@ +.dds-upload-list { + position: relative; + width: 100%; + height: 100%; + + .virtual-list { + border-radius: 8px; + overflow-y: hidden; + } + + .row-container { + display: flex; + } + + .preview-container { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + overflow: hidden; + cursor: pointer; + border-radius: 5px; + + .file-preview { + box-sizing: border-box; + object-fit: cover; + background-color: #fff; + border-radius: 5px; + } + + .file-name { + width: 90%; + margin: 5px; + word-wrap: break-word; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .remove-button { + display: none; + position: absolute !important; + top: 4px; + right: 4px; + } + + &:hover { + background-color: #f0f0f0; + border-radius: 5px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); + + .remove-button { + display: block; + } + } + } + + .preview-container-success { + .file-name { + color: @colorPrimary; + } + + .file-preview { + border: 1px solid @colorPrimary; + } + } + + .preview-container-error { + .file-name { + color: red; + } + + .file-preview { + border: 1px solid red; + } + } +} diff --git a/packages/components/src/Upload/components/FilePreviewList/index.tsx b/packages/components/src/Upload/components/FilePreviewList/index.tsx new file mode 100644 index 0000000..3e42366 --- /dev/null +++ b/packages/components/src/Upload/components/FilePreviewList/index.tsx @@ -0,0 +1,132 @@ +import { useMemo, useRef } from 'react'; +import VirtualList from 'rc-virtual-list'; +import { Button } from 'antd'; +import { DeleteOutlined } from '@ant-design/icons'; +import { chunk } from 'lodash'; +import { useSize } from 'ahooks'; +import { UploadFile } from '../..'; +import classNames from 'classnames'; +import './index.less'; + +interface IProps { + files: UploadFile[]; + fileType: 'image' | 'video'; + onRemoveFile: (index: number) => void; +} + +const FilePreviewList: React.FC = ({ + files, + fileType, + onRemoveFile, +}) => { + const containerRef = useRef(null); + const containerSize = useSize(containerRef); + const colume = containerSize?.width && containerSize.width > 800 ? 8 : 5; + + /** Group files by colume count */ + const imageGroups = useMemo(() => { + return chunk(files, colume).map((item, index) => ({ + index, + rowImages: item, + })); + }, [files, colume]); + + /** Calculate ItemSize & ImageSize */ + const itemSpace = 8; + const rowPadding = 18; + const imageAspectRatio = 0.75; + const imageWidthRatio = 0.95; + const imageNameHeight = 30; + + const itemWidth = useMemo(() => { + return containerSize?.width + ? (containerSize?.width - rowPadding * 2 - (colume - 1) * itemSpace) / + colume + : 0; + }, [containerSize?.width, colume, itemSpace]); + + const imageWidth = useMemo(() => { + return itemWidth * imageWidthRatio; + }, [itemWidth, imageWidthRatio]); + + const imageHeight = useMemo(() => { + return imageWidth * imageAspectRatio; + }, [imageWidth, imageAspectRatio]); + + const itemHeight = useMemo(() => { + return imageHeight + imageNameHeight + 16; + }, [imageHeight, imageNameHeight]); + + return ( +
+ + {(row, rowIdx) => { + return ( +
+ {row.rowImages.map((item, colIdx) => ( +
+ {fileType === 'video' ? ( +
+ ))} +
+ ); + }} +
+
+ ); +}; + +export default FilePreviewList; diff --git a/packages/components/src/Upload/index.less b/packages/components/src/Upload/index.less new file mode 100644 index 0000000..ffc291c --- /dev/null +++ b/packages/components/src/Upload/index.less @@ -0,0 +1,100 @@ +.dds-upload { + position: relative; + width: 100%; + height: 100%; + + &-loading { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.3); + } + + input { + display: none !important; + } + + &-title { + font-size: 24px; + font-weight: 500; + line-height: 1; + } + + &-text { + font-size: 14px; + font-weight: 400; + color: #c1c1c1; + } + + &-empty { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + border-radius: 5px; + border: 1px solid #c9cdd4; + cursor: pointer; + + svg { + width: 91px; + height: 75px; + } + + .dds-upload-title { + margin-top: 30px; + } + } + + &-content { + width: 100%; + height: 100%; + border-radius: 5px; + border: 1px solid #c9cdd4; + + &-list { + position: relative; + width: 100%; + height: calc(100% - 64px); + padding: 30px 0 0; + + &-count { + position: absolute; + left: 18px; + top: 8px; + font-size: 14px; + color: rgba(0, 0, 0, 0.45); + } + } + } + + &-draging { + border: 1px solid @colorPrimary; + background: #e8efff; + } + + &-topbar { + height: 64px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 18px; + border-radius: 0; + border-bottom: 1px solid #c9cdd4; + + .dds-upload-title { + font-size: 20px; + } + + .dds-upload-text { + font-size: 12px; + margin-top: 4px; + } + } +} diff --git a/packages/components/src/Upload/index.tsx b/packages/components/src/Upload/index.tsx new file mode 100644 index 0000000..84f44cb --- /dev/null +++ b/packages/components/src/Upload/index.tsx @@ -0,0 +1,234 @@ +import { useCallback, useRef, useState } from 'react'; +import { Button, Spin, message } from 'antd'; +import { useDrop } from 'ahooks'; +import { cloneDeep } from 'lodash'; +import { ReactComponent as UploadIcon } from './assets/upload.svg'; +import { useLocale } from 'dds-utils/locale'; +import { scanDataTransfer } from 'dds-utils/file'; +import FilePreviewList from './components/FilePreviewList'; +import classNames from 'classnames'; +import './index.less'; + +export interface UploadFile { + id: string; + name: string; + url: string; + status?: 'success' | 'error'; + originFileObj?: File; + path?: string; + uploadUrl?: string; + contentType?: string; + duration?: number; + frameCount?: number; + frameRate?: number; + targetFrameRate?: number; +} + +interface IProps { + fileList: UploadFile[]; + setFileList: React.Dispatch>; + fileType: 'video' | 'image'; + acceptTypes?: string[]; + maxCount?: number; + maxSize?: number; + maxDuratuion?: number; + limitRemoveFile?: (index: number) => boolean; +} + +const Upload: React.FC = ({ + fileList, + setFileList, + acceptTypes, + maxCount, + maxSize, + maxDuratuion, + limitRemoveFile, + fileType, +}: IProps) => { + const { localeText } = useLocale(); + const [loading, setLoading] = useState(false); + const [draging, setDraging] = useState(false); + const fileCancleRef = useRef(false); + const inputRef = useRef(null); + const accept = acceptTypes ? acceptTypes.join(', ') : undefined; + + const addFiles = async (files: File[]) => { + setLoading(true); + const newFiles: UploadFile[] = []; + for (let file of files) { + let [frameCount, frameRate, duration] = [0, 0, 0]; + if (maxSize && file.size && file.size / 1024 / 1024 > maxSize) { + continue; + } + if (maxCount && newFiles.length + fileList.length > maxCount - 1) { + continue; + } + if (fileList.find((item) => item.name === file.name)) { + continue; + } + newFiles.push({ + id: file.name, + name: file.name, + url: URL.createObjectURL(file as Blob), + originFileObj: file, + frameCount, + frameRate, + duration, + }); + } + setLoading(false); + if (newFiles.length > 0) { + setFileList([...newFiles, ...fileList]); + message.success( + localeText('dds-upload.tip.successLoad', { + count: newFiles.length, + }), + ); + } + }; + + const onRemoveFile = useCallback( + (index: number) => { + if (limitRemoveFile && limitRemoveFile(index)) return; + const newList = cloneDeep(fileList); + newList.splice(index, 1); + setFileList(newList); + }, + [fileList], + ); + + const handleUploadChange = (e: React.ChangeEvent) => { + fileCancleRef.current = false; + + const files: File[] = e.target.files ? [...e.target.files] : []; + if (files.length > 0) { + addFiles(files); + } + + setDraging(false); + e.target.value = ''; + }; + + const onClickUpload = useCallback(() => { + if (maxCount && fileList.length >= maxCount) { + message.warning( + localeText('dds-upload.tip.fileCountLimitMsg', { + count: maxCount, + }), + ); + return; + } + setDraging(true); + inputRef.current?.click(); + + // mock click file cancel + fileCancleRef.current = true; + window.addEventListener( + 'focus', + () => { + setTimeout(() => { + if (fileCancleRef.current) { + setDraging(false); + } + }, 100); + }, + { once: true }, + ); + }, [fileList, maxCount]); + + useDrop(window.document.body, { + onFiles: async (_files, e) => { + if (maxCount && fileList.length >= maxCount) { + message.warning( + localeText('dds-upload.tip.fileCountLimitMsg', { + count: maxCount, + }), + ); + return; + } + const files = await scanDataTransfer(e?.dataTransfer, acceptTypes); + addFiles(files); + }, + onDragEnter: () => { + setDraging(true); + }, + onDrop: () => { + setDraging(false); + }, + onDragLeave: () => { + setDraging(false); + }, + }); + + return ( +
+ + {fileList.length <= 0 ? ( +
+ +

{localeText('dds-upload.title')}

+

+ {fileType === 'video' + ? localeText('dds-upload.limit.type.video') + : localeText('dds-upload.limit.type.image')} +

+
+ ) : ( +
+
+
+
+ {localeText('dds-upload.title')} +
+
+ {fileType === 'video' + ? localeText('dds-upload.limit.type.video') + : localeText('dds-upload.limit.type.image')} +
+
+ +
+
+ {maxCount && ( +
+ {fileList.length} / {maxCount} +
+ )} + +
+
+ )} + {loading && ( + + )} +
+ ); +}; + +export default Upload; diff --git a/packages/components/src/UploadPreAnno/assets/upload_file.svg b/packages/components/src/UploadPreAnno/assets/upload_file.svg new file mode 100644 index 0000000..3f65cbd --- /dev/null +++ b/packages/components/src/UploadPreAnno/assets/upload_file.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/components/src/UploadPreAnno/index.less b/packages/components/src/UploadPreAnno/index.less new file mode 100644 index 0000000..df8ac58 --- /dev/null +++ b/packages/components/src/UploadPreAnno/index.less @@ -0,0 +1,30 @@ +.dds-upload-pre-anno { + .ant-upload { + width: 100%; + } + + .ant-card { + border: 1px solid #c9cdd4; + background: none; + + .ant-card-meta-avatar { + width: 56px; + height: 56px; + margin-right: 20px; + + svg { + width: 56px; + height: 56px; + } + } + + .ant-card-meta-title { + font-size: 24px; + font-weight: 500; + } + + &:hover { + cursor: pointer; + } + } +} diff --git a/packages/components/src/UploadPreAnno/index.tsx b/packages/components/src/UploadPreAnno/index.tsx new file mode 100644 index 0000000..80e83aa --- /dev/null +++ b/packages/components/src/UploadPreAnno/index.tsx @@ -0,0 +1,54 @@ +import Icon from '@ant-design/icons'; +import { Card, Upload, UploadFile } from 'antd'; +import { ReactNode } from 'react'; +import { ReactComponent as UploadFileIcon } from './assets/upload_file.svg'; +import { UploadChangeParam } from 'antd/es/upload'; +import { useLocale } from 'dds-utils/locale'; +import './index.less'; + +const DEFAULT_PRE_ANNO_MAX_SIZE = 20; + +interface IProps { + children?: ReactNode; + uploadFiles: UploadFile[]; + onChangeFile: (info: UploadChangeParam>) => void; + onRemoveFile: (file: UploadFile) => void; +} + +const UploadPreAnno: React.FC = ({ + uploadFiles, + onChangeFile, + onRemoveFile, + children, +}) => { + const { localeText } = useLocale(); + + return ( + false} + fileList={uploadFiles} + onChange={onChangeFile} + onRemove={onRemoveFile} + accept={'.json'} + showUploadList={true} + > + {children ? ( + children + ) : ( + + } + title={localeText('dds-upload-pre-anno')} + description={localeText('dds-upload-pre-anno.tip', { + maxSize: DEFAULT_PRE_ANNO_MAX_SIZE, + })} + /> + + )} + + ); +}; + +export default UploadPreAnno; diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 138c9cc..1c7aead 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -7,4 +7,5 @@ export { default as RunningErrorTip } from './RunningErrorTip'; export { default as ColumnSettings } from './ColumnSettings'; export { default as MobileAlert } from './MobileAlert'; export { default as DynamicPagination } from './DynamicPagination'; +export { default as QuickLabel } from './QuickLabel'; export { AnnotateEditor, AnnotatePreview, AnnotateView } from './Annotator'; diff --git a/packages/components/src/locales/en-US.ts b/packages/components/src/locales/en-US.ts index 462c9d3..4eefca8 100644 --- a/packages/components/src/locales/en-US.ts +++ b/packages/components/src/locales/en-US.ts @@ -225,4 +225,66 @@ export default { 'DDSAnnotator.video.track.setting': 'Tracking settings', 'DDSAnnotator.video.frame': 'Frames', 'DDSAnnotator.video.track.backward': 'Backward inference frames', + + /** dds-upload */ + 'dds-upload.title': 'Drag or Click to upload your data', + 'dds-upload.limit.type.image': 'Image files (.jpg/.jpeg/.png) are supported.', + 'dds-upload.limit.type.video': + 'Video files (.mp4/.mov & duration < 60s) are supported.', + 'dds-upload.upload': 'Add', + 'dds-upload.tip.successLoad': 'Had added {count} files', + 'dds-upload.tip.fileCountLimitMsg': 'File count should not exceed {count}.', + 'dds-upload.videoFrame.title': 'Adjust Frame Count', + 'dds-upload.videoFrame.tip': 'Attn', + 'dds-upload.videoFrame.tip.content': + 'Choose how many frames you want to annotate. A high frequency will create more, similarframes. A low one will create less frames but more varied imagery.', + 'dds-upload.videoFrame.adjust': 'Frame rate adjustment range', + 'dds-upload.videoFrame.fps': 'frames per second', + 'dds-upload.videoFrame.matchNative': 'Match native frame rate', + 'dds-upload.videoFrame.total': 'Total of {count} Frames', + 'dds-upload.videoFrame.batch.all': 'Apply to all videos in this upload', + 'dds-upload.videoFrame.batch.rest': 'Apply to rest videos in this upload', + 'dds-upload.videoFrame.confirmbtn': 'Upload {count} Video', + + /** dds-upload-pre-anno */ + 'dds-upload-pre-anno': 'Upload Pre-annotate Data', + 'dds-upload-pre-anno.tip': + 'Only annotations in DDS format are supported. File size should not exceed {maxSize} MB.', + + /** QuickLabel */ + 'quicklabel.formModal.attn': 'Attn', + 'quicklabel.formModal.tip': + 'The quick mode will not upload images or save annotation results. We recommend clicking the "Export Annotations" button located in the upper right corner of the workspace before leaving, which allows you to save the annotation results locally.', + 'quicklabel.formModal.start': 'Start', + 'quicklabel.formModal.confirm': 'Confirm', + 'quicklabel.title': 'Quick Label', + 'quicklabel.setting': 'Setting', + 'quicklabel.imageFilter': 'Image Filter', + 'quicklabel.clearFilter': 'Clear Filter', + 'quicklabel.allCategories': 'All Categories', + 'quicklabel.export': 'Export Annotation', + 'quicklabel.notice': + 'The quick mode will not upload images or save annotation results. We recommend clicking the "Export Annotations" button located in the upper right corner of the workspace before leaving, which allows you to save the annotation results locally.', + 'quicklabel.formModal.title': 'Before you start', + 'quicklabel.formModal.importImages': 'Import Images', + 'quicklabel.formModal.importVideos': 'Import Videos', + 'quicklabel.formModal.importPreAnnots': 'Import Annotations', + 'quicklabel.formModal.imageTips': + 'Tips: Import a maximum of {count} images, with each image not exceeding {size}MB.', + 'quicklabel.formModal.categories': 'Categories', + 'quicklabel.formModal.addCategory': 'Add', + 'quicklabel.formModal.categoryPlaceholder': + 'Please enter the category names. You can input multiple categories by separating them with a new line. E.g.: \n person \n dog \n car', + 'quicklabel.formModal.categoriesCount': 'Categories Count', + 'quicklabel.formModal.fileRequiredMsg': 'At least one image is required.', + 'quicklabel.formModal.fileSizeLimitMsg': + 'The size of each individual image cannot exceed {size} MB.', + 'quicklabel.formModal.categoryRequiredMsg': + 'At least one category is required.', + 'quicklabel.formModal.deleteCategory.title': 'Info', + 'quicklabel.formModal.deleteCategory.desc': + 'This category is used by current annotations. Please manually remove these annotations or revise their category first.', + 'quicklabel.formModal.deleteImage.title': 'Info', + 'quicklabel.formModal.deleteImage.desc': + 'This image contains annotations. Please manually remove these annotations first.', }; diff --git a/packages/components/src/locales/zh-CN.ts b/packages/components/src/locales/zh-CN.ts index 7e38ae9..acd6b65 100644 --- a/packages/components/src/locales/zh-CN.ts +++ b/packages/components/src/locales/zh-CN.ts @@ -205,4 +205,64 @@ export default { 'DDSAnnotator.video.track.setting': '推理设置', 'DDSAnnotator.video.frame': '帧', 'DDSAnnotator.video.track.backward': '向后推理帧数', + + /** dds-upload */ + 'dds-upload.title': '将文件拖动到这里或点击进行上传', + 'dds-upload.limit.type.image': '图片格式支持: .jpg/.jpeg/.png', + 'dds-upload.limit.type.video': '视频格式支持: .mp4/.mov、时长 <= 60s', + 'dds-upload.upload': '添加', + 'dds-upload.tip.successLoad': '成功加载{count}个文件', + 'dds-upload.tip.fileCountLimitMsg': '文件数量不能超过{count}', + 'dds-upload.videoFrame.title': '调整帧率', + 'dds-upload.videoFrame.tip': '注意', + 'dds-upload.videoFrame.tip.content': + '选择您想要标注的帧数。高帧率将创建更多相似的帧。低帧率将创建较少的帧,但图像更多样化。', + 'dds-upload.videoFrame.adjust': '帧数调整范围', + 'dds-upload.videoFrame.fps': '帧/秒', + 'dds-upload.videoFrame.matchNative': '与原始帧率匹配', + 'dds-upload.videoFrame.total': '共{count}帧', + 'dds-upload.videoFrame.batch.all': '应用到所有的视频中', + 'dds-upload.videoFrame.batch.rest': '应用到剩余的视频中', + 'dds-upload.videoFrame.confirmbtn': '上传{count}个视频', + + /** dds-upload-pre-anno */ + 'dds-upload-pre-anno': '上传预标注数据', + 'dds-upload-pre-anno.tip': + '目前仅支持DDS格式的标注。文件大小不得超过{maxSize} MB。', + + /** QuickLabel */ + 'quicklabel.formModal.attn': '注意', + 'quicklabel.formModal.tip': + '快速模式不会上传图像或保存标注结果。我们建议在离开之前点击工作区右上角的“导出标注”按钮,这样可以将标注结果保存到本地。', + 'quicklabel.formModal.start': '开始', + 'quicklabel.formModal.confirm': '确定', + 'quicklabel.title': '快速标注', + 'quicklabel.setting': '设置', + 'quicklabel.imageFilter': '图片筛选', + 'quicklabel.clearFilter': '清除筛选', + 'quicklabel.allCategories': '全部类别', + 'quicklabel.annotate': '标注', + 'quicklabel.export': '导出标注', + 'quicklabel.formModal.title': '开始之前', + 'quicklabel.formModal.importImages': '导入图片', + 'quicklabel.formModal.importPreAnnots': '导入预标注', + 'quicklabel.notice': + '快速标注模式不会上传任何图片或保存标注结果,为了防止数据丢失,建议您在离开前点击工作区右上方"导出标注"按钮,将标注结果保存到本地。', + 'quicklabel.formModal.imageTips': + '注意:最多导入{count}张图片,每张图片不超过{size}MB。', + 'quicklabel.formModal.categories': '导入标注类别', + 'quicklabel.formModal.addCategory': '添加', + 'quicklabel.formModal.categoryPlaceholder': + '请输入类别名称, 多个类别可以换行分隔, 例如: \n person \n dog \n car', + 'quicklabel.formModal.categoriesCount': '当前类别标签数量', + 'quicklabel.formModal.fileRequiredMsg': '请至少导入一张图片', + 'quicklabel.formModal.fileCountLimitMsg': '图片量不能超过{count}张', + 'quicklabel.formModal.fileSizeLimitMsg': '单张图片不能超过{size}MB', + 'quicklabel.formModal.categoryRequiredMsg': '请至少输入一个类别标签', + 'quicklabel.formModal.deleteCategory.title': '注意', + 'quicklabel.formModal.deleteCategory.desc': + '有标注中使用了这个类别,请先手动删除这些标注或修改它们的类别', + 'quicklabel.formModal.deleteImage.title': '注意', + 'quicklabel.formModal.deleteImage.desc': + '该图片内包含标注信息,请先手动删除这些标注' }; diff --git a/packages/utils/src/file.ts b/packages/utils/src/file.ts index 4fba96f..c1f9e29 100644 --- a/packages/utils/src/file.ts +++ b/packages/utils/src/file.ts @@ -52,3 +52,91 @@ export const loadImage = (src: string) => { }; }); }; + +export async function scanFiles( + entry: any, + filesList: any[], + acceptTypes?: string[], +) { + return new Promise((resolve, reject) => { + if (entry.isDirectory) { + const directoryReader = entry.createReader(); + directoryReader.readEntries( + async (entries: any[]) => { + for (let index = 0; index < entries.length; index++) { + await scanFiles(entries[index], filesList, acceptTypes); + if (index === entries.length - 1) { + resolve(1); + } + } + }, + (e: any) => { + reject(e); + }, + ); + } else { + entry.file( + async (file: any) => { + const path = entry.fullPath.substring(1); + /**修改webkitRelativePath 是核心操作,原因是拖拽会的事件体中webkitRelativePath是空的,而且webkitRelativePath 是只读属性,普通赋值是不行的。所以目前只能使用这种方法将entry.fullPath 赋值给webkitRelativePath**/ + const newFile: File = Object.defineProperty( + file, + 'webkitRelativePath', + { + value: path, + }, + ); + if (!acceptTypes || acceptTypes.includes(newFile.type)) { + filesList.push(newFile); + } + resolve(1); + return; + }, + (e: any) => { + reject(e); + }, + ); + } + }); +} + +export async function scanDataTransfer( + dataTransfer?: DataTransfer, + acceptTypes?: string[], +) { + if (!dataTransfer) return []; + const filesList: File[] = []; + + // files filter + for (const item of dataTransfer.files) { + if (item && (!acceptTypes || acceptTypes.includes(item.type))) { + filesList.push(item); + } + } + + // sub directory + if (dataTransfer.items.length > 0) { + for (const item of dataTransfer.items) { + const itemEntry = item.webkitGetAsEntry(); + if (itemEntry?.isDirectory) { + await scanFiles(itemEntry, filesList, acceptTypes); + } + } + } + return filesList; +} + +export async function getImageDimensions(url: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.src = url; + img.onload = () => { + const width = img.width; + const height = img.height; + resolve({ width, height }); + }; + img.onerror = () => { + reject(new Error('Load Image Error')); + }; + }); +} \ No newline at end of file