Skip to content

Commit

Permalink
Merge pull request #280 from hartmannr76/rmh_VOCXMLImport
Browse files Browse the repository at this point in the history
Initial working version of VOC XML import
  • Loading branch information
SkalskiP committed Oct 10, 2022
2 parents 282ce34 + 746dade commit b48ce89
Show file tree
Hide file tree
Showing 9 changed files with 310 additions and 5 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ You can find examples of export files along with a description and schema on our
|:-------------:|:---:|:----:|:-------:|:--------:|:---------:|:----------:|
| **Point** |||||||
| **Line** |||||||
| **Rect** ||| ||||
| **Rect** ||| ||||
| **Polygon** |||||||
| **Label** |||||||

Expand Down
4 changes: 4 additions & 0 deletions src/data/ImportFormatData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ export const ImportFormatData: ImportFormatDataMap = {
{
type: AnnotationFormatType.YOLO,
label: 'Multiple files in YOLO format along with labels names definition - labels.txt file.'
},
{
type: AnnotationFormatType.VOC,
label: 'Multiple files in VOC XML format.'
}
],
[LabelType.POINT]: [],
Expand Down
3 changes: 2 additions & 1 deletion src/data/ImporterSpecData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {AnnotationFormatType} from './enums/AnnotationFormatType';
import {AnnotationImporter} from '../logic/import/AnnotationImporter';
import {COCOImporter} from '../logic/import/coco/COCOImporter';
import {YOLOImporter} from '../logic/import/yolo/YOLOImporter';
import {VOCImporter} from '../logic/import/voc/VOCImporter';

export type ImporterSpecDataMap = Record<AnnotationFormatType, typeof AnnotationImporter>;

Expand All @@ -11,6 +12,6 @@ export const ImporterSpecData: ImporterSpecDataMap = {
[AnnotationFormatType.CSV]: undefined,
[AnnotationFormatType.JSON]: undefined,
[AnnotationFormatType.VGG]: undefined,
[AnnotationFormatType.VOC]: undefined,
[AnnotationFormatType.VOC]: VOCImporter,
[AnnotationFormatType.YOLO]: YOLOImporter
}
3 changes: 2 additions & 1 deletion src/data/enums/AcceptedFileType.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export enum AcceptedFileType {
IMAGE = 'image/jpeg, image/png',
TEXT = 'text/plain',
JSON = 'application/json'
JSON = 'application/json',
XML = 'application/xml',
}
4 changes: 3 additions & 1 deletion src/data/enums/Notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,7 @@ export enum Notification {
MODEL_DOWNLOAD_ERROR = 2,
MODEL_INFERENCE_ERROR = 3,
MODEL_LOAD_ERROR = 4,
LABELS_FILE_UPLOAD_ERROR = 5
LABELS_FILE_UPLOAD_ERROR = 5,
ANNOTATION_FILE_PARSE_ERROR = 6,
ANNOTATION_IMPORT_ASSERTION_ERROR = 7,
}
10 changes: 10 additions & 0 deletions src/data/info/NotificationsData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,15 @@ export const NotificationsDataMap: ExportFormatDataMap = {
header: 'Labels file was not uploaded',
description: 'Looks like you forgot to upload text file containing list of detected classes names. We need ' +
'it to map YOLOv5 model output to labels. Please re-upload all model files once again.'
},
[Notification.ANNOTATION_FILE_PARSE_ERROR]: {
header: 'Annotation files could not be parsed',
description: 'The contents of an annotation file is not valid JSON, CSV, or XML. Please fix the files selected' +
'to import and try again.',
},
[Notification.ANNOTATION_IMPORT_ASSERTION_ERROR]: {
header: 'Annotation files did not contain valid data',
description: 'Missing or invalid annotations provied during import. Please fix the files selected ' +
'to import and try again.',
}
}
137 changes: 137 additions & 0 deletions src/logic/__tests__/import/voc/VOCImporter.tests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@

import { ImageData, LabelName, LabelRect} from '../../../../store/labels/types';
import { AcceptedFileType } from '../../../../data/enums/AcceptedFileType';
import { v4 as uuidv4 } from 'uuid';
import { VOCImporter } from '../../../import/voc/VOCImporter';
import { isEqual } from 'lodash';

const getDummyImageData = (fileName: string): ImageData => {
return {
id: uuidv4(),
fileData: new File([''], fileName, { type: AcceptedFileType.IMAGE }),
loadStatus: true,
labelRects: [],
labelPoints: [],
labelLines: [],
labelPolygons: [],
labelNameIds: [],
isVisitedByYOLOObjectDetector: false,
isVisitedBySSDObjectDetector: false,
isVisitedByPoseDetector: false
};
};

