Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial working version of VOC XML import #280

Merged
merged 7 commits into from
Oct 10, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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