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 6 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',
}
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[],
};

class DocumentParsingError extends Error {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@SkalskiP These types of errors are likely similar to existing YOLO and COCO errors and could be genericized but since I'm only using them in this file at the moment just to indicate what imported file had the issue, I was planning to just keep them here for now. Let me know if you'd like me to move it out

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. We can keep them here for now. But you are right, long term we could centrally think about the standardization of error types for different label formats, where possible.

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

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 AnnotationParsingError) {
throw new AnnotationParsingError(`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 {
const root = document.getElementsByTagName('annotation')[0];
const filename = root.getElementsByTagName('filename')[0].textContent;

try {
const [labeledBoxes, newLabelNames] = this.parseAnnotationsFromFileString(document, labelNames);

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

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;
}, {}
);
}
}
3 changes: 2 additions & 1 deletion src/views/PopupView/ImportLabelPopup/ImportLabelPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ const ImportLabelPopup: React.FC<IProps> = (
const { getRootProps, getInputProps } = useDropzone({
accept: {
"application/json": [".json" ],
"text/plain": [".txt"]
"text/plain": [".txt"],
"application/xml": [".xml"],
},
multiple: true,
onDrop: (acceptedFiles) => {
Expand Down