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

CSV export strategy for Rectangle annotations #122

Merged
merged 7 commits into from
Apr 13, 2024
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
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 <[email protected]>
*
* 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 <[email protected]>
*
* 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);
}
}
Loading