const getDummyFileData = (fileName: string): File => {
return new File([''], fileName, { type: AcceptedFileType.TEXT });
};

class TestableVOCImporter extends VOCImporter {
public static testableParseAnnotationsFromFileString(document: Document, labelNames: Record<string, LabelName>)
:[LabelRect[], Record<string, LabelName>] {
return TestableVOCImporter.parseAnnotationsFromFileString(document, labelNames);
}
}

const parser = new DOMParser();
const validTestDocument = parser.parseFromString(
`
<annotation>
<filename>test1.jpeg</filename>
<path>\\some-test-path\\test1.jpeg</path>
<size>
<width>100</width>
<height>200</height>
<depth>3</depth>
</size>
<segmented>0</segmented>
<object>
<name>annotation1</name>
<pose>Unspecified</pose>
<truncated>0</truncated>
<difficult>0</difficult>
<bndbox>
<xmin>10</xmin>
<ymin>20</ymin>
<xmax>30</xmax>
<ymax>50</ymax>
</bndbox>
</object>
<object>
<name>annotation2</name>
<pose>Unspecified</pose>
<truncated>0</truncated>
<difficult>0</difficult>
<bndbox>
<xmin>20</xmin>
<ymin>30</ymin>
<xmax>30</xmax>
<ymax>50</ymax>
</bndbox>
</object>
</annotation>
`,
'application/xml'
);

describe('VOCImporter parseAnnotationsFromFileString method', () => {
it('should return correctly for multiple annotations', () => {
// given
const emptyRecordSet: Record<string, LabelName> = {};

// when
const [annotations, newRecordSet] = TestableVOCImporter.testableParseAnnotationsFromFileString(validTestDocument, emptyRecordSet);

// then
expect(Object.keys(emptyRecordSet).length).toBe(0);
expect(newRecordSet).toEqual(expect.objectContaining({
'annotation1': expect.objectContaining({ name: 'annotation1'}),
'annotation2': expect.objectContaining({ name: 'annotation2'}),
}))
expect(annotations).toEqual([
expect.objectContaining({
rect: {
x: 10,
y: 20,
height: 30,
width: 20
},
labelId: newRecordSet['annotation1'].id
}),
expect.objectContaining({
rect: {
x: 20,
y: 30,
height: 20,
width: 10
},
labelId: newRecordSet['annotation2'].id
})
]);
});

it('should reuse existing labels', () => {
// given
const existingRecordSet: Record<string, LabelName> = {
'annotation2': {
id: 'foobar',
name: 'annotation2'
}
};

// when
const [annotations, newRecordSet] = TestableVOCImporter.testableParseAnnotationsFromFileString(validTestDocument, existingRecordSet);

// then
expect(Object.keys(existingRecordSet).length).toBe(1);
expect(newRecordSet).toEqual(expect.objectContaining({
'annotation1': expect.objectContaining({ name: 'annotation1' }),
'annotation2': expect.objectContaining({ name: 'annotation2', id: 'foobar' }),
}));
expect(annotations.length).toBe(2);
expect(annotations).toEqual(expect.arrayContaining([
expect.objectContaining({
labelId: 'foobar'
})
]));
});
});
142 changes: 142 additions & 0 deletions src/logic/import/voc/VOCImporter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import {ImageData, LabelName, LabelRect} from '../../../store/labels/types';
import {LabelUtil} from "../../../utils/LabelUtil";
import {AnnotationImporter} from '../AnnotationImporter';
import {LabelsSelector} from '../../../store/selectors/LabelsSelector';

type FileParseResult = {
filename: string,
labeledBoxes: LabelRect[]
};

type VOCImportResult = {
labelNames: Record<string, LabelName>,
fileParseResults: FileParseResult[],
};

export class DocumentParsingError extends Error {
constructor(message?: string) {
super(message);
this.name = "DocumentParsingError";
}
}

export class AnnotationAssertionError extends Error {
constructor(message?: string) {
super(message);
this.name = "AnnotationAssertionError";
}
}

const parser = new DOMParser();

