diff --git "a/docs/annotation/format/pascalVoc/annotation-xml\346\240\274\345\274\217.jpg" "b/docs/annotation/format/pascalVoc/annotation-xml\346\240\274\345\274\217.jpg" new file mode 100644 index 0000000..c2aff4f Binary files /dev/null and "b/docs/annotation/format/pascalVoc/annotation-xml\346\240\274\345\274\217.jpg" differ diff --git a/docs/annotation/format/pascalVoc/pascalVoc.md b/docs/annotation/format/pascalVoc/pascalVoc.md new file mode 100644 index 0000000..475d02b --- /dev/null +++ b/docs/annotation/format/pascalVoc/pascalVoc.md @@ -0,0 +1,36 @@ +# 导出 VOC2012 Detection 类型 + +PASCAL VOC 为图像识别和分类提供了一整套标准化的数据集,VOC2012 Detection 预测图片中每一个目标的 bounding box(位置) and label(类别)。 + +## 格式说明 + +- 仅`矩形`可导出 VOC2012 Detection。 + +- Annotations: 一张图片导出一个 xml 文件,图片中的每一个标注框的信息需要一个 ` ` 包裹,无标注框则无`` 标签。 + +- 无拉框,则对应标签包裹内容为空。 + +标签格式: + + | 标签名称 | 包裹值 + ------------------------------------ + | folder | 图片所处文件夹 + | filename | 图片名 + | path | 图片路径 + | source | 图片来源相关信息 + | database | 导出属性映射文件内容 + | size | 图片尺寸 + | width | 宽 + | height | 高 + | depth | 管道 + | segmented | 是否有分割 + | object | 包含物体 + | name | 物体类别 + | pose | 物体姿态(默认 Unspecified) + | truncated | 物体是否被遮挡(默认 0) + | difficult | 是否为难识别的物体(默认 0) + | bndbox | 物体bound box + | xmin | 最小横坐标 + | ymin | 最小纵坐标 + | xmax | 最大横坐标 + | ymax | 最大纵坐标 diff --git a/src/ProjectPlatform/ProjectList/ExportData/index.tsx b/src/ProjectPlatform/ProjectList/ExportData/index.tsx index 5ee2085..fcefcb1 100644 --- a/src/ProjectPlatform/ProjectList/ExportData/index.tsx +++ b/src/ProjectPlatform/ProjectList/ExportData/index.tsx @@ -43,10 +43,16 @@ const ExportData = (props: IProps) => { */ const isTransfer2Yolo = projectInfo?.toolName === EToolName.Rect; + /** + * 判断当前是否允许被转换成 VOC2012 Detection 格式 + */ + const isTransfer2PascalVoc = projectInfo?.toolName === EToolName.Rect; + /** * 是否允许被转换的成 Mask */ const isTransfer2ACE20k = projectInfo?.toolName === EToolName.Polygon; + const onOk = () => { if (!ipcRenderer || !projectInfo) { return; @@ -101,7 +107,20 @@ const ExportData = (props: IProps) => { ); break; + case 'voc': + name = `${projectInfo.name}-voc`; + fileList.forEach((file, i) => { + const doc = DataTransfer.transferDefault2Voc(file); + electron.ipcRenderer.send( + EIpcEvent.SaveFile, + doc, + values.path, + 'utf8', + `${file.fileName}_voc.xml`, + ); + }); + break; case 'Mask': name = `${projectInfo.name}-Mask`; suffix = 'png'; @@ -201,22 +220,33 @@ const ExportData = (props: IProps) => {
- + {isTransfer2Coco ? ( 'COCO' ) : ( COCO )} - + {isTransfer2Yolo ? ( 'YOLO' ) : ( YOLO )} - {t('StandardFormat')} - + + + {isTransfer2PascalVoc ? ( + 'VOC2012 Detection' + ) : ( + VOC2012 Detection + )} + + + + {t('StandardFormat')} + + {isTransfer2ACE20k ? ( 'Mask' ) : ( diff --git a/src/copyApp.tsx b/src/copyApp.tsx new file mode 100644 index 0000000..51d03b8 --- /dev/null +++ b/src/copyApp.tsx @@ -0,0 +1,252 @@ +import MainView from '@/views/MainView'; +import { IPointCloudBox, i18n } from '@labelbee/lb-utils'; +import React, { useEffect, useState } from 'react'; +import { connect } from 'react-redux'; +import { store } from '.'; +import { LabelBeeContext } from '@/store/ctx'; +import { AppState } from './store'; +import { ANNOTATION_ACTIONS } from './store/Actions'; +import { + InitAnnotationState, + InitTaskData, + loadImgList, + UpdateInjectFunc, +} from './store/annotation/actionCreators'; +import { AfterCommonLoaded, LoadFileAndFileData } from './store/annotation/reducer'; +import { ToolInstance } from './store/annotation/types'; +import { + GetFileData, + IFileItem, + LoadFileList, + OnPageChange, + OnSave, + OnStepChange, + OnSubmit, +} from './types/data'; +import { Header, RenderFooter, Sider, DrawLayerSlot } from './types/main'; +import { IStepInfo } from './types/step'; +import { ConfigProvider } from 'antd/es'; +import zhCN from 'antd/es/locale/zh_CN'; +import enUS from 'antd/es/locale/en_US'; +import { EPointCloudName } from '@labelbee/lb-annotation'; + +interface IAnnotationStyle { + strokeColor: string; + fillColor: string; + textColor: string; + toolColor: any; +} + +export interface IPreDataProcessParams { + // 标注类型:暂时只支持点云 + tool: EPointCloudName.PointCloud | string; + // 更新数据 + dataList: IPointCloudBox[]; + // 更新数据的具体动作 + action: 'preDataProcess' | 'viewUpdateBox'; + // 当前步骤的config + stepConfig: IStepInfo['config']; +} + +export interface AppProps { + exportData?: (data: any[]) => void; + goBack?: () => void; + imgList?: IFileItem[]; + config: string; + stepList: IStepInfo[]; + step: number; + onSubmit?: OnSubmit; + onSave?: OnSave; + onPageChange?: OnPageChange; + onStepChange?: OnStepChange; + getFileData?: GetFileData; + pageSize: number; + loadFileList?: LoadFileList; + headerName?: string; + initialIndex?: number; + className?: string; + toolInstance: ToolInstance; + header?: Header; + footer?: RenderFooter; + sider?: Sider; + style?: { + layout?: { [key: string]: any }; + header?: { [key: string]: any }; + sider?: { [key: string]: any }; + footer?: { [key: string]: any }; + }; + setToolInstance?: (tool: ToolInstance) => void; + mode?: 'light' | 'dark'; // 临时需求应用于 toolFooter 的操作 + showTips?: boolean; // 是否展示 tips + tips?: string; // Tips 具体内容 + defaultLang: 'en' | 'cn'; // 国际化设置 + leftSider?: () => React.ReactNode | React.ReactNode; + + // data Correction + skipBeforePageTurning?: (pageTurning: Function) => void; + beforeRotate?: () => boolean; + + drawLayerSlot?: DrawLayerSlot; + + // 标注信息扩展的功能 + dataInjectionAtCreation: (annotationData: any) => {}; + // 渲染增强 + renderEnhance: { + staticRender?: (canvas: HTMLCanvasElement, data: any, style: IAnnotationStyle) => void; + selectedRender?: (canvas: HTMLCanvasElement, data: any, style: IAnnotationStyle) => void; + creatingRender?: (canvas: HTMLCanvasElement, data: any, style: IAnnotationStyle) => void; + }; + customRenderStyle?: (data: any) => IAnnotationStyle; + + checkMode?: boolean; + intelligentFit?: boolean; + enableColorPicker?: boolean; + highlightAttribute?: string; + onLoad?: ({ toolInstance }: { toolInstance: ToolInstance }) => void; + preDataProcess?: (params: IPreDataProcessParams) => IPointCloudBox[]; + auditContext?: any; +} + +const App: React.FC = (props) => { + const [_, forceRender] = useState(0); + const { + imgList, + step = 1, + stepList, + onSubmit, + onSave, + onPageChange, + onStepChange, + initialIndex = 0, + toolInstance, + setToolInstance, + getFileData, + pageSize = 10, + loadFileList, + defaultLang = 'cn', + skipBeforePageTurning, + beforeRotate, + checkMode = false, + intelligentFit = true, + highlightAttribute = '', + preDataProcess, + imgIndex + } = props; + + useEffect(() => { + store.dispatch( + InitTaskData({ + onSubmit, + stepList, + step, + getFileData, + pageSize, + loadFileList, + onSave, + onPageChange, + onStepChange, + skipBeforePageTurning, + beforeRotate, + checkMode, + highlightAttribute, + preDataProcess, + }), + ); + + initImgList(); + // 初始化国际化语言 + i18n.changeLanguage(defaultLang); + + const i18nLanguageChangedFunc = () => { + forceRender((v) => v + 1); + }; + + i18n.on('languageChanged', i18nLanguageChangedFunc); + return () => { + i18n.off('languageChanged', i18nLanguageChangedFunc); + + // Init all annotation state after unmounting + InitAnnotationState(store.dispatch); + }; + }, []); + + useEffect(() => { + store.dispatch( + UpdateInjectFunc({ + onSubmit, + stepList, + getFileData, + pageSize, + loadFileList, + onSave, + onPageChange, + onStepChange, + beforeRotate, + highlightAttribute, + preDataProcess, + }), + ); + + i18n.changeLanguage(defaultLang); + }, [ + onSubmit, + stepList, + getFileData, + pageSize, + loadFileList, + onSave, + onPageChange, + onStepChange, + defaultLang, + beforeRotate, + highlightAttribute, + preDataProcess, + ]); + + useEffect(() => { + setToolInstance?.(toolInstance); + }, [toolInstance]); + + useEffect(()=>{ + store.dispatch({ + type: ANNOTATION_ACTIONS.UPDATE_IMG_INDEX, + payload: { + imgIndex, + }, + }); + store.dispatch(LoadFileAndFileData(imgIndex)); + },[imgIndex]) + + // 初始化imgList 优先以loadFileList方式加载数据 + const initImgList = () => { + if (loadFileList) { + loadImgList(store.dispatch, store.getState, initialIndex, true).then((isSuccess) => { + if (isSuccess) { + store.dispatch(LoadFileAndFileData(initialIndex)); + } + }); + } else if (imgList && imgList.length > 0) { + store.dispatch({ + type: ANNOTATION_ACTIONS.UPDATE_IMG_LIST, + payload: { + imgList, + }, + }); + store.dispatch(LoadFileAndFileData(initialIndex)); + } + }; + + return ( +
+ + + +
+ ); +}; + +const mapStateToProps = (state: AppState) => ({ + toolInstance: state.annotation.toolInstance, +}); + +export default connect(mapStateToProps, null, null, { context: LabelBeeContext })(App); diff --git a/src/i18n.ts b/src/i18n.ts index e9896f6..9fbae6d 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -86,6 +86,7 @@ const resources = { ExportSuccess: 'Export successfully', ExportCOCOLimitMsg: 'Only rectTool and polygonTool can realize the conversion of coco data', ExportYOLOLimitMsg: 'Only rectTool can realize the conversion of yolo data', + ExportVOCLimitMsg: 'Only rectTool can realize the conversion of VOC2012 Detection data', ExportMaskLimitMsg: 'Only polygonTool can realize the conversion of Mask', SelectedExportPath: 'Choose Export path', MultiSelect: 'multi-select', @@ -187,6 +188,7 @@ const resources = { ExportSuccess: '导出成功', ExportCOCOLimitMsg: '仅限拉框、多边形工具可以实现 coco 数据的转换', ExportYOLOLimitMsg: '仅限拉框工具可以实现 yolo 数据的转换', + ExportVOCLimitMsg: '仅限拉框工具可以实现VOC2012 Detection 数据的转换', ExportMaskLimitMsg: '仅限多边形工具可以实现 Mask 数据的转换', SelectedExportPath: '选择导出的路径', MultiSelect: '多选', diff --git a/src/utils/CreateDoc.ts b/src/utils/CreateDoc.ts new file mode 100644 index 0000000..bd2fe5e --- /dev/null +++ b/src/utils/CreateDoc.ts @@ -0,0 +1,27 @@ +/** + * 创建dom节点 + */ +export default class CreateDoc { + public tagName: string = ''; + public children: any = []; + constructor(props: any) { + this.tagName = props.tagName; + const children = props.children.map((item: any) => { + if (typeof item === 'object') { + item = new CreateDoc(item); + } + return item; + }); + this.children = children; + } + + public render() { + const el = document.createElement(this.tagName); + const children = this.children || []; + children.forEach((v: any) => { + const childEl = v instanceof CreateDoc ? v.render() : document.createTextNode(v); + el.appendChild(childEl); + }); + return el; + } +} diff --git a/src/utils/DataTransfer.ts b/src/utils/DataTransfer.ts index e9d438e..86efb98 100644 --- a/src/utils/DataTransfer.ts +++ b/src/utils/DataTransfer.ts @@ -7,6 +7,7 @@ import { IFileInfo, IStepInfo } from '@/store'; import { getBaseName, jsonParser } from './tool/common'; import { DrawUtils } from '@labelbee/lb-annotation'; import ColorCheatSheet from '@/assets/color.json'; +import CreateDoc from '@/utils/CreateDoc'; // 获取 color cheat sheet 内的颜色 export const getRgbFromColorCheatSheet = (index: number) => { @@ -316,6 +317,198 @@ export default class DataTransfer { return { categories, idString }; } + /** + * 格式化xml代码 + * + * @param xmlStr + */ + public static formateXml(xmlStr: any) { + var text = xmlStr; + + //使用replace去空格 + text = + '\n' + + text + .replace(/(<\w+)(\s.*?>)/g, function ($0: any, name: any, props: any) { + return name + ' ' + props.replace(/\s+(\w+=)/g, ' $1'); + }) + .replace(/>\s*?\n<'); + + //调整格式 以压栈方式递归调整缩进 + var rgx = /\n(<(([^\?]).+?)(?:\s|\s*?>|\s*?(\/)>)(?:.*?(?:(?:(\/)>)|(?:<(\/)\2>)))?)/gm; + var nodeStack: any = []; + var output = text.replace( + rgx, + function ( + $0: any, + all: any, + name: any, + isBegin: any, + isCloseFull1: any, + isCloseFull2: any, + isFull1: any, + isFull2: any, + ) { + var isClosed = + isCloseFull1 == '/' || isCloseFull2 == '/' || isFull1 == '/' || isFull2 == '/'; + var prefix = ''; + if (isBegin == '!') { + //!开头 + prefix = DataTransfer.setPrefix(nodeStack.length); + } else { + if (isBegin != '/') { + ///开头 + prefix = DataTransfer.setPrefix(nodeStack.length); + if (!isClosed) { + //非关闭标签 + nodeStack.push(name); + } + } else { + nodeStack.pop(); //弹栈 + prefix = DataTransfer.setPrefix(nodeStack.length); + } + } + var ret = '\n' + prefix + all; + return ret; + }, + ); + var outputText = output.substring(1); + return outputText; + } + + /** + * 计算头函数,用来缩进 + * + * @param prefixIndex + */ + public static setPrefix(prefixIndex: any) { + var result = ''; + var span = ' '; //缩进长度 + var output = []; + for (var i = 0; i < prefixIndex; ++i) { + output.push(span); + } + result = output.join(''); + return result; + } + + /** + * 将 sensebee 格式转换为 pascal voc 格式 + * + * @param file + */ + public static transferDefault2Voc(file: any) { + const result = jsonParser(file?.result); + const folder = file.url.replace(file?.fileName, ''); // 文件夹 + + let labelObj = { + tagName: 'annotation', + children: [ + { + tagName: 'folder', // 图片所处文件夹 + children: [folder], + }, + { + tagName: 'filename', // 图片名 + children: [file?.fileName], + }, + { + tagName: 'path', + children: [file?.url], + }, + { + tagName: 'source', // 图片来源相关信息 + children: [ + { + tagName: 'database', + children: [], + }, + ], + }, + { + tagName: 'size', // 图片尺寸 + children: [ + { + tagName: 'width', + children: [result?.width], + }, + { + tagName: 'height', + children: [result?.height], + }, + { + tagName: 'depth', + children: [3], + }, + ], + }, + { + tagName: 'segmented', // 是否有分割 + children: [0], + }, + ], + }; + + if (result?.step_1?.result?.length > 0) { + result?.step_1.result.forEach((v: any) => { + const xmin = (v.x - v.width / 2).toFixed(6); // 最小横坐标 + const ymin = (v.x - v.height / 2).toFixed(6); // 最小纵坐标 + const xmax = (v.x + v.width / 2).toFixed(6); // 最小横坐标 + const ymax = (v.x + v.height / 2).toFixed(6); // 最大纵坐标 + const object = { + tagName: 'object', // 包含物体 + children: [ + { + tagName: 'name', // 物体类别 + children: [], + }, + { + tagName: 'pose', // 物体姿态 + children: ['Unspecified'], + }, + { + tagName: 'truncated', // 物体是否被遮挡 + children: [0], + }, + { + tagName: 'difficult', // 是否为难识别的物体 + children: [0], + }, + { + tagName: 'bndbox', + children: [ + { + tagName: 'xmin', + children: [xmin], + }, + { + tagName: 'ymin', + children: [ymin], + }, + { + tagName: 'xmax', + children: [xmax], + }, + { + tagName: 'ymax', + children: [ymax], + }, + ], + }, + ], + }; + labelObj.children.push(object); + }); + } + const doc = new CreateDoc(labelObj); + const xmlSerializer = new XMLSerializer(); + let setupSerial = xmlSerializer.serializeToString(doc.render()); + const reg = new RegExp(' xmlns="http://www.w3.org/1999/xhtml"', 'g'); + setupSerial = setupSerial.replace(reg, ''); + setupSerial = this.formateXml(setupSerial); + return setupSerial; + } + /** * 将 sensebee 格式转换为 yolo 格式 * 仅限工具: 拉框