diff --git a/build.gradle b/build.gradle index 4dd647c..356fc50 100644 --- a/build.gradle +++ b/build.gradle @@ -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' @@ -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 { diff --git a/src/main/java/com/github/mfl28/boundingboxeditor/controller/Controller.java b/src/main/java/com/github/mfl28/boundingboxeditor/controller/Controller.java index aeddb3b..0c8023d 100644 --- a/src/main/java/com/github/mfl28/boundingboxeditor/controller/Controller.java +++ b/src/main/java/com/github/mfl28/boundingboxeditor/controller/Controller.java @@ -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"; @@ -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, diff --git a/src/main/java/com/github/mfl28/boundingboxeditor/model/io/CSVSaveStrategy.java b/src/main/java/com/github/mfl28/boundingboxeditor/model/io/CSVSaveStrategy.java new file mode 100644 index 0000000..a76a8a9 --- /dev/null +++ b/src/main/java/com/github/mfl28/boundingboxeditor/model/io/CSVSaveStrategy.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2024 Markus Fleischhacker + * + * 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 . + */ +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 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 + ); + } +} diff --git a/src/main/java/com/github/mfl28/boundingboxeditor/model/io/ImageAnnotationSaveStrategy.java b/src/main/java/com/github/mfl28/boundingboxeditor/model/io/ImageAnnotationSaveStrategy.java index a76308c..82104be 100644 --- a/src/main/java/com/github/mfl28/boundingboxeditor/model/io/ImageAnnotationSaveStrategy.java +++ b/src/main/java/com/github/mfl28/boundingboxeditor/model/io/ImageAnnotationSaveStrategy.java @@ -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(); } @@ -76,6 +78,12 @@ public String toString() { public String toString() { return "JSON"; } + }, + CSV { + @Override + public String toString() { + return "CSV"; + } } } } diff --git a/src/main/java/com/github/mfl28/boundingboxeditor/ui/MenuBarView.java b/src/main/java/com/github/mfl28/boundingboxeditor/ui/MenuBarView.java index 5cb41c9..d0f4d24 100644 --- a/src/main/java/com/github/mfl28/boundingboxeditor/ui/MenuBarView.java +++ b/src/main/java/com/github/mfl28/boundingboxeditor/ui/MenuBarView.java @@ -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"; @@ -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"; @@ -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 = @@ -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, @@ -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)); diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index a5235c3..6830e69 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -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; diff --git a/src/test/java/com/github/mfl28/boundingboxeditor/model/io/CSVSaveStrategyTest.java b/src/test/java/com/github/mfl28/boundingboxeditor/model/io/CSVSaveStrategyTest.java new file mode 100644 index 0000000..95ca563 --- /dev/null +++ b/src/test/java/com/github/mfl28/boundingboxeditor/model/io/CSVSaveStrategyTest.java @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2024 Markus Fleischhacker + * + * 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 . + */ +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 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); + } +} \ No newline at end of file