From 183d9e3b1f91ce65ed1476dd9ce4af6eea3d8a88 Mon Sep 17 00:00:00 2001
From: Kai Winter <kaiwinter@gmx.de>
Date: Wed, 28 Feb 2024 21:05:51 +0100
Subject: [PATCH 1/5] 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
---
 build.gradle                                  |  2 +
 .../controller/Controller.java                |  9 +++
 .../model/io/CSVSaveStrategy.java             | 81 +++++++++++++++++++
 .../model/io/ImageAnnotationSaveStrategy.java |  8 ++
 .../boundingboxeditor/ui/MenuBarView.java     |  9 ++-
 5 files changed, 108 insertions(+), 1 deletion(-)
 create mode 100644 src/main/java/com/github/mfl28/boundingboxeditor/model/io/CSVSaveStrategy.java

diff --git a/build.gradle b/build.gradle
index 4085421..2e2e29b 100644
--- a/build.gradle
+++ b/build.gradle
@@ -107,6 +107,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 6c4d2ba..1016460 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..ac0152f
--- /dev/null
+++ b/src/main/java/com/github/mfl28/boundingboxeditor/model/io/CSVSaveStrategy.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright (C) 2023 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;
+
+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";
+
+    @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))
+                .withSeparator(',')
+                .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 {
+                        throw new ImageAnnotationLoadStrategy.InvalidAnnotationFormatException("CSV can export Rectangles only");
+                    }
+                    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 0b2db7a..5ba6ea1 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 86d898e..e53e444 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));

From bf11ad2d4c5408b2f49cbd15f22f62daa1b413a6 Mon Sep 17 00:00:00 2001
From: Kai Winter <kaiwinter@gmx.de>
Date: Wed, 28 Feb 2024 21:10:13 +0100
Subject: [PATCH 2/5] Added com.opencsv to module-info

---
 src/main/java/module-info.java | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java
index e76970b..748a123 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;

From 1efe76a7f70a480f1291abca2782eedd7f264c38 Mon Sep 17 00:00:00 2001
From: Kai Winter <kaiwinter@gmx.de>
Date: Wed, 13 Mar 2024 20:30:04 +0100
Subject: [PATCH 3/5] CSVSaveStrategy tests

---
 build.gradle                                  |   2 +
 .../model/io/CSVSaveStrategy.java             |  12 +-
 .../model/io/CSVSaveStrategyTest.java         | 103 ++++++++++++++++++
 3 files changed, 113 insertions(+), 4 deletions(-)
 create mode 100644 src/test/java/com/github/mfl28/boundingboxeditor/model/io/CSVSaveStrategyTest.java

diff --git a/build.gradle b/build.gradle
index 2e2e29b..45511ec 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'
 
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
index ac0152f..5b0fd83 100644
--- a/src/main/java/com/github/mfl28/boundingboxeditor/model/io/CSVSaveStrategy.java
+++ b/src/main/java/com/github/mfl28/boundingboxeditor/model/io/CSVSaveStrategy.java
@@ -32,6 +32,11 @@
 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";
@@ -40,6 +45,7 @@ public class CSVSaveStrategy implements ImageAnnotationSaveStrategy {
     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";
+    public static final String UNSUPPORTED_BOUNDING_SHAPE = "CSV can export Rectangles only";
 
     @Override
     public ImageAnnotationExportResult save(ImageAnnotationData annotations, Path destination,
@@ -49,9 +55,7 @@ public ImageAnnotationExportResult save(ImageAnnotationData annotations, Path de
 
         final List<IOErrorInfoEntry> errorEntries = new ArrayList<>();
 
-        try(ICSVWriter writer = new CSVWriterBuilder(Files.newBufferedWriter(destination, StandardCharsets.UTF_8))
-                .withSeparator(',')
-                .build()) {
+        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()) {
@@ -64,7 +68,7 @@ public ImageAnnotationExportResult save(ImageAnnotationData annotations, Path de
                         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 {
-                        throw new ImageAnnotationLoadStrategy.InvalidAnnotationFormatException("CSV can export Rectangles only");
+                        errorEntries.add(new IOErrorInfoEntry(imageAnnotation.getImageFileName(), UNSUPPORTED_BOUNDING_SHAPE));
                     }
                     progress.set(1.0 * nrProcessedAnnotations++ / totalNrAnnotations);
                 }
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..c8605bc
--- /dev/null
+++ b/src/test/java/com/github/mfl28/boundingboxeditor/model/io/CSVSaveStrategyTest.java
@@ -0,0 +1,103 @@
+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);
+    }
+}
\ No newline at end of file

From 3f45a542171e473cb367dbe5731847653a7e2d12 Mon Sep 17 00:00:00 2001
From: Kai Winter <kaiwinter@gmx.de>
Date: Thu, 14 Mar 2024 22:24:23 +0100
Subject: [PATCH 4/5] Made constant private

---
 .../mfl28/boundingboxeditor/model/io/CSVSaveStrategy.java       | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

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
index 5b0fd83..b301dbe 100644
--- a/src/main/java/com/github/mfl28/boundingboxeditor/model/io/CSVSaveStrategy.java
+++ b/src/main/java/com/github/mfl28/boundingboxeditor/model/io/CSVSaveStrategy.java
@@ -45,7 +45,7 @@ public class CSVSaveStrategy implements ImageAnnotationSaveStrategy {
     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";
-    public static final String UNSUPPORTED_BOUNDING_SHAPE = "CSV can export Rectangles only";
+    private static final String UNSUPPORTED_BOUNDING_SHAPE = "CSV can export Rectangles only";
 
     @Override
     public ImageAnnotationExportResult save(ImageAnnotationData annotations, Path destination,

From 917c6f1b0b19f8888bc9d426194926b230a47be6 Mon Sep 17 00:00:00 2001
From: Kai Winter <kaiwinter@gmx.de>
Date: Fri, 15 Mar 2024 20:45:24 +0100
Subject: [PATCH 5/5] Updated license headers

---
 .../model/io/CSVSaveStrategy.java              |  2 +-
 .../model/io/CSVSaveStrategyTest.java          | 18 ++++++++++++++++++
 2 files changed, 19 insertions(+), 1 deletion(-)

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
index b301dbe..a76a8a9 100644
--- a/src/main/java/com/github/mfl28/boundingboxeditor/model/io/CSVSaveStrategy.java
+++ b/src/main/java/com/github/mfl28/boundingboxeditor/model/io/CSVSaveStrategy.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2023 Markus Fleischhacker <markus.fleischhacker28@gmail.com>
+ * Copyright (C) 2024 Markus Fleischhacker <markus.fleischhacker28@gmail.com>
  *
  * This file is part of Bounding Box Editor
  *
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
index c8605bc..95ca563 100644
--- a/src/test/java/com/github/mfl28/boundingboxeditor/model/io/CSVSaveStrategyTest.java
+++ b/src/test/java/com/github/mfl28/boundingboxeditor/model/io/CSVSaveStrategyTest.java
@@ -1,3 +1,21 @@
+/*
+ * 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.*;