export class VOCImporter extends AnnotationImporter {
public import(
filesData: File[],
onSuccess: (imagesData: ImageData[], labelNames: LabelName[]) => any,
onFailure: (error?:Error) => any
): void {
try {
const inputImagesData: Record<string, ImageData> = VOCImporter.mapImageData();

this.loadAndParseFiles(filesData).then(results => {
for (const result of results.fileParseResults) {
if (inputImagesData[result.filename]) {
inputImagesData[result.filename].labelRects = result.labeledBoxes;
}
}

onSuccess(
Array.from(Object.values(inputImagesData)),
Array.from(Object.values(results.labelNames))
);
}).catch((error: Error) => onFailure(error));
} catch (error) {
onFailure(error as Error)
}
}

private loadAndParseFiles(files: File[]): Promise<VOCImportResult> {
return Promise.all(files.map((file: File) => file.text())).then((fileTexts: string[]) =>
fileTexts.reduce((current: VOCImportResult, fileText: string, currentIndex: number) =>
{
const fileName = files[currentIndex].name;
try {
return VOCImporter.parseDocumentIntoImageData(VOCImporter.tryParseVOCDocument(fileText), current);
} catch (e) {
if (e instanceof DocumentParsingError) {
throw new DocumentParsingError(`Failed trying to parse ${fileName} as VOC XML document.`)
} else if (e instanceof AnnotationAssertionError) {
throw new AnnotationAssertionError(`Failed trying to find required VOC annotations for ${fileName}.`)
} else {
throw e;
}
}
},
{
labelNames: {},
fileParseResults: [],
} as VOCImportResult)
);
}

private static tryParseVOCDocument(fileText: string): Document {
try {
return parser.parseFromString(fileText, 'application/xml');
} catch {
throw new DocumentParsingError();
}
}

protected static parseDocumentIntoImageData(document: Document, { fileParseResults, labelNames }: VOCImportResult): VOCImportResult {
try {
const root = document.getElementsByTagName('annotation')[0];
const filename = root.getElementsByTagName('filename')[0].textContent;
const [labeledBoxes, newLabelNames] = this.parseAnnotationsFromFileString(document, labelNames);

return {
labelNames: newLabelNames,
fileParseResults: fileParseResults.concat({
filename,
labeledBoxes
}),
};
} catch {
throw new AnnotationAssertionError();
}
}

protected static parseAnnotationsFromFileString(document: Document, labelNames: Record<string, LabelName>):
[LabelRect[], Record<string, LabelName>] {
const newLabelNames: Record<string, LabelName> = Object.assign({}, labelNames);
return [Array.from(document.getElementsByTagName('object')).map(d => {
const labelName = d.getElementsByTagName('name')[0].textContent;
const bbox = d.getElementsByTagName('bndbox')[0];
const xmin = parseInt(bbox.getElementsByTagName('xmin')[0].textContent);
const xmax = parseInt(bbox.getElementsByTagName('xmax')[0].textContent);
const ymin = parseInt(bbox.getElementsByTagName('ymin')[0].textContent);
const ymax = parseInt(bbox.getElementsByTagName('ymax')[0].textContent);
const rect = {
x: xmin,
y: ymin,
height: ymax - ymin,
width: xmax - xmin,
};

if (!newLabelNames[labelName]) {
newLabelNames[labelName] = LabelUtil.createLabelName(labelName);
}

const labelId = newLabelNames[labelName].id;
return LabelUtil.createLabelRect(labelId, rect);
}), newLabelNames];
}

private static mapImageData(): Record<string, ImageData> {
return LabelsSelector.getImagesData().reduce(
(imageDataMap: Record<string, ImageData>, imageData: ImageData) => {
imageDataMap[imageData.fileData.name] = imageData;
return imageDataMap;
}, {}
);
}
}
10 changes: 9 additions & 1 deletion src/views/PopupView/ImportLabelPopup/ImportLabelPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ import { updateActiveLabelType, updateImageData, updateLabelNames } from '../../
import { ImporterSpecData } from '../../../data/ImporterSpecData';
import { AnnotationFormatType } from '../../../data/enums/AnnotationFormatType';
import { ILabelFormatData } from '../../../interfaces/ILabelFormatData';
import { submitNewNotification } from '../../../store/notifications/actionCreators';
import { NotificationUtil } from '../../../utils/NotificationUtil';
import { NotificationsDataMap } from '../../../data/info/NotificationsData';
import { DocumentParsingError } from '../../../logic/import/voc/VOCImporter';
import { Notification } from '../../../data/enums/Notification';

interface IProps {
activeLabelType: LabelType,
Expand Down Expand Up @@ -59,12 +64,15 @@ const ImportLabelPopup: React.FC<IProps> = (
setLoadedLabelNames([]);
setLoadedImageData([]);
setAnnotationsLoadedError(error);
const notification = error instanceof DocumentParsingError ? Notification.ANNOTATION_FILE_PARSE_ERROR : Notification.ANNOTATION_IMPORT_ASSERTION_ERROR;
submitNewNotification(NotificationUtil.createErrorNotification(NotificationsDataMap[notification]));
};

const { getRootProps, getInputProps } = useDropzone({
accept: {
"application/json": [".json" ],
"text/plain": [".txt"]
"text/plain": [".txt"],
"application/xml": [".xml"],
},
multiple: true,
onDrop: (acceptedFiles) => {
Expand Down

0 comments on commit b48ce89

Please sign in to comment.