Skip to content

Commit

Permalink
CSV export strategy for Rectangle annotations (#122)
Browse files Browse the repository at this point in the history
* CSV export for specific format

The CSV file can be used with the tooling mentioned here: https://apple.github.io/turicreate/docs/userguide/object_detection/data-preparation.html

* Added com.opencsv to module-info

* CSVSaveStrategy tests

* Made constant private

* Updated license headers

---------

Co-authored-by: Markus Fleischhacker <markus.fleischhacker28+git@gmail.com>
  • Loading branch information
kaiwinter and mfl28 authored Apr 13, 2024
1 parent da7d562 commit 9727ce7
Show file tree
Hide file tree
Showing 7 changed files with 236 additions and 1 deletion.
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ dependencies {
// Mockito-Junit https://mvnrepository.com/artifact/org.mockito/mockito-junit-jupiter
testImplementation 'org.mockito:mockito-junit-jupiter:5.6.0'

testImplementation 'com.google.jimfs:jimfs:1.3.0'

// Commons Collections https://mvnrepository.com/artifact/org.apache.commons/commons-collections4
implementation 'org.apache.commons:commons-collections4:4.4'

Expand Down Expand Up @@ -107,6 +109,8 @@ dependencies {

// https://mvnrepository.com/artifact/com.drewnoakes/metadata-extractor
implementation 'com.drewnoakes:metadata-extractor:2.18.0'

implementation 'com.opencsv:opencsv:5.9'
}

javafx {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ public class Controller {
private static final String ANNOTATIONS_SAVE_FORMAT_DIALOG_CONTENT = "Annotation format:";
private static final String KEEP_EXISTING_CATEGORIES_DIALOG_TEXT = "Keep existing categories?";
private static final String DEFAULT_JSON_EXPORT_FILENAME = "annotations.json";
private static final String DEFAULT_CSV_EXPORT_FILENAME = "annotations.csv";
private static final String ANNOTATION_IMPORT_SAVE_EXISTING_DIALOG_CONTENT = "All current annotations are about " +
"to be removed. Do you want to save them first?";
private static final String IMAGE_IMPORT_ERROR_ALERT_TITLE = "Image Import Error";
Expand Down Expand Up @@ -1197,6 +1198,14 @@ private File getAnnotationSavingDestination(ImageAnnotationSaveStrategy.Type sav
"*.json",
"*.JSON"),
MainView.FileChooserType.SAVE);
} else if(saveFormat.equals(ImageAnnotationSaveStrategy.Type.CSV)) {
destination = MainView.displayFileChooserAndGetChoice(SAVE_IMAGE_ANNOTATIONS_FILE_CHOOSER_TITLE, stage,
ioMetaData.getDefaultAnnotationSavingDirectory(),
DEFAULT_CSV_EXPORT_FILENAME,
new FileChooser.ExtensionFilter("CSV files",
"*.csv",
"*.CSV"),
MainView.FileChooserType.SAVE);
} else {
destination =
MainView.displayDirectoryChooserAndGetChoice(SAVE_IMAGE_ANNOTATIONS_DIRECTORY_CHOOSER_TITLE, stage,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* Copyright (C) 2024 Markus Fleischhacker <markus.fleischhacker28@gmail.com>
*
* This file is part of Bounding Box Editor
*
* Bounding Box Editor is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Bounding Box Editor is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Bounding Box Editor. If not, see <http://www.gnu.org/licenses/>.
*/
package com.github.mfl28.boundingboxeditor.model.io;

import com.github.mfl28.boundingboxeditor.model.data.*;
import com.github.mfl28.boundingboxeditor.model.io.results.IOErrorInfoEntry;
import com.github.mfl28.boundingboxeditor.model.io.results.ImageAnnotationExportResult;
import com.opencsv.CSVWriterBuilder;
import com.opencsv.ICSVWriter;
import javafx.beans.property.DoubleProperty;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

/**
* Saving-strategy to export annotations to a CSV file.
*
* The CSVSaveStrategy supports {@link BoundingBoxData} only.
*/
public class CSVSaveStrategy implements ImageAnnotationSaveStrategy {
private static final String FILE_NAME_SERIALIZED_NAME = "name";
private static final String ID_SERIALIZED_NAME = "id";
private static final String LABEL_SERIALIZED_NAME = "label";
private static final String MIN_X_SERIALIZED_NAME = "xMin";
private static final String MAX_X_SERIALIZED_NAME = "xMax";
private static final String MIN_Y_SERIALIZED_NAME = "yMin";
private static final String MAX_Y_SERIALIZED_NAME = "yMax";
private static final String UNSUPPORTED_BOUNDING_SHAPE = "CSV can export Rectangles only";

@Override
public ImageAnnotationExportResult save(ImageAnnotationData annotations, Path destination,
DoubleProperty progress) {
final int totalNrAnnotations = annotations.imageAnnotations().size();
int nrProcessedAnnotations = 0;

final List<IOErrorInfoEntry> errorEntries = new ArrayList<>();

try(ICSVWriter writer = new CSVWriterBuilder(Files.newBufferedWriter(destination, StandardCharsets.UTF_8)).build()) {
String[] header = {FILE_NAME_SERIALIZED_NAME, ID_SERIALIZED_NAME, LABEL_SERIALIZED_NAME, MIN_X_SERIALIZED_NAME, MAX_X_SERIALIZED_NAME, MIN_Y_SERIALIZED_NAME, MAX_Y_SERIALIZED_NAME};
writer.writeNext(header);
for (ImageAnnotation imageAnnotation : annotations.imageAnnotations()) {
for (BoundingShapeData boundingShapeData : imageAnnotation.getBoundingShapeData()) {
if (boundingShapeData instanceof BoundingBoxData boundingBoxData) {
double xMin = imageAnnotation.getImageMetaData().getImageWidth() * boundingBoxData.getXMinRelative();
double xMax = imageAnnotation.getImageMetaData().getImageWidth() * boundingBoxData.getXMaxRelative();
double yMin = imageAnnotation.getImageMetaData().getImageHeight() * boundingBoxData.getYMinRelative();
double yMax = imageAnnotation.getImageMetaData().getImageHeight() * boundingBoxData.getYMaxRelative();
String[] line = { imageAnnotation.getImageFileName(), String.valueOf(nrProcessedAnnotations), boundingShapeData.getCategoryName(), String.valueOf((int) xMin), String.valueOf((int) xMax), String.valueOf((int) yMin), String.valueOf((int) yMax)};
writer.writeNext(line);
} else {
errorEntries.add(new IOErrorInfoEntry(imageAnnotation.getImageFileName(), UNSUPPORTED_BOUNDING_SHAPE));
}
progress.set(1.0 * nrProcessedAnnotations++ / totalNrAnnotations);
}
}
} catch(IOException e) {
errorEntries.add(new IOErrorInfoEntry(destination.getFileName().toString(), e.getMessage()));
}

return new ImageAnnotationExportResult(
errorEntries.isEmpty() ? totalNrAnnotations : 0,
errorEntries
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ static ImageAnnotationSaveStrategy createStrategy(Type type) {
return new YOLOSaveStrategy();
} else if(type.equals(Type.JSON)) {
return new JSONSaveStrategy();
} else if(type.equals(Type.CSV)) {
return new CSVSaveStrategy();
} else {
throw new InvalidParameterException();
}
Expand Down Expand Up @@ -76,6 +78,12 @@ public String toString() {
public String toString() {
return "JSON";
}
},
CSV {
@Override
public String toString() {
return "CSV";
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class MenuBarView extends MenuBar implements View {
private static final String PASCAL_VOC_FORMAT_EXPORT_TEXT = "Pascal-VOC format...";
private static final String YOLO_FORMAT_EXPORT_TEXT = "YOLO format...";
private static final String JSON_FORMAT_EXPORT_TEXT = "JSON format...";
private static final String CSV_FORMAT_EXPORT_TEXT = "CSV format...";
private static final String JSON_FORMAT_IMPORT_TEXT = "JSON format...";
private static final String FILE_MENU_ID = "file-menu";
private static final String FILE_OPEN_FOLDER_MENU_ITEM_ID = "file-open-folder-menu-item";
Expand All @@ -61,6 +62,7 @@ class MenuBarView extends MenuBar implements View {
private static final String PVOC_EXPORT_MENU_ITEM_ID = "pvoc-export-menu-item";
private static final String YOLO_EXPORT_MENU_ITEM_ID = "yolo-export-menu-item";
private static final String JSON_EXPORT_MENU_ITEM_ID = "json-export-menu-item";
private static final String CSV_EXPORT_MENU_ITEM_ID = "csv-export-menu-item";
private static final String PVOC_IMPORT_MENU_ITEM_ID = "pvoc-import-menu-item";
private static final String YOLO_IMPORT_MENU_ITEM_ID = "yolo-import-menu-item";
private static final String JSON_IMPORT_MENU_ITEM_ID = "json-import-menu-item";
Expand All @@ -79,6 +81,7 @@ class MenuBarView extends MenuBar implements View {
private final MenuItem pvocExportMenuItem = new MenuItem(PASCAL_VOC_FORMAT_EXPORT_TEXT);
private final MenuItem yoloExportMenuItem = new MenuItem(YOLO_FORMAT_EXPORT_TEXT);
private final MenuItem jsonExportMenuItem = new MenuItem(JSON_FORMAT_EXPORT_TEXT);
private final MenuItem csvExportMenuItem = new MenuItem(CSV_FORMAT_EXPORT_TEXT);
private final MenuItem settingsMenuItem = new MenuItem(SETTINGS_TEXT, createIconRegion(SETTINGS_ICON_ID));

private final Menu fileImportAnnotationsMenu =
Expand All @@ -101,11 +104,12 @@ class MenuBarView extends MenuBar implements View {
viewShowImagesPanelItem.setSelected(true);
viewMaximizeImagesItem.setSelected(true);

fileExportAnnotationsMenu.getItems().addAll(pvocExportMenuItem, yoloExportMenuItem, jsonExportMenuItem);
fileExportAnnotationsMenu.getItems().addAll(pvocExportMenuItem, yoloExportMenuItem, jsonExportMenuItem, csvExportMenuItem);

pvocExportMenuItem.setId(PVOC_EXPORT_MENU_ITEM_ID);
yoloExportMenuItem.setId(YOLO_EXPORT_MENU_ITEM_ID);
jsonExportMenuItem.setId(JSON_EXPORT_MENU_ITEM_ID);
csvExportMenuItem.setId(CSV_EXPORT_MENU_ITEM_ID);

fileImportAnnotationsMenu.getItems().addAll(pvocImportMenuItem,
yoloRImportMenuItem,
Expand All @@ -129,6 +133,9 @@ public void connectToController(final Controller controller) {
jsonExportMenuItem.setOnAction(action ->
controller.onRegisterSaveAnnotationsAction(
ImageAnnotationSaveStrategy.Type.JSON));
csvExportMenuItem.setOnAction(action ->
controller.onRegisterSaveAnnotationsAction(
ImageAnnotationSaveStrategy.Type.CSV));
pvocImportMenuItem.setOnAction(action ->
controller.onRegisterImportAnnotationsAction(
ImageAnnotationLoadStrategy.Type.PASCAL_VOC));
Expand Down
1 change: 1 addition & 0 deletions src/main/java/module-info.java
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
requires org.jvnet.mimepull;
requires org.locationtech.jts;
requires metadata.extractor;
requires com.opencsv;

opens com.github.mfl28.boundingboxeditor.model to javafx.base, com.google.gson;
opens com.github.mfl28.boundingboxeditor.model.data to javafx.base, com.google.gson;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/*
* Copyright (C) 2024 Markus Fleischhacker <markus.fleischhacker28@gmail.com>
*
* This file is part of Bounding Box Editor
*
* Bounding Box Editor is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Bounding Box Editor is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Bounding Box Editor. If not, see <http://www.gnu.org/licenses/>.
*/
package com.github.mfl28.boundingboxeditor.model.io;

import com.github.mfl28.boundingboxeditor.model.data.*;
import com.github.mfl28.boundingboxeditor.model.io.results.ImageAnnotationExportResult;
import com.google.common.jimfs.Configuration;
import com.google.common.jimfs.Jimfs;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.scene.paint.Color;
import org.junit.jupiter.api.Test;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

import static org.junit.jupiter.api.Assertions.*;

class CSVSaveStrategyTest {

/**
* One image with two annotations gets exported to CSV.
*/
@Test
void multipleRectangles() throws IOException {
ObjectCategory objectCategory = new ObjectCategory("category", Color.YELLOW);

ImageAnnotation imageAnnotation = new ImageAnnotation(new ImageMetaData("sample.png", "folderName", "url", 100, 200, 0));
List<BoundingShapeData> boundingShapeDatas = new ArrayList<>();
boundingShapeDatas.add(new BoundingBoxData(objectCategory, 0,0,0.5,0.5, List.of("tag")));
boundingShapeDatas.add(new BoundingBoxData(objectCategory, 0,0,0.25,0.25, List.of("tag")));
imageAnnotation.setBoundingShapeData(boundingShapeDatas);

ImageAnnotationData annotations = new ImageAnnotationData(List.of(imageAnnotation),
Map.of("object", 1),
Map.of("object", objectCategory));
Path destination = Jimfs.newFileSystem(Configuration.unix()).getPath("annotations.csv");
ImageAnnotationExportResult save = new CSVSaveStrategy().save(annotations, destination, new SimpleDoubleProperty(0));
assertTrue(save.getErrorTableEntries().isEmpty());

String content = Files.readString(destination);
assertEquals("""
"name","id","label","xMin","xMax","yMin","yMax"
"sample.png","0","category","0","50","0","100"
"sample.png","1","category","0","25","0","50"
""", content);
}

/**
* Two images with each one annotation gets exported.
*/
@Test
void multipleImages() throws IOException {
ObjectCategory objectCategory = new ObjectCategory("category", Color.YELLOW);

ImageAnnotation imageAnnotation1 = new ImageAnnotation(
new ImageMetaData("sample1.png", "folderName", "url", 100, 200, 0),
List.of(new BoundingBoxData(objectCategory, 0,0,0.5,0.5, List.of("tag"))));

ImageAnnotation imageAnnotation2 = new ImageAnnotation(
new ImageMetaData("sample2.png", "folderName", "url", 100, 200, 0),
List.of(new BoundingBoxData(objectCategory, 0,0,0.25,0.25, List.of("tag"))));

ImageAnnotationData annotations = new ImageAnnotationData(List.of(imageAnnotation1, imageAnnotation2),
Map.of("object", 1),
Map.of("object", objectCategory));
Path destination = Jimfs.newFileSystem(Configuration.unix()).getPath("annotations.csv");
ImageAnnotationExportResult save = new CSVSaveStrategy().save(annotations, destination, new SimpleDoubleProperty(0));
assertTrue(save.getErrorTableEntries().isEmpty());

String content = Files.readString(destination);
assertEquals("""
"name","id","label","xMin","xMax","yMin","yMax"
"sample1.png","0","category","0","50","0","100"
"sample2.png","1","category","0","25","0","50"
""", content);
}

/**
* One image with one annotation should be saved. The annotation uses an unsupported Bounding Shape, so a ErrorTableEntry is expected.
*/
@Test
void wrongBoundingShape() throws IOException {
ObjectCategory objectCategory = new ObjectCategory("category", Color.YELLOW);

ImageAnnotation imageAnnotation1 = new ImageAnnotation(
new ImageMetaData("sample1.png", "folderName", "url", 100, 200, 0),
List.of(new BoundingPolygonData(objectCategory, List.of(0.0, 0.0, 0.5, 0.5), List.of("tag"))));

ImageAnnotationData annotations = new ImageAnnotationData(List.of(imageAnnotation1),
Map.of("object", 1),
Map.of("object", objectCategory));
Path destination = Jimfs.newFileSystem(Configuration.unix()).getPath("annotations.csv");
ImageAnnotationExportResult save = new CSVSaveStrategy().save(annotations, destination, new SimpleDoubleProperty(0));
assertEquals(1, save.getErrorTableEntries().size());

String content = Files.readString(destination);
assertEquals("""
"name","id","label","xMin","xMax","yMin","yMax"
""", content);
}
}

0 comments on commit 9727ce7

Please sign in to comment.